nccgs 2.0.0-rc.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude/nccgs/MEMORY.md +3 -2
  2. package/.claude/nccgs/VERSION +1 -1
  3. package/.claude/nccgs/packages/ponytail/README.md +2 -2
  4. package/.claude/nccgs/protocols/agent-contract.md +15 -2
  5. package/.claude/nccgs/protocols/bounded-repair.md +135 -7
  6. package/.claude/nccgs/protocols/editor-session-evidence.md +52 -0
  7. package/.claude/nccgs/protocols/orchestration.md +7 -5
  8. package/.claude/nccgs/protocols/review-handoff.md +17 -1
  9. package/.claude/nccgs/protocols/standalone-gates.md +64 -8
  10. package/.claude/nccgs/protocols/standalone.md +33 -13
  11. package/.claude/nccgs/protocols/task-gates.md +13 -1
  12. package/.claude/nccgs/protocols/task-slices.md +106 -0
  13. package/.claude/nccgs/routing.generated.md +20 -0
  14. package/.claude/nccgs/tools/command-process.mjs +10 -4
  15. package/.claude/nccgs/tools/compile-policy.mjs +4 -0
  16. package/.claude/nccgs/tools/editor-session-evidence.mjs +66 -0
  17. package/.claude/nccgs/tools/gates.mjs +7 -4
  18. package/.claude/nccgs/tools/handoff.mjs +51 -0
  19. package/.claude/nccgs/tools/inputs.mjs +36 -1
  20. package/.claude/nccgs/tools/lifecycle.mjs +21 -0
  21. package/.claude/nccgs/tools/orchestration.mjs +264 -14
  22. package/.claude/nccgs/tools/repair-packet.mjs +29 -10
  23. package/.claude/nccgs/tools/returned-handoff.mjs +38 -0
  24. package/.claude/nccgs/tools/review-budget.mjs +171 -0
  25. package/.claude/nccgs/tools/review-handoff.mjs +42 -8
  26. package/.claude/nccgs/tools/runtime.mjs +7 -2
  27. package/.claude/nccgs/tools/session-budget.mjs +67 -0
  28. package/.claude/nccgs/tools/task-slices.mjs +147 -0
  29. package/.claude/nccgs/tools/task.mjs +39 -2
  30. package/.claude/nccgs/tools/tool-guard.mjs +19 -5
  31. package/.claude/nccgs/tools/unity-repair.mjs +128 -0
  32. package/.claude/nccgs/tools/verification-jobs.mjs +13 -3
  33. package/.claude/nccgs/tools/windows-compiler-server.cs +163 -0
  34. package/.claude/nccgs/tools/windows-job.cs +8 -1
  35. package/CHANGELOG.md +123 -0
  36. package/MEMORY.md +3 -2
  37. package/README.md +35 -27
  38. package/UPGRADING.md +26 -22
  39. package/VERSION +1 -1
  40. package/docs/ARCHITECTURE.md +45 -18
  41. package/docs/BOUNDED-REPAIR.md +76 -7
  42. package/docs/EDITOR-SESSION-EVIDENCE.md +52 -0
  43. package/docs/HANDOFF-AUTOMATION.md +144 -0
  44. package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +40 -11
  45. package/docs/PHASE-1-OPERATIONS.md +142 -1
  46. package/docs/PHASE-2-OPERATIONS.md +1 -1
  47. package/docs/RELEASE-STATUS.md +92 -9
  48. package/docs/TASK-SLICING.md +57 -0
  49. package/docs/UPGRADE-QUALIFICATION.md +45 -0
  50. package/docs/WORKFLOWS.md +1 -1
  51. package/package.json +2 -2
  52. package/scaffold/.nccgs/templates/repair-unity-code.json +18 -0
  53. package/scaffold/.nccgs/templates/repair-unity-editor.json +21 -0
  54. package/scaffold/.nccgs/templates/task-slices.json +28 -0
  55. package/scripts/cli.mjs +4 -0
  56. package/scripts/install.mjs +10 -0
  57. package/scripts/run.mjs +4 -3
  58. package/scripts/supervise.mjs +30 -6
  59. package/scripts/validate.mjs +4 -0
  60. package/scripts/work-handoff.mjs +38 -0
  61. package/tests/editor-session-evidence.test.mjs +45 -0
  62. package/tests/fixtures/compiler-shutdown.cs +64 -0
  63. package/tests/framework.test.mjs +38 -0
  64. package/tests/orchestration-v15.test.mjs +52 -1
  65. package/tests/phase1.test.mjs +92 -0
  66. package/tests/policy-v15.test.mjs +14 -0
  67. package/tests/review-budget.test.mjs +177 -0
  68. package/tests/runtime-v15.test.mjs +572 -4
  69. package/tests/session-budget.test.mjs +34 -0
  70. package/tests/task-slices.test.mjs +285 -0
  71. package/tests/unity-generated.test.mjs +140 -0
@@ -88,6 +88,7 @@ const overrideManifestPath = projectPath(".nccgs/framework-overrides.json");
88
88
  // even when a pre-existing file would otherwise be preserved or skipped.
89
89
  for (const relative of ["CLAUDE.md", ".claude/settings.json", ".nccgs/project.yaml", ".nccgs/execution.json", ".nccgs/backups", ".nccgs/agent-activation.json"]) projectPath(relative);
90
90
  const previousInstallState = readJsonIfPresent(installStatePath, "NCCGS install state");
91
+ const previousActivation = readJsonIfPresent(projectPath(".nccgs/agent-activation.json"), "NCCGS agent activation state");
91
92
  const executionFile = projectPath(".nccgs/execution.json");
92
93
  const previousExecution = readJsonIfPresent(executionFile, "NCCGS execution policy");
93
94
  const existingProjectPolicy = fs.existsSync(projectPath(".nccgs/project.yaml"));
@@ -308,6 +309,15 @@ function planFrameworkFile(item) {
308
309
  }
309
310
  if (equivalentFiles(item.source, item.destination)) return { ...item, action: "current", override: null };
