@juzser/blacksmith 0.2.0 → 0.4.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 +3 -0
- package/.claude/agents/grader.md +12 -5
- package/.claude/agents/reviewer.md +3 -0
- package/.claude/agents/security-reviewer.md +3 -0
- package/.claude/agents/spec-reviewer.md +3 -0
- package/.claude/agents/verifier.md +3 -0
- package/.claude/skills/bs/audit.md +25 -9
- package/.claude/skills/bs/dispatch.md +60 -7
- package/.claude/skills/bs/plan.md +33 -1
- package/.claude/skills/bs/run.md +18 -13
- package/.claude/skills/bs/wave.md +68 -10
- package/README.md +4 -4
- package/factory/orchestrator/dist/agentsSync.js +178 -0
- package/factory/orchestrator/dist/audit.js +134 -17
- package/factory/orchestrator/dist/autonomy.js +25 -0
- package/factory/orchestrator/dist/budgets.js +91 -2
- package/factory/orchestrator/dist/claims.js +8 -4
- package/factory/orchestrator/dist/cli.js +394 -26
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/daemon.js +230 -41
- package/factory/orchestrator/dist/db/projector.js +93 -36
- package/factory/orchestrator/dist/db/queries.js +58 -35
- package/factory/orchestrator/dist/db/schema.js +24 -0
- package/factory/orchestrator/dist/dispatchAudit.js +10 -2
- package/factory/orchestrator/dist/dispatchLint.js +132 -0
- package/factory/orchestrator/dist/epic.js +192 -39
- package/factory/orchestrator/dist/epicWidth.js +25 -20
- package/factory/orchestrator/dist/errorIssues.js +209 -24
- package/factory/orchestrator/dist/eventOrder.js +102 -0
- package/factory/orchestrator/dist/events.js +73 -91
- package/factory/orchestrator/dist/findingContext.js +1 -13
- package/factory/orchestrator/dist/findings.js +132 -11
- package/factory/orchestrator/dist/gh.js +29 -6
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/harness.js +92 -10
- package/factory/orchestrator/dist/hookDecision.js +320 -37
- package/factory/orchestrator/dist/impact.js +24 -2
- package/factory/orchestrator/dist/integration.js +11 -1
- package/factory/orchestrator/dist/issueReporter.js +415 -0
- package/factory/orchestrator/dist/judgeStopHook.js +181 -0
- package/factory/orchestrator/dist/judges.js +1 -1
- package/factory/orchestrator/dist/lessons.js +46 -18
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/paths.js +10 -0
- package/factory/orchestrator/dist/plan.js +138 -4
- package/factory/orchestrator/dist/planQuorum.js +1 -1
- package/factory/orchestrator/dist/policy.js +57 -11
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/queue.js +477 -29
- package/factory/orchestrator/dist/scheduler.js +51 -0
- package/factory/orchestrator/dist/severity.js +40 -8
- package/factory/orchestrator/dist/spec.js +70 -0
- package/factory/orchestrator/dist/taskEvents.js +13 -4
- package/factory/orchestrator/dist/taskStatus.js +74 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testgate.js +32 -3
- package/factory/orchestrator/dist/usage.js +42 -9
- package/factory/orchestrator/dist/waivers.js +60 -9
- package/factory/orchestrator/dist/waveConcurrency.js +29 -0
- package/factory/orchestrator/dist/waveNext.js +12 -10
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/worktree.js +19 -6
- package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +9 -2
- package/factory/policies/crosscheck.yml +2 -2
- package/factory/policies/lessons.md +298 -0
- package/factory/policies/scheduler.yml +27 -2
- package/factory/policies/taxonomy.yml +10 -3
- package/factory/specs/roadmap.md +70 -23
- package/package.json +8 -8
|
@@ -3,14 +3,15 @@ import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
|
3
3
|
import { foldTasks } from './db/projector.js';
|
|
4
4
|
import { SmithError } from './errors.js';
|
|
5
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';
|
|
6
|
+
import { AMEND_PENDING_STATUS, AMENDED_STATUS, DISCRETIONARY_FINDING_STATUSES, describeMalformedTaskId, foldFindingsDetailed, isNonEmptyString, OPEN_FINDING_STATUSES, transition, } from './findings.js';
|
|
7
7
|
import { goalCheckBlockers, latestGoalCheck, } from './goalCheck.js';
|
|
8
8
|
import { latestIntegrationCheck } from './integration.js';
|
|
9
9
|
import { mcpBlockers } from './mcp.js';
|
|
10
10
|
import { bareTaskId, latestPlanVersion, livePlanTasks, loadPlan } from './plan.js';
|
|
11
11
|
import { enabledExternalProviders, nativeProviderName, runQuorumCase, } from './quorum.js';
|
|
12
|
-
import { latestSpecReview, specReviewBlockers } from './spec.js';
|
|
13
|
-
import {
|
|
12
|
+
import { latestSpecReview, specReviewBlockers, taskSuccessors, } from './spec.js';
|
|
13
|
+
import { TERMINAL_OK_TASK_STATUSES } from './taskStatus.js';
|
|
14
|
+
import { auditWaveConcurrency, WAVE_VERDICTS, zeroedCounts, } from './waveConcurrency.js';
|
|
14
15
|
import { RESERVED_TASK_ID } from './worktree.js';
|
|
15
16
|
/**
|
|
16
17
|
* Third quorum_triggers host (factory/policies/crosscheck.yml): "epic-level
|
|
@@ -44,17 +45,92 @@ import { RESERVED_TASK_ID } from './worktree.js';
|
|
|
44
45
|
* is whatever it would have been with no externals at all (`go`, since
|
|
45
46
|
* mechanical readiness already held).
|
|
46
47
|
*/
|
|
47
|
-
const TERMINAL_OK_TASK_STATUSES = new Set(['completed', 'waived']);
|
|
48
48
|
/** The terminal-OK status that is a decision rather than a completion (D-120). */
|
|
49
49
|
const WAIVED_TASK_STATUS = 'waived';
|
|
50
50
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
51
|
+
* A task a re-plan (`plan amend`) replaced with a successor task cut under a
|
|
52
|
+
* later plan version. `TERMINAL_TASK_STATUSES` (taskStatus.ts) already counts
|
|
53
|
+
* this as over; it is deliberately NOT in `TERMINAL_OK_TASK_STATUSES`, because
|
|
54
|
+
* "over" is not the same as "well" — the work only actually landed if the
|
|
55
|
+
* successor did. `resolveSupersededRow` below is what asks that question.
|
|
56
56
|
*/
|
|
57
|
-
const
|
|
57
|
+
const SUPERSEDED_TASK_STATUS = 'superseded';
|
|
58
|
+
/**
|
|
59
|
+
* Follows a superseded task to whatever eventually carries its work, walking
|
|
60
|
+
* a possibly multi-hop chain (a successor can itself be superseded by a
|
|
61
|
+
* later amendment). Returns the first row in the chain that is not itself
|
|
62
|
+
* superseded, or `undefined` when the chain is missing a hop, names a task
|
|
63
|
+
* with no fold record, or cycles back on itself — every one of those reads
|
|
64
|
+
* as "no successor to vouch for this", the fail-closed answer.
|
|
65
|
+
*
|
|
66
|
+
* `successors` is folded off the event log (spec.ts's `taskSuccessors`), so
|
|
67
|
+
* its keys and values are whatever `plan-version-created` recorded — not
|
|
68
|
+
* necessarily bare (D-46/P9-29) — hence comparing through `bareTaskId` at
|
|
69
|
+
* every hop rather than assuming the map is pre-normalized.
|
|
70
|
+
*/
|
|
71
|
+
function resolveSupersededRow(epicId, taskId, tasks, successors) {
|
|
72
|
+
const bareSuccessors = new Map([...successors].map(([from, to]) => [bareTaskId(epicId, from), to]));
|
|
73
|
+
const visited = new Set([bareTaskId(epicId, taskId)]);
|
|
74
|
+
let currentId = bareTaskId(epicId, taskId);
|
|
75
|
+
for (;;) {
|
|
76
|
+
const nextId = bareSuccessors.get(currentId);
|
|
77
|
+
if (nextId === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
const nextBare = bareTaskId(epicId, nextId);
|
|
80
|
+
if (visited.has(nextBare))
|
|
81
|
+
return undefined;
|
|
82
|
+
visited.add(nextBare);
|
|
83
|
+
const row = tasks.find((t) => bareTaskId(epicId, t.taskId) === nextBare);
|
|
84
|
+
if (row === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (row.taskStatus !== SUPERSEDED_TASK_STATUS)
|
|
87
|
+
return row;
|
|
88
|
+
currentId = nextBare;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The `origin` taskEvents.ts's `emitFollowUpTask` stamps on a follow-up task:
|
|
93
|
+
* one minted for a finding no open task could own, rather than one a plan cut.
|
|
94
|
+
*/
|
|
95
|
+
const FOLLOW_UP_TASK_ORIGIN = 'escalation';
|
|
96
|
+
/** The finding status that is the operator's decision not to fix (D-120). */
|
|
97
|
+
const WAIVED_FINDING_STATUS = 'waived';
|
|
98
|
+
/**
|
|
99
|
+
* Whether a follow-up task's whole reason to exist has been waived away. A
|
|
100
|
+
* follow-up is minted `todo` to own findings, and no event ever moves it to
|
|
101
|
+
* `waived` — the projector has no such write — so without this the operator
|
|
102
|
+
* waiving the only finding it owns (bs-audit-2's `followup-9a8f6ac7`) left the
|
|
103
|
+
* task holding the epic verdict forever, with nothing left to do and no verb
|
|
104
|
+
* that could say so.
|
|
105
|
+
*
|
|
106
|
+
* Deliberately narrow, the same reader-side shape as the superseded-successor
|
|
107
|
+
* rule above:
|
|
108
|
+
* - only origin `escalation`: a planned task's `todo` is a claim about work
|
|
109
|
+
* its spec still owes, which waiving a finding raised against it does not
|
|
110
|
+
* discharge;
|
|
111
|
+
* - at least one attributed finding: a follow-up that owns nothing is not
|
|
112
|
+
* "all waived", it is unexplained, and absence must not vote yes (D-126);
|
|
113
|
+
* - every attributed finding `waived`: one still open, or closed any other
|
|
114
|
+
* way (a fix verified by a gate the task never ran), still blocks.
|
|
115
|
+
*
|
|
116
|
+
* Attribution is `finding.task_id`. A finding routed to a follow-up is raised
|
|
117
|
+
* under it (attribution.ts's `reattributeFinding` re-mints the id before the
|
|
118
|
+
* raise), so `finding-reattributed` needs no second reading here. Ids compare
|
|
119
|
+
* bare (D-46/P9-29).
|
|
120
|
+
*
|
|
121
|
+
* Returns the findings that discharge the follow-up, or null on every branch
|
|
122
|
+
* the rule refuses, so the quorum prompt can NAME them instead of re-deriving
|
|
123
|
+
* the rule a second way.
|
|
124
|
+
*/
|
|
125
|
+
function clearedFollowUpFindings(epicId, row, findings) {
|
|
126
|
+
if (row.origin !== FOLLOW_UP_TASK_ORIGIN)
|
|
127
|
+
return null;
|
|
128
|
+
const bare = bareTaskId(epicId, row.taskId);
|
|
129
|
+
const owned = findings.filter((f) => bareTaskId(epicId, f.task_id) === bare);
|
|
130
|
+
return owned.length > 0 && owned.every((f) => f.finding_status === WAIVED_FINDING_STATUS)
|
|
131
|
+
? owned
|
|
132
|
+
: null;
|
|
133
|
+
}
|
|
58
134
|
const GATE_OUTCOME_EVENT = 'gate-outcome';
|
|
59
135
|
const TASK_RESULT_EVENT = 'task-result-recorded';
|
|
60
136
|
/**
|
|
@@ -82,13 +158,6 @@ export function withGateEvidence(tasks, events, epicId) {
|
|
|
82
158
|
return { ...t, gate: { gateOutcome: gated.has(bare), resultRecorded: recorded.has(bare) } };
|
|
83
159
|
});
|
|
84
160
|
}
|
|
85
|
-
const WAVE_VERDICTS = [
|
|
86
|
-
'parallel',
|
|
87
|
-
'partial',
|
|
88
|
-
'serialized',
|
|
89
|
-
'single',
|
|
90
|
-
'unobserved',
|
|
91
|
-
];
|
|
92
161
|
/**
|
|
93
162
|
* Reduce one epic's waves to the fact a close records. Pure, and separate from
|
|
94
163
|
* `summariseWaveConcurrency` on purpose: that one scores an operator's audit
|
|
@@ -96,7 +165,7 @@ const WAVE_VERDICTS = [
|
|
|
96
165
|
* single epic with no verdict of its own to render.
|
|
97
166
|
*/
|
|
98
167
|
export function summariseEpicConcurrency(waves) {
|
|
99
|
-
const verdicts =
|
|
168
|
+
const verdicts = zeroedCounts(WAVE_VERDICTS);
|
|
100
169
|
for (const wave of waves)
|
|
101
170
|
verdicts[wave.verdict] += 1;
|
|
102
171
|
return {
|
|
@@ -113,7 +182,7 @@ export function summariseEpicConcurrency(waves) {
|
|
|
113
182
|
/** Nothing the log could be folded into a width, and why. */
|
|
114
183
|
const UNREADABLE_CONCURRENCY = (problem) => ({
|
|
115
184
|
waves: 0,
|
|
116
|
-
verdicts:
|
|
185
|
+
verdicts: zeroedCounts(WAVE_VERDICTS),
|
|
117
186
|
widest: { declared: 0, observed: 0 },
|
|
118
187
|
unobserved: [],
|
|
119
188
|
problem,
|
|
@@ -174,15 +243,16 @@ function integrationBlockers(epicId, integration) {
|
|
|
174
243
|
* Pure readiness check, no I/O: given the epic's own task rows (foldTasks()
|
|
175
244
|
* output, already filtered to this epic) and its findings (listFindings()
|
|
176
245
|
* output), decide whether the epic is mechanically ready to open its
|
|
177
|
-
* integration PR. "Terminal-OK"
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* those, so it blocks. "Open" finding
|
|
181
|
-
* OPEN_FINDING_STATUSES
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* here until they do, and only then does
|
|
246
|
+
* integration PR. "Terminal-OK" is taskStatus.ts's exported
|
|
247
|
+
* TERMINAL_OK_TASK_STATUSES (completed, waived) — a task at superseded,
|
|
248
|
+
* failed, escalated, blocked, in-progress or anything else is not one of
|
|
249
|
+
* those, so it blocks. "Open" finding is findings.ts's
|
|
250
|
+
* OPEN_FINDING_STATUSES, which reads the statuses LEGAL_TRANSITIONS still has
|
|
251
|
+
* a move out of rather than listing them — a finding stops being open when
|
|
252
|
+
* the table has nowhere left to take it, or at `waived`, which is a decision.
|
|
253
|
+
* `amend-pending` is the D-127 case: the amendment is written but the tasks it
|
|
254
|
+
* names have not landed, so it is open here until they do, and only then does
|
|
255
|
+
* closeEpic take it to `amended`.
|
|
186
256
|
*
|
|
187
257
|
* `integration` is REQUIRED, deliberately. An optional parameter defaulting
|
|
188
258
|
* to "no check needed" would recreate the exact hole D-42/P9-26 closes: the
|
|
@@ -232,12 +302,43 @@ function integrationBlockers(epicId, integration) {
|
|
|
232
302
|
* dropped so it can never be read as "it ran fine". It contributes nothing to
|
|
233
303
|
* `blockers` — see {@link EpicConcurrency}.
|
|
234
304
|
*/
|
|
235
|
-
export function summarizeEpic(epicId, tasks, findings, integration, mcp, specReview, goalCheck, plan = null, quarantined = [], concurrency = null
|
|
305
|
+
export function summarizeEpic(epicId, tasks, findings, integration, mcp, specReview, goalCheck, plan = null, quarantined = [], concurrency = null,
|
|
306
|
+
/**
|
|
307
|
+
* Old task id -> the successor that superseded it (spec.ts's
|
|
308
|
+
* `taskSuccessors`, folded off every `plan-version-created` event this
|
|
309
|
+
* epic has). Defaults to empty: a caller that never threads it gets
|
|
310
|
+
* exactly today's behavior — every superseded task blocks, same as before
|
|
311
|
+
* this parameter existed.
|
|
312
|
+
*/
|
|
313
|
+
successors = new Map()) {
|
|
236
314
|
const taskSummaries = tasks.map((t) => ({
|
|
237
315
|
taskId: t.taskId,
|
|
238
316
|
taskStatus: t.taskStatus,
|
|
239
317
|
}));
|
|
240
|
-
|
|
318
|
+
// A superseded task is terminal (taskStatus.ts's TERMINAL_TASK_STATUSES)
|
|
319
|
+
// but not terminal-OK by itself — it reads terminal-OK only when the
|
|
320
|
+
// successor that replaced it does, recursively (run.md's "completed/
|
|
321
|
+
// superseded/waived" is this rule, not a second one). A follow-up task whose
|
|
322
|
+
// every finding was waived reads terminal-OK too — clearedFollowUpFindings()
|
|
323
|
+
// — and is collected below so the prompt can explain the row it clears.
|
|
324
|
+
const clearedFollowUps = [];
|
|
325
|
+
const nonTerminal = taskSummaries.filter((t, i) => {
|
|
326
|
+
if (TERMINAL_OK_TASK_STATUSES.has(t.taskStatus))
|
|
327
|
+
return false;
|
|
328
|
+
if (t.taskStatus !== SUPERSEDED_TASK_STATUS) {
|
|
329
|
+
const cleared = clearedFollowUpFindings(epicId, tasks[i], findings);
|
|
330
|
+
if (cleared === null)
|
|
331
|
+
return true;
|
|
332
|
+
clearedFollowUps.push({
|
|
333
|
+
taskId: t.taskId,
|
|
334
|
+
taskStatus: t.taskStatus,
|
|
335
|
+
waivedFindingIds: cleared.map((f) => f.finding_id),
|
|
336
|
+
});
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
const successor = resolveSupersededRow(epicId, t.taskId, tasks, successors);
|
|
340
|
+
return successor === undefined || !TERMINAL_OK_TASK_STATUSES.has(successor.taskStatus);
|
|
341
|
+
});
|
|
241
342
|
// D-138: only tasks claimed done are asked for evidence. One still in flight
|
|
242
343
|
// has not been gated yet and already blocks for not being terminal-OK —
|
|
243
344
|
// repeating it here would make an in-progress task read like a forged one.
|
|
@@ -318,13 +419,35 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
318
419
|
: wellFormedObligationIds.filter((id) => {
|
|
319
420
|
const bare = bareTaskId(epicId, id);
|
|
320
421
|
const row = tasks.find((t) => bareTaskId(epicId, t.taskId) === bare);
|
|
321
|
-
|
|
322
|
-
|
|
422
|
+
// An obligation named a task a LATER, separate amendment went on
|
|
423
|
+
// to supersede: the stored amends_task_ids is never rewritten
|
|
424
|
+
// (repairObligation refuses to renegotiate a well-formed
|
|
425
|
+
// obligation), so the id is resolved through its successor here,
|
|
426
|
+
// at evaluation time, instead. Only the LANDED-AT version comes
|
|
427
|
+
// from the successor row; the evidence transition() eventually
|
|
428
|
+
// re-checks (outstandingObligations, findings.ts) bare-compares
|
|
429
|
+
// AmendmentDischarge.taskId against the obligation's own
|
|
430
|
+
// amends_task_ids, so the taskId offered as proof has to stay the
|
|
431
|
+
// originally-named id (`row.taskId`) or that revalidation would
|
|
432
|
+
// never find a match and would refuse the discharge outright —
|
|
433
|
+
// the id is resolved for landing-status purposes only, never for
|
|
434
|
+
// the identity the obligation is matched by. The original row
|
|
435
|
+
// stands in unchanged (byte-identical to before this existed)
|
|
436
|
+
// whenever it was never superseded, or its chain does not
|
|
437
|
+
// resolve.
|
|
438
|
+
const evidenceRow = row !== undefined && row.taskStatus === SUPERSEDED_TASK_STATUS
|
|
439
|
+
? (resolveSupersededRow(epicId, row.taskId, tasks, successors) ?? row)
|
|
440
|
+
: row;
|
|
441
|
+
const landed = evidenceRow !== undefined &&
|
|
442
|
+
TERMINAL_OK_TASK_STATUSES.has(evidenceRow.taskStatus) &&
|
|
323
443
|
version !== undefined &&
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (landed &&
|
|
327
|
-
satisfiedBy.push({
|
|
444
|
+
evidenceRow.planVersion !== null &&
|
|
445
|
+
evidenceRow.planVersion >= version;
|
|
446
|
+
if (landed && evidenceRow.planVersion !== null && row !== undefined)
|
|
447
|
+
satisfiedBy.push({
|
|
448
|
+
taskId: row.taskId,
|
|
449
|
+
planVersion: evidenceRow.planVersion,
|
|
450
|
+
});
|
|
328
451
|
return !landed;
|
|
329
452
|
});
|
|
330
453
|
if (malformedObligations.length === 0 && outstanding !== null && outstanding.length === 0) {
|
|
@@ -362,7 +485,17 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
362
485
|
...(taskSummaries.length === 0
|
|
363
486
|
? [`Epic "${epicId}" has no tasks in the event log — nothing to integrate.`]
|
|
364
487
|
: []),
|
|
365
|
-
...nonTerminal.map((t) =>
|
|
488
|
+
...nonTerminal.map((t) => {
|
|
489
|
+
const row = tasks.find((r) => r.taskId === t.taskId);
|
|
490
|
+
if (row?.origin === FOLLOW_UP_TASK_ORIGIN && t.taskStatus !== SUPERSEDED_TASK_STATUS)
|
|
491
|
+
return `Task "${t.taskId}" is a follow-up (origin: escalation) and is not terminal-OK (status: ${t.taskStatus}). Complete it, or waive every finding attributed to it — it clears once it owns at least one finding and all of them are waived.`;
|
|
492
|
+
if (t.taskStatus !== SUPERSEDED_TASK_STATUS)
|
|
493
|
+
return `Task "${t.taskId}" is not terminal-OK (status: ${t.taskStatus}).`;
|
|
494
|
+
const successor = resolveSupersededRow(epicId, t.taskId, tasks, successors);
|
|
495
|
+
return successor === undefined
|
|
496
|
+
? `Task "${t.taskId}" is superseded but has no successor recorded in the log — it cannot be counted terminal-OK.`
|
|
497
|
+
: `Task "${t.taskId}" is superseded by "${successor.taskId}", which is not terminal-OK (status: ${successor.taskStatus}).`;
|
|
498
|
+
}),
|
|
366
499
|
...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
500
|
...ungated.map((t) => t.gate.gateOutcome
|
|
368
501
|
? `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.`
|
|
@@ -392,6 +525,7 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
|
|
|
392
525
|
undispatchedTasks,
|
|
393
526
|
ungatedTasks,
|
|
394
527
|
waivedTasks: taskSummaries.filter((t) => t.taskStatus === WAIVED_TASK_STATUS),
|
|
528
|
+
clearedFollowUps,
|
|
395
529
|
openFindings,
|
|
396
530
|
discretionaryFindings,
|
|
397
531
|
satisfiedAmendments,
|
|
@@ -469,8 +603,21 @@ function mcpVerdict(mcp) {
|
|
|
469
603
|
* and the exit code are the refutable part; the output is not.
|
|
470
604
|
*/
|
|
471
605
|
export function epicVerdictJudgeRequest(summary, budget) {
|
|
606
|
+
// Keyed by taskId: a row in here is one clearedFollowUpFindings() cleared,
|
|
607
|
+
// so the roster line below can say so instead of printing the raw `todo`
|
|
608
|
+
// beside `Tasks not yet terminal-OK: 0` with nothing to explain the gap —
|
|
609
|
+
// the exact contradiction both external judges refuted in bs-audit-2.
|
|
610
|
+
const clearedFollowUpsByTaskId = new Map(summary.clearedFollowUps.map((c) => [c.taskId, c]));
|
|
472
611
|
const taskLines = summary.tasks.length > 0
|
|
473
|
-
? summary.tasks
|
|
612
|
+
? summary.tasks
|
|
613
|
+
.map((t) => {
|
|
614
|
+
const cleared = clearedFollowUpsByTaskId.get(t.taskId);
|
|
615
|
+
return cleared === undefined
|
|
616
|
+
? ` ${t.taskId}: ${t.taskStatus}`
|
|
617
|
+
: ` ${t.taskId}: ${t.taskStatus} — follow-up (origin: escalation) cleared by rule: ` +
|
|
618
|
+
`every attributed finding waived (${cleared.waivedFindingIds.join(', ')}); counted terminal-OK`;
|
|
619
|
+
})
|
|
620
|
+
.join('\n')
|
|
474
621
|
: ' (no tasks)';
|
|
475
622
|
// The plan's side of the roster, stated separately: these ids have no events
|
|
476
623
|
// at all, so their status is what the plan claims, not what happened (D-126).
|
|
@@ -592,6 +739,8 @@ export function epicVerdictJudgeRequest(summary, budget) {
|
|
|
592
739
|
'Discretionary closures — decided by a person, not shown by the machine:',
|
|
593
740
|
`Tasks waived rather than completed: ${summary.waivedTasks.length}`,
|
|
594
741
|
...listOr(summary.waivedTasks.map((t) => ` ${t.taskId}`)),
|
|
742
|
+
`Follow-ups cleared by waiving every finding they own: ${summary.clearedFollowUps.length}`,
|
|
743
|
+
...listOr(summary.clearedFollowUps.map((c) => ` ${c.taskId} — waived findings: ${c.waivedFindingIds.join(', ')}`)),
|
|
595
744
|
`Findings closed by waiver or amendment: ${summary.discretionaryFindings.length}`,
|
|
596
745
|
...listOr(summary.discretionaryFindings.map(findingLine)),
|
|
597
746
|
`Amendments this close will discharge: ${summary.satisfiedAmendments.length}`,
|
|
@@ -731,7 +880,11 @@ export async function runEpicVerdict(input, ctx, opts = {}) {
|
|
|
731
880
|
// Off the same lineage `events` every other fact here came from — the
|
|
732
881
|
// wave record is in that log already, so measuring width costs this call
|
|
733
882
|
// no second read and no second command anyone has to remember to type.
|
|
734
|
-
readEpicConcurrency(events, input.epicId)
|
|
883
|
+
readEpicConcurrency(events, input.epicId),
|
|
884
|
+
// Same lineage read again, no second I/O: which superseded task ids this
|
|
885
|
+
// epic's amendments paired with a replacement, so a chain of them can
|
|
886
|
+
// resolve to whatever is actually carrying the work now.
|
|
887
|
+
taskSuccessors(events, input.epicId));
|
|
735
888
|
// Step 1 — mechanical_oracles_first, literally: a deterministic blocker is
|
|
736
889
|
// final. Zero judge calls, zero events (read-only projection).
|
|
737
890
|
if (!summary.mechanicallyReady) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { UNOBSERVED_HINT } from './waveConcurrency.js';
|
|
1
|
+
import { UNOBSERVED_HINT, WAVE_VERDICTS, zeroedCounts, } from './waveConcurrency.js';
|
|
2
2
|
/**
|
|
3
3
|
* Does this factory build in parallel, or has every epic it closed been narrow?
|
|
4
4
|
*
|
|
@@ -28,20 +28,25 @@ import { UNOBSERVED_HINT } from './waveConcurrency.js';
|
|
|
28
28
|
* confidently as a factory of three hundred.
|
|
29
29
|
*/
|
|
30
30
|
const CLOSED_EVENT_TYPE = 'epic-closed';
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The three an epic can be graded that no single wave can carry, because they
|
|
33
|
+
* are facts about the close rather than about a wave. Kept apart from the five
|
|
34
|
+
* so the union below is the two rosters joined, and the five stay owned by the
|
|
35
|
+
* module that mints them.
|
|
36
|
+
*/
|
|
37
|
+
const EPIC_ONLY_VERDICTS = ['unwaved', 'unmeasured', 'unreadable'];
|
|
38
|
+
/**
|
|
39
|
+
* Every verdict an epic can be graded, widest first, and the roster the
|
|
40
|
+
* histogram's buckets are made of.
|
|
41
|
+
*
|
|
42
|
+
* The five come from the module that mints them rather than being restated
|
|
43
|
+
* here. That is the whole coupling this file turns on: `epic.ts` WRITES the
|
|
44
|
+
* width into the close and this file VALIDATES it on read — `countsFrom`
|
|
45
|
+
* rejects a record missing any of the five outright — so two hand-written
|
|
46
|
+
* copies of one list were two halves of a contract with nothing holding them
|
|
47
|
+
* to each other.
|
|
48
|
+
*/
|
|
49
|
+
export const ALL_VERDICTS = [...WAVE_VERDICTS, ...EPIC_ONLY_VERDICTS];
|
|
45
50
|
/**
|
|
46
51
|
* Said when no close carried a width anybody could read.
|
|
47
52
|
*
|
|
@@ -58,7 +63,7 @@ function blank(verdict, problem) {
|
|
|
58
63
|
return {
|
|
59
64
|
verdict,
|
|
60
65
|
waves: 0,
|
|
61
|
-
byVerdict:
|
|
66
|
+
byVerdict: zeroedCounts(WAVE_VERDICTS),
|
|
62
67
|
widest: { declared: 0, observed: 0 },
|
|
63
68
|
unobserved: [],
|
|
64
69
|
problem,
|
|
@@ -68,8 +73,8 @@ function countsFrom(raw) {
|
|
|
68
73
|
if (typeof raw !== 'object' || raw === null)
|
|
69
74
|
return null;
|
|
70
75
|
const source = raw;
|
|
71
|
-
const counts =
|
|
72
|
-
for (const verdict of
|
|
76
|
+
const counts = zeroedCounts(WAVE_VERDICTS);
|
|
77
|
+
for (const verdict of WAVE_VERDICTS) {
|
|
73
78
|
const n = source[verdict];
|
|
74
79
|
// A missing key is not read as zero. The close projects all five even at
|
|
75
80
|
// zero (D-126), so an absent one means this is not a width record at all,
|
|
@@ -102,7 +107,7 @@ function widthFrom(raw) {
|
|
|
102
107
|
function gradeOf(counts, waves) {
|
|
103
108
|
if (waves === 0)
|
|
104
109
|
return 'unwaved';
|
|
105
|
-
for (const verdict of
|
|
110
|
+
for (const verdict of WAVE_VERDICTS)
|
|
106
111
|
if (counts[verdict] > 0)
|
|
107
112
|
return verdict;
|
|
108
113
|
// Unreachable: every wave carries one of the five. Reported rather than
|
|
@@ -208,7 +213,7 @@ export function summariseEpicWidth(events) {
|
|
|
208
213
|
const epics = readClosedEpicWidths(events).sort((a, b) => a.closedAt === b.closedAt
|
|
209
214
|
? a.epicId.localeCompare(b.epicId)
|
|
210
215
|
: b.closedAt.localeCompare(a.closedAt));
|
|
211
|
-
const verdicts =
|
|
216
|
+
const verdicts = zeroedCounts(ALL_VERDICTS);
|
|
212
217
|
for (const epic of epics)
|
|
213
218
|
verdicts[epic.verdict] += 1;
|
|
214
219
|
const serialized = epics.filter((e) => e.byVerdict.serialized > 0).map((e) => e.epicId);
|