nccgs 1.1.0 → 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.
Files changed (215) hide show
  1. package/.claude/agents/nccgs-adversarial-reviewer.md +7 -1
  2. package/.claude/agents/nccgs-engine-programmer.md +2 -1
  3. package/.claude/agents/nccgs-gameplay-programmer.md +2 -1
  4. package/.claude/agents/nccgs-qa-engineer.md +2 -1
  5. package/.claude/agents/nccgs-ui-programmer.md +2 -1
  6. package/.claude/agents/nccgs-unity-build-specialist.md +2 -1
  7. package/.claude/agents/nccgs-unity-implementer.md +2 -1
  8. package/.claude/agents/nccgs-unity-systems-specialist.md +2 -1
  9. package/.claude/agents/nccgs-unity-ui-specialist.md +2 -1
  10. package/.claude/agents/nccgs-verification-engineer.md +2 -1
  11. package/.claude/nccgs/VERSION +1 -1
  12. package/.claude/{agents → nccgs/agent-library}/nccgs-accessibility-specialist.md +0 -1
  13. package/.claude/nccgs/agent-library/nccgs-adversarial-reviewer.md +30 -0
  14. package/.claude/{agents → nccgs/agent-library}/nccgs-ai-programmer.md +0 -1
  15. package/.claude/{agents → nccgs/agent-library}/nccgs-analytics-engineer.md +0 -1
  16. package/.claude/{agents → nccgs/agent-library}/nccgs-art-direction-lead.md +0 -1
  17. package/.claude/{agents → nccgs/agent-library}/nccgs-audio-direction-lead.md +0 -1
  18. package/.claude/{agents → nccgs/agent-library}/nccgs-creative-director.md +0 -1
  19. package/.claude/{agents → nccgs/agent-library}/nccgs-documentation-manager.md +0 -1
  20. package/.claude/{agents → nccgs/agent-library}/nccgs-economy-designer.md +0 -1
  21. package/.claude/nccgs/agent-library/nccgs-engine-programmer.md +25 -0
  22. package/.claude/{agents → nccgs/agent-library}/nccgs-game-design-lead.md +0 -1
  23. package/.claude/{agents → nccgs/agent-library}/nccgs-game-designer.md +0 -1
  24. package/.claude/nccgs/agent-library/nccgs-gameplay-programmer.md +25 -0
  25. package/.claude/{agents → nccgs/agent-library}/nccgs-level-designer.md +0 -1
  26. package/.claude/{agents → nccgs/agent-library}/nccgs-live-ops-designer.md +0 -1
  27. package/.claude/{agents → nccgs/agent-library}/nccgs-localization-lead.md +0 -1
  28. package/.claude/{agents → nccgs/agent-library}/nccgs-narrative-lead.md +0 -1
  29. package/.claude/{agents → nccgs/agent-library}/nccgs-network-programmer.md +0 -1
  30. package/.claude/{agents → nccgs/agent-library}/nccgs-performance-analyst.md +0 -1
  31. package/.claude/{agents → nccgs/agent-library}/nccgs-producer.md +0 -1
  32. package/.claude/{agents → nccgs/agent-library}/nccgs-production-coordinator.md +0 -1
  33. package/.claude/{agents → nccgs/agent-library}/nccgs-programming-lead.md +0 -1
  34. package/.claude/{agents → nccgs/agent-library}/nccgs-prototyper.md +0 -1
  35. package/.claude/nccgs/agent-library/nccgs-qa-engineer.md +25 -0
  36. package/.claude/{agents → nccgs/agent-library}/nccgs-qa-lead.md +0 -1
  37. package/.claude/{agents → nccgs/agent-library}/nccgs-release-engineer.md +0 -1
  38. package/.claude/{agents → nccgs/agent-library}/nccgs-release-lead.md +0 -1
  39. package/.claude/{agents → nccgs/agent-library}/nccgs-security-engineer.md +0 -1
  40. package/.claude/{agents → nccgs/agent-library}/nccgs-sound-designer.md +0 -1
  41. package/.claude/{agents → nccgs/agent-library}/nccgs-systems-designer.md +0 -1
  42. package/.claude/{agents → nccgs/agent-library}/nccgs-technical-architect.md +0 -1
  43. package/.claude/{agents → nccgs/agent-library}/nccgs-technical-artist.md +0 -1
  44. package/.claude/{agents → nccgs/agent-library}/nccgs-technical-director.md +0 -1
  45. package/.claude/{agents → nccgs/agent-library}/nccgs-tools-programmer.md +0 -1
  46. package/.claude/nccgs/agent-library/nccgs-ui-programmer.md +25 -0
  47. package/.claude/nccgs/agent-library/nccgs-unity-build-specialist.md +25 -0
  48. package/.claude/{agents → nccgs/agent-library}/nccgs-unity-content-specialist.md +0 -1
  49. package/.claude/nccgs/agent-library/nccgs-unity-implementer.md +25 -0
  50. package/.claude/{agents → nccgs/agent-library}/nccgs-unity-rendering-specialist.md +0 -1
  51. package/.claude/nccgs/agent-library/nccgs-unity-systems-specialist.md +25 -0
  52. package/.claude/nccgs/agent-library/nccgs-unity-ui-specialist.md +25 -0
  53. package/.claude/{agents → nccgs/agent-library}/nccgs-ux-designer.md +0 -1
  54. package/.claude/nccgs/agent-library/nccgs-verification-engineer.md +25 -0
  55. package/.claude/{agents → nccgs/agent-library}/nccgs-world-builder.md +0 -1
  56. package/.claude/{agents → nccgs/agent-library}/nccgs-writer.md +0 -1
  57. package/.claude/nccgs/constitution.md +13 -0
  58. package/.claude/nccgs/hooks/agent-audit.mjs +6 -0
  59. package/.claude/nccgs/hooks/closure-guard.mjs +34 -0
  60. package/.claude/nccgs/hooks/runtime-observation.mjs +11 -0
  61. package/.claude/nccgs/hooks/session-start.mjs +8 -1
  62. package/.claude/nccgs/pipeline.json +176 -0
  63. package/.claude/nccgs/protocols/agent-contract.md +5 -0
  64. package/.claude/nccgs/protocols/astra-claude.md +77 -0
  65. package/.claude/nccgs/protocols/evidence.md +6 -0
  66. package/.claude/nccgs/protocols/model-routing.md +27 -5
  67. package/.claude/nccgs/protocols/orchestration.md +5 -0
  68. package/.claude/nccgs/protocols/task-gates.md +66 -0
  69. package/.claude/nccgs/routing.generated.md +11 -0
  70. package/.claude/nccgs/settings.fragment.json +73 -9
  71. package/.claude/nccgs/studio.json +187 -96
  72. package/.claude/nccgs/tools/approve-overrides.mjs +81 -0
  73. package/.claude/nccgs/tools/compile-policy.mjs +83 -0
  74. package/.claude/nccgs/tools/configure-models.mjs +76 -31
  75. package/.claude/nccgs/tools/gates.mjs +167 -0
  76. package/.claude/nccgs/tools/integrity.mjs +69 -0
  77. package/.claude/nccgs/tools/policy.mjs +62 -0
  78. package/.claude/nccgs/tools/runtime.mjs +40 -0
  79. package/.claude/nccgs/tools/task.mjs +77 -0
  80. package/.claude/nccgs/workflow-catalog.json +91 -14
  81. package/.claude/skills/accessibility-review/SKILL.md +6 -5
  82. package/.claude/skills/accessibility-review/references/astra.md +17 -0
  83. package/.claude/skills/accessibility-review/references/legacy.md +7 -0
  84. package/.claude/skills/architecture-decision/SKILL.md +6 -5
  85. package/.claude/skills/architecture-decision/references/astra.md +12 -0
  86. package/.claude/skills/architecture-decision/references/legacy.md +7 -0
  87. package/.claude/skills/asset-audit/SKILL.md +6 -5
  88. package/.claude/skills/asset-audit/references/astra.md +17 -0
  89. package/.claude/skills/asset-audit/references/legacy.md +7 -0
  90. package/.claude/skills/audit/SKILL.md +6 -7
  91. package/.claude/skills/audit/references/astra.md +17 -0
  92. package/.claude/skills/audit/references/legacy.md +9 -0
  93. package/.claude/skills/balance-review/SKILL.md +6 -5
  94. package/.claude/skills/balance-review/references/astra.md +17 -0
  95. package/.claude/skills/balance-review/references/legacy.md +7 -0
  96. package/.claude/skills/bug-triage/SKILL.md +6 -5
  97. package/.claude/skills/bug-triage/references/astra.md +20 -0
  98. package/.claude/skills/bug-triage/references/legacy.md +7 -0
  99. package/.claude/skills/closure/SKILL.md +5 -4
  100. package/.claude/skills/closure/references/astra.md +15 -0
  101. package/.claude/skills/closure/references/legacy.md +7 -0
  102. package/.claude/skills/compatibility-review/SKILL.md +6 -5
  103. package/.claude/skills/compatibility-review/references/astra.md +17 -0
  104. package/.claude/skills/compatibility-review/references/legacy.md +7 -0
  105. package/.claude/skills/context-pack/SKILL.md +5 -4
  106. package/.claude/skills/context-pack/references/astra.md +12 -0
  107. package/.claude/skills/context-pack/references/legacy.md +7 -0
  108. package/.claude/skills/dependency-review/SKILL.md +6 -5
  109. package/.claude/skills/dependency-review/references/astra.md +17 -0
  110. package/.claude/skills/dependency-review/references/legacy.md +7 -0
  111. package/.claude/skills/design/SKILL.md +6 -11
  112. package/.claude/skills/design/references/astra.md +12 -0
  113. package/.claude/skills/design/references/legacy.md +13 -0
  114. package/.claude/skills/design-review/SKILL.md +6 -5
  115. package/.claude/skills/design-review/references/astra.md +17 -0
  116. package/.claude/skills/design-review/references/legacy.md +7 -0
  117. package/.claude/skills/evidence-review/SKILL.md +6 -5
  118. package/.claude/skills/evidence-review/references/astra.md +17 -0
  119. package/.claude/skills/evidence-review/references/legacy.md +7 -0
  120. package/.claude/skills/hotfix/SKILL.md +6 -5
  121. package/.claude/skills/hotfix/references/astra.md +23 -0
  122. package/.claude/skills/hotfix/references/legacy.md +7 -0
  123. package/.claude/skills/incident-recovery/SKILL.md +6 -5
  124. package/.claude/skills/incident-recovery/references/astra.md +20 -0
  125. package/.claude/skills/incident-recovery/references/legacy.md +7 -0
  126. package/.claude/skills/localize-game/SKILL.md +6 -5
  127. package/.claude/skills/localize-game/references/astra.md +20 -0
  128. package/.claude/skills/localize-game/references/legacy.md +7 -0
  129. package/.claude/skills/migrate-project/SKILL.md +6 -14
  130. package/.claude/skills/migrate-project/references/astra.md +23 -0
  131. package/.claude/skills/migrate-project/references/legacy.md +19 -0
  132. package/.claude/skills/migrate-project/references/procedure.md +21 -1
  133. package/.claude/skills/milestone-review/SKILL.md +6 -5
  134. package/.claude/skills/milestone-review/references/astra.md +12 -0
  135. package/.claude/skills/milestone-review/references/legacy.md +7 -0
  136. package/.claude/skills/nccgs-code-review/SKILL.md +15 -0
  137. package/.claude/skills/nccgs-code-review/references/astra.md +15 -0
  138. package/.claude/skills/{code-review/SKILL.md → nccgs-code-review/references/legacy.md} +0 -7
  139. package/.claude/skills/performance-audit/SKILL.md +6 -5
  140. package/.claude/skills/performance-audit/references/astra.md +17 -0
  141. package/.claude/skills/performance-audit/references/legacy.md +7 -0
  142. package/.claude/skills/plan-feature/SKILL.md +6 -5
  143. package/.claude/skills/plan-feature/references/astra.md +12 -0
  144. package/.claude/skills/plan-feature/references/legacy.md +7 -0
  145. package/.claude/skills/playtest/SKILL.md +5 -4
  146. package/.claude/skills/playtest/references/astra.md +20 -0
  147. package/.claude/skills/playtest/references/legacy.md +7 -0
  148. package/.claude/skills/project-stage/SKILL.md +5 -4
  149. package/.claude/skills/project-stage/references/astra.md +12 -0
  150. package/.claude/skills/project-stage/references/legacy.md +7 -0
  151. package/.claude/skills/prototype-feature/SKILL.md +6 -5
  152. package/.claude/skills/prototype-feature/references/astra.md +20 -0
  153. package/.claude/skills/prototype-feature/references/legacy.md +7 -0
  154. package/.claude/skills/qa-plan/SKILL.md +6 -5
  155. package/.claude/skills/qa-plan/references/astra.md +12 -0
  156. package/.claude/skills/qa-plan/references/legacy.md +7 -0
  157. package/.claude/skills/release/SKILL.md +6 -9
  158. package/.claude/skills/release/references/astra.md +23 -0
  159. package/.claude/skills/release/references/legacy.md +11 -0
  160. package/.claude/skills/release-readiness/SKILL.md +6 -5
  161. package/.claude/skills/release-readiness/references/astra.md +17 -0
  162. package/.claude/skills/release-readiness/references/legacy.md +7 -0
  163. package/.claude/skills/retrospective/SKILL.md +6 -5
  164. package/.claude/skills/retrospective/references/astra.md +12 -0
  165. package/.claude/skills/retrospective/references/legacy.md +7 -0
  166. package/.claude/skills/review/SKILL.md +6 -7
  167. package/.claude/skills/review/references/astra.md +15 -0
  168. package/.claude/skills/review/references/legacy.md +9 -0
  169. package/.claude/skills/security-audit/SKILL.md +6 -5
  170. package/.claude/skills/security-audit/references/astra.md +17 -0
  171. package/.claude/skills/security-audit/references/legacy.md +7 -0
  172. package/.claude/skills/sprint-plan/SKILL.md +6 -5
  173. package/.claude/skills/sprint-plan/references/astra.md +12 -0
  174. package/.claude/skills/sprint-plan/references/legacy.md +7 -0
  175. package/.claude/skills/status/SKILL.md +5 -6
  176. package/.claude/skills/status/references/astra.md +13 -0
  177. package/.claude/skills/status/references/legacy.md +9 -0
  178. package/.claude/skills/story-readiness/SKILL.md +5 -4
  179. package/.claude/skills/story-readiness/references/astra.md +17 -0
  180. package/.claude/skills/story-readiness/references/legacy.md +7 -0
  181. package/.claude/skills/test/SKILL.md +6 -7
  182. package/.claude/skills/test/references/astra.md +20 -0
  183. package/.claude/skills/test/references/legacy.md +9 -0
  184. package/.claude/skills/ui-review/SKILL.md +6 -5
  185. package/.claude/skills/ui-review/references/astra.md +17 -0
  186. package/.claude/skills/ui-review/references/legacy.md +7 -0
  187. package/.claude/skills/work/SKILL.md +6 -27
  188. package/.claude/skills/work/references/astra.md +20 -0
  189. package/.claude/skills/work/references/closure.md +10 -0
  190. package/.claude/skills/work/references/feature-contracts.md +4 -0
  191. package/.claude/skills/work/references/legacy.md +29 -0
  192. package/README.md +67 -29
  193. package/UPGRADING.md +54 -4
  194. package/VERSION +1 -1
  195. package/docs/ARCHITECTURE.md +30 -13
  196. package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +34 -322
  197. package/docs/MIGRATION-MATRIX.md +1 -1
  198. package/docs/NCCGS-1.3.md +125 -0
  199. package/docs/PROJECT-POLICY.md +18 -3
  200. package/docs/WORKFLOWS.md +20 -14
  201. package/package-assets/nccgs-project.gitignore +2 -0
  202. package/package.json +5 -3
  203. package/scaffold/.nccgs/gates.json +7 -0
  204. package/scaffold/.nccgs/project.yaml +6 -3
  205. package/scaffold/.nccgs/state.md +1 -1
  206. package/scaffold/.nccgs/templates/agent-handoff.md +10 -0
  207. package/scaffold/.nccgs/templates/closure-record.md +11 -0
  208. package/scaffold/.nccgs/templates/feature-contract.md +9 -0
  209. package/scaffold/.nccgs/templates/implementation-report.md +49 -0
  210. package/scripts/cli.mjs +10 -5
  211. package/scripts/doctor.mjs +102 -18
  212. package/scripts/install.mjs +221 -88
  213. package/scripts/validate.mjs +34 -6
  214. package/tests/framework.test.mjs +213 -8
  215. package/tests/gates.test.mjs +170 -0
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { fingerprintFile } from "./integrity.mjs";
7
+
8
+ const ownDir = path.dirname(fileURLToPath(import.meta.url));
9
+ const args = process.argv.slice(2);
10
+ const projectArg = args.indexOf("--project");
11
+ const project = path.resolve(projectArg >= 0 ? args[projectArg + 1] : path.resolve(ownDir, "../../../.."));
12
+ const approvePending = args.includes("--approve-pending");
13
+ const manifestPath = path.join(project, ".nccgs", "framework-overrides.json");
14
+
15
+ if (projectArg >= 0 && !args[projectArg + 1]) {
16
+ console.error("--project requires a directory path.");
17
+ process.exit(2);
18
+ }
19
+ if (!fs.existsSync(manifestPath)) {
20
+ console.log("No NCCGS framework overrides are recorded.");
21
+ process.exit(0);
22
+ }
23
+
24
+ let manifest;
25
+ try { manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); }
26
+ catch (error) {
27
+ console.error(`Invalid framework override manifest: ${error.message}`);
28
+ process.exit(2);
29
+ }
30
+ if (manifest.schemaVersion !== 1) {
31
+ console.error(`Unsupported framework override schema: ${manifest.schemaVersion ?? "unknown"}`);
32
+ process.exit(2);
33
+ }
34
+ for (const item of manifest.overrides ?? []) {
35
+ if (!isManagedRelative(item.path) || !new Set(["preserve", "absent"]).has(item.disposition)
36
+ || !new Set(["approved", "pending_review"]).has(item.status)) {
37
+ console.error(`Invalid framework override record: ${item.path ?? "path missing"}`);
38
+ process.exit(2);
39
+ }
40
+ }
41
+
42
+ const pending = (manifest.overrides ?? []).filter((item) => item.status !== "approved");
43
+ if (!approvePending) {
44
+ if (!pending.length) console.log("All NCCGS framework overrides are approved.");
45
+ else {
46
+ console.log(`${pending.length} NCCGS framework override(s) await migration review:`);
47
+ for (const item of pending) console.log(`- ${item.path} (${item.disposition})`);
48
+ console.log("After reviewing/rebasing every item, run again with --approve-pending.");
49
+ }
50
+ process.exit(pending.length ? 1 : 0);
51
+ }
52
+
53
+ for (const item of pending) {
54
+ const file = path.join(project, ...item.path.split("/"));
55
+ if (item.disposition === "absent") {
56
+ if (fs.existsSync(file)) {
57
+ console.error(`Cannot approve absent override because the file exists: ${item.path}`);
58
+ process.exit(2);
59
+ }
60
+ item.sha256 = null;
61
+ item.canonicalSha256 = null;
62
+ } else {
63
+ if (!fs.existsSync(file)) {
64
+ console.error(`Cannot approve preserved override because the file is missing: ${item.path}`);
65
+ process.exit(2);
66
+ }
67
+ Object.assign(item, fingerprintFile(file));
68
+ }
69
+ item.status = "approved";
70
+ item.reviewedAt = new Date().toISOString();
71
+ }
72
+
73
+ manifest.updatedAt = new Date().toISOString();
74
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
75
+ console.log(`Approved ${pending.length} NCCGS framework override(s) after migration review.`);
76
+
77
+ function isManagedRelative(value) {
78
+ if (typeof value !== "string" || !value.startsWith(".claude/")) return false;
79
+ const parts = value.split("/");
80
+ return !parts.includes("") && !parts.includes(".") && !parts.includes("..");
81
+ }
@@ -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,39 +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 projectArg = args.indexOf("--project");
8
- const profileArg = args.indexOf("--profile");
9
- const project = path.resolve(projectArg >= 0 ? args[projectArg + 1] : path.resolve(ownDir, "../../../.."));
10
- const catalogPath = path.join(project, ".claude", "nccgs", "studio.json");
11
- const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8"));
12
- const policyPath = path.join(project, ".nccgs", "project.yaml");
13
- const policy = fs.existsSync(policyPath) ? fs.readFileSync(policyPath, "utf8") : "";
14
- const configured = policy.match(/^models:\s*$[\s\S]*?^\s+profile:\s*([^#\r\n]+)/m)?.[1]?.trim();
15
- const profileName = profileArg >= 0 ? args[profileArg + 1] : (configured || catalog.defaultProfile);
16
- const profile = catalog.modelProfiles[profileName];
17
- if (!profile) throw new Error(`Unknown model profile '${profileName}'. Expected: ${Object.keys(catalog.modelProfiles).join(", ")}`);
18
-
19
- let changed = 0;
20
- for (const agent of catalog.agents) {
21
- const file = path.join(project, ".claude", "agents", `${agent.name}.md`);
22
- if (!fs.existsSync(file)) continue;
23
- let text = fs.readFileSync(file, "utf8");
24
- const model = profile[agent.modelClass];
25
- const next = model
26
- ? (/^model:/m.test(text) ? text.replace(/^model:.*$/m, `model: ${model}`) : text.replace(/^maxTurns:/m, `model: ${model}\nmaxTurns:`))
27
- : text.replace(/^model:.*\r?\n/m, "");
28
- if (next !== text) {
29
- fs.writeFileSync(file, next, "utf8");
30
- changed++;
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);
25
+ }
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");
31
67
  }
32
68
  }
33
-
34
- if (profileArg >= 0 && policy && /^models:\s*$/m.test(policy)) {
35
- const nextPolicy = policy.replace(/(^models:\s*$[\s\S]*?^\s+profile:\s*)[^#\r\n]+/m, `$1${profileName}`);
36
- if (nextPolicy !== policy) fs.writeFileSync(policyPath, nextPolicy, "utf8");
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");
37
73
  }
38
-
39
- console.log(`NCCGS model profile '${profileName}' configured; ${changed} agent file(s) changed.`);
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,69 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+
4
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
5
+
6
+ export function sha256(value) {
7
+ return crypto.createHash("sha256").update(value).digest("hex");
8
+ }
9
+
10
+ export function textContent(value) {
11
+ const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value);
12
+ if (buffer.includes(0)) return null;
13
+ try { return utf8.decode(buffer); }
14
+ catch { return null; }
15
+ }
16
+
17
+ export function normalizeLf(value) {
18
+ const text = textContent(value);
19
+ if (text === null) return null;
20
+ return `${text.replace(/\r\n?/g, "\n").replace(/\n*$/, "")}\n`;
21
+ }
22
+
23
+ export function fingerprintBuffer(value) {
24
+ const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value);
25
+ const normalized = normalizeLf(buffer);
26
+ return {
27
+ sha256: sha256(buffer),
28
+ ...(normalized === null ? {} : { canonicalSha256: sha256(Buffer.from(normalized, "utf8")) })
29
+ };
30
+ }
31
+
32
+ export function fingerprintFile(file) {
33
+ return fingerprintBuffer(fs.readFileSync(file));
34
+ }
35
+
36
+ export function equivalentBuffers(left, right) {
37
+ const a = fingerprintBuffer(left);
38
+ const b = fingerprintBuffer(right);
39
+ if (a.sha256 === b.sha256) return true;
40
+ return Boolean(a.canonicalSha256 && a.canonicalSha256 === b.canonicalSha256);
41
+ }
42
+
43
+ export function equivalentFiles(left, right) {
44
+ return equivalentBuffers(fs.readFileSync(left), fs.readFileSync(right));
45
+ }
46
+
47
+ export function matchesFingerprint(file, expected) {
48
+ if (!expected || !fs.existsSync(file)) return false;
49
+ const buffer = fs.readFileSync(file);
50
+ const actual = fingerprintBuffer(buffer);
51
+ if (expected.sha256 && actual.sha256 === expected.sha256) return true;
52
+ if (expected.canonicalSha256 && actual.canonicalSha256 === expected.canonicalSha256) return true;
53
+
54
+ // NCCGS 1.1.0 stored only a raw hash. Accept the two normal Git text
55
+ // representations so a checkout's LF policy does not manufacture drift.
56
+ if (expected.sha256 && actual.canonicalSha256) {
57
+ const lf = normalizeLf(buffer);
58
+ if (sha256(Buffer.from(lf, "utf8")) === expected.sha256) return true;
59
+ const crlf = lf.replace(/\n/g, "\r\n");
60
+ if (sha256(Buffer.from(crlf, "utf8")) === expected.sha256) return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ export function sameFingerprint(left, right) {
66
+ if (!left || !right) return false;
67
+ if (left.sha256 && right.sha256 && left.sha256 === right.sha256) return true;
68
+ return Boolean(left.canonicalSha256 && right.canonicalSha256 && left.canonicalSha256 === right.canonicalSha256);
69
+ }
@@ -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
+ }