nccgs 2.0.0-rc.3 → 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 (69) 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 +107 -0
  36. package/MEMORY.md +3 -2
  37. package/README.md +34 -27
  38. package/UPGRADING.md +21 -18
  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 +38 -10
  45. package/docs/PHASE-1-OPERATIONS.md +142 -1
  46. package/docs/PHASE-2-OPERATIONS.md +1 -1
  47. package/docs/RELEASE-STATUS.md +85 -4
  48. package/docs/TASK-SLICING.md +57 -0
  49. package/docs/UPGRADE-QUALIFICATION.md +1 -1
  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/run.mjs +4 -3
  57. package/scripts/supervise.mjs +30 -6
  58. package/scripts/validate.mjs +4 -0
  59. package/scripts/work-handoff.mjs +38 -0
  60. package/tests/editor-session-evidence.test.mjs +45 -0
  61. package/tests/fixtures/compiler-shutdown.cs +64 -0
  62. package/tests/orchestration-v15.test.mjs +52 -1
  63. package/tests/phase1.test.mjs +92 -0
  64. package/tests/policy-v15.test.mjs +14 -0
  65. package/tests/review-budget.test.mjs +177 -0
  66. package/tests/runtime-v15.test.mjs +572 -4
  67. package/tests/session-budget.test.mjs +34 -0
  68. package/tests/task-slices.test.mjs +285 -0
  69. package/tests/unity-generated.test.mjs +140 -0
@@ -4,10 +4,11 @@ import { taskPath, check } from "./gates.mjs";
4
4
  import { boundTask } from "./runtime.mjs";
5
5
  import { claim, findDispatch, rejectUnclaimed, admitRepairTool, dispatchPrompt, pinnedRepairEvidence } from "./orchestration.mjs";
6
6
  import { requireDesignReady } from "./design-readiness.mjs";
7
+ import { inFinalReviewPhase } from "./review-budget.mjs";
7
8
 
8
9
  const need = (condition, message) => { if (!condition) throw new Error(message); };
9
10
  const reads = new Set(["Read", "Glob", "Grep", "WebSearch", "WebFetch", "AskUserQuestion"]);
