@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.
Files changed (71) hide show
  1. package/.claude/agents/auditor.md +3 -0
  2. package/.claude/agents/grader.md +12 -5
  3. package/.claude/agents/reviewer.md +3 -0
  4. package/.claude/agents/security-reviewer.md +3 -0
  5. package/.claude/agents/spec-reviewer.md +3 -0
  6. package/.claude/agents/verifier.md +3 -0
  7. package/.claude/skills/bs/audit.md +25 -9
  8. package/.claude/skills/bs/dispatch.md +60 -7
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +18 -13
  11. package/.claude/skills/bs/wave.md +68 -10
  12. package/README.md +4 -4
  13. package/factory/orchestrator/dist/agentsSync.js +178 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +25 -0
  16. package/factory/orchestrator/dist/budgets.js +91 -2
  17. package/factory/orchestrator/dist/claims.js +8 -4
  18. package/factory/orchestrator/dist/cli.js +394 -26
  19. package/factory/orchestrator/dist/crossFinding.js +20 -0
  20. package/factory/orchestrator/dist/daemon.js +230 -41
  21. package/factory/orchestrator/dist/db/projector.js +93 -36
  22. package/factory/orchestrator/dist/db/queries.js +58 -35
  23. package/factory/orchestrator/dist/db/schema.js +24 -0
  24. package/factory/orchestrator/dist/dispatchAudit.js +10 -2
  25. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  26. package/factory/orchestrator/dist/epic.js +192 -39
  27. package/factory/orchestrator/dist/epicWidth.js +25 -20
  28. package/factory/orchestrator/dist/errorIssues.js +209 -24
  29. package/factory/orchestrator/dist/eventOrder.js +102 -0
  30. package/factory/orchestrator/dist/events.js +73 -91
  31. package/factory/orchestrator/dist/findingContext.js +1 -13
  32. package/factory/orchestrator/dist/findings.js +132 -11
  33. package/factory/orchestrator/dist/gh.js +29 -6
  34. package/factory/orchestrator/dist/git.js +20 -2
  35. package/factory/orchestrator/dist/harness.js +92 -10
  36. package/factory/orchestrator/dist/hookDecision.js +320 -37
  37. package/factory/orchestrator/dist/impact.js +24 -2
  38. package/factory/orchestrator/dist/integration.js +11 -1
  39. package/factory/orchestrator/dist/issueReporter.js +415 -0
  40. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  41. package/factory/orchestrator/dist/judges.js +1 -1
  42. package/factory/orchestrator/dist/lessons.js +46 -18
  43. package/factory/orchestrator/dist/logCache.js +291 -0
  44. package/factory/orchestrator/dist/paths.js +10 -0
  45. package/factory/orchestrator/dist/plan.js +138 -4
  46. package/factory/orchestrator/dist/planQuorum.js +1 -1
  47. package/factory/orchestrator/dist/policy.js +57 -11
  48. package/factory/orchestrator/dist/projects.js +12 -1
  49. package/factory/orchestrator/dist/queue.js +477 -29
  50. package/factory/orchestrator/dist/scheduler.js +51 -0
  51. package/factory/orchestrator/dist/severity.js +40 -8
  52. package/factory/orchestrator/dist/spec.js +70 -0
  53. package/factory/orchestrator/dist/taskEvents.js +13 -4
  54. package/factory/orchestrator/dist/taskStatus.js +74 -0
  55. package/factory/orchestrator/dist/taxonomy.js +11 -0
  56. package/factory/orchestrator/dist/testgate.js +32 -3
  57. package/factory/orchestrator/dist/usage.js +42 -9
  58. package/factory/orchestrator/dist/waivers.js +60 -9
  59. package/factory/orchestrator/dist/waveConcurrency.js +29 -0
  60. package/factory/orchestrator/dist/waveNext.js +12 -10
  61. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  62. package/factory/orchestrator/dist/worktree.js +19 -6
  63. package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
  64. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  65. package/factory/policies/budgets.yml +9 -2
  66. package/factory/policies/crosscheck.yml +2 -2
  67. package/factory/policies/lessons.md +298 -0
  68. package/factory/policies/scheduler.yml +27 -2
  69. package/factory/policies/taxonomy.yml +10 -3
  70. package/factory/specs/roadmap.md +70 -23
  71. 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 { auditWaveConcurrency } from './waveConcurrency.js';
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
- * 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.
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 DISCRETIONARY_FINDING_STATUSES = new Set(['waived', AMENDED_STATUS]);
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 = Object.fromEntries(WAVE_VERDICTS.map((v) => [v, 0]));
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: Object.fromEntries(WAVE_VERDICTS.map((v) => [v, 0])),
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" 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`.
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
- const nonTerminal = taskSummaries.filter((t) => !TERMINAL_OK_TASK_STATUSES.has(t.taskStatus));
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
- const landed = row !== undefined &&
322
- TERMINAL_OK_TASK_STATUSES.has(row.taskStatus) &&
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
- row.planVersion !== null &&
325
- row.planVersion >= version;
326
- if (landed && row.planVersion !== null)
327
- satisfiedBy.push({ taskId: row.taskId, planVersion: row.planVersion });
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) => `Task "${t.taskId}" is not terminal-OK (status: ${t.taskStatus}).`),
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.map((t) => ` ${t.taskId}: ${t.taskStatus}`).join('\n')
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
- /** The verdicts an epic can be graded on, widest first — see {@link gradeOf}. */
32
- const BEST_FIRST = [
33
- 'parallel',
34
- 'partial',
35
- 'serialized',
36
- 'single',
37
- 'unobserved',
38
- ];
39
- const ALL_VERDICTS = [
40
- ...BEST_FIRST,
41
- 'unwaved',
42
- 'unmeasured',
43
- 'unreadable',
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: Object.fromEntries(BEST_FIRST.map((v) => [v, 0])),
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 BEST_FIRST) {
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 BEST_FIRST)
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 = Object.fromEntries(ALL_VERDICTS.map((v) => [v, 0]));
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);