nccgs 1.3.0 → 2.0.0-rc.2

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 (180) hide show
  1. package/.claude/agents/nccgs-adversarial-reviewer.md +17 -6
  2. package/.claude/agents/nccgs-engine-programmer.md +2 -2
  3. package/.claude/agents/nccgs-fast-implementer.md +30 -0
  4. package/.claude/agents/nccgs-game-designer.md +26 -0
  5. package/.claude/agents/nccgs-gameplay-programmer.md +3 -4
  6. package/.claude/agents/nccgs-qa-engineer.md +2 -2
  7. package/.claude/agents/nccgs-task-scout.md +24 -0
  8. package/.claude/agents/nccgs-ui-programmer.md +2 -2
  9. package/.claude/agents/nccgs-unity-build-specialist.md +2 -2
  10. package/.claude/agents/nccgs-unity-implementer.md +7 -6
  11. package/.claude/agents/nccgs-unity-systems-specialist.md +2 -2
  12. package/.claude/agents/nccgs-unity-ui-specialist.md +2 -2
  13. package/.claude/agents/nccgs-verification-engineer.md +2 -2
  14. package/.claude/nccgs/MEMORY.md +59 -0
  15. package/.claude/nccgs/THIRD_PARTY_NOTICES.md +16 -0
  16. package/.claude/nccgs/VERSION +1 -1
  17. package/.claude/nccgs/agent-library/nccgs-adversarial-reviewer.md +16 -5
  18. package/.claude/nccgs/agent-library/nccgs-fast-implementer.md +28 -0
  19. package/.claude/nccgs/agent-library/nccgs-game-designer.md +1 -2
  20. package/.claude/nccgs/agent-library/nccgs-gameplay-programmer.md +1 -2
  21. package/.claude/nccgs/agent-library/nccgs-task-scout.md +22 -0
  22. package/.claude/nccgs/agent-library/nccgs-unity-implementer.md +5 -4
  23. package/.claude/nccgs/constitution.md +44 -12
  24. package/.claude/nccgs/hooks/agent-audit.mjs +2 -2
  25. package/.claude/nccgs/hooks/closure-guard.mjs +31 -8
  26. package/.claude/nccgs/hooks/common.mjs +2 -1
  27. package/.claude/nccgs/hooks/runtime-observation.mjs +2 -2
  28. package/.claude/nccgs/hooks/session-start.mjs +11 -4
  29. package/.claude/nccgs/packages/ponytail/LICENSE +21 -0
  30. package/.claude/nccgs/packages/ponytail/NOTICE.md +32 -0
  31. package/.claude/nccgs/packages/ponytail/README.md +46 -0
  32. package/.claude/nccgs/packages/ponytail/complexity-review.md +47 -0
  33. package/.claude/nccgs/packages/ponytail/engineering.md +68 -0
  34. package/.claude/nccgs/packages/ponytail/manifest.json +24 -0
  35. package/.claude/nccgs/pipeline.json +92 -8
  36. package/.claude/nccgs/protocols/agent-contract.md +50 -1
  37. package/.claude/nccgs/protocols/astra-claude.md +46 -12
  38. package/.claude/nccgs/protocols/bounded-repair.md +118 -0
  39. package/.claude/nccgs/protocols/context-packets.md +5 -0
  40. package/.claude/nccgs/protocols/delivery-acceptance.md +105 -0
  41. package/.claude/nccgs/protocols/design-lifecycle.md +139 -0
  42. package/.claude/nccgs/protocols/evidence.md +11 -1
  43. package/.claude/nccgs/protocols/model-routing.md +44 -42
  44. package/.claude/nccgs/protocols/orchestration.md +46 -5
  45. package/.claude/nccgs/protocols/review-handoff.md +65 -0
  46. package/.claude/nccgs/protocols/standalone-gates.md +273 -0
  47. package/.claude/nccgs/protocols/standalone-pilot.md +144 -0
  48. package/.claude/nccgs/protocols/standalone.md +187 -0
  49. package/.claude/nccgs/protocols/task-autonomy.md +55 -0
  50. package/.claude/nccgs/protocols/task-gates.md +73 -7
  51. package/.claude/nccgs/routing.generated.md +25 -7
  52. package/.claude/nccgs/settings.fragment.json +153 -141
  53. package/.claude/nccgs/studio.json +135 -101
  54. package/.claude/nccgs/tools/autonomy.mjs +27 -0
  55. package/.claude/nccgs/tools/command-process.mjs +44 -0
  56. package/.claude/nccgs/tools/compile-policy.mjs +34 -4
  57. package/.claude/nccgs/tools/configure-models.mjs +13 -5
  58. package/.claude/nccgs/tools/criteria.mjs +121 -0
  59. package/.claude/nccgs/tools/delivery-budget.mjs +52 -0
  60. package/.claude/nccgs/tools/design-readiness.mjs +158 -0
  61. package/.claude/nccgs/tools/evidence.mjs +153 -0
  62. package/.claude/nccgs/tools/gates.mjs +151 -24
  63. package/.claude/nccgs/tools/inputs.mjs +73 -0
  64. package/.claude/nccgs/tools/lifecycle.mjs +521 -0
  65. package/.claude/nccgs/tools/native-completion.mjs +21 -0
  66. package/.claude/nccgs/tools/orchestration.mjs +430 -0
  67. package/.claude/nccgs/tools/paths.mjs +60 -0
  68. package/.claude/nccgs/tools/policy.mjs +58 -13
  69. package/.claude/nccgs/tools/process-lock.mjs +74 -0
  70. package/.claude/nccgs/tools/repair-packet.mjs +87 -0
  71. package/.claude/nccgs/tools/review-handoff.mjs +32 -0
  72. package/.claude/nccgs/tools/runtime.mjs +33 -7
  73. package/.claude/nccgs/tools/task.mjs +62 -31
  74. package/.claude/nccgs/tools/tool-guard.mjs +117 -0
  75. package/.claude/nccgs/tools/verification-jobs.mjs +161 -0
  76. package/.claude/nccgs/tools/verification-worker.mjs +71 -0
  77. package/.claude/nccgs/tools/windows-job.cs +96 -0
  78. package/.claude/nccgs/workflow-catalog.json +286 -26
  79. package/.claude/skills/accessibility-review/SKILL.md +12 -5
  80. package/.claude/skills/architecture-decision/SKILL.md +12 -5
  81. package/.claude/skills/asset-audit/SKILL.md +12 -5
  82. package/.claude/skills/audit/SKILL.md +12 -5
  83. package/.claude/skills/balance-review/SKILL.md +12 -5
  84. package/.claude/skills/bug-triage/SKILL.md +12 -5
  85. package/.claude/skills/bug-triage/references/astra.md +12 -5
  86. package/.claude/skills/closure/SKILL.md +12 -5
  87. package/.claude/skills/compatibility-review/SKILL.md +12 -5
  88. package/.claude/skills/context-pack/SKILL.md +12 -5
  89. package/.claude/skills/dependency-review/SKILL.md +12 -5
  90. package/.claude/skills/design/SKILL.md +14 -5
  91. package/.claude/skills/design-review/SKILL.md +14 -5
  92. package/.claude/skills/evidence-review/SKILL.md +12 -5
  93. package/.claude/skills/hotfix/SKILL.md +12 -5
  94. package/.claude/skills/hotfix/references/astra.md +12 -5
  95. package/.claude/skills/incident-recovery/SKILL.md +12 -5
  96. package/.claude/skills/incident-recovery/references/astra.md +12 -5
  97. package/.claude/skills/localize-game/SKILL.md +12 -5
  98. package/.claude/skills/localize-game/references/astra.md +12 -5
  99. package/.claude/skills/migrate-project/SKILL.md +12 -5
  100. package/.claude/skills/migrate-project/references/astra.md +12 -5
  101. package/.claude/skills/migrate-project/references/procedure.md +2 -1
  102. package/.claude/skills/milestone-review/SKILL.md +12 -5
  103. package/.claude/skills/nccgs-code-review/SKILL.md +12 -5
  104. package/.claude/skills/nccgs-code-review/references/astra.md +8 -2
  105. package/.claude/skills/performance-audit/SKILL.md +12 -5
  106. package/.claude/skills/plan-feature/SKILL.md +12 -5
  107. package/.claude/skills/playtest/SKILL.md +12 -5
  108. package/.claude/skills/playtest/references/astra.md +16 -5
  109. package/.claude/skills/project-stage/SKILL.md +12 -5
  110. package/.claude/skills/prototype-feature/SKILL.md +12 -5
  111. package/.claude/skills/prototype-feature/references/astra.md +16 -5
  112. package/.claude/skills/qa-plan/SKILL.md +12 -5
  113. package/.claude/skills/release/SKILL.md +12 -5
  114. package/.claude/skills/release/references/astra.md +12 -5
  115. package/.claude/skills/release-readiness/SKILL.md +12 -5
  116. package/.claude/skills/retrospective/SKILL.md +12 -5
  117. package/.claude/skills/review/SKILL.md +12 -5
  118. package/.claude/skills/review/references/astra.md +8 -2
  119. package/.claude/skills/security-audit/SKILL.md +12 -5
  120. package/.claude/skills/sprint-plan/SKILL.md +12 -5
  121. package/.claude/skills/status/SKILL.md +12 -5
  122. package/.claude/skills/status/references/astra.md +6 -0
  123. package/.claude/skills/story-readiness/SKILL.md +14 -5
  124. package/.claude/skills/test/SKILL.md +12 -5
  125. package/.claude/skills/test/references/astra.md +12 -5
  126. package/.claude/skills/ui-review/SKILL.md +12 -5
  127. package/.claude/skills/work/SKILL.md +12 -5
  128. package/.claude/skills/work/references/astra.md +17 -5
  129. package/CHANGELOG.md +42 -0
  130. package/CLAUDE.md +2 -0
  131. package/MEMORY.md +55 -0
  132. package/README.md +132 -180
  133. package/THIRD_PARTY_NOTICES.md +15 -0
  134. package/UPGRADING.md +91 -69
  135. package/VERSION +1 -1
  136. package/docs/ARCHITECTURE.md +19 -5
  137. package/docs/BOUNDED-REPAIR.md +11 -0
  138. package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +46 -34
  139. package/docs/NCCGS-1.3.md +10 -122
  140. package/docs/NCCGS-1.4.1.md +149 -0
  141. package/docs/NCCGS-1.4.md +184 -0
  142. package/docs/NCCGS-1.5-IMPLEMENTATION-PLAN.md +378 -0
  143. package/docs/NCCGS-1.5-PILOT.md +130 -0
  144. package/docs/NCCGS-1.5.md +237 -0
  145. package/docs/PHASE-1-OPERATIONS.md +137 -0
  146. package/docs/PHASE-2-OPERATIONS.md +183 -0
  147. package/docs/PROJECT-POLICY.md +21 -9
  148. package/docs/RELEASE-STATUS.md +42 -0
  149. package/docs/WORKFLOWS.md +42 -24
  150. package/package.json +7 -3
  151. package/scaffold/.nccgs/autonomy.json +4 -0
  152. package/scaffold/.nccgs/execution.json +10 -0
  153. package/scaffold/.nccgs/inputs.json +6 -0
  154. package/scaffold/.nccgs/project.yaml +4 -2
  155. package/scaffold/.nccgs/templates/agent-handoff.md +2 -0
  156. package/scaffold/.nccgs/templates/design-contract.json +34 -0
  157. package/scaffold/.nccgs/templates/feature-contract.md +21 -5
  158. package/scaffold/.nccgs/templates/implementation-report.md +4 -1
  159. package/scaffold/.nccgs/templates/repair-packet.json +14 -0
  160. package/scripts/benchmark-snapshot.mjs +205 -0
  161. package/scripts/benchmark-v15.mjs +173 -0
  162. package/scripts/cli.mjs +129 -2
  163. package/scripts/doctor.mjs +128 -22
  164. package/scripts/install.mjs +96 -34
  165. package/scripts/run.mjs +72 -0
  166. package/scripts/supervise.mjs +124 -0
  167. package/scripts/validate.mjs +45 -4
  168. package/tests/autonomy.test.mjs +113 -0
  169. package/tests/benchmark-v15.test.mjs +65 -0
  170. package/tests/design-readiness.test.mjs +200 -0
  171. package/tests/evidence.test.mjs +186 -0
  172. package/tests/framework.test.mjs +333 -16
  173. package/tests/gates.test.mjs +313 -4
  174. package/tests/lifecycle-v15.test.mjs +300 -0
  175. package/tests/orchestration-v15.test.mjs +545 -0
  176. package/tests/phase1.test.mjs +324 -0
  177. package/tests/phase2.test.mjs +250 -0
  178. package/tests/policy-v15.test.mjs +220 -0
  179. package/tests/ponytail-package.test.mjs +75 -0
  180. package/tests/runtime-v15.test.mjs +624 -0
