Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
dfa039b
feat(cli): publish narrative documents from workspaces
101Steeps Aug 22, 2026
0355494
test(cli): cover narrative workspace publish paths
101Steeps Aug 22, 2026
bce6c04
test(cli): cover narrative workspace version states
101Steeps Aug 22, 2026
fc425ff
test(cli): cover narrative workspace error paths
101Steeps Aug 22, 2026
1853121
test(cli): cover narrative workspace edge cases
101Steeps Aug 22, 2026
bd8e1f0
test(cli): cover unreadable narrative sources
101Steeps Aug 22, 2026
239ee7c
fix(cli): accept absolute document locations
101Steeps Aug 22, 2026
d91631f
fix(shared): skip narrative documents in workspace loader
101Steeps Aug 22, 2026
ce2b95d
fix(cli): validate narrative workspace identity
101Steeps Aug 22, 2026
29d3b5e
refactor(cli): derive narrative Location types
101Steeps Aug 22, 2026
4e97d8e
feat(shared): define narrative document types
101Steeps Aug 23, 2026
31cfaba
refactor(shared): use canonical narrative document types
101Steeps Aug 23, 2026
c9b3a64
feat(cli): recover published narrative documents
101Steeps Aug 23, 2026
bab8662
docs(cli): clarify narrative failure handling
101Steeps Aug 23, 2026
7bbf9b7
Merge remote-tracking branch 'upstream/main' into feat/workspace-docu…
101Steeps Sep 9, 2026
552333e
refactor(cli): discriminate workspace manifest entries
101Steeps Sep 17, 2026
78b0ef8
fix(cli): validate narrative recovery document IDs
101Steeps Sep 17, 2026
42dd30b
fix(cli): preserve narrative identity on re-add
101Steeps Sep 17, 2026
80332af
fix(cli): make narrative create recovery idempotent
101Steeps Sep 17, 2026
d19843d
refactor(cli): centralize narrative entry validation
101Steeps Sep 17, 2026
615d99d
fix(cli): fail workspace push on mapping errors
101Steeps Sep 18, 2026
7c5860e
refactor(cli): centralize workspace document dispatch
101Steeps Sep 18, 2026
aba7582
perf(cli): batch narrative bump manifest updates
101Steeps Sep 18, 2026
e4ee2fb
perf(cli): parallelize workspace Hub checks
101Steeps Sep 18, 2026
814234e
Merge remote-tracking branch 'upstream/main' into pr3015-main-integra…
101Steeps Sep 18, 2026
b30cba4
test(cli): align bundle path expectations with main
101Steeps Sep 18, 2026
0eff450
fix(cli): persist narrative recovery before create
101Steeps Sep 18, 2026
20e3668
refactor(shared): centralize workspace document classification
101Steeps Sep 18, 2026
4702946
chore(cli): remove unrelated review noise
101Steeps Sep 18, 2026
474c07e
fix(cli): make narrative create recovery explicit
101Steeps Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions calm-models/src/types/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { CALM_NARRATIVE_DOCUMENT_TYPES_LIST, isNarrativeDocumentType } from './index';

