@nurix/nustack 0.23.12 → 0.23.14

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 (72) hide show
  1. package/dist/commands/agent.js +1 -53
  2. package/dist/commands/check.js +73 -0
  3. package/dist/commands/deploy.js +2 -1
  4. package/dist/commands/doctor.js +2 -1
  5. package/dist/commands/harness.js +127 -48
  6. package/dist/commands/init.js +103 -3
  7. package/dist/commands/login.js +9 -2
  8. package/dist/commands/mcp.js +7 -0
  9. package/dist/commands/project-sync.js +2 -1
  10. package/dist/commands/read.js +19 -0
  11. package/dist/commands/records.js +10 -0
  12. package/dist/commands/review.js +3 -2
  13. package/dist/commands/sessions.js +4 -1
  14. package/dist/commands/sync.js +2 -1
  15. package/dist/index.js +4 -0
  16. package/dist/lib/agent/claudeHooks.js +4 -1
  17. package/dist/lib/agent/desktopDoor.js +55 -0
  18. package/dist/lib/auth_grant.js +15 -3
  19. package/dist/lib/auth_recovery.js +10 -3
  20. package/dist/lib/etna/manager.js +24 -6
  21. package/dist/lib/etna/profile.js +6 -0
  22. package/dist/lib/etna/reconcile.js +6 -0
  23. package/dist/lib/gate/bulkReadRelay.js +354 -0
  24. package/dist/lib/gate/hook.js +30 -3
  25. package/dist/lib/gate/outline.js +12 -0
  26. package/dist/lib/gate/settings.js +24 -1
  27. package/dist/lib/gate/tierOne.js +22 -0
  28. package/dist/lib/harness/codes.js +24 -0
  29. package/dist/lib/harness/hookCheck.js +325 -0
  30. package/dist/lib/harness/hookEntry.js +27 -0
  31. package/dist/lib/harness/legacyMount.js +231 -0
  32. package/dist/lib/harness/mount.js +199 -131
  33. package/dist/lib/harness/storeWaivers.js +43 -0
  34. package/dist/lib/harness/vendor.js +29 -0
  35. package/dist/lib/harness/versionsClient.js +54 -0
  36. package/dist/lib/local_store/database.js +18 -0
  37. package/dist/lib/local_store/routing.js +26 -0
  38. package/dist/lib/nustack_client.js +35 -2
  39. package/dist/lib/onboarding/attach_existing_git.js +14 -2
  40. package/dist/lib/onboarding/binding.js +46 -2
  41. package/dist/lib/onboarding/commit_trailer_hook.js +66 -0
  42. package/dist/lib/onboarding/manifest_writer.js +19 -3
  43. package/dist/lib/onboarding/session_hook.js +76 -0
  44. package/dist/lib/origin_guard.js +43 -0
  45. package/dist/lib/project_contract.js +104 -0
  46. package/dist/lib/records/door.js +26 -19
  47. package/dist/lib/records/drain.js +10 -4
  48. package/dist/lib/records/refusalLog.js +30 -1
  49. package/dist/lib/session-engine/supervisor.js +6 -18
  50. package/dist/lib/sessions/client.js +0 -12
  51. package/dist/program.js +27 -4
  52. package/dist/vendor/harness/assemble/runtimes.js +32 -0
  53. package/dist/vendor/harness/assemble/settingsMerge.js +136 -0
  54. package/dist/vendor/harness/digest/index.js +38 -0
  55. package/dist/vendor/harness/lib/findings.js +32 -0
  56. package/dist/vendor/harness/lib/frontmatter.js +79 -0
  57. package/dist/vendor/harness/lib/paths.js +31 -0
  58. package/dist/vendor/harness/mount/cache.js +65 -0
  59. package/dist/vendor/harness/mount/exclude.js +77 -0
  60. package/dist/vendor/harness/mount/files.js +38 -0
  61. package/dist/vendor/harness/mount/index.js +352 -0
  62. package/dist/vendor/harness/rules/changeRules.js +162 -0
  63. package/dist/vendor/harness/rules/context.js +38 -0
  64. package/dist/vendor/harness/rules/docsRoot.js +34 -0
  65. package/dist/vendor/harness/rules/index.js +109 -0
  66. package/dist/vendor/harness/rules/node.js +144 -0
  67. package/dist/vendor/harness/rules/outputGate.js +28 -0
  68. package/dist/vendor/harness/rules/ownership.js +178 -0
  69. package/dist/vendor/harness/rules/packageTable.js +113 -0
  70. package/dist/vendor/harness/rules/plans.js +96 -0
  71. package/dist/vendor/harness/rules/sessionWalls.js +31 -0
  72. package/package.json +5 -5
