@juzser/blacksmith 0.1.0 → 0.1.1
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 +1 -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 +103 -0
- package/.claude/skills/bs/audit.md +250 -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 +33 -0
- package/.claude/skills/bs/plan.md +155 -0
- package/.claude/skills/bs/report.md +129 -0
- package/.claude/skills/bs/run.md +325 -0
- package/.claude/skills/bs/status.md +54 -0
- package/.claude/skills/bs/ui.md +12 -0
- package/.claude/skills/bs/waivers.md +13 -0
- package/.claude/skills/bs/wave.md +261 -0
- package/README.md +119 -65
- 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/cli.js +107 -7
- package/factory/orchestrator/dist/crosscheck.js +11 -0
- package/factory/orchestrator/dist/daemon.js +14 -0
- 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 +38 -4
- package/factory/orchestrator/dist/harness.js +367 -0
- 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 +167 -15
- package/factory/orchestrator/dist/plan.js +1 -1
- package/factory/orchestrator/dist/planQuorum.js +36 -8
- package/factory/orchestrator/dist/providers/api-transport.js +62 -5
- 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/scaffold.js +3 -2
- package/factory/orchestrator/dist/stack.js +10 -2
- package/factory/orchestrator/dist/templateFragments.js +178 -0
- package/factory/orchestrator/dist/usage.js +24 -5
- 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 +6 -3
- package/factory/policies/crosscheck.yml +10 -8
- package/factory/policies/lessons.md +122 -0
- package/factory/policies/taxonomy.yml +6 -2
- package/factory/specs/roadmap.md +154 -0
- package/package.json +8 -6
|
@@ -38,14 +38,30 @@ import { epicOfTaskId } from './taskId.js';
|
|
|
38
38
|
* entry still open under that task, because after a task-level failure nobody
|
|
39
39
|
* is still running.
|
|
40
40
|
*
|
|
41
|
+
* An epic-level dispatch — a planner, a spec-reviewer, a scribe, sent for
|
|
42
|
+
* the epic with no task id — closes on a terminal event that also names no
|
|
43
|
+
* task and does name its role (F2, the cross-provider UI check of
|
|
44
|
+
* 2026-09-14). Its Result is recorded as a `task-result-recorded` with no
|
|
45
|
+
* task id and `agent_role` in the payload, and until F2 every terminal branch
|
|
46
|
+
* was guarded on the task id, so ten such results across the real logs closed
|
|
47
|
+
* nothing: a planner that returned in four minutes read as live for the rest
|
|
48
|
+
* of the epic. The event speaks for the latest still-open epic-level entry of
|
|
49
|
+
* that role in the same session — latest because a result belongs to the
|
|
50
|
+
* dispatch that caused it, and that is the most recent one — and steps back
|
|
51
|
+
* when the two name different epics. There is no supersession at this level
|
|
52
|
+
* (D-244): a second planner dispatch is a second round, not a replacement,
|
|
53
|
+
* and a terminal event that names no role closes nothing here, because
|
|
54
|
+
* guessing which agent a session-level error was about is the D-244 mistake
|
|
55
|
+
* one level up.
|
|
56
|
+
*
|
|
41
57
|
* Above all of those sits `epic-closed` (D-187). Every close named so far
|
|
42
|
-
* needs a task id to speak through, so an agent whose task simply
|
|
43
|
-
* reported — and an epic-level dispatch
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
58
|
+
* needs a task id or a role to speak through, so an agent whose task simply
|
|
59
|
+
* never reported — and an epic-level dispatch whose Result never came —
|
|
60
|
+
* stayed `live` past the verdict, past the end of the run, forever. The
|
|
61
|
+
* epic's own terminal event is the only thing that can say otherwise, and it
|
|
62
|
+
* closes what is still open under that epic in that session as `abandoned`:
|
|
63
|
+
* not superseded (nothing replaced them) and not an error (they were never
|
|
64
|
+
* judged), just outrun by the run they belonged to.
|
|
49
65
|
*/
|
|
50
66
|
export const DISPATCH_EVENT_TYPE = 'dispatch_decision';
|
|
51
67
|
/** Phase 5 addition (deviation, noted in the final report): the terminal counterpart to dispatch_decision. */
|
|
@@ -147,6 +163,26 @@ function closeOpen(open, taskId, role, eventId, ts, terminalType) {
|
|
|
147
163
|
open.delete(key);
|
|
148
164
|
}
|
|
149
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Close the epic-level entry a task-less terminal event speaks for: the
|
|
168
|
+
* latest still-open dispatch of `role` in `sessionId` (F2). `epicId` is the
|
|
169
|
+
* event's own claim about which epic it belongs to; when both sides name one
|
|
170
|
+
* and they differ, the event is another epic's and closes nothing here. The
|
|
171
|
+
* list is scanned from the end because the most recent dispatch of a role is
|
|
172
|
+
* the one whose Result this is.
|
|
173
|
+
*/
|
|
174
|
+
function closeOpenEpicLevel(openEpicLevel, sessionId, role, epicId, eventId, ts, terminalType) {
|
|
175
|
+
for (let i = openEpicLevel.length - 1; i >= 0; i--) {
|
|
176
|
+
const entry = openEpicLevel[i];
|
|
177
|
+
if (!entry || entry.sessionId !== sessionId || entry.agentRole !== role)
|
|
178
|
+
continue;
|
|
179
|
+
if (epicId && entry.epicId && entry.epicId !== epicId)
|
|
180
|
+
continue;
|
|
181
|
+
closeEntry(entry, eventId, ts, terminalType);
|
|
182
|
+
openEpicLevel.splice(i, 1);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
150
186
|
/**
|
|
151
187
|
* Fold a session's full event history into agent registry rows. Pure
|
|
152
188
|
* function over the event list — used identically by db/projector.ts's
|
|
@@ -156,7 +192,8 @@ function closeOpen(open, taskId, role, eventId, ts, terminalType) {
|
|
|
156
192
|
export function foldAgents(events) {
|
|
157
193
|
const open = new Map();
|
|
158
194
|
// Dispatches with no task id have no correlation key, so they cannot live in
|
|
159
|
-
// `open` — but they are still open
|
|
195
|
+
// `open` — but they are still open: a task-less terminal naming their role
|
|
196
|
+
// closes the latest of them (F2), and `epic-closed` sweeps the rest.
|
|
160
197
|
let openEpicLevel = [];
|
|
161
198
|
const records = [];
|
|
162
199
|
for (const { event_id, record } of events) {
|
|
@@ -205,13 +242,20 @@ export function foldAgents(events) {
|
|
|
205
242
|
}
|
|
206
243
|
if (record.event_type === TASK_RESULT_EVENT_TYPE) {
|
|
207
244
|
// The Result names its author in `agent` (result.schema.json's required
|
|
208
|
-
// field). A hand-appended one that omits it still closes the task.
|
|
245
|
+
// field). A hand-appended one that omits it still closes the task. An
|
|
246
|
+
// epic-level one, recorded by hand with no task id, spells the role as
|
|
247
|
+
// `agent_role` — the dispatch's own key — in every real log so far, so
|
|
248
|
+
// the fold reads either.
|
|
209
249
|
const payload = record.payload;
|
|
210
250
|
// Both levels: result.schema.json names the task in the payload, and the
|
|
211
251
|
// dispatch this closes may have named it only there too (D-245).
|
|
212
252
|
const taskId = eventTaskId(record);
|
|
253
|
+
const role = payload.agent ?? payload.agent_role ?? null;
|
|
213
254
|
if (taskId) {
|
|
214
|
-
closeOpen(open, taskId,
|
|
255
|
+
closeOpen(open, taskId, role, event_id, record.ts, 'result');
|
|
256
|
+
}
|
|
257
|
+
else if (role) {
|
|
258
|
+
closeOpenEpicLevel(openEpicLevel, record.session_id, role, payload.epic_id, event_id, record.ts, 'result');
|
|
215
259
|
}
|
|
216
260
|
continue;
|
|
217
261
|
}
|
|
@@ -221,19 +265,25 @@ export function foldAgents(events) {
|
|
|
221
265
|
if (taskId) {
|
|
222
266
|
closeOpen(open, taskId, payload.agent_role ?? null, event_id, record.ts, 'result');
|
|
223
267
|
}
|
|
268
|
+
else if (payload.agent_role) {
|
|
269
|
+
closeOpenEpicLevel(openEpicLevel, record.session_id, payload.agent_role, payload.epic_id, event_id, record.ts, 'result');
|
|
270
|
+
}
|
|
224
271
|
continue;
|
|
225
272
|
}
|
|
226
273
|
if (record.event_type === JUDGE_VERDICT_EVENT_TYPE) {
|
|
227
274
|
const payload = record.payload;
|
|
228
275
|
const taskId = eventTaskId(record);
|
|
276
|
+
// A run that produced no schema-valid verdict did not do its job, and
|
|
277
|
+
// `ok: false` is how the log says so. Closing it as a result would let
|
|
278
|
+
// a provider failing every call read exactly like one answering every
|
|
279
|
+
// call — the distinction providerAgreement() exists to measure.
|
|
280
|
+
const terminal = payload.ok === false ? 'error' : 'result';
|
|
229
281
|
if (taskId) {
|
|
230
|
-
// A run that produced no schema-valid verdict did not do its job, and
|
|
231
|
-
// `ok: false` is how the log says so. Closing it as a result would let
|
|
232
|
-
// a provider failing every call read exactly like one answering every
|
|
233
|
-
// call — the distinction providerAgreement() exists to measure.
|
|
234
|
-
const terminal = payload.ok === false ? 'error' : 'result';
|
|
235
282
|
closeOpen(open, taskId, payload.agent ?? null, event_id, record.ts, terminal);
|
|
236
283
|
}
|
|
284
|
+
else if (payload.agent) {
|
|
285
|
+
closeOpenEpicLevel(openEpicLevel, record.session_id, payload.agent, payload.epic_id, event_id, record.ts, terminal);
|
|
286
|
+
}
|
|
237
287
|
continue;
|
|
238
288
|
}
|
|
239
289
|
if (record.event_type === ERROR_EVENT_TYPE) {
|
|
@@ -244,6 +294,11 @@ export function foldAgents(events) {
|
|
|
244
294
|
if (taskId) {
|
|
245
295
|
closeOpen(open, taskId, payload.agent_role ?? null, event_id, record.ts, 'error');
|
|
246
296
|
}
|
|
297
|
+
else if (payload.agent_role) {
|
|
298
|
+
// Only with a role: a session-level error names nobody, and every
|
|
299
|
+
// epic-level agent stays where it was.
|
|
300
|
+
closeOpenEpicLevel(openEpicLevel, record.session_id, payload.agent_role, payload.epic_id, event_id, record.ts, 'error');
|
|
301
|
+
}
|
|
247
302
|
continue;
|
|
248
303
|
}
|
|
249
304
|
if (record.event_type === EPIC_CLOSED_EVENT_TYPE) {
|
|
@@ -262,10 +317,10 @@ export function foldAgents(events) {
|
|
|
262
317
|
closeEntry(entry, event_id, record.ts, 'abandoned');
|
|
263
318
|
open.delete(key);
|
|
264
319
|
}
|
|
265
|
-
// The epic-level half keeps D-187's reasoning for an entry
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
320
|
+
// The epic-level half keeps D-187's reasoning for an entry whose Result
|
|
321
|
+
// never came -- no task id means only a terminal naming its role (F2)
|
|
322
|
+
// or the run's own verdict can close it, and this is the verdict. But
|
|
323
|
+
// an entry that DID name an epic is placed,
|
|
269
324
|
// and one session runs several epics in a row: sweeping the lot closed
|
|
270
325
|
// the next epic's planner, live and working, on the previous epic's
|
|
271
326
|
// verdict (D-234).
|
|
@@ -306,3 +361,25 @@ export function detectStale(agents, nowIso, staleHours = DEFAULT_STALE_HOURS) {
|
|
|
306
361
|
export function liveAgents(agents) {
|
|
307
362
|
return agents.filter((a) => a.status === 'live');
|
|
308
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Whether a dispatch at `dispatchedAt` still counts as *working* at `nowIso`:
|
|
366
|
+
* the dashboard's answer to "is anyone actually on this right now", as
|
|
367
|
+
* opposed to "has no terminal event closed it yet" (`liveAgents`). Same
|
|
368
|
+
* arithmetic and the same strict `>` as `detectStale` — working is the
|
|
369
|
+
* complement of stale over the live set — kept as one predicate so the
|
|
370
|
+
* queries that count, group and label agents cannot drift from the
|
|
371
|
+
* threshold the registry warns on.
|
|
372
|
+
*
|
|
373
|
+
* Two readings `detectStale`'s loop leaves implicit are pinned here because
|
|
374
|
+
* the dashboard renders them: a dispatch whose timestamp does not parse is
|
|
375
|
+
* NOT working (NaN compares false in both directions, which would otherwise
|
|
376
|
+
* read as "not stale, so working" — an unreadable clock is never evidence
|
|
377
|
+
* of activity), and a future-dated dispatch IS working (clock skew between
|
|
378
|
+
* the writer and the reader is not staleness).
|
|
379
|
+
*/
|
|
380
|
+
export function isWorkingAt(dispatchedAt, nowIso, staleHours = DEFAULT_STALE_HOURS) {
|
|
381
|
+
const liveHours = (new Date(nowIso).getTime() - new Date(dispatchedAt).getTime()) / 3.6e6;
|
|
382
|
+
if (Number.isNaN(liveHours))
|
|
383
|
+
return false;
|
|
384
|
+
return liveHours <= staleHours;
|
|
385
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, realpathSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { SmithError } from './errors.js';
|
|
4
|
-
import { STATE_ARTIFACTS_DIR } from './paths.js';
|
|
4
|
+
import { STATE_ARTIFACTS_DIR, WORK_ROOT } from './paths.js';
|
|
5
5
|
/**
|
|
6
6
|
* Where a task's artifacts belong: `state/artifacts/<task-id>/`, beside
|
|
7
7
|
* `state/results/<task-id>.json`. One home per task, named by the task, so the
|
|
@@ -38,6 +38,33 @@ function realOf(p) {
|
|
|
38
38
|
return p;
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* How the home is spelled from the work root — `state/artifacts/<task-id>` in
|
|
43
|
+
* production — or null when it does not sit under that root at all, in which
|
|
44
|
+
* case no relative declaration can be naming it.
|
|
45
|
+
*/
|
|
46
|
+
function spellingFromWorkRoot(home, workRoot) {
|
|
47
|
+
const rel = path.relative(path.resolve(workRoot), home);
|
|
48
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
49
|
+
return null;
|
|
50
|
+
return rel;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The declared path with the home's own spelling stripped off, when it starts
|
|
54
|
+
* with one: `state/artifacts/<task-id>/round2.txt` becomes `round2.txt`, and
|
|
55
|
+
* the bare spelling becomes `''`, which is the home itself and reports as
|
|
56
|
+
* `no-path`. Anything else — an absolute path, a plain filename, another
|
|
57
|
+
* task's home — is handed back as written.
|
|
58
|
+
*/
|
|
59
|
+
function withinHome(declared, homeSpelling) {
|
|
60
|
+
if (homeSpelling === null || path.isAbsolute(declared))
|
|
61
|
+
return declared;
|
|
62
|
+
const normalized = path.normalize(declared);
|
|
63
|
+
if (normalized === homeSpelling)
|
|
64
|
+
return '';
|
|
65
|
+
const prefix = homeSpelling + path.sep;
|
|
66
|
+
return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : declared;
|
|
67
|
+
}
|
|
41
68
|
/** True when `child` is `root` itself or lives beneath it. */
|
|
42
69
|
function contains(root, child) {
|
|
43
70
|
if (child === root)
|
|
@@ -48,7 +75,11 @@ function contains(root, child) {
|
|
|
48
75
|
/**
|
|
49
76
|
* Check every path a result declares as evidence. Relative paths resolve
|
|
50
77
|
* against the task's home, so `coverage.txt` means the obvious thing and the
|
|
51
|
-
* short spelling is also the correct one.
|
|
78
|
+
* short spelling is also the correct one. A path that already begins with the
|
|
79
|
+
* home's own spelling from the work root — `state/artifacts/<task-id>/x` — is
|
|
80
|
+
* read as that spelling, not as a subtree of the home: the template says
|
|
81
|
+
* "under `state/artifacts/<task-id>/`", a coder wrote exactly that, and the
|
|
82
|
+
* doubled path came back `missing` for a file that existed (FD-5).
|
|
52
83
|
*
|
|
53
84
|
* Two properties, in this order. Inside the home first: a path in the wrong
|
|
54
85
|
* place is wrong whether or not the file happens to be there today, and saying
|
|
@@ -79,8 +110,9 @@ export function checkArtifacts(artifacts, opts) {
|
|
|
79
110
|
const realHome = existsSync(home) ? realOf(home) : home;
|
|
80
111
|
const expectedHome = existsSync(home) ? homeUnder(realOf(root), opts.taskId) : home;
|
|
81
112
|
const homeEscaped = realHome !== expectedHome;
|
|
113
|
+
const homeSpelling = spellingFromWorkRoot(home, opts.workRoot ?? WORK_ROOT);
|
|
82
114
|
for (const decl of artifacts) {
|
|
83
|
-
const resolved = path.resolve(home, decl.path);
|
|
115
|
+
const resolved = path.resolve(home, withinHome(decl.path, homeSpelling));
|
|
84
116
|
if (resolved === home) {
|
|
85
117
|
issues.push({ declared: decl.path, resolved, problem: 'no-path' });
|
|
86
118
|
continue;
|
|
@@ -90,7 +122,7 @@ export function checkArtifacts(artifacts, opts) {
|
|
|
90
122
|
continue;
|
|
91
123
|
}
|
|
92
124
|
if (homeEscaped) {
|
|
93
|
-
const real = path.resolve(realHome, decl.path);
|
|
125
|
+
const real = path.resolve(realHome, withinHome(decl.path, homeSpelling));
|
|
94
126
|
issues.push({ declared: decl.path, resolved: real, problem: 'outside-home' });
|
|
95
127
|
continue;
|
|
96
128
|
}
|
|
@@ -157,11 +157,13 @@ export function loadBudgetPolicy(filePath = BUDGETS_POLICY_PATH) {
|
|
|
157
157
|
* visible, so plan validation can say "you wrote down a limit nobody can
|
|
158
158
|
* apply" instead of the plan quietly implying otherwise.
|
|
159
159
|
*
|
|
160
|
-
* `max_turns` is null on purpose, and not because the wiring is pending:
|
|
161
|
-
* limit
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
160
|
+
* `max_turns` is null on purpose, and not because the wiring is pending: the
|
|
161
|
+
* turn limit lives in the dispatch harness, which reads the role template's
|
|
162
|
+
* `maxTurns` (`.claude/agents/<role>.md`) — per role, never per task
|
|
163
|
+
* (agent-interviews.md M-4, answered (c) on 2026-08-05 and corrected
|
|
164
|
+
* 2026-09-11 once the cap was measured). A plan cannot raise or lower a
|
|
165
|
+
* role's ceiling, so a number here would be a second copy the harness never
|
|
166
|
+
* consults; the /bs dispatch contract restates the template's value instead.
|
|
165
167
|
*/
|
|
166
168
|
export const TASK_BUDGET_FIELD_READERS = Object.freeze({
|
|
167
169
|
tokens: 'gate: compared against the result’s token_usage.total_tokens',
|
|
@@ -36,6 +36,7 @@ import { appendEvent, filterEvents, listSessionIds, mergeSessionLogs, readEvents
|
|
|
36
36
|
import { findingsForDispatch } from './findingContext.js';
|
|
37
37
|
import { AMEND_PENDING_STATUS, AMENDED_STATUS, findingScope, listFindings, mintFindings, OPEN_FINDING_STATUSES, raiseFinding, repairObligation, reverifyFinding, SPEC_FINDING_SCOPE, transition as transitionFinding, } from './findings.js';
|
|
38
38
|
import { recordGoalCheck, resolveEpicGoal } from './goalCheck.js';
|
|
39
|
+
import { loadHarnessPolicy, planWorkerTurn, summarizeHarnesses } from './harness.js';
|
|
39
40
|
import { decideHookPayload } from './hookDecision.js';
|
|
40
41
|
import { checkWorktreeImmutable, fingerprintWorktree, } from './immutability.js';
|
|
41
42
|
import { collectExportDiffs, exportImpact, waveImpact } from './impact.js';
|
|
@@ -43,7 +44,7 @@ import { integrationHeadSha, runIntegrationCheck } from './integration.js';
|
|
|
43
44
|
import { judgePreflight } from './judgePreflight.js';
|
|
44
45
|
import { outstandingJudges, readJudgeTurns, recordJudgeDispatch, recordJudgeReport, } from './judges.js';
|
|
45
46
|
import { addMcpSurface, resolveMcpSurface, runMcpCheck } from './mcp.js';
|
|
46
|
-
import { DOTENV_PATH, LESSONS_MD_PATH, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, } from './paths.js';
|
|
47
|
+
import { DOTENV_PATH, LESSONS_MD_PATH, lessonsReadPath, REPO_ROOT, SANDBOX_LEASE_DIR, STATE_DB_PATH, } from './paths.js';
|
|
47
48
|
import { diffPlans, livePlanTasks, resolveTaskId, validatePlan, } from './plan.js';
|
|
48
49
|
import { runPlanQuorum } from './planQuorum.js';
|
|
49
50
|
import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
|
|
@@ -70,6 +71,7 @@ import { COMMANDS, flagSpecFor, helpText, isDocumented, positionalNames, usageFo
|
|
|
70
71
|
import { applyBatch, pendingBatch } from './waivers.js';
|
|
71
72
|
import { blocksAdmission, checkWaveBudget, } from './waveBudget.js';
|
|
72
73
|
import { computeNextWave, liveWaveTasks } from './waveNext.js';
|
|
74
|
+
import { initWorkRoot } from './workroot.js';
|
|
73
75
|
import { createTaskWorktree, listStale, RESERVED_TASK_ID, removeTaskWorktree, taskBranchName, } from './worktree.js';
|
|
74
76
|
function printJson(value) {
|
|
75
77
|
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
@@ -212,10 +214,15 @@ const DEFAULT_JUDGE_BUDGET = { timeout_ms: 120_000, max_output_bytes: 262_144 };
|
|
|
212
214
|
* reachable by typo.
|
|
213
215
|
*/
|
|
214
216
|
function judgeBudgetFromFlags(flags) {
|
|
217
|
+
// No default for max_output_tokens: the ceiling is per model, and an unset
|
|
218
|
+
// field lets the provider's policy entry (crosscheck.yml max_tokens) or the
|
|
219
|
+
// model's own default apply.
|
|
220
|
+
const maxOutputTokens = boundedIntFlag(flags, 'max-output-tokens', { min: 1 });
|
|
215
221
|
return {
|
|
216
222
|
timeout_ms: boundedIntFlag(flags, 'timeout-ms', { min: 1 }) ?? DEFAULT_JUDGE_BUDGET.timeout_ms,
|
|
217
223
|
max_output_bytes: boundedIntFlag(flags, 'max-output-bytes', { min: 1 }) ??
|
|
218
224
|
DEFAULT_JUDGE_BUDGET.max_output_bytes,
|
|
225
|
+
...(maxOutputTokens === undefined ? {} : { max_output_tokens: maxOutputTokens }),
|
|
219
226
|
};
|
|
220
227
|
}
|
|
221
228
|
/**
|
|
@@ -874,7 +881,15 @@ async function main() {
|
|
|
874
881
|
// Critique-only (planQuorum.ts module header): exit 0 means nothing
|
|
875
882
|
// needs the operator (no trigger fired, or endorsed); exit 1 means the
|
|
876
883
|
// operator must look (critiqued or escalated) before approving the plan.
|
|
877
|
-
|
|
884
|
+
//
|
|
885
|
+
// FD-45: the plan may arrive by path. plan.md critiques at step 4 and
|
|
886
|
+
// files plan-v<n>.json at step 6, so at the moment this verb runs the plan
|
|
887
|
+
// is a draft with no versioned file to read; `--plan` hands over the
|
|
888
|
+
// draft itself and `--epic` then defaults to the draft's own epic_id (an
|
|
889
|
+
// explicit `--epic` must agree -- runPlanQuorum refuses one that does
|
|
890
|
+
// not). Without `--plan` the verb reads the filed version as before.
|
|
891
|
+
const draft = flags.plan ? readJsonFile(flags.plan) : undefined;
|
|
892
|
+
const epicId = draft && flags.epic === undefined ? draft.epic_id : requireFlag(flags, 'epic');
|
|
878
893
|
// Required for this verb, unlike the shared envelope where it defaults to
|
|
879
894
|
// 1: a quorum is a critique of one specific plan version.
|
|
880
895
|
requireFlag(flags, 'plan-version');
|
|
@@ -907,8 +922,21 @@ async function main() {
|
|
|
907
922
|
// that names two different plans is worse than one that fails: reading it
|
|
908
923
|
// back, neither number can be trusted. One read, one number.
|
|
909
924
|
const version = ctx.planVersion;
|
|
910
|
-
const outcome = await runPlanQuorum({
|
|
925
|
+
const outcome = await runPlanQuorum({
|
|
926
|
+
epicId,
|
|
927
|
+
version,
|
|
928
|
+
...(draft ? { plan: draft } : {}),
|
|
929
|
+
...(plannerConfidence !== undefined ? { plannerConfidence } : {}),
|
|
930
|
+
planOpts: planOptsFromFlags(flags),
|
|
931
|
+
}, ctx, eventOptsFromFlags(flags));
|
|
911
932
|
printJson(outcome);
|
|
933
|
+
// FD-49: the outcome is a few rationales of several kB each, and the
|
|
934
|
+
// playbook reads it back more often than a terminal does. Same idiom as
|
|
935
|
+
// `lessons compile --out`: write it where told, still print it.
|
|
936
|
+
if (flags.out) {
|
|
937
|
+
mkdirSync(path.dirname(flags.out), { recursive: true });
|
|
938
|
+
writeFileSync(flags.out, `${JSON.stringify(outcome, null, 2)}\n`, 'utf8');
|
|
939
|
+
}
|
|
912
940
|
return outcome.outcome === 'endorsed' ? 0 : 1;
|
|
913
941
|
}
|
|
914
942
|
if (namespace === 'plan' && action === 'ingest') {
|
|
@@ -1223,6 +1251,23 @@ async function main() {
|
|
|
1223
1251
|
printJson(summary);
|
|
1224
1252
|
return summary.exitCode;
|
|
1225
1253
|
}
|
|
1254
|
+
if (namespace === 'init') {
|
|
1255
|
+
// The first command an operator who installed the package runs, and the
|
|
1256
|
+
// only one that exists because installing is not cloning.
|
|
1257
|
+
//
|
|
1258
|
+
// A clone already IS the work root, so this does nothing there and says
|
|
1259
|
+
// so. An install splits in two: the package under node_modules, which the
|
|
1260
|
+
// next `npm i` replaces wholesale, and `.blacksmith/` in the operator's
|
|
1261
|
+
// own repository, which it must not. This creates the second and copies
|
|
1262
|
+
// the files meant to be edited into it — because an answer typed into a
|
|
1263
|
+
// file inside node_modules survives exactly until the first upgrade.
|
|
1264
|
+
const report = initWorkRoot(flags['work-root'] ? { workRoot: flags['work-root'] } : {});
|
|
1265
|
+
printJson(report);
|
|
1266
|
+
// A default that did not ship is a packaging defect, not an operator
|
|
1267
|
+
// error: report it as red rather than leaving them to notice later that
|
|
1268
|
+
// the questionnaire they were told to answer is not there.
|
|
1269
|
+
return report.files.some((file) => file.status === 'missing-default') ? 1 : 0;
|
|
1270
|
+
}
|
|
1226
1271
|
if (namespace === 'new') {
|
|
1227
1272
|
// `smith new <project> [--ui]` — action doubles as the positional
|
|
1228
1273
|
// project name here (splitNamespaceAction), never a subcommand word.
|
|
@@ -2055,6 +2100,36 @@ async function main() {
|
|
|
2055
2100
|
printJson({ sandboxes: listSandboxes(leaseDir) });
|
|
2056
2101
|
return 0;
|
|
2057
2102
|
}
|
|
2103
|
+
// The worker-harness port. `sandbox` above governs what a judge may do once
|
|
2104
|
+
// a turn is running; these two answer the question one step earlier — what
|
|
2105
|
+
// runs the turn at all. Until now the answer was implicit in deployment: the
|
|
2106
|
+
// orchestrator is a Claude Code session, so the only thing it could start
|
|
2107
|
+
// was another one, and no dispatch anywhere recorded that.
|
|
2108
|
+
//
|
|
2109
|
+
// `plan` renders an invocation and returns it. It does not start it —
|
|
2110
|
+
// architecture §18 rule 3: `smith` observes, and an observer that could
|
|
2111
|
+
// dispatch would end up reading its own output back as evidence that a turn
|
|
2112
|
+
// happened. The caller starts the process this prints.
|
|
2113
|
+
//
|
|
2114
|
+
// No `--policy` means the built-in policy, which is the single in-process
|
|
2115
|
+
// harness this factory actually runs. There is no harness.yml to default to,
|
|
2116
|
+
// on purpose: shipping one is the operator's call, not a side effect of
|
|
2117
|
+
// adding the seam.
|
|
2118
|
+
if (namespace === 'harness' && action === 'list') {
|
|
2119
|
+
printJson(summarizeHarnesses({ policy: loadHarnessPolicy(flags.policy) }));
|
|
2120
|
+
return 0;
|
|
2121
|
+
}
|
|
2122
|
+
if (namespace === 'harness' && action === 'plan') {
|
|
2123
|
+
const invocation = planWorkerTurn({
|
|
2124
|
+
harness: flags.harness,
|
|
2125
|
+
role: requireFlag(flags, 'role'),
|
|
2126
|
+
taskId: requireFlag(flags, 'task'),
|
|
2127
|
+
promptFile: requireFlag(flags, 'prompt-file'),
|
|
2128
|
+
worktree: flags.worktree ?? null,
|
|
2129
|
+
}, { policy: loadHarnessPolicy(flags.policy) });
|
|
2130
|
+
printJson(invocation);
|
|
2131
|
+
return 0;
|
|
2132
|
+
}
|
|
2058
2133
|
if (namespace === 'stack' && (action === 'show' || action === 'check')) {
|
|
2059
2134
|
// The install interview's answers, read back (`show`) and priced against
|
|
2060
2135
|
// what factory/scaffold/ can actually build (`check`).
|
|
@@ -2192,11 +2267,18 @@ async function main() {
|
|
|
2192
2267
|
// blocking the gate on a judge that had just handed in its evidence. One
|
|
2193
2268
|
// close per role: a judge that splits its findings across two files still
|
|
2194
2269
|
// owes one turn, and a second report against it would be a duplicate.
|
|
2270
|
+
//
|
|
2271
|
+
// FD-1 (csb-audit-1): `--grader` is the same hand-over for the grader. Its
|
|
2272
|
+
// verdict document is not a findings list, so until `judge report` learned
|
|
2273
|
+
// the shape the grader's turn stayed open with its verdict on the command
|
|
2274
|
+
// line, and the gate blocked on the judge it was about to read.
|
|
2195
2275
|
const evidenceGiven = evidenceSources(args);
|
|
2196
|
-
|
|
2276
|
+
const graderGiven = flags.grader ? [{ foundBy: 'grader', file: flags.grader }] : [];
|
|
2277
|
+
const handedIn = [...evidenceGiven, ...graderGiven];
|
|
2278
|
+
if (handedIn.length > 0) {
|
|
2197
2279
|
const turns = await readJudgeTurns(taskId, ctx, eventOptsFromFlags(flags));
|
|
2198
2280
|
const closed = new Set();
|
|
2199
|
-
for (const { foundBy, file } of
|
|
2281
|
+
for (const { foundBy, file } of handedIn) {
|
|
2200
2282
|
if (closed.has(foundBy))
|
|
2201
2283
|
continue;
|
|
2202
2284
|
if (!turns.some((t) => t.role === foundBy && !t.reported))
|
|
@@ -2314,7 +2396,7 @@ async function main() {
|
|
|
2314
2396
|
// a different number, and the half it drops is the earlier one — the half
|
|
2315
2397
|
// that holds the first occurrence every repeat is counted against.
|
|
2316
2398
|
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
2317
|
-
const lessons = parseLessons(readFileSync(flags.lessons ??
|
|
2399
|
+
const lessons = parseLessons(readFileSync(flags.lessons ?? lessonsReadPath(), 'utf8'));
|
|
2318
2400
|
const report = checkSameMistakeKpi(events, lessons, { sessionId });
|
|
2319
2401
|
printJson(report);
|
|
2320
2402
|
return report.ok ? 0 : 1;
|
|
@@ -2780,7 +2862,13 @@ async function main() {
|
|
|
2780
2862
|
const { lessonsPage } = await import('./db/queries.js');
|
|
2781
2863
|
const { compileLessons } = await import('./lessons.js');
|
|
2782
2864
|
const dbPath = flags.db ?? STATE_DB_PATH;
|
|
2865
|
+
// The operator's copy, not the package's: under an install the shipped one
|
|
2866
|
+
// lives in node_modules, which the next `npm i` replaces wholesale. mkdir
|
|
2867
|
+
// because nothing has necessarily written under factory/ in the work root
|
|
2868
|
+
// before -- `smith init` seeds the two files the operator edits by hand,
|
|
2869
|
+
// and this one is not among them.
|
|
2783
2870
|
const outPath = flags.out ?? LESSONS_MD_PATH;
|
|
2871
|
+
mkdirSync(path.dirname(outPath), { recursive: true });
|
|
2784
2872
|
const handle = openDb(dbPath);
|
|
2785
2873
|
try {
|
|
2786
2874
|
const scope = flags.session ? { sessionId: flags.session } : {};
|
|
@@ -2832,7 +2920,7 @@ async function main() {
|
|
|
2832
2920
|
// question is whether an entry has EVER fired, and a session-scoped read
|
|
2833
2921
|
// answers "not in this half of the epic" while printing `retire`.
|
|
2834
2922
|
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
2835
|
-
const lessons = parseLessons(readFileSync(flags.lessons ??
|
|
2923
|
+
const lessons = parseLessons(readFileSync(flags.lessons ?? lessonsReadPath(), 'utf8'));
|
|
2836
2924
|
const report = auditLessons(events, lessons, { sessionId });
|
|
2837
2925
|
printJson(report);
|
|
2838
2926
|
return report.ok ? 0 : 1;
|
|
@@ -3022,6 +3110,17 @@ async function main() {
|
|
|
3022
3110
|
// Node stack trace on stdout with no error.code, the one shape every other
|
|
3023
3111
|
// error from this CLI has.
|
|
3024
3112
|
const port = boundedIntFlag(flags, 'port', { min: 1, max: 65535 }) ?? 4680;
|
|
3113
|
+
// A fixed clock for screenshot harnesses; never for operators. The server
|
|
3114
|
+
// now decides time-dependent facts itself (a live agent is *working* only
|
|
3115
|
+
// within DEFAULT_STALE_HOURS of its dispatch), and ui/e2e pins the browser
|
|
3116
|
+
// clock to one fixture instant — a pinned page over an unpinned server
|
|
3117
|
+
// would draw every fixture agent as stalled. Unset, every request reads
|
|
3118
|
+
// the wall clock (AppOpts.nowIso). Validated here, before the import, for
|
|
3119
|
+
// the same reason --port is: a typo should cost a message, not a build.
|
|
3120
|
+
const nowIso = flags['now-iso'];
|
|
3121
|
+
if (nowIso !== undefined && Number.isNaN(Date.parse(nowIso))) {
|
|
3122
|
+
throw new SmithError('ui.invalid-now', `--now-iso must be an ISO-8601 timestamp Date.parse can read, got "${nowIso}".`, { flag: 'now-iso', value: nowIso });
|
|
3123
|
+
}
|
|
3025
3124
|
// ui/server is a separate TS project (ui/server/tsconfig.json) built to
|
|
3026
3125
|
// ui/server/dist/index.js — dynamically imported here (via a computed,
|
|
3027
3126
|
// non-literal specifier, so tsc never tries to fold it into THIS
|
|
@@ -3058,6 +3157,7 @@ async function main() {
|
|
|
3058
3157
|
...(stateDir ? { stateDir } : {}),
|
|
3059
3158
|
...(roadmapPath ? { roadmapPath } : {}),
|
|
3060
3159
|
...(specsDir ? { specsDir } : {}),
|
|
3160
|
+
...(nowIso !== undefined ? { nowIso } : {}),
|
|
3061
3161
|
});
|
|
3062
3162
|
return 0;
|
|
3063
3163
|
}
|
|
@@ -151,6 +151,14 @@ function quorumNumber(field, value) {
|
|
|
151
151
|
}
|
|
152
152
|
return value;
|
|
153
153
|
}
|
|
154
|
+
/** A token count: `quorumNumber` plus "whole and above zero", which is what a provider accepts as max_tokens. */
|
|
155
|
+
function positiveInteger(field, value) {
|
|
156
|
+
const n = quorumNumber(field, value);
|
|
157
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
158
|
+
throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be a positive integer; got ${JSON.stringify(value)}.`, { field, value });
|
|
159
|
+
}
|
|
160
|
+
return n;
|
|
161
|
+
}
|
|
154
162
|
function quorumBoolean(field, value) {
|
|
155
163
|
if (typeof value !== 'boolean') {
|
|
156
164
|
throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be true or false; got ${JSON.stringify(value)}. This file is YAML 1.2, where no/off/yes/on are strings, and a non-empty string is truthy. On an external provider's enabled the one other legal value is auto, unquoted and lowercase, which resolves against that provider's precondition on the box reading the file. ${DECIDES_DIFFERENTLY}`, { field, value });
|
|
@@ -277,6 +285,9 @@ function parseProvider(name, raw) {
|
|
|
277
285
|
model: quorumString(`providers.${name}.model`, raw.model),
|
|
278
286
|
apiKeyEnv,
|
|
279
287
|
responseFormatJsonObject: quorumBoolean(`providers.${name}.response_format_json_object`, raw.response_format_json_object ?? true),
|
|
288
|
+
...(raw.max_tokens === undefined
|
|
289
|
+
? {}
|
|
290
|
+
: { maxTokens: positiveInteger(`providers.${name}.max_tokens`, raw.max_tokens) }),
|
|
280
291
|
};
|
|
281
292
|
}
|
|
282
293
|
throw new CrosscheckError('crosscheck.invalid-provider', `Provider "${name}" is not kind: native and has no recognized transport (got "${String(raw.transport)}").`, { provider: name, transport: raw.transport });
|
|
@@ -76,11 +76,25 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
76
76
|
const schedulerPolicy = opts.schedulerPolicy ?? loadSchedulerPolicy();
|
|
77
77
|
const staleHours = opts.staleHours ?? DEFAULT_STALE_HOURS;
|
|
78
78
|
const findings = [];
|
|
79
|
+
// A closed epic's bill is final. `checkBudgetAlarm` still prices it, and
|
|
80
|
+
// `smith budget alarm` still prints it, but "at-risk" or "unverifiable"
|
|
81
|
+
// there warns about spend that cannot happen, and the daemon would raise
|
|
82
|
+
// the same line every tick until the log is archived.
|
|
83
|
+
const closedEpics = new Set();
|
|
84
|
+
for (const { record } of events) {
|
|
85
|
+
if (record.event_type !== 'epic-closed')
|
|
86
|
+
continue;
|
|
87
|
+
const epicId = record.payload.epic_id;
|
|
88
|
+
if (typeof epicId === 'string')
|
|
89
|
+
closedEpics.add(epicId);
|
|
90
|
+
}
|
|
79
91
|
const budget = checkBudgetAlarm(events, budgetPolicy, { sessionId });
|
|
80
92
|
for (const epic of budget.epics) {
|
|
81
93
|
// `under` is the only status that is an answer rather than a question.
|
|
82
94
|
if (epic.status === 'under')
|
|
83
95
|
continue;
|
|
96
|
+
if (closedEpics.has(epic.epicId))
|
|
97
|
+
continue;
|
|
84
98
|
findings.push({
|
|
85
99
|
kind: 'budget',
|
|
86
100
|
severity: epic.status === 'unverifiable' ? 'info' : 'attention',
|