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
@@ -1,6 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { hash, json, contained, artifact, verifyArtifact, atomicJson, loadPipeline } from "./policy.mjs";
3
+ import { performance } from "node:perf_hooks";
4
+ import { hash, json, contained, artifact, verifyArtifact, atomicJson, loadPipeline, resolveExecution } from "./policy.mjs";
5
+ import { canonicalInputs, pinInputs, inputPolicy, isOutput } from "./inputs.mjs";
6
+ import { migrateTaskEvidence } from "./evidence.mjs";
7
+ import { acquireLock } from "./process-lock.mjs";
8
+ import { initializeStandalone, checkStandalone, recordStandalone, extendStandalone, refreshStandalone,
9
+ reclassifyStandalone, exportExternalStandalone, importExternalStandalone } from "./lifecycle.mjs";
4
10
 
5
11
  export const stages = ["implementation", "review", "report", "astra", "acceptance"];
6
12
  function requireValue(condition, message) { if (!condition) throw new Error(message); }
@@ -8,30 +14,67 @@ export function taskPath(project, id) {
8
14
  requireValue(typeof id === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(id), "Invalid task ID");
9
15
  return contained(project, `.nccgs/tasks/${id}.json`);
10
16
  }
11
- export function snapshot(project) {
17
+ export function snapshot(project, task = null) {
18
+ const started = performance.now();
12
19
  const excluded = new Set(loadPipeline(project).snapshotExcludes);
20
+ const policy = inputPolicy(project);
21
+ const inputs = canonicalInputs(project, policy);
13
22
  const files = [];
23
+ let bytes = 0, readHashMs = 0;
14
24
  function walk(relative = "") {
15
25
  for (const entry of fs.readdirSync(path.join(project, relative), { withFileTypes: true })) {
16
26
  if (!relative && excluded.has(entry.name)) continue;
17
27
  const name = relative ? `${relative}/${entry.name}` : entry.name;
18
28
  const file = contained(project, name);
29
+ if (isOutput(name, policy)) continue;
19
30
  if (entry.isDirectory()) walk(name);
20
- else if (entry.isFile()) files.push([name, hash(fs.readFileSync(file))]);
31
+ else if (entry.isFile()) {
32
+ const readStarted = performance.now();
33
+ const data = fs.readFileSync(file);
34
+ files.push([name, hash(data)]);
35
+ bytes += data.length;
36
+ readHashMs += performance.now() - readStarted;
37
+ }
21
38
  else throw new Error(`Unsupported snapshot entry: ${name}`);
22
39
  }
23
40
  }
24
41
  walk();
25
42
  files.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
26
- return { sha256: hash(JSON.stringify(files)), files: files.length };
43
+ return { sha256: hash(JSON.stringify({ files, canonicalInputs: inputs.sha256 })), sourceSha256: hash(JSON.stringify(files)), files: files.length,
44
+ bytes, canonicalInputSha256: inputs.sha256, canonicalInputFiles: inputs.entries.filter(e => e.kind === "file").length,
45
+ canonicalInputBytes: inputs.bytes, readHashMs: readHashMs + inputs.readHashMs, durationMs: performance.now() - started };
46
+ }
47
+ function tierRoute(pipeline, risk) {
48
+ const tier = pipeline.contract.taskTiers?.[risk];
49
+ requireValue(tier, "Task risk must be FAST, STANDARD or CONTROLLED; existing tasks without a risk tier must be recreated and evidence recorded again");
50
+ const route = pipeline.profiles["astra-claude"].models[tier.implementationClass];
51
+ requireValue(route && Number.isSafeInteger(tier.maxReviewRounds) && tier.maxReviewRounds > 0, "Invalid task tier policy");
52
+ return { tier, route: { class: tier.implementationClass, model: route.model, effort: route.effort } };
53
+ }
54
+ function taskRouting(pipeline, task) {
55
+ const { tier, route } = tierRoute(pipeline, task.risk);
56
+ requireValue(task.implementationRoute && ["class", "model", "effort"].every(key => task.implementationRoute[key] === route[key]), "Task implementation route missing or changed; create a new task against the current policy");
57
+ const budget = task.reviewBudget;
58
+ requireValue(budget && Number.isSafeInteger(budget.used) && budget.used >= 0 && Number.isSafeInteger(budget.limit) && Array.isArray(budget.extensions), "Task review budget missing or invalid; create a new task and record evidence again");
59
+ let limit = tier.maxReviewRounds;
60
+ for (const extension of budget.extensions) {
61
+ requireValue(Number.isSafeInteger(extension.rounds) && extension.rounds > 0 && extension.rounds <= tier.maxReviewRounds && extension.reason?.trim() && Number.isFinite(Date.parse(extension.at)) && extension.previousLimit === limit && extension.newLimit === limit + extension.rounds, "Invalid review budget extension audit");
62
+ limit = extension.newLimit;
63
+ }
64
+ requireValue(budget.limit === limit && budget.used <= limit, "Review budget does not match its extension audit");
65
+ return { tier, route, budget: { used: budget.used, limit, remaining: limit - budget.used } };
27
66
  }
28
- function initializeUnlocked(project, id, plan, requirements = null) {
67
+ function initializeUnlocked(project, id, plan, requirements = null, risk = "STANDARD") {
29
68
  const file = taskPath(project, id);
30
69
  requireValue(!fs.existsSync(file), `Task already exists: ${id}`);
31
70
  const projectGates = artifact(project, ".nccgs/gates.json");
32
71
  const projectPolicy = artifact(project, ".nccgs/project.yaml");
33
72
  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: [] };
73
+ const pipeline = loadPipeline(project);
74
+ const { tier, route } = tierRoute(pipeline, risk);
75
+ const task = { schemaVersion: 1, id, profile: "astra-claude", risk, implementationRoute: route,
76
+ reviewBudget: { used: 0, limit: tier.maxReviewRounds, extensions: [] },
77
+ createdAt: new Date().toISOString(), pipelineHash: hash(JSON.stringify(pipeline)), plan: artifact(project, plan), projectPolicy, projectGates, requirements: requirements ? artifact(project, requirements) : null, canonicalInputs: pinInputs(project), stages: {}, history: [] };
35
78
  atomicJson(file, task);
36
79
  return task;
37
80
  }
@@ -42,8 +85,10 @@ function runtime(project, task, stage, expected, snap) {
42
85
  requireValue(trace.taskId === task.id && trace.snapshot === snap, "Runtime evidence belongs to another task/snapshot");
43
86
  requireValue(trace.status === "completed" && trace.sessionId && trace.agentId, "Runtime observation is incomplete");
44
87
  requireValue(Date.parse(trace.capturedAt) >= Date.parse(task.createdAt) && Date.parse(trace.capturedAt) <= Date.now(), "Runtime observation has invalid time");
88
+ requireValue(Date.parse(trace.capturedAt) >= Date.parse(task.canonicalInputs.pinnedAt), "Runtime observation predates current input pin; capture a fresh run");
45
89
  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;
90
+ const matches = (model) => expected.model === "fable" ? /^claude-fable-\d[\w.-]*$/.test(model ?? "")
91
+ : expected.model === "sonnet" ? /^claude-sonnet-\d[\w.-]*$/.test(model ?? "") : model === expected.model;
47
92
  requireValue(matches(trace.observedModel) && models.every(matches), "Observed model/fallback does not satisfy pipeline");
48
93
  requireValue(trace.observedEffort === expected.effort, "Observed effort missing or mismatched; configuration is not proof");
49
94
  requireValue(trace.observationSource, "Runtime observation requires a source artifact");
@@ -53,13 +98,30 @@ function runtime(project, task, stage, expected, snap) {
53
98
  requireValue(source.capturedAt === trace.capturedAt && source.status === trace.status, "Observation source time/status mismatch");
54
99
  const sourceModels = source.modelsUsed?.length ? source.modelsUsed : [source.observedModel ?? source.resolvedModel];
55
100
  requireValue(JSON.stringify(sourceModels) === JSON.stringify(models), "Observation dropped or changed source fallback history");
101
+ requireValue(stage.runtime.path.startsWith(".nccgs/evidence/runtime/observations/") && trace.observationSource.path.startsWith(".nccgs/evidence/runtime/sources/"), "Runtime evidence is not durable; run task migrate-evidence before closure");
56
102
  return trace;
57
103
  }
104
+ function reviewEvidence(project, task, stage, expected, snap, implementer, allowNegative = false) {
105
+ const trace = runtime(project, task, stage, expected, snap);
106
+ requireValue(`${trace.sessionId}/${trace.agentId}` !== implementer, "Review must be independent of implementation");
107
+ requireValue(Date.parse(trace.capturedAt) >= Date.parse(task.stages.implementation.recordedAt), "Review runtime predates implementation evidence");
108
+ requireValue(Array.isArray(stage.findings) && typeof stage.criteriaMet === "boolean", "Review requires findings and an acceptance criteria assessment");
109
+ requireValue(stage.verdict === "PASS" || (allowNegative && ["CHANGES_REQUIRED", "BLOCKED"].includes(stage.verdict)), "Fable review has not passed");
110
+ if (stage.verdict === "PASS") {
111
+ requireValue(!stage.findings.some(f => ["BLOCKER", "HIGH"].includes(f.severity) && f.status !== "RESOLVED"), "Review has open blocking findings");
112
+ requireValue(stage.criteriaMet === true, "Required acceptance criteria are not proven");
113
+ }
114
+ verifyArtifact(project, stage.evidence);
115
+ }
58
116
  export function check(project, task, target = "done") {
117
+ if (task.schemaVersion === 2) return checkStandalone(project, task, target);
59
118
  requireValue(task.schemaVersion === 1 && task.profile === "astra-claude", "Unsupported task record");
60
119
  taskPath(project, task.id);
61
120
  const pipeline = loadPipeline(project);
62
121
  requireValue(task.pipelineHash === hash(JSON.stringify(pipeline)), "Pipeline policy changed; create a new task against the new policy");
122
+ const routing = taskRouting(pipeline, task);
123
+ requireValue(task.canonicalInputs?.schemaVersion === 1 && Number.isFinite(Date.parse(task.canonicalInputs.pinnedAt)), "Legacy task lacks canonical input pins; run task refresh-inputs --reason TEXT and record fresh evidence");
124
+ requireValue(task.canonicalInputs.sha256 === canonicalInputs(project).sha256, "Canonical inputs are stale; review the changes, run task refresh-inputs --reason TEXT, and record fresh evidence");
63
125
  verifyArtifact(project, task.plan);
64
126
  verifyArtifact(project, task.projectPolicy);
65
127
  const obligations = [task.projectGates, task.requirements].filter(Boolean).map(ref => json(verifyArtifact(project, ref)));
@@ -77,9 +139,8 @@ export function check(project, task, target = "done") {
77
139
  requireValue(Number.isFinite(at) && at >= previous && at <= Date.now(), `${name}: invalid sequence timestamp`);
78
140
  previous = at;
79
141
  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
142
  if (name === "implementation") {
143
+ const trace = runtime(project, task, stage, routing.route, snap);
83
144
  implementer = `${trace.sessionId}/${trace.agentId}`;
84
145
  requireValue(Array.isArray(stage.checks) && stage.checks.length > 0, "Implementation requires verification evidence");
85
146
  for (const test of stage.checks) {
@@ -89,18 +150,14 @@ export function check(project, task, target = "done") {
89
150
  const evidence = json(evidenceFile);
90
151
  if (evidence.origin === "verification-command") {
91
152
  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");
153
+ requireValue(Date.parse(evidence.startedAt) >= Date.parse(task.canonicalInputs.pinnedAt), "Verification execution predates current input pin; rerun the check");
92
154
  verifyArtifact(project, evidence.log);
93
155
  }
94
156
  }
95
157
  }
96
158
  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
159
  } 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);
160
+ reviewEvidence(project, task, stage, pipeline.profiles[task.profile].models.review, snap, implementer);
104
161
  }
105
162
  } else if (name === "report") {
106
163
  verifyArtifact(project, stage.evidence);
@@ -121,15 +178,27 @@ export function check(project, task, target = "done") {
121
178
  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
179
  }
123
180
  }
124
- return { allowed: true, target, snapshot: snap, phase: target === "done" ? "DONE" : `READY_FOR_${target.toUpperCase()}` };
181
+ const exhausted = target === "review" && routing.budget.remaining === 0;
182
+ return { allowed: !exhausted, target, snapshot: snap, risk: task.risk, implementationRoute: routing.route, reviewBudget: routing.budget,
183
+ ...(exhausted ? { message: `Review budget exhausted (${routing.budget.used}/${routing.budget.limit}); stop and escalate. An external operator may explicitly extend-review with a reason; exhausted budget never means PASS` } : {}),
184
+ phase: exhausted ? "REVIEW_BUDGET_EXHAUSTED" : target === "done" ? "DONE" : `READY_FOR_${target.toUpperCase()}` };
125
185
  }
