@cairnkeep/cli 2.15.4 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CONTRIBUTING.md +12 -0
  3. package/README.md +84 -590
  4. package/docs/README.md +79 -0
  5. package/docs/agents.md +173 -0
  6. package/docs/ecosystem.md +23 -0
  7. package/docs/learning/CURRICULUM-MAP.md +1 -1
  8. package/docs/learning/FEATURE-GUIDE.md +2 -2
  9. package/docs/learning/README.md +3 -0
  10. package/docs/learning/lessons/L00-why-cairnkeep.md +3 -1
  11. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  12. package/docs/learning/lessons/L02-installation.md +2 -2
  13. package/docs/learning/lessons/L03-first-project.md +9 -10
  14. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  15. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  16. package/docs/learning/lessons/L21-context-packs.md +1 -1
  17. package/docs/learning/lessons/L22-native-windows.md +2 -2
  18. package/docs/learning/lessons/L23-guided-setup.md +18 -5
  19. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  20. package/docs/learning/lessons/L25-playbooks.md +1 -1
  21. package/docs/native-windows.md +5 -0
  22. package/docs/operating.md +21 -3
  23. package/docs/quickstart.md +12 -4
  24. package/docs/releasing.md +27 -19
  25. package/examples/eval/bundled-fake.json +1 -1
  26. package/package.json +1 -1
  27. package/scripts/cairn-cli.mjs +1 -0
  28. package/scripts/setup.mjs +92 -13
  29. package/scripts/terminal-prompts.mjs +172 -0
  30. package/scripts/test-docs-navigation.sh +63 -0
  31. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  32. package/scripts/test-release-sbom.sh +41 -16
  33. package/scripts/test-setup-preflight.mjs +130 -0
  34. package/scripts/verify-docs-parity.sh +28 -28
package/docs/releasing.md CHANGED
@@ -10,37 +10,45 @@ from a workstation.
10
10
  `npm --prefix mcp-memory-server test`.
11
11
  3. Merge only after every required CI check passes.
12
12
  4. Publish a GitHub Release whose tag is exactly `v<package.json version>`.
13
- 5. The `Publish release` workflow checks out that immutable tag, repeats the
14
- tests, and publishes `@cairnkeep/cli` with npm provenance.
15
- 6. The workflow uses the exact locked `@cyclonedx/cyclonedx-npm` development
16
- dependency to generate a reproducible CycloneDX 1.6 SBOM from
17
- `package-lock.json`. Generation includes schema validation and runs before
18
- npm publication, so an invalid document blocks both publication and
19
- attachment. The workflow attaches that SBOM, the npm tarball, and SHA-256
20
- checksums to the GitHub Release.
21
- 7. The workflow publishes versioned `linux/amd64` and `linux/arm64`
22
- memory-server and workspace images to GHCR. It also updates `latest` for a
23
- stable release or `next` for a prerelease.
24
- 8. Verify both image digests, SBOM attestations, provenance attestations, and an
13
+ 5. After every platform and runtime job passes, CI builds one release candidate
14
+ keyed by the exact Git tree. It contains the npm tarball, reproducible
15
+ CycloneDX 1.6 SBOM, checksums, and a tree/version manifest. CI retains it for
16
+ 30 days.
17
+ 6. The `Publish release` workflow checks out the immutable tag, resolves its
18
+ tree, and accepts a candidate only from a successful `CI` run for that exact
19
+ tree. It verifies the manifest and checksums instead of rebuilding or
20
+ repeating the complete test suite.
21
+ 7. After candidate verification, npm publication and the memory-server and
22
+ workspace container publications run in parallel. npm retains provenance;
23
+ the containers retain their per-image SBOM and provenance attestations.
24
+ 8. The workflow attaches the verified SBOM, npm tarball, and SHA-256 checksums
25
+ to the GitHub Release. It publishes versioned `linux/amd64` and
26
+ `linux/arm64` images to GHCR and updates `latest` for a stable release or
27
+ `next` for a prerelease.
28
+ 9. Verify both image digests, SBOM attestations, provenance attestations, and an
25
29
  anonymous pull before announcing the release.
