@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
|
@@ -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);
|
|
@@ -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) {
|
|
@@ -94,8 +94,9 @@ export function projectCommandEnv(env) {
|
|
|
94
94
|
* jobs, long-lived children) running as orphans after the parent shell
|
|
95
95
|
* dies — a real leak in a 24/7 factory (reproduced: manual kill -9 needed).
|
|
96
96
|
*/
|
|
97
|
-
function runOne(check, cwd,
|
|
97
|
+
function runOne(check, cwd, defaultTimeoutMs) {
|
|
98
98
|
return new Promise((resolve) => {
|
|
99
|
+
const timeoutMs = check.timeout_ms ?? defaultTimeoutMs;
|
|
99
100
|
const child = spawn(check.cmd, {
|
|
100
101
|
cwd,
|
|
101
102
|
shell: true,
|
|
@@ -176,10 +177,10 @@ function runOne(check, cwd, timeoutMs) {
|
|
|
176
177
|
* no test-output parsing beyond the process exit code.
|
|
177
178
|
*/
|
|
178
179
|
export async function run(checks, opts) {
|
|
179
|
-
const
|
|
180
|
+
const defaultTimeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
180
181
|
const results = [];
|
|
181
182
|
for (const check of checks) {
|
|
182
|
-
const result = await runOne(check, opts.cwd,
|
|
183
|
+
const result = await runOne(check, opts.cwd, defaultTimeoutMs);
|
|
183
184
|
results.push(result);
|
|
184
185
|
if (!result.pass && !opts.runAll)
|
|
185
186
|
break;
|
|
@@ -31,7 +31,7 @@ const EVENTS_DIR = `${EVENTS} [--state-dir <dir>]`;
|
|
|
31
31
|
*/
|
|
32
32
|
const STATS = '[--db <file>] [--session <id>] [--lineage]';
|
|
33
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>]';
|
|
34
|
+
const ISSUES = '--session <id> [--epic <id>] [--since <iso>] [--state-dir <dir>] [--roadmap-path <file>] [--specs-dir <dir>]';
|
|
35
35
|
export const COMMANDS = [
|
|
36
36
|
{
|
|
37
37
|
command: 'audit open',
|
|
@@ -66,8 +66,8 @@ export const COMMANDS = [
|
|
|
66
66
|
{
|
|
67
67
|
command: 'audit resolve',
|
|
68
68
|
positionals: '<project-dir>',
|
|
69
|
-
flags: `--epic <epic-id> ${EVENTS_DIR}`,
|
|
70
|
-
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.",
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
command: 'audit close',
|
|
@@ -198,7 +198,7 @@ export const COMMANDS = [
|
|
|
198
198
|
{
|
|
199
199
|
command: 'daemon run',
|
|
200
200
|
positionals: '',
|
|
201
|
-
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>]`,
|
|
202
202
|
summary: 'Watch the event log in the foreground: budgets, stale agents, work that is due. ' +
|
|
203
203
|
'Prints one report per tick, one JSON line each; --once prints one document and exits. ' +
|
|
204
204
|
'Never dispatches.',
|
|
@@ -232,21 +232,21 @@ export const COMMANDS = [
|
|
|
232
232
|
{
|
|
233
233
|
command: 'scheduler run',
|
|
234
234
|
positionals: '',
|
|
235
|
-
flags: `[--dry] [--now <iso>] [--project <dir>...] [--no-self] ${EVENTS_DIR}`,
|
|
235
|
+
flags: `[--dry] [--now <iso>] [--project <dir>...] [--no-self] [--specs-dir <dir>] ${EVENTS_DIR}`,
|
|
236
236
|
summary: 'Propose and enact the next scheduler moves for a session. --dry proposes only.',
|
|
237
237
|
},
|
|
238
238
|
{
|
|
239
239
|
command: 'scheduler admit',
|
|
240
240
|
positionals: '',
|
|
241
241
|
flags: '--session <id> [--state-dir <dir>] [--now <iso>] [--project <dir>...] [--no-self] ' +
|
|
242
|
-
'[--policy <file>] [--crosscheck <file>]',
|
|
242
|
+
'[--specs-dir <dir>] [--policy <file>] [--crosscheck <file>]',
|
|
243
243
|
summary: "Classify what's due into auto or operator per scheduler.yml autonomy. Enacts nothing.",
|
|
244
244
|
},
|
|
245
245
|
{
|
|
246
246
|
command: 'worktree create',
|
|
247
247
|
positionals: '<project-dir> <epic> <task-id>',
|
|
248
|
-
flags: '',
|
|
249
|
-
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>.',
|
|
250
250
|
},
|
|
251
251
|
{
|
|
252
252
|
command: 'worktree rm',
|
|
@@ -275,8 +275,10 @@ export const COMMANDS = [
|
|
|
275
275
|
{
|
|
276
276
|
command: 'queue run',
|
|
277
277
|
positionals: '<epic>',
|
|
278
|
-
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>]',
|
|
279
|
-
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.',
|
|
280
282
|
},
|
|
281
283
|
{
|
|
282
284
|
command: 'queue adopt',
|
|
@@ -359,6 +361,12 @@ export const COMMANDS = [
|
|
|
359
361
|
flags: '[--task <id>] [--policy <path>] [--state-dir <dir>]',
|
|
360
362
|
summary: 'Assert crosscheck.yml’s role asymmetry against the log. Exit 1 on a violation OR an unverifiable answer.',
|
|
361
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
|
+
},
|
|
362
370
|
{
|
|
363
371
|
command: 'tester check',
|
|
364
372
|
positionals: '<session-id>',
|
|
@@ -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, WAIVABLE_STATUSES, } 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,6 +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
|
+
* 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
|
+
}
|
|
13
45
|
/**
|
|
14
46
|
* Fold waiver-granted/waiver-denied events for one fingerprint: the last
|
|
15
47
|
* decision wins (a denial can, in principle, be revisited by a later grant
|
|
@@ -20,13 +52,19 @@ export const WAIVABLE_SEVERITIES = ['S3-minor', 'S4-nit'];
|
|
|
20
52
|
* would re-raise a finding the operator already waived the moment an epic
|
|
21
53
|
* continued in a new session. "Last decision wins" is decided on the merged
|
|
22
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.
|
|
23
59
|
*/
|
|
24
60
|
export async function isWaived(fingerprint, ref, opts = {}) {
|
|
25
61
|
const events = await readLineageEvents(ref.sessionId, opts);
|
|
62
|
+
const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
|
|
26
63
|
let waived = false;
|
|
27
64
|
for (const { record } of events) {
|
|
28
65
|
const payload = record.payload;
|
|
29
|
-
|
|
66
|
+
const fp = payload.fingerprint;
|
|
67
|
+
if (fp === undefined || !equivalents.has(fp))
|
|
30
68
|
continue;
|
|
31
69
|
if (record.event_type === 'waiver-granted')
|
|
32
70
|
waived = true;
|
|
@@ -38,13 +76,17 @@ export async function isWaived(fingerprint, ref, opts = {}) {
|
|
|
38
76
|
/**
|
|
39
77
|
* Has the operator recorded ANY waiver decision for this fingerprint (granted
|
|
40
78
|
* or denied)? Lineage-wide, for isWaived's reason (D-119): a decision recorded
|
|
41
|
-
* 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).
|
|
42
81
|
*/
|
|
43
82
|
async function hasDecision(fingerprint, ref, opts) {
|
|
44
83
|
const events = await readLineageEvents(ref.sessionId, opts);
|
|
84
|
+
const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
|
|
45
85
|
return events.some(({ record }) => {
|
|
46
86
|
const payload = record.payload;
|
|
47
|
-
|
|
87
|
+
const fp = payload.fingerprint;
|
|
88
|
+
return (fp !== undefined &&
|
|
89
|
+
equivalents.has(fp) &&
|
|
48
90
|
(record.event_type === 'waiver-granted' || record.event_type === 'waiver-denied'));
|
|
49
91
|
});
|
|
50
92
|
}
|
|
@@ -56,10 +98,16 @@ async function hasDecision(fingerprint, ref, opts) {
|
|
|
56
98
|
* finding as open forever. Findings the state machine can't legally move
|
|
57
99
|
* (already terminal, or a non-S3/S4 severity that somehow shares this
|
|
58
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.
|
|
59
106
|
*/
|
|
60
107
|
async function reconcileFindingsToWaived(fingerprint, waiverEventId, ctx, opts) {
|
|
61
108
|
const findings = await listFindings(ctx.sessionId, {}, opts);
|
|
62
|
-
const
|
|
109
|
+
const equivalents = fingerprintAliases(fingerprint, findings);
|
|
110
|
+
const reconcilable = findings.filter((f) => equivalents.has(f.fingerprint) &&
|
|
63
111
|
WAIVABLE_SEVERITIES.includes(f.severity) &&
|
|
64
112
|
WAIVABLE_STATUSES.includes(f.finding_status));
|
|
65
113
|
const transitionCtx = { ...ctx, causalParent: waiverEventId };
|
|
@@ -92,11 +140,16 @@ export async function grantWaiver(fingerprint, operatorNote, ctx, opts = {}) {
|
|
|
92
140
|
*
|
|
93
141
|
* Only findings this session's log actually shows at `waived` are touched, and
|
|
94
142
|
* each goes back to the status its own grant recorded as from_status. A
|
|
95
|
-
* 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.
|
|
96
148
|
*/
|
|
97
149
|
async function reconcileFindingsFromWaived(fingerprint, denialEventId, ctx, opts) {
|
|
98
150
|
const findings = await listFindings(ctx.sessionId, {}, opts);
|
|
99
|
-
const
|
|
151
|
+
const equivalents = fingerprintAliases(fingerprint, findings);
|
|
152
|
+
const reopenable = findings.filter((f) => equivalents.has(f.fingerprint) && f.finding_status === 'waived');
|
|
100
153
|
const transitionCtx = { ...ctx, causalParent: denialEventId };
|
|
101
154
|
for (const finding of reopenable) {
|
|
102
155
|
const to = await preWaiverStatus(finding.finding_id, ctx.sessionId, opts);
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
* Pure, and it writes nothing. Like `waveNext`, admission stays where it was:
|
|
41
41
|
* a round simulated here has been admitted by nobody.
|
|
42
42
|
*/
|
|
43
|
+
import { claimsOverlap, readEdgeList } from './claims.js';
|
|
44
|
+
import { planClaimedTasks } from './plan.js';
|
|
43
45
|
import { computeNextWave, } from './waveNext.js';
|
|
44
46
|
/**
|
|
45
47
|
* Reported in this order, which is the order `computeNextWave` tests them in.
|
|
@@ -150,3 +152,85 @@ export function scheduleWaves(input) {
|
|
|
150
152
|
exitCode: stalled.length > 0 ? 1 : constraints.length > 0 ? 2 : 0,
|
|
151
153
|
};
|
|
152
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Directed reachability over the plan's `depends_on` edges, both ways: the
|
|
157
|
+
* tasks reachable by walking forward (its descendants) and the tasks
|
|
158
|
+
* reachable by walking backward (its ancestors). Two tasks are connected —
|
|
159
|
+
* "a dependency path between them in either direction" — when either set
|
|
160
|
+
* contains the other, which a chain like C -> B -> A catches for A and C
|
|
161
|
+
* even though no edge names that pair directly.
|
|
162
|
+
*/
|
|
163
|
+
function connectedTaskIds(ids, edges) {
|
|
164
|
+
const descendantsOf = new Map();
|
|
165
|
+
const ancestorsOf = new Map();
|
|
166
|
+
for (const id of ids) {
|
|
167
|
+
descendantsOf.set(id, []);
|
|
168
|
+
ancestorsOf.set(id, []);
|
|
169
|
+
}
|
|
170
|
+
for (const edge of edges) {
|
|
171
|
+
if (!ids.has(edge.task) || !ids.has(edge.dependsOn))
|
|
172
|
+
continue;
|
|
173
|
+
descendantsOf.get(edge.dependsOn)?.push(edge.task);
|
|
174
|
+
ancestorsOf.get(edge.task)?.push(edge.dependsOn);
|
|
175
|
+
}
|
|
176
|
+
const reachable = (start, adjacency) => {
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
const stack = [...(adjacency.get(start) ?? [])];
|
|
179
|
+
while (stack.length > 0) {
|
|
180
|
+
const next = stack.pop();
|
|
181
|
+
if (seen.has(next))
|
|
182
|
+
continue;
|
|
183
|
+
seen.add(next);
|
|
184
|
+
for (const n of adjacency.get(next) ?? [])
|
|
185
|
+
if (!seen.has(n))
|
|
186
|
+
stack.push(n);
|
|
187
|
+
}
|
|
188
|
+
return seen;
|
|
189
|
+
};
|
|
190
|
+
const connected = new Map();
|
|
191
|
+
for (const id of ids) {
|
|
192
|
+
connected.set(id, new Set([...reachable(id, descendantsOf), ...reachable(id, ancestorsOf)]));
|
|
193
|
+
}
|
|
194
|
+
return connected;
|
|
195
|
+
}
|
|
196
|
+
const SMALL_MAX_TASKS = 3;
|
|
197
|
+
const SMALL_MAX_CLAIMS = 10;
|
|
198
|
+
/**
|
|
199
|
+
* Pure. Superseded tasks are absent from `planClaimedTasks` already (D-126),
|
|
200
|
+
* so they never enter `widest`, `parallelWith`, or `size` — no separate
|
|
201
|
+
* filter needed here.
|
|
202
|
+
*/
|
|
203
|
+
export function computePlanParallelism(plan, policy) {
|
|
204
|
+
const claimedTasks = planClaimedTasks(plan).filter((t) => t.task_id !== undefined);
|
|
205
|
+
const ids = new Set(claimedTasks.map((t) => t.task_id));
|
|
206
|
+
const edges = readEdgeList(plan.edges);
|
|
207
|
+
const connected = connectedTaskIds(ids, edges);
|
|
208
|
+
const parallelWith = {};
|
|
209
|
+
for (const a of claimedTasks) {
|
|
210
|
+
const withIds = [];
|
|
211
|
+
for (const b of claimedTasks) {
|
|
212
|
+
if (a.task_id === b.task_id)
|
|
213
|
+
continue;
|
|
214
|
+
if (connected.get(a.task_id)?.has(b.task_id))
|
|
215
|
+
continue;
|
|
216
|
+
if (claimsOverlap(a, b).overlaps)
|
|
217
|
+
continue;
|
|
218
|
+
withIds.push(b.task_id);
|
|
219
|
+
}
|
|
220
|
+
parallelWith[a.task_id] = withIds.sort();
|
|
221
|
+
}
|
|
222
|
+
const { widest } = scheduleWaves({ plan, policy });
|
|
223
|
+
const claimPaths = new Set();
|
|
224
|
+
for (const t of claimedTasks)
|
|
225
|
+
for (const c of t.claims)
|
|
226
|
+
claimPaths.add(c);
|
|
227
|
+
return {
|
|
228
|
+
widest,
|
|
229
|
+
parallel_with: parallelWith,
|
|
230
|
+
size: {
|
|
231
|
+
tasks: claimedTasks.length,
|
|
232
|
+
claims: claimPaths.size,
|
|
233
|
+
small: claimedTasks.length <= SMALL_MAX_TASKS && claimPaths.size <= SMALL_MAX_CLAIMS,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -127,18 +127,31 @@ function ensureIntegrationBranch(projectDir, epic) {
|
|
|
127
127
|
* sibling of whatever directory the project was handed as, never a path of
|
|
128
128
|
* this module's own choosing, so a project outside this clone keeps its
|
|
129
129
|
* worktrees outside it too (see taskWorktreeDir)
|
|
130
|
-
* on branch smith/<epic>/<task-id
|
|
131
|
-
*
|
|
132
|
-
*
|
|
130
|
+
* on branch smith/<epic>/<task-id>.
|
|
131
|
+
*
|
|
132
|
+
* Without `opts.from`, cut from the CURRENT head of smith/<epic>/integration
|
|
133
|
+
* (creating the integration branch from the default branch first if it
|
|
134
|
+
* doesn't exist yet). With `opts.from`, cut from smith/<epic>/<from> instead
|
|
135
|
+
* — the predecessor's branch, commits and all — and integration is never
|
|
136
|
+
* consulted.
|
|
133
137
|
*/
|
|
134
|
-
export function createTaskWorktree(projectDir, epic, taskId) {
|
|
138
|
+
export function createTaskWorktree(projectDir, epic, taskId, opts = {}) {
|
|
135
139
|
if (taskId === RESERVED_TASK_ID) {
|
|
136
140
|
throw new WorktreeError('worktree.reserved-task-id', `Task id "${RESERVED_TASK_ID}" is reserved for the integration branch itself.`, { taskId });
|
|
137
141
|
}
|
|
138
|
-
const integrationBranch = ensureIntegrationBranch(projectDir, epic);
|
|
139
142
|
const branch = taskBranchName(epic, taskId);
|
|
140
143
|
const worktreeDir = taskWorktreeDir(projectDir, epic, taskId);
|
|
141
|
-
|
|
144
|
+
let sourceBranch;
|
|
145
|
+
if (opts.from !== undefined) {
|
|
146
|
+
sourceBranch = taskBranchName(epic, opts.from);
|
|
147
|
+
if (!localBranchExists(projectDir, sourceBranch)) {
|
|
148
|
+
throw new WorktreeError('worktree.from-missing', `Predecessor branch ${sourceBranch} does not exist; cannot cut ${taskId} from it.`, { epic, taskId, from: opts.from, branch: sourceBranch });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
sourceBranch = ensureIntegrationBranch(projectDir, epic);
|
|
153
|
+
}
|
|
154
|
+
git(projectDir, ['worktree', 'add', '-b', branch, worktreeDir, sourceBranch]);
|
|
142
155
|
return { worktreeDir, branch, epic, taskId };
|
|
143
156
|
}
|
|
144
157
|
/** Remove a task's worktree and its local branch after it has merged. */
|
package/factory/specs/roadmap.md
CHANGED
|
@@ -175,3 +175,27 @@ all; write `- error_issues: off` by hand to opt a project's tracker out.
|
|
|
175
175
|
- status: planned
|
|
176
176
|
- epics: []
|
|
177
177
|
- goal: Stop spending judge calls on outputs that a free check could have rejected, and stop grading a capped output as though it were a finished one. Four pieces, independent enough to land separately. **Free critics ahead of paid judges**: an empty diff, a missing structured completion, and claims touched without a single test added are all decidable without a model, and each one currently reaches a judge that pays full price to say so. The OpenHands SDK runs exactly this tier first and classifies what it rejects as behaviour, infrastructure or unclear-request — a taxonomy worth having because "the agent failed" and "the environment failed" are different bugs with different owners, and today they land in the same place. **Grade a budget-capped output as worth less**: SWE-agent's reviewer discards out-of-range verdicts and tie-breaks on cost; here the sharper version is that an output produced by an agent that hit its turn cap is a partial output, and a judge that does not know that is being asked to grade a fragment as a whole — which is how a capped run earns a passing verdict for the third of the task it finished. **Submit-gating and an honest forfeit**: show the worker its own diff before it may report done, with a checklist that names the mistakes workers actually make — test files touched, debug output left, claims exceeded — and give it a structured way to give up. A forfeit is strictly more useful than a confident wrong answer: it costs one round instead of a judge round plus a verifier round plus a rework round, and it is information the lessons pass can learn from. **Budget as an admission check**: `min_budget_for_dispatch` per role, so a role is never dispatched into a budget that cannot finish it, and a ring-fenced judging reserve so a capped epic still gets graded rather than spending its last tokens on more coding. Two things stay open. **Whether a free critic may block or only warn** — a false rejection that costs a whole round is worse than the judge call it saved, so the first critics should probably warn while their precision is measured against `agent-bench`. **What a forfeit does to the escalation ladder** — `budgets.yml` already declares one, and a forfeit is either its first rung or a separate path, but it must not become a cheap exit an agent prefers to the work. Researched 2026-09-18 across SWE-agent and the OpenHands SDK.
|
|
178
|
+
|
|
179
|
+
## Self-audit 1 — event-log append cost, rename-blind claim diffs, one git entry point
|
|
180
|
+
- id: bs-audit-1
|
|
181
|
+
- status: planned
|
|
182
|
+
- epics: [bs-audit-1]
|
|
183
|
+
- project: black-smith
|
|
184
|
+
- kind: factory
|
|
185
|
+
- goal: Cut from audit 20260925-f336f5e4 of `blacksmith` (7 accepted findings: 3 S2-major, 3 S3-minor, 1 S4-nit). Each finding below is one acceptance criterion; the plan decides the tasks. **d7c644db (S2-major, performance)** `factory/orchestrator/src/events.ts` — appendEvent() re-reads and re-JSON.parses the entire session event log on every single append (readEventsAtPath inside appendEventLocked), while holding both an in-process queue and a cross-process poll lock, so writing to a session log is O(n) per event and O(n^2) for a session of n events.. **0f3f9c23 (S3-minor, performance)** `factory/orchestrator/src/worktree.ts` — listStale() spawns one synchronous `git merge-base --is-ancestor` subprocess per worktree entry in a loop instead of a single batched check, so merge-queue/worktree cleanup cost scales linearly with subprocess spawns rather than with one git invocation.. **ae8cb69e (S2-major, architecture)** `factory/orchestrator/src/queue.ts` — The queue module shells out to git directly with execFileSync instead of going through git.ts, the module the codebase documents as the one place this factory shells out to git, so queue git failures bypass credential redaction and the structured GitCommandError type.. **fd03e000 (S3-minor, architecture)** `factory/orchestrator/src/diffstat.ts` — diffstat.ts calls execFileSync('git', ...) directly for rev-parse/diff/branch instead of through git.ts, duplicating the single git-invocation boundary the codebase declares for itself and losing that module's credential redaction and GitCommandError typing.. **160cc3d7 (S3-minor, architecture)** `factory/orchestrator/src/commit.ts` — commit.ts, which certifies whether a task's work is safe to integrate, runs its own execFileSync('git', ...) calls rather than routing through git.ts, so the one module the codebase names as the sole git entry point has at least two siblings that re-implement direct git shell-outs.. **07c5d1b0 (S2-major, code-quality)** `factory/orchestrator/src/claims.ts` — collectCommittedChanges, the collector behind postRunCheck (the claim-confinement check for a task worktree), runs `git diff --name-only` without `--no-renames`, so a rename that crosses a claim boundary is reported only by its destination path.. **ad9ea9ba (S4-nit, code-quality)** `factory/orchestrator/src/waveNext.ts` — The one-line helper nameList (ids.join(', ')) is duplicated verbatim in waveNext.ts and waveSchedule.ts rather than shared, so the two wave-scheduling files carry two independent copies of the same id-list formatting rule..
|
|
186
|
+
|
|
187
|
+
## Audit 2: bounded judges, parallel feedback, dashboard origin guard
|
|
188
|
+
- id: bs-audit-2
|
|
189
|
+
- status: planned
|
|
190
|
+
- epics: [bs-audit-2]
|
|
191
|
+
- project: black-smith
|
|
192
|
+
- kind: factory
|
|
193
|
+
- goal: Cut from audit 20260925-49bffa9b of `blacksmith` (5 accepted findings: 2 S2-major, 3 S3-minor). Each finding below is one acceptance criterion; the plan decides the tasks. **50acc356 (S3-minor, performance)** `factory/orchestrator/src/daemon.ts` — runTick() re-reads and re-JSON.parses every session's full .jsonl event log from disk on every daemon tick, with no incremental/fingerprint caching, so the daemon's per-tick cost grows linearly and unboundedly with the factory's total historical event volume.. **d50d4c1f (S3-minor, performance)** `ui/server/src/app.ts` — Every dashboard write route that omits an explicit causalParent (waivers apply-batch, lessons approve/reject/edit) triggers a full read-and-parse of that session's entire event log via resolveContext()/lessonContext() just to read the id of the last event.. **4c610e7c (S2-major, security)** `ui/server/src/app.ts` — The dashboard's write endpoints (waiver apply-batch, lesson approve/reject/edit) have no CSRF protection: there is no Origin/Referer check and Hono's `c.req.json()` parses the request body as JSON regardless of the Content-Type header, so a cross-origin `text/plain` form POST (a CORS-exempt 'simple request' that needs no preflight) is accepted and executed.. **1b8fe398 (S3-minor, architecture)** `.claude/skills/bs/wave.md` — The wave playbook's own loop (steps 2-10) never invokes the one instrument that measures whether a wave actually ran in parallel (auditWaveConcurrency/`smith wave check`, factory/orchestrator/src/waveConcurrency.ts), so the factory's central scaling claim goes unchecked for the entire time it could still be corrected.. **7ef3cd2f (S2-major, architecture)** `.claude/skills/bs/dispatch.md` — The one hard cap that bounds every subagent's work (`maxTurns`, enforced by the Claude Code harness itself) is transmitted into each dispatch prompt purely as prose the dispatching agent must copy correctly by hand from `.claude/agents/<role>.md` 'at dispatch time' — no module in factory/orchestrator/src validates that a dispatch's stated turn budget matches the template the harness will actually enforce, so the one enforcement path for this invariant is a human/LLM re-reading a file correctly every single time.. **Operator-added scope (2026-09-25, from GitHub research, ideas only)**: (6) a `SubagentStop` hook in the judge-class agent templates' frontmatter that blocks a judge from ending while its declared artifact file is absent — complements `agent-resume`, which handles the cap itself; (7) planning emits parallel width mechanically — at plan ingest, tasks with disjoint claims and no dependency edge are marked parallel and the plan's achievable width is reported, and a small epic takes a right-sized planning path with a bounded review focus. The per-task pipeline (no wave barrier) is deliberately out of scope until a pros/cons analysis is decided.
|
|
194
|
+
|
|
195
|
+
## Self-audit 3: UI read boundary and fail-closed daemon state
|
|
196
|
+
- id: bs-audit-3
|
|
197
|
+
- status: planned
|
|
198
|
+
- epics: [bs-audit-3]
|
|
199
|
+
- project: black-smith
|
|
200
|
+
- kind: factory
|
|
201
|
+
- goal: Cut from audit 20260927-b61eaa7e of `black-smith` (2 accepted findings: 2 S3-minor). Each finding below is one acceptance criterion; the plan decides the tasks. **871ee8f7 (S3-minor, architecture)** `ui/server/src/app.ts` — lessonSession() builds its own drizzle query directly against the `lessons` schema table instead of going through db/queries.ts, the module the file's own header comment declares as the single 1:1 wrapper boundary between the UI and the projection.. **442cd7f2 (S3-minor, code-quality)** `factory/orchestrator/src/daemon.ts` — daemon.ts defines its own readJsonFile() that silently swallows any parse/read error and returns null, diverging from the fail-closed readJsonFile() in cli.ts that throws on malformed JSON, so a corrupted daemon lock/status/memory file is treated as absent rather than reported..
|