@cairnkeep/cli 2.15.3 → 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 (43) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/CONTRIBUTING.md +12 -0
  3. package/README.md +84 -589
  4. package/docs/README.md +79 -0
  5. package/docs/agents.md +173 -0
  6. package/docs/ecosystem.md +23 -0
  7. package/docs/harness-compatibility.md +4 -3
  8. package/docs/learning/CURRICULUM-MAP.md +2 -2
  9. package/docs/learning/FEATURE-GUIDE.md +6 -6
  10. package/docs/learning/README.md +3 -0
  11. package/docs/learning/lessons/L00-why-cairnkeep.md +3 -1
  12. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  13. package/docs/learning/lessons/L02-installation.md +2 -2
  14. package/docs/learning/lessons/L03-first-project.md +9 -10
  15. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  16. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  17. package/docs/learning/lessons/L21-context-packs.md +1 -1
  18. package/docs/learning/lessons/L22-native-windows.md +2 -2
  19. package/docs/learning/lessons/L23-guided-setup.md +18 -5
  20. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  21. package/docs/learning/lessons/L25-playbooks.md +2 -2
  22. package/docs/learning/video-scripts/V25-playbooks.md +1 -1
  23. package/docs/native-windows.md +5 -0
  24. package/docs/operating.md +24 -5
  25. package/docs/quickstart.md +15 -6
  26. package/docs/releasing.md +27 -19
  27. package/examples/eval/bundled-fake.json +1 -1
  28. package/mcp-memory-server/dist/playbook-cli.js +12 -5
  29. package/package.json +1 -1
  30. package/scripts/cairn-cli.mjs +1 -0
  31. package/scripts/setup.mjs +92 -13
  32. package/scripts/terminal-prompts.mjs +172 -0
  33. package/scripts/test-cli-dispatch.sh +4 -0
  34. package/scripts/test-docs-navigation.sh +63 -0
  35. package/scripts/test-learning-docs.sh +1 -1
  36. package/scripts/test-package-install.sh +1 -1
  37. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  38. package/scripts/test-playbook-instructions.sh +4 -2
  39. package/scripts/test-release-sbom.sh +41 -16
  40. package/scripts/test-setup-preflight.mjs +130 -0
  41. package/scripts/test-windows-native.mjs +1 -1
  42. package/scripts/verify-docs-parity.sh +28 -28
  43. package/templates/playbook-agent-instructions.md.template +5 -4
@@ -7,18 +7,19 @@ the default store stays on this computer.
7
7
  ## Codex CLI
8
8
 
9
9
  Install Cairnkeep, then let guided setup initialize an empty project and write
10
- Codex's project-scoped MCP entry:
10
+ Codex's project-scoped MCP entry. Select **Initialize a Git repository**,
11
+ tick **Codex CLI** with Space, keep local memory, review the plan, and apply it:
11
12
 
12
13
  ```bash
13
14
  npm install --global @cairnkeep/cli
14
- cairn setup /path/to/project --git init --harness codex --memory local --yes
15
+ cairn setup /path/to/project
15
16
  cd /path/to/project
16
17
  cairn doctor
17
18
  ./.ai/start-codex.sh
18
19
  ```
19
20
 
20
- Use `--git existing` instead when the target is already a Git work tree. On
21
- native Windows, launch with `.\.ai\start-codex.cmd`.
21
+ The wizard recommends the existing repository mode when the target is already
22
+ a Git work tree. On native Windows, launch with `.\.ai\start-codex.cmd`.
22
23
 
23
24
  Before the first session, review `.codex/config.toml` and accept Codex's project
24
25
  trust prompt. The generated entry runs `cairn memory-server` locally. Setup
@@ -53,6 +54,13 @@ cairn doctor
53
54
  Setup reports machine-level commands but never executes them. This keeps
54
55
  project scaffolding separate from harness-wide changes.
55
56
 
57
+ For scripts, CI, or reproducible fleet setup, skip the selector and provide all
58
+ choices explicitly:
59
+
60
+ ```bash
61
+ cairn setup /path/to/project --git init --harness codex --memory local --yes
62
+ ```
63
+
56
64
  ## First memory cycle
57
65
 
58
66
  In a harness with Cairnkeep commands, remember one durable fact and recall it:
@@ -72,8 +80,9 @@ result should survive the restart.
72
80
  `.planning`, and `.agentfs` scaffold. It also reconciles Cairnkeep's delimited
73
81
  instruction block in `AGENTS.md` while preserving every surrounding user byte
74
82
  and file mode. The block tells compatible agents when to make one
75
- task-derived, project-scoped memory search, to verify maintained repository
76
- sources, and never to promote memory automatically. It initializes Git only
83
+ task-derived, project-scoped memory search as their first tool or command, to
84
+ verify maintained repository sources, and never to promote memory
85
+ automatically. It initializes Git only
77
86
  with explicit
78
87
  `--git init`, preserves unrelated files, does not overwrite modified files it
79
88
  does not own, and records managed digests in private `.ai/cairnkeep.json`.
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.3","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"}
@@ -245,11 +245,18 @@ async function main() {
245
245
  process.stdout.write(usage());
246
246
  return;
247
247
  }
