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
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import test from 'node:test';
6
+ import { atomicJson, artifact } from '../.claude/nccgs/tools/policy.mjs';
7
+ import { acquireLock } from '../.claude/nccgs/tools/process-lock.mjs';
8
+ import { sessionBudgetCoverage, sessionBudgetLock } from '../.claude/nccgs/tools/session-budget.mjs';
9
+
10
+ // Synthetic budgeting fixtures only; no native provider execution claims.
11
+ test('session coverage retains cumulative spend and unknown worker cost; rejects uncovered, stale, corrupt and oversubscribed allowances', () => {
12
+ const temp=fs.realpathSync(os.tmpdir()), project=fs.mkdtempSync(path.join(temp,'nccgs-session-budget-'));
13
+ const write=(p,v)=>atomicJson(path.join(project,p),v);
14
+ write('.nccgs/tasks/root.json',{parentId:null});write('.nccgs/tasks/child.json',{parentId:'root'});
15
+ const old={id:'old',project,taskId:'root',sessionId:'old-session',costCoverage:'complete',cumulativeCostUsd:2.3,invocations:[]};
16
+ for(const [i,cost] of [1.1,2.3].entries()) {const p=`.nccgs/runs/old/native-${i}.jsonl`;write(p,{});fs.writeFileSync(path.join(project,p),JSON.stringify({type:'result',session_id:old.sessionId,total_cost_usd:cost})+'\n');old.invocations.push({status:'finished',log:`native-${i}.jsonl`,evidence:artifact(project,p)});}
17
+ write('.nccgs/runs/old/run.json',old);
18
+ const current={id:'current',project,taskId:'child',sessionId:'current-session',budgetUsd:9.7,cumulativeCostUsd:0,deadline:Date.now()+100000,status:'running',invocations:[{status:'running',pid:process.pid}]};
19
+ const lock=acquireLock(path.join(project,sessionBudgetLock),{runId:current.id,sessionId:current.sessionId});
20
+ const items=[{startedAt:new Date().toISOString(),sessionId:old.sessionId,telemetry:{costUsd:null}}];
21
+ const coverage=(c=current,limit=12)=>sessionBudgetCoverage(project,'root',items,limit,c,{running:false});
22
+ try {
23
+ const result=coverage();assert.equal(result.previousCostUsd,2.3);assert.equal(result.ceilingUsd,12);assert.equal(items[0].telemetry.costUsd,null);assert.equal(result.workerCostInferred,false);
24
+ assert.throws(()=>coverage({...current,budgetUsd:9.71}),/exceed aggregate/);
25
+ assert.throws(()=>coverage({...current,deadline:Date.now()-1}),/deadline/);
26
+ assert.throws(()=>coverage({...current,cumulativeCostUsd:9.7}),/exhausted/);
27
+ items.push({startedAt:'now',sessionId:'uncovered'});assert.throws(()=>coverage(),/no supervised cost coverage/);items.pop();
28
+ old.costCoverage='unknown-current-invocation';write('.nccgs/runs/old/run.json',old);assert.throws(()=>coverage(),/unknown/);
29
+ old.costCoverage='complete';old.cumulativeCostUsd=1.1;write('.nccgs/runs/old/run.json',old);assert.throws(()=>coverage(),/differs/);
30
+ old.cumulativeCostUsd=2.3;write('.nccgs/runs/old/run.json',old);
31
+ fs.appendFileSync(path.join(project,'.nccgs/runs/old/native-1.jsonl'),'tamper');assert.throws(()=>coverage(),/stale artifact/);
32
+ lock.release();assert.throws(()=>coverage());
33
+ } finally {lock.release();assert.equal(path.dirname(project),temp);assert.ok(path.basename(project).startsWith('nccgs-session-budget-'));fs.rmSync(project,{recursive:true,force:true});}
34
+ });
@@ -0,0 +1,285 @@
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 { initializeStandalone, recordStandalone } from "../.claude/nccgs/tools/lifecycle.mjs";
9
+ import { reserve, status, planTaskSlices, taskSliceStatus, rejectUnclaimed, dispatchPrompt } from "../.claude/nccgs/tools/orchestration.mjs";
10
+ import { snapshot } from "../.claude/nccgs/tools/gates.mjs";
11
+ import { observe } from "../.claude/nccgs/tools/runtime.mjs";
12
+ import { guardTool } from "../.claude/nccgs/tools/tool-guard.mjs";
13
+ import { run as taskRun } from "../.claude/nccgs/tools/task.mjs";
14
+ import { slicePromptContext } from "../.claude/nccgs/tools/task-slices.mjs";
15
+
16
+ // All workers, model observations, design documents and measurements here are
17
+ // SYNTHETIC fixtures. No Claude provider or Unity process is launched.
18
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
19
+ function fixture(parent = false) {
20
+ const temporaryRoot = fs.realpathSync(os.tmpdir()), project = fs.mkdtempSync(path.join(temporaryRoot, "nccgs-slices-"));
21
+ const write = (relative, value) => {
22
+ const file = path.join(project, relative); fs.mkdirSync(path.dirname(file), { recursive: true });
23
+ fs.writeFileSync(file, typeof value === "string" || Buffer.isBuffer(value) ? value : JSON.stringify(value)); return artifact(project, relative);
24
+ };
25
+ for (const file of ["pipeline.json", "studio.json"]) write(`.claude/nccgs/${file}`, fs.readFileSync(path.join(root, ".claude/nccgs", file)));
26
+ write(".nccgs/project.yaml", "schema_version: 2\nmodels:\n profile: claude-standalone\n");
27
+ write(".nccgs/execution.json", { schemaVersion: 1, name: "standalone", externalReview: { mode: "off" }, acceptanceRequired: false,
28
+ requireObservedEffort: false, reviewContractRequired: true, maxParallelWorkers: 2 });
29
+ write(".nccgs/requirements.yaml", "schema_version: 1\nrequirements: []\n");
30
+ write("Assets/first.cs", "// SYNTHETIC first file\n"); write("Assets/second.cs", "// SYNTHETIC second file\n");
31
+ const acceptance = ["first", "second"].map(name => ({ id: `AC-${name}`, description: `Synthetic ${name} acceptance`, check: "integration-check",
32
+ assertions: [{ artifact: ".nccgs/evidence/measurements/result.json", pointer: `/${name}`, op: "eq", value: true }] }));
33
+ acceptance.push({ id: "OWNER", description: "Synthetic mandatory owner decision", manual: true });
34
+ write(".nccgs/intake.json", { schemaVersion: 1, mode: "implement", objective: "SYNTHETIC large task", scope: "feature", target: "production-change", readiness: "ready",
35
+ acceptance, requiredChecks: ["integration-check"], analysisArtifacts: [".nccgs/evidence/analysis/review-1.md", ".nccgs/evidence/analysis/review-2.md", ".nccgs/evidence/analysis/review-3.md"] });
36
+ if (parent) initializeStandalone(project, "parent", { intake: ".nccgs/intake.json" });
37
+ initializeStandalone(project, "large", { intake: ".nccgs/intake.json", ...(parent ? { parentId: "parent" } : {}) });
38
+ write(".nccgs/runtime/bindings/synthetic-session.json", { taskId: "large" });
39
+ const task = () => json(path.join(project, ".nccgs/tasks/large.json"));
40
+ const snap = () => snapshot(project, task()).sha256;
41
+ const draft = ".nccgs/evidence/slice-plans/plan.json";
42
+ const plan = () => ({ schemaVersion: 1, taskId: "large", contractHash: task().contractHash, sourceSnapshot: snap(), planId: "plan-one", supersedes: null,
43
+ reason: "SYNTHETIC bounded decomposition", integration: "integrate", slices: [
44
+ { id: "first", objective: "Implement the first bounded part", agent: "nccgs-gameplay-programmer", dependsOn: [], writePaths: ["Assets/first.cs"], criteria: ["AC-first"] },
45
+ { id: "integrate", objective: "Implement second part and integrate the full result", agent: "nccgs-gameplay-programmer", dependsOn: ["first"], writePaths: ["Assets/second.cs"], criteria: ["AC-second", "OWNER"] }
46
+ ] });
47
+ const register = (p = plan()) => { write(draft, p); return planTaskSlices(project, "large", draft, true); };
48
+ let sequence = 0;
49
+ const start = (request, agentId) => {
50
+ const dispatch = reserve(project, "large", request), agent = agentId ?? `synthetic-agent-${++sequence}`;
51
+ const envelope = { hook_event_name: "PreToolUse", session_id: "synthetic-session", tool_name: "Agent", tool_use_id: `tool-${dispatch.id}`,
52
+ tool_input: { subagent_type: request.agent, prompt: `[NCCGS-DISPATCH:${dispatch.id}] Caller prose cannot replace the slice contract` } };
53
+ const prompt = guardTool(project, envelope)?.updatedInput?.prompt;
54
+ observe(project, { session_id: "synthetic-session", agent_id: agent, agent_type: request.agent }, "agent_start");
55
+ return { dispatch, request, envelope, agentId: agent, prompt };
56
+ };
57
+ const finish = (run, { partial = false, model = "claude-opus-5-5" } = {}) => observe(project, { ...run.envelope, tool_response: {
58
+ agentId: run.agentId, status: "completed", resolvedModel: model, modelsUsed: model ? [model] : [], totalDurationMs: 123, totalTokens: 45,
59
+ totalToolUseCount: 3, content: partial ? "SYNTHETIC partial" : "SYNTHETIC completed" } }, "agent_result");
60
+ const deliver = (request = taskSliceStatus(project, "large").next, options = {}) => {
61
+ const run = start(request);
62
+ if (!options.partial) for (const [index, file] of request.writePaths.entries()) {
63
+ guardTool(project, { session_id: "synthetic-session", agent_id: run.agentId, tool_use_id: `write-${run.dispatch.id}-${index}`, tool_name: "Write", tool_input: { file_path: file, content: "synthetic" } });
64
+ write(file, `// SYNTHETIC change from ${run.dispatch.id}\n`);
65
+ }
66
+ write(request.handoffPaths[0], `NCCGS-HANDOFF: ${options.partial ? "PARTIAL" : "COMPLETE"}\nSynthetic handoff, never acceptance proof.`);
67
+ return { ...run, observed: finish(run, options) };
68
+ };
69
+ const verify = (second = true) => {
70
+ write(".nccgs/verify.mjs", `import fs from 'node:fs'; fs.mkdirSync('.nccgs/evidence/measurements',{recursive:true}); fs.writeFileSync('.nccgs/evidence/measurements/result.json',JSON.stringify({first:true,second:${second}})); console.log('SYNTHETIC measurement fixture only');`);
71
+ return taskRun(["verify", "--project", project, "--id", "large", "--name", "integration-check", "--", process.execPath, ".nccgs/verify.mjs"]);
72
+ };
73
+ const stage = (run, verification) => ({ snapshot: run.observed.snapshot, dispatchId: run.dispatch.id, runtime: artifact(project, run.observed.path),
74
+ checks: [{ name: "integration-check", status: verification.status, evidence: verification.evidence }] });
75
+ const review = (number = 1, agentId, verdict = "PASS", conditions = []) => {
76
+ const file = `.nccgs/evidence/analysis/review-${number}.md`;
77
+ const run = start({ kind: "review", agent: "nccgs-adversarial-reviewer", requestKey: `review-${number}`, reason: "SYNTHETIC independent integrated review", writePaths: [], handoffPaths: [file] }, agentId);
78
+ write(file, `NCCGS-HANDOFF: COMPLETE\n\n\`\`\`nccgs-review\n${JSON.stringify({ schemaVersion: 1, taskId: "large", contractHash: task().contractHash, snapshot: snap(),
79
+ verdict, criteriaMet: verdict === "PASS", findings: [], conditions })}\n\`\`\`\n`);
80
+ const observed = finish(run);
81
+ return { ...run, observed, record: () => taskRun(["record-review", "--project", project, "--id", "large", "--dispatch", run.dispatch.id, "--file", file]) };
82
+ };
83
+ return { project, write, task, snap, draft, plan, register, start, finish, deliver, verify, stage, review,
84
+ dispose() {
85
+ assert.equal(path.dirname(project).toLowerCase(), temporaryRoot.toLowerCase()); assert.ok(path.basename(project).startsWith("nccgs-slices-"));
86
+ assert.equal(fs.lstatSync(project).isSymbolicLink(), false); assert.equal(fs.realpathSync(project).toLowerCase(), project.toLowerCase());
87
+ fs.rmSync(project, { recursive: true, force: true });
88
+ } };
89
+ }
90
+
91
+ test("slice preview/registration preserve the full task and budgets; injected contexts select original criteria", () => {
92
+ const f = fixture();
93
+ try {
94
+ const original = fs.readFileSync(path.join(f.project, ".nccgs/tasks/large.json")), plan = f.plan(); f.write(f.draft, plan);
95
+ const preview = planTaskSlices(f.project, "large", f.draft);
96
+ assert.equal(preview.status, "PREVIEW"); assert.equal(fs.existsSync(path.join(f.project, ".nccgs/orchestration.json")), false);
97
+ assert.equal(preview.budgets[0].minimumRemainingSlices, 2); assert.equal(preview.budgets[0].protectedSlots, 4);
98
+ const saved = f.register(plan), ledger = fs.readFileSync(path.join(f.project, ".nccgs/orchestration.json"));
99
+ assert.equal(f.register(plan).status, "ALREADY_REGISTERED");
100
+ assert.deepEqual(fs.readFileSync(path.join(f.project, ".nccgs/orchestration.json")), ledger);
101
+ assert.deepEqual(fs.readFileSync(path.join(f.project, ".nccgs/tasks/large.json")), original);
102
+ assert.equal(status(f.project, "large").usage.dispatches, 0);
103
+ const run = f.start(taskSliceStatus(f.project, "large").next);
104
+ assert.ok(run.prompt.startsWith(`[NCCGS-DISPATCH:${run.dispatch.id}]`));
105
+ assert.match(run.prompt, /AC-first/); assert.doesNotMatch(run.prompt, /AC-second/);
106
+ assert.match(run.prompt, /same task.*shared budget/i);
107
+ const contextTask = structuredClone(f.task());
108
+ contextTask.contract.design = { document: f.write(".nccgs/evidence/synthetic-design.json", { requirements: [
109
+ { id: "REQ-A", criteria: ["AC-first"], rules: "Preserve exact fixed rule A" }, { id: "REQ-B", criteria: ["AC-second"], rules: "Fixed rule B" }
110
+ ], decisions: [{ resolution: "Keep the global decision" }], outOfScope: ["No product changes"] }) };
111
+ const context = slicePromptContext(f.project, contextTask, run.dispatch);
112
+ assert.match(context, /Preserve exact fixed rule A/); assert.doesNotMatch(context, /Fixed rule B/); assert.match(context, /Keep the global decision/);
113
+ assert.throws(() => guardTool(f.project, { session_id: "synthetic-session", tool_name: "Write", tool_input: { file_path: saved.reference.path } }), /immutable/);
114
+ assert.throws(() => guardTool(f.project, { session_id: "synthetic-session", agent_id: run.agentId, tool_name: "Write", tool_input: { file_path: "Assets/second.cs" } }), /exact planned file/);
115
+ } finally { f.dispose(); }
116
+ });
117
+
118
+ test("slices reject omitted criteria, scope expansion, cycles and unaffordable decomposition without mutation", () => {
119
+ const f = fixture();
120
+ try {
121
+ const base = f.plan();
122
+ const variants = [
123
+ { ...base, acceptance: [] }, { ...base, budgets: { maxDispatches: 999 } }, { ...base, risk: "FAST" }, { ...base, sourceSnapshot: "stale" },
124
+ { ...base, slices: [base.slices[0], { ...base.slices[1], criteria: ["AC-second"] }] },
125
+ { ...base, slices: [base.slices[0], { ...base.slices[1], criteria: ["AC-first", "AC-second", "OWNER"] }] },
126
+ { ...base, slices: [{ ...base.slices[0], dependsOn: ["integrate"] }, base.slices[1]] },
127
+ { ...base, slices: [base.slices[0], { ...base.slices[1], dependsOn: [] }] },
128
+ { ...base, slices: [{ ...base.slices[0], writePaths: ["Assets"] }, base.slices[1]] },
129
+ { ...base, slices: [{ ...base.slices[0], writePaths: [".nccgs/tasks/large.json"] }, base.slices[1]] },
130
+ { ...base, slices: [{ ...base.slices[0], agent: "nccgs-adversarial-reviewer" }, base.slices[1]] }
131
+ ];
132
+ const many = Array.from({ length: 9 }, (_, i) => ({ id: `part-${i}`, objective: "Synthetic small unit", agent: "nccgs-gameplay-programmer",
133
+ dependsOn: i === 8 ? Array.from({ length: 8 }, (_, j) => `part-${j}`) : [], writePaths: [`Assets/part-${i}.cs`], criteria: i === 0 ? ["AC-first", "AC-second", "OWNER"] : [] }));
134
+ variants.push({ ...base, slices: many, integration: "part-8" });
135
+ for (const plan of variants) {
136
+ f.write(f.draft, plan); assert.throws(() => planTaskSlices(f.project, "large", f.draft, true));
137
+ assert.equal(fs.existsSync(path.join(f.project, ".nccgs/orchestration.json")), false);
138
+ }
139
+ f.register(); const before = fs.readFileSync(path.join(f.project, ".nccgs/orchestration.json"));
140
+ const request = taskSliceStatus(f.project, "large").next;
141
+ for (const changed of [{ ...request, sliceId: "integrate", agent: base.slices[1].agent, writePaths: base.slices[1].writePaths },
142
+ { ...request, sliceId: undefined }, { ...request, writePaths: ["Assets"] }, { ...request, agent: "nccgs-engine-programmer" }]) {
143
+ assert.throws(() => reserve(f.project, "large", changed));
144
+ assert.deepEqual(fs.readFileSync(path.join(f.project, ".nccgs/orchestration.json")), before);
145
+ }
146
+ } finally { f.dispose(); }
147
+ });
148
+
149
+ test("integrated slices require current full measurements, independent review of every contributor and original manual acceptance", () => {
150
+ const f = fixture();
151
+ try {
152
+ const original = f.task(); f.register();
153
+ const first = f.deliver(), earlyCheck = f.verify();
154
+ assert.throws(() => recordStandalone(f.project, f.task(), "implementation", f.stage(first, earlyCheck)), /All planned slices/);
155
+ assert.throws(() => f.review(), /implementation.*missing|stale/);
156
+ const integrated = f.deliver();
157
+ assert.match(integrated.prompt, /AC-first/); assert.match(integrated.prompt, /AC-second/); assert.match(integrated.prompt, /OWNER/);
158
+ assert.throws(() => recordStandalone(f.project, f.task(), "implementation", f.stage(integrated, earlyCheck)), /Verification execution failed or stale/);
159
+ const failedMeasure = f.verify(false);
160
+ recordStandalone(f.project, f.task(), "implementation", f.stage(integrated, failedMeasure));
161
+ const negative = f.review(1, undefined, "CHANGES_REQUIRED", [{ id: "C1", description: "Second original criterion is still false" }]);
162
+ negative.record(); assert.equal(f.task().stages.review.conditions[0].id, "C1");
163
+ assert.throws(() => recordStandalone(f.project, f.task(), "report", { outcome: "success", summary: "Cannot waive criterion" }), /criteria failed|not passed/);
164
+ // A fresh measured check, without mutating source or historical observations.
165
+ f.verify(true);
166
+ const formerContributor = f.review(2, first.agentId);
167
+ assert.throws(() => formerContributor.record(), /independent of every implementation contributor/);
168
+ const independent = f.review(3); independent.record();
169
+ assert.throws(() => reserve(f.project, "large", { ...first.request, requestKey: "no-extra-review",
170
+ handoffPaths: [".nccgs/evidence/analysis/no-extra-review.md"] }), /independent-review budget exhausted/);
171
+ recordStandalone(f.project, f.task(), "report", { outcome: "success", summary: "Synthetic technical result, manual decision pending" });
172
+ assert.throws(() => taskRun(["close", "--project", f.project, "--id", "large"]), /acceptance: missing/);
173
+ assert.deepEqual(f.task().contract, original.contract);
174
+ assert.equal(f.task().reviewBudget.limit, original.reviewBudget.limit); assert.equal(f.task().reviewBudget.used, 2);
175
+ assert.ok(f.task().history.some(h => h.stages?.review?.verdict === "CHANGES_REQUIRED"));
176
+ assert.equal(status(f.project, "large").usage.dispatches, 5);
177
+ } finally { f.dispose(); }
178
+ });
179
+
180
+ test("slice retries and revisions preserve failed attempts, invalidate dependent work and never reset budget", () => {
181
+ const f = fixture();
182
+ try {
183
+ const plan = f.plan(); f.register(plan);
184
+ const failed = f.deliver(undefined, { partial: true });
185
+ assert.equal(taskSliceStatus(f.project, "large").slices[0].state, "PARTIAL");
186
+ const revision = { ...plan, planId: "plan-two", supersedes: "plan-one", sourceSnapshot: f.snap(), reason: "Refine only unattempted integration", slices: structuredClone(plan.slices) };
187
+ revision.slices[1].objective = "Refined integration assignment";
188
+ f.register(revision); assert.equal(taskSliceStatus(f.project, "large").slices[0].attempts[0].dispatchId, failed.dispatch.id);
189
+ const changed = structuredClone(revision); changed.planId = "plan-three"; changed.supersedes = "plan-two";
190
+ changed.slices[0].id = "hide-first"; changed.slices[1].dependsOn = ["hide-first"];
191
+ assert.throws(() => f.register(changed), /attempted slice first cannot be removed or renamed/);
192
+ const first = f.deliver(), second = f.deliver();
193
+ assert.equal(taskSliceStatus(f.project, "large").slices[1].state, "DELIVERED");
194
+ const retry = { ...first.request, requestKey: "explicit-first-retry", handoffPaths: [".nccgs/evidence/analysis/first-retry.md"] };
195
+ f.deliver(retry);
196
+ const progress = taskSliceStatus(f.project, "large");
197
+ assert.equal(progress.slices[1].state, "STALE"); assert.equal(progress.next.sliceId, "integrate");
198
+ assert.equal(progress.slices[1].dispatchId, second.dispatch.id);
199
+ assert.equal(status(f.project, "large").usage.dispatches, 4);
200
+ const expanded = structuredClone(revision); expanded.planId = "plan-three"; expanded.supersedes = "plan-two"; expanded.sourceSnapshot = f.snap();
201
+ expanded.reason = "Explicitly refine the attempted slice without resetting its history"; expanded.slices[0].writePaths.push("Assets/focused-regression.cs");
202
+ f.register(expanded);
203
+ assert.equal(taskSliceStatus(f.project, "large").slices[0].state, "STALE");
204
+ assert.equal(taskSliceStatus(f.project, "large").slices[0].attempts.length, 3);
205
+ f.write(f.draft, plan); assert.equal(planTaskSlices(f.project, "large", f.draft, true).status, "SUPERSEDED");
206
+ assert.equal(taskSliceStatus(f.project, "large").planId, "plan-three");
207
+ assert.equal(f.task().history.length, 0); // Planning never rewrites lifecycle history/stages.
208
+ } finally { f.dispose(); }
209
+ });
210
+
211
+ test("slice allocation protects ancestor dispatch, cost and duration budgets", () => {
212
+ for (const metric of ["cost", "duration"]) {
213
+ const f = fixture(true);
214
+ try {
215
+ const request = { kind: "analysis", agent: "nccgs-task-scout", requestKey: "prior-parent", reason: "SYNTHETIC existing parent allocation", writePaths: [],
216
+ limits: { maxDispatches: 7, ...(metric === "cost" ? { maxCostUsd: 1 } : { maxDurationMs: 100 }) } };
217
+ const old = reserve(f.project, "parent", request);
218
+ rejectUnclaimed(f.project, old.id, { sessionId: "never-launched", toolUseId: "old", agent: request.agent, reason: "SYNTHETIC rejected launch, no refund" });
219
+ const registered = f.register();
220
+ assert.equal(registered.budgets.find(b => b.taskId === "parent").spareDispatches, 0);
221
+ assert.throws(() => reserve(f.project, "parent", { ...request, requestKey: "steal-integration" }), /cannot fit/);
222
+ assert.equal(status(f.project, "parent").usage.dispatches, 1);
223
+ f.deliver();
224
+ assert.throws(() => reserve(f.project, "large", taskSliceStatus(f.project, "large").next), metric === "cost" ? /missing costUsd telemetry/ : /maxDurationMs budget exhausted/);
225
+ assert.equal(status(f.project, "parent").limits[metric === "cost" ? "maxCostUsd" : "maxDurationMs"], metric === "cost" ? 1 : 100);
226
+ assert.equal(status(f.project, "parent").limits.maxDispatches, 7);
227
+ assert.equal(status(f.project, "parent").usage.dispatches, 2);
228
+ } finally { f.dispose(); }
229
+ }
230
+ });
231
+
232
+ test("integrated task slices retain the existing bounded repair and independent re-review path", () => {
233
+ const f = fixture();
234
+ try {
235
+ const original = f.task(); f.register(); f.deliver(); const integrated = f.deliver();
236
+ recordStandalone(f.project, f.task(), "implementation", f.stage(integrated, f.verify(false)));
237
+ f.review(1, undefined, "CHANGES_REQUIRED", [{ id: "FIX", description: "Correct second behavior without changing its criterion" }]).record();
238
+ const workPacket = ".nccgs/evidence/work-packets/repair.json";
239
+ f.write(workPacket, { schemaVersion: 1, taskId: "large", contractHash: f.task().contractHash,
240
+ sourceSnapshot: f.snap(), problem: "Synthetic second behavior fails the original criterion", solution: "Correct only second behavior",
241
+ changes: [{ path: "Assets/second.cs", instruction: "Apply the chosen correction" }], context: [], verification: ["integration-check"],
242
+ budget: { workCalls: 12, readCalls: 6, handoffCalls: 3, durationMs: 600000 } });
243
+ const repaired = f.deliver({ kind: "implementation", purpose: "repair", agent: "nccgs-gameplay-programmer", requestKey: "bounded-repair",
244
+ reason: "SYNTHETIC repair against recorded feedback", writePaths: ["Assets/second.cs"], workPacket,
245
+ handoffPaths: [".nccgs/evidence/analysis/repair-result.md"] });
246
+ recordStandalone(f.project, f.task(), "implementation", f.stage(repaired, f.verify(true)));
247
+ f.review(2).record();
248
+ assert.equal(f.task().stages.implementation.dispatchId, repaired.dispatch.id);
249
+ assert.equal(f.task().stages.review.verdict, "PASS");
250
+ assert.equal(f.task().reviewBudget.used, 2); assert.equal(f.task().reviewBudget.limit, original.reviewBudget.limit);
251
+ assert.deepEqual(f.task().contract, original.contract);
252
+ assert.ok(f.task().history.some(h => h.stages?.review?.verdict === "CHANGES_REQUIRED"));
253
+ assert.equal(taskSliceStatus(f.project, "large").next, null);
254
+ assert.equal(status(f.project, "large").usage.dispatches, 5);
255
+ } finally { f.dispose(); }
256
+ });
257
+
258
+ test("slice closure rejects missing native model, tampered handoffs or deleted plan history", () => {
259
+ for (const variant of ["model", "handoff", "plan", "history"]) {
260
+ const f = fixture();
261
+ try {
262
+ const registration = f.register(), delivered = f.deliver(undefined, { model: variant === "model" ? null : "claude-opus-5-5" });
263
+ if (variant === "handoff") f.write(delivered.request.handoffPaths[0], "SYNTHETIC tampering");
264
+ if (variant === "plan") f.write(registration.reference.path, "{}");
265
+ if (variant === "history") {
266
+ const ledger = json(path.join(f.project, ".nccgs/orchestration.json")); delete ledger.tasks.large.slicePlan; f.write(".nccgs/orchestration.json", ledger);
267
+ }
268
+ if (["model", "handoff"].includes(variant)) {
269
+ const state = taskSliceStatus(f.project, "large"); assert.equal(state.slices[0].state, "BLOCKED");
270
+ assert.equal(state.slices[1].ready, false);
271
+ } else assert.throws(() => taskSliceStatus(f.project, "large"), /stale artifact|history missing/);
272
+ } finally { f.dispose(); }
273
+ }
274
+ });
275
+
276
+ test("slice commands reject budget overrides and workers cannot register plans", () => {
277
+ const f = fixture();
278
+ try {
279
+ f.register(); const run = f.start(taskSliceStatus(f.project, "large").next);
280
+ for (const extra of [["--file", "second.json"], ["--budget", "999"], ["--", "node"]]) assert.throws(() => taskRun([
281
+ "slice-preview", "--project", f.project, "--id", "large", "--file", f.draft, ...extra]), /unique/);
282
+ assert.throws(() => guardTool(f.project, { session_id: "synthetic-session", agent_id: run.agentId, tool_name: "Bash", tool_input: {
283
+ command: `node .claude/nccgs/tools/task.mjs slice-plan --id large --file ${f.draft}` } }), /Coordinator owns lifecycle/);
284
+ } finally { f.dispose(); }
285
+ });
@@ -0,0 +1,140 @@
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 test from 'node:test';
6
+ import {fileURLToPath} from 'node:url';
7
+ import {initialize, snapshot} from '../.claude/nccgs/tools/gates.mjs';
8
+ import {inputPolicy} from '../.claude/nccgs/tools/inputs.mjs';
9
+ import {json} from '../.claude/nccgs/tools/policy.mjs';
10
+ import {startVerification, waitVerification} from '../.claude/nccgs/tools/verification-jobs.mjs';
11
+ const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
12
+ const outputs=['Game.csproj','Game.Tests.csproj','Game.slnx','.vscode/extensions.json','.vscode/launch.json','.vscode/settings.json','UserSettings/EditorUserSettings.asset','UserSettings/Search.settings'];
13
+ function fixture(enabled=true) {
14
+ const parent=fs.realpathSync(os.tmpdir()),project=fs.mkdtempSync(path.join(parent,'nccgs-unity-inputs-'));
15
+ const write=(relative,value)=>{const file=path.join(project,relative);fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,typeof value==='string'?value:JSON.stringify(value));};
16
+ fs.cpSync(path.join(root,'.claude/nccgs/tools'),path.join(project,'.claude/nccgs/tools'),{recursive:true});
17
+ for(const name of ['pipeline.json','studio.json'])write('.claude/nccgs/'+name,fs.readFileSync(path.join(root,'.claude/nccgs',name),'utf8'));
18
+ write('Assets/Game.cs','// unchanged game source');
19
+ write('Packages/manifest.json',{dependencies:{}});
20
+ write('ProjectSettings/ProjectVersion.txt','m_EditorVersion: 6000.3.17f1\n');
21
+ write('.nccgs/project.yaml','schema_version: 2\nmodels:\n profile: claude-standalone\n');
22
+ write('.nccgs/execution.json',{schemaVersion:1,name:'standalone',externalReview:{mode:'off'}});
23
+ const policy={schemaVersion:1,canonicalInputs:[],outputDirectories:[],checks:{fixture:{timeoutMs:15000}},...(enabled?{unityGeneratedFiles:outputs}:{})};
24
+ write('.nccgs/inputs.json',policy);
25
+ write('.nccgs/intake.json',{objective:'Synthetic source protection check, not Unity/provider execution',mode:'implement',requiredChecks:['fixture']});
26
+ initialize(project,'fixture',null,null,null,{intake:'.nccgs/intake.json'});
27
+ let count=0;
28
+ const run=(code,key='attempt-'+(++count))=>startVerification(project,'fixture',{name:'fixture',executable:process.execPath,arguments:['-e',code],requestKey:key});
29
+ const finish=job=>{const result=waitVerification(project,job.jobId,'fixture',20000);return {result,receipt:json(path.join(project,result.evidence.path))};};
30
+ const dispose=()=>{assert.equal(path.dirname(fs.realpathSync(project)),parent);assert.ok(path.basename(project).startsWith('nccgs-unity-inputs-'));fs.rmSync(project,{recursive:true,force:true});};
31
+ return {project,write,policy,run,finish,dispose};
32
+ }
33
+ const generation=`const fs=require('node:fs'),path=require('node:path');for(const name of ${JSON.stringify(outputs)}){fs.mkdirSync(path.dirname(name),{recursive:true});fs.writeFileSync(name,'synthetic generated metadata');}`;
34
+
35
+ test('Unity outputs are opt-in, exact paths, and their creation remains auditable',()=>{
36
+ const f=fixture();
37
+ try {
38
+ const before=snapshot(f.project);
39
+ for(const name of outputs)f.write(name,'synthetic generated metadata');
40
+ const after=snapshot(f.project);
41
+ assert.equal(after.sha256,before.sha256);
42
+ assert.equal(after.sourceSha256,before.sourceSha256);
43
+ assert.equal(before.unityGeneratedFiles.every(file=>file.sha256===null),true);
44
+ assert.equal(after.unityGeneratedFiles.every(file=>/^[a-f0-9]{64}$/.test(file.sha256)),true);
45
+ const protectedFiles=['Assets/new.cs','Assets/Scene.unity','Assets/Scene.unity.meta','Assets/test.asmdef','Assets/Tests/Test.cs',
46
+ 'Packages/new.json','ProjectSettings/EditorBuildSettings.asset','Tools/Build.csproj','Custom.csproj','Custom.sln',
47
+ '.vscode/tasks.json','.vscode/custom.json','UserSettings/source.cs','UserSettings/custom.asset','new.cs'];
48
+ for(const name of protectedFiles) {
49
+ f.write(name,'unexpected change');
50
+ assert.notEqual(snapshot(f.project).sha256,before.sha256,name);
51
+ fs.unlinkSync(path.join(f.project,name));
52
+ assert.equal(snapshot(f.project).sha256,before.sha256);
53
+ }
54
+ f.write('Assets/Game.cs','// mutation');assert.notEqual(snapshot(f.project).sha256,before.sha256);
55
+ fs.unlinkSync(path.join(f.project,'Assets/Game.cs'));assert.notEqual(snapshot(f.project).sha256,before.sha256);
56
+ } finally {f.dispose();}
57
+ });
58
+
59
+ test('Existing policy without Unity outputs preserves its old strict snapshot semantics',()=>{
60
+ const f=fixture(false);
61
+ try {
62
+ const before=snapshot(f.project);
63
+ assert.equal(before.unityGeneratedFiles,undefined);
64
+ for(const name of outputs) {
65
+ f.write(name,'metadata remains tracked');assert.notEqual(snapshot(f.project).sha256,before.sha256);
66
+ fs.unlinkSync(path.join(f.project,name));assert.equal(snapshot(f.project).sha256,before.sha256);
67
+ }
68
+ } finally {f.dispose();}
69
+ });
70
+
71
+ test('Unity metadata policy rejects source paths, globs, directories, overlaps and malformed declarations',()=>{
72
+ const f=fixture();
73
+ try {
74
+ for(const value of [null,true,'*.csproj',['Assets/Game.cs'],['Assets/Game.csproj'],['Tools/Build.csproj'],['ProjectSettings/EditorBuildSettings.asset'],
75
+ ['Packages/manifest.json'],['.vscode/tasks.json'],['UserSettings'],['*.csproj'],['../Game.csproj'],['C:/Game.csproj'],
76
+ ['Game.csproj','game.csproj'],Array(257).fill('Game.csproj')]) {
77
+ f.write('.nccgs/inputs.json',{...f.policy,unityGeneratedFiles:value});assert.throws(()=>inputPolicy(f.project),undefined,JSON.stringify(value));
78
+ }
79
+ for(const canonical of ['Game.csproj','.vscode','UserSettings/EditorUserSettings.asset','GAME.CSPROJ']) {
80
+ f.write('.nccgs/inputs.json',{...f.policy,canonicalInputs:[canonical]});assert.throws(()=>inputPolicy(f.project),/overlap/);
81
+ }
82
+ f.write('.nccgs/inputs.json',f.policy);fs.mkdirSync(path.join(f.project,'Game.csproj'));
83
+ assert.throws(()=>inputPolicy(f.project),/not a file/);
84
+ } finally {f.dispose();}
85
+ });
86
+
87
+ test('Unity metadata declarations cannot apply to non-Unity projects or silently refresh task inputs',()=>{
88
+ const f=fixture();
89
+ try {
90
+ const before=snapshot(f.project),taskPath=path.join(f.project,'.nccgs/tasks/fixture.json'),original=fs.readFileSync(taskPath);
91
+ f.write('.nccgs/inputs.json',{...f.policy,unityGeneratedFiles:outputs.slice(1)});
92
+ const after=snapshot(f.project);
93
+ assert.notEqual(after.sha256,before.sha256);assert.notEqual(after.canonicalInputSha256,before.canonicalInputSha256);
94
+ assert.deepEqual(fs.readFileSync(taskPath),original);
95
+ fs.unlinkSync(path.join(f.project,'ProjectSettings/ProjectVersion.txt'));
96
+ assert.throws(()=>snapshot(f.project),/requires a Unity project/);
97
+ } finally {f.dispose();}
98
+ });
99
+
100
+ test('Managed first-run generation passes with a frozen output delta and idempotent request',()=>{
101
+ const f=fixture();
102
+ try {
103
+ const job=f.run(generation,'first-run'),{result,receipt}=f.finish(job);
104
+ assert.equal(result.status,'PASS');assert.equal(receipt.sourceUnchanged,true);
105
+ assert.equal(receipt.unityGeneratedFiles.changes.length,outputs.length);
106
+ assert.ok(receipt.unityGeneratedFiles.changes.every(c=>c.beforeSha256===null&&/^[a-f0-9]{64}$/.test(c.afterSha256)));
107
+ const original=fs.readFileSync(path.join(f.project,result.evidence.path));
108
+ assert.deepEqual(f.run(generation,'first-run').evidence,result.evidence);
109
+ f.write(outputs[0],'another generated version');
110
+ assert.deepEqual(fs.readFileSync(path.join(f.project,result.evidence.path)),original);
111
+ const next=f.finish(f.run("require('node:fs').writeFileSync('Game.csproj','next version')"));
112
+ assert.equal(next.result.status,'PASS');assert.equal(next.receipt.unityGeneratedFiles.changes.length,1);
113
+ assert.notEqual(next.receipt.unityGeneratedFiles.changes[0].beforeSha256,next.receipt.unityGeneratedFiles.changes[0].afterSha256);
114
+ } finally {f.dispose();}
115
+ });
116
+
117
+ test('Generated files never mask real source changes or undeclared IDE writes in managed verification',()=>{
118
+ const f=fixture();
119
+ try {
120
+ const changed=f.finish(f.run(generation+"require('node:fs').writeFileSync('Assets/Game.cs','changed source')"));
121
+ assert.equal(changed.result.status,'FAIL');assert.equal(changed.receipt.sourceUnchanged,false);
122
+ assert.equal(changed.receipt.unityGeneratedFiles.changes.length,outputs.length);
123
+ const ide=f.finish(f.run("require('node:fs').writeFileSync('.vscode/tasks.json','custom tool config')"));
124
+ assert.equal(ide.result.status,'FAIL');assert.equal(ide.receipt.sourceUnchanged,false);
125
+ const exit=f.finish(f.run('process.exitCode=7;'));
126
+ assert.equal(exit.result.status,'FAIL');assert.equal(exit.receipt.exitCode,7);
127
+ } finally {f.dispose();}
128
+ });
129
+
130
+ test('Generated-only writes cannot mask a managed timeout',()=>{
131
+ const f=fixture();
132
+ try {
133
+ const job=startVerification(f.project,'fixture',{name:'fixture',executable:process.execPath,
134
+ arguments:['-e',generation+'setTimeout(()=>{},30000);'],timeoutMs:3000,requestKey:'generated-timeout'});
135
+ const {result,receipt}=f.finish(job);
136
+ assert.equal(result.status,'FAIL');assert.equal(receipt.timedOut,true);
137
+ assert.equal(receipt.sourceUnchanged,true);
138
+ assert.equal(receipt.unityGeneratedFiles.changes.length,outputs.length);
139
+ } finally {f.dispose();}
140
+ });