nccgs 1.3.0 → 2.0.0-rc.2

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 (180) hide show
  1. package/.claude/agents/nccgs-adversarial-reviewer.md +17 -6
  2. package/.claude/agents/nccgs-engine-programmer.md +2 -2
  3. package/.claude/agents/nccgs-fast-implementer.md +30 -0
  4. package/.claude/agents/nccgs-game-designer.md +26 -0
  5. package/.claude/agents/nccgs-gameplay-programmer.md +3 -4
  6. package/.claude/agents/nccgs-qa-engineer.md +2 -2
  7. package/.claude/agents/nccgs-task-scout.md +24 -0
  8. package/.claude/agents/nccgs-ui-programmer.md +2 -2
  9. package/.claude/agents/nccgs-unity-build-specialist.md +2 -2
  10. package/.claude/agents/nccgs-unity-implementer.md +7 -6
  11. package/.claude/agents/nccgs-unity-systems-specialist.md +2 -2
  12. package/.claude/agents/nccgs-unity-ui-specialist.md +2 -2
  13. package/.claude/agents/nccgs-verification-engineer.md +2 -2
  14. package/.claude/nccgs/MEMORY.md +59 -0
  15. package/.claude/nccgs/THIRD_PARTY_NOTICES.md +16 -0
  16. package/.claude/nccgs/VERSION +1 -1
  17. package/.claude/nccgs/agent-library/nccgs-adversarial-reviewer.md +16 -5
  18. package/.claude/nccgs/agent-library/nccgs-fast-implementer.md +28 -0
  19. package/.claude/nccgs/agent-library/nccgs-game-designer.md +1 -2
  20. package/.claude/nccgs/agent-library/nccgs-gameplay-programmer.md +1 -2
  21. package/.claude/nccgs/agent-library/nccgs-task-scout.md +22 -0
  22. package/.claude/nccgs/agent-library/nccgs-unity-implementer.md +5 -4
  23. package/.claude/nccgs/constitution.md +44 -12
  24. package/.claude/nccgs/hooks/agent-audit.mjs +2 -2
  25. package/.claude/nccgs/hooks/closure-guard.mjs +31 -8
  26. package/.claude/nccgs/hooks/common.mjs +2 -1
  27. package/.claude/nccgs/hooks/runtime-observation.mjs +2 -2
  28. package/.claude/nccgs/hooks/session-start.mjs +11 -4
  29. package/.claude/nccgs/packages/ponytail/LICENSE +21 -0
  30. package/.claude/nccgs/packages/ponytail/NOTICE.md +32 -0
  31. package/.claude/nccgs/packages/ponytail/README.md +46 -0
  32. package/.claude/nccgs/packages/ponytail/complexity-review.md +47 -0
  33. package/.claude/nccgs/packages/ponytail/engineering.md +68 -0
  34. package/.claude/nccgs/packages/ponytail/manifest.json +24 -0
  35. package/.claude/nccgs/pipeline.json +92 -8
  36. package/.claude/nccgs/protocols/agent-contract.md +50 -1
  37. package/.claude/nccgs/protocols/astra-claude.md +46 -12
  38. package/.claude/nccgs/protocols/bounded-repair.md +118 -0
  39. package/.claude/nccgs/protocols/context-packets.md +5 -0
  40. package/.claude/nccgs/protocols/delivery-acceptance.md +105 -0
  41. package/.claude/nccgs/protocols/design-lifecycle.md +139 -0
  42. package/.claude/nccgs/protocols/evidence.md +11 -1
  43. package/.claude/nccgs/protocols/model-routing.md +44 -42
  44. package/.claude/nccgs/protocols/orchestration.md +46 -5
  45. package/.claude/nccgs/protocols/review-handoff.md +65 -0
  46. package/.claude/nccgs/protocols/standalone-gates.md +273 -0
  47. package/.claude/nccgs/protocols/standalone-pilot.md +144 -0
  48. package/.claude/nccgs/protocols/standalone.md +187 -0
  49. package/.claude/nccgs/protocols/task-autonomy.md +55 -0
  50. package/.claude/nccgs/protocols/task-gates.md +73 -7
  51. package/.claude/nccgs/routing.generated.md +25 -7
  52. package/.claude/nccgs/settings.fragment.json +153 -141
  53. package/.claude/nccgs/studio.json +135 -101
  54. package/.claude/nccgs/tools/autonomy.mjs +27 -0
  55. package/.claude/nccgs/tools/command-process.mjs +44 -0
  56. package/.claude/nccgs/tools/compile-policy.mjs +34 -4
  57. package/.claude/nccgs/tools/configure-models.mjs +13 -5
  58. package/.claude/nccgs/tools/criteria.mjs +121 -0
  59. package/.claude/nccgs/tools/delivery-budget.mjs +52 -0
  60. package/.claude/nccgs/tools/design-readiness.mjs +158 -0
  61. package/.claude/nccgs/tools/evidence.mjs +153 -0
  62. package/.claude/nccgs/tools/gates.mjs +151 -24
  63. package/.claude/nccgs/tools/inputs.mjs +73 -0
  64. package/.claude/nccgs/tools/lifecycle.mjs +521 -0
  65. package/.claude/nccgs/tools/native-completion.mjs +21 -0
  66. package/.claude/nccgs/tools/orchestration.mjs +430 -0
  67. package/.claude/nccgs/tools/paths.mjs +60 -0
  68. package/.claude/nccgs/tools/policy.mjs +58 -13
  69. package/.claude/nccgs/tools/process-lock.mjs +74 -0
  70. package/.claude/nccgs/tools/repair-packet.mjs +87 -0
  71. package/.claude/nccgs/tools/review-handoff.mjs +32 -0
  72. package/.claude/nccgs/tools/runtime.mjs +33 -7
  73. package/.claude/nccgs/tools/task.mjs +62 -31
  74. package/.claude/nccgs/tools/tool-guard.mjs +117 -0
  75. package/.claude/nccgs/tools/verification-jobs.mjs +161 -0
  76. package/.claude/nccgs/tools/verification-worker.mjs +71 -0
  77. package/.claude/nccgs/tools/windows-job.cs +96 -0
  78. package/.claude/nccgs/workflow-catalog.json +286 -26
  79. package/.claude/skills/accessibility-review/SKILL.md +12 -5
  80. package/.claude/skills/architecture-decision/SKILL.md +12 -5
  81. package/.claude/skills/asset-audit/SKILL.md +12 -5
  82. package/.claude/skills/audit/SKILL.md +12 -5
  83. package/.claude/skills/balance-review/SKILL.md +12 -5
  84. package/.claude/skills/bug-triage/SKILL.md +12 -5
  85. package/.claude/skills/bug-triage/references/astra.md +12 -5
  86. package/.claude/skills/closure/SKILL.md +12 -5
  87. package/.claude/skills/compatibility-review/SKILL.md +12 -5
  88. package/.claude/skills/context-pack/SKILL.md +12 -5
  89. package/.claude/skills/dependency-review/SKILL.md +12 -5
  90. package/.claude/skills/design/SKILL.md +14 -5
  91. package/.claude/skills/design-review/SKILL.md +14 -5
  92. package/.claude/skills/evidence-review/SKILL.md +12 -5
  93. package/.claude/skills/hotfix/SKILL.md +12 -5
  94. package/.claude/skills/hotfix/references/astra.md +12 -5
  95. package/.claude/skills/incident-recovery/SKILL.md +12 -5
  96. package/.claude/skills/incident-recovery/references/astra.md +12 -5
  97. package/.claude/skills/localize-game/SKILL.md +12 -5
  98. package/.claude/skills/localize-game/references/astra.md +12 -5
  99. package/.claude/skills/migrate-project/SKILL.md +12 -5
  100. package/.claude/skills/migrate-project/references/astra.md +12 -5
  101. package/.claude/skills/migrate-project/references/procedure.md +2 -1
  102. package/.claude/skills/milestone-review/SKILL.md +12 -5
  103. package/.claude/skills/nccgs-code-review/SKILL.md +12 -5
  104. package/.claude/skills/nccgs-code-review/references/astra.md +8 -2
  105. package/.claude/skills/performance-audit/SKILL.md +12 -5
  106. package/.claude/skills/plan-feature/SKILL.md +12 -5
  107. package/.claude/skills/playtest/SKILL.md +12 -5
  108. package/.claude/skills/playtest/references/astra.md +16 -5
  109. package/.claude/skills/project-stage/SKILL.md +12 -5
  110. package/.claude/skills/prototype-feature/SKILL.md +12 -5
  111. package/.claude/skills/prototype-feature/references/astra.md +16 -5
  112. package/.claude/skills/qa-plan/SKILL.md +12 -5
  113. package/.claude/skills/release/SKILL.md +12 -5
  114. package/.claude/skills/release/references/astra.md +12 -5
  115. package/.claude/skills/release-readiness/SKILL.md +12 -5
  116. package/.claude/skills/retrospective/SKILL.md +12 -5
  117. package/.claude/skills/review/SKILL.md +12 -5
  118. package/.claude/skills/review/references/astra.md +8 -2
  119. package/.claude/skills/security-audit/SKILL.md +12 -5
  120. package/.claude/skills/sprint-plan/SKILL.md +12 -5
  121. package/.claude/skills/status/SKILL.md +12 -5
  122. package/.claude/skills/status/references/astra.md +6 -0
  123. package/.claude/skills/story-readiness/SKILL.md +14 -5
  124. package/.claude/skills/test/SKILL.md +12 -5
  125. package/.claude/skills/test/references/astra.md +12 -5
  126. package/.claude/skills/ui-review/SKILL.md +12 -5
  127. package/.claude/skills/work/SKILL.md +12 -5
  128. package/.claude/skills/work/references/astra.md +17 -5
  129. package/CHANGELOG.md +42 -0
  130. package/CLAUDE.md +2 -0
  131. package/MEMORY.md +55 -0
  132. package/README.md +132 -180
  133. package/THIRD_PARTY_NOTICES.md +15 -0
  134. package/UPGRADING.md +91 -69
  135. package/VERSION +1 -1
  136. package/docs/ARCHITECTURE.md +19 -5
  137. package/docs/BOUNDED-REPAIR.md +11 -0
  138. package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +46 -34
  139. package/docs/NCCGS-1.3.md +10 -122
  140. package/docs/NCCGS-1.4.1.md +149 -0
  141. package/docs/NCCGS-1.4.md +184 -0
  142. package/docs/NCCGS-1.5-IMPLEMENTATION-PLAN.md +378 -0
  143. package/docs/NCCGS-1.5-PILOT.md +130 -0
  144. package/docs/NCCGS-1.5.md +237 -0
  145. package/docs/PHASE-1-OPERATIONS.md +137 -0
  146. package/docs/PHASE-2-OPERATIONS.md +183 -0
  147. package/docs/PROJECT-POLICY.md +21 -9
  148. package/docs/RELEASE-STATUS.md +42 -0
  149. package/docs/WORKFLOWS.md +42 -24
  150. package/package.json +7 -3
  151. package/scaffold/.nccgs/autonomy.json +4 -0
  152. package/scaffold/.nccgs/execution.json +10 -0
  153. package/scaffold/.nccgs/inputs.json +6 -0
  154. package/scaffold/.nccgs/project.yaml +4 -2
  155. package/scaffold/.nccgs/templates/agent-handoff.md +2 -0
  156. package/scaffold/.nccgs/templates/design-contract.json +34 -0
  157. package/scaffold/.nccgs/templates/feature-contract.md +21 -5
  158. package/scaffold/.nccgs/templates/implementation-report.md +4 -1
  159. package/scaffold/.nccgs/templates/repair-packet.json +14 -0
  160. package/scripts/benchmark-snapshot.mjs +205 -0
  161. package/scripts/benchmark-v15.mjs +173 -0
  162. package/scripts/cli.mjs +129 -2
  163. package/scripts/doctor.mjs +128 -22
  164. package/scripts/install.mjs +96 -34
  165. package/scripts/run.mjs +72 -0
  166. package/scripts/supervise.mjs +124 -0
  167. package/scripts/validate.mjs +45 -4
  168. package/tests/autonomy.test.mjs +113 -0
  169. package/tests/benchmark-v15.test.mjs +65 -0
  170. package/tests/design-readiness.test.mjs +200 -0
  171. package/tests/evidence.test.mjs +186 -0
  172. package/tests/framework.test.mjs +333 -16
  173. package/tests/gates.test.mjs +313 -4
  174. package/tests/lifecycle-v15.test.mjs +300 -0
  175. package/tests/orchestration-v15.test.mjs +545 -0
  176. package/tests/phase1.test.mjs +324 -0
  177. package/tests/phase2.test.mjs +250 -0
  178. package/tests/policy-v15.test.mjs +220 -0
  179. package/tests/ponytail-package.test.mjs +75 -0
  180. package/tests/runtime-v15.test.mjs +624 -0
