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,106 @@
1
+ # Bounded slices inside one task
2
+
3
+ Standalone implementation only. Use this when one assignment is too broad for a
4
+ worker's existing turn/context limit. Keep small tasks as one normal dispatch.
5
+ The coordinator selects the decomposition; the CLI validates and tracks it.
6
+
7
+ ## Choose a useful boundary
8
+
9
+ Start from the original acceptance criteria and reviewed requirements. Group a
10
+ small, coherent behavior with its tests and exact files. Separate independent
11
+ responsibilities; order shared files explicitly. Include Unity metadata files in
12
+ the write set when import will create them. Do not divide by an arbitrary file
13
+ count or invent new acceptance thresholds. If the original early checkpoint is
14
+ required, put the work needed for it in the first slice and measure it before
15
+ expanding. Slicing does not bypass that checkpoint.
16
+
17
+ Use the fewest slices that fit bounded assignments: 2–12 slices, each with 1–8
18
+ exact files. Dependencies form an acyclic graph. One final `integration` slice
19
+ must transitively depend on every other slice; it can implement the last behavior
20
+ while integrating, so a separate ceremonial integration dispatch is unnecessary.
21
+ Each original criterion ID, including manual criteria, has exactly one primary
22
+ slice owner. Ownership is responsibility, not local approval. Original checks,
23
+ artifacts, design rules, risk/model route and manual/external gates still apply.
24
+
25
+ ## Preview and register
26
+
27
+ Copy `.nccgs/templates/task-slices.json` to a draft under
28
+ `.nccgs/evidence/slice-plans/`. Fill in the existing task ID, current contract hash
29
+ and source snapshot, a unique plan ID, reason, slice scopes and criterion IDs.
30
+ Use `task inspect` and `task snapshot` to obtain current values. A first plan has
31
+ `supersedes: null`; a revision names the current plan ID. No budget or replacement
32
+ contract fields are accepted.
33
+
34
+ ```text
35
+ node .claude/nccgs/tools/task.mjs slice-preview --id TASK --file .nccgs/evidence/slice-plans/TASK.json
36
+ node .claude/nccgs/tools/task.mjs slice-plan --id TASK --file .nccgs/evidence/slice-plans/TASK.json
37
+ node .claude/nccgs/tools/task.mjs slice-status --id TASK
38
+ ```
39
+
40
+ Preview is read-only. Registration captures an immutable plan under
41
+ `.nccgs/evidence/slices/` and appends a ledger event. It does not rewrite the task,
42
+ record implementation, spend a dispatch or start a provider invocation. Existing
43
+ limits and ancestor relationships are preserved. If dispatch limits have not yet
44
+ been pinned, registration pins the ordinary task defaults. Use the existing
45
+ reservation workflow to establish any intended custom limits before registration.
46
+
47
+ Capacity accounts for already spent attempts, all pending slices in the ancestor
48
+ subtree, the original protected repair/QA/review reserve and required independent
49
+ review still ahead. It rejects a plan or later dispatch that cannot fit. It is a
50
+ dispatch lower bound, not an estimate of cost, time, retries or semantic completeness.
51
+ Existing observed cost/duration admission and `nccgs run` session budgets remain
52
+ in force. Missing usage stays unknown. No child task, review round, native turn
53
+ allowance, new session budget or refund is created by splitting.
54
+
55
+ ## Execute and integrate
56
+
57
+ `slice-status.next` contains the next eligible reservation request, with the exact
58
+ agent, files, `sliceId`, fresh handoff path and stable request key. Save that object
59
+ under `.nccgs/evidence/requests/`, reserve it normally, then use `dispatch-prompt`
60
+ and the existing foreground Agent/handoff workflow. This is preparation, not an
61
+ automatic launch. Finish one slice before starting the next. A dependency must
62
+ have a complete pinned native handoff with valid model evidence. The dependent
63
+ dispatch pins its exact predecessor dispatch IDs.
64
+
65
+ The launch hook adds the pinned objective, original criterion definitions,
66
+ relevant reviewed requirements and global constraints. It keeps the same task and
67
+ model route. Integration receives the full original acceptance list. Structured
68
+ source writes are restricted to exact planned files; existing shell/MCP policy
69
+ still applies. These controls do not provide an operating-system sandbox.
70
+
71
+ Status `DELIVERED` means native completion plus a complete intact handoff. It is
72
+ not acceptance PASS. Do not record intermediate slices as implementation stages.
73
+ After all slices are delivered, run the original applicable checks against the
74
+ current integrated source and record the final integration dispatch. A subsequent
75
+ bounded repair follows the existing repair workflow and can supply the replacement
76
+ implementation stage; it must still pass the whole task's gates.
77
+
78
+ The independent reviewer evaluates the full integrated contract and must differ
79
+ from every implementation contributor in the task subtree, including earlier
80
+ slices and failed repairs. Reviewer-owned negative findings/conditions, review
81
+ limits, objective measurements and manual/external acceptance remain unchanged.
82
+ No per-slice review ceremony replaces or adds to the required whole-task review.
83
+
84
+ ## Partial work and revisions
85
+
86
+ Keep failed/PARTIAL attempts and their handoffs. Retry only with an eligible fresh
87
+ reservation and handoff inside the same remaining budget. Fix malformed/tampered
88
+ evidence through the existing recovery process; never edit captured history.
89
+
90
+ Settle active reservations before revising. Use a new plan ID, current source
91
+ snapshot, explicit reason and `supersedes` pointing at the active plan. For the same
92
+ contract, retain the IDs of all attempted slices. Their definitions may be refined
93
+ explicitly, but changed slices and affected dependents become stale and require
94
+ fresh delivery. Unchanged work can carry forward only when its latest attempt,
95
+ definition, contract and exact dependency dispatches remain valid. Retrying an
96
+ upstream slice also invalidates downstream delivery even if its source looks similar.
97
+
98
+ Plan versions, reasons, reservations and lifecycle history remain available. Replaying
99
+ an old plan is idempotent and cannot reactivate it. A plan cannot refresh a contract
100
+ or authorize new product scope; use the original explicit contract workflow for
101
+ that, retaining history and spent budgets. If the remaining budget cannot fit the
102
+ work, report partial/blocked with unresolved criteria. Do not create replacement
103
+ tasks, reduce criteria or increase limits to make the plan pass.
104
+
105
+ This feature is included in the prepared, unpublished 2.0.0 stable package. Synthetic framework tests do not
106
+ establish fresh Claude Code or Unity execution, cost savings or game quality.
@@ -27,3 +27,23 @@ Existing installations without execution.json retain compatibility execution: As
27
27
  Legacy independent review: fable/high. Updating never converts historical approval into a current approval. Explicit migration preserves tasks and consumed budgets.
