nccgs 1.1.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.
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.1.1
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { fingerprintFile } from "./integrity.mjs";
7
+
8
+ const ownDir = path.dirname(fileURLToPath(import.meta.url));
9
+ const args = process.argv.slice(2);
10
+ const projectArg = args.indexOf("--project");
11
+ const project = path.resolve(projectArg >= 0 ? args[projectArg + 1] : path.resolve(ownDir, "../../../.."));
12
+ const approvePending = args.includes("--approve-pending");
13
+ const manifestPath = path.join(project, ".nccgs", "framework-overrides.json");
14
+
15
+ if (projectArg >= 0 && !args[projectArg + 1]) {
16
+ console.error("--project requires a directory path.");
17
+ process.exit(2);
18
+ }
19
+ if (!fs.existsSync(manifestPath)) {
20
+ console.log("No NCCGS framework overrides are recorded.");
21
+ process.exit(0);
22
+ }
23
+
24
+ let manifest;
25
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); }
26
+ catch (error) {
27
+ console.error(`Invalid framework override manifest: ${error.message}`);
28
+ process.exit(2);
29
+ }
30
+ if (manifest.schemaVersion !== 1) {
31
+ console.error(`Unsupported framework override schema: ${manifest.schemaVersion ?? "unknown"}`);
32
+ process.exit(2);
33
+ }
34
+ for (const item of manifest.overrides ?? []) {
35
+ if (!isManagedRelative(item.path) || !new Set(["preserve", "absent"]).has(item.disposition)
36
+ || !new Set(["approved", "pending_review"]).has(item.status)) {
37
+ console.error(`Invalid framework override record: ${item.path ?? "path missing"}`);
38
+ process.exit(2);
39
+ }
40
+ }
41
+
42
+ const pending = (manifest.overrides ?? []).filter((item) => item.status !== "approved");
43
+ if (!approvePending) {
44
+ if (!pending.length) console.log("All NCCGS framework overrides are approved.");
45
+ else {
46
+ console.log(`${pending.length} NCCGS framework override(s) await migration review:`);
47
+ for (const item of pending) console.log(`- ${item.path} (${item.disposition})`);
48
+ console.log("After reviewing/rebasing every item, run again with --approve-pending.");
49
+ }
50
+ process.exit(pending.length ? 1 : 0);
51
+ }
52
+
53
+ for (const item of pending) {
54
+ const file = path.join(project, ...item.path.split("/"));
55
+ if (item.disposition === "absent") {
56
+ if (fs.existsSync(file)) {
57
+ console.error(`Cannot approve absent override because the file exists: ${item.path}`);
58
+ process.exit(2);
59
+ }
60
+ item.sha256 = null;
61
+ item.canonicalSha256 = null;
62
+ } else {
63
+ if (!fs.existsSync(file)) {
64
+ console.error(`Cannot approve preserved override because the file is missing: ${item.path}`);
65
+ process.exit(2);
66
+ }
67
+ Object.assign(item, fingerprintFile(file));
68
+ }
69
+ item.status = "approved";
70
+ item.reviewedAt = new Date().toISOString();
71
+ }
72
+
73
+ manifest.updatedAt = new Date().toISOString();
74
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
75
+ console.log(`Approved ${pending.length} NCCGS framework override(s) after migration review.`);
76
+
77
+ function isManagedRelative(value) {
78
+ if (typeof value !== "string" || !value.startsWith(".claude/")) return false;
79
+ const parts = value.split("/");
80
+ return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
81
+ }
@@ -6,6 +6,10 @@ const ownDir = path.dirname(fileURLToPath(import.meta.url));
6
6
  const args = process.argv.slice(2);
7
7
  const projectArg = args.indexOf("--project");
8
8
  const profileArg = args.indexOf("--profile");
9
+ const skippedPaths = new Set();
10
+ for (let index = 0; index < args.length; index++) {
11
+ if (args[index] === "--skip-path" && args[index + 1]) skippedPaths.add(args[index + 1].replace(/\\/g, "/"));
12
+ }
9
13
  const project = path.resolve(projectArg >= 0 ? args[projectArg + 1] : path.resolve(ownDir, "../../../.."));
10
14
  const catalogPath = path.join(project, ".claude", "nccgs", "studio.json");
11
15
  const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8"));
