@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
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ import { applyMemoryProposal, createMemoryProposal, doctorMemoryProposals, listMemoryProposals, showMemoryProposal, } from "./memory-proposal-store.js";
3
+ const categories = new Set(["decision", "preference", "pattern", "pitfall", "constraint", "bug", "convention"]);
4
+ function usage() {
5
+ return `cairn proposals — review-gated session memory proposals
6
+
7
+ Usage:
8
+ cairn proposals create --session ID --scope SCOPE [--model MODEL] [--category CATEGORY] [--project PATH] [--json]
9
+ cairn proposals list [--project PATH] [--json]
10
+ cairn proposals show DIGEST [--project PATH] [--json]
11
+ cairn proposals apply DIGEST [--project PATH] [--json]
12
+ cairn proposals doctor [--project PATH] [--json]
13
+
14
+ Creation is explicit and reads an already stored local trajectory. Applying requires
15
+ the proposal's complete 64-character digest and fails atomically if its source or
16
+ any target memory changed after proposal creation.
17
+ `;
18
+ }
19
+ function valueAfter(args, flag) {
20
+ const index = args.indexOf(flag);
21
+ if (index < 0)
22
+ return undefined;
23
+ const value = args[index + 1];
24
+ if (!value || value.startsWith("--"))
25
+ throw new Error(`${flag} requires a value.`);
26
+ return value;
27
+ }
28
+ function positionals(args) {
29
+ const valued = new Set(["--project", "--session", "--scope", "--model", "--category"]);
30
+ const output = [];
31
+ for (let index = 0; index < args.length; index += 1) {
32
+ const arg = args[index];
33
+ if (valued.has(arg)) {
34
+ index += 1;
35
+ continue;
36
+ }
37
+ if (arg === "--json")
38
+ continue;
39
+ if (arg.startsWith("--"))
40
+ throw new Error(`unknown option "${arg}".`);
41
+ output.push(arg);
42
+ }
43
+ return output;
44
+ }
45
+ function print(value, json) {
46
+ if (json)
47
+ process.stdout.write(`${JSON.stringify(value)}\n`);
48
+ else
49
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
50
+ }
51
+ async function main() {
52
+ const [command = "help", ...args] = process.argv.slice(2);
53
+ const projectRoot = valueAfter(args, "--project") ?? process.cwd();
54
+ const json = args.includes("--json");
55
+ try {
56
+ if (["help", "--help", "-h"].includes(command)) {
57
+ process.stdout.write(usage());
58
+ return;
59
+ }
60
+ if (command === "create") {
61
+ const sessionId = valueAfter(args, "--session");
62
+ const scope = valueAfter(args, "--scope");
63
+ if (!sessionId || !scope)
64
+ throw new Error("create requires --session ID and --scope SCOPE.");
65
+ const rawCategory = valueAfter(args, "--category");
66
+ if (rawCategory && !categories.has(rawCategory))
67
+ throw new Error(`unsupported category "${rawCategory}".`);
68
+ if (positionals(args).length !== 0)
69
+ throw new Error("create accepts no positional arguments.");
70
+ print(await createMemoryProposal({ projectRoot, sessionId, scope, model: valueAfter(args, "--model"), category: rawCategory }), json);
71
+ return;
72
+ }
73
+ if (command === "list") {
74
+ if (positionals(args).length !== 0)
75
+ throw new Error("list accepts no positional arguments.");
76
+ const proposals = listMemoryProposals(projectRoot);
77
+ print({ count: proposals.length, proposals: proposals.map(({ digest, created_at, scope, source, extraction, candidates }) => ({ digest, created_at, scope, source, extraction, candidate_count: candidates.length })) }, json);
78
+ return;
79
+ }
80
+ if (command === "show") {
81
+ const values = positionals(args);
82
+ if (values.length !== 1)
83
+ throw new Error("show requires one full proposal digest.");
84
+ print(showMemoryProposal(values[0], projectRoot), json);
85
+ return;
86
+ }
87
+ if (command === "apply") {
88
+ const values = positionals(args);
89
+ if (values.length !== 1)
90
+ throw new Error("apply requires one full proposal digest.");
91
+ print(await applyMemoryProposal(values[0], projectRoot), json);
92
+ return;
93
+ }
94
+ if (command === "doctor") {
95
+ if (positionals(args).length !== 0)
96
+ throw new Error("doctor accepts no positional arguments.");
97
+ const result = doctorMemoryProposals(projectRoot);
98
+ print(result, json);
99
+ if (!result.ok)
100
+ process.exitCode = 2;
101
+ return;
102
+ }
103
+ throw new Error(`unknown proposals command "${command}".`);
104
+ }
105
+ catch (error) {
106
+ process.stderr.write(`cairn proposals: ${error instanceof Error ? error.message : "unknown error"}\n`);
107
+ process.exitCode = 2;
108
+ }
109
+ }
110
+ await main();
@@ -0,0 +1,78 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ export const MEMORY_PROPOSAL_SCHEMA_VERSION = 1;
4
+ export const MEMORY_PROPOSAL_MAX_BYTES = 1024 * 1024;
5
+ export const MEMORY_PROPOSAL_MAX_CANDIDATES = 128;
6
+ const digestSchema = z.string().regex(/^[a-f0-9]{64}$/);
7
+ const candidateSchema = z.object({
8
+ key: z.string().min(1).max(512),
9
+ value: z.string().max(64 * 1024),
10
+ category: z.string().max(64).optional(),
11
+ importance: z.number().min(0).max(1).optional(),
12
+ operation: z.enum(["create", "update", "noop"]),
13
+ base_hash: digestSchema.nullable(),
14
+ value_hash: digestSchema,
15
+ }).strict();
16
+ export const memoryProposalBodySchema = z.object({
17
+ schema_version: z.literal(MEMORY_PROPOSAL_SCHEMA_VERSION),
18
+ created_at: z.iso.datetime(),
19
+ project_root: z.string().min(1),
20
+ scope: z.string().regex(/^(?:project|[a-z0-9][a-z0-9-]*)$/),
21
+ source: z.object({
22
+ kind: z.literal("trajectory"),
23
+ session_id: z.string().min(1).max(256),
24
+ digest: digestSchema,
25
+ }).strict(),
26
+ extraction: z.object({ model: z.string().min(1).max(256) }).strict(),
27
+ candidates: z.array(candidateSchema).min(1).max(MEMORY_PROPOSAL_MAX_CANDIDATES),
28
+ }).strict();
29
+ export const memoryProposalSchema = memoryProposalBodySchema.extend({ digest: digestSchema }).strict();
30
+ export function expectedMemoryProposalOperation(baseHash, valueHash) {
31
+ if (baseHash === null)
32
+ return "create";
33
+ return baseHash === valueHash ? "noop" : "update";
34
+ }
35
+ export function assertMemoryProposalCandidateConsistency(candidate) {
36
+ const actualValueHash = digestText(candidate.value);
37
+ if (candidate.value_hash !== actualValueHash) {
38
+ throw new Error(`Memory proposal candidate "${candidate.key}" value hash mismatch.`);
39
+ }
40
+ const expectedOperation = expectedMemoryProposalOperation(candidate.base_hash, actualValueHash);
41
+ if (candidate.operation !== expectedOperation) {
42
+ throw new Error(`Memory proposal candidate "${candidate.key}" operation is inconsistent: expected ${expectedOperation}.`);
43
+ }
44
+ }
45
+ function assertCandidateConsistency(candidates) {
46
+ for (const candidate of candidates)
47
+ assertMemoryProposalCandidateConsistency(candidate);
48
+ }
49
+ export function canonicalJson(value) {
50
+ if (value === null || typeof value !== "object")
51
+ return JSON.stringify(value);
52
+ if (Array.isArray(value))
53
+ return `[${value.map(canonicalJson).join(",")}]`;
54
+ return `{${Object.entries(value)
55
+ .filter(([, item]) => item !== undefined)
56
+ .sort(([left], [right]) => left.localeCompare(right))
57
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
58
+ .join(",")}}`;
59
+ }
60
+ export function digestValue(value) {
61
+ return createHash("sha256").update(canonicalJson(value)).digest("hex");
62
+ }
63
+ export function digestText(value) {
64
+ return createHash("sha256").update(value).digest("hex");
65
+ }
66
+ export function sealProposal(body) {
67
+ const parsed = memoryProposalBodySchema.parse(body);
68
+ assertCandidateConsistency(parsed.candidates);
69
+ return memoryProposalSchema.parse({ ...parsed, digest: digestValue(parsed) });
70
+ }
71
+ export function verifyProposal(value) {
72
+ const parsed = memoryProposalSchema.parse(value);
73
+ const { digest, ...body } = parsed;
74
+ if (digestValue(body) !== digest)
75
+ throw new Error("Memory proposal digest mismatch.");
76
+ assertCandidateConsistency(parsed.candidates);
77
+ return parsed;
78
+ }
@@ -0,0 +1,273 @@
1
+ import { constants, closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { extractMemoryCandidates } from "./memory-extraction.js";
5
+ import { MEMORY_PROPOSAL_MAX_BYTES, MEMORY_PROPOSAL_MAX_CANDIDATES, digestText, digestValue, expectedMemoryProposalOperation, memoryProposalBodySchema, sealProposal, verifyProposal, } from "./memory-proposal-schema.js";
6
+ import { applyProposalCandidates, readMemoryBaseHashes, resolveMemoryScopePath } from "./reviewed-memory-store.js";
7
+ import { redactLocalValue } from "./trajectory-redaction.js";
8
+ import { showTrajectory } from "./trajectory-store.js";
9
+ import { isTypedMemoryNodesEnabled } from "./node-schema.js";
10
+ import { atomicReplace, hardenPrivatePath, privatePathIsSafe } from "./platform-security.js";
11
+ const PROPOSAL_FILE_PATTERN = /^[a-f0-9]{64}\.json$/;
12
+ const PROPOSAL_DIGEST_PATTERN = /^[a-f0-9]{64}$/;
13
+ const MAX_PROPOSAL_FILES = 1024;
14
+ const MAX_PROPOSAL_STORE_BYTES = 64 * 1024 * 1024;
15
+ const MAX_EXTRACTION_INPUT_BYTES = 1024 * 1024;
16
+ function canonicalProjectRoot(projectRoot) {
17
+ const resolved = resolve(projectRoot);
18
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory())
19
+ throw new Error(`Project directory does not exist: ${resolved}`);
20
+ const root = realpathSync(resolved);
21
+ const info = lstatSync(root);
22
+ if (info.isSymbolicLink() || !info.isDirectory())
23
+ throw new Error(`Project directory is unsafe: ${root}`);
24
+ return root;
25
+ }
26
+ function lstatIfPresent(path) {
27
+ try {
28
+ return lstatSync(path);
29
+ }
30
+ catch (error) {
31
+ if (error.code === "ENOENT")
32
+ return null;
33
+ throw error;
34
+ }
35
+ }
36
+ function assertDirectory(path, label, requirePrivate) {
37
+ const info = lstatIfPresent(path);
38
+ if (!info)
39
+ return;
40
+ if (info.isSymbolicLink() || !info.isDirectory())
41
+ throw new Error(`${label} is not a regular directory.`);
42
+ if (requirePrivate && !privatePathIsSafe(path))
43
+ throw new Error(`${label} is not private.`);
44
+ }
45
+ export function memoryProposalDirectory(projectRoot = process.cwd()) {
46
+ const root = canonicalProjectRoot(projectRoot);
47
+ const runtimeDirectory = join(root, ".agentfs");
48
+ assertDirectory(runtimeDirectory, "Project .agentfs path", false);
49
+ const directory = join(runtimeDirectory, "memory-proposals");
50
+ assertDirectory(directory, "Memory proposal store", true);
51
+ return directory;
52
+ }
53
+ function ensureDirectory(projectRoot) {
54
+ const root = canonicalProjectRoot(projectRoot);
55
+ const runtimeDirectory = join(root, ".agentfs");
56
+ const runtimeInfo = lstatIfPresent(runtimeDirectory);
57
+ if (!runtimeInfo) {
58
+ mkdirSync(runtimeDirectory, { mode: 0o700 });
59
+ hardenPrivatePath(runtimeDirectory);
60
+ }
61
+ else if (runtimeInfo.isSymbolicLink() || !runtimeInfo.isDirectory()) {
62
+ throw new Error("Project .agentfs path is not a regular directory.");
63
+ }
64
+ assertDirectory(runtimeDirectory, "Project .agentfs path", false);
65
+ const directory = join(runtimeDirectory, "memory-proposals");
66
+ const storeInfo = lstatIfPresent(directory);
67
+ if (!storeInfo) {
68
+ mkdirSync(directory, { mode: 0o700 });
69
+ hardenPrivatePath(directory);
70
+ }
71
+ else if (storeInfo.isSymbolicLink() || !storeInfo.isDirectory()) {
72
+ throw new Error("Memory proposal store is not a regular directory.");
73
+ }
74
+ assertDirectory(directory, "Memory proposal store", true);
75
+ return directory;
76
+ }
77
+ function strictFiles(directory) {
78
+ const directoryInfo = lstatIfPresent(directory);
79
+ if (!directoryInfo)
80
+ return [];
81
+ if (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory())
82
+ throw new Error("Memory proposal store is not a regular directory.");
83
+ const names = readdirSync(directory).sort();
84
+ if (names.length > MAX_PROPOSAL_FILES)
85
+ throw new Error("Memory proposal store exceeds its file-count limit.");
86
+ let total = 0;
87
+ for (const name of names) {
88
+ if (!PROPOSAL_FILE_PATTERN.test(name))
89
+ throw new Error(`Unexpected file in memory proposal store: ${name}`);
90
+ const path = join(directory, name);
91
+ const stat = lstatSync(path);
92
+ if (stat.isSymbolicLink() || !stat.isFile())
93
+ throw new Error(`Memory proposal is not a regular file: ${name}`);
94
+ if (stat.size > MEMORY_PROPOSAL_MAX_BYTES)
95
+ throw new Error(`Memory proposal exceeds its size limit: ${name}`);
96
+ total += stat.size;
97
+ if (total > MAX_PROPOSAL_STORE_BYTES)
98
+ throw new Error("Memory proposal store exceeds its byte limit.");
99
+ }
100
+ return names;
101
+ }
102
+ function parseFile(path) {
103
+ const info = lstatIfPresent(path);
104
+ if (!info)
105
+ throw new Error("Memory proposal not found.");
106
+ if (info.isSymbolicLink() || !info.isFile())
107
+ throw new Error("Memory proposal is not a regular file.");
108
+ if (info.size > MEMORY_PROPOSAL_MAX_BYTES)
109
+ throw new Error("Memory proposal exceeds its size limit.");
110
+ if (!privatePathIsSafe(path))
111
+ throw new Error("Memory proposal is not private.");
112
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
113
+ let bytes;
114
+ try {
115
+ const opened = fstatSync(fd);
116
+ if (!opened.isFile() || opened.size > MEMORY_PROPOSAL_MAX_BYTES
117
+ || opened.dev !== info.dev || opened.ino !== info.ino) {
118
+ throw new Error("Memory proposal changed during validation.");
119
+ }
120
+ bytes = readFileSync(fd);
121
+ }
122
+ finally {
123
+ closeSync(fd);
124
+ }
125
+ let value;
126
+ try {
127
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
128
+ }
129
+ catch {
130
+ throw new Error("Memory proposal is not valid UTF-8 JSON.");
131
+ }
132
+ const proposal = verifyProposal(value);
133
+ if (`${proposal.digest}.json` !== basename(path))
134
+ throw new Error("Memory proposal filename does not match its digest.");
135
+ return proposal;
136
+ }
137
+ async function writeImmutable(projectRoot, proposal) {
138
+ const bytes = Buffer.from(`${JSON.stringify(proposal, null, 2)}\n`, "utf8");
139
+ if (bytes.byteLength > MEMORY_PROPOSAL_MAX_BYTES)
140
+ throw new Error("Memory proposal exceeds its size limit.");
141
+ const directory = ensureDirectory(projectRoot);
142
+ const finalPath = join(directory, `${proposal.digest}.json`);
143
+ if (lstatIfPresent(finalPath)) {
144
+ const existing = parseFile(finalPath);
145
+ if (existing.digest !== proposal.digest)
146
+ throw new Error("Conflicting immutable memory proposal.");
147
+ return;
148
+ }
149
+ const existing = strictFiles(directory);
150
+ const existingBytes = existing.reduce((total, name) => total + statSync(join(directory, name)).size, 0);
151
+ if (existing.length >= MAX_PROPOSAL_FILES || existingBytes + bytes.byteLength > MAX_PROPOSAL_STORE_BYTES) {
152
+ throw new Error("Memory proposal store limit reached.");
153
+ }
154
+ const temporary = join(dirname(finalPath), `.${proposal.digest}.${randomUUID()}.tmp`);
155
+ let fd;
156
+ try {
157
+ fd = openSync(temporary, "wx", 0o600);
158
+ writeFileSync(fd, bytes);
159
+ fsyncSync(fd);
160
+ closeSync(fd);
161
+ fd = undefined;
162
+ hardenPrivatePath(temporary);
163
+ await atomicReplace(temporary, finalPath);
164
+ hardenPrivatePath(finalPath);
165
+ parseFile(finalPath);
166
+ }
167
+ catch (error) {
168
+ if (fd !== undefined)
169
+ closeSync(fd);
170
+ rmSync(temporary, { force: true });
171
+ throw error;
172
+ }
173
+ }
174
+ export async function createMemoryProposal(options) {
175
+ if (options.scope === "all")
176
+ throw new Error('Proposal scope must be concrete; "all" is not allowed.');
177
+ const projectRoot = canonicalProjectRoot(options.projectRoot ?? process.cwd());
178
+ resolveMemoryScopePath(options.scope, { cwd: projectRoot, ...options.reviewedOptions });
179
+ const trajectory = await showTrajectory(options.sessionId, projectRoot);
180
+ const sourceDigest = digestValue(trajectory);
181
+ const redacted = redactLocalValue(trajectory, projectRoot).value;
182
+ const outbound = JSON.stringify(redacted);
183
+ if (Buffer.byteLength(outbound, "utf8") > MAX_EXTRACTION_INPUT_BYTES)
184
+ throw new Error("Redacted trajectory exceeds the extraction input limit.");
185
+ // Persistence intentionally happens only after the outbound request succeeds.
186
+ const extraction = await extractMemoryCandidates(outbound, options.model, options.category);
187
+ if (extraction.candidates.length > MEMORY_PROPOSAL_MAX_CANDIDATES) {
188
+ throw new Error(`Extraction returned more than ${MEMORY_PROPOSAL_MAX_CANDIDATES} candidates.`);
189
+ }
190
+ const deduped = new Map();
191
+ for (const candidate of extraction.candidates) {
192
+ if (candidate.key.length > 512 || Buffer.byteLength(candidate.value, "utf8") > 64 * 1024) {
193
+ throw new Error(`Extraction candidate "${candidate.key.slice(0, 80)}" exceeds proposal limits.`);
194
+ }
195
+ if (deduped.has(candidate.key))
196
+ throw new Error(`Extraction returned duplicate memory key "${candidate.key}".`);
197
+ deduped.set(candidate.key, candidate);
198
+ }
199
+ if (deduped.size === 0)
200
+ throw new Error("Extraction produced no durable memory candidates; no proposal was stored.");
201
+ const bases = await readMemoryBaseHashes(options.scope, [...deduped.keys()], { cwd: projectRoot, ...options.reviewedOptions });
202
+ const candidates = [...deduped.values()].map((candidate) => {
203
+ const base = bases.get(candidate.key);
204
+ const valueHash = digestText(candidate.value);
205
+ return {
206
+ ...candidate,
207
+ operation: expectedMemoryProposalOperation(base.hash, valueHash),
208
+ base_hash: base.hash,
209
+ value_hash: valueHash,
210
+ };
211
+ });
212
+ const body = memoryProposalBodySchema.parse({
213
+ schema_version: 1,
214
+ created_at: new Date().toISOString(),
215
+ project_root: projectRoot,
216
+ scope: options.scope,
217
+ source: { kind: "trajectory", session_id: trajectory.session_id, digest: sourceDigest },
218
+ extraction: { model: extraction.model },
219
+ candidates,
220
+ });
221
+ const proposal = sealProposal(body);
222
+ await writeImmutable(projectRoot, proposal);
223
+ return proposal;
224
+ }
225
+ export function listMemoryProposals(projectRoot = process.cwd()) {
226
+ const directory = memoryProposalDirectory(projectRoot);
227
+ return strictFiles(directory).map((name) => parseFile(join(directory, name)));
228
+ }
229
+ export function showMemoryProposal(digest, projectRoot = process.cwd()) {
230
+ if (!PROPOSAL_DIGEST_PATTERN.test(digest))
231
+ throw new Error("Proposal apply/show requires the full exact SHA-256 digest.");
232
+ const directory = memoryProposalDirectory(projectRoot);
233
+ const path = join(directory, `${digest}.json`);
234
+ if (!lstatIfPresent(path))
235
+ throw new Error(`Memory proposal ${digest} not found.`);
236
+ return parseFile(path);
237
+ }
238
+ export async function applyMemoryProposal(digest, projectRoot = process.cwd()) {
239
+ const root = canonicalProjectRoot(projectRoot);
240
+ const proposal = showMemoryProposal(digest, root);
241
+ if (proposal.project_root !== root)
242
+ throw new Error("Memory proposal belongs to a different project.");
243
+ const trajectory = await showTrajectory(proposal.source.session_id, root);
244
+ if (digestValue(trajectory) !== proposal.source.digest)
245
+ throw new Error("Memory proposal source trajectory is stale.");
246
+ return applyProposalCandidates(proposal.scope, proposal.digest, proposal.candidates, {
247
+ cwd: root,
248
+ typed: isTypedMemoryNodesEnabled(),
249
+ });
250
+ }
251
+ export function doctorMemoryProposals(projectRoot = process.cwd()) {
252
+ let directory = join(resolve(projectRoot), ".agentfs", "memory-proposals");
253
+ const issues = [];
254
+ let count = 0;
255
+ try {
256
+ directory = memoryProposalDirectory(projectRoot);
257
+ const names = strictFiles(directory);
258
+ count = names.length;
259
+ for (const name of names) {
260
+ const path = join(directory, name);
261
+ try {
262
+ parseFile(path);
263
+ }
264
+ catch (error) {
265
+ issues.push(`${name}: ${error instanceof Error ? error.message : "invalid proposal"}`);
266
+ }
267
+ }
268
+ }
269
+ catch (error) {
270
+ issues.push(error instanceof Error ? error.message : "invalid proposal store");
271
+ }
272
+ return { ok: issues.length === 0, directory, count, issues };
273
+ }
@@ -2,27 +2,30 @@ import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
2
2
  import { rename } from "node:fs/promises";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
