nccgs 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/nccgs-adversarial-reviewer.md +7 -1
- package/.claude/agents/nccgs-engine-programmer.md +2 -1
- package/.claude/agents/nccgs-gameplay-programmer.md +2 -1
- package/.claude/agents/nccgs-qa-engineer.md +2 -1
- package/.claude/agents/nccgs-ui-programmer.md +2 -1
- package/.claude/agents/nccgs-unity-build-specialist.md +2 -1
- package/.claude/agents/nccgs-unity-implementer.md +2 -1
- package/.claude/agents/nccgs-unity-systems-specialist.md +2 -1
- package/.claude/agents/nccgs-unity-ui-specialist.md +2 -1
- package/.claude/agents/nccgs-verification-engineer.md +2 -1
- package/.claude/nccgs/VERSION +1 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-accessibility-specialist.md +0 -1
- package/.claude/nccgs/agent-library/nccgs-adversarial-reviewer.md +30 -0
- package/.claude/{agents → nccgs/agent-library}/nccgs-ai-programmer.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-analytics-engineer.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-art-direction-lead.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-audio-direction-lead.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-creative-director.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-documentation-manager.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-economy-designer.md +0 -1
- package/.claude/nccgs/agent-library/nccgs-engine-programmer.md +25 -0
- package/.claude/{agents → nccgs/agent-library}/nccgs-game-design-lead.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-game-designer.md +0 -1
- package/.claude/nccgs/agent-library/nccgs-gameplay-programmer.md +25 -0
- package/.claude/{agents → nccgs/agent-library}/nccgs-level-designer.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-live-ops-designer.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-localization-lead.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-narrative-lead.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-network-programmer.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-performance-analyst.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-producer.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-production-coordinator.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-programming-lead.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-prototyper.md +0 -1
- package/.claude/nccgs/agent-library/nccgs-qa-engineer.md +25 -0
- package/.claude/{agents → nccgs/agent-library}/nccgs-qa-lead.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-release-engineer.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-release-lead.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-security-engineer.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-sound-designer.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-systems-designer.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-technical-architect.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-technical-artist.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-technical-director.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-tools-programmer.md +0 -1
- package/.claude/nccgs/agent-library/nccgs-ui-programmer.md +25 -0
- package/.claude/nccgs/agent-library/nccgs-unity-build-specialist.md +25 -0
- package/.claude/{agents → nccgs/agent-library}/nccgs-unity-content-specialist.md +0 -1
- package/.claude/nccgs/agent-library/nccgs-unity-implementer.md +25 -0
- package/.claude/{agents → nccgs/agent-library}/nccgs-unity-rendering-specialist.md +0 -1
- package/.claude/nccgs/agent-library/nccgs-unity-systems-specialist.md +25 -0
- package/.claude/nccgs/agent-library/nccgs-unity-ui-specialist.md +25 -0
- package/.claude/{agents → nccgs/agent-library}/nccgs-ux-designer.md +0 -1
- package/.claude/nccgs/agent-library/nccgs-verification-engineer.md +25 -0
- package/.claude/{agents → nccgs/agent-library}/nccgs-world-builder.md +0 -1
- package/.claude/{agents → nccgs/agent-library}/nccgs-writer.md +0 -1
- package/.claude/nccgs/constitution.md +13 -0
- package/.claude/nccgs/hooks/agent-audit.mjs +6 -0
- package/.claude/nccgs/hooks/closure-guard.mjs +34 -0
- package/.claude/nccgs/hooks/runtime-observation.mjs +11 -0
- package/.claude/nccgs/hooks/session-start.mjs +8 -1
- package/.claude/nccgs/pipeline.json +176 -0
- package/.claude/nccgs/protocols/agent-contract.md +5 -0
- package/.claude/nccgs/protocols/astra-claude.md +77 -0
- package/.claude/nccgs/protocols/evidence.md +6 -0
- package/.claude/nccgs/protocols/model-routing.md +27 -5
- package/.claude/nccgs/protocols/orchestration.md +5 -0
- package/.claude/nccgs/protocols/task-gates.md +66 -0
- package/.claude/nccgs/routing.generated.md +11 -0
- package/.claude/nccgs/settings.fragment.json +73 -9
- package/.claude/nccgs/studio.json +187 -96
- package/.claude/nccgs/tools/compile-policy.mjs +83 -0
- package/.claude/nccgs/tools/configure-models.mjs +75 -35
- package/.claude/nccgs/tools/gates.mjs +167 -0
- package/.claude/nccgs/tools/policy.mjs +62 -0
- package/.claude/nccgs/tools/runtime.mjs +40 -0
- package/.claude/nccgs/tools/task.mjs +77 -0
- package/.claude/nccgs/workflow-catalog.json +90 -13
- package/.claude/skills/accessibility-review/SKILL.md +6 -5
- package/.claude/skills/accessibility-review/references/astra.md +17 -0
- package/.claude/skills/accessibility-review/references/legacy.md +7 -0
- package/.claude/skills/architecture-decision/SKILL.md +6 -5
- package/.claude/skills/architecture-decision/references/astra.md +12 -0
- package/.claude/skills/architecture-decision/references/legacy.md +7 -0
- package/.claude/skills/asset-audit/SKILL.md +6 -5
- package/.claude/skills/asset-audit/references/astra.md +17 -0
- package/.claude/skills/asset-audit/references/legacy.md +7 -0
- package/.claude/skills/audit/SKILL.md +6 -7
- package/.claude/skills/audit/references/astra.md +17 -0
- package/.claude/skills/audit/references/legacy.md +9 -0
- package/.claude/skills/balance-review/SKILL.md +6 -5
- package/.claude/skills/balance-review/references/astra.md +17 -0
- package/.claude/skills/balance-review/references/legacy.md +7 -0
- package/.claude/skills/bug-triage/SKILL.md +6 -5
- package/.claude/skills/bug-triage/references/astra.md +20 -0
- package/.claude/skills/bug-triage/references/legacy.md +7 -0
- package/.claude/skills/closure/SKILL.md +5 -4
- package/.claude/skills/closure/references/astra.md +15 -0
- package/.claude/skills/closure/references/legacy.md +7 -0
- package/.claude/skills/compatibility-review/SKILL.md +6 -5
- package/.claude/skills/compatibility-review/references/astra.md +17 -0
- package/.claude/skills/compatibility-review/references/legacy.md +7 -0
- package/.claude/skills/context-pack/SKILL.md +5 -4
- package/.claude/skills/context-pack/references/astra.md +12 -0
- package/.claude/skills/context-pack/references/legacy.md +7 -0
- package/.claude/skills/dependency-review/SKILL.md +6 -5
- package/.claude/skills/dependency-review/references/astra.md +17 -0
- package/.claude/skills/dependency-review/references/legacy.md +7 -0
- package/.claude/skills/design/SKILL.md +6 -11
- package/.claude/skills/design/references/astra.md +12 -0
- package/.claude/skills/design/references/legacy.md +13 -0
- package/.claude/skills/design-review/SKILL.md +6 -5
- package/.claude/skills/design-review/references/astra.md +17 -0
- package/.claude/skills/design-review/references/legacy.md +7 -0
- package/.claude/skills/evidence-review/SKILL.md +6 -5
- package/.claude/skills/evidence-review/references/astra.md +17 -0
- package/.claude/skills/evidence-review/references/legacy.md +7 -0
- package/.claude/skills/hotfix/SKILL.md +6 -5
- package/.claude/skills/hotfix/references/astra.md +23 -0
- package/.claude/skills/hotfix/references/legacy.md +7 -0
- package/.claude/skills/incident-recovery/SKILL.md +6 -5
- package/.claude/skills/incident-recovery/references/astra.md +20 -0
- package/.claude/skills/incident-recovery/references/legacy.md +7 -0
- package/.claude/skills/localize-game/SKILL.md +6 -5
- package/.claude/skills/localize-game/references/astra.md +20 -0
- package/.claude/skills/localize-game/references/legacy.md +7 -0
- package/.claude/skills/migrate-project/SKILL.md +6 -16
- package/.claude/skills/migrate-project/references/astra.md +23 -0
- package/.claude/skills/migrate-project/references/legacy.md +19 -0
- package/.claude/skills/migrate-project/references/procedure.md +8 -0
- package/.claude/skills/milestone-review/SKILL.md +6 -5
- package/.claude/skills/milestone-review/references/astra.md +12 -0
- package/.claude/skills/milestone-review/references/legacy.md +7 -0
- package/.claude/skills/nccgs-code-review/SKILL.md +6 -4
- package/.claude/skills/nccgs-code-review/references/astra.md +15 -0
- package/.claude/skills/nccgs-code-review/references/legacy.md +7 -0
- package/.claude/skills/performance-audit/SKILL.md +6 -5
- package/.claude/skills/performance-audit/references/astra.md +17 -0
- package/.claude/skills/performance-audit/references/legacy.md +7 -0
- package/.claude/skills/plan-feature/SKILL.md +6 -5
- package/.claude/skills/plan-feature/references/astra.md +12 -0
- package/.claude/skills/plan-feature/references/legacy.md +7 -0
- package/.claude/skills/playtest/SKILL.md +5 -4
- package/.claude/skills/playtest/references/astra.md +20 -0
- package/.claude/skills/playtest/references/legacy.md +7 -0
- package/.claude/skills/project-stage/SKILL.md +5 -4
- package/.claude/skills/project-stage/references/astra.md +12 -0
- package/.claude/skills/project-stage/references/legacy.md +7 -0
- package/.claude/skills/prototype-feature/SKILL.md +6 -5
- package/.claude/skills/prototype-feature/references/astra.md +20 -0
- package/.claude/skills/prototype-feature/references/legacy.md +7 -0
- package/.claude/skills/qa-plan/SKILL.md +6 -5
- package/.claude/skills/qa-plan/references/astra.md +12 -0
- package/.claude/skills/qa-plan/references/legacy.md +7 -0
- package/.claude/skills/release/SKILL.md +6 -9
- package/.claude/skills/release/references/astra.md +23 -0
- package/.claude/skills/release/references/legacy.md +11 -0
- package/.claude/skills/release-readiness/SKILL.md +6 -5
- package/.claude/skills/release-readiness/references/astra.md +17 -0
- package/.claude/skills/release-readiness/references/legacy.md +7 -0
- package/.claude/skills/retrospective/SKILL.md +6 -5
- package/.claude/skills/retrospective/references/astra.md +12 -0
- package/.claude/skills/retrospective/references/legacy.md +7 -0
- package/.claude/skills/review/SKILL.md +6 -7
- package/.claude/skills/review/references/astra.md +15 -0
- package/.claude/skills/review/references/legacy.md +9 -0
- package/.claude/skills/security-audit/SKILL.md +6 -5
- package/.claude/skills/security-audit/references/astra.md +17 -0
- package/.claude/skills/security-audit/references/legacy.md +7 -0
- package/.claude/skills/sprint-plan/SKILL.md +6 -5
- package/.claude/skills/sprint-plan/references/astra.md +12 -0
- package/.claude/skills/sprint-plan/references/legacy.md +7 -0
- package/.claude/skills/status/SKILL.md +5 -6
- package/.claude/skills/status/references/astra.md +13 -0
- package/.claude/skills/status/references/legacy.md +9 -0
- package/.claude/skills/story-readiness/SKILL.md +5 -4
- package/.claude/skills/story-readiness/references/astra.md +17 -0
- package/.claude/skills/story-readiness/references/legacy.md +7 -0
- package/.claude/skills/test/SKILL.md +6 -7
- package/.claude/skills/test/references/astra.md +20 -0
- package/.claude/skills/test/references/legacy.md +9 -0
- package/.claude/skills/ui-review/SKILL.md +6 -5
- package/.claude/skills/ui-review/references/astra.md +17 -0
- package/.claude/skills/ui-review/references/legacy.md +7 -0
- package/.claude/skills/work/SKILL.md +6 -27
- package/.claude/skills/work/references/astra.md +20 -0
- package/.claude/skills/work/references/closure.md +10 -0
- package/.claude/skills/work/references/feature-contracts.md +4 -0
- package/.claude/skills/work/references/legacy.md +29 -0
- package/README.md +59 -25
- package/UPGRADING.md +37 -0
- package/VERSION +1 -1
- package/docs/ARCHITECTURE.md +30 -13
- package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +34 -329
- package/docs/MIGRATION-MATRIX.md +1 -1
- package/docs/NCCGS-1.3.md +125 -0
- package/docs/PROJECT-POLICY.md +18 -3
- package/docs/WORKFLOWS.md +19 -13
- package/package.json +5 -3
- package/scaffold/.nccgs/gates.json +7 -0
- package/scaffold/.nccgs/project.yaml +6 -3
- package/scaffold/.nccgs/state.md +1 -1
- package/scaffold/.nccgs/templates/agent-handoff.md +10 -0
- package/scaffold/.nccgs/templates/closure-record.md +11 -0
- package/scaffold/.nccgs/templates/feature-contract.md +9 -0
- package/scaffold/.nccgs/templates/implementation-report.md +49 -0
- package/scripts/cli.mjs +9 -4
- package/scripts/doctor.mjs +27 -1
- package/scripts/install.mjs +4 -3
- package/scripts/validate.mjs +20 -7
- package/tests/framework.test.mjs +143 -2
- package/tests/gates.test.mjs +170 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { json, loadPipeline } from "./policy.mjs";
|
|
5
|
+
|
|
6
|
+
export function renderAgent(template, route) {
|
|
7
|
+
const match = template.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
8
|
+
if (!match) throw new Error("Missing agent frontmatter");
|
|
9
|
+
const lines = match[1].split(/\r?\n/).filter(line => !/^(model|effort):/.test(line));
|
|
10
|
+
if (route.model) lines.push(`model: ${route.model}`);
|
|
11
|
+
if (route.effort) lines.push(`effort: ${route.effort}`);
|
|
12
|
+
return `---\n${lines.join("\n")}\n---${template.slice(match[0].length).replace(/\r\n/g, "\n")}`;
|
|
13
|
+
}
|
|
14
|
+
export function generated(project) {
|
|
15
|
+
const pipeline = loadPipeline(project);
|
|
16
|
+
const studio = json(path.join(project, ".claude/nccgs/studio.json"));
|
|
17
|
+
const workflows = json(path.join(project, ".claude/nccgs/workflow-catalog.json"));
|
|
18
|
+
const outputs = new Map();
|
|
19
|
+
studio.defaultProfile = pipeline.defaultProfile;
|
|
20
|
+
studio.modelProfiles = {};
|
|
21
|
+
studio.effortProfiles = {};
|
|
22
|
+
studio.sessionProfiles = {};
|
|
23
|
+
for (const [name, profile] of Object.entries(pipeline.profiles)) {
|
|
24
|
+
studio.modelProfiles[name] = Object.fromEntries(Object.entries(profile.models).map(([k, v]) => [k, v.model]));
|
|
25
|
+
studio.effortProfiles[name] = Object.fromEntries(Object.entries(profile.models).map(([k, v]) => [k, v.effort]));
|
|
26
|
+
if (profile.session) {
|
|
27
|
+
const route = profile.models[profile.session.class];
|
|
28
|
+
studio.sessionProfiles[name] = { model: route.model, effortLevel: route.effort };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const primary = pipeline.profiles[pipeline.defaultProfile];
|
|
32
|
+
for (const agent of studio.agents) {
|
|
33
|
+
const route = primary.models[agent.modelClass];
|
|
34
|
+
if (!route) throw new Error(`Missing route for ${agent.name}`);
|
|
35
|
+
agent.defaultModel = route.model;
|
|
36
|
+
agent.defaultEffort = route.effort;
|
|
37
|
+
if (primary.activeAgents === "all" || primary.activeAgents.includes(agent.name)) {
|
|
38
|
+
const template = fs.readFileSync(path.join(project, ".claude/nccgs/agent-library", `${agent.name}.md`), "utf8");
|
|
39
|
+
outputs.set(`.claude/agents/${agent.name}.md`, renderAgent(template, route));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const { contract } = pipeline;
|
|
43
|
+
const contractProfile = pipeline.profiles["astra-claude"];
|
|
44
|
+
workflows.profileContracts = { "astra-claude": { ...contract,
|
|
45
|
+
implementationModel: contractProfile.models.standard.model, implementationEffort: contractProfile.models.standard.effort,
|
|
46
|
+
reviewModel: contractProfile.models.review.model, reviewEffort: contractProfile.models.review.effort } };
|
|
47
|
+
for (const workflow of workflows.workflows) {
|
|
48
|
+
delete workflow.ownerByProfile;
|
|
49
|
+
delete workflow.primaryRolesByProfile;
|
|
50
|
+
if (contract.astraWorkflows.includes(workflow.name)) workflow.ownerByProfile = { "astra-claude": "Astra" };
|
|
51
|
+
if (contract.reviewWorkflows.includes(workflow.name)) workflow.primaryRolesByProfile = { "astra-claude": [contract.reviewAgent] };
|
|
52
|
+
}
|
|
53
|
+
outputs.set(".claude/nccgs/studio.json", `${JSON.stringify(studio, null, 2)}\n`);
|
|
54
|
+
outputs.set(".claude/nccgs/workflow-catalog.json", `${JSON.stringify(workflows, null, 2)}\n`);
|
|
55
|
+
outputs.set(".claude/nccgs/routing.generated.md", `# Generated routing — edit pipeline.json, then compile-policy.mjs\n\n| Stage | Owner/model | Effort |\n|---|---|---|\n| Plan and documents | Astra | External session |\n| Implementation | ${primary.models.standard.model} | ${primary.models.standard.effort} |\n| Independent review | ${primary.models.review.model} | ${primary.models.review.effort} |\n| Report check | Astra | External session |\n| Acceptance | Product Owner | Explicit decision |\n\nDefault active agents: ${primary.activeAgents.length}. Full library: ${studio.agents.length}.\n`);
|
|
56
|
+
const scaffold = path.join(project, "scaffold/.nccgs/project.yaml");
|
|
57
|
+
if (fs.existsSync(scaffold)) {
|
|
58
|
+
const text = fs.readFileSync(scaffold, "utf8");
|
|
59
|
+
const start = text.indexOf("# Apply only when models.profile");
|
|
60
|
+
const end = text.indexOf("\nworkflow:", start);
|
|
61
|
+
const reference = "# Profile contracts are resolved from this canonical configuration.\npipeline_policy: .claude/nccgs/pipeline.json\n";
|
|
62
|
+
outputs.set("scaffold/.nccgs/project.yaml", start >= 0 && end > start ? text.slice(0, start) + reference + text.slice(end) : text);
|
|
63
|
+
}
|
|
64
|
+
return outputs;
|
|
65
|
+
}
|
|
66
|
+
export function compile(project, write = false) {
|
|
67
|
+
const differences = [];
|
|
68
|
+
for (const [relative, content] of generated(project)) {
|
|
69
|
+
const file = path.join(project, relative);
|
|
70
|
+
if (fs.existsSync(file) && fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n") === content.replace(/\r\n/g, "\n")) continue;
|
|
71
|
+
differences.push(relative);
|
|
72
|
+
if (write) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content, "utf8"); }
|
|
73
|
+
}
|
|
74
|
+
return differences;
|
|
75
|
+
}
|
|
76
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
77
|
+
const project = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
78
|
+
const write = process.argv.includes("--write");
|
|
79
|
+
const differences = compile(project, write);
|
|
80
|
+
console.log(`${write ? "Generated" : "Stale generated files"}: ${differences.length}`);
|
|
81
|
+
for (const file of differences) console.log(file);
|
|
82
|
+
if (!write && differences.length) process.exitCode = 1;
|
|
83
|
+
}
|
|
@@ -1,44 +1,84 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { atomicJson, hash, json, profilePolicy, contained } from "./policy.mjs";
|
|
5
|
+
import { renderAgent } from "./compile-policy.mjs";
|
|
6
|
+
import { fingerprintFile } from "./integrity.mjs";
|
|
4
7
|
|
|
5
|
-
const ownDir = path.dirname(fileURLToPath(import.meta.url));
|
|
6
8
|
const args = process.argv.slice(2);
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
const values = flag => args.flatMap((arg, index) => {
|
|
10
|
+
if (arg !== flag) return [];
|
|
11
|
+
if (!args[index + 1] || args[index + 1].startsWith("--")) throw new Error(`${flag} requires a value`);
|
|
12
|
+
return [args[index + 1]];
|
|
13
|
+
});
|
|
14
|
+
const project = path.resolve(values("--project")[0] || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."));
|
|
15
|
+
const explicit = values("--profile")[0];
|
|
16
|
+
const { name, profile, pipeline } = profilePolicy(project, explicit);
|
|
17
|
+
const studio = json(path.join(project, ".claude/nccgs/studio.json"));
|
|
18
|
+
const skip = new Set(values("--skip-path").map(p => p.replaceAll("\\", "/")));
|
|
19
|
+
const activationFile = contained(project, ".nccgs/agent-activation.json");
|
|
20
|
+
const previous = fs.existsSync(activationFile) ? json(activationFile) : { enabled: [], generated: {} };
|
|
21
|
+
const extras = new Set(previous.enabled);
|
|
22
|
+
for (const role of values("--enable")) {
|
|
23
|
+
if (!studio.agents.some(a => a.name === role)) throw new Error(`Unknown agent: ${role}`);
|
|
24
|
+
extras.add(role);
|
|
12
25
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const next =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
for (const role of values("--disable")) {
|
|
27
|
+
if (pipeline.coreAgents.includes(role)) throw new Error(`Cannot disable core agent: ${role}`);
|
|
28
|
+
extras.delete(role);
|
|
29
|
+
}
|
|
30
|
+
const active = new Set(profile.activeAgents === "all" ? studio.agents.map(a => a.name) : [...profile.activeAgents, ...extras]);
|
|
31
|
+
const settingsFile = contained(project, ".claude/settings.json");
|
|
32
|
+
const oldSettings = fs.existsSync(settingsFile) ? fs.readFileSync(settingsFile, "utf8") : "";
|
|
33
|
+
const settings = oldSettings ? JSON.parse(oldSettings) : {};
|
|
34
|
+
const writes = [], removes = [], warnings = [], generated = {}, generatedCanonical = {};
|
|
35
|
+
for (const agent of studio.agents) {
|
|
36
|
+
const relative = `.claude/agents/${agent.name}.md`;
|
|
37
|
+
const file = contained(project, relative);
|
|
38
|
+
const old = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
|
|
39
|
+
if (skip.has(relative)) { warnings.push(`Preserved override: ${relative}`); continue; }
|
|
40
|
+
const template = fs.readFileSync(contained(project, `.claude/nccgs/agent-library/${agent.name}.md`), "utf8");
|
|
41
|
+
const route = profile.models[agent.modelClass];
|
|
42
|
+
if (!route) throw new Error(`Missing class: ${agent.modelClass}`);
|
|
43
|
+
const next = renderAgent(template, route);
|
|
44
|
+
const shipped = renderAgent(template, pipeline.profiles[pipeline.defaultProfile].models[agent.modelClass]);
|
|
45
|
+
const known = old === null || old.replace(/\r\n/g, "\n") === shipped || hash(old) === previous.generated?.[relative] || hash(old.replace(/\r\n/g, "\n")) === previous.generatedCanonical?.[relative];
|
|
46
|
+
if (!known) { warnings.push(`Preserved modified agent: ${relative}`); continue; }
|
|
47
|
+
if (active.has(agent.name)) {
|
|
48
|
+
if (old !== next) writes.push([file, next]);
|
|
49
|
+
generated[relative] = hash(next);
|
|
50
|
+
generatedCanonical[relative] = hash(next.replace(/\r\n/g, "\n"));
|
|
51
|
+
} else if (old !== null) removes.push(file);
|
|
52
|
+
}
|
|
53
|
+
// No writes until all inputs and existing settings have passed preflight.
|
|
54
|
+
for (const [file, text] of writes) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text, "utf8"); }
|
|
55
|
+
for (const file of removes) fs.unlinkSync(file); // contained, known generated files only
|
|
56
|
+
if (profile.session) {
|
|
57
|
+
const route = profile.models[profile.session.class];
|
|
58
|
+
const merged = { ...settings, model: route.model, effortLevel: route.effort,
|
|
59
|
+
modelSettings: { ...settings.modelSettings, [route.model]: { ...settings.modelSettings?.[route.model], effortLevel: route.effort } } };
|
|
60
|
+
const next = `${JSON.stringify(merged, null, 2)}\n`;
|
|
61
|
+
if (oldSettings !== next) {
|
|
62
|
+
if (oldSettings) {
|
|
63
|
+
const backup = contained(project, `.nccgs/backups/models-${Date.now()}/settings.json`);
|
|
64
|
+
fs.mkdirSync(path.dirname(backup), { recursive: true }); fs.writeFileSync(backup, oldSettings, "utf8");
|
|
65
|
+
}
|
|
66
|
+
fs.mkdirSync(path.dirname(settingsFile), { recursive: true }); fs.writeFileSync(settingsFile, next, "utf8");
|
|
36
67
|
}
|
|
37
68
|
}
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
const
|
|
41
|
-
|
|
69
|
+
const policyFile = contained(project, ".nccgs/project.yaml");
|
|
70
|
+
if (explicit && fs.existsSync(policyFile)) {
|
|
71
|
+
const text = fs.readFileSync(policyFile, "utf8");
|
|
72
|
+
fs.writeFileSync(policyFile, text.replace(/(^models:[ \t]*\r?\n(?:[ \t]+[^\n]*\n)*?[ \t]+profile:[ \t]*)[^#\r\n]+/m, `$1${name}`), "utf8");
|
|
42
73
|
}
|
|
43
|
-
|
|
44
|
-
|
|
74
|
+
atomicJson(activationFile, { schemaVersion: 1, profile: name, enabled: [...extras], generated, generatedCanonical, warnings });
|
|
75
|
+
const installState = contained(project, ".claude/nccgs/install-state.json");
|
|
76
|
+
if (fs.existsSync(installState)) {
|
|
77
|
+
const state = json(installState);
|
|
78
|
+
state.modelProfile = name;
|
|
79
|
+
state.files = state.files.filter(item => !removes.includes(contained(project, item.relative))).map(item =>
|
|
80
|
+
generated[item.relative] ? { ...item, ...fingerprintFile(contained(project, item.relative)) } : item);
|
|
81
|
+
atomicJson(installState, state);
|
|
82
|
+
}
|
|
83
|
+
console.log(`NCCGS model profile '${name}' configured; ${writes.length} agent file(s) changed; ${active.size} selected agents.`);
|
|
84
|
+
for (const warning of warnings) console.warn(warning);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { hash, json, contained, artifact, verifyArtifact, atomicJson, loadPipeline } from "./policy.mjs";
|
|
4
|
+
|
|
5
|
+
export const stages = ["implementation", "review", "report", "astra", "acceptance"];
|
|
6
|
+
function requireValue(condition, message) { if (!condition) throw new Error(message); }
|
|
7
|
+
export function taskPath(project, id) {
|
|
8
|
+
requireValue(typeof id === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(id), "Invalid task ID");
|
|
9
|
+
return contained(project, `.nccgs/tasks/${id}.json`);
|
|
10
|
+
}
|
|
11
|
+
export function snapshot(project) {
|
|
12
|
+
const excluded = new Set(loadPipeline(project).snapshotExcludes);
|
|
13
|
+
const files = [];
|
|
14
|
+
function walk(relative = "") {
|
|
15
|
+
for (const entry of fs.readdirSync(path.join(project, relative), { withFileTypes: true })) {
|
|
16
|
+
if (!relative && excluded.has(entry.name)) continue;
|
|
17
|
+
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
|
18
|
+
const file = contained(project, name);
|
|
19
|
+
if (entry.isDirectory()) walk(name);
|
|
20
|
+
else if (entry.isFile()) files.push([name, hash(fs.readFileSync(file))]);
|
|
21
|
+
else throw new Error(`Unsupported snapshot entry: ${name}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
walk();
|
|
25
|
+
files.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
26
|
+
return { sha256: hash(JSON.stringify(files)), files: files.length };
|
|
27
|
+
}
|
|
28
|
+
function initializeUnlocked(project, id, plan, requirements = null) {
|
|
29
|
+
const file = taskPath(project, id);
|
|
30
|
+
requireValue(!fs.existsSync(file), `Task already exists: ${id}`);
|
|
31
|
+
const projectGates = artifact(project, ".nccgs/gates.json");
|
|
32
|
+
const projectPolicy = artifact(project, ".nccgs/project.yaml");
|
|
33
|
+
requireValue(json(verifyArtifact(project, projectGates)).policySha256 === projectPolicy.sha256, "Astra must map/review project gates against the current project.yaml hash");
|
|
34
|
+
const task = { schemaVersion: 1, id, profile: "astra-claude", createdAt: new Date().toISOString(), pipelineHash: hash(JSON.stringify(loadPipeline(project))), plan: artifact(project, plan), projectPolicy, projectGates, requirements: requirements ? artifact(project, requirements) : null, stages: {}, history: [] };
|
|
35
|
+
atomicJson(file, task);
|
|
36
|
+
return task;
|
|
37
|
+
}
|
|
38
|
+
function runtime(project, task, stage, expected, snap) {
|
|
39
|
+
const file = verifyArtifact(project, stage.runtime);
|
|
40
|
+
const trace = json(file);
|
|
41
|
+
requireValue(trace.schemaVersion === 1 && trace.origin === "runtime-observation", "Runtime evidence must be an observation, not configuration");
|
|
42
|
+
requireValue(trace.taskId === task.id && trace.snapshot === snap, "Runtime evidence belongs to another task/snapshot");
|
|
43
|
+
requireValue(trace.status === "completed" && trace.sessionId && trace.agentId, "Runtime observation is incomplete");
|
|
44
|
+
requireValue(Date.parse(trace.capturedAt) >= Date.parse(task.createdAt) && Date.parse(trace.capturedAt) <= Date.now(), "Runtime observation has invalid time");
|
|
45
|
+
const models = trace.modelsUsed?.length ? trace.modelsUsed : [trace.observedModel];
|
|
46
|
+
const matches = (model) => expected.model === "fable" ? /^claude-fable-\d[\w.-]*$/.test(model ?? "") : model === expected.model;
|
|
47
|
+
requireValue(matches(trace.observedModel) && models.every(matches), "Observed model/fallback does not satisfy pipeline");
|
|
48
|
+
requireValue(trace.observedEffort === expected.effort, "Observed effort missing or mismatched; configuration is not proof");
|
|
49
|
+
requireValue(trace.observationSource, "Runtime observation requires a source artifact");
|
|
50
|
+
const source = json(verifyArtifact(project, trace.observationSource));
|
|
51
|
+
requireValue((source.observedModel ?? source.resolvedModel) === trace.observedModel && source.observedEffort === trace.observedEffort, "Observation differs from its raw model/effort source");
|
|
52
|
+
requireValue(source.taskId === task.id && source.snapshot === snap && source.sessionId === trace.sessionId && source.agentId === trace.agentId, "Observation source identity/snapshot mismatch");
|
|
53
|
+
requireValue(source.capturedAt === trace.capturedAt && source.status === trace.status, "Observation source time/status mismatch");
|
|
54
|
+
const sourceModels = source.modelsUsed?.length ? source.modelsUsed : [source.observedModel ?? source.resolvedModel];
|
|
55
|
+
requireValue(JSON.stringify(sourceModels) === JSON.stringify(models), "Observation dropped or changed source fallback history");
|
|
56
|
+
return trace;
|
|
57
|
+
}
|
|
58
|
+
export function check(project, task, target = "done") {
|
|
59
|
+
requireValue(task.schemaVersion === 1 && task.profile === "astra-claude", "Unsupported task record");
|
|
60
|
+
taskPath(project, task.id);
|
|
61
|
+
const pipeline = loadPipeline(project);
|
|
62
|
+
requireValue(task.pipelineHash === hash(JSON.stringify(pipeline)), "Pipeline policy changed; create a new task against the new policy");
|
|
63
|
+
verifyArtifact(project, task.plan);
|
|
64
|
+
verifyArtifact(project, task.projectPolicy);
|
|
65
|
+
const obligations = [task.projectGates, task.requirements].filter(Boolean).map(ref => json(verifyArtifact(project, ref)));
|
|
66
|
+
requireValue(obligations.length > 0, "Project gate manifest is required");
|
|
67
|
+
for (const spec of obligations) requireValue(spec.schemaVersion === 1 && [spec.requiredChecks, spec.requiredArtifacts, spec.manualGates].every(Array.isArray), "Invalid gate manifest schema");
|
|
68
|
+
const snap = snapshot(project).sha256;
|
|
69
|
+
const end = target === "done" ? 5 : stages.indexOf(target);
|
|
70
|
+
requireValue(end >= 0, "Invalid gate target");
|
|
71
|
+
let previous = Date.parse(task.createdAt);
|
|
72
|
+
let implementer;
|
|
73
|
+
for (const name of stages.slice(0, end)) {
|
|
74
|
+
const stage = task.stages[name];
|
|
75
|
+
requireValue(stage && stage.snapshot === snap, `${name}: missing or stale for current snapshot`);
|
|
76
|
+
const at = Date.parse(stage.recordedAt);
|
|
77
|
+
requireValue(Number.isFinite(at) && at >= previous && at <= Date.now(), `${name}: invalid sequence timestamp`);
|
|
78
|
+
previous = at;
|
|
79
|
+
if (name === "implementation" || name === "review") {
|
|
80
|
+
const className = name === "review" ? "review" : "standard";
|
|
81
|
+
const trace = runtime(project, task, stage, pipeline.profiles[task.profile].models[className], snap);
|
|
82
|
+
if (name === "implementation") {
|
|
83
|
+
implementer = `${trace.sessionId}/${trace.agentId}`;
|
|
84
|
+
requireValue(Array.isArray(stage.checks) && stage.checks.length > 0, "Implementation requires verification evidence");
|
|
85
|
+
for (const test of stage.checks) {
|
|
86
|
+
requireValue(test.name && test.status === "PASS", "Verification contains missing/failed/unproven checks");
|
|
87
|
+
const evidenceFile = verifyArtifact(project, test.evidence);
|
|
88
|
+
if (test.evidence.path.endsWith(".json")) {
|
|
89
|
+
const evidence = json(evidenceFile);
|
|
90
|
+
if (evidence.origin === "verification-command") {
|
|
91
|
+
requireValue(evidence.taskId === task.id && evidence.name === test.name && evidence.snapshot === snap && evidence.status === "PASS" && evidence.exitCode === 0 && evidence.sourceUnchanged === true, "Recorded verification execution failed or is stale");
|
|
92
|
+
verifyArtifact(project, evidence.log);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const spec of obligations) for (const name of spec.requiredChecks) requireValue(stage.checks.some(c => c.name === name && c.status === "PASS"), `Required check missing: ${name}`);
|
|
97
|
+
} else {
|
|
98
|
+
requireValue(`${trace.sessionId}/${trace.agentId}` !== implementer, "Review must be independent of implementation");
|
|
99
|
+
requireValue(Date.parse(trace.capturedAt) >= Date.parse(task.stages.implementation.recordedAt), "Review runtime predates implementation evidence");
|
|
100
|
+
requireValue(stage.verdict === "PASS" && Array.isArray(stage.findings), "Fable review has not passed");
|
|
101
|
+
requireValue(!stage.findings.some(f => ["BLOCKER", "HIGH"].includes(f.severity) && f.status !== "RESOLVED"), "Review has open blocking findings");
|
|
102
|
+
requireValue(stage.criteriaMet === true, "Required acceptance criteria are not proven");
|
|
103
|
+
verifyArtifact(project, stage.evidence);
|
|
104
|
+
}
|
|
105
|
+
} else if (name === "report") {
|
|
106
|
+
verifyArtifact(project, stage.evidence);
|
|
107
|
+
for (const spec of obligations) for (const relative of spec.requiredArtifacts) {
|
|
108
|
+
const ref = stage.artifacts?.find(item => item.path === relative);
|
|
109
|
+
requireValue(ref, `Required closure artifact missing: ${relative}`);
|
|
110
|
+
verifyArtifact(project, ref);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const approval = json(verifyArtifact(project, stage.evidence));
|
|
115
|
+
const actor = name === "astra" ? "Astra" : "Product Owner";
|
|
116
|
+
const decision = name === "astra" ? "PASS" : "ACCEPTED";
|
|
117
|
+
requireValue(approval.actor === actor && approval.decision === decision, `${name}: explicit ${actor} decision required`);
|
|
118
|
+
requireValue(approval.taskId === task.id && approval.snapshot === snap, `${name}: sign-off covers a different snapshot`);
|
|
119
|
+
requireValue(approval.statement?.trim() && approval.reference?.trim(), `${name}: decision statement and source reference required`);
|
|
120
|
+
requireValue(Date.parse(approval.decidedAt) >= Date.parse(task.stages[name === "astra" ? "report" : "astra"].recordedAt) && Date.parse(approval.decidedAt) <= at, `${name}: sign-off predates its prerequisite`);
|
|
121
|
+
if (name === "acceptance") for (const spec of obligations) for (const id of spec.manualGates) requireValue(approval.gates?.some(g => g.id === id && g.decision === "ACCEPTED" && g.reference?.trim()), `Manual gate missing: ${id}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { allowed: true, target, snapshot: snap, phase: target === "done" ? "DONE" : `READY_FOR_${target.toUpperCase()}` };
|
|
125
|
+
}
|
|
126
|
+
function recordUnlocked(project, id, name, input) {
|
|
127
|
+
requireValue(stages.includes(name), "Unknown pipeline stage");
|
|
128
|
+
if (["astra", "acceptance"].includes(name) && (process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT)) throw new Error("External sign-offs must be imported outside the Claude session");
|
|
129
|
+
const file = taskPath(project, id);
|
|
130
|
+
const task = json(file);
|
|
131
|
+
const status = check(project, task, name);
|
|
132
|
+
requireValue(input.snapshot === status.snapshot, "Submitted evidence does not cover the current snapshot");
|
|
133
|
+
task.history.push({ recordedAt: new Date().toISOString(), replacedFrom: name, stages: structuredClone(task.stages) });
|
|
134
|
+
for (const downstream of stages.slice(stages.indexOf(name))) delete task.stages[downstream];
|
|
135
|
+
task.stages[name] = { ...input, recordedAt: new Date().toISOString() };
|
|
136
|
+
// Failed reviews can be retained as evidence but never unlock the next stage.
|
|
137
|
+
const externalDecision = ["astra", "acceptance"].includes(name) ? json(verifyArtifact(project, input.evidence)).decision : null;
|
|
138
|
+
const negative = (name === "review" && ["CHANGES_REQUIRED", "BLOCKED"].includes(input.verdict)) || ["CHANGES_REQUIRED", "REJECTED"].includes(externalDecision);
|
|
139
|
+
if (!negative) {
|
|
140
|
+
const next = stages[stages.indexOf(name) + 1] ?? "done";
|
|
141
|
+
check(project, task, next);
|
|
142
|
+
}
|
|
143
|
+
delete task.closed;
|
|
144
|
+
atomicJson(file, task);
|
|
145
|
+
return task;
|
|
146
|
+
}
|
|
147
|
+
function closeUnlocked(project, id) {
|
|
148
|
+
const file = taskPath(project, id);
|
|
149
|
+
const task = json(file);
|
|
150
|
+
const status = check(project, task, "done");
|
|
151
|
+
task.closed = { snapshot: status.snapshot, at: new Date().toISOString() };
|
|
152
|
+
atomicJson(file, task);
|
|
153
|
+
return status;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function mutate(project, id, action) {
|
|
157
|
+
const lock = `${taskPath(project, id)}.lock`;
|
|
158
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
159
|
+
let descriptor;
|
|
160
|
+
try { descriptor = fs.openSync(lock, "wx"); }
|
|
161
|
+
catch (error) { if (error.code === "EEXIST") throw new Error(`Task ${id} is locked by another writer; inspect ${lock} before recovering a crashed writer`); throw error; }
|
|
162
|
+
try { fs.writeSync(descriptor, JSON.stringify({ pid: process.pid, at: new Date().toISOString() })); return action(); }
|
|
163
|
+
finally { fs.closeSync(descriptor); fs.unlinkSync(lock); }
|
|
164
|
+
}
|
|
165
|
+
export const initialize = (project, id, plan, requirements = null) => mutate(project, id, () => initializeUnlocked(project, id, plan, requirements));
|
|
166
|
+
export const record = (project, id, stage, evidence) => mutate(project, id, () => recordUnlocked(project, id, stage, evidence));
|
|
167
|
+
export const close = (project, id) => mutate(project, id, () => closeUnlocked(project, id));
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
6
|
+
export const json = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
7
|
+
export function loadPipeline(project) {
|
|
8
|
+
const pipeline = json(path.join(project, ".claude/nccgs/pipeline.json"));
|
|
9
|
+
if (pipeline.schemaVersion !== 1) throw new Error("Unsupported pipeline schema");
|
|
10
|
+
if (!pipeline.profiles?.[pipeline.defaultProfile]) throw new Error("Invalid default profile");
|
|
11
|
+
if (!Array.isArray(pipeline.coreAgents) || !pipeline.coreAgents.includes(pipeline.contract?.reviewAgent)) throw new Error("Core agents must include the required reviewer");
|
|
12
|
+
if (JSON.stringify(pipeline.profiles["astra-claude"]?.activeAgents) !== JSON.stringify(pipeline.coreAgents)) throw new Error("Default Astra active agents must match coreAgents");
|
|
13
|
+
for (const profile of Object.values(pipeline.profiles)) {
|
|
14
|
+
for (const route of Object.values(profile.models ?? {})) {
|
|
15
|
+
if (!(route.model === null || typeof route.model === "string") || ![null, "low", "medium", "high", "xhigh", "max"].includes(route.effort)) throw new Error("Invalid model/effort route");
|
|
16
|
+
}
|
|
17
|
+
if (profile.session && !profile.models[profile.session.class]) throw new Error("Unknown session model class");
|
|
18
|
+
}
|
|
19
|
+
return pipeline;
|
|
20
|
+
}
|
|
21
|
+
export function selectedProfile(project, explicit) {
|
|
22
|
+
const file = path.join(project, ".nccgs/project.yaml");
|
|
23
|
+
const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
24
|
+
const block = text.match(/^models:[ \t]*\r?\n((?:[ \t]+[^\n]*\n?|\s*\n)*)/m)?.[1] ?? "";
|
|
25
|
+
const value = block.match(/^\s+profile:\s*([^#\r\n]+)/m)?.[1]?.trim().replace(/^['"]|['"]$/g, "");
|
|
26
|
+
return explicit || value || loadPipeline(project).defaultProfile;
|
|
27
|
+
}
|
|
28
|
+
export function profilePolicy(project, explicit) {
|
|
29
|
+
const pipeline = loadPipeline(project);
|
|
30
|
+
const name = selectedProfile(project, explicit);
|
|
31
|
+
if (!Object.hasOwn(pipeline.profiles, name)) throw new Error(`Unknown model profile '${name}'`);
|
|
32
|
+
return { pipeline, name, profile: pipeline.profiles[name] };
|
|
33
|
+
}
|
|
34
|
+
export function contained(project, relative) {
|
|
35
|
+
if (typeof relative !== "string" || !relative || path.isAbsolute(relative) || relative.includes("\\")) throw new Error("Expected a project-relative POSIX path");
|
|
36
|
+
const resolved = path.resolve(project, relative);
|
|
37
|
+
const rel = path.relative(path.resolve(project), resolved);
|
|
38
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`Path escapes project: ${relative}`);
|
|
39
|
+
let current = path.resolve(project);
|
|
40
|
+
for (const part of rel.split(path.sep)) {
|
|
41
|
+
current = path.join(current, part);
|
|
42
|
+
if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) throw new Error(`Symlink/reparse path not supported: ${relative}`);
|
|
43
|
+
}
|
|
44
|
+
return resolved;
|
|
45
|
+
}
|
|
46
|
+
export function atomicJson(file, value) {
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
48
|
+
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
49
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
|
|
50
|
+
fs.renameSync(temp, file);
|
|
51
|
+
}
|
|
52
|
+
export function artifact(project, relative) {
|
|
53
|
+
const file = contained(project, relative);
|
|
54
|
+
if (!fs.statSync(file).isFile()) throw new Error(`Artifact is not a file: ${relative}`);
|
|
55
|
+
const bytes = fs.readFileSync(file);
|
|
56
|
+
if (!bytes.length) throw new Error(`Empty artifact: ${relative}`);
|
|
57
|
+
return { path: relative, sha256: hash(bytes) };
|
|
58
|
+
}
|
|
59
|
+
export function verifyArtifact(project, ref) {
|
|
60
|
+
if (!ref?.sha256 || artifact(project, ref.path).sha256 !== ref.sha256) throw new Error(`Missing or stale artifact: ${ref?.path ?? "unknown"}`);
|
|
61
|
+
return contained(project, ref.path);
|
|
62
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { json, contained, artifact, atomicJson, profilePolicy } from "./policy.mjs";
|
|
4
|
+
import { snapshot, taskPath } from "./gates.mjs";
|
|
5
|
+
|
|
6
|
+
export function boundTask(project, session) {
|
|
7
|
+
if (process.env.NCCGS_TASK_ID) { taskPath(project, process.env.NCCGS_TASK_ID); return process.env.NCCGS_TASK_ID; }
|
|
8
|
+
if (!/^[\w-]+$/.test(session ?? "")) return null;
|
|
9
|
+
const file = contained(project, `.nccgs/runtime/bindings/${session}.json`);
|
|
10
|
+
return fs.existsSync(file) ? json(file).taskId : null;
|
|
11
|
+
}
|
|
12
|
+
export function observe(project, input, event) {
|
|
13
|
+
const { profile } = profilePolicy(project);
|
|
14
|
+
const response = input.tool_response ?? {};
|
|
15
|
+
const role = input.agent_type || input.tool_input?.subagent_type || null;
|
|
16
|
+
const studio = json(contained(project, ".claude/nccgs/studio.json"));
|
|
17
|
+
const agent = studio.agents.find(a => a.name === role);
|
|
18
|
+
const requested = agent ? profile.models[agent.modelClass] : null;
|
|
19
|
+
const taskId = boundTask(project, input.session_id);
|
|
20
|
+
const observedModel = typeof response.resolvedModel === "string" ? response.resolvedModel : null;
|
|
21
|
+
const modelsUsed = Array.isArray(response.modelsUsed) ? response.modelsUsed.filter(m => typeof m === "string") : observedModel ? [observedModel] : [];
|
|
22
|
+
const capturedAt = new Date().toISOString();
|
|
23
|
+
const sourceSnapshot = taskId ? snapshot(project).sha256 : null;
|
|
24
|
+
const id = crypto.randomUUID();
|
|
25
|
+
// Only documented model telemetry is copied. Prompts, arbitrary tool results,
|
|
26
|
+
// credentials, and configuration guesses are never copied into observations.
|
|
27
|
+
const raw = { capturedAt, event, taskId, snapshot: sourceSnapshot, sessionId: input.session_id ?? null, agentId: input.agent_id ?? response.agentId ?? null,
|
|
28
|
+
resolvedModel: observedModel, modelsUsed, status: response.status ?? (event === "agent_stop" ? "completed" : "unknown"),
|
|
29
|
+
durationMs: response.totalDurationMs ?? null, toolUseCount: response.totalToolUseCount ?? null };
|
|
30
|
+
const source = `.nccgs/runtime/sources/${id}.json`;
|
|
31
|
+
atomicJson(contained(project, source), raw);
|
|
32
|
+
const observation = { schemaVersion: 1, origin: "runtime-observation", capturedAt,
|
|
33
|
+
taskId, snapshot: sourceSnapshot, sessionId: raw.sessionId, agentId: raw.agentId,
|
|
34
|
+
agentType: role, requestedModel: requested?.model ?? null, requestedEffort: requested?.effort ?? null,
|
|
35
|
+
observedModel, modelsUsed, observedEffort: null, status: raw.status,
|
|
36
|
+
missing: [observedModel ? null : "model", "effort"].filter(Boolean), observationSource: artifact(project, source) };
|
|
37
|
+
const relative = `.nccgs/runtime/observations/${id}.json`;
|
|
38
|
+
atomicJson(contained(project, relative), observation);
|
|
39
|
+
return { ...observation, path: relative };
|
|
40
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { json, contained, artifact, atomicJson, profilePolicy } from "./policy.mjs";
|
|
7
|
+
import { snapshot, initialize, record, check, close, taskPath } from "./gates.mjs";
|
|
8
|
+
|
|
9
|
+
export function run(args) {
|
|
10
|
+
const command = args[0];
|
|
11
|
+
const options = args.includes("--") ? args.slice(0, args.indexOf("--")) : args;
|
|
12
|
+
const value = (flag) => {
|
|
13
|
+
const index = options.indexOf(flag);
|
|
14
|
+
if (index < 0) return null;
|
|
15
|
+
if (!options[index + 1] || options[index + 1].startsWith("--")) throw new Error(`${flag} requires a value`);
|
|
16
|
+
return options[index + 1];
|
|
17
|
+
};
|
|
18
|
+
const project = path.resolve(value("--project") || process.cwd());
|
|
19
|
+
const id = value("--id");
|
|
20
|
+
if (profilePolicy(project).name !== "astra-claude") throw new Error("Task gates require astra-claude profile");
|
|
21
|
+
if (command === "snapshot") return snapshot(project);
|
|
22
|
+
if (command === "verify") {
|
|
23
|
+
json(taskPath(project, id));
|
|
24
|
+
const name = value("--name");
|
|
25
|
+
const separator = args.indexOf("--");
|
|
26
|
+
if (!name || separator < 0 || !args[separator + 1]) throw new Error("verify requires --name CHECK -- EXECUTABLE [ARGS]");
|
|
27
|
+
const before = snapshot(project).sha256;
|
|
28
|
+
const result = spawnSync(args[separator + 1], args.slice(separator + 2), { cwd: project, encoding: "utf8", timeout: 300000, maxBuffer: 16 * 1024 * 1024, shell: false, windowsHide: true });
|
|
29
|
+
const after = snapshot(project).sha256;
|
|
30
|
+
const stamp = crypto.randomUUID();
|
|
31
|
+
const log = `.nccgs/evidence/check-${stamp}.log`;
|
|
32
|
+
fs.mkdirSync(path.dirname(contained(project, log)), { recursive: true });
|
|
33
|
+
fs.writeFileSync(contained(project, log), `${result.stdout ?? ""}\n${result.stderr ?? ""}\n${result.error?.message ?? ""}`, "utf8");
|
|
34
|
+
const evidencePath = `.nccgs/evidence/check-${stamp}.json`;
|
|
35
|
+
const status = result.status === 0 && !result.error && before === after ? "PASS" : "FAIL";
|
|
36
|
+
atomicJson(contained(project, evidencePath), { schemaVersion: 1, origin: "verification-command", taskId: id, name, snapshot: before,
|
|
37
|
+
finishedAt: new Date().toISOString(), executable: args[separator + 1], arguments: args.slice(separator + 2), exitCode: result.status,
|
|
38
|
+
sourceUnchanged: before === after, status, log: artifact(project, log) });
|
|
39
|
+
return { name, status, evidence: artifact(project, evidencePath) };
|
|
40
|
+
}
|
|
41
|
+
if (command === "init") return initialize(project, id, value("--plan"), value("--requirements"));
|
|
42
|
+
if (command === "record") return record(project, id, value("--stage"), json(contained(project, value("--evidence"))));
|
|
43
|
+
if (command === "check") return check(project, json(taskPath(project, id)), value("--target") || "done");
|
|
44
|
+
if (command === "close") return close(project, id);
|
|
45
|
+
if (command === "ref") return artifact(project, value("--file"));
|
|
46
|
+
if (command === "observe") {
|
|
47
|
+
const task = json(taskPath(project, id));
|
|
48
|
+
const source = artifact(project, value("--file"));
|
|
49
|
+
const raw = json(contained(project, source.path));
|
|
50
|
+
if (raw.taskId !== id || raw.snapshot !== snapshot(project).sha256) throw new Error("Observation task/snapshot mismatch");
|
|
51
|
+
if (!["provider-log", "operator-observation"].includes(raw.provenance?.kind) || !raw.provenance.reference?.trim()) throw new Error("Runtime source provenance required");
|
|
52
|
+
if (!raw.sessionId || !raw.agentId || !raw.observedModel || !raw.observedEffort || raw.status !== "completed") throw new Error("Incomplete observed model/effort/run identity");
|
|
53
|
+
if (!(Date.parse(raw.capturedAt) >= Date.parse(task.createdAt) && Date.parse(raw.capturedAt) <= Date.now())) throw new Error("Invalid observation time");
|
|
54
|
+
const relative = `.nccgs/runtime/observations/${crypto.randomUUID()}.json`;
|
|
55
|
+
atomicJson(contained(project, relative), { schemaVersion: 1, origin: "runtime-observation", taskId: raw.taskId, snapshot: raw.snapshot,
|
|
56
|
+
sessionId: raw.sessionId, agentId: raw.agentId, capturedAt: raw.capturedAt, observedModel: raw.observedModel,
|
|
57
|
+
observedEffort: raw.observedEffort, modelsUsed: raw.modelsUsed ?? [raw.observedModel], status: raw.status,
|
|
58
|
+
provenance: raw.provenance, observationSource: source });
|
|
59
|
+
return artifact(project, relative);
|
|
60
|
+
}
|
|
61
|
+
if (command === "bind") {
|
|
62
|
+
const session = value("--session");
|
|
63
|
+
if (!session || !/^[\w-]+$/.test(session)) throw new Error("Invalid session ID");
|
|
64
|
+
json(taskPath(project, id));
|
|
65
|
+
atomicJson(contained(project, `.nccgs/runtime/bindings/${session}.json`), { taskId: id });
|
|
66
|
+
return { session, taskId: id };
|
|
67
|
+
}
|
|
68
|
+
throw new Error("Usage: task init|snapshot|ref|observe|verify|record|check|close|bind --id TASK [--project PATH] [--plan FILE] [--stage implementation|review|report|astra|acceptance] [--evidence FILE] [--target report|astra|done]; verify --name CHECK -- EXECUTABLE ARGS");
|
|
69
|
+
}
|
|
70
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
71
|
+
try {
|
|
72
|
+
const result = run(process.argv.slice(2));
|
|
73
|
+
console.log(JSON.stringify(result, null, 2));
|
|
74
|
+
if (result.status === "FAIL") process.exitCode = 1;
|
|
75
|
+
}
|
|
76
|
+
catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
77
|
+
}
|