@juzser/blacksmith 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
import { EPIC_CLOSED_EVENT_TYPE } from './agents-registry.js';
|
|
2
|
+
import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
3
|
+
import { foldTasks } from './db/projector.js';
|
|
4
|
+
import { SmithError } from './errors.js';
|
|
5
|
+
import { appendEvent, readEvents, readLineageEvents, } from './events.js';
|
|
6
|
+
import { AMEND_PENDING_STATUS, AMENDED_STATUS, describeMalformedTaskId, foldFindingsDetailed, isNonEmptyString, OPEN_FINDING_STATUSES, transition, } from './findings.js';
|
|
7
|
+
import { goalCheckBlockers, latestGoalCheck, } from './goalCheck.js';
|
|
8
|
+
import { latestIntegrationCheck } from './integration.js';
|
|
9
|
+
import { mcpBlockers } from './mcp.js';
|
|
10
|
+
import { bareTaskId, latestPlanVersion, livePlanTasks, loadPlan } from './plan.js';
|
|
11
|
+
import { enabledExternalProviders, nativeProviderName, runQuorumCase, } from './quorum.js';
|
|
12
|
+
import { latestSpecReview, specReviewBlockers } from './spec.js';
|
|
13
|
+
import { auditWaveConcurrency } from './waveConcurrency.js';
|
|
14
|
+
import { RESERVED_TASK_ID } from './worktree.js';
|
|
15
|
+
/**
|
|
16
|
+
* Third quorum_triggers host (factory/policies/crosscheck.yml): "epic-level
|
|
17
|
+
* final verdict, before the integration PR opens". Same trigger-agnostic
|
|
18
|
+
* quorum.ts engine gate.ts already uses for blocking-finding/same-mistake —
|
|
19
|
+
* this module is a CALLER, not a variant of it.
|
|
20
|
+
*
|
|
21
|
+
* mechanical_oracles_first, applied literally: summarizeEpic() below is the
|
|
22
|
+
* deterministic oracle and runs first; a mechanically-not-ready epic never
|
|
23
|
+
* reaches a judge (§ runEpicVerdict step 1).
|
|
24
|
+
*
|
|
25
|
+
* §4 consequence of asymmetric_roles.finder_ne_critic (crosscheck.yml): the
|
|
26
|
+
* readiness claim here is the native provider's own ("this epic is done"),
|
|
27
|
+
* so native is always the finder and is always excluded from the gating
|
|
28
|
+
* pool, exactly like any other claim. With quorum_rule.min_providers: 2:
|
|
29
|
+
* - 0 enabled externals: computeQuorum() would escalate
|
|
30
|
+
* insufficient-providers on EVERY mechanically-ready epic, since the
|
|
31
|
+
* gating pool is empty by construction. Calling runQuorumCase() at all
|
|
32
|
+
* in that case would be pure overhead for a foregone conclusion, so
|
|
33
|
+
* step 2 below short-circuits straight to `go` — this is the same
|
|
34
|
+
* "shipped default costs nothing" rule as gate.ts's resolveCrosscheck()
|
|
35
|
+
* returning null, and it is mandatory, not an optimisation.
|
|
36
|
+
* - exactly 1 active external: gating pool size 1 < 2 -> escalate
|
|
37
|
+
* insufficient-providers -> hold, every time. This is fail-closed and
|
|
38
|
+
* deliberate: the operator sees the reason and turns on a second
|
|
39
|
+
* provider or overrides by hand, rather than the epic silently going on
|
|
40
|
+
* the word of one external model.
|
|
41
|
+
* - 2+ active externals: a real quorum vote decides it.
|
|
42
|
+
* - shadow-only externals: verdicts are recorded (judge-verdict events)
|
|
43
|
+
* for calibration, but shadow mode has zero gating power — the outcome
|
|
44
|
+
* is whatever it would have been with no externals at all (`go`, since
|
|
45
|
+
* mechanical readiness already held).
|
|
46
|
+
*/
|
|
47
|
+
const TERMINAL_OK_TASK_STATUSES = new Set(['completed', 'waived']);
|
|
48
|
+
/** The terminal-OK status that is a decision rather than a completion (D-120). */
|
|
49
|
+
const WAIVED_TASK_STATUS = 'waived';
|
|
50
|
+
/**
|
|
51
|
+
* Statuses that close a finding by DECIDING rather than by showing a fix
|
|
52
|
+
* (D-120). Disjoint from OPEN_FINDING_STATUSES by construction — a finding is
|
|
53
|
+
* open, earned-closed (`fix-verified`, `refuted`, `expired`), or one of these.
|
|
54
|
+
* The gate does not block on them, which is precisely why the judge is told:
|
|
55
|
+
* what nothing blocks on is what nobody re-reads.
|
|
56
|
+
*/
|
|
57
|
+
const DISCRETIONARY_FINDING_STATUSES = new Set(['waived', AMENDED_STATUS]);
|
|
58
|
+
const GATE_OUTCOME_EVENT = 'gate-outcome';
|
|
59
|
+
const TASK_RESULT_EVENT = 'task-result-recorded';
|
|
60
|
+
/**
|
|
61
|
+
* Attach each row's gate evidence, folded from the same events the rows came
|
|
62
|
+
* from — no new I/O, the same way latestIntegrationCheck() and
|
|
63
|
+
* latestSpecReview() read the events runEpicVerdict already holds.
|
|
64
|
+
*
|
|
65
|
+
* Ids compare bare (D-46/P9-29): the fold row may carry `task-1` while the gate
|
|
66
|
+
* stamped `epic-1/task-1`, and comparing raw would read every such task as
|
|
67
|
+
* ungated — a false blocker is as bad here as the false pass this closes.
|
|
68
|
+
*/
|
|
69
|
+
export function withGateEvidence(tasks, events, epicId) {
|
|
70
|
+
const gated = new Set();
|
|
71
|
+
const recorded = new Set();
|
|
72
|
+
for (const { record } of events) {
|
|
73
|
+
if (!record.task_id)
|
|
74
|
+
continue;
|
|
75
|
+
if (record.event_type === GATE_OUTCOME_EVENT)
|
|
76
|
+
gated.add(bareTaskId(epicId, record.task_id));
|
|
77
|
+
else if (record.event_type === TASK_RESULT_EVENT)
|
|
78
|
+
recorded.add(bareTaskId(epicId, record.task_id));
|
|
79
|
+
}
|
|
80
|
+
return tasks.map((t) => {
|
|
81
|
+
const bare = bareTaskId(epicId, t.taskId);
|
|
82
|
+
return { ...t, gate: { gateOutcome: gated.has(bare), resultRecorded: recorded.has(bare) } };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const WAVE_VERDICTS = [
|
|
86
|
+
'parallel',
|
|
87
|
+
'partial',
|
|
88
|
+
'serialized',
|
|
89
|
+
'single',
|
|
90
|
+
'unobserved',
|
|
91
|
+
];
|
|
92
|
+
/**
|
|
93
|
+
* Reduce one epic's waves to the fact a close records. Pure, and separate from
|
|
94
|
+
* `summariseWaveConcurrency` on purpose: that one scores an operator's audit
|
|
95
|
+
* across every epic in a log and returns an exit code, and this one answers a
|
|
96
|
+
* single epic with no verdict of its own to render.
|
|
97
|
+
*/
|
|
98
|
+
export function summariseEpicConcurrency(waves) {
|
|
99
|
+
const verdicts = Object.fromEntries(WAVE_VERDICTS.map((v) => [v, 0]));
|
|
100
|
+
for (const wave of waves)
|
|
101
|
+
verdicts[wave.verdict] += 1;
|
|
102
|
+
return {
|
|
103
|
+
waves: waves.length,
|
|
104
|
+
verdicts,
|
|
105
|
+
widest: {
|
|
106
|
+
declared: waves.reduce((max, wave) => Math.max(max, wave.declared.length), 0),
|
|
107
|
+
observed: waves.reduce((max, wave) => Math.max(max, wave.peak), 0),
|
|
108
|
+
},
|
|
109
|
+
unobserved: waves.filter((wave) => wave.verdict === 'unobserved').map((wave) => wave.eventId),
|
|
110
|
+
problem: null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Nothing the log could be folded into a width, and why. */
|
|
114
|
+
const UNREADABLE_CONCURRENCY = (problem) => ({
|
|
115
|
+
waves: 0,
|
|
116
|
+
verdicts: Object.fromEntries(WAVE_VERDICTS.map((v) => [v, 0])),
|
|
117
|
+
widest: { declared: 0, observed: 0 },
|
|
118
|
+
unobserved: [],
|
|
119
|
+
problem,
|
|
120
|
+
});
|
|
121
|
+
/**
|
|
122
|
+
* Fold the epic's waves off already-read events, reporting a refusal rather
|
|
123
|
+
* than propagating it. See {@link EpicConcurrency.problem}: width never blocks
|
|
124
|
+
* a close, so nothing about measuring it may be able to stop one.
|
|
125
|
+
*/
|
|
126
|
+
export function readEpicConcurrency(events, epicId) {
|
|
127
|
+
try {
|
|
128
|
+
return summariseEpicConcurrency(auditWaveConcurrency(events, { epicId }));
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return UNREADABLE_CONCURRENCY(err instanceof Error ? err.message : String(err));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* D-42/P9-26: every gate this factory runs, runs inside a task worktree, so
|
|
136
|
+
* every quality claim it makes is a claim about a worktree. The dogfood epic
|
|
137
|
+
* reached a `ship` verdict with six green per-task gates and not one command
|
|
138
|
+
* ever run against the assembled branch — which was broken, and stayed broken
|
|
139
|
+
* until a human typed `pnpm lint` at the root. These blockers are the epic
|
|
140
|
+
* gate refusing to certify what it has not seen: an assembled branch is
|
|
141
|
+
* shippable only when the suite ran at the integration root, passed, and ran
|
|
142
|
+
* against the commit the branch is on now.
|
|
143
|
+
*/
|
|
144
|
+
function integrationBlockers(epicId, integration) {
|
|
145
|
+
const branch = `smith/${epicId}/${RESERVED_TASK_ID}`;
|
|
146
|
+
const { check, headSha } = integration;
|
|
147
|
+
if (check === null) {
|
|
148
|
+
return [
|
|
149
|
+
`Epic "${epicId}" has no integration-root check on record: no command has ever run against ${branch}. Per-task gates only ever saw a worktree (D-42).`,
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
// Unknown head: the record cannot be shown to cover anything. Fail closed —
|
|
153
|
+
// the alternative is trusting a sha nobody can compare against.
|
|
154
|
+
if (headSha === null) {
|
|
155
|
+
return [
|
|
156
|
+
`Could not read the head of ${branch}, so the integration-root check recorded at ${check.headSha.slice(0, 8)} cannot be shown to cover it.`,
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
if (check.headSha !== headSha) {
|
|
160
|
+
return [
|
|
161
|
+
`The integration-root check for "${epicId}" is stale: it ran against ${check.headSha.slice(0, 8)}, and ${branch} is now at ${headSha.slice(0, 8)}. Anything merged since is uncertified.`,
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
if (!check.pass) {
|
|
165
|
+
const failed = check.results.filter((r) => !r.pass).map((r) => r.name);
|
|
166
|
+
const named = failed.length > 0 ? failed.join(', ') : 'unnamed check(s)';
|
|
167
|
+
return [
|
|
168
|
+
`The integration-root check for "${epicId}" failed at ${headSha.slice(0, 8)}: ${named}.`,
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Pure readiness check, no I/O: given the epic's own task rows (foldTasks()
|
|
175
|
+
* output, already filtered to this epic) and its findings (listFindings()
|
|
176
|
+
* output), decide whether the epic is mechanically ready to open its
|
|
177
|
+
* integration PR. "Terminal-OK" mirrors db/queries.ts's
|
|
178
|
+
* MILESTONE_COMPLETE_TASK_STATUSES precedent (completed, waived) — a
|
|
179
|
+
* superseded/failed/escalated/blocked/in-progress/etc. task is not one of
|
|
180
|
+
* those, so it blocks. "Open" finding mirrors db/queries.ts's
|
|
181
|
+
* OPEN_FINDING_STATUSES precedent (raised, confirmed, fix-pending,
|
|
182
|
+
* fix-landed, amend-pending) — a finding only stops being open at
|
|
183
|
+
* fix-verified, waived, or amended. `amend-pending` is the D-127 case: the
|
|
184
|
+
* amendment is written but the tasks it names have not landed, so it is open
|
|
185
|
+
* here until they do, and only then does closeEpic take it to `amended`.
|
|
186
|
+
*
|
|
187
|
+
* `integration` is REQUIRED, deliberately. An optional parameter defaulting
|
|
188
|
+
* to "no check needed" would recreate the exact hole D-42/P9-26 closes: the
|
|
189
|
+
* one caller that forgot it would get a `ship` verdict for an epic nothing
|
|
190
|
+
* had ever been run against, and it would look like every other green run.
|
|
191
|
+
* `mcp` is required for the same reason — pass MCP_SURFACE_NOT_REQUIRED to
|
|
192
|
+
* say "this epic owes no surface" out loud, so a skip reads as a decision in
|
|
193
|
+
* the diff rather than as an omission. `specReview` is required with no escape
|
|
194
|
+
* value at all: an epic can legitimately owe no MCP surface, but every epic
|
|
195
|
+
* has a plan, and only a review run after the code exists can see the defects
|
|
196
|
+
* the code reveals (P9-9/D-33). `goalCheck` is required on the same terms and
|
|
197
|
+
* for the sharper reason: every gate above it reads text the planner wrote, so
|
|
198
|
+
* a plan that decomposes the wrong goal passes all of them, and the roadmap
|
|
199
|
+
* goal is the only reference in this pipeline the planner did not author.
|
|
200
|
+
*
|
|
201
|
+
* `plan` is the epic's live plan roster, or null when it has no plan file.
|
|
202
|
+
* D-126: without it the roster is the event-log fold alone, so a task the plan
|
|
203
|
+
* claims and the log has no events for is not "not done" — it is absent, and
|
|
204
|
+
* absence votes yes. `envkit-mcp-surface` closed `go` on a four-task roster
|
|
205
|
+
* while its live plan v5 held five. The fifth had in fact been built and
|
|
206
|
+
* merged, which is the point: the gate certified an epic it had no record of
|
|
207
|
+
* the work for, and the code being there anyway was luck rather than evidence.
|
|
208
|
+
* Null is a real answer, not a default: Phase 9's
|
|
209
|
+
* epics were driven as punch-list branches with no plan file to consult, and a
|
|
210
|
+
* plan that does not exist must cast no vote rather than an unclearable one.
|
|
211
|
+
* The plan's own `task_status` never satisfies the gate — the plan file is
|
|
212
|
+
* operator-writable and the log is hash-chained, so a plan row claiming
|
|
213
|
+
* `completed` with nothing behind it is a claim, not evidence.
|
|
214
|
+
*
|
|
215
|
+
* `tasks` carries each row's gate evidence (D-138) — a bare foldTasks() row no
|
|
216
|
+
* longer typechecks here, deliberately. `envkit-mcp-followup` closed `ship`
|
|
217
|
+
* with three of its four tasks holding a `gate-outcome` someone had typed:
|
|
218
|
+
* their session contains exactly one `task-result-recorded`, for the one task
|
|
219
|
+
* actually replayed through the gate. The gate had been reading the record as
|
|
220
|
+
* the thing it records. A task claimed done now has to show the run.
|
|
221
|
+
*
|
|
222
|
+
* `quarantined` is `foldFindingsDetailed`'s skipped list (D-135): records the
|
|
223
|
+
* fold could not turn into findings. It defaults to `[]` — unlike `integration`
|
|
224
|
+
* and `mcp`, which are required precisely so a forgotten argument cannot
|
|
225
|
+
* manufacture a green — because omitting it leaves the gate exactly as
|
|
226
|
+
* informed as it was before this parameter existed, and the one production
|
|
227
|
+
* caller threads it from the same fold that produced `findings`.
|
|
228
|
+
*
|
|
229
|
+
* `concurrency` follows the same trailing-optional shape and for the same
|
|
230
|
+
* reason, but its default carries a stronger claim: `null` means nobody
|
|
231
|
+
* measured how wide this epic ran, and it is projected as `null` rather than
|
|
232
|
+
* dropped so it can never be read as "it ran fine". It contributes nothing to
|
|
233
|
+
* `blockers` — see {@link EpicConcurrency}.
|
|
234
|
+
*/
|
|
235
|
+
export function summarizeEpic(epicId, tasks, findings, integration, mcp, specReview, goalCheck, plan = null, quarantined = [], concurrency = null) {
|
|
236
|
+
const taskSummaries = tasks.map((t) => ({
|
|
237
|
+
taskId: t.taskId,
|
|
238
|
+
taskStatus: t.taskStatus,
|
|
239
|
+
}));
|
|
240
|
+
const nonTerminal = taskSummaries.filter((t) => !TERMINAL_OK_TASK_STATUSES.has(t.taskStatus));
|
|
241
|
+
// D-138: only tasks claimed done are asked for evidence. One still in flight
|
|
242
|
+
// has not been gated yet and already blocks for not being terminal-OK —
|
|
243
|
+
// repeating it here would make an in-progress task read like a forged one.
|
|
244
|
+
const ungated = tasks.filter((t) => TERMINAL_OK_TASK_STATUSES.has(t.taskStatus) && !(t.gate.gateOutcome && t.gate.resultRecorded));
|
|
245
|
+
const ungatedTasks = ungated.map((t) => ({
|
|
246
|
+
taskId: t.taskId,
|
|
247
|
+
taskStatus: t.taskStatus,
|
|
248
|
+
}));
|
|
249
|
+
// Both registers spell ids either way (D-46/P9-29), so compare on the bare
|
|
250
|
+
// form or every planned task reads as undispatched.
|
|
251
|
+
const logged = new Set(taskSummaries.map((t) => bareTaskId(epicId, t.taskId)));
|
|
252
|
+
const undispatchedTasks = (plan?.tasks ?? []).filter((t) => !logged.has(bareTaskId(epicId, t.taskId)));
|
|
253
|
+
const toFindingSummary = (f) => ({
|
|
254
|
+
findingId: f.finding_id,
|
|
255
|
+
taskId: f.task_id,
|
|
256
|
+
severity: f.severity,
|
|
257
|
+
findingStatus: f.finding_status,
|
|
258
|
+
summary: f.summary,
|
|
259
|
+
});
|
|
260
|
+
const openFindings = [];
|
|
261
|
+
const discretionaryFindings = [];
|
|
262
|
+
const satisfiedAmendments = [];
|
|
263
|
+
const findingBlockers = [];
|
|
264
|
+
for (const f of findings) {
|
|
265
|
+
if (!OPEN_FINDING_STATUSES.has(f.finding_status)) {
|
|
266
|
+
// Closed either way, so the readiness answer is the same; D-120 keeps
|
|
267
|
+
// the two kinds of closed apart for the judge, which is the only reader
|
|
268
|
+
// that can ask whether a waiver should have been one.
|
|
269
|
+
if (DISCRETIONARY_FINDING_STATUSES.has(f.finding_status))
|
|
270
|
+
discretionaryFindings.push(toFindingSummary(f));
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (f.finding_status !== AMEND_PENDING_STATUS) {
|
|
274
|
+
openFindings.push(toFindingSummary(f));
|
|
275
|
+
findingBlockers.push(`Finding "${f.finding_id}" on "${f.task_id}" is still open (status: ${f.finding_status}, severity: ${f.severity}).`);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
// D-127 Part B: an amend-pending finding is discharged by its own
|
|
279
|
+
// obligation — every id in amends_task_ids landed terminal-OK at or after
|
|
280
|
+
// amends_plan_version — not by the raw status alone. Ids compare bare
|
|
281
|
+
// (D-46/P9-29): amends_task_ids comes from the plan side, the fold rows
|
|
282
|
+
// from the event side, and the two registers spell ids either way.
|
|
283
|
+
const obligationIds = f.amends_task_ids ?? [];
|
|
284
|
+
const version = f.amends_plan_version;
|
|
285
|
+
// The rows that did discharge, kept as they are found: closeEpic has to
|
|
286
|
+
// hand them to transition() as evidence, and re-deriving them there would
|
|
287
|
+
// be the same computation answering the same question twice.
|
|
288
|
+
const satisfiedBy = [];
|
|
289
|
+
// D-21: `amends_task_ids` is typed `string[]`, but it is folded off an
|
|
290
|
+
// event payload nobody has validated — a malformed amendment (the same
|
|
291
|
+
// supersede-as-array mistake `draftNextVersion` now refuses) could still
|
|
292
|
+
// write a non-string entry, e.g. `[null, "epic-1/task-2"]`. `bareTaskId`
|
|
293
|
+
// calls `.startsWith` on its argument, so passing the malformed entry to
|
|
294
|
+
// it crashed `summarizeEpic` outright — and with it `smith epic verdict`
|
|
295
|
+
// and `smith epic close` — rendering nothing at all. The house rule for a
|
|
296
|
+
// check that cannot answer (dispatch check / escalation check's
|
|
297
|
+
// `unverifiable`) is that it must not read as a pass: the malformed entry
|
|
298
|
+
// is named as its own blocker below and never reaches `bareTaskId`, but it
|
|
299
|
+
// still counts against discharge, so a corrupt amendment cannot silently
|
|
300
|
+
// satisfy itself. The well-formed ids sharing its list are unaffected —
|
|
301
|
+
// they are genuine obligations and are still evaluated normally.
|
|
302
|
+
// D-21 Part 4 review finding (S3 behavioral-drift): this used to filter on
|
|
303
|
+
// `typeof id !== 'string'` alone, so `""` read as well-formed here while
|
|
304
|
+
// repairObligation's guard 1 (findings.ts) already calls it corrupt --
|
|
305
|
+
// `bareTaskId`/`taskIdsMatch` can never match `""` to a real task id, so
|
|
306
|
+
// an empty entry sat outstanding forever, never malformed and never
|
|
307
|
+
// landable. isNonEmptyString is the shared definition now: epic.ts is the
|
|
308
|
+
// side that had to move, not the guard.
|
|
309
|
+
const malformedObligations = obligationIds.filter((id) => !isNonEmptyString(id));
|
|
310
|
+
const wellFormedObligationIds = obligationIds.filter(isNonEmptyString);
|
|
311
|
+
const outstanding = obligationIds.length === 0
|
|
312
|
+
? // An amendment naming no ids can never be satisfied. transition()'s
|
|
313
|
+
// own amendment-without-obligation guard should have refused this at
|
|
314
|
+
// the write (D-127), but a hand-edited or pre-guard record must not
|
|
315
|
+
// silently read as done — null here, not [], is what distinguishes
|
|
316
|
+
// "nothing to wait on" from "waiting on nothing outstanding".
|
|
317
|
+
null
|
|
318
|
+
: wellFormedObligationIds.filter((id) => {
|
|
319
|
+
const bare = bareTaskId(epicId, id);
|
|
320
|
+
const row = tasks.find((t) => bareTaskId(epicId, t.taskId) === bare);
|
|
321
|
+
const landed = row !== undefined &&
|
|
322
|
+
TERMINAL_OK_TASK_STATUSES.has(row.taskStatus) &&
|
|
323
|
+
version !== undefined &&
|
|
324
|
+
row.planVersion !== null &&
|
|
325
|
+
row.planVersion >= version;
|
|
326
|
+
if (landed && row.planVersion !== null)
|
|
327
|
+
satisfiedBy.push({ taskId: row.taskId, planVersion: row.planVersion });
|
|
328
|
+
return !landed;
|
|
329
|
+
});
|
|
330
|
+
if (malformedObligations.length === 0 && outstanding !== null && outstanding.length === 0) {
|
|
331
|
+
satisfiedAmendments.push({
|
|
332
|
+
...toFindingSummary(f),
|
|
333
|
+
satisfiedBy,
|
|
334
|
+
// D-21 Part 4: never let a clean discharge read as ordinary when the
|
|
335
|
+
// obligation it rested on was corrected -- carry the repair's own
|
|
336
|
+
// reason, omitted entirely (not merely undefined) when there was none.
|
|
337
|
+
...(f.obligation_repair_reason !== undefined
|
|
338
|
+
? { repairedObligationReason: f.obligation_repair_reason }
|
|
339
|
+
: {}),
|
|
340
|
+
});
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
openFindings.push(toFindingSummary(f));
|
|
344
|
+
if (malformedObligations.length > 0) {
|
|
345
|
+
findingBlockers.push(`Finding "${f.finding_id}" carries ${malformedObligations.length} malformed ` +
|
|
346
|
+
`amends_task_ids entr${malformedObligations.length === 1 ? 'y' : 'ies'} ` +
|
|
347
|
+
`(${malformedObligations.map(describeMalformedTaskId).join(', ')}), not a task id, so it cannot ` +
|
|
348
|
+
'be checked as landed — this finding cannot be counted discharged. Repair or ' +
|
|
349
|
+
'supersede the amendment record.');
|
|
350
|
+
}
|
|
351
|
+
if (outstanding === null) {
|
|
352
|
+
findingBlockers.push(`Finding "${f.finding_id}" is amend-pending but names no task ids to wait on — it can never be discharged this way; check how it entered amend-pending.`);
|
|
353
|
+
}
|
|
354
|
+
else if (outstanding.length > 0) {
|
|
355
|
+
findingBlockers.push(`Finding "${f.finding_id}" is amend-pending on plan v${version}: waiting on ${outstanding.join(', ')} to land terminal-OK at v${version} or later.`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const blockers = [
|
|
359
|
+
// An epic with no tasks is vacuously "all tasks done", which would let a
|
|
360
|
+
// typo'd --epic (or an epic whose plan was never dispatched) sail through
|
|
361
|
+
// as `go`. Nothing to integrate is a hold, not a pass.
|
|
362
|
+
...(taskSummaries.length === 0
|
|
363
|
+
? [`Epic "${epicId}" has no tasks in the event log — nothing to integrate.`]
|
|
364
|
+
: []),
|
|
365
|
+
...nonTerminal.map((t) => `Task "${t.taskId}" is not terminal-OK (status: ${t.taskStatus}).`),
|
|
366
|
+
...undispatchedTasks.map((t) => `Task "${t.taskId}" is in plan v${plan?.version} but has no events in the log — nothing records it as dispatched, let alone done (plan status: ${t.taskStatus}).`),
|
|
367
|
+
...ungated.map((t) => t.gate.gateOutcome
|
|
368
|
+
? `Task "${t.taskId}" is recorded ${t.taskStatus} and has a gate-outcome, but no task-result-recorded for it exists in the log. \`gate run\` writes both for the task it grades, so this outcome was written by hand — it is a claim, not a gate.`
|
|
369
|
+
: `Task "${t.taskId}" is recorded ${t.taskStatus} with no gate-outcome in the log at all — nothing gated it. Run \`smith gate run\` for it, or supersede the record.`),
|
|
370
|
+
...findingBlockers,
|
|
371
|
+
// A record the fold could not read is a finding of UNKNOWN status, and
|
|
372
|
+
// the open/closed split above simply does not contain it. Treating it as
|
|
373
|
+
// closed is absence read as a yes — the same error as counting an
|
|
374
|
+
// undispatched task done (D-126).
|
|
375
|
+
...quarantined.map((q) => `Finding record ${q.event_id}${q.finding_id ? ` ("${q.finding_id}")` : ''} could not be read: ${q.reason}. Its status is unknown, so it cannot be counted as closed — repair or supersede the record before closing "${epicId}".`),
|
|
376
|
+
...integrationBlockers(epicId, integration),
|
|
377
|
+
// docs/standards/mcp.md step 4: the surface milestone's epics do not close
|
|
378
|
+
// while `smith mcp check` is red.
|
|
379
|
+
...mcpBlockers(epicId, mcp),
|
|
380
|
+
// The roster is what knows the live plan version, so the review's own
|
|
381
|
+
// plan_version has something to be stale against (D-125).
|
|
382
|
+
...specReviewBlockers(epicId, specReview, plan?.version ?? null),
|
|
383
|
+
...goalCheckBlockers(epicId, goalCheck, plan?.version ?? null, plan?.tasks.map((t) => t.taskId) ?? []),
|
|
384
|
+
];
|
|
385
|
+
return {
|
|
386
|
+
epicId,
|
|
387
|
+
tasks: taskSummaries,
|
|
388
|
+
// Undispatched tasks are unfinished work, so the one number the judge
|
|
389
|
+
// prompt and the closed record both read has to include them.
|
|
390
|
+
nonTerminalTaskCount: nonTerminal.length + undispatchedTasks.length,
|
|
391
|
+
planVersion: plan?.version ?? null,
|
|
392
|
+
undispatchedTasks,
|
|
393
|
+
ungatedTasks,
|
|
394
|
+
waivedTasks: taskSummaries.filter((t) => t.taskStatus === WAIVED_TASK_STATUS),
|
|
395
|
+
openFindings,
|
|
396
|
+
discretionaryFindings,
|
|
397
|
+
satisfiedAmendments,
|
|
398
|
+
integration,
|
|
399
|
+
mcp,
|
|
400
|
+
specReview,
|
|
401
|
+
goalCheck,
|
|
402
|
+
concurrency,
|
|
403
|
+
blockers,
|
|
404
|
+
mechanicallyReady: blockers.length === 0,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
const findingLine = (f) => ` ${f.findingId} (${f.taskId}, ${f.severity}, ${f.findingStatus}): ${f.summary}`;
|
|
408
|
+
/** An empty list rendered as nothing reads as a section the prompt forgot. */
|
|
409
|
+
const listOr = (lines, empty = ' (none)') => lines.length > 0 ? lines : [empty];
|
|
410
|
+
/**
|
|
411
|
+
* Whether a recorded sha still covers the branch. Every caller here runs after
|
|
412
|
+
* the matching blocker has already passed, so `(the current head)` is the
|
|
413
|
+
* expected answer — the other two exist so that a record which somehow reached
|
|
414
|
+
* a judge without covering anything says so rather than showing a bare sha the
|
|
415
|
+
* judge has no way to place.
|
|
416
|
+
*/
|
|
417
|
+
function coverage(recorded, current) {
|
|
418
|
+
if (current === null)
|
|
419
|
+
return ' (the current head could not be read)';
|
|
420
|
+
if (current === recorded)
|
|
421
|
+
return ' (the current head)';
|
|
422
|
+
return ` (stale — the branch is now at ${current.slice(0, 8)})`;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* D-198. Both null branches used to say the same thing, and the violation
|
|
426
|
+
* branch said `[object Object]` — `violations` holds records, and `join` calls
|
|
427
|
+
* String() on each. The one sentence describing HOW a surface is red carried
|
|
428
|
+
* nothing a judge could be wrong about, which is the material D-120 exists to
|
|
429
|
+
* supply. mcpBlockers renders the same records correctly two functions over.
|
|
430
|
+
*/
|
|
431
|
+
function mcpVerdict(mcp) {
|
|
432
|
+
if (mcp.check === null) {
|
|
433
|
+
return mcp.problem === 'unreadable'
|
|
434
|
+
? 'no verdict could be rendered — the manifest exists and could not be read'
|
|
435
|
+
: 'no verdict could be rendered — there is no manifest';
|
|
436
|
+
}
|
|
437
|
+
if (mcp.check.ok)
|
|
438
|
+
return 'clean';
|
|
439
|
+
const rules = mcp.check.violations.map((v) => `${v.rule} at ${v.path} — ${v.message}`);
|
|
440
|
+
return `${mcp.check.violations.length} violation(s): ${rules.join('; ')}`;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Pure prompt builder, mirrors quorum.ts's findingJudgeRequest() style and
|
|
444
|
+
* trust boundary: never file contents or a diff — this module is a fold over
|
|
445
|
+
* events and has no source to hand over.
|
|
446
|
+
*
|
|
447
|
+
* D-120 is why it carries more than the claim. The trigger fired twice on
|
|
448
|
+
* byte-identical input (4 tasks completed, 0 non-terminal, 0 open findings) and
|
|
449
|
+
* answered `refute` then `confirm`, same model, minutes apart. Neither answer
|
|
450
|
+
* was derivable: the old prompt was a refute mandate, a status table, and the
|
|
451
|
+
* sentence "you do not have file contents or the diff". A judge that takes the
|
|
452
|
+
* mandate seriously must refute every epic forever; one that does not must
|
|
453
|
+
* confirm every epic forever. Which you get is sampling noise, and the day a
|
|
454
|
+
* provider is promoted to `mode: active` that coin acquires gating power.
|
|
455
|
+
*
|
|
456
|
+
* The fix is not a sharper mandate. It is material the judge can be WRONG
|
|
457
|
+
* about, and the summary already holds it: which commands actually ran against
|
|
458
|
+
* the assembled branch and how each exited, who closed the spec review and
|
|
459
|
+
* against what, the MCP surface verdict, and every closure a person decided
|
|
460
|
+
* rather than earned. summarizeEpic drops all of it from `blockers` because
|
|
461
|
+
* none of it blocks — which is exactly the argument for showing it here. A
|
|
462
|
+
* mechanically perfect epic can be a single `lint` command and four waivers,
|
|
463
|
+
* and that is a judgement about a list, which is the one thing a judge can do
|
|
464
|
+
* that a blocker cannot.
|
|
465
|
+
*
|
|
466
|
+
* NEVER include CheckResult.tail: it is the last 50 lines of combined
|
|
467
|
+
* stdout+stderr from commands run at the integration root, and this prompt goes
|
|
468
|
+
* to an external provider (guardrails.md, "No secrets in outputs"). The name
|
|
469
|
+
* and the exit code are the refutable part; the output is not.
|
|
470
|
+
*/
|
|
471
|
+
export function epicVerdictJudgeRequest(summary, budget) {
|
|
472
|
+
const taskLines = summary.tasks.length > 0
|
|
473
|
+
? summary.tasks.map((t) => ` ${t.taskId}: ${t.taskStatus}`).join('\n')
|
|
474
|
+
: ' (no tasks)';
|
|
475
|
+
// The plan's side of the roster, stated separately: these ids have no events
|
|
476
|
+
// at all, so their status is what the plan claims, not what happened (D-126).
|
|
477
|
+
const undispatchedLines = summary.undispatchedTasks.length > 0
|
|
478
|
+
? summary.undispatchedTasks
|
|
479
|
+
.map((t) => ` ${t.taskId}: plan v${summary.planVersion} says ${t.taskStatus}`)
|
|
480
|
+
.join('\n')
|
|
481
|
+
: summary.planVersion === null
|
|
482
|
+
? ' (no plan file for this epic)'
|
|
483
|
+
: ' (none)';
|
|
484
|
+
const findingLines = summary.openFindings.length > 0 ? summary.openFindings.map(findingLine).join('\n') : ' (none)';
|
|
485
|
+
// The assembled branch. `integrationBlockers` above holds the epic on every
|
|
486
|
+
// shape but this one, so by the time a judge runs the check is present,
|
|
487
|
+
// passing, and current — the refutable question left is what it RAN.
|
|
488
|
+
const branch = `smith/${summary.epicId}/${RESERVED_TASK_ID}`;
|
|
489
|
+
const { check, headSha } = summary.integration;
|
|
490
|
+
const integrationLines = check === null
|
|
491
|
+
? [` There is no integration-root check on record for ${branch}.`]
|
|
492
|
+
: [
|
|
493
|
+
` Branch: ${branch}`,
|
|
494
|
+
` Checked at commit ${check.headSha.slice(0, 8)}${coverage(check.headSha, headSha)}, recorded ${check.ts}`,
|
|
495
|
+
` Result: ${check.pass ? 'passed' : 'FAILED'} — ${check.results.length} command${check.results.length === 1 ? '' : 's'} ran:`,
|
|
496
|
+
// Name and exit code only — see this function's header on tail.
|
|
497
|
+
...listOr(check.results.map((r) => ` ${r.name}: ${r.pass ? 'passed' : 'FAILED'} (exit ${r.exitCode})`), ' (none — the check recorded no commands at all)'),
|
|
498
|
+
];
|
|
499
|
+
const review = summary.specReview.review;
|
|
500
|
+
const specReviewLines = review === null
|
|
501
|
+
? [' There is no closing spec review on record for this epic.']
|
|
502
|
+
: [
|
|
503
|
+
` Reviewed by: ${review.reviewedBy}`,
|
|
504
|
+
` Against commit ${review.headSha.slice(0, 8)}${coverage(review.headSha, summary.specReview.headSha)}, plan v${review.planVersion}`,
|
|
505
|
+
` Findings that review raised: ${review.findingIds.length}${review.findingIds.length > 0 ? ` (${review.findingIds.join(', ')})` : ''}`,
|
|
506
|
+
];
|
|
507
|
+
const goalText = summary.goalCheck.goal;
|
|
508
|
+
const goalRecord = summary.goalCheck.check;
|
|
509
|
+
const goalCheckLines = goalText.goal === null
|
|
510
|
+
? [
|
|
511
|
+
` No roadmap milestone states a goal for this epic${goalText.milestoneId === null ? '' : ` (milestone ${goalText.milestoneId})`}.`,
|
|
512
|
+
]
|
|
513
|
+
: [
|
|
514
|
+
` Goal (milestone ${goalText.milestoneId}): ${goalText.goal}`,
|
|
515
|
+
...(goalRecord === null
|
|
516
|
+
? [' There is no spec-vs-goal check on record for this epic.']
|
|
517
|
+
: [
|
|
518
|
+
` Checked by: ${goalRecord.checkedBy}, against plan v${goalRecord.planVersion}`,
|
|
519
|
+
` Goal digest read: ${goalRecord.goalDigest}${goalRecord.goalDigest === goalText.digest ? ' (current)' : ` — the roadmap now digests to ${goalText.digest}`}`,
|
|
520
|
+
// Verdict per clause, in the goal's own order. An out-of-scope
|
|
521
|
+
// dismissal prints its reason: it is the one verdict a judge
|
|
522
|
+
// can use to make a clause disappear, so it is the one an
|
|
523
|
+
// operator most needs to read back.
|
|
524
|
+
...goalRecord.coverage.map((entry, i) => ` Clause ${i + 1} [${entry.verdict}]: ${entry.clause}${entry.verdict === 'covered'
|
|
525
|
+
? ` — ${(entry.taskIds ?? []).join(', ')}`
|
|
526
|
+
: entry.verdict === 'out-of-scope'
|
|
527
|
+
? ` — dismissed: ${entry.reason ?? ''}`
|
|
528
|
+
: ''}`),
|
|
529
|
+
]),
|
|
530
|
+
];
|
|
531
|
+
const mcpLine = !summary.mcp.required
|
|
532
|
+
? ' This epic owes no MCP surface.'
|
|
533
|
+
: ` Milestone ${summary.mcp.milestoneId ?? '(unnamed)'}, manifest ${summary.mcp.manifestPath ?? '(none)'}: ${mcpVerdict(summary.mcp)}`;
|
|
534
|
+
// The parallelism fact, stated whether or not it flatters the epic. Null is
|
|
535
|
+
// rendered rather than dropped: a missing section reads as an older prompt,
|
|
536
|
+
// and "nobody measured" is a different answer from "it ran one at a time".
|
|
537
|
+
const concurrency = summary.concurrency;
|
|
538
|
+
const concurrencyLines = concurrency === null
|
|
539
|
+
? [' Nothing measured this. No wave record was read for this epic.']
|
|
540
|
+
: concurrency.problem !== null
|
|
541
|
+
? [
|
|
542
|
+
` The wave record could not be read, so nothing here is a count: ${concurrency.problem}`,
|
|
543
|
+
]
|
|
544
|
+
: concurrency.waves === 0
|
|
545
|
+
? [' Waves admitted: 0 — no wave was ever cut under this epic id.']
|
|
546
|
+
: [
|
|
547
|
+
` Waves admitted: ${concurrency.waves}`,
|
|
548
|
+
` Width: ${concurrency.widest.declared} admitted at the widest, ${concurrency.widest.observed} ever in flight at once`,
|
|
549
|
+
` Verdict per wave: ${WAVE_VERDICTS.map((v) => `${v} ${concurrency.verdicts[v]}`).join(', ')}`,
|
|
550
|
+
` Waves with no dispatch on record: ${concurrency.unobserved.length}`,
|
|
551
|
+
...listOr(concurrency.unobserved.map((id) => ` wave ${id}: admitted, and the log holds no dispatch on record for it`), ' (none — every admitted wave has work behind it)'),
|
|
552
|
+
];
|
|
553
|
+
const prompt = [
|
|
554
|
+
'You are an adversarial critic in an automated epic-close gate.',
|
|
555
|
+
`Epic "${summary.epicId}" is claimed ready to open its integration PR.`,
|
|
556
|
+
'Your mandate is to REFUTE that claim: assume it is NOT ready until the',
|
|
557
|
+
'evidence below forces you to agree. Confirm only if every task is',
|
|
558
|
+
'genuinely done and no finding is genuinely still open.',
|
|
559
|
+
'',
|
|
560
|
+
`Epic: ${summary.epicId}`,
|
|
561
|
+
`Tasks not yet terminal-OK: ${summary.nonTerminalTaskCount}`,
|
|
562
|
+
'Tasks (from the event log):',
|
|
563
|
+
taskLines,
|
|
564
|
+
'',
|
|
565
|
+
`Planned but never dispatched: ${summary.undispatchedTasks.length}`,
|
|
566
|
+
undispatchedLines,
|
|
567
|
+
'',
|
|
568
|
+
`Open findings: ${summary.openFindings.length}`,
|
|
569
|
+
findingLines,
|
|
570
|
+
'',
|
|
571
|
+
'The assembled branch — what actually ran against the merged code:',
|
|
572
|
+
...integrationLines,
|
|
573
|
+
'',
|
|
574
|
+
'Closing spec review (run after the code existed, P9-9/D-33):',
|
|
575
|
+
...specReviewLines,
|
|
576
|
+
'',
|
|
577
|
+
'Spec vs goal — the plan against the one reference the planner did not write:',
|
|
578
|
+
...goalCheckLines,
|
|
579
|
+
'',
|
|
580
|
+
'MCP surface:',
|
|
581
|
+
mcpLine,
|
|
582
|
+
'',
|
|
583
|
+
'How wide this epic ran — this factory claims a plan is built by many',
|
|
584
|
+
'agents working its tasks at the same time, and this is the log read back:',
|
|
585
|
+
...concurrencyLines,
|
|
586
|
+
' A narrow epic is not by itself a refutation: a plan whose tasks genuinely',
|
|
587
|
+
' depend on one another has nothing to run side by side, and refuting on',
|
|
588
|
+
' width alone would make your verdict a constant rather than a measurement.',
|
|
589
|
+
' What is refutable here is a wave whose tasks were admitted and nothing',
|
|
590
|
+
' shows them running: that is a declaration with no work behind it.',
|
|
591
|
+
'',
|
|
592
|
+
'Discretionary closures — decided by a person, not shown by the machine:',
|
|
593
|
+
`Tasks waived rather than completed: ${summary.waivedTasks.length}`,
|
|
594
|
+
...listOr(summary.waivedTasks.map((t) => ` ${t.taskId}`)),
|
|
595
|
+
`Findings closed by waiver or amendment: ${summary.discretionaryFindings.length}`,
|
|
596
|
+
...listOr(summary.discretionaryFindings.map(findingLine)),
|
|
597
|
+
`Amendments this close will discharge: ${summary.satisfiedAmendments.length}`,
|
|
598
|
+
...listOr(summary.satisfiedAmendments.map((f) => `${findingLine(f)} — discharged by ${f.satisfiedBy
|
|
599
|
+
.map((d) => `${d.taskId} at plan v${d.planVersion}`)
|
|
600
|
+
.join(', ')}` +
|
|
601
|
+
// D-21 Part 4: a clean discharge must never read as ordinary when
|
|
602
|
+
// the obligation it rested on was corrected -- the judge is the
|
|
603
|
+
// reader this honesty requirement exists for.
|
|
604
|
+
(f.repairedObligationReason
|
|
605
|
+
? ` (obligation repaired: ${f.repairedObligationReason})`
|
|
606
|
+
: ''))),
|
|
607
|
+
'',
|
|
608
|
+
"What you have: the roster above and each task's status, the commands that",
|
|
609
|
+
'ran against the assembled branch and whether each passed, the closing spec',
|
|
610
|
+
'review, the MCP surface verdict, how wide the waves ran, and every closure',
|
|
611
|
+
'a person decided.',
|
|
612
|
+
'What you do NOT have: file contents, the diff for any task, or the output',
|
|
613
|
+
'of any command — only its name and how it exited.',
|
|
614
|
+
'',
|
|
615
|
+
'Refute if a line above is inconsistent with the readiness claim, and name',
|
|
616
|
+
'the line you are refuting. Do not refute merely because the diff is absent:',
|
|
617
|
+
'that is true of every epic this gate will ever see, so it distinguishes',
|
|
618
|
+
'nothing and makes your verdict a constant rather than a measurement.',
|
|
619
|
+
'',
|
|
620
|
+
'Return only JSON matching judge-verdict.schema.json:',
|
|
621
|
+
'{"verdict": "confirm" | "refute", "rationale": "<one or two sentences>"}',
|
|
622
|
+
'"confirm" = the epic is genuinely ready to open its integration PR.',
|
|
623
|
+
'"refute" = it is not ready.',
|
|
624
|
+
].join('\n');
|
|
625
|
+
return {
|
|
626
|
+
kind: 'verify',
|
|
627
|
+
taskId: `${summary.epicId}/${RESERVED_TASK_ID}`,
|
|
628
|
+
inputRefs: { epic_id: summary.epicId },
|
|
629
|
+
prompt,
|
|
630
|
+
schemaName: 'judge-verdict',
|
|
631
|
+
budget,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/** A judge call is a network round-trip, not a test — same rationale and same numbers as
|
|
635
|
+
* gate.ts's DEFAULT_JUDGE_BUDGET; not imported from there since gate.ts doesn't export it and
|
|
636
|
+
* epic.ts must not depend on gate.ts (parallel quorum_triggers hosts, not a hierarchy). */
|
|
637
|
+
const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
|
|
638
|
+
function epicQuorumDecisionPayload(quorum, epicId, finderProvider, ready) {
|
|
639
|
+
const gating = quorum.gating;
|
|
640
|
+
return {
|
|
641
|
+
task_id: `${epicId}/${RESERVED_TASK_ID}`,
|
|
642
|
+
epic_id: epicId,
|
|
643
|
+
finding_id: null,
|
|
644
|
+
trigger_reason: 'epic-final-verdict',
|
|
645
|
+
finder_provider: finderProvider,
|
|
646
|
+
outcome: gating.outcome,
|
|
647
|
+
decision: gating.outcome === 'decided' ? gating.decision : null,
|
|
648
|
+
agreement: gating.outcome === 'decided' ? gating.agreement : null,
|
|
649
|
+
gating_participants: gating.outcome === 'decided' ? gating.participants : [],
|
|
650
|
+
escalation_reason: gating.outcome === 'escalate' ? gating.reason : null,
|
|
651
|
+
rationales: gating.outcome === 'escalate' ? gating.rationales : [],
|
|
652
|
+
participants: quorum.participants.map((p) => ({
|
|
653
|
+
provider: p.provider,
|
|
654
|
+
mode: p.mode,
|
|
655
|
+
ok: p.ok,
|
|
656
|
+
verdict: p.verdict,
|
|
657
|
+
excluded_as_finder: p.excludedAsFinder,
|
|
658
|
+
})),
|
|
659
|
+
native_verdict: 'confirm',
|
|
660
|
+
/** Whether the epic actually goes to integration after the quorum spoke. */
|
|
661
|
+
ready,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Read the epic's latest plan version and reduce it to the roster the gate
|
|
666
|
+
* reads. The only I/O between the plan file and summarizeEpic (D-126).
|
|
667
|
+
*
|
|
668
|
+
* A plan that cannot be read at all is treated as no plan rather than as a
|
|
669
|
+
* thrown error: `epic verdict` is an operator's read-only question, and
|
|
670
|
+
* failing it closed on a malformed file would make the answer unreachable
|
|
671
|
+
* exactly when the operator most needs it. The blocker set is unchanged in
|
|
672
|
+
* that case, and `planVersion: null` records that nothing was consulted.
|
|
673
|
+
*/
|
|
674
|
+
function resolvePlanRoster(epicId, planOpts) {
|
|
675
|
+
const version = latestPlanVersion(epicId, planOpts);
|
|
676
|
+
if (version === null)
|
|
677
|
+
return null;
|
|
678
|
+
try {
|
|
679
|
+
const plan = loadPlan(epicId, version, planOpts);
|
|
680
|
+
return {
|
|
681
|
+
version,
|
|
682
|
+
tasks: livePlanTasks(plan).map((t) => ({ taskId: t.task_id, taskStatus: t.task_status })),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Operator-invoked verdict for one epic: mechanical readiness first, then
|
|
691
|
+
* (only when mechanically ready and it isn't free to skip) a cross-provider
|
|
692
|
+
* quorum on the claim "this epic is ready to open its integration PR". Never
|
|
693
|
+
* dispatches anything itself — the caller decides what to do with `go`/`hold`.
|
|
694
|
+
*/
|
|
695
|
+
export async function runEpicVerdict(input, ctx, opts = {}) {
|
|
696
|
+
// Lineage (D-119). An epic is not a session: `validateCausalParent`'s own
|
|
697
|
+
// header calls chaining a fresh session onto a full one "the documented way
|
|
698
|
+
// to run a large epic", and this verdict read one session. Split the real
|
|
699
|
+
// dogfood-mcp-1 log in two and `hold` on eleven open findings became `go` on
|
|
700
|
+
// none — not a wrong number in a report, a close that the gate was built to
|
|
701
|
+
// refuse.
|
|
702
|
+
const events = await readLineageEvents(ctx.sessionId, opts);
|
|
703
|
+
// Membership is the row's field, not a prefix test on its id (D-49/P9-10):
|
|
704
|
+
// a task admitted under a bare id with the epic in its payload used to be
|
|
705
|
+
// invisible here, and an epic with real work in it read "no tasks".
|
|
706
|
+
// Gate evidence rides along from the same events (D-138) — no second read.
|
|
707
|
+
// The fold reports the status a task's record CLAIMS; withGateEvidence says
|
|
708
|
+
// whether the log holds the gate run behind the claim.
|
|
709
|
+
const tasks = withGateEvidence(foldTasks(events).filter((t) => t.epicId === input.epicId), events, input.epicId);
|
|
710
|
+
// Folded from `events` rather than re-read via listFindings: the detailed
|
|
711
|
+
// form is the only one that reports what it had to quarantine (D-135), and
|
|
712
|
+
// the gate is precisely the caller that must not treat an unreadable
|
|
713
|
+
// finding record as one fewer open finding.
|
|
714
|
+
const { findings, skipped } = foldFindingsDetailed(events, { epic: input.epicId });
|
|
715
|
+
const summary = summarizeEpic(input.epicId, tasks, findings, {
|
|
716
|
+
check: latestIntegrationCheck(events, input.epicId),
|
|
717
|
+
headSha: input.integrationHeadSha,
|
|
718
|
+
}, input.mcp,
|
|
719
|
+
// Folded from the log this call already read, and pinned to the same head
|
|
720
|
+
// as the integration check: the closing spec review reads the assembled
|
|
721
|
+
// branch, so "current" means the same commit for both.
|
|
722
|
+
{
|
|
723
|
+
review: latestSpecReview(events, input.epicId),
|
|
724
|
+
headSha: input.integrationHeadSha,
|
|
725
|
+
},
|
|
726
|
+
// Same shape, other axis: the recorded check comes from the log this call
|
|
727
|
+
// already read, and the goal it is measured against comes from the
|
|
728
|
+
// roadmap the caller resolved — a check is only evidence about the text it
|
|
729
|
+
// read, so the pair is what makes "current" decidable.
|
|
730
|
+
{ check: latestGoalCheck(events, input.epicId), goal: input.goal }, resolvePlanRoster(input.epicId, input.planOpts ?? {}), skipped,
|
|
731
|
+
// Off the same lineage `events` every other fact here came from — the
|
|
732
|
+
// wave record is in that log already, so measuring width costs this call
|
|
733
|
+
// no second read and no second command anyone has to remember to type.
|
|
734
|
+
readEpicConcurrency(events, input.epicId));
|
|
735
|
+
// Step 1 — mechanical_oracles_first, literally: a deterministic blocker is
|
|
736
|
+
// final. Zero judge calls, zero events (read-only projection).
|
|
737
|
+
if (!summary.mechanicallyReady) {
|
|
738
|
+
return { outcome: 'hold', epicId: input.epicId, summary, reason: 'mechanical-blockers' };
|
|
739
|
+
}
|
|
740
|
+
const policy = input.crosscheck?.policy ?? loadCrosscheckPolicy();
|
|
741
|
+
const providers = enabledExternalProviders(policy);
|
|
742
|
+
// Step 2 — zero-cost-by-default short-circuit, see module header §4.
|
|
743
|
+
if (providers.length === 0) {
|
|
744
|
+
return { outcome: 'go', epicId: input.epicId, summary };
|
|
745
|
+
}
|
|
746
|
+
const nativeProvider = nativeProviderName(policy);
|
|
747
|
+
const budget = input.crosscheck?.budget ?? DEFAULT_JUDGE_BUDGET;
|
|
748
|
+
const rationale = `${input.epicId}: ${summary.tasks.length} task(s), all terminal-OK; 0 open findings.`;
|
|
749
|
+
// Step 3 — the claim is native's own ("ready"); finder_ne_critic excludes
|
|
750
|
+
// it from gating (§4).
|
|
751
|
+
const quorum = await runQuorumCase({
|
|
752
|
+
taskId: `${input.epicId}/${RESERVED_TASK_ID}`,
|
|
753
|
+
triggerReason: 'epic-final-verdict',
|
|
754
|
+
finderProvider: nativeProvider,
|
|
755
|
+
kind: 'verify',
|
|
756
|
+
native: { provider: nativeProvider, verdict: 'confirm', rationale },
|
|
757
|
+
providers,
|
|
758
|
+
request: epicVerdictJudgeRequest(summary, budget),
|
|
759
|
+
policy,
|
|
760
|
+
quorumPolicy: { minProviders: policy.quorumRule.minProviders },
|
|
761
|
+
fetchImpl: input.crosscheck?.fetchImpl,
|
|
762
|
+
}, ctx, opts);
|
|
763
|
+
// Step 4 — map the quorum's gating decision to go/hold. escalate only
|
|
764
|
+
// holds when an ACTIVE judge actually ran (gate.ts's crossCheckFinding
|
|
765
|
+
// rule, reused verbatim): shadow-only/all-failed participants have zero
|
|
766
|
+
// gating power by construction, so there's nothing for an operator to
|
|
767
|
+
// arbitrate and the epic goes exactly as it would have with 0 externals.
|
|
768
|
+
const gating = quorum.gating;
|
|
769
|
+
let outcome;
|
|
770
|
+
if (gating.outcome === 'decided') {
|
|
771
|
+
outcome =
|
|
772
|
+
gating.decision === 'confirm'
|
|
773
|
+
? { outcome: 'go', epicId: input.epicId, summary, quorum }
|
|
774
|
+
: { outcome: 'hold', epicId: input.epicId, summary, reason: 'quorum-refuted', quorum };
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
const hadActiveJudge = quorum.participants.some((p) => p.mode === 'active' && p.ok && !p.excludedAsFinder);
|
|
778
|
+
outcome = hadActiveJudge
|
|
779
|
+
? { outcome: 'hold', epicId: input.epicId, summary, reason: gating.reason, quorum }
|
|
780
|
+
: { outcome: 'go', epicId: input.epicId, summary, quorum };
|
|
781
|
+
}
|
|
782
|
+
// Step 5 — emit exactly once, for any case that actually ran a quorum.
|
|
783
|
+
await appendEvent({
|
|
784
|
+
session_id: ctx.sessionId,
|
|
785
|
+
actor: ctx.actor ?? 'system',
|
|
786
|
+
event_type: 'quorum-decision',
|
|
787
|
+
task_id: `${input.epicId}/${RESERVED_TASK_ID}`,
|
|
788
|
+
plan_version: ctx.planVersion,
|
|
789
|
+
causal_parent: ctx.causalParent,
|
|
790
|
+
payload: epicQuorumDecisionPayload(quorum, input.epicId, nativeProvider, outcome.outcome === 'go'),
|
|
791
|
+
}, opts);
|
|
792
|
+
return outcome;
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* D-43/P9-27. Everything above is a read-only probe, and that is the point:
|
|
796
|
+
* in the zero-cost default config BOTH of runEpicVerdict's terminal outcomes
|
|
797
|
+
* return without appending anything, so asking "is this epic ready?" stays
|
|
798
|
+
* free and repeatable. The cost is that the dogfood epic was declared
|
|
799
|
+
* shippable, held on mechanical blockers, overridden by a human, merged and
|
|
800
|
+
* tagged — and the log recorded none of it. The verdict was a conversation,
|
|
801
|
+
* not a fact.
|
|
802
|
+
*
|
|
803
|
+
* `closeEpic` is the verb that makes it a fact. It runs the same probe and
|
|
804
|
+
* then writes down what the probe said, who closed on it, and — when a human
|
|
805
|
+
* closed over a hold — the blockers they overrode and why. An override stays
|
|
806
|
+
* possible, because a human shipping over a known carry-forward defect is
|
|
807
|
+
* legitimate; doing it silently is not.
|
|
808
|
+
*/
|
|
809
|
+
export class EpicCloseError extends SmithError {
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Re-exported, not re-declared — the same reason judges.ts re-exports
|
|
813
|
+
* `JUDGE_REPORT_EVENT_TYPE`. The registry folds this event now (D-187): it is
|
|
814
|
+
* what closes the agents still open when the verdict lands, so two copies of
|
|
815
|
+
* the literal would be two places to keep in sync, and the day they disagreed
|
|
816
|
+
* the registry would stop recognising the close this file writes.
|
|
817
|
+
*/
|
|
818
|
+
export { EPIC_CLOSED_EVENT_TYPE };
|
|
819
|
+
/**
|
|
820
|
+
* snake_case projection of the summary, so the payload reads like every other
|
|
821
|
+
* event's.
|
|
822
|
+
*
|
|
823
|
+
* Every field is projected even when null or empty, for the reason spelled out
|
|
824
|
+
* beside `plan_version` below: an absent key reads as an older event rather
|
|
825
|
+
* than as nothing to report, and "nothing to report" is itself the answer a
|
|
826
|
+
* reader of a close most often needs.
|
|
827
|
+
*/
|
|
828
|
+
function epicSummaryPayload(summary) {
|
|
829
|
+
const { check, headSha } = summary.integration;
|
|
830
|
+
const { review } = summary.specReview;
|
|
831
|
+
const taskRef = (t) => ({ task_id: t.taskId, task_status: t.taskStatus });
|
|
832
|
+
const findingRef = (f) => ({
|
|
833
|
+
finding_id: f.findingId,
|
|
834
|
+
task_id: f.taskId,
|
|
835
|
+
severity: f.severity,
|
|
836
|
+
finding_status: f.findingStatus,
|
|
837
|
+
summary: f.summary,
|
|
838
|
+
});
|
|
839
|
+
return {
|
|
840
|
+
tasks: summary.tasks.map((t) => ({ task_id: t.taskId, task_status: t.taskStatus })),
|
|
841
|
+
non_terminal_task_count: summary.nonTerminalTaskCount,
|
|
842
|
+
// Projected even when null/empty, for the same reason as spec_review below:
|
|
843
|
+
// "which plan version was consulted, and did it claim anything the log
|
|
844
|
+
// never saw" is exactly the question the envkit-mcp-surface close could not
|
|
845
|
+
// be asked afterwards (D-126).
|
|
846
|
+
plan_version: summary.planVersion,
|
|
847
|
+
undispatched_tasks: summary.undispatchedTasks.map((t) => ({
|
|
848
|
+
task_id: t.taskId,
|
|
849
|
+
plan_task_status: t.taskStatus,
|
|
850
|
+
})),
|
|
851
|
+
// D-120/D-138: the closures nobody re-derives afterwards. The judge is
|
|
852
|
+
// handed all of this (epicVerdictJudgeRequest), renders one verdict and is
|
|
853
|
+
// gone; this event is the only reader left. Without these three lists,
|
|
854
|
+
// "was this epic closed on waivers?" and "did any task close ungated?"
|
|
855
|
+
// are answerable for the length of one prompt and unanswerable after —
|
|
856
|
+
// and a `go` verdict is exactly the case where nothing else in the log
|
|
857
|
+
// says so, because none of it blocked.
|
|
858
|
+
ungated_tasks: summary.ungatedTasks.map(taskRef),
|
|
859
|
+
waived_tasks: summary.waivedTasks.map(taskRef),
|
|
860
|
+
open_findings: summary.openFindings.map(findingRef),
|
|
861
|
+
discretionary_findings: summary.discretionaryFindings.map(findingRef),
|
|
862
|
+
// D-127 Part B: satisfied amend-pending findings are not blockers, but
|
|
863
|
+
// closeEpic() has not discharged them yet at the point this payload is
|
|
864
|
+
// built for the verdict — projecting them here is what lets a reader see
|
|
865
|
+
// "this close is about to turn these amend-pending findings into amended"
|
|
866
|
+
// rather than inferring it from the finding-transitioned events after.
|
|
867
|
+
satisfied_amendments: summary.satisfiedAmendments.map((f) => ({
|
|
868
|
+
finding_id: f.findingId,
|
|
869
|
+
task_id: f.taskId,
|
|
870
|
+
severity: f.severity,
|
|
871
|
+
finding_status: f.findingStatus,
|
|
872
|
+
summary: f.summary,
|
|
873
|
+
// The evidence, not just the verdict: transition() will refuse to close
|
|
874
|
+
// these on anything less, so the record of the close carries the same
|
|
875
|
+
// proof the gate demanded rather than making a reader re-fold the tasks.
|
|
876
|
+
satisfied_by: f.satisfiedBy.map((row) => ({
|
|
877
|
+
task_id: row.taskId,
|
|
878
|
+
plan_version: row.planVersion,
|
|
879
|
+
})),
|
|
880
|
+
// D-21 Part 4: this record outlives the session -- it is what an
|
|
881
|
+
// operator auditing the close months later actually reads -- so the
|
|
882
|
+
// honesty requirement has to reach it, not only the live verdict
|
|
883
|
+
// epicVerdictJudgeRequest hands the judge. Omitted entirely (not
|
|
884
|
+
// merely null) when the finding's obligation was never repaired.
|
|
885
|
+
...(f.repairedObligationReason !== undefined
|
|
886
|
+
? { repaired_obligation_reason: f.repairedObligationReason }
|
|
887
|
+
: {}),
|
|
888
|
+
})),
|
|
889
|
+
integration: {
|
|
890
|
+
head_sha: headSha,
|
|
891
|
+
check: check === null ? null : { branch: check.branch, head_sha: check.headSha, pass: check.pass },
|
|
892
|
+
},
|
|
893
|
+
// The blockers list already names a broken rule, but it does not say the
|
|
894
|
+
// surface was checked, which manifest was read, or whether the epic was in
|
|
895
|
+
// the surface milestone at all — and on a green surface it says nothing.
|
|
896
|
+
//
|
|
897
|
+
// Each violation is projected as its rule and path only. A violation's
|
|
898
|
+
// `message` is manifest-derived text and this payload is persisted, which
|
|
899
|
+
// is the same reason D-198 keeps the JSON parser's message out of an
|
|
900
|
+
// mcpBlockers string.
|
|
901
|
+
mcp: {
|
|
902
|
+
required: summary.mcp.required,
|
|
903
|
+
milestone_id: summary.mcp.milestoneId,
|
|
904
|
+
manifest_path: summary.mcp.manifestPath,
|
|
905
|
+
problem: summary.mcp.problem,
|
|
906
|
+
check: summary.mcp.check === null
|
|
907
|
+
? null
|
|
908
|
+
: {
|
|
909
|
+
ok: summary.mcp.check.ok,
|
|
910
|
+
violations: summary.mcp.check.violations.map((v) => ({ rule: v.rule, path: v.path })),
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
// Projected even when null: "no closing spec review" is the fact the
|
|
914
|
+
// epic-closed record most needs to carry, and an absent key would read
|
|
915
|
+
// as an older event rather than as a missing review.
|
|
916
|
+
spec_review: review === null
|
|
917
|
+
? null
|
|
918
|
+
: {
|
|
919
|
+
plan_version: review.planVersion,
|
|
920
|
+
head_sha: review.headSha,
|
|
921
|
+
reviewed_by: review.reviewedBy,
|
|
922
|
+
finding_count: review.findingIds.length,
|
|
923
|
+
},
|
|
924
|
+
// Projected even at zero, and even as null, for the reason plan_version
|
|
925
|
+
// gives above. This is the only record that outlives the close, and the
|
|
926
|
+
// question it answers — did this epic actually run its plan in parallel,
|
|
927
|
+
// or did it run the plan one task at a time and call it a factory — has no
|
|
928
|
+
// other reader once the wave state dir is gone. Every key is spelled the
|
|
929
|
+
// same either side: the field names are single words, so there is no
|
|
930
|
+
// snake_case form of them to drift from.
|
|
931
|
+
concurrency: summary.concurrency === null
|
|
932
|
+
? null
|
|
933
|
+
: {
|
|
934
|
+
waves: summary.concurrency.waves,
|
|
935
|
+
verdicts: { ...summary.concurrency.verdicts },
|
|
936
|
+
widest: { ...summary.concurrency.widest },
|
|
937
|
+
unobserved: [...summary.concurrency.unobserved],
|
|
938
|
+
problem: summary.concurrency.problem,
|
|
939
|
+
},
|
|
940
|
+
mechanically_ready: summary.mechanicallyReady,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
export async function closeEpic(input, ctx, opts = {}) {
|
|
944
|
+
// D-45: `event tail` answers a typo'd session id with [] and exit 0. Close
|
|
945
|
+
// must not inherit that. Appending to an unknown session would create a
|
|
946
|
+
// brand-new log whose first line is "this epic is closed" — a record that
|
|
947
|
+
// is both true and unfindable, which is worse than the refusal.
|
|
948
|
+
//
|
|
949
|
+
// Deliberately `readEvents` and not the lineage read D-119 put everywhere
|
|
950
|
+
// else. The question here is "does THIS session have a log", and only this
|
|
951
|
+
// session's log answers it; a lineage read would be asking whether some
|
|
952
|
+
// ancestor exists, which is not what the refusal below says. Nothing is lost
|
|
953
|
+
// — every DECISION this function makes comes from runEpicVerdict, and that
|
|
954
|
+
// reads the lineage.
|
|
955
|
+
const events = await readEvents(ctx.sessionId, opts);
|
|
956
|
+
if (events.length === 0) {
|
|
957
|
+
throw new EpicCloseError('epic.unknown-session', `Refusing to close "${input.epicId}" against session "${ctx.sessionId}", which has no event log: the close would be the first line of a log nobody is reading.`, { epicId: input.epicId, sessionId: ctx.sessionId });
|
|
958
|
+
}
|
|
959
|
+
const rationale = input.overrideRationale?.trim() ?? '';
|
|
960
|
+
if (input.overrideRationale !== undefined && rationale === '') {
|
|
961
|
+
throw new EpicCloseError('epic.close-refused', `Refusing to close "${input.epicId}" with a blank override rationale: the reason is the whole point of recording the override.`, { epicId: input.epicId });
|
|
962
|
+
}
|
|
963
|
+
const verdict = await runEpicVerdict(input, ctx, opts);
|
|
964
|
+
const blockers = verdict.outcome === 'hold' ? verdict.summary.blockers : [];
|
|
965
|
+
if (verdict.outcome === 'hold' && rationale === '') {
|
|
966
|
+
throw new EpicCloseError('epic.close-refused', `Refusing to close "${input.epicId}": the verdict is hold (${verdict.reason}). Pass --override-rationale to close over it.\n${blockers.map((b) => ` - ${b}`).join('\n')}`, { epicId: input.epicId, reason: verdict.reason, blockers });
|
|
967
|
+
}
|
|
968
|
+
// D-127 Part B: a satisfied amendment is not a blocker (summarizeEpic
|
|
969
|
+
// already left it out of `blockers`/`openFindings`), but it is not a fact
|
|
970
|
+
// yet either — nothing has told the finding it may leave amend-pending.
|
|
971
|
+
// Discharge every satisfied amendment here, before the epic-closed event,
|
|
972
|
+
// so a reader scanning the log in order sees the amendments land and THEN
|
|
973
|
+
// the close that depended on them. This runs unconditionally, whether the
|
|
974
|
+
// close is a clean verdict or an operator override over an unrelated
|
|
975
|
+
// blocker: satisfaction was computed as a pure fact about the amendment
|
|
976
|
+
// itself, independent of what else is or isn't wrong with the epic, so the
|
|
977
|
+
// override question (should THIS close proceed) has no bearing on whether
|
|
978
|
+
// THIS amendment already discharged.
|
|
979
|
+
for (const amendment of verdict.summary.satisfiedAmendments) {
|
|
980
|
+
await transition(amendment.findingId, AMENDED_STATUS, ctx, opts, {
|
|
981
|
+
amendsSatisfiedBy: amendment.satisfiedBy,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
const closedBy = verdict.outcome === 'go' ? 'verdict' : 'operator-override';
|
|
985
|
+
const machineReason = verdict.outcome === 'hold' ? verdict.reason : null;
|
|
986
|
+
const stored = await appendEvent({
|
|
987
|
+
session_id: ctx.sessionId,
|
|
988
|
+
actor: ctx.actor ?? (closedBy === 'operator-override' ? 'operator' : 'system'),
|
|
989
|
+
event_type: EPIC_CLOSED_EVENT_TYPE,
|
|
990
|
+
// <epic>/integration, the established epic-level id. An unreserved
|
|
991
|
+
// suffix like <epic>/epic escapes foldTasks()'s isReservedRef guard and
|
|
992
|
+
// mints a phantom task row — which is exactly what the hand-written
|
|
993
|
+
// dogfood close did (D-44).
|
|
994
|
+
task_id: `${input.epicId}/${RESERVED_TASK_ID}`,
|
|
995
|
+
plan_version: ctx.planVersion,
|
|
996
|
+
causal_parent: ctx.causalParent,
|
|
997
|
+
payload: {
|
|
998
|
+
epic_id: input.epicId,
|
|
999
|
+
closed_by: closedBy,
|
|
1000
|
+
machine_verdict: verdict.outcome,
|
|
1001
|
+
machine_reason: machineReason,
|
|
1002
|
+
override_rationale: closedBy === 'operator-override' ? rationale : null,
|
|
1003
|
+
blockers,
|
|
1004
|
+
summary: epicSummaryPayload(verdict.summary),
|
|
1005
|
+
},
|
|
1006
|
+
}, opts);
|
|
1007
|
+
return {
|
|
1008
|
+
epicId: input.epicId,
|
|
1009
|
+
closedBy,
|
|
1010
|
+
machineVerdict: verdict.outcome,
|
|
1011
|
+
machineReason,
|
|
1012
|
+
overrideRationale: closedBy === 'operator-override' ? rationale : null,
|
|
1013
|
+
blockers,
|
|
1014
|
+
summary: verdict.summary,
|
|
1015
|
+
eventId: stored.event_id,
|
|
1016
|
+
ts: stored.record.ts,
|
|
1017
|
+
};
|
|
1018
|
+
}
|