@juzser/blacksmith 0.3.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 (46) 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 +12 -5
  8. package/.claude/skills/bs/dispatch.md +40 -4
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +11 -5
  11. package/.claude/skills/bs/wave.md +40 -3
  12. package/README.md +1 -1
  13. package/factory/orchestrator/dist/agentsSync.js +23 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +6 -2
  16. package/factory/orchestrator/dist/cli.js +309 -28
  17. package/factory/orchestrator/dist/crossFinding.js +20 -0
  18. package/factory/orchestrator/dist/daemon.js +198 -31
  19. package/factory/orchestrator/dist/db/projector.js +51 -27
  20. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  21. package/factory/orchestrator/dist/epic.js +179 -12
  22. package/factory/orchestrator/dist/errorIssues.js +175 -10
  23. package/factory/orchestrator/dist/events.js +59 -11
  24. package/factory/orchestrator/dist/findings.js +25 -2
  25. package/factory/orchestrator/dist/gh.js +19 -2
  26. package/factory/orchestrator/dist/git.js +20 -2
  27. package/factory/orchestrator/dist/hookDecision.js +320 -37
  28. package/factory/orchestrator/dist/integration.js +11 -1
  29. package/factory/orchestrator/dist/issueReporter.js +81 -16
  30. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  31. package/factory/orchestrator/dist/logCache.js +291 -0
  32. package/factory/orchestrator/dist/paths.js +10 -0
  33. package/factory/orchestrator/dist/plan.js +130 -0
  34. package/factory/orchestrator/dist/projects.js +12 -1
  35. package/factory/orchestrator/dist/queue.js +469 -28
  36. package/factory/orchestrator/dist/scheduler.js +3 -3
  37. package/factory/orchestrator/dist/spec.js +70 -0
  38. package/factory/orchestrator/dist/taskEvents.js +13 -4
  39. package/factory/orchestrator/dist/taxonomy.js +11 -0
  40. package/factory/orchestrator/dist/testgate.js +4 -3
  41. package/factory/orchestrator/dist/usage.js +18 -10
  42. package/factory/orchestrator/dist/waivers.js +60 -7
  43. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  44. package/factory/orchestrator/dist/worktree.js +19 -6
  45. package/factory/specs/roadmap.md +24 -0
  46. package/package.json +1 -1
@@ -9,7 +9,7 @@ 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';
12
+ import { latestSpecReview, specReviewBlockers, taskSuccessors, } from './spec.js';
13
13
  import { TERMINAL_OK_TASK_STATUSES } from './taskStatus.js';
14
14
  import { auditWaveConcurrency, WAVE_VERDICTS, zeroedCounts, } from './waveConcurrency.js';
15
15
  import { RESERVED_TASK_ID } from './worktree.js';
@@ -47,6 +47,90 @@ import { RESERVED_TASK_ID } from './worktree.js';
47
47
  */
48
48
  /** The terminal-OK status that is a decision rather than a completion (D-120). */
49
49
  const WAIVED_TASK_STATUS = 'waived';
50
+ /**
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
+ */
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
+ }
50
134
  const GATE_OUTCOME_EVENT = 'gate-outcome';
51
135
  const TASK_RESULT_EVENT = 'task-result-recorded';
52
136
  /**
@@ -218,12 +302,43 @@ function integrationBlockers(epicId, integration) {
218
302
  * dropped so it can never be read as "it ran fine". It contributes nothing to
219
303
  * `blockers` — see {@link EpicConcurrency}.
220
304
  */
221
- 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()) {
222
314
  const taskSummaries = tasks.map((t) => ({
223
315
  taskId: t.taskId,
224
316
  taskStatus: t.taskStatus,
225
317
  }));
226
- 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
+ });
227
342
  // D-138: only tasks claimed done are asked for evidence. One still in flight
228
343
  // has not been gated yet and already blocks for not being terminal-OK —
229
344
  // repeating it here would make an in-progress task read like a forged one.
