@juzser/blacksmith 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude/agents/auditor.md +3 -0
  2. package/.claude/agents/grader.md +12 -5
  3. package/.claude/agents/reviewer.md +3 -0
  4. package/.claude/agents/security-reviewer.md +3 -0
  5. package/.claude/agents/spec-reviewer.md +3 -0
  6. package/.claude/agents/verifier.md +3 -0
  7. package/.claude/skills/bs/audit.md +25 -9
  8. package/.claude/skills/bs/dispatch.md +60 -7
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +18 -13
  11. package/.claude/skills/bs/wave.md +68 -10
  12. package/README.md +4 -4
  13. package/factory/orchestrator/dist/agentsSync.js +178 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +25 -0
  16. package/factory/orchestrator/dist/budgets.js +91 -2
  17. package/factory/orchestrator/dist/claims.js +8 -4
  18. package/factory/orchestrator/dist/cli.js +394 -26
  19. package/factory/orchestrator/dist/crossFinding.js +20 -0
  20. package/factory/orchestrator/dist/daemon.js +230 -41
  21. package/factory/orchestrator/dist/db/projector.js +93 -36
  22. package/factory/orchestrator/dist/db/queries.js +58 -35
  23. package/factory/orchestrator/dist/db/schema.js +24 -0
  24. package/factory/orchestrator/dist/dispatchAudit.js +10 -2
  25. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  26. package/factory/orchestrator/dist/epic.js +192 -39
  27. package/factory/orchestrator/dist/epicWidth.js +25 -20
  28. package/factory/orchestrator/dist/errorIssues.js +209 -24
  29. package/factory/orchestrator/dist/eventOrder.js +102 -0
  30. package/factory/orchestrator/dist/events.js +73 -91
  31. package/factory/orchestrator/dist/findingContext.js +1 -13
  32. package/factory/orchestrator/dist/findings.js +132 -11
  33. package/factory/orchestrator/dist/gh.js +29 -6
  34. package/factory/orchestrator/dist/git.js +20 -2
  35. package/factory/orchestrator/dist/harness.js +92 -10
  36. package/factory/orchestrator/dist/hookDecision.js +320 -37
  37. package/factory/orchestrator/dist/impact.js +24 -2
  38. package/factory/orchestrator/dist/integration.js +11 -1
  39. package/factory/orchestrator/dist/issueReporter.js +415 -0
  40. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  41. package/factory/orchestrator/dist/judges.js +1 -1
  42. package/factory/orchestrator/dist/lessons.js +46 -18
  43. package/factory/orchestrator/dist/logCache.js +291 -0
  44. package/factory/orchestrator/dist/paths.js +10 -0
  45. package/factory/orchestrator/dist/plan.js +138 -4
  46. package/factory/orchestrator/dist/planQuorum.js +1 -1
  47. package/factory/orchestrator/dist/policy.js +57 -11
  48. package/factory/orchestrator/dist/projects.js +12 -1
  49. package/factory/orchestrator/dist/queue.js +477 -29
  50. package/factory/orchestrator/dist/scheduler.js +51 -0
  51. package/factory/orchestrator/dist/severity.js +40 -8
  52. package/factory/orchestrator/dist/spec.js +70 -0
  53. package/factory/orchestrator/dist/taskEvents.js +13 -4
  54. package/factory/orchestrator/dist/taskStatus.js +74 -0
  55. package/factory/orchestrator/dist/taxonomy.js +11 -0
  56. package/factory/orchestrator/dist/testgate.js +32 -3
  57. package/factory/orchestrator/dist/usage.js +42 -9
  58. package/factory/orchestrator/dist/waivers.js +60 -9
  59. package/factory/orchestrator/dist/waveConcurrency.js +29 -0
  60. package/factory/orchestrator/dist/waveNext.js +12 -10
  61. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  62. package/factory/orchestrator/dist/worktree.js +19 -6
  63. package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
  64. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  65. package/factory/policies/budgets.yml +9 -2
  66. package/factory/policies/crosscheck.yml +2 -2
  67. package/factory/policies/lessons.md +298 -0
  68. package/factory/policies/scheduler.yml +27 -2
  69. package/factory/policies/taxonomy.yml +10 -3
  70. package/factory/specs/roadmap.md +70 -23
  71. package/package.json +8 -8
@@ -182,21 +182,53 @@ export function parseLessons(markdown) {
182
182
  return rules;
183
183
  }
