nccgs 2.1.0 → 2.1.1

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 (76) hide show
  1. package/.claude/agents/nccgs-adversarial-reviewer.md +1 -1
  2. package/.claude/agents/nccgs-engine-programmer.md +1 -1
  3. package/.claude/agents/nccgs-fast-implementer.md +1 -1
  4. package/.claude/agents/nccgs-game-designer.md +1 -1
  5. package/.claude/agents/nccgs-gameplay-programmer.md +1 -1
  6. package/.claude/agents/nccgs-qa-engineer.md +1 -1
  7. package/.claude/agents/nccgs-task-scout.md +1 -1
  8. package/.claude/agents/nccgs-ui-programmer.md +1 -1
  9. package/.claude/agents/nccgs-unity-build-specialist.md +1 -1
  10. package/.claude/agents/nccgs-unity-implementer.md +1 -1
  11. package/.claude/agents/nccgs-unity-systems-specialist.md +1 -1
  12. package/.claude/agents/nccgs-unity-ui-specialist.md +1 -1
  13. package/.claude/agents/nccgs-verification-engineer.md +1 -1
  14. package/.claude/nccgs/MEMORY.md +3 -1
  15. package/.claude/nccgs/VERSION +1 -1
  16. package/.claude/nccgs/constitution.md +187 -176
  17. package/.claude/nccgs/hooks/closure-guard.mjs +2 -0
  18. package/.claude/nccgs/hooks/unity-transport-guard.mjs +11 -0
  19. package/.claude/nccgs/pipeline.json +260 -260
  20. package/.claude/nccgs/protocols/context-packets.md +5 -0
  21. package/.claude/nccgs/protocols/model-routing.md +46 -46
  22. package/.claude/nccgs/protocols/review-handoff.md +21 -1
  23. package/.claude/nccgs/protocols/shared-workflow-context.md +33 -0
  24. package/.claude/nccgs/protocols/unity-boundary.md +20 -0
  25. package/.claude/nccgs/routing.generated.md +4 -4
  26. package/.claude/nccgs/settings.fragment.json +84 -24
  27. package/.claude/nccgs/studio.json +53 -53
  28. package/.claude/nccgs/tools/autonomy.mjs +13 -0
  29. package/.claude/nccgs/tools/command-tokens.mjs +22 -0
  30. package/.claude/nccgs/tools/hook-transport.mjs +34 -0
  31. package/.claude/nccgs/tools/native-completion.mjs +10 -1
  32. package/.claude/nccgs/tools/returned-handoff.mjs +11 -2
  33. package/.claude/nccgs/tools/shared-context-guard.mjs +60 -0
  34. package/.claude/nccgs/tools/shared-context.mjs +25 -0
  35. package/.claude/nccgs/tools/tool-guard.mjs +120 -131
  36. package/.claude/nccgs/tools/unity-transport.mjs +170 -0
  37. package/.claude/nccgs/workflow-catalog.json +4 -4
  38. package/CHANGELOG.md +235 -200
  39. package/CLAUDE.md +10 -8
  40. package/MEMORY.md +109 -60
  41. package/README.md +206 -191
  42. package/UPGRADING.md +130 -118
  43. package/VERSION +1 -1
  44. package/docs/ARCHITECTURE.md +131 -131
  45. package/docs/ASSET-PRODUCTION-GRAPHIFY-PLAN.md +230 -0
  46. package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +117 -116
  47. package/docs/NCCGS-1.5.md +237 -237
  48. package/docs/PROJECT-POLICY.md +94 -94
  49. package/docs/RELEASE-STATUS.md +17 -9
  50. package/docs/UNINSTALL.md +3 -3
  51. package/docs/WFK17-HOOK-REPAIR.md +71 -0
  52. package/docs/WORKFLOWS.md +85 -85
  53. package/package.json +1 -1
  54. package/scaffold/.nccgs/project.yaml +109 -108
  55. package/scripts/benchmark-v15.mjs +173 -173
  56. package/scripts/doctor.mjs +261 -261
  57. package/scripts/install.mjs +530 -529
  58. package/scripts/run.mjs +5 -4
  59. package/scripts/supervise.mjs +4 -0
  60. package/scripts/uninstall.mjs +292 -292
  61. package/scripts/validate.mjs +285 -280
  62. package/tests/autonomy.test.mjs +126 -113
  63. package/tests/design-readiness.test.mjs +199 -199
  64. package/tests/fixtures/compiler-shutdown.cs +67 -64
  65. package/tests/fixtures/native-provider.mjs +61 -0
  66. package/tests/framework.test.mjs +740 -740
  67. package/tests/hook-repair.test.mjs +106 -0
  68. package/tests/lifecycle-v15.test.mjs +346 -300
  69. package/tests/native-completion-notices.test.mjs +181 -0
  70. package/tests/phase1.test.mjs +427 -416
  71. package/tests/phase2.test.mjs +250 -250
  72. package/tests/policy-v15.test.mjs +234 -234
  73. package/tests/returned-handoff-notices.test.mjs +170 -0
  74. package/tests/runtime-v15.test.mjs +1197 -1191
  75. package/tests/task-slices.test.mjs +285 -285
  76. package/tests/unity-transport.test.mjs +348 -0
