@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.
- package/CHANGELOG.md +36 -0
- package/CONTRIBUTING.md +12 -0
- package/README.md +84 -589
- package/docs/README.md +79 -0
- package/docs/agents.md +173 -0
- package/docs/ecosystem.md +23 -0
- package/docs/harness-compatibility.md +4 -3
- package/docs/learning/CURRICULUM-MAP.md +2 -2
- package/docs/learning/FEATURE-GUIDE.md +6 -6
- package/docs/learning/README.md +3 -0
- package/docs/learning/lessons/L00-why-cairnkeep.md +3 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +9 -10
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +18 -5
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +2 -2
- package/docs/learning/video-scripts/V25-playbooks.md +1 -1
- package/docs/native-windows.md +5 -0
- package/docs/operating.md +24 -5
- package/docs/quickstart.md +15 -6
- package/docs/releasing.md +27 -19
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/playbook-cli.js +12 -5
- package/package.json +1 -1
- package/scripts/cairn-cli.mjs +1 -0
- package/scripts/setup.mjs +92 -13
- package/scripts/terminal-prompts.mjs +172 -0
- package/scripts/test-cli-dispatch.sh +4 -0
- package/scripts/test-docs-navigation.sh +63 -0
- package/scripts/test-learning-docs.sh +1 -1
- package/scripts/test-package-install.sh +1 -1
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-playbook-instructions.sh +4 -2
- package/scripts/test-release-sbom.sh +41 -16
- package/scripts/test-setup-preflight.mjs +130 -0
- package/scripts/test-windows-native.mjs +1 -1
- package/scripts/verify-docs-parity.sh +28 -28
- package/templates/playbook-agent-instructions.md.template +5 -4
package/docs/quickstart.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
76
|
-
sources, and never to promote memory
|
|
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.
|
|
14
|
-
|
|
15
|
-
6
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
7.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
8.
|
|
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
|
|
32
|
-
image when that exact version is already
|
|
33
|
-
|
|
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 `
|
|
42
|
-
`
|
|
43
|
-
|
|
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.
|
|
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 (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
package/scripts/cairn-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
276
|
+
const accepted = await interactive.terminal.confirm();
|
|
200
277
|
interactive.terminal.close();
|
|
201
|
-
if (
|
|
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
|
-
'
|
|
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 '
|
|
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/
|
|
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 '
|
|
18
|
-
grep -qF '`rg --files`, `find
|
|
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
|