nccgs 2.0.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 (82) 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 +9 -3
  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/compile-policy.mjs +4 -0
  31. package/.claude/nccgs/tools/configure-models.mjs +4 -0
  32. package/.claude/nccgs/tools/hook-transport.mjs +34 -0
  33. package/.claude/nccgs/tools/installation-guard.mjs +55 -0
  34. package/.claude/nccgs/tools/native-completion.mjs +10 -1
  35. package/.claude/nccgs/tools/returned-handoff.mjs +11 -2
  36. package/.claude/nccgs/tools/shared-context-guard.mjs +60 -0
  37. package/.claude/nccgs/tools/shared-context.mjs +25 -0
  38. package/.claude/nccgs/tools/tool-guard.mjs +120 -131
  39. package/.claude/nccgs/tools/unity-transport.mjs +170 -0
  40. package/.claude/nccgs/workflow-catalog.json +4 -4
  41. package/CHANGELOG.md +235 -165
  42. package/CLAUDE.md +10 -8
  43. package/MEMORY.md +109 -56
  44. package/README.md +206 -168
  45. package/UPGRADING.md +130 -112
  46. package/VERSION +1 -1
  47. package/docs/ARCHITECTURE.md +131 -131
  48. package/docs/ASSET-PRODUCTION-GRAPHIFY-PLAN.md +230 -0
  49. package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +117 -87
  50. package/docs/NCCGS-1.5.md +237 -237
  51. package/docs/PROJECT-POLICY.md +94 -94
  52. package/docs/RELEASE-STATUS.md +32 -7
  53. package/docs/UNINSTALL.md +134 -0
  54. package/docs/WFK17-HOOK-REPAIR.md +71 -0
  55. package/docs/WORKFLOWS.md +85 -85
  56. package/package.json +1 -1
  57. package/scaffold/.nccgs/project.yaml +109 -108
  58. package/scripts/benchmark-v15.mjs +173 -173
  59. package/scripts/cli.mjs +11 -2
  60. package/scripts/doctor.mjs +261 -261
  61. package/scripts/install.mjs +530 -515
  62. package/scripts/recover-session.mjs +89 -0
  63. package/scripts/run.mjs +22 -9
  64. package/scripts/supervise.mjs +4 -0
  65. package/scripts/uninstall.mjs +292 -0
  66. package/scripts/validate.mjs +285 -278
  67. package/tests/autonomy.test.mjs +126 -113
  68. package/tests/design-readiness.test.mjs +199 -199
  69. package/tests/fixtures/compiler-shutdown.cs +67 -64
  70. package/tests/fixtures/native-provider.mjs +61 -0
  71. package/tests/framework.test.mjs +740 -740
  72. package/tests/hook-repair.test.mjs +106 -0
  73. package/tests/lifecycle-v15.test.mjs +346 -300
  74. package/tests/native-completion-notices.test.mjs +181 -0
  75. package/tests/phase1.test.mjs +427 -416
  76. package/tests/phase2.test.mjs +250 -250
  77. package/tests/policy-v15.test.mjs +234 -234
  78. package/tests/returned-handoff-notices.test.mjs +170 -0
  79. package/tests/runtime-v15.test.mjs +1197 -1191
  80. package/tests/task-slices.test.mjs +285 -285
  81. package/tests/uninstall.test.mjs +477 -0
  82. package/tests/unity-transport.test.mjs +348 -0
@@ -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
@@ -1,9 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
- import { autonomyPolicy } from "../.claude/nccgs/tools/autonomy.mjs";
4
+ import { autonomyPolicy, conflictingLaunchOption, permissionModeArgs } 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";
8
+ import { unityCliMcpArgs, assertStrictUnityCliArgs } from "../.claude/nccgs/tools/unity-transport.mjs";
7
9
 
8
10
  // No shell construction, restrictive tool allowlist, or model override.