@@ -30,29 +30,59 @@ export function generated(project) {
30
30
  }
31
31
  const primary = pipeline.profiles[pipeline.defaultProfile];
32
32
  for (const agent of studio.agents) {
33
+ const template = fs.readFileSync(path.join(project, ".claude/nccgs/agent-library", `${agent.name}.md`), "utf8");
34
+ const turns = template.match(/^maxTurns: (\d+)\r?$/m);
35
+ if (turns) agent.maxTurns = Number(turns[1]);
33
36
  const route = primary.models[agent.modelClass];
34
37
  if (!route) throw new Error(`Missing route for ${agent.name}`);
35
38
  agent.defaultModel = route.model;
36
39
  agent.defaultEffort = route.effort;
37
40
  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
41
  outputs.set(`.claude/agents/${agent.name}.md`, renderAgent(template, route));
40
42
  }
41
43
  }
42
44
  const { contract } = pipeline;
43
45
  const contractProfile = pipeline.profiles["astra-claude"];
44
- workflows.profileContracts = { "astra-claude": { ...contract,
46
+ const standalone = pipeline.executionPolicies.standalone;
47
+ const standaloneProfile = pipeline.profiles["claude-standalone"];
48
+ workflows.executionContracts = { standalone, compatibility: { ...contract, runtime: "claude-code" } };
49
+ workflows.profileContracts = { "claude-standalone": { ...standalone,
50
+ implementationRoutes: Object.fromEntries(Object.entries(contract.taskTiers).map(([risk, tier]) =>
51
+ [risk, { ...tier, ...standaloneProfile.models[tier.implementationClass] }])),
52
+ reviewModel: standaloneProfile.models.review.model, reviewEffort: standaloneProfile.models.review.effort }, "astra-claude": { ...contract,
45
53
  implementationModel: contractProfile.models.standard.model, implementationEffort: contractProfile.models.standard.effort,
54
+ implementationRoutes: Object.fromEntries(Object.entries(contract.taskTiers).map(([risk, tier]) =>
55
+ [risk, { ...tier, ...contractProfile.models[tier.implementationClass] }])),
46
56
  reviewModel: contractProfile.models.review.model, reviewEffort: contractProfile.models.review.effort } };
47
57
  for (const workflow of workflows.workflows) {
48
58
  delete workflow.ownerByProfile;
49
59
  delete workflow.primaryRolesByProfile;
60
+ workflow.ownerByExecution = { standalone: "Claude Code" };
61
+ if (contract.astraWorkflows.includes(workflow.name)) workflow.ownerByExecution.compatibility = "Astra";
50
62
  if (contract.astraWorkflows.includes(workflow.name)) workflow.ownerByProfile = { "astra-claude": "Astra" };
51
- if (contract.reviewWorkflows.includes(workflow.name)) workflow.primaryRolesByProfile = { "astra-claude": [contract.reviewAgent] };
63
+ if (contract.reviewWorkflows.includes(workflow.name)) workflow.primaryRolesByProfile = { "astra-claude": [contract.reviewAgent], "claude-standalone": [contract.reviewAgent] };
64
+ const relative = `.claude/skills/${workflow.name}/SKILL.md`;
65
+ if (!fs.existsSync(path.join(project, relative))) continue; // Minimal policy fixtures may omit workflow files; validate checks package completeness.
66
+ const wrapper = fs.readFileSync(path.join(project, relative), "utf8").replace(/\r\n/g, "\n");
67
+ const frontmatter = wrapper.match(/^---\n[\s\S]*?\n---/);
68
+ const heading = wrapper.match(/^# .+$/m);
69
+ if (!frontmatter || !heading) throw new Error(`Invalid workflow wrapper: ${relative}`);
70
+ const designRoute = ["design", "design-review", "story-readiness"].includes(workflow.name)
71
+ ? " Also load the [standalone design lifecycle](../../nccgs/protocols/design-lifecycle.md)\n and follow its specific procedure for this workflow.\n" : "";
72
+ outputs.set(relative, `${frontmatter[0]}\n\n${heading[0]}\n\nResolve execution policy from .nccgs/execution.json before model routing. Missing\nexecution.json means compatibility; model profile alone never removes obligations.\n\n- standalone: load the [shared standalone procedure](../../nccgs/protocols/standalone.md)\n for this workflow, using its intake, mode, bounded domain steps and report rules.\n${designRoute} Do not load the compatibility branches or inherit their external approval chain.\n- compatibility: read models.profile from .nccgs/project.yaml and load exactly one:\n astra-claude uses [Astra/Claude procedure](references/astra.md); quality, balanced\n or inherit uses [Legacy procedure](references/legacy.md). Existing task records\n retain their original gates even after project execution policy changes.\n\nUnknown execution policies or model profiles are configuration errors. External\nreview in standalone is off, advisory or required according to execution policy;\nconfigured routes do not prove actual provider model or effort.\n`);
52
73
  }
53
74
  outputs.set(".claude/nccgs/studio.json", `${JSON.stringify(studio, null, 2)}\n`);
54
75
  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`);
76
+ const tierRows = Object.entries(contract.taskTiers).map(([risk, tier]) => {
77
+ const route = standaloneProfile.models[tier.implementationClass];
78
+ return `| ${risk} implementation | ${route.model} | ${route.effort} | ${tier.maxReviewRounds} |`;
79
+ }).join("\n");
80
+ const activeCount = primary.activeAgents === "all" ? studio.agents.length : primary.activeAgents.length;
81
+ const legacyRows = Object.entries(contract.taskTiers).map(([risk, tier]) => {
82
+ const route = contractProfile.models[tier.implementationClass];
83
+ return `| ${risk} | ${route.model} | ${route.effort} |`;
84
+ }).join("\n");
85
+ outputs.set(".claude/nccgs/routing.generated.md", `# Generated routing — edit pipeline.json, then compile-policy.mjs\n\nNCCGS runs in Claude Code. New installations use standalone execution; model aliases describe requested routes, not observed provider versions. Model selection in project.yaml and execution obligations in execution.json are separate policies.\n\n| Stage | Owner/model | Effort | Maximum review rounds |\n|---|---|---|---|\n| Intake, plan and documents | Claude Code | Routed by task | — |\n${tierRows}\n| Independent review | ${standaloneProfile.models.review.model} | ${standaloneProfile.models.review.effort} | Shared task budget |\n| External review | Off by default; advisory or required when configured | External evidence | — |\n| Acceptance | Contract-defined Product Owner decision | Explicit when required | — |\n\nFAST: nccgs-fast-implementer. STANDARD: the relevant domain programmer. CONTROLLED: nccgs-unity-implementer with the affected specialists. Use nccgs-task-scout only for a bounded missing fact; it does not replace review.\n\nA task-wide review budget survives implementation retries and continuation. Exhaustion stops the loop; a reasoned extension is required. Report is available for blocked/partial work and is not approval. Missing runtime effort remains unknown unless supplied by observed evidence.\n\nExisting installations without execution.json retain compatibility execution: Astra plan/document ownership, required external check and Product Owner acceptance. Legacy astra-claude routes remain unchanged:\n\n| Risk | Model | Effort |\n|---|---|---|\n${legacyRows}\n\nLegacy independent review: ${contractProfile.models.review.model}/${contractProfile.models.review.effort}. Updating never converts historical approval into a current approval. Explicit migration preserves tasks and consumed budgets.\n\nDefault active agents: ${activeCount}. Full library: ${studio.agents.length}.\n`);
56
86
  const scaffold = path.join(project, "scaffold/.nccgs/project.yaml");
57
87
  if (fs.existsSync(scaffold)) {
58
88
  const text = fs.readFileSync(scaffold, "utf8");
@@ -1,7 +1,7 @@
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";
4
+ import { atomicJson, hash, json, profilePolicy, contained, resolveExecution } from "./policy.mjs";
5
5
  import { renderAgent } from "./compile-policy.mjs";
6
6
  import { fingerprintFile } from "./integrity.mjs";
7
7
 
@@ -14,6 +14,8 @@ const values = flag => args.flatMap((arg, index) => {
14
14
  const project = path.resolve(values("--project")[0] || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."));
15
15
  const explicit = values("--profile")[0];
16
16
  const { name, profile, pipeline } = profilePolicy(project, explicit);
17
+ // Profile changes never change the execution policy or pending task obligations.
18
+ resolveExecution(project);
17
19
  const studio = json(path.join(project, ".claude/nccgs/studio.json"));
18
20
  const skip = new Set(values("--skip-path").map(p => p.replaceAll("\\", "/")));
19
21
  const activationFile = contained(project, ".nccgs/agent-activation.json");
@@ -31,6 +33,12 @@ const active = new Set(profile.activeAgents === "all" ? studio.agents.map(a => a
31
33
  const settingsFile = contained(project, ".claude/settings.json");
32
34
  const oldSettings = fs.existsSync(settingsFile) ? fs.readFileSync(settingsFile, "utf8") : "";
33
35
  const settings = oldSettings ? JSON.parse(oldSettings) : {};
36
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new Error("Claude settings must be an object");
37
+ const policyFile = contained(project, ".nccgs/project.yaml");
38
+ const oldPolicy = fs.existsSync(policyFile) ? fs.readFileSync(policyFile, "utf8") : "";
39
+ const policyPattern = /(^models:[ \t]*\r?\n(?:[ \t]+[^\n]*\n)*?[ \t]+profile:[ \t]*)[^#\r\n]+/m;
40
+ const legacyPolicy = /^schema_version:\s*1\s*$/m.test(oldPolicy);
41
+ if (explicit && !policyPattern.test(oldPolicy) && !legacyPolicy) throw new Error("Project policy must declare models.profile before reconfiguration");
34
42
  const writes = [], removes = [], warnings = [], generated = {}, generatedCanonical = {};
35
43
  for (const agent of studio.agents) {
36
44
  const relative = `.claude/agents/${agent.name}.md`;
@@ -66,10 +74,8 @@ if (profile.session) {
66
74
  fs.mkdirSync(path.dirname(settingsFile), { recursive: true }); fs.writeFileSync(settingsFile, next, "utf8");
67
75
  }
68
76
  }
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");
77
+ if (explicit && policyPattern.test(oldPolicy)) {
78
+ fs.writeFileSync(policyFile, oldPolicy.replace(policyPattern, `$1${name}`), "utf8");
73
79
  }
74
80
  atomicJson(activationFile, { schemaVersion: 1, profile: name, enabled: [...extras], generated, generatedCanonical, warnings });
75
81
  const installState = contained(project, ".claude/nccgs/install-state.json");
@@ -81,4 +87,6 @@ if (fs.existsSync(installState)) {
81
87
  atomicJson(installState, state);
82
88
  }
83
89
  console.log(`NCCGS model profile '${name}' configured; ${writes.length} agent file(s) changed; ${active.size} selected agents.`);
90
+ console.log("Model configuration records requested aliases/effort only. Runtime resolution requires Claude Code observation; execution policy and existing task approvals are unchanged.");
91
+ if (legacyPolicy && !policyPattern.test(oldPolicy)) console.log("Legacy project policy preserved; model selection is stored in activation/install metadata until explicit policy migration.");
84
92
  for (const warning of warnings) console.warn(warning);
@@ -0,0 +1,121 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { artifact, atomicJson, contained, hash, json, verifyArtifact } from "./policy.mjs";
4
+
5
+ const need = (condition, message) => { if (!condition) throw new Error(message); };
6
+ const text = value => typeof value === "string" && value.trim().length > 0;
7
+ const scalar = value => value === null || typeof value === "boolean" || typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
8
+
9
+ export function validateCriteria(project, criteria) {
10
+ for (const item of criteria) {
11
+ need(item.manual === undefined || typeof item.manual === "boolean", "Criterion manual must be boolean");
12
+ need(item.checkpoint === undefined || ["early", "final"].includes(item.checkpoint), "Criterion checkpoint must be early or final");
13
+ need(!(item.manual && (item.check || item.assertions || item.checkpoint === "early")), "Manual criteria cannot also declare automatic checks/assertions or an early checkpoint");
14
+ if (item.assertions === undefined) continue; // Old check-only criteria remain explicitly UNVERIFIED.
15
+ need(Array.isArray(item.assertions) && item.assertions.length > 0 && item.assertions.length <= 100, "Criterion assertions must be a nonempty bounded array");
16
+ for (const rule of item.assertions) {
17
+ need(rule && text(rule.artifact) && rule.artifact.startsWith(".nccgs/evidence/measurements/") && rule.artifact.endsWith(".json"), "Criterion measurement must be JSON under .nccgs/evidence/measurements/");
18
+ need(rule.artifact.split("/").every(part => part && part !== "." && part !== ".." && !/[:\\]/.test(part) && !/[. ]$/.test(part)), "Criterion measurement paths must be canonical and cannot alias governance files");
19
+ contained(project, rule.artifact);
20
+ need(typeof rule.pointer === "string" && (rule.pointer === "" || rule.pointer.startsWith("/")) && !/~(?![01])/.test(rule.pointer), "Criterion needs a valid JSON pointer");
21
+ need(["eq", "gte", "lte"].includes(rule.op) && Object.hasOwn(rule, "value") && scalar(rule.value), "Criterion assertion needs eq/gte/lte and an explicit scalar value");
22
+ need(rule.op === "eq" || (typeof rule.value === "number" && Number.isFinite(rule.value)), "Numeric criterion thresholds must be finite numbers");
23
+ }
24
+ }
25
+ }
26
+
27
+ function fingerprint(project, relative) {
28
+ const file = contained(project, relative);
29
+ if (!fs.existsSync(file)) return null;
30
+ const stat = fs.statSync(file, { bigint: true });
31
+ need(stat.isFile() && stat.size <= 1024n * 1024n, "Criterion measurement must be a file of at most 1 MiB");
32
+ return { sha256: hash(fs.readFileSync(file)), size: String(stat.size), mtimeNs: String(stat.mtimeNs), ctimeNs: String(stat.ctimeNs) };
33
+ }
34
+ export function measurementBaseline(project, criteria) {
35
+ return Object.fromEntries([...new Set(criteria.flatMap(c => (c.assertions ?? []).map(r => r.artifact)))].map(relative => [relative, fingerprint(project, relative)]));
36
+ }
37
+ function readPointer(value, pointer) {
38
+ for (const part of pointer === "" ? [] : pointer.slice(1).split("/").map(s => s.replaceAll("~1", "/").replaceAll("~0", "~"))) {
39
+ if (value === null || typeof value !== "object" || !Object.hasOwn(value, part)) return { found: false };
40
+ value = value[part];
41
+ }
42
+ return { found: true, value };
43
+ }
44
+ function evaluate(rule, data) {
45
+ const actual = readPointer(data, rule.pointer);
46
+ if (!actual.found || !scalar(actual.value)) return { status: "UNVERIFIED", reason: "Missing or non-scalar measurement", pointer: rule.pointer };
47
+ if (rule.op !== "eq" && typeof actual.value !== "number") return { status: "UNVERIFIED", reason: "Numeric measurement has the wrong type", pointer: rule.pointer };
48
+ const pass = rule.op === "eq" ? actual.value === rule.value : rule.op === "gte" ? actual.value >= rule.value : actual.value <= rule.value;
49
+ return { status: pass ? "PASS" : "FAIL", pointer: rule.pointer, actual: actual.value, op: rule.op, expected: rule.value };
50
+ }
51
+ const overall = items => items.some(i => i.status === "FAIL") ? "FAIL" : items.some(i => i.status !== "PASS") ? "UNVERIFIED" : "PASS";
52
+
53
+ // Capture measurement bytes once, at command completion, independently of exit 0.
54
+ export function captureCriteria(project, job, technicalStatus) {
55
+ const outputs = new Map();
56
+ const criteria = (job.criteria ?? []).map(criterion => {
57
+ const checks = (criterion.assertions ?? []).map(rule => {
58
+ if (!outputs.has(rule.artifact)) {
59
+ try {
60
+ const after = fingerprint(project, rule.artifact);
61
+ need(after && JSON.stringify(after) !== JSON.stringify(job.measurementBaseline?.[rule.artifact] ?? null), "Measurement missing or unchanged since command launch");
62
+ const bytes = fs.readFileSync(contained(project, rule.artifact));
63
+ need(hash(bytes) === after.sha256, "Measurement changed while capturing");
64
+ const data = JSON.parse(bytes.toString("utf8"));
65
+ const relative = `.nccgs/evidence/criteria/${job.id}/${outputs.size}.json`;
66
+ atomicJson(contained(project, relative), data);
67
+ outputs.set(rule.artifact, { data, evidence: artifact(project, relative) });
68
+ } catch (error) { outputs.set(rule.artifact, { error: error.message }); }
69
+ }
70
+ const output = outputs.get(rule.artifact);
71
+ return output.error ? { status: "UNVERIFIED", reason: output.error, artifact: rule.artifact }
72
+ : { ...evaluate(rule, output.data), artifact: rule.artifact, evidence: output.evidence };
73
+ });
74
+ const status = technicalStatus !== "PASS" || checks.length === 0 ? "UNVERIFIED" : overall(checks);
75
+ return { id: criterion.id, status, checks, ...(checks.length ? {} : { reason: "Criterion has no measurable assertions" }) };
76
+ });
77
+ return { schemaVersion: 1, criteria };
78
+ }
79
+
80
+ // Read-only assessment: latest attempts win, even failures/pending attempts.
81
+ export function assessCriteria(project, task, snap, { earlyOnly = false } = {}) {
82
+ const directory = contained(project, ".nccgs/jobs");
83
+ const jobs = fs.existsSync(directory) ? fs.readdirSync(directory, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => {
84
+ const request = json(path.join(directory, e.name, "request.json"));
85
+ need(request.id === e.name && request.taskId, "Invalid criterion job identity");
86
+ return request;
87
+ }).filter(j => j.taskId === task.id) : [];
88
+ const criteria = (task.contract.acceptance ?? []).filter(c => !earlyOnly || c.checkpoint === "early").map(c => {
89
+ const base = { id: c.id, description: c.description, check: c.check ?? null, checkpoint: c.checkpoint ?? "final" };
90
+ if (c.manual) return { ...base, status: "MANUAL_PENDING", reason: "Requires separate Product Owner gate" };
91
+ const candidates = jobs.filter(j => j.name === c.check).sort((a, b) => Date.parse(b.queuedAt) - Date.parse(a.queuedAt));
92
+ const job = candidates[0];
93
+ let receiptRef = null;
94
+ try {
95
+ need(c.assertions?.length, "Check name alone does not prove the acceptance criterion; declare assertions");
96
+ need(job, "No verification attempt for criterion");
97
+ need(!candidates[1] || candidates[1].queuedAt !== job.queuedAt, "Ambiguous concurrent verification attempts");
98
+ need(job.snapshot === snap && job.contractHash === task.contractHash, "Criterion evidence is stale for source/contract");
99
+ const result = json(path.join(directory, job.id, "result.json"));
100
+ const receipt = json(verifyArtifact(project, result.evidence));
101
+ need(receipt.taskId === task.id && receipt.jobId === job.id && receipt.name === c.check && receipt.snapshot === snap && receipt.contractHash === task.contractHash, "Criterion receipt identity mismatch");
102
+ need(Date.parse(receipt.startedAt) >= Date.parse(task.canonicalInputs.pinnedAt), "Criterion evidence predates current input pin");
103
+ receiptRef = result.evidence;
104
+ need(receipt.status === "PASS" && receipt.exitCode === 0 && receipt.sourceUnchanged && !receipt.timedOut, "Technical verification failed or is incomplete");
105
+ verifyArtifact(project, receipt.log);
106
+ const captured = receipt.acceptance?.criteria?.find(x => x.id === c.id);
107
+ need(captured && captured.checks.length === c.assertions.length, "Receipt does not capture every criterion assertion");
108
+ const checks = c.assertions.map((rule, index) => {
109
+ const observed = captured.checks[index];
110
+ need(observed.artifact === rule.artifact, "Captured measurement mapping mismatch");
111
+ if (!observed.evidence) return { status: "UNVERIFIED", reason: observed.reason || "Missing captured measurement" };
112
+ return evaluate(rule, json(verifyArtifact(project, observed.evidence)));
113
+ });
114
+ return { ...base, status: overall(checks), checks, receipt: result.evidence, jobId: job.id };
115
+ } catch (error) { return { ...base, status: "UNVERIFIED", reason: error.message, jobId: job?.id ?? null, ...(receiptRef ? { receipt: receiptRef } : {}) }; }
116
+ });
117
+ const automatic = criteria.filter(c => c.status !== "MANUAL_PENDING");
118
+ return { schemaVersion: 1, taskId: task.id, snapshot: snap, contractHash: task.contractHash, criteria,
119
+ automaticPassed: automatic.every(c => c.status === "PASS"), manualPending: criteria.filter(c => c.status === "MANUAL_PENDING").map(c => c.id),
120
+ status: overall(automatic), scope: "Measured criteria only; technical PASS and human acceptance are separate" };
121
+ }
@@ -0,0 +1,52 @@
1
+ import { verifyArtifact } from "./policy.mjs";
2
+ import { assessCriteria } from "./criteria.mjs";
3
+
4
+ const need = (condition, message) => { if (!condition) throw new Error(message); };
5
+ export function deliveryBudget(entry, items) {
6
+ if (!entry?.deliveryReserve) return null;
7
+ const remaining = Object.fromEntries(Object.entries(entry.deliveryReserve).map(([purpose, count]) =>
8
+ [purpose, Math.max(0, count - items.filter(d => (d.purpose ?? (d.kind === "review" ? "review" : "delivery")) === purpose).length)]));
9
+ const protectedSlots = Object.values(remaining).reduce((a, b) => a + b, 0);
10
+ return { reserved: entry.deliveryReserve, remaining, protectedSlots,
11
+ totalRemaining: Math.max(0, entry.limits.maxDispatches - items.length),
12
+ deliveryRemaining: Math.max(0, entry.limits.maxDispatches - items.length - protectedSlots),
13
+ note: "Attempts consume quota even on failure/cancellation; protected slots are not extra budget" };
14
+ }
15
+ export function requireDeliveryRoom(entry, items, purpose, taskId) {
16
+ const next = deliveryBudget(entry, [...items, { purpose }]);
17
+ need(!next || items.length + 1 + next.protectedSlots <= entry.limits.maxDispatches,
18
+ `Delivery budget reserved for ${taskId}: repair, QA and independent review; stop expansion and report partial or use an eligible protected purpose`);
19
+ }
20
+ export function dispatchPurpose(project, task, request, snap) {
21
+ const purpose = request.purpose ?? (request.kind === "review" ? "review" : "delivery");
22
+ need(["delivery", "repair", "qa", "review"].includes(purpose), "Invalid dispatch purpose");
23
+ need((purpose === "review") === (request.kind === "review"), "Review dispatch requires review purpose");
24
+ need(purpose !== "repair" || request.kind === "implementation", "Repair purpose requires an implementation worker");
25
+ need(purpose !== "qa" || (request.kind === "analysis" && ["nccgs-qa-engineer", "nccgs-verification-engineer"].includes(request.agent)), "QA purpose requires a read-only QA/verification worker");
26
+ need(purpose !== "qa" || request.repairFeedback, "Protected QA is reserved for a completed repair; initial QA uses delivery purpose");
27
+ let feedback = null;
28
+ if (["repair", "qa"].includes(purpose)) {
29
+ const reviews = [task.stages?.review, ...(task.history ?? []).slice().reverse().map(h => h.stages?.review)].filter(Boolean);
30
+ const latest = reviews.find(r => r.contractHash === task.contractHash);
31
+ const negative = latest && ["CHANGES_REQUIRED", "BLOCKED"].includes(latest.verdict) ? latest : null;
32
+ if (negative) { verifyArtifact(project, negative.evidence); feedback = { kind: "review", evidence: negative.evidence, verdict: negative.verdict }; }
33
+ else {
34
+ const assessment = assessCriteria(project, task, snap);
35
+ const failed = assessment.criteria.filter(c => ["FAIL", "UNVERIFIED"].includes(c.status) && c.receipt);
36
+ if (failed.length) feedback = { kind: "criteria", criteria: failed.map(c => ({ id: c.id, status: c.status, receipt: c.receipt })) };
37
+ }
38
+ // QA must also be available after a repair changes source and invalidates
39
+ // the failing measurements. The caller supplies the pinned repair history.
40
+ if (!feedback && purpose === "qa" && request.repairFeedback) feedback = request.repairFeedback;
41
+ need(feedback, "Protected repair/QA requires recorded negative review or measured acceptance failure; missing initial evidence alone is not feedback");
42
+ }
43
+ return { purpose, feedback };
44
+ }
45
+ export function requireEarlyCheckpoint(project, task, entry, items, purpose, kind, snap) {
46
+ const early = (task.contract.acceptance ?? []).filter(c => c.checkpoint === "early");
47
+ if (kind !== "implementation" || purpose !== "delivery" || !early.length || !items.some(d => d.kind === "implementation" && (d.status === "completed" || d.runtime))) return;
48
+ if (entry.earlyCheckpoint?.contractHash === task.contractHash) return;
49
+ const assessment = assessCriteria(project, task, snap, { earlyOnly: true });
50
+ need(assessment.automaticPassed, `Early acceptance checkpoint blocks expansion: ${assessment.criteria.filter(c => c.status !== "PASS").map(c => `${c.id}=${c.status}`).join(", ")}; collect a playable-slice measurement or report partial`);
51
+ entry.earlyCheckpoint = { contractHash: task.contractHash, snapshot: snap, capturedAt: new Date().toISOString(), assessment };
52
+ }
@@ -0,0 +1,158 @@
1
+ import crypto from "node:crypto";
2
+ import { artifact, atomicJson, contained, json, resolveExecution, verifyArtifact } from "./policy.mjs";
3
+ import { check, taskPath } from "./gates.mjs";
4
+ import { validateCriteria } from "./criteria.mjs";
5
+
6
+ const need = (ok, message) => { if (!ok) throw new Error(`Design readiness: ${message}`); };
7
+ const text = value => typeof value === "string" && value.trim().length > 0;
8
+ const list = (value, label) => { need(Array.isArray(value), `${label} must be an array`); return value; };
9
+ const strings = (value, label) => { list(value, label); need(value.every(text) && new Set(value).size === value.length, `${label} must contain unique nonempty strings`); return value; };
10
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === "object"
11
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
12
+ export const sameDesignValue = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
13
+
14
+ // Structural coverage is enforceable; the independent reviewer owns semantic adequacy.
15
+ export function validateDesign(project, document) {
16
+ need(document?.schemaVersion === 1 && text(document.objective), "document needs schemaVersion 1 and objective");
17
+ need(["READY", "READY_WITH_RISKS", "BLOCKED"].includes(document.readiness), "invalid document readiness");
18
+ need(["local", "feature", "cross-system"].includes(document.scope), "invalid scope");
19
+ need(["fix", "learning-prototype", "alpha-ready", "production-change"].includes(document.target), "invalid target");
20
+ const ready = document.readiness !== "BLOCKED";
21
+ const sources = list(document.sources, "sources");
22
+ need(sources.length > 0, "at least one hashed canonical source is required");
23
+ for (const source of sources) verifyArtifact(project, source);
24
+ need(new Set(sources.map(s => s.path)).size === sources.length, "duplicate source path");
25
+ const citation = (value, label) => need(value && text(value.anchor) && sources.some(s => s.path === value.path && s.sha256 === value.sha256), `${label} needs a pinned source and section/excerpt anchor`);
26
+ const criteria = list(document.acceptance, "acceptance"), ids = new Set();
27
+ for (const criterion of criteria) {
28
+ need(text(criterion.id) && !ids.has(criterion.id) && text(criterion.description), "criterion needs unique id and description"); ids.add(criterion.id);
29
+ for (const field of ["procedure", "environment", "passCondition", "rationale"]) need(text(criterion[field]), `${criterion.id} needs ${field}`);
30
+ citation(criterion.authority, `${criterion.id} threshold authority`);
31
+ need(criterion.manual === true || (text(criterion.check) && criterion.assertions?.length > 0), `${criterion.id} needs measured assertions or an explicit human gate`);
32
+ }
33
+ validateCriteria(project, criteria);
34
+ const requirements = list(document.requirements, "requirements"), reqIds = new Set(), covered = new Set();
35
+ need(requirements.length > 0, "requirements are missing");
36
+ for (const requirement of requirements) {
37
+ need(text(requirement.id) && !reqIds.has(requirement.id), "requirement id is missing or duplicated"); reqIds.add(requirement.id);
38
+ citation(requirement.source, `${requirement.id} source`);
39
+ need(text(requirement.statement) && text(requirement.rules), `${requirement.id} needs statement and explicit rules/states/edge cases`);
40
+ need(["fixed", "delegated-tuning"].includes(requirement.kind), `${requirement.id} must distinguish fixed rules and delegated tuning`);
41
+ if (requirement.kind === "delegated-tuning") {
42
+ need(text(requirement.tuning?.bounds) && text(requirement.tuning?.parameters), `${requirement.id} needs tuning parameters and bounds`);
43
+ citation(requirement.tuning?.authority, `${requirement.id} tuning authority`);
44
+ }
45
+ strings(requirement.criteria, `${requirement.id} criteria`);
46
+ need(!ready || requirement.criteria.length > 0, `${requirement.id} has no acceptance mapping`);
47
+ for (const id of requirement.criteria) { need(ids.has(id), `${requirement.id} references unknown criterion ${id}`); covered.add(id); }
48
+ }
49
+ need(criteria.every(c => covered.has(c.id)), "an acceptance criterion has no requirement source");
50
+ const decisions = list(document.decisions, "decisions"), decisionIds = new Set();
51
+ for (const decision of decisions) {
52
+ need(text(decision.id) && !decisionIds.has(decision.id) && text(decision.question), "decision needs unique id and question"); decisionIds.add(decision.id);
53
+ need(["open", "resolved"].includes(decision.status), "invalid decision status");
54
+ if (decision.status === "resolved") { need(text(decision.resolution), "resolved decision needs resolution"); citation(decision.authority, `${decision.id} decision authority`); }
55
+ need(!ready || decision.status === "resolved", "open product decisions cannot be READY");
56
+ }
57
+ strings(document.requiredChecks, "requiredChecks"); strings(document.manualGates, "manualGates");
58
+ for (const criterion of criteria) need(criterion.manual ? document.manualGates.includes(criterion.id) : document.requiredChecks.includes(criterion.check), `${criterion.id} is absent from required checks/manual gates`);
59
+ strings(document.outOfScope, "outOfScope"); strings(document.risks, "risks");
60
+ for (const diagnostic of list(document.diagnostics, "diagnostics")) {
61
+ need(text(diagnostic.id) && text(diagnostic.procedure) && text(diagnostic.limitations), "diagnostics need id, procedure and limitations");
62
+ need(!ids.has(diagnostic.id), "a diagnostic cannot silently double as an acceptance criterion");
63
+ }
64
+ return document;
65
+ }
66
+
67
+ export function validateDesignCommand(project, id, relative) {
68
+ const task = json(taskPath(project, id));
69
+ need(task.schemaVersion === 2 && task.mode === "plan" && task.contract.workflow === "design", "design-validate requires a standalone /design plan task");
70
+ need(task.contract.analysisArtifacts.includes(relative), "document must be a declared analysis artifact");
71
+ need(task.contract.requiredChecks.includes("design-contract"), "plan must require the design-contract check");
72
+ const state = check(project, task, "analysis"); need(state.allowed, state.message);
73
+ const reference = artifact(project, relative), document = validateDesign(project, json(verifyArtifact(project, reference)));
74
+ const receipt = { schemaVersion: 1, origin: "design-contract-validation", taskId: id, contractHash: task.contractHash,
75
+ snapshot: state.snapshot, status: "PASS", name: "design-contract", document: reference,
76
+ readiness: document.readiness, capturedAt: new Date().toISOString(), scope: "Structural document validation only; independent semantic review is still required" };
77
+ const output = `.nccgs/evidence/design-checks/${crypto.randomUUID()}.json`;
78
+ atomicJson(contained(project, output), receipt);
79
+ return { ...receipt, evidence: artifact(project, output) };
80
+ }
81
+
82
+ export function verifyDesignReceipt(project, task, stage, receipt) {
83
+ need(receipt.origin === "design-contract-validation" && receipt.status === "PASS" && receipt.name === "design-contract"
84
+ && receipt.taskId === task.id && receipt.contractHash === task.contractHash && receipt.snapshot === stage.snapshot, "validation receipt is stale or belongs to another task");
85
+ need(sameDesignValue(receipt.document, stage.evidence), "validated document differs from the analysis handoff");
86
+ return validateDesign(project, json(verifyArtifact(project, receipt.document)));
87
+ }
88
+
89
+ // Fixed read-only comparison for plan coordinators; no arbitrary shell or code.
90
+ // Allowed changes remain a semantic scope decision for the independent reviewer.
91
+ export function compareDesignCommand(project, id, baselinePath, candidatePath) {
92
+ const task = json(taskPath(project, id));
93
+ need(task.schemaVersion === 2 && task.mode === "plan" && task.contract.workflow === "design", "design-compare requires a standalone /design plan task");
94
+ need(task.contract.analysisArtifacts.includes(candidatePath), "candidate must be a declared analysis artifact");
95
+ const baseline = task.contract.sources.find(ref => ref.path === baselinePath);
96
+ need(baseline && baselinePath !== candidatePath, "baseline must be a distinct pinned intake source");
97
+ const state = check(project, task, "analysis"); need(state.allowed, state.message);
98
+ const candidate = artifact(project, candidatePath);
99
+ const before = json(verifyArtifact(project, baseline)), after = json(verifyArtifact(project, candidate));
100
+ const changes = key => {
101
+ const index = document => {
102
+ const entries = list(document[key], key), ids = new Set();
103
+ for (const item of entries) { need(text(item.id) && !ids.has(item.id), `${key} needs unique ids`); ids.add(item.id); }
104
+ return new Map(entries.map(item => [item.id, item]));
105
+ };
106
+ const a = index(before), b = index(after);
107
+ return { added: [...b.keys()].filter(id => !a.has(id)), removed: [...a.keys()].filter(id => !b.has(id)),
108
+ changed: [...a.keys()].filter(id => b.has(id) && !sameDesignValue(a.get(id), b.get(id))),
109
+ orderChanged: !sameDesignValue([...a.keys()], [...b.keys()]) };
110
+ };
111
+ const otherKeys = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(key => !["requirements", "acceptance"].includes(key));
112
+ const receipt = { schemaVersion: 1, origin: "design-contract-comparison", taskId: id, contractHash: task.contractHash,
113
+ snapshot: state.snapshot, baseline, candidate, capturedAt: new Date().toISOString(),
114
+ requirements: changes("requirements"), acceptance: changes("acceptance"),
115
+ otherTopLevelChanges: otherKeys.filter(key => !sameDesignValue(before[key], after[key])),
116
+ scope: "JSON value comparison only; not semantic review, allowed-change approval or game acceptance" };
117
+ // Recheck the bytes after comparing so the receipt never pins a raced read.
118
+ verifyArtifact(project, baseline); verifyArtifact(project, candidate);
119
+ const output = `.nccgs/evidence/design-checks/${crypto.randomUUID()}.json`;
120
+ atomicJson(contained(project, output), receipt);
121
+ return { ...receipt, evidence: artifact(project, output) };
122
+ }
123
+
124
+ export function pinDesign(project, designId, consumerId) {
125
+ need(text(designId) && designId !== consumerId, "use a distinct completed design task");
126
+ const taskRef = artifact(project, `.nccgs/tasks/${designId}.json`), task = json(verifyArtifact(project, taskRef));
127
+ need(task.id === designId && task.mode === "plan" && task.contract.workflow === "design"
128
+ && task.closed?.snapshot && task.closed.snapshot === task.stages.review?.snapshot, "design task must be a completed /design plan");
129
+ const gate = check(project, task, "done"); need(gate.allowed, gate.message);
130
+ const analysis = task.stages.analysis, review = task.stages.review;
131
+ const document = validateDesign(project, json(verifyArtifact(project, analysis.evidence)));
132
+ need(document.readiness !== "BLOCKED", "design is BLOCKED");
133
+ const checked = analysis.checks.find(c => c.name === "design-contract"); need(checked, "design validation receipt missing");
134
+ verifyDesignReceipt(project, task, analysis, json(verifyArtifact(project, checked.evidence)));
135
+ const references = [taskRef, task.intake, analysis.evidence, checked.evidence, review.evidence, ...document.sources];
136
+ for (const stage of [analysis, review]) {
137
+ references.push(stage.runtime);
138
+ const runtime = json(verifyArtifact(project, stage.runtime)); references.push(runtime.observationSource);
139
+ }
140
+ return { schemaVersion: 1, taskId: designId, document: analysis.evidence, references,
141
+ reviewedSnapshot: review.snapshot, pinnedAt: new Date().toISOString(), specification: document };
142
+ }
143
+
144
+ export function requireDesignReady(project, task) {
145
+ if (task.schemaVersion !== 2 || task.mode !== "implement") return;
146
+ const pinned = task.contract.design;
147
+ const required = (task.execution ?? resolveExecution(project)).designReviewRequired === true;
148
+ if (!required && !pinned) return; // Historical policies retain their original obligations.
149
+ need(task.contract.readiness === "ready", "implementation still needs inspection or a product decision");
150
+ const localFix = task.contract.scope === "local" && task.contract.target === "fix" && task.contract.acceptance.length > 0;
151
+ if (!pinned && localFix) return; // Explicit, testable local fix; no new feature semantics.
152
+ need(pinned?.schemaVersion === 1, "reviewed design missing; complete /design -> /design-review -> /story-readiness before implementation");
153
+ for (const ref of pinned.references) verifyArtifact(project, ref);
154
+ const document = validateDesign(project, json(verifyArtifact(project, pinned.document)));
155
+ need(document.readiness !== "BLOCKED", "design is BLOCKED");
156
+ for (const key of ["objective", "scope", "target", "acceptance"]) need(sameDesignValue(task.contract[key], document[key]), `implementation ${key} differs from reviewed design`);
157
+ for (const key of ["requiredChecks", "manualGates"]) need(document[key].every(item => task.contract[key].includes(item)), `implementation omits reviewed ${key}`);
158
+ }
@@ -0,0 +1,153 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { artifact, contained, hash, verifyArtifact } from "./policy.mjs";
4
+
5
+ const durableRoot = ".nccgs/evidence/runtime";
6
+ const legacyRoot = ".nccgs/runtime/";
7
+ const isRef = value => value && typeof value === "object" && typeof value.path === "string" && typeof value.sha256 === "string";
8
+ const encode = value => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
9
+
10
+ function readReference(project, ref, label) {
11
+ try {
12
+ const file = verifyArtifact(project, ref);
13
+ const bytes = fs.readFileSync(file);
14
+ if (hash(bytes) !== ref.sha256) throw new Error("Evidence changed while being copied");
15
+ return bytes;
16
+ } catch (error) {
17
+ throw new Error(`${label}: missing, stale, or unsafe evidence '${ref?.path ?? "unknown"}': ${error.message}`);
18
+ }
19
+ }
20
+
21
+ // Content-addressed copies retain the original bytes. Plan every copy before any
22
+ // writes so a missing legacy source cannot produce a partly migrated task.
23
+ function copyPlan(project) {
24
+ const writes = new Map();
25
+ const migrations = [];
26
+ const seenMigrations = new Set();
27
+ const add = (kind, bytes) => {
28
+ const sha256 = hash(bytes);
29
+ const relative = `${durableRoot}/${kind}/${sha256}.json`;
30
+ const file = contained(project, relative);
31
+ if (fs.existsSync(file)) {
32
+ if (!fs.statSync(file).isFile() || hash(fs.readFileSync(file)) !== sha256) {
33
+ throw new Error(`Durable evidence collision or corruption: ${relative}`);
34
+ }
35
+ } else writes.set(relative, bytes);
36
+ return { path: relative, sha256 };
37
+ };
38
+ const note = (from, to, kind) => {
39
+ if (from.path === to.path && from.sha256 === to.sha256) return;
40
+ const key = `${kind}:${from.path}:${from.sha256}:${to.path}`;
41
+ if (!seenMigrations.has(key)) {
42
+ migrations.push({ kind, from: { ...from }, to: { ...to } });
43
+ seenMigrations.add(key);
44
+ }
45
+ };
46
+ const source = ref => {
47
+ const bytes = readReference(project, ref, "Runtime source");
48
+ if (ref.path.startsWith(`${durableRoot}/sources/`)) return { ...ref };
49
+ const durable = add("sources", bytes);
50
+ note(ref, durable, "source");
51
+ return durable;
52
+ };
53
+ const observation = (value, sourceRef) => {
54
+ const durableSource = source(sourceRef);
55
+ const normalized = { ...value, observationSource: durableSource };
56
+ if (sourceRef.path !== durableSource.path || sourceRef.sha256 !== durableSource.sha256) {
57
+ normalized.originalObservationSource = value.originalObservationSource ?? { ...sourceRef };
58
+ }
59
+ return add("observations", encode(normalized));
60
+ };
61
+ const commit = () => {
62
+ // Recheck destinations before writing; never overwrite an existing artifact.
63
+ for (const [relative, bytes] of writes) {
64
+ const file = contained(project, relative);
65
+ if (fs.existsSync(file) && (!fs.statSync(file).isFile() || hash(fs.readFileSync(file)) !== hash(bytes))) {
66
+ throw new Error(`Durable evidence collision or corruption: ${relative}`);
67
+ }
68
+ }
69
+ for (const [relative, bytes] of writes) {
70
+ const file = contained(project, relative);
71
+ fs.mkdirSync(path.dirname(file), { recursive: true });
72
+ try { fs.writeFileSync(file, bytes, { flag: "wx" }); }
73
+ catch (error) { if (error.code !== "EEXIST") throw error; }
74
+ if (artifact(project, relative).sha256 !== hash(bytes)) {
75
+ throw new Error(`Durable evidence copy failed verification: ${relative}`);
76
+ }
77
+ }
78
+ };
79
+ return { add, source, observation, note, migrations, commit };
80
+ }
81
+
82
+ /** Persist an observation and an exact durable copy of its raw source.
83
+ * Callers retain responsibility for validating runtime identity and provenance.
84
+ */
85
+ export function persistRuntimeObservation(project, observation, sourceRef) {
86
+ if (!observation || typeof observation !== "object" || Array.isArray(observation)) {
87
+ throw new Error("Runtime observation must be an object");
88
+ }
89
+ const plan = copyPlan(project);
90
+ const result = plan.observation(observation, sourceRef);
91
+ plan.commit();
92
+ return result;
93
+ }
94
+
95
+ /** Return a migrated clone; the caller owns task locking and atomic task writes.
96
+ * No source, stage, history entry, review counter, or original artifact is deleted.
97
+ * This does not refresh snapshots, evidence times, approvals, or task validity.
98
+ */
99
+ export function migrateTaskEvidence(project, task) {
100
+ const plan = copyPlan(project);
101
+ const cache = new Map();
102
+ const migrateReference = ref => {
103
+ const key = `${ref.path}:${ref.sha256}`;
104
+ if (cache.has(key)) return { ...ref, ...cache.get(key) };
105
+ const bytes = readReference(project, ref, "Runtime observation");
106
+ let value;
107
+ try { value = JSON.parse(bytes.toString("utf8")); }
108
+ catch {
109
+ if (ref.path.includes("/observations/")) throw new Error(`Invalid runtime observation JSON: ${ref.path}`);
110
+ }
111
+ let result = ref;
112
+ if (value?.origin === "runtime-observation") {
113
+ if (!isRef(value.observationSource)) throw new Error(`Runtime observation has no verifiable raw source: ${ref.path}`);
114
+ const sourceRef = value.observationSource;
115
+ const source = plan.source(sourceRef);
116
+ const needsCopy = !ref.path.startsWith(`${durableRoot}/observations/`) || source.path !== sourceRef.path;
117
+ if (needsCopy) {
118
+ const archive = plan.add("sources", bytes);
119
+ plan.note(ref, archive, "observation-archive");
120
+ const migrated = { ...value, observationSource: source,
121
+ originalObservationSource: value.originalObservationSource ?? { ...sourceRef },
122
+ evidenceMigration: { originalObservation: { ...ref }, archivedObservation: archive,
123
+ ...(value.evidenceMigration ? { previous: value.evidenceMigration } : {}) } };
124
+ result = plan.add("observations", encode(migrated));
125
+ plan.note(ref, result, "observation");
126
+ }
127
+ } else if (ref.path.startsWith(legacyRoot)) {
128
+ if (ref.path.includes("/observations/")) throw new Error(`Invalid runtime observation origin: ${ref.path}`);
129
+ result = plan.source(ref);
130
+ }
131
+ cache.set(key, result);
132
+ return { ...ref, ...result };
133
+ };
134
+ const walk = (value, key = "") => {
135
+ if (Array.isArray(value)) return value.map(item => walk(item, key));
136
+ if (!value || typeof value !== "object") return value;
137
+ // Original paths in our audit records describe where bytes came from; they
138
+ // are not live dependencies after a clean copy removes the ignored runtime.
139
+ if (value.action === "migrate-evidence" ||
140
+ (["source", "observation", "observation-archive"].includes(value.kind) && isRef(value.from) && isRef(value.to))) {
141
+ return structuredClone(value);
142
+ }
143
+ if (isRef(value)) {
144
+ // Do not revalidate unrelated old plan/report refs when migrating history.
145
+ if (key === "runtime" || value.path.startsWith(legacyRoot)) return migrateReference(value);
146
+ return { ...value };
147
+ }
148
+ return Object.fromEntries(Object.entries(value).map(([name, child]) => [name, walk(child, name)]));
149
+ };
150
+ const migrated = walk(task);
151
+ plan.commit();
152
+ return { task: migrated, migrations: plan.migrations, changed: plan.migrations.length > 0 };
153
+ }