@juzser/blacksmith 0.3.0 → 0.5.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 (141) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +16 -9
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +45 -31
  17. package/.claude/skills/bs/dispatch.md +133 -47
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +57 -16
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +190 -52
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +143 -50
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +70 -13
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +245 -20
  35. package/factory/orchestrator/dist/autonomy.js +6 -2
  36. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  37. package/factory/orchestrator/dist/budgets.js +302 -84
  38. package/factory/orchestrator/dist/claims.js +9 -19
  39. package/factory/orchestrator/dist/cli.js +1087 -116
  40. package/factory/orchestrator/dist/cliName.js +27 -0
  41. package/factory/orchestrator/dist/crossFinding.js +20 -0
  42. package/factory/orchestrator/dist/crosscheck.js +136 -8
  43. package/factory/orchestrator/dist/daemon.js +352 -67
  44. package/factory/orchestrator/dist/db/projector.js +327 -54
  45. package/factory/orchestrator/dist/db/queries.js +1831 -94
  46. package/factory/orchestrator/dist/db/schema.js +57 -4
  47. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  48. package/factory/orchestrator/dist/delegation.js +86 -19
  49. package/factory/orchestrator/dist/diffstat.js +15 -8
  50. package/factory/orchestrator/dist/dispatchLint.js +108 -0
  51. package/factory/orchestrator/dist/effort.js +14 -2
  52. package/factory/orchestrator/dist/effortTiers.js +13 -0
  53. package/factory/orchestrator/dist/env.js +26 -0
  54. package/factory/orchestrator/dist/epic.js +494 -42
  55. package/factory/orchestrator/dist/epicBudget.js +93 -0
  56. package/factory/orchestrator/dist/epicWidth.js +22 -5
  57. package/factory/orchestrator/dist/errorIssues.js +182 -11
  58. package/factory/orchestrator/dist/escalation.js +2 -2
  59. package/factory/orchestrator/dist/events.js +164 -14
  60. package/factory/orchestrator/dist/feedback.js +169 -0
  61. package/factory/orchestrator/dist/findingContext.js +26 -0
  62. package/factory/orchestrator/dist/findings.js +108 -14
  63. package/factory/orchestrator/dist/gate.js +120 -10
  64. package/factory/orchestrator/dist/gh.js +19 -2
  65. package/factory/orchestrator/dist/git.js +20 -2
  66. package/factory/orchestrator/dist/goalCheck.js +2 -2
  67. package/factory/orchestrator/dist/harness.js +25 -17
  68. package/factory/orchestrator/dist/hookDecision.js +411 -39
  69. package/factory/orchestrator/dist/integration.js +20 -8
  70. package/factory/orchestrator/dist/issueReporter.js +81 -16
  71. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  72. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  73. package/factory/orchestrator/dist/judgeStopHook.js +239 -0
  74. package/factory/orchestrator/dist/judges.js +419 -21
  75. package/factory/orchestrator/dist/lessons.js +190 -11
  76. package/factory/orchestrator/dist/logCache.js +291 -0
  77. package/factory/orchestrator/dist/mcp.js +6 -6
  78. package/factory/orchestrator/dist/paths.js +20 -7
  79. package/factory/orchestrator/dist/plan.js +399 -33
  80. package/factory/orchestrator/dist/planQuorum.js +5 -2
  81. package/factory/orchestrator/dist/policy.js +1275 -30
  82. package/factory/orchestrator/dist/projectName.js +25 -0
  83. package/factory/orchestrator/dist/projects.js +12 -1
  84. package/factory/orchestrator/dist/promptCapture.js +306 -0
  85. package/factory/orchestrator/dist/promptHook.js +33 -0
  86. package/factory/orchestrator/dist/promptImport.js +281 -0
  87. package/factory/orchestrator/dist/prompts.js +13 -0
  88. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  89. package/factory/orchestrator/dist/providers/types.js +1 -1
  90. package/factory/orchestrator/dist/queue.js +471 -30
  91. package/factory/orchestrator/dist/quorum.js +18 -5
  92. package/factory/orchestrator/dist/results.js +25 -7
  93. package/factory/orchestrator/dist/roadmap.js +10 -5
  94. package/factory/orchestrator/dist/run-cli.js +15 -10
  95. package/factory/orchestrator/dist/runner.js +2 -2
  96. package/factory/orchestrator/dist/scheduler.js +3 -3
  97. package/factory/orchestrator/dist/schemas.js +56 -8
  98. package/factory/orchestrator/dist/severity.js +8 -4
  99. package/factory/orchestrator/dist/spec.js +246 -12
  100. package/factory/orchestrator/dist/specChange.js +13 -9
  101. package/factory/orchestrator/dist/stack.js +6 -6
  102. package/factory/orchestrator/dist/storeRoot.js +90 -0
  103. package/factory/orchestrator/dist/symbols.js +261 -6
  104. package/factory/orchestrator/dist/taskEvents.js +192 -31
  105. package/factory/orchestrator/dist/taskStatus.js +14 -0
  106. package/factory/orchestrator/dist/taxonomy.js +11 -0
  107. package/factory/orchestrator/dist/testerAudit.js +31 -12
  108. package/factory/orchestrator/dist/testgate.js +20 -16
  109. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  110. package/factory/orchestrator/dist/usage.js +113 -48
  111. package/factory/orchestrator/dist/waivers.js +99 -12
  112. package/factory/orchestrator/dist/waveBudget.js +2 -0
  113. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  114. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  115. package/factory/orchestrator/dist/workroot.js +1 -1
  116. package/factory/orchestrator/dist/worktree.js +76 -21
  117. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  118. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  119. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  120. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  121. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  122. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  123. package/factory/policies/budgets.yml +123 -33
  124. package/factory/policies/crosscheck.yml +51 -13
  125. package/factory/policies/delegation.yml +3 -3
  126. package/factory/policies/effort.yml +18 -10
  127. package/factory/policies/guardrails.yml +8 -6
  128. package/factory/policies/harness.yml +5 -5
  129. package/factory/policies/lessons.md +53 -2
  130. package/factory/policies/scheduler.yml +4 -4
  131. package/factory/policies/sensitive-paths.yml +1 -1
  132. package/factory/policies/severity.yml +13 -1
  133. package/factory/policies/stack.yml +6 -6
  134. package/factory/policies/taxonomy.yml +9 -3
  135. package/factory/policies/worktree.yml +15 -9
  136. package/factory/specs/roadmap.md +27 -3
  137. package/factory/specs/schema/event.schema.json +8 -1
  138. package/factory/specs/schema/result.schema.json +21 -8
  139. package/factory/specs/schema/task-spec.schema.json +12 -0
  140. package/package.json +16 -12
  141. package/.claude/agents/uiux.md +0 -133