126
186
  function recordUnlocked(project, id, name, input) {
187
+ const existing = json(taskPath(project, id));
188
+ if (existing.schemaVersion === 2) return recordStandalone(project, existing, name, input);
127
189
  requireValue(stages.includes(name), "Unknown pipeline stage");
128
190
  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
191
  const file = taskPath(project, id);
130
192
  const task = json(file);
131
193
  const status = check(project, task, name);
194
+ requireValue(status.allowed, status.message);
132
195
  requireValue(input.snapshot === status.snapshot, "Submitted evidence does not cover the current snapshot");
196
+ if (name === "review") {
197
+ const pipeline = loadPipeline(project);
198
+ const implementation = runtime(project, task, task.stages.implementation, status.implementationRoute, status.snapshot);
199
+ reviewEvidence(project, task, input, pipeline.profiles[task.profile].models.review, status.snapshot, `${implementation.sessionId}/${implementation.agentId}`, true);
200
+ task.reviewBudget.used += 1;
201
+ }
133
202
  task.history.push({ recordedAt: new Date().toISOString(), replacedFrom: name, stages: structuredClone(task.stages) });
134
203
  for (const downstream of stages.slice(stages.indexOf(name))) delete task.stages[downstream];
135
204
  task.stages[name] = { ...input, recordedAt: new Date().toISOString() };
@@ -144,6 +213,24 @@ function recordUnlocked(project, id, name, input) {
144
213
  atomicJson(file, task);
145
214
  return task;
146
215
  }
216
+ function extendReviewUnlocked(project, id, reason, rounds) {
217
+ const existing = json(taskPath(project, id));
218
+ if (existing.schemaVersion === 2) return extendStandalone(project, existing, reason, rounds);
219
+ requireValue(!(process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT), "Review budget extensions must be requested outside the Claude session");
220
+ requireValue(typeof reason === "string" && reason.trim(), "Review budget extension requires a reason");
221
+ const file = taskPath(project, id);
222
+ const task = json(file);
223
+ check(project, task, "implementation");
224
+ const { tier } = taskRouting(loadPipeline(project), task);
225
+ requireValue(Number.isSafeInteger(rounds) && rounds > 0 && rounds <= tier.maxReviewRounds, `Review budget extension requires an integer from 1 to ${tier.maxReviewRounds}`);
226
+ requireValue(Number.isSafeInteger(task.reviewBudget.limit + rounds), "Review budget limit is too large");
227
+ const extension = { at: new Date().toISOString(), reason: reason.trim(), rounds, previousLimit: task.reviewBudget.limit, newLimit: task.reviewBudget.limit + rounds };
228
+ task.reviewBudget.limit = extension.newLimit;
229
+ task.reviewBudget.extensions.push(extension);
230
+ task.history.push({ recordedAt: extension.at, action: "extend-review", ...extension });
231
+ atomicJson(file, task);
232
+ return task;
233
+ }
147
234
  function closeUnlocked(project, id) {
148
235
  const file = taskPath(project, id);
149
236
  const task = json(file);
@@ -153,15 +240,55 @@ function closeUnlocked(project, id) {
153
240
  return status;
154
241
  }
155
242
 
243
+ function refreshInputsUnlocked(project, id, reason) {
244
+ const existing = json(taskPath(project, id));
245
+ if (existing.schemaVersion === 2) return refreshStandalone(project, existing, reason);
246
+ requireValue(typeof reason === "string" && reason.trim(), "Input refresh requires a reason");
247
+ const file = taskPath(project, id);
248
+ const task = json(file);
249
+ requireValue(task.schemaVersion === 1 && task.profile === "astra-claude", "Unsupported task record");
250
+ const pipeline = loadPipeline(project);
251
+ requireValue(task.pipelineHash === hash(JSON.stringify(pipeline)), "Input refresh cannot migrate a changed pipeline");
252
+ taskRouting(pipeline, task);
253
+ const projectPolicy = artifact(project, ".nccgs/project.yaml");
254
+ const projectGates = artifact(project, ".nccgs/gates.json");
255
+ requireValue(json(verifyArtifact(project, projectGates)).policySha256 === projectPolicy.sha256, "Project gates must be remapped to the current project.yaml hash before input refresh");
256
+ const plan = artifact(project, task.plan.path);
257
+ const requirements = task.requirements ? artifact(project, task.requirements.path) : null;
258
+ const pins = pinInputs(project);
259
+ task.history.push({ action: "refresh-inputs", recordedAt: pins.pinnedAt, reason: reason.trim(),
260
+ canonicalInputs: task.canonicalInputs ?? null, plan: task.plan, projectPolicy: task.projectPolicy,
261
+ projectGates: task.projectGates, requirements: task.requirements, stages: structuredClone(task.stages), closed: task.closed ?? null });
262
+ Object.assign(task, { canonicalInputs: pins, plan, projectPolicy, projectGates, requirements, stages: {} });
263
+ delete task.closed;
264
+ // Validate new mappings before committing; a failed refresh leaves the task intact.
265
+ check(project, task, "implementation");
266
+ atomicJson(file, task);
267
+ return task;
268
+ }
269
+
156
270
  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); }
