nccgs 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- 1.0.0
1
+ 1.1.0
@@ -6,10 +6,11 @@ allowed-tools: Read, Glob, Grep, Write, Edit, Bash, Task, AskUserQuestion
6
6
 
7
7
  # Migrate project
8
8
 
9
- Treat brownfield adoption and NCCGS upgrades as CONTROLLED work. Read the complete [migration procedure](references/procedure.md). Default to `review` when no mode is given.
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
11
  ## Modes
12
12
 
13
+ - `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.
13
14
  - `review`: read-only inventory and KEEP/ADAPT/MIGRATE/DEPRECATE/REMOVE/MISSING classification.
14
15
  - `plan`: write a dated reversible plan and proposed schema-2 policy without overwriting project truth.
15
16
  - `apply`: execute an approved plan in validated batches with backups.
@@ -17,5 +18,6 @@ Treat brownfield adoption and NCCGS upgrades as CONTROLLED work. Read the comple
17
18
 
18
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.
19
20
 
20
- Never remove reports, registers, checklists, user settings, source assets, or legacy instructions until their live purpose is mapped and the Product Owner approves the replacement. Do not infer canon from code. Return batches, commits, preserved evidence, unresolved decisions, rollback, and final migration state.
21
+ 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.
21
22
 
23
+ Never remove reports, registers, checklists, user settings, source assets, or legacy instructions until their live purpose is mapped and the Product Owner approves the replacement. Do not infer canon from code. Return batches, commits, preserved evidence, unresolved decisions, rollback, and final migration state.
@@ -1,5 +1,17 @@
1
1
  # Brownfield migration procedure
2
2
 
3
+ ## Full run
4
+
5
+ `/migrate-project` without a mode executes Review -> Plan -> Apply -> Verify as one
6
+ CONTROLLED operation. Keep the phase boundaries in the migration record, but do not
7
+ make the user invoke each phase separately. Continue automatically when changes are
8
+ reversible, backed up, within the requested adoption/migration scope, and do not
9
+ alter unresolved project truth. Stop before apply only when review exposes a genuine
10
+ Product Owner decision, destructive action, external side effect, or authority gap.
11
+
12
+ If the NCCGS installer and current schema indicate that an ordinary framework update
13
+ requires no project migration, report that fact and do not manufacture migration work.
14
+
3
15
  ## Review
4
16
 
5
17
  1. Inspect repository status and project root without modifying it.
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.0 contains:
8
+ Version 1.1 contains:
9
9
 
10
10
  - 45 studio agents across leadership, design, programming, Unity, content, quality,
11
11
  and release;
@@ -48,13 +48,18 @@ npx nccgs@latest install
48
48
  For an existing NCCGS installation:
49
49
 
50
50
  ```powershell
51
- npx nccgs@latest install --dry-run
52
- npx nccgs@latest install --force
51
+ npx nccgs@latest update --dry-run
52
+ npx nccgs@latest update
53
+ npx nccgs@latest doctor
53
54
  ```
54
55
 
55
56
  The current directory is the installation target. `--project <directory>` remains
56
57
  available for automation, but is not needed for ordinary use.
57
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.
62
+
58
63
  The installer validates the package, backs up replaced framework/settings files to
59
64
  `.nccgs/backups/<timestamp>/`, copies `.claude/`, initializes missing `.nccgs/`
60
65
  scaffold files without overwriting project truth, merges managed hooks into shared
@@ -64,13 +69,16 @@ import to `CLAUDE.md`.
64
69
  Select a model profile during installation when needed:
65
70
 
66
71
  ```powershell
67
- npx nccgs@latest install --force --model-profile quality
68
- npx nccgs@latest install --force --model-profile balanced
69
- npx nccgs@latest install --force --model-profile inherit
72
+ npx nccgs@latest update --model-profile quality
73
+ npx nccgs@latest update --model-profile balanced
74
+ npx nccgs@latest update --model-profile inherit
70
75
  ```
71
76
 
72
- Open a new Claude Code session after installation. Run `/migrate-project review` for
73
- an existing project; installing framework files alone is not migration.
77
+ Open a new Claude Code session after first-time adoption of an existing project and
78
+ run `/migrate-project` once. It performs review, plan, apply, and verify as one
79
+ CONTROLLED operation, pausing only for a real Product Owner decision or unsafe side
80
+ effect. Ordinary framework updates report `No project migration required` and need
81
+ no Claude migration command.
74
82
 
75
83
  ## Model routing
76
84
 
package/UPGRADING.md CHANGED
@@ -4,23 +4,27 @@ Always inspect a dry run and preserve project policy.
4
4
 
5
5
  ```powershell