@@ -19,6 +23,7 @@ if (!profile) throw new Error(`Unknown model profile '${profileName}'. Expected:
19
23
  let changed = 0;
20
24
  for (const agent of catalog.agents) {
21
25
  const file = path.join(project, ".claude", "agents", `${agent.name}.md`);
26
+ if (skippedPaths.has(`.claude/agents/${agent.name}.md`)) continue;
22
27
  if (!fs.existsSync(file)) continue;
23
28
  let text = fs.readFileSync(file, "utf8");
24
29
  const model = profile[agent.modelClass];
@@ -0,0 +1,69 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+
4
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
5
+
6
+ export function sha256(value) {
7
+ return crypto.createHash("sha256").update(value).digest("hex");
8
+ }
9
+
10
+ export function textContent(value) {
11
+ const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value);
12
+ if (buffer.includes(0)) return null;
13
+ try { return utf8.decode(buffer); }
14
+ catch { return null; }
15
+ }
16
+
17
+ export function normalizeLf(value) {
18
+ const text = textContent(value);
19
+ if (text === null) return null;
20
+ return `${text.replace(/\r\n?/g, "\n").replace(/\n*$/, "")}\n`;
21
+ }
22
+
23
+ export function fingerprintBuffer(value) {
24
+ const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value);
25
+ const normalized = normalizeLf(buffer);
26
+ return {
27
+ sha256: sha256(buffer),
28
+ ...(normalized === null ? {} : { canonicalSha256: sha256(Buffer.from(normalized, "utf8")) })
29
+ };
30
+ }
31
+
32
+ export function fingerprintFile(file) {
33
+ return fingerprintBuffer(fs.readFileSync(file));
34
+ }
35
+
36
+ export function equivalentBuffers(left, right) {
37
+ const a = fingerprintBuffer(left);
38
+ const b = fingerprintBuffer(right);
39
+ if (a.sha256 === b.sha256) return true;
40
+ return Boolean(a.canonicalSha256 && a.canonicalSha256 === b.canonicalSha256);
41
+ }
42
+
43
+ export function equivalentFiles(left, right) {
44
+ return equivalentBuffers(fs.readFileSync(left), fs.readFileSync(right));
45
+ }
46
+
47
+ export function matchesFingerprint(file, expected) {
48
+ if (!expected || !fs.existsSync(file)) return false;
49
+ const buffer = fs.readFileSync(file);
50
+ const actual = fingerprintBuffer(buffer);
51
+ if (expected.sha256 && actual.sha256 === expected.sha256) return true;
52
+ if (expected.canonicalSha256 && actual.canonicalSha256 === expected.canonicalSha256) return true;
53
+
54
+ // NCCGS 1.1.0 stored only a raw hash. Accept the two normal Git text
55
+ // representations so a checkout's LF policy does not manufacture drift.
56
+ if (expected.sha256 && actual.canonicalSha256) {
57
+ const lf = normalizeLf(buffer);
58
+ if (sha256(Buffer.from(lf, "utf8")) === expected.sha256) return true;
59
+ const crlf = lf.replace(/\n/g, "\r\n");
60
+ if (sha256(Buffer.from(crlf, "utf8")) === expected.sha256) return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ export function sameFingerprint(left, right) {
66
+ if (!left || !right) return false;
67
+ if (left.sha256 && right.sha256 && left.sha256 === right.sha256) return true;
68
+ return Boolean(left.canonicalSha256 && right.canonicalSha256 && left.canonicalSha256 === right.canonicalSha256);
69
+ }
@@ -130,7 +130,7 @@
130
130
  "defaultClassification": "STANDARD"
131
131
  },
132
132
  {
133
- "name": "code-review",
133
+ "name": "nccgs-code-review",
134
134
  "type": "module",
135
135
  "primaryRoles": [
136
136
  "nccgs-programming-lead"
@@ -8,6 +8,8 @@ allowed-tools: Read, Glob, Grep, Write, Edit, Bash, Task, AskUserQuestion
8
8
 
9
9
  Treat brownfield adoption and migrations explicitly requested by the installer as CONTROLLED work. Read the complete [migration procedure](references/procedure.md). Default to `full` when no mode is given. Do not require migration merely because NCCGS was updated when the installer reported `No project migration required`.
10
10
 
11
+ When `.nccgs/framework-overrides.json` exists, treat every `pending_review` entry as preserved project truth, not installer debris. Compare the project version with the current NCCGS version, keep or rebase the customization deliberately, and record the decision in the migration plan. Only after every pending item has been reviewed and the final bytes or intentional absence are correct, run `node .claude/nccgs/tools/approve-overrides.mjs --project . --approve-pending`. Never approve first to make `doctor` green.
12
+
11
13
  ## Modes
12
14
 
13
15
  - `full` (default): run review, plan, apply, and verify in one invocation. Continue across phase boundaries without asking for ceremonial approval. Pause only for an unresolved Product Owner decision, destructive action, external side effect, or authority outside the current request.
@@ -16,7 +18,7 @@ Treat brownfield adoption and migrations explicitly requested by the installer a
16
18
  - `apply`: execute an approved plan in validated batches with backups.
17
19
  - `verify`: prove policy, routing, hooks, Unity integration, evidence, closure, and a representative work path.
18
20
 
19
- Migration decisions must cover canonical sources, ownership/protected paths, architecture, Unity tooling, model profile/fallback, enabled departments, context packets, verification baselines, closure safeguards, Git tracking, local settings, obsolete global skills, and rollback.
21
+ Migration decisions must cover canonical sources, ownership/protected paths, architecture, Unity tooling, model profile/fallback, enabled departments, context packets, verification baselines, closure safeguards, Git tracking, local settings, preserved framework overrides, obsolete global skills, and rollback.
20
22
 
21
23
  In `full` mode, write the plan before applying it, preserve the same evidence and rollback guarantees as the individual modes, and stop safely if review finds a decision that would materially change project truth. A reversible plan created within the same invocation is approved for in-scope application unless project policy explicitly requires a separate approval.
22
24
 
@@ -28,6 +28,10 @@ requires no project migration, report that fact and do not manufacture migration
28
28
  strongest active safeguard until the PO explicitly accepts a weaker model.
29
29
  7. Determine the project model profile, enabled departments, permitted fallback,
30
30
  orchestration depth/parallelism, context-packet locations, and hook compatibility.
31
+ 8. If `.nccgs/framework-overrides.json` exists, inspect every override. Diff its
32
+ preserved bytes or intentional absence against the installed upstream version,
33
+ and distinguish a still-needed project policy from a customization now supplied
34
+ by NCCGS. Line-ending-only differences are not overrides.
31
35
 
32
36
  ## Plan
33
37
 
@@ -42,6 +46,7 @@ Create `.nccgs/migrations/<date>-migration-plan.md` with:
42
46
  - ordered reversible batches and dependencies;
43
47
  - exact broad/irreversible decisions requiring PO authority;
44
48
  - backup/rollback method;
49
+ - an explicit KEEP/REBASE/RETIRE decision for every pending framework override;
45
50
  - validation for each batch and final end-to-end proof.
46
51
 
47
52
  Prioritize authority and policy conflicts, then model/routing conflicts, Unity
@@ -64,6 +69,12 @@ traceability/evidence, lifecycle hooks, workflow replacement, and cleanup.
64
69
  - Do not leave two active agents/skills advertising mutually exclusive Unity
65
70
  execution bridges. Disable obsolete global routing signals outside the repository
66
71
  through an explicit user action.
72
+ - Preserve pending framework overrides while reviewing them. Rebase each retained
73
+ override onto the newly installed upstream content when needed. After all entries
74
+ match the migration decisions, run
75
+ `node .claude/nccgs/tools/approve-overrides.mjs --project . --approve-pending`.
76
+ Approval records the final hashes; it is the last apply step, never a substitute
77
+ for the review.
67
78
 
68
79
  ## Verify
69
80
 
@@ -72,4 +83,5 @@ role/workflow catalogs, bounded delegation, hook health, context packet freshnes
72
83
  canonical map, requirements/state, Unity Skills integrity, CLI/Pipeline operation,
73
84
  relevant tests, runtime evidence, and subjective-gate routing. Verify that required
74
85
  linked artifacts block DONE in `linked` or `strict` mode. Use a small representative
75
- task or dry run when product changes are not authorized.
86
+ task or dry run when product changes are not authorized. Run `npx nccgs@latest doctor`
87
+ and require zero pending framework overrides before declaring migration complete.
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: code-review
2
+ name: nccgs-code-review
3
3
  description: Performs a read-only code review against requirements, architecture, Unity constraints, ownership, tests, performance, security, and regression risk.
4
4
  allowed-tools: Read, Glob, Grep, Bash, Task
5
5
  ---
@@ -11,4 +11,3 @@ Read `.nccgs/project.yaml`, `.nccgs/state.md`, and the active context packet whe
11
11
  Read the context packet, final diff, governing decisions, affected callers, and tests. Route distinct Unity, networking, security, performance, or UI lenses when needed. Report only actionable findings with severity, exact evidence, impact, and remediation direction. Verify that tests assert requirements and that serialized/lifecycle behavior is addressed. Do not edit or praise generally; state when no material issue is found.
12
12
 
13
13
  Return outcome, evidence, affected records, blockers or decisions, residual risk, and the exact next workflow. Do not claim DONE unless this is the `closure` skill and every configured gate passes.
14
-
package/README.md CHANGED
@@ -5,7 +5,7 @@ specialization of a full game-development team with project-aware governance,
5
5
  bounded orchestration, configurable model routing, official Unity Skills, objective
6
6
  evidence, and strict closure.
7
7
 
8
- Version 1.1 contains:
8
+ Version 1.1.1 contains:
9
9
 
10
10
  - 45 studio agents across leadership, design, programming, Unity, content, quality,
11
11
  and release;
@@ -56,9 +56,12 @@ npx nccgs@latest doctor
56
56
  The current directory is the installation target. `--project <directory>` remains
57
57
  available for automation, but is not needed for ordinary use.
58
58
 
59
- Updates are hash-aware. Files that still match the previous install state are backed
60
- up and replaced automatically; project-modified files block the update until they
61
- are reviewed. Use `--force` only to back up and replace intentional overrides.
59
+ Updates are hash-aware and line-ending-aware. Files that still match the previous
60
+ install state update automatically even when Git converted CRLF to LF. Genuine
61
+ project modifications are preserved, recorded in `.nccgs/framework-overrides.json`,
62
+ and routed through one `/migrate-project` review instead of blocking the whole
63
+ framework update. `--force` remains an explicit discard-and-replace escape hatch;
64
+ it is not the normal upgrade path.
62
65
 
63
66
  The installer validates the package, backs up replaced framework/settings files to
64
67
  `.nccgs/backups/<timestamp>/`, copies `.claude/`, initializes missing `.nccgs/`
@@ -113,7 +116,7 @@ node .\.claude\nccgs\tools\configure-models.mjs --project . --profile inherit
113
116
  | `/status` | Current work, roles/models, evidence, blockers, closure, and next action |
114
117
 
115
118
  Thirty-six discoverable workflows include focused modules for architecture decisions,
116
- context packets, story readiness, code/design/evidence review, closure, QA planning,
119
+ context packets, story readiness, NCCGS code/design/evidence review, closure, QA planning,
117
120
  triage, performance, security, accessibility, balance, prototypes, playtests,
118
121
  localization, hotfixes, milestones, assets, compatibility, incidents, and UI review.
119
122
  Ordinary natural-language requests still work; the user does not need to manually
@@ -124,6 +127,7 @@ chain these modules.
124
127
  ```text
125
128
  .nccgs/
126
129
  project.yaml authority, model, studio, context, ownership and gate policy
130
+ framework-overrides.json reviewed project-specific framework customizations
127
131
  state.md compact recovery checkpoint
128
132
  requirements.yaml targeted requirement registry
129
133
  context/ task-specific source and ownership maps
package/UPGRADING.md CHANGED
@@ -9,10 +9,23 @@ npx nccgs@latest update
9
9
  npx nccgs@latest doctor
10
10
  ```
11
11
 
12
- Version 1.1 adds hash-aware, idempotent updates. Managed files that still match the
13
- previous install state update automatically. Project-modified framework files stop
14
- the update and require review; `--force` backs them up before replacement. Existing
15
- `.nccgs/project.yaml` remains project-owned and is never blindly overwritten.
12
+ Version 1.1.1 makes updates hash-aware, line-ending-aware, and override-aware.
13
+ Managed files that still match the previous install state update automatically;
14
+ CRLF/LF conversion alone is accepted as the same text. Genuine project-modified or
15
+ intentionally missing framework files are preserved and recorded in
16
+ `.nccgs/framework-overrides.json` as `pending_review`. The rest of the framework is
17
+ updated normally, so `--force` is no longer required to get past intentional local
18
+ customizations. Existing `.nccgs/project.yaml` remains project-owned and is never
19
+ blindly overwritten.
20
+
21
+ When pending overrides are reported, open a fresh Claude Code session and run
22
+ `/migrate-project` once. The workflow reviews and rebases each override, migrates the
23
+ project policy when needed, and records approval of the final hashes. A direct
24
+ `doctor` remains red until pending override review is complete. Use `--force` only
25
+ when you intentionally want to back up and discard every detected customization.
26
+
27
+ NCCGS 1.1.1 also names its specialized review command `/nccgs-code-review` to avoid
28
+ colliding with Claude Code's built-in `/code-review` command.
16
29
 
17
30
  When the installer reports that project migration is required, open a new Claude
18
31
  Code session and run one command:
package/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.1.1
@@ -1,6 +1,6 @@
1
- # Hướng dẫn migrate và sử dụng NCCGS 1.1
1
+ # Hướng dẫn migrate và sử dụng NCCGS 1.1.1
2
2
 
3
- NCCGS 1.1 là studio operating system dành cho Unity project trong Claude Code. Nó
3
+ NCCGS 1.1.1 là studio operating system dành cho Unity project trong Claude Code. Nó
4
4
  không thay thế cách chat trực tiếp; nó tự phân loại rủi ro, chọn workflow, agent và
5
5
  model phù hợp, sử dụng official Unity Skills, thu thập bằng chứng và áp dụng closure.
6
6
 
@@ -46,9 +46,12 @@ npx nccgs@latest doctor
46
46
  NCCGS mặc định cài vào directory hiện tại. Bạn không cần truyền đường dẫn project;
47
47
  hãy mở PowerShell tại Unity project root hoặc dùng `cd` đến đó trước.
48
48
 
49
- Từ 1.1, update kiểm tra hash của lần cài trước. File NCCGS chưa bị project sửa sẽ tự
50
- được backup và cập nhật. File đã sửa tay sẽ chặn update; chỉ dùng `--force` sau khi
51
- đã review và chấp nhận thay thế bản override.
49
+ Từ 1.1.1, update kiểm tra cả raw hash và hash text đã chuẩn hóa line ending. Việc Git
50
+ đổi CRLF/LF không còn bị báo nhầm là sửa file. File thật sự đã customize hoặc cố ý
51
+ xóa sẽ được giữ nguyên, ghi vào `.nccgs/framework-overrides.json` với trạng thái
52
+ `pending_review`, còn các file an toàn khác vẫn cập nhật bình thường. Sau đó chỉ cần
53
+ chạy `/migrate-project` một lần để review/rebase và phê duyệt các override. Không cần
54
+ `--force`; chỉ dùng cờ đó khi thực sự muốn backup rồi loại bỏ customization.
52
55
 
53
56
  Installer sẽ:
54
57
 
@@ -60,6 +63,10 @@ Installer sẽ:
60
63
  6. cấu hình model cho agents;
61
64
  7. thêm NCCGS import vào `CLAUDE.md`.
62
65
 
66
+ Nếu installer báo `Project migration required`, mở Claude Code session mới và chạy
67
+ `/migrate-project`. `doctor` trực tiếp sẽ tiếp tục báo pending cho tới khi workflow
68
+ đã review xong mọi override; đây là safety gate có chủ đích.
69
+
63
70
  Sau khi cài, đóng phiên Claude Code cũ và mở phiên mới tại project root.
64
71
 
65
72
  ## 3. Chọn model profile
@@ -185,7 +192,7 @@ NCCGS sẽ tự chọn:
185
192
  | `/release` | Tạo release candidate |
186
193
  | `/status` | Xem trạng thái, model, agents, evidence và blockers |
187
194
 
188
- Các workflow chuyên biệt như `/architecture-decision`, `/code-review`,
195
+ Các workflow chuyên biệt như `/architecture-decision`, `/nccgs-code-review`,
189
196
  `/performance-audit`, `/security-audit`, `/balance-review`, `/playtest`, `/hotfix`,
190
197
  `/incident-recovery` có thể gọi trực tiếp, nhưng thông thường `/work` tự phối hợp.
191
198
 
package/docs/WORKFLOWS.md CHANGED
@@ -33,7 +33,7 @@ available when a user wants a bounded workflow or output.
33
33
  - **Design**: `design`, `design-review`, `balance-review`, `accessibility-review`,
34
34
  `ui-review`, `prototype-feature`, `playtest`.
35
35
  - **Technical**: `architecture-decision`, `dependency-review`,
36
- `compatibility-review`, `code-review`, `performance-audit`, `security-audit`.
36
+ `compatibility-review`, `nccgs-code-review`, `performance-audit`, `security-audit`.
37
37
  - **Implementation/quality**: `work`, `qa-plan`, `test`, `bug-triage`,
38
38
  `evidence-review`, `review`, `asset-audit`, `closure`.
39
39
  - **Delivery/recovery**: `release-readiness`, `release`, `hotfix`,
@@ -0,0 +1,2 @@
1
+ backups/
2
+ runtime/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nccgs",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Unity-first game-studio operating system for Claude Code",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/scripts/cli.mjs CHANGED
@@ -53,7 +53,7 @@ Install target:
53
53
 
54
54
  Options:
55
55
  --dry-run Preview without writing files
56
- --force Back up and replace framework conflicts
56
+ --force Back up and discard detected project overrides
57
57
  --model-profile <profile> quality, balanced, or inherit
58
58
  --project <directory> Optional explicit target directory
59
59
  --allow-non-unity Allow a target without Unity markers`);