@@ -304,13 +419,35 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
304
419
  : wellFormedObligationIds.filter((id) => {
305
420
  const bare = bareTaskId(epicId, id);
306
421
  const row = tasks.find((t) => bareTaskId(epicId, t.taskId) === bare);
307
- const landed = row !== undefined &&
308
- 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) &&
309
443
  version !== undefined &&
310
- row.planVersion !== null &&
311
- row.planVersion >= version;
312
- if (landed && row.planVersion !== null)
313
- 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
+ });
314
451
  return !landed;
315
452
  });
316
453
  if (malformedObligations.length === 0 && outstanding !== null && outstanding.length === 0) {
@@ -348,7 +485,17 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
348
485
  ...(taskSummaries.length === 0
349
486
  ? [`Epic "${epicId}" has no tasks in the event log — nothing to integrate.`]
350
487
  : []),
351
- ...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
+ }),
352
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}).`),
353
500
  ...ungated.map((t) => t.gate.gateOutcome
354
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.`
@@ -378,6 +525,7 @@ export function summarizeEpic(epicId, tasks, findings, integration, mcp, specRev
378
525
  undispatchedTasks,
379
526
  ungatedTasks,
380
527
  waivedTasks: taskSummaries.filter((t) => t.taskStatus === WAIVED_TASK_STATUS),
528
+ clearedFollowUps,
381
529
  openFindings,
382
530
  discretionaryFindings,
383
531
  satisfiedAmendments,
@@ -455,8 +603,21 @@ function mcpVerdict(mcp) {
455
603
  * and the exit code are the refutable part; the output is not.
456
604
  */
457
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]));
458
611
  const taskLines = summary.tasks.length > 0
459
- ? 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')
460
621
  : ' (no tasks)';
461
622
  // The plan's side of the roster, stated separately: these ids have no events
462
623
  // at all, so their status is what the plan claims, not what happened (D-126).