10
- const governance = new Set(["init", "bind", "inspect", "check", "snapshot", "ref", "record", "report", "reclassify", "refresh-inputs", "reserve", "dispatch-status", "dispatch-prompt", "checkpoint", "resume", "telemetry", "export-external", "close", "job-status", "job-wait", "acceptance-status", "design-validate", "design-compare"]);
11
+ const governance = new Set(["init", "bind", "inspect", "check", "snapshot", "ref", "record", "report", "reclassify", "refresh-inputs", "reserve", "dispatch-status", "dispatch-prompt", "checkpoint", "resume", "telemetry", "export-external", "close", "job-status", "job-wait", "acceptance-status", "design-validate", "design-compare", "handoff-validate", "record-review", "slice-preview", "slice-plan", "slice-status", "budget-status"]);
11
12
  export function parseTaskCommand(command, project = process.cwd()) {
12
13
  // Parse data quotes without interpreting a shell. Punctuation inside report
13
14
  // text is data; composition/redirection/expansion outside quotes stays blocked.
@@ -47,6 +48,16 @@ export function safeVerifyCommand(command, project = process.cwd()) {
47
48
  export function recoveryTool(input, project = process.cwd()) {
48
49
  return !input.agent_id && ["Bash", "PowerShell"].includes(input.tool_name) && ["report", "inspect"].includes(safeTaskCommand(input.tool_input?.command ?? "", project));
49
50
  }
51
+ function ownHandoffValidation(command, project, task, dispatch) {
52
+ const parsed = parseTaskCommand(command, project);
53
+ if (!parsed || parsed.command !== "handoff-validate" || parsed.forwarded || dispatch?.status !== "running") return false;
54
+ const values = new Map(), args = parsed.options.slice(3);
55
+ for (let i = 0; i < args.length; i += 2) {
56
+ if (!["--id", "--dispatch", "--file", "--project"].includes(args[i]) || values.has(args[i]) || !args[i + 1] || args[i + 1].startsWith("--")) return false;
57
+ values.set(args[i], args[i + 1]);
58
+ }
59
+ return values.get("--id") === task?.id && values.get("--dispatch") === dispatch.id && dispatch.handoffPaths?.includes(values.get("--file"));
60
+ }
50
61
  export function guardTool(project, input) {
51
62
  if (recoveryTool(input, project)) return;
52
63
  if (resolveExecution(project).name !== "standalone") return;
@@ -68,7 +79,7 @@ export function guardTool(project, input) {
68
79
  const readiness = check(project, task, "review");
69
80
  need(readiness.allowed, readiness.message);
70
81
  }
71
- const prompt = dispatch.workPacket ? dispatchPrompt(project, task.id, dispatch.id).prompt : null;
82
+ const prompt = dispatch.workPacket || dispatch.slice ? dispatchPrompt(project, task.id, dispatch.id).prompt : null;
72
83
  claim(project, dispatch.id, { sessionId: input.session_id, toolUseId: input.tool_use_id, agent: tool.subagent_type });
73
84
  if (prompt) return { updatedInput: { ...tool, prompt } };
74
85
  } catch (error) {
@@ -87,8 +98,9 @@ export function guardTool(project, input) {
87
98
  contained(project, relative);
88
99
  need(!/^\.(?:claude|nccgs)(?:\/|$)/i.test(relative) || relative.startsWith(".nccgs/evidence/"), "Framework/governance records must be changed through the task CLI or external configuration");
89
100
  if (relative.startsWith(".nccgs/evidence/")) {
101
+ need(!(dispatch?.handoffTransport === "native-result" && dispatch.handoffPaths?.includes(relative)), "Native-result handoff is captured only from the correlated final response; return text without writing this path");
90
102
  need(!pinnedRepairEvidence(project, relative), "Reserved repair packet/context evidence is immutable; preserve it and create a fresh packet for a new eligible attempt");
91
- need(!/^\.nccgs\/evidence\/(?:(?:runtime|criteria|design-checks|checkpoints|external\/requests)\/|check-)/i.test(relative), "Captured runtime/checkpoint/verification/external request evidence is immutable and never authored by Claude");
103
+ need(!/^\.nccgs\/evidence\/(?:(?:runtime|criteria|design-checks|checkpoints|repair-operations|slices|external\/requests)\/|check-)/i.test(relative), "Captured runtime/checkpoint/verification/external request evidence is immutable and never authored by Claude");
92
104
  if (input.agent_id && readOnly) need(task?.contract.analysisArtifacts?.includes(relative), "Read-only worker may write only declared analysis artifacts");
93
105
  return;
94
106
  }
@@ -96,18 +108,20 @@ export function guardTool(project, input) {
96
108
  requireDesignReady(project, task);
97
109
  need(dispatch && dispatch.status === "running" && dispatch.agentId === input.agent_id, "Source writes require a running, correlated implementation dispatch");
98
110
  const lower = relative.toLowerCase();
111
+ if (dispatch.slice) need(dispatch.writePaths.some(p => lower === p.toLowerCase()), "Slice writes require an exact planned file; directory ownership is not granted");
99
112
  need(dispatch.writePaths.some(p => lower === p.toLowerCase() || lower.startsWith(p.toLowerCase() + "/")), "Write is outside this dispatch's owned paths");
100
113
  return;
101
114
  }
102
115
  if (["Bash", "PowerShell"].includes(name)) {
103
116
  const command = tool.command ?? "";
104
- need(!/\b(?:dispatch-reconcile|extend-dispatch|extend-review|import-external|migrate-execution)\b|--stage\s+["']?(?:acceptance|astra)\b/i.test(command), "External decisions cannot be made by the Claude executor");
117
+ if (input.agent_id && ownHandoffValidation(command, project, task, dispatch)) return;
118
+ need(!/\b(?:dispatch-reconcile|extend-dispatch|extend-review|import-external|migrate-execution|migrate-framework|budget-plan)\b|--stage\s+["']?(?:acceptance|astra)\b/i.test(command), "External decisions cannot be made by the Claude executor");
105
119
  if (safeTaskCommand(command, project)) { need(!input.agent_id, "Coordinator owns lifecycle commands"); return; }
106
120
  need(task && !readOnly, "Audit/plan/review shell execution is blocked; use read tools or externally supplied verification evidence");
107
121
  requireDesignReady(project, task);
108
122
  // Coordinator may run declared checks after workers finish. This command
109
123
  // can execute a verifier, so it is allowed only for implementation tasks.
110
- if (!input.agent_id && safeVerifyCommand(command, project)) return;
124
+ if (!input.agent_id && safeVerifyCommand(command, project)) { need(!inFinalReviewPhase(project, input.session_id), "Final-review reserve cannot run new verification jobs; use existing receipts"); return; }
111
125
  need(dispatch?.kind === "implementation" && dispatch.status === "running" && input.agent_id === dispatch.agentId, "Shell mutation requires a correlated implementation worker");
112
126
  return;
113
127
  }
@@ -0,0 +1,128 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { artifact, atomicJson, contained, json, verifyArtifact } from "./policy.mjs";
5
+ import { snapshot } from "./gates.mjs";
6
+
7
+ const need = (ok, message) => { if (!ok) throw new Error(`Unity repair: ${message}`); };
8
+ const keys = (value, allowed) => value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every(k => allowed.includes(k));
9
+ const code = /\.(?:cs|asmdef|asmref|shader|hlsl|cginc|compute|uxml|uss)$/i;
10
+ const asset = /\.(?:unity|prefab|asset|mat|controller|overrideController|anim)$/i;
11
+ export const unityCodePath = relative => relative.startsWith("Assets/") && code.test(relative);
12
+ export const unitySceneSafety = "loaded-scenes-clean-v1";
13
+ export const unityRepairProfiles = Object.freeze({
14
+ "unity-code": Object.freeze({ files: 8, workCalls: 24, readCalls: 12, handoffCalls: 3, durationMs: 900000 }),
15
+ "unity-editor": Object.freeze({ files: 8, workCalls: 20, readCalls: 8, handoffCalls: 3, durationMs: 900000 })
16
+ });
17
+
18
+ function guid(project, relative) {
19
+ const value = fs.readFileSync(contained(project, relative), "utf8").match(/^guid: ([a-f0-9]{32})\r?$/m)?.[1];
20
+ need(value, `missing or invalid Unity GUID in ${relative}`);
21
+ return value;
22
+ }
23
+
24
+ // The packet pins a chosen operation, not a general shell/MCP capability. The
25
+ // installed Unity CLI binds every call to this project's absolute path.
26
+ export function validateUnityRepair(project, packet) {
27
+ const profile = unityRepairProfiles[packet.repairType];
28
+ need(profile, "repairType must be unity-code or unity-editor");
29
+ need(fs.existsSync(contained(project, "ProjectSettings/ProjectVersion.txt")), "requires an existing Unity project");
30
+ need(packet.changes.length <= profile.files, `at most ${profile.files} exact files per packet`);
31
+ for (const change of packet.changes) {
32
+ need(keys(change, ["path", "operation", "instruction"]) && ["edit", "create"].includes(change.operation), "each change needs edit or create operation");
33
+ need(unityCodePath(change.path) || (packet.repairType === "unity-editor" && change.path.startsWith("Assets/") && asset.test(change.path)), "file type must match the repair profile under Assets/; no raw metadata, packages or project settings");
34
+ const file = contained(project, change.path);
35
+ need(fs.statSync(path.dirname(file)).isDirectory(), "create files only in existing folders; folder creation needs a separate scoped change");
36
+ if (change.operation === "edit") {
37
+ need(fs.existsSync(file) && fs.statSync(file).isFile(), "edit requires an existing file");
38
+ guid(project, `${change.path}.meta`);
39
+ } else need(!fs.existsSync(file) && !fs.existsSync(contained(project, `${change.path}.meta`)), "create target or companion metadata already exists");
40
+ }
41
+ need(keys(packet.unity, ["capabilities", "steps"]) && Array.isArray(packet.unity.steps) && packet.unity.steps.length <= 4, "unity requires capabilities and at most four fixed steps");
42
+ if (packet.unity.steps.length || packet.unity.capabilities !== undefined) {
43
+ need(packet.unity.capabilities?.path?.startsWith(".nccgs/evidence/") && !packet.unity.capabilities.path.startsWith(".nccgs/evidence/work-packets/"), "pin Unity command discovery evidence");
44
+ verifyArtifact(project, packet.unity.capabilities);
45
+ }
46
+ need((packet.repairType === "unity-code" && packet.changes.every(c => c.operation === "edit")) || packet.unity.steps.length > 0, "asset repairs and new code require an Editor/import step before completion");
47
+ const ids = new Set();
48
+ for (const step of packet.unity.steps) {
49
+ need(keys(step, ["id", "command", "arguments", "timeoutMs"]) && /^[a-zA-Z][\w-]{0,39}$/.test(step.id ?? "") && !ids.has(step.id), "steps need unique bounded IDs");
50
+ ids.add(step.id);
51
+ need(/^[a-zA-Z][\w-]{0,79}$/.test(step.command ?? "") && Array.isArray(step.arguments) && step.arguments.length <= 8 &&
52
+ step.arguments.every(a => typeof a === "string" && a.length <= 8000 && !a.startsWith("-") && !a.includes("\0")), "use a discovered command and positional arguments only; routing/global flags are generated");
53
+ need(Number.isSafeInteger(step.timeoutMs) && step.timeoutMs >= 1000 && step.timeoutMs <= 60000, "step timeoutMs must be 1000-60000; long verification belongs to managed jobs");
54
+ need(step.command === "eval" && step.arguments.length === 1, "new Editor steps require eval with one C# statement body so the dirty-scene check runs atomically before the operation");
55
+ need(!/^\s*(?:global\s+)?using\s+(?:static\s+)?[\w.]+\s*(?:=|;)/m.test(step.arguments[0]), "eval bodies must use fully qualified types; top-level using directives cannot follow the scene guard");
56
+ }
57
+ const { files, ...limits } = profile;
58
+ return limits;
59
+ }
60
+
61
+ export function captureUnityRepairScope(project, dispatch, source) {
62
+ if (dispatch.workPacket?.schemaVersion !== 2) return;
63
+ const metaGuids = Object.fromEntries(dispatch.workPacket.changes.map(c => [c.path, c.operation === "edit" ? guid(project, `${c.path}.meta`) : null]));
64
+ const relative = `.nccgs/evidence/work-packets/${dispatch.id}-scope.json`;
65
+ atomicJson(contained(project, relative), { schemaVersion: 1, taskId: dispatch.taskId, dispatchId: dispatch.id,
66
+ sourceSnapshot: source.sha256, fileEntries: source.fileEntries, metaGuids });
67
+ dispatch.workPacket.scope = artifact(project, relative);
68
+ }
69
+
70
+ export function verifyUnityRepairScope(project, dispatch, complete = false) {
71
+ if (dispatch.workPacket?.schemaVersion !== 2) return;
72
+ const p = dispatch.workPacket, baseline = json(verifyArtifact(project, p.scope));
73
+ need(baseline.dispatchId === dispatch.id && baseline.taskId === dispatch.taskId && baseline.sourceSnapshot === dispatch.snapshot, "scope baseline identity changed");
74
+ const before = new Map(baseline.fileEntries), after = new Map(snapshot(project, null, { includeFiles: true }).fileEntries);
75
+ const owned = new Set(p.changes.flatMap(c => [c.path, `${c.path}.meta`]));
76
+ for (const [file, hash] of before) {
77
+ need(after.has(file), `source file removed: ${file}; deletions/renames are outside this packet`);
78
+ need(after.get(file) === hash || owned.has(file), `source changed outside exact ownership: ${file}`);
79
+ }
80
+ for (const file of after.keys()) need(before.has(file) || owned.has(file), `source created outside exact ownership: ${file}`);
81
+ for (const c of p.changes) {
82
+ const meta = `${c.path}.meta`, present = after.has(meta);
83
+ if (present) {
84
+ const current = guid(project, meta);
85
+ need(!baseline.metaGuids[c.path] || current === baseline.metaGuids[c.path], `existing GUID changed: ${meta}`);
86
+ }
87
+ if (complete) need(after.has(c.path) && present, `output or imported metadata missing: ${c.path}; finish import before native completion`);
88
+ }
89
+ }
90
+
91
+ export function repairEditorCommand(dispatch, step) {
92
+ return `node .claude/nccgs/tools/task.mjs repair-editor --id ${dispatch.taskId} --dispatch ${dispatch.id} --step ${step.id}`;
93
+ }
94
+
95
+ export function unityEditorToolScope(dispatch, input) {
96
+ if (dispatch.workPacket?.schemaVersion !== 2 || input.tool_name !== "Bash") return null;
97
+ const tool = input.tool_input ?? {}, step = dispatch.workPacket.unity.steps.find(s => repairEditorCommand(dispatch, s) === tool.command);
98
+ need(step && keys(tool, ["command", "timeout", "description", "run_in_background"]) && tool.run_in_background !== true &&
99
+ Number.isSafeInteger(tool.timeout) && tool.timeout >= step.timeoutMs + 5000 && tool.timeout <= 120000,
100
+ "only the fixed repair-editor command is permitted, foreground, with timeout at least step timeoutMs + 5000");
101
+ return { bucket: "work", read: false, editorStep: step.id };
102
+ }
103
+
104
+ export function unityEditorInvocation(project, step, safety = null) {
105
+ need(safety === null || safety === unitySceneSafety, "unknown pinned scene safety policy");
106
+ let sceneGuard = null, args = step.arguments;
107
+ if (safety) {
108
+ need(step.command === "eval" && args.length === 1, "guarded Editor step requires one eval body");
109
+ sceneGuard = { policy: safety, token: crypto.randomUUID() };
110
+ // Same Editor eval, before any packet statement: includes untitled/additive
111
+ // loaded scenes. Never auto-save, discard or switch a dirty scene.
112
+ const prefix = `{ for (int nccgsSceneIndex = 0; nccgsSceneIndex < UnityEngine.SceneManagement.SceneManager.sceneCount; nccgsSceneIndex++) { var nccgsScene = UnityEngine.SceneManagement.SceneManager.GetSceneAt(nccgsSceneIndex); if (nccgsScene.isDirty) return new { nccgsSceneGuard = "${safety}", token = "${sceneGuard.token}", blockedScene = string.IsNullOrEmpty(nccgsScene.path) ? "<untitled>" : nccgsScene.path }; } }\n`;
113
+ args = [prefix + args[0]];
114
+ }
115
+ return { ...(sceneGuard ? { sceneGuard } : {}), executable: process.platform === "win32" ? "unity.exe" : "unity", arguments: ["command", step.command, ...args,
116
+ "--caller", "plugin", "--skill", "unity-cli",
117
+ "--project-path", fs.realpathSync(project), "--timeout", String(Math.ceil(step.timeoutMs / 1000)), "--format", "json", "--no-pager", "--non-interactive"] };
118
+ }
119
+
120
+ export function blockedUnityScene(invocation, result) {
121
+ if (!invocation.sceneGuard || result.status !== 0 || result.error || result.signal) return null;
122
+ try {
123
+ const response = JSON.parse(result.stdout), envelope = response.data?.result, value = envelope?.result;
124
+ if (response.success === true && envelope.success === true && value?.nccgsSceneGuard === invocation.sceneGuard.policy &&
125
+ value.token === invocation.sceneGuard.token && typeof value.blockedScene === "string") return value.blockedScene;
126
+ } catch { /* Unknown output retains existing conservative execution handling. */ }
127
+ return null;
128
+ }
@@ -8,6 +8,7 @@ import { snapshot, taskPath } from "./gates.mjs";
8
8
  import { inputPolicy } from "./inputs.mjs";
9
9
  import { acquireLock, ownerState, pidState } from "./process-lock.mjs";
10
10
  import { captureCriteria, measurementBaseline } from "./criteria.mjs";
11
+ import { inFinalReviewPhase } from "./review-budget.mjs";
11
12
 
12
13
  const worker = fileURLToPath(new URL("./verification-worker.mjs", import.meta.url));
13
14
  const need = (condition, message) => { if (!condition) throw new Error(message); };
@@ -24,8 +25,14 @@ export function finishJob(project, job, result) {
24
25
  const directory = jobDirectory(project, job.id);
25
26
  const receipt = `.nccgs/evidence/check-${job.id}.json`;
26
27
  if (fs.existsSync(contained(project, receipt))) return jobStatus(project, job.id, job.taskId);
27
- let after = null, snapshotError = null;
28
- try { after = snapshot(project, json(taskPath(project, job.taskId))).sha256; } catch (error) { snapshotError = error.message; }
28
+ let after = null, afterGenerated = [], snapshotError = null;
29
+ try {
30
+ const captured = snapshot(project, json(taskPath(project, job.taskId)));
31
+ after = captured.sha256; afterGenerated = captured.unityGeneratedFiles ?? [];
32
+ } catch (error) { snapshotError = error.message; }
33
+ const beforeGenerated = new Map((job.unityGeneratedBaseline ?? []).map(file => [file.path, file.sha256]));
34
+ const generatedFileChanges = afterGenerated.filter(file => beforeGenerated.get(file.path) !== file.sha256)
35
+ .map(file => ({ path: file.path, beforeSha256: beforeGenerated.get(file.path) ?? null, afterSha256: file.sha256 }));
29
36
  const logPath = `.nccgs/evidence/check-${job.id}.log`;
30
37
  fs.mkdirSync(path.dirname(contained(project, logPath)), { recursive: true });
31
38
  if (!fs.existsSync(contained(project, logPath))) fs.writeFileSync(contained(project, logPath), result.error || "No command output captured\n");
@@ -38,6 +45,7 @@ export function finishJob(project, job, result) {
38
45
  timeoutMs: job.timeoutMs, supervisorDeadline: job.supervisorDeadline ?? null,
39
46
  timedOut: result.timedOut === true, signal: result.signal ?? null,
40
47
  snapshotError, executable: job.executable, arguments: job.arguments, exitCode: result.exitCode ?? null,
48
+ ...(job.unityGeneratedBaseline ? { unityGeneratedFiles: { before: job.unityGeneratedBaseline, after: afterGenerated, changes: generatedFileChanges } } : {}),
41
49
  error: result.error ?? null, sourceUnchanged: job.snapshot === after, status, log: artifact(project, logPath),
42
50
  acceptance: captureCriteria(project, job, status) });
43
51
  atomicJson(path.join(directory, "result.json"), { status, evidence: artifact(project, receipt) });
@@ -83,6 +91,7 @@ export function listJobs(project, taskId) {
83
91
  export function pendingJobs(project, taskId) { return listJobs(project, taskId).filter(job => !["PASS", "FAIL"].includes(job.status)); }
84
92
  export function startVerification(project, taskId, options) {
85
93
  project = fs.realpathSync(project);
94
+ need(!inFinalReviewPhase(project, process.env.NCCGS_SUPERVISED_SESSION), "Final-review reserve cannot run new verification jobs; use existing receipts");
86
95
  const task = json(taskPath(project, taskId));
87
96
  need(task.schemaVersion !== 2 || task.mode === "implement" || !(process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT), "Audit/plan tasks cannot run arbitrary verification commands inside Claude");
88
97
  need(typeof options.name === "string" && options.name.trim() && options.executable, "verify requires --name CHECK -- EXECUTABLE [ARGS]");
@@ -107,7 +116,7 @@ export function startVerification(project, taskId, options) {
107
116
  const lock = acquireLock(`${index}.lock`, { taskId, requestKey });
108
117
  let measurementLock;
109
118
  try {
110
- const current = snapshot(project, task).sha256;
119
+ const captured = snapshot(project, task), current = captured.sha256;
111
120
  const spec = { taskId, name: options.name, executable: options.executable, arguments: options.arguments ?? [],
112
121
  timeoutMs, snapshot: current, contractHash: task.contractHash ?? null,
113
122
  ...(supervisorDeadline === undefined ? {} : { supervisorDeadline }),
@@ -133,6 +142,7 @@ export function startVerification(project, taskId, options) {
133
142
  fs.mkdirSync(directory, { recursive: true });
134
143
  fs.mkdirSync(contained(project, ".nccgs/evidence"), { recursive: true });
135
144
  const job = { schemaVersion: 1, id: jobId, project, ...spec, requestKey, requestHash, measurementBaseline: baseline, queuedAt: new Date().toISOString(),
145
+ ...(captured.unityGeneratedFiles ? { unityGeneratedBaseline: captured.unityGeneratedFiles } : {}),
136
146
  sessionId: process.env.NCCGS_SUPERVISED_SESSION || null };
137
147
  atomicJson(path.join(directory, "request.json"), job);
138
148
  atomicJson(index, { jobId, requestHash }); // pinned before launch: an ambiguous crash cannot mint a duplicate
@@ -0,0 +1,163 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.IO;
4
+ using System.IO.Pipes;
5
+ using System.Runtime.InteropServices;
6
+ using System.Security.Cryptography;
7
+ using System.Security.Principal;
8
+ using System.Text;
9
+ using System.Text.RegularExpressions;
10
+
11
+ // Optional, fail-closed cleanup for the qualified Unity/Roslyn binary pair.
12
+ // Never kill or exempt a descendant. The caller still waits for an empty Job.
13
+ // Shutdown frame matches BuildRequest.CreateShutdown().WriteAsync() in Roslyn
14
+ // 41a5af9d2c459a06c0795bf21a1c046200f375bf (4.3.1). Unknown builds are untouched.
15
+ internal sealed class NccgsUnityCompilerShutdown {
16
+ const string CompilerHash = "a671cfd0e1ca5621845cc62c62e1de06233a9c48de76151bc51d36758cd1ca5f";
17
+ const string HostHash = "d2607dbc6c95c252baaba299b659f156b388c6130ac846f79eb4c768f91a019f";
18
+ const string Commit = "41a5af9d2c459a06c0795bf21a1c046200f375bf";
19
+ readonly HashSet<int> attempted = new HashSet<int>();
20
+ DateTime nextScan = DateTime.MinValue;
21
+ sealed class Candidate : IDisposable {
22
+ internal int Pid; internal IntPtr Handle; internal string Pipe;
23
+ public void Dispose() { if(Handle!=IntPtr.Zero) CloseHandle(Handle); Handle=IntPtr.Zero; }
24
+ }
25
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job,int kind,IntPtr data,int size,IntPtr returned);
26
+ [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint access,bool inherit,int pid);
27
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool IsProcessInJob(IntPtr process,IntPtr job,out bool member);
28
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool QueryFullProcessImageName(IntPtr process,int flags,StringBuilder name,ref int size);
29
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetNamedPipeServerProcessId(IntPtr pipe,out uint pid);
30
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
31
+ [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr handle,uint timeout);
32
+ [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr memory);
33
+ [DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr process,int kind,IntPtr data,int size,out int needed);
34
+ [DllImport("shell32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CommandLineToArgvW(string command,out int count);
35
+
36
+ static bool OwnedAlive(IntPtr job,IntPtr process) {
37
+ bool member;
38
+ return WaitForSingleObject(process,0)==258 && IsProcessInJob(process,job,out member) && member;
39
+ }
40
+ static int[] Members(IntPtr job) {
41
+ for(int size=4096;size<=1048576;size*=2) {
42
+ IntPtr data=Marshal.AllocHGlobal(size);
43
+ try {
44
+ if(!QueryInformationJobObject(job,3,data,size,IntPtr.Zero)) {
45
+ if(Marshal.GetLastWin32Error()==234) continue;
46
+ return null;
47
+ }
48
+ int count=Marshal.ReadInt32(data,4);
49
+ if(count<0 || count>(size-8)/IntPtr.Size) return null;
50
+ int[] result=new int[count];
51
+ for(int i=0;i<count;i++) result[i]=checked((int)Marshal.ReadIntPtr(data,8+i*IntPtr.Size).ToInt64());
52
+ return result;
53
+ } finally { Marshal.FreeHGlobal(data); }
54
+ }
55
+ return null;
56
+ }
57
+ static string Image(IntPtr process) {
58
+ int length=32768; var text=new StringBuilder(length);
59
+ return QueryFullProcessImageName(process,0,text,ref length) ? text.ToString() : null;
60
+ }
61
+ static string Command(IntPtr process) {
62
+ int needed; NtQueryInformationProcess(process,60,IntPtr.Zero,0,out needed);
63
+ if(needed<16 || needed>131072) return null;
64
+ IntPtr data=Marshal.AllocHGlobal(needed);
65
+ try {
66
+ int size; if(NtQueryInformationProcess(process,60,data,needed,out size)!=0) return null;
67
+ int bytes=(ushort)Marshal.ReadInt16(data);
68
+ IntPtr text=Marshal.ReadIntPtr(data,IntPtr.Size==8?8:4);
69
+ long offset=text.ToInt64()-data.ToInt64();
70
+ if(bytes%2!=0 || offset<0 || offset+bytes>needed) return null;
71
+ return Marshal.PtrToStringUni(text,bytes/2);
72
+ } finally { Marshal.FreeHGlobal(data); }
73
+ }
74
+ static string[] Arguments(string command) {
75
+ if(String.IsNullOrEmpty(command)) return null;
76
+ int count; IntPtr data=CommandLineToArgvW(command,out count);
77
+ if(data==IntPtr.Zero) return null;
78
+ try {
79
+ var result=new string[count];
80
+ for(int i=0;i<count;i++) result[i]=Marshal.PtrToStringUni(Marshal.ReadIntPtr(data,i*IntPtr.Size));
81
+ return result;
82
+ } finally { LocalFree(data); }
83
+ }
84
+ static string Digest(string file) {
85
+ using(var stream=File.OpenRead(file)) using(var hash=SHA256.Create())
86
+ return BitConverter.ToString(hash.ComputeHash(stream)).Replace("-","").ToLowerInvariant();
87
+ }
88
+ internal static string QualifiedPipe(string image,string[] args) {
89
+ if(image==null || args==null || args.Length!=4 || args[1]!="exec" || !Path.IsPathRooted(args[2])) return null;
90
+ string compiler=Path.GetFullPath(args[2]);
91
+ string suffix=Path.Combine("Editor","Data","DotNetSdkRoslyn","VBCSCompiler.dll");
92
+ if(!compiler.EndsWith(Path.DirectorySeparatorChar+suffix,StringComparison.OrdinalIgnoreCase)) return null;
93
+ string data=Path.GetDirectoryName(Path.GetDirectoryName(compiler));
94
+ if(!String.Equals(Path.GetFullPath(image),Path.Combine(data,"NetCoreRuntime","dotnet.exe"),StringComparison.OrdinalIgnoreCase)) return null;
95
+ if(!args[3].StartsWith("-pipename:",StringComparison.Ordinal)) return null;
96
+ string pipe=args[3].Substring(10);
97
+ if(!Regex.IsMatch(pipe,@"\A[A-Za-z0-9_.-]{1,160}\z")) return null;
98
+ if(Digest(compiler)!=CompilerHash || Digest(image)!=HostHash) return null;
99
+ return pipe;
100
+ }
101
+ internal static byte[] ShutdownFrame() {
102
+ using(var body=new MemoryStream()) {
103
+ using(var writer=new BinaryWriter(body,Encoding.Unicode,true)) {
104
+ writer.Write(Guid.Empty.ToByteArray()); writer.Write((uint)1146299681);
105
+ writer.Write(Commit); writer.Write(1); writer.Write(1360294437);
106
+ writer.Write(0); writer.Write(0); writer.Flush();
107
+ }
108
+ using(var framed=new MemoryStream()) using(var writer=new BinaryWriter(framed)) {
109
+ writer.Write(checked((int)body.Length)); writer.Write(body.ToArray()); return framed.ToArray();
110
+ }
111
+ }
112
+ }
113
+ static bool ReadExact(Stream stream,byte[] bytes) {
114
+ int offset=0;
115
+ while(offset<bytes.Length) {
116
+ var read=stream.ReadAsync(bytes,offset,bytes.Length-offset);
117
+ if(!read.Wait(1000) || read.Result==0) return false;
118
+ offset+=read.Result;
119
+ }
120
+ return true;
121
+ }
122
+ // Peer PID is verified on this exact open pipe before any bytes are written.
123
+ // A same-name pipe belonging to a foreign server receives no shutdown request.
124
+ internal static bool Request(IntPtr job,IntPtr process,int pid,string pipeName) {
125
+ using(var pipe=new NamedPipeClientStream(".",pipeName,PipeDirection.InOut,PipeOptions.Asynchronous,TokenImpersonationLevel.Identification)) {
126
+ pipe.Connect(250);
127
+ uint serverPid;
128
+ if(!GetNamedPipeServerProcessId(pipe.SafePipeHandle.DangerousGetHandle(),out serverPid) || serverPid!=(uint)pid || !OwnedAlive(job,process)) return false;
129
+ byte[] frame=ShutdownFrame();
130
+ if(!pipe.WriteAsync(frame,0,frame.Length).Wait(1000)) return false;
131
+ byte[] length=new byte[4]; if(!ReadExact(pipe,length) || BitConverter.ToInt32(length,0)!=8) return false;
132
+ byte[] response=new byte[8]; if(!ReadExact(pipe,response)) return false;
133
+ return BitConverter.ToInt32(response,0)==3 && BitConverter.ToInt32(response,4)==pid;
134
+ }
135
+ }
136
+ internal void TryRequest(IntPtr job) {
137
+ if(DateTime.UtcNow<nextScan) return;
138
+ nextScan=DateTime.UtcNow.AddMilliseconds(500);
139
+ var candidates=new List<Candidate>();
140
+ try {
141
+ int[] members=Members(job); if(members==null || members.Length==0) return;
142
+ foreach(int pid in members) {
143
+ IntPtr process=OpenProcess(0x00101000,false,pid);
144
+ if(process==IntPtr.Zero) return; // uncertain membership/liveness: leave ownership alone
145
+ var candidate=new Candidate { Pid=pid,Handle=process };
146
+ candidates.Add(candidate);
147
+ if(!OwnedAlive(job,process)) return;
148
+ string image=Image(process);
149
+ if(String.Equals(image,Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"conhost.exe"),StringComparison.OrdinalIgnoreCase)) continue;
150
+ candidate.Pipe=QualifiedPipe(image,Arguments(Command(process)));
151
+ // Do not shut down even a known compiler while another real worker remains.
152
+ if(candidate.Pipe==null) return;
153
+ }
154
+ foreach(var c in candidates) {
155
+ if(c.Pipe==null || !attempted.Add(c.Pid)) continue;
156
+ bool acknowledged=false;
157
+ try { acknowledged=Request(job,c.Handle,c.Pid,c.Pipe); } catch { /* keep original ownership and timeout */ }
158
+ Console.Error.WriteLine("[NCCGS] Owned Unity compiler {0}: graceful shutdown {1}; waiting for all descendants.",c.Pid,acknowledged?"acknowledged":"unconfirmed");
159
+ }
160
+ } catch { /* Unknown runtime/process data never authorizes cleanup or completion. */ }
161
+ finally { foreach(var c in candidates) c.Dispose(); }
162
+ }
163
+ }
@@ -73,7 +73,14 @@ public static class NccgsWindowsJob {
73
73
  if(!CreateProcess(executable,command,IntPtr.Zero,IntPtr.Zero,true,0x00080000|0x08000000,IntPtr.Zero,cwd,ref start,out proc)) throw Error("Create process in Job Object");
74
74
  WaitForSingleObject(proc.process,0xffffffff); uint code; if(!GetExitCodeProcess(proc.process,out code)) throw Error("Process result");
75
75
  // A root exit is not completion: retain ownership until every descendant exits.
76
- for(;;) { Accounting data; if(!QueryInformationJobObject(job,1,out data,Marshal.SizeOf(typeof(Accounting)),IntPtr.Zero)) throw Error("Descendant state"); if(data.active==0) break; System.Threading.Thread.Sleep(50); }
76
+ var compilerShutdown=new NccgsUnityCompilerShutdown();
77
+ for(;;) {
78
+ Accounting data;
79
+ if(!QueryInformationJobObject(job,1,out data,Marshal.SizeOf(typeof(Accounting)),IntPtr.Zero)) throw Error("Descendant state");
80
+ if(data.active==0) break;
81
+ compilerShutdown.TryRequest(job);
82
+ System.Threading.Thread.Sleep(50);
83
+ }
77
84
  return unchecked((int)code);
78
85
  } finally {
79
86
  if(proc.thread!=IntPtr.Zero) CloseHandle(proc.thread); if(proc.process!=IntPtr.Zero) CloseHandle(proc.process);
package/CHANGELOG.md CHANGED
@@ -1,5 +1,102 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0 — 2026-10-28
4
+
5
+ Prepared stable package; unpublished. Local preparation does not move npm tags.
6
+
7
+ - Publish existing external operator Editor start, graceful shutdown command and
8
+ settled stop receipts into immutable reviewer-readable project bundles. Preserve
9
+ original bytes, hashes and ownership, reject inconsistent/uncertain/forced stops,
10
+ and identify historical imports without rewriting sealed tasks or verdicts.
11
+ - Keep RC4 handoff, scene guard, source controls, process management, independent
12
+ review, acceptance and total-budget enforcement unchanged. No new game gate or
13
+ process permission is introduced.
14
+ - Record scoped exact-RC4 native qualification: real dirty-scene refusal, new repair,
15
+ automatic PARTIAL handoff with no continuation, all 28 criteria/385 assertions,
16
+ independent PASS and normal closure. The original LOW evidence-location finding
17
+ remains in that sealed review; its addendum is assessed separately.
18
+ - Stable archive validation and independent maintenance review are recorded in
19
+ release receipts. They do not claim another full Unity task through these bytes.
20
+
21
+
22
+ ## 2.0.0-rc.4 — 2026-10-28
23
+
24
+ Prepared candidate; unpublished. No npm tag or stable promotion.
25
+
26
+ - Settle a protected implementation work phase with a deterministic PARTIAL
27
+ report when current implementation and measured checks are ready for review.
28
+ A prose-only response no longer spends another continuation solely on handoff.
29
+ Preserve independent verdicts, all acceptance gates, history and original caps.
30
+ - Pin an atomic loaded-scene precondition to new Unity repair reservations.
31
+ Dirty named, additive or untitled scenes block the operation without auto-save
32
+ or discard. Retain failed attempts and old reservations; no implicit retry.
33
+ - Reserve final independent review money/time within the original root budget.
34
+ Share the work pool across design, slices, repair and verification. Admit
35
+ unknown worker cost only through verified, exclusive native session coverage;
36
+ missing session usage fails closed, and resumed cumulative cost is never summed
37
+ twice. The prior native reserve case qualified its disclosed new-script and
38
+ asset repair path with 28 criteria/385 assertions and independent final PASS.
39
+ - RC4-specific handoff/scene changes receive framework tests and operator Unity
40
+ checks. A fresh native task through this exact archive remains pending.
41
+
42
+ - Qualify the full component lifecycle on a fresh Unity project through actual
43
+ Claude Code: reviewed design, two dependent implementation slices from API
44
+ scaffolds, first-import verification, independent review, a disclosed fault,
45
+ typed repair and independent re-review. All 24 original criteria (349 assertions)
46
+ pass; the real failed PlayMode attempt remains recorded. The combined design and
47
+ implementation phases used 7/12 dispatches, three independent reviews and
48
+ USD 10.214066/12 within the original 75-minute envelope. Installed source guards,
49
+ process ownership, role limits, criteria and history remained unchanged. This
50
+ known-test component qualification does not establish broad game productivity.
51
+ - Add opt-in exact `unityGeneratedFiles` declarations before task initialization
52
+ so Unity's first import can create IDE/local metadata without a false source
53
+ mutation failure. Pin the declaration as a canonical input and retain output
54
+ before/after hashes in managed receipts. Keep all undeclared files tracked;
55
+ reject source paths, folders, globs and canonical overlaps. Existing policies,
56
+ historical attempts, process ownership, review and budgets remain unchanged.
57
+ - Settle cold Unity compilation without waiting for the qualified Roslyn server's
58
+ idle keepalive. After the command root exits and no other worker remains, request
59
+ graceful shutdown only from the verified compiler pipe inside the owned Windows
60
+ Job Object. Match the tested compiler/host binary hashes; unknown builds retain
61
+ the original wait/timeout behavior. Still drain every descendant and preserve the
62
+ command exit code, deadlines, independent review, criteria, history and budgets.
63
+ - Avoid Unity CLI's two-minute idle auth broker in managed verification command
64
+ trees using the supported process-local `UNITY_NO_AUTH_BROKER=1` setting.
65
+ Preserve OS process ownership, descendant draining, failure/timeout semantics,
66
+ account settings, independent review, acceptance criteria and spent budgets.
67
+ - Add bounded task slices with preview, pinned plans, dependency tracking and
68
+ focused worker prompts inside one original task. Final integration retains all
69
+ original criteria/checks and a reviewer independent of every contributor.
70
+ - Protect aggregate dispatch capacity, original repair/QA/review reserves and
71
+ review limits. Preserve plan revisions, partial attempts and dependency history;
72
+ retries do not reset limits or create child/session budgets. Cost/time admission
73
+ stays unchanged.
74
+ - Qualify the two-slice and existing-file `unity-code` repair path with actual
75
+ Claude Code workers and Unity tests on 2026-10-27. The controlled regression
76
+ retains all 24 criteria, failed evidence, both independent reviews and original
77
+ total budgets. This known-component case does not qualify large-game productivity,
78
+ new-file imports, Editor asset operations or the exact published RC archive.
79
+ - Add opt-in schema-2 Unity repair packets: exact code edits/creation or pinned
80
+ Editor asset operations. Preserve schema-1 limits and historical reservations.
81
+ - Bound Editor operations to one project, fixed positional arguments, finite
82
+ foreground calls and durable single-attempt receipts. Check source scope and
83
+ existing GUIDs; import new metadata before completion. Uncertain operations
84
+ retain ownership until externally reconciled, without refunds or automatic retry.
85
+ - Unity repair still requires original measured checks, independent review and
86
+ manual/external acceptance. Synthetic tests do not qualify native Unity delivery.
87
+ - Synchronize architecture and orchestration guidance with standalone defaults;
88
+ generate native role ceilings from agent-library frontmatter alongside routing.
89
+ - Extend `task dispatch-prompt` to prepare registered implementation, analysis and
90
+ review assignments with safe PARTIAL handoff templates. Repair packets keep
91
+ their existing prompt and permissions.
92
+ - Add read-only `task handoff-validate` for a worker's own declared handoff and
93
+ coordinator inspection, so schema errors can be corrected before completion.
94
+ - Add coordinator `task record-review` to import a completed reviewer's exact
95
+ pinned result through the existing record gate. Review independence, objective
96
+ criteria, external/manual gates, task history and spent budgets are preserved.
97
+ - The scoped native qualifications above predate the final RC4 handoff/scene
98
+ additions. Their frozen source, tests and historical evidence remain separate.
99
+
3
100
  ## 2.0.0-rc.3 — 2026-10-07
4
101
 
5
102
  Published-1.3.0 upgrade correction on next; latest remains unchanged.
@@ -56,3 +153,13 @@ The game case ran a frozen framework with a session-only reviewer turn-capacity
56
153
  override. This RC packages the maintained source. Upgrade qualification from
57
154
  published 1.3.0 and a fresh end-to-end task using this exact RC are pending.
58
155
  Try a disposable project or backed-up copy; see UPGRADING.md.
156
+ # Stable preparation follow-up — 2.0.0 (unpublished)
157
+
158
+ - Correct remaining current-version RC labels while retaining historical release
159
+ records and their original qualification scope.
160
+ - Add opt-in, correlated native-result transport for read-only analysis/review
161
+ handoffs when the native client requires returned text. Copies exact output;
162
+ existing review, acceptance, source, process and budget gates still apply.
163
+ - Failed audit attempts and their consumed allowances remain in their original
164
+ history. New qualification and publication require their own explicit evidence.
165
+
package/MEMORY.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # NCCGS — portable framework decisions
2
2
 
3
3
  NCCGS means **New Claude Code Game Studio** and targets **Claude Code**.
4
- This is the public 2.0.0-rc.3 release candidate, distributed through npm's next tag.
5
- It is not stable promotion. See README.md and CHANGELOG.md for release scope.
4
+ This is the prepared 2.0.0 stable package. It has not been published.
5
+ Local preparation does not change npm tags. Native RC4 qualification is scoped
6
+ to its frozen archive; see README.md and CHANGELOG.md for evidence boundaries.
6
7
  Project-specific authorizations, provider sessions, budgets and internal development
7
8
  history are retained separately, outside the public package. Nothing in this file
8
9
  authorizes spending, provider access or changes to a user's project by itself.