@@ -0,0 +1,325 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { openLocalStore, openLocalStoreReadOnly } from "../local_store/database.js";
6
+ import { writeRecords } from "../records/recordsTable.js";
7
+ import { RECORD_SCHEMA_VERSION } from "../records/door.js";
8
+ import { idempotencyKeyOf } from "../records/naturalKey.js";
9
+ import { recordCredentialOf } from "../records/environment.js";
10
+ import { readProjectContract, CONTRACT_PATH } from "../project_contract.js";
11
+ import { loadHarness } from "./vendor.js";
12
+ import { readStoreDocumentationWaivers } from "./storeWaivers.js";
13
+ export const HOOK_DEADLINE_MS = 3000;
14
+ export const HOOK_DEADLINE_VARIABLE = "NUSTACK_HOOK_DEADLINE_MS";
15
+ export const ENFORCEMENT_MAP_PATH = ".nustack/enforcement.json";
16
+ export const HOOK_EVENTS = ["pre-tool-use", "stop"];
17
+ export const RULE_UNAVAILABLE_GATE = "hook.rule_unavailable";
18
+ export const DEADLINE_GATE = "hook.deadline";
19
+ const EVENT_NAMESPACE = "3f0c9a52-6d1e-4b7a-9c2f-8e5d4a1b7c60";
20
+ const RECORDS_STORE_VERSION = 9;
21
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
22
+ export const COMMIT_RULES = new Set(["changelog", "commit-gate", "changelog-presence", "harness-checks"]);
23
+ class CannotRun extends Error {
24
+ }
25
+ export function uuidV5(name, namespace = EVENT_NAMESPACE) {
26
+ const namespaceBytes = Buffer.from(namespace.replace(/-/g, ""), "hex");
27
+ const hash = createHash("sha1").update(Buffer.concat([namespaceBytes, Buffer.from(name, "utf8")])).digest();
28
+ const bytes = Uint8Array.prototype.slice.call(hash, 0, 16);
29
+ bytes[6] = (bytes[6] & 0x0f) | 0x50;
30
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
31
+ const hex = Buffer.from(bytes).toString("hex");
32
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
33
+ }
34
+ function parsePayload(text) {
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(text);
38
+ }
39
+ catch {
40
+ throw new CannotRun("the hook payload on stdin is not JSON");
41
+ }
42
+ if (typeof parsed !== "object" || parsed === null)
43
+ throw new CannotRun("the hook payload on stdin is not an object");
44
+ const payload = parsed;
45
+ return {
46
+ sessionId: typeof payload.session_id === "string" ? payload.session_id : null,
47
+ cwd: typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd(),
48
+ toolName: typeof payload.tool_name === "string" ? payload.tool_name : null,
49
+ toolInput: typeof payload.tool_input === "object" && payload.tool_input !== null ? payload.tool_input : {},
50
+ isStopHookActive: payload.stop_hook_active === true,
51
+ };
52
+ }
53
+ function boundRootOf(dir) {
54
+ let current = path.resolve(dir);
55
+ for (;;) {
56
+ if (existsSync(path.join(current, CONTRACT_PATH)))
57
+ return current;
58
+ const parent = path.dirname(current);
59
+ if (parent === current)
60
+ return null;
61
+ current = parent;
62
+ }
63
+ }
64
+ function readMap(root) {
65
+ let text;
66
+ try {
67
+ text = readFileSync(path.join(root, ENFORCEMENT_MAP_PATH), "utf8");
68
+ }
69
+ catch {
70
+ throw new CannotRun(`no enforcement map at ${ENFORCEMENT_MAP_PATH}; run \`nustack harness mount\``);
71
+ }
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(text);
75
+ }
76
+ catch {
77
+ throw new CannotRun(`${ENFORCEMENT_MAP_PATH} is not JSON`);
78
+ }
79
+ if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.rules))
80
+ throw new CannotRun(`${ENFORCEMENT_MAP_PATH} is not a schemaVersion 1 map`);
81
+ return parsed.rules.filter((row) => typeof row === "object" && row !== null && typeof row.rule === "string" && (row.rung === "flag" || row.rung === "block"));
82
+ }
83
+ function editedPath(root, payload) {
84
+ const raw = payload.toolInput.file_path ?? payload.toolInput.notebook_path;
85
+ if (typeof raw !== "string" || raw === "")
86
+ return null;
87
+ const absolute = path.isAbsolute(raw) ? raw : path.resolve(payload.cwd, raw);
88
+ const relative = path.relative(root, absolute);
89
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative))
90
+ return null;
91
+ return relative.split(path.sep).join("/");
92
+ }
93
+ function postEditText(root, relative, payload) {
94
+ const onDisk = (() => {
95
+ try {
96
+ return readFileSync(path.join(root, relative), "utf8");
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ })();
102
+ const input = payload.toolInput;
103
+ const apply = (text, oldString, newString, isAll) => {
104
+ if (typeof oldString !== "string" || typeof newString !== "string" || oldString === "")
105
+ return text;
106
+ return isAll === true ? text.split(oldString).join(newString) : text.replace(oldString, () => newString);
107
+ };
108
+ switch (payload.toolName) {
109
+ case "Write":
110
+ return typeof input.content === "string" ? input.content : onDisk;
111
+ case "Edit":
112
+ return apply(onDisk ?? "", input.old_string, input.new_string, input.replace_all);
113
+ case "MultiEdit": {
114
+ let text = onDisk ?? "";
115
+ for (const edit of Array.isArray(input.edits) ? input.edits : []) {
116
+ if (typeof edit === "object" && edit !== null) {
117
+ const step = edit;
118
+ text = apply(text, step.old_string, step.new_string, step.replace_all);
119
+ }
120
+ }
121
+ return text;
122
+ }
123
+ default:
124
+ return onDisk;
125
+ }
126
+ }
127
+ function overlaidTree(base, relative, text) {
128
+ return {
129
+ read: (candidate) => (candidate === relative ? text : base.read(candidate)),
130
+ list: (prefix) => {
131
+ const listed = base.list(prefix).filter((candidate) => candidate !== relative);
132
+ if (text !== null && relative.startsWith(prefix))
133
+ listed.push(relative);
134
+ return listed.sort();
135
+ },
136
+ };
137
+ }
138
+ function branchOf(root) {
139
+ try {
140
+ return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
141
+ }
142
+ catch {
143
+ return "";
144
+ }
145
+ }
146
+ function todayOf() {
147
+ return new Date().toISOString().slice(0, 10);
148
+ }
149
+ function findingsText(verdicts) {
150
+ return verdicts.map((verdict) => verdict.reason).join("\n");
151
+ }
152
+ function reasonOf(gate, findings) {
153
+ return findings.length === 0 ? gate : findings.map((finding) => `${gate}: ${finding.message}${finding.path ? ` (${finding.path})` : ""}`).join("\n");
154
+ }
155
+ async function writeVerdicts(root, projectId, payload, target, verdicts) {
156
+ if (verdicts.length === 0)
157
+ return null;
158
+ try {
159
+ const held = await openLocalStoreReadOnly();
160
+ const version = held?.version ?? RECORDS_STORE_VERSION;
161
+ held?.close();
162
+ if (version < RECORDS_STORE_VERSION)
163
+ return null;
164
+ const store = await openLocalStore();
165
+ try {
166
+ const now = Date.now();
167
+ const writes = verdicts.map((verdict) => {
168
+ const eventId = uuidV5(JSON.stringify([payload.sessionId ?? "", verdict.gate, target ?? "", verdict.reason]));
169
+ const naturalKey = `event:${eventId}`;
170
+ return {
171
+ kind: "gate_event",
172
+ schemaVersion: RECORD_SCHEMA_VERSION,
173
+ projectId,
174
+ sessionId: payload.sessionId,
175
+ planAnchor: null,
176
+ naturalKey,
177
+ idempotencyKey: idempotencyKeyOf(projectId ?? root, "gate_event", naturalKey),
178
+ payload: { eventId, gate: verdict.gate, verdict: verdict.verdict, ...(target ? { target } : {}), reason: verdict.reason.slice(0, 20_000) },
179
+ createdAt: now,
180
+ repositoryRoot: root,
181
+ placement: "server",
182
+ credential: recordCredentialOf(),
183
+ };
184
+ });
185
+ writeRecords(store, writes);
186
+ }
187
+ finally {
188
+ store.close();
189
+ }
190
+ return null;
191
+ }
192
+ catch (error) {
193
+ return error instanceof Error ? error.message : String(error);
194
+ }
195
+ }
196
+ function preToolUseAnswer(fields) {
197
+ const output = { hookEventName: "PreToolUse" };
198
+ if (fields.deny !== undefined) {
199
+ output.permissionDecision = "deny";
200
+ output.permissionDecisionReason = fields.deny;
201
+ }
202
+ if (fields.context !== undefined)
203
+ output.additionalContext = fields.context;
204
+ return JSON.stringify({ hookSpecificOutput: output });
205
+ }
206
+ function stopAnswer(fields) {
207
+ const output = {};
208
+ if (fields.block !== undefined) {
209
+ output.decision = "block";
210
+ output.reason = fields.block;
211
+ }
212
+ if (fields.message !== undefined)
213
+ output.systemMessage = fields.message;
214
+ return JSON.stringify(output);
215
+ }
216
+ function cannotRunAnswer(event, isProtection, reason) {
217
+ const line = `NuStack enforcement could not run: ${reason}`;
218
+ if (isProtection)
219
+ return { stdout: "", stderr: `${line}\n`, exitCode: 2 };
220
+ return { stdout: event === "pre-tool-use" ? preToolUseAnswer({ context: line }) : stopAnswer({ message: line }), stderr: "", exitCode: 0 };
221
+ }
222
+ export async function runHookCheck(event, isProtection, stdinText, env = process.env) {
223
+ const started = Date.now();
224
+ const lowered = Number(env[HOOK_DEADLINE_VARIABLE]);
225
+ const deadline = Number.isFinite(lowered) && lowered > 0 ? Math.min(lowered, HOOK_DEADLINE_MS) : HOOK_DEADLINE_MS;
226
+ let payload;
227
+ let root;
228
+ let rows;
229
+ let rules;
230
+ let rulesNode;
231
+ try {
232
+ payload = parsePayload(stdinText);
233
+ const bound = boundRootOf(payload.cwd);
234
+ if (!bound)
235
+ throw new CannotRun("this folder is not bound to a NuStack Project");
236
+ root = bound;
237
+ rows = readMap(root).filter((row) => (isProtection ? row.protection && row.rung === "block" : !(row.protection && row.rung === "block")));
238
+ try {
239
+ rules = await loadHarness("rules");
240
+ rulesNode = await loadHarness("rulesNode");
241
+ }
242
+ catch (error) {
243
+ throw new CannotRun(`the rule library did not load: ${error instanceof Error ? error.message : String(error)}`);
244
+ }
245
+ }
246
+ catch (error) {
247
+ return cannotRunAnswer(event, isProtection, error instanceof Error ? error.message : String(error));
248
+ }
249
+ let target = null;
250
+ if (event === "pre-tool-use") {
251
+ if (payload.toolName !== null && !EDIT_TOOLS.has(payload.toolName))
252
+ return { stdout: "", stderr: "", exitCode: 0 };
253
+ target = editedPath(root, payload);
254
+ if (target === null)
255
+ return { stdout: "", stderr: "", exitCode: 0 };
256
+ }
257
+ const contract = await readProjectContract(root);
258
+ const verdicts = [];
259
+ let isPastDeadline = false;
260
+ try {
261
+ const waivers = [...rulesNode.readBookkeepingWaivers(root), ...(await readStoreDocumentationWaivers(contract.projectId))];
262
+ let context;
263
+ if (event === "pre-tool-use") {
264
+ const tree = rulesNode.workingTree(root);
265
+ const existed = tree.read(target) !== null;
266
+ context = {
267
+ head: overlaidTree(tree, target, postEditText(root, target, payload)),
268
+ base: tree,
269
+ changes: [{ path: target, status: existed ? "M" : "A" }],
270
+ commit: { author: "", message: "" },
271
+ today: todayOf(),
272
+ pullRequest: { headRef: branchOf(root), baseRef: "" },
273
+ waivers,
274
+ };
275
+ }
276
+ else {
277
+ context = { ...rulesNode.nodeRuleContext({ root, baseRef: "HEAD" }), waivers };
278
+ }
279
+ for (const row of rows) {
280
+ if (COMMIT_RULES.has(row.rule))
281
+ continue;
282
+ if (!rules.isRuleId(row.rule)) {
283
+ if (event === "stop")
284
+ verdicts.push({ gate: RULE_UNAVAILABLE_GATE, verdict: "flag", findings: [], reason: `${row.rule} is not a rule this CLI serves` });
285
+ continue;
286
+ }
287
+ if (Date.now() - started >= deadline) {
288
+ isPastDeadline = true;
289
+ break;
290
+ }
291
+ const result = rules.runRule(row.rule, { ...context, rung: row.rung });
292
+ if (result.verdict === "pass")
293
+ continue;
294
+ verdicts.push({ gate: row.rule, verdict: result.verdict, findings: result.findings, reason: reasonOf(row.rule, result.findings) });
295
+ }
296
+ }
297
+ catch (error) {
298
+ return cannotRunAnswer(event, isProtection, error instanceof Error ? error.message : String(error));
299
+ }
300
+ if (isPastDeadline) {
301
+ for (const verdict of verdicts)
302
+ verdict.verdict = "flag";
303
+ verdicts.push({ gate: DEADLINE_GATE, verdict: "flag", findings: [], reason: `the checks did not finish within ${deadline} ms; the call was let through` });
304
+ }
305
+ const blocks = verdicts.filter((verdict) => verdict.verdict === "block");
306
+ const flags = verdicts.filter((verdict) => verdict.verdict === "flag");
307
+ const shownFlags = flags.filter((verdict) => verdict.gate !== RULE_UNAVAILABLE_GATE);
308
+ const writeError = await writeVerdicts(root, contract.projectId, payload, target, verdicts);
309
+ const writeLine = writeError ? `the gate_event record could not be written: ${writeError}` : null;
310
+ if (event === "pre-tool-use") {
311
+ if (blocks.length > 0)
312
+ return { stdout: preToolUseAnswer({ deny: [findingsText(blocks), writeLine].filter(Boolean).join("\n") }), stderr: "", exitCode: 0 };
313
+ if (flags.length > 0 || writeLine)
314
+ return { stdout: preToolUseAnswer({ context: [findingsText(flags), writeLine].filter(Boolean).join("\n") }), stderr: "", exitCode: 0 };
315
+ return { stdout: "", stderr: "", exitCode: 0 };
316
+ }
317
+ if (blocks.length > 0) {
318
+ const reason = [findingsText(blocks), writeLine].filter(Boolean).join("\n");
319
+ const shown = [reason, findingsText(shownFlags)].filter(Boolean).join("\n");
320
+ return { stdout: payload.isStopHookActive ? stopAnswer({ message: shown }) : stopAnswer({ block: reason }), stderr: "", exitCode: 0 };
321
+ }
322
+ if (shownFlags.length > 0 || writeLine)
323
+ return { stdout: stopAnswer({ message: [findingsText(shownFlags), writeLine].filter(Boolean).join("\n") }), stderr: "", exitCode: 0 };
324
+ return { stdout: "", stderr: "", exitCode: 0 };
325
+ }
@@ -0,0 +1,27 @@
1
+ import { HOOK_EVENTS, runHookCheck } from "./hookCheck.js";
2
+ function readStdin() {
3
+ return new Promise((resolve) => {
4
+ if (process.stdin.isTTY) {
5
+ resolve("");
6
+ return;
7
+ }
8
+ let text = "";
9
+ process.stdin.setEncoding("utf8");
10
+ process.stdin.on("data", (chunk) => (text += chunk));
11
+ process.stdin.on("end", () => resolve(text));
12
+ process.stdin.on("error", () => resolve(text));
13
+ });
14
+ }
15
+ export async function runHookMode(event, isProtection) {
16
+ if (!HOOK_EVENTS.includes(event)) {
17
+ console.error(`--hook must be one of ${HOOK_EVENTS.join(", ")}.`);
18
+ process.exitCode = isProtection ? 2 : 0;
19
+ return;
20
+ }
21
+ const answer = await runHookCheck(event, isProtection, await readStdin());
22
+ if (answer.stdout)
23
+ process.stdout.write(`${answer.stdout}\n`);
24
+ if (answer.stderr)
25
+ process.stderr.write(answer.stderr);
26
+ process.exitCode = answer.exitCode;
27
+ }
@@ -0,0 +1,231 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { mkdir, readFile, rename, rm, rmdir, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { HarnessError, HARNESS_CODES } from "./codes.js";
7
+ export const LEGACY_MOUNT_RECORD_PATH = ".claude/state/legacy-mount.json";
8
+ export const LEGACY_EXCLUDE_START = "# etna:mount:start";
9
+ export const LEGACY_EXCLUDE_END = "# etna:mount:end";
10
+ const SHA256 = /^[0-9a-f]{64}$/;
11
+ const REQUEST_TIMEOUT_MS = 15_000;
12
+ const FETCH_CONCURRENCY = 8;
13
+ const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
14
+ export function isMountedPath(file) {
15
+ if (file.policy)
16
+ return file.policy === "mount";
17
+ const contract = ["CLAUDE.md", "AGENTS.md", ".claude/harness.json", ".claude/settings.json", ".claude/hooks/mount.mjs", ".codex/hooks.json"];
18
+ if (contract.includes(file.path))
19
+ return false;
20
+ return file.path.startsWith(".claude/") || file.path.startsWith(".agents/") || file.path.startsWith(".codex/");
21
+ }
22
+ function isSafePath(file) {
23
+ return file !== "" && !path.isAbsolute(file) && !file.split(/[\\/]/).includes("..") && !file.split("/").includes(".git") && !file.startsWith(".claude/state/");
24
+ }
25
+ async function readIfPresent(file) {
26
+ try {
27
+ return await readFile(file);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ async function writeAtomic(target, bytes) {
34
+ await mkdir(path.dirname(target), { recursive: true });
35
+ const staging = `${target}.mount-${process.pid}`;
36
+ await writeFile(staging, bytes);
37
+ await rename(staging, target);
38
+ }
39
+ async function fetchBytes(url) {
40
+ const response = await globalThis.fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
41
+ if (!response.ok)
42
+ throw new Error(`${url} answered ${response.status}`);
43
+ return Buffer.from(await response.arrayBuffer());
44
+ }
45
+ function objectUrlOf(manifestUrl, key) {
46
+ return `${new URL(manifestUrl).origin}/${key.replace(/^\/+/, "")}`;
47
+ }
48
+ function excludePathOf(root) {
49
+ try {
50
+ const relative = execFileSync("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
51
+ return path.isAbsolute(relative) ? relative : path.join(root, relative);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function withoutLegacyBlock(held) {
58
+ const start = held.indexOf(LEGACY_EXCLUDE_START);
59
+ const end = held.indexOf(LEGACY_EXCLUDE_END);
60
+ if (start < 0 || end <= start)
61
+ return held;
62
+ return held.slice(0, start) + held.slice(end + LEGACY_EXCLUDE_END.length).replace(/^\n/, "");
63
+ }
64
+ function ignoredNamesOf(files) {
65
+ const names = new Set([".claude/state/"]);
66
+ for (const file of files) {
67
+ const parts = file.path.split("/");
68
+ if (parts[0] === ".agents")
69
+ names.add(".agents/skills/");
70
+ else if (parts[0] === ".codex")
71
+ names.add(parts.length > 2 ? `.codex/${parts[1]}/` : `.codex/${parts[1]}`);
72
+ else if (parts.length > 3)
73
+ names.add(`${parts.slice(0, 3).join("/")}/`);
74
+ else
75
+ names.add(file.path);
76
+ }
77
+ return [...names].sort();
78
+ }
79
+ async function writeLegacyExcludeBlock(root, files) {
80
+ const excludePath = excludePathOf(root);
81
+ if (!excludePath)
82
+ return;
83
+ const outside = withoutLegacyBlock((await readIfPresent(excludePath))?.toString("utf8") ?? "");
84
+ const block = [LEGACY_EXCLUDE_START, ...ignoredNamesOf(files), LEGACY_EXCLUDE_END].join("\n");
85
+ await writeAtomic(excludePath, `${outside.replace(/\n*$/, outside ? "\n" : "")}${block}\n`);
86
+ }
87
+ export async function removeLegacyExcludeBlock(root) {
88
+ const excludePath = excludePathOf(root);
89
+ if (!excludePath)
90
+ return;
91
+ const held = (await readIfPresent(excludePath))?.toString("utf8");
92
+ if (held === undefined || !held.includes(LEGACY_EXCLUDE_START))
93
+ return;
94
+ await writeAtomic(excludePath, withoutLegacyBlock(held));
95
+ }
96
+ function parseLegacyRecord(raw) {
97
+ if (raw === null)
98
+ return null;
99
+ try {
100
+ const parsed = JSON.parse(raw.toString());
101
+ if (typeof parsed.version !== "string" || typeof parsed.digest !== "string")
102
+ return null;
103
+ return {
104
+ version: parsed.version,
105
+ digest: parsed.digest,
106
+ mountedAt: typeof parsed.mountedAt === "string" ? parsed.mountedAt : "",
107
+ source: "legacy",
108
+ origin: typeof parsed.origin === "string" ? parsed.origin : null,
109
+ legacyFiles: Array.isArray(parsed.legacyFiles)
110
+ ? parsed.legacyFiles.filter((file) => typeof file?.path === "string" && typeof file?.sha256 === "string")
111
+ : [],
112
+ };
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ export async function readLegacyMountRecord(root) {
119
+ return parseLegacyRecord(await readIfPresent(path.join(root, LEGACY_MOUNT_RECORD_PATH)));
120
+ }
121
+ export function readLegacyMountRecordSync(root) {
122
+ const file = path.join(root, LEGACY_MOUNT_RECORD_PATH);
123
+ if (!existsSync(file))
124
+ return null;
125
+ try {
126
+ return parseLegacyRecord(readFileSync(file));
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ export async function mountLegacyManifest(root, manifestUrl, origin) {
133
+ const manifestBytes = await fetchBytes(manifestUrl);
134
+ let manifest;
135
+ try {
136
+ manifest = JSON.parse(manifestBytes.toString("utf8"));
137
+ }
138
+ catch {
139
+ throw new HarnessError(HARNESS_CODES.HARNESS_MOUNT_FAILED, `the manifest at ${manifestUrl} is not JSON`);
140
+ }
141
+ if (!manifest || !Array.isArray(manifest.files))
142
+ throw new HarnessError(HARNESS_CODES.HARNESS_MOUNT_FAILED, `the manifest at ${manifestUrl} lists no files`);
143
+ const mounted = manifest.files.filter(isMountedPath);
144
+ for (const file of mounted) {
145
+ if (typeof file.sha256 !== "string" || !SHA256.test(file.sha256)) {
146
+ throw new HarnessError(HARNESS_CODES.HARNESS_DIGEST_MISMATCH, `the manifest entry ${String(file.path)} carries no verifiable sha256; nothing was written`);
147
+ }
148
+ if (typeof file.path !== "string" || !isSafePath(file.path) || typeof file.key !== "string") {
149
+ throw new HarnessError(HARNESS_CODES.HARNESS_MOUNT_FAILED, `the manifest names an unsafe path: ${String(file.path)}`);
150
+ }
151
+ }
152
+ const toWrite = [];
153
+ const kept = [];
154
+ const needed = [];
155
+ for (const file of mounted) {
156
+ const held = await readIfPresent(path.join(root, file.path));
157
+ if (held && sha256Of(held) === file.sha256)
158
+ kept.push(file.path);
159
+ else
160
+ needed.push(file);
161
+ }
162
+ const fetched = new Map();
163
+ let next = 0;
164
+ const worker = async () => {
165
+ while (next < needed.length) {
166
+ const file = needed[next++];
167
+ const bytes = await fetchBytes(objectUrlOf(manifestUrl, file.key));
168
+ if (sha256Of(bytes) !== file.sha256) {
169
+ throw new HarnessError(HARNESS_CODES.HARNESS_DIGEST_MISMATCH, `the object for ${file.path} does not match its sha256; nothing was written`);
170
+ }
171
+ fetched.set(file, bytes);
172
+ }
173
+ };
174
+ await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, needed.length) }, worker));
175
+ for (const file of needed)
176
+ toWrite.push({ file, bytes: fetched.get(file) });
177
+ for (const { file, bytes } of toWrite)
178
+ await writeAtomic(path.join(root, file.path), bytes);
179
+ await writeLegacyExcludeBlock(root, mounted);
180
+ const stateDir = path.join(root, ".claude", "state");
181
+ await mkdir(stateDir, { recursive: true });
182
+ if (!(await readIfPresent(path.join(stateDir, ".gitignore"))))
183
+ await writeFile(path.join(stateDir, ".gitignore"), "*\n");
184
+ const record = {
185
+ version: String(manifest.version ?? ""),
186
+ digest: sha256Of(manifestBytes),
187
+ mountedAt: new Date().toISOString(),
188
+ source: "legacy",
189
+ origin,
190
+ legacyFiles: mounted.map((file) => ({ path: file.path, sha256: file.sha256 })),
191
+ };
192
+ await writeAtomic(path.join(root, LEGACY_MOUNT_RECORD_PATH), `${JSON.stringify(record, null, 2)}\n`);
193
+ return { version: record.version, digest: record.digest, written: toWrite.map((item) => item.file.path), kept };
194
+ }
195
+ export async function handOverLegacyMount(root, keptByVersion) {
196
+ const record = await readLegacyMountRecord(root);
197
+ if (!record)
198
+ return null;
199
+ const removed = [];
200
+ const editedKept = [];
201
+ for (const file of record.legacyFiles) {
202
+ if (keptByVersion.has(file.path) || !isSafePath(file.path))
203
+ continue;
204
+ const target = path.join(root, file.path);
205
+ const held = await readIfPresent(target);
206
+ if (!held)
207
+ continue;
208
+ if (sha256Of(held) !== file.sha256) {
209
+ editedKept.push(file.path);
210
+ continue;
211
+ }
212
+ await rm(target, { force: true });
213
+ await removeEmptyParents(root, path.dirname(file.path));
214
+ removed.push(file.path);
215
+ }
216
+ await removeLegacyExcludeBlock(root);
217
+ await rm(path.join(root, LEGACY_MOUNT_RECORD_PATH), { force: true });
218
+ return { removed, editedKept };
219
+ }
220
+ async function removeEmptyParents(root, relativeDir) {
221
+ let dir = relativeDir;
222
+ while (dir && dir !== "." && dir !== "/") {
223
+ try {
224
+ await rmdir(path.join(root, dir));
225
+ }
226
+ catch {
227
+ return;
228
+ }
229
+ dir = path.dirname(dir);
230
+ }
231
+ }