nccgs 2.0.0-rc.3 → 2.1.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 (76) hide show
  1. package/.claude/nccgs/MEMORY.md +7 -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 +8 -0
  16. package/.claude/nccgs/tools/configure-models.mjs +4 -0
  17. package/.claude/nccgs/tools/editor-session-evidence.mjs +66 -0
  18. package/.claude/nccgs/tools/gates.mjs +7 -4
  19. package/.claude/nccgs/tools/handoff.mjs +51 -0
  20. package/.claude/nccgs/tools/inputs.mjs +36 -1
  21. package/.claude/nccgs/tools/installation-guard.mjs +55 -0
  22. package/.claude/nccgs/tools/lifecycle.mjs +21 -0
  23. package/.claude/nccgs/tools/orchestration.mjs +264 -14
  24. package/.claude/nccgs/tools/repair-packet.mjs +29 -10
  25. package/.claude/nccgs/tools/returned-handoff.mjs +38 -0
  26. package/.claude/nccgs/tools/review-budget.mjs +171 -0
  27. package/.claude/nccgs/tools/review-handoff.mjs +42 -8
  28. package/.claude/nccgs/tools/runtime.mjs +7 -2
  29. package/.claude/nccgs/tools/session-budget.mjs +67 -0
  30. package/.claude/nccgs/tools/task-slices.mjs +147 -0
  31. package/.claude/nccgs/tools/task.mjs +39 -2
  32. package/.claude/nccgs/tools/tool-guard.mjs +19 -5
  33. package/.claude/nccgs/tools/unity-repair.mjs +128 -0
  34. package/.claude/nccgs/tools/verification-jobs.mjs +13 -3
  35. package/.claude/nccgs/tools/windows-compiler-server.cs +163 -0
  36. package/.claude/nccgs/tools/windows-job.cs +8 -1
  37. package/CHANGELOG.md +142 -0
  38. package/MEMORY.md +7 -2
  39. package/README.md +57 -27
  40. package/UPGRADING.md +27 -18
  41. package/VERSION +1 -1
  42. package/docs/ARCHITECTURE.md +45 -18
  43. package/docs/BOUNDED-REPAIR.md +76 -7
  44. package/docs/EDITOR-SESSION-EVIDENCE.md +52 -0
  45. package/docs/HANDOFF-AUTOMATION.md +144 -0
  46. package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +68 -11
  47. package/docs/PHASE-1-OPERATIONS.md +142 -1
  48. package/docs/PHASE-2-OPERATIONS.md +1 -1
  49. package/docs/RELEASE-STATUS.md +102 -4
  50. package/docs/TASK-SLICING.md +57 -0
  51. package/docs/UNINSTALL.md +134 -0
  52. package/docs/UPGRADE-QUALIFICATION.md +1 -1
  53. package/docs/WORKFLOWS.md +1 -1
  54. package/package.json +2 -2
  55. package/scaffold/.nccgs/templates/repair-unity-code.json +18 -0
  56. package/scaffold/.nccgs/templates/repair-unity-editor.json +21 -0
  57. package/scaffold/.nccgs/templates/task-slices.json +28 -0
  58. package/scripts/cli.mjs +15 -2
  59. package/scripts/install.mjs +14 -0
  60. package/scripts/recover-session.mjs +89 -0
  61. package/scripts/run.mjs +21 -8
  62. package/scripts/supervise.mjs +30 -6
  63. package/scripts/uninstall.mjs +292 -0
  64. package/scripts/validate.mjs +6 -0
  65. package/scripts/work-handoff.mjs +38 -0
  66. package/tests/editor-session-evidence.test.mjs +45 -0
  67. package/tests/fixtures/compiler-shutdown.cs +64 -0
  68. package/tests/orchestration-v15.test.mjs +52 -1
  69. package/tests/phase1.test.mjs +92 -0
  70. package/tests/policy-v15.test.mjs +14 -0
  71. package/tests/review-budget.test.mjs +177 -0
  72. package/tests/runtime-v15.test.mjs +572 -4
  73. package/tests/session-budget.test.mjs +34 -0
  74. package/tests/task-slices.test.mjs +285 -0
  75. package/tests/uninstall.test.mjs +477 -0
  76. package/tests/unity-generated.test.mjs +140 -0
