@juzser/blacksmith 0.1.0 → 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/coder.md +9 -3
- package/.claude/agents/grader.md +13 -5
- package/.claude/agents/merger.md +2 -0
- package/.claude/agents/planner.md +22 -1
- package/.claude/agents/researcher.md +3 -1
- package/.claude/agents/reviewer.md +9 -2
- package/.claude/agents/security-reviewer.md +15 -7
- package/.claude/agents/spec-reviewer.md +12 -3
- package/.claude/agents/tester.md +5 -2
- package/.claude/agents/uiux.md +10 -3
- package/.claude/agents/verifier.md +8 -3
- package/.claude/skills/bs/SKILL.md +119 -0
- package/.claude/skills/bs/audit.md +252 -0
- package/.claude/skills/bs/dispatch.md +394 -0
- package/.claude/skills/bs/lessons.md +73 -0
- package/.claude/skills/bs/mcp.md +29 -0
- package/.claude/skills/bs/new.md +35 -0
- package/.claude/skills/bs/plan.md +172 -0
- package/.claude/skills/bs/report.md +129 -0
- package/.claude/skills/bs/run.md +327 -0
- package/.claude/skills/bs/status.md +54 -0
- package/.claude/skills/bs/ui.md +19 -0
- package/.claude/skills/bs/waivers.md +13 -0
- package/.claude/skills/bs/wave.md +267 -0
- package/README.md +136 -79
- package/factory/orchestrator/dist/actors.js +4 -3
- package/factory/orchestrator/dist/agents-registry.js +96 -19
- package/factory/orchestrator/dist/artifacts.js +36 -4
- package/factory/orchestrator/dist/budgets.js +7 -5
- package/factory/orchestrator/dist/claims.js +27 -1
- package/factory/orchestrator/dist/cli.js +196 -27
- package/factory/orchestrator/dist/crosscheck.js +11 -0
- package/factory/orchestrator/dist/daemon.js +30 -5
- package/factory/orchestrator/dist/db/projector.js +272 -90
- package/factory/orchestrator/dist/db/queries.js +83 -14
- package/factory/orchestrator/dist/db/schema.js +7 -0
- package/factory/orchestrator/dist/events.js +88 -4
- package/factory/orchestrator/dist/harness.js +516 -0
- package/factory/orchestrator/dist/impact.js +76 -5
- package/factory/orchestrator/dist/judges.js +49 -8
- package/factory/orchestrator/dist/lessons.js +4 -4
- package/factory/orchestrator/dist/mcp.js +4 -3
- package/factory/orchestrator/dist/paths.js +169 -15
- package/factory/orchestrator/dist/plan.js +61 -1
- package/factory/orchestrator/dist/planQuorum.js +36 -8
- package/factory/orchestrator/dist/providers/api-transport.js +62 -5
- package/factory/orchestrator/dist/providers/cli-transport.js +7 -73
- package/factory/orchestrator/dist/providers/index.js +1 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
- package/factory/orchestrator/dist/providers/types.js +5 -0
- package/factory/orchestrator/dist/roadmap.js +3 -3
- package/factory/orchestrator/dist/run-cli.js +130 -0
- package/factory/orchestrator/dist/runner.js +264 -0
- package/factory/orchestrator/dist/scaffold.js +3 -2
- package/factory/orchestrator/dist/spawn.js +84 -0
- package/factory/orchestrator/dist/stack.js +10 -2
- package/factory/orchestrator/dist/templateFragments.js +178 -0
- package/factory/orchestrator/dist/usage.js +30 -9
- package/factory/orchestrator/dist/waveNext.js +42 -14
- package/factory/orchestrator/dist/waveSchedule.js +7 -1
- package/factory/orchestrator/dist/workroot.js +113 -0
- package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +7 -4
- package/factory/policies/crosscheck.yml +10 -8
- package/factory/policies/harness.yml +82 -0
- package/factory/policies/lessons.md +122 -0
- package/factory/policies/severity.yml +1 -1
- package/factory/policies/taxonomy.yml +6 -2
- package/factory/specs/roadmap.md +154 -0
- package/factory/specs/schema/task-spec.schema.json +5 -0
- package/package.json +10 -7
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// smith-run's library half (architecture §18 rule 3: "nothing that observes
|
|
2
|
+
// may dispatch"). harness.ts's planWorkerTurn() renders a WorkerInvocation
|
|
3
|
+
// but never spawns it — this is the one function that actually starts a
|
|
4
|
+
// harness program. Imports nothing under src/db/, src/events.ts or
|
|
5
|
+
// src/projector.ts on purpose (see test/runner.test.ts's own source-level
|
|
6
|
+
// check): a function that can start a process has no business also being
|
|
7
|
+
// able to see or extend the event log.
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { SmithError } from './errors.js';
|
|
11
|
+
import { REPO_ROOT } from './paths.js';
|
|
12
|
+
import { extractAndValidate, extractBalancedJson } from './providers/schema-validate.js';
|
|
13
|
+
import { spawnCapped } from './spawn.js';
|
|
14
|
+
export class RunnerError extends SmithError {
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* harness.yml's own two shipped CLI harnesses both set an explicit
|
|
18
|
+
* `timeout_ms`; this only covers a hand-built WorkerInvocation (or a future
|
|
19
|
+
* policy entry) that leaves it null. 30 minutes, same order of magnitude as
|
|
20
|
+
* those two examples.
|
|
21
|
+
*/
|
|
22
|
+
export const RUNNER_DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
23
|
+
const FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
|
|
24
|
+
/** Strips a role template's leading `---`-delimited YAML frontmatter, keeping only the markdown body. */
|
|
25
|
+
function stripFrontmatter(templateText) {
|
|
26
|
+
return templateText.replace(FRONTMATTER, '');
|
|
27
|
+
}
|
|
28
|
+
/** Builds the child's environment from the invocation's allowlist only — never the full parent env. */
|
|
29
|
+
function buildEnv(envAllowlist, parentEnv) {
|
|
30
|
+
const env = {};
|
|
31
|
+
for (const name of envAllowlist) {
|
|
32
|
+
const value = parentEnv[name];
|
|
33
|
+
if (value !== undefined)
|
|
34
|
+
env[name] = value;
|
|
35
|
+
}
|
|
36
|
+
return env;
|
|
37
|
+
}
|
|
38
|
+
const DIAGNOSTIC_TAIL_BYTES = 400;
|
|
39
|
+
/** Last few hundred bytes of stderr, collapsed to one line — same convention as cli-transport.ts's tail(). */
|
|
40
|
+
function tail(text) {
|
|
41
|
+
const trimmed = text.trim();
|
|
42
|
+
const cut = trimmed.length > DIAGNOSTIC_TAIL_BYTES ? trimmed.slice(-DIAGNOSTIC_TAIL_BYTES) : trimmed;
|
|
43
|
+
return cut.replace(/\s+/g, ' ');
|
|
44
|
+
}
|
|
45
|
+
/** codex's own `input_tokens` already includes its `cached_input_tokens` — pass through as-is. */
|
|
46
|
+
function normalizeCodexUsage(raw) {
|
|
47
|
+
const input = raw.input_tokens ?? 0;
|
|
48
|
+
const output = raw.output_tokens ?? 0;
|
|
49
|
+
const cached = raw.cached_input_tokens;
|
|
50
|
+
return {
|
|
51
|
+
input_tokens: input,
|
|
52
|
+
output_tokens: output,
|
|
53
|
+
...(cached !== undefined ? { cached_input_tokens: cached } : {}),
|
|
54
|
+
total_tokens: input + output,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parses a `codex exec --json` JSONL stream: last `agent_message` item wins,
|
|
59
|
+
* `turn.completed` carries usage, and a top-level `{"type":"error", ...}`
|
|
60
|
+
* line — distinct from a non-fatal `item.completed` item whose own
|
|
61
|
+
* `item.type` is `"error"`, which stays ignored — sets `harness_error` (last
|
|
62
|
+
* one wins, same convention as `agent_message`). The process can still exit
|
|
63
|
+
* 0 and carry both an answer and a harness_error: codex reports partial
|
|
64
|
+
* progress before a failure, and neither field should hide the other.
|
|
65
|
+
*/
|
|
66
|
+
function parseCodexJson(combined) {
|
|
67
|
+
let answer = '';
|
|
68
|
+
let usage = null;
|
|
69
|
+
let harness_error = null;
|
|
70
|
+
for (const line of combined.split('\n')) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (trimmed === '')
|
|
73
|
+
continue;
|
|
74
|
+
let event;
|
|
75
|
+
try {
|
|
76
|
+
event = JSON.parse(trimmed);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (typeof event !== 'object' || event === null)
|
|
82
|
+
continue;
|
|
83
|
+
const record = event;
|
|
84
|
+
if (record.type === 'item.completed') {
|
|
85
|
+
const item = record.item;
|
|
86
|
+
if (item !== undefined && item.type === 'agent_message' && typeof item.text === 'string') {
|
|
87
|
+
answer = item.text;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (record.type === 'turn.completed') {
|
|
91
|
+
const rawUsage = record.usage;
|
|
92
|
+
if (rawUsage !== undefined)
|
|
93
|
+
usage = normalizeCodexUsage(rawUsage);
|
|
94
|
+
}
|
|
95
|
+
else if (record.type === 'error' && typeof record.message === 'string') {
|
|
96
|
+
harness_error = record.message;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { answer, usage, harness_error };
|
|
100
|
+
}
|
|
101
|
+
/** Anthropic's raw `input_tokens` excludes cache tokens — fold cache-creation + cache-read into it. */
|
|
102
|
+
function normalizeClaudeUsage(raw) {
|
|
103
|
+
const cached = (raw.cache_creation_input_tokens ?? 0) + (raw.cache_read_input_tokens ?? 0);
|
|
104
|
+
const input = (raw.input_tokens ?? 0) + cached;
|
|
105
|
+
const output = raw.output_tokens ?? 0;
|
|
106
|
+
return {
|
|
107
|
+
input_tokens: input,
|
|
108
|
+
output_tokens: output,
|
|
109
|
+
cached_input_tokens: cached,
|
|
110
|
+
total_tokens: input + output,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Parses a `claude -p --output-format json` single JSON object.
|
|
115
|
+
* `is_error: true` is `claude -p`'s own way of reporting a failed turn while
|
|
116
|
+
* still exiting 0 — `harness_error` prefers `subtype` (e.g.
|
|
117
|
+
* `error_during_execution`), falls back to `result` when that is the only
|
|
118
|
+
* string the payload carries, and otherwise names the shape generically.
|
|
119
|
+
* `answer` still comes from `result` either way, unchanged from today.
|
|
120
|
+
*/
|
|
121
|
+
function parseClaudeJson(combined) {
|
|
122
|
+
const value = extractBalancedJson(combined);
|
|
123
|
+
if (typeof value !== 'object' || value === null) {
|
|
124
|
+
return { answer: combined.trim(), usage: null, harness_error: null };
|
|
125
|
+
}
|
|
126
|
+
const record = value;
|
|
127
|
+
const answer = typeof record.result === 'string' ? record.result : combined.trim();
|
|
128
|
+
const rawUsage = record.usage;
|
|
129
|
+
const usage = rawUsage !== undefined ? normalizeClaudeUsage(rawUsage) : null;
|
|
130
|
+
const harness_error = record.is_error === true
|
|
131
|
+
? typeof record.subtype === 'string' && record.subtype !== ''
|
|
132
|
+
? record.subtype
|
|
133
|
+
: typeof record.result === 'string' && record.result !== ''
|
|
134
|
+
? record.result
|
|
135
|
+
: 'claude reported is_error'
|
|
136
|
+
: null;
|
|
137
|
+
return {
|
|
138
|
+
answer,
|
|
139
|
+
usage,
|
|
140
|
+
harness_error,
|
|
141
|
+
...(typeof record.session_id === 'string' ? { session_id: record.session_id } : {}),
|
|
142
|
+
...(typeof record.total_cost_usd === 'number' ? { cost_usd: record.total_cost_usd } : {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Dispatches on the invocation's declared output mode — never sniffs the payload to guess it. */
|
|
146
|
+
function parseOutput(mode, combined) {
|
|
147
|
+
if (mode === 'codex-json')
|
|
148
|
+
return parseCodexJson(combined);
|
|
149
|
+
if (mode === 'claude-json')
|
|
150
|
+
return parseClaudeJson(combined);
|
|
151
|
+
return { answer: combined.trim(), usage: null, harness_error: null };
|
|
152
|
+
}
|
|
153
|
+
/** Validates the answer against a schema when one is set; reports failure, never throws. */
|
|
154
|
+
function validate(answer, schema) {
|
|
155
|
+
if (schema === null)
|
|
156
|
+
return { validation: null, parsed: null };
|
|
157
|
+
const result = extractAndValidate(answer, schema);
|
|
158
|
+
if (result.valid)
|
|
159
|
+
return { validation: { ok: true }, parsed: result.value };
|
|
160
|
+
const errors = 'errors' in result
|
|
161
|
+
? result.errors.map((issue) => `${issue.path}: ${issue.message}`)
|
|
162
|
+
: [result.reason];
|
|
163
|
+
return { validation: { ok: false, errors }, parsed: null };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Actually spawns a rendered `WorkerInvocation` — the one thing harness.ts
|
|
167
|
+
* itself is forbidden from doing. Refuses `in-process` invocations: those are
|
|
168
|
+
* Agent-tool subagent turns, not programs smith-run knows how to start.
|
|
169
|
+
*/
|
|
170
|
+
export async function runInvocation(invocation, opts = {}) {
|
|
171
|
+
if (invocation.kind === 'in-process') {
|
|
172
|
+
throw new RunnerError('runner.in-process', `Invocation for role "${invocation.role}" is in-process (harness "${invocation.harness}") — ` +
|
|
173
|
+
'smith-run starts programs, not Agent-tool subagents. In-process harnesses run inside the ' +
|
|
174
|
+
'orchestrator session itself and have no separate binary for smith-run to spawn.', { harness: invocation.harness, role: invocation.role });
|
|
175
|
+
}
|
|
176
|
+
const now = opts.now ?? Date.now;
|
|
177
|
+
const start = now();
|
|
178
|
+
const prompt = opts.promptText ?? readFileSync(invocation.promptFile, 'utf8');
|
|
179
|
+
const templatePath = path.join(REPO_ROOT, invocation.template);
|
|
180
|
+
const templateBody = stripFrontmatter(readFileSync(templatePath, 'utf8'));
|
|
181
|
+
const stdin = `${templateBody}\n\n${prompt}`;
|
|
182
|
+
const env = buildEnv(invocation.envAllowlist, opts.env ?? process.env);
|
|
183
|
+
const timeout_ms = invocation.budget.timeout_ms ?? RUNNER_DEFAULT_TIMEOUT_MS;
|
|
184
|
+
let outcome;
|
|
185
|
+
try {
|
|
186
|
+
outcome = await spawnCapped({
|
|
187
|
+
command: invocation.command,
|
|
188
|
+
args: [...invocation.args],
|
|
189
|
+
cwd: invocation.cwd ?? undefined,
|
|
190
|
+
env,
|
|
191
|
+
}, stdin, { timeout_ms, max_output_bytes: invocation.budget.max_output_bytes });
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
// spawnCapped resolves rather than rejects on a spawn failure; this catch
|
|
195
|
+
// is defensive only, kept so a future change there cannot turn into an
|
|
196
|
+
// uncaught rejection here.
|
|
197
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
198
|
+
return {
|
|
199
|
+
harness: invocation.harness,
|
|
200
|
+
role: invocation.role,
|
|
201
|
+
taskId: invocation.taskId,
|
|
202
|
+
exitCode: null,
|
|
203
|
+
signal: null,
|
|
204
|
+
timedOut: false,
|
|
205
|
+
sizeExceeded: false,
|
|
206
|
+
spawnError: message,
|
|
207
|
+
latency_ms: now() - start,
|
|
208
|
+
answer: '',
|
|
209
|
+
parsed: null,
|
|
210
|
+
validation: null,
|
|
211
|
+
usage: null,
|
|
212
|
+
over_budget: null,
|
|
213
|
+
stderr_tail: '',
|
|
214
|
+
harness_error: null,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const latency_ms = now() - start;
|
|
218
|
+
if (outcome.spawnError !== undefined) {
|
|
219
|
+
const reason = outcome.spawnError.code ?? outcome.spawnError.message;
|
|
220
|
+
return {
|
|
221
|
+
harness: invocation.harness,
|
|
222
|
+
role: invocation.role,
|
|
223
|
+
taskId: invocation.taskId,
|
|
224
|
+
exitCode: null,
|
|
225
|
+
signal: null,
|
|
226
|
+
timedOut: outcome.timedOut,
|
|
227
|
+
sizeExceeded: outcome.sizeExceeded,
|
|
228
|
+
spawnError: reason,
|
|
229
|
+
latency_ms,
|
|
230
|
+
answer: '',
|
|
231
|
+
parsed: null,
|
|
232
|
+
validation: null,
|
|
233
|
+
usage: null,
|
|
234
|
+
over_budget: null,
|
|
235
|
+
stderr_tail: tail(outcome.stderr),
|
|
236
|
+
harness_error: null,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const { answer, usage, harness_error } = parseOutput(invocation.output, outcome.combined);
|
|
240
|
+
const { validation, parsed } = validate(answer, invocation.schema);
|
|
241
|
+
// Unknown (null) unless the harness both sets a cap and the output mode
|
|
242
|
+
// reported usage to compare it against — never guess a verdict either way.
|
|
243
|
+
const over_budget = invocation.budget.cap_tokens === null || usage === null
|
|
244
|
+
? null
|
|
245
|
+
: usage.total_tokens > invocation.budget.cap_tokens;
|
|
246
|
+
return {
|
|
247
|
+
harness: invocation.harness,
|
|
248
|
+
role: invocation.role,
|
|
249
|
+
taskId: invocation.taskId,
|
|
250
|
+
exitCode: outcome.exitCode,
|
|
251
|
+
signal: outcome.signal,
|
|
252
|
+
timedOut: outcome.timedOut,
|
|
253
|
+
sizeExceeded: outcome.sizeExceeded,
|
|
254
|
+
spawnError: null,
|
|
255
|
+
latency_ms,
|
|
256
|
+
answer,
|
|
257
|
+
parsed,
|
|
258
|
+
validation,
|
|
259
|
+
usage,
|
|
260
|
+
over_budget,
|
|
261
|
+
stderr_tail: tail(outcome.stderr),
|
|
262
|
+
harness_error,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -16,9 +16,10 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSyn
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { SmithError } from './errors.js';
|
|
18
18
|
import { runGit as git } from './git.js';
|
|
19
|
-
import { PROJECTS_DIR, REPO_ROOT,
|
|
19
|
+
import { PROJECTS_DIR, REPO_ROOT, SCAFFOLD_DIR } from './paths.js';
|
|
20
20
|
import { readRoadmapText, roadmapDeclaresId } from './roadmap.js';
|
|
21
21
|
import { loadStackAnswers, requireScaffoldable } from './stack.js';
|
|
22
|
+
import { ensureWritableRoadmap } from './workroot.js';
|
|
22
23
|
export class ScaffoldError extends SmithError {
|
|
23
24
|
}
|
|
24
25
|
const PROJECT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
@@ -355,7 +356,7 @@ export function scaffoldProject(opts) {
|
|
|
355
356
|
* gets a placeholder milestone so the Projects/Roadmap pages have something
|
|
356
357
|
* to show the moment it exists, edited further as real epics get scoped).
|
|
357
358
|
*/
|
|
358
|
-
export function registerProjectInRoadmap(projectName, roadmapPath =
|
|
359
|
+
export function registerProjectInRoadmap(projectName, roadmapPath = ensureWritableRoadmap()) {
|
|
359
360
|
const text = readRoadmapText(roadmapPath);
|
|
360
361
|
const id = `${projectName}-bootstrap`;
|
|
361
362
|
// See registerMcpMilestone: the substring `- id: <id>` is not what
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// A capped, detached-group child process — the mechanics both cli-transport.ts
|
|
2
|
+
// (an external judge, no worktree, no cwd) and runner.ts (smith-run, a
|
|
3
|
+
// harness that may hold a worktree) spawn a process the same way. Extracted
|
|
4
|
+
// so the two do not carry two copies of a timeout/size-cap/group-kill
|
|
5
|
+
// implementation that drift the way SpawnOnce's original inline copy would
|
|
6
|
+
// have, the day a second caller needed it.
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
/**
|
|
9
|
+
* One spawn: detached process group (POSIX `setsid` via `detached: true`) +
|
|
10
|
+
* group-kill on timeout or size-cap breach — mirrors testgate.ts's runOne()
|
|
11
|
+
* exactly (a spawned CLI can start its own children; a plain per-child kill
|
|
12
|
+
* would orphan them the same way testgate's own regression note describes).
|
|
13
|
+
*/
|
|
14
|
+
export function spawnCapped(config, stdin, budget) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const child = spawn(config.command, config.args, {
|
|
17
|
+
cwd: config.cwd,
|
|
18
|
+
env: config.env,
|
|
19
|
+
detached: true,
|
|
20
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
21
|
+
});
|
|
22
|
+
let combined = '';
|
|
23
|
+
let stderr = '';
|
|
24
|
+
let totalBytes = 0;
|
|
25
|
+
let timedOut = false;
|
|
26
|
+
let sizeExceeded = false;
|
|
27
|
+
let settled = false;
|
|
28
|
+
let spawnError;
|
|
29
|
+
const killGroup = () => {
|
|
30
|
+
if (child.pid !== undefined) {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Group already gone — fine.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
timedOut = true;
|
|
41
|
+
killGroup();
|
|
42
|
+
}, budget.timeout_ms);
|
|
43
|
+
const onChunk = (chunk) => {
|
|
44
|
+
totalBytes += chunk.length;
|
|
45
|
+
if (totalBytes > budget.max_output_bytes) {
|
|
46
|
+
if (!sizeExceeded) {
|
|
47
|
+
sizeExceeded = true;
|
|
48
|
+
killGroup();
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
combined += chunk.toString('utf8');
|
|
53
|
+
};
|
|
54
|
+
child.stdout?.on('data', onChunk);
|
|
55
|
+
child.stderr?.on('data', (chunk) => {
|
|
56
|
+
// Still folded into `combined` for extraction — a judge that writes its
|
|
57
|
+
// verdict to stderr has always worked and must keep working. The second
|
|
58
|
+
// copy exists only so a refusal reason can be reported on its own.
|
|
59
|
+
stderr += chunk.toString('utf8');
|
|
60
|
+
onChunk(chunk);
|
|
61
|
+
});
|
|
62
|
+
child.stdin?.on('error', () => {
|
|
63
|
+
// Child exited before consuming stdin (e.g. a fixture that errors
|
|
64
|
+
// immediately) — writing to a closed pipe would otherwise raise an
|
|
65
|
+
// unhandled EPIPE; the close handler below still resolves correctly.
|
|
66
|
+
});
|
|
67
|
+
child.stdin?.write(stdin);
|
|
68
|
+
child.stdin?.end();
|
|
69
|
+
const finish = (exitCode, signal) => {
|
|
70
|
+
if (settled)
|
|
71
|
+
return;
|
|
72
|
+
settled = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
resolve({ combined, stderr, exitCode, signal, spawnError, timedOut, sizeExceeded });
|
|
75
|
+
};
|
|
76
|
+
child.on('error', (err) => {
|
|
77
|
+
// The child never ran (ENOENT, EACCES, …). Distinct from a child that
|
|
78
|
+
// ran and failed: 'close' carries no code in this case.
|
|
79
|
+
spawnError = err;
|
|
80
|
+
finish(null, null);
|
|
81
|
+
});
|
|
82
|
+
child.on('close', finish);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -25,7 +25,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
25
25
|
import path from 'node:path';
|
|
26
26
|
import { parse as parseYaml } from 'yaml';
|
|
27
27
|
import { SmithError } from './errors.js';
|
|
28
|
-
import { REPO_ROOT,
|
|
28
|
+
import { REPO_ROOT, stackPolicyReadPath } from './paths.js';
|
|
29
29
|
export class StackError extends SmithError {
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
@@ -124,7 +124,7 @@ export function parseStackAnswers(yamlText) {
|
|
|
124
124
|
}
|
|
125
125
|
return answers;
|
|
126
126
|
}
|
|
127
|
-
export function loadStackAnswers(filePath =
|
|
127
|
+
export function loadStackAnswers(filePath = stackPolicyReadPath()) {
|
|
128
128
|
return parseStackAnswers(readFileSync(filePath, 'utf8'));
|
|
129
129
|
}
|
|
130
130
|
/** Values `factory/scaffold/` can build. Everything else is a refusal, not a substitution. */
|
|
@@ -222,6 +222,14 @@ function reportFor(field, value, source) {
|
|
|
222
222
|
note: `The uiux agent is told to ground its specs in ${value}; with design_system_source empty, no kit is copied into scaffolded projects.`,
|
|
223
223
|
};
|
|
224
224
|
}
|
|
225
|
+
// A relative `design_system_source` is relative to the *package*, not to
|
|
226
|
+
// the operator's repository -- which is what it has always meant and what
|
|
227
|
+
// scaffold.ts resolves it against too. Stated here because this diff moved
|
|
228
|
+
// the file the value is typed into (stack.yml is now read from the work
|
|
229
|
+
// root under an install) without moving the base it resolves against, so a
|
|
230
|
+
// reader could reasonably assume the two travel together. They do not:
|
|
231
|
+
// changing the base is a behaviour change for every existing clone and
|
|
232
|
+
// needs a matching sentence in stack.yml, which is a policy file.
|
|
225
233
|
const resolved = path.isAbsolute(source) ? source : path.join(REPO_ROOT, source);
|
|
226
234
|
return existsSync(resolved)
|
|
227
235
|
? {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { SmithError } from './errors.js';
|
|
4
|
+
import { AGENTS_DIR, REPO_ROOT } from './paths.js';
|
|
5
|
+
/**
|
|
6
|
+
* Shared fragments: one paragraph, stamped into several role templates.
|
|
7
|
+
*
|
|
8
|
+
* Six judges carry the same read-only rule, six workers the same
|
|
9
|
+
* `token_usage` paragraph, three judges the same finding-field rule. Before
|
|
10
|
+
* this module each copy was hand-maintained, and the copies had drifted —
|
|
11
|
+
* the same rule in three wordings, one of them a sentence short. The fix is
|
|
12
|
+
* not a template include: an agent template is what Claude Code reads
|
|
13
|
+
* *verbatim* as a system prompt, so the text has to be *in* the file. So the
|
|
14
|
+
* fragment is the source, the template carries a stamped copy inside a fence,
|
|
15
|
+
* and a test holds every fence byte-equal to its fragment. Editing the
|
|
16
|
+
* paragraph means editing the fragment and re-running the sync — editing one
|
|
17
|
+
* copy fails the gate by name.
|
|
18
|
+
*/
|
|
19
|
+
export class TemplateFragmentsError extends SmithError {
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Where the fragments live. Deliberately not a `paths.ts` export: everything
|
|
23
|
+
* anchored on `REPO_ROOT` there must ship in the npm package (packaging
|
|
24
|
+
* test), and the fragments are dev-time source — the templates carry the
|
|
25
|
+
* stamped text, and nothing the CLI runs at an operator's desk reads this
|
|
26
|
+
* directory.
|
|
27
|
+
*
|
|
28
|
+
* And deliberately not `.claude/agents/_shared/`: Claude Code's agent loader
|
|
29
|
+
* recurses into `.claude/agents/`, so a fragment there with front matter
|
|
30
|
+
* registers as an agent and one without is skipped silently — neither is a
|
|
31
|
+
* fragment. `.claude/fragments/` is a directory the loader does not watch.
|
|
32
|
+
*/
|
|
33
|
+
export const FRAGMENTS_DIR = path.join(REPO_ROOT, '.claude', 'fragments');
|
|
34
|
+
/** A fragment name is its file's basename; the fence names it the same way. */
|
|
35
|
+
export const FRAGMENT_NAME = /^[a-z][a-z0-9-]*$/;
|
|
36
|
+
const BEGIN_FENCE = /^<!-- BEGIN SHARED:(\S+) -->$/;
|
|
37
|
+
const END_FENCE = /^<!-- END SHARED:(\S+) -->$/;
|
|
38
|
+
export function beginFence(name) {
|
|
39
|
+
return `<!-- BEGIN SHARED:${name} -->`;
|
|
40
|
+
}
|
|
41
|
+
export function endFence(name) {
|
|
42
|
+
return `<!-- END SHARED:${name} -->`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Every `<!-- BEGIN SHARED:x -->` … `<!-- END SHARED:x -->` region in a
|
|
46
|
+
* template, in file order. Fences are whole lines, so a fence quoted inside
|
|
47
|
+
* a sentence is prose, not a region. Regions do not nest: a BEGIN inside a
|
|
48
|
+
* region and an END with no open region are both errors, as is an END that
|
|
49
|
+
* names a different fragment than the BEGIN it closes — the fence pair is the
|
|
50
|
+
* unit the sync rewrites, and a mismatched pair has no single body to rewrite.
|
|
51
|
+
*/
|
|
52
|
+
export function parseSharedRegions(text, file = '<template>') {
|
|
53
|
+
const lines = text.split('\n');
|
|
54
|
+
const regions = [];
|
|
55
|
+
let open = null;
|
|
56
|
+
for (let i = 0; i < lines.length; i++) {
|
|
57
|
+
const line = lines[i];
|
|
58
|
+
const begin = BEGIN_FENCE.exec(line);
|
|
59
|
+
if (begin) {
|
|
60
|
+
const name = begin[1];
|
|
61
|
+
if (open) {
|
|
62
|
+
throw new TemplateFragmentsError('fragments.nested-fence', `${file}:${i + 1}: BEGIN SHARED:${name} opens inside SHARED:${open.name} (opened at line ${open.start + 1}); regions do not nest.`, { file, line: i + 1, name, open: open.name });
|
|
63
|
+
}
|
|
64
|
+
assertFragmentName(name, `${file}:${i + 1}`);
|
|
65
|
+
open = { name, start: i };
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const end = END_FENCE.exec(line);
|
|
69
|
+
if (end) {
|
|
70
|
+
const name = end[1];
|
|
71
|
+
if (!open || open.name !== name) {
|
|
72
|
+
throw new TemplateFragmentsError('fragments.unbalanced-fence', open
|
|
73
|
+
? `${file}:${i + 1}: END SHARED:${name} closes SHARED:${open.name} (opened at line ${open.start + 1}).`
|
|
74
|
+
: `${file}:${i + 1}: END SHARED:${name} has no open BEGIN.`, { file, line: i + 1, name, open: open?.name ?? null });
|
|
75
|
+
}
|
|
76
|
+
const between = lines.slice(open.start + 1, i);
|
|
77
|
+
regions.push({
|
|
78
|
+
name,
|
|
79
|
+
start: open.start,
|
|
80
|
+
end: i,
|
|
81
|
+
body: between.length === 0 ? '' : `${between.join('\n')}\n`,
|
|
82
|
+
});
|
|
83
|
+
open = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (open) {
|
|
87
|
+
throw new TemplateFragmentsError('fragments.unbalanced-fence', `${file}:${open.start + 1}: BEGIN SHARED:${open.name} is never closed.`, { file, line: open.start + 1, name: open.name, open: open.name });
|
|
88
|
+
}
|
|
89
|
+
return regions;
|
|
90
|
+
}
|
|
91
|
+
function assertFragmentName(name, where) {
|
|
92
|
+
if (!FRAGMENT_NAME.test(name)) {
|
|
93
|
+
throw new TemplateFragmentsError('fragments.invalid-fragment-name', `${where}: ${JSON.stringify(name)} is not a fragment name (expected ${FRAGMENT_NAME}).`, { name, where });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The fragments in a directory, by name. Every `.md` file there is a
|
|
98
|
+
* fragment and its basename is the name — the directory holds nothing else,
|
|
99
|
+
* so a README or a draft dropped in it fails by name rather than stamping
|
|
100
|
+
* itself into a template that asks for it.
|
|
101
|
+
*/
|
|
102
|
+
export function listFragments(dir = FRAGMENTS_DIR) {
|
|
103
|
+
const fragments = new Map();
|
|
104
|
+
const files = readdirSync(dir)
|
|
105
|
+
.filter((f) => f.endsWith('.md'))
|
|
106
|
+
.sort();
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
const name = path.basename(file, '.md');
|
|
109
|
+
assertFragmentName(name, path.join(dir, file));
|
|
110
|
+
fragments.set(name, readFileSync(path.join(dir, file), 'utf8'));
|
|
111
|
+
}
|
|
112
|
+
return fragments;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Rewrite every fenced region so its body equals the named fragment. The
|
|
116
|
+
* fences stay; only the lines between them change. Stamping a template that
|
|
117
|
+
* is already in sync returns it byte-for-byte, which is what lets the sync
|
|
118
|
+
* script run on every template without a diff to explain.
|
|
119
|
+
*/
|
|
120
|
+
export function stampFragments(text, fragments, file = '<template>') {
|
|
121
|
+
const regions = parseSharedRegions(text, file);
|
|
122
|
+
const lines = text.split('\n');
|
|
123
|
+
const out = [];
|
|
124
|
+
const stamped = [];
|
|
125
|
+
let cursor = 0;
|
|
126
|
+
for (const region of regions) {
|
|
127
|
+
const fragment = fragments.get(region.name);
|
|
128
|
+
if (fragment === undefined) {
|
|
129
|
+
throw new TemplateFragmentsError('fragments.unknown-fragment', `${file}:${region.start + 1}: SHARED:${region.name} names no file under the fragments directory (have: ${[...fragments.keys()].join(', ') || 'none'}).`, { file, line: region.start + 1, name: region.name, known: [...fragments.keys()] });
|
|
130
|
+
}
|
|
131
|
+
out.push(...lines.slice(cursor, region.start + 1));
|
|
132
|
+
if (fragment !== '')
|
|
133
|
+
out.push(...fragment.replace(/\n$/, '').split('\n'));
|
|
134
|
+
out.push(lines[region.end]);
|
|
135
|
+
cursor = region.end + 1;
|
|
136
|
+
stamped.push({ name: region.name, drifted: region.body !== fragment });
|
|
137
|
+
}
|
|
138
|
+
out.push(...lines.slice(cursor));
|
|
139
|
+
return { text: out.join('\n'), regions: stamped };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Stamp every fragment into every role template. Reads the templates
|
|
143
|
+
* non-recursively, the same set `scripts/check.sh` validates, so a file in a
|
|
144
|
+
* subdirectory is neither a template here nor an agent there.
|
|
145
|
+
*/
|
|
146
|
+
export function syncTemplates(opts = {}) {
|
|
147
|
+
const agentsDir = opts.agentsDir ?? AGENTS_DIR;
|
|
148
|
+
const fragments = listFragments(opts.fragmentsDir ?? FRAGMENTS_DIR);
|
|
149
|
+
const used = new Set();
|
|
150
|
+
const report = {
|
|
151
|
+
fragments: [...fragments.keys()],
|
|
152
|
+
templates: [],
|
|
153
|
+
drifted: [],
|
|
154
|
+
unused: [],
|
|
155
|
+
written: [],
|
|
156
|
+
};
|
|
157
|
+
const files = readdirSync(agentsDir, { withFileTypes: true })
|
|
158
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
159
|
+
.map((entry) => entry.name)
|
|
160
|
+
.sort();
|
|
161
|
+
for (const file of files) {
|
|
162
|
+
const templatePath = path.join(agentsDir, file);
|
|
163
|
+
const before = readFileSync(templatePath, 'utf8');
|
|
164
|
+
const { text, regions } = stampFragments(before, fragments, templatePath);
|
|
165
|
+
report.templates.push({ file, regions });
|
|
166
|
+
for (const region of regions) {
|
|
167
|
+
used.add(region.name);
|
|
168
|
+
if (region.drifted)
|
|
169
|
+
report.drifted.push({ file, name: region.name });
|
|
170
|
+
}
|
|
171
|
+
if (opts.write && text !== before) {
|
|
172
|
+
writeFileSync(templatePath, text, 'utf8');
|
|
173
|
+
report.written.push(file);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
report.unused = report.fragments.filter((name) => !used.has(name));
|
|
177
|
+
return report;
|
|
178
|
+
}
|