6
6
  cd "D:\path\to\UnityProject"
7
- npx nccgs@latest install --dry-run
8
- npx nccgs@latest install --force
7
+ npx nccgs@latest update --dry-run
8
+ npx nccgs@latest update
9
+ npx nccgs@latest doctor
9
10
  ```
10
11
 
11
- Version 1.0 is a framework and policy-schema upgrade from 0.2. The installer updates
12
- framework files and merges managed hooks, but deliberately does not overwrite an
13
- existing `.nccgs/project.yaml`. After installation, open a new Claude Code session
14
- and run:
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.
16
+
17
+ When the installer reports that project migration is required, open a new Claude
18
+ Code session and run one command:
15
19
 
16
20
  ```text
17
- /migrate-project review
18
- /migrate-project plan
19
- /migrate-project apply
20
- /migrate-project verify
21
+ /migrate-project
21
22
  /status full
22
23
  ```
23
24
 
25
+ The workflow retains review, plan, apply, and verify internally. Do not run it after
26
+ an ordinary update when the installer reports `No project migration required`.
27
+
24
28
  The migration plan must move schema 1 policy to schema 2, choose a model profile,
25
29
  enable departments, configure context packets and orchestration, preserve closure
26
30
  artifacts, verify hooks, and resolve obsolete global skills. Keep
package/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0
1
+ 1.1.0
@@ -1,6 +1,6 @@
1
- # Hướng dẫn migrate và sử dụng NCCGS 1.0
1
+ # Hướng dẫn migrate và sử dụng NCCGS 1.1
2
2
 
3
- NCCGS 1.0 là studio operating system dành cho Unity project trong Claude Code. Nó
3
+ NCCGS 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
 
@@ -38,13 +38,18 @@ npx nccgs@latest install
38
38
  Nếu project đã có NCCGS:
39
39
 
40
40
  ```powershell
41
- npx nccgs@latest install --dry-run
42
- npx nccgs@latest install --force
41
+ npx nccgs@latest update --dry-run
42
+ npx nccgs@latest update
43
+ npx nccgs@latest doctor
43
44
  ```
44
45
 
45
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;
46
47
  hãy mở PowerShell tại Unity project root hoặc dùng `cd` đến đó trước.
47
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.
52
+
48
53
  Installer sẽ:
49
54
 
50
55
  1. validate NCCGS;
@@ -68,7 +73,7 @@ Sau khi cài, đóng phiên Claude Code cũ và mở phiên mới tại project
68
73
  Cài với profile cụ thể:
69
74
 
70
75
  ```powershell
71
- npx nccgs@latest install --force --model-profile quality
76
+ npx nccgs@latest update --model-profile quality
72
77
  ```
73
78
 
74
79
  Đổi profile trong project đã cài:
@@ -82,12 +87,17 @@ khoản hoặc context mode hiện tại, dùng `inherit` hoặc cho phép inher
82
87
 
83
88
  ## 4. Migrate project cũ
84
89
 
85
- Migration gồm bốn bước:
90
+ Thông thường chỉ cần gọi một command:
86
91
 
87
92
  ```text
88
- review -> plan -> apply -> verify
93
+ /migrate-project
89
94
  ```
90
95
 
96
+ NCCGS vẫn thực hiện bốn phase `review -> plan -> apply -> verify` ở bên trong một
97
+ lần chạy. Nó chỉ dừng khi có quyết định Product Owner thật sự, thao tác destructive,
98
+ external side effect hoặc thiếu authority. Các mode riêng dưới đây vẫn tồn tại để
99
+ debug hoặc kiểm soát thủ công.
100
+
91
101
  ### Review
92
102
 
93
103
  ```text
