nccgs 2.1.0 → 2.1.1

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 (76) hide show
  1. package/.claude/agents/nccgs-adversarial-reviewer.md +1 -1
  2. package/.claude/agents/nccgs-engine-programmer.md +1 -1
  3. package/.claude/agents/nccgs-fast-implementer.md +1 -1
  4. package/.claude/agents/nccgs-game-designer.md +1 -1
  5. package/.claude/agents/nccgs-gameplay-programmer.md +1 -1
  6. package/.claude/agents/nccgs-qa-engineer.md +1 -1
  7. package/.claude/agents/nccgs-task-scout.md +1 -1
  8. package/.claude/agents/nccgs-ui-programmer.md +1 -1
  9. package/.claude/agents/nccgs-unity-build-specialist.md +1 -1
  10. package/.claude/agents/nccgs-unity-implementer.md +1 -1
  11. package/.claude/agents/nccgs-unity-systems-specialist.md +1 -1
  12. package/.claude/agents/nccgs-unity-ui-specialist.md +1 -1
  13. package/.claude/agents/nccgs-verification-engineer.md +1 -1
  14. package/.claude/nccgs/MEMORY.md +3 -1
  15. package/.claude/nccgs/VERSION +1 -1
  16. package/.claude/nccgs/constitution.md +187 -176
  17. package/.claude/nccgs/hooks/closure-guard.mjs +2 -0
  18. package/.claude/nccgs/hooks/unity-transport-guard.mjs +11 -0
  19. package/.claude/nccgs/pipeline.json +260 -260
  20. package/.claude/nccgs/protocols/context-packets.md +5 -0
  21. package/.claude/nccgs/protocols/model-routing.md +46 -46
  22. package/.claude/nccgs/protocols/review-handoff.md +21 -1
  23. package/.claude/nccgs/protocols/shared-workflow-context.md +33 -0
  24. package/.claude/nccgs/protocols/unity-boundary.md +20 -0
  25. package/.claude/nccgs/routing.generated.md +4 -4
  26. package/.claude/nccgs/settings.fragment.json +84 -24
  27. package/.claude/nccgs/studio.json +53 -53
  28. package/.claude/nccgs/tools/autonomy.mjs +13 -0
  29. package/.claude/nccgs/tools/command-tokens.mjs +22 -0
  30. package/.claude/nccgs/tools/hook-transport.mjs +34 -0
  31. package/.claude/nccgs/tools/native-completion.mjs +10 -1
  32. package/.claude/nccgs/tools/returned-handoff.mjs +11 -2
  33. package/.claude/nccgs/tools/shared-context-guard.mjs +60 -0
  34. package/.claude/nccgs/tools/shared-context.mjs +25 -0
  35. package/.claude/nccgs/tools/tool-guard.mjs +120 -131
  36. package/.claude/nccgs/tools/unity-transport.mjs +170 -0
  37. package/.claude/nccgs/workflow-catalog.json +4 -4
  38. package/CHANGELOG.md +235 -200
  39. package/CLAUDE.md +10 -8
  40. package/MEMORY.md +109 -60
  41. package/README.md +206 -191
  42. package/UPGRADING.md +130 -118
  43. package/VERSION +1 -1
  44. package/docs/ARCHITECTURE.md +131 -131
  45. package/docs/ASSET-PRODUCTION-GRAPHIFY-PLAN.md +230 -0
  46. package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +117 -116
  47. package/docs/NCCGS-1.5.md +237 -237
  48. package/docs/PROJECT-POLICY.md +94 -94
  49. package/docs/RELEASE-STATUS.md +17 -9
  50. package/docs/UNINSTALL.md +3 -3
  51. package/docs/WFK17-HOOK-REPAIR.md +71 -0
  52. package/docs/WORKFLOWS.md +85 -85
  53. package/package.json +1 -1
  54. package/scaffold/.nccgs/project.yaml +109 -108
  55. package/scripts/benchmark-v15.mjs +173 -173
  56. package/scripts/doctor.mjs +261 -261
  57. package/scripts/install.mjs +530 -529
  58. package/scripts/run.mjs +5 -4
  59. package/scripts/supervise.mjs +4 -0
  60. package/scripts/uninstall.mjs +292 -292
  61. package/scripts/validate.mjs +285 -280
  62. package/tests/autonomy.test.mjs +126 -113
  63. package/tests/design-readiness.test.mjs +199 -199
  64. package/tests/fixtures/compiler-shutdown.cs +67 -64
  65. package/tests/fixtures/native-provider.mjs +61 -0
  66. package/tests/framework.test.mjs +740 -740
  67. package/tests/hook-repair.test.mjs +106 -0
  68. package/tests/lifecycle-v15.test.mjs +346 -300
  69. package/tests/native-completion-notices.test.mjs +181 -0
  70. package/tests/phase1.test.mjs +427 -416
  71. package/tests/phase2.test.mjs +250 -250
  72. package/tests/policy-v15.test.mjs +234 -234
  73. package/tests/returned-handoff-notices.test.mjs +170 -0
  74. package/tests/runtime-v15.test.mjs +1197 -1191
  75. package/tests/task-slices.test.mjs +285 -285
  76. package/tests/unity-transport.test.mjs +348 -0
