@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.
- package/CHANGELOG.md +20 -0
- package/CONTRIBUTING.md +12 -0
- package/README.md +84 -590
- package/docs/README.md +79 -0
- package/docs/agents.md +173 -0
- package/docs/ecosystem.md +23 -0
- package/docs/learning/CURRICULUM-MAP.md +1 -1
- package/docs/learning/FEATURE-GUIDE.md +2 -2
- 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 +1 -1
- package/docs/native-windows.md +5 -0
- package/docs/operating.md +21 -3
- package/docs/quickstart.md +12 -4
- package/docs/releasing.md +27 -19
- package/examples/eval/bundled-fake.json +1 -1
- 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-docs-navigation.sh +63 -0
- package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
- package/scripts/test-release-sbom.sh +41 -16
- package/scripts/test-setup-preflight.mjs +130 -0
- 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.
|
|
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"}
|
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
|
+
}
|
|
@@ -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/
|
|
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
|
-
|
|
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" "$
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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' "$
|
|
41
|
-
grep -Fq -- '--package-lock-only' "$
|
|
42
|
-
grep -Fq -- '--omit dev' "$
|
|
43
|
-
grep -Fq -- '--output-reproducible' "$
|
|
44
|
-
grep -Fq -- '--spec-version 1.6' "$
|
|
45
|
-
grep -Fq -- '--validate' "$
|
|
46
|
-
if grep -Fq 'npm sbom' "$
|
|
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' "$
|
|
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
|
|