@@ -258,7 +268,7 @@ Sau migration:
258
268
  ```powershell
259
269
  git add .claude .nccgs CLAUDE.md
260
270
  git status --short
261
- git commit -m "chore: adopt NCCGS 1.0 studio workflow"
271
+ git commit -m "chore: adopt NCCGS 1.1 studio workflow"
262
272
  ```
263
273
 
264
274
  Luôn đọc diff trước commit.
@@ -1,6 +1,6 @@
1
- # CCGS to NCCGS 1.0 migration matrix
1
+ # CCGS to NCCGS 1.1 migration matrix
2
2
 
3
- | CCGS capability | NCCGS 1.0 treatment | Reason |
3
+ | CCGS capability | NCCGS 1.1 treatment | Reason |
4
4
  |---|---|---|
5
5
  | Three-tier studio hierarchy | ADAPT into 44 roles and a central one-level orchestrator | Preserve specialization without recursive context chains |
6
6
  | Opus/Sonnet/Haiku agent pins | ADAPT into quality/balanced/inherit profiles | Preserve model routing while handling compatibility and cost |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nccgs",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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
@@ -19,13 +19,15 @@ if (command === "version" || command === "--version" || command === "-v") {
19
19
  process.exit(0);
20
20
  }
21
21
 
22
- if (command !== "install") {
22
+ if (!new Set(["install", "update", "doctor"]).has(command)) {
23
23
  console.error(`Unknown command: ${command}`);
24
24
  printHelp();
25
25
  process.exit(2);
26
26
  }
27
27
 
28
- const result = spawnSync(process.execPath, [path.join(scriptDir, "install.mjs"), ...args], {
28
+ const script = command === "doctor" ? "doctor.mjs" : "install.mjs";
29
+ const forwardedArgs = command === "update" ? ["--update-only", ...args] : args;
30
+ const result = spawnSync(process.execPath, [path.join(scriptDir, script), ...forwardedArgs], {
29
31
  cwd: process.cwd(),
30
32
  stdio: "inherit"
31
33
  });
@@ -41,6 +43,8 @@ function printHelp() {
41
43
 
42
44
  Usage:
43
45
  nccgs install [options]
46
+ nccgs update [options]
47
+ nccgs doctor [options]
44
48
  nccgs --version
45
49
 
46
50
  Install target:
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+
7
+ const args = process.argv.slice(2);
8
+ const projectIndex = args.indexOf("--project");
9
+ const allowPolicyMigration = args.includes("--allow-policy-migration");
10
+ const warnings = [];
11
+ if (projectIndex >= 0 && !args[projectIndex + 1]) {
12
+ console.error("--project requires a directory path.");
13
+ process.exit(2);
14
+ }
15
+
16
+ const project = projectIndex >= 0 ? path.resolve(args[projectIndex + 1]) : process.cwd();
17
+ const errors = [];
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
+ 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}`); }
28
+ }
29
+
30
+ if (state) {
31
+ for (const item of state.files ?? []) {
32
+ const file = path.join(project, ...item.relative.split("/"));
33
+ 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}`);
35
+ }
36
+ }
37
+
38
+ const claudePath = path.join(project, "CLAUDE.md");
39
+ if (!fs.existsSync(claudePath) || !fs.readFileSync(claudePath, "utf8").includes("@.claude/nccgs/constitution.md")) {
40
+ errors.push("CLAUDE.md does not import the NCCGS constitution.");
41
+ }
42
+
43
+ const policyPath = path.join(project, ".nccgs", "project.yaml");
44
+ let policySchema = null;
45
+ if (!fs.existsSync(policyPath)) errors.push(".nccgs/project.yaml is missing.");
46
+ else {
47
+ const match = fs.readFileSync(policyPath, "utf8").match(/^schema_version:\s*(\d+)\s*$/m);
48
+ 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
+ }
54
+ }
55
+
56
+ if (errors.length) {
57
+ for (const error of errors) console.error(`ERROR: ${error}`);
58
+ console.error(`NCCGS doctor failed with ${errors.length} error(s).`);
59
+ process.exit(1);
60
+ }
61
+
62
+ for (const warning of warnings) console.warn(`WARN: ${warning}`);
63
+ console.log("NCCGS doctor passed.");
64
+ console.log(`Framework version: ${state.frameworkVersion}`);
65
+ console.log(`Model profile: ${state.modelProfile}`);
66
+ console.log(`Managed files verified: ${state.files.length}`);
67
+ console.log(`Project policy schema: ${policySchema ?? "migration required"}`);
68
+
69
+ function hashFile(file) {
70
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
71
+ }
@@ -10,6 +10,7 @@ const args = process.argv.slice(2);
10
10
  const projectIndex = args.indexOf("--project");
