@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
@@ -25,12 +25,15 @@
25
25
  * is a required parameter, supplied by the caller (task 5), never read off
26
26
  * the filesystem by this module.
27
27
  */
28
- import { FINGERPRINT_LINE_PREFIX, foldErrorEvents, renderBody, renderComment, renderTitle, toIssueBodyFields, toIssueCommentFields, } from './errorIssues.js';
28
+ import { FINGERPRINT_LINE_PREFIX, foldErrorEvents, renderBody, renderComment, renderTitle, toIssueBodyFields, toIssueCommentFields, withSessionFallback, } from './errorIssues.js';
29
29
  import { appendEvent } from './events.js';
30
30
  import { buildCommentArgv, buildCreateIssueArgv, buildSearchIssuesArgv, classifyGh, resolveProjectRepo, } from './gh.js';
31
31
  /**
32
- * The closed, named outcome vocabulary. Exactly one of these eight words is
32
+ * The closed, named outcome vocabulary. Exactly one of these nine words is
33
33
  * recorded for every candidate error, always — functional clause 2.
34
+ * `skipped-unresolved-project` is settled before any of the others can be:
35
+ * a row this factory cannot identify the project of is never filed against
36
+ * this factory's own repository by default (the privacy-leak guard).
34
37
  */
35
38
  export const ISSUE_REPORT_OUTCOMES = [
36
39
  'opened',
@@ -40,6 +43,7 @@ export const ISSUE_REPORT_OUTCOMES = [
40
43
  'skipped-no-remote',
41
44
  'skipped-gh-missing',
42
45
  'skipped-unauthenticated',
46
+ 'skipped-unresolved-project',
43
47
  'failed',
44
48
  ];
45
49
  const ISSUE_REPORTED_EVENT_TYPE = 'issue-reported';
@@ -60,8 +64,6 @@ export const ISSUE_REPORT_PAYLOAD_KEYS = [
60
64
  'source',
61
65
  'task_ref',
62
66
  ];
63
- /** Default project for an event stamped with none — matches events.ts's documented convention. */
64
- const DEFAULT_PROJECT = 'black-smith';
65
67
  function parseSearchResult(stdout) {
66
68
  let parsed;
67
69
  try {
@@ -94,14 +96,49 @@ function findExactMatch(issues, fingerprint) {
94
96
  const line = fingerprintLine(fingerprint);
95
97
  return issues.find((issue) => issue.body.split('\n').includes(line)) ?? null;
96
98
  }
99
+ /**
100
+ * The task ref an unstamped row carries, if any -- the envelope's own
101
+ * `task_id` first, falling back to a hand-appended row's `payload.task_ref`.
102
+ * Mirrors errorIssues.ts's `candidateTaskRef`, which this module never
103
+ * imports so as to keep the two folds' internals independent.
104
+ */
105
+ function candidateTaskRef(record) {
106
+ if (typeof record.task_id === 'string' && record.task_id.length > 0)
107
+ return record.task_id;
108
+ const payload = (record.payload ?? {});
109
+ return typeof payload.task_ref === 'string' && payload.task_ref.length > 0
110
+ ? payload.task_ref
111
+ : undefined;
112
+ }
113
+ /**
114
+ * A row's project, resolved the same way `errorIssues.ts`'s fold resolves
115
+ * one: an explicit stamp always wins; absent that, `resolveRow` (the row's
116
+ * own task ref widened with `withSessionFallback`'s session fallbacks) gets
117
+ * the answer. `null` when none of them can tell -- this factory's own
118
+ * project is never guessed. This is what keeps an unstamped row from a
119
+ * foreign (or unidentifiable) project's session from being folded into this
120
+ * factory's own prior-report history (and, through `decideOutcome`'s dedup
121
+ * key, its own repository) merely because nothing stamped it.
122
+ */
123
+ function resolveRecordProject(record, resolveRow) {
124
+ if (typeof record.project === 'string')
125
+ return record.project;
126
+ return resolveRow(candidateTaskRef(record), record.session_id);
127
+ }
97
128
  /**
98
129
  * Every already-appended `issue-reported` record still open, read off the
99
130
  * SNAPSHOT of events this call was handed — never off events this same call
100
131
  * itself appends. That is what lets the five duplicate candidates a single
101
132
  * broken gate produces in one round resolve through GitHub search (one
102
133
  * create, four comments) rather than short-circuiting on each other.
134
+ *
135
+ * A record whose project cannot be resolved at all is dropped rather than
136
+ * pushed: it can never match `priorReportFor`'s `h.project === report.project`
137
+ * comparison against a null-project report either (Step 0 below settles
138
+ * those before Step 4 runs), so keeping it around would only cost a lookup.
103
139
  */
104
- function priorOpenReports(events) {
140
+ function priorOpenReports(events, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
141
+ const resolveRow = withSessionFallback(scopeEvents, resolveProject, selfFallback);
105
142
  const out = [];
106
143
  for (const event of events) {
107
144
  const { record } = event;
@@ -116,8 +153,11 @@ function priorOpenReports(events) {
116
153
  if (typeof payload.fingerprint !== 'string' || typeof payload.latest_event_id !== 'string') {
117
154
  continue;
118
155
  }
156
+ const project = resolveRecordProject(record, resolveRow);
157
+ if (project === null)
158
+ continue;
119
159
  out.push({
120
- project: record.project ?? DEFAULT_PROJECT,
160
+ project,
121
161
  fingerprint: payload.fingerprint,
122
162
  outcome: payload.outcome,
123
163
  latest_event_id: payload.latest_event_id,
@@ -155,7 +195,10 @@ async function appendIssueReported(report, record, opts) {
155
195
  plan_version: report.plan_version ?? 1,
156
196
  causal_parent: report.latest_event_id,
157
197
  payload: toPayload(record),
158
- project: report.project,
198
+ // `EventInput.project` is `string | undefined` -- omitted, never
199
+ // `null`, when a row's project could not be resolved; the writer
200
+ // never stamps a guess (events.ts's documented convention).
201
+ ...(report.project !== null ? { project: report.project } : {}),
159
202
  }, opts);
160
203
  }
161
204
  /**
@@ -187,12 +230,21 @@ async function decideOutcome(report, isProjectEnabled, register, runner, history
187
230
  task_ref: report.task_ref,
188
231
  latest_event_id: report.latest_event_id,
189
232
  };
233
+ // Step 0: can this row's project be identified at all. A row still
234
+ // unresolved after the fold's own session fallbacks is never filed
235
+ // against this factory's own repository by default (the privacy-leak
236
+ // guard this whole module exists to close) -- settled before repo
237
+ // resolution or any `gh` interaction, ahead of every other step.
238
+ if (report.project === null) {
239
+ return { ...base, outcome: 'skipped-unresolved-project', reason: 'project-unresolved' };
240
+ }
241
+ const project = report.project;
190
242
  // Step 1: the switch.
191
- if (!isProjectEnabled(report.project)) {
243
+ if (!isProjectEnabled(project)) {
192
244
  return { ...base, outcome: 'skipped-disabled', reason: 'switch-off' };
193
245
  }
194
246
  // Step 2: which repository.
195
- const repo = resolveProjectRepo(report.project, register);
247
+ const repo = resolveProjectRepo(project, register);
196
248
  if (!('slug' in repo)) {
197
249
  return { ...base, outcome: 'skipped-no-remote', reason: repo.reason, detail: repo.detail };
198
250
  }
@@ -234,7 +286,11 @@ async function decideOutcome(report, isProjectEnabled, register, runner, history
234
286
  }
235
287
  const issues = parseSearchResult(searchResult.stdout);
236
288
  if (issues === null) {
237
- return { ...base, outcome: 'failed', reason: 'search-failed', repo_slug: repoSlug };
289
+ // gh exited 0 but the output could not be read as an issue list (e.g.
290
+ // --json was dropped somewhere upstream and gh printed its human table,
291
+ // or the response was truncated). Distinct from a genuine nonzero exit
292
+ // above, which is a process failure rather than an unreadable payload.
293
+ return { ...base, outcome: 'failed', reason: 'search-unparseable', repo_slug: repoSlug };
238
294
  }
239
295
  // Step 6: an exact fingerprint-line match gets a comment.
240
296
  const match = findExactMatch(issues, report.fingerprint);
@@ -274,17 +330,26 @@ const DECISION_NOTE = 'create or comment: which of the two would run is decided
274
330
  * `0`, a placeholder known only after that search. `runner` is accepted for
275
331
  * signature parity with `reportErrors()`; nothing here reaches it.
276
332
  */
277
- export async function previewOutcomes(events, isProjectEnabled, register, runner, clock) {
333
+ export async function previewOutcomes(events, isProjectEnabled, register, runner, clock, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
278
334
  requireRegister(register, 'previewOutcomes');
279
335
  void runner;
280
- const { reports } = foldErrorEvents(events, clock(), () => true);
281
- const history = priorOpenReports(events);
336
+ const { reports } = foldErrorEvents(events, clock(), () => true, resolveProject, selfFallback, scopeEvents);
337
+ const history = priorOpenReports(events, resolveProject, selfFallback, scopeEvents);
282
338
  const out = [];
283
339
  for (const report of reports) {
284
340
  const { fingerprint, project, source, error_class, task_ref, latest_event_id } = report;
285
341
  const base = { fingerprint, project, source, error_class, task_ref, latest_event_id };
286
342
  const step3 = { step_3_gh_availability: STEP_3_NOT_PERFORMED };
287
343
  const settle = (rest) => out.push({ ...base, ...step3, ...rest });
344
+ // Step 0: can this row's project be identified at all.
345
+ if (project === null) {
346
+ settle({
347
+ settled_at_step: 0,
348
+ outcome: 'skipped-unresolved-project',
349
+ reason: 'project-unresolved',
350
+ });
351
+ continue;
352
+ }
288
353
  // Step 1: the switch.
289
354
  if (!isProjectEnabled(project)) {
290
355
  settle({ settled_at_step: 1, outcome: 'skipped-disabled', reason: 'switch-off' });
@@ -333,13 +398,13 @@ export async function previewOutcomes(events, isProjectEnabled, register, runner
333
398
  * calls `factoryProjects()` itself), `runner` is task 3's injected `gh`
334
399
  * command runner, and `clock` supplies `now` for the fold below.
335
400
  */
336
- export async function reportErrors(events, isProjectEnabled, register, runner, clock, opts = {}) {
401
+ export async function reportErrors(events, isProjectEnabled, register, runner, clock, opts = {}, resolveProject = () => null, selfFallback = () => null, scopeEvents = events) {
337
402
  requireRegister(register, 'reportErrors');
338
403
  // Every project's switch is applied by this module's own step 1, not by
339
404
  // the fold: foldErrorEvents dropping a disabled project's candidates
340
405
  // silently would lose the `skipped-disabled` record clause 2 requires.
341
- const { reports } = foldErrorEvents(events, clock(), () => true);
342
- const history = priorOpenReports(events);
406
+ const { reports } = foldErrorEvents(events, clock(), () => true, resolveProject, selfFallback, scopeEvents);
407
+ const history = priorOpenReports(events, resolveProject, selfFallback, scopeEvents);
343
408
  const out = [];
344
409
  for (const report of reports) {
345
410
  const record = await decideOutcome(report, isProjectEnabled, register, runner, history);
@@ -28,7 +28,8 @@
28
28
  // locally: is the key set, is the binary on PATH, and does the arithmetic of
29
29
  // the promotions add up.
30
30
  import { readFileSync } from 'node:fs';
31
- import { parseCrosscheckPolicy } from './crosscheck.js';
31
+ import { loadProjectProviderOverlay, parseCrosscheckPolicy, } from './crosscheck.js';
32
+ import { readEnv } from './env.js';
32
33
  import { CROSSCHECK_POLICY_PATH } from './paths.js';
33
34
  import { apiKeyPresent, commandOnPath } from './preconditions.js';
34
35
  function inspect(config) {
@@ -49,6 +50,7 @@ function inspect(config) {
49
50
  kind: config.kind,
50
51
  enabled: config.enabled,
51
52
  mode: config.mode,
53
+ configSource: config.configSource,
52
54
  };
53
55
  if (config.transport === 'api') {
54
56
  const set = apiKeyPresent(config.apiKeyEnv);
@@ -63,6 +65,22 @@ function inspect(config) {
63
65
  : `Disabled in the policy, so ${config.apiKeyEnv} is not read.`,
64
66
  };
65
67
  }
68
+ // Substitution keeps the provider's own name, mode and gating power
69
+ // (operator decision 1a) -- only the transport target and model changed
70
+ // underneath it, so this reads as the same provider succeeding, not a
71
+ // new one appearing.
72
+ if (config.via === 'openrouter') {
73
+ return {
74
+ ...base,
75
+ transport: 'api',
76
+ precondition: config.apiKeyEnv,
77
+ status: set ? 'ok' : 'unmet',
78
+ via: 'openrouter',
79
+ detail: set
80
+ ? `Running via openrouter as ${config.model}: its own precondition is unmet on this box, and ${config.apiKeyEnv} is set.`
81
+ : `Running via openrouter as ${config.model}, but ${config.apiKeyEnv} is unset or empty. Every quorum trigger will spend a call that cannot be sent, and record it as provider.missing-api-key.`,
82
+ };
83
+ }
66
84
  return {
67
85
  ...base,
68
86
  transport: 'api',
@@ -91,7 +109,7 @@ function inspect(config) {
91
109
  precondition: config.command,
92
110
  status: resolvable ? 'ok' : 'unmet',
93
111
  detail: resolvable
94
- ? `${config.command} is executable on PATH. Whether it is authenticated is not knowable without spending a call — run "smith judge run --provider ${config.name}" to find out.`
112
+ ? `${config.command} is executable on PATH. Whether it is authenticated is not knowable without spending a call — run "bs judge run --provider ${config.name}" to find out.`
95
113
  : `${config.command} is not executable on PATH. Every quorum trigger will spend a call that cannot start.`,
96
114
  };
97
115
  }
@@ -99,11 +117,14 @@ function inspect(config) {
99
117
  * Read the policy as written and report what it would cost and buy. Pure
100
118
  * except for `process.env` and PATH lookups; appends nothing and calls nobody.
101
119
  */
102
- export function judgePreflight(policyPath = CROSSCHECK_POLICY_PATH) {
120
+ export function judgePreflight(policyPath = CROSSCHECK_POLICY_PATH, options = {}) {
103
121
  // parseCrosscheckPolicy with `offline: false` rather than
104
122
  // loadCrosscheckPolicy: see `offlineSwitch` above.
105
123
  const policy = parseCrosscheckPolicy(readFileSync(policyPath, 'utf8'), {
106
124
  offline: false,
125
+ providerOverlay: options.projectDir
126
+ ? loadProjectProviderOverlay(options.projectDir)
127
+ : undefined,
107
128
  });
108
129
  const providers = Object.values(policy.providers)
109
130
  .map(inspect)
@@ -146,7 +167,7 @@ export function judgePreflight(policyPath = CROSSCHECK_POLICY_PATH) {
146
167
  : `${activeExternal.length} active external provider(s) against min_providers ${minProviders}: nothing can be decided.`;
147
168
  return {
148
169
  policyPath,
149
- offlineSwitch: Boolean(process.env.SMITH_CROSSCHECK_OFFLINE),
170
+ offlineSwitch: Boolean(readEnv(process.env, 'SMITH_CROSSCHECK_OFFLINE')),
150
171
  providers,
151
172
  gating: { activeExternal, shadowExternal, minProviders, canDecide, detail },
152
173
  problems,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The epic criterion's six judge roles (task 5 and task 6 objectives both
3
+ * name this exact set: spec-reviewer, reviewer, verifier, grader,
4
+ * security-reviewer, auditor) -- the roles whose dispatch declares an
5
+ * artifact and whose SubagentStop is guarded by judge-stop.sh.
6
+ *
7
+ * A leaf module with no local imports of its own, so both `dispatchLint.ts`
8
+ * (which needs `readJudgeTurns` from `judges.ts`) and `judges.ts` (which
9
+ * needs this set for `foldJudgeTurns` and `recordJudgeDispatch`) can import
10
+ * it without forming a cycle between the two. `dispatchLint.ts` re-exports
11
+ * this set for its existing callers (judgeStopHook.ts, pluginManifest tests)
12
+ * — one array, never copied.
13
+ *
14
+ * Not the same six as dispatch.md's "Fingerprint the worktree around every
15
+ * judge" list, which swaps `auditor` for `uiux`: that section is about
16
+ * which judges run inside a task's worktree (auditor runs over a whole
17
+ * project at HEAD, never per-task, so a worktree fingerprint does not apply
18
+ * to it; uiux does run per-task). This list is about which roles owe a
19
+ * declared artifact and a blocked Stop, and `auditor` is one of them.
20
+ */
21
+ export const JUDGE_ROLES = [
22
+ 'reviewer',
23
+ 'verifier',
24
+ 'grader',
25
+ 'spec-reviewer',
26
+ 'security-reviewer',
27
+ 'auditor',
28
+ ];
29
+ /**
30
+ * `JUDGE_ROLES` plus `uiux`: every role a `smith judge dispatch` / `smith
31
+ * judge report` pair can open and close a turn for. wave.md's steps 5-7
32
+ * bracket the uiux visual pass with those same two verbs, same as grader,
33
+ * reviewer, verifier and security-reviewer (wave.md:242-253) — so
34
+ * `foldJudgeTurns` and `recordJudgeDispatch` (judges.ts) need uiux in scope
35
+ * too, or a real uiux dispatch throws `judges.non-judge-role` and its turn
36
+ * never folds.
37
+ *
38
+ * Deliberately NOT folded into `JUDGE_ROLES` itself: that set's meaning
39
+ * ("owes a declared-artifact line and a blocked Stop") stays exactly the six
40
+ * roles it always was for `dispatchLint.ts`'s `checkArtifact()` and
41
+ * `judgeStopHook.ts`'s SubagentStop guard — uiux's artifact is always the
42
+ * OBJECT `{run_status, structured_output, artifacts}` (bs-uiux.md), never the
43
+ * array shape those two callers check for, so widening `JUDGE_ROLES` itself
44
+ * would start demanding a declared-artifact line and a blocked Stop uiux was
45
+ * never designed to carry.
46
+ */
47
+ export const JUDGE_TURN_ROLES = [...JUDGE_ROLES, 'uiux'];
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `.claude/hooks/judge-stop.sh` — the SubagentStop decision for a judge-class
4
+ * agent (reviewer, verifier, grader, spec-reviewer, security-reviewer,
5
+ * auditor; `JUDGE_ROLES` in judgeRoles.ts is the authoritative set).
6
+ *
7
+ * A judge can end its turn without ever writing the artifact its dispatch
8
+ * declared, and today that gap is found only downstream, when `smith judge
9
+ * report` answers `judges.artifact-missing` — after the agent is gone. This
10
+ * hook closes the loop at the one moment it can still act: block the stop
11
+ * while the declared path does not exist, allow it once it does. A harness
12
+ * `maxTurns` cap is out of scope; this hook cannot override it.
13
+ *
14
+ * The declared-artifact line and its parser come from
15
+ * `declaredArtifactLine.ts`, never redefined — in this clone the PreToolUse
16
+ * guard refuses, before dispatch, any judge dispatch whose prompt lacks an
17
+ * absolute line (and `dispatch lint` checks it against the ledger), so this
18
+ * hook only asks "does the file exist yet", never "is the line well-formed".
19
+ *
20
+ * Fail open on anything this hook cannot read: a non-judge `agent_type`, a
21
+ * prompt with no declared-artifact line, a relative path, unparseable stdin,
22
+ * an unreadable transcript — blocking on those would trap the agent until
23
+ * `maxTurns` for an obligation the hook could never state. Every fail-open
24
+ * path prints a stderr note and is covered upstream by `dispatch lint` or
25
+ * downstream by `judge report`'s `judges.artifact-missing`.
26
+ *
27
+ * Fail closed ONLY on a readable absolute declared path that does not exist.
28
+ *
29
+ * The hook reads the subagent's first non-meta user text -- its dispatch
30
+ * prompt -- from `agent_transcript_path`. Per
31
+ * https://code.claude.com/docs/en/hooks#subagentstop: "The `transcript_path`
32
+ * is the main session's transcript, while `agent_transcript_path` is the
33
+ * subagent's own transcript stored in a nested `subagents/` folder." Later
34
+ * user-role entries (meta reminders, resumes, compaction notices) are never
35
+ * the prompt. An older CLI that sends no `agent_transcript_path` falls back
36
+ * to `transcript_path`: the main session's first prompt, which carries no
37
+ * Declared line unless that session was itself dispatched as a judge, so the
38
+ * hook fails open there.
39
+ *
40
+ * SubagentStop stdin fields: https://code.claude.com/docs/en/hooks
41
+ */
42
+ import { closeSync, existsSync, openSync, readFileSync, readSync } from 'node:fs';
43
+ import path from 'node:path';
44
+ import { StringDecoder } from 'node:string_decoder';
45
+ import { pathToFileURL } from 'node:url';
46
+ import { roleOfAgentType } from './agentNames.js';
47
+ import { parseDeclaredArtifactLine } from './declaredArtifactLine.js';
48
+ import { JUDGE_ROLES } from './judgeRoles.js';
49
+ const JUDGE_ROLE_SET = new Set(JUDGE_ROLES);
50
+ /**
51
+ * The pure decision: given the hook's stdin and the subagent's prompt text
52
+ * (or `null` when it could not be read at all), decide whether the stop is
53
+ * allowed. Touches the filesystem only to check whether the declared path
54
+ * exists -- the one question this hook exists to ask.
55
+ */
56
+ export function decideJudgeStop(input, promptText) {
57
+ // `bs-reviewer`, `blacksmith:bs-reviewer` and (transition) the bare old names
58
+ // all map to the role; a name that maps to nothing is not a judge.
59
+ const role = input.agent_type === undefined ? null : roleOfAgentType(input.agent_type);
60
+ if (role === null || !JUDGE_ROLE_SET.has(role)) {
61
+ // Not one of the six judge roles -- a coder's turn carries no declared
62
+ // artifact at all, so there is nothing for this hook to check.
63
+ return { decision: 'allow' };
64
+ }
65
+ if (promptText === null) {
66
+ return {
67
+ decision: 'allow',
68
+ note: "judge-stop: could not read this agent's prompt (transcript missing or " +
69
+ "unreadable); allowing the stop. judge report's judges.artifact-missing " +
70
+ 'remains the backstop.',
71
+ };
72
+ }
73
+ const declared = parseDeclaredArtifactLine(promptText);
74
+ if (declared === null) {
75
+ return {
76
+ decision: 'allow',
77
+ note: 'judge-stop: no "Declared artifact: <path>" line in this prompt; allowing ' +
78
+ "the stop. The PreToolUse guard refuses this before dispatch in this clone, and judge report's " +
79
+ 'judges.artifact-missing catches it downstream.',
80
+ };
81
+ }
82
+ if (!path.isAbsolute(declared)) {
83
+ return {
84
+ decision: 'allow',
85
+ note: `judge-stop: declared artifact "${declared}" is not an absolute path; ` +
86
+ 'allowing the stop. The PreToolUse guard refuses this before dispatch in this clone, and judge ' +
87
+ "report's judges.artifact-missing catches it downstream.",
88
+ };
89
+ }
90
+ if (!existsSync(declared)) {
91
+ return {
92
+ decision: 'block',
93
+ reason: `Declared artifact ${declared} does not exist yet. Write it before ending this turn.`,
94
+ };
95
+ }
96
+ // Existence is this hook's whole question -- an empty or unparseable file
97
+ // is `judge report`'s to refuse (`judges.artifact-unparseable`, `judges.
98
+ // artifact-not-a-list`), not this hook's to trap the agent over.
99
+ return { decision: 'allow' };
100
+ }
101
+ /**
102
+ * The text of the first non-meta user-role entry in a Claude Code JSONL
103
+ * transcript -- in a subagent's own transcript that is its dispatch prompt,
104
+ * where the "Declared artifact:" line lives; meta reminders, resumes, tool
105
+ * results and compaction notices all come after it or are skipped. Streams
106
+ * the file in fixed-size chunks (a transcript can pass 512 MB, where a
107
+ * whole-file read throws), splitting on `\n` only, and stops reading as soon
108
+ * as the prompt is found; work is linear in the bytes read because only the
109
+ * newly read chunk is scanned for line breaks. A torn or unparseable line is
110
+ * skipped. Returns `null` on any read failure or when no such entry exists.
111
+ */
112
+ export function extractDispatchPromptText(transcriptPath, chunkSize = 64 * 1024) {
113
+ let fd;
114
+ try {
115
+ fd = openSync(transcriptPath, 'r');
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ const consume = (line) => {
121
+ if (line.trim() === '')
122
+ return null;
123
+ let entry;
124
+ try {
125
+ entry = JSON.parse(line);
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ if (entry?.isMeta === true)
131
+ return null;
132
+ return userTextOf(entry);
133
+ };
134
+ try {
135
+ const buf = Buffer.alloc(chunkSize);
136
+ const decoder = new StringDecoder('utf8');
137
+ let carry = [];
138
+ for (;;) {
139
+ const n = readSync(fd, buf, 0, chunkSize, null);
140
+ if (n === 0)
141
+ break;
142
+ const chunk = decoder.write(buf.subarray(0, n));
143
+ let start = 0;
144
+ for (let nl = chunk.indexOf('\n'); nl !== -1; nl = chunk.indexOf('\n', start)) {
145
+ carry.push(chunk.slice(start, nl));
146
+ const found = consume(carry.join(''));
147
+ if (found !== null)
148
+ return found;
149
+ carry = [];
150
+ start = nl + 1;
151
+ }
152
+ if (start < chunk.length)
153
+ carry.push(chunk.slice(start));
154
+ }
155
+ carry.push(decoder.end());
156
+ return consume(carry.join(''));
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ finally {
162
+ try {
163
+ closeSync(fd);
164
+ }
165
+ catch {
166
+ // nothing to do about a failed close
167
+ }
168
+ }
169
+ }
170
+ function userTextOf(entry) {
171
+ if (typeof entry !== 'object' || entry === null)
172
+ return null;
173
+ const rec = entry;
174
+ if (rec.type !== 'user')
175
+ return null;
176
+ const message = rec.message;
177
+ if (typeof message !== 'object' || message === null)
178
+ return null;
179
+ const content = message.content;
180
+ if (typeof content === 'string')
181
+ return content;
182
+ if (Array.isArray(content)) {
183
+ const parts = content
184
+ .filter((block) => typeof block === 'object' && block !== null)
185
+ .filter((block) => block.type === 'text' && typeof block.text === 'string')
186
+ .map((block) => block.text);
187
+ if (parts.length > 0)
188
+ return parts.join('\n');
189
+ }
190
+ return null;
191
+ }
192
+ /**
193
+ * The whole hook, minus process I/O: parse stdin, read the prompt, decide,
194
+ * and format the two output streams Claude Code and an operator each read.
195
+ * Exercised directly by tests without spawning a process; `main` below is
196
+ * the only piece that touches fd 0 and process.exit.
197
+ */
198
+ export function runJudgeStopHook(rawStdin) {
199
+ let input;
200
+ try {
201
+ input = JSON.parse(rawStdin);
202
+ }
203
+ catch {
204
+ return {
205
+ stdout: '',
206
+ stderr: 'judge-stop: could not parse stdin as JSON; allowing the stop. ' +
207
+ "judge report's judges.artifact-missing remains the backstop.\n",
208
+ };
209
+ }
210
+ // Older CLIs send no agent_transcript_path; the fallback then reads the
211
+ // main session's transcript, whose first prompt carries no declared line
212
+ // unless that session was itself dispatched as a judge, so the hook
213
+ // fails open there.
214
+ const promptPath = typeof input.agent_transcript_path === 'string' && input.agent_transcript_path !== ''
215
+ ? input.agent_transcript_path
216
+ : input.transcript_path;
217
+ const promptText = promptPath ? extractDispatchPromptText(promptPath) : null;
218
+ const result = decideJudgeStop(input, promptText);
219
+ if (result.decision === 'block') {
220
+ return {
221
+ stdout: `${JSON.stringify({ decision: 'block', reason: result.reason })}\n`,
222
+ stderr: '',
223
+ };
224
+ }
225
+ return { stdout: '', stderr: result.note ? `${result.note}\n` : '' };
226
+ }
227
+ function main() {
228
+ const { stdout, stderr } = runJudgeStopHook(readFileSync(0, 'utf8'));
229
+ if (stdout)
230
+ process.stdout.write(stdout);
231
+ if (stderr)
232
+ process.stderr.write(stderr);
233
+ }
234
+ // Runs only when executed directly (judge-stop.sh execs `node
235
+ // dist/judgeStopHook.js`) -- importing this module for its exports, as the
236
+ // tests do, must never read stdin as a side effect.
237
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
238
+ main();
239
+ }