28
28
 
29
29
  Default active agents: 13. Full library: 47.
30
+
31
+ ## Configured native turn ceilings
32
+
33
+ Generated from agent-library frontmatter by compile-policy.mjs. These are ceilings, not targets or observed session settings. Verify the installed role and any native session override before dispatch; prompt text cannot increase maxTurns.
34
+
35
+ | Default active role | maxTurns |
36
+ |---|---:|
37
+ | nccgs-fast-implementer | 12 |
38
+ | nccgs-task-scout | 10 |
39
+ | nccgs-game-designer | 48 |
40
+ | nccgs-gameplay-programmer | 120 |
41
+ | nccgs-engine-programmer | 22 |
42
+ | nccgs-ui-programmer | 22 |
43
+ | nccgs-qa-engineer | 22 |
44
+ | nccgs-verification-engineer | 22 |
45
+ | nccgs-adversarial-reviewer | 200 |
46
+ | nccgs-unity-implementer | 120 |
47
+ | nccgs-unity-systems-specialist | 22 |
48
+ | nccgs-unity-ui-specialist | 22 |
49
+ | nccgs-unity-build-specialist | 24 |
@@ -7,8 +7,9 @@ import { spawn, spawnSync } from "node:child_process";
7
7
  import { atomicJson } from "./policy.mjs";
8
8
 
9
9
  const windowsSource = fileURLToPath(new URL("./windows-job.cs", import.meta.url));
