@cairnkeep/cli 2.15.4 → 2.17.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 (70) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/CONTRIBUTING.md +12 -0
  3. package/README.md +85 -590
  4. package/docs/README.md +87 -0
  5. package/docs/agents.md +193 -0
  6. package/docs/context-intelligence.md +143 -0
  7. package/docs/context-packs.md +33 -0
  8. package/docs/domain-knowledge.md +29 -0
  9. package/docs/ecosystem.md +23 -0
  10. package/docs/learning/CURRICULUM-MAP.md +3 -1
  11. package/docs/learning/FEATURE-GUIDE.md +23 -4
  12. package/docs/learning/README.md +4 -0
  13. package/docs/learning/lessons/L00-why-cairnkeep.md +3 -1
  14. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  15. package/docs/learning/lessons/L02-installation.md +2 -2
  16. package/docs/learning/lessons/L03-first-project.md +9 -10
  17. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  18. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  19. package/docs/learning/lessons/L21-context-packs.md +1 -1
  20. package/docs/learning/lessons/L22-native-windows.md +2 -2
  21. package/docs/learning/lessons/L23-guided-setup.md +18 -5
  22. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  23. package/docs/learning/lessons/L25-playbooks.md +1 -1
  24. package/docs/learning/lessons/L26-context-intelligence.md +180 -0
  25. package/docs/learning/tracks/evidence-and-evaluation.md +1 -0
  26. package/docs/learning/tracks/operator.md +1 -0
  27. package/docs/learning/tracks/practitioner.md +1 -0
  28. package/docs/learning/video-scripts/V26-context-intelligence.md +144 -0
  29. package/docs/native-windows.md +5 -0
  30. package/docs/operating.md +65 -3
  31. package/docs/privacy-and-data-flow.md +22 -0
  32. package/docs/quickstart.md +12 -4
  33. package/docs/releasing.md +27 -19
  34. package/docs/storage.md +21 -0
  35. package/docs/work-evidence.md +14 -0
  36. package/examples/eval/bundled-fake.json +1 -1
  37. package/mcp-memory-server/dist/context-pack-cli.js +3 -1
  38. package/mcp-memory-server/dist/context-pack-retrieval.js +413 -0
  39. package/mcp-memory-server/dist/context-pack.js +171 -3
  40. package/mcp-memory-server/dist/domain-retrieval-provider.js +257 -0
  41. package/mcp-memory-server/dist/index.js +73 -387
  42. package/mcp-memory-server/dist/mcp-tool-catalog.js +2 -0
  43. package/mcp-memory-server/dist/memory-extraction.js +87 -0
  44. package/mcp-memory-server/dist/memory-proposal-cli.js +110 -0
  45. package/mcp-memory-server/dist/memory-proposal-schema.js +78 -0
  46. package/mcp-memory-server/dist/memory-proposal-store.js +273 -0
  47. package/mcp-memory-server/dist/platform-security.js +32 -26
  48. package/mcp-memory-server/dist/retrieval-benchmark.js +199 -0
  49. package/mcp-memory-server/dist/reviewed-memory-store.js +281 -0
  50. package/mcp-memory-server/dist/work-evidence-schema.js +17 -0
  51. package/mcp-memory-server/dist/work-evidence-store.js +57 -1
  52. package/mcp-memory-server/package.json +8 -1
  53. package/package.json +1 -1
  54. package/schemas/context-usage-receipt.schema.json +41 -0
  55. package/schemas/memory-proposal.schema.json +65 -0
  56. package/schemas/retrieval-benchmark.schema.json +64 -0
  57. package/scripts/cairn-cli.mjs +3 -0
  58. package/scripts/completion.sh +17 -6
  59. package/scripts/doctor.sh +4 -2
  60. package/scripts/setup.mjs +92 -13
  61. package/scripts/terminal-prompts.mjs +172 -0
  62. package/scripts/test-completion.sh +8 -1
  63. package/scripts/test-docs-navigation.sh +63 -0
  64. package/scripts/test-doctor.sh +21 -0
  65. package/scripts/test-phase17-artifact-lifecycle.sh +1 -1
  66. package/scripts/test-release-sbom.sh +44 -16
  67. package/scripts/test-setup-preflight.mjs +130 -0
  68. package/scripts/test-windows-native.mjs +30 -0
  69. package/scripts/verify-docs-parity.sh +28 -28
  70. package/scripts/windows-platform.mjs +8 -5
