@juzser/blacksmith 0.1.1 → 0.2.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/planner.md +21 -0
- package/.claude/skills/bs/SKILL.md +35 -19
- package/.claude/skills/bs/audit.md +4 -2
- package/.claude/skills/bs/dispatch.md +5 -5
- package/.claude/skills/bs/new.md +4 -2
- package/.claude/skills/bs/plan.md +22 -5
- package/.claude/skills/bs/report.md +3 -3
- package/.claude/skills/bs/run.md +6 -4
- package/.claude/skills/bs/ui.md +10 -3
- package/.claude/skills/bs/wave.md +13 -7
- package/README.md +56 -53
- package/factory/orchestrator/dist/claims.js +27 -1
- package/factory/orchestrator/dist/cli.js +94 -25
- package/factory/orchestrator/dist/daemon.js +16 -5
- package/factory/orchestrator/dist/events.js +50 -0
- package/factory/orchestrator/dist/harness.js +233 -84
- package/factory/orchestrator/dist/impact.js +76 -5
- package/factory/orchestrator/dist/paths.js +2 -0
- package/factory/orchestrator/dist/plan.js +60 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +7 -73
- package/factory/orchestrator/dist/run-cli.js +130 -0
- package/factory/orchestrator/dist/runner.js +264 -0
- package/factory/orchestrator/dist/spawn.js +84 -0
- package/factory/orchestrator/dist/usage.js +8 -6
- package/factory/orchestrator/dist/waveNext.js +42 -14
- package/factory/orchestrator/dist/waveSchedule.js +7 -1
- package/factory/policies/budgets.yml +1 -1
- package/factory/policies/harness.yml +82 -0
- package/factory/policies/severity.yml +1 -1
- package/factory/specs/schema/task-spec.schema.json +5 -0
- package/package.json +3 -2
|
@@ -198,6 +198,7 @@ export const COMMANDS = [
|
|
|
198
198
|
positionals: '',
|
|
199
199
|
flags: `[--interval <seconds>] [--once] [--dir <dir>] [--project <dir>...] [--no-self] [--db <path>] [--no-db] [--state-dir <dir>]`,
|
|
200
200
|
summary: 'Watch the event log in the foreground: budgets, stale agents, work that is due. ' +
|
|
201
|
+
'Prints one report per tick, one JSON line each; --once prints one document and exits. ' +
|
|
201
202
|
'Never dispatches.',
|
|
202
203
|
},
|
|
203
204
|
{
|
|
@@ -300,8 +301,9 @@ export const COMMANDS = [
|
|
|
300
301
|
{
|
|
301
302
|
command: 'event tail',
|
|
302
303
|
positionals: '<session-id>',
|
|
303
|
-
flags: '[--n <count>] [--task <task-id>] [--lineage] [--state-dir <dir>]',
|
|
304
|
-
summary: 'Print the last n records of a session that exists. Exit 1 if it does not.'
|
|
304
|
+
flags: '[--n <count>] [--task <task-id>] [--lineage] [--follow] [--state-dir <dir>]',
|
|
305
|
+
summary: 'Print the last n records of a session that exists. Exit 1 if it does not. ' +
|
|
306
|
+
'--follow keeps printing as the log grows, one JSON record per line, until ^C.',
|
|
305
307
|
},
|
|
306
308
|
{
|
|
307
309
|
command: 'event lineage',
|
|
@@ -340,14 +342,14 @@ export const COMMANDS = [
|
|
|
340
342
|
form: '--plan',
|
|
341
343
|
positionals: '<task-id>...',
|
|
342
344
|
flags: '--plan <file> [--repo <dir>]',
|
|
343
|
-
summary: 'Pre-run: refuse a wave whose tasks sit on either end of an import edge. Exit 1 when coupled.',
|
|
345
|
+
summary: 'Pre-run: refuse a wave whose tasks sit on either end of an import edge the producer did not promise in keeps_exports. Exit 1 when coupled.',
|
|
344
346
|
},
|
|
345
347
|
{
|
|
346
348
|
command: 'claims impact',
|
|
347
349
|
form: 'spec',
|
|
348
350
|
positionals: '<worktree-dir> <spec.json>',
|
|
349
351
|
flags: '',
|
|
350
|
-
summary:
|
|
352
|
+
summary: "Post-run: an export this task removed, still imported outside its claims, and verifies the spec's keeps_exports promises. Exit 1 on a proven break or a broken promise.",
|
|
351
353
|
},
|
|
352
354
|
{
|
|
353
355
|
command: 'dispatch check',
|
|
@@ -389,12 +391,12 @@ export const COMMANDS = [
|
|
|
389
391
|
command: 'harness list',
|
|
390
392
|
positionals: '',
|
|
391
393
|
flags: '[--policy <file>]',
|
|
392
|
-
summary: 'The harnesses that may run a worker turn, and which roles each one serves.
|
|
394
|
+
summary: 'The harnesses that may run a worker turn, and which roles each one serves. Shipped policy unless --policy names a file.',
|
|
393
395
|
},
|
|
394
396
|
{
|
|
395
397
|
command: 'harness plan',
|
|
396
398
|
positionals: '',
|
|
397
|
-
flags: '--role <role> --task <id> --prompt-file <path> [--harness <name>] [--worktree <dir>] [--policy <file>]',
|
|
399
|
+
flags: '--role <role> --task <id> --prompt-file <path> [--harness <name>] [--worktree <dir>] [--policy <file>] [--tier <frontier|mid|small>] [--schema <name>]',
|
|
398
400
|
summary: 'Render the invocation that would run one worker turn, as JSON. Prints it; never starts it.',
|
|
399
401
|
},
|
|
400
402
|
{
|
|
@@ -85,10 +85,36 @@ function admissionOrder(candidates, edges, crossings) {
|
|
|
85
85
|
* until `computeNextWave` has partitioned them by status.
|
|
86
86
|
*/
|
|
87
87
|
export function liveWaveTasks(plan) {
|
|
88
|
-
return livePlanTasks(plan).map((record) => readClaimList(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
return livePlanTasks(plan).map((record) => readClaimList(proposedFrom(record)));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The plan record as `readClaimList` wants it: claims always, the
|
|
92
|
+
* `keeps_exports` promise only when the record has one, so a task that
|
|
93
|
+
* promised nothing reads back without the key rather than with `undefined`.
|
|
94
|
+
*/
|
|
95
|
+
function proposedFrom(record) {
|
|
96
|
+
const { claims, keeps_exports } = record;
|
|
97
|
+
return keeps_exports === undefined
|
|
98
|
+
? { task_id: record.task_id, claims }
|
|
99
|
+
: { task_id: record.task_id, claims, keeps_exports };
|
|
100
|
+
}
|
|
101
|
+
const NAMED_SYMBOLS = 3;
|
|
102
|
+
/**
|
|
103
|
+
* The deferral names both files and both remedies, because the operator's
|
|
104
|
+
* choice is between them: order the tasks, or have the producer promise the
|
|
105
|
+
* exporting file in `keeps_exports` (impact.ts verifies it post-run). Only
|
|
106
|
+
* the producer can promise, so the sentence names the producer either way.
|
|
107
|
+
*/
|
|
108
|
+
function describeCoupling(taskId, blocker, coupling) {
|
|
109
|
+
const { crossing, role } = coupling;
|
|
110
|
+
const shown = crossing.symbols.slice(0, NAMED_SYMBOLS);
|
|
111
|
+
const symbols = [...shown, ...(crossing.symbols.length > NAMED_SYMBOLS ? ['…'] : [])].join(', ');
|
|
112
|
+
const typeOnly = crossing.typeOnly ? ', type-only' : '';
|
|
113
|
+
return role === 'consumes'
|
|
114
|
+
? `Imports ${symbols} from ${crossing.exportedBy} (${blocker}) into ${crossing.importedBy}${typeOnly}: ` +
|
|
115
|
+
`the producer runs first, or ${blocker} promises the file in keeps_exports and both run now.`
|
|
116
|
+
: `${blocker} imports ${symbols} from ${crossing.exportedBy} into ${crossing.importedBy}${typeOnly}${typeOnly ? ',' : ''} ` +
|
|
117
|
+
`and is already scheduled, so this one follows, or ${taskId} promises the file in keeps_exports and both run now.`;
|
|
92
118
|
}
|
|
93
119
|
export function computeNextWave(input) {
|
|
94
120
|
const { plan, policy } = input;
|
|
@@ -107,7 +133,7 @@ export function computeNextWave(input) {
|
|
|
107
133
|
}
|
|
108
134
|
// Past this door claims are a real string[], for a candidate and for a
|
|
109
135
|
// task merely holding ground — the wave is unanswerable either way.
|
|
110
|
-
const task = readClaimList(
|
|
136
|
+
const task = readClaimList(proposedFrom(record));
|
|
111
137
|
if (CANDIDATE_TASK_STATUSES.has(status)) {
|
|
112
138
|
candidates.push(task);
|
|
113
139
|
}
|
|
@@ -125,15 +151,19 @@ export function computeNextWave(input) {
|
|
|
125
151
|
list.push(edge.dependsOn);
|
|
126
152
|
dependsByTask.set(edge.task, list);
|
|
127
153
|
}
|
|
154
|
+
// task -> (other task -> the first crossing between them, and this task's
|
|
155
|
+
// side of it). First wins: the caller hands crossings in a stable order,
|
|
156
|
+
// and one named crossing is what the deferral renders.
|
|
128
157
|
const coupledWith = new Map();
|
|
129
|
-
const link = (a, b,
|
|
158
|
+
const link = (a, b, coupling) => {
|
|
130
159
|
const map = coupledWith.get(a) ?? new Map();
|
|
131
|
-
map.
|
|
160
|
+
if (!map.has(b))
|
|
161
|
+
map.set(b, coupling);
|
|
132
162
|
coupledWith.set(a, map);
|
|
133
163
|
};
|
|
134
164
|
for (const crossing of crossings) {
|
|
135
|
-
link(crossing.consumer, crossing.producer, 'consumes');
|
|
136
|
-
link(crossing.producer, crossing.consumer, 'produces');
|
|
165
|
+
link(crossing.consumer, crossing.producer, { role: 'consumes', crossing });
|
|
166
|
+
link(crossing.producer, crossing.consumer, { role: 'produces', crossing });
|
|
137
167
|
}
|
|
138
168
|
const admitted = [];
|
|
139
169
|
const deferred = [];
|
|
@@ -189,11 +219,9 @@ export function computeNextWave(input) {
|
|
|
189
219
|
const couplings = coupledWith.get(id);
|
|
190
220
|
const coupled = couplings ? holders.filter((h) => couplings.has(h.task_id)) : [];
|
|
191
221
|
const blocker = coupled[0];
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
: `${blocker.task_id} imports symbols from it and is already scheduled, so this one follows.`;
|
|
196
|
-
defer(id, 'symbol-coupled', coupled.map((h) => h.task_id), detail);
|
|
222
|
+
const coupling = couplings?.get(blocker?.task_id ?? '');
|
|
223
|
+
if (blocker !== undefined && coupling !== undefined) {
|
|
224
|
+
defer(id, 'symbol-coupled', coupled.map((h) => h.task_id), describeCoupling(id, blocker.task_id, coupling));
|
|
197
225
|
continue;
|
|
198
226
|
}
|
|
199
227
|
admitted.push(task);
|
|
@@ -65,10 +65,16 @@ function buildHint(stalled, occupied, constraints, depth) {
|
|
|
65
65
|
}
|
|
66
66
|
if (constraints.length > 0) {
|
|
67
67
|
const reasons = constraints.map((c) => c.reason);
|
|
68
|
+
// A crossing has a remedy that is not a re-slice, and the hint is where
|
|
69
|
+
// the operator learns it exists: the producer promises the exporting file
|
|
70
|
+
// in `keeps_exports`, and `claims impact` verifies the promise post-run.
|
|
71
|
+
const promise = reasons.includes('symbol-coupled')
|
|
72
|
+
? ' A symbol-coupled task widens when its producer lists the exporting file in keeps_exports (verified post-run).'
|
|
73
|
+
: '';
|
|
68
74
|
return (`This plan runs in ${depth} ${depth === 1 ? 'round' : 'rounds'}, and some of that ` +
|
|
69
75
|
`depth is claim geometry rather than dependencies (${nameList(reasons)}). ` +
|
|
70
76
|
'Re-slicing the claims may widen it; whether it does depends on the dependency graph, ' +
|
|
71
|
-
|
|
77
|
+
`which this command does not speculate about.${promise}`);
|
|
72
78
|
}
|
|
73
79
|
return '';
|
|
74
80
|
}
|
|
@@ -235,7 +235,7 @@ escalation_ladder:
|
|
|
235
235
|
trigger: 2 failed rounds on the same task
|
|
236
236
|
failed_rounds: 2
|
|
237
237
|
enforce: model-tier
|
|
238
|
-
action: escalate model tier automatically (
|
|
238
|
+
action: escalate model tier automatically (mid -> frontier, taxonomy.yml model_tier), logged
|
|
239
239
|
- rung: 3
|
|
240
240
|
trigger: 3 failed rounds on the same task
|
|
241
241
|
failed_rounds: 3
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Blacksmith — worker harness policy
|
|
2
|
+
#
|
|
3
|
+
# architecture §19 "Worker harness port". A *provider* is the model vendor a
|
|
4
|
+
# judge's verdict comes from (crosscheck.yml); a *harness* is the program
|
|
5
|
+
# that holds a worker turn — gives the agent its tools, its working directory,
|
|
6
|
+
# its turn limit. HOST: `smith harness list` / `smith harness plan`
|
|
7
|
+
# (factory/orchestrator/src/harness.ts), which read this file when `--policy`
|
|
8
|
+
# names no other. `smith` renders an invocation from it and never starts one
|
|
9
|
+
# (§18 rule 3); `smith-run` (src/run-cli.ts) is the separate binary that does.
|
|
10
|
+
#
|
|
11
|
+
# Shape, per harness:
|
|
12
|
+
# kind `in-process` — the Claude Code Agent tool, no program started
|
|
13
|
+
# (`command`/`args` unused); `cli` — a program `smith-run` spawns
|
|
14
|
+
# with the rendered prompt on stdin.
|
|
15
|
+
# roles taxonomy roles this harness serves; empty = every role that
|
|
16
|
+
# ships a `.claude/agents/<role>.md`.
|
|
17
|
+
# args argv for every turn. Placeholders, closed set:
|
|
18
|
+
# {prompt_file} {worktree} {role} {task} {model} {schema_file}.
|
|
19
|
+
# worker_args appended for a worker or write-scoped role (guardrails.yml
|
|
20
|
+
# `role_write_scopes`).
|
|
21
|
+
# judge_args appended for a judge role (guardrails.yml `judge_sandbox.roles`).
|
|
22
|
+
# Non-empty means the program itself enforces read-only, and
|
|
23
|
+
# only then may a judge on this harness be handed a worktree
|
|
24
|
+
# (§18 rule 5 — the refusal `harness.judge-worktree` stands
|
|
25
|
+
# when this is empty). Rule 6's fingerprint runs regardless.
|
|
26
|
+
# schema_args appended only when the turn names a schema (`--schema`), so a
|
|
27
|
+
# turn that validates nothing renders without one.
|
|
28
|
+
# output how smith-run reads the program's output: `text` (default),
|
|
29
|
+
# `codex-json` (the `codex exec --json` JSONL stream),
|
|
30
|
+
# `claude-json` (the `claude -p --output-format json` object).
|
|
31
|
+
# models tier → model id for `{model}`; tiers are taxonomy.yml
|
|
32
|
+
# `model_tier` (frontier | mid | small). Optional.
|
|
33
|
+
# env variable NAMES the child may read. Never values.
|
|
34
|
+
# timeout_ms wall-clock cap per turn; max_output_bytes defaults to 2 MiB.
|
|
35
|
+
#
|
|
36
|
+
# A cli harness runs in the worktree it is handed (`cwd` on the rendered
|
|
37
|
+
# invocation), so no `-C`/`--add-dir` is needed to point it there, and a turn
|
|
38
|
+
# with no worktree still renders.
|
|
39
|
+
|
|
40
|
+
version: 2
|
|
41
|
+
default: claude-code
|
|
42
|
+
harnesses:
|
|
43
|
+
# The Claude Code Agent tool: in-process, starts no program. Serves every
|
|
44
|
+
# role with a template. Still the default (§19: switching harnesses is a
|
|
45
|
+
# `--harness` flag on `plan`, not an edit here).
|
|
46
|
+
- name: claude-code
|
|
47
|
+
kind: in-process
|
|
48
|
+
|
|
49
|
+
# OpenAI Codex CLI. `-s workspace-write` / `-s read-only` are Codex's own
|
|
50
|
+
# sandbox modes, which is what lets a judge role hold a worktree here. They
|
|
51
|
+
# land after the `-` (read the prompt from stdin) positional, which Codex's
|
|
52
|
+
# parser accepts: `codex exec - -s bogus` is refused as an invalid --sandbox
|
|
53
|
+
# value, not taken as a second prompt.
|
|
54
|
+
# No `-m {model}` and no `models:` are shipped: Codex runs whatever model its
|
|
55
|
+
# own config names, and this file does not guess which of its models is
|
|
56
|
+
# frontier, mid or small. An operator who wants tier routing adds
|
|
57
|
+
# `models: { frontier: <id>, mid: <id>, small: <id> }` and `-m`, `{model}`
|
|
58
|
+
# to `args`; the templates' move to `tier:` frontmatter (PR 2) fills it in.
|
|
59
|
+
- name: codex-cli
|
|
60
|
+
kind: cli
|
|
61
|
+
command: codex
|
|
62
|
+
args: ["exec", "--json", "--color", "never", "--skip-git-repo-check", "-"]
|
|
63
|
+
worker_args: ["-s", "workspace-write"]
|
|
64
|
+
judge_args: ["-s", "read-only"]
|
|
65
|
+
schema_args: ["--output-schema", "{schema_file}"]
|
|
66
|
+
output: codex-json
|
|
67
|
+
env: [HOME, PATH, CODEX_HOME]
|
|
68
|
+
timeout_ms: 1800000
|
|
69
|
+
|
|
70
|
+
# Claude Code as a separate process, for a runner that is not itself a Claude
|
|
71
|
+
# Code session. A judge gets no write tools at all (`--disallowedTools`),
|
|
72
|
+
# which is the read-only promise `judge_args` requires.
|
|
73
|
+
- name: claude-cli
|
|
74
|
+
kind: cli
|
|
75
|
+
command: claude
|
|
76
|
+
args: ["-p", "--output-format", "json", "--model", "{model}"]
|
|
77
|
+
worker_args: []
|
|
78
|
+
judge_args: ["--disallowedTools", "Write,Edit,MultiEdit,NotebookEdit"]
|
|
79
|
+
output: claude-json
|
|
80
|
+
models: { frontier: opus, mid: sonnet, small: haiku }
|
|
81
|
+
env: [HOME, PATH, ANTHROPIC_API_KEY, CLAUDE_CONFIG_DIR]
|
|
82
|
+
timeout_ms: 1800000
|
|
@@ -33,7 +33,7 @@ levels:
|
|
|
33
33
|
- behavioral-drift touching a core flow
|
|
34
34
|
on_trigger: >
|
|
35
35
|
bounce to coder on the same branch; after 2 failed rounds, escalate
|
|
36
|
-
model tier (
|
|
36
|
+
model tier (mid -> frontier, budgets.yml); after 3, escalate to
|
|
37
37
|
operator.
|
|
38
38
|
|
|
39
39
|
S3-minor:
|
|
@@ -128,6 +128,11 @@
|
|
|
128
128
|
"project": {
|
|
129
129
|
"type": "string",
|
|
130
130
|
"description": "Optional plain-string project identifier (Phase 6b, architecture §8) — NOT a closed taxonomy.yml vocabulary value (projects open/close far more often than any §8 dimension). Absent tasks default to 'black-smith' in the projection layer."
|
|
131
|
+
},
|
|
132
|
+
"keeps_exports": {
|
|
133
|
+
"type": "array",
|
|
134
|
+
"items": { "type": "string" },
|
|
135
|
+
"description": "Repo-relative file paths, each inside one of this task's own claims, whose existing exports this task keeps: no export removed, no export's declaration changed; adding exports is allowed. A symbol crossing whose producer promised the exporting file does not serialize the wave (impact.ts waveImpact); the promise is verified post-run by `smith claims impact <worktree> <spec.json>` against the diff."
|
|
131
136
|
}
|
|
132
137
|
},
|
|
133
138
|
"additionalProperties": false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juzser/blacksmith",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Autonomous agent factory — loop runner and worktree engine.",
|
|
6
6
|
"keywords": [
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"packageManager": "pnpm@9.3.0",
|
|
35
35
|
"bin": {
|
|
36
|
-
"smith": "factory/orchestrator/dist/cli.js"
|
|
36
|
+
"smith": "factory/orchestrator/dist/cli.js",
|
|
37
|
+
"smith-run": "factory/orchestrator/dist/run-cli.js"
|
|
37
38
|
},
|
|
38
39
|
"files": [
|
|
39
40
|
"factory/orchestrator/dist",
|