@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
@@ -1,10 +1,13 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parse as parseYaml } from 'yaml';
4
- import { loadBudgetPolicy } from './budgets.js';
4
+ import { agentNameFor, templateFileFor } from './agentNames.js';
5
+ import { loadBudgetPolicy, roleCapTokens } from './budgets.js';
6
+ import { epicBudgetPolicies } from './epicBudget.js';
5
7
  import { SmithError } from './errors.js';
6
8
  import { AGENTS_DIR, HARNESS_POLICY_PATH, REPO_ROOT } from './paths.js';
7
9
  import { INSPECTED_FILE_TOOLS, loadGuardrailPolicy } from './policy.js';
10
+ import { epicOfTaskId } from './taskId.js';
8
11
  import { loadTaxonomy } from './taxonomy.js';
9
12
  /**
10
13
  * The worker harness port: *how* a turn runs, named at last.
@@ -298,7 +301,7 @@ export function roleAccess(role, guardrails = loadGuardrailPolicy()) {
298
301
  }
299
302
  /** Where a role's prompt template lives, and whether it is there. */
300
303
  function templateFor(role, agentsDir) {
301
- const file = path.join(agentsDir, `${role}.md`);
304
+ const file = path.join(agentsDir, templateFileFor(role));
302
305
  return { file, exists: existsSync(file) };
303
306
  }
304
307
  function serves(harness, role) {
@@ -347,17 +350,22 @@ function resolveSchema(name, harnessName) {
347
350
  }
348
351
  return { name, file };
349
352
  }