- function currentWindowsSid() {
5
+ let cachedWindowsIdentity;
6
+ function currentWindowsIdentity() {
7
+ if (cachedWindowsIdentity)
8
+ return cachedWindowsIdentity;
6
9
  const result = spawnSync("whoami.exe", ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", windowsHide: true });
7
10
  if (result.status !== 0)
8
11
  throw new Error("Unable to resolve the current Windows security identity.");
9
- const match = result.stdout.match(/"(S-1-[0-9-]+)"/i);
12
+ const match = result.stdout.match(/^"([^"]+)","(S-1-[0-9-]+)"/im);
10
13
  if (!match)
11
14
  throw new Error("Unable to resolve the current Windows security identity.");
12
- return match[1];
15
+ cachedWindowsIdentity = { account: match[1], sid: match[2] };
16
+ return cachedWindowsIdentity;
13
17
  }
14
18
  export function hardenPrivatePath(path) {
15
19
  if (process.platform !== "win32") {
16
20
  chmodSync(path, lstatSync(path).isDirectory() ? 0o700 : 0o600);
17
21
  return;
18
22
  }
19
- const sid = currentWindowsSid();
23
+ const identity = currentWindowsIdentity();
20
24
  const args = [
21
25
  path,
22
26
  "/inheritance:r",
23
- "/grant:r", `*${sid}:(F)`,
24
- "/grant:r", "*S-1-5-18:(F)",
25
- "/grant:r", "*S-1-5-32-544:(F)",
27
+ "/remove:g", "*S-1-5-18", "*S-1-5-32-544",
28
+ "/grant:r", `*${identity.sid}:(F)`,
26
29
  ];
27
30
  const result = spawnSync("icacls.exe", args, { encoding: "utf8", windowsHide: true });
28
31
  if (result.status !== 0)
@@ -36,25 +39,28 @@ export function privatePathIsSafe(path) {
36
39
  return false;
37
40
  if (process.platform !== "win32")
38
41
  return (info.mode & 0o077) === 0;
39
- const script = [
40
- "$p=$env:CK_INTERNAL_PRIVATE_PATH",
41
- "$me=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
42
- "$ok=@($me,'S-1-5-18','S-1-5-32-544')",
43
- "$acl=Get-Acl -LiteralPath $p",
44
- "foreach($a in $acl.Access){",
45
- "if($a.AccessControlType -eq 'Allow'){",
46
- "try{$sid=$a.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value}catch{exit 1}",
47
- "if($ok -notcontains $sid){exit 1}",
48
- "}",
49
- "}",
50
- "exit 0",
51
- ].join(";");
52
- const result = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
53
- encoding: "utf8",
54
- windowsHide: true,
55
- env: { ...process.env, CK_INTERNAL_PRIVATE_PATH: path },
56
- });
57
- return result.status === 0;
42
+ try {
43
+ const account = currentWindowsIdentity().account.toLowerCase();
44
+ const result = spawnSync("icacls.exe", [path], { encoding: "utf8", windowsHide: true });
45
+ if (result.status !== 0 || !result.stdout)
46
+ return false;
47
+ const grants = result.stdout.split(/\r?\n/).filter((line) => {
48
+ const body = line.trim();
49
+ const marker = body.indexOf(":(");
50
+ if (marker < 0)
51
+ return false;
52
+ const permissions = body.slice(marker + 1).toUpperCase();
53
+ return !permissions.includes("(DENY)") && !permissions.includes("(NW)");
54
+ });
55
+ const normalized = grants.map((grant) => grant.toLowerCase());
56
+ const currentMarker = `${account}:(`;
57
+ return normalized.some((grant) => grant.includes(currentMarker))
58
+ && normalized.every((grant) => grant.includes(currentMarker)
59
+ || /\\logonsessionid_[0-9]+_[0-9]+:\(rx\)$/.test(grant.trim()));
60
+ }
61
+ catch {
62
+ return false;
63
+ }
58
64
  }
59
65
  function delay(milliseconds) {
60
66
  return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));