@juzser/blacksmith 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +16 -9
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +45 -31
  17. package/.claude/skills/bs/dispatch.md +133 -47
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +57 -16
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +190 -52
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +143 -50
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +70 -13
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +245 -20
  35. package/factory/orchestrator/dist/autonomy.js +6 -2
  36. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  37. package/factory/orchestrator/dist/budgets.js +302 -84
  38. package/factory/orchestrator/dist/claims.js +9 -19
  39. package/factory/orchestrator/dist/cli.js +1087 -116
  40. package/factory/orchestrator/dist/cliName.js +27 -0
  41. package/factory/orchestrator/dist/crossFinding.js +20 -0
  42. package/factory/orchestrator/dist/crosscheck.js +136 -8
  43. package/factory/orchestrator/dist/daemon.js +352 -67
  44. package/factory/orchestrator/dist/db/projector.js +327 -54
  45. package/factory/orchestrator/dist/db/queries.js +1831 -94
  46. package/factory/orchestrator/dist/db/schema.js +57 -4
  47. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  48. package/factory/orchestrator/dist/delegation.js +86 -19
  49. package/factory/orchestrator/dist/diffstat.js +15 -8
  50. package/factory/orchestrator/dist/dispatchLint.js +108 -0
  51. package/factory/orchestrator/dist/effort.js +14 -2
  52. package/factory/orchestrator/dist/effortTiers.js +13 -0
  53. package/factory/orchestrator/dist/env.js +26 -0
  54. package/factory/orchestrator/dist/epic.js +494 -42
  55. package/factory/orchestrator/dist/epicBudget.js +93 -0
  56. package/factory/orchestrator/dist/epicWidth.js +22 -5
  57. package/factory/orchestrator/dist/errorIssues.js +182 -11
  58. package/factory/orchestrator/dist/escalation.js +2 -2
  59. package/factory/orchestrator/dist/events.js +164 -14
  60. package/factory/orchestrator/dist/feedback.js +169 -0
  61. package/factory/orchestrator/dist/findingContext.js +26 -0
  62. package/factory/orchestrator/dist/findings.js +108 -14
  63. package/factory/orchestrator/dist/gate.js +120 -10
  64. package/factory/orchestrator/dist/gh.js +19 -2
  65. package/factory/orchestrator/dist/git.js +20 -2
  66. package/factory/orchestrator/dist/goalCheck.js +2 -2
  67. package/factory/orchestrator/dist/harness.js +25 -17
  68. package/factory/orchestrator/dist/hookDecision.js +411 -39
  69. package/factory/orchestrator/dist/integration.js +20 -8
  70. package/factory/orchestrator/dist/issueReporter.js +81 -16
  71. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  72. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  73. package/factory/orchestrator/dist/judgeStopHook.js +239 -0
  74. package/factory/orchestrator/dist/judges.js +419 -21
  75. package/factory/orchestrator/dist/lessons.js +190 -11
  76. package/factory/orchestrator/dist/logCache.js +291 -0
  77. package/factory/orchestrator/dist/mcp.js +6 -6
  78. package/factory/orchestrator/dist/paths.js +20 -7
  79. package/factory/orchestrator/dist/plan.js +399 -33
  80. package/factory/orchestrator/dist/planQuorum.js +5 -2
  81. package/factory/orchestrator/dist/policy.js +1275 -30
  82. package/factory/orchestrator/dist/projectName.js +25 -0
  83. package/factory/orchestrator/dist/projects.js +12 -1
  84. package/factory/orchestrator/dist/promptCapture.js +306 -0
  85. package/factory/orchestrator/dist/promptHook.js +33 -0
  86. package/factory/orchestrator/dist/promptImport.js +281 -0
  87. package/factory/orchestrator/dist/prompts.js +13 -0
  88. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  89. package/factory/orchestrator/dist/providers/types.js +1 -1
  90. package/factory/orchestrator/dist/queue.js +471 -30
  91. package/factory/orchestrator/dist/quorum.js +18 -5
  92. package/factory/orchestrator/dist/results.js +25 -7
  93. package/factory/orchestrator/dist/roadmap.js +10 -5
  94. package/factory/orchestrator/dist/run-cli.js +15 -10
  95. package/factory/orchestrator/dist/runner.js +2 -2
  96. package/factory/orchestrator/dist/scheduler.js +3 -3
  97. package/factory/orchestrator/dist/schemas.js +56 -8
  98. package/factory/orchestrator/dist/severity.js +8 -4
  99. package/factory/orchestrator/dist/spec.js +246 -12
  100. package/factory/orchestrator/dist/specChange.js +13 -9
  101. package/factory/orchestrator/dist/stack.js +6 -6
  102. package/factory/orchestrator/dist/storeRoot.js +90 -0
  103. package/factory/orchestrator/dist/symbols.js +261 -6
  104. package/factory/orchestrator/dist/taskEvents.js +192 -31
  105. package/factory/orchestrator/dist/taskStatus.js +14 -0
  106. package/factory/orchestrator/dist/taxonomy.js +11 -0
  107. package/factory/orchestrator/dist/testerAudit.js +31 -12
  108. package/factory/orchestrator/dist/testgate.js +20 -16
  109. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  110. package/factory/orchestrator/dist/usage.js +113 -48
  111. package/factory/orchestrator/dist/waivers.js +99 -12
  112. package/factory/orchestrator/dist/waveBudget.js +2 -0
  113. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  114. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  115. package/factory/orchestrator/dist/workroot.js +1 -1
  116. package/factory/orchestrator/dist/worktree.js +76 -21
  117. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  118. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  119. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  120. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  121. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  122. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  123. package/factory/policies/budgets.yml +123 -33
  124. package/factory/policies/crosscheck.yml +51 -13
  125. package/factory/policies/delegation.yml +3 -3
  126. package/factory/policies/effort.yml +18 -10
  127. package/factory/policies/guardrails.yml +8 -6
  128. package/factory/policies/harness.yml +5 -5
  129. package/factory/policies/lessons.md +53 -2
  130. package/factory/policies/scheduler.yml +4 -4
  131. package/factory/policies/sensitive-paths.yml +1 -1
  132. package/factory/policies/severity.yml +13 -1
  133. package/factory/policies/stack.yml +6 -6
  134. package/factory/policies/taxonomy.yml +9 -3
  135. package/factory/policies/worktree.yml +15 -9
  136. package/factory/specs/roadmap.md +27 -3
  137. package/factory/specs/schema/event.schema.json +8 -1
  138. package/factory/specs/schema/result.schema.json +21 -8
  139. package/factory/specs/schema/task-spec.schema.json +12 -0
  140. package/package.json +16 -12
  141. package/.claude/agents/uiux.md +0 -133