271
+ const lock = acquireLock(`${taskPath(project, id)}.lock`, { taskId: id });
272
+ try { return action(); } finally { lock.release(); }
164
273
  }
165
- export const initialize = (project, id, plan, requirements = null) => mutate(project, id, () => initializeUnlocked(project, id, plan, requirements));
274
+ export const withTask = mutate;
275
+ export const initialize = (project, id, plan, requirements = null, risk = null, options = {}) => mutate(project, id, () =>
276
+ resolveExecution(project).name === "standalone"
277
+ ? initializeStandalone(project, id, { ...options, plan, requirements, ...(risk ? { risk } : {}) })
278
+ : initializeUnlocked(project, id, plan, requirements, risk || "STANDARD"));
166
279
  export const record = (project, id, stage, evidence) => mutate(project, id, () => recordUnlocked(project, id, stage, evidence));
280
+ export const extendReview = (project, id, reason, rounds) => mutate(project, id, () => extendReviewUnlocked(project, id, reason, rounds));
167
281
  export const close = (project, id) => mutate(project, id, () => closeUnlocked(project, id));
282
+ export const refreshInputs = (project, id, reason) => mutate(project, id, () => refreshInputsUnlocked(project, id, reason));
283
+ export const reclassify = (project, id, changes) => mutate(project, id, () => reclassifyStandalone(project, json(taskPath(project, id)), changes));
284
+ export const exportExternal = (project, id, relative) => mutate(project, id, () => exportExternalStandalone(project, json(taskPath(project, id)), relative));
285
+ export const importExternal = (project, id, relative) => mutate(project, id, () => importExternalStandalone(project, json(taskPath(project, id)), relative));
286
+ export const migrateEvidence = (project, id) => mutate(project, id, () => {
287
+ const file = taskPath(project, id);
288
+ const result = migrateTaskEvidence(project, json(file));
289
+ if (result.changed) {
290
+ result.task.history.push({ action: "migrate-evidence", recordedAt: new Date().toISOString(), migrations: result.migrations });
291
+ atomicJson(file, result.task);
292
+ }
293
+ return { id, changed: result.changed, migrations: result.migrations };
294
+ });
@@ -0,0 +1,73 @@
1
+ import fs from "node:fs";
2
+ import { performance } from "node:perf_hooks";
3
+ import { contained, json, hash } from "./policy.mjs";
4
+
5
+ export const inputManifest = ".nccgs/inputs.json";
6
+ export function inputPolicy(project) {
7
+ const file = contained(project, inputManifest);
8
+ const policy = fs.existsSync(file) ? json(file) : { schemaVersion: 1, canonicalInputs: [], outputDirectories: [], checks: {} };
9
+ if (policy.schemaVersion !== 1 || !Array.isArray(policy.canonicalInputs) || !Array.isArray(policy.outputDirectories)
10
+ || !policy.checks || typeof policy.checks !== "object" || Array.isArray(policy.checks)) throw new Error("Invalid .nccgs/inputs.json schema");
11
+ for (const key of Object.keys(policy)) if (!["schemaVersion", "canonicalInputs", "outputDirectories", "checks"].includes(key)) throw new Error(`Unknown input manifest field: ${key}`);
12
+ for (const relative of [...policy.canonicalInputs, ...policy.outputDirectories]) contained(project, relative);
13
+ for (const relative of policy.outputDirectories) {
14
+ // Keep source roots in the snapshot. Unity build destinations must be deliberately
15
+ // placed below these dedicated roots; arbitrary ignore globs are not supported.
16
+ if (!/^(Build|Builds)\/[A-Za-z0-9_-][A-Za-z0-9_./ -]*$/.test(relative)) throw new Error("Output directories must be explicit subdirectories of Build/ or Builds/");
17
+ const file = contained(project, relative);
18
+ if (fs.existsSync(file) && !fs.statSync(file).isDirectory()) throw new Error(`Output path is not a directory: ${relative}`);
19
+ for (const input of policy.canonicalInputs) if (overlaps(input, relative)) throw new Error(`Canonical input/output overlap: ${input} and ${relative}`);
20
+ }
21
+ for (const relative of policy.canonicalInputs) {
22
+ if (relative.toLowerCase() === ".nccgs" || /^\.nccgs\/(evidence|runtime|tasks|backups|closures)(\/|$)/i.test(relative)) throw new Error(`Generated state cannot be a canonical input: ${relative}`);
23
+ }
24
+ for (const [name, config] of Object.entries(policy.checks)) {
25
+ if (!name.trim() || !config || typeof config !== "object" || Array.isArray(config)
26
+ || Object.keys(config).some(key => key !== "timeoutMs") || !Number.isSafeInteger(config.timeoutMs)
27
+ || config.timeoutMs < 100 || config.timeoutMs > 7200000) throw new Error(`Check ${name}: timeoutMs must be an integer from 100 to 7200000`);
28
+ }
29
+ return policy;
30
+ }
31
+ function overlaps(a, b) {
32
+ a = a.toLowerCase(); b = b.toLowerCase();
33
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
34
+ }
35
+ export function isOutput(relative, policy) {
36
+ const normalize = name => process.platform === "win32" ? name.toLowerCase() : name;
37
+ return policy.outputDirectories.some(dir => normalize(relative) === normalize(dir) || normalize(relative).startsWith(`${normalize(dir)}/`));
38
+ }
39
+ export function canonicalInputs(project, policy = inputPolicy(project)) {
40
+ const entries = new Map();
41
+ let bytes = 0, readHashMs = 0;
42
+ function capture(relative, optional = false) {
43
+ if (entries.has(relative)) return;
44
+ const file = contained(project, relative);
45
+ if (!fs.existsSync(file)) {
46
+ if (!optional) throw new Error(`Canonical input missing: ${relative}`);
47
+ entries.set(relative, { path: relative, kind: "missing" });
48
+ return;
49
+ }
50
+ const stat = fs.statSync(file);
51
+ if (stat.isDirectory()) {
52
+ entries.set(relative, { path: relative, kind: "directory" });
53
+ for (const name of fs.readdirSync(file).sort()) capture(`${relative}/${name}`);
54
+ } else if (stat.isFile()) {
55
+ const start = performance.now();
56
+ const data = fs.readFileSync(file);
57
+ entries.set(relative, { path: relative, sha256: hash(data), kind: "file" });
58
+ readHashMs += performance.now() - start;
59
+ bytes += data.length;
60
+ } else throw new Error(`Unsupported canonical input: ${relative}`);
61
+ }
62
+ capture(inputManifest, true);
63
+ capture(".nccgs/requirements.yaml", true);
64
+ capture(".nccgs/autonomy.json", true);
65
+ capture(".nccgs/decisions", true);
66
+ for (const relative of policy.canonicalInputs) capture(relative);
67
+ const files = [...entries.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
68
+ return { schemaVersion: 1, sha256: hash(JSON.stringify(files)), entries: files, bytes, readHashMs };
69
+ }
70
+ export function pinInputs(project) {
71
+ const { schemaVersion, sha256, entries } = canonicalInputs(project);
72
+ return { schemaVersion, sha256, entries, pinnedAt: new Date().toISOString() };
73
+ }