describe('isNarrativeDocumentType', () => {
it.each(CALM_NARRATIVE_DOCUMENT_TYPES_LIST)('accepts %s', (type) => {
expect(isNarrativeDocumentType(type)).toBe(true);
});

it('rejects unsupported values', () => {
expect(isNarrativeDocumentType('architecture')).toBe(false);
expect(isNarrativeDocumentType(1)).toBe(false);
});
});
8 changes: 8 additions & 0 deletions calm-models/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export const CALM_DOCUMENT_TYPES_LIST = [

export type CalmDocumentType = (typeof CALM_DOCUMENT_TYPES_LIST)[number];

export const CALM_NARRATIVE_DOCUMENT_TYPES_LIST = ['knowledge', 'sad'] as const;

export type NarrativeDocumentType = (typeof CALM_NARRATIVE_DOCUMENT_TYPES_LIST)[number];

export function isNarrativeDocumentType(input: unknown): input is NarrativeDocumentType {
return typeof input === 'string' && CALM_NARRATIVE_DOCUMENT_TYPES_LIST.includes(input as NarrativeDocumentType);
}

export function isValidCalmDocumentType(input: string): input is CalmDocumentType {
return CALM_DOCUMENT_TYPES_LIST.some((type) => type === input);
}
Expand Down
39 changes: 32 additions & 7 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -828,32 +828,44 @@ calm workspace init my-system

#### `calm workspace add <file>`

Register a CALM document with the active workspace. By default the file is referenced at its current location on disk (no copying). Prompts interactively for document type and (manifest) name if they cannot be determined automatically.
Register a CALM JSON document or narrative Markdown document with the active workspace. By default the file is referenced at its current location on disk (no copying). Prompts interactively for document type and (manifest) name if they cannot be determined automatically.

```
calm workspace add <file> [--id <id>] [--type <type>] [--namespace <namespace>] [--copy]
calm workspace add <file> [--id <id>] [--type <type>] [--namespace <namespace>] [--copy] [--calm-hub-document-id <id> --ver <version> [--calm-hub-url <url>]]
```

| Option | Description |
|--------|-------------|
| `--id <id>` | Explicit manifest registration id. Overrides automatic resolution. |
| `--type <type>` | Document type. If omitted, an interactive dropdown is shown. One of: `pattern`, `architecture`, `interface`, `flow`, `control`, `schema`, `timeline`, `adr`. |
| `--namespace <namespace>` | CalmHub namespace to record in the manifest. If omitted, it is derived from the document `$id`. |
| `--type <type>` | Document type. If omitted, an interactive dropdown is shown. One of: `pattern`, `architecture`, `interface`, `flow`, `control`, `schema`, `timeline`, `adr`, `knowledge`, `sad`. |
| `--namespace <namespace>` | CalmHub namespace to record in the manifest. It is required for narrative Markdown and otherwise derived from the document `$id` when omitted. |
| `--copy` | Copy the file into the bundle's `files/` directory instead of referencing it in place. |
| `--calm-hub-document-id <id>` and `--ver <version>` | Recover an existing narrative document. Both options are required together. |
| `--calm-hub-url <url>` | Optional CalmHub URL used only for narrative recovery. It otherwise uses the configured URL. |

**Document `$id` handling.** `add` inspects the file's CalmHub `$id`:
**Narrative Markdown documents.** Use `--type knowledge` or `sad`. `add` reads YAML frontmatter. A non-empty `title` becomes the manifest name unless you supply `--id`. `--namespace` is required. The initial manifest version is `1.0.0`. Markdown has no CALM `$id` and is never rewritten.

To restore a removed narrative document without creating a new CalmHub document, supply its verified Hub id and version. The local Markdown must exactly match the stored Hub version.

**JSON document `$id` handling.** For JSON mapping documents, `add` inspects the file's CalmHub `$id`:
- **No `$id`** β†’ you are prompted interactively to build one from its components (see below); the `$id` is written into the file and the document is added.
- **Conformant `$id`** β†’ left untouched; the manifest namespace is derived from it.
- **Non-conformant `$id`** β†’ left as-is; a warning is printed and the document is still tracked, but it cannot be pushed to CalmHub until the `$id` is fixed (silently rewriting it would lose data for types that don't use CalmHub URLs, e.g. `flow`, `adr`, `timeline`).

**Manifest name resolution** (when `--id` is not given): the `title` field from the JSON file, else an interactive prompt.
**Manifest name resolution** (when `--id` is not given): the `title` field from the JSON file or Markdown frontmatter, else an interactive prompt.

```shell
# Interactive β€” prompts for type, builds the $id if needed, then the manifest name
calm workspace add ./architectures/payment-service.json

# Reference an already-conformant document without copying
calm workspace add ./architectures/payment-service.json --type architecture

# Register a narrative Markdown document; the frontmatter title becomes its manifest name
calm workspace add ./docs/payments-sad.md --type sad --namespace finos

# Restore an existing narrative document
calm workspace add ./docs/payments-sad.md --type sad --namespace finos --calm-hub-document-id 42 --ver 1.2.0
```

#### `calm workspace new [type] [name] [template]`
Expand Down Expand Up @@ -888,7 +900,9 @@ where `$TYPE` is one of `patterns`, `architectures`, `standards`, `interfaces`.

#### `calm workspace push`

Push every document in the workspace manifest to a CalmHub instance. Each document's identity β€” namespace, type, mapping id and **version** β€” comes from its `$id` (of the form `$BASE_URL/calm/namespaces/$NAMESPACE/$TYPE/$MAPPING_ID/versions/$VERSION`). Push **does not auto-bump**: it creates exactly the version each document declares. Documents without a well-formed mapping `$id` (or whose type has no CalmHub resource type) are skipped with a warning.
Push every document in the workspace manifest to a CalmHub instance. JSON mapping documents derive their identity β€” namespace, type, mapping id and **version** β€” from `$id` (of the form `$BASE_URL/calm/namespaces/$NAMESPACE/$TYPE/$MAPPING_ID/versions/$VERSION`). Push **does not auto-bump**: it creates exactly the version each document declares. Documents without a well-formed mapping `$id` (or whose type has no CalmHub resource type) are skipped with a warning.

Narrative Markdown documents use `--type knowledge` or `--type sad`. They require YAML frontmatter with a `title` and `--namespace`. The first push stores the Hub numeric document id, location, and version (`1.0.0`) in `workspace-manifest.json`. Later changes require `workspace bump`; the command updates the manifest version without rewriting the Markdown.

```
calm workspace push [--calm-hub-url <url>] [--fail-if-modified]
Expand All @@ -912,6 +926,17 @@ calm workspace push --calm-hub-url https://calmhub.example.com
calm workspace push --fail-if-modified # strict merge-time mode
```

```shell
# First-class document POC: add, publish, inspect, edit, bump, and publish again
calm workspace add ./docs/payments-sad.md --type sad --namespace finos
calm workspace push --calm-hub-url http://localhost:8080
calm workspace show # shows the published Hub location
calm workspace check --calm-hub-url http://localhost:8080
# Edit ./docs/payments-sad.md, then bump and publish the new version
calm workspace bump --minor --calm-hub-url http://localhost:8080
calm workspace push --calm-hub-url http://localhost:8080
```

#### `calm workspace check`

Check whether any tracked document has changed on disk relative to CalmHub but has **not** been version-bumped. Intended as a CI/PR gate β€” it **exits non-zero** when a bump is required, so a PR cannot merge with unversioned changes.
Expand Down
5 changes: 5 additions & 0 deletions cli/smoke/harness/hub-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,10 @@ export function hubApi(baseUrl: string = SMOKE_HUB_URL) {
`${baseUrl}/calm/namespaces/${namespace}/${type}/${mapping}/versions/${version}`
);
},
async getNarrativeDocument(namespace: string, type: string, id: number, version: string): Promise<string> {
const body = await getJson(`${baseUrl}/api/calm/namespaces/${namespace}/documents/${type}/${id}/versions/${version}`);
if (typeof body.documentMarkdown !== 'string') throw new Error('Narrative document response has no documentMarkdown');
return body.documentMarkdown;
},
};
}
72 changes: 72 additions & 0 deletions cli/smoke/workspace-documents.smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import path from 'path';
import * as fs from 'fs';
import { execSync } from 'child_process';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { installPackedCli, type CliInstall } from '../src/test_helpers/cli-runner';
import { SMOKE_HUB_URL } from './global-setup';
import { hubApi } from './harness/hub-api';
import { hubDocId } from './harness/fixtures';