26
30
 
27
31
  Stable versions must use a stable GitHub Release and publish to npm's `latest`
28
32
  tag. SemVer prerelease versions must use a GitHub prerelease and publish to
29
33
  `next`. A mismatch fails before publication.
30
34
 
31
- The workflow is safe to rerun: it skips `npm publish` and each versioned OCI
32
- image when that exact version is already present. It regenerates release
33
- artifacts, retries attestations against the resolved image digests, and moves
35
+ The workflow is safe to rerun: it re-verifies the same CI candidate, skips
36
+ `npm publish` and each versioned OCI image when that exact version is already
37
+ present, retries attestations against the resolved image digests, and moves
34
38
  only the `latest` or `next` channel tag. A versioned image tag is never rebuilt
35
- or moved.
39
+ or moved. If the tree-addressed candidate has expired, rerun CI for the exact
40
+ release commit before retrying publication; the workflow never substitutes a
41
+ different version or tree.
36
42
 
37
43
  ## Repository configuration
38
44
 
39
45
  The repository must provide an `NPM_TOKEN` Actions secret authorized to publish
40
46
  `@cairnkeep/cli`. Keep that credential out of local files and rotate it according
41
- to the npm account's security policy. The workflow grants `contents: write`,
42
- `packages: write`, `attestations: write`, and `id-token: write`; the last two
43
- are required for registry-backed build provenance.
47
+ to the npm account's security policy. The workflow grants `actions: read`,
48
+ `contents: write`, `packages: write`, `attestations: write`, and
49
+ `id-token: write`. Read access resolves the successful tree-addressed CI
50
+ artifact; the last two permissions are required for registry-backed build
51
+ provenance.
44
52
 
45
53
  GitHub creates each new GHCR package as private. After the first container
