@juzser/blacksmith 0.1.0 → 0.2.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 (72) hide show
  1. package/.claude/agents/coder.md +9 -3
  2. package/.claude/agents/grader.md +13 -5
  3. package/.claude/agents/merger.md +2 -0
  4. package/.claude/agents/planner.md +22 -1
  5. package/.claude/agents/researcher.md +3 -1
  6. package/.claude/agents/reviewer.md +9 -2
  7. package/.claude/agents/security-reviewer.md +15 -7
  8. package/.claude/agents/spec-reviewer.md +12 -3
  9. package/.claude/agents/tester.md +5 -2
  10. package/.claude/agents/uiux.md +10 -3
  11. package/.claude/agents/verifier.md +8 -3
  12. package/.claude/skills/bs/SKILL.md +119 -0
  13. package/.claude/skills/bs/audit.md +252 -0
  14. package/.claude/skills/bs/dispatch.md +394 -0
  15. package/.claude/skills/bs/lessons.md +73 -0
  16. package/.claude/skills/bs/mcp.md +29 -0
  17. package/.claude/skills/bs/new.md +35 -0
  18. package/.claude/skills/bs/plan.md +172 -0
  19. package/.claude/skills/bs/report.md +129 -0
  20. package/.claude/skills/bs/run.md +327 -0
  21. package/.claude/skills/bs/status.md +54 -0
  22. package/.claude/skills/bs/ui.md +19 -0
  23. package/.claude/skills/bs/waivers.md +13 -0
  24. package/.claude/skills/bs/wave.md +267 -0
  25. package/README.md +136 -79
  26. package/factory/orchestrator/dist/actors.js +4 -3
  27. package/factory/orchestrator/dist/agents-registry.js +96 -19
  28. package/factory/orchestrator/dist/artifacts.js +36 -4
  29. package/factory/orchestrator/dist/budgets.js +7 -5
  30. package/factory/orchestrator/dist/claims.js +27 -1
  31. package/factory/orchestrator/dist/cli.js +196 -27
  32. package/factory/orchestrator/dist/crosscheck.js +11 -0
  33. package/factory/orchestrator/dist/daemon.js +30 -5
  34. package/factory/orchestrator/dist/db/projector.js +272 -90
  35. package/factory/orchestrator/dist/db/queries.js +83 -14
  36. package/factory/orchestrator/dist/db/schema.js +7 -0
  37. package/factory/orchestrator/dist/events.js +88 -4
  38. package/factory/orchestrator/dist/harness.js +516 -0
  39. package/factory/orchestrator/dist/impact.js +76 -5
  40. package/factory/orchestrator/dist/judges.js +49 -8
  41. package/factory/orchestrator/dist/lessons.js +4 -4
  42. package/factory/orchestrator/dist/mcp.js +4 -3
  43. package/factory/orchestrator/dist/paths.js +169 -15
  44. package/factory/orchestrator/dist/plan.js +61 -1
  45. package/factory/orchestrator/dist/planQuorum.js +36 -8
  46. package/factory/orchestrator/dist/providers/api-transport.js +62 -5
  47. package/factory/orchestrator/dist/providers/cli-transport.js +7 -73
  48. package/factory/orchestrator/dist/providers/index.js +1 -0
  49. package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
  50. package/factory/orchestrator/dist/providers/types.js +5 -0
  51. package/factory/orchestrator/dist/roadmap.js +3 -3
  52. package/factory/orchestrator/dist/run-cli.js +130 -0
  53. package/factory/orchestrator/dist/runner.js +264 -0
  54. package/factory/orchestrator/dist/scaffold.js +3 -2
  55. package/factory/orchestrator/dist/spawn.js +84 -0
  56. package/factory/orchestrator/dist/stack.js +10 -2
  57. package/factory/orchestrator/dist/templateFragments.js +178 -0
  58. package/factory/orchestrator/dist/usage.js +30 -9
  59. package/factory/orchestrator/dist/waveNext.js +42 -14
  60. package/factory/orchestrator/dist/waveSchedule.js +7 -1
  61. package/factory/orchestrator/dist/workroot.js +113 -0
  62. package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
  63. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  64. package/factory/policies/budgets.yml +7 -4
  65. package/factory/policies/crosscheck.yml +10 -8
  66. package/factory/policies/harness.yml +82 -0
  67. package/factory/policies/lessons.md +122 -0
  68. package/factory/policies/severity.yml +1 -1
  69. package/factory/policies/taxonomy.yml +6 -2
  70. package/factory/specs/roadmap.md +154 -0
  71. package/factory/specs/schema/task-spec.schema.json +5 -0
  72. package/package.json +10 -7
