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,292 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { fileURLToPath } from "node:url";
5
+ import { assertProjectRoot, assertRelativePath, containedPath } from "../.claude/nccgs/tools/paths.mjs";
6
+ import { matchesFingerprint, equivalentBuffers, sha256 } from "../.claude/nccgs/tools/integrity.mjs";
7
+ import { canonicalHookCommand } from "../.claude/nccgs/tools/autonomy.mjs";
8
+ import { acquireLock, ownerState } from "../.claude/nccgs/tools/process-lock.mjs";
9
+ import { assertInstallationAvailable } from "../.claude/nccgs/tools/installation-guard.mjs";
10
+
11
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ const statePath = ".claude/nccgs/install-state.json";
13
+ const lockPath = ".nccgs/uninstall.lock";
14
+ const need = (ok, message) => { if (!ok) throw new Error(message); };
15
+ const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
16
+ const digest = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
17
+ const read = file => JSON.parse(fs.readFileSync(file, "utf8"));
18
+
19
+ function files(project, relative) {
20
+ const directory = containedPath(project, relative);
21
+ if (!fs.existsSync(directory)) return [];
22
+ need(fs.statSync(directory).isDirectory(), `Expected a directory: ${relative}`);
23
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
24
+ const name = `${relative}/${entry.name}`, file = containedPath(project, name);
25
+ if (entry.isDirectory()) return files(project, name);
26
+ need(entry.isFile() && fs.statSync(file).isFile(), `Unsupported entry: ${name}`);
27
+ return [name];
28
+ });
29
+ }
30
+
31
+ // Read only: do not settle/recover a job, rewrite a task, reclaim an old lock or
32
+ // terminate a process merely to make uninstallation possible.
33
+ export function assertUninstallIdle(project, ownToken = null) {
34
+ need(!(process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT || process.env.NCCGS_SUPERVISED_SESSION),
35
+ "Close Claude Code and run uninstall from an external terminal.");
36
+ const at = relative => containedPath(project, relative);
37
+ const candidates = [...[".nccgs/orchestration.lock", ".nccgs/measurement-start.lock", lockPath].flatMap(name => [name, `${name}.recovery`]),
38
+ ...[".nccgs/tasks", ".nccgs/jobs", ".nccgs/runs", ".nccgs/sessions"].flatMap(dir => files(project, dir))];
39
+ for (const relative of candidates.filter(name => /\.lock(?:\.recovery)?$/.test(name))) {
40
+ if (!fs.existsSync(at(relative))) continue;
41
+ const lock = read(at(relative));
42
+ if (relative === lockPath && ownToken && lock.token === ownToken) continue;
43
+ need(ownerState(lock) === "dead", `Cannot uninstall: active or unverified process lock ${relative}. Stop/recover the owning work first.${relative.startsWith(".nccgs/sessions/") && lock.operation === "nccgs-run" ? " For a crashed launcher, see recover-session --dry-run and docs/UNINSTALL.md; never infer descendant shutdown from PID death." : ""}`);
44
+ }
45
+ const ledger = at(".nccgs/orchestration.json");
46
+ if (fs.existsSync(ledger)) {
47
+ const value = read(ledger);
48
+ need(object(value.dispatches), "Invalid dispatch ledger; inspect it before uninstalling.");
49
+ for (const dispatch of Object.values(value.dispatches)) {
50
+ need(object(dispatch) && ["completed", "failed", "cancelled"].includes(dispatch.status),
51
+ `Cannot uninstall: dispatch ${dispatch?.id ?? "unknown"} is active or unverified.`);
52
+ }
53
+ }
54
+ for (const relative of candidates.filter(name => /^\.nccgs\/jobs\/[^/]+\/request\.json$/.test(name))) {
55
+ const result = at(relative.replace(/request\.json$/, "result.json"));
56
+ need(fs.existsSync(result) && ["PASS", "FAIL"].includes(read(result).status), `Cannot uninstall: unsettled verification job ${relative}.`);
57
+ }
58
+ for (const relative of candidates.filter(name => /^\.nccgs\/runs\/[^/]+\/run\.json$/.test(name))) {
59
+ const run = read(at(relative));
60
+ need(["ready", "settled", "blocked"].includes(run.status) && Array.isArray(run.invocations)
61
+ && run.invocations.every(item => ["finished", "recovered"].includes(item.status)), `Cannot uninstall: active or unverified run ${relative}.`);
62
+ }
63
+ }
64
+
65
+ function catalog() {
66
+ const known = new Set(files(root, ".claude"));
67
+ known.add(".claude/skills/setup-vivox-voice-chat/evals/.gitignore");
68
+ const studio = read(path.join(root, ".claude/nccgs/studio.json"));
69
+ for (const agent of studio.agents) known.add(`.claude/agents/${agent.name}.md`);
70
+ return known;
71
+ }
72
+
73
+ function removeHooks(text) {
74
+ const settings = JSON.parse(text);
75
+ need(object(settings), "Claude settings must be an object.");
76
+ if (settings.hooks === undefined) return text;
77
+ need(object(settings.hooks), "Invalid Claude hooks; no files were removed.");
78
+ const fragment = read(path.join(root, ".claude/nccgs/settings.fragment.json"));
79
+ const commands = new Set(Object.values(fragment.hooks).flatMap(groups => groups.flatMap(g => g.hooks))
80
+ .map(hook => canonicalHookCommand(hook.command)));
81
+ let changed = false;
82
+ for (const [event, groups] of Object.entries(settings.hooks)) {
83
+ need(Array.isArray(groups), `Invalid hook groups: ${event}`);
84
+ settings.hooks[event] = groups.flatMap(group => {
85
+ need(object(group) && Array.isArray(group.hooks), `Invalid hook group: ${event}`);
86
+ const hooks = group.hooks.filter(hook => {
87
+ need(object(hook), `Invalid hook: ${event}`);
88
+ if (hook.type !== "command" || typeof hook.command !== "string") return true;
89
+ if (commands.has(canonicalHookCommand(hook.command))) { changed = true; return false; }
90
+ need(!/[\\/]\.claude[\\/]nccgs[\\/]|\.claude[\\/]nccgs[\\/]/i.test(hook.command),
91
+ `Customized NCCGS hook in ${event}; separate/remove it manually before uninstalling.`);
92
+ return true;
93
+ });
94
+ // Keep unrelated hooks and their matcher/timeout/metadata, even when they
95
+ // share a group with a framework hook.
96
+ return hooks.length || !group.hooks.length ? [{ ...group, hooks }] : [];
97
+ });
98
+ if (!settings.hooks[event].length && groups.length) delete settings.hooks[event];
99
+ }
100
+ if (!Object.keys(settings.hooks).length) delete settings.hooks;
101
+ // Old installers did not record ownership of generic deny/model preferences.
102
+ // Preserve those shared settings instead of guessing which values to remove.
103
+ return changed ? `${JSON.stringify(settings, null, 2)}\n` : text;
104
+ }
105
+
106
+ function removeImport(buffer) {
107
+ if (equivalentBuffers(buffer, fs.readFileSync(path.join(root, "CLAUDE.md")))) return null;
108
+ const text = buffer.toString("utf8");
109
+ need(Buffer.from(text, "utf8").equals(buffer), "CLAUDE.md must be UTF-8 to remove the NCCGS import safely.");
110
+ const next = text.replace(/^<!-- NCCGS:BEGIN -->\r?\n@\.claude\/nccgs\/constitution\.md\r?\n<!-- NCCGS:END -->(?:\r?\n|$)/gm, "")
111
+ .replace(/^[ \t]*@\.claude\/nccgs\/constitution\.md[ \t]*(?:\r?\n|$)/gm, "");
112
+ need(!/@\.claude\/nccgs\//.test(next), "Customized NCCGS import in CLAUDE.md; remove it manually before uninstalling.");
113
+ return Buffer.from(next, "utf8");
114
+ }
115
+
116
+ export function planUninstall(project, ownToken = null) {
117
+ project = assertProjectRoot(project);
118
+ need(fs.realpathSync(project) !== fs.realpathSync(root), "The NCCGS package cannot uninstall itself.");
119
+ assertUninstallIdle(project, ownToken);
120
+ const at = relative => containedPath(project, relative), known = catalog(), file = at(statePath);
121
+ if (!fs.existsSync(file)) return { project, status: "NOT_INSTALLED", changes: [], preserved: [], warnings: [] };
122
+ const state = read(file);
123
+ need([1, 2, 3].includes(state.schemaVersion) && Array.isArray(state.files) && typeof state.frameworkVersion === "string", "Invalid install state; refuse unsafe cleanup.");
124
+ const managed = new Map(), aliases = new Set();
125
+ for (const item of state.files) {
126
+ const relative = assertRelativePath(item.relative);
127
+ need(relative.startsWith(".claude/") && ![".claude/settings.json", ".claude/settings.local.json", statePath].includes(relative), `Unsafe managed path: ${relative}`);
128
+ need(digest(item.sha256) && (item.canonicalSha256 === undefined || digest(item.canonicalSha256)), `Invalid managed fingerprint: ${relative}`);
129
+ need(!aliases.has(relative.toLowerCase()), `Duplicate managed path: ${relative}`);
130
+ aliases.add(relative.toLowerCase()); at(relative); managed.set(relative, item);
131
+ }
132
+ const activationPath = at(".nccgs/agent-activation.json");
133
+ if (fs.existsSync(activationPath)) {
134
+ const activation = read(activationPath);
135
+ need(activation.schemaVersion === 1 && object(activation.generated), "Invalid generated-agent inventory.");
136
+ for (const [relative, hash] of Object.entries(activation.generated)) {
137
+ need(known.has(relative) && /^\.claude\/agents\/nccgs-[\w-]+\.md$/.test(relative) && digest(hash), `Unsafe generated agent: ${relative}`);
138
+ at(relative);
139
+ // Profile/optional-agent configuration can be newer than install-state.
140
+ const canonicalSha256 = activation.generatedCanonical?.[relative];
141
+ need(canonicalSha256 === undefined || digest(canonicalSha256), `Invalid generated-agent fingerprint: ${relative}`);
142
+ managed.set(relative, { sha256: hash, ...(canonicalSha256 ? { canonicalSha256 } : {}) });
143
+ }
144
+ }
145
+ const changes = [], preserved = [], warnings = [];
146
+ const change = (relative, after) => {
147
+ const current = fs.readFileSync(at(relative));
148
+ if (after !== null && current.equals(after)) return;
149
+ changes.push({ path: relative, before: sha256(current), after: after === null ? null : sha256(after), bytes: current, output: after });
150
+ };
151
+ for (const [relative, fingerprint] of managed) {
152
+ if (!fs.existsSync(at(relative))) continue;
153
+ need(fs.statSync(at(relative)).isFile(), `Managed target is not a file: ${relative}`);
154
+ if (!known.has(relative)) { preserved.push({ path: relative, reason: "Not in this package's trusted file catalog" }); continue; }
155
+ if (!matchesFingerprint(at(relative), fingerprint)) { preserved.push({ path: relative, reason: "Locally modified or pre-existing override" }); continue; }
156
+ change(relative, null);
157
+ }
158
+ if (fs.existsSync(at(".claude/settings.json"))) change(".claude/settings.json", Buffer.from(removeHooks(fs.readFileSync(at(".claude/settings.json"), "utf8"))));
159
+ if (fs.existsSync(at("CLAUDE.md"))) change("CLAUDE.md", removeImport(fs.readFileSync(at("CLAUDE.md"))));
160
+ // Removing this marker last makes repeated uninstall a no-op and stops run.
161
+ change(statePath, null);
162
+ warnings.push("Project .nccgs history, budgets, policy, backups and generic Claude permission/model settings are retained.");
163
+ if (preserved.length) warnings.push("Modified/unknown managed files remain in place; inspect the preserved list before removing them manually.");
164
+ return { project, status: "PLANNED", frameworkVersion: state.frameworkVersion, changes, preserved, warnings };
165
+ }
166
+
167
+ function manifestPath(id) {
168
+ need(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id), "Rollback requires the printed uninstall UUID.");
169
+ return `.nccgs/uninstall/${id}/manifest.json`;
170
+ }
171
+
172
+ function writeManifest(project, relative, value) {
173
+ const temporary = `${relative}.${crypto.randomUUID()}.tmp`;
174
+ fs.writeFileSync(containedPath(project, temporary), JSON.stringify(value, null, 2) + "\n", { flag: "wx" });
175
+ fs.renameSync(containedPath(project, temporary), containedPath(project, relative));
176
+ }
177
+
178
+ export function restoreUninstall(project, id, { dryRun = false, ownToken = null } = {}) {
179
+ project = assertProjectRoot(project); assertUninstallIdle(project, ownToken);
180
+ const relative = manifestPath(id), manifest = read(containedPath(project, relative));
181
+ need(manifest.schemaVersion === 1 && manifest.kind === "nccgs-uninstall" && manifest.id === id
182
+ && ["prepared", "applying", "completed", "restoring", "failed"].includes(manifest.status) && Array.isArray(manifest.files), "Invalid or already restored uninstall receipt.");
183
+ const known = catalog(), aliases = new Set(), restores = [];
184
+ for (const item of manifest.files) {
185
+ assertRelativePath(item.path);
186
+ need(known.has(item.path) || [statePath, "CLAUDE.md", ".claude/settings.json"].includes(item.path), `Unsafe rollback path: ${item.path}`);
187
+ need(!aliases.has(item.path.toLowerCase()) && digest(item.before) && (item.after === null || digest(item.after)), "Invalid rollback record.");
188
+ aliases.add(item.path.toLowerCase());
189
+ const destination = containedPath(project, item.path), backup = containedPath(project, `.nccgs/uninstall/${id}/files/${item.path}`);
190
+ const bytes = fs.readFileSync(backup); need(sha256(bytes) === item.before, `Changed rollback backup: ${item.path}`);
191
+ const current = fs.existsSync(destination) ? sha256(fs.readFileSync(destination)) : null;
192
+ need(current === item.before || current === item.after, `File changed after uninstall; refusing to overwrite: ${item.path}`);
193
+ if (current !== item.before) restores.push({ ...item, bytes });
194
+ }
195
+ if (dryRun) return { status: "ROLLBACK_PREVIEW", id, restoredFiles: restores.map(x => x.path) };
196
+ manifest.status = "restoring"; writeManifest(project, relative, manifest);
197
+ for (const item of restores) {
198
+ const file = containedPath(project, item.path), current = fs.existsSync(file) ? sha256(fs.readFileSync(file)) : null;
199
+ need(current === item.after, `Concurrent change during rollback: ${item.path}`);
200
+ fs.mkdirSync(path.dirname(file), { recursive: true });
201
+ fs.writeFileSync(containedPath(project, item.path), item.bytes, { flag: item.after === null ? "wx" : "w" });
202
+ }
203
+ manifest.status = "restored"; manifest.restoredAt = new Date().toISOString(); writeManifest(project, relative, manifest);
204
+ return { status: "RESTORED", id, restoredFiles: restores.map(x => x.path) };
205
+ }
206
+
207
+ export function uninstall(project, { dryRun = false, rollback = null } = {}) {
208
+ project = assertProjectRoot(project);
209
+ need(fs.realpathSync(project) !== fs.realpathSync(root), "The NCCGS package cannot uninstall itself.");
210
+ if (!rollback) assertInstallationAvailable(project);
211
+ if (dryRun) return rollback ? restoreUninstall(project, rollback, { dryRun: true }) : summary(planUninstall(project));
212
+ assertUninstallIdle(project);
213
+ if (!rollback && !fs.existsSync(containedPath(project, statePath))) return summary(planUninstall(project));
214
+ const lock = acquireLock(containedPath(project, lockPath), { operation: "uninstall" });
215
+ try {
216
+ if (rollback) return restoreUninstall(project, rollback, { ownToken: lock.record.token });
217
+ // Planning is read-only, with our own lock explicitly recognized.
218
+ const plan = planUninstall(project, lock.record.token);
219
+ const id = crypto.randomUUID(), relative = manifestPath(id), directory = path.dirname(containedPath(project, relative));
220
+ for (const item of plan.changes) {
221
+ const file = containedPath(project, `.nccgs/uninstall/${id}/files/${item.path}`);
222
+ fs.mkdirSync(path.dirname(file), { recursive: true });
223
+ fs.writeFileSync(containedPath(project, `.nccgs/uninstall/${id}/files/${item.path}`), item.bytes, { flag: "wx" });
224
+ }
225
+ fs.mkdirSync(directory, { recursive: true });
226
+ const manifest = { schemaVersion: 1, kind: "nccgs-uninstall", id, frameworkVersion: plan.frameworkVersion,
227
+ createdAt: new Date().toISOString(), status: "prepared", files: plan.changes.map(({ path, before, after }) => ({ path, before, after })),
228
+ preserved: plan.preserved, warnings: plan.warnings };
229
+ writeManifest(project, relative, manifest);
230
+ let mutationStarted = false;
231
+ try {
232
+ assertUninstallIdle(project, lock.record.token);
233
+ for (const item of plan.changes) need(sha256(fs.readFileSync(containedPath(project, item.path))) === item.before, `Concurrent change before uninstall: ${item.path}`);
234
+ manifest.status = "applying"; writeManifest(project, relative, manifest);
235
+ for (const item of plan.changes) {
236
+ const file = containedPath(project, item.path);
237
+ need(sha256(fs.readFileSync(file)) === item.before, `Concurrent change during uninstall: ${item.path}`);
238
+ mutationStarted = true;
239
+ if (item.output === null) fs.unlinkSync(containedPath(project, item.path));
240
+ else fs.writeFileSync(containedPath(project, item.path), item.output);
241
+ }
242
+ manifest.status = "completed"; manifest.completedAt = new Date().toISOString(); writeManifest(project, relative, manifest);
243
+ } catch (error) {
244
+ if (!mutationStarted) {
245
+ manifest.status = "cancelled"; manifest.error = error.message; manifest.cancelledAt = new Date().toISOString();
246
+ writeManifest(project, relative, manifest);
247
+ throw new Error(`${error.message}; uninstall cancelled before changing files; receipt ${relative}`);
248
+ }
249
+ manifest.status = "failed"; manifest.error = error.message; writeManifest(project, relative, manifest);
250
+ try { restoreUninstall(project, id, { ownToken: lock.record.token }); }
251
+ catch (restoreError) { throw new Error(`${error.message}; backup ${relative}; recovery requires inspection: ${restoreError.message}`); }
252
+ throw new Error(`${error.message}; all planned files restored from ${relative}`);
253
+ }
254
+ // Only empty directories derived from individually checked managed paths.
255
+ const directories = new Set();
256
+ for (const item of plan.changes.filter(x => x.output === null && x.path.startsWith(".claude/"))) {
257
+ let dir = path.posix.dirname(item.path);
258
+ while (dir.startsWith(".claude/")) { directories.add(dir); dir = path.posix.dirname(dir); }
259
+ }
260
+ const cleanupWarnings = [];
261
+ for (const dir of [...directories].sort((a, b) => b.length - a.length)) {
262
+ try { fs.rmdirSync(containedPath(project, dir)); }
263
+ catch (error) {
264
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code))
265
+ cleanupWarnings.push(`Empty-directory cleanup skipped for ${dir}: ${error.code || error.message}`);
266
+ }
267
+ }
268
+ return { ...summary(plan), warnings: [...plan.warnings, ...cleanupWarnings], status: plan.preserved.length ? "UNINSTALLED_WITH_PRESERVED_FILES" : "UNINSTALLED", id, backup: relative };
269
+ } finally { lock.release(); }
270
+ }
271
+
272
+ function summary(plan) {
273
+ return { project: plan.project, status: plan.status, frameworkVersion: plan.frameworkVersion,
274
+ remove: plan.changes.filter(x => x.after === null).map(x => x.path),
275
+ edit: plan.changes.filter(x => x.after !== null).map(x => x.path), preserved: plan.preserved, warnings: plan.warnings };
276
+ }
277
+
278
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
279
+ try {
280
+ let project = process.cwd(), dryRun = false, rollback = null;
281
+ const args = process.argv.slice(2), seen = new Set();
282
+ for (let i = 0; i < args.length; i++) {
283
+ const arg = args[i]; need(!seen.has(arg), `Duplicate option: ${arg}`); seen.add(arg);
284
+ if (arg === "--dry-run") dryRun = true;
285
+ else if (["--project", "--rollback"].includes(arg)) {
286
+ const value = args[++i]; need(value && !value.startsWith("--"), `${arg} requires a value`);
287
+ if (arg === "--project") project = path.resolve(value); else rollback = value;
288
+ } else throw new Error(`Unknown uninstall option: ${arg}`);
289
+ }
290
+ console.log(JSON.stringify(uninstall(project, { dryRun, rollback }), null, 2));
291
+ } catch (error) { console.error(`NCCGS uninstall refused: ${error.message}`); process.exitCode = 1; }
292
+ }
@@ -50,7 +50,11 @@ for (const relative of [
50
50
  ".claude/nccgs/pipeline.json", ".claude/nccgs/protocols/task-gates.md",
51
51
  ".claude/nccgs/protocols/design-lifecycle.md", ".claude/nccgs/tools/design-readiness.mjs", "scaffold/.nccgs/templates/design-contract.json",
52
52
  ".claude/nccgs/tools/task.mjs", ".claude/nccgs/tools/gates.mjs", ".claude/nccgs/tools/runtime.mjs",
53
+ ".claude/nccgs/tools/handoff.mjs", "docs/HANDOFF-AUTOMATION.md",
54
+ ".claude/nccgs/tools/task-slices.mjs", ".claude/nccgs/protocols/task-slices.md",
55
+ "scaffold/.nccgs/templates/task-slices.json", "docs/TASK-SLICING.md", "tests/task-slices.test.mjs",
53
56
  ".claude/nccgs/tools/repair-packet.mjs", ".claude/nccgs/protocols/bounded-repair.md", "scaffold/.nccgs/templates/repair-packet.json", "docs/BOUNDED-REPAIR.md",
57
+ ".claude/nccgs/tools/unity-repair.mjs", "scaffold/.nccgs/templates/repair-unity-code.json", "scaffold/.nccgs/templates/repair-unity-editor.json",
54
58
  "scaffold/.nccgs/gates.json", "scaffold/.nccgs/execution.json", "tests/gates.test.mjs",
55
59
  ".claude/nccgs/settings.fragment.json", ".claude/nccgs/unity-skills-manifest.json",
56
60
  ".claude/nccgs/protocols/agent-contract.md", ".claude/nccgs/protocols/orchestration.md",
@@ -62,6 +66,8 @@ for (const relative of [
62
66
  "scaffold/.nccgs/templates/closure-record.md", "package-assets/setup-vivox-voice-chat-evals.gitignore",
63
67
  "package-assets/nccgs-project.gitignore",
64
68
  "scripts/cli.mjs", "scripts/doctor.mjs", "scripts/install.mjs",
69
+ "scripts/uninstall.mjs", ".claude/nccgs/tools/installation-guard.mjs", "tests/uninstall.test.mjs", "docs/UNINSTALL.md",
70
+ "scripts/recover-session.mjs",
65
71
  "scripts/sync-unity-skills.mjs", "tests/framework.test.mjs",
66
72
  ".claude/nccgs/tools/integrity.mjs", ".claude/nccgs/tools/approve-overrides.mjs"
67
73
  ]) requireFile(relative);
@@ -0,0 +1,38 @@
1
+ import { check, record, taskPath } from "../.claude/nccgs/tools/gates.mjs";
2
+ import { json } from "../.claude/nccgs/tools/policy.mjs";
3
+ import { assessCriteria } from "../.claude/nccgs/tools/criteria.mjs";
4
+ import { status } from "../.claude/nccgs/tools/orchestration.mjs";
5
+ import { listJobs, readJob } from "../.claude/nccgs/tools/verification-jobs.mjs";
6
+
7
+ // Settle only an already recorded implementation. This is deterministic supervisor
8
+ // bookkeeping, never a provider/reviewer verdict or permission to close the task.
9
+ export function settleWorkHandoff(project, state, latestReceiptAt = 0) {
10
+ if (!state.budgetAllocation || state.budgetPhase !== "work" || state.costCoverage !== "complete") return null;
11
+ const last = state.invocations.at(-1);
12
+ if (!last || !["finished", "recovered"].includes(last.status) || last.terminal?.is_error ||
13
+ (last.code != null && last.code !== 0) || last.signal) return null;
14
+ const task = json(taskPath(project, state.taskId));
15
+ if (task.mode !== "implement" || task.closed || task.contractHash !== state.contractHash) return null;
16
+ if (Date.parse(task.stages.report?.recordedAt) >= Date.parse(state.createdAt)) return null;
17
+ if (!(Date.parse(task.stages.implementation?.recordedAt) >= Math.max(Date.parse(state.createdAt), latestReceiptAt))) return null;
18
+ const jobs = listJobs(project, task.id);
19
+ if (jobs.some(j => !["PASS", "FAIL"].includes(j.status)) || status(project, task.id).active.length) return null;
20
+ // A later failure cannot be hidden by re-recording an older PASS, even for a
21
+ // legacy check-only contract without measured acceptance assertions.
22
+ const byName = new Map();
23
+ for (const job of jobs) {
24
+ const queuedAt = Date.parse(readJob(project, job.jobId).queuedAt), previous = byName.get(job.name);
25
+ if (!Number.isFinite(queuedAt) || previous?.queuedAt === queuedAt) return null;
26
+ if (!previous || previous.queuedAt < queuedAt) byName.set(job.name, { ...job, queuedAt });
27
+ }
28
+ if ([...byName.values()].some(j => j.status !== "PASS")) return null;
29
+ let ready;
30
+ try {
31
+ ready = check(project, task, "review");
32
+ if (!ready.allowed || !assessCriteria(project, task, ready.snapshot).automaticPassed) return null;
33
+ } catch { return null; } // Incomplete/stale evidence still follows the bounded continuation path.
34
+ return record(project, task.id, "report", {
35
+ outcome: "partial", origin: "supervisor-work-handoff", runId: state.id,
36
+ summary: "Implementation and current measured checks recorded. Awaiting protected independent delivery review and all remaining acceptance gates. This handoff is deterministic supervisor bookkeeping, not a reviewer verdict."
37
+ });
38
+ }
@@ -0,0 +1,45 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import crypto from "node:crypto";
7
+ import { publishEditorSessionEvidence } from "../.claude/nccgs/tools/editor-session-evidence.mjs";
8
+ const hash = bytes => crypto.createHash("sha256").update(bytes).digest("hex");
9
+ function fixture(t) {
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-editor-evidence-"));
11
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
12
+ const project = path.join(root, "project"); fs.mkdirSync(project);
13
+ const identity = { pid: 123, windowsJob: true, groupName: "Local\\NCCGS-12345678-1234-1234-1234-123456789abc" };
14
+ const values = {
15
+ start: { at: "2026-01-01T00:00:00Z", project, identity },
16
+ stop: { identity, state: "dead", forced: false, result: { code: 0, signal: null } },
17
+ shutdown: { at: "2026-01-01T00:01:00Z", args: ["command", "eval", "UnityEditor.EditorApplication.Exit(0);", "--project-path", project], code: 0, signal: null, identity: { ...identity, pid: 124 }, treeState: "dead" }
18
+ };
19
+ const request = { sourceProject: project, id: crypto.randomUUID() };
20
+ const save = () => { for (const [key, value] of Object.entries(values)) { const f = path.join(root, key + ".json"); fs.writeFileSync(f, JSON.stringify(value)); request[key + "File"] = f; } };
21
+ save(); return { project, request, values, save, root };
22
+ }
23
+ test("publishes original bytes and hashes under reviewer-readable evidence without task mutation", t => {
24
+ const f = fixture(t); fs.mkdirSync(path.join(f.project, ".nccgs")); fs.writeFileSync(path.join(f.project, ".nccgs", "task-sentinel"), "original history and budgets");
25
+ const ref = publishEditorSessionEvidence(f.project, f.request), bytes = fs.readFileSync(path.join(f.project, ref.path)), receipt = JSON.parse(bytes);
26
+ assert.equal(hash(bytes), ref.sha256); assert.equal(receipt.origin, "external-operator-editor-evidence"); assert.equal(receipt.historicalImport, false);
27
+ for (const [key, value] of Object.entries(receipt.references)) { const copied = fs.readFileSync(path.join(f.project, value.path)); assert.equal(hash(copied), value.sha256); assert.deepEqual(copied, fs.readFileSync(f.request[key + "File"])); }
28
+ assert.equal(fs.readFileSync(path.join(f.project, ".nccgs", "task-sentinel"), "utf8"), "original history and budgets");
29
+ assert.throws(() => publishEditorSessionEvidence(f.project, f.request), /EEXIST/);
30
+ });
31
+ test("separate addendum identifies historical import and preserves the source project", t => {
32
+ const f = fixture(t), addendum = path.join(f.root, "addendum"); fs.mkdirSync(addendum);
33
+ const ref = publishEditorSessionEvidence(addendum, f.request), receipt = JSON.parse(fs.readFileSync(path.join(addendum, ref.path)));
34
+ assert.equal(receipt.historicalImport, true); assert.equal(receipt.sourceProject, f.project); assert.equal(fs.readdirSync(f.project).length, 0);
35
+ });
36
+ for (const [label, mutate] of Object.entries({
37
+ "mismatched ownership": f => { f.values.stop.identity = { ...f.values.stop.identity, pid: 999 }; },
38
+ "wrong project": f => { f.values.shutdown.args[4] = path.join(f.root, "other"); },
39
+ "live process": f => { f.values.stop.state = "alive"; },
40
+ "unknown process": f => { f.values.shutdown.treeState = "unknown"; },
41
+ "forced exit": f => { f.values.stop.forced = true; },
42
+ "failed exit": f => { f.values.stop.result.code = 1; },
43
+ "missing shutdown method": f => { f.values.shutdown.args[2] = "return 0;"; },
44
+ "reversed events": f => { f.values.shutdown.at = "2025-01-01T00:00:00Z"; }
45
+ })) test(`rejects ${label} before publishing`, t => { const f = fixture(t); mutate(f); f.save(); assert.throws(() => publishEditorSessionEvidence(f.project, f.request)); assert.equal(fs.readdirSync(f.project).length, 0); });
@@ -0,0 +1,64 @@
1
+ using System;
2
+ using System.Diagnostics;
3
+ using System.IO;
4
+ using System.IO.Pipes;
5
+ using System.Runtime.InteropServices;
6
+ using System.Threading;
7
+ using System.Threading.Tasks;
8
+
9
+ // Synthetic IPC guard fixture, not Unity execution evidence.
10
+ public static class CompilerShutdownFixture {
11
+ [DllImport("kernel32.dll")] static extern IntPtr CreateJobObject(IntPtr attributes,string name);
12
+ [DllImport("kernel32.dll")] static extern bool AssignProcessToJobObject(IntPtr job,IntPtr process);
13
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
14
+ static void Need(bool ok,string message) { if(!ok) throw new Exception(message); }
15
+ static void Exchange(IntPtr job,IntPtr process,int pid,bool expected,bool answer) {
16
+ string name="nccgs-compiler-guard-"+Guid.NewGuid().ToString("N");
17
+ using(var server=new NamedPipeServerStream(name,PipeDirection.InOut,1,PipeTransmissionMode.Byte,PipeOptions.Asynchronous)) {
18
+ var connected=server.WaitForConnectionAsync();
19
+ var receive=Task.Run(()=>{
20
+ connected.GetAwaiter().GetResult();
21
+ var length=new byte[4]; int count=server.Read(length,0,4);
22
+ if(count==0) return 0; // Refused peers must receive no request bytes.
23
+ Need(count==4 && BitConverter.ToInt32(length,0)==117,"bad request frame");
24
+ int remaining=117; var buffer=new byte[117];
25
+ while(remaining>0) { int n=server.Read(buffer,117-remaining,remaining); Need(n>0,"truncated request"); remaining-=n; }
26
+ if(answer) {
27
+ using(var writer=new BinaryWriter(server,System.Text.Encoding.Unicode,true)) {
28
+ writer.Write(8); writer.Write(3); writer.Write(Process.GetCurrentProcess().Id); writer.Flush();
29
+ }
30
+ } else Thread.Sleep(1500);
31
+ return 121;
32
+ });
33
+ bool accepted=NccgsUnityCompilerShutdown.Request(job,process,pid,name);
34
+ Need(accepted==expected,"incorrect shutdown acknowledgement");
35
+ Need(receive.Wait(3000),"pipe fixture failed to settle");
36
+ if(!expected && answer) Need(receive.Result==0,"foreign/unowned peer received shutdown bytes");
37
+ }
38
+ }
39
+ public static int Main(string[] args) {
40
+ IntPtr job=CreateJobObject(IntPtr.Zero,null);
41
+ try {
42
+ Need(job!=IntPtr.Zero,"job creation failed");
43
+ var process=Process.GetCurrentProcess();
44
+ // Golden frame captured from the installed Roslyn 4.3.1 serializer, not a
45
+ // second implementation of our own encoder.
46
+ const string golden="75000000000000000000000000000000000000002125534450340031006100350061006600390064003200630034003500390061003000360063003000370039003500620066003200310061003100630030003400360032003000300066003300370035006200660001000000257214510000000000000000";
47
+ Need(BitConverter.ToString(NccgsUnityCompilerShutdown.ShutdownFrame()).Replace("-","").ToLowerInvariant()==golden,"Roslyn protocol changed");
48
+ Exchange(job,process.Handle,process.Id,false,true); // Same PID, foreign job.
49
+ Need(AssignProcessToJobObject(job,process.Handle),"fixture job assignment failed");
50
+ Exchange(job,process.Handle,process.Id+1000000,false,true); // Wrong pipe server PID.
51
+ Exchange(job,process.Handle,process.Id,true,true); // Exact owned pipe.
52
+ Exchange(job,process.Handle,process.Id,false,false); // Missing acknowledgement stays unconfirmed.
53
+ string data=Path.Combine(args[0],"Editor","Data");
54
+ string dll=Path.Combine(data,"DotNetSdkRoslyn","VBCSCompiler.dll"),host=Path.Combine(data,"NetCoreRuntime","dotnet.exe");
55
+ Directory.CreateDirectory(Path.GetDirectoryName(dll)); Directory.CreateDirectory(Path.GetDirectoryName(host));
56
+ File.WriteAllText(dll,"unqualified binary"); File.WriteAllText(host,"unqualified host");
57
+ Need(NccgsUnityCompilerShutdown.QualifiedPipe(host,new[]{host,"exec",dll,"-pipename:safe"})==null,"unqualified binaries accepted");
58
+ Need(NccgsUnityCompilerShutdown.QualifiedPipe(host,new[]{host,"exec",dll,"-pipename:../../foreign"})==null,"invalid pipe accepted");
59
+ Need(NccgsUnityCompilerShutdown.QualifiedPipe(host,new[]{host,"exec",dll,"-pipename:safe","-shutdown"})==null,"unexpected arguments accepted");
60
+ Console.WriteLine("compiler shutdown guards PASS"); return 0;
61
+ } catch(Exception error) { Console.Error.WriteLine(error); return 1; }
62
+ finally { if(job!=IntPtr.Zero) CloseHandle(job); }
63
+ }
64
+ }
@@ -11,8 +11,10 @@ import { pinInputs } from "../.claude/nccgs/tools/inputs.mjs";
11
11
  import { artifact, atomicJson, hash, json } from "../.claude/nccgs/tools/policy.mjs";
12
12
  import { persistRuntimeObservation } from "../.claude/nccgs/tools/evidence.mjs";
13
13
  import { observe } from "../.claude/nccgs/tools/runtime.mjs";
14
- import { validateStandaloneRuntime, initializeStandalone, recordStandalone } from "../.claude/nccgs/tools/lifecycle.mjs";
14
+ import { validateStandaloneRuntime, initializeStandalone, recordStandalone, migrateFrameworkBaseline } from "../.claude/nccgs/tools/lifecycle.mjs";
15
15
  import { guardTool } from "../.claude/nccgs/tools/tool-guard.mjs";
16
+ import { acquireLock } from "../.claude/nccgs/tools/process-lock.mjs";
17
+ import { sessionBudgetLock } from "../.claude/nccgs/tools/session-budget.mjs";
16
18
 
17
19
  // These are synthetic framework fixtures, never Claude Code execution evidence.
18
20
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -87,6 +89,55 @@ function external(action) {
87
89
  function failRun(f, run) {
88
90
  return external(() => reconcile(f.project, run.reservation.id, { status: "failed", reason: "TEST FIXTURE process confirmed terminated", reference: "TEST FIXTURE external process-exit receipt" }));
89
91
  }
92
+ test("external framework migration proves the old inventory, rejects game changes and invalidates all approvals without quota refunds", () => {
93
+ const f = fixture();
94
+ try {
95
+ f.write('.nccgs/intake.json', { objective: 'Synthetic plan migration', mode: 'plan', analysisArtifacts: ['.nccgs/evidence/analysis/plan.md'] });
96
+ const t = initializeStandalone(f.project, 'migration', { intake: '.nccgs/intake.json' });
97
+ const beforeFiles = snapshot(f.project, t, { includeFiles: true }).fileEntries;
98
+ const run = f.start('migration'); failRun(f, run);
99
+ t.reviewBudget.used = 1; t.stages = { analysis: { fixture: true }, report: { outcome: 'partial' } }; f.write('.nccgs/tasks/migration.json', t);
100
+ const decision = '.nccgs/evidence/migration.json';
101
+ f.write(decision, { reason: 'Fixture framework patch', authority: 'Synthetic external fixture', beforeFiles });
102
+ f.write('.claude/nccgs/new-tool.mjs', '// fixture framework update');
103
+ f.write('Assets/game.cs', '// unauthorized source change');
104
+ assert.throws(() => external(() => migrateFrameworkBaseline(f.project, 'migration', decision)), /non-framework/);
105
+ f.write('Assets/game.cs', '// TEST FIXTURE source, not a game implementation\n');
106
+ process.env.CLAUDECODE = 'fixture';
107
+ try { assert.throws(() => migrateFrameworkBaseline(f.project, 'migration', decision), /external|External/); } finally { delete process.env.CLAUDECODE; }
108
+ const result = external(() => migrateFrameworkBaseline(f.project, 'migration', decision));
109
+ assert.equal(result.contractHash, t.contractHash); assert.equal(result.reviewBudget.used, 1); assert.deepEqual(result.stages, {});
110
+ assert.deepEqual(result.history.at(-1).stages, t.stages); assert.equal(result.history.at(-1).previousBaseline, t.analysisBaseline);
111
+ assert.equal(status(f.project, 'migration').usage.dispatches, 1);
112
+ assert.throws(() => external(() => migrateFrameworkBaseline(f.project, 'migration', decision)), /inventory/);
113
+ } finally { f.dispose(); }
114
+ });
115
+ test("missing worker cost admits only its live supervised allowance and keeps telemetry/limits/history", () => {
116
+ const f = fixture(), saved = [process.env.NCCGS_SUPERVISED_RUN, process.env.NCCGS_SUPERVISED_SESSION];
117
+ let lock;
118
+ try {
119
+ f.task("budget");
120
+ const run = f.start("budget", f.request("analysis", { limits: { maxCostUsd: 3 } }));
121
+ const observed = f.observation(run); complete(f.project, run.reservation.id, { runtime: observed });
122
+ assert.throws(() => reserve(f.project, "budget", f.request("analysis")), /missing costUsd/);
123
+ const id = "12345678-1234-1234-1234-123456789abc", sessionId = run.identity.sessionId;
124
+ f.write(`.nccgs/runs/${id}/run.json`, { id, project: f.project, taskId: "budget", sessionId, budgetUsd: 3, cumulativeCostUsd: 0,
125
+ deadline: Date.now() + 60000, status: "running", invocations: [{ status: "running", pid: process.pid }] });
126
+ lock = acquireLock(path.join(f.project, sessionBudgetLock), { runId: id, sessionId });
127
+ process.env.NCCGS_SUPERVISED_RUN = id; process.env.NCCGS_SUPERVISED_SESSION = sessionId;
128
+ const next = reserve(f.project, "budget", f.request("analysis"));
129
+ assert.equal(next.budgetEvidence[0].ceilingUsd, 3);
130
+ assert.throws(() => claim(f.project, next.id, { sessionId: "other", toolUseId: "next", agent: next.agent }), /owns its budget/);
131
+ const current = status(f.project, "budget"); assert.equal(current.usage.dispatches, 2); assert.equal(current.limits.maxCostUsd, 3);
132
+ assert.equal(current.telemetry.metrics.costUsd.total, null); assert.equal(current.telemetry.metrics.costUsd.measured, 0);
133
+ lock.release(); lock = null;
134
+ assert.throws(() => reserve(f.project, "budget", f.request("analysis")), /ENOENT/);
135
+ } finally {
136
+ lock?.release();
137
+ for (const [i,k] of ["NCCGS_SUPERVISED_RUN", "NCCGS_SUPERVISED_SESSION"].entries()) { if(saved[i]===undefined) delete process.env[k]; else process.env[k]=saved[i]; }
138
+ f.dispose();
139
+ }
140
+ });
90
141
  test("external dispatch extension keeps spent attempts and parent cap, and rejects live or native changes", () => {
91
142
  const f = fixture();
92
143
  try {