nccgs 2.0.0 → 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.
- package/.claude/nccgs/MEMORY.md +7 -3
- package/.claude/nccgs/VERSION +1 -1
- package/.claude/nccgs/tools/compile-policy.mjs +4 -0
- package/.claude/nccgs/tools/configure-models.mjs +4 -0
- package/.claude/nccgs/tools/installation-guard.mjs +55 -0
- package/CHANGELOG.md +36 -1
- package/MEMORY.md +7 -3
- package/README.md +34 -11
- package/UPGRADING.md +11 -5
- package/VERSION +1 -1
- package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +38 -9
- package/docs/RELEASE-STATUS.md +24 -7
- package/docs/UNINSTALL.md +134 -0
- package/package.json +1 -1
- package/scripts/cli.mjs +11 -2
- package/scripts/install.mjs +14 -0
- package/scripts/recover-session.mjs +89 -0
- package/scripts/run.mjs +17 -5
- package/scripts/uninstall.mjs +292 -0
- package/scripts/validate.mjs +2 -0
- package/tests/uninstall.test.mjs +477 -0
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,6 +171,10 @@ 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]
|
package/scripts/install.mjs
CHANGED
|
@@ -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) {
|
|
@@ -29,6 +30,7 @@ export function launchPlan(argv, env = process.env) {
|
|
|
29
30
|
else supervision.budgetUsd = Number(value);
|
|
30
31
|
}
|
|
31
32
|
project = fs.realpathSync(project);
|
|
33
|
+
assertInstallationAvailable(project);
|
|
32
34
|
if (!fs.existsSync(containedPath(project, ".claude/nccgs/install-state.json"))) throw new Error("Install/update NCCGS in the target project first");
|
|
33
35
|
const mode = autonomyPolicy(project).mode;
|
|
34
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");
|
|
@@ -60,14 +62,24 @@ 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
|
-
|
|
64
|
-
|
|
65
|
-
const interrupt = () => child
|
|
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
|
+
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);
|
|
68
80
|
return await new Promise((resolve, reject) => {
|
|
69
81
|
child.once("error", error => reject(new Error(`Cannot start Claude Code: ${error.message}. Use --claude with the native executable path.`)));
|
|
70
82
|
child.once("close", (code, signal) => resolve(code ?? (signal ? 130 : 1)));
|
|
71
83
|
});
|
|
72
|
-
} finally { process.off("SIGINT", interrupt); }
|
|
84
|
+
} finally { process.off("SIGINT", interrupt); lease.release(); }
|
|
73
85
|
}
|
|
@@ -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
|
+
}
|
package/scripts/validate.mjs
CHANGED
|
@@ -66,6 +66,8 @@ for (const relative of [
|
|
|
66
66
|
"scaffold/.nccgs/templates/closure-record.md", "package-assets/setup-vivox-voice-chat-evals.gitignore",
|
|
67
67
|
"package-assets/nccgs-project.gitignore",
|
|
68
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",
|
|
69
71
|
"scripts/sync-unity-skills.mjs", "tests/framework.test.mjs",
|
|
70
72
|
".claude/nccgs/tools/integrity.mjs", ".claude/nccgs/tools/approve-overrides.mjs"
|
|
71
73
|
]) requireFile(relative);
|