184
184
  /**
185
- * Scopes whose claim_path is meaningful for the per-file same-mistake match
186
- * (lessons.md convention). `security` is here because a security lesson is
187
- * written against the paths it guards ("src/auth/**") — a repeat security
188
- * finding on one of those paths is exactly what escalation is for. Left out, a
189
- * security lesson would compile and be read by agents but never escalate
190
- * anything (interview N-8).
185
+ * Whether each lesson scope's claim_path is meaningful for the per-file
186
+ * same-mistake match (lessons.md convention). `security` is true because a
187
+ * security lesson is written against the paths it guards ("src/auth/**") — a
188
+ * repeat security finding on one of those paths is exactly what escalation is
189
+ * for. Left out, a security lesson would compile and be read by agents but
190
+ * never escalate anything (interview N-8).
191
+ *
192
+ * The ruling stays hand-written: which scopes have a file to match against is
193
+ * a design decision, not something to derive. Only its COMPLETENESS stops
194
+ * being hand-kept. Keyed on LESSON_SCOPES, a sixth scope added to taxonomy.yml
195
+ * is a type error here until someone rules on it, instead of defaulting to
196
+ * `false` — which is silence, and silence is what interview N-8 was about.
197
+ *
198
+ * It is also the only copy. `lessons.ts` kept a second set of the same three
199
+ * strings under a comment saying it was "kept in sync"; nothing kept it. That
200
+ * is the N-8 failure the LESSON_SCOPES const above already fixed for the
201
+ * vocabulary, repeated one level down on its partition.
202
+ */
203
+ const SCOPE_MATCHES_A_FILE = Object.freeze({
204
+ 'agent-role': false,
205
+ 'claim-path': true,
206
+ 'case-type': false,
207
+ 'stack-wide': true,
208
+ security: true,
209
+ });
210
+ /**
211
+ * The scopes it says yes to, in the order LESSON_SCOPES declares them.
212
+ * Exported because `lessons.ts` names them in a warning it shows the operator,
213
+ * and a sentence that lists them by hand is the same drift written in prose.
191
214
  */
192
- const FILE_SCOPED = new Set(['claim-path', 'stack-wide', 'security']);
215
+ export const FILE_SCOPED_SCOPES = Object.freeze(LESSON_SCOPES.filter((scope) => SCOPE_MATCHES_A_FILE[scope]));
216
+ const FILE_SCOPED = new Set(FILE_SCOPED_SCOPES);
217
+ /**
218
+ * Whether a scope makes a `claim_path` mean anything, asked by name — the same
219
+ * question as `isFileScoped`, for callers judging an entry that does not exist
220
+ * yet and so have no `LessonRule` to hand.
221
+ */
222
+ export function isFileScopedScope(scope) {
223
+ return FILE_SCOPED.has(scope);
224
+ }
193
225
  /**
194
226
  * Whether this lesson's scope makes its `claim_path` mean anything. An
195
227
  * `agent-role`/`case-type` entry has no file to check a finding against, so it
196
228
  * never participates in the per-file match however it is written.
197
229
  */
198
230
  export function isFileScoped(lesson) {
199
- return FILE_SCOPED.has(lesson.scope);
231
+ return isFileScopedScope(lesson.scope);
200
232
  }