@@ -578,6 +739,8 @@ export function epicVerdictJudgeRequest(summary, budget) {
578
739
  'Discretionary closures — decided by a person, not shown by the machine:',
579
740
  `Tasks waived rather than completed: ${summary.waivedTasks.length}`,
580
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(', ')}`)),
581
744
  `Findings closed by waiver or amendment: ${summary.discretionaryFindings.length}`,
582
745
  ...listOr(summary.discretionaryFindings.map(findingLine)),
583
746
  `Amendments this close will discharge: ${summary.satisfiedAmendments.length}`,
@@ -717,7 +880,11 @@ export async function runEpicVerdict(input, ctx, opts = {}) {
717
880
  // Off the same lineage `events` every other fact here came from — the
718
881
  // wave record is in that log already, so measuring width costs this call
719
882
  // no second read and no second command anyone has to remember to type.
720
- 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));
721
888
  // Step 1 — mechanical_oracles_first, literally: a deterministic blocker is
722
889
  // final. Zero judge calls, zero events (read-only projection).
723
890
  if (!summary.mechanicallyReady) {
@@ -20,6 +20,10 @@ const BLOCKING_SEVERITY = 'S2-major';
20
20
  const GATE_OUTCOME_EVENT_TYPE = 'gate-outcome';
21
21
  const ERROR_LOGGED_EVENT_TYPE = 'error-logged';
22
22
  const TASK_ADDED_EVENT_TYPE = 'task-added';
23
+ /** `agents-registry.ts`'s `DISPATCH_EVENT_TYPE`, hand-restated rather than
24
+ * imported -- this module's only imports are `node:crypto` and two local,
25
+ * type-only siblings, on purpose (see the file banner). */
26
+ const DISPATCH_DECISION_EVENT_TYPE = 'dispatch_decision';
23
27
  /**
24
28
  * Source 2: `task_status: failed` is set in exactly one place in the whole
25
29
  * projector, `db/projector.ts`'s `case 'task-added'` arm copying
@@ -29,8 +33,6 @@ const TASK_ADDED_EVENT_TYPE = 'task-added';
29
33
  * carries no failure discriminator beyond that.
30
34
  */
31
35
  const TASK_FAILED_ERROR_CLASS = 'task.failed';
32
- /** Default project for an event stamped with none (events.ts's documented convention; the writer never sets it, only read helpers default it). */
33
- const DEFAULT_PROJECT = 'black-smith';
34
36
  function asString(value) {
35
37
  return typeof value === 'string' && value.length > 0 ? value : undefined;
36
38
  }
@@ -118,20 +120,158 @@ const CANDIDATE_READERS = Object.freeze({
118
120
  * would be reported outside the window the operator asked about.
119
121
  */
120
122
  export const ISSUE_CANDIDATE_EVENT_TYPES = new Set(Object.keys(CANDIDATE_READERS));
123
+ /** The row's task ref, off the envelope first and the payload second -- the same two places every `CandidateReader` above already looks. */
124
+ function candidateTaskRef(record) {
125
+ const payload = (record.payload ?? {});
126
+ return asString(record.task_id) ?? asString(payload.task_ref);
127
+ }
128
+ /**
129
+ * Event types allowed to stamp a session's project for
130
+ * `sessionProjectStamps` below: the issue candidates themselves, plus
131
+ * `task-added` and `dispatch_decision` rows recording where a task was
132
+ * planned or dispatched. Deliberately excludes `issue-reported` and every
133
+ * other reporter-authored row -- those carry this module's own *guess* at a
134
+ * row's project (see `appendIssueReported` in `issueReporter.ts`), not an
135
+ * independent origin stamp, so folding them back in here would let one run's
136
+ * guess leak into the next run's resolution of an unrelated, unstamped row
137
+ * in the same session.
138
+ */
139
+ const ORIGIN_STAMP_EVENT_TYPES = new Set([
140
+ ...ISSUE_CANDIDATE_EVENT_TYPES,
141
+ DISPATCH_DECISION_EVENT_TYPE,
142
+ ]);
143
+ /**
144
+ * The session's project, from its origin-stamped rows only -- and only when
145
+ * every one of them agrees. Real sessions mix dozens of stamped rows with
146
+ * unstamped ones (evidence: a foreign project's session stamps some events
147
+ * and not others); a unanimous stamped sibling in the same session is strong
148
+ * evidence for an unstamped one. A session whose origin stamps disagree
149
+ * tells us nothing safe to guess, so it answers unresolved rather than
150
+ * picking whichever stamp happened to come first.
151
+ */
152
+ function sessionProjectStamps(events) {
153
+ const bySession = new Map();
154
+ for (const { record } of events) {
155
+ if (!ORIGIN_STAMP_EVENT_TYPES.has(record.event_type))
156
+ continue;
157
+ const project = asString(record.project);
158
+ if (project === undefined)
159
+ continue;
160
+ const seen = bySession.get(record.session_id);
161
+ if (seen)
162
+ seen.add(project);
163
+ else
164
+ bySession.set(record.session_id, new Set([project]));
165
+ }
166
+ const stamps = new Map();
167
+ for (const [sessionId, projects] of bySession) {
168
+ if (projects.size !== 1)
169
+ continue;
170
+ const [project] = projects;
171
+ if (project !== undefined)
172
+ stamps.set(sessionId, project);
173
+ }
174
+ return stamps;
175
+ }
176
+ function taskAddedKey(sessionId, taskId) {
177
+ return `${sessionId}\u0000${taskId}`;
178
+ }
179
+ /** `task-added`'s own `payload.epic_id`, keyed by session and task id, so a bare ref (no epic segment) can still be widened to `<epic>/<ref>` before asking the resolver. */
180
+ function taskAddedEpicIds(events) {
181
+ const out = new Map();
182
+ for (const { record } of events) {
183
+ if (record.event_type !== TASK_ADDED_EVENT_TYPE)
184
+ continue;
185
+ const taskId = asString(record.task_id);
186
+ const epicId = asString(record.payload?.epic_id);
187
+ if (taskId !== undefined && epicId !== undefined) {
188
+ out.set(taskAddedKey(record.session_id, taskId), epicId);
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+ /**
194
+ * Widens a per-epic `ResolveProjectForTaskRef` into a `RowProjectResolver`
195
+ * with three session-scoped fallbacks a single task ref cannot answer alone,
196
+ * tried strictly in this order (S2-c):
197
+ *
198
+ * 1. `resolveProject`, the strict/direct resolver -- a bare ref (no epic
199
+ * segment) tried again as `<epic>/<ref>` using the same session's own
200
+ * `task-added` row when the bare ref alone cannot answer.
201
+ * 2. The session stamp: the ONE project every origin-stamped row in the same
202
+ * session agrees on (`sessionProjectStamps`), when unanimous.
203
+ * 3. `selfFallback`, tried last and only when neither of the above could
204
+ * answer -- same widening as step 1.
205
+ *
206
+ * Self-fallback sits last on purpose: it is "no plan says otherwise, and a
207
+ * plan exists locally for this epic, so guess this factory's own name" --
208
+ * the weakest of the three signals. A session's OWN unanimous stamp naming a
209
+ * DIFFERENT project is stronger evidence than that guess, and must not be
210
+ * overridden by it just because the same session also has an unrelated,
211
+ * unstamped row whose epic happens to have a local, unlabeled plan. Before
212
+ * this order existed, the self-fallback was folded into `resolveProject`
213
+ * itself (step 1) and so always outran the session stamp.
214
+ *
215
+ * Exported so `issueReporter.ts`'s `priorOpenReports` can apply the identical
216
+ * fallback to the same event snapshot.
217
+ */
218
+ export function withSessionFallback(events, resolveProject, selfFallback = () => null) {
219
+ const stamps = sessionProjectStamps(events);
220
+ const epicIds = taskAddedEpicIds(events);
221
+ const tryResolve = (resolve, taskRef, sessionId) => {
222
+ if (taskRef === undefined)
223
+ return null;
224
+ const direct = resolve(taskRef);
225
+ if (direct !== null)
226
+ return direct;
227
+ if (sessionId === undefined)
228
+ return null;
229
+ const epicId = epicIds.get(taskAddedKey(sessionId, taskRef));
230
+ if (epicId === undefined)
231
+ return null;
232
+ return resolve(`${epicId}/${taskRef}`);
233
+ };
234
+ return (taskRef, sessionId) => {
235
+ const direct = tryResolve(resolveProject, taskRef, sessionId);
236
+ if (direct !== null)
237
+ return direct;
238
+ if (sessionId !== undefined) {
239
+ const stamp = stamps.get(sessionId);
240
+ if (stamp !== undefined)
241
+ return stamp;
242
+ }
243
+ return tryResolve(selfFallback, taskRef, sessionId);
244
+ };
245
+ }
121
246
  /** One event to a `Candidate`, or `'ignore'` when no source claims its event type at all. */
122
- function toCandidate(event) {
247
+ function toCandidate(event, resolveRow) {
123
248
  const { record } = event;
124
249
  const read = CANDIDATE_READERS[record.event_type];
125
250
  if (!read)
126
251
  return 'ignore';
252
+ const stamped = asString(record.project);
253
+ const taskRef = candidateTaskRef(record);
254
+ const sessionId = asString(record.session_id);
255
+ const project = stamped ?? resolveRow(taskRef, sessionId);
127
256
  return read(event, {
128
257
  payload: record.payload ?? {},
129
- project: asString(record.project) ?? DEFAULT_PROJECT,
130
- sessionId: asString(record.session_id),
258
+ project,
259
+ sessionId,
131
260
  planVersion: asNumber(record.plan_version) ?? null,
132
261
  ts: asString(record.ts),
133
262
  });
134
263
  }
264
+ /**
265
+ * A row whose project cannot be resolved at all still needs a stable
266
+ * fingerprint-grouping key -- otherwise two unrelated foreign projects'
267
+ * rows sharing `(source, error_class, task_ref)` would incorrectly group
268
+ * together and leak one row's session/epic/severity metadata onto the
269
+ * other's report, inside this factory's own private log (both rows are
270
+ * always skipped before anything is filed publicly, but the log itself
271
+ * should stay correct). `task_ref` alone already disambiguates in practice
272
+ * (refs are namespaced by epic id), so a single fixed sentinel is enough.
273
+ */
274
+ const UNRESOLVED_PROJECT_FINGERPRINT_KEY = '\u0000unresolved-project\u0000';
135
275
  /**
136
276
  * The first 16 hex characters of a SHA-256 over the NUL-joined tuple
137
277
  * `project`, `source`, `error_class`, `task_ref`. Deliberately excludes
@@ -140,27 +280,52 @@ function toCandidate(event) {
140
280
  * and including any of them would open one issue per round instead of one.
141
281
  */
142
282
  function computeFingerprint(project, source, errorClass, taskRef) {
143
- const material = [project, source, errorClass, taskRef].join('\0');
283
+ const material = [
284
+ project ?? UNRESOLVED_PROJECT_FINGERPRINT_KEY,
285
+ source,
286
+ errorClass,
287
+ taskRef,
288
+ ].join('\0');
144
289
  return createHash('sha256').update(material).digest('hex').slice(0, 16);
145
290
  }
146
291
  /**
147
292
  * Fold a stored event sequence into reportable errors. Pure: two calls with
148
293
  * identical input return byte-identical output. `now` is threaded in rather
149
294
  * than read off the clock; nothing in this contract branches on it yet.
295
+ *
296
+ * `resolveProject` and `selfFallback` both default to "no answer" so the
297
+ * ~50 existing call sites across this module's own tests and
298
+ * `issueReporter.ts`'s need no change. An unstamped row still unresolved
299
+ * after `withSessionFallback`'s three fallbacks reports `project: null`
300
+ * rather than this factory's own name -- `isProjectEnabled` is never asked
301
+ * about a row it cannot identify, and the decision to skip filing it
302
+ * belongs to `issueReporter.ts`, the one caller that goes on to call `gh`.
303
+ *
304
+ * `scopeEvents` defaults to `events` itself -- the session-stamp/`task-added`
305
+ * maps `withSessionFallback` builds read the same rows this fold iterates,
306
+ * same as before this parameter existed. A caller that narrows `events` to a
307
+ * candidate WINDOW (`cli.ts`'s `--epic`/`--since`) passes the full, unscoped
308
+ * lineage here instead (S3), so a session-mate's stamp sitting outside that
309
+ * window still counts as evidence for a row inside it -- scoping which rows
310
+ * get REPORTED must not also scope which rows get CONSULTED, or the same
311
+ * unstamped row resolves to a different project (and hashes a different
312
+ * fingerprint, S1-b) depending on whether the caller happened to pass
313
+ * `--epic`.
150
314
  */
151
- export function foldErrorEvents(events, now, isProjectEnabled) {
315
+ export function foldErrorEvents(events, now, isProjectEnabled, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
152
316
  void now;
317
+ const resolveRow = withSessionFallback(scopeEvents, resolveProject, selfFallback);
153
318
  const candidates = [];
154
319
  let skipped = 0;
155
320
  for (const event of events) {
156
- const outcome = toCandidate(event);
321
+ const outcome = toCandidate(event, resolveRow);
157
322
  if (outcome === 'ignore')
158
323
  continue;
159
324
  if (outcome === null) {
160
325
  skipped += 1;
161
326
  continue;
162
327
  }
163
- if (!isProjectEnabled(outcome.project))
328
+ if (outcome.project !== null && !isProjectEnabled(outcome.project))
164
329
  continue;
165
330
  candidates.push(outcome);
166
331
  }
@@ -245,7 +410,7 @@ export function renderBody(fields) {
245
410
  `Session: ${fields.session_id}`,
246
411
  `Epic: ${fields.epic_id ?? '(none)'}`,
247
412
  `Plan version: ${fields.plan_version ?? '(unknown)'}`,
248
- `Project: ${fields.project}`,
413
+ `Project: ${fields.project ?? '(unknown)'}`,
249
414
  `Source: ${fields.source}`,
250
415
  fingerprintLine(fields.fingerprint),
251
416
  '',
@@ -471,8 +471,28 @@ function validateResultArtifactsShape(record) {
471
471
  errors: [{ path: '/artifacts', message: `must be an array, got ${typeof artifacts}` }],
472
472
  });
473
473
  }
474
+ /**
475
+ * `error-logged`'s task_id (D-245: envelope or payload, envelope wins) names
476
+ * exactly one task, the same rule taxonomy.ts enforces for `task_ref` — but
477
+ * `task_id` is schema-typed as a plain string, so no taxonomy rule ever sees
478
+ * it, and a comma-joined "epic-1/task-1,epic-1/task-2" passed straight
479
+ * through. db/projector.ts's fold read that string as a single task id and
480
+ * minted a phantom row for it that stayed `todo` forever. Checked here,
481
+ * beside validateResultArtifactsShape, for the same reason: the shape a
482
+ * later reader assumes is not one the event schema or taxonomy already rule
483
+ * out.
484
+ */
485
+ function validateErrorLoggedTaskIdShape(record) {
486
+ if (record.event_type !== 'error-logged')
487
+ return;
488
+ const taskId = eventTaskId(record);
489
+ if (taskId === null || !taskId.includes(','))
490
+ return;
491
+ throw new EventError('events.multi-value-task-id', `Event "error-logged" names more than one task in its task_id ("${taskId}"). Log one error-logged event per task instead.`, { event_type: record.event_type, task_id: taskId });
492
+ }
474
493
  function validateTypedPayload(schemas, taxonomy, record) {
475
494
  validateResultArtifactsShape(record);
495
+ validateErrorLoggedTaskIdShape(record);
476
496
  const schemaName = TYPED_PAYLOAD_SCHEMAS[record.event_type];
477
497
  if (schemaName === undefined)
478
498
  return;
@@ -524,19 +544,47 @@ async function validateCausalParent(input, opts, existing) {
524
544
  throw new EventError('events.unknown-causal-parent', `causal_parent "${input.causal_parent}" does not reference an existing event in session "${parent.sessionId}" (it holds ${parentEvents.length}).`, { causal_parent: input.causal_parent, session_id: parent.sessionId });
525
545
  }
526
546
  }
547
+ // EventInput requires causal_parent, so inside the orchestrator undefined is
548
+ // unreachable. It is reachable from outside: every event appended through the
549
+ // CLI crosses a JSON boundary where the type guarantees nothing, and omitting
550
+ // a field is the likeliest way a hand-written event is malformed. This used
551
+ // to compare against `null` alone, so an omitted parent fell through into
552
+ // parseEventId(undefined) and reached the caller as a raw TypeError with no
553
+ // `code` — the one shape produced by accident was the one shape with nothing
554
+ // to branch on. Normalise once so every caller reads the same guard.
555
+ function requireCausalParentPresence(eventType, causalParent) {
556
+ if (causalParent === null && eventType !== ROOT_EVENT_TYPE) {
557
+ throw new EventError('events.missing-causal-parent', `causal_parent may only be null for "${ROOT_EVENT_TYPE}" events (got event_type "${eventType}").`, { event_type: eventType });
558
+ }
559
+ }
560
+ /**
561
+ * Validate an event's envelope — the same causal_parent checks `appendEvent`
562
+ * runs immediately before it writes — without appending anything.
563
+ *
564
+ * `queue.ts`'s `step`/`batchStep` and `integration.ts`'s check suite each do
565
+ * real work of their own (a rebase and a no-ff merge; a full check run) ahead
566
+ * of their own first append, so a bad `--causal-parent` used to surface only
567
+ * there — after the git work had already landed for real, with no event to
568
+ * show for it. A retry then found the branch already advanced and reported it
569
+ * as stuck. Calling this first, before any of that work starts, means a bad
570
+ * envelope is refused with nothing done yet to undo.
571
+ *
572
+ * Only the causal_parent rules and the session-id shape are checked here. The
573
+ * event schema, the taxonomy and typed payloads are not: a caller whose payload
574
+ * could fail those must not treat a pass as "the append will succeed".
575
+ */
576
+ export async function validateEventEnvelope(input, opts = {}) {
577
+ const causalParent = input.causal_parent ?? null;
578
+ requireCausalParentPresence(input.event_type, causalParent);
579
+ if (causalParent === null)
580
+ return;
581
+ const filePath = logPath(input.session_id, opts);
582
+ const existing = await readEventsAtPath(filePath, input.session_id);
583
+ await validateCausalParent(input, opts, existing);
584
+ }
527
585
  async function appendEventLocked(input, opts, filePath) {
528
- // EventInput requires causal_parent, so inside the orchestrator undefined is
529
- // unreachable. It is reachable from outside: every event appended through the
530
- // CLI crosses a JSON boundary where the type guarantees nothing, and omitting
531
- // a field is the likeliest way a hand-written event is malformed. Both guards
532
- // below used to compare against `null` alone, so an omitted parent fell
533
- // through them into parseEventId(undefined) and reached the caller as a raw
534
- // TypeError with no `code` — the one shape produced by accident was the one
535
- // shape with nothing to branch on. Normalise once so both guards read it.
536
586
  const causalParent = input.causal_parent ?? null;
537
- if (causalParent === null && input.event_type !== ROOT_EVENT_TYPE) {
538
- throw new EventError('events.missing-causal-parent', `causal_parent may only be null for "${ROOT_EVENT_TYPE}" events (got event_type "${input.event_type}").`, { event_type: input.event_type });
539
- }
587
+ requireCausalParentPresence(input.event_type, causalParent);
540
588
  await mkdir(path.dirname(filePath), { recursive: true });
541
589
  const existing = await readEventsAtPath(filePath, input.session_id);
542
590
  if (causalParent !== null) {
@@ -250,7 +250,9 @@ function escapeRegExp(text) {
250
250
  * forms) — never a bare `:digits` anywhere in the text. A blanket
251
251
  * `:\d+` strip is wrong: free-text like "debug endpoint exposed on :8080"
252
252
  * would collide with an unrelated ":9090" finding (reproduced regression,
253
- * reviewer finding #3).
253
+ * reviewer finding #3). The line number may itself be a range
254
+ * (`Widget.tsx:1549-1576`), which a re-review reports as often as a single
255
+ * line (issue #178).
254
256
  */
255
257
  function stripPathLineRef(summary, filePath) {
256
258
  const forwardSlashPath = filePath.replace(/\\/g, '/');
@@ -259,16 +261,37 @@ function stripPathLineRef(summary, filePath) {
259
261
  for (const candidate of candidates) {
260
262
  if (!candidate)
261
263
  continue;
262
- const pattern = new RegExp(`${escapeRegExp(candidate)}:\\d+(?::\\d+)?`, 'g');
264
+ const pattern = new RegExp(`${escapeRegExp(candidate)}:\\d+(?:[-–—]\\d+)?(?::\\d+)?`, 'g');
263
265
  result = result.replace(pattern, candidate);
264
266
  }
265
267
  return result;
266
268
  }
269
+ /**
270
+ * A `lines N-M` / `lines N–M` (en dash) / `line N to line M` range — the
271
+ * shape a judge describing a large component actually writes, and the one
272
+ * the original single `line N` strip below never covered (issue #178).
273
+ * Anchored on the word "line"/"lines" immediately before the numbers, so
274
+ * free-text numerics elsewhere in the sentence are never touched.
275
+ */
276
+ const LINE_SPAN_RE = /\blines?\s+\d[\d,]*\s*(?:[-–—]|to(?:\s+lines?)?)\s*\d[\d,]*\b/g;
277
+ /**
278
+ * `~5,050-line` / `6,720-line` — a size given as a hyphenated adjective
279
+ * before "line"/"component"/"file"/etc, approximate or exact. Only the
280
+ * `<number>-line` token is removed; the noun it modifies is untouched, and
281
+ * a hyphenated count before any OTHER noun (`3-day`, `5-retry`) is left
282
+ * alone — it is not a line reference.
283
+ */
284
+ const LINE_COUNT_RE = /~?\d[\d,]*-line\b/g;
285
+ /** `L1549-L6600` — a GitHub-style permalink line range, bare or after `#`. */
286
+ const GITHUB_LINE_RANGE_RE = /\bl\d+-l\d+\b/g;
267
287
  /** lowercase, whitespace collapsed (spec wording; path-anchored line refs stripped by the caller). */
268
288
  function normalizeSummary(summary) {
269
289
  return summary
270
290
  .toLowerCase()
291
+ .replace(LINE_SPAN_RE, '')
271
292
  .replace(/\bline\s+\d+\b/g, '')
293
+ .replace(LINE_COUNT_RE, '')
294
+ .replace(GITHUB_LINE_RANGE_RE, '')
272
295
  .replace(/\s+/g, ' ')
273
296
  .trim();
274
297
  }