@@ -0,0 +1,324 @@
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
+
17
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
18
+ function fixture(timeoutMs = 5000, mode = "implement") {
19
+ const base = fs.realpathSync(os.tmpdir()), project = fs.mkdtempSync(path.join(base, "nccgs-phase1-"));
20
+ const write = (relative, value) => {
21
+ const file = path.join(project, relative); fs.mkdirSync(path.dirname(file), { recursive: true });
22
+ fs.writeFileSync(file, typeof value === "string" ? value : JSON.stringify(value)); return file;
23
+ };
24
+ fs.cpSync(path.join(root, ".claude/nccgs/tools"), path.join(project, ".claude/nccgs/tools"), { recursive: true });
25
+ for (const file of ["pipeline.json", "studio.json"]) write(`.claude/nccgs/${file}`, fs.readFileSync(path.join(root, ".claude/nccgs", file), "utf8"));
26
+ write("Assets/source.cs", "// frozen fixture source");
27
+ write(".nccgs/project.yaml", "schema_version: 2\nmodels:\n profile: claude-standalone\n");
28
+ write(".nccgs/execution.json", { schemaVersion: 1, name: "standalone", externalReview: { mode: "off" } });
29
+ write(".nccgs/inputs.json", { schemaVersion: 1, canonicalInputs: [], outputDirectories: [], checks: { test: { timeoutMs } } });
30
+ write(".nccgs/intake.json", { objective: "Process lifecycle fixture; no actual Claude/model/game execution", mode, requiredChecks: ["test"] });
31
+ initialize(project, "fixture", null, null, null, { intake: ".nccgs/intake.json" });
32
+ const env = { ...process.env };
33
+ for (const key of ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "NCCGS_TASK_ID", "NCCGS_SUPERVISED_SESSION", "NCCGS_PROJECT_ROOT"]) delete env[key];
34
+ 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 });
35
+ return { project, write, env, cli, dispose() {
36
+ assert.equal(path.dirname(fs.realpathSync(project)).toLowerCase(), base.toLowerCase());
37
+ assert.ok(path.basename(project).startsWith("nccgs-phase1-"));
38
+ fs.rmSync(project, { recursive: true, force: true });
39
+ } };
40
+ }
41
+
42
+ test("verify parser separates helper options from the framework root and rejects shell composition", () => {
43
+ const f = fixture();
44
+ try {
45
+ const prefix = `node "${f.project.replaceAll("\\", "/")}/.claude/nccgs/tools/task.mjs" verify --id fixture --name test --project "${f.project}"`;
46
+ assert.equal(safeVerifyCommand(`${prefix} -- node helper.mjs --project "${f.project}"`, f.project), true);
47
+ assert.equal(safeVerifyCommand(`${prefix} -- node helper.mjs --project=anything`, f.project), true);
48
+ assert.equal(safeVerifyCommand(`${prefix} -- node -e "console.log('a;b')"`, f.project), true);
49
+ assert.equal(safeVerifyCommand(`${prefix} --project other -- node x`, f.project), false);
50
+ assert.equal(safeVerifyCommand(`${prefix.replace(`--project "${f.project}"`, '--project "../foreign"')} -- node x`, f.project), false);
51
+ for (const suffix of ["; echo bad", " && echo bad", " > output", " | node x"]) assert.equal(safeVerifyCommand(`${prefix} -- node x${suffix}`, f.project), false);
52
+ assert.equal(safeVerifyCommand(`${prefix} -- node -e "$(touch bad)"`, f.project), false);
53
+ f.write(".nccgs/runtime/bindings/parser.json", { taskId: "fixture" });
54
+ assert.doesNotThrow(() => guardTool(f.project, { session_id: "parser", tool_name: "Bash", tool_input: { command: `${prefix} -- node helper.mjs --project "${f.project}"` } }));
55
+ } finally { f.dispose(); }
56
+ });
57
+
58
+ test("detached verification survives its caller and repeated request keys reuse one immutable receipt", () => {
59
+ const f = fixture();
60
+ try {
61
+ const code = "setTimeout(()=>{require('node:fs').appendFileSync('.nccgs/evidence/count.txt','run\\n');console.log('real subprocess output')},400)";
62
+ const args = ["verify", "--id", "fixture", "--name", "test", "--background", "--request-key", "once", "--", process.execPath, "-e", code];
63
+ // Add the framework project before the separator, as real callers must do.
64
+ 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 });
65
+ assert.equal(first.status, 0, first.stderr); const started = JSON.parse(first.stdout);
66
+ const result = waitVerification(f.project, started.jobId, "fixture", 10000);
67
+ assert.equal(result.status, "PASS");
68
+ const duplicate = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", code], requestKey: "once" });
69
+ assert.deepEqual(duplicate.evidence, result.evidence);
70
+ assert.equal(fs.readFileSync(path.join(f.project, ".nccgs/evidence/count.txt"), "utf8"), "run\n");
71
+ const receipt = json(path.join(f.project, result.evidence.path));
72
+ assert.equal(receipt.sourceUnchanged, true); assert.equal(receipt.exitCode, 0);
73
+ f.write("Assets/source.cs", "// changed");
74
+ assert.throws(() => startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", code], requestKey: "once" }), /changed source/);
75
+ assert.throws(() => jobStatus(f.project, started.jobId, "other"), /another task/);
76
+ } finally { f.dispose(); }
77
+ });
78
+
79
+ test("timeout and source mutation produce failed receipts, never reusable PASS", () => {
80
+ const f = fixture(200);
81
+ try {
82
+ const timed = startVerification(f.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", "setTimeout(()=>{},10000)"], requestKey: "timeout" });
83
+ const failure = waitVerification(f.project, timed.jobId, "fixture", 15000);
84
+ assert.equal(failure.status, "FAIL"); assert.equal(json(path.join(f.project, failure.evidence.path)).timedOut, true);
85
+ } finally { f.dispose(); }
86
+ // A mutation needs time to execute; the timeout probe's 200ms budget could kill
87
+ // Node startup first under concurrent Windows tests and never touch the source.
88
+ const mutationFixture = fixture(5000);
89
+ try {
90
+ const changed = startVerification(mutationFixture.project, "fixture", { name: "test", executable: process.execPath, arguments: ["-e", "require('node:fs').writeFileSync('Assets/source.cs','changed')"], requestKey: "mutation" });
91
+ const mutation = waitVerification(mutationFixture.project, changed.jobId, "fixture", 10000);
92
+ const receipt = json(path.join(mutationFixture.project, mutation.evidence.path));
93
+ assert.equal(mutation.status, "FAIL"); assert.equal(receipt.sourceUnchanged, false);
94
+ assert.equal(receipt.timedOut, false);
95
+ } finally { mutationFixture.dispose(); }
96
+ });
97
+
98
+ test("per-attempt timeout is bounded, pinned and forwarded only before the helper separator", () => {
99
+ const f = fixture();
100
+ try {
101
+ const beforeInputs = fs.readFileSync(path.join(f.project, ".nccgs/inputs.json"), "utf8");
102
+ const command = [process.execPath, "-e", "console.log('bounded timeout fixture')"];
103
+ const invoke = extra => spawnSync(process.execPath, [path.join(f.project, ".claude/nccgs/tools/task.mjs"),
104
+ "verify", "--id", "fixture", "--name", "long-check", "--request-key", "long-once", ...extra, "--", ...command],
105
+ { cwd: f.project, env: f.env, encoding: "utf8", windowsHide: true, timeout: 20000 });
106
+ const first = invoke(["--timeout-ms", "900000"]);
107
+ assert.equal(first.status, 0, first.stderr);
108
+ const result = JSON.parse(first.stdout);
109
+ assert.equal(result.status, "PASS");
110
+ assert.equal(json(path.join(f.project, result.evidence.path)).timeoutMs, 900000);
111
+ assert.equal(readJob(f.project, result.jobId).timeoutMs, 900000);
112
+ const duplicate = invoke(["--timeout-ms", "900000"]);
113
+ assert.equal(duplicate.status, 0, duplicate.stderr);
114
+ assert.deepEqual(JSON.parse(duplicate.stdout).evidence, result.evidence);
115
+ assert.notEqual(invoke(["--timeout-ms", "800000"]).status, 0);
116
+ assert.equal(fs.readFileSync(path.join(f.project, ".nccgs/inputs.json"), "utf8"), beforeInputs);
117
+ for (const timeoutMs of [0, 99, 1.5, 7200001, NaN, Infinity, null, "900000"]) {
118
+ assert.throws(() => startVerification(f.project, "fixture", { name: "long-check", executable: process.execPath,
119
+ arguments: command.slice(1), requestKey: "invalid", timeoutMs }), /timeoutMs/);
120
+ }
121
+ assert.throws(() => startVerification(f.project, "fixture", { name: "test", executable: process.execPath,
122
+ arguments: command.slice(1), requestKey: "configured-limit", timeoutMs: 5001 }), /configured check timeout/);
123
+ const helper = f.write(".nccgs/helper.mjs", "console.log(process.argv.slice(2).join(' '));");
124
+ const forwarded = spawnSync(process.execPath, [path.join(f.project, ".claude/nccgs/tools/task.mjs"),
125
+ "verify", "--id", "fixture", "--name", "test", "--request-key", "helper-timeout", "--", process.execPath, helper,
126
+ "--timeout-ms", "900000"], { cwd: f.project, env: f.env, encoding: "utf8", windowsHide: true, timeout: 20000 });
127
+ assert.equal(forwarded.status, 0, forwarded.stderr);
128
+ const forwardedReceipt = json(path.join(f.project, JSON.parse(forwarded.stdout).evidence.path));
129
+ assert.equal(forwardedReceipt.timeoutMs, 5000);
130
+ assert.match(fs.readFileSync(path.join(f.project, forwardedReceipt.log.path), "utf8"), /--timeout-ms 900000/);
131
+ } finally { f.dispose(); }
132
+ });
133
+
134
+ test("long verification remains owned and bounded by its pinned supervisor deadline", () => {
135
+ const f = fixture();
136
+ try {
137
+ const runId = crypto.randomUUID(), sessionId = crypto.randomUUID(), deadline = Date.now() + 2500;
138
+ f.write(`.nccgs/runs/task-fixture.lock`, { runId });
139
+ const run = { project: f.project, taskId: "fixture", sessionId, deadline };
140
+ f.write(`.nccgs/runs/${runId}/run.json`, run);
141
+ const args = [path.join(f.project, ".claude/nccgs/tools/task.mjs"), "verify", "--id", "fixture",
142
+ "--name", "long-check", "--request-key", "supervised-long", "--timeout-ms", "900000", "--",
143
+ process.execPath, "-e", "setTimeout(()=>{require('node:fs').writeFileSync('.nccgs/evidence/late.txt','bad')},60000)"];
144
+ const env = { ...f.env, NCCGS_SUPERVISED_SESSION: sessionId };
145
+ const result = spawnSync(process.execPath, args, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 20000 });
146
+ assert.equal(result.status, 1, result.stderr);
147
+ const stopped = JSON.parse(result.stdout), receipt = json(path.join(f.project, stopped.evidence.path));
148
+ assert.equal(receipt.status, "FAIL"); assert.equal(receipt.timedOut, true);
149
+ assert.equal(receipt.timeoutMs, 900000); assert.equal(receipt.supervisorDeadline, deadline);
150
+ const launch = json(path.join(jobDirectory(f.project, stopped.jobId), "launch.json"));
151
+ assert.equal(pidState(launch.pid), "dead");
152
+ assert.equal(fs.existsSync(path.join(f.project, ".nccgs/evidence/late.txt")), false);
153
+ // A settled request remains idempotent after expiry; a fresh attempt cannot run.
154
+ const repeat = spawnSync(process.execPath, args, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 10000 });
155
+ assert.deepEqual(JSON.parse(repeat.stdout).evidence, stopped.evidence);
156
+ const freshArgs = args.map(a => a === "supervised-long" ? "too-late" : a);
157
+ const expired = spawnSync(process.execPath, freshArgs, { cwd: f.project, env, encoding: "utf8", windowsHide: true, timeout: 10000 });
158
+ assert.notEqual(expired.status, 0); assert.match(expired.stderr, /deadline reached/);
159
+ const foreign = spawnSync(process.execPath, freshArgs, { cwd: f.project, env: { ...env, NCCGS_SUPERVISED_SESSION: crypto.randomUUID() }, encoding: "utf8", windowsHide: true, timeout: 10000 });
160
+ assert.notEqual(foreign.status, 0); assert.match(foreign.stderr, /binding mismatch/);
161
+ } finally { f.dispose(); }
162
+ });
163
+
164
+ test("dead lock owners recover with evidence; live, unknown, legacy and spawning owners do not expire", () => {
165
+ const f = fixture();
166
+ try {
167
+ const file = path.join(f.project, ".nccgs/test.lock");
168
+ const module = new URL("../.claude/nccgs/tools/process-lock.mjs", import.meta.url).href;
169
+ const held = spawnSync(process.execPath, ["--input-type=module", "-e", `import {acquireLock} from ${JSON.stringify(module)}; acquireLock(${JSON.stringify(file)});`], { encoding: "utf8", windowsHide: true });
170
+ assert.equal(held.status, 0, held.stderr);
171
+ const recovered = acquireLock(file);
172
+ assert.ok(fs.existsSync(`${file}.recoveries.jsonl`));
173
+ assert.throws(() => acquireLock(file), /live or unverified/); recovered.release();
174
+ for (const owner of [{ pid: 99999999 }, { schemaVersion: 1, host: "other-machine", pid: 99999999, token: "remote" },
175
+ { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "gap", phase: "spawning" }]) {
176
+ atomicJson(file, owner); assert.throws(() => acquireLock(file), /live or unverified/); fs.unlinkSync(file);
177
+ }
178
+ } finally { f.dispose(); }
179
+ });
180
+
181
+ test("a dead owner with a live command retains its engine lock", async () => {
182
+ const f = fixture(), child = spawn(process.execPath, ["-e", "setTimeout(()=>{},20000)"], { stdio: "ignore", windowsHide: true });
183
+ try {
184
+ await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
185
+ const file = path.join(f.project, ".nccgs/engine.lock");
186
+ atomicJson(file, { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "dead-parent", phase: "running", child: { pid: child.pid, group: false } });
187
+ assert.throws(() => acquireLock(file), /live or unverified/);
188
+ const closed = new Promise(resolve => child.once("close", resolve)); child.kill(); await closed;
189
+ // Legacy/unmanaged children cannot prove that descendants have exited.
190
+ if (process.platform === "win32") assert.throws(() => acquireLock(file), /live or unverified/);
191
+ } finally { if (pidState(child.pid) === "alive") child.kill(); f.dispose(); }
192
+ });
193
+
194
+ test("OS-owned process group retains orphan descendants and proves their exit after host termination", { skip: process.platform !== "win32" }, async () => {
195
+ const f = fixture(); let host, descendant;
196
+ try {
197
+ const id = crypto.randomUUID(), directory = path.join(f.project, ".nccgs");
198
+ 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();";
199
+ const managed = startCommand({ executable: process.execPath, args: ["-e", code], cwd: f.project, stateDirectory: directory, id, stdio: "ignore" });
200
+ host = managed.child;
201
+ const closed = new Promise(resolve => host.once("close", resolve));
202
+ const deadline = Date.now() + 10000;
203
+ while (!fs.existsSync(path.join(directory, "descendant.json")) && Date.now() < deadline) await delay(50);
204
+ descendant = json(path.join(directory, "descendant.json")).pid;
205
+ assert.equal(pidState(descendant), "alive"); assert.equal(pidState(host.pid), "alive");
206
+ const file = path.join(directory, "engine.lock");
207
+ atomicJson(file, { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "orphan-owner", phase: "running", child: managed.identity() });
208
+ assert.throws(() => acquireLock(file), /live or unverified/);
209
+ host.kill("SIGKILL"); await closed;
210
+ assert.equal(treeState(managed.identity()), "dead"); assert.equal(pidState(descendant), "dead");
211
+ const recovered = acquireLock(file); recovered.release();
212
+ assert.ok(fs.existsSync(`${file}.recoveries.jsonl`));
213
+ } finally { if (host && pidState(host.pid) === "alive") host.kill("SIGKILL"); f.dispose(); }
214
+ });
215
+
216
+ test("orphan verification becomes a retained failed attempt without rerunning the command", () => {
217
+ const f = fixture();
218
+ try {
219
+ const jobId = crypto.randomUUID(), directory = jobDirectory(f.project, jobId);
220
+ fs.mkdirSync(directory, { recursive: true });
221
+ 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: [] });
222
+ atomicJson(path.join(directory, "owner.lock"), { schemaVersion: 1, host: os.hostname(), pid: 99999999, token: "orphan", phase: "owned", child: null });
223
+ const failed = jobStatus(f.project, jobId, "fixture");
224
+ assert.equal(failed.status, "FAIL");
225
+ assert.equal(json(path.join(f.project, failed.evidence.path)).exitCode, null);
226
+ assert.match(json(path.join(f.project, failed.evidence.path)).error, /without a terminal receipt/);
227
+ assert.deepEqual(jobStatus(f.project, jobId, "fixture").evidence, failed.evidence);
228
+ } finally { f.dispose(); }
229
+ });
230
+
231
+ // The fake native executable below is explicitly a fixture, never Claude evidence.
232
+ function fakeNative(f, { missingUsage = false, omitReport = false } = {}) {
233
+ return f.write(".nccgs/fake-native.mjs", `
234
+ import fs from 'node:fs';
235
+ import {run} from '../.claude/nccgs/tools/task.mjs';
236
+ const args=process.argv.slice(2), resumed=args.includes('--resume');
237
+ const session=args[args.indexOf(resumed?'--resume':'--session-id')+1];
238
+ if(!resumed) run(['verify','--id','fixture','--name','test','--background','--request-key','native-once','--',process.execPath,'-e',"setTimeout(()=>console.log('fixture command done'),800)"]);
239
+ else if(${!omitReport}) run(['report','--id','fixture','--outcome','partial','--summary','Fixture native continuation read the managed receipt; no game acceptance']);
240
+ 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'}));
241
+ `);
242
+ }
243
+ test("supervisor waits outside the model, resumes the same session once, preserves cumulative budget and receipts", async () => {
244
+ const f = fixture();
245
+ try {
246
+ const binary = fakeNative(f), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
247
+ const plan = { project: f.project, binary: process.execPath, mode: "full", args: [binary], env: f.env,
248
+ supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 1, timeoutMs: 20000 } };
249
+ assert.equal(await supervise(plan), 0);
250
+ const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
251
+ const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
252
+ assert.equal(state.status, "settled"); assert.equal(state.invocations.length, 2);
253
+ assert.equal(state.cumulativeCostUsd, 0.6); assert.equal(state.invocations[1].allowanceUsd, 0.6);
254
+ assert.equal(state.invocations[0].terminal.session_id, state.invocations[1].terminal.session_id);
255
+ assert.equal(fs.readdirSync(path.join(f.project, ".nccgs/jobs")).length, 1);
256
+ assert.equal(await supervise({ ...plan, supervision: { recoverRun: runId } }), 0);
257
+ assert.equal(json(path.join(f.project, `.nccgs/runs/${runId}/run.json`)).invocations.length, 2);
258
+ } finally { f.dispose(); }
259
+ });
260
+
261
+ test("supervisor can settle a plan without granting source writes or shell checks", async () => {
262
+ const f = fixture(5000, "plan");
263
+ try {
264
+ const binary = f.write(".nccgs/plan-native.mjs", `import {run} from '../.claude/nccgs/tools/task.mjs';
265
+ const args=process.argv.slice(2);const session=args[args.indexOf('--session-id')+1];
266
+ run(['report','--id','fixture','--outcome','partial','--summary','Synthetic plan report; document review pending']);
267
+ console.log(JSON.stringify({type:'result',session_id:session,total_cost_usd:0.1,result:'Synthetic, not real Claude'}));`);
268
+ const prompt = f.write(".nccgs/prompt.txt", "Synthetic plan supervision");
269
+ assert.equal(await supervise({ project: f.project, binary: process.execPath, mode: "full", args: [binary], env: f.env,
270
+ supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 0, timeoutMs: 20000 } }), 0);
271
+ const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
272
+ const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
273
+ assert.equal(state.status, "settled"); assert.equal(state.invocations.length, 1);
274
+ assert.throws(() => guardTool(f.project, { session_id: state.sessionId, tool_name: "Write", tool_input: { file_path: "Assets/source.cs" } }), /cannot edit source/);
275
+ assert.throws(() => guardTool(f.project, { session_id: state.sessionId, tool_name: "Bash", tool_input: { command: "node helper.mjs" } }), /shell execution is blocked/);
276
+ } finally { f.dispose(); }
277
+ });
278
+
279
+ test("supervisor retains unknown usage and refuses a renewed budget", async () => {
280
+ const f = fixture();
281
+ try {
282
+ const binary = fakeNative(f, { missingUsage: true }), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
283
+ assert.equal(await supervise({ project: f.project, binary: process.execPath, mode: "full", args: [binary], env: f.env,
284
+ supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 2, timeoutMs: 20000 } }), 1);
285
+ const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
286
+ const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
287
+ assert.equal(state.status, "blocked"); assert.equal(state.invocations.length, 1); assert.match(state.reason, /cost/);
288
+ const jobId = fs.readdirSync(path.join(f.project, ".nccgs/jobs"))[0];
289
+ const verified = waitVerification(f.project, jobId, "fixture", 10000);
290
+ assert.equal(verified.status, "PASS", JSON.stringify(verified.evidence ? json(path.join(f.project, verified.evidence.path)) : verified));
291
+ } finally { f.dispose(); }
292
+ });
293
+
294
+ test("cumulative native cost rejects double counting, missing and mismatched sessions", () => {
295
+ assert.equal(cumulativeCost(0.4, { type: "result", session_id: "s", total_cost_usd: 0.6 }, "s"), 0.6);
296
+ assert.throws(() => cumulativeCost(0.4, { type: "result", session_id: "s", total_cost_usd: 0.2 }, "s"), /decreasing/);
297
+ assert.throws(() => cumulativeCost(0, { type: "result", session_id: "wrong", total_cost_usd: 0.2 }, "s"), /correlated/);
298
+ });
299
+
300
+ test("supervisor stops at the pinned continuation limit without resetting the attempt", async () => {
301
+ const f = fixture();
302
+ try {
303
+ const executable = fakeNative(f, { omitReport: true }), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
304
+ assert.equal(await supervise({ project: f.project, binary: process.execPath, mode: "full", args: [executable], env: f.env,
305
+ supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 1, maxResumes: 1, timeoutMs: 20000 } }), 1);
306
+ const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
307
+ const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
308
+ assert.equal(state.invocations.length, 2); assert.match(state.reason, /Continuation limit/);
309
+ assert.equal(state.cumulativeCostUsd, 0.6); assert.ok(state.checkpoint);
310
+ } finally { f.dispose(); }
311
+ });
312
+
313
+ test("supervisor refuses a fresh native invocation after the session allowance is spent", async () => {
314
+ const f = fixture();
315
+ try {
316
+ const executable = fakeNative(f), prompt = f.write(".nccgs/prompt.txt", "Fixture only");
317
+ assert.equal(await supervise({ project: f.project, binary: process.execPath, mode: "full", args: [executable], env: f.env,
318
+ supervision: { taskId: "fixture", promptFile: prompt, budgetUsd: 0.4, maxResumes: 2, timeoutMs: 20000 } }), 1);
319
+ const runId = fs.readdirSync(path.join(f.project, ".nccgs/runs"), { withFileTypes: true }).find(e => e.isDirectory()).name;
320
+ const state = json(path.join(f.project, `.nccgs/runs/${runId}/run.json`));
321
+ assert.equal(state.invocations.length, 1); assert.equal(state.cumulativeCostUsd, 0.4);
322
+ assert.match(state.reason, /budget exhausted/); assert.equal(state.costCoverage, "complete");
323
+ } finally { f.dispose(); }
324
+ });
@@ -0,0 +1,250 @@
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 { fileURLToPath } from "node:url";
6
+ import test from "node:test";
7
+ import { artifact, json } from "../.claude/nccgs/tools/policy.mjs";
8
+ import { initialize, snapshot, record, close, taskPath, refreshInputs, reclassify } from "../.claude/nccgs/tools/gates.mjs";
9
+ import { reserve, claim, attachAgent, complete, status, reconcile } from "../.claude/nccgs/tools/orchestration.mjs";
10
+ import { startVerification, waitVerification } from "../.claude/nccgs/tools/verification-jobs.mjs";
11
+ import { assessCriteria } from "../.claude/nccgs/tools/criteria.mjs";
12
+ import { guardTool, safeTaskCommand } from "../.claude/nccgs/tools/tool-guard.mjs";
13
+ import { run } from "../.claude/nccgs/tools/task.mjs";
14
+
15
+ // Provider observations in these tests are SYNTHETIC fixtures, never Claude evidence.
16
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
+ const measurement = ".nccgs/evidence/measurements/runtime.json";
18
+ const criteria = ["needle", "orbit"].map(id => ({ id, description: `${id} wins unassisted`, check: "runtime", checkpoint: "early",
19
+ assertions: [{ artifact: measurement, pointer: `/builds/${id}/won`, op: "eq", value: true },
20
+ { artifact: measurement, pointer: "/assisted", op: "eq", value: false },
21
+ { artifact: measurement, pointer: "/reportOnly", op: "eq", value: false }] }));
22
+ const passed = { builds: { needle: { won: true }, orbit: { won: true } }, assisted: false, reportOnly: false };
23
+
24
+ function fixture(acceptance = criteria, contractOverrides = {}) {
25
+ const temporaryRoot = fs.realpathSync(os.tmpdir()), project = fs.mkdtempSync(path.join(temporaryRoot, "nccgs-phase2-"));
26
+ const write = (relative, value) => {
27
+ const file = path.join(project, relative); fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ fs.writeFileSync(file, typeof value === "string" ? value : JSON.stringify(value)); return artifact(project, relative);
29
+ };
30
+ fs.cpSync(path.join(root, ".claude/nccgs/tools"), path.join(project, ".claude/nccgs/tools"), { recursive: true });
31
+ for (const file of ["pipeline.json", "studio.json"]) write(`.claude/nccgs/${file}`, fs.readFileSync(path.join(root, ".claude/nccgs", file), "utf8"));
32
+ write("Assets/game.cs", "// fixture baseline");
33
+ write("Tools/probe.mjs", `import fs from 'node:fs';fs.mkdirSync('.nccgs/evidence/measurements',{recursive:true});const input=JSON.parse(process.argv[2]);if(input.delayMs)await new Promise(r=>setTimeout(r,input.delayMs));if(!input.skip)fs.writeFileSync('${measurement}',input.malformed?'bad JSON':JSON.stringify(input.data));console.log('Synthetic gameplay measurement fixture');process.exitCode=input.exitCode||0;`);
34
+ write(".nccgs/project.yaml", "schema_version: 2\nmodels:\n profile: claude-standalone\n");
35
+ write(".nccgs/execution.json", { schemaVersion: 1, name: "standalone", externalReview: { mode: "off" } });
36
+ write(".nccgs/inputs.json", { schemaVersion: 1, canonicalInputs: [], outputDirectories: [], checks: { runtime: { timeoutMs: 15000 } } });
37
+ const intake = { objective: "Synthetic test of acceptance mapping and repair budget", mode: "implement", readiness: "ready", requiredChecks: ["runtime"], acceptance, ...contractOverrides };
38
+ write(".nccgs/intake.json", intake);
39
+ initialize(project, "fixture", null, null, null, { intake: ".nccgs/intake.json" });
40
+ let serial = 0;
41
+ const task = () => json(taskPath(project, "fixture"));
42
+ const snap = () => snapshot(project, task()).sha256;
43
+ const assess = () => assessCriteria(project, task(), snap());
44
+ function verify(data = passed, options = {}) {
45
+ const job = startVerification(project, "fixture", { name: "runtime", executable: process.execPath, arguments: ["Tools/probe.mjs", JSON.stringify({ data, ...options })], requestKey: `probe-${++serial}` });
46
+ return waitVerification(project, job.jobId, "fixture", 20000);
47
+ }
48
+ function request(kind = "implementation", overrides = {}) {
49
+ return { kind, agent: kind === "review" ? "nccgs-adversarial-reviewer" : kind === "analysis" ? "nccgs-qa-engineer" : "nccgs-gameplay-programmer",
50
+ requestKey: `dispatch-${++serial}`, reason: "SYNTHETIC TEST FIXTURE", writePaths: kind === "implementation" ? ["Assets/game.cs"] : [], ...overrides };
51
+ }
52
+ function worker(kind = "implementation", overrides = {}, mutate = null) {
53
+ const req = request(kind, overrides);
54
+ if (req.purpose === "repair") {
55
+ req.workPacket = `.nccgs/evidence/work-packets/${req.requestKey}.json`;
56
+ req.handoffPaths = [`.nccgs/evidence/analysis/${req.requestKey}.md`];
57
+ write(req.workPacket, { schemaVersion: 1, taskId: "fixture", contractHash: task().contractHash, sourceSnapshot: snap(),
58
+ problem: "Synthetic acceptance failure", solution: "Apply the predetermined fixture correction", changes: [{ path: "Assets/game.cs", instruction: "Apply the synthetic bounded correction" }],
59
+ context: [], verification: ["runtime"] });
60
+ }
61
+ const d = reserve(project, "fixture", req), count = ++serial;
62
+ claim(project, d.id, { sessionId: "fixture-session", toolUseId: `tool-${count}`, agent: req.agent });
63
+ if (req.workPacket) {
64
+ attachAgent(project, d.id, `${kind}-${count}`);
65
+ guardTool(project, { session_id: "fixture-session", agent_id: `${kind}-${count}`, tool_use_id: `edit-${count}`, tool_name: "Edit", tool_input: { file_path: "Assets/game.cs", old_string: "fixture", new_string: "correction" } });
66
+ }
67
+ if (mutate) mutate();
68
+ if (req.workPacket) {
69
+ const content = "NCCGS-HANDOFF: COMPLETE\nSynthetic bounded correction; coordinator verification pending.";
70
+ guardTool(project, { session_id: "fixture-session", agent_id: `${kind}-${count}`, tool_use_id: `handoff-${count}`, tool_name: "Write", tool_input: { file_path: req.handoffPaths[0], content } });
71
+ write(req.handoffPaths[0], content);
72
+ }
73
+ const current = task(), route = kind === "review" ? current.execution.profile.models.review : current.implementationRoute;
74
+ const source = `.nccgs/evidence/raw-${count}.json`;
75
+ write(source, { taskId: "fixture", contractHash: current.contractHash, snapshot: snap(), dispatchId: d.id,
76
+ sessionId: "fixture-session", toolUseId: `tool-${count}`, agentId: `${kind}-${count}`, status: "completed", capturedAt: new Date().toISOString(),
77
+ observedProvider: "claude-code", observedModel: route.model === "sonnet" ? "claude-sonnet-4-6" : "claude-opus-5-5", observedEffort: null,
78
+ provenance: { kind: "provider-log", reference: "SYNTHETIC TEST FIXTURE, not real Claude" } });
79
+ const runtime = run(["observe", "--project", project, "--id", "fixture", "--file", source]);
80
+ complete(project, d.id, { runtime });
81
+ return { snapshot: snap(), dispatchId: d.id, runtime };
82
+ }
83
+ const work = (attempt, receipt) => record(project, "fixture", "implementation", { ...attempt, checks: [{ name: "runtime", status: "PASS", evidence: receipt.evidence }] });
84
+ const review = (attempt, verdict = "PASS") => record(project, "fixture", "review", { ...attempt, verdict, criteriaMet: verdict === "PASS", findings: [], evidence: write(`.nccgs/evidence/review-${++serial}.md`, "Synthetic independent review; never native evidence") });
85
+ return { project, write, intake, task, snap, assess, verify, request, worker, work, review, dispose() {
86
+ assert.equal(path.dirname(fs.realpathSync(project)).toLowerCase(), temporaryRoot.toLowerCase());
87
+ assert.ok(path.basename(project).startsWith("nccgs-phase2-")); assert.equal(fs.lstatSync(project).isSymbolicLink(), false);
88
+ fs.rmSync(project, { recursive: true, force: true });
89
+ } };
90
+ }
91
+
92
+ test("technical exit 0 cannot turn failed or assisted gameplay into acceptance PASS", () => {
93
+ const f = fixture();
94
+ try {
95
+ const attempt = f.worker();
96
+ const receipt = f.verify({ ...passed, builds: { needle: { won: false }, orbit: { won: false } }, reportOnly: true });
97
+ assert.equal(receipt.status, "PASS"); assert.deepEqual(f.assess().criteria.map(c => c.status), ["FAIL", "FAIL"]);
98
+ f.work(attempt, receipt);
99
+ const reviewer = f.worker("review");
100
+ assert.throws(() => f.review(reviewer), /Acceptance criteria not proven/);
101
+ f.review(reviewer, "CHANGES_REQUIRED");
102
+ assert.throws(() => record(f.project, "fixture", "report", { outcome: "success", summary: "Incorrect PASS" }), /Cannot report success/);
103
+ const partial = record(f.project, "fixture", "report", { outcome: "partial", summary: "Two unassisted builds remain unproven" });
104
+ assert.equal(partial.stages.report.acceptanceAssessment.status, "FAIL");
105
+ assert.throws(() => close(f.project, "fixture"), /review has not passed/);
106
+ assert.equal(f.verify({ ...passed, assisted: true }).status, "PASS");
107
+ assert.equal(f.assess().status, "FAIL");
108
+ } finally { f.dispose(); }
109
+ });
110
+
111
+ test("fresh measured criteria plus independent review permit DONE", () => {
112
+ const f = fixture();
113
+ try {
114
+ const attempt = f.worker(), receipt = f.verify(); f.work(attempt, receipt);
115
+ assert.equal(f.assess().status, "PASS"); f.review(f.worker("review"));
116
+ record(f.project, "fixture", "report", { outcome: "success", summary: "Synthetic acceptance passed" });
117
+ assert.equal(close(f.project, "fixture").phase, "DONE");
118
+ assert.equal(run(["acceptance-status", "--project", f.project, "--id", "fixture"]).automaticPassed, true);
119
+ } finally { f.dispose(); }
120
+ });
121
+
122
+ test("missing, malformed, old, failed and source-stale measurements stay unverified", () => {
123
+ const f = fixture();
124
+ try {
125
+ assert.equal(f.assess().status, "UNVERIFIED");
126
+ f.write(measurement, passed); f.verify(passed, { skip: true });
127
+ assert.equal(f.assess().status, "UNVERIFIED");
128
+ f.verify(passed, { malformed: true }); assert.equal(f.assess().status, "UNVERIFIED");
129
+ f.verify({ ...passed, builds: {} }); assert.equal(f.assess().status, "UNVERIFIED");
130
+ f.verify(); assert.equal(f.assess().status, "PASS");
131
+ assert.equal(f.verify(passed, { exitCode: 2 }).status, "FAIL"); assert.equal(f.assess().status, "UNVERIFIED");
132
+ f.verify(); f.write("Assets/game.cs", "// changed after verification"); assert.equal(f.assess().status, "UNVERIFIED");
133
+ } finally { f.dispose(); }
134
+ });
135
+
136
+ test("frozen measurement survives output overwrite but detects captured-evidence tampering", () => {
137
+ const f = fixture();
138
+ try {
139
+ f.verify(); f.write(measurement, { ...passed, assisted: true }); assert.equal(f.assess().status, "PASS");
140
+ const assessment = f.assess(), receipt = json(path.join(f.project, assessment.criteria[0].receipt.path));
141
+ const captured = receipt.acceptance.criteria[0].checks[0].evidence.path;
142
+ f.write(captured, { ...passed, assisted: true }); assert.equal(f.assess().status, "UNVERIFIED");
143
+ assert.throws(() => guardTool(f.project, { tool_name: "Write", tool_input: { file_path: captured } }), /immutable/);
144
+ assert.equal(safeTaskCommand("node .claude/nccgs/tools/task.mjs acceptance-status --id fixture", f.project), "acceptance-status");
145
+ } finally { f.dispose(); }
146
+ });
147
+
148
+ test("pending latest attempt supersedes PASS and owns its measurement paths exclusively", () => {
149
+ const f = fixture(); let job;
150
+ try {
151
+ f.verify(); assert.equal(f.assess().status, "PASS");
152
+ const args = { name: "runtime", executable: process.execPath, arguments: ["Tools/probe.mjs", JSON.stringify({ data: passed, delayMs: 1000 })], requestKey: "pending" };
153
+ job = startVerification(f.project, "fixture", args);
154
+ assert.equal(f.assess().status, "UNVERIFIED");
155
+ assert.throws(() => startVerification(f.project, "fixture", { ...args, requestKey: "overlap" }), /owns these measurement/);
156
+ assert.equal(waitVerification(f.project, job.jobId, "fixture", 20000).status, "PASS");
157
+ assert.equal(f.assess().status, "PASS");
158
+ } finally { if (job) waitVerification(f.project, job.jobId, "fixture", 20000); f.dispose(); }
159
+ });
160
+
161
+ test("check-only criteria require mapping and manual criteria keep their owner gate", () => {
162
+ const f = fixture([{ id: "legacy", description: "Must not infer acceptance from exit 0", check: "runtime" }, { id: "feel", description: "Human playtest", manual: true }]);
163
+ try {
164
+ f.verify(); assert.deepEqual(f.assess().criteria.map(c => c.status), ["UNVERIFIED", "MANUAL_PENDING"]);
165
+ assert.deepEqual(f.task().contract.manualGates, ["feel"]);
166
+ f.write(".nccgs/bad.json", { ...f.intake, acceptance: [{ ...criteria[0], assertions: [{ artifact: "../outside.json", pointer: "/x", op: "eq", value: true }] }] });
167
+ assert.throws(() => initialize(f.project, "bad", null, null, null, { intake: ".nccgs/bad.json" }), /measurement/);
168
+ f.write(".nccgs/bad.json", { ...f.intake, acceptance: [{ ...criteria[0], assertions: [{ artifact: ".nccgs/evidence/measurements/../criteria/x.json", pointer: "/x", op: "eq", value: true }] }] });
169
+ assert.throws(() => initialize(f.project, "alias", null, null, null, { intake: ".nccgs/bad.json" }), /canonical/);
170
+ } finally { f.dispose(); }
171
+ });
172
+
173
+ test("typed thresholds and escaped JSON pointers are evaluated without coercion", () => {
174
+ const f = fixture([{ id: "load", description: "Measured load", check: "runtime", assertions: [
175
+ { artifact: measurement, pointer: "/a~1b/~0load", op: "gte", value: 200 },
176
+ { artifact: measurement, pointer: "/p95", op: "lte", value: 16.7 }] }]);
177
+ try {
178
+ f.verify({ "a/b": { "~load": 201 }, p95: 15 }); assert.equal(f.assess().status, "PASS");
179
+ f.verify({ "a/b": { "~load": "201" }, p95: 15 }); assert.equal(f.assess().status, "UNVERIFIED");
180
+ f.verify({ "a/b": { "~load": 201 }, p95: 20 }); assert.equal(f.assess().status, "FAIL");
181
+ } finally { f.dispose(); }
182
+ });
183
+
184
+ test("early viability blocks expansion while leaving repair, QA and review available", () => {
185
+ const f = fixture();
186
+ try {
187
+ f.worker();
188
+ assert.throws(() => reserve(f.project, "fixture", f.request()), /Early acceptance checkpoint/);
189
+ f.verify({ ...passed, assisted: true });
190
+ assert.throws(() => reserve(f.project, "fixture", f.request()), /Early acceptance checkpoint/);
191
+ assert.throws(() => reserve(f.project, "fixture", f.request("analysis", { purpose: "qa" })), /reserved for a completed repair/);
192
+ const repair = f.worker("implementation", { purpose: "repair" }, () => f.write("Assets/game.cs", "// repaired tuning"));
193
+ assert.equal(f.assess().status, "UNVERIFIED");
194
+ f.verify();
195
+ f.worker("analysis", { purpose: "qa" });
196
+ f.worker();
197
+ assert.equal(status(f.project, "fixture").deliveryBudget.remaining.repair, 0);
198
+ assert.ok(repair.dispatchId);
199
+ } finally { f.dispose(); }
200
+ });
201
+
202
+ test("STANDARD preserves a repair plus QA plus re-review after initial review at total cap", () => {
203
+ const f = fixture(criteria.map(c => ({ ...c, checkpoint: "final" })));
204
+ try {
205
+ let implementation;
206
+ for (let i = 0; i < 8; i++) implementation = f.worker();
207
+ const failed = f.verify({ ...passed, assisted: true }); f.work(implementation, failed);
208
+ assert.throws(() => reserve(f.project, "fixture", f.request()), /budget reserved/);
209
+ f.review(f.worker("review"), "CHANGES_REQUIRED");
210
+ assert.deepEqual(status(f.project, "fixture").deliveryBudget.remaining, { repair: 1, qa: 1, review: 1 });
211
+ const repair = f.worker("implementation", { purpose: "repair" }, () => f.write("Assets/game.cs", "// bounded correction"));
212
+ const fresh = f.verify(); f.work(repair, fresh);
213
+ f.worker("analysis", { purpose: "qa" }); f.review(f.worker("review"));
214
+ assert.equal(status(f.project, "fixture").usage.dispatches, 12);
215
+ record(f.project, "fixture", "report", { outcome: "success", summary: "Synthetic repaired acceptance passed" });
216
+ assert.equal(close(f.project, "fixture").phase, "DONE");
217
+ assert.throws(() => reserve(f.project, "fixture", f.request()), /budget exhausted/);
218
+ } finally { f.dispose(); }
219
+ });
220
+
221
+ test("protected purposes cannot be used without feedback or by an unrelated role", () => {
222
+ const f = fixture([]);
223
+ try {
224
+ assert.throws(() => reserve(f.project, "fixture", f.request("implementation", { purpose: "repair" })), /requires recorded negative/);
225
+ assert.throws(() => reserve(f.project, "fixture", f.request("implementation", { purpose: "qa" })), /read-only QA/);
226
+ assert.throws(() => reserve(f.project, "fixture", f.request("analysis", { purpose: "qa", repairFeedback: { fake: true } })), /reserved for a completed repair/);
227
+ assert.throws(() => reserve(f.project, "fixture", f.request("implementation", { limits: { maxDispatches: 4 } })), /cannot fit/);
228
+ } finally { f.dispose(); }
229
+ });
230
+
231
+ test("cancellation, child tasks, input refresh and reclassification cannot refund protected budget", () => {
232
+ const f = fixture([]);
233
+ try {
234
+ for (let i = 0; i < 7; i++) f.worker();
235
+ f.write(".nccgs/child.json", { ...f.intake, acceptance: [] });
236
+ initialize(f.project, "child", null, null, null, { intake: ".nccgs/child.json", parentId: "fixture" });
237
+ const req = f.request("analysis"), child = reserve(f.project, "child", req);
238
+ const saved = [process.env.CLAUDECODE, process.env.CLAUDE_CODE_ENTRYPOINT];
239
+ delete process.env.CLAUDECODE; delete process.env.CLAUDE_CODE_ENTRYPOINT;
240
+ try { reconcile(f.project, child.id, { status: "cancelled", reason: "Synthetic cancelled before launch", reference: "Synthetic fixture" }); }
241
+ finally { for (const [i, name] of ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"].entries()) if (saved[i] === undefined) delete process.env[name]; else process.env[name] = saved[i]; }
242
+ assert.equal(status(f.project, "fixture").deliveryBudget.deliveryRemaining, 0);
243
+ refreshInputs(f.project, "fixture", "Synthetic explicit refresh");
244
+ reclassify(f.project, "fixture", { risk: "CONTROLLED", reason: "Synthetic escalation", evidence: ["Synthetic observed risk"] });
245
+ assert.throws(() => reserve(f.project, "fixture", f.request()), /budget reserved/);
246
+ assert.throws(() => reserve(f.project, "child", f.request("analysis")), /budget reserved/);
247
+ assert.equal(reserve(f.project, "child", req).id, child.id);
248
+ assert.equal(status(f.project, "fixture").usage.dispatches, 8);
249
+ } finally { f.dispose(); }
250
+ });