@@ -1,39 +1,70 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import crypto from "node:crypto";
4
3
  import fs from "node:fs";
5
4
  import path from "node:path";
5
+ import { matchesFingerprint, sameFingerprint } from "../.claude/nccgs/tools/integrity.mjs";
6
6
 
7
7
  const args = process.argv.slice(2);
8
8
  const projectIndex = args.indexOf("--project");
9
- const allowPolicyMigration = args.includes("--allow-policy-migration");
9
+ const allowMigrationRequired = args.includes("--allow-migration-required") || args.includes("--allow-policy-migration");
10
10
  const warnings = [];
11
+ const errors = [];
11
12
  if (projectIndex >= 0 && !args[projectIndex + 1]) {
12
13
  console.error("--project requires a directory path.");
13
14
  process.exit(2);
14
15
  }
15
16
 
16
17
  const project = projectIndex >= 0 ? path.resolve(args[projectIndex + 1]) : process.cwd();
17
- const errors = [];
18
18
  for (const marker of ["Assets", "Packages", "ProjectSettings"]) {
19
19
  if (!fs.existsSync(path.join(project, marker))) errors.push(`Missing Unity marker: ${marker}`);
20
20
  }
21
21
 
22
22
  const statePath = path.join(project, ".claude", "nccgs", "install-state.json");