10
+ const compilerShutdownSource = fileURLToPath(new URL("./windows-compiler-server.cs", import.meta.url));
10
11
  function windowsHost() {
11
- const digest = crypto.createHash("sha256").update(fs.readFileSync(windowsSource)).digest("hex");
12
+ const digest = crypto.createHash("sha256").update(fs.readFileSync(windowsSource)).update(fs.readFileSync(compilerShutdownSource)).digest("hex");
12
13
  // Build the bundled, inspectable helper; no system execution policy changes.
13
14
  const directory = path.join(os.tmpdir(), "nccgs-native", digest);
14
15
  fs.mkdirSync(directory, { recursive: true });
@@ -16,22 +17,27 @@ function windowsHost() {
16
17
  if (fs.existsSync(binary)) return binary;
17
18
  const compiler = path.join(process.env.SystemRoot || "C:/Windows", "Microsoft.NET/Framework64/v4.0.30319/csc.exe");
18
19
  const temporary = path.join(directory, `${crypto.randomUUID()}.exe`);
19
- const result = spawnSync(compiler, ["/nologo", "/target:exe", "/reference:System.Web.Extensions.dll", `/out:${temporary}`, windowsSource], { encoding: "utf8", windowsHide: true, timeout: 30000 });
20
+ const result = spawnSync(compiler, ["/nologo", "/target:exe", "/reference:System.Web.Extensions.dll", `/out:${temporary}`, windowsSource, compilerShutdownSource], { encoding: "utf8", windowsHide: true, timeout: 30000 });
20
21
  if (result.status !== 0 || result.error) throw new Error(`Cannot compile Windows process-group helper: ${result.error?.message || result.stdout || result.stderr}`);
21
22
  try { fs.renameSync(temporary, binary); }
22
23
  catch (error) { if (!fs.existsSync(binary)) throw error; fs.unlinkSync(temporary); }
23
24
  return binary;
24
25
  }
25
26
  export function startCommand({ executable, args, cwd, stateDirectory, id, stdio }) {
27
+ // Unity CLI's on-demand auth broker outlives a short test by two idle minutes.
28
+ // Keep every descendant owned; use the CLI's supported direct OS-keyring path
29
+ // for this command tree instead of spawning a resident broker. Wrappers inherit
30
+ // the flag too. Do not change global settings, peer checks or the parent's env.
31
+ const env = { ...process.env, UNITY_NO_AUTH_BROKER: "1" };
26
32
  if (process.platform !== "win32") {
27
- const child = spawn(executable, args, { cwd, detached: true, shell: false, windowsHide: true, stdio });
33
+ const child = spawn(executable, args, { cwd, detached: true, shell: false, windowsHide: true, stdio, env });
28
34
  return { child, identity: () => ({ pid: child.pid, group: true }) };
29
35
  }
30
36
  const groupName = `Local\\NCCGS-${id}`;
31
37
  const spec = path.join(stateDirectory, `${id}-command.json`);
32
38
  atomicJson(spec, { executable, arguments: args, cwd, groupName });
33
39
  const child = spawn(windowsHost(), ["run", spec],
34
- { cwd, shell: false, windowsHide: true, stdio });
40
+ { cwd, shell: false, windowsHide: true, stdio, env });
35
41
  return { child, identity: () => ({ pid: child.pid, groupName, windowsJob: true }) };
36
42
  }
37
43
  export function windowsGroupState(groupName) {
@@ -83,6 +83,10 @@ export function generated(project) {
83
83
  return `| ${risk} | ${route.model} | ${route.effort} |`;
84
84
  }).join("\n");
85
85
  outputs.set(".claude/nccgs/routing.generated.md", `# Generated routing — edit pipeline.json, then compile-policy.mjs\n\nNCCGS runs in Claude Code. New installations use standalone execution; model aliases describe requested routes, not observed provider versions. Model selection in project.yaml and execution obligations in execution.json are separate policies.\n\n| Stage | Owner/model | Effort | Maximum review rounds |\n|---|---|---|---|\n| Intake, plan and documents | Claude Code | Routed by task | — |\n${tierRows}\n| Independent review | ${standaloneProfile.models.review.model} | ${standaloneProfile.models.review.effort} | Shared task budget |\n| External review | Off by default; advisory or required when configured | External evidence | — |\n| Acceptance | Contract-defined Product Owner decision | Explicit when required | — |\n\nFAST: nccgs-fast-implementer. STANDARD: the relevant domain programmer. CONTROLLED: nccgs-unity-implementer with the affected specialists. Use nccgs-task-scout only for a bounded missing fact; it does not replace review.\n\nA task-wide review budget survives implementation retries and continuation. Exhaustion stops the loop; a reasoned extension is required. Report is available for blocked/partial work and is not approval. Missing runtime effort remains unknown unless supplied by observed evidence.\n\nExisting installations without execution.json retain compatibility execution: Astra plan/document ownership, required external check and Product Owner acceptance. Legacy astra-claude routes remain unchanged:\n\n| Risk | Model | Effort |\n|---|---|---|\n${legacyRows}\n\nLegacy independent review: ${contractProfile.models.review.model}/${contractProfile.models.review.effort}. Updating never converts historical approval into a current approval. Explicit migration preserves tasks and consumed budgets.\n\nDefault active agents: ${activeCount}. Full library: ${studio.agents.length}.\n`);