350
- function capTokensFor(role, access, budgets) {
351
- // Only these three named roles carry a cap in budgets.yml today — every
352
- // other role (security-reviewer, merger, tester, uiux, planner, scribe)
353
- // has none, and null says so rather than inventing a number nobody set.
354
- if (access === 'judge')
355
- return budgets.task.judges.capTokens;
356
- if (role === 'coder')
357
- return budgets.task.coder.capTokens;
358
- if (role === 'researcher')
359
- return budgets.task.researcher.capTokens;
360
- return null;
353
+ function capTokensFor(role, budgets) {
354
+ // budgets.yml prices each role on its own line, sized for the policy's
355
+ // effort tier. A role it prices nowhere (merger, uiux, scribe, wave-runner)
356
+ // gets null, which says so rather than inventing a number nobody set.
357
+ return roleCapTokens(budgets, role);
358
+ }
359
+ /**
360
+ * budgets.yml sized for the effort tier of the epic `taskId` belongs to — the
361
+ * tier `cap_tokens` (and so the runner's `over_budget`) is judged at. A bare
362
+ * task id names no epic, and is priced at effort.yml's default tier.
363
+ */
364
+ function budgetsForTask(taskId, planOpts) {
365
+ const epicId = epicOfTaskId(taskId);
366
+ return epicId === null
367
+ ? loadBudgetPolicy()
368
+ : epicBudgetPolicies(planOpts === undefined ? {} : { planOpts })(epicId);
361
369
  }
362
370
  function substitute(template, values, harness) {
363
371
  return template.replace(PLACEHOLDER_RE, (_match, token) => {
@@ -471,7 +479,7 @@ export function planWorkerTurn(request, options = {}) {
471
479
  throw new HarnessError('harness.unknown-tier', `"${request.tier}" is not a model tier. It is one of ${MODEL_TIERS.join(', ')}.`, { tier: request.tier });
472
480
  }
473
481
  if (request.promptFile === undefined || request.promptFile === '') {
474
- throw new HarnessError('harness.missing-prompt', 'A worker turn needs a prompt file. This port renders invocations, not prompts — write the prompt first (`smith prompt record`), then plan the turn that carries it.', { role });
482
+ throw new HarnessError('harness.missing-prompt', 'A worker turn needs a prompt file. This port renders invocations, not prompts — write the prompt first (`bs prompt record`), then plan the turn that carries it.', { role });
475
483
  }
476
484
  const name = request.harness ?? policy.defaultHarness;
477
485
  const harness = policy.harnesses.find((h) => h.name === name);
@@ -509,7 +517,7 @@ export function planWorkerTurn(request, options = {}) {
509
517
  return {
510
518
  ...base,
511
519
  kind: 'in-process',
512
- subagentType: role,
520
+ subagentType: agentNameFor(role),
513
521
  template: templateRelative,
514
522
  };
515
523
  }
@@ -536,11 +544,11 @@ export function planWorkerTurn(request, options = {}) {
536
544
  const roleArgs = access === 'judge' ? harness.judgeArgs : harness.workerArgs;
537
545
  const rawArgs = [...harness.args, ...roleArgs, ...(schema !== null ? harness.schemaArgs : [])];
538
546
  const args = rawArgs.map((arg) => substitute(arg, values, harness.name));
539
- const budgets = options.budgets ?? loadBudgetPolicy();
547
+ const budgets = options.budgets ?? budgetsForTask(taskId, options.planOpts);
540
548
  const budget = {
541
549
  timeout_ms: harness.timeoutMs,
542
550
  max_output_bytes: harness.maxOutputBytes ?? HARNESS_DEFAULT_MAX_OUTPUT_BYTES,
543
- cap_tokens: capTokensFor(role, access, budgets),
551
+ cap_tokens: capTokensFor(role, budgets),
544
552
  };
545
553
  return {
546
554
  ...base,
@@ -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
@@ -11,8 +11,49 @@
11
11
  // `policyHook.ts` be an entry point whose imports are only what deciding
12
12
  // actually needs, while `cli.ts` keeps `smith policy hook` by calling the
13
13
  // same function rather than a second copy of it.
14
- import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
14
+ import { accessSync, constants, realpathSync, statSync } from 'node:fs';
15
+ import path from 'node:path';
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';
15
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
+ }
16
57
  /**
17
58
  * Reads one PreToolUse payload and answers with the envelope to print, or
18
59
  * `null` to print nothing.
@@ -32,8 +73,13 @@ import { activeSandboxFor } from './sandbox.js';
32
73
  * again: the first is failure, the second is a real allow, and only guard.sh's
33
74
  * fail-closed handling of a non-zero exit may turn "failure" into a denial.
34
75
  */
35
- export function decideHookPayload(raw, fallbackCwd) {
76
+ export function decideHookPayload(raw, fallbackCwd,
77
+ /** Where judge leases live; the live `state/sandboxes` unless a test points elsewhere. */
78
+ leaseDir) {
36
79
  const payload = JSON.parse(raw);
80
+ if (payload.tool_name === 'Agent' || payload.tool_name === 'Task') {
81
+ return decideAgentDispatch(payload.tool_input);
82
+ }
37
83
  const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
38
84
  const command = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
39
85
  // A `Write`/`Edit`/`MultiEdit` payload carries no command; its target is
@@ -44,34 +90,99 @@ export function decideHookPayload(raw, fallbackCwd) {
44
90
  // handed. Same shape as `command`: a missing or non-string field reads as
45
91
  // absent, never as a guess.
46
92
  const filePath = typeof payload.tool_input?.file_path === 'string' ? payload.tool_input.file_path : null;
47
- // Branch and repo root come from where the command would actually run, which
48
- // the PreToolUse payload carries as `cwd`. Resolving them from this binary's
49
- // own checkout instead would be wrong in the case this factory spends most of
50
- // its time in: guard.sh always execs the main clone's dist, while the session
51
- // issuing the command is usually inside a per-task git worktree on a
52
- // different branch. That gap is not academic — `git rebase` on
53
- // smith/<epic>/integration is a rule-5 denial, and the merge queue performs
54
- // rebases from a worktree standing on exactly that branch while the main
55
- // clone stands elsewhere. The old bash hook got this right for free by
56
- // running `git rev-parse` in its own inherited cwd; the port has to ask for
57
- // it. `fallbackCwd` covers a payload without the field (a hand-driven
58
- // invocation, or an older client), and a cwd outside any repo resolves to
59
- // ''/null, which denies an out-of-bounds removal rather than allowing it.
93
+ // Branch and repo root come from where the command would actually run.
94
+ // Resolving them from this binary's own checkout would be wrong in the case
95
+ // this factory spends most of its time in: guard.sh always execs the main
96
+ // clone's dist, while the session issuing the command is usually inside a
97
+ // per-task git worktree on a different branch. That gap is not academic —
98
+ // `git rebase` on smith/<epic>/integration is a rule-5 denial, and the merge
99
+ // queue performs rebases from a worktree standing on exactly that branch
100
+ // while the main clone stands elsewhere. `fallbackCwd` covers a payload
101
+ // without the field (a hand-driven invocation, or an older client), and a
102
+ // cwd outside any repo resolves to ''/null, which denies an out-of-bounds
103
+ // removal rather than allowing it.
104
+ //
105
+ // The payload's `cwd` is where the SESSION stands, though, and a command can
106
+ // move before it acts. `cd <worktree> && git merge main` from a session in
107
+ // the main clone runs on the worktree's side branch, so judging it on `main`
108
+ // is a false deny; `cd <main clone> && git merge x` from a worktree session
109
+ // is the matching false allow. So one narrow shape — `shortcutDirectories`
110
+ // — is judged in its target alone, dropping `cwd`; that is the ONLY way this
111
+ // function can ever be more lenient than judging `cwd`, so the shape is an
112
+ // allowlist and anything it cannot read with certainty (including a target
113
+ // that is not on a named branch of a repo) falls back. The fallback judges
114
+ // `cwd` plus every directory the command names, and the command is denied
115
+ // if ANY of them denies it, so it is never more lenient than `cwd` alone.
116
+ // A command naming no directory change is judged on `cwd` alone, as before.
60
117
  const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? payload.cwd : fallbackCwd;
61
- const context = {
62
- toolName,
63
- command,
64
- branch: detectCurrentBranch(cwd),
65
- repoRoot: detectRepoRoot(cwd),
66
- // Resolved from the same `cwd`, and for the same reason: the lease is keyed
67
- // by the worktree a judge was handed, and `cwd` is the only thing in a
68
- // PreToolUse payload that says where the command will run. No lease is the
69
- // ordinary case and costs one directory read.
70
- sandbox: activeSandboxFor(cwd),
71
- filePath,
72
- };
73
- const decision = evaluateCommand(context, loadGuardrailPolicy());
74
- if (decision.allowed) {
118
+ const policy = loadGuardrailPolicy();
119
+ // The lease binds the session a judge was handed, so it is read from `cwd`
120
+ // whatever the command does next: reading it only from the cd target would
121
+ // let `cd <elsewhere> && curl …` leave the sandbox in one hop. A lease over
122
+ // a target is checked too, so moving INTO a leased worktree is bound by it.
123
+ // No lease is the ordinary case and costs one directory read.
124
+ const sessionLease = activeSandboxFor(cwd, leaseDir);
125
+ const locate = (dir) => ({
126
+ dir,
127
+ branch: detectCurrentBranch(dir),
128
+ repoRoot: detectRepoRoot(dir),
129
+ });
130
+ const certain = MOVES_RE.test(command) ? shortcutDirectories(command, cwd)?.map(locate) : null;
131
+ // `HEAD` is what a detached worktree's branch reads as — not a name a
132
+ // protected-branch rule can ever match, and guardrails.md requires the
133
+ // shortcut's target to sit on a named branch.
134
+ const places = certain?.every((p) => p.branch !== '' && p.branch !== 'HEAD' && p.repoRoot !== null) === true
135
+ ? certain
136
+ : fallbackDirectories(command, cwd).map(locate);
137
+ let reason = null;
138
+ let firingRuleId = null;
139
+ let firingDir = null;
140
+ for (const { dir, branch, repoRoot } of places) {
141
+ const targetLease = dir === cwd ? null : activeSandboxFor(dir, leaseDir);
142
+ for (const sandbox of distinctLeases(sessionLease, targetLease)) {
143
+ const context = { toolName, command, branch, repoRoot, sandbox, filePath };
144
+ const decision = evaluateCommand(context, policy);
145
+ // guard.sh only ever surfaced one reason: each rule block()ed and exited
146
+ // immediately on its own match, sequentially, so the first rule in
147
+ // guard.sh's 1-6 order to fire is the only one an agent ever saw.
148
+ // evaluateCommand's checks array preserves that order, so violations[0]
149
+ // of the first denying evaluation reproduces guard.sh's visible
150
+ // behaviour even though evaluateCommand (policy check's diagnostic
151
+ // output) reports every rule that tripped.
152
+ if (!decision.allowed) {
153
+ const violation = decision.violations[0];
154
+ reason = violation?.reason ?? 'guardrails.yml denied this command.';
155
+ firingRuleId = violation?.ruleId ?? null;
156
+ firingDir = dir;
157
+ break;
158
+ }
159
+ }
160
+ if (reason !== null)
161
+ break;
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
+ }
185
+ if (reason === null) {
75
186
  // Allow prints NOTHING, on purpose, and this is not a cosmetic choice.
76
187
  // Claude Code reads an explicit `permissionDecision: "allow"` from a hook
77
188
  // as "skip the permission system for this call" — it outranks the
@@ -87,15 +198,6 @@ export function decideHookPayload(raw, fallbackCwd) {
87
198
  // load-bearing the way the old hook's was.
88
199
  return null;
89
200
  }
90
- // guard.sh only ever surfaced one reason: each rule block()ed and exited
91
- // immediately on its own match, sequentially, so the first rule in guard.sh's
92
- // 1-6 order to fire is the only one an agent ever saw. evaluateCommand's
93
- // checks array preserves that same order, so violations[0] reproduces
94
- // guard.sh's exact visible behaviour even though evaluateCommand (policy
95
- // check's diagnostic output) reports every rule that tripped, not just the
96
- // first.
97
- const first = decision.violations[0];
98
- const reason = first ? first.reason : 'guardrails.yml denied this command.';
99
201
  return {
100
202
  hookSpecificOutput: {
101
203
  hookEventName: 'PreToolUse',
@@ -104,3 +206,273 @@ export function decideHookPayload(raw, fallbackCwd) {
104
206
  },
105
207
  };
106
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
+ }
229
+ /**
230
+ * The one shape judged in its target alone, or `null` to fall back. An
231
+ * allowlist, because this is the only path that drops `cwd` and so the only
232
+ * one that can allow what `cwd` alone would deny:
233
+ *
234
+ * - `cd <target> && <cmd> && <cmd> …`: a cd, then plain words joined by
235
+ * nothing but `&&` — so everything after runs in the target or not at
236
+ * all. A relative target must start with `./` or `../` (or be `.`/`..`),
237
+ * since a bare name is looked up on CDPATH first.
238
+ * - `git -C <target> <plain words>`, alone.
239
+ *
240
+ * A plain word is unquoted and uses only `PLAIN_WORD_RE`'s characters: no
241
+ * `;`, `|`, `&`, newline, backslash, `$`, backtick, quote, parens, glob or
242
+ * redirection. The target may also be one whole single- or double-quoted
243
+ * span. Every `&&`-joined command after the target must be a `git`
244
+ * invocation whose subcommand sits in `GIT_SUBCOMMAND_ALLOWLIST` —
245
+ * positively, not a denylist of mover words: any plain word could be a shell
246
+ * alias or function (zsh's autopushd defines `-` and `1`..`9` as `cd`
247
+ * shortcuts), so nothing short of "must be a git builtin that cannot run a
248
+ * command in another repo" is sound. `checkout` and `switch` are excluded
249
+ * even though they only ever touch the repo they run in: either can move the
250
+ * target onto a different branch mid-command, and the branch judged is read
251
+ * once, before the command runs — `cd <W> && git checkout main && git merge
252
+ * x` would be judged on W's branch before the checkout, not the `main` it
253
+ * actually merges onto. Git builtins cannot be shadowed by a git alias; a
254
+ * shell alias named `git` itself is out of scope. A mover flag
255
+ * (`MOVER_FLAG_RE`), a short option bundling `x` or `s`
256
+ * (`hasDangerousShortOption` — rebase `-x`/`-kx`, an arbitrary command; merge
257
+ * or checkout `-s`, a strategy or search run off PATH as `git-<name>`), or a
258
+ * long option starting `--e`/`--s` (`hasDangerousLongOption` — `--exec`,
259
+ * `--strategy`, and their `=value` forms) still forfeits, and so does a git
260
+ * location override anywhere. Returns the target lexically and physically
261
+ * when the two differ (a cd is logical, git's chdir physical), and the
262
+ * caller falls back too if any of them is not on a named branch of a repo.
263
+ */
264
+ function shortcutDirectories(command, cwd) {
265
+ // No `.trim()`/split reliance below this line for a security decision: both
266
+ // read NBSP, BOM and form-feed as whitespace and drop them, while a shell's
267
+ // IFS does not — so a target's trailing NBSP would parse away to a clean,
268
+ // different directory than the one the shell actually enters.
269
+ if (NON_ASCII_RE.test(command))
270
+ return null;
271
+ if (GIT_LOCATION_RE.test(command) || /[\r\n]/.test(command.trim()))
272
+ return null;
273
+ const segments = command
274
+ .trim()
275
+ .split('&&')
276
+ .map((segment) => segment.trim().split(/[ \t]+/));
277
+ const [first = [], ...rest] = segments;
278
+ let target;
279
+ let after;
280
+ if (first.length === 2 && first[0] === 'cd' && rest.length > 0) {
281
+ target = first[1] ?? '';
282
+ after = rest.flat();
283
+ if (!/^\.{1,2}(?:\/|$)|^\//.test(unquote(target) ?? ''))
284
+ return null;
285
+ if (!rest.every((segment) => segment[0] === 'git' && GIT_SUBCOMMAND_ALLOWLIST.has(segment[1] ?? '')))
286
+ return null;
287
+ }
288
+ else if (segments.length === 1 && first[0] === 'git' && first[1] === '-C' && first.length > 3) {
289
+ target = first[2] ?? '';
290
+ after = first.slice(3);
291
+ if (!GIT_SUBCOMMAND_ALLOWLIST.has(after[0] ?? ''))
292
+ return null;
293
+ }
294
+ else {
295
+ return null;
296
+ }
297
+ const plain = (word) => PLAIN_WORD_RE.test(word) && !MOVER_WORDS.has(word) && !MOVER_FLAG_RE.test(word);
298
+ if (!after.every(plain))
299
+ return null;
300
+ if (after.some((word) => hasDangerousShortOption(word) || hasDangerousLongOption(word)))
301
+ return null;
302
+ if (!PLAIN_WORD_RE.test(target) && !/^'[^']*'$|^"[^"]*"$/.test(target))
303
+ return null;
304
+ return literalDirectories(target, cwd);
305
+ }
306
+ /** Every directory a command names, plus `cwd` — the fail-closed fallback. */
307
+ function fallbackDirectories(command, cwd) {
308
+ const dirs = [cwd];
309
+ for (const [, word = ''] of command.matchAll(TARGET_RE)) {
310
+ // A relative hop is resolved against every directory found so far, so
311
+ // `cd a && cd b` still reaches `a/b`: an extra candidate costs nothing
312
+ // but a possible denial, and a missed one is a hole.
313
+ for (const base of [...dirs]) {
314
+ for (const target of literalDirectories(word, base) ?? []) {
315
+ if (!dirs.includes(target))
316
+ dirs.push(target);
317
+ }
318
+ }
319
+ }
320
+ return dirs;
321
+ }
322
+ /**
323
+ * Anything that could put a command somewhere other than where it started.
324
+ * Deliberately loose — `echo cd` or `git commit -C HEAD` match too — because
325
+ * a match only forfeits the no-move path, never a check.
326
+ */
327
+ const MOVES_RE = /(?:^|[\s;&|(])(?:cd|pushd|popd)(?=$|[\s;&|)])|\s-C(?:\s|$)|[()`]/;
328
+ /** A word the shortcut reads as itself: nothing the shell would expand, split or redirect. */
329
+ const PLAIN_WORD_RE = /^[A-Za-z0-9_./:@%+,=-]+$/;
330
+ /**
331
+ * Anything outside printable ASCII (tab allowed as a separator), forfeiting
332
+ * the shortcut before any `.trim()`/split touches the command — see
333
+ * `shortcutDirectories`'s note on why those cannot be trusted past this
334
+ * point.
335
+ */
336
+ const NON_ASCII_RE = /[^\t\x20-\x7E]/;
337
+ /**
338
+ * Git builtins the shortcut may follow a `cd`/`-C` target with: read-only or
339
+ * ordinary write commands that act on the repo they are invoked in, never on
340
+ * another one, and that cannot themselves move the target onto a different
341
+ * branch than the one just read. Deliberately excludes indirect executors
342
+ * that run a git command somewhere else (`for-each-repo`, `submodule`),
343
+ * anything that rewrites history destructively outside a plain invocation
344
+ * (`filter-branch`, `filter-repo`, `worktree`, `bisect`), and `checkout`/
345
+ * `switch` — either can move the target's branch mid-command, after
346
+ * `shortcutDirectories`' caller has already read it once, so a later command
347
+ * in the same chain would be judged on the branch the target left rather
348
+ * than the one it moved to. A git builtin cannot be shadowed by a git alias,
349
+ * which is what makes this allowlist sound.
350
+ */
351
+ const GIT_SUBCOMMAND_ALLOWLIST = new Set([
352
+ 'status',
353
+ 'log',
354
+ 'diff',
355
+ 'show',
356
+ 'add',
357
+ 'commit',
358
+ 'merge',
359
+ 'rebase',
360
+ 'push',
361
+ 'fetch',
362
+ 'pull',
363
+ 'branch',
364
+ 'reset',
365
+ 'rev-parse',
366
+ 'tag',
367
+ 'stash',
368
+ ]);
369
+ /**
370
+ * A short option (`-x`, `-kx`, `-x./s`) that bundles `x` or `s` among its
371
+ * option letters, read up to the first non-letter (a stuck argument, `=`, or
372
+ * end of word) — `git rebase -x <cmd>`/`-kx<cmd>` runs an arbitrary command,
373
+ * and `git merge -s <strategy>`/`git checkout -s` (`-s` is a bundlable short
374
+ * flag on several subcommands) can run `git-<strategy>` off PATH. A plain
375
+ * number (`-5`) is exempt, since `git log -5` is common and harmless.
376
+ */
377
+ function hasDangerousShortOption(word) {
378
+ if (!word.startsWith('-') || word.startsWith('--'))
379
+ return false;
380
+ if (/^-\d+$/.test(word))
381
+ return false;
382
+ const letters = /^-([A-Za-z]+)/.exec(word)?.[1] ?? '';
383
+ return letters.includes('x') || letters.includes('s');
384
+ }
385
+ /**
386
+ * A long option that could be `--exec`/`--exec=…` (rebase, arbitrary command)
387
+ * or `--strategy`/`--strategy-option=…` (merge/rebase, runs `git-<name>` off
388
+ * PATH) by prefix rather than exact match, since either can carry `=value`.
389
+ * Over-forfeits `--edit`, `--squash`, and the like on purpose: forfeiting
390
+ * only falls back to the full check, never widens what the shortcut allows.
391
+ */
392
+ function hasDangerousLongOption(word) {
393
+ return word.startsWith('--e') || word.startsWith('--s');
394
+ }
395
+ /** Commands that can move the shell, or run a command string somewhere else. */
396
+ const MOVER_WORDS = new Set([
397
+ 'cd',
398
+ 'pushd',
399
+ 'popd',
400
+ 'eval',
401
+ 'source',
402
+ '.',
403
+ 'exec',
404
+ 'command',
405
+ 'builtin',
406
+ 'env',
407
+ 'sudo',
408
+ 'xargs',
409
+ 'find',
410
+ 'sh',
411
+ 'bash',
412
+ 'zsh',
413
+ 'dash',
414
+ 'ksh',
415
+ ]);
416
+ /** Flags that move a command (`-C`, `--chdir`, `--directory`) or configure git (`-c`). */
417
+ const MOVER_FLAG_RE = /^(?:-C|-c$|--chdir|--directory)/;
418
+ /** Git location overrides: they point git at another repo whatever the cwd. */
419
+ const GIT_LOCATION_RE = /GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|--git-dir|--work-tree/;
420
+ /** One word: double-quoted, single-quoted, or bare up to a separator. */
421
+ const WORD = `("[^"]*"|'[^']*'|[^\\s;&|<>()]+)`;
422
+ /** Every `cd`/`pushd <word>` and every `-C <word>`, wherever it sits. */
423
+ const TARGET_RE = new RegExp(`(?:(?:^|[\\s;&|(])(?:cd|pushd)|\\s-C)\\s+${WORD}`, 'g');
424
+ /**
425
+ * `word` with its quotes removed, or `null` when it is quoted any other way
426
+ * than one whole span: `"a"/"b"` is two spans the shell splices, and reading
427
+ * it as one quoted `a"/"b` names a different directory.
428
+ */
429
+ function unquote(word) {
430
+ if (/^'[^']*'$/.test(word) || /^"[^"]*"$/.test(word))
431
+ return word.slice(1, -1);
432
+ return /["'\\]/.test(word) ? null : word;
433
+ }
434
+ /**
435
+ * `word` as an existing, enterable directory resolved against `base` —
436
+ * lexically (where a logical `cd` lands) and physically (where git's chdir
437
+ * lands, `..` after a symlink included), both when they differ — or `null`
438
+ * when it is not a literal path: an expansion (`$`, backtick, `~`), a glob, a
439
+ * flag or `-`, an escape, a quote this does not read, or a path that does not
440
+ * resolve. A cd there fails and runs nothing after its `&&`, and this is not
441
+ * the place to guess what it meant.
442
+ */
443
+ function literalDirectories(word, base) {
444
+ const bare = unquote(word);
445
+ if (bare === null || bare === '' || bare.startsWith('-') || /[$`~*?[\]\\]/.test(bare)) {
446
+ return null;
447
+ }
448
+ const lexical = path.resolve(base, bare);
449
+ try {
450
+ // `native` is realpath(3); the JS realpathSync resolves `..` lexically first.
451
+ const physical = realpathSync.native(path.isAbsolute(bare) ? bare : `${base}/${bare}`);
452
+ if (!statSync(physical).isDirectory())
453
+ return null;
454
+ accessSync(physical, constants.X_OK);
455
+ if (lexical === physical || !isDirectory(lexical))
456
+ return [physical];
457
+ return [lexical, physical];
458
+ }
459
+ catch {
460
+ return null;
461
+ }
462
+ }
463
+ function isDirectory(dir) {
464
+ try {
465
+ return statSync(dir).isDirectory();
466
+ }
467
+ catch {
468
+ return false;
469
+ }
470
+ }
471
+ /** The leases to judge under — `[null]` for none, so one unleased evaluation still runs. */
472
+ function distinctLeases(session, target) {
473
+ if (session === null)
474
+ return [target];
475
+ if (target === null || target.worktreeDir === session.worktreeDir)
476
+ return [session];
477
+ return [session, target];
478
+ }
@@ -1,8 +1,8 @@
1
1
  import { SmithError } from './errors.js';
2
- import { appendEvent } from './events.js';
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) {
@@ -75,8 +77,18 @@ export function latestIntegrationCheck(events, epicId) {
75
77
  * one thing this must never do is write down a pass that nothing earned.
76
78
  */
77
79
  export async function runIntegrationCheck(input, ctx, opts = {}) {
80
+ // Refused before the check suite below spends a full run on it: a bad
81
+ // --causal-parent used to surface only at the append this function makes
82
+ // once the suite has already finished (D-42's shape, one level up — the
83
+ // waste here is CPU time, not a git mutation, but the same envelope
84
+ // `appendEvent` would refuse anyway, so refuse it first).
85
+ await validateEventEnvelope({
86
+ session_id: ctx.sessionId,
87
+ event_type: INTEGRATION_CHECK_EVENT,
88
+ causal_parent: ctx.causalParent,
89
+ }, opts);
78
90
  const { epicId, projectDir } = input;
79
- const branch = integrationBranchName(epicId);
91
+ const branch = integrationBranchName(epicId, epicBranchPrefix(projectDir, epicId));
80
92
  // testgate.run([]) returns pass: true for an empty list — correct there
81
93
  // (a task claiming no checks has nothing to short-circuit), and a silent
82
94
  // forgery here. "No checks configured" must never read back as "the