@@ -38,14 +38,30 @@ import { epicOfTaskId } from './taskId.js';
38
38
  * entry still open under that task, because after a task-level failure nobody
39
39
  * is still running.
40
40
  *
41
+ * An epic-level dispatch — a planner, a spec-reviewer, a scribe, sent for
42
+ * the epic with no task id — closes on a terminal event that also names no
43
+ * task and does name its role (F2, the cross-provider UI check of
44
+ * 2026-09-14). Its Result is recorded as a `task-result-recorded` with no
45
+ * task id and `agent_role` in the payload, and until F2 every terminal branch
46
+ * was guarded on the task id, so ten such results across the real logs closed
47
+ * nothing: a planner that returned in four minutes read as live for the rest
48
+ * of the epic. The event speaks for the latest still-open epic-level entry of
49
+ * that role in the same session — latest because a result belongs to the
50
+ * dispatch that caused it, and that is the most recent one — and steps back
51
+ * when the two name different epics. There is no supersession at this level
52
+ * (D-244): a second planner dispatch is a second round, not a replacement,
53
+ * and a terminal event that names no role closes nothing here, because
54
+ * guessing which agent a session-level error was about is the D-244 mistake
55
+ * one level up.
56
+ *
41
57
  * Above all of those sits `epic-closed` (D-187). Every close named so far
42
- * needs a task id to speak through, so an agent whose task simply never
43
- * reported — and an epic-level dispatch, which has no task id to speak
44
- * through at all — stayed `live` past the verdict, past the end of the run,
45
- * forever. The epic's own terminal event is the only thing that can say
46
- * otherwise, and it closes what is still open under that epic in that session
47
- * as `abandoned`: not superseded (nothing replaced them) and not an error
48
- * (they were never judged), just outrun by the run they belonged to.
58
+ * needs a task id or a role to speak through, so an agent whose task simply
59
+ * never reported — and an epic-level dispatch whose Result never came —
60
+ * stayed `live` past the verdict, past the end of the run, forever. The
61
+ * epic's own terminal event is the only thing that can say otherwise, and it
62
+ * closes what is still open under that epic in that session as `abandoned`:
63
+ * not superseded (nothing replaced them) and not an error (they were never
64
+ * judged), just outrun by the run they belonged to.
49
65
  */
50
66
  export const DISPATCH_EVENT_TYPE = 'dispatch_decision';
51
67
  /** Phase 5 addition (deviation, noted in the final report): the terminal counterpart to dispatch_decision. */
@@ -147,6 +163,26 @@ function closeOpen(open, taskId, role, eventId, ts, terminalType) {
147
163
  open.delete(key);
148
164
  }
149
165
  }