@@ -1,8 +1,11 @@
1
+ import { isOperatorActor } from './actors.js';
1
2
  import { SmithError } from './errors.js';
2
3
  import { appendEvent, readLineageEvents } from './events.js';
3
- import { listFindings, preWaiverStatus, staleFindings, transition, WAIVABLE_STATUSES, } from './findings.js';
4
+ import { computeFingerprint, foldFindings, listFindings, preWaiverStatus, staleFindings, transition, WAIVABLE_STATUSES, } from './findings.js';
4
5
  export class WaiverError extends SmithError {
5
6
  }
7
+ /** The event `approveTaskWaiver` writes and epic.ts's withGateEvidence reads. */
8
+ export const TASK_WAIVER_APPROVED_EVENT = 'task-waiver-approved';
6
9
  /**
7
10
  * Only S3/S4 findings are ever waived (severity.yml waiver_semantics) —
8
11
  * single source of truth, reused by findings.ts's transition() so `→ waived`
@@ -10,6 +13,38 @@ export class WaiverError extends SmithError {
10
13
  * call or via a waiver grant).
11
14
  */
12
15
  export const WAIVABLE_SEVERITIES = ['S3-minor', 'S4-nit'];
16
+ /**
17
+ * Every fingerprint that names the same finding as `fingerprint`, for each
18
+ * candidate in `findings` that could stand in for it: its own stored value,
19
+ * and what computeFingerprint would produce today from its persisted
20
+ * file_path/finding_category/summary. Mirrors audit.ts's
21
+ * suppressedFingerprints, for the same reason (issue #178): a `finding-raised`
22
+ * or waiver-decision record written before the normalizer widened to also
23
+ * strip line spans ("lines 1549-1576") fingerprints differently than the
24
+ * identical text does now, so a stored fingerprint and a freshly computed one
25
+ * for the same defect can legitimately disagree.
26
+ *
27
+ * Always includes `fingerprint` itself, so a caller with no backing finding
28
+ * yet (e.g. a batch decision not folded into findings) still gets an
29
+ * exact-match-only set — unchanged behaviour.
30
+ */
31
+ function fingerprintAliases(fingerprint, findings) {
32
+ const aliases = new Set([fingerprint]);
33
+ for (const finding of findings) {
34
+ if (finding.file_path === undefined)
35
+ continue;
36
+ const recomputed = computeFingerprint({
37
+ filePath: finding.file_path,
38
+ category: finding.finding_category,
39
+ summary: finding.summary,
40
+ });
41
+ if (finding.fingerprint === fingerprint || recomputed === fingerprint) {
42
+ aliases.add(finding.fingerprint);
43
+ aliases.add(recomputed);
44
+ }
45
+ }
46
+ return aliases;
47
+ }
13
48
  /**
14
49
  * Fold waiver-granted/waiver-denied events for one fingerprint: the last
15
50
  * decision wins (a denial can, in principle, be revisited by a later grant
@@ -20,13 +55,19 @@ export const WAIVABLE_SEVERITIES = ['S3-minor', 'S4-nit'];
20
55
  * would re-raise a finding the operator already waived the moment an epic
21
56
  * continued in a new session. "Last decision wins" is decided on the merged
22
57
  * order, which is `ts` between sessions and append order within one.
58
+ *
59
+ * Matches against `fingerprintAliases(fingerprint, ...)` rather than strict
60
+ * equality (issue #178): a decision recorded under the fingerprint's old
61
+ * spelling still answers a lookup under its current one, and vice versa.
23
62
  */