310
311
  if (!force && previous && matchesFingerprint(item.destination, previous)) return { ...item, action: "replace", override: null };
312
+ // Older profiles could generate optional agents outside install-state.files.
313
+ // When a later package starts managing one, its recorded generated bytes are
314
+ // a valid baseline. Real local edits and explicit overrides still need review.
315
+ const generated = previousActivation?.schemaVersion === 1 && item.relative.startsWith(".claude/agents/")
316
+ ? { sha256: previousActivation.generated?.[item.relative], canonicalSha256: previousActivation.generatedCanonical?.[item.relative] }
317
+ : null;
318
+ if (!force && previousInstallState && !previous && !existingOverride && matchesFingerprint(item.destination, generated)) {
319
+ return { ...item, action: "replace", override: null };
320
+ }
311
321
  if (force) return { ...item, action: "force-replace", override: null };
312
322
  return { ...item, action: "preserve", override: buildOverride(item, existingOverride, upstream, "preserve") };
313
323
  }
package/scripts/run.mjs CHANGED
@@ -16,13 +16,14 @@ export function launchPlan(argv, env = process.env) {
16
16
  const option = options[i];
17
17
  if (option === "--dry-run") { dryRun = true; continue; }
18
18
  if (option === "--supervise") { supervised = true; continue; }
19
- if (!["--project", "--claude", "--task", "--prompt-file", "--max-resumes", "--timeout-ms", "--session-budget-usd", "--recover-run"].includes(option) || !options[i + 1] || options[i + 1].startsWith("--")) throw new Error(`Invalid run option: ${option}; put Claude arguments after --`);
19
+ if (!["--project", "--claude", "--task", "--prompt-file", "--max-resumes", "--timeout-ms", "--session-budget-usd", "--recover-run", "--budget-phase"].includes(option) || !options[i + 1] || options[i + 1].startsWith("--")) throw new Error(`Invalid run option: ${option}; put Claude arguments after --`);
20
20
  const value = options[++i];
21
21
  if (option === "--project") project = path.resolve(value);
22
22
  else if (option === "--claude") binary = value;
23
23
  else if (option === "--task") supervision.taskId = value;
24
24
  else if (option === "--prompt-file") supervision.promptFile = path.resolve(value);
25
25
  else if (option === "--recover-run") { supervision.recoverRun = value; supervised = true; }
26
+ else if (option === "--budget-phase") { if (!["work", "final-review"].includes(value)) throw new Error("Budget phase must be work or final-review"); supervision.budgetPhase = value; }
26
27
  else if (option === "--max-resumes") supervision.maxResumes = Number(value);
27
28
  else if (option === "--timeout-ms") supervision.timeoutMs = Number(value);
28
29
  else supervision.budgetUsd = Number(value);
@@ -35,14 +36,14 @@ export function launchPlan(argv, env = process.env) {
35
36
  for (const argument of forwarded) if (conflicting.test(argument)) throw new Error(`NCCGS owns session permission/root settings; conflicting option: ${argument}`);
36
37
  if (supervised) {
37
38
  if (supervision.recoverRun) {
38
- if (forwarded.length || supervision.taskId || supervision.promptFile || supervision.budgetUsd !== undefined || options.includes("--max-resumes") || options.includes("--timeout-ms")) throw new Error("Recovery preserves the original prompt, limits and provider arguments");
39
+ if (forwarded.length || supervision.taskId || supervision.promptFile || supervision.budgetUsd !== undefined || supervision.budgetPhase || options.includes("--max-resumes") || options.includes("--timeout-ms")) throw new Error("Recovery preserves the original prompt, limits and provider arguments");
39
40
  } else {
40
41
  if (!supervision.taskId || !supervision.promptFile || !Number.isFinite(supervision.budgetUsd) || supervision.budgetUsd <= 0) throw new Error("Supervision needs --task, --prompt-file and --session-budget-usd");
41
42
  if (!Number.isSafeInteger(supervision.maxResumes) || supervision.maxResumes < 0 || supervision.maxResumes > 5 || !Number.isSafeInteger(supervision.timeoutMs) || supervision.timeoutMs < 1000 || supervision.timeoutMs > 86400000) throw new Error("Invalid supervision continuation/time limits");
42
43
  const owned = /^(?:-p|--print|--output-format|--input-format|--session-id|--resume|-r|--continue|-c|--fork-session|--no-session-persistence|--max-budget-usd|--verbose|--bg|--background|--remote|--cloud)(?:=|$)/;
43
44
  for (const argument of forwarded) if (owned.test(argument)) throw new Error(`Supervisor owns native lifecycle option: ${argument}`);
44
45
  }
45
- } else if (options.some(option => ["--task", "--prompt-file", "--max-resumes", "--timeout-ms", "--session-budget-usd"].includes(option))) throw new Error("Lifecycle options require --supervise");
46
+ } else if (options.some(option => ["--task", "--prompt-file", "--max-resumes", "--timeout-ms", "--session-budget-usd", "--budget-phase"].includes(option))) throw new Error("Lifecycle options require --supervise");
46
47
  return { project, binary, dryRun, mode, args: ["--permission-mode", mode === "full" ? "bypassPermissions" : "default", ...forwarded],
47
48
  supervision: supervised ? supervision : null, env: { ...env, NCCGS_PROJECT_ROOT: project, NCCGS_TASK_AUTONOMY: mode,
48
49
  // Native auto-backgrounding can otherwise turn a claimed foreground worker
@@ -7,6 +7,9 @@ import { taskPath } from "../.claude/nccgs/tools/gates.mjs";
7
7
  import { checkpoint, status as dispatchStatus } from "../.claude/nccgs/tools/orchestration.mjs";
8
8
  import { listJobs } from "../.claude/nccgs/tools/verification-jobs.mjs";
9
9
  import { acquireLock, delay, pidState } from "../.claude/nccgs/tools/process-lock.mjs";
10
+ import { sessionBudgetLock, sessionBudgetCoverage } from "../.claude/nccgs/tools/session-budget.mjs";
11
+ import { allocateReviewBudget } from "../.claude/nccgs/tools/review-budget.mjs";
12
+ import { settleWorkHandoff } from "./work-handoff.mjs";
10
13
 
11
14
  const need = (condition, message) => { if (!condition) throw new Error(message); };
12
15
  const now = () => new Date().toISOString();
@@ -44,11 +47,13 @@ export async function supervise(plan) {
44
47
  budgetUsd: config.budgetUsd, cumulativeCostUsd: 0, costCoverage: "complete", invocations: [], status: "ready" };
45
48
  }
46
49
  const lock = acquireLock(contained(project, `.nccgs/runs/task-${state.taskId}.lock`), { runId: state.id });
50
+ let budgetLock;
47
51
  const directory = path.dirname(file), save = () => atomicJson(file, state);
48
52
  let interrupted = false, child;
49
53
  const interrupt = () => { interrupted = true; if (child) child.kill("SIGINT"); };
50
54
  process.on("SIGINT", interrupt);
51
55
  try {
56
+ budgetLock = acquireLock(contained(project, sessionBudgetLock), { runId: state.id, sessionId: state.sessionId });
52
57
  fs.mkdirSync(directory, { recursive: true });
53
58
  const task = json(taskPath(project, state.taskId));
54
59
  need(task.contractHash === state.contractHash, "Task contract changed during supervised run");
@@ -63,7 +68,9 @@ export async function supervise(plan) {
63
68
  state.cumulativeCostUsd = cumulativeCost(state.cumulativeCostUsd, terminal, state.sessionId);
64
69
  state.costCoverage = "complete";
65
70
  Object.assign(last, { status: "recovered", terminal, recoveredAt: now() }); save();
71
+ last.evidence = artifact(project, `.nccgs/runs/${state.id}/${last.log}`); save();
66
72
  }
73
+ allocateReviewBudget(project, state, state.budgetPhase ?? config.budgetPhase ?? "work"); save();
67
74
  for (;;) {
68
75
  need(!interrupted, "Supervised run interrupted; jobs and checkpoint retained");
69
76
  need(Date.now() < state.deadline, "Supervised run deadline reached; no automatic extension");
@@ -75,11 +82,16 @@ export async function supervise(plan) {
75
82
  await delay(250); continue; // deterministic wait, no model request or fake verification receipt
76
83
  }
77
84
  if (state.invocations.length) {
78
- const latestTask = json(taskPath(project, state.taskId));
85
+ let latestTask = json(taskPath(project, state.taskId));
79
86
  const latestReceiptAt = Math.max(0, ...jobs.map(j => Date.parse(json(contained(project, j.evidence.path)).finishedAt)));
80
- const reportAt = Date.parse(latestTask.stages.report?.recordedAt ?? "");
81
87
  const active = dispatchStatus(project, state.taskId).active;
82
88
  need(!active.length, "Native worker dispatch remains active/uncertain; preserve quota and request reconciliation");
89
+ const handoff = settleWorkHandoff(project, state, latestReceiptAt);
90
+ if (handoff) {
91
+ latestTask = json(taskPath(project, state.taskId));
92
+ state.workHandoff = { origin: "supervisor-work-handoff", evidence: latestTask.stages.report.evidence, recordedAt: latestTask.stages.report.recordedAt };
93
+ }
94
+ const reportAt = Date.parse(latestTask.stages.report?.recordedAt ?? "");
83
95
  if (Number.isFinite(reportAt) && reportAt >= latestReceiptAt && reportAt >= Date.parse(state.createdAt)) {
84
96
  state.status = "settled"; state.taskOutcome = latestTask.outcome; state.taskPhase = latestTask.phase;
85
97
  state.finishedAt = now(); save(); return 0;
@@ -88,28 +100,40 @@ export async function supervise(plan) {
88
100
  }
89
101
  const remaining = state.budgetUsd - state.cumulativeCostUsd;
90
102
  need(remaining > 0, "Session list-cost budget exhausted; no budget reset on resume");
103
+ allocateReviewBudget(project, state, state.budgetPhase ?? "work");
104
+ for (let id = state.taskId; id; id = json(taskPath(project, id)).parentId) {
105
+ const ledger = dispatchStatus(project, id);
106
+ if (ledger.limits?.maxCostUsd != null)
107
+ sessionBudgetCoverage(project, id, ledger.reservations, ledger.limits.maxCostUsd, state, { running: false });
108
+ }
91
109
  const index = state.invocations.length, invocation = { index, startedAt: now(), status: "running", pid: null,
92
110
  log: `native-${index}.jsonl`, stderr: `native-${index}.stderr.log`, allowanceUsd: remaining };
93
- const prompt = index === 0 ? state.prompt : `Continue NCCGS task ${state.taskId} in this same session. Read task inspect and task job-status. Use the existing verification receipts; do not rerun successful commands just to create receipts. Investigate failed checks within remaining scope/budget. Record a truthful report (success/partial/blocked/failed). Do not claim DONE without all configured gates. Jobs: ${JSON.stringify(jobs)}. This continuation preserves all previous attempts, review rounds and costs.`;
111
+ const workNotice = task.mode === "implement"
112
+ ? `Final delivery review money/time is protected in a separate final-review phase. Finish the implementation record, then invoke node .claude/nccgs/tools/task.mjs report --id ${state.taskId} --outcome partial --summary "Implementation recorded; awaiting protected independent review". A prose PARTIAL response alone does not record the handoff. Do not spend this work phase on delivery review.`
113
+ : "Complete this task's normal analysis, independent design review, report and closure gates. Final delivery review money/time is protected for the later implementation task.";
114
+ const phaseNotice = state.budgetAllocation ? `Budget phase: ${state.budgetPhase}. Effective cumulative session ceiling USD ${state.budgetUsd}; absolute deadline ${new Date(state.deadline).toISOString()}. Read task budget-status for the pinned allocation. ${state.budgetPhase === "work" ? workNotice : "This phase only admits independent delivery review and settlement. No source workers, repair or new verification jobs. Read existing current measured evidence; preserve all ordinary gates and the exact reviewer verdict."}\n\n` : "";
115
+ const prompt = phaseNotice + (index === 0 ? state.prompt : `Continue NCCGS task ${state.taskId} in this same session. Read task inspect and task job-status. Use the existing verification receipts; do not rerun successful commands just to create receipts. Investigate failed checks within remaining scope/budget. Record a truthful report (success/partial/blocked/failed). Do not claim DONE without all configured gates. Jobs: ${JSON.stringify(jobs)}. This continuation preserves all previous attempts, review rounds and costs.`);
94
116
  state.invocations.push(invocation); state.status = "running"; state.costCoverage = "unknown-current-invocation"; save();
95
117
  lock.update({ phase: "spawning" });
118
+ budgetLock.update({ phase: "spawning" });
96
119
  const output = fs.openSync(path.join(directory, invocation.log), "wx"), errors = fs.openSync(path.join(directory, invocation.stderr), "wx");
97
120
  const args = [...state.providerArgs, "-p", "--output-format", "stream-json", "--verbose", "--max-budget-usd", String(remaining),
98
121
  ...(index === 0 ? ["--session-id", state.sessionId] : ["--resume", state.sessionId])];
99
122
  let deadlineTimer;
100
123
  try {
101
124
  child = spawn(state.binary, args, { cwd: project, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
102
- env: { ...plan.env, NCCGS_SUPERVISED_SESSION: state.sessionId, NCCGS_TASK_ID: state.taskId } });
125
+ env: { ...plan.env, NCCGS_SUPERVISED_SESSION: state.sessionId, NCCGS_SUPERVISED_RUN: state.id, NCCGS_TASK_ID: state.taskId } });
103
126
  child.stdin.on("error", () => {}); // early native failure is captured in its terminal/exit evidence
104
127
  child.stdout.on("data", data => { fs.writeSync(output, data); process.stdout.write(data); });
105
128
  child.stderr.on("data", data => { fs.writeSync(errors, data); process.stderr.write(data); });
106
- child.once("spawn", () => { invocation.pid = child.pid; save(); lock.update({ phase: "running", child: { pid: child.pid, group: false } }); });
129
+ child.once("spawn", () => { invocation.pid = child.pid; save(); const ownership = { phase: "running", child: { pid: child.pid, group: false } }; lock.update(ownership); budgetLock.update(ownership); });
107
130
  child.stdin.end(prompt);
108
131
  deadlineTimer = setTimeout(() => { interrupted = true; child.kill("SIGINT"); }, Math.max(1, state.deadline - Date.now()));
109
132
  const result = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", (code, signal) => resolve({ code, signal })); });
110
133
  Object.assign(invocation, result, { finishedAt: now() });
111
134
  } finally { clearTimeout(deadlineTimer); fs.closeSync(output); fs.closeSync(errors); child = null; }
112
135
  lock.update({ phase: "owned", child: null });
136
+ budgetLock.update({ phase: "owned", child: null });
113
137
  invocation.terminal = terminalFrom(path.join(directory, invocation.log));
114
138
  state.cumulativeCostUsd = cumulativeCost(state.cumulativeCostUsd, invocation.terminal, state.sessionId);
115
139
  state.costCoverage = "complete";
@@ -120,5 +144,5 @@ export async function supervise(plan) {
120
144
  state.status = "blocked"; state.reason = error.message; state.stoppedAt = now();
121
145
  try { state.checkpoint = checkpoint(project, state.taskId, { reason: error.message }); } catch { /* original failure retained */ }
122
146
  save(); console.error(`NCCGS supervisor: ${error.message}. Run record: ${file}`); return 1;
123
- } finally { process.off("SIGINT", interrupt); lock.release(); }
147
+ } finally { process.off("SIGINT", interrupt); budgetLock?.release(); lock.release(); }
124
148
  }
@@ -50,7 +50,11 @@ for (const relative of [
50
50
  ".claude/nccgs/pipeline.json", ".claude/nccgs/protocols/task-gates.md",
51
51
  ".claude/nccgs/protocols/design-lifecycle.md", ".claude/nccgs/tools/design-readiness.mjs", "scaffold/.nccgs/templates/design-contract.json",
52
52
  ".claude/nccgs/tools/task.mjs", ".claude/nccgs/tools/gates.mjs", ".claude/nccgs/tools/runtime.mjs",
53
+ ".claude/nccgs/tools/handoff.mjs", "docs/HANDOFF-AUTOMATION.md",
54
+ ".claude/nccgs/tools/task-slices.mjs", ".claude/nccgs/protocols/task-slices.md",
55
+ "scaffold/.nccgs/templates/task-slices.json", "docs/TASK-SLICING.md", "tests/task-slices.test.mjs",
53
56
  ".claude/nccgs/tools/repair-packet.mjs", ".claude/nccgs/protocols/bounded-repair.md", "scaffold/.nccgs/templates/repair-packet.json", "docs/BOUNDED-REPAIR.md",
57
+ ".claude/nccgs/tools/unity-repair.mjs", "scaffold/.nccgs/templates/repair-unity-code.json", "scaffold/.nccgs/templates/repair-unity-editor.json",
54
58
  "scaffold/.nccgs/gates.json", "scaffold/.nccgs/execution.json", "tests/gates.test.mjs",
55
59
  ".claude/nccgs/settings.fragment.json", ".claude/nccgs/unity-skills-manifest.json",
56
60
  ".claude/nccgs/protocols/agent-contract.md", ".claude/nccgs/protocols/orchestration.md",
@@ -0,0 +1,38 @@
1
+ import { check, record, taskPath } from "../.claude/nccgs/tools/gates.mjs";
2
+ import { json } from "../.claude/nccgs/tools/policy.mjs";
3
+ import { assessCriteria } from "../.claude/nccgs/tools/criteria.mjs";
4
+ import { status } from "../.claude/nccgs/tools/orchestration.mjs";
5
+ import { listJobs, readJob } from "../.claude/nccgs/tools/verification-jobs.mjs";
6
+
7
+ // Settle only an already recorded implementation. This is deterministic supervisor
8
+ // bookkeeping, never a provider/reviewer verdict or permission to close the task.
9
+ export function settleWorkHandoff(project, state, latestReceiptAt = 0) {
10
+ if (!state.budgetAllocation || state.budgetPhase !== "work" || state.costCoverage !== "complete") return null;
11
+ const last = state.invocations.at(-1);
12
+ if (!last || !["finished", "recovered"].includes(last.status) || last.terminal?.is_error ||
13
+ (last.code != null && last.code !== 0) || last.signal) return null;
14
+ const task = json(taskPath(project, state.taskId));
15
+ if (task.mode !== "implement" || task.closed || task.contractHash !== state.contractHash) return null;
16
+ if (Date.parse(task.stages.report?.recordedAt) >= Date.parse(state.createdAt)) return null;
17
+ if (!(Date.parse(task.stages.implementation?.recordedAt) >= Math.max(Date.parse(state.createdAt), latestReceiptAt))) return null;
18
+ const jobs = listJobs(project, task.id);
19
+ if (jobs.some(j => !["PASS", "FAIL"].includes(j.status)) || status(project, task.id).active.length) return null;
20
+ // A later failure cannot be hidden by re-recording an older PASS, even for a
21
+ // legacy check-only contract without measured acceptance assertions.
22
+ const byName = new Map();
23
+ for (const job of jobs) {
24
+ const queuedAt = Date.parse(readJob(project, job.jobId).queuedAt), previous = byName.get(job.name);
25
+ if (!Number.isFinite(queuedAt) || previous?.queuedAt === queuedAt) return null;
26
+ if (!previous || previous.queuedAt < queuedAt) byName.set(job.name, { ...job, queuedAt });
27
+ }
28
+ if ([...byName.values()].some(j => j.status !== "PASS")) return null;
29
+ let ready;
30
+ try {
31
+ ready = check(project, task, "review");
32
+ if (!ready.allowed || !assessCriteria(project, task, ready.snapshot).automaticPassed) return null;
33
+ } catch { return null; } // Incomplete/stale evidence still follows the bounded continuation path.
34
+ return record(project, task.id, "report", {
35
+ outcome: "partial", origin: "supervisor-work-handoff", runId: state.id,
36
+ summary: "Implementation and current measured checks recorded. Awaiting protected independent delivery review and all remaining acceptance gates. This handoff is deterministic supervisor bookkeeping, not a reviewer verdict."
37
+ });
38
+ }
@@ -0,0 +1,45 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import crypto from "node:crypto";
7
+ import { publishEditorSessionEvidence } from "../.claude/nccgs/tools/editor-session-evidence.mjs";
8
+ const hash = bytes => crypto.createHash("sha256").update(bytes).digest("hex");
9
+ function fixture(t) {
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-editor-evidence-"));
11
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
12
+ const project = path.join(root, "project"); fs.mkdirSync(project);
13
+ const identity = { pid: 123, windowsJob: true, groupName: "Local\\NCCGS-12345678-1234-1234-1234-123456789abc" };
14
+ const values = {
15
+ start: { at: "2026-01-01T00:00:00Z", project, identity },
16
+ stop: { identity, state: "dead", forced: false, result: { code: 0, signal: null } },
17
+ shutdown: { at: "2026-01-01T00:01:00Z", args: ["command", "eval", "UnityEditor.EditorApplication.Exit(0);", "--project-path", project], code: 0, signal: null, identity: { ...identity, pid: 124 }, treeState: "dead" }
18
+ };
19
+ const request = { sourceProject: project, id: crypto.randomUUID() };
20
+ const save = () => { for (const [key, value] of Object.entries(values)) { const f = path.join(root, key + ".json"); fs.writeFileSync(f, JSON.stringify(value)); request[key + "File"] = f; } };
21
+ save(); return { project, request, values, save, root };
22
+ }
23
+ test("publishes original bytes and hashes under reviewer-readable evidence without task mutation", t => {
24
+ const f = fixture(t); fs.mkdirSync(path.join(f.project, ".nccgs")); fs.writeFileSync(path.join(f.project, ".nccgs", "task-sentinel"), "original history and budgets");
25
+ const ref = publishEditorSessionEvidence(f.project, f.request), bytes = fs.readFileSync(path.join(f.project, ref.path)), receipt = JSON.parse(bytes);
26
+ assert.equal(hash(bytes), ref.sha256); assert.equal(receipt.origin, "external-operator-editor-evidence"); assert.equal(receipt.historicalImport, false);
27
+ for (const [key, value] of Object.entries(receipt.references)) { const copied = fs.readFileSync(path.join(f.project, value.path)); assert.equal(hash(copied), value.sha256); assert.deepEqual(copied, fs.readFileSync(f.request[key + "File"])); }
28
+ assert.equal(fs.readFileSync(path.join(f.project, ".nccgs", "task-sentinel"), "utf8"), "original history and budgets");
29
+ assert.throws(() => publishEditorSessionEvidence(f.project, f.request), /EEXIST/);
30
+ });
31
+ test("separate addendum identifies historical import and preserves the source project", t => {
32
+ const f = fixture(t), addendum = path.join(f.root, "addendum"); fs.mkdirSync(addendum);
33
+ const ref = publishEditorSessionEvidence(addendum, f.request), receipt = JSON.parse(fs.readFileSync(path.join(addendum, ref.path)));
34
+ assert.equal(receipt.historicalImport, true); assert.equal(receipt.sourceProject, f.project); assert.equal(fs.readdirSync(f.project).length, 0);
35
+ });
36
+ for (const [label, mutate] of Object.entries({
37
+ "mismatched ownership": f => { f.values.stop.identity = { ...f.values.stop.identity, pid: 999 }; },
38
+ "wrong project": f => { f.values.shutdown.args[4] = path.join(f.root, "other"); },
39
+ "live process": f => { f.values.stop.state = "alive"; },
40
+ "unknown process": f => { f.values.shutdown.treeState = "unknown"; },
41
+ "forced exit": f => { f.values.stop.forced = true; },
42
+ "failed exit": f => { f.values.stop.result.code = 1; },
43
+ "missing shutdown method": f => { f.values.shutdown.args[2] = "return 0;"; },
44
+ "reversed events": f => { f.values.shutdown.at = "2025-01-01T00:00:00Z"; }
45
+ })) test(`rejects ${label} before publishing`, t => { const f = fixture(t); mutate(f); f.save(); assert.throws(() => publishEditorSessionEvidence(f.project, f.request)); assert.equal(fs.readdirSync(f.project).length, 0); });
@@ -0,0 +1,64 @@
1
+ using System;
2
+ using System.Diagnostics;
3
+ using System.IO;
4
+ using System.IO.Pipes;
5
+ using System.Runtime.InteropServices;
6
+ using System.Threading;
7
+ using System.Threading.Tasks;
8
+
9
+ // Synthetic IPC guard fixture, not Unity execution evidence.
10
+ public static class CompilerShutdownFixture {
11
+ [DllImport("kernel32.dll")] static extern IntPtr CreateJobObject(IntPtr attributes,string name);
12
+ [DllImport("kernel32.dll")] static extern bool AssignProcessToJobObject(IntPtr job,IntPtr process);
13
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
14
+ static void Need(bool ok,string message) { if(!ok) throw new Exception(message); }
15
+ static void Exchange(IntPtr job,IntPtr process,int pid,bool expected,bool answer) {
16
+ string name="nccgs-compiler-guard-"+Guid.NewGuid().ToString("N");
17
+ using(var server=new NamedPipeServerStream(name,PipeDirection.InOut,1,PipeTransmissionMode.Byte,PipeOptions.Asynchronous)) {
18
+ var connected=server.WaitForConnectionAsync();
19
+ var receive=Task.Run(()=>{
20
+ connected.GetAwaiter().GetResult();
21
+ var length=new byte[4]; int count=server.Read(length,0,4);
22
+ if(count==0) return 0; // Refused peers must receive no request bytes.
23
+ Need(count==4 && BitConverter.ToInt32(length,0)==117,"bad request frame");
24
+ int remaining=117; var buffer=new byte[117];
25
+ while(remaining>0) { int n=server.Read(buffer,117-remaining,remaining); Need(n>0,"truncated request"); remaining-=n; }
26
+ if(answer) {
27
+ using(var writer=new BinaryWriter(server,System.Text.Encoding.Unicode,true)) {
28
+ writer.Write(8); writer.Write(3); writer.Write(Process.GetCurrentProcess().Id); writer.Flush();
29
+ }
30
+ } else Thread.Sleep(1500);
31
+ return 121;
32
+ });
33
+ bool accepted=NccgsUnityCompilerShutdown.Request(job,process,pid,name);
34
+ Need(accepted==expected,"incorrect shutdown acknowledgement");
35
+ Need(receive.Wait(3000),"pipe fixture failed to settle");
36
+ if(!expected && answer) Need(receive.Result==0,"foreign/unowned peer received shutdown bytes");
37
+ }
38
+ }
39
+ public static int Main(string[] args) {
40
+ IntPtr job=CreateJobObject(IntPtr.Zero,null);
41
+ try {
42
+ Need(job!=IntPtr.Zero,"job creation failed");
43
+ var process=Process.GetCurrentProcess();
44
+ // Golden frame captured from the installed Roslyn 4.3.1 serializer, not a
45
+ // second implementation of our own encoder.
46
+ const string golden="75000000000000000000000000000000000000002125534450340031006100350061006600390064003200630034003500390061003000360063003000370039003500620066003200310061003100630030003400360032003000300066003300370035006200660001000000257214510000000000000000";
47
+ Need(BitConverter.ToString(NccgsUnityCompilerShutdown.ShutdownFrame()).Replace("-","").ToLowerInvariant()==golden,"Roslyn protocol changed");
48
+ Exchange(job,process.Handle,process.Id,false,true); // Same PID, foreign job.
49
+ Need(AssignProcessToJobObject(job,process.Handle),"fixture job assignment failed");
50
+ Exchange(job,process.Handle,process.Id+1000000,false,true); // Wrong pipe server PID.
51
+ Exchange(job,process.Handle,process.Id,true,true); // Exact owned pipe.
52
+ Exchange(job,process.Handle,process.Id,false,false); // Missing acknowledgement stays unconfirmed.
53
+ string data=Path.Combine(args[0],"Editor","Data");
54
+ string dll=Path.Combine(data,"DotNetSdkRoslyn","VBCSCompiler.dll"),host=Path.Combine(data,"NetCoreRuntime","dotnet.exe");
55
+ Directory.CreateDirectory(Path.GetDirectoryName(dll)); Directory.CreateDirectory(Path.GetDirectoryName(host));
56
+ File.WriteAllText(dll,"unqualified binary"); File.WriteAllText(host,"unqualified host");
57
+ Need(NccgsUnityCompilerShutdown.QualifiedPipe(host,new[]{host,"exec",dll,"-pipename:safe"})==null,"unqualified binaries accepted");
58
+ Need(NccgsUnityCompilerShutdown.QualifiedPipe(host,new[]{host,"exec",dll,"-pipename:../../foreign"})==null,"invalid pipe accepted");
59
+ Need(NccgsUnityCompilerShutdown.QualifiedPipe(host,new[]{host,"exec",dll,"-pipename:safe","-shutdown"})==null,"unexpected arguments accepted");
60
+ Console.WriteLine("compiler shutdown guards PASS"); return 0;
61
+ } catch(Exception error) { Console.Error.WriteLine(error); return 1; }
62
+ finally { if(job!=IntPtr.Zero) CloseHandle(job); }
63
+ }
64
+ }
@@ -459,6 +459,44 @@ test("update replaces untouched files, preserves local overrides, and doctor gat
459
459
  }
460
460
  });
461
461
 
462
+ test("update adopts unchanged generated agents newly tracked by the package, preserving real edits", () => {
463
+ const f = safetyFixture("nccgs-generated-adoption-");
464
+ try {
465
+ const installed = f.run("install", "--model-profile", "quality");
466
+ assert.equal(installed.status, 0, installed.stderr || installed.stdout);
467
+ const relative = ".claude/agents/nccgs-game-designer.md";
468
+ const file = path.join(f.project, relative);
469
+ const stateFile = path.join(f.project, ".claude/nccgs/install-state.json");
470
+ const generatedFile = path.join(f.project, ".nccgs/agent-activation.json");
471
+ const original = fs.readFileSync(file, "utf8");
472
+ // Older packages can generate a profile's optional agent without listing it
473
+ // in install-state.files. A later package can make that agent a core file.
474
+ const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
475
+ state.files = state.files.filter(item => item.relative !== relative);
476
+ fs.writeFileSync(stateFile, JSON.stringify(state));
477
+ const update = f.run("update");
478
+ assert.equal(update.status, 0, update.stderr || update.stdout);
479
+ const doctor = f.run("doctor");
480
+ assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
481
+ assert.equal(fs.readFileSync(file, "utf8"), original);
482
+ assert.ok(JSON.parse(fs.readFileSync(stateFile)).files.some(item => item.relative === relative));
483
+
484
+ // A stored generated hash alone must never authorize replacing later edits.
485
+ const nextState = JSON.parse(fs.readFileSync(stateFile, "utf8"));
486
+ nextState.files = nextState.files.filter(item => item.relative !== relative);
487
+ fs.writeFileSync(stateFile, JSON.stringify(nextState));
488
+ assert.ok(JSON.parse(fs.readFileSync(generatedFile)).generated[relative]);
489
+ const customized = original + "\nOwner instruction: keep the custom design rule.\n";
490
+ fs.writeFileSync(file, customized);
491
+ const preserved = f.run("update");
492
+ assert.equal(preserved.status, 0, preserved.stderr || preserved.stdout);
493
+ assert.equal(fs.readFileSync(file, "utf8"), customized);
494
+ const manifest = JSON.parse(fs.readFileSync(path.join(f.project, ".nccgs/framework-overrides.json")));
495
+ assert.equal(manifest.overrides.find(item => item.path === relative).status, "pending_review");
496
+ assert.equal(f.run("doctor").status, 1);
497
+ } finally { f.dispose(); }
498
+ });
499
+
462
500
  test("doctor accepts a legacy CRLF raw hash after Git normalizes the file to LF", () => {
463
501
  const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-eol-test-"));
464
502
  try {
@@ -11,8 +11,10 @@ import { pinInputs } from "../.claude/nccgs/tools/inputs.mjs";
11
11
  import { artifact, atomicJson, hash, json } from "../.claude/nccgs/tools/policy.mjs";
12
12
  import { persistRuntimeObservation } from "../.claude/nccgs/tools/evidence.mjs";
13
13
  import { observe } from "../.claude/nccgs/tools/runtime.mjs";
14
- import { validateStandaloneRuntime, initializeStandalone, recordStandalone } from "../.claude/nccgs/tools/lifecycle.mjs";
14
+ import { validateStandaloneRuntime, initializeStandalone, recordStandalone, migrateFrameworkBaseline } from "../.claude/nccgs/tools/lifecycle.mjs";
15
15
  import { guardTool } from "../.claude/nccgs/tools/tool-guard.mjs";
16
+ import { acquireLock } from "../.claude/nccgs/tools/process-lock.mjs";
17
+ import { sessionBudgetLock } from "../.claude/nccgs/tools/session-budget.mjs";
16
18
 
17
19
  // These are synthetic framework fixtures, never Claude Code execution evidence.
18
20
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -87,6 +89,55 @@ function external(action) {
87
89
  function failRun(f, run) {
88
90
  return external(() => reconcile(f.project, run.reservation.id, { status: "failed", reason: "TEST FIXTURE process confirmed terminated", reference: "TEST FIXTURE external process-exit receipt" }));
89
91
  }
92
+ test("external framework migration proves the old inventory, rejects game changes and invalidates all approvals without quota refunds", () => {
93
+ const f = fixture();
94
+ try {
95
+ f.write('.nccgs/intake.json', { objective: 'Synthetic plan migration', mode: 'plan', analysisArtifacts: ['.nccgs/evidence/analysis/plan.md'] });
96
+ const t = initializeStandalone(f.project, 'migration', { intake: '.nccgs/intake.json' });
97
+ const beforeFiles = snapshot(f.project, t, { includeFiles: true }).fileEntries;
98
+ const run = f.start('migration'); failRun(f, run);
99
+ t.reviewBudget.used = 1; t.stages = { analysis: { fixture: true }, report: { outcome: 'partial' } }; f.write('.nccgs/tasks/migration.json', t);
100
+ const decision = '.nccgs/evidence/migration.json';
101
+ f.write(decision, { reason: 'Fixture framework patch', authority: 'Synthetic external fixture', beforeFiles });
102
+ f.write('.claude/nccgs/new-tool.mjs', '// fixture framework update');
103
+ f.write('Assets/game.cs', '// unauthorized source change');
104
+ assert.throws(() => external(() => migrateFrameworkBaseline(f.project, 'migration', decision)), /non-framework/);
105
+ f.write('Assets/game.cs', '// TEST FIXTURE source, not a game implementation\n');
106
+ process.env.CLAUDECODE = 'fixture';
107
+ try { assert.throws(() => migrateFrameworkBaseline(f.project, 'migration', decision), /external|External/); } finally { delete process.env.CLAUDECODE; }
108
+ const result = external(() => migrateFrameworkBaseline(f.project, 'migration', decision));
109
+ assert.equal(result.contractHash, t.contractHash); assert.equal(result.reviewBudget.used, 1); assert.deepEqual(result.stages, {});
110
+ assert.deepEqual(result.history.at(-1).stages, t.stages); assert.equal(result.history.at(-1).previousBaseline, t.analysisBaseline);
111
+ assert.equal(status(f.project, 'migration').usage.dispatches, 1);
112
+ assert.throws(() => external(() => migrateFrameworkBaseline(f.project, 'migration', decision)), /inventory/);
113
+ } finally { f.dispose(); }
114
+ });
115
+ test("missing worker cost admits only its live supervised allowance and keeps telemetry/limits/history", () => {
116
+ const f = fixture(), saved = [process.env.NCCGS_SUPERVISED_RUN, process.env.NCCGS_SUPERVISED_SESSION];
117
+ let lock;
118
+ try {
119
+ f.task("budget");
120
+ const run = f.start("budget", f.request("analysis", { limits: { maxCostUsd: 3 } }));
121
+ const observed = f.observation(run); complete(f.project, run.reservation.id, { runtime: observed });
122
+ assert.throws(() => reserve(f.project, "budget", f.request("analysis")), /missing costUsd/);
123
+ const id = "12345678-1234-1234-1234-123456789abc", sessionId = run.identity.sessionId;
124
+ f.write(`.nccgs/runs/${id}/run.json`, { id, project: f.project, taskId: "budget", sessionId, budgetUsd: 3, cumulativeCostUsd: 0,
125
+ deadline: Date.now() + 60000, status: "running", invocations: [{ status: "running", pid: process.pid }] });
126
+ lock = acquireLock(path.join(f.project, sessionBudgetLock), { runId: id, sessionId });
127
+ process.env.NCCGS_SUPERVISED_RUN = id; process.env.NCCGS_SUPERVISED_SESSION = sessionId;
128
+ const next = reserve(f.project, "budget", f.request("analysis"));
129
+ assert.equal(next.budgetEvidence[0].ceilingUsd, 3);
130
+ assert.throws(() => claim(f.project, next.id, { sessionId: "other", toolUseId: "next", agent: next.agent }), /owns its budget/);
131
+ const current = status(f.project, "budget"); assert.equal(current.usage.dispatches, 2); assert.equal(current.limits.maxCostUsd, 3);
132
+ assert.equal(current.telemetry.metrics.costUsd.total, null); assert.equal(current.telemetry.metrics.costUsd.measured, 0);
133
+ lock.release(); lock = null;
134
+ assert.throws(() => reserve(f.project, "budget", f.request("analysis")), /ENOENT/);
135
+ } finally {
136
+ lock?.release();
137
+ for (const [i,k] of ["NCCGS_SUPERVISED_RUN", "NCCGS_SUPERVISED_SESSION"].entries()) { if(saved[i]===undefined) delete process.env[k]; else process.env[k]=saved[i]; }
138
+ f.dispose();
139
+ }
140
+ });
90
141
  test("external dispatch extension keeps spent attempts and parent cap, and rejects live or native changes", () => {
91
142
  const f = fixture();
92
143
  try {
@@ -13,6 +13,7 @@ import { startCommand } from "../.claude/nccgs/tools/command-process.mjs";
13
13
  import { startVerification, waitVerification, jobStatus, readJob, jobDirectory } from "../.claude/nccgs/tools/verification-jobs.mjs";
14
14
  import { safeVerifyCommand, guardTool } from "../.claude/nccgs/tools/tool-guard.mjs";
15
15
  import { cumulativeCost, supervise } from "../scripts/supervise.mjs";
16
+ import { registerReviewBudget, reviewBudgetStatus } from "../.claude/nccgs/tools/review-budget.mjs";
16
17
 
17
18
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
18
19
  function fixture(timeoutMs = 5000, mode = "implement") {
@@ -213,6 +214,55 @@ test("OS-owned process group retains orphan descendants and proves their exit af
213
214
  } finally { if (host && pidState(host.pid) === "alive") host.kill("SIGKILL"); f.dispose(); }
214
215
  });
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
+
216
266
  test("orphan verification becomes a retained failed attempt without rerunning the command", () => {
217
267
  const f = fixture();
218
268
  try {
@@ -228,6 +278,21 @@ test("orphan verification becomes a retained failed attempt without rerunning th
228
278
  } finally { f.dispose(); }
229
279
  });
230
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
+
231
296
  // The fake native executable below is explicitly a fixture, never Claude evidence.
232
297
  function fakeNative(f, { missingUsage = false, omitReport = false } = {}) {
233
298
  return f.write(".nccgs/fake-native.mjs", `
@@ -240,6 +305,33 @@ else if(${!omitReport}) run(['report','--id','fixture','--outcome','partial','--
240
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'}));
241
306
  `);
242
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
+ });
243
335
  test("supervisor waits outside the model, resumes the same session once, preserves cumulative budget and receipts", async () => {
244
336
  const f = fixture();
245
337
  try {
@@ -96,6 +96,20 @@ test("compiler separates standalone ownership from preserved Astra contract", ()
96
96
  assert.doesNotMatch(outputs.get(".claude/agents/nccgs-adversarial-reviewer.md"), /^model: fable$/m);
97
97
  });
98
98
 
99
+ test("generated turn ceilings track the role template and stale documentation is detected", () => {
100
+ const target = temporary();
101
+ fs.cpSync(packageRoot, target, { recursive: true });
102
+ const role = ".claude/nccgs/agent-library/nccgs-adversarial-reviewer.md";
103
+ const template = fs.readFileSync(path.join(target, role), "utf8");
104
+ write(target, role, template.replace(/^maxTurns: \d+$/m, "maxTurns: 199"));
105
+ const outputs = generated(target), routing = outputs.get(".claude/nccgs/routing.generated.md");
106
+ assert.match(routing, /\| nccgs-adversarial-reviewer \| 199 \|/);
107
+ assert.match(outputs.get(".claude/agents/nccgs-adversarial-reviewer.md"), /^maxTurns: 199$/m);
108
+ assert.ok(compile(target).includes(".claude/nccgs/routing.generated.md"));
109
+ compile(target, true);
110
+ assert.deepEqual(compile(target), []);
111
+ });
112
+
99
113
  test("new install defaults to standalone without erasing custom settings and hooks", () => {
100
114
  const target = project();
101
115
  const custom = { matcher: "Read", hooks: [{ type: "command", command: "echo project-owned" }] };