201
233
  /**
202
234
  * Whether this lesson's compiled `claim_path` covers `filePath` — the second
@@ -68,6 +68,65 @@ export function latestSpecReview(events, epicId) {
68
68
  }
69
69
  return latest;
70
70
  }
71
+ /**
72
+ * Old task id -> the replacement that superseded it, folded across every
73
+ * `plan-version-created` event this epic has (D-<successor-chain>). Not a
74
+ * last-wins fold like `latestSpecReview`: a task superseded in v2 and its own
75
+ * successor superseded again in v4 contributes two independent entries to
76
+ * the same map, and epic.ts's `resolveSupersededRow` walks the chain — each
77
+ * amendment only ever needs to know its own one hop.
78
+ *
79
+ * Only the rename-supersede case is ever present here: `amendPlan` records a
80
+ * pairing only when the replacement's `task_id` differs from the id it
81
+ * replaces (the same-id case needs no successor — the fold's own row already
82
+ * carries the work forward under the id everyone already has).
83
+ *
84
+ * Events on the log from before this field shipped carry no `successors` key
85
+ * at all — only the plan `diff` `amendPlan` has always written. For exactly
86
+ * that shape (no `successors` key, present or absent notwithstanding), fall
87
+ * back to inferring the pairing from `diff.superseded`/`diff.added`, but only
88
+ * when it is completely unambiguous: one id on each side, and they differ.
89
+ * Any other shape (0 or ≥2 ids on either side, a missing/malformed diff)
90
+ * infers nothing — the same fail-closed answer an unreadable `successors`
91
+ * gets. A present `successors` — even an explicit `{}` — always wins over
92
+ * inference; the fallback only ever runs when the key is absent entirely.
93
+ */
94
+ export function taskSuccessors(events, epicId) {
95
+ const successors = new Map();
96
+ for (const event of events) {
97
+ if (event.record.event_type !== PLAN_AMENDED_EVENT)
98
+ continue;
99
+ const payload = event.record.payload;
100
+ if (payload.epic_id !== epicId)
101
+ continue;
102
+ const entries = payload.successors;
103
+ if (entries !== undefined) {
104
+ if (entries === null || typeof entries !== 'object')
105
+ continue;
106
+ for (const [oldId, newId] of Object.entries(entries)) {
107
+ if (typeof newId === 'string')
108
+ successors.set(oldId, newId);
109
+ }
110
+ continue;
111
+ }
112
+ // Legacy fallback (no `successors` key at all) — see doc comment above.
113
+ const diff = payload.diff;
114
+ if (diff === null || typeof diff !== 'object')
115
+ continue;
116
+ const supersededIds = diff.superseded;
117
+ const addedIds = diff.added;
118
+ if (!Array.isArray(supersededIds) || !Array.isArray(addedIds))
119
+ continue;
120
+ if (supersededIds.length !== 1 || addedIds.length !== 1)
121
+ continue;
122
+ const [oldId] = supersededIds;
123
+ const [newId] = addedIds;
124
+ if (typeof oldId === 'string' && typeof newId === 'string' && oldId !== newId) {
125
+ successors.set(oldId, newId);
126
+ }
127
+ }
128
+ return successors;
129
+ }
71
130
  /**
72
131
  * The epic gate refusing to certify a spec nobody re-read once the code
73
132
  * existed. Mirrors integrationBlockers exactly, including the fail-closed
@@ -345,6 +404,16 @@ export async function amendPlan(input, ctx, opts = {}) {
345
404
  // pure, so the two agree by construction, and there stays exactly one
346
405
  // function in this codebase that puts a plan version on disk.
347
406
  const amended = nextVersion(plan, input.changes ?? {}, opts);
407
+ // Old id -> replacement id, for the rename-supersede case only: a same-id
408
+ // supersede needs no pairing (the row already carries the work forward
409
+ // under the id everyone has). epic.ts's resolveSupersededRow/summarizeEpic
410
+ // is the reader — this is the one place an old task_id and its replacement
411
+ // are ever paired (PlanChanges.supersede), so it is recorded here or lost.
412
+ const successors = {};
413
+ for (const [oldId, replacement] of Object.entries(input.changes?.supersede ?? {})) {
414
+ if (replacement.task_id !== oldId)
415
+ successors[oldId] = replacement.task_id;
416
+ }
348
417
  await appendEvent({
349
418
  session_id: ctx.sessionId,
350
419
  actor: ctx.actor ?? 'system',
@@ -364,6 +433,7 @@ export async function amendPlan(input, ctx, opts = {}) {
364
433
  sites: namedSites,
365
434
  sites_unclaimed: unclaimedSites,
366
435
  diff,
436
+ successors,
367
437
  },
368
438
  }, opts);
369
439
  for (const finding of cited) {
@@ -139,7 +139,12 @@ ctx, opts = {}) {
139
139
  for (const { record } of events) {
140
140
  if (record.event_type !== 'task-added' || !record.task_id)
141
141
  continue;
142
- byId.set(record.task_id, { taskId: record.task_id, claims: record.payload?.claims });
142
+ const epicId = record.payload?.epic_id;
143
+ byId.set(record.task_id, {
144
+ taskId: record.task_id,
145
+ claims: record.payload?.claims,
146
+ epicId: typeof epicId === 'string' ? epicId : undefined,
147
+ });
143
148
  }
144
149
  return [...byId.values()];
145
150
  }
@@ -369,9 +374,13 @@ export function followUpTaskId(epicId, fingerprint) {
369
374
  *
370
375
  * Returns null when the session already carries this task, so a re-run is a
371
376
  * no-op rather than a duplicate. The task is `todo`, which is not terminal-OK,
372
- * so epic.ts's `summarizeEpic` blocks the epic verdict on it until an operator
373
- * plans or waives it — the "blocks the epic verdict instead of an unrelated
374
- * diff" half of P9-24, using the machinery that was already there.
377
+ * so epic.ts's `summarizeEpic` blocks the epic verdict on it — the "blocks the
378
+ * epic verdict instead of an unrelated diff" half of P9-24, using the
379
+ * machinery that was already there. Nothing writes a task to `waived`, so the
380
+ * two ways out are completing it (plan it, gate it) or waiving the findings
381
+ * it owns: summarizeEpic reads a follow-up terminal-OK once at least one
382
+ * finding is attributed to it and every one of them is `waived`
383
+ * (epic.ts's clearedFollowUpFindings).
375
384
  */
