@juzser/blacksmith 0.1.0 → 0.1.1

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 (61) 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 +1 -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 +103 -0
  13. package/.claude/skills/bs/audit.md +250 -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 +33 -0
  18. package/.claude/skills/bs/plan.md +155 -0
  19. package/.claude/skills/bs/report.md +129 -0
  20. package/.claude/skills/bs/run.md +325 -0
  21. package/.claude/skills/bs/status.md +54 -0
  22. package/.claude/skills/bs/ui.md +12 -0
  23. package/.claude/skills/bs/waivers.md +13 -0
  24. package/.claude/skills/bs/wave.md +261 -0
  25. package/README.md +119 -65
  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/cli.js +107 -7
  31. package/factory/orchestrator/dist/crosscheck.js +11 -0
  32. package/factory/orchestrator/dist/daemon.js +14 -0
  33. package/factory/orchestrator/dist/db/projector.js +272 -90
  34. package/factory/orchestrator/dist/db/queries.js +83 -14
  35. package/factory/orchestrator/dist/db/schema.js +7 -0
  36. package/factory/orchestrator/dist/events.js +38 -4
  37. package/factory/orchestrator/dist/harness.js +367 -0
  38. package/factory/orchestrator/dist/judges.js +49 -8
  39. package/factory/orchestrator/dist/lessons.js +4 -4
  40. package/factory/orchestrator/dist/mcp.js +4 -3
  41. package/factory/orchestrator/dist/paths.js +167 -15
  42. package/factory/orchestrator/dist/plan.js +1 -1
  43. package/factory/orchestrator/dist/planQuorum.js +36 -8
  44. package/factory/orchestrator/dist/providers/api-transport.js +62 -5
  45. package/factory/orchestrator/dist/providers/index.js +1 -0
  46. package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
  47. package/factory/orchestrator/dist/providers/types.js +5 -0
  48. package/factory/orchestrator/dist/roadmap.js +3 -3
  49. package/factory/orchestrator/dist/scaffold.js +3 -2
  50. package/factory/orchestrator/dist/stack.js +10 -2
  51. package/factory/orchestrator/dist/templateFragments.js +178 -0
  52. package/factory/orchestrator/dist/usage.js +24 -5
  53. package/factory/orchestrator/dist/workroot.js +113 -0
  54. package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
  55. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  56. package/factory/policies/budgets.yml +6 -3
  57. package/factory/policies/crosscheck.yml +10 -8
  58. package/factory/policies/lessons.md +122 -0
  59. package/factory/policies/taxonomy.yml +6 -2
  60. package/factory/specs/roadmap.md +154 -0
  61. package/package.json +8 -6
@@ -6,7 +6,7 @@
6
6
  // is the normal case; a session filter is for debugging one run).
7
7
  import { and, eq, gte, inArray, lte } from 'drizzle-orm';
8
8
  import { isOperatorActor } from '../actors.js';
