@juzser/blacksmith 0.4.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 (131) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +4 -4
  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} +13 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -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} +4 -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 +34 -27
  17. package/.claude/skills/bs/dispatch.md +100 -50
  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 +27 -18
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +181 -49
  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 +109 -53
  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 +48 -14
  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 +125 -17
  35. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  36. package/factory/orchestrator/dist/budgets.js +302 -84
  37. package/factory/orchestrator/dist/claims.js +9 -19
  38. package/factory/orchestrator/dist/cli.js +790 -100
  39. package/factory/orchestrator/dist/cliName.js +27 -0
  40. package/factory/orchestrator/dist/crosscheck.js +136 -8
  41. package/factory/orchestrator/dist/daemon.js +154 -36
  42. package/factory/orchestrator/dist/db/projector.js +278 -29
  43. package/factory/orchestrator/dist/db/queries.js +1831 -94
  44. package/factory/orchestrator/dist/db/schema.js +57 -4
  45. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  46. package/factory/orchestrator/dist/delegation.js +86 -19
  47. package/factory/orchestrator/dist/diffstat.js +15 -8
  48. package/factory/orchestrator/dist/dispatchLint.js +13 -37
  49. package/factory/orchestrator/dist/effort.js +14 -2
  50. package/factory/orchestrator/dist/effortTiers.js +13 -0
  51. package/factory/orchestrator/dist/env.js +26 -0
  52. package/factory/orchestrator/dist/epic.js +323 -38
  53. package/factory/orchestrator/dist/epicBudget.js +93 -0
  54. package/factory/orchestrator/dist/epicWidth.js +22 -5
  55. package/factory/orchestrator/dist/errorIssues.js +10 -4
  56. package/factory/orchestrator/dist/escalation.js +2 -2
  57. package/factory/orchestrator/dist/events.js +105 -3
  58. package/factory/orchestrator/dist/feedback.js +169 -0
  59. package/factory/orchestrator/dist/findingContext.js +26 -0
  60. package/factory/orchestrator/dist/findings.js +83 -12
  61. package/factory/orchestrator/dist/gate.js +120 -10
  62. package/factory/orchestrator/dist/goalCheck.js +2 -2
  63. package/factory/orchestrator/dist/harness.js +25 -17
  64. package/factory/orchestrator/dist/hookDecision.js +92 -3
  65. package/factory/orchestrator/dist/integration.js +9 -7
  66. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  67. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  68. package/factory/orchestrator/dist/judgeStopHook.js +90 -32
  69. package/factory/orchestrator/dist/judges.js +419 -21
  70. package/factory/orchestrator/dist/lessons.js +190 -11
  71. package/factory/orchestrator/dist/mcp.js +6 -6
  72. package/factory/orchestrator/dist/paths.js +10 -7
  73. package/factory/orchestrator/dist/plan.js +269 -33
  74. package/factory/orchestrator/dist/planQuorum.js +5 -2
  75. package/factory/orchestrator/dist/policy.js +1275 -30
  76. package/factory/orchestrator/dist/projectName.js +25 -0
  77. package/factory/orchestrator/dist/promptCapture.js +306 -0
  78. package/factory/orchestrator/dist/promptHook.js +33 -0
  79. package/factory/orchestrator/dist/promptImport.js +281 -0
  80. package/factory/orchestrator/dist/prompts.js +13 -0
  81. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  82. package/factory/orchestrator/dist/providers/types.js +1 -1
  83. package/factory/orchestrator/dist/queue.js +4 -4
  84. package/factory/orchestrator/dist/quorum.js +18 -5
  85. package/factory/orchestrator/dist/results.js +25 -7
  86. package/factory/orchestrator/dist/roadmap.js +10 -5
  87. package/factory/orchestrator/dist/run-cli.js +15 -10
  88. package/factory/orchestrator/dist/runner.js +2 -2
  89. package/factory/orchestrator/dist/schemas.js +56 -8
  90. package/factory/orchestrator/dist/severity.js +8 -4
  91. package/factory/orchestrator/dist/spec.js +177 -13
  92. package/factory/orchestrator/dist/specChange.js +13 -9
  93. package/factory/orchestrator/dist/stack.js +6 -6
  94. package/factory/orchestrator/dist/storeRoot.js +90 -0
  95. package/factory/orchestrator/dist/symbols.js +261 -6
  96. package/factory/orchestrator/dist/taskEvents.js +179 -27
  97. package/factory/orchestrator/dist/taskStatus.js +14 -0
  98. package/factory/orchestrator/dist/testerAudit.js +31 -12
  99. package/factory/orchestrator/dist/testgate.js +16 -13
  100. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  101. package/factory/orchestrator/dist/usage.js +97 -40
  102. package/factory/orchestrator/dist/waivers.js +39 -5
  103. package/factory/orchestrator/dist/waveBudget.js +2 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  105. package/factory/orchestrator/dist/workroot.js +1 -1
  106. package/factory/orchestrator/dist/worktree.js +61 -19
  107. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  108. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  109. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  110. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  111. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  112. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  113. package/factory/policies/budgets.yml +123 -33
  114. package/factory/policies/crosscheck.yml +51 -13
  115. package/factory/policies/delegation.yml +3 -3
  116. package/factory/policies/effort.yml +18 -10
  117. package/factory/policies/guardrails.yml +8 -6
  118. package/factory/policies/harness.yml +5 -5
  119. package/factory/policies/lessons.md +53 -2
  120. package/factory/policies/scheduler.yml +4 -4
  121. package/factory/policies/sensitive-paths.yml +1 -1
  122. package/factory/policies/severity.yml +13 -1
  123. package/factory/policies/stack.yml +6 -6
  124. package/factory/policies/taxonomy.yml +9 -3
  125. package/factory/policies/worktree.yml +15 -9
  126. package/factory/specs/roadmap.md +7 -7
  127. package/factory/specs/schema/event.schema.json +8 -1
  128. package/factory/specs/schema/result.schema.json +21 -8
  129. package/factory/specs/schema/task-spec.schema.json +12 -0
  130. package/package.json +16 -12
  131. package/.claude/agents/uiux.md +0 -133