@@ -0,0 +1,134 @@
1
+ # Remove NCCGS from a project
2
+
3
+ `uninstall` is available in the **unpublished 2.1.0 package**. The published
4
+ 2.0.0 CLI does not have this command. Use the local 2.1.0 archive (or its source
5
+ CLI) to remove an existing 2.0.0 installation; no preliminary update is needed.
6
+
7
+ Close Claude Code sessions and let managed workers/verification finish first.
8
+ Run from an external terminal. The command checks recorded process ownership,
9
+ dispatches, runs and jobs; it never kills processes, settles jobs or closes tasks.
10
+ Sessions launched directly with `claude`, or with older launchers, may have no
11
+ session record, so they must also be closed by the operator.
12
+
13
+ ## Preview and remove
14
+
15
+ ```powershell
16
+ $NccgsArchive = "D:\path\to\nccgs-2.1.0.tgz"
17
+ npx --yes --package "$NccgsArchive" nccgs uninstall --project "D:\path\to\UnityProject" --dry-run
18
+ npx --yes --package "$NccgsArchive" nccgs uninstall --project "D:\path\to\UnityProject"
19
+ ```
20
+
21
+ From this source checkout, use `node scripts/cli.mjs uninstall --project DIR`
22
+ with the same options. Always name the intended project. The package refuses
23
+ to uninstall its own source directory.
24
+
25
+ The preview writes nothing and lists files to remove, shared files to edit and
26
+ modified/unknown inventoried files to preserve. There is no `--force` or history
27
+ purge option. A repeated uninstall reports `NOT_INSTALLED` after successful removal.
28
+
29
+ | Item | Result |
30
+ |---|---|
31
+ | Unchanged inventoried NCCGS/Unity skills, agents, protocols and tools | Removed after fingerprint checks |
32
+ | Optional generated NCCGS agents | Removed only if their recorded fingerprints still match |
33
+ | Customized or unknown managed files; unrelated files in `.claude` | Kept in place |
34
+ | Exact NCCGS hooks and canonical import in `CLAUDE.md` | Removed; unrelated hooks and owner text kept |
35
+ | Unchanged NCCGS bootstrap `CLAUDE.md` | Removed |
36
+ | Game source, scenes, prefabs, packages and project settings | Unchanged |
37
+ | `.nccgs` policy, task history, reviews, evidence, budgets and prior backups | Kept byte for byte |
38
+ | Generic Claude permission/model preferences | Kept; older installers did not track their ownership |
39
+
40
+ A retained custom NCCGS skill or agent may still be discoverable by Claude and
41
+ may refer to removed tools. The output identifies those files for manual review;
42
+ they are not silently deleted. Unknown non-inventoried owner files are never
43
+ claimed as NCCGS-owned. Customized compound hooks/imports must be separated by
44
+ the owner before removal, because automatically editing them could lose behavior.
45
+
46
+ ## Backup and recovery
47
+
48
+ Removal prints an `id` and saves a manifest plus original file bytes beneath
49
+ `.nccgs/uninstall/<id>/`. Keep that directory if you may need recovery.
50
+
51
+ ```powershell
52
+ npx --yes --package "$NccgsArchive" nccgs uninstall --project "D:\path\to\UnityProject" --rollback "PRINTED-UUID" --dry-run
53
+ npx --yes --package "$NccgsArchive" nccgs uninstall --project "D:\path\to\UnityProject" --rollback "PRINTED-UUID"
54
+ npx --yes --package "$NccgsArchive" nccgs doctor --project "D:\path\to\UnityProject"
55
+ ```
56
+
57
+ Use the same package for recovery. All backup hashes and destination files are
58
+ checked before restoration; a later edit or damaged backup causes refusal. The
59
+ receipt and history remain after restoration. A write failure attempts automatic
60
+ restoration; an interrupted operation requires inspection and explicit rollback.
61
+ New launch/install/update operations refuse an incomplete uninstall receipt.
62
+ Do not delete locks or receipts merely to bypass this check.
63
+
64
+ Agent reconfiguration, execution migration and policy regeneration also participate
65
+ in the uninstall admission lock. New cooperating operations cannot register a
66
+ session while removal/rollback holds this lock. A refusal before any managed file
67
+ is changed leaves a `cancelled` receipt with its diagnostic and backups; it does
68
+ not require rollback. After changes begin, normal ownership checks still apply to
69
+ restoration. Unrecorded/older concurrent operations require operator inspection.
70
+ Failure to remove an empty directory is a warning after successful removal; the
71
+ result still returns the backup path and rollback ID.
72
+
73
+ Two launches/configuration commands can briefly contend for the admission lock.
74
+ This reports `installation admission is busy or unverified`, rather than claiming
75
+ an uninstall is running. Retry once the other operation finishes; the command
76
+ does not retry automatically or take over a live/unverified lock.
77
+ Empty, incomplete, unreadable or disappearing lock records also produce the
78
+ busy/unverified diagnostic, retaining the original cause for inspection. They
79
+ never authorize admission or automatic recovery. A holder that releases before
80
+ the error recheck still results in refusal; retry explicitly once work settles.
81
+
82
+ ## Recover a crashed launcher lease
83
+
84
+ Closing a terminal forcibly or a crash can leave a `nccgs run` lease in
85
+ `.nccgs/sessions/<UUID>.lock`. An unmanaged child's descendants cannot be proven
86
+ stopped merely because its launcher and child PIDs are dead. Uninstall therefore
87
+ continues to refuse this unknown state automatically.
88
+
89
+ The external operator can explicitly reconcile **only that launcher lease**:
90
+
91
+ ```powershell
92
+ npx --yes --package "$NccgsArchive" nccgs recover-session --project "D:\path\to\UnityProject" --id "SESSION-UUID" --dry-run
93
+ ```
94
+
95
+ Independently inspect and stop the entire session process tree first, including
96
+ any child that spawned before its PID was recorded. Rebooting the host and checking
97
+ that the session has not restarted is one way to establish that boundary. Only
98
+ when this is true, use the preview's SHA-256 and record how it was confirmed:
99
+
100
+ ```powershell
101
+ npx --yes --package "$NccgsArchive" nccgs recover-session --project "D:\path\to\UnityProject" --id "SESSION-UUID" --sha256 "HASH-FROM-PREVIEW" --confirm-process-tree-stopped --reason "Describe how the full session tree was confirmed stopped"
102
+ ```
103
+
104
+ This is a human/operator attestation, **not automatic descendant proof**. The
105
+ command refuses live/unverified recorded PIDs, a foreign host, changed hashes,
106
+ wrong operation, unsafe IDs and invocation inside Claude Code. It archives the
107
+ exact original lease and a receipt with the operator's reason under
108
+ `.nccgs/session-recoveries/<id>/`. It never kills a process or edits task/job/run
109
+ locks, verdicts, history or budgets. Other active/unknown work still blocks
110
+ uninstall. A missing child PID during `spawning` explicitly remains unobserved
111
+ in the receipt; the operator must establish shutdown independently before confirming.
112
+ Keep the receipts and retry the original uninstall/rollback only after all work
113
+ has settled. There is no age-based or automatic eviction of unknown leases.
114
+
115
+ Each recovery writes an immutable `receipt.json` with status `pending` before
116
+ moving the lease, then a separate `outcome.json` with status `completed` or
117
+ `failed`. The outcome identifies the pending receipt by SHA-256. A rename failure
118
+ leaves the original lease in place and records the error; it is not a successful
119
+ recovery. If a crash or write failure leaves no valid outcome, completion remains
120
+ unconfirmed: inspect both the original lease and the archive, retaining every
121
+ record. The lease may already have moved if recording completion failed. A pending
122
+ receipt alone never proves success, and the command does not report success when
123
+ it cannot record completion. Retrying a failed move creates a new receipt; it
124
+ does not overwrite the earlier attempt.
125
+
126
+ Reinstalling later reuses retained policy and task records. It does not replenish
127
+ spent money, turns, reviews or time. Restoring an old uninstall must not overwrite
128
+ a subsequent installation. Removal/rollback is a maintenance action, not a task
129
+ PASS, independent review, acceptance decision or a new allowance.
130
+
131
+ The command removes the project integration only. It does not uninstall Claude
132
+ Code, Unity, Node.js, global npm packages or npm's cache, and makes no provider
133
+ calls. Symlinks/junctions, malformed records and uncertain process ownership fail
134
+ closed rather than permitting deletion outside the checked project.
@@ -1,6 +1,6 @@
1
1
  # Published 1.3.0 upgrade qualification