@@ -1,131 +1,120 @@
1
- import path from "node:path";
2
- import { contained, json, resolveExecution } from "./policy.mjs";
3
- import { taskPath, check } from "./gates.mjs";
4
- import { boundTask } from "./runtime.mjs";
5
- import { claim, findDispatch, rejectUnclaimed, admitRepairTool, dispatchPrompt, pinnedRepairEvidence } from "./orchestration.mjs";
6
- import { requireDesignReady } from "./design-readiness.mjs";
7
- import { inFinalReviewPhase } from "./review-budget.mjs";
8
-
9
- const need = (condition, message) => { if (!condition) throw new Error(message); };
10
- const reads = new Set(["Read", "Glob", "Grep", "WebSearch", "WebFetch", "AskUserQuestion"]);
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"]);
12
- export function parseTaskCommand(command, project = process.cwd()) {
13
- // Parse data quotes without interpreting a shell. Punctuation inside report
14
- // text is data; composition/redirection/expansion outside quotes stays blocked.
15
- const tokens = []; let token = "", quote = null, started = false;
16
- for (let i = 0; i < command.length; i++) {
17
- const c = command[i];
18
- if (quote) {
19
- if (c === quote) { quote = null; continue; }
20
- if (quote === '"' && /[`$]/.test(c)) return null;
21
- if (quote === '"' && c === "\\" && ['"', "\\"].includes(command[i + 1])) { token += command[++i]; continue; }
22
- token += c; continue;
23
- }
24
- if (/[;&|<>`$\r\n]/.test(c)) return null;
25
- if (c === '"' || c === "'") { quote = c; started = true; continue; }
26
- if (/\s/.test(c)) { if (started) { tokens.push(token); token = ""; started = false; } continue; }
27
- token += c; started = true;
28
- }
29
- if (quote) return null;
30
- if (started) tokens.push(token);
31
- const script = tokens[1]?.replaceAll("\\", "/");
32
- if (tokens[0] !== "node" || !script || path.resolve(project, script) !== path.resolve(project, ".claude/nccgs/tools/task.mjs")) return null;
33
- const separator = tokens.indexOf("--");
34
- const options = separator < 0 ? tokens : tokens.slice(0, separator);
35
- const projectOptions = options.flatMap((t, i) => t === "--project" ? [i] : []);
36
- if (projectOptions.length > 1 || options.some(t => t.startsWith("--project="))) return null;
37
- if (projectOptions.length && (!options[projectOptions[0] + 1] || path.resolve(project, options[projectOptions[0] + 1]) !== path.resolve(project))) return null;
38
- return { command: tokens[2], options, forwarded: separator < 0 ? null : tokens.slice(separator + 1) };
39
- }
40
- export function safeTaskCommand(command, project = process.cwd()) {
41
- const parsed = parseTaskCommand(command, project);
42
- return parsed && governance.has(parsed.command) && !parsed.forwarded ? parsed.command : null;
43
- }
44
- export function safeVerifyCommand(command, project = process.cwd()) {
45
- const parsed = parseTaskCommand(command, project);
46
- return Boolean(parsed?.command === "verify" && parsed.forwarded?.length && parsed.options.includes("--id") && parsed.options.includes("--name"));
47
- }
48
- export function recoveryTool(input, project = process.cwd()) {
49
- return !input.agent_id && ["Bash", "PowerShell"].includes(input.tool_name) && ["report", "inspect"].includes(safeTaskCommand(input.tool_input?.command ?? "", project));
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
- }
61
- export function guardTool(project, input) {
62
- if (recoveryTool(input, project)) return;
63
- if (resolveExecution(project).name !== "standalone") return;
64
- const tool = input.tool_input ?? {}, name = input.tool_name;
65
- let dispatch = findDispatch(project, input);
66
- const id = dispatch?.taskId ?? boundTask(project, input.session_id);
67
- const task = id ? json(taskPath(project, id)) : null;
68
- if (task && task.schemaVersion !== 2) throw new Error("Legacy tasks keep compatibility gates; create a fresh standalone task after migration");
69
- if (input.agent_id) need(dispatch, "Worker tool has no correlated dispatch; cannot bypass its scope or budget");
70
- if (input.agent_id && dispatch?.workPacket) { requireDesignReady(project, task); return admitRepairTool(project, dispatch.id, input); }
71
- if (reads.has(name)) return;
72
- if (["Agent", "Task"].includes(name)) {
73
- need(!input.agent_id, "NCCGS workers cannot recursively delegate");
74
- need(dispatch && /^\[NCCGS-DISPATCH:[\w-]+\]/.test(tool.prompt ?? ""), "Reserve this Agent call first and prefix its prompt with [NCCGS-DISPATCH:ID]");
75
- need(task && dispatch.taskId === task.id, "Dispatch task missing");
76
- try {
77
- need(tool.run_in_background !== true, "Managed workers must use foreground Agent calls; use task verify --background for durable engine jobs");
78
- if (dispatch.kind === "review") {
79
- const readiness = check(project, task, "review");
80
- need(readiness.allowed, readiness.message);
81
- }
82
- const prompt = dispatch.workPacket || dispatch.slice ? dispatchPrompt(project, task.id, dispatch.id).prompt : null;
83
- claim(project, dispatch.id, { sessionId: input.session_id, toolUseId: input.tool_use_id, agent: tool.subagent_type });
84
- if (prompt) return { updatedInput: { ...tool, prompt } };
85
- } catch (error) {
86
- if (input.hook_event_name === "PreToolUse" && dispatch.status === "reserved" && tool.subagent_type === dispatch.agent) {
87
- try { rejectUnclaimed(project, dispatch.id, { sessionId: input.session_id, toolUseId: input.tool_use_id, agent: tool.subagent_type, reason: error.message }); }
88
- catch { /* A concurrent claim or uncertain state must stay fail-closed. */ }
89
- }
90
- throw error;
91
- }
92
- return;
93
- }
94
- const readOnly = task?.mode !== "implement" || (dispatch && dispatch.kind !== "implementation");
95
- if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(name)) {
96
- const absolute = path.resolve(project, tool.file_path || tool.path || tool.notebook_path || ".");
97
- const relative = path.relative(project, absolute).split(path.sep).join("/");
98
- contained(project, relative);
99
- need(!/^\.(?:claude|nccgs)(?:\/|$)/i.test(relative) || relative.startsWith(".nccgs/evidence/"), "Framework/governance records must be changed through the task CLI or external configuration");
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");
102
- need(!pinnedRepairEvidence(project, relative), "Reserved repair packet/context evidence is immutable; preserve it and create a fresh packet for a new eligible attempt");
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");
104
- if (input.agent_id && readOnly) need(task?.contract.analysisArtifacts?.includes(relative), "Read-only worker may write only declared analysis artifacts");
105
- return;
106
- }
107
- need(task && !readOnly, "Audit/plan tasks cannot edit source; declare analysis artifacts under .nccgs/evidence/analysis/");
108
- requireDesignReady(project, task);
109
- need(dispatch && dispatch.status === "running" && dispatch.agentId === input.agent_id, "Source writes require a running, correlated implementation dispatch");
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");
112
- need(dispatch.writePaths.some(p => lower === p.toLowerCase() || lower.startsWith(p.toLowerCase() + "/")), "Write is outside this dispatch's owned paths");
113
- return;
114
- }
115
- if (["Bash", "PowerShell"].includes(name)) {
116
- const command = tool.command ?? "";
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");
119
- if (safeTaskCommand(command, project)) { need(!input.agent_id, "Coordinator owns lifecycle commands"); return; }
120
- need(task && !readOnly, "Audit/plan/review shell execution is blocked; use read tools or externally supplied verification evidence");
121
- requireDesignReady(project, task);
122
- // Coordinator may run declared checks after workers finish. This command
123
- // can execute a verifier, so it is allowed only for implementation tasks.
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; }
125
- need(dispatch?.kind === "implementation" && dispatch.status === "running" && input.agent_id === dispatch.agentId, "Shell mutation requires a correlated implementation worker");
126
- return;
127
- }
128
- if (task) requireDesignReady(project, task);
129
- need(task && !readOnly && dispatch?.kind === "implementation" && dispatch.status === "running" && input.agent_id === dispatch.agentId,
130
- "Tool is not in the read-only allowlist; a correlated implementation dispatch is required");
131
- }
1
+ import { commandTokens } from "./command-tokens.mjs";
2
+ import { safeSharedContextCommand } from "./shared-context-guard.mjs";
3
+ import { assertUnityCliOnlyInput } from "./unity-transport.mjs";
4
+ import path from "node:path";
5
+ import { contained, json, resolveExecution } from "./policy.mjs";
6
+ import { taskPath, check } from "./gates.mjs";
7
+ import { boundTask } from "./runtime.mjs";
8
+ import { claim, findDispatch, rejectUnclaimed, admitRepairTool, dispatchPrompt, pinnedRepairEvidence } from "./orchestration.mjs";
9
+ import { requireDesignReady } from "./design-readiness.mjs";
10
+ import { inFinalReviewPhase } from "./review-budget.mjs";
11
+
12
+ const need = (condition, message) => { if (!condition) throw new Error(message); };
13
+ const reads = new Set(["Read", "Glob", "Grep", "WebSearch", "WebFetch", "AskUserQuestion"]);
14
+ 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"]);
15
+ export function parseTaskCommand(command, project = process.cwd()) {
16
+ const tokens = commandTokens(command);
17
+ if (!tokens) return null;
18
+ const script = tokens[1]?.replaceAll("\\", "/");
19
+ if (tokens[0] !== "node" || !script || path.resolve(project, script) !== path.resolve(project, ".claude/nccgs/tools/task.mjs")) return null;
20
+ const separator = tokens.indexOf("--");
21
+ const options = separator < 0 ? tokens : tokens.slice(0, separator);
22
+ const projectOptions = options.flatMap((t, i) => t === "--project" ? [i] : []);
23
+ if (projectOptions.length > 1 || options.some(t => t.startsWith("--project="))) return null;
24
+ if (projectOptions.length && (!options[projectOptions[0] + 1] || path.resolve(project, options[projectOptions[0] + 1]) !== path.resolve(project))) return null;
25
+ return { command: tokens[2], options, forwarded: separator < 0 ? null : tokens.slice(separator + 1) };
26
+ }
27
+ export function safeTaskCommand(command, project = process.cwd()) {
28
+ const parsed = parseTaskCommand(command, project);
29
+ return parsed && governance.has(parsed.command) && !parsed.forwarded ? parsed.command : null;
30
+ }
31
+ export function safeVerifyCommand(command, project = process.cwd()) {
32
+ const parsed = parseTaskCommand(command, project);
33
+ return Boolean(parsed?.command === "verify" && parsed.forwarded?.length && parsed.options.includes("--id") && parsed.options.includes("--name"));
34
+ }
35
+ export function recoveryTool(input, project = process.cwd()) {
36
+ return !input.agent_id && ["Bash", "PowerShell"].includes(input.tool_name) && ["report", "inspect"].includes(safeTaskCommand(input.tool_input?.command ?? "", project));
37
+ }
38
+ function ownHandoffValidation(command, project, task, dispatch) {
39
+ const parsed = parseTaskCommand(command, project);
40
+ if (!parsed || parsed.command !== "handoff-validate" || parsed.forwarded || dispatch?.status !== "running") return false;
41
+ const values = new Map(), args = parsed.options.slice(3);
42
+ for (let i = 0; i < args.length; i += 2) {
43
+ if (!["--id", "--dispatch", "--file", "--project"].includes(args[i]) || values.has(args[i]) || !args[i + 1] || args[i + 1].startsWith("--")) return false;
44
+ values.set(args[i], args[i + 1]);
45
+ }
46
+ return values.get("--id") === task?.id && values.get("--dispatch") === dispatch.id && dispatch.handoffPaths?.includes(values.get("--file"));
47
+ }
48
+ export function guardTool(project, input) {
49
+ assertUnityCliOnlyInput(input);
50
+ if (recoveryTool(input, project)) return;
51
+ if (resolveExecution(project).name !== "standalone") return;
52
+ const tool = input.tool_input ?? {}, name = input.tool_name;
53
+ let dispatch = findDispatch(project, input);
54
+ const id = dispatch?.taskId ?? boundTask(project, input.session_id);
55
+ const task = id ? json(taskPath(project, id)) : null;
56
+ if (task && task.schemaVersion !== 2) throw new Error("Legacy tasks keep compatibility gates; create a fresh standalone task after migration");
57
+ if (input.agent_id) need(dispatch, "Worker tool has no correlated dispatch; cannot bypass its scope or budget");
58
+ if (input.agent_id && dispatch?.workPacket) { requireDesignReady(project, task); return admitRepairTool(project, dispatch.id, input); }
59
+ if (reads.has(name)) return;
60
+ if (["Agent", "Task"].includes(name)) {
61
+ need(!input.agent_id, "NCCGS workers cannot recursively delegate");
62
+ need(dispatch && /^\[NCCGS-DISPATCH:[\w-]+\]/.test(tool.prompt ?? ""), "Reserve this Agent call first and prefix its prompt with [NCCGS-DISPATCH:ID]");
63
+ need(task && dispatch.taskId === task.id, "Dispatch task missing");
64
+ try {
65
+ need(tool.run_in_background !== true, "Managed workers must use foreground Agent calls; use task verify --background for durable engine jobs");
66
+ if (dispatch.kind === "review") {
67
+ const readiness = check(project, task, "review");
68
+ need(readiness.allowed, readiness.message);
69
+ }
70
+ const prompt = dispatch.workPacket || dispatch.slice ? dispatchPrompt(project, task.id, dispatch.id).prompt : null;
71
+ claim(project, dispatch.id, { sessionId: input.session_id, toolUseId: input.tool_use_id, agent: tool.subagent_type });
72
+ if (prompt) return { updatedInput: { ...tool, prompt } };
73
+ } catch (error) {
74
+ if (input.hook_event_name === "PreToolUse" && dispatch.status === "reserved" && tool.subagent_type === dispatch.agent) {
75
+ try { rejectUnclaimed(project, dispatch.id, { sessionId: input.session_id, toolUseId: input.tool_use_id, agent: tool.subagent_type, reason: error.message }); }
76
+ catch { /* A concurrent claim or uncertain state must stay fail-closed. */ }
77
+ }
78
+ throw error;
79
+ }
80
+ return;
81
+ }
82
+ const readOnly = task?.mode !== "implement" || (dispatch && dispatch.kind !== "implementation");
83
+ if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(name)) {
84
+ const absolute = path.resolve(project, tool.file_path || tool.path || tool.notebook_path || ".");
85
+ const relative = path.relative(project, absolute).split(path.sep).join("/");
86
+ contained(project, relative);
87
+ need(!/^\.(?:claude|nccgs)(?:\/|$)/i.test(relative) || relative.startsWith(".nccgs/evidence/"), "Framework/governance records must be changed through the task CLI or external configuration");
88
+ if (relative.startsWith(".nccgs/evidence/")) {
89
+ 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
+ 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|repair-operations|slices|external\/requests)\/|check-)/i.test(relative), "Captured runtime/checkpoint/verification/external request evidence is immutable and never authored by Claude");
92
+ if (input.agent_id && readOnly) need(task?.contract.analysisArtifacts?.includes(relative), "Read-only worker may write only declared analysis artifacts");
93
+ return;
94
+ }
95
+ need(task && !readOnly, "Audit/plan tasks cannot edit source; declare analysis artifacts under .nccgs/evidence/analysis/");
96
+ requireDesignReady(project, task);
97
+ need(dispatch && dispatch.status === "running" && dispatch.agentId === input.agent_id, "Source writes require a running, correlated implementation dispatch");
98
+ const lower = relative.toLowerCase();
99
+ if (dispatch.slice) need(dispatch.writePaths.some(p => lower === p.toLowerCase()), "Slice writes require an exact planned file; directory ownership is not granted");
100
+ need(dispatch.writePaths.some(p => lower === p.toLowerCase() || lower.startsWith(p.toLowerCase() + "/")), "Write is outside this dispatch's owned paths");
101
+ return;
102
+ }
103
+ if (["Bash", "PowerShell"].includes(name)) {
104
+ const command = tool.command ?? "";
105
+ if (input.agent_id && ownHandoffValidation(command, project, task, dispatch)) return;
106
+ 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");
107
+ if (safeTaskCommand(command, project)) { need(!input.agent_id, "Coordinator owns lifecycle commands"); return; }
108
+ if (!input.agent_id && safeSharedContextCommand(command, project)) return;
109
+ need(task && !readOnly, "Audit/plan/review shell execution is blocked; use read tools or externally supplied verification evidence");
110
+ requireDesignReady(project, task);
111
+ // Coordinator may run declared checks after workers finish. This command
112
+ // can execute a verifier, so it is allowed only for implementation tasks.
113
+ 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; }
114
+ need(dispatch?.kind === "implementation" && dispatch.status === "running" && input.agent_id === dispatch.agentId, "Shell mutation requires a correlated implementation worker");
115
+ return;
116
+ }
117
+ if (task) requireDesignReady(project, task);
118
+ need(task && !readOnly && dispatch?.kind === "implementation" && dispatch.status === "running" && input.agent_id === dispatch.agentId,
119
+ "Tool is not in the read-only allowlist; a correlated implementation dispatch is required");
120
+ }
@@ -0,0 +1,170 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { conflictingLaunchOption } from "./autonomy.mjs";
4
+
5
+ const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
6
+ const unityIdentity = /(?:^|[^a-z0-9])(?:unity(?:mcp)?|mcpforunity(?:server)?|coplay(?:dev)?|anklebreaker)(?=$|[^a-z0-9])/i;
7
+ const unityName = value => unityIdentity.test(value)
8
+ || unityIdentity.test(String(value).replace(/([a-z0-9])([A-Z])/g, "$1 $2"));
9
+ const unityProvider = value => /mcpforunity|mcp-for-unity|unity[-_]mcp|coplay(?:dev|\.dev)|com\.anklebreaker/i.test(value);
10
+ const fail = message => { throw new Error(`Unity CLI-only: ${message}`); };
11
+ // The only argv allowed before strict isolation: the framework-owned permission
12
+ // pair produced by autonomy.mjs permissionModeArgs. No other prefix is accepted.
13
+ const ownedPrefix = prefix => Array.isArray(prefix) && prefix.length === 2 && prefix[0] === "--permission-mode"
14
+ && ["default", "bypassPermissions"].includes(prefix[1]);
15
+
16
+ // Default sessions do not discover MCP servers from user settings or plugins.
17
+ // An external caller may explicitly supply a non-Unity MCP configuration. Keep
18
+ // file arguments as paths: never copy their possible credentials into run state.
19
+ // `prefix` is the fixed framework-owned launch prefix (permission mode). Strict
20
+ // isolation follows it directly, before every caller-controlled argument, so a
21
+ // forwarded option missing its value can never consume the isolation flag.
22
+ export function unityCliMcpArgs(args, project, prefix) {
23
+ if (!ownedPrefix(prefix)) fail("launch requires the fixed NCCGS --permission-mode default|bypassPermissions prefix");
24
+ let config = null, strict = false;
25
+ const remaining = [];
26
+ for (let i = 0; i < args.length; i++) {
27
+ const arg = args[i];
28
+ if (arg === "--") { remaining.push(...args.slice(i)); break; }
29
+ if (arg === "--strict-mcp-config") {
30
+ if (strict) fail("duplicate --strict-mcp-config");
31
+ strict = true; continue;
32
+ }
33
+ if (arg.startsWith("--strict-mcp-config=")) fail("strict MCP isolation cannot be disabled");
34
+ if (arg === "--mcp-config" || arg.startsWith("--mcp-config=")) {
35
+ if (config !== null) fail("use one explicit MCP configuration");
36
+ config = arg === "--mcp-config" ? args[++i] : arg.slice("--mcp-config=".length);
37
+ if (typeof config !== "string" || !config.trim() || config.startsWith("--")) fail("--mcp-config requires JSON or a JSON file");
38
+ continue;
39
+ }
40
+ remaining.push(arg);
41
+ }
42
+ config ??= '{"mcpServers":{}}';
43
+ let value;
44
+ try {
45
+ const text = config.trim().startsWith("{") ? config : fs.readFileSync(path.resolve(project, config), "utf8");
46
+ value = JSON.parse(text.replace(/^\uFEFF/, ""));
47
+ } catch { fail("MCP configuration must be readable valid JSON; no fallback to global servers"); }
48
+ if (!object(value) || Object.keys(value).some(k => k !== "mcpServers") || !object(value.mcpServers))
49
+ fail("MCP configuration must contain only a mcpServers object");
50
+ for (const [name, server] of Object.entries(value.mcpServers)) {
51
+ if (!object(server)) fail("invalid MCP server definition");
52
+ const command = typeof server.command === "string" && server.command.trim();
53
+ const url = typeof server.url === "string" && server.url.trim();
54
+ if ((!command && !url) || (command && url)
55
+ || (server.args !== undefined && (!Array.isArray(server.args) || !server.args.every(x => typeof x === "string"))))
56
+ fail("invalid MCP command/endpoint definition");
57
+ const route = [server.command, server.url, ...(Array.isArray(server.args) ? server.args : [])].filter(x => typeof x === "string").join(" ");
58
+ if (unityName(name) || unityProvider(route) || /\bunity(?:\.exe)?\s+mcp\b/i.test(route))
59
+ fail("Unity MCP servers are forbidden; use direct Unity CLI/Pipeline commands");
60
+ }
61
+ // Claude's config option is variadic. End it at argv end or the explicit
62
+ // option terminator, so no prompt or unchecked second config can be consumed.
63
+ // A trailing caller option missing its value may consume "--mcp-config"; the
64
+ // strict flag stays effective, so that fails closed with no MCP servers.
65
+ const boundary = remaining.indexOf("--");
66
+ const caller = boundary < 0 ? remaining : remaining.slice(0, boundary);
67
+ const terminated = boundary < 0 ? [] : remaining.slice(boundary);
68
+ return [...prefix, "--strict-mcp-config", ...caller, "--mcp-config", config, ...terminated];
69
+ }
70
+
71
+ // Recovery retains its original arguments and budgets. An old unsafe record is
72
+ // refused, not silently rewritten or relaunched with different server settings.
73
+ // Canonical layout: [...prefix, --strict-mcp-config, ...caller, --mcp-config,
74
+ // <one validated config>, (-- ...terminated)].
75
+ // `prefix` must be the owned pair for the run's recorded permission mode.
76
+ export function assertStrictUnityCliArgs(args, project, prefix) {
77
+ if (!ownedPrefix(prefix)) fail("retained run has no valid NCCGS permission prefix; use an authorized fresh continuation without resetting spent budgets");
78
+ if (!Array.isArray(args) || !args.every(x => typeof x === "string") || !args.includes("--strict-mcp-config")
79
+ || !args.some(x => x === "--mcp-config" || x.startsWith("--mcp-config=")))
80
+ fail("retained run lacks strict MCP isolation; use an authorized fresh continuation without resetting spent budgets");
81
+ if (args[0] !== prefix[0] || args[1] !== prefix[1] || args[2] !== "--strict-mcp-config")
82
+ fail("retained arguments do not place strict MCP isolation directly after the fixed permission prefix, before caller arguments; use an authorized fresh continuation without resetting spent budgets");
83
+ // R09: the same ownership policy as a fresh launch. Only the verified fixed
84
+ // permission prefix is skipped; an R08-canonical record may still carry e.g.
85
+ // --restricted, which would ignore project settings and hooks.
86
+ const owned = args.slice(prefix.length).find(arg => conflictingLaunchOption.test(arg));
87
+ if (owned !== undefined)
88
+ fail(`retained arguments contain NCCGS-owned launch option ${owned}; use an authorized fresh continuation without resetting spent budgets`);
89
+ const canonical = unityCliMcpArgs(args.slice(prefix.length), project, prefix);
90
+ if (JSON.stringify(canonical) !== JSON.stringify(args))
91
+ fail("retained arguments lack the canonical single-configuration boundary; use an authorized fresh continuation without resetting spent budgets");
92
+ }
93
+
94
+ // This recognizes ordinary shell invocations; it does not execute or attempt to
95
+ // sandbox arbitrary scripts, encoded payloads, or a deliberately hostile client.
96
+ function tokens(command) {
97
+ const result = []; let word = "", quote = null;
98
+ const flush = () => { if (word) result.push(word); word = ""; };
99
+ for (let i = 0; i < command.length; i++) {
100
+ const c = command[i];
101
+ if (c === "`" && command[i + 1]) { word += command[++i]; continue; }
102
+ if (quote) {
103
+ if (c === quote) {
104
+ if (quote === "'" && command[i + 1] === "'") { word += "'"; i++; }
105
+ else quote = null;
106
+ } else word += c;
107
+ continue;
108
+ }
109
+ if (c === "'" || c === '"') { quote = c; continue; }
110
+ if (c === "{" && word === "$") {
111
+ const end = command.indexOf("}", i + 1);
112
+ if (end >= 0) { word += command.slice(i, end + 1); i = end; continue; }
113
+ }
114
+ if (c === "\n" || c === "\r") { flush(); result.push(";"); continue; }
115
+ if (/\s/.test(c)) { flush(); continue; }
116
+ if (/[;&|(){}]/.test(c)) { flush(); result.push(c); continue; }
117
+ word += c;
118
+ }
119
+ flush(); return result;
120
+ }
121
+ function inspectShell(command, depth = 0) {
122
+ if (depth > 3) fail("nested command wrappers exceed the inspected transport boundary");
123
+ const parts = tokens(command);
124
+ for (let i = 0; i < parts.length; i++) {
125
+ const previous = parts[i - 1];
126
+ const invocation = i === 0 || ["&", ";", "|", "(", "{", ".", "sudo", "env", "exec", "call", "command"].includes(previous)
127
+ || /^[A-Za-z_]\w*=/.test(previous ?? "");
128
+ if (!invocation) continue;
129
+ const program = parts[i].replaceAll("\\", "/").split("/").at(-1).toLowerCase().replace(/\.exe$/, "");
130
+ if ((program === "unity" || /^\$(?:env:)?[\w:{}]+$/i.test(parts[i])) && parts[i + 1]?.toLowerCase() === "mcp")
131
+ fail("unity mcp/configure is disabled; call Unity CLI/Pipeline directly");
132
+ if (/^(?:mcp-for-unity|mcpforunityserver)$/.test(program)) fail("Coplay Unity MCP launch is disabled");
133
+ const end = parts.findIndex((x, n) => n > i && [";", "|", "&", ")"].includes(x));
134
+ const arguments_ = parts.slice(i + 1, end < 0 ? undefined : end);
135
+ if (["uvx", "uv", "pip", "pip3", "pipx", "npx", "npm", "pnpm", "bunx"].includes(program)) {
136
+ const following = arguments_.join(" ");
137
+ if (unityProvider(following)) fail("Unity MCP installation or launch is disabled");
138
+ }
139
+ if (["claude", "codex"].includes(program) && arguments_[0]?.toLowerCase() === "mcp"
140
+ && ["add", "add-json", "add-from-claude-desktop"].includes(arguments_[1]?.toLowerCase())) {
141
+ const registration = arguments_.slice(2).join(" ");
142
+ if (unityName(registration) || unityProvider(registration))
143
+ fail("Unity MCP registration through Claude/Codex is disabled");
144
+ }
145
+ if (["python", "python3", "node"].includes(program)
146
+ && unityProvider(arguments_[0] === "-m" ? arguments_[1] ?? "" : arguments_[0] ?? ""))
147
+ fail("Unity MCP launch is disabled");
148
+ if (program === "git" && parts[i + 1] === "clone" && unityProvider(parts.slice(i + 2).join(" ")))
149
+ fail("Unity MCP installation is disabled");
150
+ if (["start-process", "start"].includes(program)) {
151
+ const target = arguments_.join(" ");
152
+ if (unityProvider(target) || (/\bunity(?:\.exe)?\b/i.test(target) && /\bmcp\b/i.test(target)))
153
+ fail("Unity MCP launch is disabled");
154
+ }
155
+ if (["invoke-expression", "iex"].includes(program) && arguments_.length)
156
+ inspectShell(arguments_.join(" "), depth + 1);
157
+ if (["cmd", "powershell", "pwsh", "bash", "sh"].includes(program)) {
158
+ const option = parts.findIndex((x, n) => n > i && ["/c", "-c", "-lc", "-ic", "-command"].includes(x.toLowerCase()));
159
+ if (option >= 0 && parts[option + 1]) inspectShell(parts.slice(option + 1).join(" "), depth + 1);
160
+ }
161
+ }
162
+ }
163
+ export function assertUnityCliOnlyInput(input) {
164
+ const tool = input.tool_name ?? "";
165
+ const server = /^mcp__/i.test(tool) ? tool.slice(5).split("__")[0]
166
+ : /^mcp:/i.test(tool) ? tool.slice(4).split(":")[0] : null;
167
+ if (server !== null && unityName(server))
168
+ fail("Unity MCP tools are disabled, including discovery and read-only tools");
169
+ if (["Bash", "PowerShell"].includes(input.tool_name)) inspectShell(input.tool_input?.command ?? "");
170
+ }
@@ -498,23 +498,23 @@
498
498
  "FAST": {
499
499
  "implementationClass": "lightweight",
500
500
  "maxReviewRounds": 2,
501
- "effort": "medium",
501
+ "effort": "high",
502
502
  "model": "sonnet"
503
503
  },
504
504
  "STANDARD": {
505
505
  "implementationClass": "standard",
506
506
  "maxReviewRounds": 3,
507
- "effort": "medium",
507
+ "effort": "high",
508
508
  "model": "opus"
509
509
  },
510
510
  "CONTROLLED": {
511
511
  "implementationClass": "deep",
512
512
  "maxReviewRounds": 3,
513
- "effort": "high",
513
+ "effort": "xhigh",
514
514
  "model": "opus"
515
515
  }
516
516
  },
517
- "reviewModel": "opus",
517
+ "reviewModel": "fable",
518
518
  "reviewEffort": "high"
519
519
  },
520
520
  "astra-claude": {