@@ -2,7 +2,7 @@
2
2
  // without holding the rest of the orchestrator.
3
3
  //
4
4
  // `.claude/hooks/guard.sh` fires on every Bash/Write/Edit/MultiEdit/
5
- // NotebookEdit call an agent makes, which makes whatever it execs the most
5
+ // NotebookEdit/Agent/Task call an agent makes, which makes whatever it execs the most
6
6
  // frequently run code in this repo by orders of magnitude. It used to exec
7
7
  // `dist/cli.js policy hook`; cli.ts imports the whole orchestrator at module
8
8
  // scope, `db/projector.js` and so drizzle-orm among it, and that graph cost
@@ -13,8 +13,47 @@
13
13
  // same function rather than a second copy of it.
14
14
  import { accessSync, constants, realpathSync, statSync } from 'node:fs';
15
15
  import path from 'node:path';
16
- import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
16
+ import { roleOfAgentType } from './agentNames.js';
17
+ import { parseDeclaredArtifactLine } from './declaredArtifactLine.js';
18
+ import { JUDGE_ROLES } from './judgeRoles.js';
19
+ import { detectCurrentBranch, detectRepoRoot, evaluateCommand, isProtectedBranchName, loadGuardrailPolicy, } from './policy.js';
17
20
  import { activeSandboxFor } from './sandbox.js';
21
+ const JUDGE_ROLE_NAMES = new Set(JUDGE_ROLES);
22
+ /**
23
+ * A judge-role subagent dispatch must carry `Declared artifact: <absolute path>`
24
+ * in its prompt, because judge-stop.sh reads the prompt (never a brief file) to
25
+ * know what the judge owes. Same parser as judge-stop, so what is accepted here
26
+ * is what it reads. Any other role, prefix or missing type is allowed silently.
27
+ */
28
+ function decideAgentDispatch(input) {
29
+ const type = typeof input?.subagent_type === 'string' ? input.subagent_type : '';
30
+ // Same mapping as judge-stop: `bs-reviewer`, `blacksmith:bs-reviewer` and the
31
+ // bare pre-prefix names all reach the role. Another plugin's namespace
32
+ // (`other:reviewer`) is not a Blacksmith agent, so it is skipped, not stripped.
33
+ const colon = type.indexOf(':');
34
+ if (colon !== -1 && type.slice(0, colon) !== 'blacksmith')
35
+ return null;
36
+ const role = roleOfAgentType(type);
37
+ if (role === null || !JUDGE_ROLE_NAMES.has(role))
38
+ return null;
39
+ const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
40
+ const declared = parseDeclaredArtifactLine(prompt);
41
+ if (declared !== null && path.isAbsolute(declared))
42
+ return null;
43
+ const found = declared === null
44
+ ? ''
45
+ : ` The prompt has "${declared}", which is relative; the path must be absolute.`;
46
+ return {
47
+ hookSpecificOutput: {
48
+ hookEventName: 'PreToolUse',
49
+ permissionDecision: 'deny',
50
+ permissionDecisionReason: `A "${role}" dispatch must declare the artifact it writes. The prompt needs a line reading exactly ` +
51
+ '`Declared artifact: <absolute path>` on its own line, not indented, and in the prompt itself ' +
52
+ `(judge-stop reads the prompt, not a brief file the agent reads).${found} ` +
53
+ '`judge dispatch` prints the line to paste, as `expected_line`.',
54
+ },
55
+ };
56
+ }
18
57
  /**
19
58
  * Reads one PreToolUse payload and answers with the envelope to print, or
20
59
  * `null` to print nothing.
@@ -38,6 +77,9 @@ export function decideHookPayload(raw, fallbackCwd,
38
77
  /** Where judge leases live; the live `state/sandboxes` unless a test points elsewhere. */