23
- let state = null;
24
- if (!fs.existsSync(statePath)) errors.push("NCCGS install state is missing.");
25
- else {
26
- try { state = JSON.parse(fs.readFileSync(statePath, "utf8")); }
27
- catch (error) { errors.push(`Install state is invalid JSON: ${error.message}`); }
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);
28
38
  }
29
39
 
40
+ let verifiedManaged = 0;
30
41
  if (state) {
31
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
+ }
32
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
+ }
33
59
  if (!fs.existsSync(file)) errors.push(`Managed file is missing: ${item.relative}`);
34
- else if (hashFile(file) !== item.sha256) errors.push(`Managed file was modified: ${item.relative}`);
60
+ else if (!matchesFingerprint(file, item)) errors.push(`Managed file was modified: ${item.relative}`);
61
+ else verifiedManaged++;
35
62
  }
36
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
+ }
37
68
 
38
69
  const claudePath = path.join(project, "CLAUDE.md");
39
70
  if (!fs.existsSync(claudePath) || !fs.readFileSync(claudePath, "utf8").includes("@.claude/nccgs/constitution.md")) {
@@ -46,15 +77,12 @@ if (!fs.existsSync(policyPath)) errors.push(".nccgs/project.yaml is missing.");
46
77
  else {
47
78
  const match = fs.readFileSync(policyPath, "utf8").match(/^schema_version:\s*(\d+)\s*$/m);
48
79
  policySchema = match ? Number(match[1]) : null;
49
- if (policySchema !== 2) {
50
- const message = `Project policy schema must be 2; found ${policySchema ?? "unknown"}.`;
51
- if (allowPolicyMigration) warnings.push(message);
52
- else errors.push(message);
53
- }
80
+ if (policySchema !== 2) migrationIssue(`Project policy schema must be 2; found ${policySchema ?? "unknown"}.`);
54
81
  }
55
82
 
56
83
  if (errors.length) {
57
84
  for (const error of errors) console.error(`ERROR: ${error}`);
85
+ for (const warning of warnings) console.warn(`WARN: ${warning}`);
58
86
  console.error(`NCCGS doctor failed with ${errors.length} error(s).`);
59
87
  process.exit(1);
60
88
  }
@@ -63,9 +91,39 @@ for (const warning of warnings) console.warn(`WARN: ${warning}`);
63
91
  console.log("NCCGS doctor passed.");
64
92
  console.log(`Framework version: ${state.frameworkVersion}`);
65
93
  console.log(`Model profile: ${state.modelProfile}`);
66
- console.log(`Managed files verified: ${state.files.length}`);
94
+ console.log(`Managed files verified: ${verifiedManaged}`);
95
+ console.log(`Framework overrides: ${overrides.size} (${[...overrides.values()].filter((item) => item.status !== "approved").length} pending review)`);
67
96
  console.log(`Project policy schema: ${policySchema ?? "migration required"}`);
68
97
 
69
- function hashFile(file) {
70
- return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
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("..");
71
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, "..");
@@ -52,74 +57,108 @@ if (validation.status !== 0) {
52
57
  const frameworkSource = path.join(root, ".claude");
53
58
  const frameworkFiles = walk(frameworkSource).map((file) => ({
54
59
  source: file,
55
- relative: path.relative(root, file),
60
+ relative: posix(path.relative(root, file)),
56
61
  destination: path.join(project, path.relative(root, file)),
57
62
  }));
58
63
  const npmExcludedUnityFile = ".claude/skills/setup-vivox-voice-chat/evals/.gitignore";
59
- if (!frameworkFiles.some((item) => posix(item.relative) === npmExcludedUnityFile)) {
64
+ if (!frameworkFiles.some((item) => item.relative === npmExcludedUnityFile)) {
60
65
  const fallback = path.join(root, "package-assets", "setup-vivox-voice-chat-evals.gitignore");
61
66
  if (fs.existsSync(fallback)) {
62
67
  frameworkFiles.push({
63
68
  source: fallback,
64
- relative: npmExcludedUnityFile.split("/").join(path.sep),
69
+ relative: npmExcludedUnityFile,
65
70
  destination: path.join(project, ...npmExcludedUnityFile.split("/"))
66
71
  });
67
72
  frameworkFiles.sort((a, b) => a.relative.localeCompare(b.relative));
68
73
  }
69
74
  }
75
+
70
76
  const installStatePath = path.join(project, ".claude", "nccgs", "install-state.json");
71
- let previousInstallState = null;
72
- if (fs.existsSync(installStatePath)) {
73
- try { previousInstallState = JSON.parse(fs.readFileSync(installStatePath, "utf8")); }
74
- catch (error) {
75
- console.error(`Invalid NCCGS install state at ${installStatePath}: ${error.message}`);
76
- process.exit(2);
77
- }
78
- }
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");
79
80
  if (updateOnly && !previousInstallState) {
80
81
  console.error("NCCGS is not installed in this project. Run `npx nccgs@latest install` first.");
81
82
  process.exit(2);
82
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"}`);
97
+ process.exit(2);
98
+ }
99
+ }
100
+
83
101
  const previousFiles = new Map((previousInstallState?.files ?? []).map((item) => [item.relative, item]));
84
- const currentFrameworkNames = new Set(frameworkFiles.map((item) => posix(item.relative)));
85
- const staleFrameworkFiles = (previousInstallState?.files ?? [])
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();
105
+
106
+ const filePlans = frameworkFiles.map((item) => planFrameworkFile(item));
107
+ const stalePlans = (previousInstallState?.files ?? [])
86
108
  .filter((item) => !currentFrameworkNames.has(item.relative))
87
- .map((item) => ({
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({
88
123
  ...item,
89
- destination: path.join(project, ...item.relative.split("/")),
90
- unmodified: fs.existsSync(path.join(project, ...item.relative.split("/"))) && hashFile(path.join(project, ...item.relative.split("/"))) === item.sha256
91
- }))
92
- .filter((item) => fs.existsSync(item.destination));
93
-
94
- const differentFiles = frameworkFiles.filter((item) => {
95
- if (!fs.existsSync(item.destination)) return false;
96
- return !sameFile(item.source, item.destination);
97
- });
98
- const automaticReplacements = differentFiles.filter((item) => {
99
- const previous = previousFiles.get(posix(item.relative));
100
- return previous && hashFile(item.destination) === previous.sha256;
101
- });
102
- const conflicts = differentFiles.filter((item) => !automaticReplacements.includes(item));
103
- const automaticStale = staleFrameworkFiles.filter((item) => item.unmodified);
104
- const staleConflicts = staleFrameworkFiles.filter((item) => !item.unmodified);
105
-
106
- if ((conflicts.length || staleConflicts.length) && !force) {
107
- console.error(`NCCGS found ${conflicts.length} modified and ${staleConflicts.length} modified-stale framework file(s):`);
108
- for (const item of conflicts.slice(0, 30)) console.error(`- ${posix(item.relative)}`);
109
- for (const item of staleConflicts.slice(0, 30)) console.error(`- stale: ${item.relative}`);
110
- if (conflicts.length > 30) console.error(`- ... ${conflicts.length - 30} more`);
111
- console.error("These files differ from the hashes recorded at the previous install.");
112
- console.error("Review them, then re-run with --force to back up and replace intentional overrides.");
113
- process.exit(2);
124
+ status: item.status === "approved" && stillMatches ? "approved" : "pending_review",
125
+ reason: stillMatches ? item.reason : "Previously approved override changed and requires migration review."
126
+ });
114
127
  }
115
128
 
116
- const newFiles = frameworkFiles.filter((item) => !fs.existsSync(item.destination));
117
- const identicalFiles = frameworkFiles.length - newFiles.length - differentFiles.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
+
118
145
  const scaffoldFiles = walk(path.join(root, "scaffold")).map((file) => ({
119
146
  source: file,
120
147
  relative: path.relative(path.join(root, "scaffold"), file),
121
148
  destination: path.join(project, path.relative(path.join(root, "scaffold"), file)),
122
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
+ }
123
162
  const newScaffold = scaffoldFiles.filter((item) => !fs.existsSync(item.destination));
124
163
  const settingsPlan = planManagedSettings(project);
125
164
  const targetPolicyPath = path.join(project, ".nccgs", "project.yaml");
@@ -132,12 +171,16 @@ if (!new Set(["quality", "balanced", "inherit"]).has(modelProfile)) {
132
171
  }
133
172
 
134
173
  console.log(`NCCGS installation target: ${project}`);
135
- console.log(`Framework version: ${previousInstallState?.frameworkVersion ?? "not installed"} -> ${fs.readFileSync(path.join(root, "VERSION"), "utf8").trim()}`);
136
- console.log(`Framework files: ${frameworkFiles.length} (${newFiles.length} new, ${identicalFiles} current, ${automaticReplacements.length} automatic update, ${conflicts.length} forced override)`);
137
- console.log(`Stale framework files: ${automaticStale.length} automatic removal, ${staleConflicts.length} forced removal`);
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`);
138
177
  console.log(`Project scaffold files to initialize: ${newScaffold.length}`);
139
178
  console.log(`Managed Claude settings: ${settingsPlan.changed ? "update" : "already current"}`);
140
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
+ }
141
184
  if (dryRun) {
142
185
  console.log("Dry run complete; no files changed.");
143
186
  process.exit(0);
@@ -145,23 +188,21 @@ if (dryRun) {
145
188
 
146
189
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
147
190
  const backupRoot = path.join(project, ".nccgs", "backups", stamp);
148
- for (const item of differentFiles) {
149
- const backup = path.join(backupRoot, item.relative);
150
- fs.mkdirSync(path.dirname(backup), { recursive: true });
151
- fs.copyFileSync(item.destination, backup);
152
- }
153
- for (const item of staleFrameworkFiles) {
154
- const backup = path.join(backupRoot, ...item.relative.split("/"));
155
- fs.mkdirSync(path.dirname(backup), { recursive: true });
156
- 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("/")));
157
196
  fs.unlinkSync(item.destination);
158
197
  }
198
+ if (previousOverrideManifest && JSON.stringify(previousOverrideManifest.overrides ?? []) !== JSON.stringify(overrideRecords)) {
199
+ backupFile(overrideManifestPath, path.join(backupRoot, ".nccgs", "framework-overrides.json"));
200
+ }
159
201
 
160
- for (const item of frameworkFiles) {
202
+ for (const item of filePlans.filter((entry) => new Set(["install", "replace", "force-replace"]).has(entry.action))) {
161
203
  fs.mkdirSync(path.dirname(item.destination), { recursive: true });
162
204
  fs.copyFileSync(item.source, item.destination);
163
205
  }
164
-
165
206
  for (const item of newScaffold) {
166
207
  fs.mkdirSync(path.dirname(item.destination), { recursive: true });
167
208
  fs.copyFileSync(item.source, item.destination);
@@ -170,10 +211,14 @@ for (const item of newScaffold) {
170
211
  installManagedSettings(settingsPlan, backupRoot);
171
212
  installClaudeImport(project, backupRoot);
172
213
 
214
+ const skipAgentPaths = preservedFiles
215
+ .filter((item) => item.relative.startsWith(".claude/agents/"))
216
+ .flatMap((item) => ["--skip-path", item.relative]);
173
217
  const modelConfiguration = spawnSync(process.execPath, [
174
- path.join(project, ".claude", "nccgs", "tools", "configure-models.mjs"),
218
+ path.join(root, ".claude", "nccgs", "tools", "configure-models.mjs"),
175
219
  "--project", project,
176
220
  "--profile", modelProfile,
221
+ ...skipAgentPaths
177
222
  ], { cwd: project, encoding: "utf8" });
178
223
  if (modelConfiguration.status !== 0) {
179
224
  process.stderr.write(modelConfiguration.stdout ?? "");
@@ -182,12 +227,13 @@ if (modelConfiguration.status !== 0) {
182
227
  process.exit(1);
183
228
  }
184
229
 
185
- writeInstallState(project, modelProfile, frameworkFiles, installStatePath, previousInstallState);
230
+ writeOverrideManifest(overrideManifestPath, overrideRecords, previousOverrideManifest, currentVersion);
231
+ writeInstallState(project, modelProfile, frameworkFiles, installStatePath, previousInstallState, new Set(overrideRecords.map((item) => item.path)));
186
232
 
187
233
  const doctor = spawnSync(process.execPath, [
188
234
  path.join(scriptDir, "doctor.mjs"),
189
235
  "--project", project,
190
- "--allow-policy-migration"
236
+ "--allow-migration-required"
191
237
  ], { cwd: project, encoding: "utf8" });
192
238
  process.stdout.write(doctor.stdout ?? "");
193
239
  process.stderr.write(doctor.stderr ?? "");
@@ -197,10 +243,12 @@ if (doctor.status !== 0) {
197
243
  }
198
244
 
199
245
  console.log("NCCGS installation complete.");
200
- if (differentFiles.length || staleFrameworkFiles.length || settingsPlan.changed) console.log(`Changed shared files backed up under: ${backupRoot}`);
246
+ if (fs.existsSync(backupRoot)) {
247
+ console.log(`Replaced shared files were backed up under: ${backupRoot}`);
248
+ }
201
249
  const installedPolicy = fs.readFileSync(targetPolicyPath, "utf8");
202
250
  const installedPolicySchema = Number(installedPolicy.match(/^schema_version:\s*(\d+)\s*$/m)?.[1] ?? 0);
203
- if (installedPolicySchema !== 2) {
251
+ if (installedPolicySchema !== 2 || pendingOverrides.length) {
204
252
  console.log("Project migration required: open a new Claude Code session and run `/migrate-project` once.");
205
253
  } else if (!previousInstallState) {
206
254
  console.log("First NCCGS adoption: run `/migrate-project` once for an existing project; a new project can start with `/status`.");
@@ -208,7 +256,75 @@ if (installedPolicySchema !== 2) {
208
256
  console.log("No project migration required for this NCCGS update.");
209
257
  }
210
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
+ }
325
+
211
326
  function walk(directory) {
327
+ if (!fs.existsSync(directory)) return [];
212
328
  const output = [];
213
329
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
214
330
  const full = path.join(directory, entry.name);
@@ -218,27 +334,36 @@ function walk(directory) {
218
334
  return output.sort((a, b) => a.localeCompare(b));
219
335
  }
220
336
 
221
- function sameFile(left, right) {
222
- const a = fs.readFileSync(left);
223
- const b = fs.readFileSync(right);
224
- return a.length === b.length && a.equals(b);
337
+ function posix(value) {
338
+ return value.split(path.sep).join("/");
225
339
  }
226
340
 
227
- function hashFile(file) {
228
- 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("..");
229
345
  }
230
346
 
231
- function posix(value) {
232
- 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);
233
359
  }
234
360
 
235
361
  function planManagedSettings(projectRoot) {
236
362
  const settingsPath = path.join(projectRoot, ".claude", "settings.json");
237
363
  let current = {};
238
364
  if (fs.existsSync(settingsPath)) {
239
- try {
240
- current = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
241
- } catch (error) {
365
+ try { current = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
366
+ catch (error) {
242
367
  console.error(`Cannot merge invalid JSON settings at ${settingsPath}: ${error.message}`);
243
368
  process.exit(2);
244
369
  }
@@ -264,27 +389,37 @@ function planManagedSettings(projectRoot) {
264
389
 
265
390
  function installManagedSettings(plan, backupDirectory) {
266
391
  if (!plan.changed) return;
267
- if (plan.existed) {
268
- const backup = path.join(backupDirectory, ".claude", "settings.json");
269
- fs.mkdirSync(path.dirname(backup), { recursive: true });
270
- fs.copyFileSync(plan.settingsPath, backup);
271
- }
392
+ if (plan.existed) backupFile(plan.settingsPath, path.join(backupDirectory, ".claude", "settings.json"));
272
393
  fs.mkdirSync(path.dirname(plan.settingsPath), { recursive: true });
273
394
  fs.writeFileSync(plan.settingsPath, plan.nextText, "utf8");
274
395
  }
275
396
 
276
- function writeInstallState(projectRoot, profile, files, statePath, previousState) {
397
+ function writeOverrideManifest(file, overrides, previous, frameworkVersion) {
398
+ if (!overrides.length && !previous) return;
399
+ const now = new Date().toISOString();
400
+ const manifest = {
401
+ schemaVersion: 1,
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) {
277
412
  const now = new Date().toISOString();
278
413
  const state = {
279
- schemaVersion: 2,
280
- frameworkVersion: fs.readFileSync(path.join(root, "VERSION"), "utf8").trim(),
414
+ schemaVersion: 3,
415
+ frameworkVersion: currentVersion,
281
416
  previousFrameworkVersion: previousState?.frameworkVersion ?? null,
282
417
  modelProfile: profile,
283
418
  installedAt: previousState?.installedAt ?? now,
284
419
  updatedAt: now,
285
420
  files: files.map((item) => ({
286
- relative: posix(item.relative),
287
- 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("/"))))
288
423
  }))
289
424
  };
290
425
  fs.mkdirSync(path.dirname(statePath), { recursive: true });
@@ -299,12 +434,9 @@ function installClaudeImport(projectRoot, backupDirectory) {
299
434
  fs.copyFileSync(path.join(root, "CLAUDE.md"), claudePath);
300
435
  return;
301
436
  }
302
-
303
437
  const current = fs.readFileSync(claudePath, "utf8");
304
438
  if (current.includes(begin) || current.includes("@.claude/nccgs/constitution.md")) return;
305
-
306
- fs.mkdirSync(backupDirectory, { recursive: true });
307
- fs.copyFileSync(claudePath, path.join(backupDirectory, "CLAUDE.md"));
439
+ backupFile(claudePath, path.join(backupDirectory, "CLAUDE.md"));
308
440
  const separator = current.endsWith("\n") ? "\n" : "\n\n";
309
441
  fs.writeFileSync(claudePath, `${current}${separator}${block}\n`, "utf8");
310
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
+ "package-assets/nccgs-project.gitignore",
54
55
  "scripts/cli.mjs", "scripts/doctor.mjs", "scripts/install.mjs",
55
- "scripts/sync-unity-skills.mjs", "tests/framework.test.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/doctor.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
  }
@@ -129,7 +129,7 @@ test("installer preserves project truth, merges settings, and applies inherit pr
129
129
  }
130
130
  });
131
131
 
132
- test("update replaces untouched managed files, blocks local edits, and doctor verifies state", () => {
132
+ test("update replaces untouched files, preserves local overrides, and doctor gates review", () => {
133
133
  const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-idempotent-update-test-"));
134
134
  try {
135
135
  for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
@@ -148,20 +148,84 @@ test("update replaces untouched managed files, blocks local edits, and doctor ve
148
148
 
149
149
  const update = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
150
150
  assert.equal(update.status, 0, update.stderr || update.stdout);
151
- assert.match(update.stdout, /1\.0\.0 -> 1\.1\.0/);
151
+ assert.match(update.stdout, new RegExp(`1\\.0\\.0 -> ${frameworkVersion.replace(/\./g, "\\.")}`));
152
152
  assert.match(update.stdout, /automatic update/);
153
153
  assert.match(update.stdout, /No project migration required/);
154
154
  assert.equal(fs.readFileSync(managedPath, "utf8"), fs.readFileSync(path.join(root, managedRelative), "utf8"));
155
155
 
156
- const doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
156
+ let doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
157
157
  assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
158
158
  assert.match(doctor.stdout, /NCCGS doctor passed/);
159
159
 
160
160
  fs.writeFileSync(managedPath, "project-local override\n", "utf8");
161
- const blocked = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update", "--dry-run"], { cwd: temp, encoding: "utf8" });
162
- assert.equal(blocked.status, 2);
163
- assert.match(blocked.stderr, /modified/);
161
+ const preserved = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
162
+ assert.equal(preserved.status, 0, preserved.stderr || preserved.stdout);
163
+ assert.match(preserved.stdout, /preserved override/);
164
+ assert.match(preserved.stdout, /Project migration required/);
164
165
  assert.equal(fs.readFileSync(managedPath, "utf8"), "project-local override\n");
166
+
167
+ const overridePath = path.join(temp, ".nccgs/framework-overrides.json");
168
+ const overrides = JSON.parse(fs.readFileSync(overridePath, "utf8"));
169
+ const override = overrides.overrides.find((item) => item.path === managedRelative);
170
+ assert.equal(override.status, "pending_review");
171
+ assert.equal(override.disposition, "preserve");
172
+
173
+ doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
174
+ assert.equal(doctor.status, 1);
175
+ assert.match(doctor.stderr, /awaits \/migrate-project review/);
176
+
177
+ const approve = spawnSync(process.execPath, [path.join(temp, ".claude/nccgs/tools/approve-overrides.mjs"), "--project", temp, "--approve-pending"], { cwd: temp, encoding: "utf8" });
178
+ assert.equal(approve.status, 0, approve.stderr || approve.stdout);
179
+ doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
180
+ assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
181
+ } finally {
182
+ fs.rmSync(temp, { recursive: true, force: true });
183
+ }
184
+ });
185
+
186
+ test("doctor accepts a legacy CRLF raw hash after Git normalizes the file to LF", () => {
187
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-eol-test-"));
188
+ try {
189
+ for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
190
+ const install = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "install"], { cwd: temp, encoding: "utf8" });
191
+ assert.equal(install.status, 0, install.stderr || install.stdout);
192
+
193
+ const relative = ".claude/nccgs/constitution.md";
194
+ const file = path.join(temp, ...relative.split("/"));
195
+ const lf = fs.readFileSync(file, "utf8").replace(/\r\n?/g, "\n");
196
+ fs.writeFileSync(file, lf, "utf8");
197
+ const statePath = path.join(temp, ".claude/nccgs/install-state.json");
198
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
199
+ const record = state.files.find((item) => item.relative === relative);
200
+ record.sha256 = sha256(lf.replace(/\n/g, "\r\n"));
201
+ delete record.canonicalSha256;
202
+ state.schemaVersion = 2;
203
+ fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
204
+
205
+ const doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
206
+ assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
207
+ } finally {
208
+ fs.rmSync(temp, { recursive: true, force: true });
209
+ }
210
+ });
211
+
212
+ test("update preserves an intentionally missing managed file until migration review", () => {
213
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-absent-override-test-"));
214
+ try {
215
+ for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
216
+ const install = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "install"], { cwd: temp, encoding: "utf8" });
217
+ assert.equal(install.status, 0, install.stderr || install.stdout);
218
+
219
+ const relative = ".claude/skills/status/SKILL.md";
220
+ const file = path.join(temp, ...relative.split("/"));
221
+ fs.unlinkSync(file);
222
+ const update = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
223
+ assert.equal(update.status, 0, update.stderr || update.stdout);
224
+ assert.ok(!fs.existsSync(file));
225
+ const manifest = JSON.parse(fs.readFileSync(path.join(temp, ".nccgs/framework-overrides.json"), "utf8"));
226
+ const override = manifest.overrides.find((item) => item.path === relative);
227
+ assert.equal(override.disposition, "absent");
228
+ assert.equal(override.status, "pending_review");
165
229
  } finally {
166
230
  fs.rmSync(temp, { recursive: true, force: true });
167
231
  }