@@ -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:
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
package/docs/storage.md CHANGED
@@ -576,3 +576,24 @@ link index is derived data under `cache/graphs/<pack-digest>.json`; corruption
576
576
  or deletion does not affect identity and causes a rebuild from the object. OKF
577
577
  exports are not stored automatically: `export-okf --apply` creates a new
578
578
  operator-selected directory only after its preview digest is confirmed.
579
+
580
+ ## Context-intelligence derived and review state
581
+
582
+ Progressive context-pack abstracts and overviews live below
583
+ `${CAIRN_PACK_BASE_DIR:-~/.cairnkeep/packs}/cache/context/<pack-digest>/` as
584
+ digest-named JSON. This cache is derived, permission-restricted, outside
585
+ immutable pack objects, and safe to discard or deterministically rebuild.
586
+ Validation rejects symlinks, invalid names, oversized records, digest mismatches,
587
+ and temporary remnants. Repair removes invalid derived entries only; it never
588
+ changes a project pointer or skill approval.
589
+
590
+ Review-gated memory proposals live under
591
+ `<project>/.agentfs/memory-proposals/<proposal-digest>.json`. The directory is mode
592
+ `0700` and files are mode `0600` on POSIX. Proposals are immutable and include
593
+ the source trajectory digest and base reviewed-memory hashes used to reject stale
594
+ application. Applying a proposal writes through the existing reviewed-memory
595
+ store; it does not turn the proposal directory into a second memory authority.
596
+
597
+ Context-usage receipts are strict links in the existing work-evidence store.
598
+ They inherit work-evidence inspection, retention, and doctor behavior; there is
599
+ no separate prompt or retrieved-content log.
@@ -52,6 +52,20 @@ bodies are not included. The patch is redacted, bounded by the lower of the
52
52
  work-evidence and artifact limits, and stored in the existing artifact store.
53
53
  Cairnkeep provides no patch apply, restore or replay command.
54
54
 
55
+ ## Context-usage receipts
56
+
57
+ With context packs enabled, `context_pack_search` can return stable references
58
+ when called with `include_refs: true`. With `CAIRN_WORK_EVIDENCE=1` active, set
59
+ `CAIRN_CONTEXT_USAGE=1` as the second gate to expose the
60
+ local-only `context_usage_record` mutation. It links `task_digest`,
61
+ `result_digest`, and an outcome (`used`, `unused`, or `unknown`) to the active
62
+ work-evidence record, or to an explicit `evidence_id`.
63
+
64
+ Receipts intentionally exclude the query, prompt, retrieved text, and model
65
+ response. Their ID is deterministic, repeat recording is idempotent, conflicting
66
+ outcomes fail, and the normal work-evidence doctor and retention rules apply.
67
+ Because recording changes evidence, the read-only MCP profile excludes the tool.
68
+
55
69
  ## Inspect and maintain
56
70
 
57
71
  ```sh
@@ -1 +1 @@
1
- {"identifier":"cairn-offline-fake-v1","package_version":"2.15.4","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
1
+ {"identifier":"cairn-offline-fake-v1","package_version":"2.17.0","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
@@ -21,6 +21,7 @@ function usage() {
21
21
  cairn pack skills [--project PATH | --project-id ID] [--json]
22
22
  cairn pack approve-skill SELECTOR PATH --confirm FILE_DIGEST [--project PATH | --project-id ID]
23
23
  cairn pack revoke-skill SELECTOR PATH [--project PATH | --project-id ID]
24
+ cairn pack doctor [--repair] [--json]
24
25
  `);