2
2
 
3
- Target: **2.0.0-rc.3**. This is installation/configuration qualification on Windows
3
+ Historical target: **2.0.0-rc.3**. This is installation/configuration qualification on Windows
4
4
  with Node.js 24.12.0 using the published npm 1.3.0 archive and the exact RC3 archive.
5
5
  It does not establish Node 18, macOS/Linux, provider execution or gameplay results.
6
6
 
package/docs/WORKFLOWS.md CHANGED
@@ -1,4 +1,4 @@
1
- # NCCGS workflows — 2.0 RC
1
+ # NCCGS workflows — 2.0.0
2
2
 
3
3
  New installations use standalone Claude Code execution with the claude-standalone
4
4
  model profile. The coordinator owns intake, planning, dispatch, verification and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nccgs",
3
- "version": "2.0.0-rc.3",
3
+ "version": "2.1.0",
4
4
  "description": "Unity-first game-studio operating system for Claude Code",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "publishConfig": {
30
30
  "access": "public",
31
- "tag": "next",
31
+ "tag": "latest",
32
32
  "registry": "https://registry.npmjs.org/"
33
33
  },
34
34
  "scripts": {
@@ -0,0 +1,18 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "repairType": "unity-code",
4
+ "taskId": "REPLACE_WITH_TASK_ID",
5
+ "contractHash": "REPLACE_WITH_CURRENT_CONTRACT_HASH",
6
+ "sourceSnapshot": "REPLACE_WITH_CURRENT_SOURCE_SNAPSHOT",
7
+ "problem": "Describe the recorded failure and its evidence.",
8
+ "solution": "State the chosen correction, relevant callers and regression coverage.",
9
+ "changes": [
10
+ { "path": "Assets/REPLACE_WITH_SOURCE.cs", "operation": "edit", "instruction": "Apply the exact chosen correction; preserve the full acceptance contract." }
11
+ ],
12
+ "context": [],
13
+ "verification": ["REPLACE_WITH_REQUIRED_CHECK_NAME"],
14
+ "unity": {
15
+ "steps": []
16
+ },
17
+ "budget": { "workCalls": 24, "readCalls": 12, "handoffCalls": 3, "durationMs": 900000 }
18
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "repairType": "unity-editor",
4
+ "taskId": "REPLACE_WITH_TASK_ID",
5
+ "contractHash": "REPLACE_WITH_CURRENT_CONTRACT_HASH",
6
+ "sourceSnapshot": "REPLACE_WITH_CURRENT_SOURCE_SNAPSHOT",
7
+ "problem": "Describe the recorded scene, prefab or asset failure.",
8
+ "solution": "State the chosen Editor operation and how it saves only the exact affected files.",
9
+ "changes": [
10
+ { "path": "Assets/REPLACE_WITH_PREFAB.prefab", "operation": "edit", "instruction": "Apply and save the chosen correction through Unity Editor APIs." }
11
+ ],
12
+ "context": [],
13
+ "verification": ["REPLACE_WITH_REQUIRED_CHECK_NAME"],
14
+ "unity": {
15
+ "capabilities": { "path": ".nccgs/evidence/REPLACE_WITH_UNITY_DISCOVERY.json", "sha256": "REPLACE_WITH_CAPTURED_HASH" },
16
+ "steps": [
17
+ { "id": "apply-and-save", "command": "eval", "arguments": ["REPLACE_WITH_CHOSEN_CSHARP_BODY_USING_FULLY_QUALIFIED_TYPES"], "timeoutMs": 30000 }
18
+ ]
19
+ },
20
+ "budget": { "workCalls": 20, "readCalls": 8, "handoffCalls": 3, "durationMs": 900000 }
21
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "taskId": "REPLACE_WITH_EXISTING_TASK_ID",
4
+ "contractHash": "REPLACE_WITH_CURRENT_CONTRACT_HASH",
5
+ "sourceSnapshot": "REPLACE_WITH_CURRENT_SOURCE_SNAPSHOT",
6
+ "planId": "delivery-one",
7
+ "supersedes": null,
8
+ "reason": "Split the existing contract into bounded assignments within its remaining budget.",
9
+ "integration": "integrate",
10
+ "slices": [
11
+ {
12
+ "id": "foundation",
13
+ "objective": "Implement the first coherent behavior and its targeted test.",
14
+ "agent": "nccgs-gameplay-programmer",
15
+ "dependsOn": [],
16
+ "writePaths": ["Assets/REPLACE_WITH_FIRST_SOURCE.cs", "Assets/Tests/REPLACE_WITH_FIRST_TEST.cs"],
17
+ "criteria": ["REPLACE_WITH_ORIGINAL_CRITERION_ID"]
18
+ },
19
+ {
20
+ "id": "integrate",
21
+ "objective": "Implement the remaining behavior and integrate the full original contract.",
22
+ "agent": "nccgs-gameplay-programmer",
23
+ "dependsOn": ["foundation"],
24
+ "writePaths": ["Assets/REPLACE_WITH_SECOND_SOURCE.cs", "Assets/Tests/REPLACE_WITH_INTEGRATION_TEST.cs"],
25
+ "criteria": ["REPLACE_WITH_REMAINING_ORIGINAL_CRITERION_ID"]
26
+ }
27
+ ]
28
+ }
package/scripts/cli.mjs CHANGED
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import crypto from "node:crypto";
8
8
  import { atomicJson, contained, executionPolicy, hash, json, profilePolicy, resolveExecution } from "../.claude/nccgs/tools/policy.mjs";
9
+ import { acquireInstallationLease, assertInstallationAvailable } from "../.claude/nccgs/tools/installation-guard.mjs";
9
10
 
10
11
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
11
12
  const root = path.resolve(scriptDir, "..");
