@juzser/blacksmith 0.2.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 +25 -9
- package/.claude/skills/bs/dispatch.md +60 -7
- package/.claude/skills/bs/plan.md +33 -1
- package/.claude/skills/bs/run.md +18 -13
- package/.claude/skills/bs/wave.md +68 -10
- package/README.md +4 -4
- package/factory/orchestrator/dist/agentsSync.js +178 -0
- package/factory/orchestrator/dist/audit.js +134 -17
- package/factory/orchestrator/dist/autonomy.js +25 -0
- package/factory/orchestrator/dist/budgets.js +91 -2
- package/factory/orchestrator/dist/claims.js +8 -4
- package/factory/orchestrator/dist/cli.js +394 -26
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/daemon.js +230 -41
- package/factory/orchestrator/dist/db/projector.js +93 -36
- package/factory/orchestrator/dist/db/queries.js +58 -35
- package/factory/orchestrator/dist/db/schema.js +24 -0
- package/factory/orchestrator/dist/dispatchAudit.js +10 -2
- package/factory/orchestrator/dist/dispatchLint.js +132 -0
- package/factory/orchestrator/dist/epic.js +192 -39
- package/factory/orchestrator/dist/epicWidth.js +25 -20
- package/factory/orchestrator/dist/errorIssues.js +209 -24
- package/factory/orchestrator/dist/eventOrder.js +102 -0
- package/factory/orchestrator/dist/events.js +73 -91
- package/factory/orchestrator/dist/findingContext.js +1 -13
- package/factory/orchestrator/dist/findings.js +132 -11
- package/factory/orchestrator/dist/gh.js +29 -6
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/harness.js +92 -10
- package/factory/orchestrator/dist/hookDecision.js +320 -37
- package/factory/orchestrator/dist/impact.js +24 -2
- package/factory/orchestrator/dist/integration.js +11 -1
- package/factory/orchestrator/dist/issueReporter.js +415 -0
- package/factory/orchestrator/dist/judgeStopHook.js +181 -0
- package/factory/orchestrator/dist/judges.js +1 -1
- package/factory/orchestrator/dist/lessons.js +46 -18
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/paths.js +10 -0
- package/factory/orchestrator/dist/plan.js +138 -4
- package/factory/orchestrator/dist/planQuorum.js +1 -1
- package/factory/orchestrator/dist/policy.js +57 -11
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/queue.js +477 -29
- package/factory/orchestrator/dist/scheduler.js +51 -0
- package/factory/orchestrator/dist/severity.js +40 -8
- package/factory/orchestrator/dist/spec.js +70 -0
- package/factory/orchestrator/dist/taskEvents.js +13 -4
- package/factory/orchestrator/dist/taskStatus.js +74 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testgate.js +32 -3
- package/factory/orchestrator/dist/usage.js +42 -9
- package/factory/orchestrator/dist/waivers.js +60 -9
- package/factory/orchestrator/dist/waveConcurrency.js +29 -0
- package/factory/orchestrator/dist/waveNext.js +12 -10
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/worktree.js +19 -6
- package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +9 -2
- package/factory/policies/crosscheck.yml +2 -2
- package/factory/policies/lessons.md +298 -0
- package/factory/policies/scheduler.yml +27 -2
- package/factory/policies/taxonomy.yml +10 -3
- package/factory/specs/roadmap.md +70 -23
- package/package.json +8 -8
|
@@ -182,21 +182,53 @@ export function parseLessons(markdown) {
|
|
|
182
182
|
return rules;
|
|
183
183
|
}
|
|
184
184
|
/**
|
|
185
|
-
*
|
|
186
|
-
* (lessons.md convention). `security` is
|
|
187
|
-
* written against the paths it guards ("src/auth/**") — a
|
|
188
|
-
* finding on one of those paths is exactly what escalation is
|
|
189
|
-
* security lesson would compile and be read by agents but
|
|
190
|
-
* anything (interview N-8).
|
|
185
|
+
* Whether each lesson scope's claim_path is meaningful for the per-file
|
|
186
|
+
* same-mistake match (lessons.md convention). `security` is true because a
|
|
187
|
+
* security lesson is written against the paths it guards ("src/auth/**") — a
|
|
188
|
+
* repeat security finding on one of those paths is exactly what escalation is
|
|
189
|
+
* for. Left out, a security lesson would compile and be read by agents but
|
|
190
|
+
* never escalate anything (interview N-8).
|
|
191
|
+
*
|
|
192
|
+
* The ruling stays hand-written: which scopes have a file to match against is
|
|
193
|
+
* a design decision, not something to derive. Only its COMPLETENESS stops
|
|
194
|
+
* being hand-kept. Keyed on LESSON_SCOPES, a sixth scope added to taxonomy.yml
|
|
195
|
+
* is a type error here until someone rules on it, instead of defaulting to
|
|
196
|
+
* `false` — which is silence, and silence is what interview N-8 was about.
|
|
197
|
+
*
|
|
198
|
+
* It is also the only copy. `lessons.ts` kept a second set of the same three
|
|
199
|
+
* strings under a comment saying it was "kept in sync"; nothing kept it. That
|
|
200
|
+
* is the N-8 failure the LESSON_SCOPES const above already fixed for the
|
|
201
|
+
* vocabulary, repeated one level down on its partition.
|
|
202
|
+
*/
|
|
203
|
+
const SCOPE_MATCHES_A_FILE = Object.freeze({
|
|
204
|
+
'agent-role': false,
|
|
205
|
+
'claim-path': true,
|
|
206
|
+
'case-type': false,
|
|
207
|
+
'stack-wide': true,
|
|
208
|
+
security: true,
|
|
209
|
+
});
|
|
210
|
+
/**
|
|
211
|
+
* The scopes it says yes to, in the order LESSON_SCOPES declares them.
|
|
212
|
+
* Exported because `lessons.ts` names them in a warning it shows the operator,
|
|
213
|
+
* and a sentence that lists them by hand is the same drift written in prose.
|
|
191
214
|
*/
|
|
192
|
-
const
|
|
215
|
+
export const FILE_SCOPED_SCOPES = Object.freeze(LESSON_SCOPES.filter((scope) => SCOPE_MATCHES_A_FILE[scope]));
|
|
216
|
+
const FILE_SCOPED = new Set(FILE_SCOPED_SCOPES);
|
|
217
|
+
/**
|
|
218
|
+
* Whether a scope makes a `claim_path` mean anything, asked by name — the same
|
|
219
|
+
* question as `isFileScoped`, for callers judging an entry that does not exist
|
|
220
|
+
* yet and so have no `LessonRule` to hand.
|
|
221
|
+
*/
|
|
222
|
+
export function isFileScopedScope(scope) {
|
|
223
|
+
return FILE_SCOPED.has(scope);
|
|
224
|
+
}
|
|
193
225
|
/**
|
|
194
226
|
* Whether this lesson's scope makes its `claim_path` mean anything. An
|
|
195
227
|
* `agent-role`/`case-type` entry has no file to check a finding against, so it
|
|
196
228
|
* never participates in the per-file match however it is written.
|
|
197
229
|
*/
|
|
198
230
|
export function isFileScoped(lesson) {
|
|
199
|
-
return
|
|
231
|
+
return isFileScopedScope(lesson.scope);
|
|
200
232
|
}
|
|
201
233
|
/**
|
|
202
234
|
* Whether this lesson's compiled `claim_path` covers `filePath` — the second
|
|
@@ -68,6 +68,65 @@ export function latestSpecReview(events, epicId) {
|
|
|
68
68
|
}
|
|
69
69
|
return latest;
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Old task id -> the replacement that superseded it, folded across every
|
|
73
|
+
* `plan-version-created` event this epic has (D-<successor-chain>). Not a
|
|
74
|
+
* last-wins fold like `latestSpecReview`: a task superseded in v2 and its own
|
|
75
|
+
* successor superseded again in v4 contributes two independent entries to
|
|
76
|
+
* the same map, and epic.ts's `resolveSupersededRow` walks the chain — each
|
|
77
|
+
* amendment only ever needs to know its own one hop.
|
|
78
|
+
*
|
|
79
|
+
* Only the rename-supersede case is ever present here: `amendPlan` records a
|
|
80
|
+
* pairing only when the replacement's `task_id` differs from the id it
|
|
81
|
+
* replaces (the same-id case needs no successor — the fold's own row already
|
|
82
|
+
* carries the work forward under the id everyone already has).
|
|
83
|
+
*
|
|
84
|
+
* Events on the log from before this field shipped carry no `successors` key
|
|
85
|
+
* at all — only the plan `diff` `amendPlan` has always written. For exactly
|
|
86
|
+
* that shape (no `successors` key, present or absent notwithstanding), fall
|
|
87
|
+
* back to inferring the pairing from `diff.superseded`/`diff.added`, but only
|
|
88
|
+
* when it is completely unambiguous: one id on each side, and they differ.
|
|
89
|
+
* Any other shape (0 or ≥2 ids on either side, a missing/malformed diff)
|
|
90
|
+
* infers nothing — the same fail-closed answer an unreadable `successors`
|
|
91
|
+
* gets. A present `successors` — even an explicit `{}` — always wins over
|
|
92
|
+
* inference; the fallback only ever runs when the key is absent entirely.
|
|
93
|
+
*/
|
|
94
|
+
export function taskSuccessors(events, epicId) {
|
|
95
|
+
const successors = new Map();
|
|
96
|
+
for (const event of events) {
|
|
97
|
+
if (event.record.event_type !== PLAN_AMENDED_EVENT)
|
|
98
|
+
continue;
|
|
99
|
+
const payload = event.record.payload;
|
|
100
|
+
if (payload.epic_id !== epicId)
|
|
101
|
+
continue;
|
|
102
|
+
const entries = payload.successors;
|
|
103
|
+
if (entries !== undefined) {
|
|
104
|
+
if (entries === null || typeof entries !== 'object')
|
|
105
|
+
continue;
|
|
106
|
+
for (const [oldId, newId] of Object.entries(entries)) {
|
|
107
|
+
if (typeof newId === 'string')
|
|
108
|
+
successors.set(oldId, newId);
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// Legacy fallback (no `successors` key at all) — see doc comment above.
|
|
113
|
+
const diff = payload.diff;
|
|
114
|
+
if (diff === null || typeof diff !== 'object')
|
|
115
|
+
continue;
|
|
116
|
+
const supersededIds = diff.superseded;
|
|
117
|
+
const addedIds = diff.added;
|
|
118
|
+
if (!Array.isArray(supersededIds) || !Array.isArray(addedIds))
|
|
119
|
+
continue;
|
|
120
|
+
if (supersededIds.length !== 1 || addedIds.length !== 1)
|
|
121
|
+
continue;
|
|
122
|
+
const [oldId] = supersededIds;
|
|
123
|
+
const [newId] = addedIds;
|
|
124
|
+
if (typeof oldId === 'string' && typeof newId === 'string' && oldId !== newId) {
|
|
125
|
+
successors.set(oldId, newId);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return successors;
|
|
129
|
+
}
|
|
71
130
|
/**
|
|
72
131
|
* The epic gate refusing to certify a spec nobody re-read once the code
|
|
73
132
|
* existed. Mirrors integrationBlockers exactly, including the fail-closed
|
|
@@ -345,6 +404,16 @@ export async function amendPlan(input, ctx, opts = {}) {
|
|
|
345
404
|
// pure, so the two agree by construction, and there stays exactly one
|
|
346
405
|
// function in this codebase that puts a plan version on disk.
|
|
347
406
|
const amended = nextVersion(plan, input.changes ?? {}, opts);
|
|
407
|
+
// Old id -> replacement id, for the rename-supersede case only: a same-id
|
|
408
|
+
// supersede needs no pairing (the row already carries the work forward
|
|
409
|
+
// under the id everyone has). epic.ts's resolveSupersededRow/summarizeEpic
|
|
410
|
+
// is the reader — this is the one place an old task_id and its replacement
|
|
411
|
+
// are ever paired (PlanChanges.supersede), so it is recorded here or lost.
|
|
412
|
+
const successors = {};
|
|
413
|
+
for (const [oldId, replacement] of Object.entries(input.changes?.supersede ?? {})) {
|
|
414
|
+
if (replacement.task_id !== oldId)
|
|
415
|
+
successors[oldId] = replacement.task_id;
|
|
416
|
+
}
|
|
348
417
|
await appendEvent({
|
|
349
418
|
session_id: ctx.sessionId,
|
|
350
419
|
actor: ctx.actor ?? 'system',
|
|
@@ -364,6 +433,7 @@ export async function amendPlan(input, ctx, opts = {}) {
|
|
|
364
433
|
sites: namedSites,
|
|
365
434
|
sites_unclaimed: unclaimedSites,
|
|
366
435
|
diff,
|
|
436
|
+
successors,
|
|
367
437
|
},
|
|
368
438
|
}, opts);
|
|
369
439
|
for (const finding of cited) {
|
|
@@ -139,7 +139,12 @@ ctx, opts = {}) {
|
|
|
139
139
|
for (const { record } of events) {
|
|
140
140
|
if (record.event_type !== 'task-added' || !record.task_id)
|
|
141
141
|
continue;
|
|
142
|
-
|
|
142
|
+
const epicId = record.payload?.epic_id;
|
|
143
|
+
byId.set(record.task_id, {
|
|
144
|
+
taskId: record.task_id,
|
|
145
|
+
claims: record.payload?.claims,
|
|
146
|
+
epicId: typeof epicId === 'string' ? epicId : undefined,
|
|
147
|
+
});
|
|
143
148
|
}
|
|
144
149
|
return [...byId.values()];
|
|
145
150
|
}
|
|
@@ -369,9 +374,13 @@ export function followUpTaskId(epicId, fingerprint) {
|
|
|
369
374
|
*
|
|
370
375
|
* Returns null when the session already carries this task, so a re-run is a
|
|
371
376
|
* no-op rather than a duplicate. The task is `todo`, which is not terminal-OK,
|
|
372
|
-
* so epic.ts's `summarizeEpic` blocks the epic verdict on it
|
|
373
|
-
*
|
|
374
|
-
*
|
|
377
|
+
* so epic.ts's `summarizeEpic` blocks the epic verdict on it — the "blocks the
|
|
378
|
+
* epic verdict instead of an unrelated diff" half of P9-24, using the
|
|
379
|
+
* machinery that was already there. Nothing writes a task to `waived`, so the
|
|
380
|
+
* two ways out are completing it (plan it, gate it) or waiving the findings
|
|
381
|
+
* it owns: summarizeEpic reads a follow-up terminal-OK once at least one
|
|
382
|
+
* finding is attributed to it and every one of them is `waived`
|
|
383
|
+
* (epic.ts's clearedFollowUpFindings).
|
|
375
384
|
*/
|
|
376
385
|
export async function emitFollowUpTask(input, ctx, opts = {}) {
|
|
377
386
|
const added = await idsAlreadyEmitted('task-added', ctx, opts);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The closed readings of the `task_status` dimension, in one place.
|
|
2
|
+
//
|
|
3
|
+
// taxonomy.yml declares the vocabulary; these are the two questions the
|
|
4
|
+
// orchestrator asks *about* it that name a finite side. A roster belongs to
|
|
5
|
+
// the dimension, not to whichever consumer first needed one: these two lived
|
|
6
|
+
// in db/projector.ts and in waveNext.ts, epic.ts and db/queries.ts under four
|
|
7
|
+
// names, and two of those names were the same name for different sets.
|
|
8
|
+
//
|
|
9
|
+
// The module is deliberately a leaf — it imports nothing, and nothing added
|
|
10
|
+
// here may reach the database layer. waveNext.ts is in `dist/cli.js`'s static
|
|
11
|
+
// graph, so anything it imports is loaded by every `smith` invocation;
|
|
12
|
+
// cliBoot.test.ts pins that the db layer stays out of that graph, and it went
|
|
13
|
+
// red the moment these constants were read from db/projector.ts.
|
|
14
|
+
/**
|
|
15
|
+
* The five `task_status` values that mean the work is over — the ones
|
|
16
|
+
* db/projector.ts refuses to overwrite once a row reaches them. Read here
|
|
17
|
+
* by a reader asking "is this task still open?" (db/queries.ts's
|
|
18
|
+
* `inFlightEpics`) rather than keeping the complement by hand: taxonomy.yml
|
|
19
|
+
* can declare a thirteenth status tomorrow, and the answer `!has()` gives for
|
|
20
|
+
* one nobody has classified — still open — is the answer that loses nothing.
|
|
21
|
+
*
|
|
22
|
+
* Over is not the same as *well*: this set holds `failed` and `escalated`
|
|
23
|
+
* too. A reader that needs the other question asks TERMINAL_OK_TASK_STATUSES
|
|
24
|
+
* below, and the two are not interchangeable — waveNext.ts kept its own copy
|
|
25
|
+
* of that one under *this* name until both moved here, and reading this set
|
|
26
|
+
* there frees the claims on a worktree that still holds uncommitted work.
|
|
27
|
+
*/
|
|
28
|
+
export const TERMINAL_TASK_STATUSES = new Set([
|
|
29
|
+
'completed',
|
|
30
|
+
'superseded',
|
|
31
|
+
'failed',
|
|
32
|
+
'escalated',
|
|
33
|
+
'waived',
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* The two terminal statuses that mean the work ended *well*: the task shipped,
|
|
37
|
+
* or the operator decided it would not and waived it (D-120). Three modules
|
|
38
|
+
* ask this question and each kept its own answer — waveNext.ts called it
|
|
39
|
+
* TERMINAL_TASK_STATUSES, which is the name above for a different set;
|
|
40
|
+
* epic.ts called it TERMINAL_OK_TASK_STATUSES; db/queries.ts called it
|
|
41
|
+
* MILESTONE_COMPLETE_TASK_STATUSES. The three agreed because someone typed
|
|
42
|
+
* the same two strings three times, not because anything held them together.
|
|
43
|
+
*
|
|
44
|
+
* Declared rather than subtracted from the set above, because "ended well" is
|
|
45
|
+
* a judgement about what a status MEANS, and which way the default falls
|
|
46
|
+
* decides what forgetting one costs. Subtracting a declared failure side
|
|
47
|
+
* would read a thirteenth terminal status as *well* until someone classified
|
|
48
|
+
* it: waveNext.ts would free the claims on a worktree nobody has finished
|
|
49
|
+
* with, the roadmap bar would count the task done, and closeEpic would close
|
|
50
|
+
* over it. Declared, an unclassified status is simply not done — every
|
|
51
|
+
* reader fails closed, and the drift is visible instead of silent.
|
|
52
|
+
*/
|
|
53
|
+
export const TERMINAL_OK_TASK_STATUSES = new Set(['completed', 'waived']);
|
|
54
|
+
/**
|
|
55
|
+
* The two terminal statuses a person is still holding. `failed` and
|
|
56
|
+
* `escalated` are over in the projector's sense — it will not overwrite them —
|
|
57
|
+
* but somebody is expected to come back to them, so a reader asking "can work
|
|
58
|
+
* still land on this task?" answers yes for these two and no for every other
|
|
59
|
+
* terminal status.
|
|
60
|
+
*
|
|
61
|
+
* Declared as the exception rather than as a roster of the remainder, because
|
|
62
|
+
* the remainder is derivable and the exception is the judgement. claims.ts
|
|
63
|
+
* asked exactly this question and typed the answer out — `completed`,
|
|
64
|
+
* `waived`, `superseded` — with its docblock explaining in prose that "`failed`
|
|
65
|
+
* and `escalated` are deliberately NOT here". That is the subtraction, written
|
|
66
|
+
* where only a reader could find it; this is the same sentence written where
|
|
67
|
+
* the code reads it.
|
|
68
|
+
*
|
|
69
|
+
* Which way the default falls: a thirteenth terminal status nobody classified
|
|
70
|
+
* falls OUT of this exception, so it reads as closed to further work, and a
|
|
71
|
+
* finding about its files mints a follow-up task somebody can see rather than
|
|
72
|
+
* parking silently on a task nobody is working.
|
|
73
|
+
*/
|
|
74
|
+
export const HELD_OPEN_BY_AN_OPERATOR = new Set(['failed', 'escalated']);
|
|
@@ -15,6 +15,14 @@ const NON_DIMENSION_KEYS = new Set(['version', 'rules', 'error']);
|
|
|
15
15
|
const PRESENCE_ONLY_FIELDS = new Set(['task_ref', 'provenance_event_ids', 'model']);
|
|
16
16
|
/** The `error` dimension is validated as a `group.class` pair, not a flat tag. */
|
|
17
17
|
const ERROR_CLASS_FIELD = 'error';
|
|
18
|
+
// `task_ref` names exactly one task; it is otherwise presence-only (above),
|
|
19
|
+
// which let a comma-joined "epic-1/task-1,epic-1/task-2" through as one
|
|
20
|
+
// non-empty string. db/projector.ts's fold read that string as a single task
|
|
21
|
+
// id and minted a phantom row for it that stayed `todo` forever, holding
|
|
22
|
+
// epic verdict on a task that was never real. Reject the shape here so the
|
|
23
|
+
// log never grows another one — a producer with two failing tasks logs two
|
|
24
|
+
// error-logged events, not one with two ids stitched together.
|
|
25
|
+
const TASK_REF_FIELD = 'task_ref';
|
|
18
26
|
export function parseTaxonomy(yamlText) {
|
|
19
27
|
const doc = parseYaml(yamlText);
|
|
20
28
|
if (!doc || typeof doc !== 'object') {
|
|
@@ -102,6 +110,9 @@ export function validateRequiredDimensions(taxonomy, recordType, record) {
|
|
|
102
110
|
if (isEmpty) {
|
|
103
111
|
throw new TaxonomyError('taxonomy.missing-required-dimension', `Record type "${recordType}" is missing required field "${field}".`, { recordType, field });
|
|
104
112
|
}
|
|
113
|
+
if (field === TASK_REF_FIELD && String(value).includes(',')) {
|
|
114
|
+
throw new TaxonomyError('taxonomy.multi-value-task-ref', `Record type "${recordType}" field "task_ref" names more than one task ("${String(value)}"). Log one error-logged event per task instead.`, { recordType, field, value });
|
|
115
|
+
}
|
|
105
116
|
if (PRESENCE_ONLY_FIELDS.has(field))
|
|
106
117
|
continue;
|
|
107
118
|
if (field === ERROR_CLASS_FIELD) {
|
|
@@ -58,6 +58,33 @@ function signalNote(code, signal) {
|
|
|
58
58
|
}
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* The environment a project's command runs in: this process's, minus the
|
|
63
|
+
* factory's own namespace.
|
|
64
|
+
*
|
|
65
|
+
* A command the factory launches on a project's behalf belongs to the
|
|
66
|
+
* project, not to the factory that launched it. Every `SMITH_*` variable in
|
|
67
|
+
* this process was set for *this* process, and letting one through silently
|
|
68
|
+
* makes the factory's own invocation part of the project's verdict.
|
|
69
|
+
* `SMITH_HOME` is the one that bites: it moves `WORK_ROOT` off the worktree,
|
|
70
|
+
* so a suite that asserts its own layout fails on the launcher's environment
|
|
71
|
+
* alone, and anything the suite writes to state lands in the factory's live
|
|
72
|
+
* `state/` instead of the worktree's. The verdict then depends on how the
|
|
73
|
+
* factory happened to be invoked, which is not a verdict about the branch.
|
|
74
|
+
*
|
|
75
|
+
* The strip is a rule rather than a roster, deliberately: a roster is a list
|
|
76
|
+
* that goes stale the next time a `SMITH_*` variable is added, and it goes
|
|
77
|
+
* stale silently. Not every one of them is the factory configuring itself —
|
|
78
|
+
* `SMITH_CROSSCHECK_OFFLINE` is an operator switch — but the runbook already
|
|
79
|
+
* says of that one "pass it per command"
|
|
80
|
+
* (`docs/runbooks/providers.md`, and it names an ambient copy of that switch
|
|
81
|
+
* as its own hazard). The command the operator means is the one running
|
|
82
|
+
* here, not the factory that spawned it, and stating it inside the command
|
|
83
|
+
* string works: the shell applies it after this strip.
|
|
84
|
+
*/
|
|
85
|
+
export function projectCommandEnv(env) {
|
|
86
|
+
return Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith('SMITH_')));
|
|
87
|
+
}
|
|
61
88
|
/**
|
|
62
89
|
* Run one check command in its own process group (POSIX `setsid`, via
|
|
63
90
|
* `detached: true`) so a timeout can kill the WHOLE group — `process.kill`
|
|
@@ -67,13 +94,15 @@ function signalNote(code, signal) {
|
|
|
67
94
|
* jobs, long-lived children) running as orphans after the parent shell
|
|
68
95
|
* dies — a real leak in a 24/7 factory (reproduced: manual kill -9 needed).
|
|
69
96
|
*/
|
|
70
|
-
function runOne(check, cwd,
|
|
97
|
+
function runOne(check, cwd, defaultTimeoutMs) {
|
|
71
98
|
return new Promise((resolve) => {
|
|
99
|
+
const timeoutMs = check.timeout_ms ?? defaultTimeoutMs;
|
|
72
100
|
const child = spawn(check.cmd, {
|
|
73
101
|
cwd,
|
|
74
102
|
shell: true,
|
|
75
103
|
detached: true,
|
|
76
104
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
105
|
+
env: projectCommandEnv(process.env),
|
|
77
106
|
});
|
|
78
107
|
let output = '';
|
|
79
108
|
let dropped = false;
|
|
@@ -148,10 +177,10 @@ function runOne(check, cwd, timeoutMs) {
|
|
|
148
177
|
* no test-output parsing beyond the process exit code.
|
|
149
178
|
*/
|
|
150
179
|
export async function run(checks, opts) {
|
|
151
|
-
const
|
|
180
|
+
const defaultTimeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
152
181
|
const results = [];
|
|
153
182
|
for (const check of checks) {
|
|
154
|
-
const result = await runOne(check, opts.cwd,
|
|
183
|
+
const result = await runOne(check, opts.cwd, defaultTimeoutMs);
|
|
155
184
|
results.push(result);
|
|
156
185
|
if (!result.pass && !opts.runAll)
|
|
157
186
|
break;
|
|
@@ -30,6 +30,8 @@ const EVENTS_DIR = `${EVENTS} [--state-dir <dir>]`;
|
|
|
30
30
|
* asked about.
|
|
31
31
|
*/
|
|
32
32
|
const STATS = '[--db <file>] [--session <id>] [--lineage]';
|
|
33
|
+
/** The scope both `issues` actions accept: a session's lineage, narrowed by epic and ISO since. */
|
|
34
|
+
const ISSUES = '--session <id> [--epic <id>] [--since <iso>] [--state-dir <dir>] [--roadmap-path <file>] [--specs-dir <dir>]';
|
|
33
35
|
export const COMMANDS = [
|
|
34
36
|
{
|
|
35
37
|
command: 'audit open',
|
|
@@ -64,8 +66,8 @@ export const COMMANDS = [
|
|
|
64
66
|
{
|
|
65
67
|
command: 'audit resolve',
|
|
66
68
|
positionals: '<project-dir>',
|
|
67
|
-
flags: `--epic <epic-id> ${EVENTS_DIR}`,
|
|
68
|
-
summary:
|
|
69
|
+
flags: `--epic <epic-id> [--except <fp[,fp...]>] [--plan <plan.json>] [--specs-dir <dir>] ${EVENTS_DIR}`,
|
|
70
|
+
summary: "Append a fixed line for every carried finding the epic's newest plan still claims; list the rest as deferred. Run from the epic-close step.",
|
|
69
71
|
},
|
|
70
72
|
{
|
|
71
73
|
command: 'audit close',
|
|
@@ -196,7 +198,7 @@ export const COMMANDS = [
|
|
|
196
198
|
{
|
|
197
199
|
command: 'daemon run',
|
|
198
200
|
positionals: '',
|
|
199
|
-
flags: `[--interval <seconds>] [--once] [--dir <dir>] [--project <dir>...] [--no-self] [--db <path>] [--no-db] [--state-dir <dir>]`,
|
|
201
|
+
flags: `[--interval <seconds>] [--once] [--dir <dir>] [--project <dir>...] [--no-self] [--db <path>] [--no-db] [--state-dir <dir>] [--specs-dir <dir>]`,
|
|
200
202
|
summary: 'Watch the event log in the foreground: budgets, stale agents, work that is due. ' +
|
|
201
203
|
'Prints one report per tick, one JSON line each; --once prints one document and exits. ' +
|
|
202
204
|
'Never dispatches.',
|
|
@@ -230,21 +232,21 @@ export const COMMANDS = [
|
|
|
230
232
|
{
|
|
231
233
|
command: 'scheduler run',
|
|
232
234
|
positionals: '',
|
|
233
|
-
flags: `[--dry] [--now <iso>] [--project <dir>...] [--no-self] ${EVENTS_DIR}`,
|
|
235
|
+
flags: `[--dry] [--now <iso>] [--project <dir>...] [--no-self] [--specs-dir <dir>] ${EVENTS_DIR}`,
|
|
234
236
|
summary: 'Propose and enact the next scheduler moves for a session. --dry proposes only.',
|
|
235
237
|
},
|
|
236
238
|
{
|
|
237
239
|
command: 'scheduler admit',
|
|
238
240
|
positionals: '',
|
|
239
241
|
flags: '--session <id> [--state-dir <dir>] [--now <iso>] [--project <dir>...] [--no-self] ' +
|
|
240
|
-
'[--policy <file>] [--crosscheck <file>]',
|
|
242
|
+
'[--specs-dir <dir>] [--policy <file>] [--crosscheck <file>]',
|
|
241
243
|
summary: "Classify what's due into auto or operator per scheduler.yml autonomy. Enacts nothing.",
|
|
242
244
|
},
|
|
243
245
|
{
|
|
244
246
|
command: 'worktree create',
|
|
245
247
|
positionals: '<project-dir> <epic> <task-id>',
|
|
246
|
-
flags: '',
|
|
247
|
-
summary: 'Cut the isolated worktree and task branch one task is allowed to write in.',
|
|
248
|
+
flags: '[--from <predecessor-task-id> --session <id> [--state-dir <dir>]]',
|
|
249
|
+
summary: 'Cut the isolated worktree and task branch one task is allowed to write in. --from cuts it from a predecessor branch instead of integration, and requires --session (which supplies the log --from is checked against): refused unless that session records <epic>/<task-id> as the logged successor of <epic>/<predecessor-task-id>.',
|
|
248
250
|
},
|
|
249
251
|
{
|
|
250
252
|
command: 'worktree rm',
|
|
@@ -273,8 +275,10 @@ export const COMMANDS = [
|
|
|
273
275
|
{
|
|
274
276
|
command: 'queue run',
|
|
275
277
|
positionals: '<epic>',
|
|
276
|
-
flags: '--project <dir> --test-cmd <cmd> --tasks <tasks.json> [--select-test-cmd <cmd>] [--session <id> --causal-parent <event-id> --plan <plan.json> [--plan-version <n>] [--actor <name>]] [--state-dir <dir>]',
|
|
277
|
-
summary: 'Serially rebase, test, and merge task branches into the integration branch.'
|
|
278
|
+
flags: '--project <dir> --test-cmd <cmd> --tasks <tasks.json> [--select-test-cmd <cmd>] [--batch] [--session <id> --causal-parent <event-id> --plan <plan.json> [--plan-version <n>] [--actor <name>]] [--state-dir <dir>]',
|
|
279
|
+
summary: 'Serially rebase, test, and merge task branches into the integration branch. ' +
|
|
280
|
+
'--batch (roadmap merge-lanes) groups claim-disjoint tasks with groupForBatch and ' +
|
|
281
|
+
'tests each group once instead of once per task — see queue.ts and the operator guide.',
|
|
278
282
|
},
|
|
279
283
|
{
|
|
280
284
|
command: 'queue adopt',
|
|
@@ -357,6 +361,12 @@ export const COMMANDS = [
|
|
|
357
361
|
flags: '[--task <id>] [--policy <path>] [--state-dir <dir>]',
|
|
358
362
|
summary: 'Assert crosscheck.yml’s role asymmetry against the log. Exit 1 on a violation OR an unverifiable answer.',
|
|
359
363
|
},
|
|
364
|
+
{
|
|
365
|
+
command: 'dispatch lint',
|
|
366
|
+
positionals: '<prompt-file>',
|
|
367
|
+
flags: '--role <role> --task <id> --session <id> [--agents-dir <dir>] [--state-dir <dir>]',
|
|
368
|
+
summary: "Check a composed dispatch prompt's stated turn budget against the role template's maxTurns, and a judge prompt's declared-artifact line against the ledger. Exit 1 on a violation OR an unverifiable answer. `-` reads stdin.",
|
|
369
|
+
},
|
|
360
370
|
{
|
|
361
371
|
command: 'tester check',
|
|
362
372
|
positionals: '<session-id>',
|
|
@@ -387,6 +397,23 @@ export const COMMANDS = [
|
|
|
387
397
|
flags: '--task <task.json> [--policy <file>] [--case <case>] [--epic-tag <tag>...] [--recheck]',
|
|
388
398
|
summary: 'Compute whether the security reviewer must be dispatched. A fired trigger is not a red.',
|
|
389
399
|
},
|
|
400
|
+
// `issues`: the run's write path to a project's tracker. Preview is its
|
|
401
|
+
// own action, never a flag, so a typo cannot promote it into a write. Exit
|
|
402
|
+
// status for both: 0 when every candidate resolved to a recorded outcome
|
|
403
|
+
// (a skip is a recorded answer); non-zero only when the run could not be
|
|
404
|
+
// performed at all, e.g. the session log is unreadable.
|
|
405
|
+
{
|
|
406
|
+
command: 'issues report',
|
|
407
|
+
positionals: '',
|
|
408
|
+
flags: ISSUES,
|
|
409
|
+
summary: 'Report each candidate error to its project tracker through gh and record an issue-reported event per candidate. The only command that runs gh.',
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
command: 'issues preview',
|
|
413
|
+
positionals: '',
|
|
414
|
+
flags: ISSUES,
|
|
415
|
+
summary: 'Dry run of issues report: prints the argv that would run, spawns no gh and appends no event. The action an unattended agent uses.',
|
|
416
|
+
},
|
|
390
417
|
{
|
|
391
418
|
command: 'harness list',
|
|
392
419
|
positionals: '',
|
|
@@ -399,6 +426,12 @@ export const COMMANDS = [
|
|
|
399
426
|
flags: '--role <role> --task <id> --prompt-file <path> [--harness <name>] [--worktree <dir>] [--policy <file>] [--tier <frontier|mid|small>] [--schema <name>]',
|
|
400
427
|
summary: 'Render the invocation that would run one worker turn, as JSON. Prints it; never starts it.',
|
|
401
428
|
},
|
|
429
|
+
{
|
|
430
|
+
command: 'agents sync',
|
|
431
|
+
positionals: '',
|
|
432
|
+
flags: '[--dry-run] [--reset] [--agents-dir <dir>]',
|
|
433
|
+
summary: 'Set each agent template’s frontmatter maxTurns from SMITH_MAXTURNS_<ROLE> (role upper-cased, - as _); --reset restores git HEAD’s value; --dry-run prints role/from/to only. A local, uncommitted edit by design.',
|
|
434
|
+
},
|
|
402
435
|
{
|
|
403
436
|
command: 'sandbox open',
|
|
404
437
|
positionals: '<worktree-dir>',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SmithError } from './errors.js';
|
|
2
2
|
import { appendEvent, readLineageEvents } from './events.js';
|
|
3
|
-
import { listFindings, preWaiverStatus, staleFindings, transition } from './findings.js';
|
|
3
|
+
import { computeFingerprint, foldFindings, listFindings, preWaiverStatus, staleFindings, transition, WAIVABLE_STATUSES, } from './findings.js';
|
|
4
4
|
export class WaiverError extends SmithError {
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
@@ -10,8 +10,38 @@ export class WaiverError extends SmithError {
|
|
|
10
10
|
* call or via a waiver grant).
|
|
11
11
|
*/
|
|
12
12
|
export const WAIVABLE_SEVERITIES = ['S3-minor', 'S4-nit'];
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Every fingerprint that names the same finding as `fingerprint`, for each
|
|
15
|
+
* candidate in `findings` that could stand in for it: its own stored value,
|
|
16
|
+
* and what computeFingerprint would produce today from its persisted
|
|
17
|
+
* file_path/finding_category/summary. Mirrors audit.ts's
|
|
18
|
+
* suppressedFingerprints, for the same reason (issue #178): a `finding-raised`
|
|
19
|
+
* or waiver-decision record written before the normalizer widened to also
|
|
20
|
+
* strip line spans ("lines 1549-1576") fingerprints differently than the
|
|
21
|
+
* identical text does now, so a stored fingerprint and a freshly computed one
|
|
22
|
+
* for the same defect can legitimately disagree.
|
|
23
|
+
*
|
|
24
|
+
* Always includes `fingerprint` itself, so a caller with no backing finding
|
|
25
|
+
* yet (e.g. a batch decision not folded into findings) still gets an
|
|
26
|
+
* exact-match-only set — unchanged behaviour.
|
|
27
|
+
*/
|
|
28
|
+
function fingerprintAliases(fingerprint, findings) {
|
|
29
|
+
const aliases = new Set([fingerprint]);
|
|
30
|
+
for (const finding of findings) {
|
|
31
|
+
if (finding.file_path === undefined)
|
|
32
|
+
continue;
|
|
33
|
+
const recomputed = computeFingerprint({
|
|
34
|
+
filePath: finding.file_path,
|
|
35
|
+
category: finding.finding_category,
|
|
36
|
+
summary: finding.summary,
|
|
37
|
+
});
|
|
38
|
+
if (finding.fingerprint === fingerprint || recomputed === fingerprint) {
|
|
39
|
+
aliases.add(finding.fingerprint);
|
|
40
|
+
aliases.add(recomputed);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return aliases;
|
|
44
|
+
}
|
|
15
45
|
/**
|
|
16
46
|
* Fold waiver-granted/waiver-denied events for one fingerprint: the last
|
|
17
47
|
* decision wins (a denial can, in principle, be revisited by a later grant
|
|
@@ -22,13 +52,19 @@ const WAIVABLE_STATUSES = ['raised', 'confirmed'];
|
|
|
22
52
|
* would re-raise a finding the operator already waived the moment an epic
|
|
23
53
|
* continued in a new session. "Last decision wins" is decided on the merged
|
|
24
54
|
* order, which is `ts` between sessions and append order within one.
|
|
55
|
+
*
|
|
56
|
+
* Matches against `fingerprintAliases(fingerprint, ...)` rather than strict
|
|
57
|
+
* equality (issue #178): a decision recorded under the fingerprint's old
|
|
58
|
+
* spelling still answers a lookup under its current one, and vice versa.
|
|
25
59
|
*/
|
|
26
60
|
export async function isWaived(fingerprint, ref, opts = {}) {
|
|
27
61
|
const events = await readLineageEvents(ref.sessionId, opts);
|
|
62
|
+
const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
|
|
28
63
|
let waived = false;
|
|
29
64
|
for (const { record } of events) {
|
|
30
65
|
const payload = record.payload;
|
|
31
|
-
|
|
66
|
+
const fp = payload.fingerprint;
|
|
67
|
+
if (fp === undefined || !equivalents.has(fp))
|
|
32
68
|
continue;
|
|
33
69
|
if (record.event_type === 'waiver-granted')
|
|
34
70
|
waived = true;
|
|
@@ -40,13 +76,17 @@ export async function isWaived(fingerprint, ref, opts = {}) {
|
|
|
40
76
|
/**
|
|
41
77
|
* Has the operator recorded ANY waiver decision for this fingerprint (granted
|
|
42
78
|
* or denied)? Lineage-wide, for isWaived's reason (D-119): a decision recorded
|
|
43
|
-
* in the parent session is still a decision.
|
|
79
|
+
* in the parent session is still a decision. Same alias widening as isWaived,
|
|
80
|
+
* and for the same reason (issue #178).
|
|
44
81
|
*/
|
|
45
82
|
async function hasDecision(fingerprint, ref, opts) {
|
|
46
83
|
const events = await readLineageEvents(ref.sessionId, opts);
|
|
84
|
+
const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
|
|
47
85
|
return events.some(({ record }) => {
|
|
48
86
|
const payload = record.payload;
|
|
49
|
-
|
|
87
|
+
const fp = payload.fingerprint;
|
|
88
|
+
return (fp !== undefined &&
|
|
89
|
+
equivalents.has(fp) &&
|
|
50
90
|
(record.event_type === 'waiver-granted' || record.event_type === 'waiver-denied'));
|
|
51
91
|
});
|
|
52
92
|
}
|
|
@@ -58,10 +98,16 @@ async function hasDecision(fingerprint, ref, opts) {
|
|
|
58
98
|
* finding as open forever. Findings the state machine can't legally move
|
|
59
99
|
* (already terminal, or a non-S3/S4 severity that somehow shares this
|
|
60
100
|
* fingerprint) are left alone; this is reconciliation, not a mandate.
|
|
101
|
+
*
|
|
102
|
+
* Matched via fingerprintAliases (issue #178), not strict equality: a grant
|
|
103
|
+
* against a finding's current fingerprint also reconciles a row for the
|
|
104
|
+
* identical defect still carrying the pre-widening fingerprint it was raised
|
|
105
|
+
* under, and vice versa.
|
|
61
106
|
*/
|
|
62
107
|
async function reconcileFindingsToWaived(fingerprint, waiverEventId, ctx, opts) {
|
|
63
108
|
const findings = await listFindings(ctx.sessionId, {}, opts);
|
|
64
|
-
const
|
|
109
|
+
const equivalents = fingerprintAliases(fingerprint, findings);
|
|
110
|
+
const reconcilable = findings.filter((f) => equivalents.has(f.fingerprint) &&
|
|
65
111
|
WAIVABLE_SEVERITIES.includes(f.severity) &&
|
|
66
112
|
WAIVABLE_STATUSES.includes(f.finding_status));
|
|
67
113
|
const transitionCtx = { ...ctx, causalParent: waiverEventId };
|
|
@@ -94,11 +140,16 @@ export async function grantWaiver(fingerprint, operatorNote, ctx, opts = {}) {
|
|
|
94
140
|
*
|
|
95
141
|
* Only findings this session's log actually shows at `waived` are touched, and
|
|
96
142
|
* each goes back to the status its own grant recorded as from_status. A
|
|
97
|
-
* finding waived under some other fingerprint is not this denial's business
|
|
143
|
+
* finding waived under some other fingerprint is not this denial's business —
|
|
144
|
+
* "other" meaning a different defect, not merely a different spelling: matched
|
|
145
|
+
* via fingerprintAliases (issue #178), the same widening reconcileFindingsToWaived
|
|
146
|
+
* applies, so a row still carrying the pre-widening fingerprint it was raised
|
|
147
|
+
* under reopens alongside its current-fingerprint sibling.
|
|
98
148
|
*/
|
|
99
149
|
async function reconcileFindingsFromWaived(fingerprint, denialEventId, ctx, opts) {
|
|
100
150
|
const findings = await listFindings(ctx.sessionId, {}, opts);
|
|
101
|
-
const
|
|
151
|
+
const equivalents = fingerprintAliases(fingerprint, findings);
|
|
152
|
+
const reopenable = findings.filter((f) => equivalents.has(f.fingerprint) && f.finding_status === 'waived');
|
|
102
153
|
const transitionCtx = { ...ctx, causalParent: denialEventId };
|
|
103
154
|
for (const finding of reopenable) {
|
|
104
155
|
const to = await preWaiverStatus(finding.finding_id, ctx.sessionId, opts);
|
|
@@ -24,6 +24,35 @@ import { foldAgents } from './agents-registry.js';
|
|
|
24
24
|
* intervals open forever and score every wave after one as parallel.
|
|
25
25
|
*/
|
|
26
26
|
const ADMITTED_EVENT_TYPE = 'wave-admitted';
|
|
27
|
+
/**
|
|
28
|
+
* What the log says a wave did.
|
|
29
|
+
*
|
|
30
|
+
* - `parallel` — every admitted task was in flight at one instant.
|
|
31
|
+
* - `partial` — two or more were, but never all of them.
|
|
32
|
+
* - `serialized` — work is recorded, and no two tasks ever overlapped.
|
|
33
|
+
* - `single` — the wave admitted one task. Nothing to be parallel about,
|
|
34
|
+
* and scoring it against a width it never claimed would make
|
|
35
|
+
* the failing verdicts unreadable.
|
|
36
|
+
* - `unobserved` — the wave was admitted and the log records no work for any
|
|
37
|
+
* of its tasks. Not the same fact as `serialized`, and the
|
|
38
|
+
* whole point of separating them: one says the factory ran
|
|
39
|
+
* narrow, the other says nobody can tell.
|
|
40
|
+
*/
|
|
41
|
+
export const WAVE_VERDICTS = ['parallel', 'partial', 'serialized', 'single', 'unobserved'];
|
|
42
|
+
/**
|
|
43
|
+
* A counter with one bucket per key, all at zero.
|
|
44
|
+
*
|
|
45
|
+
* The one place the `Record` cast lives, and here it is true by construction
|
|
46
|
+
* rather than asserted: the keys ARE the roster. Every caller that used to
|
|
47
|
+
* write `Object.fromEntries(SOME_LIST.map(...)) as Record<…>` was asserting a
|
|
48
|
+
* totality that only its own hand-written list was holding up.
|
|
49
|
+
*/
|
|
50
|
+
export function zeroedCounts(keys) {
|
|
51
|
+
const counts = {};
|
|
52
|
+
for (const key of keys)
|
|
53
|
+
counts[key] = 0;
|
|
54
|
+
return counts;
|
|
55
|
+
}
|
|
27
56
|
/**
|
|
28
57
|
* Said when a wave was admitted and no agent was ever dispatched for it. The
|
|
29
58
|
* two readings are genuinely different problems and the operator has to pick
|