@@ -19,7 +19,7 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
19
19
  * operator far more often than any §8 dimension, and forcing a taxonomy PR
20
20
  * per new project would defeat the point of a multi-project hub. Nullable
21
21
  * for migration safety (every event logged before this column existed has
22
- * no value here); queries.ts's helpers COALESCE it to 'black-smith' so old
22
+ * no value here); queries.ts's helpers COALESCE it to 'blacksmith' so old
23
23
  * rows and new single-project fixtures both resolve to the same default
24
24
  * project rather than an ungrouped null bucket.
25
25
  */
@@ -40,12 +40,19 @@ export const eventsRaw = sqliteTable('events_raw', {
40
40
  // agent one of the same event_type. Nullable for migration safety —
41
41
  // events logged before this column existed simply can't be attributed.
42
42
  actor: text('actor'),
43
+ // The CLI session that wrote the event (EventRecord.cli_session_id,
44
+ // architecture §7), so a live CLI session can be linked to the factory
45
+ // sessions it drives. Nullable: absent outside a CLI session and on every
46
+ // event logged before the stamp existed. A link, never evidence -- see
47
+ // events.ts's cliStamp.
48
+ cliSessionId: text('cli_session_id'),
43
49
  }, (t) => [
44
50
  index('events_raw_session_idx').on(t.sessionId),
45
51
  index('events_raw_task_idx').on(t.taskId),
46
52
  index('events_raw_type_idx').on(t.eventType),
47
53
  index('events_raw_ts_idx').on(t.ts),
48
54
  index('events_raw_project_idx').on(t.project),
55
+ index('events_raw_cli_session_idx').on(t.cliSessionId),
49
56
  ]);
50
57
  /** One row per session, folded from its session-start event + last-seen ts. */
