@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
|
@@ -22,7 +22,7 @@ import { parseArgs } from './args.js';
|
|
|
22
22
|
import { closeAudit, consolidateAudit, cutAudit, decideAudit, openAudit, recordAudit, resolveAudit, } from './audit.js';
|
|
23
23
|
import { checkBudgetAlarm } from './budgetAlarm.js';
|
|
24
24
|
import { loadBudgetPolicy } from './budgets.js';
|
|
25
|
-
import { collectCommittedChanges, loadWorktreePolicy, postRunCheck, validateWave, writeRootCheck, } from './claims.js';
|
|
25
|
+
import { collectCommittedChanges, loadWorktreePolicy, postRunCheck, readClaimList, validateWave, writeRootCheck, } from './claims.js';
|
|
26
26
|
import { collectCoverageEvidence } from './coverage.js';
|
|
27
27
|
import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
28
28
|
import { independentFinderRequest, reconcile, runIndependentFinder, } from './crossFinding.js';
|
|
@@ -32,10 +32,11 @@ import { loadDotEnv } from './dotenv.js';
|
|
|
32
32
|
import { loadEffortPolicy, resolveEffort } from './effort.js';
|
|
33
33
|
import { SmithError } from './errors.js';
|
|
34
34
|
import { checkEscalationLadder } from './escalation.js';
|
|
35
|
-
import { appendEvent, filterEvents, listSessionIds, mergeSessionLogs, readEvents, readLineageEvents, requireSession, sessionTree, startSession, tailEvents, } from './events.js';
|
|
35
|
+
import { appendEvent, filterEvents, followEvents, listSessionIds, mergeSessionLogs, readEvents, readLineageEvents, requireSession, sessionTree, startSession, tailEvents, } from './events.js';
|
|
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') {
|
|
@@ -1098,13 +1126,17 @@ async function main() {
|
|
|
1098
1126
|
// as "disjoint from everyone" — the answer that admits the wave. The shape
|
|
1099
1127
|
// is that function's to judge, once, where the comparison happens.
|
|
1100
1128
|
const claimsById = new Map(logged.map((t) => [t.taskId, t.claims]));
|
|
1129
|
+
// A keeps_exports promise lives on the plan record alone: the log carries
|
|
1130
|
+
// claims, and a promise the plan never made is not one this verb honours.
|
|
1131
|
+
const promisesById = new Map();
|
|
1101
1132
|
for (const t of plan.tasks) {
|
|
1102
1133
|
claimsById.set(t.task_id, t.claims);
|
|
1134
|
+
if (t.keeps_exports !== undefined)
|
|
1135
|
+
promisesById.set(t.task_id, t.keeps_exports);
|
|
1103
1136
|
}
|
|
1104
|
-
const tasks = taskIds.map((id) => (
|
|
1105
|
-
task_id: id,
|
|
1106
|
-
claims: claimsById.get(id)
|
|
1107
|
-
}));
|
|
1137
|
+
const tasks = taskIds.map((id) => promisesById.has(id)
|
|
1138
|
+
? { task_id: id, claims: claimsById.get(id), keeps_exports: promisesById.get(id) }
|
|
1139
|
+
: { task_id: id, claims: claimsById.get(id) });
|
|
1108
1140
|
const policy = loadWorktreePolicy();
|
|
1109
1141
|
// D-212: the plan has been in hand since the top of this verb, and it is
|
|
1110
1142
|
// the register that says which of these tasks may not run beside which.
|
|
@@ -1223,6 +1255,23 @@ async function main() {
|
|
|
1223
1255
|
printJson(summary);
|
|
1224
1256
|
return summary.exitCode;
|
|
1225
1257
|
}
|
|
1258
|
+
if (namespace === 'init') {
|
|
1259
|
+
// The first command an operator who installed the package runs, and the
|
|
1260
|
+
// only one that exists because installing is not cloning.
|
|
1261
|
+
//
|
|
1262
|
+
// A clone already IS the work root, so this does nothing there and says
|
|
1263
|
+
// so. An install splits in two: the package under node_modules, which the
|
|
1264
|
+
// next `npm i` replaces wholesale, and `.blacksmith/` in the operator's
|
|
1265
|
+
// own repository, which it must not. This creates the second and copies
|
|
1266
|
+
// the files meant to be edited into it — because an answer typed into a
|
|
1267
|
+
// file inside node_modules survives exactly until the first upgrade.
|
|
1268
|
+
const report = initWorkRoot(flags['work-root'] ? { workRoot: flags['work-root'] } : {});
|
|
1269
|
+
printJson(report);
|
|
1270
|
+
// A default that did not ship is a packaging defect, not an operator
|
|
1271
|
+
// error: report it as red rather than leaving them to notice later that
|
|
1272
|
+
// the questionnaire they were told to answer is not there.
|
|
1273
|
+
return report.files.some((file) => file.status === 'missing-default') ? 1 : 0;
|
|
1274
|
+
}
|
|
1226
1275
|
if (namespace === 'new') {
|
|
1227
1276
|
// `smith new <project> [--ui]` — action doubles as the positional
|
|
1228
1277
|
// project name here (splitNamespaceAction), never a subcommand word.
|
|
@@ -1414,11 +1463,22 @@ async function main() {
|
|
|
1414
1463
|
};
|
|
1415
1464
|
process.once('SIGTERM', requestStop);
|
|
1416
1465
|
process.once('SIGINT', requestStop);
|
|
1417
|
-
|
|
1466
|
+
// The loop prints as it goes and `--once` prints as it ends, and the two
|
|
1467
|
+
// are different shapes on purpose. A loop that only answered when it
|
|
1468
|
+
// ended answered nothing -- the foreground form was silent for as long as
|
|
1469
|
+
// it ran, and the detached one wrote a `daemon.log` with nothing in it.
|
|
1470
|
+
// One record per tick is what a log is; one record per run is what cron
|
|
1471
|
+
// wants to parse, and `--once` keeps the shape it has always had.
|
|
1472
|
+
const once = flags.once === 'true';
|
|
1473
|
+
// `smith daemon run | head -1` closes the pipe under the second tick.
|
|
1474
|
+
// That is the reader saying "enough", not a failure worth an error line
|
|
1475
|
+
// -- and not one worth an uncaught exception that skips the lock release.
|
|
1476
|
+
process.stdout.on('error', requestStop);
|
|
1477
|
+
const run = await runDaemon({
|
|
1418
1478
|
dir,
|
|
1419
1479
|
intervalSeconds: interval,
|
|
1420
1480
|
...tickOpts,
|
|
1421
|
-
...(
|
|
1481
|
+
...(once ? { once: true } : { onTick: printJson }),
|
|
1422
1482
|
shouldContinue: () => !stopping,
|
|
1423
1483
|
sleep: (ms) => new Promise((resolve) => {
|
|
1424
1484
|
const timer = setTimeout(() => {
|
|
@@ -1432,8 +1492,7 @@ async function main() {
|
|
|
1432
1492
|
};
|
|
1433
1493
|
}),
|
|
1434
1494
|
});
|
|
1435
|
-
|
|
1436
|
-
printJson({ ticks: reports.length, dir, ...(last === undefined ? {} : { last }) });
|
|
1495
|
+
printJson({ ticks: run.ticks, dir, ...(once && run.last !== null ? { last: run.last } : {}) });
|
|
1437
1496
|
return 0;
|
|
1438
1497
|
}
|
|
1439
1498
|
if (namespace === 'daemon' && action === 'start') {
|
|
@@ -1805,9 +1864,57 @@ async function main() {
|
|
|
1805
1864
|
// last n events of a concatenation are the last n of the LAST session, so
|
|
1806
1865
|
// an operator resuming an epic saw its newest events padded with nothing
|
|
1807
1866
|
// from before the split. Now `--lineage` tails the epic in time order.
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1867
|
+
const scope = async () => flags.lineage ? readLineageEvents(sessionId, opts) : readEvents(sessionId, opts);
|
|
1868
|
+
if (flags.follow === 'true') {
|
|
1869
|
+
// A stream is not an array, so `--follow` prints one event per line --
|
|
1870
|
+
// the backlog included. A reader can pipe that into `jq -c` or `grep`
|
|
1871
|
+
// from the first event, rather than waiting on a closing bracket that
|
|
1872
|
+
// by definition never comes.
|
|
1873
|
+
const start = await scope();
|
|
1874
|
+
let backlog = start.slice(-n);
|
|
1875
|
+
if (flags.task)
|
|
1876
|
+
backlog = filterEvents(backlog, { taskId: flags.task });
|
|
1877
|
+
for (const event of backlog)
|
|
1878
|
+
printJson(event);
|
|
1879
|
+
// The interrupt has to reach the sleep, not just the flag -- `daemon
|
|
1880
|
+
// run` below wires the same pair for the same reason. An operator who
|
|
1881
|
+
// hits ^C and then waits out an interval reaches for `kill -9`.
|
|
1882
|
+
let stopping = false;
|
|
1883
|
+
let wake = null;
|
|
1884
|
+
const requestStop = () => {
|
|
1885
|
+
stopping = true;
|
|
1886
|
+
wake?.();
|
|
1887
|
+
};
|
|
1888
|
+
process.once('SIGINT', requestStop);
|
|
1889
|
+
process.once('SIGTERM', requestStop);
|
|
1890
|
+
// `smith event tail --follow | head -5` closes the pipe under us. That
|
|
1891
|
+
// is the reader saying "enough", not a failure worth an error line. We
|
|
1892
|
+
// only hear it on the next write, the way `tail -f` does: a quiet log
|
|
1893
|
+
// keeps an orphaned follower polling until something lands in it.
|
|
1894
|
+
process.stdout.on('error', requestStop);
|
|
1895
|
+
await followEvents({
|
|
1896
|
+
seen: start.map((event) => event.event_id),
|
|
1897
|
+
read: async () => {
|
|
1898
|
+
const fresh = await scope();
|
|
1899
|
+
return flags.task ? filterEvents(fresh, { taskId: flags.task }) : fresh;
|
|
1900
|
+
},
|
|
1901
|
+
emit: printJson,
|
|
1902
|
+
shouldContinue: () => !stopping,
|
|
1903
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
1904
|
+
const timer = setTimeout(() => {
|
|
1905
|
+
wake = null;
|
|
1906
|
+
resolve();
|
|
1907
|
+
}, ms);
|
|
1908
|
+
wake = () => {
|
|
1909
|
+
clearTimeout(timer);
|
|
1910
|
+
wake = null;
|
|
1911
|
+
resolve();
|
|
1912
|
+
};
|
|
1913
|
+
}),
|
|
1914
|
+
});
|
|
1915
|
+
return 0;
|
|
1916
|
+
}
|
|
1917
|
+
let events = flags.lineage ? (await scope()).slice(-n) : await tailEvents(sessionId, n, opts);
|
|
1811
1918
|
if (flags.task)
|
|
1812
1919
|
events = filterEvents(events, { taskId: flags.task });
|
|
1813
1920
|
printJson(events);
|
|
@@ -1955,14 +2062,18 @@ async function main() {
|
|
|
1955
2062
|
if (positional.length === 0) {
|
|
1956
2063
|
throw new SmithError('cli.empty-wave', 'Usage: smith claims impact --plan <plan.json> <task-id>... — a wave with no tasks is not a wave.', { plan: plan.epic_id });
|
|
1957
2064
|
}
|
|
1958
|
-
const
|
|
2065
|
+
const recordsById = new Map(plan.tasks.map((task) => [task.task_id, task]));
|
|
2066
|
+
// Claims and keeps_exports are read through the one door validateWave
|
|
2067
|
+
// uses (claims.ts readClaimList), so a promise that is not a list of file
|
|
2068
|
+
// paths is refused here with the same code it would earn at admission.
|
|
1959
2069
|
const tasks = positional.map((typed) => {
|
|
1960
2070
|
const id = resolveTaskId(plan, typed);
|
|
1961
|
-
const
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
2071
|
+
const record = recordsById.get(id);
|
|
2072
|
+
const claims = record?.claims;
|
|
2073
|
+
const keeps = record?.keeps_exports;
|
|
2074
|
+
return readClaimList(keeps === undefined
|
|
2075
|
+
? { task_id: id, claims }
|
|
2076
|
+
: { task_id: id, claims, keeps_exports: keeps });
|
|
1966
2077
|
});
|
|
1967
2078
|
// The declarations are read off the checkout, not off the plan: a claim
|
|
1968
2079
|
// says which files a task may write, and only the tree says what those
|
|
@@ -1978,7 +2089,7 @@ async function main() {
|
|
|
1978
2089
|
// the diff this task committed, and everyone in the repo who imports it.
|
|
1979
2090
|
const diffs = collectExportDiffs(worktreeDir, collectCommittedChanges(worktreeDir));
|
|
1980
2091
|
const graph = buildSymbolGraph(collectSources(worktreeDir));
|
|
1981
|
-
const report = exportImpact(graph, diffs, spec.claims);
|
|
2092
|
+
const report = exportImpact(graph, diffs, spec.claims, spec.keeps_exports ?? []);
|
|
1982
2093
|
printJson(report);
|
|
1983
2094
|
return report.ok ? 0 : 1;
|
|
1984
2095
|
}
|
|
@@ -2055,6 +2166,39 @@ async function main() {
|
|
|
2055
2166
|
printJson({ sandboxes: listSandboxes(leaseDir) });
|
|
2056
2167
|
return 0;
|
|
2057
2168
|
}
|
|
2169
|
+
// The worker-harness port. `sandbox` above governs what a judge may do once
|
|
2170
|
+
// a turn is running; these two answer the question one step earlier — what
|
|
2171
|
+
// runs the turn at all. Until now the answer was implicit in deployment: the
|
|
2172
|
+
// orchestrator is a Claude Code session, so the only thing it could start
|
|
2173
|
+
// was another one, and no dispatch anywhere recorded that.
|
|
2174
|
+
//
|
|
2175
|
+
// `plan` renders an invocation and returns it. It does not start it —
|
|
2176
|
+
// architecture §18 rule 3: `smith` observes, and an observer that could
|
|
2177
|
+
// dispatch would end up reading its own output back as evidence that a turn
|
|
2178
|
+
// happened. The caller starts the process this prints.
|
|
2179
|
+
//
|
|
2180
|
+
// No `--policy` means factory/policies/harness.yml, the shipped policy
|
|
2181
|
+
// (paths.ts's HARNESS_POLICY_PATH) — three harnesses as of this writing,
|
|
2182
|
+
// `claude-code` still the default. `--policy <file>` overrides it, same
|
|
2183
|
+
// shape `stack show` uses.
|
|
2184
|
+
if (namespace === 'harness' && action === 'list') {
|
|
2185
|
+
printJson(summarizeHarnesses({ policy: loadHarnessPolicy(flags.policy) }));
|
|
2186
|
+
return 0;
|
|
2187
|
+
}
|
|
2188
|
+
if (namespace === 'harness' && action === 'plan') {
|
|
2189
|
+
const tier = flags.tier;
|
|
2190
|
+
const invocation = planWorkerTurn({
|
|
2191
|
+
harness: flags.harness,
|
|
2192
|
+
role: requireFlag(flags, 'role'),
|
|
2193
|
+
taskId: requireFlag(flags, 'task'),
|
|
2194
|
+
promptFile: requireFlag(flags, 'prompt-file'),
|
|
2195
|
+
worktree: flags.worktree ?? null,
|
|
2196
|
+
...(tier !== undefined ? { tier } : {}),
|
|
2197
|
+
...(flags.schema !== undefined ? { schema: flags.schema } : {}),
|
|
2198
|
+
}, { policy: loadHarnessPolicy(flags.policy) });
|
|
2199
|
+
printJson(invocation);
|
|
2200
|
+
return 0;
|
|
2201
|
+
}
|
|
2058
2202
|
if (namespace === 'stack' && (action === 'show' || action === 'check')) {
|
|
2059
2203
|
// The install interview's answers, read back (`show`) and priced against
|
|
2060
2204
|
// what factory/scaffold/ can actually build (`check`).
|
|
@@ -2192,11 +2336,18 @@ async function main() {
|
|
|
2192
2336
|
// blocking the gate on a judge that had just handed in its evidence. One
|
|
2193
2337
|
// close per role: a judge that splits its findings across two files still
|
|
2194
2338
|
// owes one turn, and a second report against it would be a duplicate.
|
|
2339
|
+
//
|
|
2340
|
+
// FD-1 (csb-audit-1): `--grader` is the same hand-over for the grader. Its
|
|
2341
|
+
// verdict document is not a findings list, so until `judge report` learned
|
|
2342
|
+
// the shape the grader's turn stayed open with its verdict on the command
|
|
2343
|
+
// line, and the gate blocked on the judge it was about to read.
|
|
2195
2344
|
const evidenceGiven = evidenceSources(args);
|
|
2196
|
-
|
|
2345
|
+
const graderGiven = flags.grader ? [{ foundBy: 'grader', file: flags.grader }] : [];
|
|
2346
|
+
const handedIn = [...evidenceGiven, ...graderGiven];
|
|
2347
|
+
if (handedIn.length > 0) {
|
|
2197
2348
|
const turns = await readJudgeTurns(taskId, ctx, eventOptsFromFlags(flags));
|
|
2198
2349
|
const closed = new Set();
|
|
2199
|
-
for (const { foundBy, file } of
|
|
2350
|
+
for (const { foundBy, file } of handedIn) {
|
|
2200
2351
|
if (closed.has(foundBy))
|
|
2201
2352
|
continue;
|
|
2202
2353
|
if (!turns.some((t) => t.role === foundBy && !t.reported))
|
|
@@ -2314,7 +2465,7 @@ async function main() {
|
|
|
2314
2465
|
// a different number, and the half it drops is the earlier one — the half
|
|
2315
2466
|
// that holds the first occurrence every repeat is counted against.
|
|
2316
2467
|
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
2317
|
-
const lessons = parseLessons(readFileSync(flags.lessons ??
|
|
2468
|
+
const lessons = parseLessons(readFileSync(flags.lessons ?? lessonsReadPath(), 'utf8'));
|
|
2318
2469
|
const report = checkSameMistakeKpi(events, lessons, { sessionId });
|
|
2319
2470
|
printJson(report);
|
|
2320
2471
|
return report.ok ? 0 : 1;
|
|
@@ -2780,7 +2931,13 @@ async function main() {
|
|
|
2780
2931
|
const { lessonsPage } = await import('./db/queries.js');
|
|
2781
2932
|
const { compileLessons } = await import('./lessons.js');
|
|
2782
2933
|
const dbPath = flags.db ?? STATE_DB_PATH;
|
|
2934
|
+
// The operator's copy, not the package's: under an install the shipped one
|
|
2935
|
+
// lives in node_modules, which the next `npm i` replaces wholesale. mkdir
|
|
2936
|
+
// because nothing has necessarily written under factory/ in the work root
|
|
2937
|
+
// before -- `smith init` seeds the two files the operator edits by hand,
|
|
2938
|
+
// and this one is not among them.
|
|
2783
2939
|
const outPath = flags.out ?? LESSONS_MD_PATH;
|
|
2940
|
+
mkdirSync(path.dirname(outPath), { recursive: true });
|
|
2784
2941
|
const handle = openDb(dbPath);
|
|
2785
2942
|
try {
|
|
2786
2943
|
const scope = flags.session ? { sessionId: flags.session } : {};
|
|
@@ -2832,7 +2989,7 @@ async function main() {
|
|
|
2832
2989
|
// question is whether an entry has EVER fired, and a session-scoped read
|
|
2833
2990
|
// answers "not in this half of the epic" while printing `retire`.
|
|
2834
2991
|
const events = await readLineageEvents(sessionId, eventOptsFromFlags(flags));
|
|
2835
|
-
const lessons = parseLessons(readFileSync(flags.lessons ??
|
|
2992
|
+
const lessons = parseLessons(readFileSync(flags.lessons ?? lessonsReadPath(), 'utf8'));
|
|
2836
2993
|
const report = auditLessons(events, lessons, { sessionId });
|
|
2837
2994
|
printJson(report);
|
|
2838
2995
|
return report.ok ? 0 : 1;
|
|
@@ -3022,6 +3179,17 @@ async function main() {
|
|
|
3022
3179
|
// Node stack trace on stdout with no error.code, the one shape every other
|
|
3023
3180
|
// error from this CLI has.
|
|
3024
3181
|
const port = boundedIntFlag(flags, 'port', { min: 1, max: 65535 }) ?? 4680;
|
|
3182
|
+
// A fixed clock for screenshot harnesses; never for operators. The server
|
|
3183
|
+
// now decides time-dependent facts itself (a live agent is *working* only
|
|
3184
|
+
// within DEFAULT_STALE_HOURS of its dispatch), and ui/e2e pins the browser
|
|
3185
|
+
// clock to one fixture instant — a pinned page over an unpinned server
|
|
3186
|
+
// would draw every fixture agent as stalled. Unset, every request reads
|
|
3187
|
+
// the wall clock (AppOpts.nowIso). Validated here, before the import, for
|
|
3188
|
+
// the same reason --port is: a typo should cost a message, not a build.
|
|
3189
|
+
const nowIso = flags['now-iso'];
|
|
3190
|
+
if (nowIso !== undefined && Number.isNaN(Date.parse(nowIso))) {
|
|
3191
|
+
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 });
|
|
3192
|
+
}
|
|
3025
3193
|
// ui/server is a separate TS project (ui/server/tsconfig.json) built to
|
|
3026
3194
|
// ui/server/dist/index.js — dynamically imported here (via a computed,
|
|
3027
3195
|
// non-literal specifier, so tsc never tries to fold it into THIS
|
|
@@ -3058,6 +3226,7 @@ async function main() {
|
|
|
3058
3226
|
...(stateDir ? { stateDir } : {}),
|
|
3059
3227
|
...(roadmapPath ? { roadmapPath } : {}),
|
|
3060
3228
|
...(specsDir ? { specsDir } : {}),
|
|
3229
|
+
...(nowIso !== undefined ? { nowIso } : {}),
|
|
3061
3230
|
});
|
|
3062
3231
|
return 0;
|
|
3063
3232
|
}
|
|
@@ -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',
|
|
@@ -634,6 +648,11 @@ function defaultSleep(ms) {
|
|
|
634
648
|
* The `finally` is the point of the whole function: a loop that exits still
|
|
635
649
|
* holding its lock — because a tick threw, because the operator hit ^C — is a
|
|
636
650
|
* loop nothing can restart without a human deleting a file.
|
|
651
|
+
*
|
|
652
|
+
* It hands each report to `onTick` and keeps only the last. A process meant
|
|
653
|
+
* to run for weeks cannot hold every tick it ever ran — at the default
|
|
654
|
+
* interval that array is ~290 reports a day, forever — and nothing that
|
|
655
|
+
* outlives the loop wants more than a count and the newest one.
|
|
637
656
|
*/
|
|
638
657
|
export async function runDaemon(opts) {
|
|
639
658
|
const pid = opts.pid ?? process.pid;
|
|
@@ -664,17 +683,23 @@ export async function runDaemon(opts) {
|
|
|
664
683
|
writeFindingMemory(opts.dir, memoryOf(report.findings));
|
|
665
684
|
return report;
|
|
666
685
|
};
|
|
667
|
-
const
|
|
686
|
+
const run = { ticks: 0, last: null };
|
|
687
|
+
const tickAndTell = async () => {
|
|
688
|
+
const report = await tickWithMemory();
|
|
689
|
+
run.ticks += 1;
|
|
690
|
+
run.last = report;
|
|
691
|
+
opts.onTick?.(report);
|
|
692
|
+
};
|
|
668
693
|
try {
|
|
669
694
|
if (opts.once === true) {
|
|
670
|
-
|
|
671
|
-
return
|
|
695
|
+
await tickAndTell();
|
|
696
|
+
return run;
|
|
672
697
|
}
|
|
673
698
|
while (shouldContinue()) {
|
|
674
|
-
|
|
699
|
+
await tickAndTell();
|
|
675
700
|
await sleep(intervalSeconds * 1000);
|
|
676
701
|
}
|
|
677
|
-
return
|
|
702
|
+
return run;
|
|
678
703
|
}
|
|
679
704
|
finally {
|
|
680
705
|
releaseLock(opts.dir, pid);
|