9
11
  export function launchPlan(argv, env = process.env) {
@@ -29,11 +31,11 @@ export function launchPlan(argv, env = process.env) {
29
31
  else supervision.budgetUsd = Number(value);
30
32
  }
31
33
  project = fs.realpathSync(project);
34
+ assertInstallationAvailable(project);
32
35
  if (!fs.existsSync(containedPath(project, ".claude/nccgs/install-state.json"))) throw new Error("Install/update NCCGS in the target project first");
33
36
  const mode = autonomyPolicy(project).mode;
34
37
  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
- const conflicting = /^(?:--permission-mode|--dangerously-skip-permissions|--allow-dangerously-skip-permissions|--settings|--setting-sources|--worktree|--agent)(?:=|$)|^-w$/;
36
- for (const argument of forwarded) if (conflicting.test(argument)) throw new Error(`NCCGS owns session permission/root settings; conflicting option: ${argument}`);
38
+ for (const argument of forwarded) if (conflictingLaunchOption.test(argument)) throw new Error(`NCCGS owns session permission/root/hook settings; conflicting option: ${argument}`);
37
39
  if (supervised) {
38
40
  if (supervision.recoverRun) {
39
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");
@@ -44,7 +46,7 @@ export function launchPlan(argv, env = process.env) {
44
46
  for (const argument of forwarded) if (owned.test(argument)) throw new Error(`Supervisor owns native lifecycle option: ${argument}`);
45
47
  }
46
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");
47
- return { project, binary, dryRun, mode, args: ["--permission-mode", mode === "full" ? "bypassPermissions" : "default", ...forwarded],
49
+ return { project, binary, dryRun, mode, args: unityCliMcpArgs(forwarded, project, permissionModeArgs(mode)),
48
50
  supervision: supervised ? supervision : null, env: { ...env, NCCGS_PROJECT_ROOT: project, NCCGS_TASK_AUTONOMY: mode,
49
51
  // Native auto-backgrounding can otherwise turn a claimed foreground worker
50
52
  // into async_launched mid-flight and invalidate its guarded write identity.
@@ -60,14 +62,25 @@ export async function run(argv) {
60
62
  return 0;
61
63
  }
62
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."}`);
63
- if (plan.supervision) return supervise(plan);
64
- const child = spawn(plan.binary, plan.args, { cwd: plan.project, env: plan.env, stdio: "inherit", windowsHide: true });
65
- const interrupt = () => child.kill("SIGINT");
66
- process.on("SIGINT", interrupt);
65
+ const lease = acquireInstallationLease(plan.project, "nccgs-run");
66
+ let child = null;
67
+ const interrupt = () => child?.kill("SIGINT");
67
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
+ assertStrictUnityCliArgs(plan.args, plan.project, permissionModeArgs(plan.mode));
77
+ lease.update({ phase: "spawning" });
78
+ child = spawn(plan.binary, plan.args, { cwd: plan.project, env: plan.env, stdio: "inherit", windowsHide: true });
79
+ child.once("spawn", () => lease.update({ phase: "running", child: { pid: child.pid, group: false } }));
80
+ process.on("SIGINT", interrupt);
68
81
  return await new Promise((resolve, reject) => {
69
82
  child.once("error", error => reject(new Error(`Cannot start Claude Code: ${error.message}. Use --claude with the native executable path.`)));
70
83
  child.once("close", (code, signal) => resolve(code ?? (signal ? 130 : 1)));
71
84
  });
72
- } finally { process.off("SIGINT", interrupt); }
85
+ } finally { process.off("SIGINT", interrupt); lease.release(); }
73
86
  }
@@ -10,6 +10,8 @@ import { acquireLock, delay, pidState } from "../.claude/nccgs/tools/process-loc
10
10
  import { sessionBudgetLock, sessionBudgetCoverage } from "../.claude/nccgs/tools/session-budget.mjs";
11
11
  import { allocateReviewBudget } from "../.claude/nccgs/tools/review-budget.mjs";
12
12
  import { settleWorkHandoff } from "./work-handoff.mjs";
13
+ import { assertStrictUnityCliArgs } from "../.claude/nccgs/tools/unity-transport.mjs";
14
+ import { permissionModeArgs } from "../.claude/nccgs/tools/autonomy.mjs";
13
15
 
14
16
  const need = (condition, message) => { if (!condition) throw new Error(message); };
15
17
  const now = () => new Date().toISOString();
@@ -46,6 +48,7 @@ export async function supervise(plan) {
46
48
  createdAt: now(), deadline: Date.now() + config.timeoutMs, maxResumes: config.maxResumes,
47
49
  budgetUsd: config.budgetUsd, cumulativeCostUsd: 0, costCoverage: "complete", invocations: [], status: "ready" };
48
50
  }
51
+ assertStrictUnityCliArgs(state.providerArgs, project, permissionModeArgs(state.permissionMode));
49
52
  const lock = acquireLock(contained(project, `.nccgs/runs/task-${state.taskId}.lock`), { runId: state.id });
50
53
  let budgetLock;
51
54
  const directory = path.dirname(file), save = () => atomicJson(file, state);
@@ -121,6 +124,7 @@ export async function supervise(plan) {
121
124
  ...(index === 0 ? ["--session-id", state.sessionId] : ["--resume", state.sessionId])];
122
125
  let deadlineTimer;
123
126
  try {
127
+ assertStrictUnityCliArgs(state.providerArgs, project, permissionModeArgs(state.permissionMode));
124
128
  child = spawn(state.binary, args, { cwd: project, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
125
129
  env: { ...plan.env, NCCGS_SUPERVISED_SESSION: state.sessionId, NCCGS_SUPERVISED_RUN: state.id, NCCGS_TASK_ID: state.taskId } });
126
130
  child.stdin.on("error", () => {}); // early native failure is captured in its terminal/exit evidence
@@ -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 { managedHookKey, referencesNccgsHook } from "../.claude/nccgs/tools/hook-transport.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(managedHookKey).filter(Boolean));
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(managedHookKey(hook))) { changed = true; return false; }
90
+ need(!referencesNccgsHook(hook),
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
+ }