@juzser/blacksmith 0.1.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.
Files changed (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,250 @@
1
+ import { foldAgents } from './agents-registry.js';
2
+ /**
3
+ * Did the wave that was admitted N wide actually run N wide?
4
+ *
5
+ * `wave next` computes the widest wave a plan admits and `wave check` admits
6
+ * one. Both are statements about what MAY happen next: the first proposes, the
7
+ * second permits, and neither has any way of knowing what the dispatcher did
8
+ * afterwards. So a factory whose whole premise is "many subagents running the
9
+ * plan's tasks in parallel" could serialise every wave it ever admitted and
10
+ * say nothing about it — the admission would still be in the log, four ids
11
+ * wide, indistinguishable from a wave that genuinely ran four at once.
12
+ *
13
+ * That is the declarations-vs-state split this repo keeps drawing (AGENTS.md):
14
+ * `wave-admitted` is a declaration, and the only state that can answer it is
15
+ * when each agent was actually live. Both halves were already in the log —
16
+ * `dispatch_decision` opens an agent and its terminal event closes one, and
17
+ * agents-registry.ts already folds exactly that pair into intervals. So this
18
+ * is a fold over facts that were always there, not a new thing to record.
19
+ *
20
+ * It reuses `foldAgents` rather than reading `dispatch_decision` directly for
21
+ * the reason that fold documents (D-161): a dispatch superseded by a
22
+ * redispatch, or abandoned at `epic-closed`, has an end even though no
23
+ * terminal event names it, and reading the raw dispatches would leave those
24
+ * intervals open forever and score every wave after one as parallel.
25
+ */
26
+ const ADMITTED_EVENT_TYPE = 'wave-admitted';
27
+ /**
28
+ * Said when a wave was admitted and no agent was ever dispatched for it. The
29
+ * two readings are genuinely different problems and the operator has to pick
30
+ * between them by hand, so the hint names both rather than choosing.
31
+ */
32
+ export const UNOBSERVED_HINT = 'A wave admitted with no dispatch_decision under any of its tasks ran nowhere this log can ' +
33
+ 'see. Either the dispatcher never started it, or the agents ran outside the lineage that was ' +
34
+ 'read — narrow with --epic, or check that the run wrote its dispatches to this state dir.';
35
+ function readAdmission(event) {
36
+ const payload = event.record.payload;
37
+ const raw = payload.task_ids;
38
+ const taskIds = Array.isArray(raw) && raw.every((id) => typeof id === 'string' && id.length > 0)
39
+ ? raw
40
+ : null;
41
+ if (!taskIds || taskIds.length === 0) {
42
+ // Refused rather than read as a zero-wide wave: a malformed admission that
43
+ // audited clean is exactly the silence this command exists to end.
44
+ throw new Error(`wave-concurrency.missing-task-ids: wave-admitted "${event.event_id}" names no tasks`);
45
+ }
46
+ return {
47
+ eventId: event.event_id,
48
+ ts: event.record.ts,
49
+ // Never derived from the task ids. `emitWaveAdmitted` always writes this
50
+ // field, so an admission without one was hand-authored, and inventing the
51
+ // epic it probably meant would put a guess where the log has a gap.
52
+ epicId: typeof payload.epic_id === 'string' && payload.epic_id ? payload.epic_id : null,
53
+ taskIds,
54
+ };
55
+ }
56
+ /** Milliseconds, with an unclosed agent reaching forward without bound. */
57
+ function endMs(endedAt) {
58
+ return endedAt === null ? Number.POSITIVE_INFINITY : Date.parse(endedAt);
59
+ }
60
+ /**
61
+ * The most intervals overlapping at one instant. Ends are processed before
62
+ * starts at the same timestamp, so a task that finishes exactly as the next
63
+ * one begins counts as a handoff and not as concurrency — without that, a
64
+ * dispatcher running strictly one task at a time would score `parallel` on
65
+ * nothing but the clock's granularity.
66
+ *
67
+ * Times are doubled first so that a run whose dispatch and terminal share a
68
+ * millisecond still gets a width of one tick instead of none. On the raw axis
69
+ * such a run is an empty interval, overlapping nothing — not even itself —
70
+ * and two admitted tasks would come back as a peak of zero, which is a claim
71
+ * about the clock dressed up as a claim about the factory.
72
+ */
73
+ function peakOverlap(runs) {
74
+ const points = runs.flatMap((run) => {
75
+ const start = Date.parse(run.startedAt) * 2;
76
+ return [
77
+ { t: start, d: 1 },
78
+ { t: Math.max(endMs(run.endedAt) * 2, start + 1), d: -1 },
79
+ ];
80
+ });
81
+ // Subtraction would give NaN for two unclosed agents (Infinity - Infinity),
82
+ // and a NaN comparator silently leaves the array unsorted.
83
+ points.sort((a, b) => (a.t === b.t ? a.d - b.d : a.t < b.t ? -1 : 1));
84
+ let live = 0;
85
+ let peak = 0;
86
+ for (const point of points) {
87
+ live += point.d;
88
+ if (live > peak)
89
+ peak = live;
90
+ }
91
+ return peak;
92
+ }
93
+ function verdictFor(declared, observed, peak) {
94
+ if (declared <= 1)
95
+ return 'single';
96
+ if (observed === 0)
97
+ return 'unobserved';
98
+ if (peak >= declared)
99
+ return 'parallel';
100
+ if (peak <= 1)
101
+ return 'serialized';
102
+ return 'partial';
103
+ }
104
+ /**
105
+ * Fold one task's agents into the interval it was worked on. Every role counts,
106
+ * not just the coder's: a task whose reviewer is still running is still work
107
+ * in progress, and a wave holding it has not narrowed yet.
108
+ */
109
+ function runFor(taskId, agents) {
110
+ if (agents.length === 0)
111
+ return null;
112
+ const ordered = [...agents].sort((a, b) => a.dispatchedAt.localeCompare(b.dispatchedAt));
113
+ const startedAt = ordered[0]?.dispatchedAt;
114
+ let endedAt = null;
115
+ for (const agent of ordered) {
116
+ // One unclosed agent leaves the whole task open — the task is only done
117
+ // being worked on when every role on it is.
118
+ if (agent.terminalAt === null)
119
+ return { taskId, startedAt, endedAt: null, roles: rolesOf(ordered) };
120
+ if (endedAt === null || agent.terminalAt > endedAt)
121
+ endedAt = agent.terminalAt;
122
+ }
123
+ return { taskId, startedAt, endedAt, roles: rolesOf(ordered) };
124
+ }
125
+ function rolesOf(ordered) {
126
+ const seen = new Set();
127
+ const roles = [];
128
+ for (const agent of ordered) {
129
+ if (seen.has(agent.agentRole))
130
+ continue;
131
+ seen.add(agent.agentRole);
132
+ roles.push(agent.agentRole);
133
+ }
134
+ return roles;
135
+ }
136
+ /**
137
+ * Every wave in the log, scored against what the log says it did.
138
+ *
139
+ * A task can be admitted more than once — a follow-up, a redispatch after a
140
+ * bounce — so each wave owns its tasks' agents only until the admission that
141
+ * re-opens them. Without that window, a later wave that genuinely ran wide
142
+ * would retro-fit its parallelism onto the earlier serial one that admitted
143
+ * the same task, and the command would report the opposite of the truth.
144
+ */
145
+ export function auditWaveConcurrency(events, options = {}) {
146
+ const admissions = events
147
+ .filter((event) => event.record.event_type === ADMITTED_EVENT_TYPE)
148
+ .map(readAdmission)
149
+ .sort((a, b) => (a.ts === b.ts ? a.eventId.localeCompare(b.eventId) : a.ts < b.ts ? -1 : 1));
150
+ // Built from every admission, before the epic filter: a window closed by an
151
+ // admission the caller filtered out is still closed, and asking about one
152
+ // epic must not widen another epic's windows.
153
+ const admittedAtByTask = new Map();
154
+ for (const admission of admissions) {
155
+ for (const taskId of admission.taskIds) {
156
+ const list = admittedAtByTask.get(taskId);
157
+ if (list)
158
+ list.push(admission.ts);
159
+ else
160
+ admittedAtByTask.set(taskId, [admission.ts]);
161
+ }
162
+ }
163
+ const agentsByTask = new Map();
164
+ for (const agent of foldAgents(events)) {
165
+ if (!agent.taskId)
166
+ continue;
167
+ const list = agentsByTask.get(agent.taskId);
168
+ if (list)
169
+ list.push(agent);
170
+ else
171
+ agentsByTask.set(agent.taskId, [agent]);
172
+ }
173
+ const waves = [];
174
+ for (const admission of admissions) {
175
+ if (options.epicId !== undefined && admission.epicId !== options.epicId)
176
+ continue;
177
+ const observed = [];
178
+ const unobserved = [];
179
+ for (const taskId of admission.taskIds) {
180
+ const closesAt = (admittedAtByTask.get(taskId) ?? []).find((ts) => ts > admission.ts);
181
+ const run = runFor(taskId, (agentsByTask.get(taskId) ?? []).filter((agent) =>
182
+ // A run already under way when the wave was cut is not this wave's
183
+ // to claim; a run after the next admission belongs to that one.
184
+ agent.dispatchedAt >= admission.ts &&
185
+ (closesAt === undefined || agent.dispatchedAt < closesAt)));
186
+ if (run)
187
+ observed.push(run);
188
+ else
189
+ unobserved.push(taskId);
190
+ }
191
+ observed.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
192
+ const peak = peakOverlap(observed);
193
+ waves.push({
194
+ eventId: admission.eventId,
195
+ admittedAt: admission.ts,
196
+ epicId: admission.epicId,
197
+ declared: admission.taskIds,
198
+ observed,
199
+ unobserved,
200
+ peak,
201
+ verdict: verdictFor(admission.taskIds.length, observed.length, peak),
202
+ });
203
+ }
204
+ return waves;
205
+ }
206
+ /** The handle an operator has on a wave: its epic, or its own id when it named none. */
207
+ function labelOf(wave) {
208
+ return wave.epicId ?? wave.eventId;
209
+ }
210
+ function labelsWith(waves, verdict) {
211
+ const labels = [];
212
+ for (const wave of waves) {
213
+ if (wave.verdict !== verdict)
214
+ continue;
215
+ const label = labelOf(wave);
216
+ // Once per epic, not once per wave: an epic that serialised every wave it
217
+ // ever cut has one problem, and listing it eight times reads as eight.
218
+ if (!labels.includes(label))
219
+ labels.push(label);
220
+ }
221
+ return labels;
222
+ }
223
+ /**
224
+ * Score the audit.
225
+ *
226
+ * Exit 1 is `serialized` alone. A `partial` wave — three admitted, two in
227
+ * flight — is the factory working and is reported rather than failed: an exit
228
+ * code that cried about it would be routed to /dev/null inside a week, and
229
+ * then the serialized one would go with it.
230
+ *
231
+ * Exit 2 is the same shape `judge escalations` uses: nothing was judged. A
232
+ * wave the log holds no dispatch for is not a passing wave, and returning 0
233
+ * for it would make an empty state dir look like a healthy factory.
234
+ */
235
+ export function summariseWaveConcurrency(waves) {
236
+ const serialized = labelsWith(waves, 'serialized');
237
+ const unobserved = labelsWith(waves, 'unobserved');
238
+ return {
239
+ waves: [...waves],
240
+ serialized,
241
+ partial: labelsWith(waves, 'partial'),
242
+ unobserved,
243
+ widest: {
244
+ declared: waves.reduce((max, wave) => Math.max(max, wave.declared.length), 0),
245
+ observed: waves.reduce((max, wave) => Math.max(max, wave.peak), 0),
246
+ },
247
+ hint: unobserved.length > 0 ? UNOBSERVED_HINT : '',
248
+ exitCode: serialized.length > 0 ? 1 : unobserved.length > 0 ? 2 : 0,
249
+ };
250
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The widest wave the graph allows, computed instead of guessed.
3
+ *
4
+ * `wave check` answers a closed question — may *these* task ids run together
5
+ * — and answers it well. But it only ever sees a set someone already picked,
6
+ * and the safest set to pick is always a set of one: a single task is
7
+ * pairwise-disjoint with nothing, shares no hotspot with nothing, and crosses
8
+ * no import edge with nothing, so it passes every gate in the file. The gate
9
+ * cannot tell a deliberate wave of one from an orchestrator that never
10
+ * thought to ask for more, and both come back `valid: true`. A factory whose
11
+ * whole claim is parallel execution therefore had no command that computes
12
+ * parallelism; it had a command that declines to forbid it.
13
+ *
14
+ * `waveLayers` in graph.ts already bands a plan by longest-path depth, and
15
+ * its docblock says the band means "these can run in parallel right now" —
16
+ * but nothing outside the Flow page's layout ever imported it, so the one
17
+ * function that knew the answer only ever drew it.
18
+ *
19
+ * This module closes that gap from the other end: given a plan, the live
20
+ * status of its tasks and the import-graph crossings between them, it
21
+ * returns the largest set that `wave check` would admit, plus a named reason
22
+ * for every task it left out. It is pure and it writes nothing. Admission
23
+ * stays where it was — `wave check` remains the single command that emits
24
+ * `wave-admitted`, so a proposal here still has to survive the gate that
25
+ * enforces it, and a bug in this file costs a rejected proposal rather than
26
+ * two agents in one worktree.
27
+ */
28
+ import { claimsOverlap, readClaimList, readEdgeList, touchesSerializeAlways, } from './claims.js';
29
+ import { topoSort } from './graph.js';
30
+ import { livePlanTasks } from './plan.js';
31
+ /**
32
+ * A task is *done* only at the two statuses epic.ts already calls terminal —
33
+ * `completed` and `waived`. Everything else non-terminal still holds a
34
+ * worktree, so its claims are still spoken for even when nobody is typing in
35
+ * it: `blocked`, `failed` and `escalated` are waiting on a person, not on
36
+ * nothing, and their uncommitted work is exactly what a second agent in the
37
+ * same paths would destroy.
38
+ */
39
+ const TERMINAL_TASK_STATUSES = new Set(['completed', 'waived']);
40
+ /** The two statuses that mean "nothing has been dispatched for this yet". */
41
+ const CANDIDATE_TASK_STATUSES = new Set(['todo', 'ready']);
42
+ function nameList(ids) {
43
+ return ids.join(', ');
44
+ }
45
+ /**
46
+ * Order the candidates so that when two of them are coupled, the one that
47
+ * must land first is the one offered admission. Plan edges and import
48
+ * crossings are the same shape of constraint here — "this one after that
49
+ * one" — so both feed one sort, and a coupled pair resolves the way the
50
+ * playbook already resolves it by hand: run the producer first.
51
+ *
52
+ * A cycle among the crossings is not this function's to diagnose (plan.ts
53
+ * rejects cyclic plan edges; import cycles are legal TypeScript), so it falls
54
+ * back to lexicographic order — an arbitrary but stable pick, which still
55
+ * admits one of the pair and defers the other with a reason.
56
+ */
57
+ function admissionOrder(candidates, edges, crossings) {
58
+ const byId = new Map(candidates.map((t) => [t.task_id, t]));
59
+ const ids = [...byId.keys()].sort();
60
+ const ordering = [
61
+ ...edges,
62
+ ...crossings.map((c) => ({ task: c.consumer, dependsOn: c.producer })),
63
+ ];
64
+ const sorted = topoSort(ids, ordering);
65
+ const order = sorted.ok ? sorted.order : ids;
66
+ return order.flatMap((id) => {
67
+ const task = byId.get(id);
68
+ return task ? [task] : []; // provably total: topoSort returns only ids it was given
69
+ });
70
+ }
71
+ /**
72
+ * Every live task in the plan, with its claims read as a list of globs.
73
+ *
74
+ * Exists because a caller has to hand `waveImpact` a readable claim set
75
+ * before `computeNextWave` runs, and there must not be a second, laxer way to
76
+ * obtain one. `readClaimList` is the door `wave check` refuses through; a CLI
77
+ * that reached past it — `(t.claims ?? []) as string[]` is the obvious
78
+ * shortcut — would hand the symbol graph a claims string to iterate by
79
+ * character, and a task claiming nothing crosses nobody. The wave would then
80
+ * be admitted for the one reason it must not be: the check could not read it.
81
+ *
82
+ * Terminal tasks are kept. The graph is built over the whole live plan
83
+ * because a crossing with an in-flight task is exactly the crossing that has
84
+ * to defer a candidate, and a caller cannot know which tasks are in flight
85
+ * until `computeNextWave` has partitioned them by status.
86
+ */
87
+ export function liveWaveTasks(plan) {
88
+ return livePlanTasks(plan).map((record) => readClaimList({
89
+ task_id: record.task_id,
90
+ claims: record.claims,
91
+ }));
92
+ }
93
+ export function computeNextWave(input) {
94
+ const { plan, policy } = input;
95
+ const crossings = input.crossings ?? [];
96
+ const edges = readEdgeList(plan.edges);
97
+ const done = [];
98
+ const occupied = [];
99
+ const occupiedTasks = [];
100
+ const candidates = [];
101
+ for (const record of livePlanTasks(plan)) {
102
+ const id = record.task_id;
103
+ const status = input.statusById?.get(id) ?? String(record.task_status);
104
+ if (TERMINAL_TASK_STATUSES.has(status)) {
105
+ done.push(id);
106
+ continue;
107
+ }
108
+ // Past this door claims are a real string[], for a candidate and for a
109
+ // task merely holding ground — the wave is unanswerable either way.
110
+ const task = readClaimList({ task_id: id, claims: record.claims });
111
+ if (CANDIDATE_TASK_STATUSES.has(status)) {
112
+ candidates.push(task);
113
+ }
114
+ else {
115
+ occupied.push(id);
116
+ occupiedTasks.push(task);
117
+ }
118
+ }
119
+ done.sort();
120
+ occupied.sort();
121
+ const doneSet = new Set(done);
122
+ const dependsByTask = new Map();
123
+ for (const edge of edges) {
124
+ const list = dependsByTask.get(edge.task) ?? [];
125
+ list.push(edge.dependsOn);
126
+ dependsByTask.set(edge.task, list);
127
+ }
128
+ const coupledWith = new Map();
129
+ const link = (a, b, role) => {
130
+ const map = coupledWith.get(a) ?? new Map();
131
+ map.set(b, role);
132
+ coupledWith.set(a, map);
133
+ };
134
+ for (const crossing of crossings) {
135
+ link(crossing.consumer, crossing.producer, 'consumes');
136
+ link(crossing.producer, crossing.consumer, 'produces');
137
+ }
138
+ const admitted = [];
139
+ const deferred = [];
140
+ const defer = (taskId, reason, blockedBy, detail) => {
141
+ deferred.push({ taskId, reason, blockedBy: [...blockedBy].sort(), detail });
142
+ };
143
+ for (const task of admissionOrder(candidates, edges, crossings)) {
144
+ const id = task.task_id;
145
+ const pending = [...new Set(dependsByTask.get(id) ?? [])].filter((d) => !doneSet.has(d)).sort();
146
+ if (pending.length > 0) {
147
+ defer(id, 'dependency-pending', pending, `Depends on ${nameList(pending)}, which ${pending.length === 1 ? 'is' : 'are'} not terminal.`);
148
+ continue;
149
+ }
150
+ // Occupied tasks hold their claims for the same reason admitted ones do:
151
+ // a worktree exists and its edits are not merged.
152
+ const holders = [...admitted, ...occupiedTasks];
153
+ const overlapping = [];
154
+ let overlapDetail = '';
155
+ for (const holder of holders) {
156
+ const overlap = claimsOverlap(task, holder);
157
+ if (!overlap.overlaps)
158
+ continue;
159
+ overlapping.push(holder.task_id);
160
+ const pair = overlap.offendingGlobs[0];
161
+ if (overlapDetail === '' && pair !== undefined) {
162
+ overlapDetail = `Claims overlap ${holder.task_id} (${pair.globA} vs ${pair.globB}).`;
163
+ }
164
+ }
165
+ if (overlapping.length > 0) {
166
+ defer(id, 'claim-overlap', overlapping, overlapDetail);
167
+ continue;
168
+ }
169
+ const hotspots = touchesSerializeAlways(task, policy.serializeAlwaysGlobs);
170
+ const sharing = [];
171
+ let hotspotDetail = '';
172
+ if (hotspots.length > 0) {
173
+ for (const holder of holders) {
174
+ const glob = touchesSerializeAlways(holder, hotspots)[0];
175
+ if (glob === undefined)
176
+ continue;
177
+ sharing.push(holder.task_id);
178
+ if (hotspotDetail === '') {
179
+ hotspotDetail =
180
+ `Shares the serialize-always glob ${glob} with ${holder.task_id}, ` +
181
+ 'which worktree.yml never schedules concurrently.';
182
+ }
183
+ }
184
+ }
185
+ if (sharing.length > 0) {
186
+ defer(id, 'serialize-hotspot', sharing, hotspotDetail);
187
+ continue;
188
+ }
189
+ const couplings = coupledWith.get(id);
190
+ const coupled = couplings ? holders.filter((h) => couplings.has(h.task_id)) : [];
191
+ const blocker = coupled[0];
192
+ if (couplings !== undefined && blocker !== undefined) {
193
+ const detail = couplings.get(blocker.task_id) === 'consumes'
194
+ ? `Imports symbols from ${blocker.task_id}, which has not merged: the producer runs first.`
195
+ : `${blocker.task_id} imports symbols from it and is already scheduled, so this one follows.`;
196
+ defer(id, 'symbol-coupled', coupled.map((h) => h.task_id), detail);
197
+ continue;
198
+ }
199
+ admitted.push(task);
200
+ }
201
+ deferred.sort((a, b) => a.taskId.localeCompare(b.taskId));
202
+ return {
203
+ epicId: plan.epic_id,
204
+ wave: admitted.map((t) => t.task_id),
205
+ deferred,
206
+ done,
207
+ occupied,
208
+ remaining: deferred.length + occupied.length,
209
+ };
210
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * How wide this plan can *ever* run, answered before an agent is dispatched.
3
+ *
4
+ * The factory now brackets parallelism from two sides and neither one reaches
5
+ * the plan. `wave check` certifies that a set someone already picked *could*
6
+ * run together; `wave audit` reads the log afterwards and reports whether it
7
+ * *did*. Both take the plan as given. But a plan whose tasks all claim
8
+ * overlapping globs has a ceiling of one no matter how many agents are
9
+ * available, and a planner that sliced it that way gets the same silent
10
+ * `valid: true` from every gate downstream — each wave of one is admitted,
11
+ * each run of one is faithful to its admission, and the epic serializes with
12
+ * nothing anywhere reporting a problem. The cost is paid at plan time and
13
+ * every check that could have named it runs too late to matter.
14
+ *
15
+ * So this module replays the dispatcher against the plan. It calls the same
16
+ * pure `computeNextWave` the real wave loop calls, marks the returned wave
17
+ * terminal, and calls it again until nothing more can start. What comes back
18
+ * is the number of rounds the plan needs and the widest round it ever gets --
19
+ * computed by the containment code itself, so the ceiling reported here is
20
+ * the ceiling the dispatcher will actually hit, not a second opinion about
21
+ * it that can drift.
22
+ *
23
+ * The one distinction that makes the output actionable: a task deferred
24
+ * `dependency-pending` is waiting on work it genuinely needs, and that is the
25
+ * shape of the problem, not a defect — a chain of five real dependencies
26
+ * takes five rounds and no re-slicing changes it. A task deferred for any
27
+ * other reason had its dependencies satisfied, was ready to run that round,
28
+ * and was held back purely by how the planner drew the claims. Only the
29
+ * second kind is anybody's to fix, so only the second kind is reported as a
30
+ * constraint.
31
+ *
32
+ * What this deliberately does not do is claim a counterfactual. It never says
33
+ * "re-slice these claims and the plan runs in three rounds instead of five",
34
+ * because knowing that would mean inventing the claim geometry the planner
35
+ * would have written instead, and the dependency graph may simply bind next.
36
+ * It names which tasks were held back, by whom, and in which round. Deciding
37
+ * whether that is worth re-planning is the operator's, and the numbers are
38
+ * there to decide on.
39
+ *
40
+ * Pure, and it writes nothing. Like `waveNext`, admission stays where it was:
41
+ * a round simulated here has been admitted by nobody.
42
+ */
43
+ import { computeNextWave, } from './waveNext.js';
44
+ /**
45
+ * Reported in this order, which is the order `computeNextWave` tests them in.
46
+ * `dependency-pending` is absent on purpose — it is the one reason that is
47
+ * never a finding.
48
+ */
49
+ const AVOIDABLE_REASONS = [
50
+ 'claim-overlap',
51
+ 'serialize-hotspot',
52
+ 'symbol-coupled',
53
+ ];
54
+ function nameList(ids) {
55
+ return ids.join(', ');
56
+ }
57
+ function buildHint(stalled, occupied, constraints, depth) {
58
+ if (stalled.length > 0) {
59
+ const held = constraints.flatMap((c) => c.blockedBy).filter((id) => occupied.includes(id));
60
+ const because = held.length > 0
61
+ ? ` ${nameList([...new Set(held)].sort())} still holds claims and is waiting on a person, not on the schedule.`
62
+ : '';
63
+ return (`The schedule stalls: ${nameList(stalled)} can never start, ` +
64
+ `so ${stalled.length === 1 ? 'that task' : 'those tasks'} will not run as this plan is written.${because}`);
65
+ }
66
+ if (constraints.length > 0) {
67
+ const reasons = constraints.map((c) => c.reason);
68
+ return (`This plan runs in ${depth} ${depth === 1 ? 'round' : 'rounds'}, and some of that ` +
69
+ `depth is claim geometry rather than dependencies (${nameList(reasons)}). ` +
70
+ 'Re-slicing the claims may widen it; whether it does depends on the dependency graph, ' +
71
+ 'which this command does not speculate about.');
72
+ }
73
+ return '';
74
+ }
75
+ /**
76
+ * Replay the wave loop against a plan until nothing more can start.
77
+ *
78
+ * Termination is structural, not capped: every iteration either admits at
79
+ * least one task — which is then marked terminal, strictly shrinking the
80
+ * candidate set — or admits none, and the loop ends. So it runs at most
81
+ * once per task plus the final unproductive round that proves there is
82
+ * nothing left.
83
+ */
84
+ export function scheduleWaves(input) {
85
+ // A copy, because a caller's status register is theirs and this simulation
86
+ // completes tasks that have not been dispatched.
87
+ const statusById = new Map(input.statusById ?? []);
88
+ const rounds = [];
89
+ const accumulators = new Map();
90
+ let epicId = input.plan.epic_id;
91
+ let stalled = [];
92
+ let occupied = [];
93
+ for (;;) {
94
+ const result = computeNextWave({ ...input, statusById });
95
+ epicId = result.epicId;
96
+ occupied = result.occupied;
97
+ const avoidable = result.deferred.filter((d) => d.reason !== 'dependency-pending');
98
+ for (const deferral of avoidable) {
99
+ const acc = accumulators.get(deferral.reason) ?? {
100
+ tasks: new Set(),
101
+ blockedBy: new Set(),
102
+ detail: deferral.detail,
103
+ };
104
+ acc.tasks.add(deferral.taskId);
105
+ for (const holder of deferral.blockedBy)
106
+ acc.blockedBy.add(holder);
107
+ accumulators.set(deferral.reason, acc);
108
+ }
109
+ if (result.wave.length === 0) {
110
+ // The round that starts nothing is the round that names the stall:
111
+ // anything still deferred here is a candidate no later round reaches.
112
+ stalled = result.deferred.map((d) => d.taskId).sort();
113
+ break;
114
+ }
115
+ rounds.push({ round: rounds.length + 1, tasks: result.wave, avoidable });
116
+ for (const taskId of result.wave)
117
+ statusById.set(taskId, 'completed');
118
+ }
119
+ const constraints = AVOIDABLE_REASONS.flatMap((reason) => {
120
+ const acc = accumulators.get(reason);
121
+ if (acc === undefined)
122
+ return [];
123
+ return [
124
+ {
125
+ reason,
126
+ tasks: [...acc.tasks].sort(),
127
+ blockedBy: [...acc.blockedBy].sort(),
128
+ detail: acc.detail,
129
+ },
130
+ ];
131
+ });
132
+ const depth = rounds.length;
133
+ const hint = buildHint(stalled, occupied, constraints, depth);
134
+ return {
135
+ epicId,
136
+ rounds,
137
+ depth,
138
+ widest: rounds.reduce((max, r) => Math.max(max, r.tasks.length), 0),
139
+ scheduled: rounds.reduce((total, r) => total + r.tasks.length, 0),
140
+ stalled,
141
+ occupied,
142
+ constraints,
143
+ hint,
144
+ exitCode: stalled.length > 0 ? 1 : constraints.length > 0 ? 2 : 0,
145
+ };
146
+ }