@juzser/blacksmith 0.3.0 → 0.5.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 → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { isOperatorActor } from './actors.js';
|
|
1
2
|
import { SmithError } from './errors.js';
|
|
2
3
|
import { appendEvent, readLineageEvents } from './events.js';
|
|
3
|
-
import { listFindings, preWaiverStatus, staleFindings, transition, WAIVABLE_STATUSES, } from './findings.js';
|
|
4
|
+
import { computeFingerprint, foldFindings, listFindings, preWaiverStatus, staleFindings, transition, WAIVABLE_STATUSES, } from './findings.js';
|
|
4
5
|
export class WaiverError extends SmithError {
|
|
5
6
|
}
|
|
7
|
+
/** The event `approveTaskWaiver` writes and epic.ts's withGateEvidence reads. */
|
|
8
|
+
export const TASK_WAIVER_APPROVED_EVENT = 'task-waiver-approved';
|
|
6
9
|
/**
|
|
7
10
|
* Only S3/S4 findings are ever waived (severity.yml waiver_semantics) —
|
|
8
11
|
* single source of truth, reused by findings.ts's transition() so `→ waived`
|
|
@@ -10,6 +13,38 @@ export class WaiverError extends SmithError {
|
|
|
10
13
|
* call or via a waiver grant).
|
|
11
14
|
*/
|
|
12
15
|
export const WAIVABLE_SEVERITIES = ['S3-minor', 'S4-nit'];
|
|
16
|
+
/**
|
|
17
|
+
* Every fingerprint that names the same finding as `fingerprint`, for each
|
|
18
|
+
* candidate in `findings` that could stand in for it: its own stored value,
|
|
19
|
+
* and what computeFingerprint would produce today from its persisted
|
|
20
|
+
* file_path/finding_category/summary. Mirrors audit.ts's
|
|
21
|
+
* suppressedFingerprints, for the same reason (issue #178): a `finding-raised`
|
|
22
|
+
* or waiver-decision record written before the normalizer widened to also
|
|
23
|
+
* strip line spans ("lines 1549-1576") fingerprints differently than the
|
|
24
|
+
* identical text does now, so a stored fingerprint and a freshly computed one
|
|
25
|
+
* for the same defect can legitimately disagree.
|
|
26
|
+
*
|
|
27
|
+
* Always includes `fingerprint` itself, so a caller with no backing finding
|
|
28
|
+
* yet (e.g. a batch decision not folded into findings) still gets an
|
|
29
|
+
* exact-match-only set — unchanged behaviour.
|
|
30
|
+
*/
|
|
31
|
+
function fingerprintAliases(fingerprint, findings) {
|
|
32
|
+
const aliases = new Set([fingerprint]);
|
|
33
|
+
for (const finding of findings) {
|
|
34
|
+
if (finding.file_path === undefined)
|
|
35
|
+
continue;
|
|
36
|
+
const recomputed = computeFingerprint({
|
|
37
|
+
filePath: finding.file_path,
|
|
38
|
+
category: finding.finding_category,
|
|
39
|
+
summary: finding.summary,
|
|
40
|
+
});
|
|
41
|
+
if (finding.fingerprint === fingerprint || recomputed === fingerprint) {
|
|
42
|
+
aliases.add(finding.fingerprint);
|
|
43
|
+
aliases.add(recomputed);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return aliases;
|
|
47
|
+
}
|
|
13
48
|
/**
|
|
14
49
|
* Fold waiver-granted/waiver-denied events for one fingerprint: the last
|
|
15
50
|
* decision wins (a denial can, in principle, be revisited by a later grant
|
|
@@ -20,13 +55,19 @@ export const WAIVABLE_SEVERITIES = ['S3-minor', 'S4-nit'];
|
|
|
20
55
|
* would re-raise a finding the operator already waived the moment an epic
|
|
21
56
|
* continued in a new session. "Last decision wins" is decided on the merged
|
|
22
57
|
* order, which is `ts` between sessions and append order within one.
|
|
58
|
+
*
|
|
59
|
+
* Matches against `fingerprintAliases(fingerprint, ...)` rather than strict
|
|
60
|
+
* equality (issue #178): a decision recorded under the fingerprint's old
|
|
61
|
+
* spelling still answers a lookup under its current one, and vice versa.
|
|
23
62
|
*/
|
|
24
63
|
export async function isWaived(fingerprint, ref, opts = {}) {
|
|
25
64
|
const events = await readLineageEvents(ref.sessionId, opts);
|
|
65
|
+
const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
|
|
26
66
|
let waived = false;
|
|
27
67
|
for (const { record } of events) {
|
|
28
68
|
const payload = record.payload;
|
|
29
|
-
|
|
69
|
+
const fp = payload.fingerprint;
|
|
70
|
+
if (fp === undefined || !equivalents.has(fp))
|
|
30
71
|
continue;
|
|
31
72
|
if (record.event_type === 'waiver-granted')
|
|
32
73
|
waived = true;
|
|
@@ -38,13 +79,17 @@ export async function isWaived(fingerprint, ref, opts = {}) {
|
|
|
38
79
|
/**
|
|
39
80
|
* Has the operator recorded ANY waiver decision for this fingerprint (granted
|
|
40
81
|
* or denied)? Lineage-wide, for isWaived's reason (D-119): a decision recorded
|
|
41
|
-
* in the parent session is still a decision.
|
|
82
|
+
* in the parent session is still a decision. Same alias widening as isWaived,
|
|
83
|
+
* and for the same reason (issue #178).
|
|
42
84
|
*/
|
|
43
85
|
async function hasDecision(fingerprint, ref, opts) {
|
|
44
86
|
const events = await readLineageEvents(ref.sessionId, opts);
|
|
87
|
+
const equivalents = fingerprintAliases(fingerprint, foldFindings(events));
|
|
45
88
|
return events.some(({ record }) => {
|
|
46
89
|
const payload = record.payload;
|
|
47
|
-
|
|
90
|
+
const fp = payload.fingerprint;
|
|
91
|
+
return (fp !== undefined &&
|
|
92
|
+
equivalents.has(fp) &&
|
|
48
93
|
(record.event_type === 'waiver-granted' || record.event_type === 'waiver-denied'));
|
|
49
94
|
});
|
|
50
95
|
}
|
|
@@ -56,10 +101,16 @@ async function hasDecision(fingerprint, ref, opts) {
|
|
|
56
101
|
* finding as open forever. Findings the state machine can't legally move
|
|
57
102
|
* (already terminal, or a non-S3/S4 severity that somehow shares this
|
|
58
103
|
* fingerprint) are left alone; this is reconciliation, not a mandate.
|
|
104
|
+
*
|
|
105
|
+
* Matched via fingerprintAliases (issue #178), not strict equality: a grant
|
|
106
|
+
* against a finding's current fingerprint also reconciles a row for the
|
|
107
|
+
* identical defect still carrying the pre-widening fingerprint it was raised
|
|
108
|
+
* under, and vice versa.
|
|
59
109
|
*/
|
|
60
110
|
async function reconcileFindingsToWaived(fingerprint, waiverEventId, ctx, opts) {
|
|
61
111
|
const findings = await listFindings(ctx.sessionId, {}, opts);
|
|
62
|
-
const
|
|
112
|
+
const equivalents = fingerprintAliases(fingerprint, findings);
|
|
113
|
+
const reconcilable = findings.filter((f) => equivalents.has(f.fingerprint) &&
|
|
63
114
|
WAIVABLE_SEVERITIES.includes(f.severity) &&
|
|
64
115
|
WAIVABLE_STATUSES.includes(f.finding_status));
|
|
65
116
|
const transitionCtx = { ...ctx, causalParent: waiverEventId };
|
|
@@ -81,6 +132,33 @@ export async function grantWaiver(fingerprint, operatorNote, ctx, opts = {}) {
|
|
|
81
132
|
await reconcileFindingsToWaived(fingerprint, waiverEvent.event_id, ctx, opts);
|
|
82
133
|
return waiverEvent;
|
|
83
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Record the operator's approval of one task's waiver. A task reaches
|
|
137
|
+
* `waived` through its plan row, which carries no gate run; this event is the
|
|
138
|
+
* evidence the epic gate (epic.ts's withGateEvidence) looks for instead.
|
|
139
|
+
*
|
|
140
|
+
* Refused unless the actor is the operator (actors.ts) and a rationale is
|
|
141
|
+
* given: the approval is only worth anything as a person's recorded decision.
|
|
142
|
+
* The default actor is the one every other operator decision defaults to.
|
|
143
|
+
*/
|
|
144
|
+
export async function approveTaskWaiver(taskId, operatorNote, ctx, opts = {}) {
|
|
145
|
+
const actor = ctx.actor ?? 'user';
|
|
146
|
+
if (!isOperatorActor(actor)) {
|
|
147
|
+
throw new WaiverError('waivers.not-operator', `Actor "${actor}" cannot approve a task waiver — only the operator can. Pass --actor operator.`, { actor, taskId });
|
|
148
|
+
}
|
|
149
|
+
if (operatorNote.trim() === '') {
|
|
150
|
+
throw new WaiverError('waivers.note-required', `Approving the waiver of task "${taskId}" needs a short rationale (--note).`, { taskId });
|
|
151
|
+
}
|
|
152
|
+
return appendEvent({
|
|
153
|
+
session_id: ctx.sessionId,
|
|
154
|
+
actor,
|
|
155
|
+
event_type: TASK_WAIVER_APPROVED_EVENT,
|
|
156
|
+
task_id: taskId,
|
|
157
|
+
plan_version: ctx.planVersion,
|
|
158
|
+
causal_parent: ctx.causalParent,
|
|
159
|
+
payload: { operator_note: operatorNote },
|
|
160
|
+
}, opts);
|
|
161
|
+
}
|
|
84
162
|
/**
|
|
85
163
|
* The mirror of reconcileFindingsToWaived, and it exists for the same reason
|
|
86
164
|
* (D-180). A denial that follows a grant revokes it — isWaived() folds
|
|
@@ -92,11 +170,16 @@ export async function grantWaiver(fingerprint, operatorNote, ctx, opts = {}) {
|
|
|
92
170
|
*
|
|
93
171
|
* Only findings this session's log actually shows at `waived` are touched, and
|
|
94
172
|
* 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
|
|
173
|
+
* finding waived under some other fingerprint is not this denial's business —
|
|
174
|
+
* "other" meaning a different defect, not merely a different spelling: matched
|
|
175
|
+
* via fingerprintAliases (issue #178), the same widening reconcileFindingsToWaived
|
|
176
|
+
* applies, so a row still carrying the pre-widening fingerprint it was raised
|
|
177
|
+
* under reopens alongside its current-fingerprint sibling.
|
|
96
178
|
*/
|
|
97
179
|
async function reconcileFindingsFromWaived(fingerprint, denialEventId, ctx, opts) {
|
|
98
180
|
const findings = await listFindings(ctx.sessionId, {}, opts);
|
|
99
|
-
const
|
|
181
|
+
const equivalents = fingerprintAliases(fingerprint, findings);
|
|
182
|
+
const reopenable = findings.filter((f) => equivalents.has(f.fingerprint) && f.finding_status === 'waived');
|
|
100
183
|
const transitionCtx = { ...ctx, causalParent: denialEventId };
|
|
101
184
|
for (const finding of reopenable) {
|
|
102
185
|
const to = await preWaiverStatus(finding.finding_id, ctx.sessionId, opts);
|
|
@@ -203,7 +286,7 @@ export async function applyBatch(decisions, ctx, opts = {}) {
|
|
|
203
286
|
`"${evidence.mergedTaskId}" has merged over since the finding was raised (matched by ` +
|
|
204
287
|
`${evidence.basis}: ${evidence.matched}). Its evidence describes code that no longer ` +
|
|
205
288
|
'exists, so this waiver would answer a question about a deleted file. Re-read it first: ' +
|
|
206
|
-
`
|
|
289
|
+
`bs findings reverify --finding ${finding.finding_id}`, {
|
|
207
290
|
fingerprint: decision.fingerprint,
|
|
208
291
|
findingId: finding.finding_id,
|
|
209
292
|
filePath: evidence.filePath,
|
|
@@ -213,14 +296,18 @@ export async function applyBatch(decisions, ctx, opts = {}) {
|
|
|
213
296
|
}
|
|
214
297
|
}
|
|
215
298
|
}
|
|
216
|
-
const
|
|
299
|
+
const events = [];
|
|
300
|
+
const findingIdsToCarry = new Set();
|
|
217
301
|
for (const decision of decisions) {
|
|
218
302
|
if (decision.decision === 'granted') {
|
|
219
|
-
|
|
303
|
+
events.push(await grantWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
|
|
220
304
|
}
|
|
221
305
|
else {
|
|
222
|
-
|
|
306
|
+
events.push(await denyWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
|
|
307
|
+
for (const finding of findingsByFingerprint.get(decision.fingerprint) ?? []) {
|
|
308
|
+
findingIdsToCarry.add(finding.finding_id);
|
|
309
|
+
}
|
|
223
310
|
}
|
|
224
311
|
}
|
|
225
|
-
return
|
|
312
|
+
return { events, findingIdsToCarry: [...findingIdsToCarry] };
|
|
226
313
|
}
|
|
@@ -153,6 +153,7 @@ export function checkWaveBudget(events, policy, proposed, options) {
|
|
|
153
153
|
if (options.epicId.trim().length === 0) {
|
|
154
154
|
return {
|
|
155
155
|
epicId: null,
|
|
156
|
+
tier: policy.tier,
|
|
156
157
|
capTokens,
|
|
157
158
|
measuredTokens: 0,
|
|
158
159
|
projectedTokens: 0,
|
|
@@ -189,6 +190,7 @@ export function checkWaveBudget(events, policy, proposed, options) {
|
|
|
189
190
|
const inFlight = inFlightTasks(events, epicId).length;
|
|
190
191
|
const base = {
|
|
191
192
|
epicId,
|
|
193
|
+
tier: policy.tier,
|
|
192
194
|
capTokens,
|
|
193
195
|
measuredTokens,
|
|
194
196
|
projectedTokens,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { foldAgents } from './agents-registry.js';
|
|
1
|
+
import { DISPATCH_EVENT_TYPE, foldAgents } from './agents-registry.js';
|
|
2
|
+
import { eventTaskId } from './events.js';
|
|
3
|
+
import { epicOfTaskId, isQualifiedTaskId, taskIdsMatch } from './taskId.js';
|
|
2
4
|
/**
|
|
3
5
|
* Did the wave that was admitted N wide actually run N wide?
|
|
4
6
|
*
|
|
@@ -24,6 +26,7 @@ import { foldAgents } from './agents-registry.js';
|
|
|
24
26
|
* intervals open forever and score every wave after one as parallel.
|
|
25
27
|
*/
|
|
26
28
|
const ADMITTED_EVENT_TYPE = 'wave-admitted';
|
|
29
|
+
const MERGED_EVENT_TYPE = 'wave-merged';
|
|
27
30
|
/**
|
|
28
31
|
* What the log says a wave did.
|
|
29
32
|
*
|
|
@@ -33,12 +36,29 @@ const ADMITTED_EVENT_TYPE = 'wave-admitted';
|
|
|
33
36
|
* - `single` — the wave admitted one task. Nothing to be parallel about,
|
|
34
37
|
* and scoring it against a width it never claimed would make
|
|
35
38
|
* the failing verdicts unreadable.
|
|
39
|
+
* - `unlinked` — this fold's intervals hold nothing, but the lineage still
|
|
40
|
+
* has proof the work happened: a `wave-merged` for one of
|
|
41
|
+
* the admitted tasks, or a `dispatch_decision` parented
|
|
42
|
+
* directly on the admission. A wave-runner session started
|
|
43
|
+
* without `--continues` has `causal_parent: null` on its
|
|
44
|
+
* root, so its own dispatch_decisions never reach this fold
|
|
45
|
+
* even though its agents genuinely ran. Width is unmeasured
|
|
46
|
+
* here, not zero — a different fact from `unobserved`, which
|
|
47
|
+
* is exactly why it is not folded into it.
|
|
36
48
|
* - `unobserved` — the wave was admitted and the log records no work for any
|
|
37
|
-
* of its tasks
|
|
49
|
+
* of its tasks, and nothing else on the lineage says
|
|
50
|
+
* otherwise. Not the same fact as `serialized`, and the
|
|
38
51
|
* whole point of separating them: one says the factory ran
|
|
39
52
|
* narrow, the other says nobody can tell.
|
|
40
53
|
*/
|
|
41
|
-
export const WAVE_VERDICTS = [
|
|
54
|
+
export const WAVE_VERDICTS = [
|
|
55
|
+
'parallel',
|
|
56
|
+
'partial',
|
|
57
|
+
'serialized',
|
|
58
|
+
'single',
|
|
59
|
+
'unlinked',
|
|
60
|
+
'unobserved',
|
|
61
|
+
];
|
|
42
62
|
/**
|
|
43
63
|
* A counter with one bucket per key, all at zero.
|
|
44
64
|
*
|
|
@@ -61,6 +81,17 @@ export function zeroedCounts(keys) {
|
|
|
61
81
|
export const UNOBSERVED_HINT = 'A wave admitted with no dispatch_decision under any of its tasks ran nowhere this log can ' +
|
|
62
82
|
'see. Either the dispatcher never started it, or the agents ran outside the lineage that was ' +
|
|
63
83
|
'read — narrow with --epic, or check that the run wrote its dispatches to this state dir.';
|
|
84
|
+
/**
|
|
85
|
+
* Said when a wave came back `unlinked`: this fold has no dispatch interval
|
|
86
|
+
* for it, but the lineage holds a `wave-merged` for one of its tasks or a
|
|
87
|
+
* dispatch_decision parented on the admission itself, so the work is real —
|
|
88
|
+
* only its width is unmeasured. The usual cause is a wave-runner session
|
|
89
|
+
* started without `--continues`, which leaves its own dispatches outside the
|
|
90
|
+
* lineage this read walks even though they ran.
|
|
91
|
+
*/
|
|
92
|
+
export const UNLINKED_HINT = 'A wave admitted with merged work or a dispatch on record, but not inside this lineage, so ' +
|
|
93
|
+
'its width cannot be measured. Likely a wave-runner session started without --continues — ' +
|
|
94
|
+
'its dispatches are real but outside the chain this read walked.';
|
|
64
95
|
function readAdmission(event) {
|
|
65
96
|
const payload = event.record.payload;
|
|
66
97
|
const raw = payload.task_ids;
|
|
@@ -119,17 +150,109 @@ function peakOverlap(runs) {
|
|
|
119
150
|
}
|
|
120
151
|
return peak;
|
|
121
152
|
}
|
|
122
|
-
function verdictFor(declared, observed, peak) {
|
|
153
|
+
function verdictFor(declared, observed, peak, offLineageEvidence) {
|
|
123
154
|
if (declared <= 1)
|
|
124
155
|
return 'single';
|
|
125
|
-
if (observed === 0)
|
|
156
|
+
if (observed === 0) {
|
|
157
|
+
if (offLineageEvidence)
|
|
158
|
+
return 'unlinked';
|
|
126
159
|
return 'unobserved';
|
|
160
|
+
}
|
|
127
161
|
if (peak >= declared)
|
|
128
162
|
return 'parallel';
|
|
129
163
|
if (peak <= 1)
|
|
130
164
|
return 'serialized';
|
|
131
165
|
return 'partial';
|
|
132
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Whether `admission` is the one a `wave-merged` id names.
|
|
169
|
+
*
|
|
170
|
+
* A qualified id ("A/task-1") must match the admission's epic exactly — no
|
|
171
|
+
* falling back to the bare half, because that fallback is exactly how a bare
|
|
172
|
+
* merge under one epic used to mark an unrelated epic's qualified admission
|
|
173
|
+
* as having merge evidence. A bare id ("task-1") carries no epic of its own,
|
|
174
|
+
* so it may match any admission's declared task by its bare half; which
|
|
175
|
+
* admission it actually belongs to is for {@link latestMatchingAdmission} to
|
|
176
|
+
* decide, not this predicate.
|
|
177
|
+
*/
|
|
178
|
+
function admissionClaims(admission, mergedId) {
|
|
179
|
+
if (isQualifiedTaskId(mergedId) && epicOfTaskId(mergedId) !== admission.epicId)
|
|
180
|
+
return false;
|
|
181
|
+
return admission.taskIds.some((taskId) => taskIdsMatch(taskId, mergedId));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The one admission a `wave-merged` id belongs to: the latest whose `ts` is
|
|
185
|
+
* at or before the merge's `ts` and whose declared tasks match it — the same
|
|
186
|
+
* "nearest preceding admission" rule `admittedAtByTask` uses to close a
|
|
187
|
+
* task's window, reused here rather than re-derived, so a task re-admitted in
|
|
188
|
+
* a later wave sends its merge to that wave and not the one before it. A
|
|
189
|
+
* merge timestamped before every candidate admission belongs to none of them;
|
|
190
|
+
* returning null rather than the earliest is what keeps that merge from
|
|
191
|
+
* counting at all.
|
|
192
|
+
*/
|
|
193
|
+
function latestMatchingAdmission(mergedId, mergeTs, admissions) {
|
|
194
|
+
let best = null;
|
|
195
|
+
for (const admission of admissions) {
|
|
196
|
+
if (admission.ts > mergeTs)
|
|
197
|
+
continue;
|
|
198
|
+
if (!admissionClaims(admission, mergedId))
|
|
199
|
+
continue;
|
|
200
|
+
if (best === null || admission.ts > best.ts)
|
|
201
|
+
best = admission;
|
|
202
|
+
}
|
|
203
|
+
return best;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The `wave-admitted` event ids that own at least one `wave-merged` id,
|
|
207
|
+
* attributed one merge at a time over the whole log so each admission can ask
|
|
208
|
+
* "is my work here" without rescanning — and so an admission the epic filter
|
|
209
|
+
* excludes must not narrow what another admission can see.
|
|
210
|
+
*
|
|
211
|
+
* Both the envelope's `task_id` and the payload's `task_ids` are read,
|
|
212
|
+
* because `emitWaveMerged` writes the same id to both; either is enough.
|
|
213
|
+
*/
|
|
214
|
+
function admissionsWithMergeEvidence(events, admissions) {
|
|
215
|
+
const credited = new Set();
|
|
216
|
+
for (const event of events) {
|
|
217
|
+
if (event.record.event_type !== MERGED_EVENT_TYPE)
|
|
218
|
+
continue;
|
|
219
|
+
const ids = [];
|
|
220
|
+
const fromEnvelope = eventTaskId(event.record);
|
|
221
|
+
if (fromEnvelope)
|
|
222
|
+
ids.push(fromEnvelope);
|
|
223
|
+
const payload = event.record.payload;
|
|
224
|
+
if (Array.isArray(payload?.task_ids)) {
|
|
225
|
+
for (const id of payload.task_ids) {
|
|
226
|
+
if (typeof id === 'string' && id.length > 0)
|
|
227
|
+
ids.push(id);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const id of ids) {
|
|
231
|
+
const owner = latestMatchingAdmission(id, event.record.ts, admissions);
|
|
232
|
+
if (owner)
|
|
233
|
+
credited.add(owner.eventId);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return credited;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The `wave-admitted` event ids that a `dispatch_decision` names as its
|
|
240
|
+
* direct causal parent — the shape a wave-runner's own agent takes when it is
|
|
241
|
+
* dispatched straight off the admission rather than through a task. Read from
|
|
242
|
+
* raw events rather than `foldAgents`, because that fold only keeps agents
|
|
243
|
+
* with a task id, and this dispatch has none.
|
|
244
|
+
*/
|
|
245
|
+
function dispatchedDirectlyOn(events) {
|
|
246
|
+
const parents = new Set();
|
|
247
|
+
for (const event of events) {
|
|
248
|
+
if (event.record.event_type !== DISPATCH_EVENT_TYPE)
|
|
249
|
+
continue;
|
|
250
|
+
const parent = event.record.causal_parent;
|
|
251
|
+
if (typeof parent === 'string' && parent.length > 0)
|
|
252
|
+
parents.add(parent);
|
|
253
|
+
}
|
|
254
|
+
return parents;
|
|
255
|
+
}
|
|
133
256
|
/**
|
|
134
257
|
* Fold one task's agents into the interval it was worked on. Every role counts,
|
|
135
258
|
* not just the coder's: a task whose reviewer is still running is still work
|
|
@@ -199,6 +322,14 @@ export function auditWaveConcurrency(events, options = {}) {
|
|
|
199
322
|
else
|
|
200
323
|
agentsByTask.set(agent.taskId, [agent]);
|
|
201
324
|
}
|
|
325
|
+
// Also built from every event, unfiltered: off-lineage evidence for a wave
|
|
326
|
+
// the caller narrowed away must not leak in, but an admission's own window
|
|
327
|
+
// must see all of it regardless of which epic the caller asked for. Merge
|
|
328
|
+
// attribution needs every admission as a candidate, not just the ones the
|
|
329
|
+
// caller's epic filter kept — the same reason `admissions` above is built
|
|
330
|
+
// before that filter is applied.
|
|
331
|
+
const mergeCredited = admissionsWithMergeEvidence(events, admissions);
|
|
332
|
+
const directDispatchParents = dispatchedDirectlyOn(events);
|
|
202
333
|
const waves = [];
|
|
203
334
|
for (const admission of admissions) {
|
|
204
335
|
if (options.epicId !== undefined && admission.epicId !== options.epicId)
|
|
@@ -219,6 +350,7 @@ export function auditWaveConcurrency(events, options = {}) {
|
|
|
219
350
|
}
|
|
220
351
|
observed.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
221
352
|
const peak = peakOverlap(observed);
|
|
353
|
+
const offLineageEvidence = directDispatchParents.has(admission.eventId) || mergeCredited.has(admission.eventId);
|
|
222
354
|
waves.push({
|
|
223
355
|
eventId: admission.eventId,
|
|
224
356
|
admittedAt: admission.ts,
|
|
@@ -227,7 +359,7 @@ export function auditWaveConcurrency(events, options = {}) {
|
|
|
227
359
|
observed,
|
|
228
360
|
unobserved,
|
|
229
361
|
peak,
|
|
230
|
-
verdict: verdictFor(admission.taskIds.length, observed.length, peak),
|
|
362
|
+
verdict: verdictFor(admission.taskIds.length, observed.length, peak, offLineageEvidence),
|
|
231
363
|
});
|
|
232
364
|
}
|
|
233
365
|
return waves;
|
|
@@ -264,16 +396,20 @@ function labelsWith(waves, verdict) {
|
|
|
264
396
|
export function summariseWaveConcurrency(waves) {
|
|
265
397
|
const serialized = labelsWith(waves, 'serialized');
|
|
266
398
|
const unobserved = labelsWith(waves, 'unobserved');
|
|
399
|
+
const unlinked = labelsWith(waves, 'unlinked');
|
|
267
400
|
return {
|
|
268
401
|
waves: [...waves],
|
|
269
402
|
serialized,
|
|
270
403
|
partial: labelsWith(waves, 'partial'),
|
|
271
404
|
unobserved,
|
|
405
|
+
unlinked,
|
|
272
406
|
widest: {
|
|
273
407
|
declared: waves.reduce((max, wave) => Math.max(max, wave.declared.length), 0),
|
|
274
408
|
observed: waves.reduce((max, wave) => Math.max(max, wave.peak), 0),
|
|
275
409
|
},
|
|
276
|
-
hint: unobserved.length > 0 ? UNOBSERVED_HINT : '',
|
|
410
|
+
hint: unobserved.length > 0 ? UNOBSERVED_HINT : unlinked.length > 0 ? UNLINKED_HINT : '',
|
|
411
|
+
// `unlinked` is real work off-lineage, not a failure to judge, so it never
|
|
412
|
+
// worsens the exit code the way `unobserved` does.
|
|
277
413
|
exitCode: serialized.length > 0 ? 1 : unobserved.length > 0 ? 2 : 0,
|
|
278
414
|
};
|
|
279
415
|
}
|
|
@@ -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
|
+
}
|
|
@@ -37,7 +37,7 @@ const WORK_DIRS = [
|
|
|
37
37
|
* the roadmap and the stack answers are declarations the operator made, and a
|
|
38
38
|
* team sharing the repository should share them.
|
|
39
39
|
*/
|
|
40
|
-
const WORK_ROOT_GITIGNORE = `# Written by \`
|
|
40
|
+
const WORK_ROOT_GITIGNORE = `# Written by \`bs init\`. Blacksmith's state is a cache of its event log
|
|
41
41
|
# and belongs to this machine; the answers under factory/ are declarations
|
|
42
42
|
# this project made, so they are tracked.
|
|
43
43
|
state/
|