25
26
  process.exit(2);
26
27
  }
@@ -205,9 +206,10 @@ async function main() {
205
206
  return;
206
207
  }
207
208
  if (command === "doctor") {
209
+ const repair = flag(args, "--repair");
208
210
  if (args.length)
209
211
  usage();
210
- const result = await doctorContextPacks();
212
+ const result = await doctorContextPacks({ repair });
211
213
  output(result, json, result.ok ? `Context packs healthy (${result.objects} objects, ${result.projects} projects).` : `Context pack problems: ${[...result.issues, ...result.temporary_remnants].join("; ")}`);
212
214
  if (!result.ok)
213
215
  process.exitCode = 1;
@@ -0,0 +1,413 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ function isDirectory(node) {
5
+ return "type" in node && node.type === "directory";
6
+ }
7
+ const CACHE_SCHEMA_VERSION = 1;
8
+ const DERIVATION_ALGORITHM = "deterministic-progressive-v1";
9
+ const ABSTRACT_MAX_BYTES = 512;
10
+ const OVERVIEW_MAX_BYTES = 2048;
11
+ const MAX_TRACE_EVENTS = 64;
12
+ const MAX_SELECTED_FILES = 32;
13
+ const MAX_SELECTED_DIRECTORIES = 12;
14
+ function sha256(value) {
15
+ return createHash("sha256").update(value).digest("hex");
16
+ }
17
+ function compact(value) {
18
+ return value.replace(/\s+/g, " ").trim();
19
+ }
20
+ function boundedUtf8(value, maximum) {
21
+ const bytes = Buffer.from(value, "utf8");
22
+ if (bytes.length <= maximum)
23
+ return value;
24
+ const suffix = Buffer.from("…", "utf8");
25
+ if (suffix.length > maximum)
26
+ return "";
27
+ let end = maximum - suffix.length;
28
+ while (end > 0) {
29
+ try {
30
+ return `${new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, end)).trimEnd()}…`;
31
+ }
32
+ catch {
33
+ end -= 1;
34
+ }
35
+ }
36
+ return "";
37
+ }
38
+ function fileAbstract(file) {
39
+ const metadata = [file.title, file.description, file.keywords.join(", ")].map(compact).filter(Boolean);
40
+ return boundedUtf8(metadata.join(" — "), ABSTRACT_MAX_BYTES);
41
+ }
42
+ function fileOverview(file, abstract) {
43
+ const headings = [...file.text.matchAll(/^#{1,6}\s+(.+)$/gm)].map((match) => compact(match[1])).filter(Boolean);
44
+ const paragraphs = file.text
45
+ .split(/\n\s*\n/g)
46
+ .map((paragraph) => compact(paragraph.replace(/^#{1,6}\s+/, "")))
47
+ .filter(Boolean)
48
+ .slice(0, 4);
49
+ const pieces = [abstract, headings.length ? `Sections: ${headings.join("; ")}` : "", ...paragraphs].filter(Boolean);
50
+ return boundedUtf8([...new Set(pieces)].join("\n"), OVERVIEW_MAX_BYTES);
51
+ }
52
+ function visibleSetDigest(files) {
53
+ return sha256([
54
+ DERIVATION_ALGORITHM,
55
+ ...files.map(({ path, file_digest }) => `${path}\0${file_digest}`).sort((left, right) => left.localeCompare(right, "en")),
56
+ ].join("\n"));
57
+ }
58
+ function validCache(value, packDigest, setDigest, files) {
59
+ if (!value || typeof value !== "object")
60
+ return false;
61
+ const row = value;
62
+ if (row.schema_version !== CACHE_SCHEMA_VERSION || row.algorithm !== DERIVATION_ALGORITHM || row.pack_digest !== packDigest || row.visible_set_digest !== setDigest || !Array.isArray(row.files))
63
+ return false;
64
+ const expected = new Map(files.map((file) => [file.path, file.file_digest]));
65
+ return row.files.length === files.length && row.files.every((file) => file && typeof file.path === "string" && expected.get(file.path) === file.file_digest
66
+ && typeof file.abstract === "string" && Buffer.byteLength(file.abstract) <= ABSTRACT_MAX_BYTES
67
+ && typeof file.overview === "string" && Buffer.byteLength(file.overview) <= OVERVIEW_MAX_BYTES);
68
+ }
69
+ async function writeCache(path, record) {
70
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
71
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
72
+ try {
73
+ await writeFile(temporary, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
74
+ try {
75
+ await rename(temporary, path);
76
+ }
77
+ catch (error) {
78
+ // Concurrent deterministic builders may publish the same record.
79
+ try {
80
+ await readFile(path);
81
+ }
82
+ catch {
83
+ throw error;
84
+ }
85
+ }
86
+ }
87
+ finally {
88
+ await rm(temporary, { force: true });
89
+ }
90
+ }
91
+ async function enrichPackFiles(files, cacheBaseDirectory) {
92
+ const packDigest = files[0].pack_digest;
93
+ const setDigest = visibleSetDigest(files);
94
+ const cachePath = join(cacheBaseDirectory, packDigest, `${setDigest}.json`);
95
+ let cached;
96
+ try {
97
+ const parsed = JSON.parse(await readFile(cachePath, "utf8"));
98
+ if (validCache(parsed, packDigest, setDigest, files))
99
+ cached = parsed;
100
+ }
101
+ catch {
102
+ // Missing or corrupt derived data is rebuilt deterministically.
103
+ }
104
+ if (!cached) {
105
+ cached = {
106
+ schema_version: CACHE_SCHEMA_VERSION,
107
+ algorithm: DERIVATION_ALGORITHM,
108
+ pack_digest: packDigest,
109
+ visible_set_digest: setDigest,
110
+ files: files.map((file) => {
111
+ const abstract = fileAbstract(file);
112
+ return { path: file.path, file_digest: file.file_digest, abstract, overview: fileOverview(file, abstract) };
113
+ }),
114
+ };
115
+ await writeCache(cachePath, cached);
116
+ }
117
+ const summaries = new Map(cached.files.map((file) => [file.path, file]));
118
+ return files.map((file) => ({ ...file, ...summaries.get(file.path) }));
119
+ }
120
+ function buildTree(pack, files) {
121
+ const root = { type: "directory", path: "", title: pack.title, abstract: "", overview: "", children: [] };
122
+ for (const file of files) {
123
+ const segments = file.path.split("/");
124
+ let current = root;
125
+ for (let index = 0; index < segments.length - 1; index += 1) {
126
+ const path = segments.slice(0, index + 1).join("/");
127
+ let child = current.children.find((candidate) => isDirectory(candidate) && candidate.path === path);
128
+ if (!child) {
129
+ child = { type: "directory", path, title: segments[index], abstract: "", overview: "", children: [] };
130
+ current.children.push(child);
131
+ }
132
+ current = child;
133
+ }
134
+ current.children.push(file);
135
+ }
136
+ const summarize = (directory) => {
137
+ for (const child of directory.children)
138
+ if (isDirectory(child))
139
+ summarize(child);
140
+ directory.children.sort((left, right) => {
141
+ const leftDirectory = isDirectory(left);
142
+ const rightDirectory = isDirectory(right);
143
+ if (leftDirectory !== rightDirectory)
144
+ return leftDirectory ? -1 : 1;
145
+ const leftPath = leftDirectory ? left.path : left.path;
146
+ const rightPath = rightDirectory ? right.path : right.path;
147
+ return leftPath.localeCompare(rightPath, "en");
148
+ });
149
+ const childAbstracts = directory.children.map((child) => child.abstract);
150
+ directory.abstract = boundedUtf8(`${directory.title}: ${childAbstracts.join(" | ")}`, ABSTRACT_MAX_BYTES);
151
+ directory.overview = boundedUtf8([directory.abstract, ...directory.children.map((child) => child.overview)].join("\n"), OVERVIEW_MAX_BYTES);
152
+ };
153
+ summarize(root);
154
+ root.abstract = boundedUtf8(`${pack.title}: ${pack.description} — ${root.abstract}`, ABSTRACT_MAX_BYTES);
155
+ root.overview = boundedUtf8(`${root.abstract}\n${root.overview}`, OVERVIEW_MAX_BYTES);
156
+ return root;
157
+ }
158
+ export async function loadProgressiveContext(files, cacheBaseDirectory) {
159
+ const grouped = new Map();
160
+ for (const file of files)
161
+ grouped.set(file.pack_digest, [...(grouped.get(file.pack_digest) ?? []), file]);
162
+ const packs = [];
163
+ for (const [packDigest, packFiles] of grouped) {
164
+ packFiles.sort((left, right) => left.path.localeCompare(right.path, "en"));
165
+ const enriched = await enrichPackFiles(packFiles, cacheBaseDirectory);
166
+ const first = packFiles[0];
167
+ const tree = buildTree({ title: first.pack_title, description: first.pack_description }, enriched);
168
+ packs.push({
169
+ pack_id: first.pack_id,
170
+ version: first.version,
171
+ pack_digest: packDigest,
172
+ title: first.pack_title,
173
+ abstract: tree.abstract,
174
+ overview: tree.overview,
175
+ files: enriched,
176
+ tree,
177
+ });
178
+ }
179
+ return packs.sort((left, right) => left.pack_digest.localeCompare(right.pack_digest, "en"));
180
+ }
181
+ function normalizedTokens(value) {
182
+ return [...new Set(value.toLocaleLowerCase("en").match(/[\p{L}\p{N}._/-]+/gu) ?? [])].slice(0, 24);
183
+ }
184
+ function matchScore(query, text) {
185
+ const normalizedText = text.toLocaleLowerCase("en");
186
+ let score = normalizedText.includes(query) ? 20 : 0;
187
+ for (const token of normalizedTokens(query))
188
+ if (token.length > 1 && normalizedText.includes(token))
189
+ score += 1;
190
+ return score;
191
+ }
192
+ export function selectHierarchicalFiles(query, packs) {
193
+ const normalized = query.trim().toLocaleLowerCase("en");
194
+ const allFiles = packs.flatMap((pack) => pack.files);
195
+ const exact = allFiles.filter((file) => file.path.toLocaleLowerCase("en") === normalized || file.title.toLocaleLowerCase("en") === normalized);
196
+ if (exact.length) {
197
+ return {
198
+ files: exact.slice(0, MAX_SELECTED_FILES),
199
+ exactLeafBypass: true,
200
+ events: exact.slice(0, MAX_TRACE_EVENTS).map((file) => ({ phase: "file", pack_digest: file.pack_digest, path: file.path, score: 100, reason: "exact-leaf-bypass" })),
201
+ };
202
+ }
203
+ const events = [];
204
+ const rankedPacks = packs.map((pack) => ({ pack, score: matchScore(normalized, `${pack.title}\n${pack.abstract}\n${pack.overview}`) }))
205
+ .sort((left, right) => right.score - left.score || left.pack.pack_digest.localeCompare(right.pack.pack_digest, "en"));
206
+ for (const { pack, score } of rankedPacks.slice(0, MAX_TRACE_EVENTS)) {
207
+ events.push({ phase: "pack", pack_digest: pack.pack_digest, score, reason: score ? "summary-match" : "summary-miss" });
208
+ }
209
+ const matchingPackDigests = new Set(rankedPacks.filter(({ score }) => score > 0).map(({ pack }) => pack.pack_digest));
210
+ const packCandidates = matchingPackDigests.size ? allFiles.filter((file) => matchingPackDigests.has(file.pack_digest)) : allFiles;
211
+ const directories = [];
212
+ const visit = (packDigest, node) => {
213
+ if (node.path)
214
+ directories.push({ pack_digest: packDigest, node, score: matchScore(normalized, `${node.path}\n${node.abstract}\n${node.overview}`) });
215
+ for (const child of node.children)
216
+ if (isDirectory(child))
217
+ visit(packDigest, child);
218
+ };
219
+ for (const pack of rankedPacks.map(({ pack }) => pack)) {
220
+ if (!matchingPackDigests.size || matchingPackDigests.has(pack.pack_digest))
221
+ visit(pack.pack_digest, pack.tree);
222
+ }
223
+ directories.sort((left, right) => right.score - left.score || left.pack_digest.localeCompare(right.pack_digest, "en") || left.node.path.localeCompare(right.node.path, "en"));
224
+ const selectedDirectories = directories.filter(({ score }) => score > 0).slice(0, MAX_SELECTED_DIRECTORIES);
225
+ for (const { pack_digest, node, score } of selectedDirectories) {
226
+ if (events.length >= MAX_TRACE_EVENTS)
227
+ break;
228
+ events.push({ phase: "directory", pack_digest, path: node.path, score, reason: "selected-summary-match" });
229
+ }
230
+ const directoryCandidates = selectedDirectories.length
231
+ ? packCandidates.filter((file) => selectedDirectories.some(({ pack_digest, node }) => file.pack_digest === pack_digest && (file.path === node.path || file.path.startsWith(`${node.path}/`))))
232
+ : packCandidates;
233
+ const ranked = directoryCandidates.map((file) => ({ file, score: matchScore(normalized, `${file.path}\n${file.abstract}\n${file.overview}`) }))
234
+ .sort((left, right) => right.score - left.score || left.file.pack_digest.localeCompare(right.file.pack_digest, "en") || left.file.path.localeCompare(right.file.path, "en"));
235
+ let selected = ranked.filter(({ score }) => score > 0).slice(0, MAX_SELECTED_FILES);
236
+ if (!selected.length) {
237
+ selected = ranked.slice(0, MAX_SELECTED_FILES);
238
+ events.push({ phase: "fallback", reason: "no-summary-match" });
239
+ }
240
+ for (const { file, score } of selected) {
241
+ if (events.length >= MAX_TRACE_EVENTS)
242
+ break;
243
+ events.push({ phase: "file", pack_digest: file.pack_digest, path: file.path, score, reason: score ? "selected-summary-match" : "selected-fallback" });
244
+ }
245
+ return { files: selected.map(({ file }) => file), exactLeafBypass: false, events: events.slice(0, MAX_TRACE_EVENTS) };
246
+ }
247
+ export function publicTree(pack, detail) {
248
+ const project = (node) => {
249
+ if (isDirectory(node)) {
250
+ return { type: "directory", path: node.path, title: node.title, [detail]: node[detail], children: node.children.map(project) };
251
+ }
252
+ return {
253
+ type: "file",
254
+ pack_id: node.pack_id,
255
+ version: node.version,
256
+ pack_digest: node.pack_digest,
257
+ path: node.path,
258
+ kind: node.kind,
259
+ file_digest: node.file_digest,
260
+ title: node.title,
261
+ [detail]: node[detail],
262
+ };
263
+ };
264
+ return {
265
+ pack_id: pack.pack_id,
266
+ version: pack.version,
267
+ pack_digest: pack.pack_digest,
268
+ title: pack.title,
269
+ [detail]: pack[detail],
270
+ tree: project(pack.tree),
271
+ };
272
+ }
273
+ const CACHE_DIGEST = /^[a-f0-9]{64}$/;
274
+ const CACHE_RECORD_MAX_BYTES = 4 * 1024 * 1024;
275
+ const CACHE_MAX_FILES = 1024;
276
+ function exactKeys(value, expected) {
277
+ const actual = Object.keys(value).sort((left, right) => left.localeCompare(right, "en"));
278
+ const wanted = [...expected].sort((left, right) => left.localeCompare(right, "en"));
279
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
280
+ }
281
+ function safeCachedPath(value) {
282
+ return value.length > 0
283
+ && !value.includes("\\")
284
+ && !value.startsWith("/")
285
+ && !value.split("/").some((part) => part === "" || part === "." || part === "..");
286
+ }
287
+ function validateCacheRecord(value, directoryDigest, fileDigest, installedFiles) {
288
+ if (!value || typeof value !== "object" || Array.isArray(value))
289
+ return "record is not an object";
290
+ const record = value;
291
+ if (!exactKeys(record, ["schema_version", "algorithm", "pack_digest", "visible_set_digest", "files"]))
292
+ return "record has unknown or missing fields";
293
+ if (record.schema_version !== 1 || record.algorithm !== DERIVATION_ALGORITHM || record.pack_digest !== directoryDigest || record.visible_set_digest !== fileDigest)
294
+ return "record identity or algorithm does not match its path";
295
+ if (!Array.isArray(record.files) || record.files.length > CACHE_MAX_FILES)
296
+ return "record files are invalid or oversized";
297
+ const seen = new Set();
298
+ const visibleFiles = [];
299
+ for (const value of record.files) {
300
+ if (!value || typeof value !== "object" || Array.isArray(value))
301
+ return "record contains a non-object file";
302
+ const file = value;
303
+ if (!exactKeys(file, ["path", "file_digest", "abstract", "overview"]))
304
+ return "record file has unknown or missing fields";
305
+ if (typeof file.path !== "string" || !safeCachedPath(file.path) || seen.has(file.path))
306
+ return "record contains an unsafe or duplicate path";
307
+ if (typeof file.file_digest !== "string" || !CACHE_DIGEST.test(file.file_digest))
308
+ return "record contains an invalid file digest";
309
+ const source = installedFiles.get(file.path);
310
+ if (!source || source.file_digest !== file.file_digest)
311
+ return "record is not bound to installed pack content";
312
+ if (typeof file.abstract !== "string" || Buffer.byteLength(file.abstract, "utf8") > ABSTRACT_MAX_BYTES)
313
+ return "record abstract is invalid or oversized";
314
+ if (typeof file.overview !== "string" || Buffer.byteLength(file.overview, "utf8") > OVERVIEW_MAX_BYTES)
315
+ return "record overview is invalid or oversized";
316
+ const expectedAbstract = fileAbstract(source);
317
+ if (file.abstract !== expectedAbstract || file.overview !== fileOverview(source, expectedAbstract))
318
+ return "record summaries do not match immutable pack content";
319
+ seen.add(file.path);
320
+ visibleFiles.push({ pack_digest: directoryDigest, path: file.path, file_digest: file.file_digest });
321
+ }
322
+ if (visibleSetDigest(visibleFiles) !== fileDigest)
323
+ return "record visibility digest does not match its file set";
324
+ return null;
325
+ }
326
+ /**
327
+ * Validate the mutable, derived progressive-context cache. Cached records are
328
+ * addressed by both immutable pack digest and the exact visible file set, so a
329
+ * revoked skill can never be loaded through a document-only cache key.
330
+ *
331
+ * Repair is intentionally limited to deleting invalid derived entries. The
332
+ * next hierarchical read deterministically rebuilds them from immutable pack
333
+ * objects and the current project pointer.
334
+ */
335
+ export async function doctorProgressiveContextCache(cacheBaseDirectory, packs, options = {}) {
336
+ const issues = [];
337
+ const temporaryRemnants = [];
338
+ const repaired = [];
339
+ const installed = new Map(packs.map((pack) => [
340
+ pack.pack_digest,
341
+ new Map(pack.files.map((file) => [file.path, file])),
342
+ ]));
343
+ const removeDerived = async (path, label) => {
344
+ if (!options.repair)
345
+ return;
346
+ await rm(path, { recursive: true, force: true });
347
+ repaired.push(label);
348
+ };
349
+ let rootInfo;
350
+ try {
351
+ rootInfo = await lstat(cacheBaseDirectory);
352
+ }
353
+ catch (error) {
354
+ if (error.code === "ENOENT") {
355
+ return { ok: true, issues, temporary_remnants: temporaryRemnants, repaired };
356
+ }
357
+ issues.push(`Unable to inspect progressive context cache: ${error instanceof Error ? error.message : String(error)}`);
358
+ return { ok: false, issues, temporary_remnants: temporaryRemnants, repaired };
359
+ }
360
+ if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
361
+ issues.push("Progressive context cache root is not a regular directory.");
362
+ await removeDerived(cacheBaseDirectory, "cache/context");
363
+ if (options.repair)
364
+ issues.length = 0;
365
+ return { ok: issues.length === 0, issues, temporary_remnants: temporaryRemnants, repaired };
366
+ }
367
+ for (const packEntry of (await readdir(cacheBaseDirectory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
368
+ const packPath = join(cacheBaseDirectory, packEntry.name);
369
+ const packInfo = await lstat(packPath);
370
+ if (packEntry.name.endsWith(".tmp")) {
371
+ temporaryRemnants.push(`cache/context/${packEntry.name}`);
372
+ await removeDerived(packPath, `cache/context/${packEntry.name}`);
373
+ continue;
374
+ }
375
+ const installedFiles = installed.get(packEntry.name);
376
+ if (!CACHE_DIGEST.test(packEntry.name) || !packInfo.isDirectory() || packInfo.isSymbolicLink() || !installedFiles) {
377
+ issues.push(`Invalid progressive context cache pack entry: ${packEntry.name}`);
378
+ await removeDerived(packPath, `cache/context/${packEntry.name}`);
379
+ continue;
380
+ }
381
+ for (const cacheEntry of (await readdir(packPath, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
382
+ const cachePath = join(packPath, cacheEntry.name);
383
+ const relative = `cache/context/${packEntry.name}/${cacheEntry.name}`;
384
+ const cacheInfo = await lstat(cachePath);
385
+ if (cacheEntry.name.endsWith(".tmp")) {
386
+ temporaryRemnants.push(relative);
387
+ await removeDerived(cachePath, relative);
388
+ continue;
389
+ }
390
+ const match = /^([a-f0-9]{64})\.json$/.exec(cacheEntry.name);
391
+ if (!match || !cacheInfo.isFile() || cacheInfo.isSymbolicLink() || cacheInfo.size > CACHE_RECORD_MAX_BYTES) {
392
+ issues.push(`Invalid progressive context cache entry: ${relative}`);
393
+ await removeDerived(cachePath, relative);
394
+ continue;
395
+ }
396
+ try {
397
+ const value = JSON.parse(await readFile(cachePath, "utf8"));
398
+ const invalid = validateCacheRecord(value, packEntry.name, match[1], installedFiles);
399
+ if (invalid)
400
+ throw new Error(invalid);
401
+ }
402
+ catch (error) {
403
+ issues.push(`Invalid progressive context cache record: ${relative}: ${error instanceof Error ? error.message : String(error)}`);
404
+ await removeDerived(cachePath, relative);
405
+ }
406
+ }
407
+ }
408
+ if (options.repair) {
409
+ issues.length = 0;
410
+ temporaryRemnants.length = 0;
411
+ }
412
+ return { ok: issues.length === 0 && temporaryRemnants.length === 0, issues, temporary_remnants: temporaryRemnants, repaired };
413
+ }