11
11
  const dryRun = args.includes("--dry-run");
12
12
  const force = args.includes("--force");
13
+ const updateOnly = args.includes("--update-only");
13
14
  const allowNonUnity = args.includes("--allow-non-unity");
14
15
  const modelProfileIndex = args.indexOf("--model-profile");
15
16
  const requestedModelProfile = modelProfileIndex >= 0 ? args[modelProfileIndex + 1] : null;
@@ -75,28 +76,45 @@ if (fs.existsSync(installStatePath)) {
75
76
  process.exit(2);
76
77
  }
77
78
  }
79
+ if (updateOnly && !previousInstallState) {
80
+ console.error("NCCGS is not installed in this project. Run `npx nccgs@latest install` first.");
81
+ process.exit(2);
82
+ }
83
+ const previousFiles = new Map((previousInstallState?.files ?? []).map((item) => [item.relative, item]));
78
84
  const currentFrameworkNames = new Set(frameworkFiles.map((item) => posix(item.relative)));
79
85
  const staleFrameworkFiles = (previousInstallState?.files ?? [])
80
86
  .filter((item) => !currentFrameworkNames.has(item.relative))
81
- .map((item) => ({ ...item, destination: path.join(project, ...item.relative.split("/")) }))
87
+ .map((item) => ({
88
+ ...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
+ }))
82
92
  .filter((item) => fs.existsSync(item.destination));
83
93
 
