@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.
- package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +4 -4
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +13 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +4 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +34 -27
- package/.claude/skills/bs/dispatch.md +100 -50
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +27 -18
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +181 -49
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +109 -53
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +48 -14
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +125 -17
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +790 -100
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +154 -36
- package/factory/orchestrator/dist/db/projector.js +278 -29
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +13 -37
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +323 -38
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +10 -4
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +105 -3
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +83 -12
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +92 -3
- package/factory/orchestrator/dist/integration.js +9 -7
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +90 -32
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +10 -7
- package/factory/orchestrator/dist/plan.js +269 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +4 -4
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +177 -13
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +179 -27
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +16 -13
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +97 -40
- package/factory/orchestrator/dist/waivers.js +39 -5
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +61 -19
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +7 -7
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- 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 {
|
|
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
|
-
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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 "
|
|
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
|
|
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
|
|
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
|
|
15
|
-
* never redefined —
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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 {
|
|
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
|
-
|
|
43
|
-
|
|
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.
|
|
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.
|
|
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
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* and
|
|
89
|
-
*
|
|
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
|
|
92
|
-
let
|
|
112
|
+
export function extractDispatchPromptText(transcriptPath, chunkSize = 64 * 1024) {
|
|
113
|
+
let fd;
|
|
93
114
|
try {
|
|
94
|
-
|
|
115
|
+
fd = openSync(transcriptPath, 'r');
|
|
95
116
|
}
|
|
96
117
|
catch {
|
|
97
118
|
return null;
|
|
98
119
|
}
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 {
|