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