39
78
  leaseDir) {
40
79
  const payload = JSON.parse(raw);
80
+ if (payload.tool_name === 'Agent' || payload.tool_name === 'Task') {
81
+ return decideAgentDispatch(payload.tool_input);
82
+ }
41
83
  const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
42
84
  const command = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
43
85
  // A `Write`/`Edit`/`MultiEdit` payload carries no command; its target is
@@ -93,6 +135,8 @@ leaseDir) {
93
135
  ? certain
94
136
  : fallbackDirectories(command, cwd).map(locate);
95
137
  let reason = null;
138
+ let firingRuleId = null;
139
+ let firingDir = null;
96
140
  for (const { dir, branch, repoRoot } of places) {
97
141
  const targetLease = dir === cwd ? null : activeSandboxFor(dir, leaseDir);
98
142
  for (const sandbox of distinctLeases(sessionLease, targetLease)) {
@@ -106,13 +150,38 @@ leaseDir) {
106
150
  // behaviour even though evaluateCommand (policy check's diagnostic
107
151
  // output) reports every rule that tripped.
108
152
  if (!decision.allowed) {
109
- reason = decision.violations[0]?.reason ?? 'guardrails.yml denied this command.';
153
+ const violation = decision.violations[0];
154
+ reason = violation?.reason ?? 'guardrails.yml denied this command.';
155
+ firingRuleId = violation?.ruleId ?? null;
156
+ firingDir = dir;
110
157
  break;
111
158
  }
112
159
  }
113
160
  if (reason !== null)
114
161
  break;
115
162
  }
163
+ // Rule 3 denying on the fallback (`certain === null`) because cwd itself is
164
+ // protected is a guess dressed as fact when the command also named another
165
+ // directory that is NOT protected: the fallback ran only because the shape
166
+ // could not be read for certain, not because cwd is provably where this
167
+ // runs. Swap in a reason that says so and names the readable shapes, so the
168
+ // same merge can go through rewritten. Every other case — no named
169
+ // directory, or a named directory that is protected too — keeps the
170
+ // original reason unchanged.
171
+ if (reason !== null &&
172
+ firingRuleId === 'merge-into-protected' &&
173
+ certain == null &&
174
+ firingDir === cwd) {
175
+ const namedUnprotected = places.some((p) => p.dir !== cwd &&
176
+ p.branch !== '' &&
177
+ p.branch !== 'HEAD' &&
178
+ p.repoRoot !== null &&
179
+ !isProtectedBranchName(p.branch, policy));
180
+ if (namedUnprotected) {
181
+ const cwdBranch = places.find((p) => p.dir === cwd)?.branch ?? '';
182
+ reason = fallbackMergeReason(cwdBranch);
183
+ }
184
+ }
116
185
  if (reason === null) {
117
186
  // Allow prints NOTHING, on purpose, and this is not a cosmetic choice.
118
187
  // Claude Code reads an explicit `permissionDecision: "allow"` from a hook
@@ -137,6 +206,26 @@ leaseDir) {
137
206
  },
138
207
  };
139
208
  }
