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.
- package/.claude/agents/nccgs-adversarial-reviewer.md +17 -6
- package/.claude/agents/nccgs-engine-programmer.md +2 -2
- package/.claude/agents/nccgs-fast-implementer.md +30 -0
- package/.claude/agents/nccgs-game-designer.md +26 -0
- package/.claude/agents/nccgs-gameplay-programmer.md +3 -4
- package/.claude/agents/nccgs-qa-engineer.md +2 -2
- package/.claude/agents/nccgs-task-scout.md +24 -0
- package/.claude/agents/nccgs-ui-programmer.md +2 -2
- package/.claude/agents/nccgs-unity-build-specialist.md +2 -2
- package/.claude/agents/nccgs-unity-implementer.md +7 -6
- package/.claude/agents/nccgs-unity-systems-specialist.md +2 -2
- package/.claude/agents/nccgs-unity-ui-specialist.md +2 -2
- package/.claude/agents/nccgs-verification-engineer.md +2 -2
- package/.claude/nccgs/MEMORY.md +59 -0
- package/.claude/nccgs/THIRD_PARTY_NOTICES.md +16 -0
- package/.claude/nccgs/VERSION +1 -1
- package/.claude/nccgs/agent-library/nccgs-adversarial-reviewer.md +16 -5
- package/.claude/nccgs/agent-library/nccgs-fast-implementer.md +28 -0
- package/.claude/nccgs/agent-library/nccgs-game-designer.md +1 -2
- package/.claude/nccgs/agent-library/nccgs-gameplay-programmer.md +1 -2
- package/.claude/nccgs/agent-library/nccgs-task-scout.md +22 -0
- package/.claude/nccgs/agent-library/nccgs-unity-implementer.md +5 -4
- package/.claude/nccgs/constitution.md +44 -12
- package/.claude/nccgs/hooks/agent-audit.mjs +2 -2
- package/.claude/nccgs/hooks/closure-guard.mjs +31 -8
- package/.claude/nccgs/hooks/common.mjs +2 -1
- package/.claude/nccgs/hooks/runtime-observation.mjs +2 -2
- package/.claude/nccgs/hooks/session-start.mjs +11 -4
- package/.claude/nccgs/packages/ponytail/LICENSE +21 -0
- package/.claude/nccgs/packages/ponytail/NOTICE.md +32 -0
- package/.claude/nccgs/packages/ponytail/README.md +46 -0
- package/.claude/nccgs/packages/ponytail/complexity-review.md +47 -0
- package/.claude/nccgs/packages/ponytail/engineering.md +68 -0
- package/.claude/nccgs/packages/ponytail/manifest.json +24 -0
- package/.claude/nccgs/pipeline.json +92 -8
- package/.claude/nccgs/protocols/agent-contract.md +50 -1
- package/.claude/nccgs/protocols/astra-claude.md +46 -12
- package/.claude/nccgs/protocols/bounded-repair.md +118 -0
- package/.claude/nccgs/protocols/context-packets.md +5 -0
- package/.claude/nccgs/protocols/delivery-acceptance.md +105 -0
- package/.claude/nccgs/protocols/design-lifecycle.md +139 -0
- package/.claude/nccgs/protocols/evidence.md +11 -1
- package/.claude/nccgs/protocols/model-routing.md +44 -42
- package/.claude/nccgs/protocols/orchestration.md +46 -5
- package/.claude/nccgs/protocols/review-handoff.md +65 -0
- package/.claude/nccgs/protocols/standalone-gates.md +273 -0
- package/.claude/nccgs/protocols/standalone-pilot.md +144 -0
- package/.claude/nccgs/protocols/standalone.md +187 -0
- package/.claude/nccgs/protocols/task-autonomy.md +55 -0
- package/.claude/nccgs/protocols/task-gates.md +73 -7
- package/.claude/nccgs/routing.generated.md +25 -7
- package/.claude/nccgs/settings.fragment.json +153 -141
- package/.claude/nccgs/studio.json +135 -101
- package/.claude/nccgs/tools/autonomy.mjs +27 -0
- package/.claude/nccgs/tools/command-process.mjs +44 -0
- package/.claude/nccgs/tools/compile-policy.mjs +34 -4
- package/.claude/nccgs/tools/configure-models.mjs +13 -5
- package/.claude/nccgs/tools/criteria.mjs +121 -0
- package/.claude/nccgs/tools/delivery-budget.mjs +52 -0
- package/.claude/nccgs/tools/design-readiness.mjs +158 -0
- package/.claude/nccgs/tools/evidence.mjs +153 -0
- package/.claude/nccgs/tools/gates.mjs +151 -24
- package/.claude/nccgs/tools/inputs.mjs +73 -0
- package/.claude/nccgs/tools/lifecycle.mjs +521 -0
- package/.claude/nccgs/tools/native-completion.mjs +21 -0
- package/.claude/nccgs/tools/orchestration.mjs +430 -0
- package/.claude/nccgs/tools/paths.mjs +60 -0
- package/.claude/nccgs/tools/policy.mjs +58 -13
- package/.claude/nccgs/tools/process-lock.mjs +74 -0
- package/.claude/nccgs/tools/repair-packet.mjs +87 -0
- package/.claude/nccgs/tools/review-handoff.mjs +32 -0
- package/.claude/nccgs/tools/runtime.mjs +33 -7
- package/.claude/nccgs/tools/task.mjs +62 -31
- package/.claude/nccgs/tools/tool-guard.mjs +117 -0
- package/.claude/nccgs/tools/verification-jobs.mjs +161 -0
- package/.claude/nccgs/tools/verification-worker.mjs +71 -0
- package/.claude/nccgs/tools/windows-job.cs +96 -0
- package/.claude/nccgs/workflow-catalog.json +286 -26
- package/.claude/skills/accessibility-review/SKILL.md +12 -5
- package/.claude/skills/architecture-decision/SKILL.md +12 -5
- package/.claude/skills/asset-audit/SKILL.md +12 -5
- package/.claude/skills/audit/SKILL.md +12 -5
- package/.claude/skills/balance-review/SKILL.md +12 -5
- package/.claude/skills/bug-triage/SKILL.md +12 -5
- package/.claude/skills/bug-triage/references/astra.md +12 -5
- package/.claude/skills/closure/SKILL.md +12 -5
- package/.claude/skills/compatibility-review/SKILL.md +12 -5
- package/.claude/skills/context-pack/SKILL.md +12 -5
- package/.claude/skills/dependency-review/SKILL.md +12 -5
- package/.claude/skills/design/SKILL.md +14 -5
- package/.claude/skills/design-review/SKILL.md +14 -5
- package/.claude/skills/evidence-review/SKILL.md +12 -5
- package/.claude/skills/hotfix/SKILL.md +12 -5
- package/.claude/skills/hotfix/references/astra.md +12 -5
- package/.claude/skills/incident-recovery/SKILL.md +12 -5
- package/.claude/skills/incident-recovery/references/astra.md +12 -5
- package/.claude/skills/localize-game/SKILL.md +12 -5
- package/.claude/skills/localize-game/references/astra.md +12 -5
- package/.claude/skills/migrate-project/SKILL.md +12 -5
- package/.claude/skills/migrate-project/references/astra.md +12 -5
- package/.claude/skills/migrate-project/references/procedure.md +2 -1
- package/.claude/skills/milestone-review/SKILL.md +12 -5
- package/.claude/skills/nccgs-code-review/SKILL.md +12 -5
- package/.claude/skills/nccgs-code-review/references/astra.md +8 -2
- package/.claude/skills/performance-audit/SKILL.md +12 -5
- package/.claude/skills/plan-feature/SKILL.md +12 -5
- package/.claude/skills/playtest/SKILL.md +12 -5
- package/.claude/skills/playtest/references/astra.md +16 -5
- package/.claude/skills/project-stage/SKILL.md +12 -5
- package/.claude/skills/prototype-feature/SKILL.md +12 -5
- package/.claude/skills/prototype-feature/references/astra.md +16 -5
- package/.claude/skills/qa-plan/SKILL.md +12 -5
- package/.claude/skills/release/SKILL.md +12 -5
- package/.claude/skills/release/references/astra.md +12 -5
- package/.claude/skills/release-readiness/SKILL.md +12 -5
- package/.claude/skills/retrospective/SKILL.md +12 -5
- package/.claude/skills/review/SKILL.md +12 -5
- package/.claude/skills/review/references/astra.md +8 -2
- package/.claude/skills/security-audit/SKILL.md +12 -5
- package/.claude/skills/sprint-plan/SKILL.md +12 -5
- package/.claude/skills/status/SKILL.md +12 -5
- package/.claude/skills/status/references/astra.md +6 -0
- package/.claude/skills/story-readiness/SKILL.md +14 -5
- package/.claude/skills/test/SKILL.md +12 -5
- package/.claude/skills/test/references/astra.md +12 -5
- package/.claude/skills/ui-review/SKILL.md +12 -5
- package/.claude/skills/work/SKILL.md +12 -5
- package/.claude/skills/work/references/astra.md +17 -5
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +2 -0
- package/MEMORY.md +55 -0
- package/README.md +132 -180
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/UPGRADING.md +91 -69
- package/VERSION +1 -1
- package/docs/ARCHITECTURE.md +19 -5
- package/docs/BOUNDED-REPAIR.md +11 -0
- package/docs/HUONG-DAN-MIGRATE-VA-SU-DUNG.md +46 -34
- package/docs/NCCGS-1.3.md +10 -122
- package/docs/NCCGS-1.4.1.md +149 -0
- package/docs/NCCGS-1.4.md +184 -0
- package/docs/NCCGS-1.5-IMPLEMENTATION-PLAN.md +378 -0
- package/docs/NCCGS-1.5-PILOT.md +130 -0
- package/docs/NCCGS-1.5.md +237 -0
- package/docs/PHASE-1-OPERATIONS.md +137 -0
- package/docs/PHASE-2-OPERATIONS.md +183 -0
- package/docs/PROJECT-POLICY.md +21 -9
- package/docs/RELEASE-STATUS.md +42 -0
- package/docs/WORKFLOWS.md +42 -24
- package/package.json +7 -3
- package/scaffold/.nccgs/autonomy.json +4 -0
- package/scaffold/.nccgs/execution.json +10 -0
- package/scaffold/.nccgs/inputs.json +6 -0
- package/scaffold/.nccgs/project.yaml +4 -2
- package/scaffold/.nccgs/templates/agent-handoff.md +2 -0
- package/scaffold/.nccgs/templates/design-contract.json +34 -0
- package/scaffold/.nccgs/templates/feature-contract.md +21 -5
- package/scaffold/.nccgs/templates/implementation-report.md +4 -1
- package/scaffold/.nccgs/templates/repair-packet.json +14 -0
- package/scripts/benchmark-snapshot.mjs +205 -0
- package/scripts/benchmark-v15.mjs +173 -0
- package/scripts/cli.mjs +129 -2
- package/scripts/doctor.mjs +128 -22
- package/scripts/install.mjs +96 -34
- package/scripts/run.mjs +72 -0
- package/scripts/supervise.mjs +124 -0
- package/scripts/validate.mjs +45 -4
- package/tests/autonomy.test.mjs +113 -0
- package/tests/benchmark-v15.test.mjs +65 -0
- package/tests/design-readiness.test.mjs +200 -0
- package/tests/evidence.test.mjs +186 -0
- package/tests/framework.test.mjs +333 -16
- package/tests/gates.test.mjs +313 -4
- package/tests/lifecycle-v15.test.mjs +300 -0
- package/tests/orchestration-v15.test.mjs +545 -0
- package/tests/phase1.test.mjs +324 -0
- package/tests/phase2.test.mjs +250 -0
- package/tests/policy-v15.test.mjs +220 -0
- package/tests/ponytail-package.test.mjs +75 -0
- 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 {
|
|
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())
|
|
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
|
|
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 ?? "")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|