nccgs 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/nccgs-adversarial-reviewer.md +1 -1
- package/.claude/agents/nccgs-engine-programmer.md +1 -1
- package/.claude/agents/nccgs-fast-implementer.md +1 -1
- package/.claude/agents/nccgs-game-designer.md +1 -1
- package/.claude/agents/nccgs-gameplay-programmer.md +1 -1
- package/.claude/agents/nccgs-qa-engineer.md +1 -1
- package/.claude/agents/nccgs-task-scout.md +1 -1
- package/.claude/agents/nccgs-ui-programmer.md +1 -1
- package/.claude/agents/nccgs-unity-build-specialist.md +1 -1
- package/.claude/agents/nccgs-unity-implementer.md +1 -1
- package/.claude/agents/nccgs-unity-systems-specialist.md +1 -1
- package/.claude/agents/nccgs-unity-ui-specialist.md +1 -1
- package/.claude/agents/nccgs-verification-engineer.md +1 -1
- package/.claude/nccgs/MEMORY.md +3 -1
- package/.claude/nccgs/VERSION +1 -1
- package/.claude/nccgs/constitution.md +187 -176
- package/.claude/nccgs/hooks/closure-guard.mjs +2 -0
- package/.claude/nccgs/hooks/unity-transport-guard.mjs +11 -0
- package/.claude/nccgs/pipeline.json +260 -260
- package/.claude/nccgs/protocols/context-packets.md +5 -0
- package/.claude/nccgs/protocols/model-routing.md +46 -46
- package/.claude/nccgs/protocols/review-handoff.md +21 -1
- package/.claude/nccgs/protocols/shared-workflow-context.md +33 -0
- package/.claude/nccgs/protocols/unity-boundary.md +20 -0
- package/.claude/nccgs/routing.generated.md +4 -4
- package/.claude/nccgs/settings.fragment.json +84 -24
- package/.claude/nccgs/studio.json +53 -53
- package/.claude/nccgs/tools/autonomy.mjs +13 -0
- package/.claude/nccgs/tools/command-tokens.mjs +22 -0
- package/.claude/nccgs/tools/hook-transport.mjs +34 -0
- package/.claude/nccgs/tools/native-completion.mjs +10 -1
- package/.claude/nccgs/tools/returned-handoff.mjs +11 -2
- package/.claude/nccgs/tools/shared-context-guard.mjs +60 -0
- package/.claude/nccgs/tools/shared-context.mjs +25 -0
- package/.claude/nccgs/tools/tool-guard.mjs +120 -131
- package/.claude/nccgs/tools/unity-transport.mjs +170 -0
- package/.claude/nccgs/workflow-catalog.json +4 -4
- package/CHANGELOG.md +235 -200
- package/CLAUDE.md +10 -8
- package/MEMORY.md +109 -60
- package/README.md +206 -191
- package/UPGRADING.md +130 -118
- package/VERSION +1 -1
- package/docs/ARCHITECTURE.md +131 -131
- package/docs/ASSET-PRODUCTION-GRAPHIFY-PLAN.md +230 -0
- package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +117 -116
- package/docs/NCCGS-1.5.md +237 -237
- package/docs/PROJECT-POLICY.md +94 -94
- package/docs/RELEASE-STATUS.md +17 -9
- package/docs/UNINSTALL.md +3 -3
- package/docs/WFK17-HOOK-REPAIR.md +71 -0
- package/docs/WORKFLOWS.md +85 -85
- package/package.json +1 -1
- package/scaffold/.nccgs/project.yaml +109 -108
- package/scripts/benchmark-v15.mjs +173 -173
- package/scripts/doctor.mjs +261 -261
- package/scripts/install.mjs +530 -529
- package/scripts/run.mjs +5 -4
- package/scripts/supervise.mjs +4 -0
- package/scripts/uninstall.mjs +292 -292
- package/scripts/validate.mjs +285 -280
- package/tests/autonomy.test.mjs +126 -113
- package/tests/design-readiness.test.mjs +199 -199
- package/tests/fixtures/compiler-shutdown.cs +67 -64
- package/tests/fixtures/native-provider.mjs +61 -0
- package/tests/framework.test.mjs +740 -740
- package/tests/hook-repair.test.mjs +106 -0
- package/tests/lifecycle-v15.test.mjs +346 -300
- package/tests/native-completion-notices.test.mjs +181 -0
- package/tests/phase1.test.mjs +427 -416
- package/tests/phase2.test.mjs +250 -250
- package/tests/policy-v15.test.mjs +234 -234
- package/tests/returned-handoff-notices.test.mjs +170 -0
- package/tests/runtime-v15.test.mjs +1197 -1191
- package/tests/task-slices.test.mjs +285 -285
- package/tests/unity-transport.test.mjs +348 -0
package/tests/framework.test.mjs
CHANGED
|
@@ -1,740 +1,740 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import crypto from "node:crypto";
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import { spawnSync } from "node:child_process";
|
|
7
|
-
import test from "node:test";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { compile, renderAgent } from "../.claude/nccgs/tools/compile-policy.mjs";
|
|
10
|
-
import { assertRelativePath, containedPath } from "../.claude/nccgs/tools/paths.mjs";
|
|
11
|
-
|
|
12
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
-
const studio = JSON.parse(fs.readFileSync(path.join(root, ".claude/nccgs/studio.json"), "utf8"));
|
|
14
|
-
const workflows = JSON.parse(fs.readFileSync(path.join(root, ".claude/nccgs/workflow-catalog.json"), "utf8"));
|
|
15
|
-
const pipeline = JSON.parse(fs.readFileSync(path.join(root, ".claude/nccgs/pipeline.json"), "utf8"));
|
|
16
|
-
function copyAgentFixture(temp) {
|
|
17
|
-
fs.cpSync(path.join(root, ".claude/nccgs"), path.join(temp, ".claude/nccgs"), { recursive: true });
|
|
18
|
-
fs.mkdirSync(path.join(temp, ".claude/agents"), { recursive: true });
|
|
19
|
-
for (const agent of studio.agents) {
|
|
20
|
-
const template = fs.readFileSync(path.join(root, ".claude/nccgs/agent-library", `${agent.name}.md`), "utf8");
|
|
21
|
-
fs.writeFileSync(path.join(temp, ".claude/agents", `${agent.name}.md`), renderAgent(template, pipeline.profiles[pipeline.defaultProfile].models[agent.modelClass]));
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
const frameworkVersion = fs.readFileSync(path.join(root, "VERSION"), "utf8").trim();
|
|
25
|
-
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
26
|
-
const adaptiveRoutes = {
|
|
27
|
-
lightweight: { model: "sonnet", effort: "medium" },
|
|
28
|
-
standard: { model: "claude-opus-5-5", effort: "medium" },
|
|
29
|
-
deep: { model: "claude-opus-5-5", effort: "high" },
|
|
30
|
-
leadership: { model: "claude-opus-5-5", effort: "high" },
|
|
31
|
-
review: { model: "fable", effort: "high" }
|
|
32
|
-
};
|
|
33
|
-
const field = (text, name) => text.match(new RegExp(`^${name}:\\s*([^\\r\\n]+)`, "m"))?.[1]?.trim() ?? null;
|
|
34
|
-
function assertAgentRoute(project, agentName, expected) {
|
|
35
|
-
const text = fs.readFileSync(path.join(project, ".claude/agents", `${agentName}.md`), "utf8");
|
|
36
|
-
assert.equal(field(text, "model"), expected.model, `${agentName} model`);
|
|
37
|
-
assert.equal(field(text, "effort"), expected.effort, `${agentName} effort`);
|
|
38
|
-
return text;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
test("canonical policy changes invalidate generated routing until compilation", () => {
|
|
42
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-policy-test-"));
|
|
43
|
-
try {
|
|
44
|
-
copyAgentFixture(temp);
|
|
45
|
-
compile(temp, true);
|
|
46
|
-
assert.deepEqual(compile(temp), []);
|
|
47
|
-
const file = path.join(temp, ".claude/nccgs/pipeline.json");
|
|
48
|
-
const policy = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
49
|
-
policy.profiles["claude-standalone"].models.review.effort = "medium";
|
|
50
|
-
fs.writeFileSync(file, JSON.stringify(policy));
|
|
51
|
-
const stale = compile(temp);
|
|
52
|
-
assert.ok(stale.includes(".claude/agents/nccgs-adversarial-reviewer.md"));
|
|
53
|
-
assert.ok(stale.includes(".claude/nccgs/workflow-catalog.json"));
|
|
54
|
-
compile(temp, true);
|
|
55
|
-
assert.deepEqual(compile(temp), []);
|
|
56
|
-
const catalog = JSON.parse(fs.readFileSync(path.join(temp, ".claude/nccgs/studio.json"), "utf8"));
|
|
57
|
-
assert.equal(catalog.effortProfiles["claude-standalone"].review, "medium");
|
|
58
|
-
const reviewerTemplate = path.join(temp, ".claude/nccgs/agent-library/nccgs-adversarial-reviewer.md");
|
|
59
|
-
fs.writeFileSync(reviewerTemplate, fs.readFileSync(reviewerTemplate, "utf8").replace(/^maxTurns: \d+/m, "maxTurns: 63"));
|
|
60
|
-
assert.ok(compile(temp).includes(".claude/nccgs/studio.json"));
|
|
61
|
-
compile(temp, true);
|
|
62
|
-
assert.equal(field(fs.readFileSync(path.join(temp, ".claude/agents/nccgs-adversarial-reviewer.md"), "utf8"), "maxTurns"), "63");
|
|
63
|
-
assert.equal(JSON.parse(fs.readFileSync(path.join(temp, ".claude/nccgs/studio.json"), "utf8")).agents.find(a => a.name === "nccgs-adversarial-reviewer").maxTurns, 63);
|
|
64
|
-
} finally {
|
|
65
|
-
assert.ok(path.resolve(temp).startsWith(path.resolve(os.tmpdir()) + path.sep + "nccgs-policy-test-"));
|
|
66
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("studio roles are unique and workflow references resolve", () => {
|
|
71
|
-
const roles = new Set(studio.agents.map((agent) => agent.name));
|
|
72
|
-
assert.equal(roles.size, studio.agents.length);
|
|
73
|
-
for (const workflow of workflows.workflows) {
|
|
74
|
-
for (const role of workflow.primaryRoles) assert.ok(roles.has(role), `${workflow.name}: ${role}`);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("quality profile assigns all model classes", () => {
|
|
79
|
-
for (const agent of studio.agents) {
|
|
80
|
-
assert.ok(studio.modelProfiles.quality[agent.modelClass], agent.name);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("optional specialists survive updates and can be disabled without changing core agents", () => {
|
|
85
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-optional-agents-"));
|
|
86
|
-
try {
|
|
87
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
88
|
-
const run = (...args) => spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), ...args], { cwd: temp, encoding: "utf8" });
|
|
89
|
-
const install = run("install");
|
|
90
|
-
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
91
|
-
const role = "nccgs-network-programmer";
|
|
92
|
-
const file = path.join(temp, ".claude/agents", `${role}.md`);
|
|
93
|
-
assert.ok(!fs.existsSync(file));
|
|
94
|
-
assert.equal(run("agents", "--enable", role).status, 0);
|
|
95
|
-
assert.equal(field(fs.readFileSync(file, "utf8"), "model"), "opus");
|
|
96
|
-
const update = run("update");
|
|
97
|
-
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
98
|
-
assert.ok(fs.existsSync(file));
|
|
99
|
-
assert.equal(run("doctor").status, 0);
|
|
100
|
-
assert.equal(run("agents", "--disable", role).status, 0);
|
|
101
|
-
assert.ok(!fs.existsSync(file));
|
|
102
|
-
assert.notEqual(run("agents", "--disable", "nccgs-adversarial-reviewer").status, 0);
|
|
103
|
-
assert.equal(fs.readdirSync(path.join(temp, ".claude/agents")).length, pipeline.coreAgents.length);
|
|
104
|
-
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("explicit Astra compatibility install retains Sonnet, pinned Opus and Fable routes without erasing settings", () => {
|
|
108
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-astra-install-"));
|
|
109
|
-
try {
|
|
110
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings", ".claude"]) fs.mkdirSync(path.join(temp, marker));
|
|
111
|
-
const settingsPath = path.join(temp, ".claude/settings.json");
|
|
112
|
-
fs.writeFileSync(settingsPath, JSON.stringify({ model: "sonnet", effortLevel: "medium", modelSettings: { "claude-opus-5-5": { effortLevel: "low" }, "claude-sonnet-4-6": { effortLevel: "medium" } }, permissions: { allow: ["Bash(git status*)"] }, env: { NCCGS_TEST: "preserved" } }));
|
|
113
|
-
const run = (...args) => spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), ...args], { cwd: temp, encoding: "utf8" });
|
|
114
|
-
const dry = run("install", "--model-profile", "astra-claude", "--dry-run");
|
|
115
|
-
assert.equal(dry.status, 0, dry.stderr || dry.stdout);
|
|
116
|
-
assert.equal(JSON.parse(fs.readFileSync(settingsPath)).model, "sonnet");
|
|
117
|
-
assert.ok(!fs.existsSync(path.join(temp, ".claude/agents")));
|
|
118
|
-
const install = run("install", "--model-profile", "astra-claude");
|
|
119
|
-
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
120
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath));
|
|
121
|
-
assert.equal(settings.model, "claude-opus-5-5");
|
|
122
|
-
assert.equal(settings.effortLevel, "medium");
|
|
123
|
-
assert.equal(settings.modelSettings["claude-opus-5-5"].effortLevel, "medium");
|
|
124
|
-
assert.equal(settings.modelSettings["claude-sonnet-4-6"].effortLevel, "medium");
|
|
125
|
-
assert.equal(settings.env.NCCGS_TEST, "preserved");
|
|
126
|
-
assert.deepEqual(settings.permissions.allow, ["Bash(git status*)"]);
|
|
127
|
-
assert.deepEqual(pipeline.profiles["astra-claude"].models, adaptiveRoutes);
|
|
128
|
-
assert.equal(studio.agents.length, 47);
|
|
129
|
-
assert.equal(pipeline.coreAgents.length, 13);
|
|
130
|
-
assert.ok(pipeline.coreAgents.includes("nccgs-game-designer"), "Standalone design must be dispatchable on a default installation");
|
|
131
|
-
for (const agent of studio.agents) {
|
|
132
|
-
if (!pipeline.coreAgents.includes(agent.name)) {
|
|
133
|
-
assert.ok(!fs.existsSync(path.join(temp, ".claude/agents", `${agent.name}.md`)));
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
assertAgentRoute(temp, agent.name, adaptiveRoutes[agent.modelClass]);
|
|
137
|
-
}
|
|
138
|
-
const fast = assertAgentRoute(temp, "nccgs-fast-implementer", adaptiveRoutes.lightweight);
|
|
139
|
-
const scout = assertAgentRoute(temp, "nccgs-task-scout", adaptiveRoutes.lightweight);
|
|
140
|
-
assert.equal(field(fast, "maxTurns"), "12");
|
|
141
|
-
assert.equal(field(scout, "maxTurns"), "10");
|
|
142
|
-
assert.match(field(fast, "tools"), /\b(?:Write|Edit)\b/);
|
|
143
|
-
assert.doesNotMatch(field(scout, "tools"), /\b(?:Write|Edit|Bash|PowerShell|Agent|Task)\b/);
|
|
144
|
-
assert.match(field(scout, "tools"), /\bRead\b/);
|
|
145
|
-
assertAgentRoute(temp, "nccgs-unity-implementer", adaptiveRoutes.deep);
|
|
146
|
-
assertAgentRoute(temp, "nccgs-gameplay-programmer", adaptiveRoutes.standard);
|
|
147
|
-
assert.match(fs.readFileSync(path.join(temp, ".nccgs/project.yaml"), "utf8"), /profile: astra-claude/);
|
|
148
|
-
assert.ok(fs.existsSync(path.join(temp, ".nccgs/templates/implementation-report.md")));
|
|
149
|
-
const update = run("update");
|
|
150
|
-
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
151
|
-
assert.match(update.stdout, /0 preserved override/);
|
|
152
|
-
const doctor = run("doctor");
|
|
153
|
-
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
|
154
|
-
} finally {
|
|
155
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("update migrates previous Opus xhigh routes while preserving customized agents", () => {
|
|
160
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-adaptive-upgrade-"));
|
|
161
|
-
try {
|
|
162
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
163
|
-
const run = (...args) => spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), ...args], { cwd: temp, encoding: "utf8" });
|
|
164
|
-
const install = run("install", "--model-profile", "astra-claude");
|
|
165
|
-
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
166
|
-
const stateFile = path.join(temp, ".claude/nccgs/install-state.json");
|
|
167
|
-
const activationFile = path.join(temp, ".nccgs/agent-activation.json");
|
|
168
|
-
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
169
|
-
const activation = JSON.parse(fs.readFileSync(activationFile, "utf8"));
|
|
170
|
-
const newAgents = ["nccgs-fast-implementer", "nccgs-task-scout"];
|
|
171
|
-
for (const name of pipeline.coreAgents) {
|
|
172
|
-
const relative = `.claude/agents/${name}.md`;
|
|
173
|
-
const file = path.join(temp, relative);
|
|
174
|
-
if (newAgents.includes(name)) {
|
|
175
|
-
// These roles were not present in the previous installed route set.
|
|
176
|
-
fs.unlinkSync(file);
|
|
177
|
-
state.files = state.files.filter(item => item.relative !== relative);
|
|
178
|
-
delete activation.generated[relative];
|
|
179
|
-
delete activation.generatedCanonical[relative];
|
|
180
|
-
} else if (name !== "nccgs-adversarial-reviewer") {
|
|
181
|
-
const previous = renderAgent(fs.readFileSync(file, "utf8"), { model: "claude-opus-5-5", effort: "xhigh" });
|
|
182
|
-
fs.writeFileSync(file, previous);
|
|
183
|
-
const managed = state.files.find(item => item.relative === relative);
|
|
184
|
-
managed.sha256 = sha256(previous);
|
|
185
|
-
delete managed.canonicalSha256;
|
|
186
|
-
activation.generated[relative] = sha256(previous);
|
|
187
|
-
activation.generatedCanonical[relative] = sha256(previous);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
fs.writeFileSync(stateFile, JSON.stringify(state));
|
|
191
|
-
fs.writeFileSync(activationFile, JSON.stringify(activation));
|
|
192
|
-
const settingsFile = path.join(temp, ".claude/settings.json");
|
|
193
|
-
const settings = JSON.parse(fs.readFileSync(settingsFile));
|
|
194
|
-
settings.effortLevel = "xhigh";
|
|
195
|
-
settings.modelSettings["claude-opus-5-5"].effortLevel = "xhigh";
|
|
196
|
-
fs.writeFileSync(settingsFile, JSON.stringify(settings));
|
|
197
|
-
const customName = "nccgs-gameplay-programmer";
|
|
198
|
-
const customPath = `.claude/agents/${customName}.md`;
|
|
199
|
-
const customFile = path.join(temp, customPath);
|
|
200
|
-
const custom = fs.readFileSync(customFile, "utf8") + "\nProject rule: keep the local combat acceptance checklist.\n";
|
|
201
|
-
fs.writeFileSync(customFile, custom);
|
|
202
|
-
|
|
203
|
-
const update = run("update");
|
|
204
|
-
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
205
|
-
assert.equal(fs.readFileSync(customFile, "utf8"), custom);
|
|
206
|
-
for (const agent of studio.agents.filter(agent => pipeline.coreAgents.includes(agent.name) && agent.name !== customName)) {
|
|
207
|
-
assertAgentRoute(temp, agent.name, adaptiveRoutes[agent.modelClass]);
|
|
208
|
-
}
|
|
209
|
-
const configured = JSON.parse(fs.readFileSync(settingsFile));
|
|
210
|
-
assert.equal(configured.effortLevel, "medium");
|
|
211
|
-
assert.equal(configured.modelSettings["claude-opus-5-5"].effortLevel, "medium");
|
|
212
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(temp, ".nccgs/framework-overrides.json")));
|
|
213
|
-
assert.equal(manifest.overrides.find(item => item.path === customPath)?.status, "pending_review");
|
|
214
|
-
assert.equal(JSON.parse(fs.readFileSync(stateFile)).modelProfile, "astra-claude");
|
|
215
|
-
assert.match(update.stdout, /Project migration required/);
|
|
216
|
-
} finally {
|
|
217
|
-
assert.ok(path.resolve(temp).startsWith(path.resolve(os.tmpdir()) + path.sep + "nccgs-adaptive-upgrade-"));
|
|
218
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test("updates retain quality, balanced and inherit profiles and custom agent instructions", () => {
|
|
223
|
-
const legacyModels = {
|
|
224
|
-
quality: { lightweight: "haiku", standard: "sonnet", deep: "opus", leadership: "opus", review: "opus" },
|
|
225
|
-
balanced: { lightweight: "haiku", standard: "sonnet", deep: "opus", leadership: "sonnet", review: "opus" },
|
|
226
|
-
inherit: { lightweight: null, standard: null, deep: null, leadership: null, review: null }
|
|
227
|
-
};
|
|
228
|
-
for (const [profile, models] of Object.entries(legacyModels)) {
|
|
229
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-legacy-routing-"));
|
|
230
|
-
try {
|
|
231
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings", ".claude"]) fs.mkdirSync(path.join(temp, marker));
|
|
232
|
-
const settingsFile = path.join(temp, ".claude/settings.json");
|
|
233
|
-
fs.writeFileSync(settingsFile, JSON.stringify({ model: "sonnet", effortLevel: "high" }));
|
|
234
|
-
const run = (...args) => spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), ...args], { cwd: temp, encoding: "utf8" });
|
|
235
|
-
const install = run("install", "--model-profile", profile);
|
|
236
|
-
assert.equal(install.status, 0, `${profile}: ${install.stderr || install.stdout}`);
|
|
237
|
-
const customName = "nccgs-gameplay-programmer";
|
|
238
|
-
const customFile = path.join(temp, ".claude/agents", `${customName}.md`);
|
|
239
|
-
const custom = fs.readFileSync(customFile, "utf8") + "\nProject rule: retain existing gameplay interfaces.\n";
|
|
240
|
-
fs.writeFileSync(customFile, custom);
|
|
241
|
-
const update = run("update");
|
|
242
|
-
assert.equal(update.status, 0, `${profile}: ${update.stderr || update.stdout}`);
|
|
243
|
-
assert.equal(fs.readFileSync(customFile, "utf8"), custom, profile);
|
|
244
|
-
const policy = fs.readFileSync(path.join(temp, ".nccgs/project.yaml"), "utf8");
|
|
245
|
-
assert.match(policy, new RegExp(`profile: ${profile}\\b`));
|
|
246
|
-
const state = JSON.parse(fs.readFileSync(path.join(temp, ".claude/nccgs/install-state.json")));
|
|
247
|
-
assert.equal(state.modelProfile, profile);
|
|
248
|
-
assert.equal(fs.readdirSync(path.join(temp, ".claude/agents")).length, studio.agents.length);
|
|
249
|
-
for (const agent of studio.agents.filter(agent => agent.name !== customName)) {
|
|
250
|
-
assertAgentRoute(temp, agent.name, { model: models[agent.modelClass], effort: null });
|
|
251
|
-
}
|
|
252
|
-
const settings = JSON.parse(fs.readFileSync(settingsFile));
|
|
253
|
-
assert.equal(settings.model, "sonnet");
|
|
254
|
-
assert.equal(settings.effortLevel, "high");
|
|
255
|
-
} finally {
|
|
256
|
-
assert.ok(path.resolve(temp).startsWith(path.resolve(os.tmpdir()) + path.sep + "nccgs-legacy-routing-"));
|
|
257
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
test("profile changes clear effort pins and preserve skipped custom agents", () => {
|
|
263
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-effort-profile-"));
|
|
264
|
-
try {
|
|
265
|
-
fs.mkdirSync(path.join(temp, ".claude/nccgs"), { recursive: true });
|
|
266
|
-
fs.mkdirSync(path.join(temp, ".claude/agents"));
|
|
267
|
-
fs.mkdirSync(path.join(temp, ".nccgs"));
|
|
268
|
-
fs.copyFileSync(path.join(root, ".claude/nccgs/studio.json"), path.join(temp, ".claude/nccgs/studio.json"));
|
|
269
|
-
fs.writeFileSync(path.join(temp, ".nccgs/project.yaml"), "models:\n profile: astra-claude\n");
|
|
270
|
-
copyAgentFixture(temp);
|
|
271
|
-
const run = (profile, ...args) => spawnSync(process.execPath, [path.join(root, ".claude/nccgs/tools/configure-models.mjs"), "--project", temp, "--profile", profile, ...args], { encoding: "utf8" });
|
|
272
|
-
for (const profile of ["inherit", "quality", "balanced", "astra-claude"]) {
|
|
273
|
-
const result = run(profile);
|
|
274
|
-
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
275
|
-
for (const agent of studio.agents) {
|
|
276
|
-
if (profile === "astra-claude" && !pipeline.coreAgents.includes(agent.name)) {
|
|
277
|
-
assert.ok(!fs.existsSync(path.join(temp, ".claude/agents", `${agent.name}.md`)));
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
const text = fs.readFileSync(path.join(temp, ".claude/agents", `${agent.name}.md`), "utf8");
|
|
281
|
-
assert.equal(text.match(/^model:\s*(\S+)/m)?.[1] ?? null, studio.modelProfiles[profile][agent.modelClass], agent.name);
|
|
282
|
-
const expectedEffort = pipeline.profiles[profile].models[agent.modelClass].effort;
|
|
283
|
-
assert.equal(text.match(/^effort:\s*(\S+)/m)?.[1] ?? null, expectedEffort, agent.name);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
const file = path.join(temp, ".claude/agents/nccgs-adversarial-reviewer.md");
|
|
287
|
-
const before = fs.readFileSync(file, "utf8");
|
|
288
|
-
assert.equal(run("inherit", "--skip-path", ".claude/agents/nccgs-adversarial-reviewer.md").status, 0);
|
|
289
|
-
assert.equal(fs.readFileSync(file, "utf8"), before);
|
|
290
|
-
const invalid = run("unknown");
|
|
291
|
-
assert.notEqual(invalid.status, 0);
|
|
292
|
-
assert.equal(fs.readFileSync(file, "utf8"), before);
|
|
293
|
-
fs.writeFileSync(path.join(temp, ".claude/settings.json"), "invalid JSON");
|
|
294
|
-
assert.notEqual(run("astra-claude").status, 0);
|
|
295
|
-
assert.equal(fs.readFileSync(file, "utf8"), before);
|
|
296
|
-
const other = fs.readFileSync(path.join(temp, ".claude/agents/nccgs-unity-implementer.md"), "utf8");
|
|
297
|
-
assert.ok(!/^model:/m.test(other), "invalid settings must fail before rewriting agents");
|
|
298
|
-
} finally {
|
|
299
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
test("migrate-project defaults to a single full run", () => {
|
|
304
|
-
const skill = fs.readFileSync(path.join(root, ".claude/skills/migrate-project/references/legacy.md"), "utf8");
|
|
305
|
-
assert.match(skill, /Default to `full`/);
|
|
306
|
-
assert.match(skill, /review, plan, apply, and verify in one invocation/i);
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
test("model configurator can remove and restore model pins", () => {
|
|
310
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-model-test-"));
|
|
311
|
-
try {
|
|
312
|
-
fs.mkdirSync(path.join(temp, ".claude/nccgs/tools"), { recursive: true });
|
|
313
|
-
fs.mkdirSync(path.join(temp, ".claude/agents"), { recursive: true });
|
|
314
|
-
fs.mkdirSync(path.join(temp, ".nccgs"), { recursive: true });
|
|
315
|
-
fs.copyFileSync(path.join(root, ".claude/nccgs/studio.json"), path.join(temp, ".claude/nccgs/studio.json"));
|
|
316
|
-
fs.copyFileSync(path.join(root, ".claude/nccgs/tools/configure-models.mjs"), path.join(temp, ".claude/nccgs/tools/configure-models.mjs"));
|
|
317
|
-
fs.writeFileSync(path.join(temp, ".nccgs/project.yaml"), "models:\n profile: quality\n", "utf8");
|
|
318
|
-
copyAgentFixture(temp);
|
|
319
|
-
const tool = path.join(temp, ".claude/nccgs/tools/configure-models.mjs");
|
|
320
|
-
assert.equal(spawnSync(process.execPath, [tool, "--project", temp, "--profile", "inherit"]).status, 0);
|
|
321
|
-
assert.ok(!/^model:/m.test(fs.readFileSync(path.join(temp, ".claude/agents", `${studio.agents[0].name}.md`), "utf8")));
|
|
322
|
-
assert.equal(spawnSync(process.execPath, [tool, "--project", temp, "--profile", "quality"]).status, 0);
|
|
323
|
-
assert.ok(/^model:\s*(opus|sonnet|haiku)$/m.test(fs.readFileSync(path.join(temp, ".claude/agents", `${studio.agents[0].name}.md`), "utf8")));
|
|
324
|
-
} finally {
|
|
325
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
326
|
-
}
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test("destructive git commands are blocked by the managed hook", () => {
|
|
330
|
-
const hook = path.join(root, ".claude/nccgs/hooks/protect-git.mjs");
|
|
331
|
-
const result = spawnSync(process.execPath, [hook], {
|
|
332
|
-
cwd: root,
|
|
333
|
-
input: JSON.stringify({ tool_input: { command: "git reset --hard" } }),
|
|
334
|
-
encoding: "utf8"
|
|
335
|
-
});
|
|
336
|
-
assert.equal(result.status, 2);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
test("installer dry-run accepts an empty Unity-shaped project", () => {
|
|
340
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-install-test-"));
|
|
341
|
-
try {
|
|
342
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
343
|
-
const result = spawnSync(process.execPath, [path.join(root, "scripts/install.mjs"), "--project", temp, "--dry-run"], { cwd: root, encoding: "utf8" });
|
|
344
|
-
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
345
|
-
assert.match(result.stdout, /Dry run complete/);
|
|
346
|
-
} finally {
|
|
347
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
348
|
-
}
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
test("CLI installs into the current directory by default", () => {
|
|
352
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-cli-test-"));
|
|
353
|
-
try {
|
|
354
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
355
|
-
const result = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "install", "--dry-run"], { cwd: temp, encoding: "utf8" });
|
|
356
|
-
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
357
|
-
assert.match(result.stdout, new RegExp(`NCCGS installation target: ${temp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
358
|
-
assert.match(result.stdout, /Dry run complete/);
|
|
359
|
-
} finally {
|
|
360
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
361
|
-
}
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
test("installer preserves project truth, merges settings, and applies inherit profile", () => {
|
|
365
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-upgrade-test-"));
|
|
366
|
-
try {
|
|
367
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
368
|
-
fs.mkdirSync(path.join(temp, ".nccgs"));
|
|
369
|
-
fs.mkdirSync(path.join(temp, ".claude"));
|
|
370
|
-
const legacyPolicy = "schema_version: 1\nownership:\n protected_paths:\n - Assets/HandTuned/**\n";
|
|
371
|
-
fs.writeFileSync(path.join(temp, ".nccgs/project.yaml"), legacyPolicy, "utf8");
|
|
372
|
-
fs.writeFileSync(path.join(temp, "CLAUDE.md"), "# Existing project instructions\n", "utf8");
|
|
373
|
-
fs.writeFileSync(path.join(temp, ".claude/settings.json"), JSON.stringify({ permissions: { allow: ["Bash(git status*)"] }, hooks: { Notification: [{ matcher: "", hooks: [{ type: "command", command: "custom-notify" }] }] } }, null, 2), "utf8");
|
|
374
|
-
|
|
375
|
-
const result = spawnSync(process.execPath, [path.join(root, "scripts/install.mjs"), "--project", temp, "--force", "--model-profile", "inherit"], { cwd: root, encoding: "utf8" });
|
|
376
|
-
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
377
|
-
assert.equal(fs.readFileSync(path.join(temp, ".nccgs/project.yaml"), "utf8"), legacyPolicy);
|
|
378
|
-
assert.ok(fs.existsSync(path.join(temp, ".nccgs/templates/repair-packet.json")));
|
|
379
|
-
assert.match(fs.readFileSync(path.join(temp, ".claude/nccgs/protocols/bounded-repair.md"), "utf8"), /task\.mjs dispatch-prompt/);
|
|
380
|
-
assert.match(fs.readFileSync(path.join(temp, ".claude/nccgs/protocols/standalone.md"), "utf8"), /\[bounded repair\]\(bounded-repair\.md\)/);
|
|
381
|
-
const settings = JSON.parse(fs.readFileSync(path.join(temp, ".claude/settings.json"), "utf8"));
|
|
382
|
-
assert.deepEqual(settings.permissions.allow, ["Bash(git status*)"]);
|
|
383
|
-
assert.ok(settings.permissions.deny.includes("Bash(git reset --hard*)"));
|
|
384
|
-
assert.ok(JSON.stringify(settings.hooks.Notification).includes("custom-notify"));
|
|
385
|
-
assert.ok(JSON.stringify(settings.hooks).includes(".claude/nccgs/hooks/session-start.mjs"));
|
|
386
|
-
assert.match(fs.readFileSync(path.join(temp, "CLAUDE.md"), "utf8"), /@\.claude\/nccgs\/constitution\.md/);
|
|
387
|
-
const firstAgent = path.join(temp, ".claude/agents", `${studio.agents[0].name}.md`);
|
|
388
|
-
assert.ok(!/^model:/m.test(fs.readFileSync(firstAgent, "utf8")));
|
|
389
|
-
assert.ok(fs.existsSync(path.join(temp, ".nccgs/backups")));
|
|
390
|
-
const installStatePath = path.join(temp, ".claude/nccgs/install-state.json");
|
|
391
|
-
const installState = JSON.parse(fs.readFileSync(installStatePath, "utf8"));
|
|
392
|
-
assert.equal(installState.frameworkVersion, frameworkVersion);
|
|
393
|
-
assert.equal(installState.modelProfile, "inherit");
|
|
394
|
-
|
|
395
|
-
const obsoleteRelative = ".claude/agents/nccgs-obsolete.md";
|
|
396
|
-
const obsoletePath = path.join(temp, ...obsoleteRelative.split("/"));
|
|
397
|
-
fs.writeFileSync(obsoletePath, "obsolete framework file\n", "utf8");
|
|
398
|
-
installState.files.push({ relative: obsoleteRelative, sha256: "recorded-by-previous-installer" });
|
|
399
|
-
fs.writeFileSync(installStatePath, `${JSON.stringify(installState, null, 2)}\n`, "utf8");
|
|
400
|
-
const upgrade = spawnSync(process.execPath, [path.join(root, "scripts/install.mjs"), "--project", temp, "--force", "--model-profile", "inherit"], { cwd: root, encoding: "utf8" });
|
|
401
|
-
assert.equal(upgrade.status, 0, upgrade.stderr || upgrade.stdout);
|
|
402
|
-
assert.ok(!fs.existsSync(obsoletePath));
|
|
403
|
-
} finally {
|
|
404
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
405
|
-
}
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
test("update replaces untouched files, preserves local overrides, and doctor gates review", () => {
|
|
409
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-idempotent-update-test-"));
|
|
410
|
-
try {
|
|
411
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
412
|
-
const install = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "install", "--model-profile", "quality"], { cwd: temp, encoding: "utf8" });
|
|
413
|
-
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
414
|
-
|
|
415
|
-
const managedRelative = ".claude/nccgs/constitution.md";
|
|
416
|
-
const managedPath = path.join(temp, ...managedRelative.split("/"));
|
|
417
|
-
const statePath = path.join(temp, ".claude/nccgs/install-state.json");
|
|
418
|
-
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
419
|
-
const oldManagedContent = "managed by NCCGS 1.0.0\n";
|
|
420
|
-
fs.writeFileSync(managedPath, oldManagedContent, "utf8");
|
|
421
|
-
state.frameworkVersion = "1.0.0";
|
|
422
|
-
state.files.find((item) => item.relative === managedRelative).sha256 = sha256(oldManagedContent);
|
|
423
|
-
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
424
|
-
|
|
425
|
-
const update = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
|
|
426
|
-
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
427
|
-
assert.match(update.stdout, new RegExp(`1\\.0\\.0 -> ${frameworkVersion.replace(/\./g, "\\.")}`));
|
|
428
|
-
assert.match(update.stdout, /automatic update/);
|
|
429
|
-
assert.match(update.stdout, /No project migration required/);
|
|
430
|
-
assert.equal(fs.readFileSync(managedPath, "utf8"), fs.readFileSync(path.join(root, managedRelative), "utf8"));
|
|
431
|
-
|
|
432
|
-
let doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
|
|
433
|
-
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
|
434
|
-
assert.match(doctor.stdout, /NCCGS doctor passed/);
|
|
435
|
-
|
|
436
|
-
fs.writeFileSync(managedPath, "project-local override\n", "utf8");
|
|
437
|
-
const preserved = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
|
|
438
|
-
assert.equal(preserved.status, 0, preserved.stderr || preserved.stdout);
|
|
439
|
-
assert.match(preserved.stdout, /preserved override/);
|
|
440
|
-
assert.match(preserved.stdout, /Project migration required/);
|
|
441
|
-
assert.equal(fs.readFileSync(managedPath, "utf8"), "project-local override\n");
|
|
442
|
-
|
|
443
|
-
const overridePath = path.join(temp, ".nccgs/framework-overrides.json");
|
|
444
|
-
const overrides = JSON.parse(fs.readFileSync(overridePath, "utf8"));
|
|
445
|
-
const override = overrides.overrides.find((item) => item.path === managedRelative);
|
|
446
|
-
assert.equal(override.status, "pending_review");
|
|
447
|
-
assert.equal(override.disposition, "preserve");
|
|
448
|
-
|
|
449
|
-
doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
|
|
450
|
-
assert.equal(doctor.status, 1);
|
|
451
|
-
assert.match(doctor.stderr, /awaits \/migrate-project review/);
|
|
452
|
-
|
|
453
|
-
const approve = spawnSync(process.execPath, [path.join(temp, ".claude/nccgs/tools/approve-overrides.mjs"), "--project", temp, "--approve-pending"], { cwd: temp, encoding: "utf8" });
|
|
454
|
-
assert.equal(approve.status, 0, approve.stderr || approve.stdout);
|
|
455
|
-
doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
|
|
456
|
-
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
|
457
|
-
} finally {
|
|
458
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
459
|
-
}
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
test("update adopts unchanged generated agents newly tracked by the package, preserving real edits", () => {
|
|
463
|
-
const f = safetyFixture("nccgs-generated-adoption-");
|
|
464
|
-
try {
|
|
465
|
-
const installed = f.run("install", "--model-profile", "quality");
|
|
466
|
-
assert.equal(installed.status, 0, installed.stderr || installed.stdout);
|
|
467
|
-
const relative = ".claude/agents/nccgs-game-designer.md";
|
|
468
|
-
const file = path.join(f.project, relative);
|
|
469
|
-
const stateFile = path.join(f.project, ".claude/nccgs/install-state.json");
|
|
470
|
-
const generatedFile = path.join(f.project, ".nccgs/agent-activation.json");
|
|
471
|
-
const original = fs.readFileSync(file, "utf8");
|
|
472
|
-
// Older packages can generate a profile's optional agent without listing it
|
|
473
|
-
// in install-state.files. A later package can make that agent a core file.
|
|
474
|
-
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
475
|
-
state.files = state.files.filter(item => item.relative !== relative);
|
|
476
|
-
fs.writeFileSync(stateFile, JSON.stringify(state));
|
|
477
|
-
const update = f.run("update");
|
|
478
|
-
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
479
|
-
const doctor = f.run("doctor");
|
|
480
|
-
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
|
481
|
-
assert.equal(fs.readFileSync(file, "utf8"), original);
|
|
482
|
-
assert.ok(JSON.parse(fs.readFileSync(stateFile)).files.some(item => item.relative === relative));
|
|
483
|
-
|
|
484
|
-
// A stored generated hash alone must never authorize replacing later edits.
|
|
485
|
-
const nextState = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
486
|
-
nextState.files = nextState.files.filter(item => item.relative !== relative);
|
|
487
|
-
fs.writeFileSync(stateFile, JSON.stringify(nextState));
|
|
488
|
-
assert.ok(JSON.parse(fs.readFileSync(generatedFile)).generated[relative]);
|
|
489
|
-
const customized = original + "\nOwner instruction: keep the custom design rule.\n";
|
|
490
|
-
fs.writeFileSync(file, customized);
|
|
491
|
-
const preserved = f.run("update");
|
|
492
|
-
assert.equal(preserved.status, 0, preserved.stderr || preserved.stdout);
|
|
493
|
-
assert.equal(fs.readFileSync(file, "utf8"), customized);
|
|
494
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(f.project, ".nccgs/framework-overrides.json")));
|
|
495
|
-
assert.equal(manifest.overrides.find(item => item.path === relative).status, "pending_review");
|
|
496
|
-
assert.equal(f.run("doctor").status, 1);
|
|
497
|
-
} finally { f.dispose(); }
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
test("doctor accepts a legacy CRLF raw hash after Git normalizes the file to LF", () => {
|
|
501
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-eol-test-"));
|
|
502
|
-
try {
|
|
503
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
504
|
-
const install = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "install"], { cwd: temp, encoding: "utf8" });
|
|
505
|
-
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
506
|
-
|
|
507
|
-
const relative = ".claude/nccgs/constitution.md";
|
|
508
|
-
const file = path.join(temp, ...relative.split("/"));
|
|
509
|
-
const lf = fs.readFileSync(file, "utf8").replace(/\r\n?/g, "\n");
|
|
510
|
-
fs.writeFileSync(file, lf, "utf8");
|
|
511
|
-
const statePath = path.join(temp, ".claude/nccgs/install-state.json");
|
|
512
|
-
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
513
|
-
const record = state.files.find((item) => item.relative === relative);
|
|
514
|
-
record.sha256 = sha256(lf.replace(/\n/g, "\r\n"));
|
|
515
|
-
delete record.canonicalSha256;
|
|
516
|
-
state.schemaVersion = 2;
|
|
517
|
-
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
518
|
-
|
|
519
|
-
const doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
|
|
520
|
-
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
|
521
|
-
} finally {
|
|
522
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
523
|
-
}
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
test("update preserves an intentionally missing managed file until migration review", () => {
|
|
527
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-absent-override-test-"));
|
|
528
|
-
try {
|
|
529
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
530
|
-
const install = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "install"], { cwd: temp, encoding: "utf8" });
|
|
531
|
-
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
532
|
-
|
|
533
|
-
const relative = ".claude/skills/status/SKILL.md";
|
|
534
|
-
const file = path.join(temp, ...relative.split("/"));
|
|
535
|
-
fs.unlinkSync(file);
|
|
536
|
-
const update = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
|
|
537
|
-
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
538
|
-
assert.ok(!fs.existsSync(file));
|
|
539
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(temp, ".nccgs/framework-overrides.json"), "utf8"));
|
|
540
|
-
const override = manifest.overrides.find((item) => item.path === relative);
|
|
541
|
-
assert.equal(override.disposition, "absent");
|
|
542
|
-
assert.equal(override.status, "pending_review");
|
|
543
|
-
} finally {
|
|
544
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
545
|
-
}
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
test("update refuses a project without an NCCGS install state", () => {
|
|
549
|
-
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-update-missing-test-"));
|
|
550
|
-
try {
|
|
551
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
552
|
-
const result = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
|
|
553
|
-
assert.equal(result.status, 2);
|
|
554
|
-
assert.match(result.stderr, /not installed/);
|
|
555
|
-
} finally {
|
|
556
|
-
fs.rmSync(temp, { recursive: true, force: true });
|
|
557
|
-
}
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
function safetyFixture(prefix) {
|
|
561
|
-
const container = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
562
|
-
const project = path.join(container, "project");
|
|
563
|
-
fs.mkdirSync(project);
|
|
564
|
-
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(project, marker));
|
|
565
|
-
const run = (...args) => spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), ...args], { cwd: project, encoding: "utf8" });
|
|
566
|
-
return { container, project, run, dispose() {
|
|
567
|
-
const resolved = path.resolve(container);
|
|
568
|
-
assert.ok(resolved.startsWith(path.resolve(os.tmpdir()) + path.sep + prefix));
|
|
569
|
-
fs.rmSync(resolved, { recursive: true, force: true });
|
|
570
|
-
} };
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
test("portable managed paths reject Windows traversal, streams and device aliases", () => {
|
|
574
|
-
for (const relative of [".claude/..\\..\\sentinel.txt", ".claude/../sentinel.txt", ".claude//file", ".claude/./file", "C:/outside.txt", "//server/share/file",
|
|
575
|
-
".claude/agents/file:stream", ".claude/agents/CON.md", ".claude/agents/NUL", ".claude/agents/COM1.log", ".claude/agents/LPT².txt", ".claude/agents/CONIN$", ".claude/agents/CONOUT$", ".claude/agents/CLOCK$", ".claude/agents/file.", ".claude/agents/file ", ".claude/agents/.. /file"]) {
|
|
576
|
-
assert.throws(() => assertRelativePath(relative), /Unsafe/, relative);
|
|
577
|
-
}
|
|
578
|
-
assert.equal(assertRelativePath(".claude/agents/nccgs-ui-programmer.md"), ".claude/agents/nccgs-ui-programmer.md");
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
test("installer rejects unsafe stale and override manifest paths before touching files", () => {
|
|
582
|
-
const f = safetyFixture("nccgs-path-state-");
|
|
583
|
-
try {
|
|
584
|
-
const install = f.run("install");
|
|
585
|
-
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
586
|
-
const sentinel = path.join(f.container, "sentinel.txt");
|
|
587
|
-
fs.writeFileSync(sentinel, "outside remains untouched");
|
|
588
|
-
const settingsFile = path.join(f.project, ".claude/settings.json");
|
|
589
|
-
const settingsBefore = fs.readFileSync(settingsFile, "utf8");
|
|
590
|
-
const stateFile = path.join(f.project, ".claude/nccgs/install-state.json");
|
|
591
|
-
const original = JSON.parse(fs.readFileSync(stateFile));
|
|
592
|
-
for (const relative of [".claude/..\\..\\sentinel.txt", ".claude/../../sentinel.txt", ".claude/agents/file:stream", ".claude/agents/CON.txt", ".claude/agents/trailing."]) {
|
|
593
|
-
const malicious = { ...original, files: [...original.files, { relative, sha256: sha256("outside remains untouched") }] };
|
|
594
|
-
const before = JSON.stringify(malicious);
|
|
595
|
-
fs.writeFileSync(stateFile, before);
|
|
596
|
-
const update = f.run("update", "--force");
|
|
597
|
-
assert.equal(update.status, 2, `${relative}: ${update.stderr || update.stdout}`);
|
|
598
|
-
assert.match(update.stderr, /Unsafe/);
|
|
599
|
-
assert.equal(fs.readFileSync(sentinel, "utf8"), "outside remains untouched");
|
|
600
|
-
assert.equal(fs.readFileSync(stateFile, "utf8"), before, "rejection must happen before installer writes");
|
|
601
|
-
assert.equal(fs.readFileSync(settingsFile, "utf8"), settingsBefore);
|
|
602
|
-
assert.notEqual(f.run("doctor").status, 0);
|
|
603
|
-
}
|
|
604
|
-
fs.writeFileSync(stateFile, JSON.stringify(original));
|
|
605
|
-
fs.writeFileSync(path.join(f.project, ".nccgs/framework-overrides.json"), JSON.stringify({ schemaVersion: 1, overrides: [{ path: ".claude/..\\..\\sentinel.txt", disposition: "preserve", status: "approved" }] }));
|
|
606
|
-
const update = f.run("update", "--force");
|
|
607
|
-
assert.equal(update.status, 2, update.stderr || update.stdout);
|
|
608
|
-
assert.equal(fs.readFileSync(sentinel, "utf8"), "outside remains untouched");
|
|
609
|
-
} finally { f.dispose(); }
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
test("installer rejects directory junctions and dangling links across managed destinations", async t => {
|
|
613
|
-
for (const relative of [".claude", ".nccgs", ".claude/agents", ".nccgs/backups", ".nccgs/templates", ".claude/skills"]) {
|
|
614
|
-
await t.test(relative, child => {
|
|
615
|
-
const f = safetyFixture("nccgs-path-link-");
|
|
616
|
-
try {
|
|
617
|
-
const outside = path.join(f.container, "outside");
|
|
618
|
-
fs.mkdirSync(outside);
|
|
619
|
-
fs.writeFileSync(path.join(outside, "sentinel.txt"), "untouched");
|
|
620
|
-
const link = path.join(f.project, ...relative.split("/"));
|
|
621
|
-
fs.mkdirSync(path.dirname(link), { recursive: true });
|
|
622
|
-
// The skills case deliberately targets a path that does not exist.
|
|
623
|
-
const linkTarget = relative === ".claude/skills" ? path.join(outside, "missing") : outside;
|
|
624
|
-
try { fs.symlinkSync(linkTarget, link, process.platform === "win32" ? "junction" : "dir"); }
|
|
625
|
-
catch (error) { if (["EPERM", "EACCES", "ENOSYS"].includes(error.code)) { child.skip(`
|
|
626
|
-
assert.throws(() => containedPath(f.project, `${relative}/target.txt`), /Symlink|junction|reparse/);
|
|
627
|
-
const install = f.run("install", "--force");
|
|
628
|
-
assert.equal(install.status, 2, install.stderr || install.stdout);
|
|
629
|
-
assert.match(install.stderr, /Symlink|junction|reparse/);
|
|
630
|
-
assert.deepEqual(fs.readdirSync(outside), ["sentinel.txt"]);
|
|
631
|
-
assert.equal(fs.readFileSync(path.join(outside, "sentinel.txt"), "utf8"), "untouched");
|
|
632
|
-
} finally { f.dispose(); }
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
test("installer rejects file links for shared settings, state, scaffold and CLAUDE targets", async t => {
|
|
638
|
-
for (const relative of ["CLAUDE.md", ".claude/settings.json", ".claude/nccgs/install-state.json", ".nccgs/project.yaml", ".nccgs/requirements.yaml", ".nccgs/agent-activation.json"]) {
|
|
639
|
-
await t.test(relative, child => {
|
|
640
|
-
const f = safetyFixture("nccgs-file-link-");
|
|
641
|
-
try {
|
|
642
|
-
const sentinel = path.join(f.container, "sentinel.txt");
|
|
643
|
-
fs.writeFileSync(sentinel, "outside remains untouched");
|
|
644
|
-
const link = path.join(f.project, ...relative.split("/"));
|
|
645
|
-
fs.mkdirSync(path.dirname(link), { recursive: true });
|
|
646
|
-
try { fs.symlinkSync(sentinel, link, "file"); }
|
|
647
|
-
catch (error) { if (["EPERM", "EACCES", "ENOSYS"].includes(error.code)) { child.skip(`File links unavailable: ${error.code}`); return; } throw error; }
|
|
648
|
-
const install = f.run("install", "--force");
|
|
649
|
-
assert.equal(install.status, 2, install.stderr || install.stdout);
|
|
650
|
-
assert.match(install.stderr, /Symlink|junction|reparse/);
|
|
651
|
-
assert.equal(fs.readFileSync(sentinel, "utf8"), "outside remains untouched");
|
|
652
|
-
} finally { f.dispose(); }
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
test("doctor checks managed hooks and session settings while preserving custom hooks", () => {
|
|
658
|
-
const f = safetyFixture("nccgs-settings-health-");
|
|
659
|
-
try {
|
|
660
|
-
const settingsFile = path.join(f.project, ".claude/settings.json");
|
|
661
|
-
fs.mkdirSync(path.dirname(settingsFile));
|
|
662
|
-
const customHook = { type: "command", command: "node custom-project-check.mjs" };
|
|
663
|
-
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { SessionStart: [{ matcher: "", hooks: [customHook, { type: "command", command: "node .claude/nccgs/hooks/session-start.mjs", timeout: 10 }] }] } }));
|
|
664
|
-
const install = f.run("install");
|
|
665
|
-
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
666
|
-
const correct = JSON.parse(fs.readFileSync(settingsFile));
|
|
667
|
-
assert.ok(correct.hooks.SessionStart.some(group => group.hooks.some(hook => hook.command === customHook.command)), "custom hook sharing a managed group must survive");
|
|
668
|
-
assert.equal(f.run("doctor").status, 0);
|
|
669
|
-
const variations = [
|
|
670
|
-
[{}, /Required NCCGS hook|session model/],
|
|
671
|
-
[{ ...correct, hooks: { ...correct.hooks, PreToolUse: [] } }, /Required NCCGS hook/],
|
|
672
|
-
[{ ...correct, hooks: { ...correct.hooks, PreToolUse: [{ hooks: {} }] } }, /Required NCCGS hook/],
|
|
673
|
-
[{ ...correct, disableAllHooks: true }, /disable all hooks/],
|
|
674
|
-
[{ ...correct, model: "sonnet" }, /session model/],
|
|
675
|
-
[{ ...correct, effortLevel: "low" }, /session effort/],
|
|
676
|
-
[{ ...correct, modelSettings: {} }, /per-model effort/]
|
|
677
|
-
];
|
|
678
|
-
for (const [settings, pattern] of variations) {
|
|
679
|
-
fs.writeFileSync(settingsFile, JSON.stringify(settings));
|
|
680
|
-
const doctor = f.run("doctor");
|
|
681
|
-
assert.equal(doctor.status, 1, doctor.stderr || doctor.stdout);
|
|
682
|
-
assert.match(doctor.stderr, pattern);
|
|
683
|
-
}
|
|
684
|
-
fs.writeFileSync(settingsFile, JSON.stringify(correct));
|
|
685
|
-
const localFile = path.join(f.project, ".claude/settings.local.json");
|
|
686
|
-
fs.writeFileSync(localFile, JSON.stringify({ hooks: { Notification: [{ hooks: [customHook] }] }, env: { PROJECT_TEST: "kept" } }));
|
|
687
|
-
const validLocal = f.run("doctor");
|
|
688
|
-
assert.equal(validLocal.status, 0, validLocal.stderr || validLocal.stdout);
|
|
689
|
-
assert.match(validLocal.stdout, /Settings scope: project settings/);
|
|
690
|
-
assert.match(validLocal.stderr, /Local Claude settings are present/);
|
|
691
|
-
fs.writeFileSync(localFile, JSON.stringify({ model: "sonnet" }));
|
|
692
|
-
const conflict = f.run("doctor");
|
|
693
|
-
assert.equal(conflict.status, 1);
|
|
694
|
-
assert.match(conflict.stderr, /Local settings override/);
|
|
695
|
-
const update = f.run("update");
|
|
696
|
-
assert.notEqual(update.status, 0, "a preserved conflicting local setting must not be declared healthy");
|
|
697
|
-
assert.equal(JSON.parse(fs.readFileSync(localFile)).model, "sonnet", "installer must preserve local settings");
|
|
698
|
-
assert.ok(JSON.parse(fs.readFileSync(settingsFile)).hooks.SessionStart.some(group => group.hooks.some(hook => hook.command === customHook.command)));
|
|
699
|
-
fs.unlinkSync(localFile);
|
|
700
|
-
const inputsFile = path.join(f.project, ".nccgs/inputs.json");
|
|
701
|
-
for (const inputs of [{ schemaVersion: 999 }, { schemaVersion: 1, canonicalInputs: [], outputDirectories: ["Assets"], checks: {} }]) {
|
|
702
|
-
fs.writeFileSync(inputsFile, JSON.stringify(inputs));
|
|
703
|
-
const doctor = f.run("doctor");
|
|
704
|
-
assert.equal(doctor.status, 1);
|
|
705
|
-
assert.match(doctor.stderr, /Invalid canonical input\/output policy/);
|
|
706
|
-
}
|
|
707
|
-
} finally { f.dispose(); }
|
|
708
|
-
});
|
|
709
|
-
|
|
710
|
-
test("doctor checks historical closure evidence without treating old approvals as current", () => {
|
|
711
|
-
const f = safetyFixture("nccgs-doctor-history-");
|
|
712
|
-
try {
|
|
713
|
-
const install = f.run("install");
|
|
714
|
-
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
715
|
-
const write = (relative, value) => {
|
|
716
|
-
const file = containedPath(f.project, relative);
|
|
717
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
718
|
-
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
719
|
-
fs.writeFileSync(file, text);
|
|
720
|
-
return { path: relative, sha256: sha256(text) };
|
|
721
|
-
};
|
|
722
|
-
const raw = write(".nccgs/evidence/runtime/sources/history.json", { fixture: "Synthetic historical evidence only" });
|
|
723
|
-
const runtime = write(".nccgs/evidence/runtime/observations/history.json", { observationSource: raw });
|
|
724
|
-
const log = write(".nccgs/evidence/history.log", "Synthetic historical check");
|
|
725
|
-
const execution = write(".nccgs/evidence/history-check.json", { origin: "verification-command", log });
|
|
726
|
-
const report = write(".nccgs/evidence/history-report.md", "Synthetic historical report");
|
|
727
|
-
write(".nccgs/tasks/history.json", { schemaVersion: 1, id: "history", closed: { snapshot: "historical-source" }, stages: {
|
|
728
|
-
implementation: { runtime, checks: [{ evidence: execution }] }, review: { runtime, evidence: report },
|
|
729
|
-
report: { evidence: report }, astra: { evidence: report }, acceptance: { snapshot: "historical-source", evidence: report }
|
|
730
|
-
} });
|
|
731
|
-
const historical = f.run("doctor");
|
|
732
|
-
assert.equal(historical.status, 0, historical.stderr || historical.stdout);
|
|
733
|
-
assert.match(historical.stderr, /predates canonical input pins/);
|
|
734
|
-
assert.match(historical.stderr, /cannot approve current work/);
|
|
735
|
-
write(raw.path, { fixture: "Changed raw evidence" });
|
|
736
|
-
const stale = f.run("doctor");
|
|
737
|
-
assert.equal(stale.status, 1);
|
|
738
|
-
assert.match(stale.stderr, /Invalid closed task history.json: Missing or stale artifact/);
|
|
739
|
-
} finally { f.dispose(); }
|
|
740
|
-
});
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { compile, renderAgent } from "../.claude/nccgs/tools/compile-policy.mjs";
|
|
10
|
+
import { assertRelativePath, containedPath } from "../.claude/nccgs/tools/paths.mjs";
|
|
11
|
+
|
|
12
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
const studio = JSON.parse(fs.readFileSync(path.join(root, ".claude/nccgs/studio.json"), "utf8"));
|
|
14
|
+
const workflows = JSON.parse(fs.readFileSync(path.join(root, ".claude/nccgs/workflow-catalog.json"), "utf8"));
|
|
15
|
+
const pipeline = JSON.parse(fs.readFileSync(path.join(root, ".claude/nccgs/pipeline.json"), "utf8"));
|
|
16
|
+
function copyAgentFixture(temp) {
|
|
17
|
+
fs.cpSync(path.join(root, ".claude/nccgs"), path.join(temp, ".claude/nccgs"), { recursive: true });
|
|
18
|
+
fs.mkdirSync(path.join(temp, ".claude/agents"), { recursive: true });
|
|
19
|
+
for (const agent of studio.agents) {
|
|
20
|
+
const template = fs.readFileSync(path.join(root, ".claude/nccgs/agent-library", `${agent.name}.md`), "utf8");
|
|
21
|
+
fs.writeFileSync(path.join(temp, ".claude/agents", `${agent.name}.md`), renderAgent(template, pipeline.profiles[pipeline.defaultProfile].models[agent.modelClass]));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const frameworkVersion = fs.readFileSync(path.join(root, "VERSION"), "utf8").trim();
|
|
25
|
+
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
26
|
+
const adaptiveRoutes = {
|
|
27
|
+
lightweight: { model: "sonnet", effort: "medium" },
|
|
28
|
+
standard: { model: "claude-opus-5-5", effort: "medium" },
|
|
29
|
+
deep: { model: "claude-opus-5-5", effort: "high" },
|
|
30
|
+
leadership: { model: "claude-opus-5-5", effort: "high" },
|
|
31
|
+
review: { model: "fable", effort: "high" }
|
|
32
|
+
};
|
|
33
|
+
const field = (text, name) => text.match(new RegExp(`^${name}:\\s*([^\\r\\n]+)`, "m"))?.[1]?.trim() ?? null;
|
|
34
|
+
function assertAgentRoute(project, agentName, expected) {
|
|
35
|
+
const text = fs.readFileSync(path.join(project, ".claude/agents", `${agentName}.md`), "utf8");
|
|
36
|
+
assert.equal(field(text, "model"), expected.model, `${agentName} model`);
|
|
37
|
+
assert.equal(field(text, "effort"), expected.effort, `${agentName} effort`);
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("canonical policy changes invalidate generated routing until compilation", () => {
|
|
42
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-policy-test-"));
|
|
43
|
+
try {
|
|
44
|
+
copyAgentFixture(temp);
|
|
45
|
+
compile(temp, true);
|
|
46
|
+
assert.deepEqual(compile(temp), []);
|
|
47
|
+
const file = path.join(temp, ".claude/nccgs/pipeline.json");
|
|
48
|
+
const policy = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
49
|
+
policy.profiles["claude-standalone"].models.review.effort = "medium";
|
|
50
|
+
fs.writeFileSync(file, JSON.stringify(policy));
|
|
51
|
+
const stale = compile(temp);
|
|
52
|
+
assert.ok(stale.includes(".claude/agents/nccgs-adversarial-reviewer.md"));
|
|
53
|
+
assert.ok(stale.includes(".claude/nccgs/workflow-catalog.json"));
|
|
54
|
+
compile(temp, true);
|
|
55
|
+
assert.deepEqual(compile(temp), []);
|
|
56
|
+
const catalog = JSON.parse(fs.readFileSync(path.join(temp, ".claude/nccgs/studio.json"), "utf8"));
|
|
57
|
+
assert.equal(catalog.effortProfiles["claude-standalone"].review, "medium");
|
|
58
|
+
const reviewerTemplate = path.join(temp, ".claude/nccgs/agent-library/nccgs-adversarial-reviewer.md");
|
|
59
|
+
fs.writeFileSync(reviewerTemplate, fs.readFileSync(reviewerTemplate, "utf8").replace(/^maxTurns: \d+/m, "maxTurns: 63"));
|
|
60
|
+
assert.ok(compile(temp).includes(".claude/nccgs/studio.json"));
|
|
61
|
+
compile(temp, true);
|
|
62
|
+
assert.equal(field(fs.readFileSync(path.join(temp, ".claude/agents/nccgs-adversarial-reviewer.md"), "utf8"), "maxTurns"), "63");
|
|
63
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(temp, ".claude/nccgs/studio.json"), "utf8")).agents.find(a => a.name === "nccgs-adversarial-reviewer").maxTurns, 63);
|
|
64
|
+
} finally {
|
|
65
|
+
assert.ok(path.resolve(temp).startsWith(path.resolve(os.tmpdir()) + path.sep + "nccgs-policy-test-"));
|
|
66
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("studio roles are unique and workflow references resolve", () => {
|
|
71
|
+
const roles = new Set(studio.agents.map((agent) => agent.name));
|
|
72
|
+
assert.equal(roles.size, studio.agents.length);
|
|
73
|
+
for (const workflow of workflows.workflows) {
|
|
74
|
+
for (const role of workflow.primaryRoles) assert.ok(roles.has(role), `${workflow.name}: ${role}`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("quality profile assigns all model classes", () => {
|
|
79
|
+
for (const agent of studio.agents) {
|
|
80
|
+
assert.ok(studio.modelProfiles.quality[agent.modelClass], agent.name);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("optional specialists survive updates and can be disabled without changing core agents", () => {
|
|
85
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-optional-agents-"));
|
|
86
|
+
try {
|
|
87
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
88
|
+
const run = (...args) => spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), ...args], { cwd: temp, encoding: "utf8" });
|
|
89
|
+
const install = run("install");
|
|
90
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
91
|
+
const role = "nccgs-network-programmer";
|
|
92
|
+
const file = path.join(temp, ".claude/agents", `${role}.md`);
|
|
93
|
+
assert.ok(!fs.existsSync(file));
|
|
94
|
+
assert.equal(run("agents", "--enable", role).status, 0);
|
|
95
|
+
assert.equal(field(fs.readFileSync(file, "utf8"), "model"), "opus");
|
|
96
|
+
const update = run("update");
|
|
97
|
+
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
98
|
+
assert.ok(fs.existsSync(file));
|
|
99
|
+
assert.equal(run("doctor").status, 0);
|
|
100
|
+
assert.equal(run("agents", "--disable", role).status, 0);
|
|
101
|
+
assert.ok(!fs.existsSync(file));
|
|
102
|
+
assert.notEqual(run("agents", "--disable", "nccgs-adversarial-reviewer").status, 0);
|
|
103
|
+
assert.equal(fs.readdirSync(path.join(temp, ".claude/agents")).length, pipeline.coreAgents.length);
|
|
104
|
+
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("explicit Astra compatibility install retains Sonnet, pinned Opus and Fable routes without erasing settings", () => {
|
|
108
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-astra-install-"));
|
|
109
|
+
try {
|
|
110
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings", ".claude"]) fs.mkdirSync(path.join(temp, marker));
|
|
111
|
+
const settingsPath = path.join(temp, ".claude/settings.json");
|
|
112
|
+
fs.writeFileSync(settingsPath, JSON.stringify({ model: "sonnet", effortLevel: "medium", modelSettings: { "claude-opus-5-5": { effortLevel: "low" }, "claude-sonnet-4-6": { effortLevel: "medium" } }, permissions: { allow: ["Bash(git status*)"] }, env: { NCCGS_TEST: "preserved" } }));
|
|
113
|
+
const run = (...args) => spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), ...args], { cwd: temp, encoding: "utf8" });
|
|
114
|
+
const dry = run("install", "--model-profile", "astra-claude", "--dry-run");
|
|
115
|
+
assert.equal(dry.status, 0, dry.stderr || dry.stdout);
|
|
116
|
+
assert.equal(JSON.parse(fs.readFileSync(settingsPath)).model, "sonnet");
|
|
117
|
+
assert.ok(!fs.existsSync(path.join(temp, ".claude/agents")));
|
|
118
|
+
const install = run("install", "--model-profile", "astra-claude");
|
|
119
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
120
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath));
|
|
121
|
+
assert.equal(settings.model, "claude-opus-5-5");
|
|
122
|
+
assert.equal(settings.effortLevel, "medium");
|
|
123
|
+
assert.equal(settings.modelSettings["claude-opus-5-5"].effortLevel, "medium");
|
|
124
|
+
assert.equal(settings.modelSettings["claude-sonnet-4-6"].effortLevel, "medium");
|
|
125
|
+
assert.equal(settings.env.NCCGS_TEST, "preserved");
|
|
126
|
+
assert.deepEqual(settings.permissions.allow, ["Bash(git status*)"]);
|
|
127
|
+
assert.deepEqual(pipeline.profiles["astra-claude"].models, adaptiveRoutes);
|
|
128
|
+
assert.equal(studio.agents.length, 47);
|
|
129
|
+
assert.equal(pipeline.coreAgents.length, 13);
|
|
130
|
+
assert.ok(pipeline.coreAgents.includes("nccgs-game-designer"), "Standalone design must be dispatchable on a default installation");
|
|
131
|
+
for (const agent of studio.agents) {
|
|
132
|
+
if (!pipeline.coreAgents.includes(agent.name)) {
|
|
133
|
+
assert.ok(!fs.existsSync(path.join(temp, ".claude/agents", `${agent.name}.md`)));
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
assertAgentRoute(temp, agent.name, adaptiveRoutes[agent.modelClass]);
|
|
137
|
+
}
|
|
138
|
+
const fast = assertAgentRoute(temp, "nccgs-fast-implementer", adaptiveRoutes.lightweight);
|
|
139
|
+
const scout = assertAgentRoute(temp, "nccgs-task-scout", adaptiveRoutes.lightweight);
|
|
140
|
+
assert.equal(field(fast, "maxTurns"), "12");
|
|
141
|
+
assert.equal(field(scout, "maxTurns"), "10");
|
|
142
|
+
assert.match(field(fast, "tools"), /\b(?:Write|Edit)\b/);
|
|
143
|
+
assert.doesNotMatch(field(scout, "tools"), /\b(?:Write|Edit|Bash|PowerShell|Agent|Task)\b/);
|
|
144
|
+
assert.match(field(scout, "tools"), /\bRead\b/);
|
|
145
|
+
assertAgentRoute(temp, "nccgs-unity-implementer", adaptiveRoutes.deep);
|
|
146
|
+
assertAgentRoute(temp, "nccgs-gameplay-programmer", adaptiveRoutes.standard);
|
|
147
|
+
assert.match(fs.readFileSync(path.join(temp, ".nccgs/project.yaml"), "utf8"), /profile: astra-claude/);
|
|
148
|
+
assert.ok(fs.existsSync(path.join(temp, ".nccgs/templates/implementation-report.md")));
|
|
149
|
+
const update = run("update");
|
|
150
|
+
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
151
|
+
assert.match(update.stdout, /0 preserved override/);
|
|
152
|
+
const doctor = run("doctor");
|
|
153
|
+
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
|
154
|
+
} finally {
|
|
155
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("update migrates previous Opus xhigh routes while preserving customized agents", () => {
|
|
160
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-adaptive-upgrade-"));
|
|
161
|
+
try {
|
|
162
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
163
|
+
const run = (...args) => spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), ...args], { cwd: temp, encoding: "utf8" });
|
|
164
|
+
const install = run("install", "--model-profile", "astra-claude");
|
|
165
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
166
|
+
const stateFile = path.join(temp, ".claude/nccgs/install-state.json");
|
|
167
|
+
const activationFile = path.join(temp, ".nccgs/agent-activation.json");
|
|
168
|
+
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
169
|
+
const activation = JSON.parse(fs.readFileSync(activationFile, "utf8"));
|
|
170
|
+
const newAgents = ["nccgs-fast-implementer", "nccgs-task-scout"];
|
|
171
|
+
for (const name of pipeline.coreAgents) {
|
|
172
|
+
const relative = `.claude/agents/${name}.md`;
|
|
173
|
+
const file = path.join(temp, relative);
|
|
174
|
+
if (newAgents.includes(name)) {
|
|
175
|
+
// These roles were not present in the previous installed route set.
|
|
176
|
+
fs.unlinkSync(file);
|
|
177
|
+
state.files = state.files.filter(item => item.relative !== relative);
|
|
178
|
+
delete activation.generated[relative];
|
|
179
|
+
delete activation.generatedCanonical[relative];
|
|
180
|
+
} else if (name !== "nccgs-adversarial-reviewer") {
|
|
181
|
+
const previous = renderAgent(fs.readFileSync(file, "utf8"), { model: "claude-opus-5-5", effort: "xhigh" });
|
|
182
|
+
fs.writeFileSync(file, previous);
|
|
183
|
+
const managed = state.files.find(item => item.relative === relative);
|
|
184
|
+
managed.sha256 = sha256(previous);
|
|
185
|
+
delete managed.canonicalSha256;
|
|
186
|
+
activation.generated[relative] = sha256(previous);
|
|
187
|
+
activation.generatedCanonical[relative] = sha256(previous);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
fs.writeFileSync(stateFile, JSON.stringify(state));
|
|
191
|
+
fs.writeFileSync(activationFile, JSON.stringify(activation));
|
|
192
|
+
const settingsFile = path.join(temp, ".claude/settings.json");
|
|
193
|
+
const settings = JSON.parse(fs.readFileSync(settingsFile));
|
|
194
|
+
settings.effortLevel = "xhigh";
|
|
195
|
+
settings.modelSettings["claude-opus-5-5"].effortLevel = "xhigh";
|
|
196
|
+
fs.writeFileSync(settingsFile, JSON.stringify(settings));
|
|
197
|
+
const customName = "nccgs-gameplay-programmer";
|
|
198
|
+
const customPath = `.claude/agents/${customName}.md`;
|
|
199
|
+
const customFile = path.join(temp, customPath);
|
|
200
|
+
const custom = fs.readFileSync(customFile, "utf8") + "\nProject rule: keep the local combat acceptance checklist.\n";
|
|
201
|
+
fs.writeFileSync(customFile, custom);
|
|
202
|
+
|
|
203
|
+
const update = run("update");
|
|
204
|
+
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
205
|
+
assert.equal(fs.readFileSync(customFile, "utf8"), custom);
|
|
206
|
+
for (const agent of studio.agents.filter(agent => pipeline.coreAgents.includes(agent.name) && agent.name !== customName)) {
|
|
207
|
+
assertAgentRoute(temp, agent.name, adaptiveRoutes[agent.modelClass]);
|
|
208
|
+
}
|
|
209
|
+
const configured = JSON.parse(fs.readFileSync(settingsFile));
|
|
210
|
+
assert.equal(configured.effortLevel, "medium");
|
|
211
|
+
assert.equal(configured.modelSettings["claude-opus-5-5"].effortLevel, "medium");
|
|
212
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(temp, ".nccgs/framework-overrides.json")));
|
|
213
|
+
assert.equal(manifest.overrides.find(item => item.path === customPath)?.status, "pending_review");
|
|
214
|
+
assert.equal(JSON.parse(fs.readFileSync(stateFile)).modelProfile, "astra-claude");
|
|
215
|
+
assert.match(update.stdout, /Project migration required/);
|
|
216
|
+
} finally {
|
|
217
|
+
assert.ok(path.resolve(temp).startsWith(path.resolve(os.tmpdir()) + path.sep + "nccgs-adaptive-upgrade-"));
|
|
218
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("updates retain quality, balanced and inherit profiles and custom agent instructions", () => {
|
|
223
|
+
const legacyModels = {
|
|
224
|
+
quality: { lightweight: "haiku", standard: "sonnet", deep: "opus", leadership: "opus", review: "opus" },
|
|
225
|
+
balanced: { lightweight: "haiku", standard: "sonnet", deep: "opus", leadership: "sonnet", review: "opus" },
|
|
226
|
+
inherit: { lightweight: null, standard: null, deep: null, leadership: null, review: null }
|
|
227
|
+
};
|
|
228
|
+
for (const [profile, models] of Object.entries(legacyModels)) {
|
|
229
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-legacy-routing-"));
|
|
230
|
+
try {
|
|
231
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings", ".claude"]) fs.mkdirSync(path.join(temp, marker));
|
|
232
|
+
const settingsFile = path.join(temp, ".claude/settings.json");
|
|
233
|
+
fs.writeFileSync(settingsFile, JSON.stringify({ model: "sonnet", effortLevel: "high" }));
|
|
234
|
+
const run = (...args) => spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), ...args], { cwd: temp, encoding: "utf8" });
|
|
235
|
+
const install = run("install", "--model-profile", profile);
|
|
236
|
+
assert.equal(install.status, 0, `${profile}: ${install.stderr || install.stdout}`);
|
|
237
|
+
const customName = "nccgs-gameplay-programmer";
|
|
238
|
+
const customFile = path.join(temp, ".claude/agents", `${customName}.md`);
|
|
239
|
+
const custom = fs.readFileSync(customFile, "utf8") + "\nProject rule: retain existing gameplay interfaces.\n";
|
|
240
|
+
fs.writeFileSync(customFile, custom);
|
|
241
|
+
const update = run("update");
|
|
242
|
+
assert.equal(update.status, 0, `${profile}: ${update.stderr || update.stdout}`);
|
|
243
|
+
assert.equal(fs.readFileSync(customFile, "utf8"), custom, profile);
|
|
244
|
+
const policy = fs.readFileSync(path.join(temp, ".nccgs/project.yaml"), "utf8");
|
|
245
|
+
assert.match(policy, new RegExp(`profile: ${profile}\\b`));
|
|
246
|
+
const state = JSON.parse(fs.readFileSync(path.join(temp, ".claude/nccgs/install-state.json")));
|
|
247
|
+
assert.equal(state.modelProfile, profile);
|
|
248
|
+
assert.equal(fs.readdirSync(path.join(temp, ".claude/agents")).length, studio.agents.length);
|
|
249
|
+
for (const agent of studio.agents.filter(agent => agent.name !== customName)) {
|
|
250
|
+
assertAgentRoute(temp, agent.name, { model: models[agent.modelClass], effort: null });
|
|
251
|
+
}
|
|
252
|
+
const settings = JSON.parse(fs.readFileSync(settingsFile));
|
|
253
|
+
assert.equal(settings.model, "sonnet");
|
|
254
|
+
assert.equal(settings.effortLevel, "high");
|
|
255
|
+
} finally {
|
|
256
|
+
assert.ok(path.resolve(temp).startsWith(path.resolve(os.tmpdir()) + path.sep + "nccgs-legacy-routing-"));
|
|
257
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("profile changes clear effort pins and preserve skipped custom agents", () => {
|
|
263
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-effort-profile-"));
|
|
264
|
+
try {
|
|
265
|
+
fs.mkdirSync(path.join(temp, ".claude/nccgs"), { recursive: true });
|
|
266
|
+
fs.mkdirSync(path.join(temp, ".claude/agents"));
|
|
267
|
+
fs.mkdirSync(path.join(temp, ".nccgs"));
|
|
268
|
+
fs.copyFileSync(path.join(root, ".claude/nccgs/studio.json"), path.join(temp, ".claude/nccgs/studio.json"));
|
|
269
|
+
fs.writeFileSync(path.join(temp, ".nccgs/project.yaml"), "models:\n profile: astra-claude\n");
|
|
270
|
+
copyAgentFixture(temp);
|
|
271
|
+
const run = (profile, ...args) => spawnSync(process.execPath, [path.join(root, ".claude/nccgs/tools/configure-models.mjs"), "--project", temp, "--profile", profile, ...args], { encoding: "utf8" });
|
|
272
|
+
for (const profile of ["inherit", "quality", "balanced", "astra-claude"]) {
|
|
273
|
+
const result = run(profile);
|
|
274
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
275
|
+
for (const agent of studio.agents) {
|
|
276
|
+
if (profile === "astra-claude" && !pipeline.coreAgents.includes(agent.name)) {
|
|
277
|
+
assert.ok(!fs.existsSync(path.join(temp, ".claude/agents", `${agent.name}.md`)));
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const text = fs.readFileSync(path.join(temp, ".claude/agents", `${agent.name}.md`), "utf8");
|
|
281
|
+
assert.equal(text.match(/^model:\s*(\S+)/m)?.[1] ?? null, studio.modelProfiles[profile][agent.modelClass], agent.name);
|
|
282
|
+
const expectedEffort = pipeline.profiles[profile].models[agent.modelClass].effort;
|
|
283
|
+
assert.equal(text.match(/^effort:\s*(\S+)/m)?.[1] ?? null, expectedEffort, agent.name);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const file = path.join(temp, ".claude/agents/nccgs-adversarial-reviewer.md");
|
|
287
|
+
const before = fs.readFileSync(file, "utf8");
|
|
288
|
+
assert.equal(run("inherit", "--skip-path", ".claude/agents/nccgs-adversarial-reviewer.md").status, 0);
|
|
289
|
+
assert.equal(fs.readFileSync(file, "utf8"), before);
|
|
290
|
+
const invalid = run("unknown");
|
|
291
|
+
assert.notEqual(invalid.status, 0);
|
|
292
|
+
assert.equal(fs.readFileSync(file, "utf8"), before);
|
|
293
|
+
fs.writeFileSync(path.join(temp, ".claude/settings.json"), "invalid JSON");
|
|
294
|
+
assert.notEqual(run("astra-claude").status, 0);
|
|
295
|
+
assert.equal(fs.readFileSync(file, "utf8"), before);
|
|
296
|
+
const other = fs.readFileSync(path.join(temp, ".claude/agents/nccgs-unity-implementer.md"), "utf8");
|
|
297
|
+
assert.ok(!/^model:/m.test(other), "invalid settings must fail before rewriting agents");
|
|
298
|
+
} finally {
|
|
299
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("migrate-project defaults to a single full run", () => {
|
|
304
|
+
const skill = fs.readFileSync(path.join(root, ".claude/skills/migrate-project/references/legacy.md"), "utf8");
|
|
305
|
+
assert.match(skill, /Default to `full`/);
|
|
306
|
+
assert.match(skill, /review, plan, apply, and verify in one invocation/i);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("model configurator can remove and restore model pins", () => {
|
|
310
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-model-test-"));
|
|
311
|
+
try {
|
|
312
|
+
fs.mkdirSync(path.join(temp, ".claude/nccgs/tools"), { recursive: true });
|
|
313
|
+
fs.mkdirSync(path.join(temp, ".claude/agents"), { recursive: true });
|
|
314
|
+
fs.mkdirSync(path.join(temp, ".nccgs"), { recursive: true });
|
|
315
|
+
fs.copyFileSync(path.join(root, ".claude/nccgs/studio.json"), path.join(temp, ".claude/nccgs/studio.json"));
|
|
316
|
+
fs.copyFileSync(path.join(root, ".claude/nccgs/tools/configure-models.mjs"), path.join(temp, ".claude/nccgs/tools/configure-models.mjs"));
|
|
317
|
+
fs.writeFileSync(path.join(temp, ".nccgs/project.yaml"), "models:\n profile: quality\n", "utf8");
|
|
318
|
+
copyAgentFixture(temp);
|
|
319
|
+
const tool = path.join(temp, ".claude/nccgs/tools/configure-models.mjs");
|
|
320
|
+
assert.equal(spawnSync(process.execPath, [tool, "--project", temp, "--profile", "inherit"]).status, 0);
|
|
321
|
+
assert.ok(!/^model:/m.test(fs.readFileSync(path.join(temp, ".claude/agents", `${studio.agents[0].name}.md`), "utf8")));
|
|
322
|
+
assert.equal(spawnSync(process.execPath, [tool, "--project", temp, "--profile", "quality"]).status, 0);
|
|
323
|
+
assert.ok(/^model:\s*(opus|sonnet|haiku)$/m.test(fs.readFileSync(path.join(temp, ".claude/agents", `${studio.agents[0].name}.md`), "utf8")));
|
|
324
|
+
} finally {
|
|
325
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("destructive git commands are blocked by the managed hook", () => {
|
|
330
|
+
const hook = path.join(root, ".claude/nccgs/hooks/protect-git.mjs");
|
|
331
|
+
const result = spawnSync(process.execPath, [hook], {
|
|
332
|
+
cwd: root,
|
|
333
|
+
input: JSON.stringify({ tool_input: { command: "git reset --hard" } }),
|
|
334
|
+
encoding: "utf8"
|
|
335
|
+
});
|
|
336
|
+
assert.equal(result.status, 2);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("installer dry-run accepts an empty Unity-shaped project", () => {
|
|
340
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-install-test-"));
|
|
341
|
+
try {
|
|
342
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
343
|
+
const result = spawnSync(process.execPath, [path.join(root, "scripts/install.mjs"), "--project", temp, "--dry-run"], { cwd: root, encoding: "utf8" });
|
|
344
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
345
|
+
assert.match(result.stdout, /Dry run complete/);
|
|
346
|
+
} finally {
|
|
347
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("CLI installs into the current directory by default", () => {
|
|
352
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-cli-test-"));
|
|
353
|
+
try {
|
|
354
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
355
|
+
const result = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "install", "--dry-run"], { cwd: temp, encoding: "utf8" });
|
|
356
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
357
|
+
assert.match(result.stdout, new RegExp(`NCCGS installation target: ${temp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
358
|
+
assert.match(result.stdout, /Dry run complete/);
|
|
359
|
+
} finally {
|
|
360
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("installer preserves project truth, merges settings, and applies inherit profile", () => {
|
|
365
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-upgrade-test-"));
|
|
366
|
+
try {
|
|
367
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
368
|
+
fs.mkdirSync(path.join(temp, ".nccgs"));
|
|
369
|
+
fs.mkdirSync(path.join(temp, ".claude"));
|
|
370
|
+
const legacyPolicy = "schema_version: 1\nownership:\n protected_paths:\n - Assets/HandTuned/**\n";
|
|
371
|
+
fs.writeFileSync(path.join(temp, ".nccgs/project.yaml"), legacyPolicy, "utf8");
|
|
372
|
+
fs.writeFileSync(path.join(temp, "CLAUDE.md"), "# Existing project instructions\n", "utf8");
|
|
373
|
+
fs.writeFileSync(path.join(temp, ".claude/settings.json"), JSON.stringify({ permissions: { allow: ["Bash(git status*)"] }, hooks: { Notification: [{ matcher: "", hooks: [{ type: "command", command: "custom-notify" }] }] } }, null, 2), "utf8");
|
|
374
|
+
|
|
375
|
+
const result = spawnSync(process.execPath, [path.join(root, "scripts/install.mjs"), "--project", temp, "--force", "--model-profile", "inherit"], { cwd: root, encoding: "utf8" });
|
|
376
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
377
|
+
assert.equal(fs.readFileSync(path.join(temp, ".nccgs/project.yaml"), "utf8"), legacyPolicy);
|
|
378
|
+
assert.ok(fs.existsSync(path.join(temp, ".nccgs/templates/repair-packet.json")));
|
|
379
|
+
assert.match(fs.readFileSync(path.join(temp, ".claude/nccgs/protocols/bounded-repair.md"), "utf8"), /task\.mjs dispatch-prompt/);
|
|
380
|
+
assert.match(fs.readFileSync(path.join(temp, ".claude/nccgs/protocols/standalone.md"), "utf8"), /\[bounded repair\]\(bounded-repair\.md\)/);
|
|
381
|
+
const settings = JSON.parse(fs.readFileSync(path.join(temp, ".claude/settings.json"), "utf8"));
|
|
382
|
+
assert.deepEqual(settings.permissions.allow, ["Bash(git status*)"]);
|
|
383
|
+
assert.ok(settings.permissions.deny.includes("Bash(git reset --hard*)"));
|
|
384
|
+
assert.ok(JSON.stringify(settings.hooks.Notification).includes("custom-notify"));
|
|
385
|
+
assert.ok(JSON.stringify(settings.hooks).includes(".claude/nccgs/hooks/session-start.mjs"));
|
|
386
|
+
assert.match(fs.readFileSync(path.join(temp, "CLAUDE.md"), "utf8"), /@\.claude\/nccgs\/constitution\.md/);
|
|
387
|
+
const firstAgent = path.join(temp, ".claude/agents", `${studio.agents[0].name}.md`);
|
|
388
|
+
assert.ok(!/^model:/m.test(fs.readFileSync(firstAgent, "utf8")));
|
|
389
|
+
assert.ok(fs.existsSync(path.join(temp, ".nccgs/backups")));
|
|
390
|
+
const installStatePath = path.join(temp, ".claude/nccgs/install-state.json");
|
|
391
|
+
const installState = JSON.parse(fs.readFileSync(installStatePath, "utf8"));
|
|
392
|
+
assert.equal(installState.frameworkVersion, frameworkVersion);
|
|
393
|
+
assert.equal(installState.modelProfile, "inherit");
|
|
394
|
+
|
|
395
|
+
const obsoleteRelative = ".claude/agents/nccgs-obsolete.md";
|
|
396
|
+
const obsoletePath = path.join(temp, ...obsoleteRelative.split("/"));
|
|
397
|
+
fs.writeFileSync(obsoletePath, "obsolete framework file\n", "utf8");
|
|
398
|
+
installState.files.push({ relative: obsoleteRelative, sha256: "recorded-by-previous-installer" });
|
|
399
|
+
fs.writeFileSync(installStatePath, `${JSON.stringify(installState, null, 2)}\n`, "utf8");
|
|
400
|
+
const upgrade = spawnSync(process.execPath, [path.join(root, "scripts/install.mjs"), "--project", temp, "--force", "--model-profile", "inherit"], { cwd: root, encoding: "utf8" });
|
|
401
|
+
assert.equal(upgrade.status, 0, upgrade.stderr || upgrade.stdout);
|
|
402
|
+
assert.ok(!fs.existsSync(obsoletePath));
|
|
403
|
+
} finally {
|
|
404
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("update replaces untouched files, preserves local overrides, and doctor gates review", () => {
|
|
409
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-idempotent-update-test-"));
|
|
410
|
+
try {
|
|
411
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
412
|
+
const install = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "install", "--model-profile", "quality"], { cwd: temp, encoding: "utf8" });
|
|
413
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
414
|
+
|
|
415
|
+
const managedRelative = ".claude/nccgs/constitution.md";
|
|
416
|
+
const managedPath = path.join(temp, ...managedRelative.split("/"));
|
|
417
|
+
const statePath = path.join(temp, ".claude/nccgs/install-state.json");
|
|
418
|
+
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
419
|
+
const oldManagedContent = "managed by NCCGS 1.0.0\n";
|
|
420
|
+
fs.writeFileSync(managedPath, oldManagedContent, "utf8");
|
|
421
|
+
state.frameworkVersion = "1.0.0";
|
|
422
|
+
state.files.find((item) => item.relative === managedRelative).sha256 = sha256(oldManagedContent);
|
|
423
|
+
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
424
|
+
|
|
425
|
+
const update = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
|
|
426
|
+
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
427
|
+
assert.match(update.stdout, new RegExp(`1\\.0\\.0 -> ${frameworkVersion.replace(/\./g, "\\.")}`));
|
|
428
|
+
assert.match(update.stdout, /automatic update/);
|
|
429
|
+
assert.match(update.stdout, /No project migration required/);
|
|
430
|
+
assert.equal(fs.readFileSync(managedPath, "utf8"), fs.readFileSync(path.join(root, managedRelative), "utf8"));
|
|
431
|
+
|
|
432
|
+
let doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
|
|
433
|
+
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
|
434
|
+
assert.match(doctor.stdout, /NCCGS doctor passed/);
|
|
435
|
+
|
|
436
|
+
fs.writeFileSync(managedPath, "project-local override\n", "utf8");
|
|
437
|
+
const preserved = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
|
|
438
|
+
assert.equal(preserved.status, 0, preserved.stderr || preserved.stdout);
|
|
439
|
+
assert.match(preserved.stdout, /preserved override/);
|
|
440
|
+
assert.match(preserved.stdout, /Project migration required/);
|
|
441
|
+
assert.equal(fs.readFileSync(managedPath, "utf8"), "project-local override\n");
|
|
442
|
+
|
|
443
|
+
const overridePath = path.join(temp, ".nccgs/framework-overrides.json");
|
|
444
|
+
const overrides = JSON.parse(fs.readFileSync(overridePath, "utf8"));
|
|
445
|
+
const override = overrides.overrides.find((item) => item.path === managedRelative);
|
|
446
|
+
assert.equal(override.status, "pending_review");
|
|
447
|
+
assert.equal(override.disposition, "preserve");
|
|
448
|
+
|
|
449
|
+
doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
|
|
450
|
+
assert.equal(doctor.status, 1);
|
|
451
|
+
assert.match(doctor.stderr, /awaits \/migrate-project review/);
|
|
452
|
+
|
|
453
|
+
const approve = spawnSync(process.execPath, [path.join(temp, ".claude/nccgs/tools/approve-overrides.mjs"), "--project", temp, "--approve-pending"], { cwd: temp, encoding: "utf8" });
|
|
454
|
+
assert.equal(approve.status, 0, approve.stderr || approve.stdout);
|
|
455
|
+
doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
|
|
456
|
+
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
|
457
|
+
} finally {
|
|
458
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("update adopts unchanged generated agents newly tracked by the package, preserving real edits", () => {
|
|
463
|
+
const f = safetyFixture("nccgs-generated-adoption-");
|
|
464
|
+
try {
|
|
465
|
+
const installed = f.run("install", "--model-profile", "quality");
|
|
466
|
+
assert.equal(installed.status, 0, installed.stderr || installed.stdout);
|
|
467
|
+
const relative = ".claude/agents/nccgs-game-designer.md";
|
|
468
|
+
const file = path.join(f.project, relative);
|
|
469
|
+
const stateFile = path.join(f.project, ".claude/nccgs/install-state.json");
|
|
470
|
+
const generatedFile = path.join(f.project, ".nccgs/agent-activation.json");
|
|
471
|
+
const original = fs.readFileSync(file, "utf8");
|
|
472
|
+
// Older packages can generate a profile's optional agent without listing it
|
|
473
|
+
// in install-state.files. A later package can make that agent a core file.
|
|
474
|
+
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
475
|
+
state.files = state.files.filter(item => item.relative !== relative);
|
|
476
|
+
fs.writeFileSync(stateFile, JSON.stringify(state));
|
|
477
|
+
const update = f.run("update");
|
|
478
|
+
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
479
|
+
const doctor = f.run("doctor");
|
|
480
|
+
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
|
481
|
+
assert.equal(fs.readFileSync(file, "utf8"), original);
|
|
482
|
+
assert.ok(JSON.parse(fs.readFileSync(stateFile)).files.some(item => item.relative === relative));
|
|
483
|
+
|
|
484
|
+
// A stored generated hash alone must never authorize replacing later edits.
|
|
485
|
+
const nextState = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
|
486
|
+
nextState.files = nextState.files.filter(item => item.relative !== relative);
|
|
487
|
+
fs.writeFileSync(stateFile, JSON.stringify(nextState));
|
|
488
|
+
assert.ok(JSON.parse(fs.readFileSync(generatedFile)).generated[relative]);
|
|
489
|
+
const customized = original + "\nOwner instruction: keep the custom design rule.\n";
|
|
490
|
+
fs.writeFileSync(file, customized);
|
|
491
|
+
const preserved = f.run("update");
|
|
492
|
+
assert.equal(preserved.status, 0, preserved.stderr || preserved.stdout);
|
|
493
|
+
assert.equal(fs.readFileSync(file, "utf8"), customized);
|
|
494
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(f.project, ".nccgs/framework-overrides.json")));
|
|
495
|
+
assert.equal(manifest.overrides.find(item => item.path === relative).status, "pending_review");
|
|
496
|
+
assert.equal(f.run("doctor").status, 1);
|
|
497
|
+
} finally { f.dispose(); }
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("doctor accepts a legacy CRLF raw hash after Git normalizes the file to LF", () => {
|
|
501
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-eol-test-"));
|
|
502
|
+
try {
|
|
503
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
504
|
+
const install = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "install"], { cwd: temp, encoding: "utf8" });
|
|
505
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
506
|
+
|
|
507
|
+
const relative = ".claude/nccgs/constitution.md";
|
|
508
|
+
const file = path.join(temp, ...relative.split("/"));
|
|
509
|
+
const lf = fs.readFileSync(file, "utf8").replace(/\r\n?/g, "\n");
|
|
510
|
+
fs.writeFileSync(file, lf, "utf8");
|
|
511
|
+
const statePath = path.join(temp, ".claude/nccgs/install-state.json");
|
|
512
|
+
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
513
|
+
const record = state.files.find((item) => item.relative === relative);
|
|
514
|
+
record.sha256 = sha256(lf.replace(/\n/g, "\r\n"));
|
|
515
|
+
delete record.canonicalSha256;
|
|
516
|
+
state.schemaVersion = 2;
|
|
517
|
+
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
518
|
+
|
|
519
|
+
const doctor = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "doctor"], { cwd: temp, encoding: "utf8" });
|
|
520
|
+
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
|
521
|
+
} finally {
|
|
522
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("update preserves an intentionally missing managed file until migration review", () => {
|
|
527
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-absent-override-test-"));
|
|
528
|
+
try {
|
|
529
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
530
|
+
const install = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "install"], { cwd: temp, encoding: "utf8" });
|
|
531
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
532
|
+
|
|
533
|
+
const relative = ".claude/skills/status/SKILL.md";
|
|
534
|
+
const file = path.join(temp, ...relative.split("/"));
|
|
535
|
+
fs.unlinkSync(file);
|
|
536
|
+
const update = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
|
|
537
|
+
assert.equal(update.status, 0, update.stderr || update.stdout);
|
|
538
|
+
assert.ok(!fs.existsSync(file));
|
|
539
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(temp, ".nccgs/framework-overrides.json"), "utf8"));
|
|
540
|
+
const override = manifest.overrides.find((item) => item.path === relative);
|
|
541
|
+
assert.equal(override.disposition, "absent");
|
|
542
|
+
assert.equal(override.status, "pending_review");
|
|
543
|
+
} finally {
|
|
544
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("update refuses a project without an NCCGS install state", () => {
|
|
549
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "nccgs-update-missing-test-"));
|
|
550
|
+
try {
|
|
551
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(temp, marker));
|
|
552
|
+
const result = spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), "update"], { cwd: temp, encoding: "utf8" });
|
|
553
|
+
assert.equal(result.status, 2);
|
|
554
|
+
assert.match(result.stderr, /not installed/);
|
|
555
|
+
} finally {
|
|
556
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
function safetyFixture(prefix) {
|
|
561
|
+
const container = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
562
|
+
const project = path.join(container, "project");
|
|
563
|
+
fs.mkdirSync(project);
|
|
564
|
+
for (const marker of ["Assets", "Packages", "ProjectSettings"]) fs.mkdirSync(path.join(project, marker));
|
|
565
|
+
const run = (...args) => spawnSync(process.execPath, [path.join(root, "scripts/cli.mjs"), ...args], { cwd: project, encoding: "utf8" });
|
|
566
|
+
return { container, project, run, dispose() {
|
|
567
|
+
const resolved = path.resolve(container);
|
|
568
|
+
assert.ok(resolved.startsWith(path.resolve(os.tmpdir()) + path.sep + prefix));
|
|
569
|
+
fs.rmSync(resolved, { recursive: true, force: true });
|
|
570
|
+
} };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
test("portable managed paths reject Windows traversal, streams and device aliases", () => {
|
|
574
|
+
for (const relative of [".claude/..\\..\\sentinel.txt", ".claude/../sentinel.txt", ".claude//file", ".claude/./file", "C:/outside.txt", "//server/share/file",
|
|
575
|
+
".claude/agents/file:stream", ".claude/agents/CON.md", ".claude/agents/NUL", ".claude/agents/COM1.log", ".claude/agents/LPT².txt", ".claude/agents/CONIN$", ".claude/agents/CONOUT$", ".claude/agents/CLOCK$", ".claude/agents/file.", ".claude/agents/file ", ".claude/agents/.. /file"]) {
|
|
576
|
+
assert.throws(() => assertRelativePath(relative), /Unsafe/, relative);
|
|
577
|
+
}
|
|
578
|
+
assert.equal(assertRelativePath(".claude/agents/nccgs-ui-programmer.md"), ".claude/agents/nccgs-ui-programmer.md");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("installer rejects unsafe stale and override manifest paths before touching files", () => {
|
|
582
|
+
const f = safetyFixture("nccgs-path-state-");
|
|
583
|
+
try {
|
|
584
|
+
const install = f.run("install");
|
|
585
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
586
|
+
const sentinel = path.join(f.container, "sentinel.txt");
|
|
587
|
+
fs.writeFileSync(sentinel, "outside remains untouched");
|
|
588
|
+
const settingsFile = path.join(f.project, ".claude/settings.json");
|
|
589
|
+
const settingsBefore = fs.readFileSync(settingsFile, "utf8");
|
|
590
|
+
const stateFile = path.join(f.project, ".claude/nccgs/install-state.json");
|
|
591
|
+
const original = JSON.parse(fs.readFileSync(stateFile));
|
|
592
|
+
for (const relative of [".claude/..\\..\\sentinel.txt", ".claude/../../sentinel.txt", ".claude/agents/file:stream", ".claude/agents/CON.txt", ".claude/agents/trailing."]) {
|
|
593
|
+
const malicious = { ...original, files: [...original.files, { relative, sha256: sha256("outside remains untouched") }] };
|
|
594
|
+
const before = JSON.stringify(malicious);
|
|
595
|
+
fs.writeFileSync(stateFile, before);
|
|
596
|
+
const update = f.run("update", "--force");
|
|
597
|
+
assert.equal(update.status, 2, `${relative}: ${update.stderr || update.stdout}`);
|
|
598
|
+
assert.match(update.stderr, /Unsafe/);
|
|
599
|
+
assert.equal(fs.readFileSync(sentinel, "utf8"), "outside remains untouched");
|
|
600
|
+
assert.equal(fs.readFileSync(stateFile, "utf8"), before, "rejection must happen before installer writes");
|
|
601
|
+
assert.equal(fs.readFileSync(settingsFile, "utf8"), settingsBefore);
|
|
602
|
+
assert.notEqual(f.run("doctor").status, 0);
|
|
603
|
+
}
|
|
604
|
+
fs.writeFileSync(stateFile, JSON.stringify(original));
|
|
605
|
+
fs.writeFileSync(path.join(f.project, ".nccgs/framework-overrides.json"), JSON.stringify({ schemaVersion: 1, overrides: [{ path: ".claude/..\\..\\sentinel.txt", disposition: "preserve", status: "approved" }] }));
|
|
606
|
+
const update = f.run("update", "--force");
|
|
607
|
+
assert.equal(update.status, 2, update.stderr || update.stdout);
|
|
608
|
+
assert.equal(fs.readFileSync(sentinel, "utf8"), "outside remains untouched");
|
|
609
|
+
} finally { f.dispose(); }
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("installer rejects directory junctions and dangling links across managed destinations", async t => {
|
|
613
|
+
for (const relative of [".claude", ".nccgs", ".claude/agents", ".nccgs/backups", ".nccgs/templates", ".claude/skills"]) {
|
|
614
|
+
await t.test(relative, child => {
|
|
615
|
+
const f = safetyFixture("nccgs-path-link-");
|
|
616
|
+
try {
|
|
617
|
+
const outside = path.join(f.container, "outside");
|
|
618
|
+
fs.mkdirSync(outside);
|
|
619
|
+
fs.writeFileSync(path.join(outside, "sentinel.txt"), "untouched");
|
|
620
|
+
const link = path.join(f.project, ...relative.split("/"));
|
|
621
|
+
fs.mkdirSync(path.dirname(link), { recursive: true });
|
|
622
|
+
// The skills case deliberately targets a path that does not exist.
|
|
623
|
+
const linkTarget = relative === ".claude/skills" ? path.join(outside, "missing") : outside;
|
|
624
|
+
try { fs.symlinkSync(linkTarget, link, process.platform === "win32" ? "junction" : "dir"); }
|
|
625
|
+
catch (error) { if (["EPERM", "EACCES", "ENOSYS"].includes(error.code) || (process.platform === "win32" && relative === ".claude/skills" && error.code === "ENOENT" && !fs.existsSync(linkTarget))) { child.skip(`Link fixture unavailable on this host: ${error.code}`); return; } throw error; }
|
|
626
|
+
assert.throws(() => containedPath(f.project, `${relative}/target.txt`), /Symlink|junction|reparse/);
|
|
627
|
+
const install = f.run("install", "--force");
|
|
628
|
+
assert.equal(install.status, 2, install.stderr || install.stdout);
|
|
629
|
+
assert.match(install.stderr, /Symlink|junction|reparse/);
|
|
630
|
+
assert.deepEqual(fs.readdirSync(outside), ["sentinel.txt"]);
|
|
631
|
+
assert.equal(fs.readFileSync(path.join(outside, "sentinel.txt"), "utf8"), "untouched");
|
|
632
|
+
} finally { f.dispose(); }
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("installer rejects file links for shared settings, state, scaffold and CLAUDE targets", async t => {
|
|
638
|
+
for (const relative of ["CLAUDE.md", ".claude/settings.json", ".claude/nccgs/install-state.json", ".nccgs/project.yaml", ".nccgs/requirements.yaml", ".nccgs/agent-activation.json"]) {
|
|
639
|
+
await t.test(relative, child => {
|
|
640
|
+
const f = safetyFixture("nccgs-file-link-");
|
|
641
|
+
try {
|
|
642
|
+
const sentinel = path.join(f.container, "sentinel.txt");
|
|
643
|
+
fs.writeFileSync(sentinel, "outside remains untouched");
|
|
644
|
+
const link = path.join(f.project, ...relative.split("/"));
|
|
645
|
+
fs.mkdirSync(path.dirname(link), { recursive: true });
|
|
646
|
+
try { fs.symlinkSync(sentinel, link, "file"); }
|
|
647
|
+
catch (error) { if (["EPERM", "EACCES", "ENOSYS"].includes(error.code)) { child.skip(`File links unavailable: ${error.code}`); return; } throw error; }
|
|
648
|
+
const install = f.run("install", "--force");
|
|
649
|
+
assert.equal(install.status, 2, install.stderr || install.stdout);
|
|
650
|
+
assert.match(install.stderr, /Symlink|junction|reparse/);
|
|
651
|
+
assert.equal(fs.readFileSync(sentinel, "utf8"), "outside remains untouched");
|
|
652
|
+
} finally { f.dispose(); }
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("doctor checks managed hooks and session settings while preserving custom hooks", () => {
|
|
658
|
+
const f = safetyFixture("nccgs-settings-health-");
|
|
659
|
+
try {
|
|
660
|
+
const settingsFile = path.join(f.project, ".claude/settings.json");
|
|
661
|
+
fs.mkdirSync(path.dirname(settingsFile));
|
|
662
|
+
const customHook = { type: "command", command: "node custom-project-check.mjs" };
|
|
663
|
+
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { SessionStart: [{ matcher: "", hooks: [customHook, { type: "command", command: "node .claude/nccgs/hooks/session-start.mjs", timeout: 10 }] }] } }));
|
|
664
|
+
const install = f.run("install");
|
|
665
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
666
|
+
const correct = JSON.parse(fs.readFileSync(settingsFile));
|
|
667
|
+
assert.ok(correct.hooks.SessionStart.some(group => group.hooks.some(hook => hook.command === customHook.command)), "custom hook sharing a managed group must survive");
|
|
668
|
+
assert.equal(f.run("doctor").status, 0);
|
|
669
|
+
const variations = [
|
|
670
|
+
[{}, /Required NCCGS hook|session model/],
|
|
671
|
+
[{ ...correct, hooks: { ...correct.hooks, PreToolUse: [] } }, /Required NCCGS hook/],
|
|
672
|
+
[{ ...correct, hooks: { ...correct.hooks, PreToolUse: [{ hooks: {} }] } }, /Required NCCGS hook/],
|
|
673
|
+
[{ ...correct, disableAllHooks: true }, /disable all hooks/],
|
|
674
|
+
[{ ...correct, model: "sonnet" }, /session model/],
|
|
675
|
+
[{ ...correct, effortLevel: "low" }, /session effort/],
|
|
676
|
+
[{ ...correct, modelSettings: {} }, /per-model effort/]
|
|
677
|
+
];
|
|
678
|
+
for (const [settings, pattern] of variations) {
|
|
679
|
+
fs.writeFileSync(settingsFile, JSON.stringify(settings));
|
|
680
|
+
const doctor = f.run("doctor");
|
|
681
|
+
assert.equal(doctor.status, 1, doctor.stderr || doctor.stdout);
|
|
682
|
+
assert.match(doctor.stderr, pattern);
|
|
683
|
+
}
|
|
684
|
+
fs.writeFileSync(settingsFile, JSON.stringify(correct));
|
|
685
|
+
const localFile = path.join(f.project, ".claude/settings.local.json");
|
|
686
|
+
fs.writeFileSync(localFile, JSON.stringify({ hooks: { Notification: [{ hooks: [customHook] }] }, env: { PROJECT_TEST: "kept" } }));
|
|
687
|
+
const validLocal = f.run("doctor");
|
|
688
|
+
assert.equal(validLocal.status, 0, validLocal.stderr || validLocal.stdout);
|
|
689
|
+
assert.match(validLocal.stdout, /Settings scope: project settings/);
|
|
690
|
+
assert.match(validLocal.stderr, /Local Claude settings are present/);
|
|
691
|
+
fs.writeFileSync(localFile, JSON.stringify({ model: "sonnet" }));
|
|
692
|
+
const conflict = f.run("doctor");
|
|
693
|
+
assert.equal(conflict.status, 1);
|
|
694
|
+
assert.match(conflict.stderr, /Local settings override/);
|
|
695
|
+
const update = f.run("update");
|
|
696
|
+
assert.notEqual(update.status, 0, "a preserved conflicting local setting must not be declared healthy");
|
|
697
|
+
assert.equal(JSON.parse(fs.readFileSync(localFile)).model, "sonnet", "installer must preserve local settings");
|
|
698
|
+
assert.ok(JSON.parse(fs.readFileSync(settingsFile)).hooks.SessionStart.some(group => group.hooks.some(hook => hook.command === customHook.command)));
|
|
699
|
+
fs.unlinkSync(localFile);
|
|
700
|
+
const inputsFile = path.join(f.project, ".nccgs/inputs.json");
|
|
701
|
+
for (const inputs of [{ schemaVersion: 999 }, { schemaVersion: 1, canonicalInputs: [], outputDirectories: ["Assets"], checks: {} }]) {
|
|
702
|
+
fs.writeFileSync(inputsFile, JSON.stringify(inputs));
|
|
703
|
+
const doctor = f.run("doctor");
|
|
704
|
+
assert.equal(doctor.status, 1);
|
|
705
|
+
assert.match(doctor.stderr, /Invalid canonical input\/output policy/);
|
|
706
|
+
}
|
|
707
|
+
} finally { f.dispose(); }
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("doctor checks historical closure evidence without treating old approvals as current", () => {
|
|
711
|
+
const f = safetyFixture("nccgs-doctor-history-");
|
|
712
|
+
try {
|
|
713
|
+
const install = f.run("install");
|
|
714
|
+
assert.equal(install.status, 0, install.stderr || install.stdout);
|
|
715
|
+
const write = (relative, value) => {
|
|
716
|
+
const file = containedPath(f.project, relative);
|
|
717
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
718
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
719
|
+
fs.writeFileSync(file, text);
|
|
720
|
+
return { path: relative, sha256: sha256(text) };
|
|
721
|
+
};
|
|
722
|
+
const raw = write(".nccgs/evidence/runtime/sources/history.json", { fixture: "Synthetic historical evidence only" });
|
|
723
|
+
const runtime = write(".nccgs/evidence/runtime/observations/history.json", { observationSource: raw });
|
|
724
|
+
const log = write(".nccgs/evidence/history.log", "Synthetic historical check");
|
|
725
|
+
const execution = write(".nccgs/evidence/history-check.json", { origin: "verification-command", log });
|
|
726
|
+
const report = write(".nccgs/evidence/history-report.md", "Synthetic historical report");
|
|
727
|
+
write(".nccgs/tasks/history.json", { schemaVersion: 1, id: "history", closed: { snapshot: "historical-source" }, stages: {
|
|
728
|
+
implementation: { runtime, checks: [{ evidence: execution }] }, review: { runtime, evidence: report },
|
|
729
|
+
report: { evidence: report }, astra: { evidence: report }, acceptance: { snapshot: "historical-source", evidence: report }
|
|
730
|
+
} });
|
|
731
|
+
const historical = f.run("doctor");
|
|
732
|
+
assert.equal(historical.status, 0, historical.stderr || historical.stdout);
|
|
733
|
+
assert.match(historical.stderr, /predates canonical input pins/);
|
|
734
|
+
assert.match(historical.stderr, /cannot approve current work/);
|
|
735
|
+
write(raw.path, { fixture: "Changed raw evidence" });
|
|
736
|
+
const stale = f.run("doctor");
|
|
737
|
+
assert.equal(stale.status, 1);
|
|
738
|
+
assert.match(stale.stderr, /Invalid closed task history.json: Missing or stale artifact/);
|
|
739
|
+
} finally { f.dispose(); }
|
|
740
|
+
});
|