84
- const conflicts = frameworkFiles.filter((item) => {
94
+ const differentFiles = frameworkFiles.filter((item) => {
85
95
  if (!fs.existsSync(item.destination)) return false;
86
96
  return !sameFile(item.source, item.destination);
87
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);
88
105
 
89
- if ((conflicts.length || staleFrameworkFiles.length) && !force) {
90
- console.error(`Installation found ${conflicts.length} conflicting and ${staleFrameworkFiles.length} stale framework file(s):`);
106
+ if ((conflicts.length || staleConflicts.length) && !force) {
107
+ console.error(`NCCGS found ${conflicts.length} modified and ${staleConflicts.length} modified-stale framework file(s):`);
91
108
  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}`);
109
+ for (const item of staleConflicts.slice(0, 30)) console.error(`- stale: ${item.relative}`);
93
110
  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.");
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.");
95
113
  process.exit(2);
96
114
  }
97
115
 
98
116
  const newFiles = frameworkFiles.filter((item) => !fs.existsSync(item.destination));
99
- const identicalFiles = frameworkFiles.length - newFiles.length - conflicts.length;
117
+ const identicalFiles = frameworkFiles.length - newFiles.length - differentFiles.length;
100
118
  const scaffoldFiles = walk(path.join(root, "scaffold")).map((file) => ({
101
119
  source: file,
102
120
  relative: path.relative(path.join(root, "scaffold"), file),
@@ -114,8 +132,9 @@ if (!new Set(["quality", "balanced", "inherit"]).has(modelProfile)) {
114
132
  }
115
133
 
116
134
  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}`);
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`);
119
138
  console.log(`Project scaffold files to initialize: ${newScaffold.length}`);
120
139
  console.log(`Managed Claude settings: ${settingsPlan.changed ? "update" : "already current"}`);
121
140
  console.log(`Agent model profile: ${modelProfile}`);
@@ -126,7 +145,7 @@ if (dryRun) {
126
145
 
127
146
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
128
147
  const backupRoot = path.join(project, ".nccgs", "backups", stamp);
129
- for (const item of conflicts) {
148
+ for (const item of differentFiles) {
130
149
  const backup = path.join(backupRoot, item.relative);
131
150
  fs.mkdirSync(path.dirname(backup), { recursive: true });
132
151
  fs.copyFileSync(item.destination, backup);
@@ -163,11 +182,31 @@ if (modelConfiguration.status !== 0) {
163
182
  process.exit(1);
164
183
  }
165
184
 
166
- writeInstallState(project, modelProfile, frameworkFiles, installStatePath);
185
+ writeInstallState(project, modelProfile, frameworkFiles, installStatePath, previousInstallState);
186
+
187
+ const doctor = spawnSync(process.execPath, [
188
+ path.join(scriptDir, "doctor.mjs"),
189
+ "--project", project,
190
+ "--allow-policy-migration"
191
+ ], { cwd: project, encoding: "utf8" });
192
+ process.stdout.write(doctor.stdout ?? "");
193
+ process.stderr.write(doctor.stderr ?? "");
194
+ if (doctor.status !== 0) {
195
+ console.error("NCCGS files were installed, but post-install verification failed.");
196
+ process.exit(1);
197
+ }
167
198
 
168
199
  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.");
200
+ if (differentFiles.length || staleFrameworkFiles.length || settingsPlan.changed) console.log(`Changed shared files backed up under: ${backupRoot}`);
201
+ const installedPolicy = fs.readFileSync(targetPolicyPath, "utf8");
202
+ const installedPolicySchema = Number(installedPolicy.match(/^schema_version:\s*(\d+)\s*$/m)?.[1] ?? 0);
203
+ if (installedPolicySchema !== 2) {
204
+ console.log("Project migration required: open a new Claude Code session and run `/migrate-project` once.");
205
+ } else if (!previousInstallState) {
206
+ console.log("First NCCGS adoption: run `/migrate-project` once for an existing project; a new project can start with `/status`.");
207
+ } else {
208
+ console.log("No project migration required for this NCCGS update.");
209
+ }
171
210
 
172
211
  function walk(directory) {
173
212
  const output = [];
@@ -234,11 +273,15 @@ function installManagedSettings(plan, backupDirectory) {
234
273
  fs.writeFileSync(plan.settingsPath, plan.nextText, "utf8");
235
274
  }
236
275
 
237
- function writeInstallState(projectRoot, profile, files, statePath) {
276
+ function writeInstallState(projectRoot, profile, files, statePath, previousState) {
277
+ const now = new Date().toISOString();
238
278
  const state = {
239
- schemaVersion: 1,
279
+ schemaVersion: 2,
240
280
  frameworkVersion: fs.readFileSync(path.join(root, "VERSION"), "utf8").trim(),
281
+ previousFrameworkVersion: previousState?.frameworkVersion ?? null,
241
282
  modelProfile: profile,
283
+ installedAt: previousState?.installedAt ?? now,
284
+ updatedAt: now,
242
285
  files: files.map((item) => ({
243
286
  relative: posix(item.relative),
244
287
  sha256: hashFile(path.join(projectRoot, item.relative))
@@ -51,7 +51,7 @@ 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",
54
+ "scripts/cli.mjs", "scripts/doctor.mjs", "scripts/install.mjs",
55
55
  "scripts/sync-unity-skills.mjs", "tests/framework.test.mjs"
56
56
  ]) requireFile(relative);
57
57
 
@@ -128,7 +128,7 @@ for (const command of hookCommands) {
128
128
  const check = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
129
129
  if (check.status !== 0) errors.push(`Hook syntax failure ${match[1]}: ${check.stderr.trim()}`);
130
130
  }
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")]) {
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")]) {
132
132
  const check = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
133
133
  if (check.status !== 0) errors.push(`Script syntax failure ${posix(path.relative(root, file))}: ${check.stderr.trim()}`);
134
134
  }
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
@@ -9,6 +10,8 @@ import { fileURLToPath } from "node:url";
9
10
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
11
  const studio = JSON.parse(fs.readFileSync(path.join(root, ".claude/nccgs/studio.json"), "utf8"));
11
12
  const workflows = JSON.parse(fs.readFileSync(path.join(root, ".claude/nccgs/workflow-catalog.json"), "utf8"));
13
+ const frameworkVersion = fs.readFileSync(path.join(root, "VERSION"), "utf8").trim();
14
+ const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
12
15
 
13
16
  test("studio roles are unique and workflow references resolve", () => {
14
17
  const roles = new Set(studio.agents.map((agent) => agent.name));
@@ -24,6 +27,12 @@ test("quality profile assigns all model classes", () => {
24
27
  }
25
28
  });
26
29
 
30
+ test("migrate-project defaults to a single full run", () => {
31
+ const skill = fs.readFileSync(path.join(root, ".claude/skills/migrate-project/SKILL.md"), "utf8");
32
+ assert.match(skill, /Default to `full`/);
33
+ assert.match(skill, /review, plan, apply, and verify in one invocation/i);
34
+ });
35
+
27
36
  test("model configurator can remove and restore model pins", () => {
28
37
  const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-model-test-"));
29
38
  try {
@@ -104,7 +113,7 @@ test("installer preserves project truth, merges settings, and applies inherit pr
104
113
  assert.ok(fs.existsSync(path.join(temp, ".nccgs/backups")));
105
114
  const installStatePath = path.join(temp, ".claude/nccgs/install-state.json");
106
115
  const installState = JSON.parse(fs.readFileSync(installStatePath, "utf8"));
107
- assert.equal(installState.frameworkVersion, "1.0.0");
116
+ assert.equal(installState.frameworkVersion, frameworkVersion);
108
117
  assert.equal(installState.modelProfile, "inherit");
109
118
 
110
119
  const obsoleteRelative = ".claude/agents/nccgs-obsolete.md";
@@ -119,3 +128,53 @@ test("installer preserves project truth, merges settings, and applies inherit pr
119
128
  fs.rmSync(temp, { recursive: true, force: true });
120
129
  }
121
130
  });
131
+
132
+ test("update replaces untouched managed files, blocks local edits, and doctor verifies state", () => {
133
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-idempotent-update-test-"));
134
+ try {
135
+ for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
136
+ const install = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "install", "--model-profile", "quality"], { cwd: temp, encoding: "utf8" });
137
+ assert.equal(install.status, 0, install.stderr || install.stdout);
138
+
139
+ const managedRelative = ".claude/nccgs/constitution.md";
140
+ const managedPath = path.join(temp, ...managedRelative.split("/"));
141
+ const statePath = path.join(temp, ".claude/nccgs/install-state.json");
142
+ const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
143
+ const oldManagedContent = "managed by NCCGS 1.0.0\n";
144
+ fs.writeFileSync(managedPath, oldManagedContent, "utf8");
145
+ state.frameworkVersion = "1.0.0";
146
+ state.files.find((item) => item.relative === managedRelative).sha256 = sha256(oldManagedContent);
147
+ fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
148
+
149
+ const update = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
150
+ assert.equal(update.status, 0, update.stderr || update.stdout);
151
+ assert.match(update.stdout, /1\.0\.0 -> 1\.1\.0/);
152
+ assert.match(update.stdout, /automatic update/);
153
+ assert.match(update.stdout, /No project migration required/);
154
+ assert.equal(fs.readFileSync(managedPath, "utf8"), fs.readFileSync(path.join(root, managedRelative), "utf8"));
155
+
156
+ const doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
157
+ assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
158
+ assert.match(doctor.stdout, /NCCGS doctor passed/);
159
+
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/);
164
+ assert.equal(fs.readFileSync(managedPath, "utf8"), "project-local override\n");
165
+ } finally {
166
+ fs.rmSync(temp, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ test("update refuses a project without an NCCGS install state", () => {
171
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-update-missing-test-"));
172
+ try {
173
+ for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
174
+ const result = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
175
+ assert.equal(result.status, 2);
176
+ assert.match(result.stderr, /not installed/);
177
+ } finally {
178
+ fs.rmSync(temp, { recursive: true, force: true });
179
+ }
180
+ });