nccgs 1.0.0 → 1.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.
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { matchesFingerprint, sameFingerprint } from "../.claude/nccgs/tools/integrity.mjs";
6
+
7
+ const args = process.argv.slice(2);
8
+ const projectIndex = args.indexOf("--project");
9
+ const allowMigrationRequired = args.includes("--allow-migration-required") || args.includes("--allow-policy-migration");
10
+ const warnings = [];
11
+ const errors = [];
12
+ if (projectIndex >= 0 && !args[projectIndex + 1]) {
13
+ console.error("--project requires a directory path.");
14
+ process.exit(2);
15
+ }
16
+
17
+ const project = projectIndex >= 0 ? path.resolve(args[projectIndex + 1]) : process.cwd();
18
+ for (const marker of ["Assets", "Packages", "ProjectSettings"]) {
19
+ if (!fs.existsSync(path.join(project, marker))) errors.push(`Missing Unity marker: ${marker}`);
20
+ }
21
+
22
+ const statePath = path.join(project, ".claude", "nccgs", "install-state.json");
23
+ const overridePath = path.join(project, ".nccgs", "framework-overrides.json");
24
+ const state = readJson(statePath, "NCCGS install state", true);
25
+ const overrideManifest = readJson(overridePath, "NCCGS framework override manifest", false);
26
+ if (state && ![1, 2, 3].includes(state.schemaVersion)) errors.push(`Unsupported install-state schema: ${state.schemaVersion ?? "unknown"}.`);
27
+ if (overrideManifest && overrideManifest.schemaVersion !== 1) errors.push(`Unsupported framework override schema: ${overrideManifest.schemaVersion ?? "unknown"}.`);
28
+
29
+ const overrides = new Map();
30
+ for (const item of overrideManifest?.overrides ?? []) {
31
+ if (!isManagedRelative(item.path) || !new Set(["preserve", "absent"]).has(item.disposition)
32
+ || !new Set(["approved", "pending_review"]).has(item.status)) {
33
+ errors.push(`Invalid framework override record: ${item.path ?? "path missing"}`);
34
+ continue;
35
+ }
36
+ if (overrides.has(item.path)) errors.push(`Duplicate framework override: ${item.path}`);
37
+ overrides.set(item.path, item);
38
+ }
39
+
40
+ let verifiedManaged = 0;
41
+ if (state) {
42
+ for (const item of state.files ?? []) {
43
+ if (!isManagedRelative(item.relative)) {
44
+ errors.push(`Unsafe managed path in install state: ${item.relative ?? "missing"}`);
45
+ continue;
46
+ }
47
+ const file = path.join(project, ...item.relative.split("/"));
48
+ const override = overrides.get(item.relative);
49
+ if (override) {
50
+ const recordedUpstream = {
51
+ sha256: override.upstreamSha256,
52
+ canonicalSha256: override.upstreamCanonicalSha256
53
+ };
54
+ if (!sameFingerprint(item, recordedUpstream)) errors.push(`Framework override has stale upstream hashes: ${item.relative}`);
55
+ validateOverride(override, file);
56
+ verifiedManaged++;
57
+ continue;
58
+ }
59
+ if (!fs.existsSync(file)) errors.push(`Managed file is missing: ${item.relative}`);
60
+ else if (!matchesFingerprint(file, item)) errors.push(`Managed file was modified: ${item.relative}`);
61
+ else verifiedManaged++;
62
+ }
63
+ }
64
+ for (const item of overrides.values()) {
65
+ if ((state?.files ?? []).some((managed) => managed.relative === item.path)) continue;
66
+ validateOverride(item, path.join(project, ...item.path.split("/")));
67
+ }
68
+
69
+ const claudePath = path.join(project, "CLAUDE.md");
70
+ if (!fs.existsSync(claudePath) || !fs.readFileSync(claudePath, "utf8").includes("@.claude/nccgs/constitution.md")) {
71
+ errors.push("CLAUDE.md does not import the NCCGS constitution.");
72
+ }
73
+
74
+ const policyPath = path.join(project, ".nccgs", "project.yaml");
75
+ let policySchema = null;
76
+ if (!fs.existsSync(policyPath)) errors.push(".nccgs/project.yaml is missing.");
77
+ else {
78
+ const match = fs.readFileSync(policyPath, "utf8").match(/^schema_version:\s*(\d+)\s*$/m);
79
+ policySchema = match ? Number(match[1]) : null;
80
+ if (policySchema !== 2) migrationIssue(`Project policy schema must be 2; found ${policySchema ?? "unknown"}.`);
81
+ }
82
+
83
+ if (errors.length) {
84
+ for (const error of errors) console.error(`ERROR: ${error}`);
85
+ for (const warning of warnings) console.warn(`WARN: ${warning}`);
86
+ console.error(`NCCGS doctor failed with ${errors.length} error(s).`);
87
+ process.exit(1);
88
+ }
89
+
90
+ for (const warning of warnings) console.warn(`WARN: ${warning}`);
91
+ console.log("NCCGS doctor passed.");
92
+ console.log(`Framework version: ${state.frameworkVersion}`);
93
+ console.log(`Model profile: ${state.modelProfile}`);
94
+ console.log(`Managed files verified: ${verifiedManaged}`);
95
+ console.log(`Framework overrides: ${overrides.size} (${[...overrides.values()].filter((item) => item.status !== "approved").length} pending review)`);
96
+ console.log(`Project policy schema: ${policySchema ?? "migration required"}`);
97
+
98
+ function validateOverride(item, file) {
99
+ if (item.status !== "approved") migrationIssue(`Framework override awaits /migrate-project review: ${item.path}`);
100
+ if (item.disposition === "absent") {
101
+ if (fs.existsSync(file)) errors.push(`Override expects a missing file, but it exists: ${item.path}`);
102
+ return;
103
+ }
104
+ if (!fs.existsSync(file)) errors.push(`Preserved framework override is missing: ${item.path}`);
105
+ else if (!matchesFingerprint(file, item)) errors.push(`Preserved framework override changed after it was recorded: ${item.path}`);
106
+ }
107
+
108
+ function migrationIssue(message) {
109
+ if (allowMigrationRequired) warnings.push(message);
110
+ else errors.push(message);
111
+ }
112
+
113
+ function readJson(file, label, required) {
114
+ if (!fs.existsSync(file)) {
115
+ if (required) errors.push(`${label} is missing.`);
116
+ return null;
117
+ }
118
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); }
119
+ catch (error) {
120
+ errors.push(`${label} is invalid JSON: ${error.message}`);
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function isManagedRelative(value) {
126
+ if (typeof value !== "string" || !value.startsWith(".claude/")) return false;
127
+ const parts = value.split("/");
128
+ return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
129
+ }
@@ -1,8 +1,13 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import crypto from "node:crypto";
3
2
  import fs from "node:fs";
4
3
  import path from "node:path";
5
4
  import { fileURLToPath } from "node:url";
5
+ import {
6
+ equivalentFiles,
7
+ fingerprintFile,
8
+ matchesFingerprint,
9
+ sameFingerprint
10
+ } from "../.claude/nccgs/tools/integrity.mjs";
6
11
 
7
12
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
8
13
  const root = path.resolve(scriptDir, "..");
@@ -10,6 +15,7 @@ const args = process.argv.slice(2);
10
15
  const projectIndex = args.indexOf("--project");
11
16
  const dryRun = args.includes("--dry-run");
12
17
  const force = args.includes("--force");
18
+ const updateOnly = args.includes("--update-only");
13
19
  const allowNonUnity = args.includes("--allow-non-unity");
14
20
  const modelProfileIndex = args.indexOf("--model-profile");
15
21
  const requestedModelProfile = modelProfileIndex >= 0 ? args[modelProfileIndex + 1] : null;
@@ -51,57 +57,108 @@ if (validation.status !== 0) {
51
57
  const frameworkSource = path.join(root, ".claude");
52
58
  const frameworkFiles = walk(frameworkSource).map((file) => ({
53
59
  source: file,
54
- relative: path.relative(root, file),
60
+ relative: posix(path.relative(root, file)),
55
61
  destination: path.join(project, path.relative(root, file)),
56
62
  }));
57
63
  const npmExcludedUnityFile = ".claude/skills/setup-vivox-voice-chat/evals/.gitignore";
58
- if (!frameworkFiles.some((item) => posix(item.relative) === npmExcludedUnityFile)) {
64
+ if (!frameworkFiles.some((item) => item.relative === npmExcludedUnityFile)) {
59
65
  const fallback = path.join(root, "package-assets", "setup-vivox-voice-chat-evals.gitignore");
60
66
  if (fs.existsSync(fallback)) {
61
67
  frameworkFiles.push({
62
68
  source: fallback,
63
- relative: npmExcludedUnityFile.split("/").join(path.sep),
69
+ relative: npmExcludedUnityFile,
64
70
  destination: path.join(project, ...npmExcludedUnityFile.split("/"))
65
71
  });
66
72
  frameworkFiles.sort((a, b) => a.relative.localeCompare(b.relative));
67
73
  }
68
74
  }
75
+
69
76
  const installStatePath = path.join(project, ".claude", "nccgs", "install-state.json");
70
- let previousInstallState = null;
71
- if (fs.existsSync(installStatePath)) {
72
- try { previousInstallState = JSON.parse(fs.readFileSync(installStatePath, "utf8")); }
73
- catch (error) {
74
- console.error(`Invalid NCCGS install state at ${installStatePath}: ${error.message}`);
77
+ const overrideManifestPath = path.join(project, ".nccgs", "framework-overrides.json");
78
+ const previousInstallState = readJsonIfPresent(installStatePath, "NCCGS install state");
79
+ const previousOverrideManifest = readJsonIfPresent(overrideManifestPath, "NCCGS framework override manifest");
80
+ if (updateOnly && !previousInstallState) {
81
+ console.error("NCCGS is not installed in this project. Run `npx nccgs@latest install` first.");
82
+ process.exit(2);
83
+ }
84
+ if (previousOverrideManifest && previousOverrideManifest.schemaVersion !== 1) {
85
+ console.error(`Unsupported framework override schema: ${previousOverrideManifest.schemaVersion ?? "unknown"}`);
86
+ process.exit(2);
87
+ }
88
+ for (const item of previousInstallState?.files ?? []) {
89
+ if (!isManagedRelative(item.relative)) {
90
+ console.error(`Unsafe managed path in install state: ${item.relative ?? "missing"}`);
91
+ process.exit(2);
92
+ }
93
+ }
94
+ for (const item of previousOverrideManifest?.overrides ?? []) {
95
+ if (!isManagedRelative(item.path)) {
96
+ console.error(`Unsafe path in framework override manifest: ${item.path ?? "missing"}`);
75
97
  process.exit(2);
76
98
  }
77
99
  }
78
- const currentFrameworkNames = new Set(frameworkFiles.map((item) => posix(item.relative)));
79
- const staleFrameworkFiles = (previousInstallState?.files ?? [])
80
- .filter((item) => !currentFrameworkNames.has(item.relative))
81
- .map((item) => ({ ...item, destination: path.join(project, ...item.relative.split("/")) }))
82
- .filter((item) => fs.existsSync(item.destination));
83
100
 
84
- const conflicts = frameworkFiles.filter((item) => {
85
- if (!fs.existsSync(item.destination)) return false;
86
- return !sameFile(item.source, item.destination);
87
- });
101
+ const previousFiles = new Map((previousInstallState?.files ?? []).map((item) => [item.relative, item]));
102
+ const previousOverrides = new Map((previousOverrideManifest?.overrides ?? []).map((item) => [item.path, item]));
103
+ const currentFrameworkNames = new Set(frameworkFiles.map((item) => item.relative));
104
+ const currentVersion = fs.readFileSync(path.join(root, "VERSION"), "utf8").trim();
88
105
 
89
- if ((conflicts.length || staleFrameworkFiles.length) && !force) {
90
- console.error(`Installation found ${conflicts.length} conflicting and ${staleFrameworkFiles.length} stale framework file(s):`);
91
- for (const item of conflicts.slice(0, 30)) console.error(`- ${posix(item.relative)}`);
92
- for (const item of staleFrameworkFiles.slice(0, 30)) console.error(`- stale: ${item.relative}`);
93
- if (conflicts.length > 30) console.error(`- ... ${conflicts.length - 30} more`);
94
- console.error("Re-run with --force to back up and replace these framework files.");
95
- process.exit(2);
106
+ const filePlans = frameworkFiles.map((item) => planFrameworkFile(item));
107
+ const stalePlans = (previousInstallState?.files ?? [])
108
+ .filter((item) => !currentFrameworkNames.has(item.relative))
109
+ .map((item) => planStaleFile(item))
110
+ .filter(Boolean);
111
+
112
+ const activeOverridePaths = new Set([
113
+ ...filePlans.filter((item) => item.override).map((item) => item.relative),
114
+ ...stalePlans.filter((item) => item.override).map((item) => item.relative)
115
+ ]);
116
+ const carriedOverrides = [];
117
+ for (const item of previousOverrides.values()) {
118
+ if (activeOverridePaths.has(item.path) || currentFrameworkNames.has(item.path) || previousFiles.has(item.path)) continue;
119
+ const destination = path.join(project, ...item.path.split("/"));
120
+ const absent = item.disposition === "absent";
121
+ const stillMatches = absent ? !fs.existsSync(destination) : matchesFingerprint(destination, item);
122
+ carriedOverrides.push({
123
+ ...item,
124
+ status: item.status === "approved" && stillMatches ? "approved" : "pending_review",
125
+ reason: stillMatches ? item.reason : "Previously approved override changed and requires migration review."
126
+ });
96
127
  }
97
128
 
98
- const newFiles = frameworkFiles.filter((item) => !fs.existsSync(item.destination));
99
- const identicalFiles = frameworkFiles.length - newFiles.length - conflicts.length;
129
+ const overrideRecords = [
130
+ ...filePlans.filter((item) => item.override).map((item) => item.override),
131
+ ...stalePlans.filter((item) => item.override).map((item) => item.override),
132
+ ...carriedOverrides
133
+ ].sort((a, b) => a.path.localeCompare(b.path));
134
+ const pendingOverrides = overrideRecords.filter((item) => item.status !== "approved");
135
+
136
+ const newFiles = filePlans.filter((item) => item.action === "install");
137
+ const currentFiles = filePlans.filter((item) => item.action === "current");
138
+ const automaticReplacements = filePlans.filter((item) => item.action === "replace");
139
+ const forcedReplacements = filePlans.filter((item) => item.action === "force-replace");
140
+ const preservedFiles = filePlans.filter((item) => item.action.startsWith("preserve"));
141
+ const automaticStale = stalePlans.filter((item) => item.action === "remove");
142
+ const forcedStale = stalePlans.filter((item) => item.action === "force-remove");
143
+ const preservedStale = stalePlans.filter((item) => item.action === "preserve-stale");
144
+
100
145
  const scaffoldFiles = walk(path.join(root, "scaffold")).map((file) => ({
101
146
  source: file,
102
147
  relative: path.relative(path.join(root, "scaffold"), file),
103
148
  destination: path.join(project, path.relative(path.join(root, "scaffold"), file)),
104
149
  }));
150
+ const npmExcludedScaffoldFile = ".nccgs/.gitignore";
151
+ if (!scaffoldFiles.some((item) => posix(item.relative) === npmExcludedScaffoldFile)) {
152
+ const fallback = path.join(root, "package-assets", "nccgs-project.gitignore");
153
+ if (fs.existsSync(fallback)) {
154
+ scaffoldFiles.push({
155
+ source: fallback,
156
+ relative: npmExcludedScaffoldFile.split("/").join(path.sep),
157
+ destination: path.join(project, ...npmExcludedScaffoldFile.split("/"))
158
+ });
159
+ scaffoldFiles.sort((a, b) => a.relative.localeCompare(b.relative));
160
+ }
161
+ }
105
162
  const newScaffold = scaffoldFiles.filter((item) => !fs.existsSync(item.destination));
106
163
  const settingsPlan = planManagedSettings(project);
107
164
  const targetPolicyPath = path.join(project, ".nccgs", "project.yaml");
@@ -114,11 +171,16 @@ if (!new Set(["quality", "balanced", "inherit"]).has(modelProfile)) {
114
171
  }
115
172
 
116
173
  console.log(`NCCGS installation target: ${project}`);
117
- console.log(`Framework files: ${frameworkFiles.length} (${newFiles.length} new, ${identicalFiles} identical, ${conflicts.length} replace)`);
118
- console.log(`Stale framework files to back up and remove: ${staleFrameworkFiles.length}`);
174
+ console.log(`Framework version: ${previousInstallState?.frameworkVersion ?? "not installed"} -> ${currentVersion}`);
175
+ console.log(`Framework files: ${frameworkFiles.length} (${newFiles.length} new, ${currentFiles.length} current, ${automaticReplacements.length} automatic update, ${preservedFiles.length} preserved override, ${forcedReplacements.length} forced override)`);
176
+ console.log(`Stale framework files: ${automaticStale.length} automatic removal, ${preservedStale.length} preserved override, ${forcedStale.length} forced removal`);
119
177
  console.log(`Project scaffold files to initialize: ${newScaffold.length}`);
120
178
  console.log(`Managed Claude settings: ${settingsPlan.changed ? "update" : "already current"}`);
121
179
  console.log(`Agent model profile: ${modelProfile}`);
180
+ if (pendingOverrides.length) {
181
+ console.log(`${pendingOverrides.length} project override(s) will be preserved for /migrate-project review:`);
182
+ for (const item of pendingOverrides.slice(0, 30)) console.log(`- ${item.path} (${item.disposition})`);
183
+ }
122
184
  if (dryRun) {
123
185
  console.log("Dry run complete; no files changed.");
124
186
  process.exit(0);
@@ -126,23 +188,21 @@ if (dryRun) {
126
188
 
127
189
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
128
190
  const backupRoot = path.join(project, ".nccgs", "backups", stamp);
129
- for (const item of conflicts) {
130
- const backup = path.join(backupRoot, item.relative);
131
- fs.mkdirSync(path.dirname(backup), { recursive: true });
132
- fs.copyFileSync(item.destination, backup);
133
- }
134
- for (const item of staleFrameworkFiles) {
135
- const backup = path.join(backupRoot, ...item.relative.split("/"));
136
- fs.mkdirSync(path.dirname(backup), { recursive: true });
137
- fs.copyFileSync(item.destination, backup);
191
+ const replacedFiles = [...automaticReplacements, ...forcedReplacements];
192
+ const removedFiles = [...automaticStale, ...forcedStale];
193
+ for (const item of replacedFiles) backupFile(item.destination, path.join(backupRoot, ...item.relative.split("/")));
194
+ for (const item of removedFiles) {
195
+ backupFile(item.destination, path.join(backupRoot, ...item.relative.split("/")));
138
196
  fs.unlinkSync(item.destination);
139
197
  }
198
+ if (previousOverrideManifest && JSON.stringify(previousOverrideManifest.overrides ?? []) !== JSON.stringify(overrideRecords)) {
199
+ backupFile(overrideManifestPath, path.join(backupRoot, ".nccgs", "framework-overrides.json"));
200
+ }
140
201
 
141
- for (const item of frameworkFiles) {
202
+ for (const item of filePlans.filter((entry) => new Set(["install", "replace", "force-replace"]).has(entry.action))) {
142
203
  fs.mkdirSync(path.dirname(item.destination), { recursive: true });
143
204
  fs.copyFileSync(item.source, item.destination);
144
205
  }
145
-
146
206
  for (const item of newScaffold) {
147
207
  fs.mkdirSync(path.dirname(item.destination), { recursive: true });
148
208
  fs.copyFileSync(item.source, item.destination);
@@ -151,10 +211,14 @@ for (const item of newScaffold) {
151
211
  installManagedSettings(settingsPlan, backupRoot);
152
212
  installClaudeImport(project, backupRoot);
153
213
 
214
+ const skipAgentPaths = preservedFiles
215
+ .filter((item) => item.relative.startsWith(".claude/agents/"))
216
+ .flatMap((item) => ["--skip-path", item.relative]);
154
217
  const modelConfiguration = spawnSync(process.execPath, [
155
- path.join(project, ".claude", "nccgs", "tools", "configure-models.mjs"),
218
+ path.join(root, ".claude", "nccgs", "tools", "configure-models.mjs"),
156
219
  "--project", project,
157
220
  "--profile", modelProfile,
221
+ ...skipAgentPaths
158
222
  ], { cwd: project, encoding: "utf8" });
159
223
  if (modelConfiguration.status !== 0) {
160
224
  process.stderr.write(modelConfiguration.stdout ?? "");
@@ -163,13 +227,104 @@ if (modelConfiguration.status !== 0) {
163
227
  process.exit(1);
164
228
  }
165
229
 
166
- writeInstallState(project, modelProfile, frameworkFiles, installStatePath);
230
+ writeOverrideManifest(overrideManifestPath, overrideRecords, previousOverrideManifest, currentVersion);
231
+ writeInstallState(project, modelProfile, frameworkFiles, installStatePath, previousInstallState, new Set(overrideRecords.map((item) => item.path)));
232
+
233
+ const doctor = spawnSync(process.execPath, [
234
+ path.join(scriptDir, "doctor.mjs"),
235
+ "--project", project,
236
+ "--allow-migration-required"
237
+ ], { cwd: project, encoding: "utf8" });
238
+ process.stdout.write(doctor.stdout ?? "");
239
+ process.stderr.write(doctor.stderr ?? "");
240
+ if (doctor.status !== 0) {
241
+ console.error("NCCGS files were installed, but post-install verification failed.");
242
+ process.exit(1);
243
+ }
167
244
 
168
245
  console.log("NCCGS installation complete.");
169
- if (conflicts.length) console.log(`Replaced files backed up under: ${backupRoot}`);
170
- console.log("Next: open Claude Code and run /migrate-project review for an existing project, or /status for a configured project.");
246
+ if (fs.existsSync(backupRoot)) {
247
+ console.log(`Replaced shared files were backed up under: ${backupRoot}`);
248
+ }
249
+ const installedPolicy = fs.readFileSync(targetPolicyPath, "utf8");
250
+ const installedPolicySchema = Number(installedPolicy.match(/^schema_version:\s*(\d+)\s*$/m)?.[1] ?? 0);
251
+ if (installedPolicySchema !== 2 || pendingOverrides.length) {
252
+ console.log("Project migration required: open a new Claude Code session and run `/migrate-project` once.");
253
+ } else if (!previousInstallState) {
254
+ console.log("First NCCGS adoption: run `/migrate-project` once for an existing project; a new project can start with `/status`.");
255
+ } else {
256
+ console.log("No project migration required for this NCCGS update.");
257
+ }
258
+
259
+ function planFrameworkFile(item) {
260
+ const previous = previousFiles.get(item.relative);
261
+ const existingOverride = previousOverrides.get(item.relative);
262
+ const upstream = fingerprintFile(item.source);
263
+ if (!fs.existsSync(item.destination)) {
264
+ if (!force && (previous || existingOverride?.disposition === "absent")) {
265
+ return { ...item, action: "preserve-absent", override: buildOverride(item, existingOverride, upstream, "absent") };
266
+ }
267
+ return { ...item, action: "install", override: null };
268
+ }
269
+ if (equivalentFiles(item.source, item.destination)) return { ...item, action: "current", override: null };
270
+ if (!force && previous && matchesFingerprint(item.destination, previous)) return { ...item, action: "replace", override: null };
271
+ if (force) return { ...item, action: "force-replace", override: null };
272
+ return { ...item, action: "preserve", override: buildOverride(item, existingOverride, upstream, "preserve") };
273
+ }
274
+
275
+ function planStaleFile(previous) {
276
+ const destination = path.join(project, ...previous.relative.split("/"));
277
+ if (!fs.existsSync(destination)) return null;
278
+ if (force) return { ...previous, relative: previous.relative, destination, action: "force-remove", override: null };
279
+ if (matchesFingerprint(destination, previous)) return { ...previous, relative: previous.relative, destination, action: "remove", override: null };
280
+ const existingOverride = previousOverrides.get(previous.relative);
281
+ const current = fingerprintFile(destination);
282
+ const stillApproved = existingOverride?.status === "approved" && matchesFingerprint(destination, existingOverride)
283
+ && existingOverride.upstreamSha256 === null;
284
+ return {
285
+ ...previous,
286
+ relative: previous.relative,
287
+ destination,
288
+ action: "preserve-stale",
289
+ override: {
290
+ path: previous.relative,
291
+ disposition: "preserve",
292
+ status: stillApproved ? "approved" : "pending_review",
293
+ ...current,
294
+ upstreamSha256: null,
295
+ upstreamCanonicalSha256: null,
296
+ retiredFromFramework: true,
297
+ reason: "Locally modified file was preserved after its framework path retired; review ownership during migration."
298
+ }
299
+ };
300
+ }
301
+
302
+ function buildOverride(item, existing, upstream, disposition) {
303
+ const current = disposition === "absent" ? { sha256: null, canonicalSha256: null } : fingerprintFile(item.destination);
304
+ const currentMatches = disposition === "absent"
305
+ ? existing?.disposition === "absent" && !fs.existsSync(item.destination)
306
+ : existing?.disposition === "preserve" && sameFingerprint(current, existing);
307
+ const previousUpstream = existing ? {
308
+ sha256: existing.upstreamSha256,
309
+ canonicalSha256: existing.upstreamCanonicalSha256
310
+ } : null;
311
+ const upstreamMatches = previousUpstream && sameFingerprint(upstream, previousUpstream);
312
+ const approved = existing?.status === "approved" && currentMatches && upstreamMatches;
313
+ return {
314
+ path: item.relative,
315
+ disposition,
316
+ status: approved ? "approved" : "pending_review",
317
+ ...current,
318
+ upstreamSha256: upstream.sha256,
319
+ upstreamCanonicalSha256: upstream.canonicalSha256 ?? null,
320
+ reason: approved
321
+ ? existing.reason
322
+ : "Project-specific framework change was preserved automatically; review and rebase it with /migrate-project."
323
+ };
324
+ }
171
325
 
172
326
  function walk(directory) {
327
+ if (!fs.existsSync(directory)) return [];
173
328
  const output = [];
174
329
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
175
330
  const full = path.join(directory, entry.name);
@@ -179,27 +334,36 @@ function walk(directory) {
179
334
  return output.sort((a, b) => a.localeCompare(b));
180
335
  }
181
336
 
182
- function sameFile(left, right) {
183
- const a = fs.readFileSync(left);
184
- const b = fs.readFileSync(right);
185
- return a.length === b.length && a.equals(b);
337
+ function posix(value) {
338
+ return value.split(path.sep).join("/");
186
339
  }
187
340
 
188
- function hashFile(file) {
189
- return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
341
+ function isManagedRelative(value) {
342
+ if (typeof value !== "string" || !value.startsWith(".claude/")) return false;
343
+ const parts = value.split("/");
344
+ return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
190
345
  }
191
346
 
192
- function posix(value) {
193
- return value.split(path.sep).join("/");
347
+ function readJsonIfPresent(file, label) {
348
+ if (!fs.existsSync(file)) return null;
349
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); }
350
+ catch (error) {
351
+ console.error(`Invalid ${label} at ${file}: ${error.message}`);
352
+ process.exit(2);
353
+ }
354
+ }
355
+
356
+ function backupFile(source, destination) {
357
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
358
+ fs.copyFileSync(source, destination);
194
359
  }
195
360
 
196
361
  function planManagedSettings(projectRoot) {
197
362
  const settingsPath = path.join(projectRoot, ".claude", "settings.json");
198
363
  let current = {};
199
364
  if (fs.existsSync(settingsPath)) {
200
- try {
201
- current = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
202
- } catch (error) {
365
+ try { current = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
366
+ catch (error) {
203
367
  console.error(`Cannot merge invalid JSON settings at ${settingsPath}: ${error.message}`);
204
368
  process.exit(2);
205
369
  }
@@ -225,23 +389,37 @@ function planManagedSettings(projectRoot) {
225
389
 
226
390
  function installManagedSettings(plan, backupDirectory) {
227
391
  if (!plan.changed) return;
228
- if (plan.existed) {
229
- const backup = path.join(backupDirectory, ".claude", "settings.json");
230
- fs.mkdirSync(path.dirname(backup), { recursive: true });
231
- fs.copyFileSync(plan.settingsPath, backup);
232
- }
392
+ if (plan.existed) backupFile(plan.settingsPath, path.join(backupDirectory, ".claude", "settings.json"));
233
393
  fs.mkdirSync(path.dirname(plan.settingsPath), { recursive: true });
234
394
  fs.writeFileSync(plan.settingsPath, plan.nextText, "utf8");
235
395
  }
236
396
 
237
- function writeInstallState(projectRoot, profile, files, statePath) {
238
- const state = {
397
+ function writeOverrideManifest(file, overrides, previous, frameworkVersion) {
398
+ if (!overrides.length && !previous) return;
399
+ const now = new Date().toISOString();
400
+ const manifest = {
239
401
  schemaVersion: 1,
240
- frameworkVersion: fs.readFileSync(path.join(root, "VERSION"), "utf8").trim(),
402
+ frameworkVersion,
403
+ createdAt: previous?.createdAt ?? now,
404
+ updatedAt: now,
405
+ overrides
406
+ };
407
+ fs.mkdirSync(path.dirname(file), { recursive: true });
408
+ fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
409
+ }
410
+
411
+ function writeInstallState(projectRoot, profile, files, statePath, previousState, overridePaths) {
412
+ const now = new Date().toISOString();
413
+ const state = {
414
+ schemaVersion: 3,
415
+ frameworkVersion: currentVersion,
416
+ previousFrameworkVersion: previousState?.frameworkVersion ?? null,
241
417
  modelProfile: profile,
418
+ installedAt: previousState?.installedAt ?? now,
419
+ updatedAt: now,
242
420
  files: files.map((item) => ({
243
- relative: posix(item.relative),
244
- sha256: hashFile(path.join(projectRoot, item.relative))
421
+ relative: item.relative,
422
+ ...(overridePaths.has(item.relative) ? fingerprintFile(item.source) : fingerprintFile(path.join(projectRoot, ...item.relative.split("/"))))
245
423
  }))
246
424
  };
247
425
  fs.mkdirSync(path.dirname(statePath), { recursive: true });
@@ -256,12 +434,9 @@ function installClaudeImport(projectRoot, backupDirectory) {
256
434
  fs.copyFileSync(path.join(root, "CLAUDE.md"), claudePath);
257
435
  return;
258
436
  }
259
-
260
437
  const current = fs.readFileSync(claudePath, "utf8");
261
438
  if (current.includes(begin) || current.includes("@.claude/nccgs/constitution.md")) return;
262
-
263
- fs.mkdirSync(backupDirectory, { recursive: true });
264
- fs.copyFileSync(claudePath, path.join(backupDirectory, "CLAUDE.md"));
439
+ backupFile(claudePath, path.join(backupDirectory, "CLAUDE.md"));
265
440
  const separator = current.endsWith("\n") ? "\n" : "\n\n";
266
441
  fs.writeFileSync(claudePath, `${current}${separator}${block}\n`, "utf8");
267
442
  }
@@ -51,10 +51,18 @@ for (const relative of [
51
51
  "scaffold/.nccgs/project.yaml", "scaffold/.nccgs/state.md",
52
52
  "scaffold/.nccgs/requirements.yaml", "scaffold/.nccgs/templates/context-packet.yaml",
53
53
  "scaffold/.nccgs/templates/closure-record.md", "package-assets/setup-vivox-voice-chat-evals.gitignore",
54
- "scripts/cli.mjs", "scripts/install.mjs",
55
- "scripts/sync-unity-skills.mjs", "tests/framework.test.mjs"
54
+ "package-assets/nccgs-project.gitignore",
55
+ "scripts/cli.mjs", "scripts/doctor.mjs", "scripts/install.mjs",
56
+ "scripts/sync-unity-skills.mjs", "tests/framework.test.mjs",
57
+ ".claude/nccgs/tools/integrity.mjs", ".claude/nccgs/tools/approve-overrides.mjs"
56
58
  ]) requireFile(relative);
57
59
 
60
+ const scaffoldIgnore = path.join(root, "scaffold/.nccgs/.gitignore");
61
+ const scaffoldIgnoreFallback = path.join(root, "package-assets/nccgs-project.gitignore");
62
+ if (fs.existsSync(scaffoldIgnore) && fs.readFileSync(scaffoldIgnore, "utf8") !== fs.readFileSync(scaffoldIgnoreFallback, "utf8")) {
63
+ errors.push("Scaffold .nccgs/.gitignore differs from its npm fallback asset.");
64
+ }
65
+
58
66
  const versions = [
59
67
  fs.readFileSync(path.join(root, "VERSION"), "utf8").trim(),
60
68
  JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version,
@@ -128,7 +136,14 @@ for (const command of hookCommands) {
128
136
  const check = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
129
137
  if (check.status !== 0) errors.push(`Hook syntax failure ${match[1]}: ${check.stderr.trim()}`);
130
138
  }
131
- for (const file of [path.join(root, ".claude/nccgs/tools/configure-models.mjs"), path.join(root, "scripts/cli.mjs"), path.join(root, "scripts/install.mjs")]) {
139
+ for (const file of [
140
+ path.join(root, ".claude/nccgs/tools/configure-models.mjs"),
141
+ path.join(root, ".claude/nccgs/tools/integrity.mjs"),
142
+ path.join(root, ".claude/nccgs/tools/approve-overrides.mjs"),
143
+ path.join(root, "scripts/cli.mjs"),
144
+ path.join(root, "scripts/doctor.mjs"),
145
+ path.join(root, "scripts/install.mjs")
146
+ ]) {
132
147
  const check = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
133
148
  if (check.status !== 0) errors.push(`Script syntax failure ${posix(path.relative(root, file))}: ${check.stderr.trim()}`);
134
149
  }