@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.
- package/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- 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
|
+
}
|