@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.
@@ -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: 'Post-run: an export this task removed, still imported outside its claims. Exit 1 on a proven break.',
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. Built-in unless --policy names a file.',
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
- task_id: record.task_id,
90
- claims: record.claims,
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({ task_id: id, claims: record.claims });
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, role) => {
158
+ const link = (a, b, coupling) => {
130
159
  const map = coupledWith.get(a) ?? new Map();
131
- map.set(b, role);
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
- if (couplings !== undefined && blocker !== undefined) {
193
- const detail = couplings.get(blocker.task_id) === 'consumes'
194
- ? `Imports symbols from ${blocker.task_id}, which has not merged: the producer runs first.`
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
- 'which this command does not speculate about.');
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 (sonnet -> opus), logged
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 (sonnet -> opus, budgets.yml); after 3, escalate to
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.1.1",
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",