@juzser/blacksmith 0.3.0 → 0.4.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/auditor.md +3 -0
- package/.claude/agents/grader.md +12 -5
- package/.claude/agents/reviewer.md +3 -0
- package/.claude/agents/security-reviewer.md +3 -0
- package/.claude/agents/spec-reviewer.md +3 -0
- package/.claude/agents/verifier.md +3 -0
- package/.claude/skills/bs/audit.md +12 -5
- package/.claude/skills/bs/dispatch.md +40 -4
- package/.claude/skills/bs/plan.md +33 -1
- package/.claude/skills/bs/run.md +11 -5
- package/.claude/skills/bs/wave.md +40 -3
- package/README.md +1 -1
- package/factory/orchestrator/dist/agentsSync.js +23 -0
- package/factory/orchestrator/dist/audit.js +134 -17
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/cli.js +309 -28
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/daemon.js +198 -31
- package/factory/orchestrator/dist/db/projector.js +51 -27
- package/factory/orchestrator/dist/dispatchLint.js +132 -0
- package/factory/orchestrator/dist/epic.js +179 -12
- package/factory/orchestrator/dist/errorIssues.js +175 -10
- package/factory/orchestrator/dist/events.js +59 -11
- package/factory/orchestrator/dist/findings.js +25 -2
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/hookDecision.js +320 -37
- package/factory/orchestrator/dist/integration.js +11 -1
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgeStopHook.js +181 -0
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/paths.js +10 -0
- package/factory/orchestrator/dist/plan.js +130 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/queue.js +469 -28
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/spec.js +70 -0
- package/factory/orchestrator/dist/taskEvents.js +13 -4
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testgate.js +4 -3
- package/factory/orchestrator/dist/usage.js +18 -10
- package/factory/orchestrator/dist/waivers.js +60 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/worktree.js +19 -6
- package/factory/specs/roadmap.md +24 -0
- package/package.json +1 -1
|
@@ -22,8 +22,9 @@ import { loadCrosscheckPolicy } from './crosscheck.js';
|
|
|
22
22
|
import { apply, foldTasks, rebuild } from './db/projector.js';
|
|
23
23
|
import { summariseEpicWidth, UNMEASURED_HINT } from './epicWidth.js';
|
|
24
24
|
import { SmithError } from './errors.js';
|
|
25
|
-
import { listSessionIds, mergeSessionLogs, parseEventId
|
|
25
|
+
import { listSessionIds, mergeSessionLogs, parseEventId } from './events.js';
|
|
26
26
|
import { ageFindings, memoryOf } from './findingAge.js';
|
|
27
|
+
import { LogCache } from './logCache.js';
|
|
27
28
|
import { STATE_DAEMON_DIR, STATE_DB_PATH, STATE_EVENTS_DIR } from './paths.js';
|
|
28
29
|
import { unwatchedProjects } from './projects.js';
|
|
29
30
|
import { FACTORY_PROJECT } from './roadmap.js';
|
|
@@ -63,6 +64,20 @@ function admitFor(proposals, events, lens) {
|
|
|
63
64
|
function staleSubject(agent) {
|
|
64
65
|
return agent.taskId ?? agent.epicId ?? agent.agentId ?? agent.id;
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The session an event id names, for a finding that has nothing better to key
|
|
69
|
+
* on than the id of the event that raised it. `null` on anything that is not
|
|
70
|
+
* a well-formed event id, rather than throwing — a proposal's origin is a
|
|
71
|
+
* nicety, not a fact `inspectSession` should die over.
|
|
72
|
+
*/
|
|
73
|
+
function originSessionId(eventId) {
|
|
74
|
+
try {
|
|
75
|
+
return parseEventId(eventId).sessionId;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
66
81
|
/**
|
|
67
82
|
* Everything one session's lineage says about itself: spend against the epic
|
|
68
83
|
* cap, dispatches nobody can be billed for, agents that never came back, and
|
|
@@ -119,10 +134,24 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
119
134
|
});
|
|
120
135
|
}
|
|
121
136
|
for (const agent of detectStale(foldAgents(events), now.toISOString(), staleHours)) {
|
|
137
|
+
// Same rule as the budget check above, and the same reason: a closed
|
|
138
|
+
// epic's dispatch cannot still be live, so flagging it would raise the
|
|
139
|
+
// same line every tick until the log is archived. `foldAgents`'s own
|
|
140
|
+
// epic-closed handling only closes an entry against the session that
|
|
141
|
+
// closed it (D-234) — a dispatch and the epic's close can sit in
|
|
142
|
+
// different sessions of one lineage, which is exactly the shape this
|
|
143
|
+
// tick's merged log hands here, so the same-session fold alone would
|
|
144
|
+
// miss it.
|
|
145
|
+
if (agent.epicId !== null && closedEpics.has(agent.epicId))
|
|
146
|
+
continue;
|
|
122
147
|
findings.push({
|
|
123
148
|
kind: 'stale-agent',
|
|
124
149
|
severity: 'attention',
|
|
125
|
-
|
|
150
|
+
// The dispatch's own session, not the tree's primary leaf: a fork
|
|
151
|
+
// asks inspectSession once for the whole tree, and the agent that
|
|
152
|
+
// never came back belongs to whichever session actually dispatched
|
|
153
|
+
// it.
|
|
154
|
+
sessionId: agent.sessionId,
|
|
126
155
|
subject: staleSubject(agent),
|
|
127
156
|
detail: `${agent.agentRole} (${agent.provider}/${agent.modelTier}) has been live for ` +
|
|
128
157
|
`${agent.liveHours.toFixed(1)}h with no result, error or supersession — past the ` +
|
|
@@ -142,7 +171,10 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
142
171
|
findings.push({
|
|
143
172
|
kind: 'spec-change',
|
|
144
173
|
severity: proposal.blocking ? 'attention' : 'info',
|
|
145
|
-
|
|
174
|
+
// `proposalId` is the `spec-change-proposed` event's own id, so this is
|
|
175
|
+
// the session that actually raised the proposal — not the tree's
|
|
176
|
+
// primary leaf, which a fork may have picked for an unrelated sibling.
|
|
177
|
+
sessionId: originSessionId(proposal.proposalId) ?? sessionId,
|
|
146
178
|
subject: proposal.taskId,
|
|
147
179
|
detail: `${proposal.proposedBy} proposes amending ${proposal.criterionRef}: ${proposal.assumption} ` +
|
|
148
180
|
`(${proposal.severity}, ${proposal.blocking ? 'blocking' : 'non-blocking'}, ` +
|
|
@@ -150,15 +182,29 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
150
182
|
`${proposal.proposalId}\` or \`smith plan reject ${proposal.proposalId}\`.`,
|
|
151
183
|
});
|
|
152
184
|
}
|
|
153
|
-
const proposals = computeProposals({
|
|
185
|
+
const proposals = computeProposals({
|
|
186
|
+
events,
|
|
187
|
+
now,
|
|
188
|
+
policy: schedulerPolicy,
|
|
189
|
+
...(opts.resolveProjectForTaskRef === undefined
|
|
190
|
+
? {}
|
|
191
|
+
: { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
|
|
192
|
+
...(opts.selfFallbackForTaskRef === undefined
|
|
193
|
+
? {}
|
|
194
|
+
: { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
|
|
195
|
+
});
|
|
154
196
|
const admissions = admitFor(proposals, events, opts.admission);
|
|
197
|
+
// `TaskFoldRow.sessionId` is stamped once, the first time `touch()` builds
|
|
198
|
+
// the row, and never overwritten by a later touch — the task's origin
|
|
199
|
+
// session, not whichever one most recently merged or dispatched against it.
|
|
200
|
+
const taskSession = new Map(foldTasks(events).map((row) => [row.taskId, row.sessionId]));
|
|
155
201
|
for (const [index, proposal] of proposals.entries()) {
|
|
156
202
|
const admission = admissions[index];
|
|
157
203
|
if (proposal.kind === 'recheck') {
|
|
158
204
|
findings.push({
|
|
159
205
|
kind: 'recheck',
|
|
160
206
|
severity: 'info',
|
|
161
|
-
sessionId,
|
|
207
|
+
sessionId: taskSession.get(proposal.taskId) ?? sessionId,
|
|
162
208
|
subject: proposal.taskId,
|
|
163
209
|
detail: `Recheck due (${proposal.reasons.join(', ')}): ${proposal.mergeCount} later overlapping ` +
|
|
164
210
|
`merge(s), ${proposal.daysElapsed} day(s) elapsed, confidence ${proposal.confidence}.`,
|
|
@@ -172,13 +218,20 @@ export function inspectSession(sessionId, events, opts = {}) {
|
|
|
172
218
|
// the discharge command below clears. Reported here and not in
|
|
173
219
|
// inspectFactory because `issue-reported` lands in the error's own
|
|
174
220
|
// session, so this lineage is the one that can answer it.
|
|
175
|
-
const project = proposal.project ===
|
|
221
|
+
const project = proposal.project === null
|
|
222
|
+
? ' (unresolved project)'
|
|
223
|
+
: proposal.project === FACTORY_PROJECT
|
|
224
|
+
? ''
|
|
225
|
+
: ` (${proposal.project})`;
|
|
226
|
+
const projectPhrase = proposal.project === null ? 'an unresolved project' : `project ${proposal.project}`;
|
|
176
227
|
findings.push({
|
|
177
228
|
kind: 'unreported-error',
|
|
178
229
|
severity: 'info',
|
|
179
|
-
|
|
230
|
+
// `report.session_id` — the session the error actually lives in, and
|
|
231
|
+
// the same one the detail text below already sends the operator to.
|
|
232
|
+
sessionId: proposal.sessionId,
|
|
180
233
|
subject: `error ${proposal.fingerprint}${project}`,
|
|
181
|
-
detail: `Error ${proposal.fingerprint} in
|
|
234
|
+
detail: `Error ${proposal.fingerprint} in ${projectPhrase} ` +
|
|
182
235
|
`(${proposal.errorClass}, task ${proposal.taskRef}, ${proposal.occurrences} occurrence(s)) ` +
|
|
183
236
|
'has no issue-reported event. Report it with ' +
|
|
184
237
|
`\`smith issues report --session ${proposal.sessionId}\`; ` +
|
|
@@ -208,6 +261,12 @@ export function inspectFactory(events, opts = {}) {
|
|
|
208
261
|
policy,
|
|
209
262
|
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
210
263
|
...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
|
|
264
|
+
...(opts.resolveProjectForTaskRef === undefined
|
|
265
|
+
? {}
|
|
266
|
+
: { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
|
|
267
|
+
...(opts.selfFallbackForTaskRef === undefined
|
|
268
|
+
? {}
|
|
269
|
+
: { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
|
|
211
270
|
});
|
|
212
271
|
const admissions = admitFor(proposals, events, opts.admission);
|
|
213
272
|
for (const [index, proposal] of proposals.entries()) {
|
|
@@ -343,21 +402,29 @@ function errorMessage(err) {
|
|
|
343
402
|
/**
|
|
344
403
|
* One pass over the state directory.
|
|
345
404
|
*
|
|
346
|
-
* Two rules hold it together. It reads every log ONCE and folds
|
|
347
|
-
* memory
|
|
348
|
-
* (D-119's argument, read from the other end)
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
405
|
+
* Two rules hold it together. It reads every log ONCE and folds each lineage
|
|
406
|
+
* TREE in memory — not once per leaf — so a five-session chain reports one
|
|
407
|
+
* budget alarm rather than five (D-119's argument, read from the other end),
|
|
408
|
+
* and a base session that forked into several wave children reports it once
|
|
409
|
+
* rather than once per sibling. And no single unreadable log or failed
|
|
410
|
+
* projection ends the tick: both become findings, because a watchdog that
|
|
411
|
+
* dies on the first corrupt line is a watchdog that is silent exactly when
|
|
412
|
+
* something is wrong.
|
|
352
413
|
*/
|
|
353
414
|
export async function runTick(opts = {}) {
|
|
354
415
|
const now = opts.now ?? new Date();
|
|
355
416
|
const stateDir = opts.stateDir ?? STATE_EVENTS_DIR;
|
|
356
417
|
const findings = [];
|
|
418
|
+
// No shared cache passed in: a fresh one is cold for every session, so this
|
|
419
|
+
// reads identically to the old `readEvents`-per-session loop — one full
|
|
420
|
+
// read per log, this call only.
|
|
421
|
+
const cache = opts.logCache ?? new LogCache();
|
|
357
422
|
const logs = new Map();
|
|
423
|
+
const liveSessionIds = new Set();
|
|
358
424
|
for (const sessionId of listSessionIds(stateDir)) {
|
|
425
|
+
liveSessionIds.add(sessionId);
|
|
359
426
|
try {
|
|
360
|
-
logs.set(sessionId, await
|
|
427
|
+
logs.set(sessionId, await cache.read(sessionId, { stateDir }));
|
|
361
428
|
}
|
|
362
429
|
catch (err) {
|
|
363
430
|
findings.push({
|
|
@@ -369,6 +436,13 @@ export async function runTick(opts = {}) {
|
|
|
369
436
|
});
|
|
370
437
|
}
|
|
371
438
|
}
|
|
439
|
+
// Sessions that disappear from `listSessionIds` are evicted here, on the
|
|
440
|
+
// next tick, so the cache's memory is bounded by live logs, not by
|
|
441
|
+
// history. Driven by the LISTED set, not the read-succeeded set: a session
|
|
442
|
+
// that is listed but unreadable this tick keeps its entry. On a fresh,
|
|
443
|
+
// unshared cache (`opts.logCache` undefined) this is a no-op -- nothing
|
|
444
|
+
// was cached to evict.
|
|
445
|
+
cache.retainOnly(liveSessionIds);
|
|
372
446
|
const parentOf = new Map();
|
|
373
447
|
for (const [sessionId, events] of logs) {
|
|
374
448
|
const root = events.find((e) => e.record.event_type === ROOT_EVENT_TYPE);
|
|
@@ -390,17 +464,59 @@ export async function runTick(opts = {}) {
|
|
|
390
464
|
ancestors.add(parent);
|
|
391
465
|
}
|
|
392
466
|
const leaves = [...logs.keys()].filter((id) => !ancestors.has(id)).sort();
|
|
393
|
-
|
|
394
|
-
|
|
467
|
+
// parentOf inverted: a session's immediate children among the sessions this
|
|
468
|
+
// tick loaded. Lets a shared ancestor's descendants be walked forward once,
|
|
469
|
+
// instead of re-walked backward once per leaf below it.
|
|
470
|
+
const childrenOf = new Map();
|
|
471
|
+
for (const [childId, parent] of parentOf) {
|
|
472
|
+
if (parent === null || !logs.has(parent))
|
|
473
|
+
continue;
|
|
474
|
+
const siblings = childrenOf.get(parent) ?? [];
|
|
475
|
+
siblings.push(childId);
|
|
476
|
+
childrenOf.set(parent, siblings);
|
|
477
|
+
}
|
|
478
|
+
// The furthest ancestor of `id` that this tick still holds a log for — the
|
|
479
|
+
// root of the tree `id` belongs to, and the same root for every session in
|
|
480
|
+
// that tree regardless of which leaf asks.
|
|
481
|
+
const rootOf = (id) => {
|
|
482
|
+
let current = id;
|
|
395
483
|
const seen = new Set();
|
|
396
|
-
|
|
397
|
-
|
|
484
|
+
while (true) {
|
|
485
|
+
const parent = parentOf.get(current) ?? null;
|
|
486
|
+
if (parent === null || !logs.has(parent) || seen.has(current))
|
|
487
|
+
return current;
|
|
398
488
|
seen.add(current);
|
|
399
|
-
|
|
400
|
-
current = parentOf.get(current) ?? null;
|
|
489
|
+
current = parent;
|
|
401
490
|
}
|
|
402
|
-
return chain.map((sessionId) => ({ sessionId, events: logs.get(sessionId) ?? [] }));
|
|
403
491
|
};
|
|
492
|
+
// Every session reachable from `root` by walking children — the whole tree
|
|
493
|
+
// a lineage forks into, not just the one leaf that happened to ask for it.
|
|
494
|
+
// Guarded like rootOf: a cycle in causal parents must cost a tick nothing,
|
|
495
|
+
// not grow this list until the process dies.
|
|
496
|
+
const treeSessions = (root) => {
|
|
497
|
+
const all = [root];
|
|
498
|
+
const seen = new Set(all);
|
|
499
|
+
for (let i = 0; i < all.length; i += 1) {
|
|
500
|
+
for (const child of childrenOf.get(all[i]) ?? []) {
|
|
501
|
+
if (seen.has(child))
|
|
502
|
+
continue;
|
|
503
|
+
seen.add(child);
|
|
504
|
+
all.push(child);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return all;
|
|
508
|
+
};
|
|
509
|
+
// Leaves grouped by the tree they fork from. A base session with several
|
|
510
|
+
// wave children is one tree with several leaves — inspected once, not once
|
|
511
|
+
// per sibling, so the shared ancestor's findings are not re-emitted once
|
|
512
|
+
// per fork (the D-119 argument, which a single chain never forks to break).
|
|
513
|
+
const leavesByRoot = new Map();
|
|
514
|
+
for (const leaf of leaves) {
|
|
515
|
+
const root = rootOf(leaf);
|
|
516
|
+
const group = leavesByRoot.get(root) ?? [];
|
|
517
|
+
group.push(leaf);
|
|
518
|
+
leavesByRoot.set(root, group);
|
|
519
|
+
}
|
|
404
520
|
// A tick reports the admission line by default, where `inspectSession` and
|
|
405
521
|
// `inspectFactory` stay silent unless asked. The asymmetry is deliberate: a
|
|
406
522
|
// caller who reaches for one function is answering their own question and
|
|
@@ -422,6 +538,12 @@ export async function runTick(opts = {}) {
|
|
|
422
538
|
...(opts.projectDirs === undefined ? {} : { projectDirs: opts.projectDirs }),
|
|
423
539
|
...(opts.readOutdated === undefined ? {} : { readOutdated: opts.readOutdated }),
|
|
424
540
|
...(opts.readProjects === undefined ? {} : { readProjects: opts.readProjects }),
|
|
541
|
+
...(opts.resolveProjectForTaskRef === undefined
|
|
542
|
+
? {}
|
|
543
|
+
: { resolveProjectForTaskRef: opts.resolveProjectForTaskRef }),
|
|
544
|
+
...(opts.selfFallbackForTaskRef === undefined
|
|
545
|
+
? {}
|
|
546
|
+
: { selfFallbackForTaskRef: opts.selfFallbackForTaskRef }),
|
|
425
547
|
};
|
|
426
548
|
const projectDb = opts.projectDb ?? true;
|
|
427
549
|
const dbPath = opts.dbPath ?? STATE_DB_PATH;
|
|
@@ -437,16 +559,55 @@ export async function runTick(opts = {}) {
|
|
|
437
559
|
detail: `The read-model refresh failed: ${errorMessage(err)}. The event log is unaffected — ` +
|
|
438
560
|
'it is the UI and `smith status` that are now stale.',
|
|
439
561
|
});
|
|
440
|
-
for (const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
562
|
+
for (const [root, group] of leavesByRoot) {
|
|
563
|
+
// Sorted, so a forked tree attributes its shared findings to the same
|
|
564
|
+
// leaf on every tick, rather than whichever one Map iteration hands back
|
|
565
|
+
// first — the same determinism `leaves` itself already sorts for.
|
|
566
|
+
const primary = [...group].sort()[0];
|
|
567
|
+
const merged = mergeSessionLogs(treeSessions(root).map((sessionId) => ({ sessionId, events: logs.get(sessionId) ?? [] })));
|
|
568
|
+
findings.push(...inspectSession(primary, merged, inspectOpts));
|
|
569
|
+
}
|
|
570
|
+
// The signature a leaf's projection was last applied against: every
|
|
571
|
+
// session in its own upward chain, keyed by the fingerprint the cache read
|
|
572
|
+
// it at. Two ticks that see the same fingerprints for the same chain saw
|
|
573
|
+
// no change anywhere that leaf's own projection or lineage-scoped folds
|
|
574
|
+
// depend on, so re-applying would write the same rows a second time.
|
|
575
|
+
// `opts.logCache === undefined` opts a caller all the way out: with no
|
|
576
|
+
// cache to remember a signature against, every leaf is applied every tick,
|
|
577
|
+
// unchanged from before this cache existed.
|
|
578
|
+
const leafSignature = (leaf) => {
|
|
579
|
+
const chain = [];
|
|
580
|
+
const seen = new Set();
|
|
581
|
+
let current = leaf;
|
|
582
|
+
while (current !== null && !seen.has(current)) {
|
|
583
|
+
seen.add(current);
|
|
584
|
+
chain.push(current);
|
|
585
|
+
const parent = parentOf.get(current) ?? null;
|
|
586
|
+
current = parent !== null && logs.has(parent) ? parent : null;
|
|
447
587
|
}
|
|
448
|
-
|
|
449
|
-
|
|
588
|
+
return chain
|
|
589
|
+
.map((id) => {
|
|
590
|
+
const fp = cache.fingerprintOf(id);
|
|
591
|
+
return fp === undefined ? `${id}:?` : `${id}:${fp.dev}.${fp.ino}.${fp.size}.${fp.mtimeMs}`;
|
|
592
|
+
})
|
|
593
|
+
.join('|');
|
|
594
|
+
};
|
|
595
|
+
const applyFn = opts.applyFn ?? apply;
|
|
596
|
+
if (projectDb) {
|
|
597
|
+
for (const leaf of leaves) {
|
|
598
|
+
const signature = opts.logCache === undefined ? undefined : leafSignature(leaf);
|
|
599
|
+
if (signature !== undefined && opts.logCache?.getAppliedSignature(leaf) === signature) {
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
await applyFn(dbPath, leaf, dbOpts);
|
|
604
|
+
projected += 1;
|
|
605
|
+
if (signature !== undefined)
|
|
606
|
+
opts.logCache?.setAppliedSignature(leaf, signature);
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
findings.push(projectionFailed(leaf, err));
|
|
610
|
+
}
|
|
450
611
|
}
|
|
451
612
|
}
|
|
452
613
|
// The read-model is not made of sessions alone. `milestones` is a full
|
|
@@ -683,6 +844,10 @@ export async function runDaemon(opts) {
|
|
|
683
844
|
const tick = opts.tick ?? runTick;
|
|
684
845
|
const sleep = opts.sleep ?? defaultSleep;
|
|
685
846
|
const shouldContinue = opts.shouldContinue ?? (() => true);
|
|
847
|
+
// One cache for the whole run: this is the loop finding 50acc356 is about
|
|
848
|
+
// — the process that survives long enough for "every tick re-reads every
|
|
849
|
+
// byte ever written" to matter.
|
|
850
|
+
const logCache = opts.logCache ?? new LogCache();
|
|
686
851
|
acquireLock(opts.dir, { pid, startedAt, intervalSeconds }, opts.isAlive === undefined ? {} : { isAlive: opts.isAlive });
|
|
687
852
|
const tickOptions = {
|
|
688
853
|
...(opts.now === undefined ? {} : { now: opts.now }),
|
|
@@ -695,6 +860,8 @@ export async function runDaemon(opts) {
|
|
|
695
860
|
...(opts.projectDb === undefined ? {} : { projectDb: opts.projectDb }),
|
|
696
861
|
...(opts.dbPath === undefined ? {} : { dbPath: opts.dbPath }),
|
|
697
862
|
...(opts.dbOpts === undefined ? {} : { dbOpts: opts.dbOpts }),
|
|
863
|
+
...(opts.applyFn === undefined ? {} : { applyFn: opts.applyFn }),
|
|
864
|
+
logCache,
|
|
698
865
|
};
|
|
699
866
|
// Read from disk rather than held in a variable, so a daemon restarted by
|
|
700
867
|
// cron or by the operator picks up where the last process stopped. A tick
|
|
@@ -396,23 +396,46 @@ function waveTaskIds(record) {
|
|
|
396
396
|
const taskId = eventTaskId(record);
|
|
397
397
|
return taskId ? [taskId] : [];
|
|
398
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* The tasks one error-logged event names: the envelope/payload `task_id`
|
|
401
|
+
* (D-245) when the producer set one, else `payload.task_ref` split on `,`
|
|
402
|
+
* and each part trimmed. A comma joins several real ids into one string
|
|
403
|
+
* when a producer logged a single check's failure against more than one
|
|
404
|
+
* task at once — events.ts's append-time guard now refuses to write that
|
|
405
|
+
* shape, but a log written before the guard existed still carries it, and
|
|
406
|
+
* foldTasks() replays every log ever written. Splitting here, not only in
|
|
407
|
+
* assertedTaskIds()'s pre-pass, is what keeps the two agreeing on what the
|
|
408
|
+
* event names (see that function's own comment) — a fix that touched one
|
|
409
|
+
* and not the other used to mint a card whose id was the literal
|
|
410
|
+
* "epic-1/task-1,epic-1/task-2" string.
|
|
411
|
+
*/
|
|
412
|
+
function errorTaskRefIds(record) {
|
|
413
|
+
const envelopeId = eventTaskId(record);
|
|
414
|
+
if (envelopeId)
|
|
415
|
+
return [envelopeId];
|
|
416
|
+
const ref = record.payload.task_ref;
|
|
417
|
+
if (!ref)
|
|
418
|
+
return [];
|
|
419
|
+
return ref
|
|
420
|
+
.split(',')
|
|
421
|
+
.map((id) => id.trim())
|
|
422
|
+
.filter(Boolean);
|
|
423
|
+
}
|
|
399
424
|
/**
|
|
400
425
|
* The task ids one event asserts something about, spelled the way foldTasks()'s
|
|
401
426
|
* switch reads them: the envelope or payload `task_id` (D-245) for the
|
|
402
427
|
* single-task events, `payload.task_ids` for a wave's admission or merge, and
|
|
403
|
-
* `error-logged`'s own `payload.task_ref
|
|
404
|
-
*
|
|
405
|
-
* pre-pass never saw.
|
|
428
|
+
* `error-logged`'s own `payload.task_ref` (or its comma-joined list — see
|
|
429
|
+
* errorTaskRefIds()). Kept beside the switch on purpose — the two must agree
|
|
430
|
+
* on what names a task, or the walk touches an id the pre-pass never saw.
|
|
406
431
|
*/
|
|
407
432
|
function assertedTaskIds(record) {
|
|
408
433
|
switch (record.event_type) {
|
|
409
434
|
case 'wave-admitted':
|
|
410
435
|
case 'wave-merged':
|
|
411
436
|
return waveTaskIds(record);
|
|
412
|
-
case 'error-logged':
|
|
413
|
-
|
|
414
|
-
return id ? [id] : [];
|
|
415
|
-
}
|
|
437
|
+
case 'error-logged':
|
|
438
|
+
return errorTaskRefIds(record);
|
|
416
439
|
case 'task-added':
|
|
417
440
|
case 'gate-outcome':
|
|
418
441
|
case 'task-superseded': {
|
|
@@ -606,26 +629,27 @@ export function foldTasks(events, opts = {}) {
|
|
|
606
629
|
}
|
|
607
630
|
case 'error-logged': {
|
|
608
631
|
const p = record.payload;
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
632
|
+
// errorTaskRefIds() is this event's own spelling (D-245's envelope
|
|
633
|
+
// reader, or a comma-joined `task_ref` split into its real ids) —
|
|
634
|
+
// shared with assertedTaskIds()'s pre-pass so the two agree.
|
|
635
|
+
const taskIds = errorTaskRefIds(record);
|
|
636
|
+
for (const taskId of taskIds) {
|
|
637
|
+
const row = touch(taskId, record.ts, record.session_id);
|
|
638
|
+
row.project = record.project ?? row.project;
|
|
639
|
+
if (TERMINAL_TASK_STATUSES.has(row.taskStatus))
|
|
640
|
+
continue;
|
|
641
|
+
// Severity decides whether the task moves; the error class decides
|
|
642
|
+
// where. taxonomy.yml: S3 is "real but waivable; batched to operator
|
|
643
|
+
// at epic end", S4 is "logged, never asked" — a budget note or a
|
|
644
|
+
// tool hiccup at that level is on the record but the task carries on,
|
|
645
|
+
// and showing it as blocked is what the board did for 43 of the 55
|
|
646
|
+
// errors logged so far. A record with no severity is treated as
|
|
647
|
+
// major: the write path requires the field, so its absence means a
|
|
648
|
+
// log this reader does not own.
|
|
649
|
+
if (NOTE_ONLY_SEVERITIES.has(p.severity ?? ''))
|
|
650
|
+
continue;
|
|
651
|
+
row.taskStatus = p.error?.startsWith('coordination.') ? 'escalated' : 'blocked';
|
|
652
|
+
}
|
|
629
653
|
break;
|
|
630
654
|
}
|
|
631
655
|
default:
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `smith dispatch lint` — check a dispatch prompt against two things a
|
|
3
|
+
* dispatcher can get wrong without any signal until the agent runs:
|
|
4
|
+
*
|
|
5
|
+
* - the turn budget stated in the prompt vs the role template's
|
|
6
|
+
* `maxTurns:`, read fresh off disk (dispatch.md "Carry into the
|
|
7
|
+
* prompt" — a prompt that promises more than the template is a fiction
|
|
8
|
+
* the agent plans against and gets cut in the middle of).
|
|
9
|
+
* - a judge prompt's declared-artifact line vs what the ledger recorded
|
|
10
|
+
* for its `judge dispatch` (dispatch.md "Declare each judge's
|
|
11
|
+
* artifact" — a judge prompt missing that line is undetectable until
|
|
12
|
+
* the report never lands).
|
|
13
|
+
*
|
|
14
|
+
* Both parsers and both comparisons live here so the two checks stay one
|
|
15
|
+
* source of truth for "what does a compliant dispatch prompt say", rather
|
|
16
|
+
* than every dispatcher re-deriving it.
|
|
17
|
+
*/
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { readTemplateMaxTurns } from './agentsSync.js';
|
|
20
|
+
import { readJudgeTurns } from './judges.js';
|
|
21
|
+
import { AGENTS_DIR } from './paths.js';
|
|
22
|
+
/**
|
|
23
|
+
* The epic criterion's six judge roles (task 5 and task 6 objectives both
|
|
24
|
+
* name this exact set: spec-reviewer, reviewer, verifier, grader,
|
|
25
|
+
* security-reviewer, auditor) -- the roles whose dispatch declares an
|
|
26
|
+
* artifact and whose SubagentStop is guarded by judge-stop.sh. Kept as its
|
|
27
|
+
* own list rather than read out of judges.ts: `foldJudgeTurns` folds any
|
|
28
|
+
* role with a `declared_artifact` on its dispatch into a turn, but this
|
|
29
|
+
* module is stricter on purpose — only these six get an artifact check, so
|
|
30
|
+
* a coder's dispatch (never carries one) reads `not-applicable` rather than
|
|
31
|
+
* a false `undeclared`.
|
|
32
|
+
*
|
|
33
|
+
* Not the same six as dispatch.md's "Fingerprint the worktree around every
|
|
34
|
+
* judge" list, which swaps `auditor` for `uiux`: that section is about
|
|
35
|
+
* which judges run inside a task's worktree (auditor runs over a whole
|
|
36
|
+
* project at HEAD, never per-task, so a worktree fingerprint does not apply
|
|
37
|
+
* to it; uiux does run per-task). This list is about which roles owe a
|
|
38
|
+
* declared artifact and a blocked Stop, and `auditor` is one of them.
|
|
39
|
+
*/
|
|
40
|
+
export const JUDGE_ROLES = [
|
|
41
|
+
'reviewer',
|
|
42
|
+
'verifier',
|
|
43
|
+
'grader',
|
|
44
|
+
'spec-reviewer',
|
|
45
|
+
'security-reviewer',
|
|
46
|
+
'auditor',
|
|
47
|
+
];
|
|
48
|
+
const JUDGE_ROLE_SET = new Set(JUDGE_ROLES);
|
|
49
|
+
const TURN_BUDGET_LINE = /Turn budget:[ \t]*(\S*)/g;
|
|
50
|
+
/**
|
|
51
|
+
* `Turn budget: <n>` in a dispatch prompt — mid-sentence or on its own
|
|
52
|
+
* line, since a dispatch's leading line names the role and the budget in
|
|
53
|
+
* one breath ("Role: coder. Turn budget: 40 ..."). Null for no occurrence,
|
|
54
|
+
* more than one (two numbers is no number), or a value that is not a bare
|
|
55
|
+
* non-negative integer — every one of those is "cannot state a turn count",
|
|
56
|
+
* not "states zero".
|
|
57
|
+
*/
|
|
58
|
+
export function parseStatedTurns(prompt) {
|
|
59
|
+
const matches = [...prompt.matchAll(TURN_BUDGET_LINE)];
|
|
60
|
+
if (matches.length !== 1)
|
|
61
|
+
return null;
|
|
62
|
+
const raw = matches[0]?.[1] ?? '';
|
|
63
|
+
return /^[0-9]+$/.test(raw) ? Number(raw) : null;
|
|
64
|
+
}
|
|
65
|
+
const DECLARED_ARTIFACT_LINE = /^Declared artifact:[ \t]*(\S+)[ \t]*$/m;
|
|
66
|
+
/** `Declared artifact: <path>` in a dispatch prompt, or null when the line is not there. */
|
|
67
|
+
export function parseDeclaredArtifactLine(prompt) {
|
|
68
|
+
const match = DECLARED_ARTIFACT_LINE.exec(prompt);
|
|
69
|
+
return match?.[1] ?? null;
|
|
70
|
+
}
|
|
71
|
+
/** The line a dispatch is expected to carry verbatim, given the ledger's declared path. */
|
|
72
|
+
export function formatDeclaredArtifactLine(artifactPath) {
|
|
73
|
+
return `Declared artifact: ${artifactPath}`;
|
|
74
|
+
}
|
|
75
|
+
function checkTurns(prompt, role, agentsDir) {
|
|
76
|
+
const stated = parseStatedTurns(prompt);
|
|
77
|
+
const template = readTemplateMaxTurns(agentsDir, role) ?? null;
|
|
78
|
+
if (template === null)
|
|
79
|
+
return { status: 'unverifiable', stated, template: null };
|
|
80
|
+
if (stated === null)
|
|
81
|
+
return { status: 'missing', stated: null, template };
|
|
82
|
+
if (stated === template)
|
|
83
|
+
return { status: 'ok', stated, template };
|
|
84
|
+
return { status: stated < template ? 'under' : 'over', stated, template };
|
|
85
|
+
}
|
|
86
|
+
async function checkArtifact(prompt, role, taskId, sessionId, eventOpts) {
|
|
87
|
+
if (!JUDGE_ROLE_SET.has(role)) {
|
|
88
|
+
return { status: 'not-applicable', expected_line: null, declared_line: null };
|
|
89
|
+
}
|
|
90
|
+
const declaredLine = parseDeclaredArtifactLine(prompt);
|
|
91
|
+
const turns = await readJudgeTurns(taskId, { sessionId }, eventOpts);
|
|
92
|
+
const turn = turns.find((t) => t.role === role);
|
|
93
|
+
if (turn === undefined) {
|
|
94
|
+
return { status: 'undeclared', expected_line: null, declared_line: declaredLine };
|
|
95
|
+
}
|
|
96
|
+
const expectedLine = formatDeclaredArtifactLine(turn.declaredArtifact);
|
|
97
|
+
if (declaredLine === null) {
|
|
98
|
+
return { status: 'missing', expected_line: expectedLine, declared_line: null };
|
|
99
|
+
}
|
|
100
|
+
if (declaredLine === turn.declaredArtifact) {
|
|
101
|
+
return { status: 'ok', expected_line: expectedLine, declared_line: declaredLine };
|
|
102
|
+
}
|
|
103
|
+
if (!path.isAbsolute(declaredLine)) {
|
|
104
|
+
return { status: 'relative', expected_line: expectedLine, declared_line: declaredLine };
|
|
105
|
+
}
|
|
106
|
+
return { status: 'mismatch', expected_line: expectedLine, declared_line: declaredLine };
|
|
107
|
+
}
|
|
108
|
+
// Fail closed: every status but "ok" exits non-zero, including "under" (a
|
|
109
|
+
// prompt that understates the template's budget still disagrees with it) and
|
|
110
|
+
// "unverifiable" (an unreadable template is not a pass, exactly like `dispatch
|
|
111
|
+
// check`, `tester check`, `delegation check`, and `escalation check` already
|
|
112
|
+
// treat "cannot tell" as a failure, never a maybe).
|
|
113
|
+
const TURNS_FAIL = new Set(['under', 'over', 'missing', 'unverifiable']);
|
|
114
|
+
const ARTIFACT_FAIL = new Set([
|
|
115
|
+
'missing',
|
|
116
|
+
'relative',
|
|
117
|
+
'mismatch',
|
|
118
|
+
'undeclared',
|
|
119
|
+
]);
|
|
120
|
+
/**
|
|
121
|
+
* Lint one dispatch prompt. Never throws on a bad prompt — a bad prompt is
|
|
122
|
+
* exactly the finding this reports — only on a missing task/session (the
|
|
123
|
+
* caller's own mistake, same as every other verb that reads the ledger).
|
|
124
|
+
*/
|
|
125
|
+
export async function lintDispatchPrompt(input) {
|
|
126
|
+
const agentsDir = input.agentsDir ?? AGENTS_DIR;
|
|
127
|
+
const eventOpts = input.eventOpts ?? {};
|
|
128
|
+
const turns = checkTurns(input.prompt, input.role, agentsDir);
|
|
129
|
+
const artifact = await checkArtifact(input.prompt, input.role, input.taskId, input.sessionId, eventOpts);
|
|
130
|
+
const exitCode = TURNS_FAIL.has(turns.status) || ARTIFACT_FAIL.has(artifact.status) ? 1 : 0;
|
|
131
|
+
return { turns, artifact, exitCode };
|
|
132
|
+
}
|