@juzser/blacksmith 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/planner.md +21 -0
- package/.claude/skills/bs/SKILL.md +35 -19
- package/.claude/skills/bs/audit.md +4 -2
- package/.claude/skills/bs/dispatch.md +5 -5
- package/.claude/skills/bs/new.md +4 -2
- package/.claude/skills/bs/plan.md +22 -5
- package/.claude/skills/bs/report.md +3 -3
- package/.claude/skills/bs/run.md +6 -4
- package/.claude/skills/bs/ui.md +10 -3
- package/.claude/skills/bs/wave.md +13 -7
- package/README.md +56 -53
- package/factory/orchestrator/dist/claims.js +27 -1
- package/factory/orchestrator/dist/cli.js +94 -25
- package/factory/orchestrator/dist/daemon.js +16 -5
- package/factory/orchestrator/dist/events.js +50 -0
- package/factory/orchestrator/dist/harness.js +233 -84
- package/factory/orchestrator/dist/impact.js +76 -5
- package/factory/orchestrator/dist/paths.js +2 -0
- package/factory/orchestrator/dist/plan.js +60 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +7 -73
- package/factory/orchestrator/dist/run-cli.js +130 -0
- package/factory/orchestrator/dist/runner.js +264 -0
- package/factory/orchestrator/dist/spawn.js +84 -0
- package/factory/orchestrator/dist/usage.js +8 -6
- package/factory/orchestrator/dist/waveNext.js +42 -14
- package/factory/orchestrator/dist/waveSchedule.js +7 -1
- package/factory/policies/budgets.yml +1 -1
- package/factory/policies/harness.yml +82 -0
- package/factory/policies/severity.yml +1 -1
- package/factory/specs/schema/task-spec.schema.json +5 -0
- package/package.json +3 -2
|
@@ -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,11 +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
|
+
import { loadHarnessPolicy, planWorkerTurn, summarizeHarnesses, } from './harness.js';
|
|
40
40
|
import { decideHookPayload } from './hookDecision.js';
|
|
41
41
|
import { checkWorktreeImmutable, fingerprintWorktree, } from './immutability.js';
|
|
42
42
|
import { collectExportDiffs, exportImpact, waveImpact } from './impact.js';
|
|
@@ -1126,13 +1126,17 @@ async function main() {
|
|
|
1126
1126
|
// as "disjoint from everyone" — the answer that admits the wave. The shape
|
|
1127
1127
|
// is that function's to judge, once, where the comparison happens.
|
|
1128
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();
|
|
1129
1132
|
for (const t of plan.tasks) {
|
|
1130
1133
|
claimsById.set(t.task_id, t.claims);
|
|
1134
|
+
if (t.keeps_exports !== undefined)
|
|
1135
|
+
promisesById.set(t.task_id, t.keeps_exports);
|
|
1131
1136
|
}
|
|
1132
|
-
const tasks = taskIds.map((id) => (
|
|
1133
|
-
task_id: id,
|
|
1134
|
-
claims: claimsById.get(id)
|
|
1135
|
-
}));
|
|
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) });
|
|
1136
1140
|
const policy = loadWorktreePolicy();
|
|
1137
1141
|
// D-212: the plan has been in hand since the top of this verb, and it is
|
|
1138
1142
|
// the register that says which of these tasks may not run beside which.
|
|
@@ -1459,11 +1463,22 @@ async function main() {
|
|
|
1459
1463
|
};
|
|
1460
1464
|
process.once('SIGTERM', requestStop);
|
|
1461
1465
|
process.once('SIGINT', requestStop);
|
|
1462
|
-
|
|
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({
|
|
1463
1478
|
dir,
|
|
1464
1479
|
intervalSeconds: interval,
|
|
1465
1480
|
...tickOpts,
|
|
1466
|
-
...(
|
|
1481
|
+
...(once ? { once: true } : { onTick: printJson }),
|
|
1467
1482
|
shouldContinue: () => !stopping,
|
|
1468
1483
|
sleep: (ms) => new Promise((resolve) => {
|
|
1469
1484
|
const timer = setTimeout(() => {
|
|
@@ -1477,8 +1492,7 @@ async function main() {
|
|
|
1477
1492
|
};
|
|
1478
1493
|
}),
|
|
1479
1494
|
});
|
|
1480
|
-
|
|
1481
|
-
printJson({ ticks: reports.length, dir, ...(last === undefined ? {} : { last }) });
|
|
1495
|
+
printJson({ ticks: run.ticks, dir, ...(once && run.last !== null ? { last: run.last } : {}) });
|
|
1482
1496
|
return 0;
|
|
1483
1497
|
}
|
|
1484
1498
|
if (namespace === 'daemon' && action === 'start') {
|
|
@@ -1850,9 +1864,57 @@ async function main() {
|
|
|
1850
1864
|
// last n events of a concatenation are the last n of the LAST session, so
|
|
1851
1865
|
// an operator resuming an epic saw its newest events padded with nothing
|
|
1852
1866
|
// from before the split. Now `--lineage` tails the epic in time order.
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
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);
|
|
1856
1918
|
if (flags.task)
|
|
1857
1919
|
events = filterEvents(events, { taskId: flags.task });
|
|
1858
1920
|
printJson(events);
|
|
@@ -2000,14 +2062,18 @@ async function main() {
|
|
|
2000
2062
|
if (positional.length === 0) {
|
|
2001
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 });
|
|
2002
2064
|
}
|
|
2003
|
-
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.
|
|
2004
2069
|
const tasks = positional.map((typed) => {
|
|
2005
2070
|
const id = resolveTaskId(plan, typed);
|
|
2006
|
-
const
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
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 });
|
|
2011
2077
|
});
|
|
2012
2078
|
// The declarations are read off the checkout, not off the plan: a claim
|
|
2013
2079
|
// says which files a task may write, and only the tree says what those
|
|
@@ -2023,7 +2089,7 @@ async function main() {
|
|
|
2023
2089
|
// the diff this task committed, and everyone in the repo who imports it.
|
|
2024
2090
|
const diffs = collectExportDiffs(worktreeDir, collectCommittedChanges(worktreeDir));
|
|
2025
2091
|
const graph = buildSymbolGraph(collectSources(worktreeDir));
|
|
2026
|
-
const report = exportImpact(graph, diffs, spec.claims);
|
|
2092
|
+
const report = exportImpact(graph, diffs, spec.claims, spec.keeps_exports ?? []);
|
|
2027
2093
|
printJson(report);
|
|
2028
2094
|
return report.ok ? 0 : 1;
|
|
2029
2095
|
}
|
|
@@ -2111,21 +2177,24 @@ async function main() {
|
|
|
2111
2177
|
// dispatch would end up reading its own output back as evidence that a turn
|
|
2112
2178
|
// happened. The caller starts the process this prints.
|
|
2113
2179
|
//
|
|
2114
|
-
// No `--policy` means
|
|
2115
|
-
//
|
|
2116
|
-
//
|
|
2117
|
-
//
|
|
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.
|
|
2118
2184
|
if (namespace === 'harness' && action === 'list') {
|
|
2119
2185
|
printJson(summarizeHarnesses({ policy: loadHarnessPolicy(flags.policy) }));
|
|
2120
2186
|
return 0;
|
|
2121
2187
|
}
|
|
2122
2188
|
if (namespace === 'harness' && action === 'plan') {
|
|
2189
|
+
const tier = flags.tier;
|
|
2123
2190
|
const invocation = planWorkerTurn({
|
|
2124
2191
|
harness: flags.harness,
|
|
2125
2192
|
role: requireFlag(flags, 'role'),
|
|
2126
2193
|
taskId: requireFlag(flags, 'task'),
|
|
2127
2194
|
promptFile: requireFlag(flags, 'prompt-file'),
|
|
2128
2195
|
worktree: flags.worktree ?? null,
|
|
2196
|
+
...(tier !== undefined ? { tier } : {}),
|
|
2197
|
+
...(flags.schema !== undefined ? { schema: flags.schema } : {}),
|
|
2129
2198
|
}, { policy: loadHarnessPolicy(flags.policy) });
|
|
2130
2199
|
printJson(invocation);
|
|
2131
2200
|
return 0;
|
|
@@ -648,6 +648,11 @@ function defaultSleep(ms) {
|
|
|
648
648
|
* The `finally` is the point of the whole function: a loop that exits still
|
|
649
649
|
* holding its lock — because a tick threw, because the operator hit ^C — is a
|
|
650
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.
|
|
651
656
|
*/
|
|
652
657
|
export async function runDaemon(opts) {
|
|
653
658
|
const pid = opts.pid ?? process.pid;
|
|
@@ -678,17 +683,23 @@ export async function runDaemon(opts) {
|
|
|
678
683
|
writeFindingMemory(opts.dir, memoryOf(report.findings));
|
|
679
684
|
return report;
|
|
680
685
|
};
|
|
681
|
-
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
|
+
};
|
|
682
693
|
try {
|
|
683
694
|
if (opts.once === true) {
|
|
684
|
-
|
|
685
|
-
return
|
|
695
|
+
await tickAndTell();
|
|
696
|
+
return run;
|
|
686
697
|
}
|
|
687
698
|
while (shouldContinue()) {
|
|
688
|
-
|
|
699
|
+
await tickAndTell();
|
|
689
700
|
await sleep(intervalSeconds * 1000);
|
|
690
701
|
}
|
|
691
|
-
return
|
|
702
|
+
return run;
|
|
692
703
|
}
|
|
693
704
|
finally {
|
|
694
705
|
releaseLock(opts.dir, pid);
|
|
@@ -1047,3 +1047,53 @@ export function filterEvents(events, filter) {
|
|
|
1047
1047
|
return true;
|
|
1048
1048
|
});
|
|
1049
1049
|
}
|
|
1050
|
+
/**
|
|
1051
|
+
* How often `--follow` re-reads a log it is following.
|
|
1052
|
+
*
|
|
1053
|
+
* A second is well under the interval an operator perceives as lag, and a
|
|
1054
|
+
* re-read costs what one `smith event tail` costs -- the poll is not what
|
|
1055
|
+
* makes a busy factory expensive. There is no flag for it on purpose: a
|
|
1056
|
+
* knob no answer depends on is a knob every reader of `--help` pays for.
|
|
1057
|
+
*/
|
|
1058
|
+
export const FOLLOW_TICK_MS = 1000;
|
|
1059
|
+
/**
|
|
1060
|
+
* The events in `events` that `seen` does not already hold, in `events`'
|
|
1061
|
+
* order.
|
|
1062
|
+
*
|
|
1063
|
+
* The cursor is a set of ids rather than a count because `--lineage` merges
|
|
1064
|
+
* several logs by `ts` (mergeSessionLogs above): an event appended now can
|
|
1065
|
+
* sort BEHIND one already printed, and a stream cannot un-print. A length
|
|
1066
|
+
* cursor would either re-emit the tail or skip the new event outright, and
|
|
1067
|
+
* both are worse than holding the ids -- which cost what the read they came
|
|
1068
|
+
* from already cost.
|
|
1069
|
+
*/
|
|
1070
|
+
export function unseenEvents(events, seen) {
|
|
1071
|
+
return events.filter((event) => !seen.has(event.event_id));
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Read, emit what is new, sleep, repeat -- until `shouldContinue` says stop.
|
|
1075
|
+
*
|
|
1076
|
+
* The seams are runDaemon's, for the same reason: the contract of a loop is
|
|
1077
|
+
* what it does AROUND the read, and a loop that owns its own clock can only
|
|
1078
|
+
* be tested by waiting. `sleep` is where an interrupt lands, so the caller
|
|
1079
|
+
* that wires SIGINT resolves the sleep rather than waiting out the interval.
|
|
1080
|
+
*
|
|
1081
|
+
* Returns how many events it emitted, which is the only thing about a stream
|
|
1082
|
+
* worth having after it ends.
|
|
1083
|
+
*/
|
|
1084
|
+
export async function followEvents(opts) {
|
|
1085
|
+
const intervalMs = opts.intervalMs ?? FOLLOW_TICK_MS;
|
|
1086
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1087
|
+
const shouldContinue = opts.shouldContinue ?? (() => true);
|
|
1088
|
+
const seen = new Set(opts.seen ?? []);
|
|
1089
|
+
let emitted = 0;
|
|
1090
|
+
while (shouldContinue()) {
|
|
1091
|
+
for (const event of unseenEvents(await opts.read(), seen)) {
|
|
1092
|
+
seen.add(event.event_id);
|
|
1093
|
+
opts.emit(event);
|
|
1094
|
+
emitted += 1;
|
|
1095
|
+
}
|
|
1096
|
+
await sleep(intervalMs);
|
|
1097
|
+
}
|
|
1098
|
+
return emitted;
|
|
1099
|
+
}
|