376
385
  export async function emitFollowUpTask(input, ctx, opts = {}) {
377
386
  const added = await idsAlreadyEmitted('task-added', ctx, opts);
@@ -0,0 +1,74 @@
1
+ // The closed readings of the `task_status` dimension, in one place.
2
+ //
3
+ // taxonomy.yml declares the vocabulary; these are the two questions the
4
+ // orchestrator asks *about* it that name a finite side. A roster belongs to
5
+ // the dimension, not to whichever consumer first needed one: these two lived
6
+ // in db/projector.ts and in waveNext.ts, epic.ts and db/queries.ts under four
7
+ // names, and two of those names were the same name for different sets.
8
+ //
9
+ // The module is deliberately a leaf — it imports nothing, and nothing added
10
+ // here may reach the database layer. waveNext.ts is in `dist/cli.js`'s static
11
+ // graph, so anything it imports is loaded by every `smith` invocation;
12
+ // cliBoot.test.ts pins that the db layer stays out of that graph, and it went
13
+ // red the moment these constants were read from db/projector.ts.
14
+ /**
15
+ * The five `task_status` values that mean the work is over — the ones
16
+ * db/projector.ts refuses to overwrite once a row reaches them. Read here
17
+ * by a reader asking "is this task still open?" (db/queries.ts's
18
+ * `inFlightEpics`) rather than keeping the complement by hand: taxonomy.yml
19
+ * can declare a thirteenth status tomorrow, and the answer `!has()` gives for
20
+ * one nobody has classified — still open — is the answer that loses nothing.
21
+ *
22
+ * Over is not the same as *well*: this set holds `failed` and `escalated`
23
+ * too. A reader that needs the other question asks TERMINAL_OK_TASK_STATUSES
24
+ * below, and the two are not interchangeable — waveNext.ts kept its own copy
25
+ * of that one under *this* name until both moved here, and reading this set
26
+ * there frees the claims on a worktree that still holds uncommitted work.
27
+ */
28
+ export const TERMINAL_TASK_STATUSES = new Set([
29
+ 'completed',
30
+ 'superseded',
31
+ 'failed',
32
+ 'escalated',
33
+ 'waived',
34
+ ]);
35
+ /**
36
+ * The two terminal statuses that mean the work ended *well*: the task shipped,
37
+ * or the operator decided it would not and waived it (D-120). Three modules
38
+ * ask this question and each kept its own answer — waveNext.ts called it
39
+ * TERMINAL_TASK_STATUSES, which is the name above for a different set;
40
+ * epic.ts called it TERMINAL_OK_TASK_STATUSES; db/queries.ts called it
41
+ * MILESTONE_COMPLETE_TASK_STATUSES. The three agreed because someone typed
42
+ * the same two strings three times, not because anything held them together.
43
+ *
44
+ * Declared rather than subtracted from the set above, because "ended well" is
45
+ * a judgement about what a status MEANS, and which way the default falls
46
+ * decides what forgetting one costs. Subtracting a declared failure side
47
+ * would read a thirteenth terminal status as *well* until someone classified
48
+ * it: waveNext.ts would free the claims on a worktree nobody has finished
49
+ * with, the roadmap bar would count the task done, and closeEpic would close
50
+ * over it. Declared, an unclassified status is simply not done — every
51
+ * reader fails closed, and the drift is visible instead of silent.
52
+ */
53
+ export const TERMINAL_OK_TASK_STATUSES = new Set(['completed', 'waived']);
54
+ /**
55
+ * The two terminal statuses a person is still holding. `failed` and
56
+ * `escalated` are over in the projector's sense — it will not overwrite them —
57
+ * but somebody is expected to come back to them, so a reader asking "can work
58
+ * still land on this task?" answers yes for these two and no for every other
59
+ * terminal status.
60
+ *
61
+ * Declared as the exception rather than as a roster of the remainder, because
62
+ * the remainder is derivable and the exception is the judgement. claims.ts
63
+ * asked exactly this question and typed the answer out — `completed`,
64
+ * `waived`, `superseded` — with its docblock explaining in prose that "`failed`
65
+ * and `escalated` are deliberately NOT here". That is the subtraction, written
66
+ * where only a reader could find it; this is the same sentence written where
67
+ * the code reads it.
68
+ *
69
+ * Which way the default falls: a thirteenth terminal status nobody classified
70
+ * falls OUT of this exception, so it reads as closed to further work, and a
71
+ * finding about its files mints a follow-up task somebody can see rather than
72
+ * parking silently on a task nobody is working.
73
+ */
74
+ export const HELD_OPEN_BY_AN_OPERATOR = new Set(['failed', 'escalated']);
@@ -15,6 +15,14 @@ const NON_DIMENSION_KEYS = new Set(['version', 'rules', 'error']);
15
15
  const PRESENCE_ONLY_FIELDS = new Set(['task_ref', 'provenance_event_ids', 'model']);
16
16
  /** The `error` dimension is validated as a `group.class` pair, not a flat tag. */
17
17
  const ERROR_CLASS_FIELD = 'error';
18
+ // `task_ref` names exactly one task; it is otherwise presence-only (above),
19
+ // which let a comma-joined "epic-1/task-1,epic-1/task-2" through as one
20
+ // non-empty string. db/projector.ts's fold read that string as a single task
21
+ // id and minted a phantom row for it that stayed `todo` forever, holding
22
+ // epic verdict on a task that was never real. Reject the shape here so the
23
+ // log never grows another one — a producer with two failing tasks logs two
24
+ // error-logged events, not one with two ids stitched together.
25
+ const TASK_REF_FIELD = 'task_ref';
18
26
  export function parseTaxonomy(yamlText) {
19
27
  const doc = parseYaml(yamlText);
20
28
  if (!doc || typeof doc !== 'object') {
@@ -102,6 +110,9 @@ export function validateRequiredDimensions(taxonomy, recordType, record) {
102
110
  if (isEmpty) {
103
111
  throw new TaxonomyError('taxonomy.missing-required-dimension', `Record type "${recordType}" is missing required field "${field}".`, { recordType, field });
104
112
  }
113
+ if (field === TASK_REF_FIELD && String(value).includes(',')) {
114
+ throw new TaxonomyError('taxonomy.multi-value-task-ref', `Record type "${recordType}" field "task_ref" names more than one task ("${String(value)}"). Log one error-logged event per task instead.`, { recordType, field, value });
115
+ }
105
116
  if (PRESENCE_ONLY_FIELDS.has(field))
106
117
  continue;
107
118
  if (field === ERROR_CLASS_FIELD) {
@@ -58,6 +58,33 @@ function signalNote(code, signal) {
58
58
  }
59
59
  return null;
60
60
  }
61
+ /**
62
+ * The environment a project's command runs in: this process's, minus the
63
+ * factory's own namespace.
64
+ *
65
+ * A command the factory launches on a project's behalf belongs to the
66
+ * project, not to the factory that launched it. Every `SMITH_*` variable in
67
+ * this process was set for *this* process, and letting one through silently
68
+ * makes the factory's own invocation part of the project's verdict.
69
+ * `SMITH_HOME` is the one that bites: it moves `WORK_ROOT` off the worktree,
70
+ * so a suite that asserts its own layout fails on the launcher's environment
71
+ * alone, and anything the suite writes to state lands in the factory's live
72
+ * `state/` instead of the worktree's. The verdict then depends on how the
73
+ * factory happened to be invoked, which is not a verdict about the branch.
74
+ *
75
+ * The strip is a rule rather than a roster, deliberately: a roster is a list
76
+ * that goes stale the next time a `SMITH_*` variable is added, and it goes
77
+ * stale silently. Not every one of them is the factory configuring itself —
78
+ * `SMITH_CROSSCHECK_OFFLINE` is an operator switch — but the runbook already
79
+ * says of that one "pass it per command"
80
+ * (`docs/runbooks/providers.md`, and it names an ambient copy of that switch
81
+ * as its own hazard). The command the operator means is the one running
82
+ * here, not the factory that spawned it, and stating it inside the command
83
+ * string works: the shell applies it after this strip.
84
+ */
85
+ export function projectCommandEnv(env) {
86
+ return Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith('SMITH_')));
87
+ }
61
88
  /**
62
89
  * Run one check command in its own process group (POSIX `setsid`, via
63
90
  * `detached: true`) so a timeout can kill the WHOLE group — `process.kill`
@@ -67,13 +94,15 @@ function signalNote(code, signal) {
67
94
  * jobs, long-lived children) running as orphans after the parent shell
68
95
  * dies — a real leak in a 24/7 factory (reproduced: manual kill -9 needed).
69
96
  */
70
- function runOne(check, cwd, timeoutMs) {
97
+ function runOne(check, cwd, defaultTimeoutMs) {
71
98
  return new Promise((resolve) => {
99
+ const timeoutMs = check.timeout_ms ?? defaultTimeoutMs;
72
100
  const child = spawn(check.cmd, {
73
101
  cwd,
74
102
  shell: true,
75
103
  detached: true,
76
104
  stdio: ['ignore', 'pipe', 'pipe'],
105
+ env: projectCommandEnv(process.env),
77
106
  });
78
107
  let output = '';
79
108
  let dropped = false;
@@ -148,10 +177,10 @@ function runOne(check, cwd, timeoutMs) {
148
177
  * no test-output parsing beyond the process exit code.
149
178
  */
150
179
  export async function run(checks, opts) {
151
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
180
+ const defaultTimeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
152
181
  const results = [];
153
182
  for (const check of checks) {
154
- const result = await runOne(check, opts.cwd, timeoutMs);
183
+ const result = await runOne(check, opts.cwd, defaultTimeoutMs);
155
184
  results.push(result);
156
185
  if (!result.pass && !opts.runAll)
157
186
  break;
@@ -30,6 +30,8 @@ const EVENTS_DIR = `${EVENTS} [--state-dir <dir>]`;
30
30
  * asked about.
31
31
  */
32
32
  const STATS = '[--db <file>] [--session <id>] [--lineage]';
33
+ /** The scope both `issues` actions accept: a session's lineage, narrowed by epic and ISO since. */
34
+ const ISSUES = '--session <id> [--epic <id>] [--since <iso>] [--state-dir <dir>] [--roadmap-path <file>] [--specs-dir <dir>]';
33
35
  export const COMMANDS = [
34
36
  {
35
37
  command: 'audit open',
@@ -64,8 +66,8 @@ export const COMMANDS = [
64
66
  {
65
67
  command: 'audit resolve',
66
68
  positionals: '<project-dir>',
67
- flags: `--epic <epic-id> ${EVENTS_DIR}`,
68
- summary: 'Append a fixed line for every finding the epic carried. Run from the epic-close step.',
69
+ flags: `--epic <epic-id> [--except <fp[,fp...]>] [--plan <plan.json>] [--specs-dir <dir>] ${EVENTS_DIR}`,
70
+ summary: "Append a fixed line for every carried finding the epic's newest plan still claims; list the rest as deferred. Run from the epic-close step.",
69
71
  },
70
72
  {
71
73
  command: 'audit close',
@@ -196,7 +198,7 @@ export const COMMANDS = [
196
198
  {
197
199
  command: 'daemon run',
198
200
  positionals: '',
199
- flags: `[--interval <seconds>] [--once] [--dir <dir>] [--project <dir>...] [--no-self] [--db <path>] [--no-db] [--state-dir <dir>]`,
201
+ flags: `[--interval <seconds>] [--once] [--dir <dir>] [--project <dir>...] [--no-self] [--db <path>] [--no-db] [--state-dir <dir>] [--specs-dir <dir>]`,
200
202
  summary: 'Watch the event log in the foreground: budgets, stale agents, work that is due. ' +
201
203
  'Prints one report per tick, one JSON line each; --once prints one document and exits. ' +
202
204
  'Never dispatches.',
@@ -230,21 +232,21 @@ export const COMMANDS = [
230
232
  {
231
233
  command: 'scheduler run',
232
234
  positionals: '',
233
- flags: `[--dry] [--now <iso>] [--project <dir>...] [--no-self] ${EVENTS_DIR}`,
235
+ flags: `[--dry] [--now <iso>] [--project <dir>...] [--no-self] [--specs-dir <dir>] ${EVENTS_DIR}`,
234
236
  summary: 'Propose and enact the next scheduler moves for a session. --dry proposes only.',
235
237
  },
236
238
  {
237
239
  command: 'scheduler admit',
238
240
  positionals: '',
239
241
  flags: '--session <id> [--state-dir <dir>] [--now <iso>] [--project <dir>...] [--no-self] ' +
240
- '[--policy <file>] [--crosscheck <file>]',
242
+ '[--specs-dir <dir>] [--policy <file>] [--crosscheck <file>]',
241
243
  summary: "Classify what's due into auto or operator per scheduler.yml autonomy. Enacts nothing.",
242
244
  },
243
245
  {
244
246
  command: 'worktree create',
245
247
  positionals: '<project-dir> <epic> <task-id>',
246
- flags: '',
247
- summary: 'Cut the isolated worktree and task branch one task is allowed to write in.',
248
+ flags: '[--from <predecessor-task-id> --session <id> [--state-dir <dir>]]',
249
+ summary: 'Cut the isolated worktree and task branch one task is allowed to write in. --from cuts it from a predecessor branch instead of integration, and requires --session (which supplies the log --from is checked against): refused unless that session records <epic>/<task-id> as the logged successor of <epic>/<predecessor-task-id>.',
248
250
  },
249
251
  {
250
252
  command: 'worktree rm',
@@ -273,8 +275,10 @@ export const COMMANDS = [
273
275
  {
274
276
  command: 'queue run',
275
277
  positionals: '<epic>',
276
- flags: '--project <dir> --test-cmd <cmd> --tasks <tasks.json> [--select-test-cmd <cmd>] [--session <id> --causal-parent <event-id> --plan <plan.json> [--plan-version <n>] [--actor <name>]] [--state-dir <dir>]',
277
- summary: 'Serially rebase, test, and merge task branches into the integration branch.',
278
+ flags: '--project <dir> --test-cmd <cmd> --tasks <tasks.json> [--select-test-cmd <cmd>] [--batch] [--session <id> --causal-parent <event-id> --plan <plan.json> [--plan-version <n>] [--actor <name>]] [--state-dir <dir>]',
279
+ summary: 'Serially rebase, test, and merge task branches into the integration branch. ' +
280
+ '--batch (roadmap merge-lanes) groups claim-disjoint tasks with groupForBatch and ' +
281
+ 'tests each group once instead of once per task — see queue.ts and the operator guide.',
278
282
  },
279
283
  {
280
284
  command: 'queue adopt',
@@ -357,6 +361,12 @@ export const COMMANDS = [
357
361
  flags: '[--task <id>] [--policy <path>] [--state-dir <dir>]',
358
362
  summary: 'Assert crosscheck.yml’s role asymmetry against the log. Exit 1 on a violation OR an unverifiable answer.',
359
363
  },
364
+ {
365
+ command: 'dispatch lint',
366
+ positionals: '<prompt-file>',
367
+ flags: '--role <role> --task <id> --session <id> [--agents-dir <dir>] [--state-dir <dir>]',
368
+ summary: "Check a composed dispatch prompt's stated turn budget against the role template's maxTurns, and a judge prompt's declared-artifact line against the ledger. Exit 1 on a violation OR an unverifiable answer. `-` reads stdin.",
369
+ },
360
370
  {
361
371
  command: 'tester check',
362
372
  positionals: '<session-id>',
@@ -387,6 +397,23 @@ export const COMMANDS = [
387
397
  flags: '--task <task.json> [--policy <file>] [--case <case>] [--epic-tag <tag>...] [--recheck]',
388
398
  summary: 'Compute whether the security reviewer must be dispatched. A fired trigger is not a red.',
389
399
  },
400
+ // `issues`: the run's write path to a project's tracker. Preview is its
401
+ // own action, never a flag, so a typo cannot promote it into a write. Exit
402
+ // status for both: 0 when every candidate resolved to a recorded outcome
403
+ // (a skip is a recorded answer); non-zero only when the run could not be
404
+ // performed at all, e.g. the session log is unreadable.
405
+ {
406
+ command: 'issues report',
407
+ positionals: '',
408
+ flags: ISSUES,
409
+ summary: 'Report each candidate error to its project tracker through gh and record an issue-reported event per candidate. The only command that runs gh.',
410
+ },
411
+ {
412
+ command: 'issues preview',
413
+ positionals: '',
414
+ flags: ISSUES,
415
+ summary: 'Dry run of issues report: prints the argv that would run, spawns no gh and appends no event. The action an unattended agent uses.',
416
+ },
390
417
  {
391
418
  command: 'harness list',
392
419
  positionals: '',
@@ -399,6 +426,12 @@ export const COMMANDS = [
399
426
  flags: '--role <role> --task <id> --prompt-file <path> [--harness <name>] [--worktree <dir>] [--policy <file>] [--tier <frontier|mid|small>] [--schema <name>]',
400
427
  summary: 'Render the invocation that would run one worker turn, as JSON. Prints it; never starts it.',
401
428
  },
429
+ {
430
+ command: 'agents sync',
431
+ positionals: '',
432
+ flags: '[--dry-run] [--reset] [--agents-dir <dir>]',
433
+ summary: 'Set each agent template’s frontmatter maxTurns from SMITH_MAXTURNS_<ROLE> (role upper-cased, - as _); --reset restores git HEAD’s value; --dry-run prints role/from/to only. A local, uncommitted edit by design.',
434
+ },
402
435
  {
403
436
  command: 'sandbox open',
404
437
  positionals: '<worktree-dir>',
@@ -1,6 +1,6 @@
1
1
  import { SmithError } from './errors.js';
2
2
  import { appendEvent, readLineageEvents } from './events.js';
3
- import { listFindings, preWaiverStatus, staleFindings, transition } from './findings.js';
3
+ import { computeFingerprint, foldFindings, listFindings, preWaiverStatus, staleFindings, transition, WAIVABLE_STATUSES, } from './findings.js';
4
4
  export class WaiverError extends SmithError {
5
5
  }
6
6
  /**
@@ -10,8 +10,38 @@ export class WaiverError extends SmithError {
10
10
  * call or via a waiver grant).
11
11
  */
12
12
  export const WAIVABLE_SEVERITIES = ['S3-minor', 'S4-nit'];
13
- /** finding_status values transition() allows a "waived" edge from (findings.ts LEGAL_TRANSITIONS). */
14
- const WAIVABLE_STATUSES = ['raised', 'confirmed'];
13
+ /**
14
+ * Every fingerprint that names the same finding as `fingerprint`, for each
15
+ * candidate in `findings` that could stand in for it: its own stored value,
16
+ * and what computeFingerprint would produce today from its persisted
17
+ * file_path/finding_category/summary. Mirrors audit.ts's
18
+ * suppressedFingerprints, for the same reason (issue #178): a `finding-raised`
19
+ * or waiver-decision record written before the normalizer widened to also
20
+ * strip line spans ("lines 1549-1576") fingerprints differently than the
21
+ * identical text does now, so a stored fingerprint and a freshly computed one
22
+ * for the same defect can legitimately disagree.
23
+ *
24
+ * Always includes `fingerprint` itself, so a caller with no backing finding
25
+ * yet (e.g. a batch decision not folded into findings) still gets an
26
+ * exact-match-only set — unchanged behaviour.
27
+ */
28
+ function fingerprintAliases(fingerprint, findings) {
29
+ const aliases = new Set([fingerprint]);
30
+ for (const finding of findings) {
31
+ if (finding.file_path === undefined)
32
+ continue;
33
+ const recomputed = computeFingerprint({
34
+ filePath: finding.file_path,
35
+ category: finding.finding_category,
36
+ summary: finding.summary,
37
+ });
38
+ if (finding.fingerprint === fingerprint || recomputed === fingerprint) {
39
+ aliases.add(finding.fingerprint);
40
+ aliases.add(recomputed);
41
+ }
42
+ }
43
+ return aliases;
44
+ }
15
45
  /**
16
46
  * Fold waiver-granted/waiver-denied events for one fingerprint: the last
17
47
  * decision wins (a denial can, in principle, be revisited by a later grant
@@ -22,13 +52,19 @@ const WAIVABLE_STATUSES = ['raised', 'confirmed'];
22
52
  * would re-raise a finding the operator already waived the moment an epic
23
53
  * continued in a new session. "Last decision wins" is decided on the merged
24
54
  * order, which is `ts` between sessions and append order within one.
55
+ *
56
+ * Matches against `fingerprintAliases(fingerprint, ...)` rather than strict
57
+ * equality (issue #178): a decision recorded under the fingerprint's old
58
+ * spelling still answers a lookup under its current one, and vice versa.
25
59
  */
26
60
  export async function isWaived(fingerprint, ref, opts = {}) {
27
61
  const events = await readLineageEvents(ref.sessionId, opts);
62
+ const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
28
63
  let waived = false;
29
64
  for (const { record } of events) {
30
65
  const payload = record.payload;
31
- if (payload.fingerprint !== fingerprint)
66
+ const fp = payload.fingerprint;
67
+ if (fp === undefined || !equivalents.has(fp))
32
68
  continue;
33
69
  if (record.event_type === 'waiver-granted')
34
70
  waived = true;
@@ -40,13 +76,17 @@ export async function isWaived(fingerprint, ref, opts = {}) {
40
76
  /**
41
77
  * Has the operator recorded ANY waiver decision for this fingerprint (granted
42
78
  * or denied)? Lineage-wide, for isWaived's reason (D-119): a decision recorded
43
- * in the parent session is still a decision.
79
+ * in the parent session is still a decision. Same alias widening as isWaived,
80
+ * and for the same reason (issue #178).
44
81
  */
45
82
  async function hasDecision(fingerprint, ref, opts) {
46
83
  const events = await readLineageEvents(ref.sessionId, opts);
84
+ const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
47
85
  return events.some(({ record }) => {
48
86
  const payload = record.payload;
49
- return (payload.fingerprint === fingerprint &&
87
+ const fp = payload.fingerprint;
88
+ return (fp !== undefined &&
89
+ equivalents.has(fp) &&
50
90
  (record.event_type === 'waiver-granted' || record.event_type === 'waiver-denied'));
51
91
  });
52
92
  }
@@ -58,10 +98,16 @@ async function hasDecision(fingerprint, ref, opts) {
58
98
  * finding as open forever. Findings the state machine can't legally move
59
99
  * (already terminal, or a non-S3/S4 severity that somehow shares this
60
100
  * fingerprint) are left alone; this is reconciliation, not a mandate.
101
+ *
102
+ * Matched via fingerprintAliases (issue #178), not strict equality: a grant
103
+ * against a finding's current fingerprint also reconciles a row for the
104
+ * identical defect still carrying the pre-widening fingerprint it was raised
105
+ * under, and vice versa.
61
106
  */
62
107
  async function reconcileFindingsToWaived(fingerprint, waiverEventId, ctx, opts) {
63
108
  const findings = await listFindings(ctx.sessionId, {}, opts);
64
- const reconcilable = findings.filter((f) => f.fingerprint === fingerprint &&
109
+ const equivalents = fingerprintAliases(fingerprint, findings);
110
+ const reconcilable = findings.filter((f) => equivalents.has(f.fingerprint) &&
65
111
  WAIVABLE_SEVERITIES.includes(f.severity) &&
66
112
  WAIVABLE_STATUSES.includes(f.finding_status));
67
113
  const transitionCtx = { ...ctx, causalParent: waiverEventId };
@@ -94,11 +140,16 @@ export async function grantWaiver(fingerprint, operatorNote, ctx, opts = {}) {
94
140
  *
95
141
  * Only findings this session's log actually shows at `waived` are touched, and
96
142
  * each goes back to the status its own grant recorded as from_status. A
97
- * finding waived under some other fingerprint is not this denial's business.
143
+ * finding waived under some other fingerprint is not this denial's business —
144
+ * "other" meaning a different defect, not merely a different spelling: matched
145
+ * via fingerprintAliases (issue #178), the same widening reconcileFindingsToWaived
146
+ * applies, so a row still carrying the pre-widening fingerprint it was raised
147
+ * under reopens alongside its current-fingerprint sibling.
98
148
  */
99
149
  async function reconcileFindingsFromWaived(fingerprint, denialEventId, ctx, opts) {
100
150
  const findings = await listFindings(ctx.sessionId, {}, opts);
101
- const reopenable = findings.filter((f) => f.fingerprint === fingerprint && f.finding_status === 'waived');
151
+ const equivalents = fingerprintAliases(fingerprint, findings);
152
+ const reopenable = findings.filter((f) => equivalents.has(f.fingerprint) && f.finding_status === 'waived');
102
153
  const transitionCtx = { ...ctx, causalParent: denialEventId };
103
154
  for (const finding of reopenable) {
104
155
  const to = await preWaiverStatus(finding.finding_id, ctx.sessionId, opts);
@@ -24,6 +24,35 @@ import { foldAgents } from './agents-registry.js';
24
24
  * intervals open forever and score every wave after one as parallel.
25
25
  */
26
26
  const ADMITTED_EVENT_TYPE = 'wave-admitted';
27
+ /**
28
+ * What the log says a wave did.
29
+ *
30
+ * - `parallel` — every admitted task was in flight at one instant.
31
+ * - `partial` — two or more were, but never all of them.
32
+ * - `serialized` — work is recorded, and no two tasks ever overlapped.
33
+ * - `single` — the wave admitted one task. Nothing to be parallel about,
34
+ * and scoring it against a width it never claimed would make
35
+ * the failing verdicts unreadable.
36
+ * - `unobserved` — the wave was admitted and the log records no work for any
37
+ * of its tasks. Not the same fact as `serialized`, and the
38
+ * whole point of separating them: one says the factory ran
39
+ * narrow, the other says nobody can tell.
40
+ */
41
+ export const WAVE_VERDICTS = ['parallel', 'partial', 'serialized', 'single', 'unobserved'];
42
+ /**
43
+ * A counter with one bucket per key, all at zero.
44
+ *
45
+ * The one place the `Record` cast lives, and here it is true by construction
46
+ * rather than asserted: the keys ARE the roster. Every caller that used to
47
+ * write `Object.fromEntries(SOME_LIST.map(...)) as Record<…>` was asserting a
48
+ * totality that only its own hand-written list was holding up.
49
+ */
50
+ export function zeroedCounts(keys) {
51
+ const counts = {};
52
+ for (const key of keys)
53
+ counts[key] = 0;
54
+ return counts;
55
+ }
27
56
  /**
28
57
  * Said when a wave was admitted and no agent was ever dispatched for it. The
29
58
  * two readings are genuinely different problems and the operator has to pick