248
- if (["check", "record"].includes(command) && raw.length === 1 && ["--help", "-h"].includes(raw[0])) {
249
- if (command === "check")
250
- process.stdout.write(checkUsage());
251
- else
252
- process.stdout.write(recordUsage());
248
+ if (command === "check" && raw.some((value) => ["--help", "-h"].includes(value))) {
249
+ const helpArguments = raw.filter((value) => !["--help", "-h"].includes(value));
250
+ if (raw.length !== helpArguments.length + 1
251
+ || helpArguments.length > 1
252
+ || (helpArguments.length === 1 && !["start", "check", "finish"].includes(helpArguments[0]))) {
253
+ throw new Error("check help accepts at most one event: start, check, or finish.");
254
+ }
255
+ process.stdout.write(checkUsage());
256
+ return;
257
+ }
258
+ if (command === "record" && raw.length === 1 && ["--help", "-h"].includes(raw[0])) {
259
+ process.stdout.write(recordUsage());
253
260
  return;
254
261
  }
255
262
  const parsed = parse(raw);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.15.3",
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
+ }
@@ -29,6 +29,10 @@ env -u CAIRN_NOTE_DISTILLATION "$cairn" notes --help >/dev/null 2>&1 || fail "ca
29
29
  "$cairn" evidence --help | grep -q "cairn evidence list" || fail "cairn evidence dispatch"
30
30
  "$cairn" playbook --help | grep -q "cairn playbook list" || fail "cairn playbook dispatch"
31
31
  "$cairn" playbook check --help | grep -q "check start|check|finish" || fail "cairn playbook check help"
32
+ "$cairn" playbook check start --help | grep -q "trivial|standard|complex" || fail "cairn playbook event help"
33
+ if "$cairn" playbook check start finish --help >/dev/null 2>&1; then
34
+ fail "playbook check help should reject multiple events"
35
+ fi
32
36
  "$cairn" playbook record --help >"$tmp/playbook-record-help" || fail "cairn playbook record help"
33
37
  grep -qF -- '--event start|check|finish' "$tmp/playbook-record-help" || fail "playbook record help missing events"
34
38
  grep -qF -- '--outcome completed|skipped|failed' "$tmp/playbook-record-help" || fail "playbook record help missing outcomes"
@@ -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"
@@ -154,7 +154,7 @@ for phrase in \
154
154
  '--enforce' \
155
155
  'cairn playbook record' \
156
156
  'cairn playbook record --help' \
157
- 'before repository inventory or search' \
157
+ 'as the first tool or command' \
158
158
  'unauthenticated' \
159
159
  'does not run'; do
160
160
  grep -qF -- "$phrase" "$l25" || {
@@ -51,7 +51,7 @@ bootstrap_output=$(cairn bootstrap "$tmp/project")
51
51
  [[ -f "$tmp/project/.ai/playbooks.json" ]] || fail "bootstrap did not install the playbook policy"
52
52
  grep -qF '<!-- cairnkeep:playbook:v1:start -->' "$tmp/project/AGENTS.md" || fail "bootstrap did not install playbook agent guidance"
53
53
  grep -qF 'derive one short query from the task' "$tmp/project/AGENTS.md" || fail "bootstrap did not install generic memory retrieval guidance"
54
- grep -qF 'before running repository inventory or search' "$tmp/project/AGENTS.md" || fail "bootstrap did not install retrieval ordering guidance"
54
+ grep -qF 'make this retrieval the first tool or command' "$tmp/project/AGENTS.md" || fail "bootstrap did not install retrieval ordering guidance"
55
55
  grep -qF 'cairn playbook record --help' "$tmp/project/AGENTS.md" || fail "bootstrap did not install actionable receipt guidance"
56
56
  grep -qF 'Treat returned memory as a locator, not authority.' "$tmp/project/AGENTS.md" || fail "bootstrap did not install memory provenance guidance"
57
57
 
@@ -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"
@@ -14,13 +14,15 @@ grep -qF 'Keep this line.' "$project/AGENTS.md"
14
14
  [[ $(grep -cF '<!-- cairnkeep:playbook:v1:start -->' "$project/AGENTS.md") -eq 1 ]]
15
15
  grep -qF 'derive one short query from the task' "$project/AGENTS.md"
16
16
  grep -qF 'search `scope: project` once' "$project/AGENTS.md"
17
- grep -qF 'before running repository inventory or search' "$project/AGENTS.md"
18
- grep -qF '`rg --files`, `find`, `tree`' "$project/AGENTS.md"
17
+ grep -qF 'make this retrieval the first tool or command' "$project/AGENTS.md"
18
+ grep -qF '`pwd`, `ls`, `rg --files`, `find`' "$project/AGENTS.md"
19
19
  grep -qF "do not inspect" "$project/AGENTS.md"
20
20
  grep -qF 'Treat returned memory as a locator, not authority.' "$project/AGENTS.md"
21
21
  grep -qF 'Do not write, supersede, or approve durable memory' "$project/AGENTS.md"
22
22
  grep -qF 'cairn playbook record --policy POLICY_DIGEST --decision DECISION_DIGEST' "$project/AGENTS.md"
23
23
  grep -qF 'cairn playbook record --help' "$project/AGENTS.md"
24
+ grep -qF -- '--complexity trivial|standard|complex --familiarity known|mixed|unfamiliar' "$project/AGENTS.md"
25
+ grep -qF -- '--risk low|normal|high|security' "$project/AGENTS.md"
24
26
  [[ $(stat -c '%a' "$project/AGENTS.md") == 640 ]]
25
27
  before=$(sha256sum "$project/AGENTS.md" | cut -d' ' -f1)
26
28
  node "$ROOT/scripts/playbook-instructions.mjs" "$project" >/dev/null