166
+ /**
167
+ * Close the epic-level entry a task-less terminal event speaks for: the
168
+ * latest still-open dispatch of `role` in `sessionId` (F2). `epicId` is the
169
+ * event's own claim about which epic it belongs to; when both sides name one
170
+ * and they differ, the event is another epic's and closes nothing here. The
171
+ * list is scanned from the end because the most recent dispatch of a role is
172
+ * the one whose Result this is.
173
+ */
174
+ function closeOpenEpicLevel(openEpicLevel, sessionId, role, epicId, eventId, ts, terminalType) {
175
+ for (let i = openEpicLevel.length - 1; i >= 0; i--) {
176
+ const entry = openEpicLevel[i];
177
+ if (!entry || entry.sessionId !== sessionId || entry.agentRole !== role)
178
+ continue;
179
+ if (epicId && entry.epicId && entry.epicId !== epicId)
180
+ continue;
181
+ closeEntry(entry, eventId, ts, terminalType);
182
+ openEpicLevel.splice(i, 1);
183
+ return;
184
+ }
185
+ }
150
186
  /**
151
187
  * Fold a session's full event history into agent registry rows. Pure
152
188
  * function over the event list — used identically by db/projector.ts's
@@ -156,7 +192,8 @@ function closeOpen(open, taskId, role, eventId, ts, terminalType) {
156
192
  export function foldAgents(events) {
157
193
  const open = new Map();
158
194
  // Dispatches with no task id have no correlation key, so they cannot live in
159
- // `open` — but they are still open, and `epic-closed` still speaks for them.
195
+ // `open` — but they are still open: a task-less terminal naming their role
196
+ // closes the latest of them (F2), and `epic-closed` sweeps the rest.
160
197
  let openEpicLevel = [];
161
198
  const records = [];
162
199
  for (const { event_id, record } of events) {
@@ -205,13 +242,20 @@ export function foldAgents(events) {
205
242
  }
206
243
  if (record.event_type === TASK_RESULT_EVENT_TYPE) {
207
244
  // The Result names its author in `agent` (result.schema.json's required
208
- // field). A hand-appended one that omits it still closes the task.
245
+ // field). A hand-appended one that omits it still closes the task. An
246
+ // epic-level one, recorded by hand with no task id, spells the role as
247
+ // `agent_role` — the dispatch's own key — in every real log so far, so
248
+ // the fold reads either.
209
249
  const payload = record.payload;
210
250
  // Both levels: result.schema.json names the task in the payload, and the
211
251
  // dispatch this closes may have named it only there too (D-245).
212
252
  const taskId = eventTaskId(record);
253
+ const role = payload.agent ?? payload.agent_role ?? null;
213
254
  if (taskId) {
214
- closeOpen(open, taskId, payload.agent ?? null, event_id, record.ts, 'result');
255
+ closeOpen(open, taskId, role, event_id, record.ts, 'result');
256
+ }
257
+ else if (role) {
258
+ closeOpenEpicLevel(openEpicLevel, record.session_id, role, payload.epic_id, event_id, record.ts, 'result');
215
259
  }
216
260
  continue;
217
261
  }
@@ -221,19 +265,25 @@ export function foldAgents(events) {
221
265
  if (taskId) {
222
266
  closeOpen(open, taskId, payload.agent_role ?? null, event_id, record.ts, 'result');
223
267
  }
268
+ else if (payload.agent_role) {
269
+ closeOpenEpicLevel(openEpicLevel, record.session_id, payload.agent_role, payload.epic_id, event_id, record.ts, 'result');
270
+ }
224
271
  continue;
225
272
  }
226
273
  if (record.event_type === JUDGE_VERDICT_EVENT_TYPE) {
227
274
  const payload = record.payload;
228
275
  const taskId = eventTaskId(record);
276
+ // A run that produced no schema-valid verdict did not do its job, and
277
+ // `ok: false` is how the log says so. Closing it as a result would let
278
+ // a provider failing every call read exactly like one answering every
279
+ // call — the distinction providerAgreement() exists to measure.
280
+ const terminal = payload.ok === false ? 'error' : 'result';
229
281
  if (taskId) {
230
- // A run that produced no schema-valid verdict did not do its job, and
231
- // `ok: false` is how the log says so. Closing it as a result would let
232
- // a provider failing every call read exactly like one answering every
233
- // call — the distinction providerAgreement() exists to measure.
234
- const terminal = payload.ok === false ? 'error' : 'result';
235
282
  closeOpen(open, taskId, payload.agent ?? null, event_id, record.ts, terminal);
236
283
  }
284
+ else if (payload.agent) {
285
+ closeOpenEpicLevel(openEpicLevel, record.session_id, payload.agent, payload.epic_id, event_id, record.ts, terminal);
286
+ }
237
287
  continue;
238
288
  }
239
289
  if (record.event_type === ERROR_EVENT_TYPE) {
@@ -244,6 +294,11 @@ export function foldAgents(events) {
244
294
  if (taskId) {
245
295
  closeOpen(open, taskId, payload.agent_role ?? null, event_id, record.ts, 'error');
246
296
  }
297
+ else if (payload.agent_role) {
298
+ // Only with a role: a session-level error names nobody, and every
299
+ // epic-level agent stays where it was.
300
+ closeOpenEpicLevel(openEpicLevel, record.session_id, payload.agent_role, payload.epic_id, event_id, record.ts, 'error');
301
+ }
247
302
  continue;
248
303
  }
249
304
  if (record.event_type === EPIC_CLOSED_EVENT_TYPE) {
@@ -262,10 +317,10 @@ export function foldAgents(events) {
262
317
  closeEntry(entry, event_id, record.ts, 'abandoned');
263
318
  open.delete(key);
264
319
  }
265
- // The epic-level half keeps D-187's reasoning for an entry nothing
266
- // places -- no task id and no `epic_id` on its dispatch means no other
267
- // terminal event can ever name it, so the run's own verdict is the last
268
- // thing that can close it. But an entry that DID name an epic is placed,
320
+ // The epic-level half keeps D-187's reasoning for an entry whose Result
321
+ // never came -- no task id means only a terminal naming its role (F2)
322
+ // or the run's own verdict can close it, and this is the verdict. But
323
+ // an entry that DID name an epic is placed,
269
324
  // and one session runs several epics in a row: sweeping the lot closed
270
325
  // the next epic's planner, live and working, on the previous epic's
271
326
  // verdict (D-234).
@@ -306,3 +361,25 @@ export function detectStale(agents, nowIso, staleHours = DEFAULT_STALE_HOURS) {
306
361
  export function liveAgents(agents) {
307
362
  return agents.filter((a) => a.status === 'live');
308
363
  }
364
+ /**
365
+ * Whether a dispatch at `dispatchedAt` still counts as *working* at `nowIso`:
366
+ * the dashboard's answer to "is anyone actually on this right now", as
367
+ * opposed to "has no terminal event closed it yet" (`liveAgents`). Same
368
+ * arithmetic and the same strict `>` as `detectStale` — working is the
369
+ * complement of stale over the live set — kept as one predicate so the
370
+ * queries that count, group and label agents cannot drift from the
371
+ * threshold the registry warns on.
372
+ *
373
+ * Two readings `detectStale`'s loop leaves implicit are pinned here because
374
+ * the dashboard renders them: a dispatch whose timestamp does not parse is
375
+ * NOT working (NaN compares false in both directions, which would otherwise
376
+ * read as "not stale, so working" — an unreadable clock is never evidence
377
+ * of activity), and a future-dated dispatch IS working (clock skew between
378
+ * the writer and the reader is not staleness).
379
+ */
380
+ export function isWorkingAt(dispatchedAt, nowIso, staleHours = DEFAULT_STALE_HOURS) {
381
+ const liveHours = (new Date(nowIso).getTime() - new Date(dispatchedAt).getTime()) / 3.6e6;
382
+ if (Number.isNaN(liveHours))
383
+ return false;
384
+ return liveHours <= staleHours;
385
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { SmithError } from './errors.js';
4
- import { STATE_ARTIFACTS_DIR } from './paths.js';
4
+ import { STATE_ARTIFACTS_DIR, WORK_ROOT } from './paths.js';
5
5
  /**
6
6
  * Where a task's artifacts belong: `state/artifacts/<task-id>/`, beside
7
7
  * `state/results/<task-id>.json`. One home per task, named by the task, so the
@@ -38,6 +38,33 @@ function realOf(p) {
38
38
  return p;
39
39
  }
40
40
  }
41
+ /**
42
+ * How the home is spelled from the work root — `state/artifacts/<task-id>` in
43
+ * production — or null when it does not sit under that root at all, in which
44
+ * case no relative declaration can be naming it.
45
+ */
46
+ function spellingFromWorkRoot(home, workRoot) {
47
+ const rel = path.relative(path.resolve(workRoot), home);
48
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
49
+ return null;
50
+ return rel;
51
+ }
52
+ /**
53
+ * The declared path with the home's own spelling stripped off, when it starts
54
+ * with one: `state/artifacts/<task-id>/round2.txt` becomes `round2.txt`, and
55
+ * the bare spelling becomes `''`, which is the home itself and reports as
56
+ * `no-path`. Anything else — an absolute path, a plain filename, another
57
+ * task's home — is handed back as written.
58
+ */
59
+ function withinHome(declared, homeSpelling) {
60
+ if (homeSpelling === null || path.isAbsolute(declared))
61
+ return declared;
62
+ const normalized = path.normalize(declared);
63
+ if (normalized === homeSpelling)
64
+ return '';
65
+ const prefix = homeSpelling + path.sep;
66
+ return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : declared;
67
+ }
41
68
  /** True when `child` is `root` itself or lives beneath it. */
42
69
  function contains(root, child) {
43
70
  if (child === root)
@@ -48,7 +75,11 @@ function contains(root, child) {
48
75
  /**
49
76
  * Check every path a result declares as evidence. Relative paths resolve
50
77
  * against the task's home, so `coverage.txt` means the obvious thing and the
51
- * short spelling is also the correct one.
78
+ * short spelling is also the correct one. A path that already begins with the
79
+ * home's own spelling from the work root — `state/artifacts/<task-id>/x` — is
80
+ * read as that spelling, not as a subtree of the home: the template says
81
+ * "under `state/artifacts/<task-id>/`", a coder wrote exactly that, and the
82
+ * doubled path came back `missing` for a file that existed (FD-5).
52
83
  *
53
84
  * Two properties, in this order. Inside the home first: a path in the wrong
54
85
  * place is wrong whether or not the file happens to be there today, and saying
@@ -79,8 +110,9 @@ export function checkArtifacts(artifacts, opts) {
79
110
  const realHome = existsSync(home) ? realOf(home) : home;
80
111
  const expectedHome = existsSync(home) ? homeUnder(realOf(root), opts.taskId) : home;
81
112
  const homeEscaped = realHome !== expectedHome;
113
+ const homeSpelling = spellingFromWorkRoot(home, opts.workRoot ?? WORK_ROOT);
82
114
  for (const decl of artifacts) {
83
- const resolved = path.resolve(home, decl.path);
115
+ const resolved = path.resolve(home, withinHome(decl.path, homeSpelling));
84
116
  if (resolved === home) {
85
117
  issues.push({ declared: decl.path, resolved, problem: 'no-path' });
86
118
  continue;
@@ -90,7 +122,7 @@ export function checkArtifacts(artifacts, opts) {
90
122
  continue;
91
123
  }
92
124
  if (homeEscaped) {
93
- const real = path.resolve(realHome, decl.path);
125
+ const real = path.resolve(realHome, withinHome(decl.path, homeSpelling));
94
126
  issues.push({ declared: decl.path, resolved: real, problem: 'outside-home' });
95
127
  continue;
96
128
  }
@@ -157,11 +157,13 @@ export function loadBudgetPolicy(filePath = BUDGETS_POLICY_PATH) {
157
157
  * visible, so plan validation can say "you wrote down a limit nobody can
158
158
  * apply" instead of the plan quietly implying otherwise.
159
159
  *
160
- * `max_turns` is null on purpose, and not because the wiring is pending: a turn
161
- * limit has to live in the dispatch harness, and the Agent tool exposes no
162
- * turns parameter (agent-interviews.md M-4, answered (c) on 2026-08-05 — the
163
- * number is carried into the dispatch prompt by the /bs skill, where it is a
164
- * request to the agent, not a limit on it).
160
+ * `max_turns` is null on purpose, and not because the wiring is pending: the
161
+ * turn limit lives in the dispatch harness, which reads the role template's
162
+ * `maxTurns` (`.claude/agents/<role>.md`) — per role, never per task
163
+ * (agent-interviews.md M-4, answered (c) on 2026-08-05 and corrected
164
+ * 2026-09-11 once the cap was measured). A plan cannot raise or lower a
165
+ * role's ceiling, so a number here would be a second copy the harness never
166
+ * consults; the /bs dispatch contract restates the template's value instead.
165
167
  */
166
168
  export const TASK_BUDGET_FIELD_READERS = Object.freeze({
167
169
  tokens: 'gate: compared against the result’s token_usage.total_tokens',
@@ -226,7 +226,33 @@ export function readClaimList(task) {
226
226
  'wave for the reason that it may not edit anything. Admitting it defers the mistake to ' +
227
227
  'the first out-of-claim edit; task-spec.schema.json requires at least one claim.', { task_id: task.task_id });
228
228
  }
229
- return { task_id: task.task_id, claims: claims };
229
+ const promises = readPromiseList(task);
230
+ return promises === undefined
231
+ ? { task_id: task.task_id, claims: claims }
232
+ : { task_id: task.task_id, claims: claims, keeps_exports: promises };
233
+ }
234
+ /**
235
+ * The `keeps_exports` half of the same door. A promise the wave gate cannot
236
+ * read must not read as "no promise": that direction is safe for the gate
237
+ * (the crossing serializes), but it also hides the plan error until a
238
+ * `plan validate` nobody re-ran. The field is optional; absent stays absent.
239
+ */
240
+ function readPromiseList(task) {
241
+ const promises = task.keeps_exports;
242
+ if (promises === undefined)
243
+ return undefined;
244
+ if (!Array.isArray(promises)) {
245
+ throw new ClaimsError('claims.unreadable-promises', `Task "${task.task_id}" declares keeps_exports as ${describeType(promises)}, not a list ` +
246
+ 'of file paths, so the wave gate cannot tell which crossings it promised to keep. ' +
247
+ 'keeps_exports is a list of repo-relative file paths (task-spec.schema.json).', { task_id: task.task_id, received: describeType(promises) });
248
+ }
249
+ const badIndex = promises.findIndex((file) => typeof file !== 'string' || file === '');
250
+ if (badIndex !== -1) {
251
+ const received = promises[badIndex] === '' ? 'an empty string' : describeType(promises[badIndex]);
252
+ throw new ClaimsError('claims.unreadable-promises', `Task "${task.task_id}" declares keeps_exports entry ${badIndex} as ${received}, not a ` +
253
+ 'file path, so the wave gate cannot tell which crossings it promised to keep.', { task_id: task.task_id, index: badIndex, received });
254
+ }
255
+ return promises;
230
256
  }
231
257
  /**
232
258
  * Refuse an edge list no traversal can read, before it is traversed.