46
54
  release, a package administrator must change both `cairnkeep` and
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.15.4","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.16.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.15.4",
3
+ "version": "2.16.0",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -37,6 +37,7 @@ export const USAGE = `cairn — Cairnkeep CLI
37
37
 
38
38
  Usage:
39
39
  cairn bootstrap [--untracked] [path]
40
+ cairn setup [PATH] # interactive Git, harness, memory, and confirmation selectors
40
41
  cairn setup PATH --git init|existing|none --harness LIST --memory local|none [--policy PATH] --yes [--json]
41
42
  cairn memory-server
42
43
  cairn sync [--check|--apply] [--live-root DIR]
package/scripts/setup.mjs CHANGED
@@ -21,8 +21,9 @@ import {
21
21
  resolveSetupChoices,
22
22
  } from "./setup-core.mjs";
23
23
  import { reconcileSetupPlan } from "./setup-reconcile.mjs";
24
- import { HARNESS_IDS, machineSyncCommand, requiredHarnessAssetPaths } from "./harness-registry.mjs";
24
+ import { HARNESS_IDS, HARNESS_REGISTRY, machineSyncCommand, requiredHarnessAssetPaths } from "./harness-registry.mjs";
25
25
  import { reconcilePlaybookInstructions } from "./playbook-instructions.mjs";
26
+ import { selectManyPrompt, selectOnePrompt, supportsTerminalPrompts } from "./terminal-prompts.mjs";
26
27
 
27
28
  const HARNESSES = HARNESS_IDS;
28
29
  const GIT_MODES = Object.freeze(["init", "existing", "none"]);
@@ -70,11 +71,36 @@ function parseInteractiveHarnesses(value) {
70
71
  }
71
72
 
72
73
  export async function promptSetupChoices(parsed, streams) {
73
- const terminal = typeof streams.question === "function"
74
- ? { question: streams.question, close: streams.closePrompt ?? (() => {}) }
75
- : createInterface({ input: streams.input, output: streams.output });
74
+ let linePrompt = null;
75
+ let externallyClosed = false;
76
+ const question = typeof streams.question === "function"
77
+ ? streams.question
78
+ : async (message) => {
79
+ linePrompt ??= createInterface({ input: streams.input, output: streams.output });
80
+ return linePrompt.question(message);
81
+ };
82
+ const closeLinePrompt = () => {
83
+ if (linePrompt) linePrompt.close();
84
+ linePrompt = null;
85
+ if (!externallyClosed) {
86
+ streams.closePrompt?.();
87
+ externallyClosed = true;
88
+ }
89
+ };
90
+ const structured = Boolean(streams.selectOne && streams.selectMany)
91
+ || supportsTerminalPrompts(streams.input, streams.output);
92
+ const selectOne = streams.selectOne ?? ((options) => selectOnePrompt({
93
+ ...options,
94
+ input: streams.input,
95
+ output: streams.output,
96
+ }));
97
+ const selectMany = streams.selectMany ?? ((options) => selectManyPrompt({
98
+ ...options,
99
+ input: streams.input,
100
+ output: streams.output,
101
+ }));
76
102
  try {
77
- const target = parsed.target ?? (await terminal.question("Target path: ")).trim();
103
+ const target = parsed.target ?? (await question("Target path: ")).trim();
78
104
  if (!target) throw operational("interactive-input", "A setup target path is required.");
79
105
  const preflight = classifySetupTarget(target);
80
106
  const gitQuestion = preflight.targetState === "missing" || preflight.targetState === "empty"
@@ -82,12 +108,63 @@ export async function promptSetupChoices(parsed, streams) {
82
108
  : preflight.repository === "work-tree"
83
109
  ? "Git mode (existing recommended; choices: init, existing, none): "
84
110
  : "Git mode (init requires explicit choice for existing non-Git target; choices: init, existing, none): ";
85
- const git = parsed.git ?? (await terminal.question(gitQuestion)).trim();
86
- const harnesses = parsed.harnesses ?? parseInteractiveHarnesses(await terminal.question(`Harnesses (${HARNESSES.join(", ")}): `));
87
- const memory = parsed.memory ?? (await terminal.question("Memory mode (local, none): ")).trim();
88
- return { target, git, harnesses, memory, preflight, terminal };
111
+ if (structured) closeLinePrompt();
112
+ const gitChoices = preflight.gitExecutable === "missing"
113
+ ? [{ value: "none", label: "Continue without Git", hint: "Git executable not found; repository features will be limited" }]
114
+ : preflight.repository === "work-tree"
115
+ ? [
116
+ { value: "existing", label: "Use the existing Git repository", hint: "recommended" },
117
+ { value: "none", label: "Continue without Git integration", hint: "limited" },
118
+ ]
119
+ : [
120
+ { value: "init", label: "Initialize a Git repository", hint: preflight.targetState === "non-empty" ? "review the existing directory first" : "recommended" },
121
+ { value: "none", label: "Continue without Git integration", hint: "limited" },
122
+ ];
123
+ const git = parsed.git ?? (structured
124
+ ? await selectOne({ message: "Git integration", choices: gitChoices, initialValue: gitChoices[0].value })
125
+ : (await question(gitQuestion)).trim());
126
+ const harnesses = parsed.harnesses ?? (structured
127
+ ? await selectMany({
128
+ message: "Coding harnesses",
129
+ choices: HARNESS_REGISTRY.map(({ id, title }) => ({ value: id, label: `${title} (${id})` })),
130
+ })
131
+ : parseInteractiveHarnesses(await question(`Harnesses (${HARNESSES.join(", ")}): `)));
132
+ const memory = parsed.memory ?? (structured
133
+ ? await selectOne({
134
+ message: "Memory mode",
135
+ choices: [
136
+ { value: "local", label: "Local Cairnkeep memory", hint: "recommended" },
137
+ { value: "none", label: "No memory server" },
138
+ ],
139
+ initialValue: "local",
140
+ })
141
+ : (await question("Memory mode (local, none): ")).trim());
142
+ return {
143
+ target,
144
+ git,
145
+ harnesses,
146
+ memory,
147
+ preflight,
148
+ terminal: {
149
+ question,
150
+ close: closeLinePrompt,
151
+ confirm: structured
152
+ ? async () => (await selectOne({
153
+ message: "Apply this setup plan?",
154
+ choices: [
155
+ { value: "no", label: "No, cancel" },
156
+ { value: "yes", label: "Yes, apply the plan" },
157
+ ],
158
+ initialValue: "no",
159
+ })) === "yes"
160
+ : async () => {
161
+ const answer = (await question("Apply this setup plan? [y/N] ")).trim().toLowerCase();
162
+ return answer === "y" || answer === "yes";
163
+ },
164
+ },
165
+ };
89
166
  } catch (error) {
90
- terminal.close();
167
+ closeLinePrompt();
91
168
  throw error;
92
169
  }
93
170
  }
@@ -167,7 +244,7 @@ function initializeGit(target) {
167
244
  }
168
245
 
169
246
  async function executeSetup(args, options) {
170
- const isTTY = options.isTTY ?? Boolean(options.input.isTTY);
247
+ const isTTY = options.isTTY ?? Boolean(options.input.isTTY && options.output.isTTY);
171
248
  const parsed = parseSetupArgs(args, { isTTY });
172
249
  const policy = parsed.policyPath ? readSetupPolicy(parsed.policyPath) : null;
173
250
 
@@ -196,9 +273,9 @@ async function executeSetup(args, options) {
196
273
 
197
274
  if (interactive && !parsed.confirmed) {
198
275
  renderPlan(plan, options.output);
199
- const answer = (await interactive.terminal.question("Apply this setup plan? [y/N] ")).trim().toLowerCase();
276
+ const accepted = await interactive.terminal.confirm();
200
277
  interactive.terminal.close();
201
- if (answer !== "y" && answer !== "yes") {
278
+ if (!accepted) {
202
279
  writeLine(options.output, "Setup cancelled; no files were changed.");
203
280
  return null;
204
281
  }
@@ -221,6 +298,8 @@ export async function runSetup(args, options = {}) {
221
298
  augmentPlan: options.augmentPlan,
222
299
  question: options.question,
223
300
  closePrompt: options.closePrompt,
301
+ selectOne: options.selectOne,
302
+ selectMany: options.selectMany,
224
303
  };
225
304
  try {
226
305
  const result = await executeSetup(args, streams);
@@ -0,0 +1,172 @@
1
+ import { emitKeypressEvents } from "node:readline";
2
+
3
+ function promptError(message, status = 1) {
4
+ const error = new Error(message);
5
+ error.name = "TerminalPromptError";
6
+ error.status = status;
7
+ return error;
8
+ }
9
+
10
+ function normalizedChoices(choices) {
11
+ if (!Array.isArray(choices) || choices.length === 0) throw promptError("A terminal prompt requires at least one choice.");
12
+ const normalized = choices.map((choice) => Object.freeze({
13
+ value: String(choice.value),
14
+ label: String(choice.label),
15
+ hint: choice.hint ? String(choice.hint) : "",
16
+ }));
17
+ if (new Set(normalized.map(({ value }) => value)).size !== normalized.length) {
18
+ throw promptError("Terminal prompt choices must have unique values.");
19
+ }
20
+ return Object.freeze(normalized);
21
+ }
22
+
23
+ export function supportsTerminalPrompts(input, output, env = process.env) {
24
+ return Boolean(
25
+ input?.isTTY
26
+ && output?.isTTY
27
+ && typeof input.setRawMode === "function"
28
+ && env.TERM !== "dumb",
29
+ );
30
+ }
31
+
32
+ async function runKeypressPrompt({ input, output, draw, accept }) {
33
+ emitKeypressEvents(input);
34
+ const wasRaw = Boolean(input.isRaw);
35
+ const wasPaused = typeof input.isPaused === "function" ? input.isPaused() : false;
36
+ let renderedLines = 0;
37
+ let settled = false;
38
+ let keypressHandler = null;
39
+ let rawChanged = false;
40
+ let resumed = false;
41
+ let cursorHidden = false;
42
+
43
+ const render = () => {
44
+ const lines = draw();
45
+ if (renderedLines > 0) output.write(`\x1b[${renderedLines}A\r\x1b[0J`);
46
+ output.write(`${lines.join("\n")}\n`);
47
+ renderedLines = lines.length;
48
+ };
49
+
50
+ const clear = () => {
51
+ if (renderedLines > 0) output.write(`\x1b[${renderedLines}A\r\x1b[0J`);
52
+ renderedLines = 0;
53
+ };
54
+
55
+ try {
56
+ if (!wasRaw) {
57
+ input.setRawMode(true);
58
+ rawChanged = true;
59
+ }
60
+ if (wasPaused && typeof input.resume === "function") {
61
+ input.resume();
62
+ resumed = true;
63
+ }
64
+ output.write("\x1b[?25l");
65
+ cursorHidden = true;
66
+ render();
67
+ return await new Promise((resolve, reject) => {
68
+ const finish = (callback, value) => {
69
+ if (settled) return;
70
+ settled = true;
71
+ input.removeListener("keypress", onKeypress);
72
+ callback(value);
73
+ };
74
+ const onKeypress = (text, key = {}) => {
75
+ try {
76
+ if ((key.ctrl && key.name === "c") || key.name === "escape") {
77
+ finish(reject, promptError("Setup cancelled.", 130));
78
+ return;
79
+ }
80
+ const result = accept(text, key);
81
+ if (result?.done) finish(resolve, result.value);
82
+ else render();
83
+ } catch (error) {
84
+ finish(reject, error);
85
+ }
86
+ };
87
+ keypressHandler = onKeypress;
88
+ input.on("keypress", onKeypress);
89
+ });
90
+ } finally {
91
+ if (keypressHandler) input.removeListener("keypress", keypressHandler);
92
+ clear();
93
+ if (cursorHidden) output.write("\x1b[?25h");
94
+ if (rawChanged) input.setRawMode(false);
95
+ if (resumed && typeof input.pause === "function") input.pause();
96
+ }
97
+ }
98
+
99
+ function movement(key, cursor, length) {
100
+ if (key.name === "up" || key.name === "k") return (cursor - 1 + length) % length;
101
+ if (key.name === "down" || key.name === "j") return (cursor + 1) % length;
102
+ if (key.name === "home") return 0;
103
+ if (key.name === "end") return length - 1;
104
+ return cursor;
105
+ }
106
+
107
+ export async function selectOnePrompt({ message, choices, initialValue, input, output }) {
108
+ const options = normalizedChoices(choices);
109
+ let cursor = Math.max(0, options.findIndex(({ value }) => value === initialValue));
110
+ const selected = await runKeypressPrompt({
111
+ input,
112
+ output,
113
+ draw: () => [
114
+ `? ${message} (Up/Down to move, Enter to select)`,
115
+ ...options.map((choice, index) => `${index === cursor ? ">" : " "} ${choice.label}${choice.hint ? ` - ${choice.hint}` : ""}`),
116
+ ],
117
+ accept: (text, key) => {
118
+ cursor = movement(key, cursor, options.length);
119
+ if (key.name === "return" || key.name === "enter") return { done: true, value: options[cursor].value };
120
+ const number = Number.parseInt(text, 10);
121
+ if (Number.isInteger(number) && number >= 1 && number <= options.length) {
122
+ cursor = number - 1;
123
+ return { done: true, value: options[cursor].value };
124
+ }
125
+ return null;
126
+ },
127
+ });
128
+ const label = options.find(({ value }) => value === selected)?.label ?? selected;
129
+ output.write(`> ${message}: ${label}\n`);
130
+ return selected;
131
+ }
132
+
133
+ export async function selectManyPrompt({ message, choices, selectedValues = [], input, output, minimum = 1 }) {
134
+ const options = normalizedChoices(choices);
135
+ const allowed = new Set(options.map(({ value }) => value));
136
+ const selected = new Set(selectedValues.filter((value) => allowed.has(value)));
137
+ let cursor = 0;
138
+ let validation = "";
139
+ const values = await runKeypressPrompt({
140
+ input,
141
+ output,
142
+ draw: () => [
143
+ `? ${message} (Up/Down to move, Space to toggle, A to toggle all, Enter to continue)`,
144
+ ...options.map((choice, index) => `${index === cursor ? ">" : " "} [${selected.has(choice.value) ? "x" : " "}] ${choice.label}${choice.hint ? ` - ${choice.hint}` : ""}`),
145
+ validation,
146
+ ],
147
+ accept: (text, key) => {
148
+ cursor = movement(key, cursor, options.length);
149
+ if (key.name === "space" || text === " ") {
150
+ const value = options[cursor].value;
151
+ if (selected.has(value)) selected.delete(value);
152
+ else selected.add(value);
153
+ validation = "";
154
+ } else if (text?.toLowerCase() === "a") {
155
+ if (selected.size === options.length) selected.clear();
156
+ else for (const { value } of options) selected.add(value);
157
+ validation = "";
158
+ } else if (key.name === "return" || key.name === "enter") {
159
+ if (selected.size < minimum) {
160
+ validation = ` Select at least ${minimum} option${minimum === 1 ? "" : "s"}.`;
161
+ output.write("\x07");
162
+ } else {
163
+ return { done: true, value: options.filter(({ value }) => selected.has(value)).map(({ value }) => value) };
164
+ }
165
+ }
166
+ return null;
167
+ },
168
+ });
169
+ const labels = options.filter(({ value }) => values.includes(value)).map(({ label }) => label);
170
+ output.write(`> ${message}: ${labels.join(", ")}\n`);
171
+ return values;
172
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT=$(cd "$(dirname "$0")/.." && pwd)
5
+ cd "$ROOT"
6
+
7
+ fail() {
8
+ echo "FATAL: $*" >&2
9
+ exit 1
10
+ }
11
+
12
+ require_term() {
13
+ local file="$1" term="$2"
14
+ grep -qF "$term" "$file" || fail "$file is missing required navigation term: $term"
15
+ }
16
+
17
+ check_local_links() {
18
+ local file="$1" base target resolved
19
+ base=$(dirname "$file")
20
+ while IFS= read -r target; do
21
+ case "$target" in
22
+ http://*|https://*|mailto:*|'#'*|'') continue ;;
23
+ esac
24
+ target=${target%%#*}
25
+ resolved="$base/$target"
26
+ [[ -e "$resolved" ]] || fail "$file links to missing local target: $target"
27
+ done < <(grep -oE '\]\([^)]+' "$file" | sed 's/^](//' || true)
28
+ }
29
+
30
+ readme_lines=$(wc -l < README.md | tr -d ' ')
31
+ [[ "$readme_lines" -le 140 ]] || fail "README.md must remain a lean landing page (found $readme_lines lines; limit 140)"
32
+
33
+ require_term README.md '## For coding agents'
34
+ require_term README.md 'docs/README.md'
35
+ require_term README.md 'docs/agents.md'
36
+ require_term README.md 'docs/quickstart.md'
37
+ require_term README.md 'docs/learning/README.md'
38
+
39
+ if grep -qE '\b(CAIRN_|MCP_HTTP_)' README.md; then
40
+ fail 'README.md contains detailed environment configuration; keep it in versioned focused docs'
41
+ fi
42
+
43
+ for guide in \
44
+ quickstart.md agents.md operating.md harness-compatibility.md \
45
+ mcp-tool-profiles.md context-packs.md storage.md privacy-and-data-flow.md \
46
+ work-evidence.md native-windows.md containers.md ecosystem.md \
47
+ learning/README.md; do
48
+ require_term docs/README.md "$guide"
49
+ done
50
+
51
+ for term in \
52
+ 'memory_search' 'locator' 'memory_write' 'cairn playbook check start' \
53
+ 'cairn playbook check finish' 'cairn mcp-tools set read-only' \
54
+ 'cannot enable a disabled feature' 'not evidence'; do
55
+ require_term docs/agents.md "$term"
56
+ done
57
+
58
+ check_local_links README.md
59
+ check_local_links docs/README.md
60
+ check_local_links docs/agents.md
61
+ check_local_links docs/ecosystem.md
62
+
63
+ echo "PASS: lean README, agent contract, documentation index, and local navigation"
@@ -244,7 +244,7 @@ check_uninstall_bytes() {
244
244
 
245
245
  check_docs_contract() {
246
246
  local public_doc
247
- for public_doc in "$ROOT/README.md" "$ROOT/docs/operating.md"; do
247
+ for public_doc in "$ROOT/docs/operating.md"; do
248
248
  require_term "$public_doc" 'CAIRN_COMPACTION_CAPTURE' "$(basename "$public_doc") compaction flag"
249
249
  require_term "$public_doc" 'CAIRN_ARTIFACT_STORE' "$(basename "$public_doc") artifact-store flag"
250
250
  require_term "$public_doc" 'CAIRN_ARTIFACT_HTTP' "$(basename "$public_doc") artifact HTTP flag"
@@ -2,7 +2,8 @@
2
2
  set -euo pipefail
3
3
 
4
4
  ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
5
- WORKFLOW="$ROOT/.github/workflows/publish.yml"
5
+ CANDIDATE_WORKFLOW="$ROOT/.github/workflows/ci.yml"
6
+ PUBLISH_WORKFLOW="$ROOT/.github/workflows/publish.yml"
6
7
  TEMP_DIR=$(mktemp -d)
7
8
  trap 'rm -f "$TEMP_DIR/cairnkeep.cdx.json"; rmdir "$TEMP_DIR"' EXIT
8
9
 
@@ -11,12 +12,13 @@ fail() {
11
12
  exit 1
12
13
  }
13
14
 
14
- node - "$ROOT/package.json" "$ROOT/package-lock.json" "$WORKFLOW" <<'NODE'
15
+ node - "$ROOT/package.json" "$ROOT/package-lock.json" "$CANDIDATE_WORKFLOW" "$PUBLISH_WORKFLOW" <<'NODE'
15
16
  const fs = require("node:fs");
16
17
 
17
18
  const manifest = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
18
19
  const lock = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
19
- const workflow = fs.readFileSync(process.argv[4], "utf8");
20
+ const candidateWorkflow = fs.readFileSync(process.argv[4], "utf8");
21
+ const publishWorkflow = fs.readFileSync(process.argv[5], "utf8");
20
22
  const expected = "6.0.0";
21
23
 
22
24
  if (manifest.devDependencies?.["@cyclonedx/cyclonedx-npm"] !== expected) {
@@ -29,24 +31,47 @@ if (lock.packages?.["node_modules/@cyclonedx/cyclonedx-npm"]?.version !== expect
29
31
  throw new Error(`package-lock.json must resolve @cyclonedx/cyclonedx-npm ${expected}`);
30
32
  }
31
33
 
32
- const artifactStep = workflow.indexOf("- name: Build and validate release artifacts");
33
- const publishStep = workflow.indexOf("- name: Publish to npm with provenance");
34
- const attachStep = workflow.indexOf("- name: Attach package and SBOM to GitHub release");
35
- if (artifactStep < 0 || publishStep < 0 || attachStep < 0 || !(artifactStep < publishStep && publishStep < attachStep)) {
36
- throw new Error("release artifacts must validate before npm publication and attach only after publication");
34
+ if (!candidateWorkflow.includes("release-candidate:")
35
+ || !candidateWorkflow.includes("- name: Build release candidate")
36
+ || !candidateWorkflow.includes("name: release-candidate-${{ steps.candidate.outputs.tree }}")) {
37
+ throw new Error("CI must build and upload a tree-addressed release candidate after its required jobs");
38
+ }
39
+ if (!publishWorkflow.includes("- name: Download the CI-verified release candidate")
40
+ || !publishWorkflow.includes("jq -r '.conclusion'")
41
+ || !publishWorkflow.includes("== success")
42
+ || !publishWorkflow.includes("sha256sum --check SHA256SUMS")
43
+ || !publishWorkflow.includes("candidate.tree !== tree")) {
44
+ throw new Error("release publication must verify the successful CI run, checksums, version, and exact tree");
45
+ }
46
+ const publishStep = publishWorkflow.indexOf("- name: Publish to npm with provenance");
47
+ const attachStep = publishWorkflow.indexOf("- name: Attach package and SBOM to GitHub release");
48
+ if (publishStep < 0 || attachStep < 0 || publishStep >= attachStep) {
49
+ throw new Error("verified artifacts must publish to npm before release attachment");
50
+ }
51
+ if (!publishWorkflow.includes('npm publish "./artifacts/cairnkeep-cli-$VERSION.tgz"')) {
52
+ throw new Error("npm publication must use an explicit relative tarball path");
53
+ }
54
+ if (publishWorkflow.includes("check:public")) {
55
+ throw new Error("release workflow must promote the verified candidate without repeating the public suite");
56
+ }
57
+ if (!publishWorkflow.includes("name: containers (${{ matrix.target }})")
58
+ || !publishWorkflow.includes("- target: server")
59
+ || !publishWorkflow.includes("- target: workspace")
60
+ || !publishWorkflow.includes("needs: prepare")) {
61
+ throw new Error("npm and both container targets must fan out after candidate preparation");
37
62
  }
38
63
  NODE
39
64
 
40
- grep -Fq './node_modules/.bin/cyclonedx-npm' "$WORKFLOW" || fail "release workflow does not use the locked CycloneDX generator"
41
- grep -Fq -- '--package-lock-only' "$WORKFLOW" || fail "release workflow does not use the package lock"
42
- grep -Fq -- '--omit dev' "$WORKFLOW" || fail "release workflow does not omit development dependencies"
43
- grep -Fq -- '--output-reproducible' "$WORKFLOW" || fail "release workflow does not request reproducible output"
44
- grep -Fq -- '--spec-version 1.6' "$WORKFLOW" || fail "release workflow does not emit CycloneDX 1.6"
45
- grep -Fq -- '--validate' "$WORKFLOW" || fail "release workflow does not validate the generated SBOM"
46
- if grep -Fq 'npm sbom' "$WORKFLOW"; then
65
+ grep -Fq './node_modules/.bin/cyclonedx-npm' "$CANDIDATE_WORKFLOW" || fail "candidate workflow does not use the locked CycloneDX generator"
66
+ grep -Fq -- '--package-lock-only' "$CANDIDATE_WORKFLOW" || fail "candidate workflow does not use the package lock"
67
+ grep -Fq -- '--omit dev' "$CANDIDATE_WORKFLOW" || fail "candidate workflow does not omit development dependencies"
68
+ grep -Fq -- '--output-reproducible' "$CANDIDATE_WORKFLOW" || fail "candidate workflow does not request reproducible output"
69
+ grep -Fq -- '--spec-version 1.6' "$CANDIDATE_WORKFLOW" || fail "candidate workflow does not emit CycloneDX 1.6"
70
+ grep -Fq -- '--validate' "$CANDIDATE_WORKFLOW" || fail "candidate workflow does not validate the generated SBOM"
71
+ if grep -Fq 'npm sbom' "$CANDIDATE_WORKFLOW" "$PUBLISH_WORKFLOW"; then
47
72
  fail "release workflow still uses npm sbom"
48
73
  fi
49
- if grep -Fqi 'sbomsmith' "$WORKFLOW"; then
74
+ if grep -Fqi 'sbomsmith' "$CANDIDATE_WORKFLOW" "$PUBLISH_WORKFLOW"; then
50
75
  fail "release workflow must not depend on an external converter"
51
76
  fi
52
77