209
+ /**
210
+ * Rule 3's fallback reason for the one case where "you are on {branch}"
211
+ * states a guess as fact: the command named another directory, that
212
+ * directory is NOT protected, and the only reason cwd was judged at all is
213
+ * that the command's shape was not one `shortcutDirectories` can read for
214
+ * certain — see its own doc comment for the two shapes it accepts. Says so
215
+ * plainly, and names those shapes, rather than telling an agent already
216
+ * standing on a side branch to "check out a side branch instead".
217
+ */
218
+ function fallbackMergeReason(cwdBranch) {
219
+ return (`this command's shape is not one the guard can read for certain, so it ` +
220
+ `was also judged against your session's own directory, which is on ` +
221
+ `${cwdBranch}, a protected branch; git merge/pull is denied there. The ` +
222
+ `guard reads \`cd <dir> && git <cmd> …\` — <dir> absolute or starting ` +
223
+ `with \`./\`, joined only by \`&&\`, each command merge, pull or another ` +
224
+ `read-safe git subcommand (not checkout or switch) — or a single ` +
225
+ `\`git -C <dir> merge …\`; plain words only, no pipes, \`;\`, redirects, ` +
226
+ `quotes or variables. Write it that way and the same merge is judged in ` +
227
+ `<dir> alone.`);
228
+ }
140
229
  /**
141
230
  * The one shape judged in its target alone, or `null` to fall back. An
142
231
  * allowlist, because this is the only path that drops `cwd` and so the only
@@ -2,7 +2,7 @@ import { SmithError } from './errors.js';
2
2
  import { appendEvent, validateEventEnvelope } from './events.js';
3
3
  import { runGit as git } from './git.js';
4
4
  import { run } from './testgate.js';
5
- import { integrationBranchName, RESERVED_TASK_ID } from './worktree.js';
5
+ import { epicBranchPrefix, integrationBranchName, RESERVED_TASK_ID } from './worktree.js';
6
6
  /**
7
7
  * D-42/P9-26, second half. Moving the worktrees outside the project root
8
8
  * (worktree.ts) fixes the tool that went red; it does not fix the reason
@@ -36,13 +36,15 @@ function branchHead(projectDir, branch) {
36
36
  }
37
37
  }
38
38
  /**
39
- * The current head of `smith/<epic>/integration`, or null when the epic has
40
- * no integration branch (never planned, never dispatched, wrong --epic).
41
- * Read here rather than inside epic.ts so the verdict stays git-free and
42
- * purely a fold over events — the CLI passes the sha in.
39
+ * The current head of `<prefix>/<epic>/integration` (`bs/` for a new epic,
40
+ * `smith/` for one that already integrates there — epicBranchPrefix decides
41
+ * which), or null when the epic has no integration branch (never planned,
42
+ * never dispatched, wrong --epic). Read here rather than inside epic.ts so
43
+ * the verdict stays git-free and purely a fold over events — the CLI passes
44
+ * the sha in.
43
45
  */
44
46
  export function integrationHeadSha(projectDir, epicId) {
45
- return branchHead(projectDir, integrationBranchName(epicId));
47
+ return branchHead(projectDir, integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId)));
46
48
  }
47
49
  /** Last-wins fold: the most recent recorded check for this epic, or null. */
48
50
  export function latestIntegrationCheck(events, epicId) {
@@ -86,7 +88,7 @@ export async function runIntegrationCheck(input, ctx, opts = {}) {
86
88
  causal_parent: ctx.causalParent,
87
89
  }, opts);
88
90
  const { epicId, projectDir } = input;
89
- const branch = integrationBranchName(epicId);
91
+ const branch = integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId));
90
92
  // testgate.run([]) returns pass: true for an empty list — correct there
91
93
  // (a task claiming no checks has nothing to short-circuit), and a silent
92
94
  // forgery here. "No checks configured" must never read back as "the
@@ -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'];
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * `.claude/hooks/judge-stop.sh` — the SubagentStop decision for a judge-class
4
4
  * agent (reviewer, verifier, grader, spec-reviewer, security-reviewer,
5
- * auditor; `JUDGE_ROLES` in dispatchLint.ts is the authoritative set).
5
+ * auditor; `JUDGE_ROLES` in judgeRoles.ts is the authoritative set).
6
6
  *
7
7
  * A judge can end its turn without ever writing the artifact its dispatch