@@ -1,416 +1,427 @@
1
- import assert from "node:assert/strict";
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import crypto from "node:crypto";
6
- import { spawn, spawnSync } from "node:child_process";
7
- import { fileURLToPath } from "node:url";
8
- import test from "node:test";
9
- import { atomicJson, json } from "../.claude/nccgs/tools/policy.mjs";
10
- import { initialize } from "../.claude/nccgs/tools/gates.mjs";
11
- import { acquireLock, delay, pidState, treeState } from "../.claude/nccgs/tools/process-lock.mjs";
12
- import { startCommand } from "../.claude/nccgs/tools/command-process.mjs";
13
- import { startVerification, waitVerification, jobStatus, readJob, jobDirectory } from "../.claude/nccgs/tools/verification-jobs.mjs";
14
- import { safeVerifyCommand, guardTool } from "../.claude/nccgs/tools/tool-guard.mjs";
15
- import { cumulativeCost, supervise } from "../scripts/supervise.mjs";
16
- import { registerReviewBudget, reviewBudgetStatus } from "../.claude/nccgs/tools/review-budget.mjs";
17
-
18
- const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
19
- function fixture(timeoutMs = 5000, mode = "implement") {
20
- const base = fs.realpathSync(os.tmpdir()), project = fs.mkdtempSync(path.join(base, "nccgs-phase1-"));
21
- const write = (relative, value) => {
22
- const file = path.join(project, relative); fs.mkdirSync(path.dirname(file), { recursive: true });
23
- fs.writeFileSync(file, typeof value === "string" ? value : JSON.stringify(value)); return file;
24
- };
25
- fs.cpSync(path.join(root, ".claude/nccgs/tools"), path.join(project, ".claude/nccgs/tools"), { recursive: true });
26
- for (const file of ["pipeline.json", "studio.json"]) write(`.claude/nccgs/${file}`, fs.readFileSync(path.join(root, ".claude/nccgs", file), "utf8"));
27
- write("Assets/source.cs", "// frozen fixture source");
28
- write(".nccgs/project.yaml", "schema_version: 2\nmodels:\n profile: claude-standalone\n");
29
- write(".nccgs/execution.json", { schemaVersion: 1, name: "standalone", externalReview: { mode: "off" } });
30
- write(".nccgs/inputs.json", { schemaVersion: 1, canonicalInputs: [], outputDirectories: [], checks: { test: { timeoutMs } } });
31
- write(".nccgs/intake.json", { objective: "Process lifecycle fixture; no actual Claude/model/game execution", mode, requiredChecks: ["test"] });
32
- initialize(project, "fixture", null, null, null, { intake: ".nccgs/intake.json" });
33
- const env = { ...process.env };
34
- for (const key of ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "NCCGS_TASK_ID", "NCCGS_SUPERVISED_SESSION", "NCCGS_PROJECT_ROOT"]) delete env[key];
35
- const cli = (...args) => spawnSync(process.execPath, [path.join(project, ".claude/nccgs/tools/task.mjs"), ...args, "--project", project], { cwd: project, env, encoding: "utf8", timeout: 20000, windowsHide: true });
36
- return { project, write, env, cli, dispose() {
37
- assert.equal(path.dirname(fs.realpathSync(project)).toLowerCase(), base.toLowerCase());
38
- assert.ok(path.basename(project).startsWith("nccgs-phase1-"));
39
- fs.rmSync(project, { recursive: true, force: true });
40
- } };
41
- }
42
-
43
- test("verify parser separates helper options from the framework root and rejects shell composition", () => {
44
- const f = fixture();
45
- try {
46
- const prefix = `node "${f.project.replaceAll("\\", "/")}/.claude/nccgs/tools/task.mjs" verify --id fixture --name test --project "${f.project}"`;
47
- assert.equal(safeVerifyCommand(`${prefix} -- node helper.mjs --project "${f.project}"`, f.project), true);
48
- assert.equal(safeVerifyCommand(`${prefix} -- node helper.mjs --project=anything`, f.project), true);
49
- assert.equal(safeVerifyCommand(`${prefix} -- node -e "console.log('a;b')"`, f.project), true);
50
- assert.equal(safeVerifyCommand(`${prefix} --project other -- node x`, f.project), false);
51
- assert.equal(safeVerifyCommand(`${prefix.replace(`--project "${f.project}"`, '--project "../foreign"')} -- node x`, f.project), false);
52
- for (const suffix of ["; echo bad", " && echo bad", " > output", " | node x"]) assert.equal(safeVerifyCommand(`${prefix} -- node x${suffix}`, f.project), false);
53
- assert.equal(safeVerifyCommand(`${prefix} -- node -e "$(touch bad)"`, f.project), false);
54
- f.write(".nccgs/runtime/bindings/parser.json", { taskId: "fixture" });
55
- assert.doesNotThrow(() => guardTool(f.project, { session_id: "parser", tool_name: "Bash", tool_input: { command: `${prefix} -- node helper.mjs --project "${f.project}"` } }));
56
- } finally { f.dispose(); }
57
- });
58
-
59
- test("detached verification survives its caller and repeated request keys reuse one immutable receipt", () => {
60
- const f = fixture();
61
- try {
62
- const code = "setTimeout(()=>{require('node:fs').appendFileSync('.nccgs/evidence/count.txt','run\\n');console.log('real subprocess output')},400)";
63
- const args = ["verify", "--id", "fixture", "--name", "test", "--background", "--request-key", "once", "--", process.execPath, "-e", code];
64
- // Add the framework project before the separator, as real callers must do.
65
- const first = spawnSync(process.execPath, [path.join(f.project, ".claude/nccgs/tools/task.mjs"), ...args], { cwd: f.project, env: f.env, encoding: "utf8", windowsHide: true });
66
- assert.equal(first.status, 0, first.stderr); const started = JSON.parse(first.stdout);
67
- const result = waitVerification(f.project, started.jobId, "fixture", 10000);
68
- assert.equal(result.status, "PASS");
69
- const duplicate = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", code], requestKey: "once" });
70
- assert.deepEqual(duplicate.evidence, result.evidence);
71
- assert.equal(fs.readFileSync(path.join(f.project, ".nccgs/evidence/count.txt"), "utf8"), "run\n");
72
- const receipt = json(path.join(f.project, result.evidence.path));
73
- assert.equal(receipt.sourceUnchanged, true); assert.equal(receipt.exitCode, 0);
74
- f.write("Assets/source.cs", "// changed");
75
- assert.throws(() => startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", code], requestKey: "once" }), /changed source/);
76
- assert.throws(() => jobStatus(f.project, started.jobId, "other"), /another task/);
77
- } finally { f.dispose(); }
78
- });
79
-
80
- test("timeout and source mutation produce failed receipts, never reusable PASS", () => {
81
- const f = fixture(200);
82
- try {
83
- const timed = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", "setTimeout(()=>{},10000)"], requestKey: "timeout" });
84
- const failure = waitVerification(f.project, timed.jobId, "fixture", 15000);
85
- assert.equal(failure.status, "FAIL"); assert.equal(json(path.join(f.project, failure.evidence.path)).timedOut, true);
86
- } finally { f.dispose(); }
87
- // A mutation needs time to execute; the timeout probe's 200ms budget could kill
88
- // Node startup first under concurrent Windows tests and never touch the source.
89
- const mutationFixture = fixture(5000);
90
- try {
91
- const changed = startVerification(mutationFixture.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", "require('node:fs').writeFileSync('Assets/source.cs','changed')"], requestKey: "mutation" });
92
- const mutation = waitVerification(mutationFixture.project, changed.jobId, "fixture", 10000);
93
- const receipt = json(path.join(mutationFixture.project, mutation.evidence.path));
94
- assert.equal(mutation.status, "FAIL"); assert.equal(receipt.sourceUnchanged, false);
95
- assert.equal(receipt.timedOut, false);
96
- } finally { mutationFixture.dispose(); }
97
- });
98
-
99
- test("per-attempt timeout is bounded, pinned and forwarded only before the helper separator", () => {
100
- const f = fixture();
101
- try {
102
- const beforeInputs = fs.readFileSync(path.join(f.project, ".nccgs/inputs.json"), "utf8");
103
- const command = [process.execPath, "-e", "console.log('bounded timeout fixture')"];
104
- const invoke = extra => spawnSync(process.execPath, [path.join(f.project, ".claude/nccgs/tools/task.mjs"),
105
- "verify", "--id", "fixture", "--name", "long-check", "--request-key", "long-once", ...extra, "--", ...command],
106
- { cwd: f.project, env: f.env, encoding: "utf8", windowsHide: true, timeout: 20000 });
107
- const first = invoke(["--timeout-ms", "900000"]);
108
- assert.equal(first.status, 0, first.stderr);
109
- const result = JSON.parse(first.stdout);
110
- assert.equal(result.status, "PASS");
111
- assert.equal(json(path.join(f.project, result.evidence.path)).timeoutMs, 900000);
112
- assert.equal(readJob(f.project, result.jobId).timeoutMs, 900000);
113
- const duplicate = invoke(["--timeout-ms", "900000"]);
114
- assert.equal(duplicate.status, 0, duplicate.stderr);
115
- assert.deepEqual(JSON.parse(duplicate.stdout).evidence, result.evidence);
116
- assert.notEqual(invoke(["--timeout-ms", "800000"]).status, 0);
117
- assert.equal(fs.readFileSync(path.join(f.project, ".nccgs/inputs.json"), "utf8"), beforeInputs);
118
- for (const timeoutMs of [0, 99, 1.5, 7200001, NaN, Infinity, null, "900000"]) {
119
- assert.throws(() => startVerification(f.project, "fixture", { name: "long-check", executable: process.execPath,
120
- arguments: command.slice(1), requestKey: "invalid", timeoutMs }), /timeoutMs/);
121
- }
122
- assert.throws(() => startVerification(f.project, "fixture", { name: "test", executable: process.execPath,
123
- arguments: command.slice(1), requestKey: "configured-limit", timeoutMs: 5001 }), /configured check timeout/);
124
- const helper = f.write(".nccgs/helper.mjs", "console.log(process.argv.slice(2).join(' '));");
125
- const forwarded = spawnSync(process.execPath, [path.join(f.project, ".claude/nccgs/tools/task.mjs"),
126
- "verify", "--id", "fixture", "--name", "test", "--request-key", "helper-timeout", "--", process.execPath, helper,
127
- "--timeout-ms", "900000"], { cwd: f.project, env: f.env, encoding: "utf8", windowsHide: true, timeout: 20000 });
128
- assert.equal(forwarded.status, 0, forwarded.stderr);
129
- const forwardedReceipt = json(path.join(f.project, JSON.parse(forwarded.stdout).evidence.path));
130
- assert.equal(forwardedReceipt.timeoutMs, 5000);
131
- assert.match(fs.readFileSync(path.join(f.project, forwardedReceipt.log.path), "utf8"), /--timeout-ms 900000/);
132
- } finally { f.dispose(); }
133
- });
134
-
135
- test("long verification remains owned and bounded by its pinned supervisor deadline", () => {
136
- const f = fixture();
137
- try {
138
- const runId = crypto.randomUUID(), sessionId = crypto.randomUUID(), deadline = Date.now() + 2500;
139
- f.write(`.nccgs/runs/task-fixture.lock`, { runId });
140
- const run = { project: f.project, taskId: "fixture", sessionId, deadline };
141
- f.write(`.nccgs/runs/${runId}/run.json`, run);
142
- const args = [path.join(f.project, ".claude/nccgs/tools/task.mjs"), "verify", "--id", "fixture",
143
- "--name", "long-check", "--request-key", "supervised-long", "--timeout-ms", "900000", "--",
144
- process.execPath, "-e", "setTimeout(()=>{require('node:fs').writeFileSync('.nccgs/evidence/late.txt','bad')},60000)"];
145
- const env = { ...f.env, NCCGS_SUPERVISED_SESSION: sessionId };
146
- const result = spawnSync(process.execPath, args, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 20000 });
147
- assert.equal(result.status, 1, result.stderr);
148
- const stopped = JSON.parse(result.stdout), receipt = json(path.join(f.project, stopped.evidence.path));
149
- assert.equal(receipt.status, "FAIL"); assert.equal(receipt.timedOut, true);
150
- assert.equal(receipt.timeoutMs, 900000); assert.equal(receipt.supervisorDeadline, deadline);
151
- const launch = json(path.join(jobDirectory(f.project, stopped.jobId), "launch.json"));
152
- assert.equal(pidState(launch.pid), "dead");
153
- assert.equal(fs.existsSync(path.join(f.project, ".nccgs/evidence/late.txt")), false);
154
- // A settled request remains idempotent after expiry; a fresh attempt cannot run.
155
- const repeat = spawnSync(process.execPath, args, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 10000 });
156
- assert.deepEqual(JSON.parse(repeat.stdout).evidence, stopped.evidence);
157
- const freshArgs = args.map(a => a === "supervised-long" ? "too-late" : a);
158
- const expired = spawnSync(process.execPath, freshArgs, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 10000 });
159
- assert.notEqual(expired.status, 0); assert.match(expired.stderr, /deadline reached/);
160
- const foreign = spawnSync(process.execPath, freshArgs, { cwd: f.project, env: { ...env, NCCGS_SUPERVISED_SESSION: crypto.randomUUID() }, encoding: "utf8", windowsHide: true, timeout: 10000 });
161
- assert.notEqual(foreign.status, 0); assert.match(foreign.stderr, /binding mismatch/);
162
- } finally { f.dispose(); }
163
- });
164
-
165
- test("dead lock owners recover with evidence; live, unknown, legacy and spawning owners do not expire", () => {
166
- const f = fixture();
167
- try {
168
- const file = path.join(f.project, ".nccgs/test.lock");
169
- const module = new URL("../.claude/nccgs/tools/process-lock.mjs", import.meta.url).href;
170
- const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import {acquireLock} from ${JSON.stringify(module)}; acquireLock(${JSON.stringify(file)});`], { encoding: "utf8", windowsHide: true });
171
- assert.equal(held.status, 0, held.stderr);
172
- const recovered = acquireLock(file);
173
- assert.ok(fs.existsSync(`${file}.recoveries.jsonl`));
174
- assert.throws(() => acquireLock(file), /live or unverified/); recovered.release();
175
- for (const owner of [{ pid: 99999999 }, { schemaVersion: 1, host: "other-machine", pid: 99999999, token: "remote" },
176
- { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "gap", phase: "spawning" }]) {
177
- atomicJson(file, owner); assert.throws(() => acquireLock(file), /live or unverified/); fs.unlinkSync(file);
178
- }
179
- } finally { f.dispose(); }
180
- });
181
-
182
- test("a dead owner with a live command retains its engine lock", async () => {
183
- const f = fixture(), child = spawn(process.execPath, ["-e", "setTimeout(()=>{},20000)"], { stdio: "ignore", windowsHide: true });
184
- try {
185
- await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
186
- const file = path.join(f.project, ".nccgs/engine.lock");
187
- atomicJson(file, { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "dead-parent", phase: "running", child: { pid: child.pid, group: false } });
188
- assert.throws(() => acquireLock(file), /live or unverified/);
189
- const closed = new Promise(resolve => child.once("close", resolve)); child.kill(); await closed;
190
- // Legacy/unmanaged children cannot prove that descendants have exited.
191
- if (process.platform === "win32") assert.throws(() => acquireLock(file), /live or unverified/);
192
- } finally { if (pidState(child.pid) === "alive") child.kill(); f.dispose(); }
193
- });
194
-
195
- test("OS-owned process group retains orphan descendants and proves their exit after host termination", { skip: process.platform !== "win32" }, async () => {
196
- const f = fixture(); let host, descendant;
197
- try {
198
- const id = crypto.randomUUID(), directory = path.join(f.project, ".nccgs");
199
- const code = "const cp=require('node:child_process'),fs=require('node:fs');const p=cp.spawn(process.execPath,['-e','setTimeout(()=>{},20000)'],{stdio:'ignore',detached:true});fs.writeFileSync('.nccgs/descendant.json',JSON.stringify({pid:p.pid}));p.unref();";
200
- const managed = startCommand({ executable: process.execPath, args: ["-e", code], cwd: f.project, stateDirectory: directory, id, stdio: "ignore" });
201
- host = managed.child;
202
- const closed = new Promise(resolve => host.once("close", resolve));
203
- const deadline = Date.now() + 10000;
204
- while (!fs.existsSync(path.join(directory, "descendant.json")) && Date.now() < deadline) await delay(50);
205
- descendant = json(path.join(directory, "descendant.json")).pid;
206
- assert.equal(pidState(descendant), "alive"); assert.equal(pidState(host.pid), "alive");
207
- const file = path.join(directory, "engine.lock");
208
- atomicJson(file, { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "orphan-owner", phase: "running", child: managed.identity() });
209
- assert.throws(() => acquireLock(file), /live or unverified/);
210
- host.kill("SIGKILL"); await closed;
211
- assert.equal(treeState(managed.identity()), "dead"); assert.equal(pidState(descendant), "dead");
212
- const recovered = acquireLock(file); recovered.release();
213
- assert.ok(fs.existsSync(`${file}.recoveries.jsonl`));
214
- } finally { if (host && pidState(host.pid) === "alive") host.kill("SIGKILL"); f.dispose(); }
215
- });
216
-
217
- test("managed wrappers avoid the resident Unity auth broker without changing the caller or persisting its environment", () => {
218
- const f = fixture();
219
- const key = "UNITY_NO_AUTH_BROKER", previous = process.env[key];
220
- const sentinelKey = "NCCGS_TEST_PRIVATE_ENV", previousSentinel = process.env[sentinelKey];
221
- const sentinel = crypto.randomUUID();
222
- try {
223
- // Even a wrapper, rather than a direct `unity` executable, must inherit it.
224
- // Explicitly avoid modifying stored auth, peer-signature or account settings.
225
- process.env[key] = "0"; process.env[sentinelKey] = sentinel;
226
- const code = "const cp=require('node:child_process'); const c=cp.spawnSync(process.execPath,['-e',\"console.log(JSON.stringify({broker:process.env.UNITY_NO_AUTH_BROKER,peer:process.env.UNITY_PEER_AUTH_MODE??null,privateEnv:process.env.NCCGS_TEST_PRIVATE_ENV}))\"],{encoding:'utf8'});process.stdout.write(c.stdout);process.exitCode=c.status;";
227
- const started = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", code], requestKey: "direct-keyring" });
228
- const settled = waitVerification(f.project, started.jobId, "fixture", 15000);
229
- assert.equal(settled.status, "PASS");
230
- const receipt = json(path.join(f.project, settled.evidence.path));
231
- const values = JSON.parse(fs.readFileSync(path.join(f.project, receipt.log.path), "utf8"));
232
- assert.equal(values.broker, "1");
233
- assert.equal(values.peer, process.env.UNITY_PEER_AUTH_MODE ?? null);
234
- assert.equal(values.privateEnv, sentinel);
235
- assert.equal(process.env[key], "0"); assert.equal(process.env[sentinelKey], sentinel);
236
- // Environment values are inherited in memory, never serialized in the job
237
- // specification or receipt (which may otherwise expose credentials).
238
- assert.equal(JSON.stringify(receipt).includes(sentinel), false);
239
- const spec = path.join(jobDirectory(f.project, started.jobId), `${started.jobId}-command.json`);
240
- if (fs.existsSync(spec)) assert.equal(fs.readFileSync(spec, "utf8").includes(sentinel), false);
241
- } finally {
242
- if (previous === undefined) delete process.env[key]; else process.env[key] = previous;
243
- if (previousSentinel === undefined) delete process.env[sentinelKey]; else process.env[sentinelKey] = previousSentinel;
244
- f.dispose();
245
- }
246
- });
247
-
248
- test("a completed wrapper still waits for its real descendant and retains the wrapper exit code", { skip: process.platform !== "win32" }, async () => {
249
- const f = fixture();
250
- try {
251
- const descendant = "setTimeout(()=>require('node:fs').writeFileSync('.nccgs/evidence/descendant-completed.txt','done'),1800)";
252
- const wrapper = `const cp=require('node:child_process'),fs=require('node:fs');const p=cp.spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{stdio:'ignore',detached:true});fs.writeFileSync('.nccgs/evidence/wrapper-completed.txt','done');p.unref();process.exitCode=7;`;
253
- const started = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", wrapper], requestKey: "descendant-drain" });
254
- const marker = path.join(f.project, '.nccgs/evidence/wrapper-completed.txt');
255
- const deadline = Date.now() + 10000;
256
- while (!fs.existsSync(marker) && Date.now() < deadline) await delay(20);
257
- assert.equal(fs.existsSync(marker), true);
258
- assert.equal(jobStatus(f.project, started.jobId, "fixture").status, "RUNNING");
259
- const result = waitVerification(f.project, started.jobId, "fixture", 15000);
260
- assert.equal(result.status, "FAIL");
261
- assert.equal(json(path.join(f.project, result.evidence.path)).exitCode, 7);
262
- assert.equal(fs.readFileSync(path.join(f.project, '.nccgs/evidence/descendant-completed.txt'), 'utf8'), 'done');
263
- } finally { f.dispose(); }
264
- });
265
-
266
- test("orphan verification becomes a retained failed attempt without rerunning the command", () => {
267
- const f = fixture();
268
- try {
269
- const jobId = crypto.randomUUID(), directory = jobDirectory(f.project, jobId);
270
- fs.mkdirSync(directory, { recursive: true });
271
- atomicJson(path.join(directory, "request.json"), { schemaVersion: 1, id: jobId, project: fs.realpathSync(f.project), taskId: "fixture", name: "test", queuedAt: new Date().toISOString(), snapshot: "unknown", executable: "unlaunched", arguments: [] });
272
- atomicJson(path.join(directory, "owner.lock"), { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "orphan", phase: "owned", child: null });
273
- const failed = jobStatus(f.project, jobId, "fixture");
274
- assert.equal(failed.status, "FAIL");
275
- assert.equal(json(path.join(f.project, failed.evidence.path)).exitCode, null);
276
- assert.match(json(path.join(f.project, failed.evidence.path)).error, /without a terminal receipt/);
277
- assert.deepEqual(jobStatus(f.project, jobId, "fixture").evidence, failed.evidence);
278
- } finally { f.dispose(); }
279
- });
280
-
281
- test("Unity compiler shutdown verifies exact pipe ownership, qualified binaries and bounded acknowledgement", { skip: process.platform !== "win32" }, () => {
282
- const f = fixture();
283
- try {
284
- const binary = path.join(f.project, '.nccgs/compiler-shutdown-fixture.exe');
285
- const compiler = path.join(process.env.SystemRoot || 'C:/Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe');
286
- const built = spawnSync(compiler, ['/nologo', '/target:exe', `/out:${binary}`,
287
- path.join(root, '.claude/nccgs/tools/windows-compiler-server.cs'),
288
- path.join(root, 'tests/fixtures/compiler-shutdown.cs')], { encoding: 'utf8', windowsHide: true, timeout: 30000 });
289
- assert.equal(built.status, 0, built.stdout + built.stderr);
290
- const result = spawnSync(binary, [path.join(f.project, '.nccgs/fake-install')], { encoding: 'utf8', windowsHide: true, timeout: 10000 });
291
- assert.equal(result.status, 0, result.stdout + result.stderr);
292
- assert.match(result.stdout, /compiler shutdown guards PASS/);
293
- } finally { f.dispose(); }
294
- });
295
-
296
- // The fake native executable below is explicitly a fixture, never Claude evidence.
297
- function fakeNative(f, { missingUsage = false, omitReport = false } = {}) {
298
- return f.write(".nccgs/fake-native.mjs", `
299
- import fs from 'node:fs';
300
- import {run} from '../.claude/nccgs/tools/task.mjs';
301
- const args=process.argv.slice(2), resumed=args.includes('--resume');
302
- const session=args[args.indexOf(resumed?'--resume':'--session-id')+1];
303
- if(!resumed) run(['verify','--id','fixture','--name','test','--background','--request-key','native-once','--',process.execPath,'-e',"setTimeout(()=>console.log('fixture command done'),800)"]);
304
- else if(${!omitReport}) run(['report','--id','fixture','--outcome','partial','--summary','Fixture native continuation read the managed receipt; no game acceptance']);
305
- console.log(JSON.stringify({type:'result',session_id:session,total_cost_usd:${missingUsage ? "null" : "resumed?0.6:0.4"},result:'Synthetic native result; no actual model execution'}));
306
- `);
307
- }
308
- test("supervisor clamps real launch arguments to work allowance and retains the final review reserve", async () => {
309
- const f = fixture();
310
- try {
311
- f.write('.nccgs/evidence/budget-request.json', { schemaVersion: 1, authority: 'Synthetic test only', totalUsd: 1, finalReviewUsd: 0.4, totalDurationMs: 60000, finalReviewDurationMs: 10000 });
312
- registerReviewBudget(f.project, 'fixture', '.nccgs/evidence/budget-request.json');
313
- const binary = fakeNative(f), prompt = f.write('.nccgs/prompt.txt', 'Synthetic fixture only');
314
- assert.equal(await supervise({ project: f.project, binary: process.execPath, mode: 'full', args: [binary], env: f.env,
315
- supervision: { taskId: 'fixture', promptFile: prompt, budgetUsd: 1, maxResumes: 1, timeoutMs: 20000, budgetPhase: 'work' } }), 0);
316
- const id = fs.readdirSync(path.join(f.project, '.nccgs/runs'), { withFileTypes: true }).find(e => e.isDirectory()).name;
317
- const run = json(path.join(f.project, `.nccgs/runs/${id}/run.json`));
318
- assert.equal(run.budgetUsd, 0.6); assert.equal(run.invocations[0].allowanceUsd, 0.6);
319
- assert.ok(Math.abs(run.invocations[1].allowanceUsd - 0.2) < 1e-9); assert.equal(run.cumulativeCostUsd, 0.6);
320
- assert.equal(reviewBudgetStatus(f.project, 'fixture').protectedReviewUsd, 0.4);
321
- } finally { f.dispose(); }
322
- });
323
- test("final-review session blocks new engine jobs in both hook and direct lifecycle command", () => {
324
- const f = fixture(), names = ['NCCGS_SUPERVISED_RUN','NCCGS_SUPERVISED_SESSION'], saved = names.map(n => process.env[n]);
325
- try {
326
- const id = crypto.randomUUID(), session = 'final-review-fixture';
327
- f.write(`.nccgs/runs/${id}/run.json`, { sessionId: session, budgetPhase: 'final-review' });
328
- f.write(`.nccgs/runtime/bindings/${session}.json`, { taskId: 'fixture' });
329
- process.env.NCCGS_SUPERVISED_RUN = id; process.env.NCCGS_SUPERVISED_SESSION = session;
330
- assert.throws(() => startVerification(f.project, 'fixture', { name: 'test', executable: process.execPath, arguments: ['-e','console.log(1)'] }), /Final-review reserve/);
331
- assert.throws(() => guardTool(f.project, { session_id: session, tool_name: 'Bash', tool_input: { command: 'node .claude/nccgs/tools/task.mjs verify --id fixture --name test -- node helper.mjs' } }), /Final-review reserve/);
332
- assert.throws(() => guardTool(f.project, { session_id: session, tool_name: 'Bash', tool_input: { command: 'node .claude/nccgs/tools/task.mjs budget-plan --id fixture --file .nccgs/evidence/budget.json' } }), /External decisions/);
333
- } finally { names.forEach((n,i) => { if(saved[i]===undefined) delete process.env[n]; else process.env[n]=saved[i]; }); f.dispose(); }
334
- });
335
- test("supervisor waits outside the model, resumes the same session once, preserves cumulative budget and receipts", async () => {
336
- const f = fixture();
337
- try {
338
- const binary = fakeNative(f), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
339
- const plan = { project: f.project, binary: process.execPath, mode: "full", args: [binary], env: f.env,
340
- supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 1, timeoutMs: 20000 } };
341
- assert.equal(await supervise(plan), 0);
342
- const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
343
- const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
344
- assert.equal(state.status, "settled"); assert.equal(state.invocations.length, 2);
345
- assert.equal(state.cumulativeCostUsd, 0.6); assert.equal(state.invocations[1].allowanceUsd, 0.6);
346
- assert.equal(state.invocations[0].terminal.session_id, state.invocations[1].terminal.session_id);
347
- assert.equal(fs.readdirSync(path.join(f.project, ".nccgs/jobs")).length, 1);
348
- assert.equal(await supervise({ ...plan, supervision: { recoverRun: runId } }), 0);
349
- assert.equal(json(path.join(f.project, `.nccgs/runs/${runId}/run.json`)).invocations.length, 2);
350
- } finally { f.dispose(); }
351
- });
352
-
353
- test("supervisor can settle a plan without granting source writes or shell checks", async () => {
354
- const f = fixture(5000, "plan");
355
- try {
356
- const binary = f.write(".nccgs/plan-native.mjs", `import {run} from '../.claude/nccgs/tools/task.mjs';
357
- const args=process.argv.slice(2);const session=args[args.indexOf('--session-id')+1];
358
- run(['report','--id','fixture','--outcome','partial','--summary','Synthetic plan report; document review pending']);
359
- console.log(JSON.stringify({type:'result',session_id:session,total_cost_usd:0.1,result:'Synthetic, not real Claude'}));`);
360
- const prompt = f.write(".nccgs/prompt.txt", "Synthetic plan supervision");
361
- assert.equal(await supervise({ project: f.project, binary: process.execPath, mode: "full", args: [binary], env: f.env,
362
- supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 0, timeoutMs: 20000 } }), 0);
363
- const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
364
- const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
365
- assert.equal(state.status, "settled"); assert.equal(state.invocations.length, 1);
366
- assert.throws(() => guardTool(f.project, { session_id: state.sessionId, tool_name: "Write", tool_input: { file_path: "Assets/source.cs" } }), /cannot edit source/);
367
- assert.throws(() => guardTool(f.project, { session_id: state.sessionId, tool_name: "Bash", tool_input: { command: "node helper.mjs" } }), /shell execution is blocked/);
368
- } finally { f.dispose(); }
369
- });
370
-
371
- test("supervisor retains unknown usage and refuses a renewed budget", async () => {
372
- const f = fixture();
373
- try {
374
- const binary = fakeNative(f, { missingUsage: true }), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
375
- assert.equal(await supervise({ project: f.project, binary: process.execPath, mode: "full", args: [binary], env: f.env,
376
- supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 2, timeoutMs: 20000 } }), 1);
377
- const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
378
- const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
379
- assert.equal(state.status, "blocked"); assert.equal(state.invocations.length, 1); assert.match(state.reason, /cost/);
380
- const jobId = fs.readdirSync(path.join(f.project, ".nccgs/jobs"))[0];
381
- const verified = waitVerification(f.project, jobId, "fixture", 10000);
382
- assert.equal(verified.status, "PASS", JSON.stringify(verified.evidence ? json(path.join(f.project, verified.evidence.path)) : verified));
383
- } finally { f.dispose(); }
384
- });
385
-
386
- test("cumulative native cost rejects double counting, missing and mismatched sessions", () => {
387
- assert.equal(cumulativeCost(0.4, { type: "result", session_id: "s", total_cost_usd: 0.6 }, "s"), 0.6);
388
- assert.throws(() => cumulativeCost(0.4, { type: "result", session_id: "s", total_cost_usd: 0.2 }, "s"), /decreasing/);
389
- assert.throws(() => cumulativeCost(0, { type: "result", session_id: "wrong", total_cost_usd: 0.2 }, "s"), /correlated/);
390
- });
391
-
392
- test("supervisor stops at the pinned continuation limit without resetting the attempt", async () => {
393
- const f = fixture();
394
- try {
395
- const executable = fakeNative(f, { omitReport: true }), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
396
- assert.equal(await supervise({ project: f.project, binary: process.execPath, mode: "full", args: [executable], env: f.env,
397
- supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 1, timeoutMs: 20000 } }), 1);
398
- const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
399
- const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
400
- assert.equal(state.invocations.length, 2); assert.match(state.reason, /Continuation limit/);
401
- assert.equal(state.cumulativeCostUsd, 0.6); assert.ok(state.checkpoint);
402
- } finally { f.dispose(); }
403
- });
404
-
405
- test("supervisor refuses a fresh native invocation after the session allowance is spent", async () => {
406
- const f = fixture();
407
- try {
408
- const executable = fakeNative(f), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
409
- assert.equal(await supervise({ project: f.project, binary: process.execPath, mode: "full", args: [executable], env: f.env,
410
- supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 0.4, maxResumes: 2, timeoutMs: 20000 } }), 1);
411
- const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
412
- const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
413
- assert.equal(state.invocations.length, 1); assert.equal(state.cumulativeCostUsd, 0.4);
414
- assert.match(state.reason, /budget exhausted/); assert.equal(state.costCoverage, "complete");
415
- } finally { f.dispose(); }
416
- });
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import crypto from "node:crypto";
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+ import test from "node:test";
9
+ import { atomicJson, json } from "../.claude/nccgs/tools/policy.mjs";
10
+ import { initialize } from "../.claude/nccgs/tools/gates.mjs";
11
+ import { acquireLock, delay, pidState, treeState } from "../.claude/nccgs/tools/process-lock.mjs";
12
+ import { startCommand } from "../.claude/nccgs/tools/command-process.mjs";
13
+ import { startVerification, waitVerification, jobStatus, readJob, jobDirectory } from "../.claude/nccgs/tools/verification-jobs.mjs";
14
+ import { safeVerifyCommand, guardTool } from "../.claude/nccgs/tools/tool-guard.mjs";
15
+ import { cumulativeCost, supervise } from "../scripts/supervise.mjs";
16
+ import { registerReviewBudget, reviewBudgetStatus } from "../.claude/nccgs/tools/review-budget.mjs";
17
+ import { nativeProvider } from "./fixtures/native-provider.mjs";
18
+
19
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
20
+ function fixture(timeoutMs = 5000, mode = "implement") {
21
+ const base = fs.realpathSync(os.tmpdir()), project = fs.mkdtempSync(path.join(base, "nccgs-phase1-"));
22
+ const write = (relative, value) => {
23
+ const file = path.join(project, relative); fs.mkdirSync(path.dirname(file), { recursive: true });
24
+ fs.writeFileSync(file, typeof value === "string" ? value : JSON.stringify(value)); return file;
25
+ };
26
+ fs.cpSync(path.join(root, ".claude/nccgs/tools"), path.join(project, ".claude/nccgs/tools"), { recursive: true });
27
+ for (const file of ["pipeline.json", "studio.json"]) write(`.claude/nccgs/${file}`, fs.readFileSync(path.join(root, ".claude/nccgs", file), "utf8"));
28
+ write("Assets/source.cs", "// frozen fixture source");
29
+ write(".nccgs/project.yaml", "schema_version: 2\nmodels:\n profile: claude-standalone\n");
30
+ write(".nccgs/execution.json", { schemaVersion: 1, name: "standalone", externalReview: { mode: "off" } });
31
+ write(".nccgs/inputs.json", { schemaVersion: 1, canonicalInputs: [], outputDirectories: [], checks: { test: { timeoutMs } } });
32
+ write(".nccgs/intake.json", { objective: "Process lifecycle fixture; no actual Claude/model/game execution", mode, requiredChecks: ["test"] });
33
+ initialize(project, "fixture", null, null, null, { intake: ".nccgs/intake.json" });
34
+ const env = { ...process.env };
35
+ for (const key of ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "NCCGS_TASK_ID", "NCCGS_SUPERVISED_SESSION", "NCCGS_PROJECT_ROOT"]) delete env[key];
36
+ const cli = (...args) => spawnSync(process.execPath, [path.join(project, ".claude/nccgs/tools/task.mjs"), ...args, "--project", project], { cwd: project, env, encoding: "utf8", timeout: 20000, windowsHide: true });
37
+ return { project, write, env, cli, dispose() {
38
+ assert.equal(path.dirname(fs.realpathSync(project)).toLowerCase(), base.toLowerCase());
39
+ assert.ok(path.basename(project).startsWith("nccgs-phase1-"));
40
+ fs.rmSync(project, { recursive: true, force: true });
41
+ } };
42
+ }
43
+
44
+ test("verify parser separates helper options from the framework root and rejects shell composition", () => {
45
+ const f = fixture();
46
+ try {
47
+ const prefix = `node "${f.project.replaceAll("\\", "/")}/.claude/nccgs/tools/task.mjs" verify --id fixture --name test --project "${f.project}"`;
48
+ assert.equal(safeVerifyCommand(`${prefix} -- node helper.mjs --project "${f.project}"`, f.project), true);
49
+ assert.equal(safeVerifyCommand(`${prefix} -- node helper.mjs --project=anything`, f.project), true);
50
+ assert.equal(safeVerifyCommand(`${prefix} -- node -e "console.log('a;b')"`, f.project), true);
51
+ assert.equal(safeVerifyCommand(`${prefix} --project other -- node x`, f.project), false);
52
+ assert.equal(safeVerifyCommand(`${prefix.replace(`--project "${f.project}"`, '--project "../foreign"')} -- node x`, f.project), false);
53
+ for (const suffix of ["; echo bad", " && echo bad", " > output", " | node x"]) assert.equal(safeVerifyCommand(`${prefix} -- node x${suffix}`, f.project), false);
54
+ assert.equal(safeVerifyCommand(`${prefix} -- node -e "$(touch bad)"`, f.project), false);
55
+ f.write(".nccgs/runtime/bindings/parser.json", { taskId: "fixture" });
56
+ assert.doesNotThrow(() => guardTool(f.project, { session_id: "parser", tool_name: "Bash", tool_input: { command: `${prefix} -- node helper.mjs --project "${f.project}"` } }));
57
+ } finally { f.dispose(); }
58
+ });
59
+
60
+ test("detached verification survives its caller and repeated request keys reuse one immutable receipt", () => {
61
+ const f = fixture();
62
+ try {
63
+ const code = "setTimeout(()=>{require('node:fs').appendFileSync('.nccgs/evidence/count.txt','run\\n');console.log('real subprocess output')},400)";
64
+ const args = ["verify", "--id", "fixture", "--name", "test", "--background", "--request-key", "once", "--", process.execPath, "-e", code];
65
+ // Add the framework project before the separator, as real callers must do.
66
+ const first = spawnSync(process.execPath, [path.join(f.project, ".claude/nccgs/tools/task.mjs"), ...args], { cwd: f.project, env: f.env, encoding: "utf8", windowsHide: true });
67
+ assert.equal(first.status, 0, first.stderr); const started = JSON.parse(first.stdout);
68
+ const result = waitVerification(f.project, started.jobId, "fixture", 10000);
69
+ assert.equal(result.status, "PASS");
70
+ const duplicate = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", code], requestKey: "once" });
71
+ assert.deepEqual(duplicate.evidence, result.evidence);
72
+ assert.equal(fs.readFileSync(path.join(f.project, ".nccgs/evidence/count.txt"), "utf8"), "run\n");
73
+ const receipt = json(path.join(f.project, result.evidence.path));
74
+ assert.equal(receipt.sourceUnchanged, true); assert.equal(receipt.exitCode, 0);
75
+ f.write("Assets/source.cs", "// changed");
76
+ assert.throws(() => startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", code], requestKey: "once" }), /changed source/);
77
+ assert.throws(() => jobStatus(f.project, started.jobId, "other"), /another task/);
78
+ } finally { f.dispose(); }
79
+ });
80
+
81
+ test("timeout and source mutation produce failed receipts, never reusable PASS", () => {
82
+ const f = fixture(200);
83
+ try {
84
+ const timed = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", "setTimeout(()=>{},10000)"], requestKey: "timeout" });
85
+ const failure = waitVerification(f.project, timed.jobId, "fixture", 15000);
86
+ assert.equal(failure.status, "FAIL"); assert.equal(json(path.join(f.project, failure.evidence.path)).timedOut, true);
87
+ } finally { f.dispose(); }
88
+ // A mutation needs time to execute; the timeout probe's 200ms budget could kill
89
+ // Node startup first under concurrent Windows tests and never touch the source.
90
+ const mutationFixture = fixture(5000);
91
+ try {
92
+ const changed = startVerification(mutationFixture.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", "require('node:fs').writeFileSync('Assets/source.cs','changed')"], requestKey: "mutation" });
93
+ const mutation = waitVerification(mutationFixture.project, changed.jobId, "fixture", 10000);
94
+ const receipt = json(path.join(mutationFixture.project, mutation.evidence.path));
95
+ assert.equal(mutation.status, "FAIL"); assert.equal(receipt.sourceUnchanged, false);
96
+ assert.equal(receipt.timedOut, false);
97
+ } finally { mutationFixture.dispose(); }
98
+ });
99
+
100
+ test("per-attempt timeout is bounded, pinned and forwarded only before the helper separator", () => {
101
+ const f = fixture();
102
+ try {
103
+ const beforeInputs = fs.readFileSync(path.join(f.project, ".nccgs/inputs.json"), "utf8");
104
+ const command = [process.execPath, "-e", "console.log('bounded timeout fixture')"];
105
+ const invoke = extra => spawnSync(process.execPath, [path.join(f.project, ".claude/nccgs/tools/task.mjs"),
106
+ "verify", "--id", "fixture", "--name", "long-check", "--request-key", "long-once", ...extra, "--", ...command],
107
+ { cwd: f.project, env: f.env, encoding: "utf8", windowsHide: true, timeout: 20000 });
108
+ const first = invoke(["--timeout-ms", "900000"]);
109
+ assert.equal(first.status, 0, first.stderr);
110
+ const result = JSON.parse(first.stdout);
111
+ assert.equal(result.status, "PASS");
112
+ assert.equal(json(path.join(f.project, result.evidence.path)).timeoutMs, 900000);
113
+ assert.equal(readJob(f.project, result.jobId).timeoutMs, 900000);
114
+ const duplicate = invoke(["--timeout-ms", "900000"]);
115
+ assert.equal(duplicate.status, 0, duplicate.stderr);
116
+ assert.deepEqual(JSON.parse(duplicate.stdout).evidence, result.evidence);
117
+ assert.notEqual(invoke(["--timeout-ms", "800000"]).status, 0);
118
+ assert.equal(fs.readFileSync(path.join(f.project, ".nccgs/inputs.json"), "utf8"), beforeInputs);
119
+ for (const timeoutMs of [0, 99, 1.5, 7200001, NaN, Infinity, null, "900000"]) {
120
+ assert.throws(() => startVerification(f.project, "fixture", { name: "long-check", executable: process.execPath,
121
+ arguments: command.slice(1), requestKey: "invalid", timeoutMs }), /timeoutMs/);
122
+ }
123
+ assert.throws(() => startVerification(f.project, "fixture", { name: "test", executable: process.execPath,
124
+ arguments: command.slice(1), requestKey: "configured-limit", timeoutMs: 5001 }), /configured check timeout/);
125
+ const helper = f.write(".nccgs/helper.mjs", "console.log(process.argv.slice(2).join(' '));");
126
+ const forwarded = spawnSync(process.execPath, [path.join(f.project, ".claude/nccgs/tools/task.mjs"),
127
+ "verify", "--id", "fixture", "--name", "test", "--request-key", "helper-timeout", "--", process.execPath, helper,
128
+ "--timeout-ms", "900000"], { cwd: f.project, env: f.env, encoding: "utf8", windowsHide: true, timeout: 20000 });
129
+ assert.equal(forwarded.status, 0, forwarded.stderr);
130
+ const forwardedReceipt = json(path.join(f.project, JSON.parse(forwarded.stdout).evidence.path));
131
+ assert.equal(forwardedReceipt.timeoutMs, 5000);
132
+ assert.match(fs.readFileSync(path.join(f.project, forwardedReceipt.log.path), "utf8"), /--timeout-ms 900000/);
133
+ } finally { f.dispose(); }
134
+ });
135
+
136
+ test("long verification remains owned and bounded by its pinned supervisor deadline", () => {
137
+ const f = fixture();
138
+ try {
139
+ const runId = crypto.randomUUID(), sessionId = crypto.randomUUID(), deadline = Date.now() + 2500;
140
+ f.write(`.nccgs/runs/task-fixture.lock`, { runId });
141
+ const run = { project: f.project, taskId: "fixture", sessionId, deadline };
142
+ f.write(`.nccgs/runs/${runId}/run.json`, run);
143
+ const args = [path.join(f.project, ".claude/nccgs/tools/task.mjs"), "verify", "--id", "fixture",
144
+ "--name", "long-check", "--request-key", "supervised-long", "--timeout-ms", "900000", "--",
145
+ process.execPath, "-e", "setTimeout(()=>{require('node:fs').writeFileSync('.nccgs/evidence/late.txt','bad')},60000)"];
146
+ const env = { ...f.env, NCCGS_SUPERVISED_SESSION: sessionId };
147
+ const result = spawnSync(process.execPath, args, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 20000 });
148
+ assert.equal(result.status, 1, result.stderr);
149
+ const stopped = JSON.parse(result.stdout), receipt = json(path.join(f.project, stopped.evidence.path));
150
+ assert.equal(receipt.status, "FAIL"); assert.equal(receipt.timedOut, true);
151
+ assert.equal(receipt.timeoutMs, 900000); assert.equal(receipt.supervisorDeadline, deadline);
152
+ const launch = json(path.join(jobDirectory(f.project, stopped.jobId), "launch.json"));
153
+ assert.equal(pidState(launch.pid), "dead");
154
+ assert.equal(fs.existsSync(path.join(f.project, ".nccgs/evidence/late.txt")), false);
155
+ // A settled request remains idempotent after expiry; a fresh attempt cannot run.
156
+ const repeat = spawnSync(process.execPath, args, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 10000 });
157
+ assert.deepEqual(JSON.parse(repeat.stdout).evidence, stopped.evidence);
158
+ const freshArgs = args.map(a => a === "supervised-long" ? "too-late" : a);
159
+ const expired = spawnSync(process.execPath, freshArgs, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 10000 });
160
+ assert.notEqual(expired.status, 0); assert.match(expired.stderr, /deadline reached/);
161
+ const foreign = spawnSync(process.execPath, freshArgs, { cwd: f.project, env: { ...env, NCCGS_SUPERVISED_SESSION: crypto.randomUUID() }, encoding: "utf8", windowsHide: true, timeout: 10000 });
162
+ assert.notEqual(foreign.status, 0); assert.match(foreign.stderr, /binding mismatch/);
163
+ } finally { f.dispose(); }
164
+ });
165
+
166
+ test("dead lock owners recover with evidence; live, unknown, legacy and spawning owners do not expire", () => {
167
+ const f = fixture();
168
+ try {
169
+ const file = path.join(f.project, ".nccgs/test.lock");
170
+ const module = new URL("../.claude/nccgs/tools/process-lock.mjs", import.meta.url).href;
171
+ const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import {acquireLock} from ${JSON.stringify(module)}; acquireLock(${JSON.stringify(file)});`], { encoding: "utf8", windowsHide: true });
172
+ assert.equal(held.status, 0, held.stderr);
173
+ const recovered = acquireLock(file);
174
+ assert.ok(fs.existsSync(`${file}.recoveries.jsonl`));
175
+ assert.throws(() => acquireLock(file), /live or unverified/); recovered.release();
176
+ for (const owner of [{ pid: 99999999 }, { schemaVersion: 1, host: "other-machine", pid: 99999999, token: "remote" },
177
+ { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "gap", phase: "spawning" }]) {
178
+ atomicJson(file, owner); assert.throws(() => acquireLock(file), /live or unverified/); fs.unlinkSync(file);
179
+ }
180
+ } finally { f.dispose(); }
181
+ });
182
+
183
+ test("a dead owner with a live command retains its engine lock", async () => {
184
+ const f = fixture(), child = spawn(process.execPath, ["-e", "setTimeout(()=>{},20000)"], { stdio: "ignore", windowsHide: true });
185
+ try {
186
+ await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
187
+ const file = path.join(f.project, ".nccgs/engine.lock");
188
+ atomicJson(file, { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "dead-parent", phase: "running", child: { pid: child.pid, group: false } });
189
+ assert.throws(() => acquireLock(file), /live or unverified/);
190
+ const closed = new Promise(resolve => child.once("close", resolve)); child.kill(); await closed;
191
+ // Legacy/unmanaged children cannot prove that descendants have exited.
192
+ if (process.platform === "win32") assert.throws(() => acquireLock(file), /live or unverified/);
193
+ } finally { if (pidState(child.pid) === "alive") child.kill(); f.dispose(); }
194
+ });
195
+
196
+ test("OS-owned process group retains orphan descendants and proves their exit after host termination", { skip: process.platform !== "win32" }, async () => {
197
+ const f = fixture(); let host, descendant;
198
+ try {
199
+ const id = crypto.randomUUID(), directory = path.join(f.project, ".nccgs");
200
+ const code = "const cp=require('node:child_process'),fs=require('node:fs');const p=cp.spawn(process.execPath,['-e','setTimeout(()=>{},20000)'],{stdio:'ignore',detached:true});fs.writeFileSync('.nccgs/descendant.json',JSON.stringify({pid:p.pid}));p.unref();";
201
+ const managed = startCommand({ executable: process.execPath, args: ["-e", code], cwd: f.project, stateDirectory: directory, id, stdio: "ignore" });
202
+ host = managed.child;
203
+ const closed = new Promise(resolve => host.once("close", resolve));
204
+ const deadline = Date.now() + 10000;
205
+ while (!fs.existsSync(path.join(directory, "descendant.json")) && Date.now() < deadline) await delay(50);
206
+ descendant = json(path.join(directory, "descendant.json")).pid;
207
+ assert.equal(pidState(descendant), "alive"); assert.equal(pidState(host.pid), "alive");
208
+ const file = path.join(directory, "engine.lock");
209
+ atomicJson(file, { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "orphan-owner", phase: "running", child: managed.identity() });
210
+ assert.throws(() => acquireLock(file), /live or unverified/);
211
+ host.kill("SIGKILL"); await closed;
212
+ assert.equal(treeState(managed.identity()), "dead"); assert.equal(pidState(descendant), "dead");
213
+ const recovered = acquireLock(file); recovered.release();
214
+ assert.ok(fs.existsSync(`${file}.recoveries.jsonl`));
215
+ } finally { if (host && pidState(host.pid) === "alive") host.kill("SIGKILL"); f.dispose(); }
216
+ });
217
+
218
+ test("managed wrappers avoid the resident Unity auth broker without changing the caller or persisting its environment", () => {
219
+ const f = fixture();
220
+ const key = "UNITY_NO_AUTH_BROKER", previous = process.env[key];
221
+ const sentinelKey = "NCCGS_TEST_PRIVATE_ENV", previousSentinel = process.env[sentinelKey];
222
+ const sentinel = crypto.randomUUID();
223
+ try {
224
+ // Even a wrapper, rather than a direct `unity` executable, must inherit it.
225
+ // Explicitly avoid modifying stored auth, peer-signature or account settings.
226
+ process.env[key] = "0"; process.env[sentinelKey] = sentinel;
227
+ const code = "const cp=require('node:child_process'); const c=cp.spawnSync(process.execPath,['-e',\"console.log(JSON.stringify({broker:process.env.UNITY_NO_AUTH_BROKER,peer:process.env.UNITY_PEER_AUTH_MODE??null,privateEnv:process.env.NCCGS_TEST_PRIVATE_ENV}))\"],{encoding:'utf8'});process.stdout.write(c.stdout);process.exitCode=c.status;";
228
+ const started = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", code], requestKey: "direct-keyring" });
229
+ const settled = waitVerification(f.project, started.jobId, "fixture", 15000);
230
+ assert.equal(settled.status, "PASS");
231
+ const receipt = json(path.join(f.project, settled.evidence.path));
232
+ const values = JSON.parse(fs.readFileSync(path.join(f.project, receipt.log.path), "utf8"));
233
+ assert.equal(values.broker, "1");
234
+ assert.equal(values.peer, process.env.UNITY_PEER_AUTH_MODE ?? null);
235
+ assert.equal(values.privateEnv, sentinel);
236
+ assert.equal(process.env[key], "0"); assert.equal(process.env[sentinelKey], sentinel);
237
+ // Environment values are inherited in memory, never serialized in the job
238
+ // specification or receipt (which may otherwise expose credentials).
239
+ assert.equal(JSON.stringify(receipt).includes(sentinel), false);
240
+ const spec = path.join(jobDirectory(f.project, started.jobId), `${started.jobId}-command.json`);
241
+ if (fs.existsSync(spec)) assert.equal(fs.readFileSync(spec, "utf8").includes(sentinel), false);
242
+ } finally {
243
+ if (previous === undefined) delete process.env[key]; else process.env[key] = previous;
244
+ if (previousSentinel === undefined) delete process.env[sentinelKey]; else process.env[sentinelKey] = previousSentinel;
245
+ f.dispose();
246
+ }
247
+ });
248
+
249
+ test("a completed wrapper still waits for its real descendant and retains the wrapper exit code", { skip: process.platform !== "win32" }, async () => {
250
+ const f = fixture();
251
+ try {
252
+ const descendant = "setTimeout(()=>require('node:fs').writeFileSync('.nccgs/evidence/descendant-completed.txt','done'),1800)";
253
+ const wrapper = `const cp=require('node:child_process'),fs=require('node:fs');const p=cp.spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{stdio:'ignore',detached:true});fs.writeFileSync('.nccgs/evidence/wrapper-completed.txt','done');p.unref();process.exitCode=7;`;
254
+ const started = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", wrapper], requestKey: "descendant-drain" });
255
+ const marker = path.join(f.project, '.nccgs/evidence/wrapper-completed.txt');
256
+ const deadline = Date.now() + 10000;
257
+ while (!fs.existsSync(marker) && Date.now() < deadline) await delay(20);
258
+ assert.equal(fs.existsSync(marker), true);
259
+ assert.equal(jobStatus(f.project, started.jobId, "fixture").status, "RUNNING");
260
+ const result = waitVerification(f.project, started.jobId, "fixture", 15000);
261
+ assert.equal(result.status, "FAIL");
262
+ assert.equal(json(path.join(f.project, result.evidence.path)).exitCode, 7);
263
+ assert.equal(fs.readFileSync(path.join(f.project, '.nccgs/evidence/descendant-completed.txt'), 'utf8'), 'done');
264
+ } finally { f.dispose(); }
265
+ });
266
+
267
+ test("orphan verification becomes a retained failed attempt without rerunning the command", () => {
268
+ const f = fixture();
269
+ try {
270
+ const jobId = crypto.randomUUID(), directory = jobDirectory(f.project, jobId);
271
+ fs.mkdirSync(directory, { recursive: true });
272
+ atomicJson(path.join(directory, "request.json"), { schemaVersion: 1, id: jobId, project: fs.realpathSync(f.project), taskId: "fixture", name: "test", queuedAt: new Date().toISOString(), snapshot: "unknown", executable: "unlaunched", arguments: [] });
273
+ atomicJson(path.join(directory, "owner.lock"), { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "orphan", phase: "owned", child: null });
274
+ const failed = jobStatus(f.project, jobId, "fixture");
275
+ assert.equal(failed.status, "FAIL");
276
+ assert.equal(json(path.join(f.project, failed.evidence.path)).exitCode, null);
277
+ assert.match(json(path.join(f.project, failed.evidence.path)).error, /without a terminal receipt/);
278
+ assert.deepEqual(jobStatus(f.project, jobId, "fixture").evidence, failed.evidence);
279
+ } finally { f.dispose(); }
280
+ });
281
+
282
+ test("Unity compiler shutdown verifies exact pipe ownership, qualified binaries and bounded acknowledgement", { skip: process.platform !== "win32" }, () => {
283
+ const f = fixture();
284
+ try {
285
+ const binary = path.join(f.project, '.nccgs/compiler-shutdown-fixture.exe');
286
+ const compiler = path.join(process.env.SystemRoot || 'C:/Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe');
287
+ const built = spawnSync(compiler, ['/nologo', '/target:exe', `/out:${binary}`,
288
+ path.join(root, '.claude/nccgs/tools/windows-compiler-server.cs'),
289
+ path.join(root, 'tests/fixtures/compiler-shutdown.cs')], { encoding: 'utf8', windowsHide: true, timeout: 30000 });
290
+ assert.equal(built.status, 0, built.stdout + built.stderr);
291
+ const result = spawnSync(binary, [path.join(f.project, '.nccgs/fake-install')], { encoding: 'utf8', windowsHide: true, timeout: 10000 });
292
+ assert.equal(result.status, 0, result.stdout + result.stderr);
293
+ assert.match(result.stdout, /compiler shutdown guards PASS/);
294
+ } finally { f.dispose(); }
295
+ });
296
+
297
+ // The fake native executable below is explicitly a fixture, never Claude evidence.
298
+ function fakeNative(f, { missingUsage = false, omitReport = false } = {}) {
299
+ return f.write(".nccgs/fake-native.mjs", `
300
+ import fs from 'node:fs';
301
+ import {run} from '../.claude/nccgs/tools/task.mjs';
302
+ const args=process.argv.slice(2), resumed=args.includes('--resume');
303
+ fs.appendFileSync('.nccgs/fixture-argv.jsonl', JSON.stringify(args)+'\\n');
304
+ const session=args[args.indexOf(resumed?'--resume':'--session-id')+1];
305
+ if(!resumed) run(['verify','--id','fixture','--name','test','--background','--request-key','native-once','--',process.execPath,'-e',"setTimeout(()=>console.log('fixture command done'),800)"]);
306
+ else if(${!omitReport}) run(['report','--id','fixture','--outcome','partial','--summary','Fixture native continuation read the managed receipt; no game acceptance']);
307
+ console.log(JSON.stringify({type:'result',session_id:session,total_cost_usd:${missingUsage ? "null" : "resumed?0.6:0.4"},result:'Synthetic native result; no actual model execution'}));
308
+ `);
309
+ }
310
+ test("supervisor clamps real launch arguments to work allowance and retains the final review reserve", async () => {
311
+ const f = fixture();
312
+ try {
313
+ f.write('.nccgs/evidence/budget-request.json', { schemaVersion: 1, authority: 'Synthetic test only', totalUsd: 1, finalReviewUsd: 0.4, totalDurationMs: 60000, finalReviewDurationMs: 10000 });
314
+ registerReviewBudget(f.project, 'fixture', '.nccgs/evidence/budget-request.json');
315
+ const binary = fakeNative(f), prompt = f.write('.nccgs/prompt.txt', 'Synthetic fixture only');
316
+ assert.equal(await supervise({ project: f.project, ...nativeProvider(f.project, binary, { env: f.env }),
317
+ supervision: { taskId: 'fixture', promptFile: prompt, budgetUsd: 1, maxResumes: 1, timeoutMs: 20000, budgetPhase: 'work' } }), 0);
318
+ const id = fs.readdirSync(path.join(f.project, '.nccgs/runs'), { withFileTypes: true }).find(e => e.isDirectory()).name;
319
+ const run = json(path.join(f.project, `.nccgs/runs/${id}/run.json`));
320
+ assert.equal(run.budgetUsd, 0.6); assert.equal(run.invocations[0].allowanceUsd, 0.6);
321
+ assert.ok(Math.abs(run.invocations[1].allowanceUsd - 0.2) < 1e-9); assert.equal(run.cumulativeCostUsd, 0.6);
322
+ assert.equal(reviewBudgetStatus(f.project, 'fixture').protectedReviewUsd, 0.4);
323
+ } finally { f.dispose(); }
324
+ });
325
+ test("final-review session blocks new engine jobs in both hook and direct lifecycle command", () => {
326
+ const f = fixture(), names = ['NCCGS_SUPERVISED_RUN','NCCGS_SUPERVISED_SESSION'], saved = names.map(n => process.env[n]);
327
+ try {
328
+ const id = crypto.randomUUID(), session = 'final-review-fixture';
329
+ f.write(`.nccgs/runs/${id}/run.json`, { sessionId: session, budgetPhase: 'final-review' });
330
+ f.write(`.nccgs/runtime/bindings/${session}.json`, { taskId: 'fixture' });
331
+ process.env.NCCGS_SUPERVISED_RUN = id; process.env.NCCGS_SUPERVISED_SESSION = session;
332
+ assert.throws(() => startVerification(f.project, 'fixture', { name: 'test', executable: process.execPath, arguments: ['-e','console.log(1)'] }), /Final-review reserve/);
333
+ assert.throws(() => guardTool(f.project, { session_id: session, tool_name: 'Bash', tool_input: { command: 'node .claude/nccgs/tools/task.mjs verify --id fixture --name test -- node helper.mjs' } }), /Final-review reserve/);
334
+ assert.throws(() => guardTool(f.project, { session_id: session, tool_name: 'Bash', tool_input: { command: 'node .claude/nccgs/tools/task.mjs budget-plan --id fixture --file .nccgs/evidence/budget.json' } }), /External decisions/);
335
+ } finally { names.forEach((n,i) => { if(saved[i]===undefined) delete process.env[n]; else process.env[n]=saved[i]; }); f.dispose(); }
336
+ });
337
+ test("supervisor waits outside the model, resumes the same session once, preserves cumulative budget and receipts", async () => {
338
+ const f = fixture();
339
+ try {
340
+ const binary = fakeNative(f), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
341
+ const plan = { project: f.project, ...nativeProvider(f.project, binary, { env: f.env }),
342
+ supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 1, timeoutMs: 20000 } };
343
+ assert.equal(await supervise(plan), 0);
344
+ const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
345
+ const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
346
+ assert.equal(state.status, "settled"); assert.equal(state.invocations.length, 2);
347
+ assert.equal(state.cumulativeCostUsd, 0.6); assert.equal(state.invocations[1].allowanceUsd, 0.6);
348
+ assert.equal(state.invocations[0].terminal.session_id, state.invocations[1].terminal.session_id);
349
+ assert.equal(fs.readdirSync(path.join(f.project, ".nccgs/jobs")).length, 1);
350
+ // R08: the actual spawned provider argv keeps the owned prefix, strict flag before
351
+ // all other options, and one config ended by the supervisor's own lifecycle flags.
352
+ const argv = fs.readFileSync(path.join(f.project, ".nccgs/fixture-argv.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line));
353
+ assert.equal(argv.length, 2);
354
+ for (const [index, args] of argv.entries()) {
355
+ assert.deepEqual(args.slice(0, 11), ["--permission-mode", "bypassPermissions", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
356
+ "-p", "--output-format", "stream-json", "--verbose", "--max-budget-usd", index ? "0.6" : "1"]);
357
+ assert.deepEqual(args.slice(11), [index ? "--resume" : "--session-id", state.sessionId]);
358
+ }
359
+ assert.equal(await supervise({ ...plan, supervision: { recoverRun: runId } }), 0);
360
+ assert.equal(json(path.join(f.project, `.nccgs/runs/${runId}/run.json`)).invocations.length, 2);
361
+ } finally { f.dispose(); }
362
+ });
363
+
364
+ test("supervisor can settle a plan without granting source writes or shell checks", async () => {
365
+ const f = fixture(5000, "plan");
366
+ try {
367
+ const binary = f.write(".nccgs/plan-native.mjs", `import {run} from '../.claude/nccgs/tools/task.mjs';
368
+ const args=process.argv.slice(2);const session=args[args.indexOf('--session-id')+1];
369
+ run(['report','--id','fixture','--outcome','partial','--summary','Synthetic plan report; document review pending']);
370
+ console.log(JSON.stringify({type:'result',session_id:session,total_cost_usd:0.1,result:'Synthetic, not real Claude'}));`);
371
+ const prompt = f.write(".nccgs/prompt.txt", "Synthetic plan supervision");
372
+ assert.equal(await supervise({ project: f.project, ...nativeProvider(f.project, binary, { env: f.env }),
373
+ supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 0, timeoutMs: 20000 } }), 0);
374
+ const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
375
+ const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
376
+ assert.equal(state.status, "settled"); assert.equal(state.invocations.length, 1);
377
+ assert.throws(() => guardTool(f.project, { session_id: state.sessionId, tool_name: "Write", tool_input: { file_path: "Assets/source.cs" } }), /cannot edit source/);
378
+ assert.throws(() => guardTool(f.project, { session_id: state.sessionId, tool_name: "Bash", tool_input: { command: "node helper.mjs" } }), /shell execution is blocked/);
379
+ } finally { f.dispose(); }
380
+ });
381
+
382
+ test("supervisor retains unknown usage and refuses a renewed budget", async () => {
383
+ const f = fixture();
384
+ try {
385
+ const binary = fakeNative(f, { missingUsage: true }), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
386
+ assert.equal(await supervise({ project: f.project, ...nativeProvider(f.project, binary, { env: f.env }),
387
+ supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 2, timeoutMs: 20000 } }), 1);
388
+ const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
389
+ const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
390
+ assert.equal(state.status, "blocked"); assert.equal(state.invocations.length, 1); assert.match(state.reason, /cost/);
391
+ const jobId = fs.readdirSync(path.join(f.project, ".nccgs/jobs"))[0];
392
+ const verified = waitVerification(f.project, jobId, "fixture", 10000);
393
+ assert.equal(verified.status, "PASS", JSON.stringify(verified.evidence ? json(path.join(f.project, verified.evidence.path)) : verified));
394
+ } finally { f.dispose(); }
395
+ });
396
+
397
+ test("cumulative native cost rejects double counting, missing and mismatched sessions", () => {
398
+ assert.equal(cumulativeCost(0.4, { type: "result", session_id: "s", total_cost_usd: 0.6 }, "s"), 0.6);
399
+ assert.throws(() => cumulativeCost(0.4, { type: "result", session_id: "s", total_cost_usd: 0.2 }, "s"), /decreasing/);
400
+ assert.throws(() => cumulativeCost(0, { type: "result", session_id: "wrong", total_cost_usd: 0.2 }, "s"), /correlated/);
401
+ });
402
+
403
+ test("supervisor stops at the pinned continuation limit without resetting the attempt", async () => {
404
+ const f = fixture();
405
+ try {
406
+ const executable = fakeNative(f, { omitReport: true }), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
407
+ assert.equal(await supervise({ project: f.project, ...nativeProvider(f.project, executable, { env: f.env }),
408
+ supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 1, timeoutMs: 20000 } }), 1);
409
+ const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
410
+ const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
411
+ assert.equal(state.invocations.length, 2); assert.match(state.reason, /Continuation limit/);
412
+ assert.equal(state.cumulativeCostUsd, 0.6); assert.ok(state.checkpoint);
413
+ } finally { f.dispose(); }
414
+ });
415
+
416
+ test("supervisor refuses a fresh native invocation after the session allowance is spent", async () => {
417
+ const f = fixture();
418
+ try {
419
+ const executable = fakeNative(f), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
420
+ assert.equal(await supervise({ project: f.project, ...nativeProvider(f.project, executable, { env: f.env }),
421
+ supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 0.4, maxResumes: 2, timeoutMs: 20000 } }), 1);
422
+ const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
423
+ const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
424
+ assert.equal(state.invocations.length, 1); assert.equal(state.cumulativeCostUsd, 0.4);
425
+ assert.match(state.reason, /budget exhausted/); assert.equal(state.costCoverage, "complete");
426
+ } finally { f.dispose(); }
427
+ });