@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.
@@ -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
- const reports = await runDaemon({
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
- ...(flags.once === 'true' ? { once: true } : {}),
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
- const last = reports[reports.length - 1];
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
- let events = flags.lineage
1854
- ? (await readLineageEvents(sessionId, opts)).slice(-n)
1855
- : await tailEvents(sessionId, n, opts);
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 claimsById = new Map(plan.tasks.map((task) => [task.task_id, task.claims]));
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 claims = claimsById.get(id);
2007
- if (!Array.isArray(claims) || claims.some((claim) => typeof claim !== 'string')) {
2008
- throw new SmithError('claims.unreadable-claims', `Task "${id}" does not declare its claims as a list of globs.`, { task_id: id, received: claims === undefined ? 'undefined' : typeof claims });
2009
- }
2010
- return { task_id: id, claims };
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 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.
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 reports = [];
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
- reports.push(await tickWithMemory());
685
- return reports;
695
+ await tickAndTell();
696
+ return run;
686
697
  }
687
698
  while (shouldContinue()) {
688
- reports.push(await tickWithMemory());
699
+ await tickAndTell();
689
700
  await sleep(intervalSeconds * 1000);
690
701
  }
691
- return reports;
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
+ }