86
+ const nativeRows = studio.agents.filter(agent => primary.activeAgents === "all" || primary.activeAgents.includes(agent.name))
87
+ .map(agent => `| ${agent.name} | ${agent.maxTurns ?? "Unspecified"} |`).join("\n");
88
+ const routingPath = ".claude/nccgs/routing.generated.md";
89
+ outputs.set(routingPath, outputs.get(routingPath) + `\n## Configured native turn ceilings\n\nGenerated from agent-library frontmatter by compile-policy.mjs. These are ceilings, not targets or observed session settings. Verify the installed role and any native session override before dispatch; prompt text cannot increase maxTurns.\n\n| Default active role | maxTurns |\n|---|---:|\n${nativeRows}\n`);
86
90
  const scaffold = path.join(project, "scaffold/.nccgs/project.yaml");
87
91
  if (fs.existsSync(scaffold)) {
88
92
  const text = fs.readFileSync(scaffold, "utf8");
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+
5
+ const need = (ok, message) => { if (!ok) throw new Error(`Editor session evidence: ${message}`); };
6
+ const hash = bytes => crypto.createHash("sha256").update(bytes).digest("hex");
7
+ const samePath = (a, b) => path.resolve(a) === path.resolve(b);
8
+ const identity = value => value?.windowsJob === true && Number.isSafeInteger(value.pid) && value.pid > 0 && /^Local\\NCCGS-[a-f0-9-]{36}$/.test(value.groupName ?? "");
9
+
10
+ // Copy existing operator receipts for the reviewer. Never execute a command,
11
+ // stop a process, rewrite a task/review, or manufacture a new runtime observation.
12
+ // This is evidence publication, not acceptance or independent verification.
13
+ export function publishEditorSessionEvidence(project, { sourceProject, startFile, stopFile, shutdownFile, id = crypto.randomUUID() }) {
14
+ need(typeof sourceProject === "string" && path.isAbsolute(sourceProject), "sourceProject must be explicit and absolute");
15
+ need(/^[a-f0-9-]{36}$/.test(id), "invalid bundle id");
16
+ project = fs.realpathSync(project);
17
+ const originals = { start: startFile, stop: stopFile, shutdown: shutdownFile };
18
+ const data = {}, bytes = {};
19
+ for (const [kind, file] of Object.entries(originals)) {
20
+ need(typeof file === "string" && path.isAbsolute(file), `${kind} path must be absolute`);
21
+ const stat = fs.lstatSync(file);
22
+ need(stat.isFile() && !stat.isSymbolicLink() && stat.size <= 1024 * 1024, `${kind} must be a bounded regular receipt`);
23
+ bytes[kind] = fs.readFileSync(file);
24
+ try { data[kind] = JSON.parse(bytes[kind].toString("utf8")); } catch { need(false, `${kind} is not JSON`); }
25
+ }
26
+ const { start, stop, shutdown } = data;
27
+ need(identity(start.identity) && identity(stop.identity), "owned Windows Editor identities required");
28
+ need(start.identity.pid === stop.identity.pid && start.identity.groupName === stop.identity.groupName, "start/stop ownership mismatch");
29
+ need(typeof start.project === "string" && samePath(start.project, sourceProject), "start project mismatch");
30
+ need(stop.state === "dead" && stop.forced === false && stop.result?.code === 0 && stop.result?.signal === null, "normal settled Editor exit required; retain failed/uncertain evidence separately");
31
+ const args = shutdown.args;
32
+ need(Array.isArray(args) && args.every(v => typeof v === "string"), "shutdown arguments required");
33
+ need(args[0] === "command" && ["eval", "script"].includes(args[1]), "expected recorded Unity command");
34
+ need(args.filter(v => v === "--project-path").length === 1 && samePath(args[args.indexOf("--project-path") + 1] ?? "", sourceProject), "shutdown project mismatch");
35
+ need(args.some(v => /UnityEditor\.EditorApplication\.Exit\(0\)/.test(v)), "recorded graceful Editor exit is missing");
36
+ need(identity(shutdown.identity) && shutdown.treeState === "dead", "shutdown CLI ownership must be settled");
37
+ need(shutdown.code === 0 && shutdown.signal === null, "shutdown CLI did not complete normally");
38
+ need(Number.isFinite(Date.parse(start.at)) && Number.isFinite(Date.parse(shutdown.at)) && Date.parse(shutdown.at) >= Date.parse(start.at), "invalid event ordering");
39
+
40
+ // No junction/symlink in the output chain, even when importing into an addendum.
41
+ const relative = `.nccgs/evidence/editor-control/operator-${id}`;
42
+ let directory = project;
43
+ for (const segment of [".nccgs", "evidence", "editor-control"]) {
44
+ directory = path.join(directory, segment);
45
+ if (fs.existsSync(directory)) need(fs.lstatSync(directory).isDirectory() && !fs.lstatSync(directory).isSymbolicLink() && fs.realpathSync(directory) === directory, "output path redirects outside the project");
46
+ else fs.mkdirSync(directory);
47
+ }
48
+ directory = path.join(project, relative);
49
+ fs.mkdirSync(directory); // exclusive directory creation: never replace a prior bundle
50
+ const references = {};
51
+ for (const kind of Object.keys(originals)) {
52
+ const name = `${kind}.json`;
53
+ fs.writeFileSync(path.join(directory, name), bytes[kind], { flag: "wx" });
54
+ references[kind] = { path: `${relative}/${name}`, sha256: hash(bytes[kind]) };
55
+ }
56
+ const receipt = {
57
+ schemaVersion: 1, origin: "external-operator-editor-evidence", recordedAt: new Date().toISOString(),
58
+ sourceProject, evidenceProject: project, historicalImport: !samePath(sourceProject, project),
59
+ editorIdentity: start.identity, reportedProcessState: stop.state, forced: stop.forced,
60
+ references, currentProcessProbe: "not performed by this publisher",
61
+ note: "Byte-preserved operator receipts only. Not native worker execution, acceptance, a reviewer verdict, or a new Editor stop. Original event times and ownership remain in the referenced files."
62
+ };
63
+ const manifest = Buffer.from(JSON.stringify(receipt, null, 2) + "\n");
64
+ fs.writeFileSync(path.join(directory, "receipt.json"), manifest, { flag: "wx" });
65
+ return { path: `${relative}/receipt.json`, sha256: hash(manifest) };
66
+ }
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { performance } from "node:perf_hooks";
4
4
  import { hash, json, contained, artifact, verifyArtifact, atomicJson, loadPipeline, resolveExecution } from "./policy.mjs";
5
- import { canonicalInputs, pinInputs, inputPolicy, isOutput } from "./inputs.mjs";
5
+ import { canonicalInputs, pinInputs, inputPolicy, isOutput, isUnityGenerated, unityGeneratedState } from "./inputs.mjs";
6
6
  import { migrateTaskEvidence } from "./evidence.mjs";
7
7
  import { acquireLock } from "./process-lock.mjs";
8
8
  import { initializeStandalone, checkStandalone, recordStandalone, extendStandalone, refreshStandalone,
@@ -14,7 +14,7 @@ export function taskPath(project, id) {
14
14
  requireValue(typeof id === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(id), "Invalid task ID");
15
15
  return contained(project, `.nccgs/tasks/${id}.json`);
16
16
  }
17
- export function snapshot(project, task = null) {
17
+ export function snapshot(project, task = null, { includeFiles = false } = {}) {
18
18
  const started = performance.now();
19
19
  const excluded = new Set(loadPipeline(project).snapshotExcludes);
20
20
  const policy = inputPolicy(project);
@@ -26,7 +26,7 @@ export function snapshot(project, task = null) {
26
26
  if (!relative && excluded.has(entry.name)) continue;
27
27
  const name = relative ? `${relative}/${entry.name}` : entry.name;
28
28
  const file = contained(project, name);
29
- if (isOutput(name, policy)) continue;
29
+ if (isOutput(name, policy) || isUnityGenerated(name, policy)) continue;
30
30
  if (entry.isDirectory()) walk(name);
31
31
  else if (entry.isFile()) {
32
32
  const readStarted = performance.now();
@@ -39,10 +39,13 @@ export function snapshot(project, task = null) {
39
39
  }
40
40
  }
41
41
  walk();
42
+ const generatedFiles = unityGeneratedState(project, policy);
42
43
  files.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
43
44
  return { sha256: hash(JSON.stringify({ files, canonicalInputs: inputs.sha256 })), sourceSha256: hash(JSON.stringify(files)), files: files.length,
44
45
  bytes, canonicalInputSha256: inputs.sha256, canonicalInputFiles: inputs.entries.filter(e => e.kind === "file").length,
45
- canonicalInputBytes: inputs.bytes, readHashMs: readHashMs + inputs.readHashMs, durationMs: performance.now() - started };
46
+ canonicalInputBytes: inputs.bytes, readHashMs: readHashMs + inputs.readHashMs, durationMs: performance.now() - started,
47
+ ...(includeFiles ? { fileEntries: files } : {}),
48
+ ...(generatedFiles.length ? { unityGeneratedFiles: generatedFiles } : {}) };
46
49
  }
47
50
  function tierRoute(pipeline, risk) {
48
51
  const tier = pipeline.contract.taskTiers?.[risk];
@@ -0,0 +1,51 @@
1
+ import fs from "node:fs";
2
+ import { artifact, json, verifyArtifact } from "./policy.mjs";
3
+ import { check, record, taskPath } from "./gates.mjs";
4
+ import { getDispatch } from "./orchestration.mjs";
5
+ import { readReviewHandoff, validateReviewStructure } from "./review-handoff.mjs";
6
+
7
+ const need = (ok, message) => { if (!ok) throw new Error(`Handoff: ${message}`); };
8
+
9
+ function context(project, id, dispatchId, relative) {
10
+ const task = json(taskPath(project, id)), dispatch = getDispatch(project, id, dispatchId);
11
+ need(dispatch.contractHash === task.contractHash, "dispatch contract is stale");
12
+ need(dispatch.handoffPaths?.includes(relative), "file must be this dispatch's declared handoff");
13
+ need(["running", "completed"].includes(dispatch.status), "handoff inspection requires a running or completed dispatch");
14
+ const state = check(project, task, task.mode === "implement" ? "implementation" : "analysis");
15
+ need(state.allowed, state.message);
16
+ const expected = dispatch.status === "completed" ? dispatch.completedSnapshot : dispatch.kind === "implementation" ? state.snapshot : dispatch.snapshot;
17
+ need(state.snapshot === expected, "source snapshot is stale");
18
+ // A completed handoff is always read through its captured reference. A new hash
19
+ // cannot legitimize edits made after completion.
20
+ const evidence = dispatch.status === "completed"
21
+ ? dispatch.handoff?.artifacts?.find(ref => ref.path === relative) : artifact(project, relative);
22
+ need(evidence, "completed dispatch did not pin this handoff");
23
+ const content = fs.readFileSync(verifyArtifact(project, evidence), "utf8");
24
+ return { task, dispatch, snapshot: state.snapshot, evidence, content };
25
+ }
26
+
27
+ export function validateHandoff(project, id, dispatchId, relative) {
28
+ const ctx = context(project, id, dispatchId, relative);
29
+ const markers = [...ctx.content.matchAll(/^NCCGS-HANDOFF: (PARTIAL|COMPLETE)\r?$/gm)];
30
+ need(markers.length === 1, "exactly one PARTIAL or COMPLETE marker is required");
31
+ const complete = markers[0][1] === "COMPLETE";
32
+ if (ctx.dispatch.kind === "review") {
33
+ const { value } = readReviewHandoff(ctx.content, ctx.task, ctx.snapshot, false);
34
+ validateReviewStructure(value, ctx.task);
35
+ }
36
+ verifyArtifact(project, ctx.evidence);
37
+ return { status: "VALID", taskId: id, dispatchId, evidence: ctx.evidence, complete,
38
+ scope: "Structure only; not native completion, semantic review, verification or acceptance. No task, budget or handoff was changed." };
39
+ }
40
+
41
+ export function recordReviewHandoff(project, id, dispatchId, relative) {
42
+ const ctx = context(project, id, dispatchId, relative);
43
+ need(ctx.dispatch.kind === "review" && ctx.dispatch.status === "completed", "a completed independent review dispatch is required");
44
+ need(ctx.task.execution.reviewContractRequired === true, "automatic import requires the reviewer-owned contract policy; legacy records retain their workflow");
45
+ const { value } = readReviewHandoff(ctx.content, ctx.task, ctx.snapshot);
46
+ // No caller-supplied verdict, findings, conditions, runtime or snapshot. The
47
+ // existing record path checks independence, telemetry, criteria and budgets,
48
+ // appends history, and rejects reusing a dispatch. It never closes the task.
49
+ return record(project, id, "review", { snapshot: ctx.snapshot, dispatchId, runtime: ctx.dispatch.runtime, evidence: ctx.evidence,
50
+ verdict: value.verdict, criteriaMet: value.criteriaMet, findings: value.findings, conditions: value.conditions });
51
+ }
@@ -8,7 +8,32 @@ export function inputPolicy(project) {
8
8
  const policy = fs.existsSync(file) ? json(file) : { schemaVersion: 1, canonicalInputs: [], outputDirectories: [], checks: {} };
9
9
  if (policy.schemaVersion !== 1 || !Array.isArray(policy.canonicalInputs) || !Array.isArray(policy.outputDirectories)
10
10
  || !policy.checks || typeof policy.checks !== "object" || Array.isArray(policy.checks)) throw new Error("Invalid .nccgs/inputs.json schema");
11
- for (const key of Object.keys(policy)) if (!["schemaVersion", "canonicalInputs", "outputDirectories", "checks"].includes(key)) throw new Error(`Unknown input manifest field: ${key}`);
11
+ for (const key of Object.keys(policy)) if (!["schemaVersion", "canonicalInputs", "outputDirectories", "checks", "unityGeneratedFiles"].includes(key)) throw new Error(`Unknown input manifest field: ${key}`);
12
+ const generated = policy.unityGeneratedFiles === undefined ? [] : policy.unityGeneratedFiles;
13
+ if (!Array.isArray(generated) || generated.length > 256) throw new Error("unityGeneratedFiles must be an array of at most 256 exact file paths");
14
+ const seenGenerated = new Set();
15
+ for (const relative of generated) {
16
+ const file = contained(project, relative);
17
+ // Opt-in derived metadata only. Never ignore whole directories, wildcards,
18
+ // nested tool projects, source, assets, packages or project settings.
19
+ if (!/^[^/]+\.(?:csproj|sln|slnx)$/.test(relative)
20
+ && !/^\.vscode\/(?:extensions|launch|settings)\.json$/.test(relative)
21
+ && !/^UserSettings\/(?:EditorUserSettings\.asset|Search\.settings)$/.test(relative)) {
22
+ throw new Error(`Unsupported Unity generated file: ${relative}`);
23
+ }
24
+ const key = relative.toLowerCase();
25
+ if (seenGenerated.has(key)) throw new Error(`Duplicate Unity generated file: ${relative}`);
26
+ seenGenerated.add(key);
27
+ if (fs.existsSync(file) && !fs.statSync(file).isFile()) throw new Error(`Unity generated path is not a file: ${relative}`);
28
+ for (const input of policy.canonicalInputs) if (overlaps(input, relative)) throw new Error(`Canonical input/generated file overlap: ${input} and ${relative}`);
29
+ }
30
+ if (generated.length) {
31
+ const version = contained(project, "ProjectSettings/ProjectVersion.txt");
32
+ if (!fs.existsSync(version) || !/^m_EditorVersion: \d+\.\d+\.\d+[abfp]\d+\s*$/m.test(fs.readFileSync(version, "utf8"))
33
+ || !fs.existsSync(contained(project, "Packages/manifest.json")) || !fs.statSync(contained(project, "Assets")).isDirectory()) {
34
+ throw new Error("unityGeneratedFiles requires a Unity project (Assets, Packages/manifest.json, ProjectSettings/ProjectVersion.txt)");
35
+ }
36
+ }
12
37
  for (const relative of [...policy.canonicalInputs, ...policy.outputDirectories]) contained(project, relative);
13
38
  for (const relative of policy.outputDirectories) {
14
39
  // Keep source roots in the snapshot. Unity build destinations must be deliberately
@@ -36,6 +61,16 @@ export function isOutput(relative, policy) {
36
61
  const normalize = name => process.platform === "win32" ? name.toLowerCase() : name;
37
62
  return policy.outputDirectories.some(dir => normalize(relative) === normalize(dir) || normalize(relative).startsWith(`${normalize(dir)}/`));
38
63
  }
64
+ export function isUnityGenerated(relative, policy) {
65
+ const normalize = name => process.platform === "win32" ? name.toLowerCase() : name;
66
+ return (policy.unityGeneratedFiles ?? []).some(file => normalize(relative) === normalize(file));
67
+ }
68
+ export function unityGeneratedState(project, policy) {
69
+ return (policy.unityGeneratedFiles ?? []).map(relative => {
70
+ const file = contained(project, relative);
71
+ return { path: relative, sha256: fs.existsSync(file) ? hash(fs.readFileSync(file)) : null };
72
+ }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
73
+ }
39
74
  export function canonicalInputs(project, policy = inputPolicy(project)) {
40
75
  const entries = new Map();
41
76
  let bytes = 0, readHashMs = 0;
@@ -229,6 +229,7 @@ function verification(project, task, input, spec, snap) {
229
229
  const file = verifyArtifact(project, check.evidence);
230
230
  if (check.evidence.path.endsWith(".json")) {
231
231
  const data = json(file);
232
+ assert(data.origin !== "unity-repair-operation", "Unity repair operation receipts are not verification evidence; run the original declared checks");
232
233
  if (data.origin === "verification-command") {
233
234
  assert(data.taskId === task.id && data.name === check.name && data.snapshot === snap && data.contractHash === task.contractHash && data.status === "PASS" && data.exitCode === 0 && data.sourceUnchanged === true, "Verification execution failed or stale");
234
235
  assert(Date.parse(data.startedAt) >= Date.parse(task.canonicalInputs.pinnedAt), "Verification predates current input pin");
@@ -474,6 +475,26 @@ export function refreshStandalone(project, task, reason) {
474
475
  // Do not reset audit/plan source baseline or any spent/allocated budget.
475
476
  return writeTask(project, task);
476
477
  }
478
+ export function migrateFrameworkBaseline(project, id, relative) {
479
+ externalOnly();
480
+ const task = json(taskPath(project, id)), reference = artifact(project, relative), decision = json(verifyArtifact(project, reference));
481
+ assert(task.schemaVersion === 2 && task.mode !== "implement" && !task.closed, "Framework migration requires an open plan/audit task");
482
+ assert(nonempty(decision.reason) && nonempty(decision.authority) && Array.isArray(decision.beforeFiles), "Framework migration requires explicit external authority and original file inventory");
483
+ assert(digest(decision.beforeFiles) === task.analysisBaseline, "Original inventory does not match the pinned analysis baseline");
484
+ assert(task.executionHash === digest(resolveExecution(project)) && task.pipelineHash === digest(loadPipeline(project)), "Framework migration cannot change execution/pipeline policy");
485
+ assert(task.canonicalInputs.sha256 === canonicalInputs(project).sha256, "Framework migration cannot change canonical inputs");
486
+ assert(!dispatchStatus(project, id).active.length && !pendingJobs(project).length, "Settle all workers and verification before framework migration");
487
+ validateBudget(task);
488
+ const captured = snapshot(project, task, { includeFiles: true }), before = new Map(decision.beforeFiles), after = new Map(captured.fileEntries);
489
+ const changes = [...new Set([...before.keys(), ...after.keys()])].filter(file => before.get(file) !== after.get(file)).map(file => ({ path: file, before: before.get(file) ?? null, after: after.get(file) ?? null }));
490
+ assert(changes.length > 0 && changes.every(change => change.path.startsWith(".claude/")), "Framework migration refuses non-framework source changes");
491
+ invalidate(task, "migrate-framework-baseline");
492
+ Object.assign(task.history.at(-1), { reference, reason: decision.reason, authority: decision.authority,
493
+ previousBaseline: task.analysisBaseline, newBaseline: captured.sourceSha256, changes });
494
+ task.analysisBaseline = captured.sourceSha256; task.phase = "READY"; task.outcome = null;
495
+ // No evidence is promoted: fresh native analysis and independent review required.
496
+ return writeTask(project, task);
497
+ }
477
498
  export function exportExternalStandalone(project, task, relative) {
478
499
  assert(task.execution.externalReview.mode !== "off", "External review is off for this task");
479
500
  const context = current(project, task);