9
- import { foldAgents, liveAgents as foldLiveAgents, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
9
+ import { foldAgents, liveAgents as foldLiveAgents, isWorkingAt, REGISTRY_EVENT_TYPES, } from '../agents-registry.js';
10
10
  import { compareLogOrder, isLaterEvent, parseEventId, ROOT_EVENT_TYPE } from '../events.js';
11
11
  import { waveLayers } from '../graph.js';
12
12
  import { judgeFailureKind } from '../providers/types.js';
@@ -514,14 +514,19 @@ function allAgentsForScope(db, scope) {
514
514
  * Ties on `lastEventAt` break on `sessionId` so the order is stable under the
515
515
  * dashboard's 5s poll rather than shuffling between renders.
516
516
  */
517
- export function runningSessions(db, scope = {}) {
517
+ export function runningSessions(db, scope = {}, opts = {}) {
518
+ const nowIso = opts.nowIso ?? new Date().toISOString();
518
519
  const sessionCond = scopedToSessions(sessions.sessionId, scope);
519
520
  const rows = sessionCond
520
521
  ? db.select().from(sessions).where(sessionCond).all()
521
522
  : db.select().from(sessions).all();
522
523
  const liveBySession = new Map();
524
+ const workingBySession = new Map();
523
525
  for (const a of allAgentsForScope(db, scope)) {
524
526
  liveBySession.set(a.sessionId, (liveBySession.get(a.sessionId) ?? 0) + 1);
527
+ if (isWorkingAt(a.dispatchedAt, nowIso)) {
528
+ workingBySession.set(a.sessionId, (workingBySession.get(a.sessionId) ?? 0) + 1);
529
+ }
525
530
  }
526
531
  const projectsBySession = new Map();
527
532
  for (const t of allTasksForScope(db, scope)) {
@@ -555,6 +560,7 @@ export function runningSessions(db, scope = {}) {
555
560
  lastEventAt: s.lastEventAt,
556
561
  eventCount: s.eventCount,
557
562
  liveAgentCount: liveBySession.get(s.sessionId) ?? 0,
563
+ workingAgentCount: workingBySession.get(s.sessionId) ?? 0,
558
564
  lastEventType: lastEvent.get(s.sessionId)?.eventType ?? null,
559
565
  projects: [...(projectsBySession.get(s.sessionId) ?? [])].sort(),
560
566
  }))
@@ -621,8 +627,14 @@ function inFlightEpics(taskRows, closed) {
621
627
  .map((t) => t.epicId)),
622
628
  ].sort();
623
629
  }
624
- /** One project's overview slice, computed by the same logic overview() itself uses (no drift). */
625
- function projectSummary(db, project, baseScope) {
630
+ /**
631
+ * One project's overview slice, computed by the same logic overview() itself
632
+ * uses (no drift). `nowIso` is the caller's single instant, not a fresh
633
+ * read: overview() hands every per-project summary the same "now" it used
634
+ * for its own counts, so the project rows and the global card can never
635
+ * disagree about which agents are working.
636
+ */
637
+ function projectSummary(db, project, baseScope, nowIso) {
626
638
  const scope = { ...baseScope, project };
627
639
  const liveRows = allAgentsForScope(db, scope);
628
640
  const taskRows = allTasksForScope(db, scope);
@@ -637,6 +649,7 @@ function projectSummary(db, project, baseScope) {
637
649
  return {
638
650
  project,
639
651
  liveAgentCount: liveRows.length,
652
+ workingAgentCount: liveRows.filter((a) => isWorkingAt(a.dispatchedAt, nowIso)).length,
640
653
  epicsInFlight,
641
654
  tokensSpent,
642
655
  tokensBudget,
@@ -664,7 +677,7 @@ const SNAPSHOT_EVENT_TYPES = REGISTRY_EVENT_TYPES;
664
677
  * project, so its agent stayed in the live count and vanished from the
665
678
  * historical one, and the StatCard reported an arrival that never happened.
666
679
  */
667
- function liveAgentCountAt(db, scope, cutoffIso) {
680
+ function liveAgentsAt(db, scope, cutoffIso) {
668
681
  const conds = [lte(eventsRaw.ts, cutoffIso), inArray(eventsRaw.eventType, SNAPSHOT_EVENT_TYPES)];
669
682
  const sessionCond = scopedToSessions(eventsRaw.sessionId, scope);
670
683
  if (sessionCond)
@@ -690,9 +703,25 @@ function liveAgentCountAt(db, scope, cutoffIso) {
690
703
  }));
691
704
  const live = foldLiveAgents(foldAgents(storedEvents));
692
705
  if (scope.project === undefined)
693
- return live.length;
706
+ return live;
694
707
  const scopedTaskIds = new Set(allTasksForScope(db, scope).map((t) => t.taskId));
695
- return live.filter((a) => a.taskId !== null && scopedTaskIds.has(a.taskId)).length;
708
+ return live.filter((a) => a.taskId !== null && scopedTaskIds.has(a.taskId));
709
+ }
710
+ function liveAgentCountAt(db, scope, cutoffIso) {
711
+ return liveAgentsAt(db, scope, cutoffIso).length;
712
+ }
713
+ /**
714
+ * The "working" end of workingAgentCountDelta5m's subtraction: the agents
715
+ * live as of `cutoffIso` (the very re-fold liveAgentCountAt does, project
716
+ * scoping included) that were ALSO within DEFAULT_STALE_HOURS of dispatch
717
+ * *at that cutoff*. Judging the historical rows against today's clock
718
+ * instead would be D-170 again in a new disguise — the "now" end would
719
+ * apply a 4h window and the "ago" end a 4h05m one, and an agent that
720
+ * stalled since the cutoff would read as no change.
721
+ */
722
+ function workingAgentCountAt(db, scope, cutoffIso) {
723
+ return liveAgentsAt(db, scope, cutoffIso).filter((a) => isWorkingAt(a.dispatchedAt, cutoffIso))
724
+ .length;
696
725
  }
697
726
  /**
698
727
  * Total tokens spent (task-result-recorded token_usage) across `epicByTask`'s tasks, as of `cutoffIso`.
@@ -781,7 +810,14 @@ function tokensBudgetedAt(db, scope, cutoffIso, epicByTask) {
781
810
  return total;
782
811
  }
783
812
  export function overview(db, scope = {}, opts = {}) {
813
+ // One instant for everything this call reports. runningSessions(), the
814
+ // per-project summaries and the deltas each take the clock as an argument
815
+ // rather than reading it, so a request that straddles a threshold cannot
816
+ // count an agent as working in one field and stalled in the next.
817
+ const now = opts.nowIso ? new Date(opts.nowIso) : new Date();
818
+ const nowIso = now.toISOString();
784
819
  const liveRows = allAgentsForScope(db, scope);
820
+ const workingRows = liveRows.filter((a) => isWorkingAt(a.dispatchedAt, nowIso));
785
821
  const taskRows = allTasksForScope(db, scope);
786
822
  const closedEpics = closedEpicsForScope(db, scope);
787
823
  const epicsInFlight = inFlightEpics(taskRows, closedEpics);
@@ -835,12 +871,12 @@ export function overview(db, scope = {}, opts = {}) {
835
871
  // Milestones carry no session_id and milestoneProgressRows() never
836
872
  // filters them by one, so they join the list under a session scope too.
837
873
  const declaredRows = db.select({ project: milestones.project }).from(milestones).all();
838
- projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope));
874
+ projects = distinctProjects(allTaskRowsUnfiltered, declaredRows).map((p) => projectSummary(db, p, scope, nowIso));
839
875
  }
840
- const now = opts.nowIso ? new Date(opts.nowIso) : new Date();
841
876
  const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
842
877
  const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000).toISOString();
843
878
  const liveAgentCountDelta5m = liveRows.length - liveAgentCountAt(db, scope, fiveMinAgo);
879
+ const workingAgentCountDelta5m = workingRows.length - workingAgentCountAt(db, scope, fiveMinAgo);
844
880
  const epicByTask = new Map(taskRows.filter((t) => t.epicId !== null).map((t) => [t.taskId, t.epicId]));
845
881
  const totalBudgetNow = [...budgetByEpic.values()].reduce((s, v) => s + v, 0);
846
882
  const totalSpentNow = [...spentByEpic.values()].reduce((s, v) => s + v, 0);
@@ -873,7 +909,9 @@ export function overview(db, scope = {}, opts = {}) {
873
909
  dispatchedAt: a.dispatchedAt,
874
910
  })),
875
911
  liveAgentCount: liveRows.length,
876
- runningSessions: runningSessions(db, scope),
912
+ workingAgentCount: workingRows.length,
913
+ stalledAgentCount: liveRows.length - workingRows.length,
914
+ runningSessions: runningSessions(db, scope, { nowIso }),
877
915
  epicsInFlight,
878
916
  closedEpics,
879
917
  tokensByEpic,
@@ -881,6 +919,7 @@ export function overview(db, scope = {}, opts = {}) {
881
919
  milestoneProgress: milestoneProgressRows(db, scope),
882
920
  recentDispatches,
883
921
  liveAgentCountDelta5m,
922
+ workingAgentCountDelta5m,
884
923
  budgetUsedPctPointDelta1h,
885
924
  ...(projects ? { projects } : {}),
886
925
  };
@@ -959,6 +998,12 @@ export const FREE_TIMELINE_EVENT_TYPES = [
959
998
  'audit-cut',
960
999
  'audit-resolved',
961
1000
  'audit-closed',
1001
+ // The integration PR run.md step 17 opens, appended by hand through `smith
1002
+ // event append` under `<epic>/integration`. It is the epic's terminal
1003
+ // deliverable -- the one thing the operator is asked to merge -- and the
1004
+ // only event written after `epic-closed`, so without it the timeline of a
1005
+ // closed epic ends at the closing and never shows what the closing was for.
1006
+ 'integration-pr-opened',
962
1007
  ];
963
1008
  let cachedTaxonomy;
964
1009
  /**
@@ -1174,7 +1219,8 @@ function worstSeverity(severities) {
1174
1219
  * task_status, tagged with case/origin/worst-open-finding severity plus
1175
1220
  * (Phase 6b) title/agent-role/milestone.
1176
1221
  */
1177
- export function kanban(db, epicId, scope = {}) {
1222
+ export function kanban(db, epicId, scope = {}, opts = {}) {
1223
+ const nowIso = opts.nowIso ?? new Date().toISOString();
1178
1224
  const epicCond = epicId !== undefined ? eq(tasks.epicId, epicId) : undefined;
1179
1225
  const sessionCond = scopedToSessions(tasks.sessionId, scope);
1180
1226
  const taskConds = [epicCond, sessionCond].filter((c) => c !== undefined);
@@ -1221,6 +1267,18 @@ export function kanban(db, epicId, scope = {}) {
1221
1267
  modelTier: d.modelTier,
1222
1268
  });
1223
1269
  }
1270
+ // Who is still on each task, from the agents rows (live only — see
1271
+ // allAgentsForScope). A working row wins over a stalled one on the same
1272
+ // task for the reason flowGraph gives: the chip says who is on the task.
1273
+ const activityByTask = new Map();
1274
+ for (const a of allAgentsForScope(db, scope)) {
1275
+ if (!a.taskId)
1276
+ continue;
1277
+ if (isWorkingAt(a.dispatchedAt, nowIso))
1278
+ activityByTask.set(a.taskId, 'working');
1279
+ else if (!activityByTask.has(a.taskId))
1280
+ activityByTask.set(a.taskId, 'stalled');
1281
+ }
1224
1282
  // epicId -> milestoneId, from roadmap.md's milestones table.
1225
1283
  const milestoneRows = db.select().from(milestones).all();
1226
1284
  const milestoneByEpic = new Map();
@@ -1237,6 +1295,7 @@ export function kanban(db, epicId, scope = {}) {
1237
1295
  title: t.objective,
1238
1296
  agentRole: latestAgentRoleByTask.get(t.taskId)?.agentRole ?? null,
1239
1297
  agentModelTier: latestAgentRoleByTask.get(t.taskId)?.modelTier ?? null,
1298
+ agentActivity: activityByTask.get(t.taskId) ?? null,
1240
1299
  milestoneId: t.epicId ? (milestoneByEpic.get(t.epicId) ?? null) : null,
1241
1300
  tags: {
1242
1301
  case: t.caseTag,
@@ -1462,7 +1521,8 @@ export function analytics(db, scope = {}) {
1462
1521
  * stack.md: "no separate layout library" — this IS that orchestrator
1463
1522
  * utility, exposed via this query/endpoint per the same doc's instruction).
1464
1523
  */
1465
- export function flowGraph(db, filter = {}) {
1524
+ export function flowGraph(db, filter = {}, opts = {}) {
1525
+ const nowIso = opts.nowIso ?? new Date().toISOString();
1466
1526
  let taskRows = allTasksForScope(db, filter);
1467
1527
  if (filter.epicId)
1468
1528
  taskRows = taskRows.filter((t) => t.epicId === filter.epicId);
@@ -1526,9 +1586,17 @@ export function flowGraph(db, filter = {}) {
1526
1586
  const scopedEdges = edgeRows.filter((e) => taskIds.has(e.taskId) && taskIds.has(e.dependsOn));
1527
1587
  const liveRows = allAgentsForScope(db, filter);
1528
1588
  const liveRoleByTask = new Map();
1589
+ // The same last-write-wins walk, over the working rows only. Where a task
1590
+ // carries both a stalled live row and a working one, the working role is
1591
+ // the one to name: the marker says who is on the task, not who was
1592
+ // dispatched to it most recently.
1593
+ const workingRoleByTask = new Map();
1529
1594
  for (const a of liveRows) {
1530
- if (a.taskId)
1531
- liveRoleByTask.set(a.taskId, a.agentRole);
1595
+ if (!a.taskId)
1596
+ continue;
1597
+ liveRoleByTask.set(a.taskId, a.agentRole);
1598
+ if (isWorkingAt(a.dispatchedAt, nowIso))
1599
+ workingRoleByTask.set(a.taskId, a.agentRole);
1532
1600
  }
1533
1601
  const waveMap = waveLayers([...taskIds], scopedEdges.map((e) => ({ task: e.taskId, dependsOn: e.dependsOn })));
1534
1602
  const nodes = taskRows.map((t) => ({
@@ -1536,6 +1604,7 @@ export function flowGraph(db, filter = {}) {
1536
1604
  taskStatus: t.taskStatus,
1537
1605
  title: t.objective,
1538
1606
  liveAgentRole: liveRoleByTask.get(t.taskId) ?? null,
1607
+ workingAgentRole: workingRoleByTask.get(t.taskId) ?? null,
1539
1608
  planVersion: t.planVersion,
1540
1609
  wave: waveMap.get(t.taskId) ?? 0,
1541
1610
  }));
@@ -189,6 +189,13 @@ export const findings = sqliteTable('findings', {
189
189
  raisedAt: text('raised_at').notNull(),
190
190
  updatedAt: text('updated_at').notNull(),
191
191
  project: text('project'), // Phase 6b — see eventsRaw's project comment above.
192
+ // What a spec finding is about (findings.ts SpecRef). The scope is filled
193
+ // in from findingScope(), so an absent field lands as 'diff' rather than
194
+ // as a null the reader has to know the default for; the two spec_ref
195
+ // columns are null on every diff finding because there is no criterion.
196
+ findingScope: text('finding_scope').notNull().default('diff'),
197
+ specPlanVersion: integer('spec_plan_version'),
198
+ criterionRef: text('criterion_ref'),
192
199
  }, (t) => [
193
200
  index('findings_session_idx').on(t.sessionId),
194
201
  index('findings_task_idx').on(t.taskId),
@@ -203,10 +203,20 @@ async function readEventsAtPath(filePath, sessionId) {
203
203
  .trimEnd()
204
204
  .split('\n')
205
205
  .filter((line) => line.length > 0);
206
- return lines.map((line, index) => ({
207
- event_id: `${sessionId}#${index}`,
208
- record: JSON.parse(line),
209
- }));
206
+ return lines.map((line, index) => {
207
+ let record;
208
+ try {
209
+ record = JSON.parse(line);
210
+ }
211
+ catch (err) {
212
+ // A bare SyntaxError says "Unexpected token" and nothing else: not which
213
+ // log, not which line. The dashboard prints this for every session it
214
+ // then fails to fold (the global folds read every log), so the message
215
+ // has to carry the path the operator needs to open.
216
+ throw new EventError('events.unreadable-session-log', `Line ${index + 1} of ${filePath} is not JSON, so session "${sessionId}" cannot be read: ${errorText(err)}.`, { session_id: sessionId, path: filePath, line: index + 1 });
217
+ }
218
+ return { event_id: `${sessionId}#${index}`, record };
219
+ });
210
220
  }
211
221
  /**
212
222
  * Per-log serialization *inside one process*: concurrent appendEvent calls on
@@ -504,7 +514,31 @@ function validatePayloadDimensions(taxonomy, record) {
504
514
  const TYPED_PAYLOAD_SCHEMAS = {
505
515
  'finding-raised': 'finding',
506
516
  };
517
+ /**
518
+ * The one field of a `task-result-recorded` payload a reader iterates. The
519
+ * projector writes `artifacts[]` into rows inside the session's transaction,
520
+ * so an object here (csb-audit-1#100 carried `{ claude_half, external_half,
521
+ * repair_brief }`) rolled back the whole session and the dashboard drew
522
+ * nothing for it. Pinning only this field is deliberate: the full result
523
+ * schema is not enforced on the event, because real logs carry many
524
+ * hand-written result payloads that no reader rehydrates -- refusing them
525
+ * would refuse history for a shape nothing reads.
526
+ */
527
+ function validateResultArtifactsShape(record) {
528
+ if (record.event_type !== 'task-result-recorded')
529
+ return;
530
+ const payload = record.payload;
531
+ const artifacts = payload?.artifacts;
532
+ if (artifacts === undefined || Array.isArray(artifacts))
533
+ return;
534
+ throw new EventError('events.invalid-typed-payload', 'Event payload for "task-result-recorded" carries an `artifacts` that is not an array. The projector iterates this list to write artifact rows, so any other shape becomes a crash that rolls back the whole session rather than an error here.', {
535
+ event_type: record.event_type,
536
+ schema: 'result',
537
+ errors: [{ path: '/artifacts', message: `must be an array, got ${typeof artifacts}` }],
538
+ });
539
+ }
507
540
  function validateTypedPayload(schemas, taxonomy, record) {
541
+ validateResultArtifactsShape(record);
508
542
  const schemaName = TYPED_PAYLOAD_SCHEMAS[record.event_type];
509
543
  if (schemaName === undefined)
510
544
  return;
@@ -0,0 +1,367 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { SmithError } from './errors.js';
5
+ import { AGENTS_DIR, REPO_ROOT } from './paths.js';
6
+ import { loadGuardrailPolicy } from './policy.js';
7
+ import { loadTaxonomy } from './taxonomy.js';
8
+ /**
9
+ * The worker harness port: *how* a turn runs, named at last.
10
+ *
11
+ * Every worker turn this factory has ever dispatched ran under one harness —
12
+ * Claude Code's `Agent` tool — and nothing anywhere says so. It is true by
13
+ * accident of deployment: the orchestrator is itself a Claude Code session, so
14
+ * the only thing it can spawn is another one. `dispatch_decision` records the
15
+ * role, the provider, the model tier and the model (P9-23), which is to say it
16
+ * records *who answered* and with *what weight*, and never *what ran the
17
+ * turn*. The two are independent axes and the factory has been folding them
18
+ * into one:
19
+ *
20
+ * - **provider** — taxonomy.yml `provider: [claude, codex, deepseek]` — is
21
+ * the vendor whose model produced the words.
22
+ * - **harness** — this module — is the program that held the session: gave
23
+ * the agent its tools, its working directory, its turn limit.
24
+ *
25
+ * `claude-code` + `claude` is the only pair that exists today. `codex-cli` +
26
+ * `codex` is the pair that makes the distinction load-bearing, and the point
27
+ * of writing the port before that pair exists is that the second harness
28
+ * should be a config entry and an invocation shape, not a rewrite of dispatch.
29
+ *
30
+ * ## This module renders an invocation. It never runs one.
31
+ *
32
+ * Architecture §18 rule 3 — "nothing that observes may dispatch". `smith` is
33
+ * an observer: it reads logs, folds them, and prints. If `smith harness plan`
34
+ * spawned the process it describes, the CLI would close its own loop, and
35
+ * every check that reads a `dispatch_decision` as evidence that a separate
36
+ * session ran would be reading evidence `smith` produced about itself.
37
+ *
38
+ * So `planWorkerTurn()` returns a `WorkerInvocation` — a description, in JSON,
39
+ * of a process someone else starts. The orchestrator session (or a runner that
40
+ * is not Claude Code at all) reads it and acts. That separation is the whole
41
+ * reason this is a port rather than a spawner.
42
+ *
43
+ * ## The default policy lives here, in code, on purpose
44
+ *
45
+ * There is no `factory/policies/harness.yml`, and this module does not name
46
+ * one. Two reasons, both checkable:
47
+ *
48
+ * - Writing a policy file is the operator's decision, not a side effect of
49
+ * adding a seam. The built-in policy below describes what the factory
50
+ * already does; it invents no capability.
51
+ * - paths.test.ts holds `factory/policies/` and `paths.ts` to each other in
52
+ * both directions — a constant with no file on disk fails the build. A
53
+ * path constant for a file we are not shipping would be exactly that.
54
+ *
55
+ * An operator who wants a second harness writes a YAML file wherever they like
56
+ * and names it: `--policy <file>`, the same shape `stack show` already uses.
57
+ * Absent that flag the built-in policy answers, which is the honest reading of
58
+ * a factory that has one harness.
59
+ *
60
+ * ## A cli harness is outside the trust boundary; a judge gets no worktree
61
+ *
62
+ * `providers/types.ts` states the rule for external judge transports: they are
63
+ * handed nothing but `prompt` — "no worktree paths, credentials beyond its own
64
+ * key, or anything callable". A `cli` harness is the same kind of thing one
65
+ * step over: a process this factory starts, on the far side of its own memory,
66
+ * holding whatever the command line hands it. So the same boundary applies,
67
+ * and `planWorkerTurn()` refuses to render a `cli` invocation that would put a
68
+ * worktree path in a judge role's hands (§18 rule 5, "Judges read; they never
69
+ * gain write access").
70
+ *
71
+ * An `in-process` harness is inside the boundary: a reviewer subagent does
72
+ * read the worktree, under a lease `smith sandbox open` holds for exactly as
73
+ * long as the verdict takes. The invocation says so — `sandboxRequired` is the
74
+ * obligation, stated where the caller will see it rather than left to the
75
+ * playbook to remember.
76
+ *
77
+ * Which roles are judges is not a list this file keeps. It is
78
+ * guardrails.yml's `judge_sandbox.roles` and `role_write_scopes.scopes`, read
79
+ * through policy.ts, which already refuses a role declared as both. A second
80
+ * hand-written copy here would be a copy that drifts, and the direction it
81
+ * would drift in is a judge quietly reclassified as a worker.
82
+ *
83
+ * ## Env is an allowlist of names. Values never pass through here.
84
+ *
85
+ * A harness that shells out needs credentials, and a rendered invocation is
86
+ * printed as JSON to a terminal and into logs. So the invocation carries the
87
+ * *names* a harness may read from the environment and never their values: the
88
+ * runner applies the allowlist against its own `process.env`. Naming a path
89
+ * and never a value is the same rule every artifact in this factory writes
90
+ * under.
91
+ */
92
+ export class HarnessError extends SmithError {
93
+ }
94
+ /** The policy shape this module parses. Bumped when the YAML shape changes. */
95
+ export const HARNESS_POLICY_VERSION = 1;
96
+ /**
97
+ * What a rendered invocation is allowed to interpolate. Closed on purpose: a
98
+ * typo'd `{worktre}` in an args template would otherwise reach the runner as a
99
+ * literal argument, and a harness would start in the wrong directory rather
100
+ * than fail to start.
101
+ */
102
+ const PLACEHOLDERS = ['prompt_file', 'worktree', 'role', 'task'];
103
+ const PLACEHOLDER_RE = /\{([a-z_]+)\}/g;
104
+ /**
105
+ * Today's factory, written down.
106
+ *
107
+ * One harness, serving every role, starting no program — because the Claude
108
+ * Code `Agent` tool is a call inside the orchestrator's own session, not an
109
+ * exec. `roles: []` rather than the fifteen taxonomy values: the constraint
110
+ * that actually binds is whether `.claude/agents/<role>.md` exists, and
111
+ * `planWorkerTurn()` checks that against the directory rather than against a
112
+ * list here that would go stale the next time a role is added.
113
+ */
114
+ export const BUILT_IN_HARNESS_POLICY = Object.freeze({
115
+ version: HARNESS_POLICY_VERSION,
116
+ defaultHarness: 'claude-code',
117
+ harnesses: Object.freeze([
118
+ Object.freeze({
119
+ name: 'claude-code',
120
+ kind: 'in-process',
121
+ roles: Object.freeze([]),
122
+ command: null,
123
+ args: Object.freeze([]),
124
+ envAllowlist: Object.freeze([]),
125
+ }),
126
+ ]),
127
+ source: 'built-in',
128
+ });
129
+ function invalid(message, details) {
130
+ return new HarnessError('harness.invalid-policy', message, details);
131
+ }
132
+ function requireStringArray(raw, key) {
133
+ if (raw === undefined || raw === null)
134
+ return [];
135
+ if (!Array.isArray(raw) || raw.some((v) => typeof v !== 'string')) {
136
+ throw invalid(`harness policy ${key} is not a list of strings.`);
137
+ }
138
+ return raw;
139
+ }
140
+ /** Every placeholder an args template names, in order of appearance. */
141
+ function placeholdersIn(text) {
142
+ return [...text.matchAll(PLACEHOLDER_RE)].map((m) => m[1]);
143
+ }
144
+ export function parseHarnessPolicy(yamlText) {
145
+ const doc = (parseYaml(yamlText) ?? {});
146
+ if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
147
+ throw invalid('harness policy is not a mapping.');
148
+ }
149
+ const version = doc.version ?? HARNESS_POLICY_VERSION;
150
+ if (typeof version !== 'number' || !Number.isInteger(version)) {
151
+ throw invalid('harness policy `version` is not an integer.');
152
+ }
153
+ const rawHarnesses = doc.harnesses;
154
+ if (!Array.isArray(rawHarnesses) || rawHarnesses.length === 0) {
155
+ throw invalid('harness policy declares no `harnesses`. A file that names none says less than shipping no file at all, which is why the built-in policy is what answers when no --policy is given.');
156
+ }
157
+ const harnesses = [];
158
+ for (const [index, entry] of rawHarnesses.entries()) {
159
+ const key = `harnesses[${index}]`;
160
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
161
+ throw invalid(`harness policy ${key} is not a mapping.`);
162
+ }
163
+ const item = entry;
164
+ const name = item.name;
165
+ if (typeof name !== 'string' || name === '') {
166
+ throw invalid(`harness policy ${key} is missing \`name\`.`);
167
+ }
168
+ if (harnesses.some((h) => h.name === name)) {
169
+ throw invalid(`harness policy declares "${name}" twice.`, { harness: name });
170
+ }
171
+ const kind = item.kind;
172
+ if (kind !== 'in-process' && kind !== 'cli') {
173
+ throw invalid(`harness policy ${key} has kind "${String(kind)}"; it is one of in-process, cli.`, { harness: name });
174
+ }
175
+ const args = requireStringArray(item.args, `${key}.args`);
176
+ const command = item.command ?? null;
177
+ if (kind === 'cli') {
178
+ if (typeof command !== 'string' || command === '') {
179
+ throw invalid(`harness policy ${key} is a cli harness with no \`command\`, so nothing would start.`, { harness: name });
180
+ }
181
+ }
182
+ else if (command !== null) {
183
+ // An in-process harness runs inside the caller's session. A command here
184
+ // would be a line nothing executes, read by the next person as a
185
+ // promise the port does not keep.
186
+ throw invalid(`harness policy ${key} is in-process but names a \`command\`; an in-process harness starts no program.`, { harness: name });
187
+ }
188
+ for (const arg of args) {
189
+ for (const found of placeholdersIn(arg)) {
190
+ if (!PLACEHOLDERS.includes(found)) {
191
+ throw invalid(`harness policy ${key}.args names {${found}}, which no invocation substitutes. Known placeholders: ${PLACEHOLDERS.map((p) => `{${p}}`).join(', ')}.`, { harness: name, placeholder: found });
192
+ }
193
+ }
194
+ }
195
+ harnesses.push({
196
+ name,
197
+ kind,
198
+ roles: requireStringArray(item.roles, `${key}.roles`),
199
+ command: kind === 'cli' ? command : null,
200
+ args,
201
+ envAllowlist: requireStringArray(item.env, `${key}.env`),
202
+ });
203
+ }
204
+ const declaredDefault = doc.default;
205
+ const defaultHarness = declaredDefault === undefined || declaredDefault === null
206
+ ? harnesses[0].name
207
+ : declaredDefault;
208
+ if (typeof defaultHarness !== 'string' || defaultHarness === '') {
209
+ throw invalid('harness policy `default` is not a harness name.');
210
+ }
211
+ if (!harnesses.some((h) => h.name === defaultHarness)) {
212
+ throw invalid(`harness policy names "${defaultHarness}" as the default, and declares no harness by that name.`, { harness: defaultHarness });
213
+ }
214
+ return { version, defaultHarness, harnesses, source: 'file' };
215
+ }
216
+ /**
217
+ * The built-in policy, or the file the caller named.
218
+ *
219
+ * No default path: see the header. An absent `filePath` is not a missing file,
220
+ * it is the statement that this factory runs one harness.
221
+ */
222
+ export function loadHarnessPolicy(filePath) {
223
+ if (filePath === undefined)
224
+ return BUILT_IN_HARNESS_POLICY;
225
+ if (!existsSync(filePath)) {
226
+ throw new HarnessError('harness.policy-not-found', `No harness policy at ${filePath}. Omit --policy to use the built-in one.`, { filePath });
227
+ }
228
+ return parseHarnessPolicy(readFileSync(filePath, 'utf8'));
229
+ }
230
+ export function roleAccess(role, guardrails = loadGuardrailPolicy()) {
231
+ if (guardrails.judgeSandbox.roles.includes(role))
232
+ return 'judge';
233
+ if (guardrails.roleWriteScopes.some((s) => s.role === role))
234
+ return 'write-scoped';
235
+ return 'worker';
236
+ }
237
+ /** Where a role's prompt template lives, and whether it is there. */
238
+ function templateFor(role, agentsDir) {
239
+ const file = path.join(agentsDir, `${role}.md`);
240
+ return { file, exists: existsSync(file) };
241
+ }
242
+ function serves(harness, role) {
243
+ return harness.roles.length === 0 || harness.roles.includes(role);
244
+ }
245
+ function substitute(template, values, harness) {
246
+ return template.replace(PLACEHOLDER_RE, (_match, token) => {
247
+ const value = values[token];
248
+ if (value === undefined || value === null || value === '') {
249
+ throw new HarnessError('harness.missing-substitution', `Harness "${harness}" interpolates {${token}}, and this request carries no ${token.replace('_', ' ')}.`, { harness, placeholder: token });
250
+ }
251
+ return value;
252
+ });
253
+ }
254
+ /**
255
+ * Describe the process that would run this turn. Start nothing.
256
+ *
257
+ * Every refusal below is a fact the caller could not have checked cheaply:
258
+ * whether the role exists in the taxonomy, whether a template ships for it,
259
+ * whether the harness serves it, and whether handing it a worktree would cross
260
+ * the judge boundary. A runner that skips this call is not faster — it is
261
+ * making the same four decisions with none of them written down.
262
+ */
263
+ export function planWorkerTurn(request, options = {}) {
264
+ const policy = options.policy ?? BUILT_IN_HARNESS_POLICY;
265
+ const guardrails = options.guardrails ?? loadGuardrailPolicy();
266
+ const taxonomy = options.taxonomy ?? loadTaxonomy();
267
+ const agentsDir = options.agentsDir ?? AGENTS_DIR;
268
+ const { role, taskId } = request;
269
+ const agentRoles = taxonomy.dimensions.agent ?? [];
270
+ if (!agentRoles.includes(role)) {
271
+ throw new HarnessError('harness.unknown-role', `"${role}" is not a taxonomy \`agent\` value, so no dispatch about it could be written even if the turn ran.`, { role });
272
+ }
273
+ if (taskId === undefined || taskId === '') {
274
+ throw new HarnessError('harness.missing-task', 'A worker turn is about a task; the invocation carries its id so the dispatch the caller writes can name the same one.', { role });
275
+ }
276
+ if (request.promptFile === undefined || request.promptFile === '') {
277
+ throw new HarnessError('harness.missing-prompt', 'A worker turn needs a prompt file. This port renders invocations, not prompts — write the prompt first (`smith prompt record`), then plan the turn that carries it.', { role });
278
+ }
279
+ const name = request.harness ?? policy.defaultHarness;
280
+ const harness = policy.harnesses.find((h) => h.name === name);
281
+ if (harness === undefined) {
282
+ throw new HarnessError('harness.unknown', `No harness named "${name}". This policy declares: ${policy.harnesses.map((h) => h.name).join(', ')}.`, { harness: name, declared: policy.harnesses.map((h) => h.name) });
283
+ }
284
+ if (!serves(harness, role)) {
285
+ throw new HarnessError('harness.role-not-served', `Harness "${harness.name}" serves ${harness.roles.join(', ')}, and not ${role}.`, { harness: harness.name, role });
286
+ }
287
+ const worktree = request.worktree ?? null;
288
+ const access = roleAccess(role, guardrails);
289
+ const base = {
290
+ harness: harness.name,
291
+ role,
292
+ taskId,
293
+ access,
294
+ promptFile: request.promptFile,
295
+ worktree,
296
+ sandboxRequired: access === 'judge' && worktree !== null,
297
+ };
298
+ if (harness.kind === 'in-process') {
299
+ const template = templateFor(role, agentsDir);
300
+ if (!template.exists) {
301
+ // D-191's reading, applied to the harness rather than to a grant: a
302
+ // role with no template reaches no agent. `operator` is the standing
303
+ // case — taxonomy.yml carries it as a `found_by` for a defect a person
304
+ // read off the code, and it is never dispatched.
305
+ throw new HarnessError('harness.no-template', `${role} ships no template at ${path.relative(REPO_ROOT, template.file)}, so an in-process harness has no agent to start.`, { role, template: path.relative(REPO_ROOT, template.file) });
306
+ }
307
+ return {
308
+ ...base,
309
+ kind: 'in-process',
310
+ subagentType: role,
311
+ template: path.relative(REPO_ROOT, template.file),
312
+ };
313
+ }
314
+ if (access === 'judge' && worktree !== null) {
315
+ // §18 rule 5, one step out from providers/types.ts. An external process
316
+ // holding a worktree path holds write access to it; no lease this side of
317
+ // the boundary can take that back.
318
+ throw new HarnessError('harness.judge-worktree', `Harness "${harness.name}" runs a separate program, and ${role} is a judge role. A judge outside this process gets the prompt and nothing else — a worktree path it holds is write access no sandbox lease can revoke (architecture §18 rule 5).`, { harness: harness.name, role });
319
+ }
320
+ const values = {
321
+ prompt_file: request.promptFile,
322
+ worktree,
323
+ role,
324
+ task: taskId,
325
+ };
326
+ return {
327
+ ...base,
328
+ kind: 'cli',
329
+ command: harness.command,
330
+ args: harness.args.map((arg) => substitute(arg, values, harness.name)),
331
+ cwd: worktree,
332
+ envAllowlist: harness.envAllowlist,
333
+ };
334
+ }
335
+ /**
336
+ * What `smith harness list` prints: not the policy as written, but the policy
337
+ * as it resolves. A harness whose `roles` is empty reads as "all" in YAML and
338
+ * means "every role that ships a template" in practice, and the difference
339
+ * between those two is the kind of thing an operator finds out at 2am
340
+ * otherwise.
341
+ */
342
+ export function summarizeHarnesses(options = {}) {
343
+ const policy = options.policy ?? BUILT_IN_HARNESS_POLICY;
344
+ const taxonomy = options.taxonomy ?? loadTaxonomy();
345
+ const guardrails = options.guardrails ?? loadGuardrailPolicy();
346
+ const agentsDir = options.agentsDir ?? AGENTS_DIR;
347
+ const agentRoles = taxonomy.dimensions.agent ?? [];
348
+ return {
349
+ source: policy.source,
350
+ version: policy.version,
351
+ defaultHarness: policy.defaultHarness,
352
+ harnesses: policy.harnesses.map((harness) => ({
353
+ name: harness.name,
354
+ kind: harness.kind,
355
+ isDefault: harness.name === policy.defaultHarness,
356
+ roles: agentRoles.filter((role) => {
357
+ if (!serves(harness, role))
358
+ return false;
359
+ if (harness.kind === 'in-process')
360
+ return templateFor(role, agentsDir).exists;
361
+ return roleAccess(role, guardrails) !== 'judge';
362
+ }),
363
+ command: harness.command,
364
+ envAllowlist: harness.envAllowlist,
365
+ })),
366
+ };
367
+ }