@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
@@ -0,0 +1,93 @@
1
+ // budgets.yml sized for one epic's effort tier.
2
+ //
3
+ // budgets.ts cannot read a plan or effort.yml's resolution itself — plan.ts
4
+ // imports it, and effort.ts imports plan.ts — so the step from "a plan" or
5
+ // "an epic id seen in the event log" to "the policy for that epic's tier"
6
+ // lives here, one level up. Every budget reader that knows its plan goes
7
+ // through `budgetTierForPlan`: the tier a budget is sized for is the tier the
8
+ // epic RUNS at, after effort.yml's security floor, not the plan's raw ask.
9
+ import { loadBudgetPolicy, resolveBudgetTier } from './budgets.js';
10
+ import { loadCrosscheckPolicy } from './crosscheck.js';
11
+ import { loadEffortPolicy, resolveEffort } from './effort.js';
12
+ import { BUDGETS_POLICY_PATH } from './paths.js';
13
+ import { isEffortTier, latestPlanVersion, loadPlan, } from './plan.js';
14
+ /**
15
+ * An epic id is a directory name under the specs dir. One read out of the
16
+ * event log is data, not a path: anything that is not a plain name is treated
17
+ * as "no plan found" rather than joined onto a filesystem path.
18
+ */
19
+ const PLAIN_EPIC_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
20
+ /**
21
+ * The effort tier `plan` is budgeted at: effort.ts `resolveEffort(...).effective`,
22
+ * so a `small` plan whose live tasks trip a crosscheck.yml security trigger is
23
+ * budgeted at effort.yml's `security_floor`, exactly the tier it runs at.
24
+ *
25
+ * A budget reader must not fail a run over a policy it only consults: when
26
+ * effort.yml or crosscheck.yml cannot be read, or the plan's tasks cannot be
27
+ * walked, the tier falls back to the plan's own `effort`, else effort.yml's
28
+ * `default_tier` (which budgets.ts refuses to guess when that is broken too).
29
+ */
30
+ export function budgetTierForPlan(plan, opts = {}) {
31
+ let effortPolicy;
32
+ try {
33
+ effortPolicy = loadEffortPolicy(opts.effortPolicyFile);
34
+ }
35
+ catch {
36
+ return resolveBudgetTier(plan?.effort, opts.effortPolicyFile);
37
+ }
38
+ if (plan === null || plan === undefined)
39
+ return effortPolicy.defaultTier;
40
+ try {
41
+ const securityPolicy = opts.securityPolicy ?? loadCrosscheckPolicy(opts.crosscheckPolicyFile).planQuorum;
42
+ return resolveEffort(effortPolicy, securityPolicy, { plan }).effective;
43
+ }
44
+ catch {
45
+ return isEffortTier(plan.effort) ? plan.effort : effortPolicy.defaultTier;
46
+ }
47
+ }
48
+ /** budgets.yml sized for `plan`'s effective tier (see `budgetTierForPlan`). */
49
+ export function budgetPolicyForPlan(plan, opts = {}) {
50
+ return loadBudgetPolicy(opts.policyFile, opts.env ?? process.env, budgetTierForPlan(plan, opts));
51
+ }
52
+ /**
53
+ * The epic's latest plan, or undefined when there is no plan to read — a log
54
+ * from another project, an epic planned outside this specs dir, an unreadable
55
+ * file. undefined sizes the budget at effort.yml's `default_tier`, which is
56
+ * what every reader did before tiers existed.
57
+ */
58
+ export function latestPlan(epicId, planOpts = {}) {
59
+ if (!PLAIN_EPIC_ID.test(epicId) || epicId.includes('..'))
60
+ return undefined;
61
+ try {
62
+ const version = latestPlanVersion(epicId, planOpts);
63
+ return version === null ? undefined : loadPlan(epicId, version, planOpts);
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
69
+ /**
70
+ * A lookup from epic id to that epic's budget policy, each tier loaded once.
71
+ * Env overrides apply per tier (`<NAME>_<TIER>` before `<NAME>`), so two
72
+ * epics of different tiers can be judged against different caps in one
73
+ * report.
74
+ */
75
+ export function epicBudgetPolicies(opts = {}) {
76
+ const policyFile = opts.policyFile ?? BUDGETS_POLICY_PATH;
77
+ const env = opts.env ?? process.env;
78
+ const byTier = new Map();
79
+ const byEpic = new Map();
80
+ return (epicId) => {
81
+ const cached = byEpic.get(epicId);
82
+ if (cached !== undefined)
83
+ return cached;
84
+ const tier = budgetTierForPlan(latestPlan(epicId, opts.planOpts), opts);
85
+ let policy = byTier.get(tier);
86
+ if (policy === undefined) {
87
+ policy = loadBudgetPolicy(policyFile, env, tier);
88
+ byTier.set(tier, policy);
89
+ }
90
+ byEpic.set(epicId, policy);
91
+ return policy;
92
+ };
93
+ }
@@ -1,4 +1,4 @@
1
- import { UNOBSERVED_HINT, WAVE_VERDICTS, zeroedCounts, } from './waveConcurrency.js';
1
+ import { UNLINKED_HINT, 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
  *
@@ -55,9 +55,9 @@ export const ALL_VERDICTS = [...WAVE_VERDICTS, ...EPIC_ONLY_VERDICTS];
55
55
  * opposite states of knowledge, and a factory that has never measured itself
56
56
  * must not be able to read as a healthy one.
57
57
  */
58
- export const UNMEASURED_HINT = 'No close read here carried a width. Either these epics were closed before `smith epic close` ' +
59
- 'recorded one, or the closes were written by hand — close a current epic with `smith epic ' +
60
- 'close`, or read a live log back with `smith wave audit --session <id>`.';
58
+ export const UNMEASURED_HINT = 'No close read here carried a width. Either these epics were closed before `bs epic close` ' +
59
+ 'recorded one, or the closes were written by hand — close a current epic with `bs epic ' +
60
+ 'close`, or read a live log back with `bs wave audit --session <id>`.';
61
61
  /** Nothing was recorded, or nothing readable was. */
62
62
  function blank(verdict, problem) {
63
63
  return {
@@ -66,6 +66,7 @@ function blank(verdict, problem) {
66
66
  byVerdict: zeroedCounts(WAVE_VERDICTS),
67
67
  widest: { declared: 0, observed: 0 },
68
68
  unobserved: [],
69
+ unlinked: [],
69
70
  problem,
70
71
  };
71
72
  }
@@ -148,6 +149,9 @@ function widthOf(payload) {
148
149
  unobserved: Array.isArray(record.unobserved)
149
150
  ? record.unobserved.filter((id) => typeof id === 'string')
150
151
  : [],
152
+ unlinked: Array.isArray(record.unlinked)
153
+ ? record.unlinked.filter((id) => typeof id === 'string')
154
+ : [],
151
155
  problem,
152
156
  };
153
157
  }
@@ -220,6 +224,9 @@ export function summariseEpicWidth(events) {
220
224
  const unobserved = epics
221
225
  .filter((e) => e.byVerdict.unobserved > 0 || e.unobserved.length > 0)
222
226
  .map((e) => e.epicId);
227
+ const unlinked = epics
228
+ .filter((e) => e.byVerdict.unlinked > 0 || e.unlinked.length > 0)
229
+ .map((e) => e.epicId);
223
230
  const unmeasured = epics
224
231
  .filter((e) => e.verdict === 'unmeasured' || e.verdict === 'unreadable')
225
232
  .map((e) => e.epicId);
@@ -236,8 +243,18 @@ export function summariseEpicWidth(events) {
236
243
  },
237
244
  serialized,
238
245
  unobserved,
246
+ unlinked,
239
247
  unmeasured,
240
- hint: unobserved.length > 0 ? UNOBSERVED_HINT : judged ? '' : UNMEASURED_HINT,
248
+ // `unlinked` names real work, so it earns a hint pointing the operator at
249
+ // it but never the exit code `unobserved` carries -- that code is reserved
250
+ // for "nothing was judged", and here something plainly was.
251
+ hint: unobserved.length > 0
252
+ ? UNOBSERVED_HINT
253
+ : unlinked.length > 0
254
+ ? UNLINKED_HINT
255
+ : judged
256
+ ? ''
257
+ : UNMEASURED_HINT,
241
258
  exitCode: serialized.length > 0 ? 1 : unobserved.length > 0 || !judged ? 2 : 0,
242
259
  };
243
260
  }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { isLaterEvent } from './eventOrder.js';
3
+ import { normalizeProjectName } from './projectName.js';
3
4
  /** A `Record` over every `GateBlockedReason`, not a switch with a default: a reason missing from `GateBlockedReason` fails to compile here. */
4
5
  const GATE_BLOCKED_ERROR_CLASSES = {
5
6
  'schema-invalid': 'gate.blocked.schema-invalid',
@@ -7,6 +8,10 @@ const GATE_BLOCKED_ERROR_CLASSES = {
7
8
  'not-committed': 'gate.blocked.not-committed',
8
9
  'deps-missing': 'gate.blocked.deps-missing',
9
10
  'judges-outstanding': 'gate.blocked.judges-outstanding',
11
+ 'uiux-spec-missing': 'gate.blocked.uiux-spec-missing',
12
+ 'screenshots-missing': 'gate.blocked.screenshots-missing',
13
+ 'screenshots-stale': 'gate.blocked.screenshots-stale',
14
+ 'uiux-visual-missing': 'gate.blocked.uiux-visual-missing',
10
15
  'grader-invalid': 'gate.blocked.grader-invalid',
11
16
  'grader-fail': 'gate.blocked.grader-fail',
12
17
  'tests-failed': 'gate.blocked.tests-failed',
@@ -20,6 +25,10 @@ const BLOCKING_SEVERITY = 'S2-major';
20
25
  const GATE_OUTCOME_EVENT_TYPE = 'gate-outcome';
21
26
  const ERROR_LOGGED_EVENT_TYPE = 'error-logged';
22
27
  const TASK_ADDED_EVENT_TYPE = 'task-added';
28
+ /** `agents-registry.ts`'s `DISPATCH_EVENT_TYPE`, hand-restated rather than
29
+ * imported -- this module's only imports are `node:crypto` and two local,
30
+ * type-only siblings, on purpose (see the file banner). */
31
+ const DISPATCH_DECISION_EVENT_TYPE = 'dispatch_decision';
23
32
  /**
24
33
  * Source 2: `task_status: failed` is set in exactly one place in the whole
25
34
  * projector, `db/projector.ts`'s `case 'task-added'` arm copying
@@ -29,8 +38,6 @@ const TASK_ADDED_EVENT_TYPE = 'task-added';
29
38
  * carries no failure discriminator beyond that.
30
39
  */
31
40
  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
41
  function asString(value) {
35
42
  return typeof value === 'string' && value.length > 0 ? value : undefined;
36
43
  }
@@ -118,20 +125,159 @@ const CANDIDATE_READERS = Object.freeze({
118
125
  * would be reported outside the window the operator asked about.
119
126
  */
120
127
  export const ISSUE_CANDIDATE_EVENT_TYPES = new Set(Object.keys(CANDIDATE_READERS));
128
+ /** The row's task ref, off the envelope first and the payload second -- the same two places every `CandidateReader` above already looks. */
129
+ function candidateTaskRef(record) {
130
+ const payload = (record.payload ?? {});
131
+ return asString(record.task_id) ?? asString(payload.task_ref);
132
+ }
133
+ /**
134
+ * Event types allowed to stamp a session's project for
135
+ * `sessionProjectStamps` below: the issue candidates themselves, plus
136
+ * `task-added` and `dispatch_decision` rows recording where a task was
137
+ * planned or dispatched. Deliberately excludes `issue-reported` and every
138
+ * other reporter-authored row -- those carry this module's own *guess* at a
139
+ * row's project (see `appendIssueReported` in `issueReporter.ts`), not an
140
+ * independent origin stamp, so folding them back in here would let one run's
141
+ * guess leak into the next run's resolution of an unrelated, unstamped row
142
+ * in the same session.
143
+ */
144
+ const ORIGIN_STAMP_EVENT_TYPES = new Set([
145
+ ...ISSUE_CANDIDATE_EVENT_TYPES,
146
+ DISPATCH_DECISION_EVENT_TYPE,
147
+ ]);
148
+ /**
149
+ * The session's project, from its origin-stamped rows only -- and only when
150
+ * every one of them agrees. Real sessions mix dozens of stamped rows with
151
+ * unstamped ones (evidence: a foreign project's session stamps some events
152
+ * and not others); a unanimous stamped sibling in the same session is strong
153
+ * evidence for an unstamped one. A session whose origin stamps disagree
154
+ * tells us nothing safe to guess, so it answers unresolved rather than
155
+ * picking whichever stamp happened to come first.
156
+ */
157
+ function sessionProjectStamps(events) {
158
+ const bySession = new Map();
159
+ for (const { record } of events) {
160
+ if (!ORIGIN_STAMP_EVENT_TYPES.has(record.event_type))
161
+ continue;
162
+ const stamped = asString(record.project);
163
+ if (stamped === undefined)
164
+ continue;
165
+ const project = normalizeProjectName(stamped);
166
+ const seen = bySession.get(record.session_id);
167
+ if (seen)
168
+ seen.add(project);
169
+ else
170
+ bySession.set(record.session_id, new Set([project]));
171
+ }
172
+ const stamps = new Map();
173
+ for (const [sessionId, projects] of bySession) {
174
+ if (projects.size !== 1)
175
+ continue;
176
+ const [project] = projects;
177
+ if (project !== undefined)
178
+ stamps.set(sessionId, project);
179
+ }
180
+ return stamps;
181
+ }
182
+ function taskAddedKey(sessionId, taskId) {
183
+ return `${sessionId}\u0000${taskId}`;
184
+ }
185
+ /** `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. */
186
+ function taskAddedEpicIds(events) {
187
+ const out = new Map();
188
+ for (const { record } of events) {
189
+ if (record.event_type !== TASK_ADDED_EVENT_TYPE)
190
+ continue;
191
+ const taskId = asString(record.task_id);
192
+ const epicId = asString(record.payload?.epic_id);
193
+ if (taskId !== undefined && epicId !== undefined) {
194
+ out.set(taskAddedKey(record.session_id, taskId), epicId);
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+ /**
200
+ * Widens a per-epic `ResolveProjectForTaskRef` into a `RowProjectResolver`
201
+ * with three session-scoped fallbacks a single task ref cannot answer alone,
202
+ * tried strictly in this order (S2-c):
203
+ *
204
+ * 1. `resolveProject`, the strict/direct resolver -- a bare ref (no epic
205
+ * segment) tried again as `<epic>/<ref>` using the same session's own
206
+ * `task-added` row when the bare ref alone cannot answer.
207
+ * 2. The session stamp: the ONE project every origin-stamped row in the same
208
+ * session agrees on (`sessionProjectStamps`), when unanimous.
209
+ * 3. `selfFallback`, tried last and only when neither of the above could
210
+ * answer -- same widening as step 1.
211
+ *
212
+ * Self-fallback sits last on purpose: it is "no plan says otherwise, and a
213
+ * plan exists locally for this epic, so guess this factory's own name" --
214
+ * the weakest of the three signals. A session's OWN unanimous stamp naming a
215
+ * DIFFERENT project is stronger evidence than that guess, and must not be
216
+ * overridden by it just because the same session also has an unrelated,
217
+ * unstamped row whose epic happens to have a local, unlabeled plan. Before
218
+ * this order existed, the self-fallback was folded into `resolveProject`
219
+ * itself (step 1) and so always outran the session stamp.
220
+ *
221
+ * Exported so `issueReporter.ts`'s `priorOpenReports` can apply the identical
222
+ * fallback to the same event snapshot.
223
+ */
224
+ export function withSessionFallback(events, resolveProject, selfFallback = () => null) {
225
+ const stamps = sessionProjectStamps(events);
226
+ const epicIds = taskAddedEpicIds(events);
227
+ const tryResolve = (resolve, taskRef, sessionId) => {
228
+ if (taskRef === undefined)
229
+ return null;
230
+ const direct = resolve(taskRef);
231
+ if (direct !== null)
232
+ return direct;
233
+ if (sessionId === undefined)
234
+ return null;
235
+ const epicId = epicIds.get(taskAddedKey(sessionId, taskRef));
236
+ if (epicId === undefined)
237
+ return null;
238
+ return resolve(`${epicId}/${taskRef}`);
239
+ };
240
+ return (taskRef, sessionId) => {
241
+ const direct = tryResolve(resolveProject, taskRef, sessionId);
242
+ if (direct !== null)
243
+ return direct;
244
+ if (sessionId !== undefined) {
245
+ const stamp = stamps.get(sessionId);
246
+ if (stamp !== undefined)
247
+ return stamp;
248
+ }
249
+ return tryResolve(selfFallback, taskRef, sessionId);
250
+ };
251
+ }
121
252
  /** One event to a `Candidate`, or `'ignore'` when no source claims its event type at all. */
122
- function toCandidate(event) {
253
+ function toCandidate(event, resolveRow) {
123
254
  const { record } = event;
124
255
  const read = CANDIDATE_READERS[record.event_type];
125
256
  if (!read)
126
257
  return 'ignore';
258
+ const stamped = asString(record.project);
259
+ const taskRef = candidateTaskRef(record);
260
+ const sessionId = asString(record.session_id);
261
+ const project = stamped === undefined ? resolveRow(taskRef, sessionId) : normalizeProjectName(stamped);
127
262
  return read(event, {
128
263
  payload: record.payload ?? {},
129
- project: asString(record.project) ?? DEFAULT_PROJECT,
130
- sessionId: asString(record.session_id),
264
+ project,
265
+ sessionId,
131
266
  planVersion: asNumber(record.plan_version) ?? null,
132
267
  ts: asString(record.ts),
133
268
  });
134
269
  }
270
+ /**
271
+ * A row whose project cannot be resolved at all still needs a stable
272
+ * fingerprint-grouping key -- otherwise two unrelated foreign projects'
273
+ * rows sharing `(source, error_class, task_ref)` would incorrectly group
274
+ * together and leak one row's session/epic/severity metadata onto the
275
+ * other's report, inside this factory's own private log (both rows are
276
+ * always skipped before anything is filed publicly, but the log itself
277
+ * should stay correct). `task_ref` alone already disambiguates in practice
278
+ * (refs are namespaced by epic id), so a single fixed sentinel is enough.
279
+ */
280
+ const UNRESOLVED_PROJECT_FINGERPRINT_KEY = '\u0000unresolved-project\u0000';
135
281
  /**
136
282
  * The first 16 hex characters of a SHA-256 over the NUL-joined tuple
137
283
  * `project`, `source`, `error_class`, `task_ref`. Deliberately excludes
@@ -140,27 +286,52 @@ function toCandidate(event) {
140
286
  * and including any of them would open one issue per round instead of one.
141
287
  */
142
288
  function computeFingerprint(project, source, errorClass, taskRef) {
143
- const material = [project, source, errorClass, taskRef].join('\0');
289
+ const material = [
290
+ project ?? UNRESOLVED_PROJECT_FINGERPRINT_KEY,
291
+ source,
292
+ errorClass,
293
+ taskRef,
294
+ ].join('\0');
144
295
  return createHash('sha256').update(material).digest('hex').slice(0, 16);
145
296
  }
146
297
  /**
147
298
  * Fold a stored event sequence into reportable errors. Pure: two calls with
148
299
  * identical input return byte-identical output. `now` is threaded in rather
149
300
  * than read off the clock; nothing in this contract branches on it yet.
301
+ *
302
+ * `resolveProject` and `selfFallback` both default to "no answer" so the
303
+ * ~50 existing call sites across this module's own tests and
304
+ * `issueReporter.ts`'s need no change. An unstamped row still unresolved
305
+ * after `withSessionFallback`'s three fallbacks reports `project: null`
306
+ * rather than this factory's own name -- `isProjectEnabled` is never asked
307
+ * about a row it cannot identify, and the decision to skip filing it
308
+ * belongs to `issueReporter.ts`, the one caller that goes on to call `gh`.
309
+ *
310
+ * `scopeEvents` defaults to `events` itself -- the session-stamp/`task-added`
311
+ * maps `withSessionFallback` builds read the same rows this fold iterates,
312
+ * same as before this parameter existed. A caller that narrows `events` to a
313
+ * candidate WINDOW (`cli.ts`'s `--epic`/`--since`) passes the full, unscoped
314
+ * lineage here instead (S3), so a session-mate's stamp sitting outside that
315
+ * window still counts as evidence for a row inside it -- scoping which rows
316
+ * get REPORTED must not also scope which rows get CONSULTED, or the same
317
+ * unstamped row resolves to a different project (and hashes a different
318
+ * fingerprint, S1-b) depending on whether the caller happened to pass
319
+ * `--epic`.
150
320
  */
151
- export function foldErrorEvents(events, now, isProjectEnabled) {
321
+ export function foldErrorEvents(events, now, isProjectEnabled, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
152
322
  void now;
323
+ const resolveRow = withSessionFallback(scopeEvents, resolveProject, selfFallback);
153
324
  const candidates = [];
154
325
  let skipped = 0;
155
326
  for (const event of events) {
156
- const outcome = toCandidate(event);
327
+ const outcome = toCandidate(event, resolveRow);
157
328
  if (outcome === 'ignore')
158
329
  continue;
159
330
  if (outcome === null) {
160
331
  skipped += 1;
161
332
  continue;
162
333
  }
163
- if (!isProjectEnabled(outcome.project))
334
+ if (outcome.project !== null && !isProjectEnabled(outcome.project))
164
335
  continue;
165
336
  candidates.push(outcome);
166
337
  }
@@ -204,7 +375,7 @@ function fingerprintLine(fingerprint) {
204
375
  }
205
376
  /** The local, read-only command an operator runs to see the detail this body/comment deliberately omit. */
206
377
  function smithEventCommand(sessionId) {
207
- return `smith event tail ${sessionId} --lineage`;
378
+ return `bs event tail ${sessionId} --lineage`;
208
379
  }
209
380
  /** Builds a body-renderer input record from a folded report -- the record's key set is the allowlist; nothing else can reach `renderBody`. */
210
381
  export function toIssueBodyFields(report) {
@@ -245,7 +416,7 @@ export function renderBody(fields) {
245
416
  `Session: ${fields.session_id}`,
246
417
  `Epic: ${fields.epic_id ?? '(none)'}`,
247
418
  `Plan version: ${fields.plan_version ?? '(unknown)'}`,
248
- `Project: ${fields.project}`,
419
+ `Project: ${fields.project ?? '(unknown)'}`,
249
420
  `Source: ${fields.source}`,
250
421
  fingerprintLine(fields.fingerprint),
251
422
  '',
@@ -3,8 +3,8 @@ import { bareTaskId, epicOfTaskId, isQualifiedTaskId, taskIdsMatch } from './tas
3
3
  /**
4
4
  * Roles whose dispatch constitutes a new round on the task.
5
5
  *
6
- * The split follows budgets.yml: `task.judges` names spec-reviewer, reviewer,
7
- * verifier and grader, and `context_window.narrowing_roles` adds
6
+ * The split follows budgets.yml: its judges (spec-reviewer, reviewer, verifier,
7
+ * grader) read a diff, and `context_window.narrowing_roles` adds
8
8
  * security-reviewer and merger — those roles read a diff, they do not produce
9
9
  * the one the gate scores. planner/researcher/scribe produce no task diff
10
10
  * either. What is left is what a "round" means: someone built again.