const CLI_ROOT = path.resolve(__dirname, '..');
const NS = 'smoke-workspace-documents';
const api = hubApi();

describe('workspace narrative-document POC', () => {
let cli: CliInstall;
let wsDir: string;
let documentPath: string;
let architecturePath: string;
let documentId: number;
const initial = '---\ntitle: Payments SAD\ndescription: Smoke document\n---\n# Payments\n';

async function run(args: string[]) {
return cli.run(args, { cwd: wsDir });
}

beforeAll(async () => {
cli = installPackedCli(CLI_ROOT, 'calm-smoke-workspace-documents');
wsDir = path.join(cli.tempDir, 'repo');
fs.mkdirSync(wsDir, { recursive: true });
execSync('git init', { cwd: wsDir, stdio: 'inherit' });
documentPath = path.join(wsDir, 'payments-sad.md');
fs.writeFileSync(documentPath, initial);
architecturePath = path.join(wsDir, 'payments.architecture.json');
fs.writeFileSync(architecturePath, JSON.stringify({
$schema: 'https://calm.finos.org/release/1.0/meta/calm.json',
$id: hubDocId(NS, 'architectures', 'payments', '1.0.0'),
title: 'Payments', nodes: [], relationships: [],
}, null, 2));
await cli.run(['hub', 'create', 'namespace', '--name', NS, '--description', 'workspace documents smoke', '-c', SMOKE_HUB_URL]);
}, 120_000);

afterAll(() => cli?.cleanup());

test('publishes, retrieves, bumps, and republishes a Markdown document', async () => {
await run(['workspace', 'init', 'documents']);
await run(['workspace', 'add', architecturePath, '--type', 'architecture', '--namespace', NS]);
await run(['workspace', 'add', documentPath, '--type', 'sad', '--namespace', NS]);
await run(['workspace', 'push', '--calm-hub-url', SMOKE_HUB_URL]);
expect(await api.listVersions(NS, 'architectures', 'payments')).toContain('1.0.0');

const manifestPath = path.join(wsDir, '.calm-workspace', 'bundles', 'documents', 'workspace-manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, { calmHubDocumentId: number }>;
documentId = manifest['Payments SAD'].calmHubDocumentId;
expect(await api.getNarrativeDocument(NS, 'sad', documentId, '1.0.0')).toBe(initial);

fs.writeFileSync(documentPath, initial.replace('# Payments', '# Updated payments'));
await expect(run(['workspace', 'check', '--calm-hub-url', SMOKE_HUB_URL])).rejects.toHaveProperty('exitCode', 1);
await run(['workspace', 'bump', '--minor', '--calm-hub-url', SMOKE_HUB_URL]);
await run(['workspace', 'push', '--calm-hub-url', SMOKE_HUB_URL]);
expect(await api.getNarrativeDocument(NS, 'sad', documentId, '1.1.0')).toContain('# Updated payments');

await run(['workspace', 'rm', 'Payments SAD']);
await run([
'workspace', 'add', documentPath, '--type', 'sad', '--namespace', NS,
'--calm-hub-document-id', String(documentId), '--ver', '1.1.0', '--calm-hub-url', SMOKE_HUB_URL,
]);
const recovered = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
expect(recovered['Payments SAD']).toMatchObject({ calmHubDocumentId: documentId, version: '1.1.0' });
await run(['workspace', 'push', '--fail-if-modified', '--calm-hub-url', SMOKE_HUB_URL]);
expect(await api.getNarrativeDocument(NS, 'sad', documentId, '1.0.0')).toBe(initial);
});
});
Loading
Loading