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
@@ -1,515 +1,530 @@
1
- import { spawnSync } from "node:child_process";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import {
6
- equivalentFiles,
7
- fingerprintFile,
8
- matchesFingerprint,
9
- sameFingerprint
10
- } from "../.claude/nccgs/tools/integrity.mjs";
11
- import { assertProjectRoot, assertRelativePath, containedPath } from "../.claude/nccgs/tools/paths.mjs";
12
- import { executionPolicy } from "../.claude/nccgs/tools/policy.mjs";
13
- import { autonomyPolicy, canonicalHookCommand } from "../.claude/nccgs/tools/autonomy.mjs";
14
-
15
- const scriptDir = path.dirname(fileURLToPath(import.meta.url));
16
- const root = path.resolve(scriptDir, "..");
17
- const args = process.argv.slice(2);
18
- const projectIndex = args.indexOf("--project");
19
- const dryRun = args.includes("--dry-run");
20
- const force = args.includes("--force");
21
- const updateOnly = args.includes("--update-only");
22
- const allowNonUnity = args.includes("--allow-non-unity");
23
- const modelProfileIndex = args.indexOf("--model-profile");
24
- const requestedModelProfile = modelProfileIndex >= 0 ? args[modelProfileIndex + 1] : null;
25
- const autonomyIndex = args.indexOf("--autonomy");
26
- const requestedAutonomy = autonomyIndex >= 0 ? args[autonomyIndex + 1] : null;
27
- if (autonomyIndex >= 0 && !["full", "guarded"].includes(requestedAutonomy)) {
28
- console.error("--autonomy requires full or guarded"); process.exit(2);
29
- }
30
-
31
- if (projectIndex >= 0 && !args[projectIndex + 1]) {
32
- console.error("--project requires a directory path.");
33
- process.exit(2);
34
- }
35
-
36
- const project = projectIndex >= 0 ? path.resolve(args[projectIndex + 1]) : process.cwd();
37
- try { assertProjectRoot(project); }
38
- catch (error) {
39
- console.error(`Unsafe installation target: ${error.message}`);
40
- process.exit(2);
41
- }
42
- if (project === root) {
43
- console.error("The NCCGS package directory cannot be its own installation target.");
44
- process.exit(2);
45
- }
46
-
47
- const unityMarkers = ["Assets", "Packages", "ProjectSettings"];
48
- const missingMarkers = unityMarkers.filter((item) => !fs.existsSync(projectPath(item)));
49
- if (missingMarkers.length && !allowNonUnity) {
50
- console.error(`Target does not look like a Unity project; missing: ${missingMarkers.join(", ")}`);
51
- console.error("Use --allow-non-unity only when intentionally scaffolding before Unity project creation.");
52
- process.exit(2);
53
- }
54
-
55
- const validation = spawnSync(process.execPath, [path.join(scriptDir, "validate.mjs")], {
56
- cwd: root,
57
- encoding: "utf8",
58
- });
59
- if (validation.status !== 0) {
60
- process.stderr.write(validation.stdout ?? "");
61
- process.stderr.write(validation.stderr ?? "");
62
- console.error("NCCGS package validation failed; installation aborted.");
63
- process.exit(1);
64
- }
65
-
66
- const frameworkSource = path.join(root, ".claude");
67
- const frameworkFiles = walk(frameworkSource).map((file) => ({
68
- source: file,
69
- relative: posix(path.relative(root, file)),
70
- destination: projectPath(posix(path.relative(root, file))),
71
- }));
72
- const npmExcludedUnityFile = ".claude/skills/setup-vivox-voice-chat/evals/.gitignore";
73
- if (!frameworkFiles.some((item) => item.relative === npmExcludedUnityFile)) {
74
- const fallback = path.join(root, "package-assets", "setup-vivox-voice-chat-evals.gitignore");
75
- if (fs.existsSync(fallback)) {
76
- frameworkFiles.push({
77
- source: fallback,
78
- relative: npmExcludedUnityFile,
79
- destination: projectPath(npmExcludedUnityFile)
80
- });
81
- frameworkFiles.sort((a, b) => a.relative.localeCompare(b.relative));
82
- }
83
- }
84
-
85
- const installStatePath = projectPath(".claude/nccgs/install-state.json");
86
- const overrideManifestPath = projectPath(".nccgs/framework-overrides.json");
87
- // Check shared/scaffold/configurator destinations before any framework mutation,
88
- // even when a pre-existing file would otherwise be preserved or skipped.
89
- for (const relative of ["CLAUDE.md", ".claude/settings.json", ".nccgs/project.yaml", ".nccgs/execution.json", ".nccgs/backups", ".nccgs/agent-activation.json"]) projectPath(relative);
90
- const previousInstallState = readJsonIfPresent(installStatePath, "NCCGS install state");
91
- const previousActivation = readJsonIfPresent(projectPath(".nccgs/agent-activation.json"), "NCCGS agent activation state");
92
- const executionFile = projectPath(".nccgs/execution.json");
93
- const previousExecution = readJsonIfPresent(executionFile, "NCCGS execution policy");
94
- const existingProjectPolicy = fs.existsSync(projectPath(".nccgs/project.yaml"));
95
- const existingInstallation = Boolean(previousInstallState || existingProjectPolicy);
96
- const autonomyFile = projectPath(".nccgs/autonomy.json");
97
- let previousAutonomy;
98
- try { previousAutonomy = autonomyPolicy(project); }
99
- catch (error) { console.error(error.message); process.exit(2); }
100
- const autonomyMode = requestedAutonomy || (existingInstallation ? previousAutonomy.mode : "full");
101
- try { if (previousExecution) executionPolicy(previousExecution); }
102
- catch (error) { console.error(`Invalid execution policy: ${error.message}`); process.exit(2); }
103
- const previousOverrideManifest = readJsonIfPresent(overrideManifestPath, "NCCGS framework override manifest");
104
- if (updateOnly && !previousInstallState) {
105
- console.error("NCCGS is not installed in this project. Run `npx nccgs@latest install` first.");
106
- process.exit(2);
107
- }
108
- if (previousOverrideManifest && previousOverrideManifest.schemaVersion !== 1) {
109
- console.error(`Unsupported framework override schema: ${previousOverrideManifest.schemaVersion ?? "unknown"}`);
110
- process.exit(2);
111
- }
112
- for (const item of previousInstallState?.files ?? []) {
113
- if (!isManagedRelative(item.relative)) {
114
- console.error(`Unsafe managed path in install state: ${item.relative ?? "missing"}`);
115
- process.exit(2);
116
- }
117
- projectPath(item.relative);
118
- }
119
- for (const item of previousOverrideManifest?.overrides ?? []) {
120
- if (!isManagedRelative(item.path)) {
121
- console.error(`Unsafe path in framework override manifest: ${item.path ?? "missing"}`);
122
- process.exit(2);
123
- }
124
- projectPath(item.path);
125
- }
126
-
127
- const previousFiles = new Map((previousInstallState?.files ?? []).map((item) => [item.relative, item]));
128
- const previousOverrides = new Map((previousOverrideManifest?.overrides ?? []).map((item) => [item.path, item]));
129
- const currentFrameworkNames = new Set(frameworkFiles.map((item) => item.relative));
130
- const currentVersion = fs.readFileSync(path.join(root, "VERSION"), "utf8").trim();
131
-
132
- const filePlans = frameworkFiles.map((item) => planFrameworkFile(item));
133
- const stalePlans = (previousInstallState?.files ?? [])
134
- .filter((item) => !currentFrameworkNames.has(item.relative))
135
- .map((item) => planStaleFile(item))
136
- .filter(Boolean);
137
-
138
- const activeOverridePaths = new Set([
139
- ...filePlans.filter((item) => item.override).map((item) => item.relative),
140
- ...stalePlans.filter((item) => item.override).map((item) => item.relative)
141
- ]);
142
- const carriedOverrides = [];
143
- for (const item of previousOverrides.values()) {
144
- if (activeOverridePaths.has(item.path) || currentFrameworkNames.has(item.path) || previousFiles.has(item.path)) continue;
145
- const destination = projectPath(item.path);
146
- const absent = item.disposition === "absent";
147
- const stillMatches = absent ? !fs.existsSync(destination) : matchesFingerprint(destination, item);
148
- carriedOverrides.push({
149
- ...item,
150
- status: item.status === "approved" && stillMatches ? "approved" : "pending_review",
151
- reason: stillMatches ? item.reason : "Previously approved override changed and requires migration review."
152
- });
153
- }
154
-
155
- const overrideRecords = [
156
- ...filePlans.filter((item) => item.override).map((item) => item.override),
157
- ...stalePlans.filter((item) => item.override).map((item) => item.override),
158
- ...carriedOverrides
159
- ].sort((a, b) => a.path.localeCompare(b.path));
160
- const pendingOverrides = overrideRecords.filter((item) => item.status !== "approved");
161
-
162
- const newFiles = filePlans.filter((item) => item.action === "install");
163
- const currentFiles = filePlans.filter((item) => item.action === "current");
164
- const automaticReplacements = filePlans.filter((item) => item.action === "replace");
165
- const forcedReplacements = filePlans.filter((item) => item.action === "force-replace");
166
- const preservedFiles = filePlans.filter((item) => item.action.startsWith("preserve"));
167
- const automaticStale = stalePlans.filter((item) => item.action === "remove");
168
- const forcedStale = stalePlans.filter((item) => item.action === "force-remove");
169
- const preservedStale = stalePlans.filter((item) => item.action === "preserve-stale");
170
-
171
- const scaffoldFiles = walk(path.join(root, "scaffold")).map((file) => ({
172
- source: file,
173
- relative: posix(path.relative(path.join(root, "scaffold"), file)),
174
- destination: projectPath(posix(path.relative(path.join(root, "scaffold"), file))),
175
- }));
176
- const npmExcludedScaffoldFile = ".nccgs/.gitignore";
177
- if (!scaffoldFiles.some((item) => posix(item.relative) === npmExcludedScaffoldFile)) {
178
- const fallback = path.join(root, "package-assets", "nccgs-project.gitignore");
179
- if (fs.existsSync(fallback)) {
180
- scaffoldFiles.push({
181
- source: fallback,
182
- relative: npmExcludedScaffoldFile,
183
- destination: projectPath(npmExcludedScaffoldFile)
184
- });
185
- scaffoldFiles.sort((a, b) => a.relative.localeCompare(b.relative));
186
- }
187
- }
188
- // An update must not introduce standalone policy into an existing legacy project.
189
- const newScaffold = scaffoldFiles.filter((item) => !fs.existsSync(item.destination)
190
- && item.relative !== ".nccgs/autonomy.json"
191
- && !(existingInstallation && item.relative === ".nccgs/execution.json"));
192
- const settingsPlan = planManagedSettings(project);
193
- const targetPolicyPath = projectPath(".nccgs/project.yaml");
194
- const targetPolicy = fs.existsSync(targetPolicyPath) ? fs.readFileSync(targetPolicyPath, "utf8") : "";
195
- const policyModelProfile = targetPolicy.match(/^models:\s*$[\s\S]*?^\s+profile:\s*([^#\r\n]+)/m)?.[1]?.trim().replace(/^['"]|['"]$/g, "");
196
- const studio = JSON.parse(fs.readFileSync(path.join(root, ".claude/nccgs/studio.json"), "utf8"));
197
- for (const agent of studio.agents) projectPath(`.claude/agents/${agent.name}.md`);
198
- const modelProfile = requestedModelProfile || policyModelProfile || previousInstallState?.modelProfile || (existingInstallation ? "astra-claude" : studio.defaultProfile);
199
- if (!Object.hasOwn(studio.modelProfiles, modelProfile)) {
200
- console.error(`Unknown model profile: ${modelProfile}`);
201
- process.exit(2);
202
- }
203
-
204
- console.log(`NCCGS installation target: ${project}`);
205
- console.log(`Framework version: ${previousInstallState?.frameworkVersion ?? "not installed"} -> ${currentVersion}`);
206
- console.log(`Framework files: ${frameworkFiles.length} (${newFiles.length} new, ${currentFiles.length} current, ${automaticReplacements.length} automatic update, ${preservedFiles.length} preserved override, ${forcedReplacements.length} forced override)`);
207
- console.log(`Stale framework files: ${automaticStale.length} automatic removal, ${preservedStale.length} preserved override, ${forcedStale.length} forced removal`);
208
- console.log(`Project scaffold files to initialize: ${newScaffold.length}`);
209
- console.log(`Managed Claude settings: ${settingsPlan.changed ? "update" : "already current"}`);
210
- console.log(`Task autonomy: ${autonomyMode}; activate native permissions with nccgs run. OS and managed restrictions still apply.`);
211
- console.log(`Agent model profile: ${modelProfile}`);
212
- console.log(`Execution policy: ${previousExecution?.name ?? (existingInstallation ? "compatibility (missing execution.json preserved)" : "standalone")}; external review: ${previousExecution?.externalReview?.mode ?? (existingInstallation ? "required" : "off")}`);
213
- if (pendingOverrides.length) {
214
- console.log(`${pendingOverrides.length} project override(s) will be preserved for /migrate-project review:`);
215
- for (const item of pendingOverrides.slice(0, 30)) console.log(`- ${item.path} (${item.disposition})`);
216
- }
217
- if (dryRun) {
218
- console.log("Dry run complete; no files changed.");
219
- process.exit(0);
220
- }
221
-
222
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
223
- const backupRoot = projectPath(`.nccgs/backups/${stamp}`);
224
- const replacedFiles = [...automaticReplacements, ...forcedReplacements];
225
- const removedFiles = [...automaticStale, ...forcedStale];
226
- for (const item of replacedFiles) backupFile(item.destination, path.join(backupRoot, ...item.relative.split("/")));
227
- for (const item of removedFiles) {
228
- backupFile(item.destination, path.join(backupRoot, ...item.relative.split("/")));
229
- fs.unlinkSync(recheckTarget(item.destination));
230
- }
231
- if (previousOverrideManifest && JSON.stringify(previousOverrideManifest.overrides ?? []) !== JSON.stringify(overrideRecords)) {
232
- backupFile(overrideManifestPath, path.join(backupRoot, ".nccgs", "framework-overrides.json"));
233
- }
234
-
235
- for (const item of filePlans.filter((entry) => new Set(["install", "replace", "force-replace"]).has(entry.action))) {
236
- recheckTarget(item.destination);
237
- fs.mkdirSync(path.dirname(item.destination), { recursive: true });
238
- fs.copyFileSync(item.source, recheckTarget(item.destination));
239
- }
240
- for (const item of newScaffold) {
241
- recheckTarget(item.destination);
242
- fs.mkdirSync(path.dirname(item.destination), { recursive: true });
243
- fs.copyFileSync(item.source, recheckTarget(item.destination));
244
- }
245
-
246
- installManagedSettings(settingsPlan, backupRoot);
247
- const autonomyText = `${JSON.stringify({ schemaVersion: 1, mode: autonomyMode }, null, 2)}\n`;
248
- if (!fs.existsSync(autonomyFile) || fs.readFileSync(autonomyFile, "utf8") !== autonomyText) {
249
- if (fs.existsSync(autonomyFile)) backupFile(autonomyFile, path.join(backupRoot, ".nccgs/autonomy.json"));
250
- fs.mkdirSync(path.dirname(autonomyFile), { recursive: true });
251
- fs.writeFileSync(recheckTarget(autonomyFile), autonomyText);
252
- }
253
- installClaudeImport(project, backupRoot);
254
-
255
- const skipAgentPaths = preservedFiles
256
- .filter((item) => item.relative.startsWith(".claude/agents/"))
257
- .flatMap((item) => ["--skip-path", item.relative]);
258
- const modelConfiguration = spawnSync(process.execPath, [
259
- path.join(root, ".claude", "nccgs", "tools", "configure-models.mjs"),
260
- "--project", project,
261
- "--profile", modelProfile,
262
- ...skipAgentPaths
263
- ], { cwd: project, encoding: "utf8" });
264
- if (modelConfiguration.status !== 0) {
265
- process.stderr.write(modelConfiguration.stdout ?? "");
266
- process.stderr.write(modelConfiguration.stderr ?? "");
267
- console.error("NCCGS was copied, but agent model configuration failed.");
268
- process.exit(1);
269
- }
270
-
271
- writeOverrideManifest(overrideManifestPath, overrideRecords, previousOverrideManifest, currentVersion);
272
- writeInstallState(project, modelProfile, frameworkFiles, installStatePath, previousInstallState, new Set(overrideRecords.map((item) => item.path)));
273
-
274
- const doctor = spawnSync(process.execPath, [
275
- path.join(scriptDir, "doctor.mjs"),
276
- "--project", project,
277
- "--allow-migration-required"
278
- ], { cwd: project, encoding: "utf8" });
279
- process.stdout.write(doctor.stdout ?? "");
280
- process.stderr.write(doctor.stderr ?? "");
281
- if (doctor.status !== 0) {
282
- console.error("NCCGS files were installed, but post-install verification failed.");
283
- process.exit(1);
284
- }
285
-
286
- console.log("NCCGS installation complete.");
287
- if (fs.existsSync(backupRoot)) {
288
- console.log(`Replaced shared files were backed up under: ${backupRoot}`);
289
- }
290
- const installedPolicy = fs.readFileSync(targetPolicyPath, "utf8");
291
- const installedPolicySchema = Number(installedPolicy.match(/^schema_version:\s*(\d+)\s*$/m)?.[1] ?? 0);
292
- if (installedPolicySchema !== 2 || pendingOverrides.length || /^profile_contracts:/m.test(installedPolicy)) {
293
- console.log("Project migration required: open a new Claude Code session and run `/migrate-project` once.");
294
- } else if (!previousInstallState) {
295
- console.log("First NCCGS adoption: run `/migrate-project` once for an existing project; a new project can start with `/status`.");
296
- } else {
297
- console.log("No project migration required for this NCCGS update.");
298
- }
299
-
300
- function planFrameworkFile(item) {
301
- const previous = previousFiles.get(item.relative);
302
- const existingOverride = previousOverrides.get(item.relative);
303
- const upstream = fingerprintFile(item.source);
304
- if (!fs.existsSync(item.destination)) {
305
- if (!force && (previous || existingOverride?.disposition === "absent")) {
306
- return { ...item, action: "preserve-absent", override: buildOverride(item, existingOverride, upstream, "absent") };
307
- }
308
- return { ...item, action: "install", override: null };
309
- }
310
- if (equivalentFiles(item.source, item.destination)) return { ...item, action: "current", override: null };
311
- if (!force && previous && matchesFingerprint(item.destination, previous)) return { ...item, action: "replace", override: null };
312
- // Older profiles could generate optional agents outside install-state.files.
313
- // When a later package starts managing one, its recorded generated bytes are
314
- // a valid baseline. Real local edits and explicit overrides still need review.
315
- const generated = previousActivation?.schemaVersion === 1 && item.relative.startsWith(".claude/agents/")
316
- ? { sha256: previousActivation.generated?.[item.relative], canonicalSha256: previousActivation.generatedCanonical?.[item.relative] }
317
- : null;
318
- if (!force && previousInstallState && !previous && !existingOverride && matchesFingerprint(item.destination, generated)) {
319
- return { ...item, action: "replace", override: null };
320
- }
321
- if (force) return { ...item, action: "force-replace", override: null };
322
- return { ...item, action: "preserve", override: buildOverride(item, existingOverride, upstream, "preserve") };
323
- }
324
-
325
- function planStaleFile(previous) {
326
- const destination = projectPath(previous.relative);
327
- if (!fs.existsSync(destination)) return null;
328
- if (force) return { ...previous, relative: previous.relative, destination, action: "force-remove", override: null };
329
- if (matchesFingerprint(destination, previous)) return { ...previous, relative: previous.relative, destination, action: "remove", override: null };
330
- const existingOverride = previousOverrides.get(previous.relative);
331
- const current = fingerprintFile(destination);
332
- const stillApproved = existingOverride?.status === "approved" && matchesFingerprint(destination, existingOverride)
333
- && existingOverride.upstreamSha256 === null;
334
- return {
335
- ...previous,
336
- relative: previous.relative,
337
- destination,
338
- action: "preserve-stale",
339
- override: {
340
- path: previous.relative,
341
- disposition: "preserve",
342
- status: stillApproved ? "approved" : "pending_review",
343
- ...current,
344
- upstreamSha256: null,
345
- upstreamCanonicalSha256: null,
346
- retiredFromFramework: true,
347
- reason: "Locally modified file was preserved after its framework path retired; review ownership during migration."
348
- }
349
- };
350
- }
351
-
352
- function buildOverride(item, existing, upstream, disposition) {
353
- const current = disposition === "absent" ? { sha256: null, canonicalSha256: null } : fingerprintFile(item.destination);
354
- const currentMatches = disposition === "absent"
355
- ? existing?.disposition === "absent" && !fs.existsSync(item.destination)
356
- : existing?.disposition === "preserve" && sameFingerprint(current, existing);
357
- const previousUpstream = existing ? {
358
- sha256: existing.upstreamSha256,
359
- canonicalSha256: existing.upstreamCanonicalSha256
360
- } : null;
361
- const upstreamMatches = previousUpstream && sameFingerprint(upstream, previousUpstream);
362
- const approved = existing?.status === "approved" && currentMatches && upstreamMatches;
363
- return {
364
- path: item.relative,
365
- disposition,
366
- status: approved ? "approved" : "pending_review",
367
- ...current,
368
- upstreamSha256: upstream.sha256,
369
- upstreamCanonicalSha256: upstream.canonicalSha256 ?? null,
370
- reason: approved
371
- ? existing.reason
372
- : "Project-specific framework change was preserved automatically; review and rebase it with /migrate-project."
373
- };
374
- }
375
-
376
- function walk(directory) {
377
- if (!fs.existsSync(directory)) return [];
378
- const output = [];
379
- for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
380
- const full = path.join(directory, entry.name);
381
- if (entry.isDirectory()) output.push(...walk(full));
382
- else if (entry.isFile()) output.push(full);
383
- }
384
- return output.sort((a, b) => a.localeCompare(b));
385
- }
386
-
387
- function posix(value) {
388
- return value.split(path.sep).join("/");
389
- }
390
-
391
- function isManagedRelative(value) {
392
- try { return assertRelativePath(value).startsWith(".claude/"); }
393
- catch { return false; }
394
- }
395
-
396
- function projectPath(relative) {
397
- try { return containedPath(project, relative); }
398
- catch (error) {
399
- console.error(`Unsafe NCCGS target: ${error.message}`);
400
- process.exit(2);
401
- }
402
- }
403
-
404
- function recheckTarget(file) {
405
- return projectPath(posix(path.relative(project, file)));
406
- }
407
-
408
- function readJsonIfPresent(file, label) {
409
- if (!fs.existsSync(file)) return null;
410
- try { return JSON.parse(fs.readFileSync(file, "utf8")); }
411
- catch (error) {
412
- console.error(`Invalid ${label} at ${file}: ${error.message}`);
413
- process.exit(2);
414
- }
415
- }
416
-
417
- function backupFile(source, destination) {
418
- recheckTarget(source);
419
- recheckTarget(destination);
420
- fs.mkdirSync(path.dirname(destination), { recursive: true });
421
- fs.copyFileSync(recheckTarget(source), recheckTarget(destination));
422
- }
423
-
424
- function planManagedSettings(projectRoot) {
425
- const settingsPath = projectPath(".claude/settings.json");
426
- let current = {};
427
- if (fs.existsSync(settingsPath)) {
428
- try { current = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
429
- catch (error) {
430
- console.error(`Cannot merge invalid JSON settings at ${settingsPath}: ${error.message}`);
431
- process.exit(2);
432
- }
433
- }
434
- const fragment = JSON.parse(fs.readFileSync(path.join(root, ".claude", "nccgs", "settings.fragment.json"), "utf8"));
435
- const managedCommands = new Set(Object.values(fragment.hooks ?? {}).flatMap(groups => groups.flatMap(group => group.hooks ?? [])).map(hook => canonicalHookCommand(hook.command)));
436
- const merged = structuredClone(current);
437
- merged.permissions ??= {};
438
- for (const key of ["allow", "deny", "ask"]) {
439
- const existing = Array.isArray(merged.permissions[key]) ? merged.permissions[key] : [];
440
- const additions = Array.isArray(fragment.permissions?.[key]) ? fragment.permissions[key] : [];
441
- if (existing.length || additions.length) merged.permissions[key] = [...new Set([...existing, ...additions])];
442
- }
443
- merged.hooks ??= {};
444
- for (const [event, groups] of Object.entries(fragment.hooks ?? {})) {
445
- const existing = Array.isArray(merged.hooks[event]) ? merged.hooks[event] : [];
446
- // A user's command may share a group with a managed command. Remove only
447
- // the managed hook, preserving the group metadata and every custom hook.
448
- const retained = existing.flatMap((group) => {
449
- if (!Array.isArray(group.hooks)) return [group];
450
- const hooks = group.hooks.filter((hook) => !managedCommands.has(canonicalHookCommand(hook.command)));
451
- return hooks.length ? [{ ...group, hooks }] : [];
452
- });
453
- merged.hooks[event] = [...retained, ...groups];
454
- }
455
- const nextText = `${JSON.stringify(merged, null, 2)}\n`;
456
- const currentText = fs.existsSync(settingsPath) ? fs.readFileSync(settingsPath, "utf8") : "";
457
- return { settingsPath, existed: fs.existsSync(settingsPath), nextText, changed: currentText !== nextText };
458
- }
459
-
460
- function installManagedSettings(plan, backupDirectory) {
461
- if (!plan.changed) return;
462
- if (plan.existed) backupFile(plan.settingsPath, path.join(backupDirectory, ".claude", "settings.json"));
463
- recheckTarget(plan.settingsPath);
464
- fs.mkdirSync(path.dirname(plan.settingsPath), { recursive: true });
465
- fs.writeFileSync(recheckTarget(plan.settingsPath), plan.nextText, "utf8");
466
- }
467
-
468
- function writeOverrideManifest(file, overrides, previous, frameworkVersion) {
469
- if (!overrides.length && !previous) return;
470
- const now = new Date().toISOString();
471
- const manifest = {
472
- schemaVersion: 1,
473
- frameworkVersion,
474
- createdAt: previous?.createdAt ?? now,
475
- updatedAt: now,
476
- overrides
477
- };
478
- recheckTarget(file);
479
- fs.mkdirSync(path.dirname(file), { recursive: true });
480
- fs.writeFileSync(recheckTarget(file), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
481
- }
482
-
483
- function writeInstallState(projectRoot, profile, files, statePath, previousState, overridePaths) {
484
- const now = new Date().toISOString();
485
- const state = {
486
- schemaVersion: 3,
487
- frameworkVersion: currentVersion,
488
- previousFrameworkVersion: previousState?.frameworkVersion ?? null,
489
- modelProfile: profile,
490
- installedAt: previousState?.installedAt ?? now,
491
- updatedAt: now,
492
- files: files.map((item) => ({
493
- relative: item.relative,
494
- ...(overridePaths.has(item.relative) ? fingerprintFile(item.source) : fingerprintFile(projectPath(item.relative)))
495
- }))
496
- };
497
- recheckTarget(statePath);
498
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
499
- fs.writeFileSync(recheckTarget(statePath), `${JSON.stringify(state, null, 2)}\n`, "utf8");
500
- }
501
-
502
- function installClaudeImport(projectRoot, backupDirectory) {
503
- const claudePath = projectPath("CLAUDE.md");
504
- const begin = "<!-- NCCGS:BEGIN -->";
505
- const block = `${begin}\n@.claude/nccgs/constitution.md\n<!-- NCCGS:END -->`;
506
- if (!fs.existsSync(claudePath)) {
507
- fs.copyFileSync(path.join(root, "CLAUDE.md"), recheckTarget(claudePath));
508
- return;
509
- }
510
- const current = fs.readFileSync(claudePath, "utf8");
511
- if (current.includes(begin) || current.includes("@.claude/nccgs/constitution.md")) return;
512
- backupFile(claudePath, path.join(backupDirectory, "CLAUDE.md"));
513
- const separator = current.endsWith("\n") ? "\n" : "\n\n";
514
- fs.writeFileSync(recheckTarget(claudePath), `${current}${separator}${block}\n`, "utf8");
515
- }
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ equivalentFiles,
7
+ fingerprintFile,
8
+ matchesFingerprint,
9
+ sameFingerprint
10
+ } from "../.claude/nccgs/tools/integrity.mjs";
11
+ import { assertProjectRoot, assertRelativePath, containedPath } from "../.claude/nccgs/tools/paths.mjs";
12
+ import { executionPolicy } from "../.claude/nccgs/tools/policy.mjs";
13
+ import { autonomyPolicy } from "../.claude/nccgs/tools/autonomy.mjs";
14
+ import { managedHookKey } from "../.claude/nccgs/tools/hook-transport.mjs";
15
+ import { acquireInstallationLease, assertInstallationAvailable } from "../.claude/nccgs/tools/installation-guard.mjs";
16
+
17
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
18
+ const root = path.resolve(scriptDir, "..");
19
+ const args = process.argv.slice(2);
20
+ const projectIndex = args.indexOf("--project");
21
+ const dryRun = args.includes("--dry-run");
22
+ const force = args.includes("--force");
23
+ const updateOnly = args.includes("--update-only");
24
+ const allowNonUnity = args.includes("--allow-non-unity");
25
+ const modelProfileIndex = args.indexOf("--model-profile");
26
+ const requestedModelProfile = modelProfileIndex >= 0 ? args[modelProfileIndex + 1] : null;
27
+ const autonomyIndex = args.indexOf("--autonomy");
28
+ const requestedAutonomy = autonomyIndex >= 0 ? args[autonomyIndex + 1] : null;
29
+ if (autonomyIndex >= 0 && !["full", "guarded"].includes(requestedAutonomy)) {
30
+ console.error("--autonomy requires full or guarded"); process.exit(2);
31
+ }
32
+
33
+ if (projectIndex >= 0 && !args[projectIndex + 1]) {
34
+ console.error("--project requires a directory path.");
35
+ process.exit(2);
36
+ }
37
+
38
+ const project = projectIndex >= 0 ? path.resolve(args[projectIndex + 1]) : process.cwd();
39
+ try { assertProjectRoot(project); }
40
+ catch (error) {
41
+ console.error(`Unsafe installation target: ${error.message}`);
42
+ process.exit(2);
43
+ }
44
+ if (project === root) {
45
+ console.error("The NCCGS package directory cannot be its own installation target.");
46
+ process.exit(2);
47
+ }
48
+ try { assertInstallationAvailable(project); }
49
+ catch (error) { console.error(error.message); process.exit(2); }
50
+
51
+ const unityMarkers = ["Assets", "Packages", "ProjectSettings"];
52
+ const missingMarkers = unityMarkers.filter((item) => !fs.existsSync(projectPath(item)));
53
+ if (missingMarkers.length && !allowNonUnity) {
54
+ console.error(`Target does not look like a Unity project; missing: ${missingMarkers.join(", ")}`);
55
+ console.error("Use --allow-non-unity only when intentionally scaffolding before Unity project creation.");
56
+ process.exit(2);
57
+ }
58
+
59
+ const validation = spawnSync(process.execPath, [path.join(scriptDir, "validate.mjs")], {
60
+ cwd: root,
61
+ encoding: "utf8",
62
+ });
63
+ if (validation.status !== 0) {
64
+ process.stderr.write(validation.stdout ?? "");
65
+ process.stderr.write(validation.stderr ?? "");
66
+ console.error("NCCGS package validation failed; installation aborted.");
67
+ process.exit(1);
68
+ }
69
+
70
+ // Register before reading the installation to keep a concurrent uninstall from
71
+ // invalidating the plan. The second guard closes the acquisition race. Dry-run
72
+ // remains read-only; the exit handler covers all existing early exit paths.
73
+ if (!dryRun) {
74
+ try {
75
+ const lease = acquireInstallationLease(project, "nccgs-install");
76
+ process.once("exit", () => lease.release());
77
+ assertInstallationAvailable(project);
78
+ } catch (error) { console.error(error.message); process.exit(2); }
79
+ }
80
+
81
+ const frameworkSource = path.join(root, ".claude");
82
+ const frameworkFiles = walk(frameworkSource).map((file) => ({
83
+ source: file,
84
+ relative: posix(path.relative(root, file)),
85
+ destination: projectPath(posix(path.relative(root, file))),
86
+ }));
87
+ const npmExcludedUnityFile = ".claude/skills/setup-vivox-voice-chat/evals/.gitignore";
88
+ if (!frameworkFiles.some((item) => item.relative === npmExcludedUnityFile)) {
89
+ const fallback = path.join(root, "package-assets", "setup-vivox-voice-chat-evals.gitignore");
90
+ if (fs.existsSync(fallback)) {
91
+ frameworkFiles.push({
92
+ source: fallback,
93
+ relative: npmExcludedUnityFile,
94
+ destination: projectPath(npmExcludedUnityFile)
95
+ });
96
+ frameworkFiles.sort((a, b) => a.relative.localeCompare(b.relative));
97
+ }
98
+ }
99
+
100
+ const installStatePath = projectPath(".claude/nccgs/install-state.json");
101
+ const overrideManifestPath = projectPath(".nccgs/framework-overrides.json");
102
+ // Check shared/scaffold/configurator destinations before any framework mutation,
103
+ // even when a pre-existing file would otherwise be preserved or skipped.
104
+ for (const relative of ["CLAUDE.md", ".claude/settings.json", ".nccgs/project.yaml", ".nccgs/execution.json", ".nccgs/backups", ".nccgs/agent-activation.json"]) projectPath(relative);
105
+ const previousInstallState = readJsonIfPresent(installStatePath, "NCCGS install state");
106
+ const previousActivation = readJsonIfPresent(projectPath(".nccgs/agent-activation.json"), "NCCGS agent activation state");
107
+ const executionFile = projectPath(".nccgs/execution.json");
108
+ const previousExecution = readJsonIfPresent(executionFile, "NCCGS execution policy");
109
+ const existingProjectPolicy = fs.existsSync(projectPath(".nccgs/project.yaml"));
110
+ const existingInstallation = Boolean(previousInstallState || existingProjectPolicy);
111
+ const autonomyFile = projectPath(".nccgs/autonomy.json");
112
+ let previousAutonomy;
113
+ try { previousAutonomy = autonomyPolicy(project); }
114
+ catch (error) { console.error(error.message); process.exit(2); }
115
+ const autonomyMode = requestedAutonomy || (existingInstallation ? previousAutonomy.mode : "full");
116
+ try { if (previousExecution) executionPolicy(previousExecution); }
117
+ catch (error) { console.error(`Invalid execution policy: ${error.message}`); process.exit(2); }
118
+ const previousOverrideManifest = readJsonIfPresent(overrideManifestPath, "NCCGS framework override manifest");
119
+ if (updateOnly && !previousInstallState) {
120
+ console.error("NCCGS is not installed in this project. Run `npx nccgs@latest install` first.");
121
+ process.exit(2);
122
+ }
123
+ if (previousOverrideManifest && previousOverrideManifest.schemaVersion !== 1) {
124
+ console.error(`Unsupported framework override schema: ${previousOverrideManifest.schemaVersion ?? "unknown"}`);
125
+ process.exit(2);
126
+ }
127
+ for (const item of previousInstallState?.files ?? []) {
128
+ if (!isManagedRelative(item.relative)) {
129
+ console.error(`Unsafe managed path in install state: ${item.relative ?? "missing"}`);
130
+ process.exit(2);
131
+ }
132
+ projectPath(item.relative);
133
+ }
134
+ for (const item of previousOverrideManifest?.overrides ?? []) {
135
+ if (!isManagedRelative(item.path)) {
136
+ console.error(`Unsafe path in framework override manifest: ${item.path ?? "missing"}`);
137
+ process.exit(2);
138
+ }
139
+ projectPath(item.path);
140
+ }
141
+
142
+ const previousFiles = new Map((previousInstallState?.files ?? []).map((item) => [item.relative, item]));
143
+ const previousOverrides = new Map((previousOverrideManifest?.overrides ?? []).map((item) => [item.path, item]));
144
+ const currentFrameworkNames = new Set(frameworkFiles.map((item) => item.relative));
145
+ const currentVersion = fs.readFileSync(path.join(root, "VERSION"), "utf8").trim();
146
+
147
+ const filePlans = frameworkFiles.map((item) => planFrameworkFile(item));
148
+ const stalePlans = (previousInstallState?.files ?? [])
149
+ .filter((item) => !currentFrameworkNames.has(item.relative))
150
+ .map((item) => planStaleFile(item))
151
+ .filter(Boolean);
152
+
153
+ const activeOverridePaths = new Set([
154
+ ...filePlans.filter((item) => item.override).map((item) => item.relative),
155
+ ...stalePlans.filter((item) => item.override).map((item) => item.relative)
156
+ ]);
157
+ const carriedOverrides = [];
158
+ for (const item of previousOverrides.values()) {
159
+ if (activeOverridePaths.has(item.path) || currentFrameworkNames.has(item.path) || previousFiles.has(item.path)) continue;
160
+ const destination = projectPath(item.path);
161
+ const absent = item.disposition === "absent";
162
+ const stillMatches = absent ? !fs.existsSync(destination) : matchesFingerprint(destination, item);
163
+ carriedOverrides.push({
164
+ ...item,
165
+ status: item.status === "approved" && stillMatches ? "approved" : "pending_review",
166
+ reason: stillMatches ? item.reason : "Previously approved override changed and requires migration review."
167
+ });
168
+ }
169
+
170
+ const overrideRecords = [
171
+ ...filePlans.filter((item) => item.override).map((item) => item.override),
172
+ ...stalePlans.filter((item) => item.override).map((item) => item.override),
173
+ ...carriedOverrides
174
+ ].sort((a, b) => a.path.localeCompare(b.path));
175
+ const pendingOverrides = overrideRecords.filter((item) => item.status !== "approved");
176
+
177
+ const newFiles = filePlans.filter((item) => item.action === "install");
178
+ const currentFiles = filePlans.filter((item) => item.action === "current");
179
+ const automaticReplacements = filePlans.filter((item) => item.action === "replace");
180
+ const forcedReplacements = filePlans.filter((item) => item.action === "force-replace");
181
+ const preservedFiles = filePlans.filter((item) => item.action.startsWith("preserve"));
182
+ const automaticStale = stalePlans.filter((item) => item.action === "remove");
183
+ const forcedStale = stalePlans.filter((item) => item.action === "force-remove");
184
+ const preservedStale = stalePlans.filter((item) => item.action === "preserve-stale");
185
+
186
+ const scaffoldFiles = walk(path.join(root, "scaffold")).map((file) => ({
187
+ source: file,
188
+ relative: posix(path.relative(path.join(root, "scaffold"), file)),
189
+ destination: projectPath(posix(path.relative(path.join(root, "scaffold"), file))),
190
+ }));
191
+ const npmExcludedScaffoldFile = ".nccgs/.gitignore";
192
+ if (!scaffoldFiles.some((item) => posix(item.relative) === npmExcludedScaffoldFile)) {
193
+ const fallback = path.join(root, "package-assets", "nccgs-project.gitignore");
194
+ if (fs.existsSync(fallback)) {
195
+ scaffoldFiles.push({
196
+ source: fallback,
197
+ relative: npmExcludedScaffoldFile,
198
+ destination: projectPath(npmExcludedScaffoldFile)
199
+ });
200
+ scaffoldFiles.sort((a, b) => a.relative.localeCompare(b.relative));
201
+ }
202
+ }
203
+ // An update must not introduce standalone policy into an existing legacy project.
204
+ const newScaffold = scaffoldFiles.filter((item) => !fs.existsSync(item.destination)
205
+ && item.relative !== ".nccgs/autonomy.json"
206
+ && !(existingInstallation && item.relative === ".nccgs/execution.json"));
207
+ const settingsPlan = planManagedSettings(project);
208
+ const targetPolicyPath = projectPath(".nccgs/project.yaml");
209
+ const targetPolicy = fs.existsSync(targetPolicyPath) ? fs.readFileSync(targetPolicyPath, "utf8") : "";
210
+ const policyModelProfile = targetPolicy.match(/^models:\s*$[\s\S]*?^\s+profile:\s*([^#\r\n]+)/m)?.[1]?.trim().replace(/^['"]|['"]$/g, "");
211
+ const studio = JSON.parse(fs.readFileSync(path.join(root, ".claude/nccgs/studio.json"), "utf8"));
212
+ for (const agent of studio.agents) projectPath(`.claude/agents/${agent.name}.md`);
213
+ const modelProfile = requestedModelProfile || policyModelProfile || previousInstallState?.modelProfile || (existingInstallation ? "astra-claude" : studio.defaultProfile);
214
+ if (!Object.hasOwn(studio.modelProfiles, modelProfile)) {
215
+ console.error(`Unknown model profile: ${modelProfile}`);
216
+ process.exit(2);
217
+ }
218
+
219
+ console.log(`NCCGS installation target: ${project}`);
220
+ console.log(`Framework version: ${previousInstallState?.frameworkVersion ?? "not installed"} -> ${currentVersion}`);
221
+ console.log(`Framework files: ${frameworkFiles.length} (${newFiles.length} new, ${currentFiles.length} current, ${automaticReplacements.length} automatic update, ${preservedFiles.length} preserved override, ${forcedReplacements.length} forced override)`);
222
+ console.log(`Stale framework files: ${automaticStale.length} automatic removal, ${preservedStale.length} preserved override, ${forcedStale.length} forced removal`);
223
+ console.log(`Project scaffold files to initialize: ${newScaffold.length}`);
224
+ console.log(`Managed Claude settings: ${settingsPlan.changed ? "update" : "already current"}`);
225
+ console.log(`Task autonomy: ${autonomyMode}; activate native permissions with nccgs run. OS and managed restrictions still apply.`);
226
+ console.log(`Agent model profile: ${modelProfile}`);
227
+ console.log(`Execution policy: ${previousExecution?.name ?? (existingInstallation ? "compatibility (missing execution.json preserved)" : "standalone")}; external review: ${previousExecution?.externalReview?.mode ?? (existingInstallation ? "required" : "off")}`);
228
+ if (pendingOverrides.length) {
229
+ console.log(`${pendingOverrides.length} project override(s) will be preserved for /migrate-project review:`);
230
+ for (const item of pendingOverrides.slice(0, 30)) console.log(`- ${item.path} (${item.disposition})`);
231
+ }
232
+ if (dryRun) {
233
+ console.log("Dry run complete; no files changed.");
234
+ process.exit(0);
235
+ }
236
+
237
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
238
+ const backupRoot = projectPath(`.nccgs/backups/${stamp}`);
239
+ const replacedFiles = [...automaticReplacements, ...forcedReplacements];
240
+ const removedFiles = [...automaticStale, ...forcedStale];
241
+ for (const item of replacedFiles) backupFile(item.destination, path.join(backupRoot, ...item.relative.split("/")));
242
+ for (const item of removedFiles) {
243
+ backupFile(item.destination, path.join(backupRoot, ...item.relative.split("/")));
244
+ fs.unlinkSync(recheckTarget(item.destination));
245
+ }
246
+ if (previousOverrideManifest && JSON.stringify(previousOverrideManifest.overrides ?? []) !== JSON.stringify(overrideRecords)) {
247
+ backupFile(overrideManifestPath, path.join(backupRoot, ".nccgs", "framework-overrides.json"));
248
+ }
249
+
250
+ for (const item of filePlans.filter((entry) => new Set(["install", "replace", "force-replace"]).has(entry.action))) {
251
+ recheckTarget(item.destination);
252
+ fs.mkdirSync(path.dirname(item.destination), { recursive: true });
253
+ fs.copyFileSync(item.source, recheckTarget(item.destination));
254
+ }
255
+ for (const item of newScaffold) {
256
+ recheckTarget(item.destination);
257
+ fs.mkdirSync(path.dirname(item.destination), { recursive: true });
258
+ fs.copyFileSync(item.source, recheckTarget(item.destination));
259
+ }
260
+
261
+ installManagedSettings(settingsPlan, backupRoot);
262
+ const autonomyText = `${JSON.stringify({ schemaVersion: 1, mode: autonomyMode }, null, 2)}\n`;
263
+ if (!fs.existsSync(autonomyFile) || fs.readFileSync(autonomyFile, "utf8") !== autonomyText) {
264
+ if (fs.existsSync(autonomyFile)) backupFile(autonomyFile, path.join(backupRoot, ".nccgs/autonomy.json"));
265
+ fs.mkdirSync(path.dirname(autonomyFile), { recursive: true });
266
+ fs.writeFileSync(recheckTarget(autonomyFile), autonomyText);
267
+ }
268
+ installClaudeImport(project, backupRoot);
269
+
270
+ const skipAgentPaths = preservedFiles
271
+ .filter((item) => item.relative.startsWith(".claude/agents/"))
272
+ .flatMap((item) => ["--skip-path", item.relative]);
273
+ const modelConfiguration = spawnSync(process.execPath, [
274
+ path.join(root, ".claude", "nccgs", "tools", "configure-models.mjs"),
275
+ "--project", project,
276
+ "--profile", modelProfile,
277
+ ...skipAgentPaths
278
+ ], { cwd: project, encoding: "utf8" });
279
+ if (modelConfiguration.status !== 0) {
280
+ process.stderr.write(modelConfiguration.stdout ?? "");
281
+ process.stderr.write(modelConfiguration.stderr ?? "");
282
+ console.error("NCCGS was copied, but agent model configuration failed.");
283
+ process.exit(1);
284
+ }
285
+
286
+ writeOverrideManifest(overrideManifestPath, overrideRecords, previousOverrideManifest, currentVersion);
287
+ writeInstallState(project, modelProfile, frameworkFiles, installStatePath, previousInstallState, new Set(overrideRecords.map((item) => item.path)));
288
+
289
+ const doctor = spawnSync(process.execPath, [
290
+ path.join(scriptDir, "doctor.mjs"),
291
+ "--project", project,
292
+ "--allow-migration-required"
293
+ ], { cwd: project, encoding: "utf8" });
294
+ process.stdout.write(doctor.stdout ?? "");
295
+ process.stderr.write(doctor.stderr ?? "");
296
+ if (doctor.status !== 0) {
297
+ console.error("NCCGS files were installed, but post-install verification failed.");
298
+ process.exit(1);
299
+ }
300
+
301
+ console.log("NCCGS installation complete.");
302
+ if (fs.existsSync(backupRoot)) {
303
+ console.log(`Replaced shared files were backed up under: ${backupRoot}`);
304
+ }
305
+ const installedPolicy = fs.readFileSync(targetPolicyPath, "utf8");
306
+ const installedPolicySchema = Number(installedPolicy.match(/^schema_version:\s*(\d+)\s*$/m)?.[1] ?? 0);
307
+ if (installedPolicySchema !== 2 || pendingOverrides.length || /^profile_contracts:/m.test(installedPolicy)) {
308
+ console.log("Project migration required: open a new Claude Code session and run `/migrate-project` once.");
309
+ } else if (!previousInstallState) {
310
+ console.log("First NCCGS adoption: run `/migrate-project` once for an existing project; a new project can start with `/status`.");
311
+ } else {
312
+ console.log("No project migration required for this NCCGS update.");
313
+ }
314
+
315
+ function planFrameworkFile(item) {
316
+ const previous = previousFiles.get(item.relative);
317
+ const existingOverride = previousOverrides.get(item.relative);
318
+ const upstream = fingerprintFile(item.source);
319
+ if (!fs.existsSync(item.destination)) {
320
+ if (!force && (previous || existingOverride?.disposition === "absent")) {
321
+ return { ...item, action: "preserve-absent", override: buildOverride(item, existingOverride, upstream, "absent") };
322
+ }
323
+ return { ...item, action: "install", override: null };
324
+ }
325
+ if (equivalentFiles(item.source, item.destination)) return { ...item, action: "current", override: null };
326
+ if (!force && previous && matchesFingerprint(item.destination, previous)) return { ...item, action: "replace", override: null };
327
+ // Older profiles could generate optional agents outside install-state.files.
328
+ // When a later package starts managing one, its recorded generated bytes are
329
+ // a valid baseline. Real local edits and explicit overrides still need review.
330
+ const generated = previousActivation?.schemaVersion === 1 && item.relative.startsWith(".claude/agents/")
331
+ ? { sha256: previousActivation.generated?.[item.relative], canonicalSha256: previousActivation.generatedCanonical?.[item.relative] }
332
+ : null;
333
+ if (!force && previousInstallState && !previous && !existingOverride && matchesFingerprint(item.destination, generated)) {
334
+ return { ...item, action: "replace", override: null };
335
+ }
336
+ if (force) return { ...item, action: "force-replace", override: null };
337
+ return { ...item, action: "preserve", override: buildOverride(item, existingOverride, upstream, "preserve") };
338
+ }
339
+
340
+ function planStaleFile(previous) {
341
+ const destination = projectPath(previous.relative);
342
+ if (!fs.existsSync(destination)) return null;
343
+ if (force) return { ...previous, relative: previous.relative, destination, action: "force-remove", override: null };
344
+ if (matchesFingerprint(destination, previous)) return { ...previous, relative: previous.relative, destination, action: "remove", override: null };
345
+ const existingOverride = previousOverrides.get(previous.relative);
346
+ const current = fingerprintFile(destination);
347
+ const stillApproved = existingOverride?.status === "approved" && matchesFingerprint(destination, existingOverride)
348
+ && existingOverride.upstreamSha256 === null;
349
+ return {
350
+ ...previous,
351
+ relative: previous.relative,
352
+ destination,
353
+ action: "preserve-stale",
354
+ override: {
355
+ path: previous.relative,
356
+ disposition: "preserve",
357
+ status: stillApproved ? "approved" : "pending_review",
358
+ ...current,
359
+ upstreamSha256: null,
360
+ upstreamCanonicalSha256: null,
361
+ retiredFromFramework: true,
362
+ reason: "Locally modified file was preserved after its framework path retired; review ownership during migration."
363
+ }
364
+ };
365
+ }
366
+
367
+ function buildOverride(item, existing, upstream, disposition) {
368
+ const current = disposition === "absent" ? { sha256: null, canonicalSha256: null } : fingerprintFile(item.destination);
369
+ const currentMatches = disposition === "absent"
370
+ ? existing?.disposition === "absent" && !fs.existsSync(item.destination)
371
+ : existing?.disposition === "preserve" && sameFingerprint(current, existing);
372
+ const previousUpstream = existing ? {
373
+ sha256: existing.upstreamSha256,
374
+ canonicalSha256: existing.upstreamCanonicalSha256
375
+ } : null;
376
+ const upstreamMatches = previousUpstream && sameFingerprint(upstream, previousUpstream);
377
+ const approved = existing?.status === "approved" && currentMatches && upstreamMatches;
378
+ return {
379
+ path: item.relative,
380
+ disposition,
381
+ status: approved ? "approved" : "pending_review",
382
+ ...current,
383
+ upstreamSha256: upstream.sha256,
384
+ upstreamCanonicalSha256: upstream.canonicalSha256 ?? null,
385
+ reason: approved
386
+ ? existing.reason
387
+ : "Project-specific framework change was preserved automatically; review and rebase it with /migrate-project."
388
+ };
389
+ }
390
+
391
+ function walk(directory) {
392
+ if (!fs.existsSync(directory)) return [];
393
+ const output = [];
394
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
395
+ const full = path.join(directory, entry.name);
396
+ if (entry.isDirectory()) output.push(...walk(full));
397
+ else if (entry.isFile()) output.push(full);
398
+ }
399
+ return output.sort((a, b) => a.localeCompare(b));
400
+ }
401
+
402
+ function posix(value) {
403
+ return value.split(path.sep).join("/");
404
+ }
405
+
406
+ function isManagedRelative(value) {
407
+ try { return assertRelativePath(value).startsWith(".claude/"); }
408
+ catch { return false; }
409
+ }
410
+
411
+ function projectPath(relative) {
412
+ try { return containedPath(project, relative); }
413
+ catch (error) {
414
+ console.error(`Unsafe NCCGS target: ${error.message}`);
415
+ process.exit(2);
416
+ }
417
+ }
418
+
419
+ function recheckTarget(file) {
420
+ return projectPath(posix(path.relative(project, file)));
421
+ }
422
+
423
+ function readJsonIfPresent(file, label) {
424
+ if (!fs.existsSync(file)) return null;
425
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); }
426
+ catch (error) {
427
+ console.error(`Invalid ${label} at ${file}: ${error.message}`);
428
+ process.exit(2);
429
+ }
430
+ }
431
+
432
+ function backupFile(source, destination) {
433
+ recheckTarget(source);
434
+ recheckTarget(destination);
435
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
436
+ fs.copyFileSync(recheckTarget(source), recheckTarget(destination));
437
+ }
438
+
439
+ function planManagedSettings(projectRoot) {
440
+ const settingsPath = projectPath(".claude/settings.json");
441
+ let current = {};
442
+ if (fs.existsSync(settingsPath)) {
443
+ try { current = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
444
+ catch (error) {
445
+ console.error(`Cannot merge invalid JSON settings at ${settingsPath}: ${error.message}`);
446
+ process.exit(2);
447
+ }
448
+ }
449
+ const fragment = JSON.parse(fs.readFileSync(path.join(root, ".claude", "nccgs", "settings.fragment.json"), "utf8"));
450
+ const managedCommands = new Set(Object.values(fragment.hooks ?? {}).flatMap(groups => groups.flatMap(group => group.hooks ?? [])).map(managedHookKey).filter(Boolean));
451
+ const merged = structuredClone(current);
452
+ merged.permissions ??= {};
453
+ for (const key of ["allow", "deny", "ask"]) {
454
+ const existing = Array.isArray(merged.permissions[key]) ? merged.permissions[key] : [];
455
+ const additions = Array.isArray(fragment.permissions?.[key]) ? fragment.permissions[key] : [];
456
+ if (existing.length || additions.length) merged.permissions[key] = [...new Set([...existing, ...additions])];
457
+ }
458
+ merged.hooks ??= {};
459
+ for (const [event, groups] of Object.entries(fragment.hooks ?? {})) {
460
+ const existing = Array.isArray(merged.hooks[event]) ? merged.hooks[event] : [];
461
+ // A user's command may share a group with a managed command. Remove only
462
+ // the managed hook, preserving the group metadata and every custom hook.
463
+ const retained = existing.flatMap((group) => {
464
+ if (!Array.isArray(group.hooks)) return [group];
465
+ const hooks = group.hooks.filter((hook) => !managedCommands.has(managedHookKey(hook)));
466
+ return hooks.length ? [{ ...group, hooks }] : [];
467
+ });
468
+ merged.hooks[event] = [...retained, ...groups];
469
+ }
470
+ const nextText = `${JSON.stringify(merged, null, 2)}\n`;
471
+ const currentText = fs.existsSync(settingsPath) ? fs.readFileSync(settingsPath, "utf8") : "";
472
+ return { settingsPath, existed: fs.existsSync(settingsPath), nextText, changed: currentText !== nextText };
473
+ }
474
+
475
+ function installManagedSettings(plan, backupDirectory) {
476
+ if (!plan.changed) return;
477
+ if (plan.existed) backupFile(plan.settingsPath, path.join(backupDirectory, ".claude", "settings.json"));
478
+ recheckTarget(plan.settingsPath);
479
+ fs.mkdirSync(path.dirname(plan.settingsPath), { recursive: true });
480
+ fs.writeFileSync(recheckTarget(plan.settingsPath), plan.nextText, "utf8");
481
+ }
482
+
483
+ function writeOverrideManifest(file, overrides, previous, frameworkVersion) {
484
+ if (!overrides.length && !previous) return;
485
+ const now = new Date().toISOString();
486
+ const manifest = {
487
+ schemaVersion: 1,
488
+ frameworkVersion,
489
+ createdAt: previous?.createdAt ?? now,
490
+ updatedAt: now,
491
+ overrides
492
+ };
493
+ recheckTarget(file);
494
+ fs.mkdirSync(path.dirname(file), { recursive: true });
495
+ fs.writeFileSync(recheckTarget(file), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
496
+ }
497
+
498
+ function writeInstallState(projectRoot, profile, files, statePath, previousState, overridePaths) {
499
+ const now = new Date().toISOString();
500
+ const state = {
501
+ schemaVersion: 3,
502
+ frameworkVersion: currentVersion,
503
+ previousFrameworkVersion: previousState?.frameworkVersion ?? null,
504
+ modelProfile: profile,
505
+ installedAt: previousState?.installedAt ?? now,
506
+ updatedAt: now,
507
+ files: files.map((item) => ({
508
+ relative: item.relative,
509
+ ...(overridePaths.has(item.relative) ? fingerprintFile(item.source) : fingerprintFile(projectPath(item.relative)))
510
+ }))
511
+ };
512
+ recheckTarget(statePath);
513
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
514
+ fs.writeFileSync(recheckTarget(statePath), `${JSON.stringify(state, null, 2)}\n`, "utf8");
515
+ }
516
+
517
+ function installClaudeImport(projectRoot, backupDirectory) {
518
+ const claudePath = projectPath("CLAUDE.md");
519
+ const begin = "<!-- NCCGS:BEGIN -->";
520
+ const block = `${begin}\n@.claude/nccgs/constitution.md\n<!-- NCCGS:END -->`;
521
+ if (!fs.existsSync(claudePath)) {
522
+ fs.copyFileSync(path.join(root, "CLAUDE.md"), recheckTarget(claudePath));
523
+ return;
524
+ }
525
+ const current = fs.readFileSync(claudePath, "utf8");
526
+ if (current.includes(begin) || current.includes("@.claude/nccgs/constitution.md")) return;
527
+ backupFile(claudePath, path.join(backupDirectory, "CLAUDE.md"));
528
+ const separator = current.endsWith("\n") ? "\n" : "\n\n";
529
+ fs.writeFileSync(recheckTarget(claudePath), `${current}${separator}${block}\n`, "utf8");
530
+ }