8
8
  * declared, and today that gap is found only downstream, when `smith judge
@@ -11,10 +11,11 @@
11
11
  * while the declared path does not exist, allow it once it does. A harness
12
12
  * `maxTurns` cap is out of scope; this hook cannot override it.
13
13
  *
14
- * The declared-artifact line and its parser come from `dispatchLint.ts`,
15
- * never redefined — `dispatch lint` already refuses, before dispatch, any
16
- * judge prompt whose line is missing, relative, or mismatched, so this hook
17
- * only asks "does the file exist yet", never "is the line well-formed".
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".
18
19
  *
19
20
  * Fail open on anything this hook cannot read: a non-judge `agent_type`, a
20
21
  * prompt with no declared-artifact line, a relative path, unparseable stdin,
@@ -25,12 +26,26 @@
25
26
  *
26
27
  * Fail closed ONLY on a readable absolute declared path that does not exist.
27
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
+ *
28
40
  * SubagentStop stdin fields: https://code.claude.com/docs/en/hooks
29
41
  */
30
- import { existsSync, readFileSync } from 'node:fs';
42
+ import { closeSync, existsSync, openSync, readFileSync, readSync } from 'node:fs';
31
43
  import path from 'node:path';
44
+ import { StringDecoder } from 'node:string_decoder';
32
45
  import { pathToFileURL } from 'node:url';
33
- import { JUDGE_ROLES, parseDeclaredArtifactLine } from './dispatchLint.js';
46
+ import { roleOfAgentType } from './agentNames.js';
47
+ import { parseDeclaredArtifactLine } from './declaredArtifactLine.js';
48
+ import { JUDGE_ROLES } from './judgeRoles.js';
34
49
  const JUDGE_ROLE_SET = new Set(JUDGE_ROLES);
35
50
  /**
36
51
  * The pure decision: given the hook's stdin and the subagent's prompt text
@@ -39,8 +54,10 @@ const JUDGE_ROLE_SET = new Set(JUDGE_ROLES);
39
54
  * exists -- the one question this hook exists to ask.
40
55
  */
41
56
  export function decideJudgeStop(input, promptText) {
42
- const agentType = input.agent_type;
43
- if (agentType === undefined || !JUDGE_ROLE_SET.has(agentType)) {
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)) {
44
61
  // Not one of the six judge roles -- a coder's turn carries no declared
45
62
  // artifact at all, so there is nothing for this hook to check.
46
63
  return { decision: 'allow' };
@@ -58,7 +75,7 @@ export function decideJudgeStop(input, promptText) {
58
75
  return {
59
76
  decision: 'allow',
60
77
  note: 'judge-stop: no "Declared artifact: <path>" line in this prompt; allowing ' +
61
- "the stop. dispatch lint refuses this before dispatch, and judge report's " +
78
+ "the stop. The PreToolUse guard refuses this before dispatch in this clone, and judge report's " +
62
79
  'judges.artifact-missing catches it downstream.',
63
80
  };
64
81
  }
@@ -66,7 +83,7 @@ export function decideJudgeStop(input, promptText) {
66
83
  return {
67
84
  decision: 'allow',
68
85
  note: `judge-stop: declared artifact "${declared}" is not an absolute path; ` +
69
- 'allowing the stop. dispatch lint refuses this before dispatch, and judge ' +
86
+ 'allowing the stop. The PreToolUse guard refuses this before dispatch in this clone, and judge ' +
70
87
  "report's judges.artifact-missing catches it downstream.",
71
88
  };
72
89
  }
@@ -82,37 +99,73 @@ export function decideJudgeStop(input, promptText) {
82
99
  return { decision: 'allow' };
83
100
  }
84
101
  /**
85
- * The last user-role message's text in a Claude Code JSONL transcript --
86
- * where a dispatch prompt and its "Declared artifact:" line live. Walks
87
- * backward from the end, since a subagent's transcript can carry many turns
88
- * and the dispatch prompt need not be the only user turn. Returns `null` on
89
- * any read or parse failure.
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.
90
111
  */
91
- export function extractLastUserPromptText(transcriptPath) {
92
- let raw;
112
+ export function extractDispatchPromptText(transcriptPath, chunkSize = 64 * 1024) {
113
+ let fd;
93
114
  try {
94
- raw = readFileSync(transcriptPath, 'utf8');
115
+ fd = openSync(transcriptPath, 'r');
95
116
  }
96
117
  catch {
97
118
  return null;
98
119
  }
99
- const lines = raw.split('\n');
100
- for (let i = lines.length - 1; i >= 0; i--) {
101
- const line = lines[i];
102
- if (line === undefined || line.trim() === '')
103
- continue;
120
+ const consume = (line) => {
121
+ if (line.trim() === '')
122
+ return null;
104
123
  let entry;
105
124
  try {
106
125
  entry = JSON.parse(line);
107
126
  }
108
127
  catch {
109
- continue;
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
110
167
  }
111
- const text = userTextOf(entry);
112
- if (text !== null)
113
- return text;
114
168
  }
115
- return null;
116
169
  }
117
170
  function userTextOf(entry) {
118
171
  if (typeof entry !== 'object' || entry === null)
@@ -154,9 +207,14 @@ export function runJudgeStopHook(rawStdin) {
154
207
  "judge report's judges.artifact-missing remains the backstop.\n",
155
208
  };
156
209
  }
157
- const promptText = input.transcript_path
158
- ? extractLastUserPromptText(input.transcript_path)
159
- : null;
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;
160
218
  const result = decideJudgeStop(input, promptText);
161
219
  if (result.decision === 'block') {
162
220
  return {