@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,228 @@
1
+ import { SmithError } from './errors.js';
2
+ import { appendEvent, readLineageEvents } from './events.js';
3
+ import { listFindings, preWaiverStatus, staleFindings, transition } from './findings.js';
4
+ export class WaiverError extends SmithError {
5
+ }
6
+ /**
7
+ * Only S3/S4 findings are ever waived (severity.yml waiver_semantics) —
8
+ * single source of truth, reused by findings.ts's transition() so `→ waived`
9
+ * is gated the same way everywhere it can be reached (direct transition
10
+ * call or via a waiver grant).
11
+ */
12
+ export const WAIVABLE_SEVERITIES = ['S3-minor', 'S4-nit'];
13
+ /** finding_status values transition() allows a "waived" edge from (findings.ts LEGAL_TRANSITIONS). */
14
+ const WAIVABLE_STATUSES = ['raised', 'confirmed'];
15
+ /**
16
+ * Fold waiver-granted/waiver-denied events for one fingerprint: the last
17
+ * decision wins (a denial can, in principle, be revisited by a later grant
18
+ * — the log keeps both, this just answers "is it waived right now").
19
+ *
20
+ * Over the lineage since D-119: a waiver is granted against a FINGERPRINT, and
21
+ * findings.ts folds those over the lineage now, so a session-scoped answer here
22
+ * would re-raise a finding the operator already waived the moment an epic
23
+ * continued in a new session. "Last decision wins" is decided on the merged
24
+ * order, which is `ts` between sessions and append order within one.
25
+ */
26
+ export async function isWaived(fingerprint, ref, opts = {}) {
27
+ const events = await readLineageEvents(ref.sessionId, opts);
28
+ let waived = false;
29
+ for (const { record } of events) {
30
+ const payload = record.payload;
31
+ if (payload.fingerprint !== fingerprint)
32
+ continue;
33
+ if (record.event_type === 'waiver-granted')
34
+ waived = true;
35
+ else if (record.event_type === 'waiver-denied')
36
+ waived = false;
37
+ }
38
+ return waived;
39
+ }
40
+ /**
41
+ * Has the operator recorded ANY waiver decision for this fingerprint (granted
42
+ * or denied)? Lineage-wide, for isWaived's reason (D-119): a decision recorded
43
+ * in the parent session is still a decision.
44
+ */
45
+ async function hasDecision(fingerprint, ref, opts) {
46
+ const events = await readLineageEvents(ref.sessionId, opts);
47
+ return events.some(({ record }) => {
48
+ const payload = record.payload;
49
+ return (payload.fingerprint === fingerprint &&
50
+ (record.event_type === 'waiver-granted' || record.event_type === 'waiver-denied'));
51
+ });
52
+ }
53
+ /**
54
+ * Every finding sharing `fingerprint` that is still open (raised/confirmed,
55
+ * a waivable severity) is transitioned to `waived` with `waiver_id` set to
56
+ * the granting event's id — otherwise a granted waiver never shows up on
57
+ * finding_status and kanban/analytics/queue consumers that trust it see the
58
+ * finding as open forever. Findings the state machine can't legally move
59
+ * (already terminal, or a non-S3/S4 severity that somehow shares this
60
+ * fingerprint) are left alone; this is reconciliation, not a mandate.
61
+ */
62
+ async function reconcileFindingsToWaived(fingerprint, waiverEventId, ctx, opts) {
63
+ const findings = await listFindings(ctx.sessionId, {}, opts);
64
+ const reconcilable = findings.filter((f) => f.fingerprint === fingerprint &&
65
+ WAIVABLE_SEVERITIES.includes(f.severity) &&
66
+ WAIVABLE_STATUSES.includes(f.finding_status));
67
+ const transitionCtx = { ...ctx, causalParent: waiverEventId };
68
+ for (const finding of reconcilable) {
69
+ await transition(finding.finding_id, 'waived', transitionCtx, opts, {
70
+ waiverId: waiverEventId,
71
+ });
72
+ }
73
+ }
74
+ export async function grantWaiver(fingerprint, operatorNote, ctx, opts = {}) {
75
+ const waiverEvent = await appendEvent({
76
+ session_id: ctx.sessionId,
77
+ actor: ctx.actor ?? 'user',
78
+ event_type: 'waiver-granted',
79
+ plan_version: ctx.planVersion,
80
+ causal_parent: ctx.causalParent,
81
+ payload: { fingerprint, operator_note: operatorNote },
82
+ }, opts);
83
+ await reconcileFindingsToWaived(fingerprint, waiverEvent.event_id, ctx, opts);
84
+ return waiverEvent;
85
+ }
86
+ /**
87
+ * The mirror of reconcileFindingsToWaived, and it exists for the same reason
88
+ * (D-180). A denial that follows a grant revokes it — isWaived() folds
89
+ * last-decision-wins and answers "not waived" the moment it lands — but the
90
+ * findings that grant closed stayed at `waived` regardless: discretionary at
91
+ * the epic gate, absent from pendingBatch because a decision exists, and
92
+ * unreachable by `finding transition` because `waived` was terminal. The
93
+ * operator's reversal changed nothing anyone downstream could see.
94
+ *
95
+ * Only findings this session's log actually shows at `waived` are touched, and
96
+ * each goes back to the status its own grant recorded as from_status. A
97
+ * finding waived under some other fingerprint is not this denial's business.
98
+ */
99
+ async function reconcileFindingsFromWaived(fingerprint, denialEventId, ctx, opts) {
100
+ const findings = await listFindings(ctx.sessionId, {}, opts);
101
+ const reopenable = findings.filter((f) => f.fingerprint === fingerprint && f.finding_status === 'waived');
102
+ const transitionCtx = { ...ctx, causalParent: denialEventId };
103
+ for (const finding of reopenable) {
104
+ const to = await preWaiverStatus(finding.finding_id, ctx.sessionId, opts);
105
+ // A `waived` finding whose grant left no from_status is not something this
106
+ // reconciliation can place, so it is left where it is: a guessed status
107
+ // here either credits a verification that never ran or discards one that
108
+ // did. Only transitions written before from_status existed can produce it.
109
+ if (to === undefined)
110
+ continue;
111
+ await transition(finding.finding_id, to, transitionCtx, opts, {
112
+ waiverRevokedBy: denialEventId,
113
+ });
114
+ }
115
+ }
116
+ export async function denyWaiver(fingerprint, operatorNote, ctx, opts = {}) {
117
+ const denialEvent = await appendEvent({
118
+ session_id: ctx.sessionId,
119
+ actor: ctx.actor ?? 'user',
120
+ event_type: 'waiver-denied',
121
+ plan_version: ctx.planVersion,
122
+ causal_parent: ctx.causalParent,
123
+ payload: { fingerprint, operator_note: operatorNote },
124
+ }, opts);
125
+ await reconcileFindingsFromWaived(fingerprint, denialEvent.event_id, ctx, opts);
126
+ return denialEvent;
127
+ }
128
+ /**
129
+ * S3/S4 findings for one epic that have no waiver decision yet, grouped for
130
+ * a single batched operator prompt (architecture §11: never re-ask). Not
131
+ * "all S3/S4 ever raised" — a finding already granted or denied is settled
132
+ * and never resurfaces here.
133
+ */
134
+ export async function pendingBatch(epic, ref, opts = {}) {
135
+ const findings = await listFindings(ref.sessionId, { epic }, opts);
136
+ const pending = [];
137
+ for (const finding of findings) {
138
+ if (!WAIVABLE_SEVERITIES.includes(finding.severity))
139
+ continue;
140
+ if (await hasDecision(finding.fingerprint, ref, opts))
141
+ continue;
142
+ pending.push(finding);
143
+ }
144
+ return pending;
145
+ }
146
+ /**
147
+ * Apply a batch of operator waiver answers. Validates every fingerprint
148
+ * against a real finding in this session, AND (for `granted` decisions)
149
+ * that every finding sharing that fingerprint sits at a waivable severity
150
+ * (S3/S4) — all before writing anything. A batch referencing an unknown
151
+ * fingerprint, or attempting to grant a waiver over a non-waivable
152
+ * (S1/S2) finding, fails entirely (typed error), rather than partially
153
+ * applying. This is a stricter, operator-facing policy check on top of
154
+ * grantWaiver()'s own lower-level reconciliation, which — reached directly,
155
+ * not through this batch API — still only ever moves the WAIVABLE findings
156
+ * sharing a fingerprint to `waived` and leaves any non-waivable sibling
157
+ * alone (see waivers.test.ts).
158
+ *
159
+ * Without this gate, grantWaiver() would append `waiver-granted` for a
160
+ * fingerprint whose only finding is S2 — isWaived() folds that event
161
+ * regardless of severity, so the operator's answer would stand on the record
162
+ * as having settled a merge-blocking finding it was never allowed to be asked
163
+ * about, while the finding itself stays open forever (reconcileFindingsToWaived
164
+ * leaves non-waivable findings alone, by design). Since D-196, raiseFinding no
165
+ * longer suppresses a re-raise at a non-waivable severity, so the damage stops
166
+ * at the record rather than reaching the next round's gate — but a waiver that
167
+ * settles nothing is still not a waiver, and the batch refuses to write one.
168
+ *
169
+ * A `granted` decision is additionally refused when the finding's evidence is
170
+ * stale — a wave has merged over its file since it was raised or last
171
+ * re-verified (P9-15). The operator is answering a question about code that
172
+ * no longer exists; the remedy is `smith findings reverify`, which is named in
173
+ * the error. Denials are allowed through: a denial closes nothing and grants
174
+ * nothing, so blocking it would only strand the batch.
175
+ */
176
+ export async function applyBatch(decisions, ctx, opts = {}) {
177
+ const findings = await listFindings(ctx.sessionId, {}, opts);
178
+ const findingsByFingerprint = new Map();
179
+ for (const f of findings) {
180
+ const list = findingsByFingerprint.get(f.fingerprint) ?? [];
181
+ list.push(f);
182
+ findingsByFingerprint.set(f.fingerprint, list);
183
+ }
184
+ // Computed once for the batch, and only when it can matter: a batch of pure
185
+ // denials never reads the merge history at all.
186
+ const stale = decisions.some((d) => d.decision === 'granted')
187
+ ? await staleFindings(ctx.sessionId, opts)
188
+ : new Map();
189
+ for (const decision of decisions) {
190
+ const owning = findingsByFingerprint.get(decision.fingerprint);
191
+ if (!owning) {
192
+ throw new WaiverError('waivers.unknown-fingerprint', `No finding with fingerprint "${decision.fingerprint}" in session "${ctx.sessionId}".`, { fingerprint: decision.fingerprint });
193
+ }
194
+ if (decision.decision === 'granted') {
195
+ const nonWaivable = owning.filter((f) => !WAIVABLE_SEVERITIES.includes(f.severity));
196
+ if (nonWaivable.length > 0) {
197
+ throw new WaiverError('waivers.non-waivable-severity', `Fingerprint "${decision.fingerprint}" has finding(s) at a non-waivable severity ` +
198
+ `(${nonWaivable.map((f) => f.severity).join(', ')}) — only ${WAIVABLE_SEVERITIES.join('/')} findings can be waived.`, { fingerprint: decision.fingerprint, severities: nonWaivable.map((f) => f.severity) });
199
+ }
200
+ for (const finding of owning) {
201
+ const evidence = stale.get(finding.finding_id);
202
+ if (!evidence)
203
+ continue;
204
+ throw new WaiverError('waivers.stale-evidence', `Finding "${finding.finding_id}" is anchored to ${evidence.filePath}, which task ` +
205
+ `"${evidence.mergedTaskId}" has merged over since the finding was raised (matched by ` +
206
+ `${evidence.basis}: ${evidence.matched}). Its evidence describes code that no longer ` +
207
+ 'exists, so this waiver would answer a question about a deleted file. Re-read it first: ' +
208
+ `smith findings reverify --finding ${finding.finding_id}`, {
209
+ fingerprint: decision.fingerprint,
210
+ findingId: finding.finding_id,
211
+ filePath: evidence.filePath,
212
+ mergedTaskId: evidence.mergedTaskId,
213
+ basis: evidence.basis,
214
+ });
215
+ }
216
+ }
217
+ }
218
+ const results = [];
219
+ for (const decision of decisions) {
220
+ if (decision.decision === 'granted') {
221
+ results.push(await grantWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
222
+ }
223
+ else {
224
+ results.push(await denyWaiver(decision.fingerprint, decision.operatorNote, ctx, opts));
225
+ }
226
+ }
227
+ return results;
228
+ }
@@ -0,0 +1,223 @@
1
+ // Wave admission's budget gate, added the same day `epic.max_in_flight_tasks`
2
+ // reached budgets.yml.
3
+ //
4
+ // budgetAlarm.ts (P9-33) made `epic.cap_tokens` and `epic.alarm_ratio`
5
+ // readable, but every reader that gave those numbers stands downstream of the
6
+ // spend it reports: planQuorum's budget trigger and `smith budget alarm` both
7
+ // describe a bill, neither refuses one. `smith wave check` (this module) is
8
+ // the first reader standing upstream of the spend it is judging -- at wave
9
+ // admission, before any task in the wave has been dispatched -- so it is the
10
+ // only one of the three that CAN refuse the spend instead of merely
11
+ // describing it afterward.
12
+ //
13
+ // It reuses checkBudgetAlarm for the epic's measured/projected spend rather
14
+ // than re-deriving either number: a second price list is a second thing that
15
+ // can disagree with the first, and this module has no business owning its
16
+ // own opinion about what a role costs or how a task's tokens are counted.
17
+ // What it adds is the one question checkBudgetAlarm was never asked: given
18
+ // the headroom that leaves under the cap right now, does the wave about to
19
+ // be admitted still fit once its own declared cost is added.
20
+ //
21
+ // This does not revisit D-29's finding for the task gate: a coder cap the
22
+ // worker polices itself is pressure on the very work it is measuring, and it
23
+ // stays advisory for exactly that reason (see budgets.yml's header). An epic
24
+ // cap checked at wave admission is not a stricter version of that same
25
+ // check -- it is a different question asked at a different time. It asks
26
+ // before any task in the wave has started, when there is nothing in flight
27
+ // yet for a refusal to distort. A decision made before the work exists cannot
28
+ // become pressure on the work.
29
+ import { checkBudgetAlarm } from './budgetAlarm.js';
30
+ import { bareTaskId } from './taskId.js';
31
+ /**
32
+ * The statuses that must stop an admission.
33
+ *
34
+ * `unchecked` is deliberately not among them, and the distinction is the
35
+ * whole reason it exists as a separate status rather than being folded into
36
+ * `ok`. A session-less `wave check` cannot write a `wave-admitted` event in
37
+ * the first place -- it has no log to write to -- so it is always advisory,
38
+ * and refusing it would only stop the operator who asked politely before
39
+ * committing to anything. What it must not do is answer `ok`: that is one
40
+ * indistinguishable yes for "checked, and it fits" and "never checked", which
41
+ * is the exact shape of the bug that made the old guard hook allow everything
42
+ * on macOS for eight phases.
43
+ */
44
+ export function blocksAdmission(status) {
45
+ return status === 'refused' || status === 'over-fan-out' || status === 'unverifiable';
46
+ }
47
+ const NUMBER = new Intl.NumberFormat('en-US');
48
+ function fmt(n) {
49
+ return NUMBER.format(n);
50
+ }
51
+ function payloadEpicId(payload) {
52
+ const value = payload.epic_id;
53
+ return typeof value === 'string' && value.length > 0 ? value : null;
54
+ }
55
+ /**
56
+ * Bare task ids this epic has admitted and not yet merged, sorted.
57
+ *
58
+ * Folded through bareTaskId exactly as checkBudgetAlarm's own accumulators
59
+ * are (D-46/P9-10): the log is append-only, so a task admitted under its
60
+ * qualified `<epic>/<task>` spelling can be merged under the bare `<task>`
61
+ * spelling later in the same session (or the reverse), and a set keyed on
62
+ * exact string equality would either miss the cancellation or count the one
63
+ * task twice under its two spellings.
64
+ */
65
+ export function inFlightTasks(events, epicId) {
66
+ const admitted = new Set();
67
+ const merged = new Set();
68
+ for (const event of events) {
69
+ const { record } = event;
70
+ if (record.event_type !== 'wave-admitted' && record.event_type !== 'wave-merged')
71
+ continue;
72
+ if (payloadEpicId(record.payload) !== epicId)
73
+ continue;
74
+ const taskIds = record.payload.task_ids;
75
+ if (!Array.isArray(taskIds))
76
+ continue;
77
+ const target = record.event_type === 'wave-admitted' ? admitted : merged;
78
+ for (const taskId of taskIds) {
79
+ if (typeof taskId === 'string' && taskId.length > 0)
80
+ target.add(bareTaskId(taskId));
81
+ }
82
+ }
83
+ return [...admitted].filter((taskId) => !merged.has(taskId)).sort();
84
+ }
85
+ function unverifiableDetail(unpricedTasks) {
86
+ return (`${fmt(unpricedTasks.length)} of the proposed task(s) (${unpricedTasks.join(', ')}) declare ` +
87
+ `no budget.tokens, so the wave's cost cannot be summed. Price every task before the wave is ` +
88
+ `admitted -- a wave with an unpriced task is not cheaper, it is unmeasured.`);
89
+ }
90
+ function overFanOutDetail(inFlight, waveTaskCount, maxInFlightTasks) {
91
+ return (`Admitting this wave would put ${fmt(inFlight + waveTaskCount)} of the epic's tasks in ` +
92
+ `flight at once (${fmt(inFlight)} already admitted and unmerged, ${fmt(waveTaskCount)} ` +
93
+ `proposed), past the ${fmt(maxInFlightTasks)} epic.max_in_flight_tasks allows. Merge or ` +
94
+ `cancel enough in-flight work first, or raise the cap by explicit operator decision.`);
95
+ }
96
+ function refusedDetail(projectedTokens, waveTokens, capTokens) {
97
+ return (`${fmt(projectedTokens)} tokens projected for this epic plus ${fmt(waveTokens)} declared ` +
98
+ `for this wave would reach ${fmt(projectedTokens + waveTokens)}, past the ${fmt(capTokens)} ` +
99
+ `epic.cap_tokens allows. Split the epic at spec time so the remaining work fits, or extend ` +
100
+ `the cap by explicit operator decision.`);
101
+ }
102
+ function okDetail(headroomTokens, waveTokens, capTokens) {
103
+ return (`${fmt(waveTokens)} tokens declared for this wave fit within ${fmt(headroomTokens)} of ` +
104
+ `headroom under the ${fmt(capTokens)} epic.cap_tokens cap.`);
105
+ }
106
+ function uncheckedDetail(waveTokens, capTokens) {
107
+ return (`${fmt(waveTokens)} tokens declared for this wave fit under the ${fmt(capTokens)} ` +
108
+ `epic.cap_tokens cap on their own, but no --session was given, so the spend this epic has ` +
109
+ `already run up could not be read and the real headroom is unknown. Re-run with --session ` +
110
+ `to have the cap actually checked -- this is not a pass, it is an unasked question.`);
111
+ }
112
+ const NOT_APPLICABLE_DETAIL = 'No epic id was given for this wave, so there is no epic cap to check it against.';
113
+ /**
114
+ * Whether a wave may be admitted, checked once, before any task in it has
115
+ * been dispatched.
116
+ *
117
+ * Reuses checkBudgetAlarm for the epic's measured/projected spend (see this
118
+ * file's header) and adds one number of its own: the wave's own declared
119
+ * cost, summed from `proposed`. Decision order matters and is
120
+ * first-match-wins:
121
+ *
122
+ * 1. not-applicable -- no epic id was given for the wave at all. Reserved
123
+ * for exactly this: an epic with no history yet still has an epic, so
124
+ * its first wave is judged on rule 5 below, not this one.
125
+ * 2. unverifiable -- some proposed task declares no budget.tokens, so
126
+ * the wave's own cost cannot be summed. Checked before the fan-out and
127
+ * cap comparisons because neither means anything once one of their
128
+ * inputs is missing.
129
+ * 3. over-fan-out -- admitting the wave would put more of the epic's
130
+ * tasks in flight than epic.max_in_flight_tasks allows.
131
+ * 4. refused -- admitting the wave would cross epic.cap_tokens.
132
+ * Ranked above `unchecked` on purpose: with no session the epic's prior
133
+ * spend reads as zero, so this fires only when the wave's own declared
134
+ * cost exceeds the whole cap by itself -- a sound refusal that needs no
135
+ * log to reach.
136
+ * 5. unchecked -- nothing checkable is wrong, but no session id was
137
+ * given, so the epic's spend so far could not be read and the headroom
138
+ * the wave was measured against is not the real one.
139
+ * 6. ok -- otherwise, including an epic's genuine first wave.
140
+ *
141
+ * An epic on its genuine first wave (checkBudgetAlarm finds no prior event
142
+ * attributing anything to it) reads `measuredTokens: 0, projectedTokens: 0`
143
+ * -- ok if the wave itself fits under the cap, never not-applicable. A fresh
144
+ * epic is not an unreadable one, and neither is a session-less check: that
145
+ * one is `unchecked`, which is not the same as either.
146
+ */
147
+ export function checkWaveBudget(events, policy, proposed, options) {
148
+ const capTokens = policy.epic.capTokens;
149
+ const maxInFlightTasks = policy.epic.maxInFlightTasks;
150
+ const waveTaskCount = proposed.length;
151
+ const unpricedTasks = proposed.filter((task) => task.tokens === null).map((task) => task.taskId);
152
+ const waveTokens = proposed.reduce((sum, task) => sum + (task.tokens ?? 0), 0);
153
+ if (options.epicId.trim().length === 0) {
154
+ return {
155
+ epicId: null,
156
+ capTokens,
157
+ measuredTokens: 0,
158
+ projectedTokens: 0,
159
+ headroomTokens: capTokens,
160
+ waveTokens,
161
+ waveTaskCount,
162
+ unpricedTasks,
163
+ inFlightTasks: 0,
164
+ maxInFlightTasks,
165
+ status: 'not-applicable',
166
+ detail: NOT_APPLICABLE_DETAIL,
167
+ };
168
+ }
169
+ const epicId = options.epicId;
170
+ // No session id means no lineage to read, so checkBudgetAlarm is not asked a
171
+ // question it cannot answer -- it would fold an empty event list into its
172
+ // synthetic `*` entry and hand back zeros indistinguishable from a genuine
173
+ // first wave. The zeros below are the same zeros, but the status that
174
+ // carries them says which kind they are.
175
+ const hasSession = options.sessionId.trim().length > 0;
176
+ const report = hasSession
177
+ ? checkBudgetAlarm(events, policy, { sessionId: options.sessionId, epicId })
178
+ : undefined;
179
+ const epicCheck = report?.epics[0];
180
+ // checkBudgetAlarm emits a synthetic `epicId: '*'` entry exactly when it
181
+ // found no event attributing anything to this epic id (its own comment on
182
+ // the empty-epics-array branch) -- that is this epic's genuine first wave,
183
+ // not a hole in the record, so it is measured at zero rather than reported
184
+ // unverifiable.
185
+ const isFirstWave = epicCheck === undefined || epicCheck.epicId === '*';
186
+ const measuredTokens = isFirstWave ? 0 : epicCheck.measuredTokens;
187
+ const projectedTokens = isFirstWave ? 0 : epicCheck.projectedTokens;
188
+ const headroomTokens = Math.max(0, capTokens - projectedTokens);
189
+ const inFlight = inFlightTasks(events, epicId).length;
190
+ const base = {
191
+ epicId,
192
+ capTokens,
193
+ measuredTokens,
194
+ projectedTokens,
195
+ headroomTokens,
196
+ waveTokens,
197
+ waveTaskCount,
198
+ unpricedTasks,
199
+ inFlightTasks: inFlight,
200
+ maxInFlightTasks,
201
+ };
202
+ if (unpricedTasks.length > 0) {
203
+ return { ...base, status: 'unverifiable', detail: unverifiableDetail(unpricedTasks) };
204
+ }
205
+ if (maxInFlightTasks !== null && inFlight + waveTaskCount > maxInFlightTasks) {
206
+ return {
207
+ ...base,
208
+ status: 'over-fan-out',
209
+ detail: overFanOutDetail(inFlight, waveTaskCount, maxInFlightTasks),
210
+ };
211
+ }
212
+ if (projectedTokens + waveTokens > capTokens) {
213
+ return {
214
+ ...base,
215
+ status: 'refused',
216
+ detail: refusedDetail(projectedTokens, waveTokens, capTokens),
217
+ };
218
+ }
219
+ if (!hasSession) {
220
+ return { ...base, status: 'unchecked', detail: uncheckedDetail(waveTokens, capTokens) };
221
+ }
222
+ return { ...base, status: 'ok', detail: okDetail(headroomTokens, waveTokens, capTokens) };
223
+ }