24
63
  export async function isWaived(fingerprint, ref, opts = {}) {
25
64
  const events = await readLineageEvents(ref.sessionId, opts);
65
+ const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
26
66
  let waived = false;
27
67
  for (const { record } of events) {
28
68
  const payload = record.payload;
29
- if (payload.fingerprint !== fingerprint)
69
+ const fp = payload.fingerprint;
70
+ if (fp === undefined || !equivalents.has(fp))
30
71
  continue;
31
72
  if (record.event_type === 'waiver-granted')
32
73
  waived = true;
@@ -38,13 +79,17 @@ export async function isWaived(fingerprint, ref, opts = {}) {
38
79
  /**
39
80
  * Has the operator recorded ANY waiver decision for this fingerprint (granted
40
81
  * or denied)? Lineage-wide, for isWaived's reason (D-119): a decision recorded
41
- * in the parent session is still a decision.
82
+ * in the parent session is still a decision. Same alias widening as isWaived,
83
+ * and for the same reason (issue #178).
42
84
  */
43
85
  async function hasDecision(fingerprint, ref, opts) {
44
86
  const events = await readLineageEvents(ref.sessionId, opts);
87
+ const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
45
88
  return events.some(({ record }) => {
46
89
  const payload = record.payload;
47
- return (payload.fingerprint === fingerprint &&
90
+ const fp = payload.fingerprint;
91
+ return (fp !== undefined &&
92
+ equivalents.has(fp) &&
48
93
  (record.event_type === 'waiver-granted' || record.event_type === 'waiver-denied'));
49
94
  });
50
95
  }
@@ -56,10 +101,16 @@ async function hasDecision(fingerprint, ref, opts) {
56
101
  * finding as open forever. Findings the state machine can't legally move
57
102
  * (already terminal, or a non-S3/S4 severity that somehow shares this
58
103
  * fingerprint) are left alone; this is reconciliation, not a mandate.
104
+ *
105
+ * Matched via fingerprintAliases (issue #178), not strict equality: a grant
106
+ * against a finding's current fingerprint also reconciles a row for the
107
+ * identical defect still carrying the pre-widening fingerprint it was raised
108
+ * under, and vice versa.
59
109
  */
60
110
  async function reconcileFindingsToWaived(fingerprint, waiverEventId, ctx, opts) {
61
111
  const findings = await listFindings(ctx.sessionId, {}, opts);
62
- const reconcilable = findings.filter((f) => f.fingerprint === fingerprint &&
112
+ const equivalents = fingerprintAliases(fingerprint, findings);
113
+ const reconcilable = findings.filter((f) => equivalents.has(f.fingerprint) &&
63
114
  WAIVABLE_SEVERITIES.includes(f.severity) &&
64
115
  WAIVABLE_STATUSES.includes(f.finding_status));
65
116
  const transitionCtx = { ...ctx, causalParent: waiverEventId };
@@ -81,6 +132,33 @@ export async function grantWaiver(fingerprint, operatorNote, ctx, opts = {}) {
81
132
  await reconcileFindingsToWaived(fingerprint, waiverEvent.event_id, ctx, opts);
82
133
  return waiverEvent;
83
134
  }
135
+ /**
136
+ * Record the operator's approval of one task's waiver. A task reaches
137
+ * `waived` through its plan row, which carries no gate run; this event is the
138
+ * evidence the epic gate (epic.ts's withGateEvidence) looks for instead.
139
+ *
140
+ * Refused unless the actor is the operator (actors.ts) and a rationale is
141
+ * given: the approval is only worth anything as a person's recorded decision.
142
+ * The default actor is the one every other operator decision defaults to.
143
+ */
144
+ export async function approveTaskWaiver(taskId, operatorNote, ctx, opts = {}) {
145
+ const actor = ctx.actor ?? 'user';
146
+ if (!isOperatorActor(actor)) {
147
+ throw new WaiverError('waivers.not-operator', `Actor "${actor}" cannot approve a task waiver — only the operator can. Pass --actor operator.`, { actor, taskId });
148
+ }
149
+ if (operatorNote.trim() === '') {
150
+ throw new WaiverError('waivers.note-required', `Approving the waiver of task "${taskId}" needs a short rationale (--note).`, { taskId });
151
+ }
152
+ return appendEvent({
153
+ session_id: ctx.sessionId,
154
+ actor,
155
+ event_type: TASK_WAIVER_APPROVED_EVENT,
156
+ task_id: taskId,
157
+ plan_version: ctx.planVersion,
158
+ causal_parent: ctx.causalParent,
159
+ payload: { operator_note: operatorNote },
160
+ }, opts);
161
+ }
84
162
  /**
85
163
  * The mirror of reconcileFindingsToWaived, and it exists for the same reason
86
164
  * (D-180). A denial that follows a grant revokes it — isWaived() folds
@@ -92,11 +170,16 @@ export async function grantWaiver(fingerprint, operatorNote, ctx, opts = {}) {
92
170
  *
93
171
  * Only findings this session's log actually shows at `waived` are touched, and
94
172
  * each goes back to the status its own grant recorded as from_status. A
95
- * finding waived under some other fingerprint is not this denial's business.
173
+ * finding waived under some other fingerprint is not this denial's business —
174
+ * "other" meaning a different defect, not merely a different spelling: matched
175
+ * via fingerprintAliases (issue #178), the same widening reconcileFindingsToWaived
176
+ * applies, so a row still carrying the pre-widening fingerprint it was raised
177
+ * under reopens alongside its current-fingerprint sibling.
96
178
  */
97
179
  async function reconcileFindingsFromWaived(fingerprint, denialEventId, ctx, opts) {
98
180
  const findings = await listFindings(ctx.sessionId, {}, opts);
99
- const reopenable = findings.filter((f) => f.fingerprint === fingerprint && f.finding_status === 'waived');
181
+ const equivalents = fingerprintAliases(fingerprint, findings);
182
+ const reopenable = findings.filter((f) => equivalents.has(f.fingerprint) && f.finding_status === 'waived');
100
183
  const transitionCtx = { ...ctx, causalParent: denialEventId };
101
184
  for (const finding of reopenable) {
102
185
  const to = await preWaiverStatus(finding.finding_id, ctx.sessionId, opts);
@@ -203,7 +286,7 @@ export async function applyBatch(decisions, ctx, opts = {}) {
203
286
  `"${evidence.mergedTaskId}" has merged over since the finding was raised (matched by ` +
204
287
  `${evidence.basis}: ${evidence.matched}). Its evidence describes code that no longer ` +
205
288
  'exists, so this waiver would answer a question about a deleted file. Re-read it first: ' +
206
- `smith findings reverify --finding ${finding.finding_id}`, {
289
+ `bs findings reverify --finding ${finding.finding_id}`, {
207
290
  fingerprint: decision.fingerprint,
208
291
  findingId: finding.finding_id,
209
292
  filePath: evidence.filePath,
@@ -213,14 +296,18 @@ export async function applyBatch(decisions, ctx, opts = {}) {
213
296
  }
214
297
  }
215
298
  }
216
- const results = [];
299
+ const events = [];
300
+ const findingIdsToCarry = new Set();
217
301
  for (const decision of decisions) {
218
302
  if (decision.decision === 'granted') {
219
- results.push(await grantWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
303
+ events.push(await grantWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
220
304
  }
221
305
  else {
222
- results.push(await denyWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
306
+ events.push(await denyWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
307
+ for (const finding of findingsByFingerprint.get(decision.fingerprint) ?? []) {
308
+ findingIdsToCarry.add(finding.finding_id);
309
+ }
223
310
  }
224
311
  }
225
- return results;
312
+ return { events, findingIdsToCarry: [...findingIdsToCarry] };
226
313
  }
@@ -153,6 +153,7 @@ export function checkWaveBudget(events, policy, proposed, options) {
153
153
  if (options.epicId.trim().length === 0) {
154
154
  return {
155
155
  epicId: null,
156
+ tier: policy.tier,
156
157
  capTokens,
157
158
  measuredTokens: 0,
158
159
  projectedTokens: 0,
@@ -189,6 +190,7 @@ export function checkWaveBudget(events, policy, proposed, options) {
189
190
  const inFlight = inFlightTasks(events, epicId).length;
190
191
  const base = {
191
192
  epicId,
193
+ tier: policy.tier,
192
194
  capTokens,
193
195
  measuredTokens,
194
196
  projectedTokens,
@@ -1,4 +1,6 @@
1
- import { foldAgents } from './agents-registry.js';
1
+ import { DISPATCH_EVENT_TYPE, foldAgents } from './agents-registry.js';
2
+ import { eventTaskId } from './events.js';
3
+ import { epicOfTaskId, isQualifiedTaskId, taskIdsMatch } from './taskId.js';
2
4
  /**
3
5
  * Did the wave that was admitted N wide actually run N wide?
4
6
  *
@@ -24,6 +26,7 @@ import { foldAgents } from './agents-registry.js';
24
26
  * intervals open forever and score every wave after one as parallel.
25
27
  */
26
28
  const ADMITTED_EVENT_TYPE = 'wave-admitted';
29
+ const MERGED_EVENT_TYPE = 'wave-merged';
27
30
  /**
28
31
  * What the log says a wave did.
29
32
  *
@@ -33,12 +36,29 @@ const ADMITTED_EVENT_TYPE = 'wave-admitted';
33
36
  * - `single` — the wave admitted one task. Nothing to be parallel about,
34
37
  * and scoring it against a width it never claimed would make
35
38
  * the failing verdicts unreadable.
39
+ * - `unlinked` — this fold's intervals hold nothing, but the lineage still
40
+ * has proof the work happened: a `wave-merged` for one of
41
+ * the admitted tasks, or a `dispatch_decision` parented
42
+ * directly on the admission. A wave-runner session started
43
+ * without `--continues` has `causal_parent: null` on its
44
+ * root, so its own dispatch_decisions never reach this fold
45
+ * even though its agents genuinely ran. Width is unmeasured
46
+ * here, not zero — a different fact from `unobserved`, which
47
+ * is exactly why it is not folded into it.
36
48
  * - `unobserved` — the wave was admitted and the log records no work for any
37
- * of its tasks. Not the same fact as `serialized`, and the
49
+ * of its tasks, and nothing else on the lineage says
50
+ * otherwise. Not the same fact as `serialized`, and the
38
51
  * whole point of separating them: one says the factory ran
39
52
  * narrow, the other says nobody can tell.
40
53
  */
41
- export const WAVE_VERDICTS = ['parallel', 'partial', 'serialized', 'single', 'unobserved'];
54
+ export const WAVE_VERDICTS = [
55
+ 'parallel',
56
+ 'partial',
57
+ 'serialized',
58
+ 'single',
59
+ 'unlinked',
60
+ 'unobserved',
61
+ ];
42
62
  /**
43
63
  * A counter with one bucket per key, all at zero.
44
64
  *
@@ -61,6 +81,17 @@ export function zeroedCounts(keys) {
61
81
  export const UNOBSERVED_HINT = 'A wave admitted with no dispatch_decision under any of its tasks ran nowhere this log can ' +
62
82
  'see. Either the dispatcher never started it, or the agents ran outside the lineage that was ' +
63
83
  'read — narrow with --epic, or check that the run wrote its dispatches to this state dir.';
84
+ /**
85
+ * Said when a wave came back `unlinked`: this fold has no dispatch interval
86
+ * for it, but the lineage holds a `wave-merged` for one of its tasks or a
87
+ * dispatch_decision parented on the admission itself, so the work is real —
88
+ * only its width is unmeasured. The usual cause is a wave-runner session
89
+ * started without `--continues`, which leaves its own dispatches outside the
90
+ * lineage this read walks even though they ran.
91
+ */
92
+ export const UNLINKED_HINT = 'A wave admitted with merged work or a dispatch on record, but not inside this lineage, so ' +
93
+ 'its width cannot be measured. Likely a wave-runner session started without --continues — ' +
94
+ 'its dispatches are real but outside the chain this read walked.';
64
95
  function readAdmission(event) {
65
96
  const payload = event.record.payload;
66
97
  const raw = payload.task_ids;
@@ -119,17 +150,109 @@ function peakOverlap(runs) {
119
150
  }
120
151
  return peak;
121
152
  }
122
- function verdictFor(declared, observed, peak) {
153
+ function verdictFor(declared, observed, peak, offLineageEvidence) {
123
154
  if (declared <= 1)
124
155
  return 'single';
125
- if (observed === 0)
156
+ if (observed === 0) {
157
+ if (offLineageEvidence)
158
+ return 'unlinked';
126
159
  return 'unobserved';
160
+ }
127
161
  if (peak >= declared)
128
162
  return 'parallel';
129
163
  if (peak <= 1)
130
164
  return 'serialized';
131
165
  return 'partial';
132
166
  }
167
+ /**
168
+ * Whether `admission` is the one a `wave-merged` id names.
169
+ *
170
+ * A qualified id ("A/task-1") must match the admission's epic exactly — no
171
+ * falling back to the bare half, because that fallback is exactly how a bare
172
+ * merge under one epic used to mark an unrelated epic's qualified admission
173
+ * as having merge evidence. A bare id ("task-1") carries no epic of its own,
174
+ * so it may match any admission's declared task by its bare half; which
175
+ * admission it actually belongs to is for {@link latestMatchingAdmission} to
176
+ * decide, not this predicate.
177
+ */
178
+ function admissionClaims(admission, mergedId) {
179
+ if (isQualifiedTaskId(mergedId) && epicOfTaskId(mergedId) !== admission.epicId)
180
+ return false;
181
+ return admission.taskIds.some((taskId) => taskIdsMatch(taskId, mergedId));
182
+ }
183
+ /**
184
+ * The one admission a `wave-merged` id belongs to: the latest whose `ts` is
185
+ * at or before the merge's `ts` and whose declared tasks match it — the same
186
+ * "nearest preceding admission" rule `admittedAtByTask` uses to close a
187
+ * task's window, reused here rather than re-derived, so a task re-admitted in
188
+ * a later wave sends its merge to that wave and not the one before it. A
189
+ * merge timestamped before every candidate admission belongs to none of them;
190
+ * returning null rather than the earliest is what keeps that merge from
191
+ * counting at all.
192
+ */
193
+ function latestMatchingAdmission(mergedId, mergeTs, admissions) {
194
+ let best = null;
195
+ for (const admission of admissions) {
196
+ if (admission.ts > mergeTs)
197
+ continue;
198
+ if (!admissionClaims(admission, mergedId))
199
+ continue;
200
+ if (best === null || admission.ts > best.ts)
201
+ best = admission;
202
+ }
203
+ return best;
204
+ }
205
+ /**
206
+ * The `wave-admitted` event ids that own at least one `wave-merged` id,
207
+ * attributed one merge at a time over the whole log so each admission can ask
208
+ * "is my work here" without rescanning — and so an admission the epic filter
209
+ * excludes must not narrow what another admission can see.
210
+ *
211
+ * Both the envelope's `task_id` and the payload's `task_ids` are read,
212
+ * because `emitWaveMerged` writes the same id to both; either is enough.
213
+ */
214
+ function admissionsWithMergeEvidence(events, admissions) {
215
+ const credited = new Set();
216
+ for (const event of events) {
217
+ if (event.record.event_type !== MERGED_EVENT_TYPE)
218
+ continue;
219
+ const ids = [];
220
+ const fromEnvelope = eventTaskId(event.record);
221
+ if (fromEnvelope)
222
+ ids.push(fromEnvelope);
223
+ const payload = event.record.payload;
224
+ if (Array.isArray(payload?.task_ids)) {
225
+ for (const id of payload.task_ids) {
226
+ if (typeof id === 'string' && id.length > 0)
227
+ ids.push(id);
228
+ }
229
+ }
230
+ for (const id of ids) {
231
+ const owner = latestMatchingAdmission(id, event.record.ts, admissions);
232
+ if (owner)
233
+ credited.add(owner.eventId);
234
+ }
235
+ }
236
+ return credited;
237
+ }
238
+ /**
239
+ * The `wave-admitted` event ids that a `dispatch_decision` names as its
240
+ * direct causal parent — the shape a wave-runner's own agent takes when it is
241
+ * dispatched straight off the admission rather than through a task. Read from
242
+ * raw events rather than `foldAgents`, because that fold only keeps agents
243
+ * with a task id, and this dispatch has none.
244
+ */
245
+ function dispatchedDirectlyOn(events) {
246
+ const parents = new Set();
247
+ for (const event of events) {
248
+ if (event.record.event_type !== DISPATCH_EVENT_TYPE)
249
+ continue;
250
+ const parent = event.record.causal_parent;
251
+ if (typeof parent === 'string' && parent.length > 0)
252
+ parents.add(parent);
253
+ }
254
+ return parents;
255
+ }
133
256
  /**
134
257
  * Fold one task's agents into the interval it was worked on. Every role counts,
135
258
  * not just the coder's: a task whose reviewer is still running is still work
@@ -199,6 +322,14 @@ export function auditWaveConcurrency(events, options = {}) {
199
322
  else
200
323
  agentsByTask.set(agent.taskId, [agent]);
201
324
  }
325
+ // Also built from every event, unfiltered: off-lineage evidence for a wave
326
+ // the caller narrowed away must not leak in, but an admission's own window
327
+ // must see all of it regardless of which epic the caller asked for. Merge
328
+ // attribution needs every admission as a candidate, not just the ones the
329
+ // caller's epic filter kept — the same reason `admissions` above is built
330
+ // before that filter is applied.
331
+ const mergeCredited = admissionsWithMergeEvidence(events, admissions);
332
+ const directDispatchParents = dispatchedDirectlyOn(events);
202
333
  const waves = [];
203
334
  for (const admission of admissions) {
204
335
  if (options.epicId !== undefined && admission.epicId !== options.epicId)
@@ -219,6 +350,7 @@ export function auditWaveConcurrency(events, options = {}) {
219
350
  }
220
351
  observed.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
221
352
  const peak = peakOverlap(observed);
353
+ const offLineageEvidence = directDispatchParents.has(admission.eventId) || mergeCredited.has(admission.eventId);
222
354
  waves.push({
223
355
  eventId: admission.eventId,
224
356
  admittedAt: admission.ts,
@@ -227,7 +359,7 @@ export function auditWaveConcurrency(events, options = {}) {
227
359
  observed,
228
360
  unobserved,
229
361
  peak,
230
- verdict: verdictFor(admission.taskIds.length, observed.length, peak),
362
+ verdict: verdictFor(admission.taskIds.length, observed.length, peak, offLineageEvidence),
231
363
  });
232
364
  }
233
365
  return waves;
@@ -264,16 +396,20 @@ function labelsWith(waves, verdict) {
264
396
  export function summariseWaveConcurrency(waves) {
265
397
  const serialized = labelsWith(waves, 'serialized');
266
398
  const unobserved = labelsWith(waves, 'unobserved');
399
+ const unlinked = labelsWith(waves, 'unlinked');
267
400
  return {
268
401
  waves: [...waves],
269
402
  serialized,
270
403
  partial: labelsWith(waves, 'partial'),
271
404
  unobserved,
405
+ unlinked,
272
406
  widest: {
273
407
  declared: waves.reduce((max, wave) => Math.max(max, wave.declared.length), 0),
274
408
  observed: waves.reduce((max, wave) => Math.max(max, wave.peak), 0),
275
409
  },
276
- hint: unobserved.length > 0 ? UNOBSERVED_HINT : '',
410
+ hint: unobserved.length > 0 ? UNOBSERVED_HINT : unlinked.length > 0 ? UNLINKED_HINT : '',
411
+ // `unlinked` is real work off-lineage, not a failure to judge, so it never
412
+ // worsens the exit code the way `unobserved` does.
277
413
  exitCode: serialized.length > 0 ? 1 : unobserved.length > 0 ? 2 : 0,
278
414
  };
279
415
  }
@@ -40,6 +40,8 @@
40
40
  * Pure, and it writes nothing. Like `waveNext`, admission stays where it was:
41
41
  * a round simulated here has been admitted by nobody.
42
42
  */
43
+ import { claimsOverlap, readEdgeList } from './claims.js';
44
+ import { planClaimedTasks } from './plan.js';
43
45
  import { computeNextWave, } from './waveNext.js';
44
46
  /**
45
47
  * Reported in this order, which is the order `computeNextWave` tests them in.
@@ -150,3 +152,85 @@ export function scheduleWaves(input) {
150
152
  exitCode: stalled.length > 0 ? 1 : constraints.length > 0 ? 2 : 0,
151
153
  };
152
154
  }
155
+ /**
156
+ * Directed reachability over the plan's `depends_on` edges, both ways: the
157
+ * tasks reachable by walking forward (its descendants) and the tasks
158
+ * reachable by walking backward (its ancestors). Two tasks are connected —
159
+ * "a dependency path between them in either direction" — when either set
160
+ * contains the other, which a chain like C -> B -> A catches for A and C
161
+ * even though no edge names that pair directly.
162
+ */
163
+ function connectedTaskIds(ids, edges) {
164
+ const descendantsOf = new Map();
165
+ const ancestorsOf = new Map();
166
+ for (const id of ids) {
167
+ descendantsOf.set(id, []);
168
+ ancestorsOf.set(id, []);
169
+ }
170
+ for (const edge of edges) {
171
+ if (!ids.has(edge.task) || !ids.has(edge.dependsOn))
172
+ continue;
173
+ descendantsOf.get(edge.dependsOn)?.push(edge.task);
174
+ ancestorsOf.get(edge.task)?.push(edge.dependsOn);
175
+ }
176
+ const reachable = (start, adjacency) => {
177
+ const seen = new Set();
178
+ const stack = [...(adjacency.get(start) ?? [])];
179
+ while (stack.length > 0) {
180
+ const next = stack.pop();
181
+ if (seen.has(next))
182
+ continue;
183
+ seen.add(next);
184
+ for (const n of adjacency.get(next) ?? [])
185
+ if (!seen.has(n))
186
+ stack.push(n);
187
+ }
188
+ return seen;
189
+ };
190
+ const connected = new Map();
191
+ for (const id of ids) {
192
+ connected.set(id, new Set([...reachable(id, descendantsOf), ...reachable(id, ancestorsOf)]));
193
+ }
194
+ return connected;
195
+ }
196
+ const SMALL_MAX_TASKS = 3;
197
+ const SMALL_MAX_CLAIMS = 10;
198
+ /**
199
+ * Pure. Superseded tasks are absent from `planClaimedTasks` already (D-126),
200
+ * so they never enter `widest`, `parallelWith`, or `size` — no separate
201
+ * filter needed here.
202
+ */
203
+ export function computePlanParallelism(plan, policy) {
204
+ const claimedTasks = planClaimedTasks(plan).filter((t) => t.task_id !== undefined);
205
+ const ids = new Set(claimedTasks.map((t) => t.task_id));
206
+ const edges = readEdgeList(plan.edges);
207
+ const connected = connectedTaskIds(ids, edges);
208
+ const parallelWith = {};
209
+ for (const a of claimedTasks) {
210
+ const withIds = [];
211
+ for (const b of claimedTasks) {
212
+ if (a.task_id === b.task_id)
213
+ continue;
214
+ if (connected.get(a.task_id)?.has(b.task_id))
215
+ continue;
216
+ if (claimsOverlap(a, b).overlaps)
217
+ continue;
218
+ withIds.push(b.task_id);
219
+ }
220
+ parallelWith[a.task_id] = withIds.sort();
221
+ }
222
+ const { widest } = scheduleWaves({ plan, policy });
223
+ const claimPaths = new Set();
224
+ for (const t of claimedTasks)
225
+ for (const c of t.claims)
226
+ claimPaths.add(c);
227
+ return {
228
+ widest,
229
+ parallel_with: parallelWith,
230
+ size: {
231
+ tasks: claimedTasks.length,
232
+ claims: claimPaths.size,
233
+ small: claimedTasks.length <= SMALL_MAX_TASKS && claimPaths.size <= SMALL_MAX_CLAIMS,
234
+ },
235
+ };
236
+ }
@@ -37,7 +37,7 @@ const WORK_DIRS = [
37
37
  * the roadmap and the stack answers are declarations the operator made, and a
38
38
  * team sharing the repository should share them.
39
39
  */
40
- const WORK_ROOT_GITIGNORE = `# Written by \`smith init\`. Blacksmith's state is a cache of its event log
40
+ const WORK_ROOT_GITIGNORE = `# Written by \`bs init\`. Blacksmith's state is a cache of its event log
41
41
  # and belongs to this machine; the answers under factory/ are declarations
42
42
  # this project made, so they are tracked.
43
43
  state/