@@ -29,13 +30,13 @@ if (command === "run") {
29
30
  catch (error) { console.error(`Execution migration failed: ${error.message}`); process.exitCode = 1; }
30
31
  } else {
31
32
 
32
- if (!new Set(["install", "update", "doctor", "task", "agents", "policy"]).has(command)) {
33
+ if (!new Set(["install", "update", "uninstall", "recover-session", "doctor", "task", "agents", "policy"]).has(command)) {
33
34
  console.error(`Unknown command: ${command}`);
34
35
  printHelp();
35
36
  process.exit(2);
36
37
  }
37
38
 
38
- const scripts = { doctor: "doctor.mjs", task: "../.claude/nccgs/tools/task.mjs", agents: "../.claude/nccgs/tools/configure-models.mjs", policy: "../.claude/nccgs/tools/compile-policy.mjs" };
39
+ const scripts = { uninstall: "uninstall.mjs", "recover-session": "recover-session.mjs", doctor: "doctor.mjs", task: "../.claude/nccgs/tools/task.mjs", agents: "../.claude/nccgs/tools/configure-models.mjs", policy: "../.claude/nccgs/tools/compile-policy.mjs" };
39
40
  const script = scripts[command] || "install.mjs";
40
41
  let forwardedArgs = command === "update" ? ["--update-only", ...args] : args;
41
42
  if (command === "agents" && !args.includes("--project")) forwardedArgs = ["--project", process.cwd(), ...args];
@@ -66,6 +67,9 @@ function migrateExecution() {
66
67
  if (!allowed.has(args[i])) throw new Error(`Unknown migration option: ${args[i]}`);
67
68
  if (args[i] !== "--dry-run") i++;
68
69
  }
70
+ assertInstallationAvailable(target);
71
+ const lease = dryRun ? null : acquireInstallationLease(target, "nccgs-migrate-execution");
72
+ try {
69
73
  const at = relative => contained(target, relative);
70
74
  if (!fs.existsSync(at(".claude/nccgs/install-state.json"))) throw new Error("Install/update NCCGS before migrating execution");
71
75
  const studio = json(at(".claude/nccgs/studio.json"));
@@ -158,6 +162,7 @@ function migrateExecution() {
158
162
  throw error;
159
163
  }
160
164
  console.log(`Execution migration ${id} applied. Rollback: nccgs migrate-execution --rollback ${id} --dry-run`);
165
+ } finally { lease?.release(); }
161
166
  }
162
167
 
163
168
  function printHelp() {
@@ -166,11 +171,19 @@ function printHelp() {
166
171
  Usage:
167
172
  nccgs install [options]
168
173
  nccgs update [options]
174
+ nccgs uninstall [--project DIR] [--dry-run]
175
+ nccgs uninstall --rollback UNINSTALL_ID [--project DIR] [--dry-run]
176
+ nccgs recover-session --id SESSION_UUID [--project DIR] --dry-run
177
+ nccgs recover-session --id SESSION_UUID --sha256 HASH --confirm-process-tree-stopped --reason TEXT [--project DIR]
169
178
  nccgs doctor [options]
170
179
  nccgs run [--project DIR] [--claude EXECUTABLE] [--dry-run] -- [Claude arguments/prompt]
171
180
  nccgs run --supervise --task ID --prompt-file FILE --session-budget-usd N [--max-resumes 2] [--timeout-ms 3600000] -- [provider options]
172
181
  nccgs run --recover-run RUN_ID [--project DIR] [--claude EXECUTABLE]
173
182
  nccgs task init|snapshot|ref|observe|verify|acceptance-status|record|check|close|bind|extend-review|refresh-inputs|migrate-evidence [options]
183
+ nccgs task dispatch-prompt|handoff-validate|record-review --id TASK --dispatch ID [--file HANDOFF]
184
+ nccgs task repair-editor --id TASK --dispatch ID --step STEP (admitted Unity repair worker only)
185
+ nccgs task slice-preview|slice-plan --id TASK --file PLAN (same task and budget)
186
+ nccgs task slice-status --id TASK
174
187
  nccgs agents --enable <role> | --disable <role> [--project <directory>]
175
188
  nccgs policy [--write] Check/regenerate package routing
176
189
  nccgs migrate-execution --to standalone [--external-review off|advisory|required] [--model-profile <profile>] [--dry-run]
@@ -11,6 +11,7 @@ import {
11
11
  import { assertProjectRoot, assertRelativePath, containedPath } from "../.claude/nccgs/tools/paths.mjs";
12
12
  import { executionPolicy } from "../.claude/nccgs/tools/policy.mjs";
13
13
  import { autonomyPolicy, canonicalHookCommand } from "../.claude/nccgs/tools/autonomy.mjs";
14
+ import { acquireInstallationLease, assertInstallationAvailable } from "../.claude/nccgs/tools/installation-guard.mjs";
14
15
 
15
16
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
16
17
  const root = path.resolve(scriptDir, "..");
@@ -43,6 +44,8 @@ if (project === root) {
43
44
  console.error("The NCCGS package directory cannot be its own installation target.");
44
45
  process.exit(2);
45
46
  }
47
+ try { assertInstallationAvailable(project); }
48
+ catch (error) { console.error(error.message); process.exit(2); }
46
49
 
47
50
  const unityMarkers = ["Assets", "Packages", "ProjectSettings"];
48
51
  const missingMarkers = unityMarkers.filter((item) => !fs.existsSync(projectPath(item)));
@@ -63,6 +66,17 @@ if (validation.status !== 0) {
63
66
  process.exit(1);
64
67
  }
65
68
 
69
+ // Register before reading the installation to keep a concurrent uninstall from
70
+ // invalidating the plan. The second guard closes the acquisition race. Dry-run
71
+ // remains read-only; the exit handler covers all existing early exit paths.
72
+ if (!dryRun) {
73
+ try {
74
+ const lease = acquireInstallationLease(project, "nccgs-install");
75
+ process.once("exit", () => lease.release());
76
+ assertInstallationAvailable(project);
77
+ } catch (error) { console.error(error.message); process.exit(2); }
78
+ }
79
+
66
80
  const frameworkSource = path.join(root, ".claude");
67
81
  const frameworkFiles = walk(frameworkSource).map((file) => ({
68
82
  source: file,
@@ -0,0 +1,89 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import crypto from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
6
+ import { assertProjectRoot, containedPath } from "../.claude/nccgs/tools/paths.mjs";
7
+ import { acquireLock, pidState } from "../.claude/nccgs/tools/process-lock.mjs";
8
+ import { sha256 } from "../.claude/nccgs/tools/integrity.mjs";
9
+
10
+ const need = (ok, message) => { if (!ok) throw new Error(message); };
11
+ const uuid = value => typeof value === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value);
12
+
13
+ // Explicit operator reconciliation, never automatic proof that an unmanaged
14
+ // descendant tree stopped. The immutable receipt says which facts are observed
15
+ // and which are attested; ownerState and all task/job/run locks stay unchanged.
16
+ export function recoverSession(project, { id, expectedSha256, confirmStopped = false, reason, dryRun = false } = {}) {
17
+ project = assertProjectRoot(project);
18
+ need(!(process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT || process.env.NCCGS_SUPERVISED_SESSION), "Session recovery requires an external operator terminal.");
19
+ need(uuid(id), "Use the UUID from .nccgs/sessions/<UUID>.lock.");
20
+ const relative = `.nccgs/sessions/${id}.lock`, at = name => containedPath(project, name);
21
+ const inspect = () => {
22
+ const bytes = fs.readFileSync(at(relative)), record = JSON.parse(bytes.toString("utf8"));
23
+ need(record.schemaVersion === 1 && record.operation === "nccgs-run" && record.host === os.hostname()
24
+ && uuid(record.token) && ["owned", "spawning", "running"].includes(record.phase), "Only a valid local nccgs-run session lease can be reconciled.");
25
+ need(pidState(record.pid) === "dead", "Session launcher is live or unverified; stop/inspect it first.");
26
+ if (record.child !== null) need(record.child && pidState(record.child.pid) === "dead", "Recorded child is live or unverified; stop/inspect it first.");
27
+ return { bytes, record, hash: sha256(bytes) };
28
+ };
29
+ const preview = inspect();
30
+ if (dryRun) return { status: "RECOVERY_PREVIEW", project, id, sha256: preview.hash, launcherPid: preview.record.pid,
31
+ childPid: preview.record.child?.pid ?? null, observedPids: "dead", descendantTree: "unverified",
32
+ required: "Independently stop/verify the entire session process tree (including any spawn without a recorded PID), then provide --sha256, --confirm-process-tree-stopped and --reason. PID death alone is insufficient." };
33
+ need(typeof expectedSha256 === "string" && /^[a-f0-9]{64}$/.test(expectedSha256) && preview.hash === expectedSha256, "Lease changed or --sha256 does not match the preview.");
34
+ need(confirmStopped && typeof reason === "string" && reason.trim().length >= 10 && reason.length <= 2000,
35
+ "Require --confirm-process-tree-stopped and a specific --reason recording how the entire process tree was confirmed stopped; this cannot be inferred from dead PIDs.");
36
+ const gate = acquireLock(at(".nccgs/uninstall.lock"), { operation: "session-reconciliation" });
37
+ try {
38
+ const current = inspect(); need(current.hash === expectedSha256, "Lease changed after preview.");
39
+ const recoveryId = crypto.randomUUID(), directory = `.nccgs/session-recoveries/${recoveryId}`;
40
+ fs.mkdirSync(at(directory), { recursive: true });
41
+ const receipt = { schemaVersion: 1, kind: "operator-session-reconciliation", status: "pending", outcomeFile: "outcome.json", id: recoveryId, sessionId: id,
42
+ at: new Date().toISOString(), host: os.hostname(), operator: os.userInfo().username, originalPath: relative,
43
+ originalSha256: current.hash, originalToken: current.record.token,
44
+ observed: { launcherPid: current.record.pid, launcherState: "dead", childPid: current.record.child?.pid ?? null, childState: current.record.child ? "dead" : "not-recorded" },
45
+ attestation: { entireProcessTreeStopped: true, reason: reason.trim(), automaticDescendantProof: false },
46
+ history: "Intent: archive only this launcher lease. This pending receipt does not prove completion. No process is killed; no task, job, run, verdict or budget is modified." };
47
+ const receiptText = JSON.stringify(receipt, null, 2) + "\n";
48
+ fs.writeFileSync(at(`${directory}/receipt.json`), receiptText, { flag: "wx" });
49
+ const writeOutcome = (status, details) => fs.writeFileSync(at(`${directory}/outcome.json`), JSON.stringify({
50
+ schemaVersion: 1, kind: "operator-session-reconciliation-outcome", id: recoveryId, sessionId: id,
51
+ at: new Date().toISOString(), receiptSha256: sha256(receiptText), status, ...details
52
+ }, null, 2) + "\n", { flag: "wx" });
53
+ try {
54
+ need(inspect().hash === expectedSha256, "Lease changed during reconciliation; retained receipt did not release it.");
55
+ // Both resolved paths are inside the project and checked for links/aliases.
56
+ // Rename archives the exact original bytes instead of deleting the record.
57
+ fs.renameSync(at(relative), at(`${directory}/lease.lock`));
58
+ } catch (error) {
59
+ let auditError = "";
60
+ try { writeOutcome("failed", { error: error.message, code: error.code ?? null }); }
61
+ catch (recordError) { auditError = ` Outcome could not be recorded: ${recordError.message}; completion remains unconfirmed.`; }
62
+ throw new Error(`Session recovery ${recoveryId} failed: ${error.message}. Inspect ${directory}; keep its pending receipt.${auditError}`);
63
+ }
64
+ try { writeOutcome("completed", { archivedLease: `${directory}/lease.lock`, originalSha256: current.hash }); }
65
+ catch (error) {
66
+ throw new Error(`Session recovery ${recoveryId}: lease was archived, but completion could not be recorded: ${error.message}. Inspect ${directory}; the pending receipt alone is not completion evidence.`);
67
+ }
68
+ return { status: "SESSION_RECONCILED", id, recoveryId, receipt: `${directory}/receipt.json`, outcome: `${directory}/outcome.json`, archivedLease: `${directory}/lease.lock`,
69
+ scope: "Explicit operator attestation, not automatic descendant proof. Other active/unknown records continue to block uninstall." };
70
+ } finally { gate.release(); }
71
+ }
72
+
73
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
74
+ try {
75
+ const options = {}, args = process.argv.slice(2), seen = new Set(); let project = process.cwd();
76
+ const values = { "--id": "id", "--sha256": "expectedSha256", "--reason": "reason" };
77
+ for (let i = 0; i < args.length; i++) {
78
+ const option = args[i]; need(!seen.has(option), `Duplicate option: ${option}`); seen.add(option);
79
+ if (option === "--dry-run") options.dryRun = true;
80
+ else if (option === "--confirm-process-tree-stopped") options.confirmStopped = true;
81
+ else {
82
+ need(option === "--project" || Object.hasOwn(values, option), `Unknown recovery option: ${option}`);
83
+ const value = args[++i]; need(value && !value.startsWith("--"), `${option} requires a value`);
84
+ if (option === "--project") project = path.resolve(value); else options[values[option]] = value;
85
+ }
86
+ }
87
+ console.log(JSON.stringify(recoverSession(project, options), null, 2));
88
+ } catch (error) { console.error(`NCCGS session recovery refused: ${error.message}`); process.exitCode = 1; }
89
+ }
package/scripts/run.mjs CHANGED
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { autonomyPolicy } from "../.claude/nccgs/tools/autonomy.mjs";
5
5
  import { containedPath } from "../.claude/nccgs/tools/paths.mjs";
6
6
  import { supervise } from "./supervise.mjs";
7
+ import { acquireInstallationLease, assertInstallationAvailable } from "../.claude/nccgs/tools/installation-guard.mjs";
7
8
 
8
9
  // No shell construction, restrictive tool allowlist, or model override.
9
10
  export function launchPlan(argv, env = process.env) {
@@ -16,18 +17,20 @@ export function launchPlan(argv, env = process.env) {
16
17
  const option = options[i];
17
18
  if (option === "--dry-run") { dryRun = true; continue; }
18
19
  if (option === "--supervise") { supervised = true; continue; }
19
- if (!["--project", "--claude", "--task", "--prompt-file", "--max-resumes", "--timeout-ms", "--session-budget-usd", "--recover-run"].includes(option) || !options[i + 1] || options[i + 1].startsWith("--")) throw new Error(`Invalid run option: ${option}; put Claude arguments after --`);
20
+ if (!["--project", "--claude", "--task", "--prompt-file", "--max-resumes", "--timeout-ms", "--session-budget-usd", "--recover-run", "--budget-phase"].includes(option) || !options[i + 1] || options[i + 1].startsWith("--")) throw new Error(`Invalid run option: ${option}; put Claude arguments after --`);
20
21
  const value = options[++i];
21
22
  if (option === "--project") project = path.resolve(value);
22
23
  else if (option === "--claude") binary = value;
23
24
  else if (option === "--task") supervision.taskId = value;
24
25
  else if (option === "--prompt-file") supervision.promptFile = path.resolve(value);
25
26
  else if (option === "--recover-run") { supervision.recoverRun = value; supervised = true; }
27
+ else if (option === "--budget-phase") { if (!["work", "final-review"].includes(value)) throw new Error("Budget phase must be work or final-review"); supervision.budgetPhase = value; }
26
28
  else if (option === "--max-resumes") supervision.maxResumes = Number(value);
27
29
  else if (option === "--timeout-ms") supervision.timeoutMs = Number(value);
28
30
  else supervision.budgetUsd = Number(value);
29
31
  }
30
32
  project = fs.realpathSync(project);
33
+ assertInstallationAvailable(project);
31
34
  if (!fs.existsSync(containedPath(project, ".claude/nccgs/install-state.json"))) throw new Error("Install/update NCCGS in the target project first");
32
35
  const mode = autonomyPolicy(project).mode;
33
36
  if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) throw new Error("Start nccgs run from the external terminal/operator, not by nesting it inside an active Claude session");
@@ -35,14 +38,14 @@ export function launchPlan(argv, env = process.env) {
35
38
  for (const argument of forwarded) if (conflicting.test(argument)) throw new Error(`NCCGS owns session permission/root settings; conflicting option: ${argument}`);
36
39
  if (supervised) {
37
40
  if (supervision.recoverRun) {
38
- if (forwarded.length || supervision.taskId || supervision.promptFile || supervision.budgetUsd !== undefined || options.includes("--max-resumes") || options.includes("--timeout-ms")) throw new Error("Recovery preserves the original prompt, limits and provider arguments");
41
+ if (forwarded.length || supervision.taskId || supervision.promptFile || supervision.budgetUsd !== undefined || supervision.budgetPhase || options.includes("--max-resumes") || options.includes("--timeout-ms")) throw new Error("Recovery preserves the original prompt, limits and provider arguments");
39
42
  } else {
40
43
  if (!supervision.taskId || !supervision.promptFile || !Number.isFinite(supervision.budgetUsd) || supervision.budgetUsd <= 0) throw new Error("Supervision needs --task, --prompt-file and --session-budget-usd");
41
44
  if (!Number.isSafeInteger(supervision.maxResumes) || supervision.maxResumes < 0 || supervision.maxResumes > 5 || !Number.isSafeInteger(supervision.timeoutMs) || supervision.timeoutMs < 1000 || supervision.timeoutMs > 86400000) throw new Error("Invalid supervision continuation/time limits");
42
45
  const owned = /^(?:-p|--print|--output-format|--input-format|--session-id|--resume|-r|--continue|-c|--fork-session|--no-session-persistence|--max-budget-usd|--verbose|--bg|--background|--remote|--cloud)(?:=|$)/;
43
46
  for (const argument of forwarded) if (owned.test(argument)) throw new Error(`Supervisor owns native lifecycle option: ${argument}`);
44
47
  }
45
- } else if (options.some(option => ["--task", "--prompt-file", "--max-resumes", "--timeout-ms", "--session-budget-usd"].includes(option))) throw new Error("Lifecycle options require --supervise");
48
+ } else if (options.some(option => ["--task", "--prompt-file", "--max-resumes", "--timeout-ms", "--session-budget-usd", "--budget-phase"].includes(option))) throw new Error("Lifecycle options require --supervise");
46
49
  return { project, binary, dryRun, mode, args: ["--permission-mode", mode === "full" ? "bypassPermissions" : "default", ...forwarded],
47
50
  supervision: supervised ? supervision : null, env: { ...env, NCCGS_PROJECT_ROOT: project, NCCGS_TASK_AUTONOMY: mode,
48
51
  // Native auto-backgrounding can otherwise turn a claimed foreground worker
@@ -59,14 +62,24 @@ export async function run(argv) {
59
62
  return 0;
60
63
  }
61
64
  console.error(`NCCGS ${plan.mode} task session | ${plan.project} | ${plan.mode === "full" ? "Native task permissions enabled, including outside-project commands; explicit deny rules and integrity guards remain effective." : "Existing guarded permissions retained."}`);
62
- if (plan.supervision) return supervise(plan);
63
- const child = spawn(plan.binary, plan.args, { cwd: plan.project, env: plan.env, stdio: "inherit", windowsHide: true });
64
- const interrupt = () => child.kill("SIGINT");
65
- process.on("SIGINT", interrupt);
65
+ const lease = acquireInstallationLease(plan.project, "nccgs-run");
66
+ let child = null;
67
+ const interrupt = () => child?.kill("SIGINT");
66
68
  try {
69
+ // Recheck after registering the session: uninstall checks this lease before
70
+ // removing any file. A racing launch cannot proceed through both gates.
71
+ assertInstallationAvailable(plan.project);
72
+ // Removal may have finished between launchPlan and lease acquisition.
73
+ if (!fs.existsSync(containedPath(plan.project, ".claude/nccgs/install-state.json")))
74
+ throw new Error("Install/update NCCGS in the target project first; it was removed before launch");
75
+ if (plan.supervision) return await supervise(plan);
76
+ lease.update({ phase: "spawning" });
77
+ child = spawn(plan.binary, plan.args, { cwd: plan.project, env: plan.env, stdio: "inherit", windowsHide: true });
78
+ child.once("spawn", () => lease.update({ phase: "running", child: { pid: child.pid, group: false } }));
79
+ process.on("SIGINT", interrupt);
67
80
  return await new Promise((resolve, reject) => {
68
81
  child.once("error", error => reject(new Error(`Cannot start Claude Code: ${error.message}. Use --claude with the native executable path.`)));
69
82
  child.once("close", (code, signal) => resolve(code ?? (signal ? 130 : 1)));
70
83
  });
71
- } finally { process.off("SIGINT", interrupt); }
84
+ } finally { process.off("SIGINT", interrupt); lease.release(); }
72
85
  }
@@ -7,6 +7,9 @@ import { taskPath } from "../.claude/nccgs/tools/gates.mjs";
7
7
  import { checkpoint, status as dispatchStatus } from "../.claude/nccgs/tools/orchestration.mjs";
8
8
  import { listJobs } from "../.claude/nccgs/tools/verification-jobs.mjs";
9
9
  import { acquireLock, delay, pidState } from "../.claude/nccgs/tools/process-lock.mjs";
10
+ import { sessionBudgetLock, sessionBudgetCoverage } from "../.claude/nccgs/tools/session-budget.mjs";
11
+ import { allocateReviewBudget } from "../.claude/nccgs/tools/review-budget.mjs";
12
+ import { settleWorkHandoff } from "./work-handoff.mjs";
10
13
 
11
14
  const need = (condition, message) => { if (!condition) throw new Error(message); };
12
15
  const now = () => new Date().toISOString();
@@ -44,11 +47,13 @@ export async function supervise(plan) {
44
47
  budgetUsd: config.budgetUsd, cumulativeCostUsd: 0, costCoverage: "complete", invocations: [], status: "ready" };
45
48
  }
46
49
  const lock = acquireLock(contained(project, `.nccgs/runs/task-${state.taskId}.lock`), { runId: state.id });
50
+ let budgetLock;
47
51
  const directory = path.dirname(file), save = () => atomicJson(file, state);
48
52
  let interrupted = false, child;
49
53
  const interrupt = () => { interrupted = true; if (child) child.kill("SIGINT"); };
50
54
  process.on("SIGINT", interrupt);
51
55
  try {
56
+ budgetLock = acquireLock(contained(project, sessionBudgetLock), { runId: state.id, sessionId: state.sessionId });
52
57
  fs.mkdirSync(directory, { recursive: true });
53
58
  const task = json(taskPath(project, state.taskId));
54
59
  need(task.contractHash === state.contractHash, "Task contract changed during supervised run");
@@ -63,7 +68,9 @@ export async function supervise(plan) {
63
68
  state.cumulativeCostUsd = cumulativeCost(state.cumulativeCostUsd, terminal, state.sessionId);
64
69
  state.costCoverage = "complete";
65
70
  Object.assign(last, { status: "recovered", terminal, recoveredAt: now() }); save();
71
+ last.evidence = artifact(project, `.nccgs/runs/${state.id}/${last.log}`); save();
66
72
  }
73
+ allocateReviewBudget(project, state, state.budgetPhase ?? config.budgetPhase ?? "work"); save();
67
74
  for (;;) {
68
75
  need(!interrupted, "Supervised run interrupted; jobs and checkpoint retained");
69
76
  need(Date.now() < state.deadline, "Supervised run deadline reached; no automatic extension");
@@ -75,11 +82,16 @@ export async function supervise(plan) {
75
82
  await delay(250); continue; // deterministic wait, no model request or fake verification receipt
76
83
  }
77
84
  if (state.invocations.length) {
78
- const latestTask = json(taskPath(project, state.taskId));
85
+ let latestTask = json(taskPath(project, state.taskId));
79
86
  const latestReceiptAt = Math.max(0, ...jobs.map(j => Date.parse(json(contained(project, j.evidence.path)).finishedAt)));
80
- const reportAt = Date.parse(latestTask.stages.report?.recordedAt ?? "");
81
87
  const active = dispatchStatus(project, state.taskId).active;
82
88
  need(!active.length, "Native worker dispatch remains active/uncertain; preserve quota and request reconciliation");
89
+ const handoff = settleWorkHandoff(project, state, latestReceiptAt);
90
+ if (handoff) {
91
+ latestTask = json(taskPath(project, state.taskId));
92
+ state.workHandoff = { origin: "supervisor-work-handoff", evidence: latestTask.stages.report.evidence, recordedAt: latestTask.stages.report.recordedAt };
93
+ }
94
+ const reportAt = Date.parse(latestTask.stages.report?.recordedAt ?? "");
83
95
  if (Number.isFinite(reportAt) && reportAt >= latestReceiptAt && reportAt >= Date.parse(state.createdAt)) {
84
96
  state.status = "settled"; state.taskOutcome = latestTask.outcome; state.taskPhase = latestTask.phase;
85
97
  state.finishedAt = now(); save(); return 0;
@@ -88,28 +100,40 @@ export async function supervise(plan) {
88
100
  }
89
101
  const remaining = state.budgetUsd - state.cumulativeCostUsd;
90
102
  need(remaining > 0, "Session list-cost budget exhausted; no budget reset on resume");
103
+ allocateReviewBudget(project, state, state.budgetPhase ?? "work");
104
+ for (let id = state.taskId; id; id = json(taskPath(project, id)).parentId) {
105
+ const ledger = dispatchStatus(project, id);
106
+ if (ledger.limits?.maxCostUsd != null)
107
+ sessionBudgetCoverage(project, id, ledger.reservations, ledger.limits.maxCostUsd, state, { running: false });
108
+ }
91
109
  const index = state.invocations.length, invocation = { index, startedAt: now(), status: "running", pid: null,
92
110
  log: `native-${index}.jsonl`, stderr: `native-${index}.stderr.log`, allowanceUsd: remaining };
93
- const prompt = index === 0 ? state.prompt : `Continue NCCGS task ${state.taskId} in this same session. Read task inspect and task job-status. Use the existing verification receipts; do not rerun successful commands just to create receipts. Investigate failed checks within remaining scope/budget. Record a truthful report (success/partial/blocked/failed). Do not claim DONE without all configured gates. Jobs: ${JSON.stringify(jobs)}. This continuation preserves all previous attempts, review rounds and costs.`;
111
+ const workNotice = task.mode === "implement"
112
+ ? `Final delivery review money/time is protected in a separate final-review phase. Finish the implementation record, then invoke node .claude/nccgs/tools/task.mjs report --id ${state.taskId} --outcome partial --summary "Implementation recorded; awaiting protected independent review". A prose PARTIAL response alone does not record the handoff. Do not spend this work phase on delivery review.`
113
+ : "Complete this task's normal analysis, independent design review, report and closure gates. Final delivery review money/time is protected for the later implementation task.";
114
+ const phaseNotice = state.budgetAllocation ? `Budget phase: ${state.budgetPhase}. Effective cumulative session ceiling USD ${state.budgetUsd}; absolute deadline ${new Date(state.deadline).toISOString()}. Read task budget-status for the pinned allocation. ${state.budgetPhase === "work" ? workNotice : "This phase only admits independent delivery review and settlement. No source workers, repair or new verification jobs. Read existing current measured evidence; preserve all ordinary gates and the exact reviewer verdict."}\n\n` : "";
115
+ const prompt = phaseNotice + (index === 0 ? state.prompt : `Continue NCCGS task ${state.taskId} in this same session. Read task inspect and task job-status. Use the existing verification receipts; do not rerun successful commands just to create receipts. Investigate failed checks within remaining scope/budget. Record a truthful report (success/partial/blocked/failed). Do not claim DONE without all configured gates. Jobs: ${JSON.stringify(jobs)}. This continuation preserves all previous attempts, review rounds and costs.`);
94
116
  state.invocations.push(invocation); state.status = "running"; state.costCoverage = "unknown-current-invocation"; save();
95
117
  lock.update({ phase: "spawning" });
118
+ budgetLock.update({ phase: "spawning" });
96
119
  const output = fs.openSync(path.join(directory, invocation.log), "wx"), errors = fs.openSync(path.join(directory, invocation.stderr), "wx");
97
120
  const args = [...state.providerArgs, "-p", "--output-format", "stream-json", "--verbose", "--max-budget-usd", String(remaining),
98
121
  ...(index === 0 ? ["--session-id", state.sessionId] : ["--resume", state.sessionId])];
99
122
  let deadlineTimer;
100
123
  try {
101
124
  child = spawn(state.binary, args, { cwd: project, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
102
- env: { ...plan.env, NCCGS_SUPERVISED_SESSION: state.sessionId, NCCGS_TASK_ID: state.taskId } });
125
+ env: { ...plan.env, NCCGS_SUPERVISED_SESSION: state.sessionId, NCCGS_SUPERVISED_RUN: state.id, NCCGS_TASK_ID: state.taskId } });
103
126
  child.stdin.on("error", () => {}); // early native failure is captured in its terminal/exit evidence
104
127
  child.stdout.on("data", data => { fs.writeSync(output, data); process.stdout.write(data); });
105
128
  child.stderr.on("data", data => { fs.writeSync(errors, data); process.stderr.write(data); });
106
- child.once("spawn", () => { invocation.pid = child.pid; save(); lock.update({ phase: "running", child: { pid: child.pid, group: false } }); });
129
+ child.once("spawn", () => { invocation.pid = child.pid; save(); const ownership = { phase: "running", child: { pid: child.pid, group: false } }; lock.update(ownership); budgetLock.update(ownership); });
107
130
  child.stdin.end(prompt);
108
131
  deadlineTimer = setTimeout(() => { interrupted = true; child.kill("SIGINT"); }, Math.max(1, state.deadline - Date.now()));
109
132
  const result = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", (code, signal) => resolve({ code, signal })); });
110
133
  Object.assign(invocation, result, { finishedAt: now() });
111
134
  } finally { clearTimeout(deadlineTimer); fs.closeSync(output); fs.closeSync(errors); child = null; }
112
135
  lock.update({ phase: "owned", child: null });
136
+ budgetLock.update({ phase: "owned", child: null });
113
137
  invocation.terminal = terminalFrom(path.join(directory, invocation.log));
114
138
  state.cumulativeCostUsd = cumulativeCost(state.cumulativeCostUsd, invocation.terminal, state.sessionId);
115
139
  state.costCoverage = "complete";
@@ -120,5 +144,5 @@ export async function supervise(plan) {
120
144
  state.status = "blocked"; state.reason = error.message; state.stoppedAt = now();
121
145
  try { state.checkpoint = checkpoint(project, state.taskId, { reason: error.message }); } catch { /* original failure retained */ }
122
146
  save(); console.error(`NCCGS supervisor: ${error.message}. Run record: ${file}`); return 1;
123
- } finally { process.off("SIGINT", interrupt); lock.release(); }
147
+ } finally { process.off("SIGINT", interrupt); budgetLock?.release(); lock.release(); }
124
148
  }