@juzser/blacksmith 0.1.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 (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,1156 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { claimCoversPath } from './claims.js';
4
+ import { SmithError } from './errors.js';
5
+ import { appendEvent, readLineageEvents } from './events.js';
6
+ import { describeType } from './plan.js';
7
+ import { compileSchemas, validateRecord } from './schemas.js';
8
+ import { readAddedTasks } from './taskEvents.js';
9
+ import { bareTaskId, epicOfTaskId, taskIdsMatch } from './taskId.js';
10
+ import { loadTaxonomy, validateTag } from './taxonomy.js';
11
+ import { isWaived, WAIVABLE_SEVERITIES } from './waivers.js';
12
+ export class FindingError extends SmithError {
13
+ }
14
+ /**
15
+ * Legal finding_status transitions (architecture §11, agent-constraints.md).
16
+ * `raised` and `confirmed` are the only non-terminal states that can end in
17
+ * `waived`/`expired` directly: S3/S4 findings never enter the bounce-to-coder
18
+ * fix cycle at all (severity.yml waiver_semantics), so a waiver or an
19
+ * epic-boundary expiry can land straight from either. Everything else is a
20
+ * strict forward chain; every other state is terminal (empty list).
21
+ *
22
+ * `amend-pending` hangs off the same two states and is gated a second time, on
23
+ * scope: only a spec-scoped finding can reach it (P9-9). It opens the sole
24
+ * exit for the S1/S2 spec finding of D-33, which is unwaivable by severity and
25
+ * unfixable by diff — the criterion it names moved in a new plan version.
26
+ *
27
+ * D-127: `amended` is reachable ONLY from `amend-pending`, and that is the
28
+ * point of the state existing. It used to hang off `raised`/`confirmed`
29
+ * directly, so cutting a plan version — or typing `finding transition --to
30
+ * amended` — discharged the one severity class severity.yml refuses to waive
31
+ * in a single call, referring to no diff, no task outcome and no grade. The
32
+ * amendment now names the task ids it made the discharge condition and the
33
+ * finding waits on them, exactly as `fix-pending` waits on a diff.
34
+ *
35
+ * `amend-pending → expired` is the way an unwaivable finding can still leave
36
+ * without its replacement work landing, and it is deliberate: it mirrors
37
+ * `fix-pending` and `fix-landed`, an epic-boundary expiry over real
38
+ * outstanding work. Narrower than what it replaces — an expiry is a recorded
39
+ * epic-boundary fact, not a status anyone can type at any moment — but it is
40
+ * not nothing, and this comment would be lying if it claimed otherwise.
41
+ *
42
+ * D-180: `waived` is the one closed state with a way back, because it is the
43
+ * one closed state a later event can contradict. isWaived() folds
44
+ * waiver-granted/waiver-denied last-decision-wins, so an operator who revokes
45
+ * a grant has already changed the answer — and while this row was empty, the
46
+ * finding stayed closed anyway, discretionary at the epic gate forever, with
47
+ * no verb that could reopen it. The edge goes back to where the waiver found
48
+ * it (`raised` or `confirmed`, whichever the grant recorded as from_status),
49
+ * and it is gated on `waiverRevokedBy` naming the denial: this is a
50
+ * reconciliation the log earns, not a status anyone can type.
51
+ */
52
+ export const LEGAL_TRANSITIONS = Object.freeze({
53
+ raised: ['confirmed', 'refuted', 'waived', 'expired', 'amend-pending'],
54
+ confirmed: ['fix-pending', 'waived', 'expired', 'amend-pending'],
55
+ 'fix-pending': ['fix-landed', 'expired'],
56
+ 'fix-landed': ['fix-verified', 'expired'],
57
+ 'amend-pending': ['amended', 'expired'],
58
+ refuted: [],
59
+ 'fix-verified': [],
60
+ waived: ['raised', 'confirmed'],
61
+ expired: [],
62
+ amended: [],
63
+ });
64
+ /** The status an amendment puts a cited spec finding into; `amended` is what discharges it. */
65
+ export const AMEND_PENDING_STATUS = 'amend-pending';
66
+ /** Terminal: the amendment's task ids landed. Reachable only from AMEND_PENDING_STATUS. */
67
+ export const AMENDED_STATUS = 'amended';
68
+ /**
69
+ * Both edges of the amendment path. Scope is checked on each rather than on
70
+ * the entry edge alone: the table already makes `amended` unreachable except
71
+ * through `amend-pending`, so the second check is redundant *given the table*
72
+ * — which is exactly why it is here. It is the invariant, not a derivation of
73
+ * one, and it survives someone widening the table again (D-127).
74
+ */
75
+ const AMENDMENT_STATUSES = [AMEND_PENDING_STATUS, AMENDED_STATUS];
76
+ /**
77
+ * A finding still awaiting something. Derived from LEGAL_TRANSITIONS above by
78
+ * reading, not by code: these are exactly the statuses with a non-empty
79
+ * outgoing edge set that are not themselves a decision. `waived` has outgoing
80
+ * edges too (a denial can reopen it) but is closed until one arrives, so it
81
+ * belongs with `refuted`/`expired`/`fix-verified`/`amended` on the other side.
82
+ *
83
+ * `amend-pending` is in it for D-127 Part B: that finding is open the same way
84
+ * `fix-pending` is, carrying an assigned discharge condition (amends_task_ids
85
+ * at amends_plan_version, not a diff) that has not yet been shown to hold. A
86
+ * reader holding the obligation data can tell a satisfied amendment from an
87
+ * unsatisfied one and epic.ts does; every reader that cannot treats the status
88
+ * as unconditionally open, which fails closed rather than silently agreeing
89
+ * the amendment landed.
90
+ *
91
+ * Lives here rather than in the modules that ask, because they are all asking
92
+ * the same lifecycle question about the same vocabulary: epic.ts's close gate
93
+ * ("what is still open against this epic"), db/queries.ts's kanban chip, and
94
+ * `smith crossfind run` ("which native findings is a second opinion allowed to
95
+ * corroborate"). A second copy would drift the moment taxonomy.yml grows a
96
+ * status.
97
+ */
98
+ export const OPEN_FINDING_STATUSES = new Set([
99
+ 'raised',
100
+ 'confirmed',
101
+ 'fix-pending',
102
+ 'fix-landed',
103
+ AMEND_PENDING_STATUS,
104
+ ]);
105
+ /**
106
+ * D-21 Part 4. Appended by `repairObligation` to correct a malformed
107
+ * `amends_task_ids` entry on a finding parked at `amend-pending` — never a
108
+ * `finding-transitioned` event, because a repair changes no status. Folded
109
+ * onto `amends_task_ids` the same way `finding-transitioned` is, last one in
110
+ * the log wins, mirroring `isWaived`'s waiver-granted/waiver-denied fold.
111
+ */
112
+ export const FINDING_OBLIGATION_REPAIRED_EVENT_TYPE = 'finding-obligation-repaired';
113
+ /** The scope dimension's default: a record written before P9-9 carries none. */
114
+ export const DEFAULT_FINDING_SCOPE = 'diff';
115
+ /**
116
+ * Read a finding's scope, defaulting rather than requiring it. Every finding
117
+ * already in an event log predates the dimension, and rewriting history to
118
+ * add it is exactly the runtime taxonomy write the architecture forbids.
119
+ */
120
+ export function findingScope(finding) {
121
+ return finding.finding_scope ?? DEFAULT_FINDING_SCOPE;
122
+ }
123
+ export const SPEC_FINDING_SCOPE = 'spec';
124
+ let cachedTaxonomy;
125
+ let cachedSchemas;
126
+ function resolveTaxonomyAndSchemas(opts) {
127
+ if (cachedTaxonomy === undefined)
128
+ cachedTaxonomy = loadTaxonomy();
129
+ if (cachedSchemas === undefined)
130
+ cachedSchemas = compileSchemas(cachedTaxonomy);
131
+ const taxonomy = opts.taxonomy ?? cachedTaxonomy;
132
+ const schemas = opts.schemas ?? cachedSchemas;
133
+ return { taxonomy, schemas };
134
+ }
135
+ /**
136
+ * The fingerprint's first component, and the only path form two findings may
137
+ * be compared on. Exported because crossFinding.ts co-locates an independent
138
+ * finder's evidence against native findings by path: `./src/a.ts`,
139
+ * `src/a.ts` and `src\\a.ts` are the same file, and a private normalizer
140
+ * would mean a second implementation that is allowed to disagree with the
141
+ * one the fingerprints were built with.
142
+ */
143
+ export function normalizeFilePath(filePath) {
144
+ return path.posix.normalize(filePath.replace(/\\/g, '/')).replace(/^\.\//, '');
145
+ }
146
+ function escapeRegExp(text) {
147
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
148
+ }
149
+ /**
150
+ * Strip a `<path>:<line>[:<col>]` reference anchored to THIS finding's own
151
+ * file path (full path and bare basename, since reviewers report both
152
+ * forms) — never a bare `:digits` anywhere in the text. A blanket
153
+ * `:\d+` strip is wrong: free-text like "debug endpoint exposed on :8080"
154
+ * would collide with an unrelated ":9090" finding (reproduced regression,
155
+ * reviewer finding #3).
156
+ */
157
+ function stripPathLineRef(summary, filePath) {
158
+ const forwardSlashPath = filePath.replace(/\\/g, '/');
159
+ const candidates = new Set([filePath, forwardSlashPath, path.posix.basename(forwardSlashPath)]);
160
+ let result = summary;
161
+ for (const candidate of candidates) {
162
+ if (!candidate)
163
+ continue;
164
+ const pattern = new RegExp(`${escapeRegExp(candidate)}:\\d+(?::\\d+)?`, 'g');
165
+ result = result.replace(pattern, candidate);
166
+ }
167
+ return result;
168
+ }
169
+ /** lowercase, whitespace collapsed (spec wording; path-anchored line refs stripped by the caller). */
170
+ function normalizeSummary(summary) {
171
+ return summary
172
+ .toLowerCase()
173
+ .replace(/\bline\s+\d+\b/g, '')
174
+ .replace(/\s+/g, ' ')
175
+ .trim();
176
+ }
177
+ /**
178
+ * Stable dedup key: same file + same finding_category + same normalized
179
+ * summary maps to the same fingerprint regardless of line-number drift
180
+ * between re-review rounds. Uses node:crypto (no new dependency).
181
+ */
182
+ export function computeFingerprint(input) {
183
+ const summaryWithoutLineRef = stripPathLineRef(input.summary, input.filePath);
184
+ const material = [
185
+ normalizeFilePath(input.filePath),
186
+ input.category,
187
+ normalizeSummary(summaryWithoutLineRef),
188
+ ].join(' ');
189
+ return createHash('sha256').update(material).digest('hex');
190
+ }
191
+ /** Fields the orchestrator owns; evidence carrying any of them is a contract breach, not a hint. */
192
+ const ORCHESTRATOR_OWNED_FINDING_FIELDS = [
193
+ 'finding_id',
194
+ 'task_id',
195
+ 'fingerprint',
196
+ 'finding_status',
197
+ 'found_by',
198
+ 'found_by_provider',
199
+ ];
200
+ /**
201
+ * Readable and unique: the task scopes it (two tasks can legitimately hit the
202
+ * same file+category+summary), the fingerprint digest makes it deterministic,
203
+ * so re-minting the same evidence in a later round yields the same id.
204
+ */
205
+ function mintFindingId(taskId, fingerprint) {
206
+ return `f-${taskId}-${fingerprint.slice(0, 8)}`;
207
+ }
208
+ /**
209
+ * Guess which taxonomy value a judge meant when it wrote something close but
210
+ * not equal to one. Derived from the vocabulary itself rather than a synonym
211
+ * table, so it cannot drift from taxonomy.yml the way a hand-written map
212
+ * would, and it suggests without ever correcting — the judge's evidence is
213
+ * rejected either way, the suggestion just saves a round trip.
214
+ *
215
+ * Three rules, each returning only when it picks out exactly one candidate:
216
+ * the bare head (`S2` → `S2-major`), the bare tail (`coverage` →
217
+ * `test-coverage`, `design` → `visual-design`), and one shared hyphen token
218
+ * (`test-gap` → `test-coverage` — the wave-3 slip in D-37 that cost a full
219
+ * gate round).
220
+ */
221
+ function suggestCanonical(values, written) {
222
+ const head = values.filter((value) => value.startsWith(`${written}-`));
223
+ if (head.length === 1)
224
+ return head[0];
225
+ const tail = values.filter((value) => value.endsWith(`-${written}`));
226
+ if (tail.length === 1)
227
+ return tail[0];
228
+ const tokens = new Set(written.split('-').filter((token) => token !== ''));
229
+ const shared = values.filter((value) => value.split('-').some((token) => tokens.has(token)));
230
+ if (shared.length === 1)
231
+ return shared[0];
232
+ return undefined;
233
+ }
234
+ /**
235
+ * Turn a judge's evidence array into `raiseFinding` inputs, minting the
236
+ * identity fields the orchestrator owns (interview N-2).
237
+ *
238
+ * Three failures are caught here rather than at the schema, because the
239
+ * schema's message does not tell an operator which side broke the contract:
240
+ * evidence that carries orchestrator-owned identity (the judge overstepped),
241
+ * a severity written as the bare grade "S2" instead of the canonical taxonomy
242
+ * value "S2-major" — by far the most common template-to-taxonomy slip — and
243
+ * (P9-20 / D-37) a `finding_category` outside the vocabulary. The category
244
+ * used to be copied through untouched and first rejected at gate intake,
245
+ * five checks later, by an error naming no legal value; a reviewer that wrote
246
+ * `test-gap` for `test-coverage` cost a whole gate round that way.
247
+ */
248
+ export function mintFindings(evidence, ctx, opts = {}) {
249
+ const { taxonomy } = resolveTaxonomyAndSchemas(opts);
250
+ const severities = taxonomy.dimensions.severity ?? [];
251
+ const categories = taxonomy.dimensions.finding_category ?? [];
252
+ return evidence.map((item, index) => {
253
+ const overstep = ORCHESTRATOR_OWNED_FINDING_FIELDS.filter((field) => item[field] !== undefined);
254
+ if (overstep.length > 0) {
255
+ throw new FindingError('findings.evidence-carries-identity', `Finding evidence at index ${index} set orchestrator-owned field(s) ${overstep.join(', ')}. Judges return evidence only.`, { index, fields: [...overstep] });
256
+ }
257
+ if (!severities.includes(item.severity)) {
258
+ const canonical = suggestCanonical(severities, item.severity);
259
+ throw new FindingError('findings.non-canonical-severity', canonical
260
+ ? `Finding evidence at index ${index} used severity "${item.severity}"; the taxonomy value is "${canonical}" — write it out in full.`
261
+ : `Finding evidence at index ${index} used unknown severity "${item.severity}". Valid: ${severities.join(', ')}.`, { index, severity: item.severity });
262
+ }
263
+ if (!categories.includes(item.finding_category)) {
264
+ const canonical = suggestCanonical(categories, item.finding_category);
265
+ throw new FindingError('findings.non-canonical-finding-category', `Finding evidence at index ${index} used ` +
266
+ (canonical
267
+ ? `finding_category "${item.finding_category}"; the taxonomy value is "${canonical}" — write it out in full. `
268
+ : `unknown finding_category "${item.finding_category}". `) +
269
+ `Valid: ${categories.join(', ')}.`, { index, finding_category: item.finding_category });
270
+ }
271
+ if (ctx.spec && !item.criterion_ref) {
272
+ throw new FindingError('findings.spec-evidence-needs-criterion', `Spec-review evidence at index ${index} names no criterion_ref. "The plan is wrong" has to say which clause.`, { index });
273
+ }
274
+ const fingerprint = computeFingerprint({
275
+ filePath: item.file_path,
276
+ category: item.finding_category,
277
+ summary: item.summary,
278
+ });
279
+ const epicId = epicOfTaskId(ctx.taskId);
280
+ const finding = {
281
+ finding_id: mintFindingId(ctx.taskId, fingerprint),
282
+ task_id: ctx.taskId,
283
+ // Carried, not derived downstream (D-49/P9-10). Omitted rather than
284
+ // guessed when the task id names no epic: a wrong epic reads exactly
285
+ // like a right one everywhere it is consumed.
286
+ ...(epicId === null ? {} : { epic_id: epicId }),
287
+ finding_category: item.finding_category,
288
+ severity: item.severity,
289
+ // raiseFinding forces this too; set here so the minted draft is already
290
+ // a valid finding record on its own (e.g. when written to disk first).
291
+ finding_status: 'raised',
292
+ // A criterion_ref on a diff dispatch is dropped, not promoted: scope
293
+ // comes from how the judge was dispatched, never from what it returned.
294
+ ...(ctx.spec && item.criterion_ref
295
+ ? {
296
+ finding_scope: SPEC_FINDING_SCOPE,
297
+ spec_ref: { plan_version: ctx.spec.planVersion, criterion_ref: item.criterion_ref },
298
+ }
299
+ : {}),
300
+ summary: item.summary,
301
+ failure_scenario: item.failure_scenario,
302
+ found_by: ctx.foundBy,
303
+ ...(ctx.foundByProvider ? { found_by_provider: ctx.foundByProvider } : {}),
304
+ };
305
+ return { finding, filePath: item.file_path };
306
+ });
307
+ }
308
+ /**
309
+ * Move a minted finding to the task that actually owns its file (D-41/P9-24).
310
+ *
311
+ * The id is re-minted, not patched: `finding_id` embeds the task id, so a
312
+ * finding carrying `f-<gated-task>-<fp>` while claiming `task_id: <owner>`
313
+ * would read as two different answers to "whose is this" in the one record
314
+ * an operator greps. The fingerprint is unchanged by construction — it is
315
+ * computed from file, category and summary, none of which ownership touches —
316
+ * so dedup and waivers survive the move.
317
+ */
318
+ export function reattributeFinding(input, taskId) {
319
+ const fingerprint = computeFingerprint({
320
+ filePath: input.filePath,
321
+ category: input.finding.finding_category,
322
+ summary: input.finding.summary,
323
+ });
324
+ const epicId = epicOfTaskId(taskId);
325
+ // `epic_id` moves with `task_id` — a finding reassigned across epics whose
326
+ // epic field still named the old one would be counted twice by the epic
327
+ // verdict, once correctly and once as a phantom open finding.
328
+ const { epic_id: _dropped, ...rest } = input.finding;
329
+ return {
330
+ ...input,
331
+ finding: {
332
+ ...rest,
333
+ task_id: taskId,
334
+ ...(epicId === null ? {} : { epic_id: epicId }),
335
+ finding_id: mintFindingId(taskId, fingerprint),
336
+ },
337
+ };
338
+ }
339
+ /**
340
+ * Validate the finding (schema + taxonomy, same pattern as plan.ts), compute
341
+ * its fingerprint, and append it to the event log — unless that fingerprint
342
+ * is already waived AND this finding sits at a waivable severity, in which
343
+ * case a finding-suppressed event is logged instead (analytics keep seeing
344
+ * the recurrence) and no duplicate raised finding is appended. An S1/S2 is
345
+ * raised even over a waived fingerprint (D-196): a waiver answers the
346
+ * question it was asked, and severity.yml never lets that question be asked
347
+ * about a merge-blocking finding. Always forces finding_status to "raised": the whole
348
+ * point of this call is the raise transition, regardless of what the
349
+ * reviewer/verifier JSON happened to set.
350
+ */
351
+ export async function raiseFinding(input, ctx, opts = {}) {
352
+ const fingerprint = computeFingerprint({
353
+ filePath: input.filePath,
354
+ category: input.finding.finding_category,
355
+ summary: input.finding.summary,
356
+ });
357
+ // Backfill `epic_id` for producers that never set it (`smith findings raise`,
358
+ // a hand-written draft): this is the one boundary every finding crosses, so
359
+ // one derivation here means every stored record carries the field. Still
360
+ // omitted when the task id names no epic — see the field's comment.
361
+ const epicId = input.finding.epic_id ?? epicOfTaskId(input.finding.task_id) ?? undefined;
362
+ // `file_path` is stamped in the same normalized form the fingerprint was
363
+ // computed from, so "same file" means the same thing to the dedup key and to
364
+ // the claims join.
365
+ const finding = {
366
+ ...input.finding,
367
+ ...(epicId === undefined ? {} : { epic_id: epicId }),
368
+ finding_status: 'raised',
369
+ fingerprint,
370
+ file_path: normalizeFilePath(input.filePath),
371
+ };
372
+ // Scope and ref travel together or not at all. Enforced here rather than in
373
+ // finding.schema.json because the JSON-Schema form of "required when scope
374
+ // is spec" needs `const: "spec"` inline, and inlining a taxonomy value in a
375
+ // schema is the drift the schema's own $comment forbids (architecture §8).
376
+ if (findingScope(finding) === SPEC_FINDING_SCOPE && finding.spec_ref === undefined) {
377
+ throw new FindingError('findings.spec-finding-needs-ref', `Finding "${finding.finding_id}" is spec-scoped but names no criterion. A spec finding blocks the plan, so it has to say which clause moved.`, { findingId: finding.finding_id });
378
+ }
379
+ if (findingScope(finding) !== SPEC_FINDING_SCOPE && finding.spec_ref !== undefined) {
380
+ throw new FindingError('findings.spec-ref-without-scope', `Finding "${finding.finding_id}" carries a spec_ref but is scoped "${findingScope(finding)}". A diff finding is answered by a diff, not by a plan amendment.`, { findingId: finding.finding_id, scope: findingScope(finding) });
381
+ }
382
+ const { taxonomy, schemas } = resolveTaxonomyAndSchemas(opts);
383
+ const validation = validateRecord(schemas, taxonomy, 'finding', finding);
384
+ if (!validation.valid) {
385
+ throw new FindingError('findings.invalid-record', 'Finding failed schema/taxonomy validation.', { errors: validation.errors });
386
+ }
387
+ // D-196: the waiver has to be one that could have been granted over THIS
388
+ // finding. The fingerprint is file + category + summary and deliberately
389
+ // blind to severity, so that line-number and wording drift between
390
+ // re-review rounds still dedups (see computeFingerprint) — which means a
391
+ // grant made over an S3 is keyed the same as an S1 raised on the identical
392
+ // sentence, and the S1 vanished into a finding-suppressed event that
393
+ // nothing blocks on. Every other door into "a waiver answers this finding"
394
+ // asks about severity first: pendingBatch only offers S3/S4, applyBatch
395
+ // refuses to grant over a non-waivable sibling, reconcileFindingsToWaived
396
+ // skips them, and transition() refuses `-> waived` outright. This is the
397
+ // fifth door, and suppression is the only one of the five that leaves no
398
+ // finding behind at all.
399
+ const suppressible = WAIVABLE_SEVERITIES.includes(finding.severity);
400
+ const waived = suppressible && (await isWaived(fingerprint, { sessionId: ctx.sessionId }, opts));
401
+ if (waived) {
402
+ await appendEvent({
403
+ session_id: ctx.sessionId,
404
+ actor: ctx.actor ?? finding.found_by,
405
+ event_type: 'finding-suppressed',
406
+ task_id: finding.task_id,
407
+ plan_version: ctx.planVersion,
408
+ causal_parent: ctx.causalParent,
409
+ payload: { fingerprint, finding_id: finding.finding_id, task_id: finding.task_id },
410
+ }, opts);
411
+ return { suppressed: true, fingerprint, taskId: finding.task_id };
412
+ }
413
+ await appendEvent({
414
+ session_id: ctx.sessionId,
415
+ actor: ctx.actor ?? finding.found_by,
416
+ event_type: 'finding-raised',
417
+ task_id: finding.task_id,
418
+ plan_version: ctx.planVersion,
419
+ causal_parent: ctx.causalParent,
420
+ payload: finding,
421
+ }, opts);
422
+ return { suppressed: false, finding };
423
+ }
424
+ /**
425
+ * Current state of every finding in this session's LINEAGE: a fold, never a
426
+ * mutable store.
427
+ *
428
+ * Lineage, not session, since D-119. An epic that continues in a fresh session
429
+ * — the thing `validateCausalParent` documents as "the documented way to run a
430
+ * large epic" — used to arrive at its second session with an empty board, and
431
+ * every gate downstream of this call answered `go` on it in the same words it
432
+ * would have used for a genuinely clean one.
433
+ */
434
+ export async function listFindings(sessionId, filter = {}, opts = {}) {
435
+ return foldFindings(await readLineageEvents(sessionId, opts), filter);
436
+ }
437
+ /**
438
+ * The same fold, over events the caller already holds.
439
+ *
440
+ * `db/projector.ts` is why this is separate. `projectFindings` re-folds ONE
441
+ * session's log and replaces only that session's rows, "leaving every other
442
+ * session's projection intact" (`apply()`'s own contract) — so it must keep
443
+ * reading one session, or a continuation's rows would become a second copy of
444
+ * every ancestor's findings under a new session_id. Handing it a narrower
445
+ * reader by NAME is the point: the safe scope is what `listFindings` does by
446
+ * default, and the narrow one has to be asked for.
447
+ */
448
+ export function foldFindings(events, filter = {}) {
449
+ return foldFindingsDetailed(events, filter).findings;
450
+ }
451
+ /**
452
+ * The fields the fold itself dereferences. Deliberately NOT the full
453
+ * `finding.schema.json` required list: this is a reader coping with history,
454
+ * and rejecting an old record for a field no reader touches would quarantine
455
+ * data that is in fact usable. `appendEvent` enforces the full schema on
456
+ * everything written from now on.
457
+ */
458
+ export const REQUIRED_FOLD_FIELDS = ['finding_id', 'task_id'];
459
+ /**
460
+ * The fields the SQLite *projection* needs on top of the fold's — together,
461
+ * every payload-sourced `notNull()` column of `db/schema.ts`'s `findings`
462
+ * table. Superset of `REQUIRED_FOLD_FIELDS` by construction, and
463
+ * `test/db/projector.test.ts` asserts the other direction: no `notNull()`
464
+ * column may be added to that table without a payload field here to fill it.
465
+ *
466
+ * D-141. These two lists are allowed to differ — the fold serves
467
+ * `smith findings list`, which can show a record it only partly understands,
468
+ * while an INSERT either satisfies every constraint or aborts the transaction —
469
+ * but they must differ *knowingly*. They did not: 18 of this repo's 57
470
+ * `finding-raised` records predate `fingerprint`, the fold returned all of
471
+ * them, and `smith db rebuild` died on the first with
472
+ * `NOT NULL constraint failed: findings.fingerprint`. Splitting the lists
473
+ * explicitly lets the projector quarantine what it cannot store while the fold
474
+ * keeps showing it.
475
+ */
476
+ export const REQUIRED_PROJECTION_FIELDS = [
477
+ ...REQUIRED_FOLD_FIELDS,
478
+ 'fingerprint',
479
+ 'finding_category',
480
+ 'severity',
481
+ 'finding_status',
482
+ 'summary',
483
+ 'found_by',
484
+ ];
485
+ /**
486
+ * Names the projection-required fields this finding cannot fill, in declaration
487
+ * order. Empty means the record can become a row.
488
+ */
489
+ export function missingProjectionFields(finding) {
490
+ const record = finding;
491
+ return REQUIRED_PROJECTION_FIELDS.filter((field) => typeof record[field] !== 'string');
492
+ }
493
+ /**
494
+ * `foldFindings`, plus what it had to leave behind.
495
+ *
496
+ * D-135. The fold rebuilds each Finding from `record.payload` alone, so a
497
+ * payload missing `task_id` folded to `task_id: undefined` and the `--epic`
498
+ * filter threw a bare TypeError out of `smith findings list`. `appendEvent`
499
+ * now rejects such a payload at write time, but the log is append-only:
500
+ * records written before that guard existed are still on disk and cannot be
501
+ * validated away retroactively, so the reader has to cope with them.
502
+ *
503
+ * Coping is not the same as ignoring. Dropping a malformed record silently
504
+ * would trade a loud crash for a quiet undercount — the same failure shape as
505
+ * D-130, where a well-formed short answer read exactly like a complete one.
506
+ * So the fold quarantines the record and NAMES it, and callers that report to
507
+ * a human surface the quarantine alongside the count.
508
+ */
509
+ export function foldFindingsDetailed(events, filter = {}) {
510
+ const byId = new Map();
511
+ const skipped = [];
512
+ // Which record raised each finding, so a quarantine entry can name the row
513
+ // an operator has to go repair (D-143).
514
+ const eventIdOf = new Map();
515
+ for (const { event_id, record } of events) {
516
+ if (record.event_type === 'finding-raised') {
517
+ const finding = record.payload;
518
+ const missing = REQUIRED_FOLD_FIELDS.filter((field) => typeof finding?.[field] !== 'string');
519
+ if (missing.length > 0) {
520
+ skipped.push({
521
+ event_id,
522
+ ...(typeof finding?.finding_id === 'string' ? { finding_id: finding.finding_id } : {}),
523
+ reason: `finding-raised payload is missing required string field(s): ${missing.join(', ')}`,
524
+ });
525
+ continue;
526
+ }
527
+ byId.set(finding.finding_id, { ...finding });
528
+ eventIdOf.set(finding.finding_id, event_id);
529
+ }
530
+ else if (record.event_type === 'finding-transitioned') {
531
+ const payload = record.payload;
532
+ const existing = byId.get(payload.finding_id);
533
+ if (existing) {
534
+ // D-180: the revocation takes the waiver's id with it, so a reopened
535
+ // finding does not still name the waiver that stopped holding it.
536
+ if (payload.waiver_revoked_by)
537
+ delete existing.waiver_id;
538
+ byId.set(payload.finding_id, {
539
+ ...existing,
540
+ finding_status: payload.to_status,
541
+ ...(payload.waiver_id ? { waiver_id: payload.waiver_id } : {}),
542
+ ...(payload.amends_task_ids ? { amends_task_ids: payload.amends_task_ids } : {}),
543
+ ...(payload.amends_plan_version
544
+ ? { amends_plan_version: payload.amends_plan_version }
545
+ : {}),
546
+ });
547
+ }
548
+ }
549
+ else if (record.event_type === FINDING_OBLIGATION_REPAIRED_EVENT_TYPE) {
550
+ // D-21 Part 4: last-decision-wins onto amends_task_ids, mirroring
551
+ // isWaived's waiver-granted/waiver-denied fold -- a repair changes no
552
+ // status, so it is not a finding-transitioned event and would
553
+ // otherwise never reach this fold at all.
554
+ const payload = record.payload;
555
+ const existing = byId.get(payload.finding_id);
556
+ if (existing) {
557
+ byId.set(payload.finding_id, {
558
+ ...existing,
559
+ ...(payload.to_obligation ? { amends_task_ids: [...payload.to_obligation] } : {}),
560
+ ...(payload.reason !== undefined ? { obligation_repair_reason: payload.reason } : {}),
561
+ });
562
+ }
563
+ }
564
+ }
565
+ let results = [...byId.values()];
566
+ // D-143: the same two-spelling rule D-130 gave `filterEvents`. A raw `===`
567
+ // answered `findings list --task <epic>/<task>` with an empty list for a
568
+ // task holding two findings raised under the bare spelling.
569
+ if (filter.taskId !== undefined)
570
+ results = results.filter((f) => taskIdsMatch(f.task_id, filter.taskId));
571
+ if (filter.epic !== undefined) {
572
+ // The filter is right not to guess (see FindingFilter.epic): a bare id is
573
+ // not evidence that the finding belongs to an epic of that name. But this
574
+ // fold feeds the epic close gate, and there "matches nothing" was read as
575
+ // "this epic has no findings" — `envkit-config-loader` folded 0 findings
576
+ // and 0 quarantine while three `raised` ones sat in its log. So a record
577
+ // this filter cannot PLACE is quarantined rather than dropped: unknown
578
+ // membership is not evidence of non-membership, the same fail-closed call
579
+ // as an unreadable payload above (D-143).
580
+ const wanted = filter.epic;
581
+ const placed = [];
582
+ for (const finding of results) {
583
+ const epicId = finding.epic_id ?? epicOfTaskId(finding.task_id);
584
+ if (epicId === wanted) {
585
+ placed.push(finding);
586
+ }
587
+ else if (epicId === null) {
588
+ skipped.push({
589
+ event_id: eventIdOf.get(finding.finding_id) ?? '(unknown)',
590
+ finding_id: finding.finding_id,
591
+ reason: `finding names no epic: task_id "${finding.task_id}" is unqualified and the record carries no epic_id, so it cannot be shown to belong to "${wanted}" or to any other epic`,
592
+ });
593
+ }
594
+ }
595
+ results = placed;
596
+ }
597
+ if (filter.status !== undefined)
598
+ results = results.filter((f) => f.finding_status === filter.status);
599
+ if (filter.severity !== undefined)
600
+ results = results.filter((f) => f.severity === filter.severity);
601
+ if (filter.category !== undefined)
602
+ results = results.filter((f) => f.finding_category === filter.category);
603
+ return { findings: results, skipped };
604
+ }
605
+ /**
606
+ * The status a finding held immediately before its waiver closed it, read off
607
+ * the grant's own `from_status` (D-180). `undefined` when the finding is not
608
+ * waived, is unknown, or was waived by a transition written before this field
609
+ * existed — callers reopening a waiver must treat that as "cannot place it"
610
+ * rather than picking a status.
611
+ *
612
+ * Exported for waivers.ts, which needs the target BEFORE it calls transition()
613
+ * and cannot get it from listFindings: the fold keeps the current status, and
614
+ * the one this asks for is precisely the one the fold overwrote.
615
+ */
616
+ export async function preWaiverStatus(findingId, sessionId, opts = {}) {
617
+ const events = await readLineageEvents(sessionId, opts);
618
+ let from;
619
+ for (const { record } of events) {
620
+ if (record.event_type !== 'finding-transitioned')
621
+ continue;
622
+ const payload = record.payload;
623
+ if (payload.finding_id !== findingId)
624
+ continue;
625
+ if (payload.to_status === 'waived')
626
+ from = payload.from_status;
627
+ }
628
+ return from;
629
+ }
630
+ /**
631
+ * Which of `finding`'s obligations the offered evidence does NOT discharge.
632
+ *
633
+ * An obligation is discharged by a row naming the same task (bare compare,
634
+ * D-46/P9-29: `amends_task_ids` comes off the plan and the evidence off the
635
+ * fold, and the two registers spell ids either way) at a plan version at or
636
+ * past the one the amendment cut. The version clause is what stops a task that
637
+ * landed under the plan the amendment REPLACED from discharging it — the D-125
638
+ * shape, where a superseded task would satisfy the amendment that superseded it.
639
+ *
640
+ * An amendment naming no ids, or carrying no version, has no discharge
641
+ * condition and so can never be satisfied: every id is outstanding and there
642
+ * are none, which reads as `null` rather than `[]`. summarizeEpic draws the
643
+ * same distinction, and for the same reason — "nothing to wait on" and
644
+ * "waiting on nothing outstanding" must not look alike.
645
+ */
646
+ export function outstandingObligations(finding, evidence) {
647
+ const obligations = finding.amends_task_ids ?? [];
648
+ const version = finding.amends_plan_version;
649
+ if (obligations.length === 0 || version === undefined)
650
+ return null;
651
+ return obligations.filter((id) => {
652
+ const bare = bareTaskId(id);
653
+ return !evidence.some((row) => bareTaskId(row.taskId) === bare && row.planVersion >= version);
654
+ });
655
+ }
656
+ /**
657
+ * Enforce the finding_status state machine (LEGAL_TRANSITIONS) and append a
658
+ * finding-transitioned event. Folds the log itself (rather than delegating
659
+ * to listFindings) so it has the one finding's raw history without paying
660
+ * for a full-session scan-and-filter.
661
+ *
662
+ * `→ waived` is additionally gated on WAIVABLE_SEVERITIES (severity.yml
663
+ * waiver_semantics: only S3/S4 findings are ever waived) — the same
664
+ * constant waivers.ts uses, so this is enforced identically whether reached
665
+ * via a direct transition() call (e.g. the CLI) or via a waiver grant
666
+ * (waivers.ts's reconciliation).
667
+ *
668
+ * Both amendment edges are gated the same way on scope: they are the spec
669
+ * finding's exit, and letting a diff finding take them would turn "the plan
670
+ * changed" into a way to close code defects nobody fixed (P9-9). `→
671
+ * amend-pending` is additionally gated on naming at least one task id, since
672
+ * an amendment owing nothing discharges the finding it cites on the spot
673
+ * (D-127).
674
+ *
675
+ * `→ amended` is gated on the caller SHOWING the obligation discharged —
676
+ * `amendsSatisfiedBy`, checked against the finding's own amends_task_ids and
677
+ * amends_plan_version. The gate lives here for the reason the waiver gate does:
678
+ * closeEpic is not the only caller, and while the satisfaction check lived only
679
+ * there, `smith findings transition <id> amended` closed the unwaivable class
680
+ * with nothing built — D-127 through the operator's door. Evidence is supplied
681
+ * rather than folded here so transition() keeps costing one finding's history
682
+ * instead of the whole task log; a caller that fabricates it is forging a
683
+ * record, which is a different problem from an open door.
684
+ */
685
+ export async function transition(findingId, newStatus, ctx, opts = {}, extra = {}) {
686
+ // Lineage (D-119): a finding raised in the first session of a cross-session
687
+ // epic is otherwise not FOUND from the second, and this function's own
688
+ // "unknown finding" error would be the answer to a finding that plainly
689
+ // exists.
690
+ const events = await readLineageEvents(ctx.sessionId, opts);
691
+ let current;
692
+ /**
693
+ * The status the waiver closed over, from the grant's own from_status
694
+ * (D-180). Read off the log rather than assumed: a finding waived out of
695
+ * `raised` was never confirmed, and sending it back to `confirmed` would
696
+ * credit it with a verification that never happened.
697
+ */
698
+ let preWaiverStatus;
699
+ for (const { record } of events) {
700
+ if (record.event_type === 'finding-raised') {
701
+ const finding = record.payload;
702
+ if (finding.finding_id === findingId)
703
+ current = { ...finding };
704
+ }
705
+ else if (record.event_type === 'finding-transitioned') {
706
+ const payload = record.payload;
707
+ if (payload.finding_id === findingId && current) {
708
+ if (payload.to_status === 'waived')
709
+ preWaiverStatus = payload.from_status;
710
+ if (payload.waiver_revoked_by)
711
+ delete current.waiver_id;
712
+ current = {
713
+ ...current,
714
+ finding_status: payload.to_status,
715
+ ...(payload.waiver_id ? { waiver_id: payload.waiver_id } : {}),
716
+ ...(payload.amends_task_ids ? { amends_task_ids: payload.amends_task_ids } : {}),
717
+ ...(payload.amends_plan_version
718
+ ? { amends_plan_version: payload.amends_plan_version }
719
+ : {}),
720
+ };
721
+ }
722
+ }
723
+ else if (record.event_type === FINDING_OBLIGATION_REPAIRED_EVENT_TYPE) {
724
+ // D-21 Part 4: a repair changes amends_task_ids without touching
725
+ // finding_status, so it is folded here too -- otherwise a -> amended
726
+ // discharge (outstandingObligations, just below) would keep judging the
727
+ // ORIGINAL, possibly-malformed obligation rather than the repaired one.
728
+ const payload = record.payload;
729
+ if (payload.finding_id === findingId && current) {
730
+ current = {
731
+ ...current,
732
+ ...(payload.to_obligation ? { amends_task_ids: [...payload.to_obligation] } : {}),
733
+ ...(payload.reason !== undefined ? { obligation_repair_reason: payload.reason } : {}),
734
+ };
735
+ }
736
+ }
737
+ }
738
+ if (!current) {
739
+ throw new FindingError('findings.unknown-finding', `No raised finding with id "${findingId}".`, {
740
+ findingId,
741
+ });
742
+ }
743
+ const legal = LEGAL_TRANSITIONS[current.finding_status] ?? [];
744
+ if (!legal.includes(newStatus)) {
745
+ throw new FindingError('findings.illegal-transition', `Cannot transition finding "${findingId}" from "${current.finding_status}" to "${newStatus}".`, { findingId, from: current.finding_status, to: newStatus });
746
+ }
747
+ // D-180: the only way out of `waived` is a revocation the log can be pointed
748
+ // at. Checked before the target is compared so "you named no denial" is the
749
+ // answer to a caller who named none, rather than a status complaint.
750
+ if (current.finding_status === 'waived') {
751
+ if (extra.waiverRevokedBy === undefined) {
752
+ throw new FindingError('findings.waiver-revocation-unproven', `Finding "${findingId}" is waived, and a waiver is undone by revoking it, not by typing a status at it. Record the operator's reversal first — "smith waiver apply" with a "denied" decision on this fingerprint reopens the finding as part of writing the denial.`, { findingId, to: newStatus });
753
+ }
754
+ if (newStatus !== preWaiverStatus) {
755
+ throw new FindingError('findings.waiver-revocation-wrong-status', `Finding "${findingId}" was waived out of "${preWaiverStatus ?? 'an unrecorded status'}", so revoking that waiver returns it there, not to "${newStatus}". A revocation restores what the waiver closed over; it does not re-grade the finding.`, { findingId, to: newStatus, preWaiverStatus: preWaiverStatus ?? null });
756
+ }
757
+ }
758
+ if (newStatus === 'waived' && !WAIVABLE_SEVERITIES.includes(current.severity)) {
759
+ throw new FindingError('findings.not-waivable', `Finding "${findingId}" has severity "${current.severity}"; only ${WAIVABLE_SEVERITIES.join('/')} findings are waivable.`, { findingId, severity: current.severity });
760
+ }
761
+ if (AMENDMENT_STATUSES.includes(newStatus) && findingScope(current) !== SPEC_FINDING_SCOPE) {
762
+ throw new FindingError('findings.not-amendable', `Finding "${findingId}" is scoped "${findingScope(current)}", so it cannot take the amendment path at "${newStatus}": only a spec finding closes by amending the plan. A defect in the diff is closed by fixing the diff.`, { findingId, scope: findingScope(current), to: newStatus });
763
+ }
764
+ if (newStatus === AMEND_PENDING_STATUS && (extra.amendsTaskIds?.length ?? 0) === 0) {
765
+ throw new FindingError('findings.amendment-without-obligation', `Finding "${findingId}" cannot enter "${AMEND_PENDING_STATUS}" naming no task ids. An amendment that obligates nothing discharges the finding the moment it is written, which is D-127 — say which added or superseded task this finding is now waiting on. "smith plan amend" derives that list from the plan diff and is the only verb that does (D-136).`, { findingId });
766
+ }
767
+ if (newStatus === AMENDED_STATUS) {
768
+ const outstanding = outstandingObligations(current, extra.amendsSatisfiedBy ?? []);
769
+ if (outstanding === null || outstanding.length > 0) {
770
+ throw new FindingError('findings.amendment-not-discharged', outstanding === null
771
+ ? `Finding "${findingId}" is at "${AMEND_PENDING_STATUS}" but names no task ids at a plan version, so no evidence can discharge it. Expire it at the epic boundary instead.`
772
+ : `Finding "${findingId}" cannot close at "${AMENDED_STATUS}": ${outstanding.join(', ')} has not been shown to have landed at plan version ${current.amends_plan_version} or later. An amendment is discharged by its tasks landing, not by being told they did — this is D-127. "smith epic close" computes and supplies that evidence.`, {
773
+ findingId,
774
+ outstanding: outstanding ?? [],
775
+ amendsPlanVersion: current.amends_plan_version,
776
+ });
777
+ }
778
+ }
779
+ const { taxonomy } = resolveTaxonomyAndSchemas(opts);
780
+ validateTag(taxonomy, 'finding_status', newStatus);
781
+ const payload = {
782
+ finding_id: findingId,
783
+ fingerprint: current.fingerprint,
784
+ from_status: current.finding_status,
785
+ to_status: newStatus,
786
+ };
787
+ if (extra.waiverId !== undefined)
788
+ payload.waiver_id = extra.waiverId;
789
+ if (extra.waiverRevokedBy !== undefined)
790
+ payload.waiver_revoked_by = extra.waiverRevokedBy;
791
+ if (extra.amendsTaskIds !== undefined)
792
+ payload.amends_task_ids = [...extra.amendsTaskIds];
793
+ if (extra.amendsPlanVersion !== undefined)
794
+ payload.amends_plan_version = extra.amendsPlanVersion;
795
+ // The evidence goes in the log, not on the finding: it is why THIS
796
+ // transition was allowed, and a reader asking "what closed this?" gets the
797
+ // landed tasks and their versions rather than having to re-derive them.
798
+ if (extra.amendsSatisfiedBy !== undefined)
799
+ payload.amends_satisfied_by = extra.amendsSatisfiedBy.map((row) => ({ ...row }));
800
+ await appendEvent({
801
+ session_id: ctx.sessionId,
802
+ actor: ctx.actor ?? 'system',
803
+ event_type: 'finding-transitioned',
804
+ task_id: current.task_id,
805
+ plan_version: ctx.planVersion,
806
+ causal_parent: ctx.causalParent,
807
+ payload,
808
+ }, opts);
809
+ const result = {
810
+ ...current,
811
+ finding_status: newStatus,
812
+ ...(extra.waiverId !== undefined ? { waiver_id: extra.waiverId } : {}),
813
+ ...(extra.amendsTaskIds !== undefined ? { amends_task_ids: [...extra.amendsTaskIds] } : {}),
814
+ ...(extra.amendsPlanVersion !== undefined
815
+ ? { amends_plan_version: extra.amendsPlanVersion }
816
+ : {}),
817
+ };
818
+ // A reopened finding carrying the id of the waiver that no longer holds it
819
+ // is the same split state D-180 is about, one field smaller.
820
+ if (extra.waiverRevokedBy !== undefined)
821
+ delete result.waiver_id;
822
+ return result;
823
+ }
824
+ // ---------------------------------------------------------------------------
825
+ // Obligation repair (D-21 Part 4)
826
+ // ---------------------------------------------------------------------------
827
+ /** Whether `value` is the shape a real task id always has — never a non-empty check alone. */
828
+ /**
829
+ * Whether `value` is the shape a real task id always has -- never a
830
+ * non-empty check alone. Exported: epic.ts's malformed-obligation check
831
+ * (summarizeEpic) has to agree with this definition exactly, or a "" entry
832
+ * reads as well-formed to one reader and corrupt to the other -- which is
833
+ * precisely the D-21 Part 4 review finding this sharing closes. epic.ts is
834
+ * the side that had to move: bareTaskId/taskIdsMatch can never match "" to a
835
+ * real task id downstream, so a looser epic.ts definition would leave this
836
+ * shape permanently unrepairable rather than merely unusual.
837
+ */
838
+ export function isNonEmptyString(value) {
839
+ return typeof value === 'string' && value.length > 0;
840
+ }
841
+ /**
842
+ * describeType, but naming an empty string as itself rather than merely
843
+ * "string" -- an empty task id is exactly as malformed as a non-string one,
844
+ * and "string" alone would misname it as fine. Exported for the same reason
845
+ * as isNonEmptyString above: epic.ts's malformed-entries blocker names the
846
+ * bad entry the same way guard 6's refusal does, one register.
847
+ */
848
+ export function describeMalformedTaskId(value) {
849
+ if (typeof value === 'string' && value.length === 0)
850
+ return 'an empty string';
851
+ return describeType(value);
852
+ }
853
+ /**
854
+ * D-21 Part 4. Corrects a malformed `amends_task_ids` entry on a finding
855
+ * parked at `amend-pending` — the shape `f-demo-rpg-reading-interface/
856
+ * integration-3e6bd014` carries (`[null, "…/task-5-reader-memory"]`), written
857
+ * by a `plan amend` call parts 1-3 of D-21 now refuse at the source. The log
858
+ * is append-only, so a record written before that guard existed still holds
859
+ * the malformed entry, and no existing verb can discharge it truthfully:
860
+ * `reverify` is not a status transition, `amend-pending -> expired` asserts
861
+ * the replacement work never landed (it did), a fresh `plan amend` citing the
862
+ * same finding cannot re-enter `amend-pending` from `amend-pending`, and S2 is
863
+ * categorically unwaivable (severity.yml) — the epic deadlocks.
864
+ *
865
+ * This verb can discharge an unwaivable finding, so every one of the six
866
+ * guards below is load-bearing (D-21 part 4 brief):
867
+ * 1. Repairs corruption only — refused unless the CURRENT obligation
868
+ * already holds at least one entry that is not a non-empty string.
869
+ * 2. Cannot weaken — the replacement must retain every well-formed id from
870
+ * the original (taskIdsMatch, D-46/P9-29: either spelling counts).
871
+ * 3. Cannot empty — the resulting obligation must be non-empty; naming
872
+ * nothing discharges the finding on the sentence that wrote it (D-127).
873
+ * 4. Reason required, non-empty — an unaudited repair is not a repair.
874
+ * 5. Only from `amend-pending` — the only status where the obligation is
875
+ * load-bearing.
876
+ * 6. Every replacement id is a non-empty string.
877
+ *
878
+ * Appends a `finding-obligation-repaired` event — never `finding-transitioned`,
879
+ * because a repair changes no status. The fold applies the LATEST such event
880
+ * for a finding onto `amends_task_ids` directly, last-decision-wins, mirroring
881
+ * `isWaived`'s waiver-granted/waiver-denied fold — so `outstandingObligations`
882
+ * (transition()'s own `-> amended` gate) and summarizeEpic both evaluate the
883
+ * repaired list, never the original. `obligation_repair_reason` rides along so
884
+ * a later discharge can be shown to rest on a repaired list rather than
885
+ * quietly dropping the null (epic.ts's SatisfiedAmendment).
886
+ */
887
+ export async function repairObligation(input, ctx, opts = {}) {
888
+ const events = await readLineageEvents(ctx.sessionId, opts);
889
+ let current;
890
+ for (const { record } of events) {
891
+ if (record.event_type === 'finding-raised') {
892
+ const finding = record.payload;
893
+ if (finding.finding_id === input.findingId)
894
+ current = { ...finding };
895
+ }
896
+ else if (record.event_type === 'finding-transitioned') {
897
+ const payload = record.payload;
898
+ if (payload.finding_id === input.findingId && current) {
899
+ if (payload.waiver_revoked_by)
900
+ delete current.waiver_id;
901
+ current = {
902
+ ...current,
903
+ finding_status: payload.to_status,
904
+ ...(payload.waiver_id ? { waiver_id: payload.waiver_id } : {}),
905
+ ...(payload.amends_task_ids ? { amends_task_ids: payload.amends_task_ids } : {}),
906
+ ...(payload.amends_plan_version
907
+ ? { amends_plan_version: payload.amends_plan_version }
908
+ : {}),
909
+ };
910
+ }
911
+ }
912
+ else if (record.event_type === FINDING_OBLIGATION_REPAIRED_EVENT_TYPE) {
913
+ const payload = record.payload;
914
+ if (payload.finding_id === input.findingId && current) {
915
+ current = {
916
+ ...current,
917
+ ...(payload.to_obligation ? { amends_task_ids: [...payload.to_obligation] } : {}),
918
+ ...(payload.reason !== undefined ? { obligation_repair_reason: payload.reason } : {}),
919
+ };
920
+ }
921
+ }
922
+ }
923
+ if (!current) {
924
+ throw new FindingError('findings.unknown-finding', `No raised finding with id "${input.findingId}".`, { findingId: input.findingId });
925
+ }
926
+ // Guard 5: only from amend-pending. That is the only status where the
927
+ // obligation is load-bearing -- checked before guard 1 (repairs corruption
928
+ // only) so a finding in the wrong status is refused for that, first, rather
929
+ // than for whatever its (irrelevant) amends_task_ids happens to look like.
930
+ if (current.finding_status !== AMEND_PENDING_STATUS) {
931
+ throw new FindingError('findings.repair-not-pending', `Finding "${input.findingId}" is at "${current.finding_status}", not ` +
932
+ `"${AMEND_PENDING_STATUS}" -- an obligation is only load-bearing while a finding waits ` +
933
+ 'on it. Repairing it anywhere else changes a fact nothing is waiting on.', { findingId: input.findingId, findingStatus: current.finding_status });
934
+ }
935
+ const original = current.amends_task_ids ?? [];
936
+ const malformedEntries = [];
937
+ const wellFormedOriginal = [];
938
+ original.forEach((id, index) => {
939
+ if (isNonEmptyString(id))
940
+ wellFormedOriginal.push(id);
941
+ else
942
+ malformedEntries.push({ index, type: describeType(id) });
943
+ });
944
+ // Guard 1: repairs corruption only. This verb repairs malformed data; it
945
+ // never renegotiates a well-formed obligation — that is "smith plan amend"'s
946
+ // job, and it is the one that can compute a real diff against the plan.
947
+ if (malformedEntries.length === 0) {
948
+ throw new FindingError('findings.repair-not-corrupt', `Finding "${input.findingId}"'s obligation names ${original.length} task id(s), all ` +
949
+ 'well-formed — there is nothing to repair. This verb repairs malformed amends_task_ids ' +
950
+ 'data; it never renegotiates a well-formed obligation. Use "smith plan amend" to change ' +
951
+ 'what a finding is waiting on.', { findingId: input.findingId });
952
+ }
953
+ // Guard 4: reason required, non-empty. An unaudited repair is not a repair.
954
+ const reason = input.reason.trim();
955
+ if (reason === '') {
956
+ throw new FindingError('findings.repair-reason-required', `Repairing finding "${input.findingId}"'s obligation needs a non-empty --reason: an ` +
957
+ 'unaudited repair is not a repair.', { findingId: input.findingId });
958
+ }
959
+ // Guard 3: cannot empty. Checked before guard 2 (cannot weaken) so an empty
960
+ // replacement is named for what it is -- naming nothing -- rather than as
961
+ // "dropped every well-formed id", which is true but not the clearest thing
962
+ // to tell the caller first.
963
+ if (input.replaceWith.length === 0) {
964
+ throw new FindingError('findings.repair-would-empty', `Refusing to repair finding "${input.findingId}"'s obligation to an empty list: an ` +
965
+ 'obligation naming nothing discharges the finding it names on the sentence that wrote ' +
966
+ 'it (D-127) -- name at least one task id.', { findingId: input.findingId });
967
+ }
968
+ // Guard 2: cannot weaken. The replacement must retain every well-formed id
969
+ // from the original -- it may drop only malformed entries, and it may add.
970
+ // Without this, "repair" becomes a way to delete a real obligation.
971
+ const dropped = wellFormedOriginal.filter((id) => !input.replaceWith.some((replacement) => taskIdsMatch(id, replacement)));
972
+ if (dropped.length > 0) {
973
+ throw new FindingError('findings.repair-would-weaken', `Refusing to repair finding "${input.findingId}"'s obligation: the replacement drops ` +
974
+ `${dropped.join(', ')}, which the original obligation already named and is well-formed. ` +
975
+ 'A repair may drop only malformed entries and may add, never drop a real obligation.', { findingId: input.findingId, dropped });
976
+ }
977
+ // Guard 6: every replacement id is a non-empty string. Same describeType
978
+ // error style as part 1 -- name the type and the index, never the value.
979
+ // describeMalformedTaskId also names an empty string as itself, not merely
980
+ // "string" -- an empty entry is exactly as malformed as a non-string one.
981
+ input.replaceWith.forEach((id, index) => {
982
+ if (!isNonEmptyString(id)) {
983
+ throw new FindingError('findings.repair-replacement-not-string', `--replace-with[${index}] is ${describeMalformedTaskId(id)}, not a task id. Every ` +
984
+ 'replacement entry must be a non-empty string.', { findingId: input.findingId, index, received: describeMalformedTaskId(id) });
985
+ }
986
+ });
987
+ const toObligation = [...input.replaceWith];
988
+ const payload = {
989
+ finding_id: input.findingId,
990
+ from_obligation: original,
991
+ to_obligation: toObligation,
992
+ malformed_entries: malformedEntries,
993
+ reason,
994
+ };
995
+ if (current.amends_plan_version !== undefined) {
996
+ payload.amends_plan_version = current.amends_plan_version;
997
+ }
998
+ await appendEvent({
999
+ session_id: ctx.sessionId,
1000
+ actor: ctx.actor ?? 'user',
1001
+ event_type: FINDING_OBLIGATION_REPAIRED_EVENT_TYPE,
1002
+ task_id: current.task_id,
1003
+ plan_version: ctx.planVersion,
1004
+ causal_parent: ctx.causalParent,
1005
+ payload,
1006
+ }, opts);
1007
+ return { ...current, amends_task_ids: toObligation, obligation_repair_reason: reason };
1008
+ }
1009
+ /**
1010
+ * Which open findings are answering about code that no longer exists.
1011
+ *
1012
+ * A finding's evidence — the failure scenario, the summary, the line it points
1013
+ * at — is a claim about one file at one moment. Once a wave merges over that
1014
+ * file, the evidence describes code that has been rewritten, and a waiver
1015
+ * granted from it is a decision made on deleted evidence. This reports the
1016
+ * findings in that state so the gate can demand a re-read first.
1017
+ *
1018
+ * Strictly ordering-based: a finding is stale iff the last `wave-merged`
1019
+ * touching its file comes *after* both its raise and its most recent
1020
+ * `finding-reverified`. Reading positions out of one ordered log is what makes
1021
+ * "re-verified, then merged over again" come out stale — a timestamp
1022
+ * comparison across separately-clocked producers would not.
1023
+ *
1024
+ * Over the lineage since D-119, and that caution survives the change: within a
1025
+ * session the positions are still the writer's own append order, and only
1026
+ * BETWEEN sessions does `readLineageEvents` fall back to the wall clock, which
1027
+ * is the only reference two sessions share. The alternative was leaving a
1028
+ * continuation blind to every merge its parent recorded, which reports a
1029
+ * finding raised against long-rewritten code as fresh evidence.
1030
+ */
1031
+ export async function staleFindings(sessionId, opts = {}) {
1032
+ const events = await readLineageEvents(sessionId, opts);
1033
+ const raisedAt = new Map();
1034
+ const reverifiedAt = new Map();
1035
+ const merges = [];
1036
+ events.forEach(({ record }, index) => {
1037
+ if (record.event_type === 'finding-raised') {
1038
+ const finding = record.payload;
1039
+ raisedAt.set(finding.finding_id, { index, finding });
1040
+ }
1041
+ else if (record.event_type === 'finding-reverified') {
1042
+ const payload = record.payload;
1043
+ if (payload.finding_id)
1044
+ reverifiedAt.set(payload.finding_id, index);
1045
+ }
1046
+ else if (record.event_type === 'wave-merged' && record.task_id) {
1047
+ const payload = record.payload;
1048
+ merges.push({
1049
+ index,
1050
+ taskId: record.task_id,
1051
+ ...(Array.isArray(payload.files_changed)
1052
+ ? { filesChanged: payload.files_changed }
1053
+ : {}),
1054
+ });
1055
+ }
1056
+ });
1057
+ if (raisedAt.size === 0 || merges.length === 0)
1058
+ return new Map();
1059
+ // Only loaded when a merge actually needs the fallback, so the common path
1060
+ // (every wave-merged carries its file list) costs nothing.
1061
+ let claimsByTask;
1062
+ const claimsFor = async (taskId) => {
1063
+ if (claimsByTask === undefined) {
1064
+ const added = await readAddedTasks({ sessionId }, opts);
1065
+ claimsByTask = new Map(added.map((task) => [task.taskId, task.claims]));
1066
+ }
1067
+ // Empty is the safe direction for THIS question, unlike the wave gate's:
1068
+ // this list decides whether a merge could have rewritten the file a
1069
+ // finding names, and a task whose claims cannot be read is one this cannot
1070
+ // show rewrote anything — so the finding stays answerable rather than
1071
+ // being marked stale on evidence nobody has.
1072
+ const claims = claimsByTask.get(taskId);
1073
+ return Array.isArray(claims) ? claims.filter((c) => typeof c === 'string') : [];
1074
+ };
1075
+ const stale = new Map();
1076
+ for (const [findingId, { index: raisedIndex, finding }] of raisedAt) {
1077
+ const freshUntil = Math.max(raisedIndex, reverifiedAt.get(findingId) ?? -1);
1078
+ // Last merge wins: it names the most recent rewrite the operator has to
1079
+ // answer for, and reporting an older one would point at the wrong task.
1080
+ // Both bases below are joins on the path, so a finding that never carried
1081
+ // one is unanswerable here rather than fresh or stale (D-191). Skipped, not
1082
+ // dereferenced: the claims branch used to throw on it.
1083
+ const filePath = finding.file_path;
1084
+ if (filePath === undefined)
1085
+ continue;
1086
+ for (const merge of merges) {
1087
+ if (merge.index <= freshUntil)
1088
+ continue;
1089
+ if (merge.filesChanged) {
1090
+ const hit = merge.filesChanged.find((changed) => normalizeFilePath(changed) === filePath);
1091
+ if (hit === undefined)
1092
+ continue;
1093
+ stale.set(findingId, {
1094
+ findingId,
1095
+ filePath,
1096
+ mergedTaskId: merge.taskId,
1097
+ basis: 'files-changed',
1098
+ matched: hit,
1099
+ });
1100
+ continue;
1101
+ }
1102
+ const claim = (await claimsFor(merge.taskId)).find((glob) => claimCoversPath(glob, filePath));
1103
+ if (claim === undefined)
1104
+ continue;
1105
+ stale.set(findingId, {
1106
+ findingId,
1107
+ filePath,
1108
+ mergedTaskId: merge.taskId,
1109
+ basis: 'claims',
1110
+ matched: claim,
1111
+ });
1112
+ }
1113
+ }
1114
+ return stale;
1115
+ }
1116
+ /**
1117
+ * Record that a finding's evidence was re-read against the current code.
1118
+ *
1119
+ * Deliberately not a `finding_status` transition: `confirmed` cannot be
1120
+ * re-entered and `refuted` means the finding was wrong. Neither says "someone
1121
+ * looked again after the file was rewritten and the finding still stands",
1122
+ * which is the only thing that can un-stale evidence. So it is its own event,
1123
+ * and the status machine keeps meaning exactly what it meant before.
1124
+ */
1125
+ export async function reverifyFinding(findingId, note, ctx, opts = {}) {
1126
+ // Lineage (D-119): staleFindings reads the lineage, so the findings it names
1127
+ // as stale have to be re-verifiable from the session that was told about them.
1128
+ const events = await readLineageEvents(ctx.sessionId, opts);
1129
+ let current;
1130
+ for (const { record } of events) {
1131
+ if (record.event_type !== 'finding-raised')
1132
+ continue;
1133
+ const finding = record.payload;
1134
+ if (finding.finding_id === findingId)
1135
+ current = finding;
1136
+ }
1137
+ if (!current) {
1138
+ throw new FindingError('findings.unknown-finding', `No raised finding with id "${findingId}".`, {
1139
+ findingId,
1140
+ });
1141
+ }
1142
+ await appendEvent({
1143
+ session_id: ctx.sessionId,
1144
+ actor: ctx.actor ?? 'system',
1145
+ event_type: 'finding-reverified',
1146
+ task_id: current.task_id,
1147
+ plan_version: ctx.planVersion,
1148
+ causal_parent: ctx.causalParent,
1149
+ payload: {
1150
+ finding_id: findingId,
1151
+ fingerprint: current.fingerprint,
1152
+ file_path: current.file_path,
1153
+ note,
1154
+ },
1155
+ }, opts);
1156
+ }