51
58
  export const sessions = sqliteTable('sessions', {
@@ -128,12 +135,30 @@ export const tasks = sqliteTable('tasks', {
128
135
  taskStatus: text('task_status').notNull(),
129
136
  planVersion: integer('plan_version'),
130
137
  objective: text('objective'),
138
+ title: text('title'), // DS3 — optional human-facing title (task-spec field)
139
+ summary: text('summary'), // DS3 — optional human-facing summary (task-spec field)
131
140
  claims: text('claims'), // JSON array
132
141
  budgetTokens: integer('budget_tokens'),
133
142
  branch: text('branch'),
134
143
  createdAt: text('created_at').notNull(),
135
144
  updatedAt: text('updated_at').notNull(),
136
145
  project: text('project'), // Phase 6b — see eventsRaw's project comment above.
146
+ /**
147
+ * DS4 S5a round 2 — the `ts` of the event that FIRST moved this task into
148
+ * `TERMINAL_TASK_STATUSES` (wave-merged / task-superseded / an
149
+ * `error-logged` naming it `escalated` / an auditor's judge-reported or
150
+ * task-result-recorded, since an audit axis never joins a wave), null
151
+ * while the task is still open. `updatedAt` keeps moving after a task is
152
+ * done — a later `error-logged` naming an already-terminal task (e.g.
153
+ * from a following lessons/audit flow) rewrites it via `touch()` without
154
+ * reopening the task — so it is the wrong column for "when did this
155
+ * finish" (db/queries.ts's taskDateRange). Written once by
156
+ * db/projector.ts's foldTasks() and left alone after that, with one
157
+ * named exception: a later auditor dispatch_decision for a completed id
158
+ * (an audit axis re-run) resets this to null, since the row is open
159
+ * again.
160
+ */
161
+ terminalAt: text('terminal_at'),
137
162
  }, (t) => [
138
163
  index('tasks_session_idx').on(t.sessionId),
139
164
  index('tasks_epic_idx').on(t.epicId),
@@ -287,14 +312,14 @@ export const milestones = sqliteTable('milestones', {
287
312
  goal: text('goal'),
288
313
  epicIds: text('epic_ids').notNull(), // JSON array
289
314
  // Phase 6b — see eventsRaw's project comment above. roadmap.ts defaults
290
- // an unspecified `- project:` bullet to 'black-smith' at parse time (not
315
+ // an unspecified `- project:` bullet to 'blacksmith' at parse time (not
291
316
  // left null here), since factory/specs/roadmap.md (ROADMAP_PATH) is a
292
317
  // hand-authored declaration, not an event replay — there is no "old row"
293
318
  // to migrate.
294
- project: text('project').notNull().default('black-smith'),
319
+ project: text('project').notNull().default('blacksmith'),
295
320
  // Phase 10 -- 'factory' | 'dogfood' | 'product' (roadmap.ts's
296
321
  // MilestoneKind). Defaults to 'factory' here for the same reason project
297
- // defaults to 'black-smith': the column default has to agree with what
322
+ // defaults to 'blacksmith': the column default has to agree with what
298
323
  // roadmap.ts resolves for a milestone that declares neither bullet, and
299
324
  // for those two that answer is the factory's own.
300
325
  kind: text('kind').notNull().default('factory'),
@@ -334,3 +359,31 @@ export const artifacts = sqliteTable('artifacts', {
334
359
  path: text('path').notNull(),
335
360
  description: text('description'),
336
361
  }, (t) => [index('artifacts_session_idx').on(t.sessionId), index('artifacts_task_idx').on(t.taskId)]);
362
+ /**
363
+ * Operator feedback left mid-run on a task, from the dashboard or from
364
+ * GitHub (feedback.ts). A GLOBAL fold, the same shape as `lessons` and
365
+ * `findings` above and for the same reason (D-199/D-200): a comment can be
366
+ * recorded in one session and resolved in a later, continuing one, so the
367
+ * table's primary key is the feedback id, not (session, feedback) — one
368
+ * global key, one global fold, rewritten whole by
369
+ * db/projector.ts's projectOperatorFeedback().
370
+ */
371
+ export const operatorFeedback = sqliteTable('operator_feedback', {
372
+ id: text('id').primaryKey(),
373
+ taskId: text('task_id').notNull(),
374
+ sessionId: text('session_id').notNull(),
375
+ body: text('body').notNull(),
376
+ kind: text('kind').notNull(),
377
+ source: text('source').notNull(),
378
+ externalId: text('external_id'),
379
+ author: text('author'),
380
+ recordedAt: text('recorded_at').notNull(),
381
+ recordedEventId: text('recorded_event_id').notNull(),
382
+ resolvedAt: text('resolved_at'),
383
+ resolution: text('resolution'),
384
+ followUpTaskId: text('follow_up_task_id'),
385
+ }, (t) => [
386
+ index('operator_feedback_task_idx').on(t.taskId),
387
+ index('operator_feedback_session_idx').on(t.sessionId),
388
+ index('operator_feedback_external_idx').on(t.externalId),
389
+ ]);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The `Declared artifact: <path>` line a judge dispatch carries in its prompt.
3
+ *
4
+ * A leaf module with no local imports, so the PreToolUse hook (which must stay
5
+ * free of the database layer), `judgeStopHook.ts`, and `dispatchLint.ts` all
6
+ * read the line with one parser: what the dispatch guard accepts is exactly
7
+ * what judge-stop later reads. `dispatchLint.ts` re-exports these.
8
+ */
9
+ const DECLARED_ARTIFACT_LINE = /^Declared artifact:[ \t]*(\S+)[ \t]*$/m;
10
+ /** `Declared artifact: <path>` in a dispatch prompt, or null when the line is not there. */
11
+ export function parseDeclaredArtifactLine(prompt) {
12
+ const match = DECLARED_ARTIFACT_LINE.exec(prompt);
13
+ return match?.[1] ?? null;
14
+ }
15
+ /** The line a dispatch is expected to carry verbatim, given the ledger's declared path. */
16
+ export function formatDeclaredArtifactLine(artifactPath) {
17
+ return `Declared artifact: ${artifactPath}`;
18
+ }
@@ -26,6 +26,7 @@
26
26
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
27
27
  import path from 'node:path';
28
28
  import { parse as parseYaml } from 'yaml';
29
+ import { AGENT_PREFIX, agentNameFor, roleOfAgentType, roleOfTemplateFile, templateFileFor, } from './agentNames.js';
29
30
  import { DISPATCH_EVENT_TYPE, ERROR_EVENT_TYPE, TASK_RESULT_EVENT_TYPE, } from './agents-registry.js';
30
31
  import { SmithError } from './errors.js';
31
32
  import { ROOT_EVENT_TYPE } from './events.js';
@@ -84,11 +85,11 @@ export function loadDelegationPolicy(filePath = DELEGATION_POLICY_PATH) {
84
85
  * because a scoped grant contains commas of its own.
85
86
  */
86
87
  const AGENT_TOOL = /\bAgent\b/;
87
- /** The roles a scoped grant names, e.g. `Agent(coder, tester)`. */
88
+ /** The agents a scoped grant names, e.g. `Agent(bs-coder, bs-tester)`. */
88
89
  const AGENT_SCOPE = /\bAgent\s*\(([^)]*)\)/;
89
90
  /** The raw `tools:` line of a role template's frontmatter. Null when there is no template. */
90
91
  function templateTools(dir, role) {
91
- const file = path.join(dir, `${role}.md`);
92
+ const file = path.join(dir, templateFileFor(role));
92
93
  if (!existsSync(file))
93
94
  return null;
94
95
  return toolsOf(readFileSync(file, 'utf8'));
@@ -135,7 +136,7 @@ export function checkDelegationGrants(policy, options) {
135
136
  }
136
137
  for (const pair of options.isolationPairs) {
137
138
  if (pair.worker === grant.role && grant.mayDispatch.includes(pair.auditor)) {
138
- problems.push(`\`${grant.role}\` may dispatch \`${pair.auditor}\`, its own auditor under crosscheck.yml \`role_isolation\`. The worker would choose and prompt its grader, and \`smith tester check\` would report the isolation it was reading evidence for.`);
139
+ problems.push(`\`${grant.role}\` may dispatch \`${pair.auditor}\`, its own auditor under crosscheck.yml \`role_isolation\`. The worker would choose and prompt its grader, and \`bs tester check\` would report the isolation it was reading evidence for.`);
139
140
  }
140
141
  }
141
142
  for (const pair of options.asymmetricPairs) {
@@ -145,10 +146,10 @@ export function checkDelegationGrants(policy, options) {
145
146
  }
146
147
  const tools = templateTools(dir, grant.role);
147
148
  if (tools === null) {
148
- problems.push(`\`${grant.role}\` holds a grant but ships no template at ${path.join(path.basename(dir), `${grant.role}.md`)}, so the grant reaches no agent (D-191).`);
149
+ problems.push(`\`${grant.role}\` holds a grant but ships no template at ${path.join(path.basename(dir), templateFileFor(grant.role))}, so the grant reaches no agent (D-191).`);
149
150
  }
150
151
  else if (!holdsAgent(tools)) {
151
- problems.push(`${grant.role}.md does not list \`Agent\` in \`tools\`, so the grant reaches no agent (D-191).`);
152
+ problems.push(`${templateFileFor(grant.role)} does not list \`Agent\` in \`tools\`, so the grant reaches no agent (D-191).`);
152
153
  }
153
154
  else {
154
155
  // A scoped grant is enforced by the harness at dispatch time and this
@@ -156,10 +157,20 @@ export function checkDelegationGrants(policy, options) {
156
157
  // of both: the file an operator reads is not the file that binds.
157
158
  const scope = agentScope(tools);
158
159
  if (scope !== null) {
159
- const declared = [...grant.mayDispatch].sort().join(', ');
160
+ for (const name of scope) {
161
+ if (name.startsWith(AGENT_PREFIX))
162
+ continue;
163
+ // Suggest a name only for a role this grant covers: `general-purpose`
164
+ // maps to itself, and `bs-general-purpose` is no agent either.
165
+ const role = roleOfAgentType(name);
166
+ problems.push(role !== null && grant.mayDispatch.includes(role)
167
+ ? `${templateFileFor(grant.role)} scopes \`Agent\` to name \`${name}\`, which is no agent; use \`${agentNameFor(role)}\`. The harness enforces the agent names, and the bare role no longer exists as one.`
168
+ : `${templateFileFor(grant.role)} scopes \`Agent\` to name \`${name}\`, which is no Blacksmith agent. A scope names only the \`${AGENT_PREFIX}<role>\` agents of the roles delegation.yml grants.`);
169
+ }
170
+ const declared = [...grant.mayDispatch].map(agentNameFor).sort().join(', ');
160
171
  const scoped = [...scope].sort().join(', ');
161
172
  if (declared !== scoped) {
162
- problems.push(`${grant.role}.md scopes \`Agent\` to (${scoped}) while delegation.yml grants (${declared}). The template is what the harness enforces and the policy is what the audits read, so a disagreement is a rule nobody applies.`);
173
+ problems.push(`${templateFileFor(grant.role)} scopes \`Agent\` to (${scoped}) while delegation.yml grants (${declared}). The template is what the harness enforces and the policy is what the audits read, so a disagreement is a rule nobody applies.`);
163
174
  }
164
175
  }
165
176
  }
@@ -178,11 +189,13 @@ export function checkDelegationGrants(policy, options) {
178
189
  // The converse of D-191, and the one that actually widens the topology: a
179
190
  // template may hand itself `Agent` without any grant naming it, and nothing
180
191
  // else in the repo reads the frontmatter closely enough to notice.
181
- const templates = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')) : [];
192
+ const templates = existsSync(dir)
193
+ ? readdirSync(dir).filter((f) => roleOfTemplateFile(f) !== null)
194
+ : [];
182
195
  const granted = new Set(policy.grants.map((g) => g.role));
183
196
  let ungrantedHolders = 0;
184
197
  for (const file of templates) {
185
- const role = path.basename(file, '.md');
198
+ const role = roleOfTemplateFile(file);
186
199
  if (granted.has(role))
187
200
  continue;
188
201
  if (!holdsAgent(toolsOf(readFileSync(path.join(dir, file), 'utf8'))))
@@ -210,19 +223,42 @@ export function checkDelegationGrants(policy, options) {
210
223
  }
211
224
  /**
212
225
  * How a dispatched turn is recorded as over, and which payload field names the
213
- * role that ended it -- testerAudit.ts's table, for its reason. The fallback to
214
- * `actor` is that file's too: the older half of the log names the role there
215
- * and nowhere else, and without it a finished wave-runner reads as one still
216
- * running, which is the difference between `violation` and `unverifiable`.
226
+ * role that ended it -- testerAudit.ts's table, for its reason. Each event type
227
+ * lists every key its role may be spelled under, tried in order, before the
228
+ * fallback to `actor`: that fallback is testerAudit.ts's too -- the older half
229
+ * of the log names the role there and nowhere else, and without it a finished
230
+ * wave-runner reads as one still running, which is the difference between
231
+ * `violation` and `unverifiable`.
232
+ *
233
+ * `error-logged` lists both `agent` (this module's long-standing read, the
234
+ * Result file's own spelling) and `agent_role` (the dispatch's) because a
235
+ * hand-written error event may carry only one of the two, so a role named
236
+ * either way still finds its terminal instead of falling through to
237
+ * whichever actor happened to log the error. agents-registry.ts's fold of
238
+ * the same event type accepts the same two keys, but tries `agent_role`
239
+ * first where this module tries `agent` first -- not symmetric in order,
240
+ * because each reader keeps its own historical key first rather than the two
241
+ * agreeing on one: `agent_role` was already this event type's only key in
242
+ * agents-registry.ts's fold before `agent` was added as a fallback there,
243
+ * same as `agent` was here before `agent_role` was added as this module's.
217
244
  */
218
- const TERMINAL_ROLE_KEY = {
219
- [TASK_RESULT_EVENT_TYPE]: 'agent',
220
- [ERROR_EVENT_TYPE]: 'agent',
245
+ const TERMINAL_ROLE_KEYS = {
246
+ [TASK_RESULT_EVENT_TYPE]: ['agent'],
247
+ [ERROR_EVENT_TYPE]: ['agent', 'agent_role'],
221
248
  };
222
249
  function str(payload, key) {
223
250
  const value = payload[key];
224
251
  return typeof value === 'string' && value.length > 0 ? value : null;
225
252
  }
253
+ /** The first of `keys` that names a non-empty string in `payload`, or null. */
254
+ function firstStr(payload, keys) {
255
+ for (const key of keys) {
256
+ const value = str(payload, key);
257
+ if (value !== null)
258
+ return value;
259
+ }
260
+ return null;
261
+ }
226
262
  function readDispatches(events) {
227
263
  const rows = [];
228
264
  for (const { event_id, record } of events) {
@@ -246,11 +282,11 @@ function readDispatches(events) {
246
282
  function readTerminals(events) {
247
283
  const rows = [];
248
284
  for (const { record } of events) {
249
- const key = TERMINAL_ROLE_KEY[record.event_type];
250
- if (key === undefined)
285
+ const keys = TERMINAL_ROLE_KEYS[record.event_type];
286
+ if (keys === undefined)
251
287
  continue;
252
288
  const payload = (record.payload ?? {});
253
- const role = str(payload, key) ?? record.actor;
289
+ const role = firstStr(payload, keys) ?? record.actor;
254
290
  if (role === null || role.length === 0)
255
291
  continue;
256
292
  rows.push({
@@ -274,6 +310,37 @@ function sessionsOpenedByDispatch(events) {
274
310
  }
275
311
  return opened;
276
312
  }
313
+ /**
314
+ * The role that dispatched this session, if this session is that dispatch's
315
+ * own log -- the answer a write wants before it defaults its `actor` to a
316
+ * placeholder like `system` (issue #218). A session's `session-start` names
317
+ * its entry edge as `causal_parent`; when that edge is a `dispatch_decision`
318
+ * event elsewhere in `events`, this session was opened against it and its
319
+ * owner is the role that dispatch named.
320
+ *
321
+ * Returns null for every session nothing dispatched -- an operator's own
322
+ * top-level session, or a `--continues` split whose entry edge names some
323
+ * other event (P9-7's cross-session edge is not only for delegation) -- so a
324
+ * caller's existing `system` fallback for "no owner" is unchanged.
325
+ *
326
+ * Deliberately reads the event graph only, not delegation.yml: it answers
327
+ * "who dispatched this session" for a dispatch to any role, granted or not.
328
+ * That is wider than `checkDelegationLog`'s rule 3, which flags a mismatch
329
+ * only for sessions a grant opened -- but a wider *default* cannot loosen
330
+ * that check, because an explicit `--actor` always wins over this inference
331
+ * (every caller checks `ctx.actor` first) and a foreign or `system` actor
332
+ * stamped that way is still exactly what rule 3 flags.
333
+ */
334
+ export function sessionOwnerRole(events, sessionId) {
335
+ const root = events.find((e) => e.record.session_id === sessionId && e.record.event_type === ROOT_EVENT_TYPE);
336
+ const parentId = root?.record.causal_parent;
337
+ if (parentId === null || parentId === undefined)
338
+ return null;
339
+ const parent = events.find((e) => e.event_id === parentId);
340
+ if (parent === undefined || parent.record.event_type !== DISPATCH_EVENT_TYPE)
341
+ return null;
342
+ return str((parent.record.payload ?? {}), 'agent_role');
343
+ }
277
344
  /**
278
345
  * Asserts delegation.yml against a lineage's log.
279
346
  *
@@ -96,9 +96,10 @@ const GIT_OUTPUT_MAX_BUFFER = 32 * 1024 * 1024;
96
96
  /**
97
97
  * The declared diff cap is only worth writing down if something measures the
98
98
  * real diff. This does: committed work on a task branch, against the
99
- * integration branch it was cut from (`smith/<epic>/<task-id>` ->
100
- * `smith/<epic>/integration`, the same convention `collectCommittedChanges`
101
- * relies on), or against an explicit `baseRef`.
99
+ * integration branch it was cut from (`<prefix>/<epic>/<task-id>` ->
100
+ * `<prefix>/<epic>/integration`, the same convention `collectCommittedChanges`
101
+ * relies on — `<prefix>` is `bs` or the legacy `smith`, echoed back from the
102
+ * task branch itself), or against an explicit `baseRef`.
102
103
  *
103
104
  * It throws rather than returning zero when it cannot measure. A zero that
104
105
  * means "no diff" and a zero that means "I could not look" are the same number
@@ -140,7 +141,11 @@ export function measureDiff(worktreeDir, options = {}) {
140
141
  }
141
142
  return { baseRef, diffLines, excludedLines, unmeasuredFiles, files };
142
143
  }
143
- const TASK_BRANCH_PATTERN = /^smith\/(?<epic>[^/]+)\/(?<task>[^/]+)$/;
144
+ // Operator decision 2 (bs-rename): a task branch is cut under whichever
145
+ // prefix its integration branch already used — bs/ for a new epic, smith/
146
+ // for one that still integrates there — so the base ref this derives must
147
+ // echo the same prefix back, not hardcode the new one.
148
+ const TASK_BRANCH_PATTERN = /^(?<prefix>smith|bs)\/(?<epic>[^/]+)\/(?<task>[^/]+)$/;
144
149
  function deriveBaseRef(worktreeDir) {
145
150
  let branch;
146
151
  try {
@@ -156,9 +161,11 @@ function deriveBaseRef(worktreeDir) {
156
161
  // directory git has never heard of, and it deserves a code like any other.
157
162
  throw new DiffstatError('diffstat.not-a-git-worktree', `Cannot read the current branch in ${worktreeDir} — it is not a git worktree, or git refused to answer.`, { worktreeDir, cause: err instanceof Error ? err.message : String(err) });
158
163
  }
159
- const epic = TASK_BRANCH_PATTERN.exec(branch)?.groups?.epic;
160
- if (epic === undefined) {
161
- throw new DiffstatError('diffstat.cannot-derive-base-ref', `Branch "${branch}" does not follow smith/<epic>/<task-id>, so there is no integration branch to diff against. Pass an explicit baseRef.`, { branch, worktreeDir });
164
+ const groups = TASK_BRANCH_PATTERN.exec(branch)?.groups;
165
+ const epic = groups?.epic;
166
+ const prefix = groups?.prefix;
167
+ if (epic === undefined || prefix === undefined) {
168
+ throw new DiffstatError('diffstat.cannot-derive-base-ref', `Branch "${branch}" does not follow <smith|bs>/<epic>/<task-id>, so there is no integration branch to diff against. Pass an explicit baseRef.`, { branch, worktreeDir });
162
169
  }
163
- return `smith/${epic}/integration`;
170
+ return `${prefix}/${epic}/integration`;
164
171
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `smith dispatch lint` — check a dispatch prompt against two things a
3
+ * dispatcher can get wrong without any signal until the agent runs:
4
+ *
5
+ * - the turn budget stated in the prompt vs the role template's
6
+ * `maxTurns:`, read fresh off disk (dispatch.md "Carry into the
7
+ * prompt" — a prompt that promises more than the template is a fiction
8
+ * the agent plans against and gets cut in the middle of).
9
+ * - a judge prompt's declared-artifact line vs what the ledger recorded
10
+ * for its `judge dispatch` (dispatch.md "Declare each judge's
11
+ * artifact" — a judge prompt missing that line is undetectable until
12
+ * the report never lands).
13
+ *
14
+ * Both parsers and both comparisons live here so the two checks stay one
15
+ * source of truth for "what does a compliant dispatch prompt say", rather
16
+ * than every dispatcher re-deriving it.
17
+ */
18
+ import path from 'node:path';
19
+ import { readTemplateMaxTurns } from './agentsSync.js';
20
+ import { formatDeclaredArtifactLine, parseDeclaredArtifactLine } from './declaredArtifactLine.js';
21
+ import { JUDGE_ROLES } from './judgeRoles.js';
22
+ import { readJudgeTurns } from './judges.js';
23
+ import { AGENTS_DIR } from './paths.js';
24
+ /**
25
+ * Re-exported, not re-declared (judgeRoles.ts is the canonical source, kept
26
+ * dependency-free so both this module and judges.ts can import it without a
27
+ * cycle -- see that file for the full set and why it is these six). This
28
+ * module still does its own, stricter artifact check on top of the shared
29
+ * set: only these six get a `mismatch`/`missing` verdict, so a coder's
30
+ * dispatch (never carries one) reads `not-applicable` rather than a false
31
+ * `undeclared`.
32
+ */
33
+ export { formatDeclaredArtifactLine, JUDGE_ROLES, parseDeclaredArtifactLine };
34
+ const JUDGE_ROLE_SET = new Set(JUDGE_ROLES);
35
+ const TURN_BUDGET_LINE = /Turn budget:[ \t]*(\S*)/g;
36
+ /**
37
+ * `Turn budget: <n>` in a dispatch prompt — mid-sentence or on its own
38
+ * line, since a dispatch's leading line names the role and the budget in
39
+ * one breath ("Role: coder. Turn budget: 40 ..."). Null for no occurrence,
40
+ * more than one (two numbers is no number), or a value that is not a bare
41
+ * non-negative integer — every one of those is "cannot state a turn count",
42
+ * not "states zero".
43
+ */
44
+ export function parseStatedTurns(prompt) {
45
+ const matches = [...prompt.matchAll(TURN_BUDGET_LINE)];
46
+ if (matches.length !== 1)
47
+ return null;
48
+ const raw = matches[0]?.[1] ?? '';
49
+ return /^[0-9]+$/.test(raw) ? Number(raw) : null;
50
+ }
51
+ function checkTurns(prompt, role, agentsDir) {
52
+ const stated = parseStatedTurns(prompt);
53
+ const template = readTemplateMaxTurns(agentsDir, role) ?? null;
54
+ if (template === null)
55
+ return { status: 'unverifiable', stated, template: null };
56
+ if (stated === null)
57
+ return { status: 'missing', stated: null, template };
58
+ if (stated === template)
59
+ return { status: 'ok', stated, template };
60
+ return { status: stated < template ? 'under' : 'over', stated, template };
61
+ }
62
+ async function checkArtifact(prompt, role, taskId, sessionId, eventOpts) {
63
+ if (!JUDGE_ROLE_SET.has(role)) {
64
+ return { status: 'not-applicable', expected_line: null, declared_line: null };
65
+ }
66
+ const declaredLine = parseDeclaredArtifactLine(prompt);
67
+ const turns = await readJudgeTurns(taskId, { sessionId }, eventOpts);
68
+ const turn = turns.find((t) => t.role === role);
69
+ if (turn === undefined) {
70
+ return { status: 'undeclared', expected_line: null, declared_line: declaredLine };
71
+ }
72
+ const expectedLine = formatDeclaredArtifactLine(turn.declaredArtifact);
73
+ if (declaredLine === null) {
74
+ return { status: 'missing', expected_line: expectedLine, declared_line: null };
75
+ }
76
+ if (!path.isAbsolute(declaredLine)) {
77
+ return { status: 'relative', expected_line: expectedLine, declared_line: declaredLine };
78
+ }
79
+ if (declaredLine === turn.declaredArtifact) {
80
+ return { status: 'ok', expected_line: expectedLine, declared_line: declaredLine };
81
+ }
82
+ return { status: 'mismatch', expected_line: expectedLine, declared_line: declaredLine };
83
+ }
84
+ // Fail closed: every status but "ok" exits non-zero, including "under" (a
85
+ // prompt that understates the template's budget still disagrees with it) and
86
+ // "unverifiable" (an unreadable template is not a pass, exactly like `dispatch
87
+ // check`, `tester check`, `delegation check`, and `escalation check` already
88
+ // treat "cannot tell" as a failure, never a maybe).
89
+ const TURNS_FAIL = new Set(['under', 'over', 'missing', 'unverifiable']);
90
+ const ARTIFACT_FAIL = new Set([
91
+ 'missing',
92
+ 'relative',
93
+ 'mismatch',
94
+ 'undeclared',
95
+ ]);
96
+ /**
97
+ * Lint one dispatch prompt. Never throws on a bad prompt — a bad prompt is
98
+ * exactly the finding this reports — only on a missing task/session (the
99
+ * caller's own mistake, same as every other verb that reads the ledger).
100
+ */
101
+ export async function lintDispatchPrompt(input) {
102
+ const agentsDir = input.agentsDir ?? AGENTS_DIR;
103
+ const eventOpts = input.eventOpts ?? {};
104
+ const turns = checkTurns(input.prompt, input.role, agentsDir);
105
+ const artifact = await checkArtifact(input.prompt, input.role, input.taskId, input.sessionId, eventOpts);
106
+ const exitCode = TURNS_FAIL.has(turns.status) || ARTIFACT_FAIL.has(artifact.status) ? 1 : 0;
107
+ return { turns, artifact, exitCode };
108
+ }
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { parse as parseYaml } from 'yaml';
3
3
  import { SmithError } from './errors.js';
4
4
  import { EFFORT_POLICY_PATH } from './paths.js';
5
- import { EFFORT_TIERS, isEffortTier } from './plan.js';
5
+ import { EFFORT_TIERS, isEffortTier, isUiAffecting, } from './plan.js';
6
6
  import { evaluatePlanSecurityTriggers } from './planQuorum.js';
7
7
  /**
8
8
  * How much judgment an epic buys — factory/policies/effort.yml's host.
@@ -51,7 +51,10 @@ function tierRank(tier) {
51
51
  return EFFORT_TIERS.indexOf(tier);
52
52
  }
53
53
  const PRE_CODE_RESEARCH = ['when-needed', 'never'];
54
- const PRE_CODE_UIUX = ['always', 'when-ui-criterion', 'never'];
54
+ // 'never' is not a parseable value: wave.md already says preCodeUiux cannot
55
+ // be turned off, so a policy file that still spells it out should fail to
56
+ // parse rather than silently skip the uiux judge turn on every UI task (U2).
57
+ const PRE_CODE_UIUX = ['always', 'when-ui-criterion'];
55
58
  const SPEC_REVIEW_ROUNDS = ['until-clean', 'single-pass'];
56
59
  const PLAN_QUORUM = ['always', 'when-triggered'];
57
60
  const CLOSING_SPEC_REVIEW = ['always', 'when-plan-amended'];
@@ -86,6 +89,15 @@ function requireStringList(value, where, minLength) {
86
89
  }
87
90
  return value.map(String);
88
91
  }
92
+ /**
93
+ * Whether a task must get the uiux judge turn: either the task itself is
94
+ * flagged `ui_affecting` (plan.ts isUiAffecting), or the tier's own profile
95
+ * says every task does (`preCodeUiux: 'always'`). `when-ui-criterion` adds
96
+ * nothing here — that arm is read off the flag already.
97
+ */
98
+ export function requiresUiux(spec, profile) {
99
+ return isUiAffecting(spec) || profile.preCodeUiux === 'always';
100
+ }
89
101
  function parseProfile(node, tier) {
90
102
  const t = requireNode(node, `tiers.${tier}`);
91
103
  const summary = typeof t.summary === 'string' ? t.summary.trim() : '';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * How much judgment an epic buys, cheapest first — factory/policies/effort.yml
3
+ * declares what each tier means; this is only the closed list of names.
4
+ *
5
+ * A leaf module on purpose: plan.ts types `PlanFile.effort` with it, budgets.ts
6
+ * sizes its caps by it, and effort.ts is the semantic owner. plan.ts imports
7
+ * budgets.ts and effort.ts (transitively) imports plan.ts, so the list can live
8
+ * in none of them without an import cycle. plan.ts and effort.ts re-export it.
9
+ */
10
+ export const EFFORT_TIERS = ['small', 'medium', 'huge'];
11
+ export function isEffortTier(value) {
12
+ return typeof value === 'string' && EFFORT_TIERS.includes(value);
13
+ }
@@ -0,0 +1,26 @@
1
+ /** The current and legacy namespace prefixes, for call sites that filter a whole env object rather than read one name (e.g. `testgate.ts`'s `projectCommandEnv`). */
2
+ export const BS_PREFIX = 'BS_';
3
+ export const LEGACY_PREFIX = 'SMITH_';
4
+ /**
5
+ * The one place that knows `BS_<X>` is the current name and `SMITH_<X>` is
6
+ * the transitional fallback (operator decision 3, bs-rename): `BS_` wins
7
+ * when both are set, `SMITH_` is read when `BS_` is unset, and neither set
8
+ * reads as unset — exactly what every direct `env.SMITH_*`/`env.BS_*` read
9
+ * in this codebase should go through instead of hand-rolling the pair.
10
+ *
11
+ * `name` may be spelled either way — as the new `BS_<X>` name, or as the
12
+ * legacy `SMITH_<X>` name a call site's own constant still carries — since
13
+ * both resolve to the same pair of variables either way.
14
+ */
15
+ export function readEnv(env, name) {
16
+ const bsName = name.startsWith(LEGACY_PREFIX)
17
+ ? `${BS_PREFIX}${name.slice(LEGACY_PREFIX.length)}`
18
+ : name;
19
+ const legacyName = name.startsWith(BS_PREFIX)
20
+ ? `${LEGACY_PREFIX}${name.slice(BS_PREFIX.length)}`
21
+ : name;
22
+ const bsValue = env[bsName];
23
+ if (bsValue !== undefined)
24
+ return bsValue;
25
+ return env[legacyName];
26
+ }