@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,376 @@
|
|
|
1
|
+
import { claimCoversPath } from './claims.js';
|
|
2
|
+
import { SmithError } from './errors.js';
|
|
3
|
+
import { appendEvent } from './events.js';
|
|
4
|
+
import {
|
|
5
|
+
// The status an amendment can actually put a finding into. Not `amended`:
|
|
6
|
+
// since D-127 that is reachable only once the amendment's task ids land, so
|
|
7
|
+
// a guard here that asked about the terminal status would be asking about a
|
|
8
|
+
// transition this module never attempts.
|
|
9
|
+
AMEND_PENDING_STATUS, findingScope, LEGAL_TRANSITIONS, listFindings, mintFindings, raiseFinding, SPEC_FINDING_SCOPE, transition, } from './findings.js';
|
|
10
|
+
import { diffPlans, draftNextVersion, livePlanTasks, nextVersion, planRefTaskId, validatePlan, } from './plan.js';
|
|
11
|
+
import { RESERVED_TASK_ID } from './worktree.js';
|
|
12
|
+
/**
|
|
13
|
+
* P9-9/D-33. Every judge in this factory returns findings against a diff, and
|
|
14
|
+
* every finding against a diff blocks the diff. That is right until the judge
|
|
15
|
+
* is reading the plan rather than the code: "the criterion is wrong" recorded
|
|
16
|
+
* as a builder defect blocks a task whose diff cannot legally contain the fix,
|
|
17
|
+
* because the fix is a new plan version and plans are immutable. The dogfood
|
|
18
|
+
* epic deadlocked there — a wave-3 spec defect, correctly found, recorded as a
|
|
19
|
+
* coder failure, bounced back to a coder who had nothing to change.
|
|
20
|
+
*
|
|
21
|
+
* This module is the other route. A spec-scoped finding (findings.ts's
|
|
22
|
+
* `finding_scope`) leaves the task gate untouched and arrives here, where the
|
|
23
|
+
* only legitimate way to change an immutable plan lives: `amendPlan` cuts a
|
|
24
|
+
* new version, records which criterion moved and why, and puts the finding on
|
|
25
|
+
* the amendment path at `amend-pending` — the one exit an S1/S2 spec finding
|
|
26
|
+
* has, since severity.yml makes it categorically unwaivable. It opens that
|
|
27
|
+
* exit; the tasks the new version added or superseded are what walk through
|
|
28
|
+
* it (D-127).
|
|
29
|
+
*
|
|
30
|
+
* The second half is `recordSpecReview`: the closing spec-reviewer dispatch,
|
|
31
|
+
* run at epic close against composite behaviour, when the code the spec
|
|
32
|
+
* describes finally exists. A spec review before the code is written cannot
|
|
33
|
+
* see the wave-3 defect; only this one can, and epic.ts's verdict now refuses
|
|
34
|
+
* to certify an epic that never ran it.
|
|
35
|
+
*
|
|
36
|
+
* Dependency direction is one-way on purpose: epic.ts imports from here, never
|
|
37
|
+
* the reverse, so the fold that decides readiness stays downstream of the
|
|
38
|
+
* facts it folds.
|
|
39
|
+
*/
|
|
40
|
+
export class SpecError extends SmithError {
|
|
41
|
+
}
|
|
42
|
+
export const SPEC_REVIEW_EVENT = 'spec-review-recorded';
|
|
43
|
+
/**
|
|
44
|
+
* The graph event the taxonomy has declared since Phase 2 and nothing has ever
|
|
45
|
+
* written (P9-12's shape, in the one place a producer is genuinely owed).
|
|
46
|
+
* `nextVersion()` writes the plan file; this module is what makes the cut a
|
|
47
|
+
* recorded decision rather than a new file appearing on disk.
|
|
48
|
+
*/
|
|
49
|
+
export const PLAN_AMENDED_EVENT = 'plan-version-created';
|
|
50
|
+
/** Last-wins fold: a re-review supersedes its predecessor, it does not stack. */
|
|
51
|
+
export function latestSpecReview(events, epicId) {
|
|
52
|
+
let latest = null;
|
|
53
|
+
for (const event of events) {
|
|
54
|
+
if (event.record.event_type !== SPEC_REVIEW_EVENT)
|
|
55
|
+
continue;
|
|
56
|
+
const payload = event.record.payload;
|
|
57
|
+
if (payload.epic_id !== epicId)
|
|
58
|
+
continue;
|
|
59
|
+
latest = {
|
|
60
|
+
epicId,
|
|
61
|
+
planVersion: Number(payload.plan_version ?? 0),
|
|
62
|
+
headSha: String(payload.head_sha ?? ''),
|
|
63
|
+
reviewedBy: String(payload.reviewed_by ?? ''),
|
|
64
|
+
findingIds: (payload.finding_ids ?? []),
|
|
65
|
+
eventId: event.event_id,
|
|
66
|
+
ts: event.record.ts,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return latest;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The epic gate refusing to certify a spec nobody re-read once the code
|
|
73
|
+
* existed. Mirrors integrationBlockers exactly, including the fail-closed
|
|
74
|
+
* unknown-head case: a review pinned to a sha nobody can compare against is
|
|
75
|
+
* not evidence about anything.
|
|
76
|
+
*
|
|
77
|
+
* There is deliberately no SPEC_REVIEW_NOT_REQUIRED escape hatch. An epic can
|
|
78
|
+
* legitimately owe no MCP surface; every epic has a plan, and every plan can
|
|
79
|
+
* be wrong in a way only the finished code reveals.
|
|
80
|
+
*
|
|
81
|
+
* A review reads two things, so it goes stale two ways (D-125). The sha axis
|
|
82
|
+
* was the only one this function knew, and the plan axis is the one that
|
|
83
|
+
* matters most: an amendment is by definition a plan that just changed because
|
|
84
|
+
* a review found it wrong, so the single moment a plan is likeliest to be
|
|
85
|
+
* freshly defective was the one moment the gate was blind to. `epic verdict`
|
|
86
|
+
* certified a review of v4 against a plan that had been v5 for seconds. The
|
|
87
|
+
* two axes are independent — an amendment can land before any commit
|
|
88
|
+
* implements it — so a current sha must not vouch for a plan the review never
|
|
89
|
+
* read, and neither check is allowed to stand in for the other.
|
|
90
|
+
*
|
|
91
|
+
* `planVersion` is the live plan's version, or null when the epic has no
|
|
92
|
+
* readable plan file. Null casts no vote, which is D-126's deliberate scope
|
|
93
|
+
* line rather than a hole: most epics ran as punch-list branches with no plan
|
|
94
|
+
* directory, and making absence a blocker would make them unclosable. It is
|
|
95
|
+
* a required parameter for the reason `integration` and `mcp` are required on
|
|
96
|
+
* summarizeEpic — a defaulted one is a forgotten argument that manufactures a
|
|
97
|
+
* green.
|
|
98
|
+
*/
|
|
99
|
+
export function specReviewBlockers(epicId, status, planVersion) {
|
|
100
|
+
const branch = `smith/${epicId}/${RESERVED_TASK_ID}`;
|
|
101
|
+
const { review, headSha } = status;
|
|
102
|
+
if (review === null) {
|
|
103
|
+
return [
|
|
104
|
+
`Epic "${epicId}" has no closing spec review on record: nothing re-read the plan against the code that now exists. A spec review run before the code was written cannot see the defects the code reveals (D-33).`,
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
if (planVersion !== null) {
|
|
108
|
+
// latestSpecReview reads a missing plan_version as 0, and "I did not
|
|
109
|
+
// record which plan I read" is unverifiable, not current — the same
|
|
110
|
+
// fail-closed call as the unknown head below.
|
|
111
|
+
if (!Number.isInteger(review.planVersion) || review.planVersion < 1) {
|
|
112
|
+
return [
|
|
113
|
+
`The closing spec review for "${epicId}" records no plan version, so it cannot be shown to cover plan v${planVersion}.`,
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
if (review.planVersion < planVersion) {
|
|
117
|
+
return [
|
|
118
|
+
`The closing spec review for "${epicId}" is stale: it read plan v${review.planVersion}, and the epic's live plan is v${planVersion}. Whatever the amendment changed has been reviewed against no spec at all.`,
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
// Newer than the plan on disk: a rolled-back or hand-written record. The
|
|
122
|
+
// review names a version nothing can be compared against, so it is
|
|
123
|
+
// evidence about a plan this epic is not on.
|
|
124
|
+
if (review.planVersion > planVersion) {
|
|
125
|
+
return [
|
|
126
|
+
`The closing spec review for "${epicId}" records plan v${review.planVersion}, and the epic's live plan is v${planVersion}: the review names a version the repository does not have.`,
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (headSha === null) {
|
|
131
|
+
return [
|
|
132
|
+
`Could not read the head of ${branch}, so the closing spec review recorded at ${review.headSha.slice(0, 8)} cannot be shown to cover it.`,
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
if (review.headSha !== headSha) {
|
|
136
|
+
return [
|
|
137
|
+
`The closing spec review for "${epicId}" is stale: it read ${review.headSha.slice(0, 8)}, and ${branch} is now at ${headSha.slice(0, 8)}. Anything merged since has been reviewed against no spec at all.`,
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Run the closing spec review's bookkeeping: mint the reviewer's evidence as
|
|
144
|
+
* spec-scoped findings, raise each, and record that the review happened.
|
|
145
|
+
*
|
|
146
|
+
* The event is written even when the evidence is empty. "Ran and was clean" and
|
|
147
|
+
* "never ran" are different facts and the epic gate distinguishes them; a
|
|
148
|
+
* review that only logged when it found something would make a silent skip
|
|
149
|
+
* indistinguishable from a pass, which is the same shape of hole D-42 was.
|
|
150
|
+
*
|
|
151
|
+
* Findings are raised BEFORE the review event so the event can cite them: a
|
|
152
|
+
* record naming finding ids that do not exist yet is a record that lies for
|
|
153
|
+
* however long the raise loop takes to crash.
|
|
154
|
+
*/
|
|
155
|
+
export async function recordSpecReview(input, ctx, opts = {}) {
|
|
156
|
+
const { epicId, planVersion, headSha, reviewedBy } = input;
|
|
157
|
+
const drafts = mintFindings(input.evidence, {
|
|
158
|
+
taskId: `${epicId}/${RESERVED_TASK_ID}`,
|
|
159
|
+
foundBy: reviewedBy,
|
|
160
|
+
...(input.reviewedByProvider === undefined
|
|
161
|
+
? {}
|
|
162
|
+
: { foundByProvider: input.reviewedByProvider }),
|
|
163
|
+
spec: { planVersion },
|
|
164
|
+
}, opts);
|
|
165
|
+
const findingIds = [];
|
|
166
|
+
for (const draft of drafts) {
|
|
167
|
+
const raised = await raiseFinding(draft, ctx, opts);
|
|
168
|
+
// A suppressed raise (fingerprint already waived) logged its own
|
|
169
|
+
// finding-suppressed event and appended no finding. Citing an id that was
|
|
170
|
+
// never raised would make this record unresolvable; the suppression is
|
|
171
|
+
// already in the log where analytics can see the recurrence.
|
|
172
|
+
if (raised.suppressed)
|
|
173
|
+
continue;
|
|
174
|
+
findingIds.push(raised.finding.finding_id);
|
|
175
|
+
}
|
|
176
|
+
const stored = await appendEvent({
|
|
177
|
+
session_id: ctx.sessionId,
|
|
178
|
+
actor: ctx.actor ?? 'system',
|
|
179
|
+
event_type: SPEC_REVIEW_EVENT,
|
|
180
|
+
task_id: `${epicId}/${RESERVED_TASK_ID}`,
|
|
181
|
+
plan_version: ctx.planVersion,
|
|
182
|
+
causal_parent: ctx.causalParent,
|
|
183
|
+
payload: {
|
|
184
|
+
epic_id: epicId,
|
|
185
|
+
plan_version: planVersion,
|
|
186
|
+
head_sha: headSha,
|
|
187
|
+
reviewed_by: reviewedBy,
|
|
188
|
+
...(input.reviewedByProvider === undefined
|
|
189
|
+
? {}
|
|
190
|
+
: { reviewed_by_provider: input.reviewedByProvider }),
|
|
191
|
+
finding_ids: findingIds,
|
|
192
|
+
finding_count: findingIds.length,
|
|
193
|
+
},
|
|
194
|
+
}, opts);
|
|
195
|
+
return {
|
|
196
|
+
epicId,
|
|
197
|
+
planVersion,
|
|
198
|
+
headSha,
|
|
199
|
+
reviewedBy,
|
|
200
|
+
findingIds,
|
|
201
|
+
eventId: stored.event_id,
|
|
202
|
+
ts: stored.record.ts,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The one legitimate way to change an immutable plan.
|
|
207
|
+
*
|
|
208
|
+
* Three guards make this an amendment rather than a rewrite: it must cite at
|
|
209
|
+
* least one spec-scoped finding, it must say why, and it must enumerate the
|
|
210
|
+
* sites the finding's shape occurs at. Without the first, "the plan changed"
|
|
211
|
+
* has no recorded cause and the immutability is decorative — a new version any
|
|
212
|
+
* time anyone wants one is just a mutable plan with extra files. Without the
|
|
213
|
+
* second, the diff records what moved and nothing records the argument, which
|
|
214
|
+
* is the half that a future reader actually needs. Without the third, the
|
|
215
|
+
* remediation scope is chosen by whoever writes the amendment and checked by
|
|
216
|
+
* nothing: a finding names one site, and the question of where else that shape
|
|
217
|
+
* lives gets asked by nobody (D-123, and D-83 one run before it — the same
|
|
218
|
+
* defect twice, which is what moved it out of prose and into a guard).
|
|
219
|
+
*
|
|
220
|
+
* Everything validates before anything acts. The alternative ordering — cut
|
|
221
|
+
* the version, then discover a cited finding is already closed — leaves a plan
|
|
222
|
+
* file on disk that no event explains and findings that were never
|
|
223
|
+
* transitioned, and plan files are not deletable by anything in this codebase.
|
|
224
|
+
*
|
|
225
|
+
* What this does NOT do is close the findings it cites. It used to transition
|
|
226
|
+
* them straight to `amended` — terminal, and counted closed by the epic
|
|
227
|
+
* verdict — in the same call that wrote the plan file, so the severity class
|
|
228
|
+
* severity.yml refuses to waive was discharged in full by writing a sentence
|
|
229
|
+
* (D-127). They now reach `amend-pending`, carrying the task ids this version
|
|
230
|
+
* added or superseded. Those ids landing is what earns `amended`.
|
|
231
|
+
*/
|
|
232
|
+
export async function amendPlan(input, ctx, opts = {}) {
|
|
233
|
+
const { plan, findingIds, rationale, sites } = input;
|
|
234
|
+
const epicId = plan.epic_id;
|
|
235
|
+
if (findingIds.length === 0) {
|
|
236
|
+
throw new SpecError('plan.amendment-without-finding', `Refusing to amend plan "${epicId}" v${plan.version}: an amendment cites the spec finding that forced it. A version cut on request is not an amendment, it is a mutable plan.`, { epicId, version: plan.version });
|
|
237
|
+
}
|
|
238
|
+
if (rationale.trim() === '') {
|
|
239
|
+
throw new SpecError('plan.amendment-without-rationale', `Refusing to amend plan "${epicId}" v${plan.version} with a blank rationale: the diff already records what moved, and only the rationale records why.`, { epicId, version: plan.version, findingIds: [...findingIds] });
|
|
240
|
+
}
|
|
241
|
+
// Trimmed and de-duplicated once, here: what the guard accepts and what the
|
|
242
|
+
// event records have to be the same list, or the record answers a question
|
|
243
|
+
// the guard never asked.
|
|
244
|
+
const namedSites = [...new Set(sites.map((s) => s.trim()))];
|
|
245
|
+
if (namedSites.length === 0 || namedSites.some((s) => s === '')) {
|
|
246
|
+
throw new SpecError('plan.amendment-without-sites', `Refusing to amend plan "${epicId}" v${plan.version}: name every site the finding's shape occurs at, not only the one it was reported against. A finding names where it was noticed; the amendment has to answer where the shape lives (D-123).`, { epicId, version: plan.version, findingIds: [...findingIds] });
|
|
247
|
+
}
|
|
248
|
+
// listFindings has no by-id filter (it folds by task/epic/status/severity),
|
|
249
|
+
// so index the session's findings here rather than folding once per id.
|
|
250
|
+
const all = await listFindings(ctx.sessionId, {}, opts);
|
|
251
|
+
const byId = new Map(all.map((f) => [f.finding_id, f]));
|
|
252
|
+
const cited = [];
|
|
253
|
+
for (const findingId of findingIds) {
|
|
254
|
+
const finding = byId.get(findingId);
|
|
255
|
+
if (finding === undefined) {
|
|
256
|
+
throw new SpecError('plan.amendment-unknown-finding', `Amendment of "${epicId}" v${plan.version} cites finding ${findingId}, which was never raised in session ${ctx.sessionId}.`, { epicId, version: plan.version, findingId });
|
|
257
|
+
}
|
|
258
|
+
cited.push(finding);
|
|
259
|
+
}
|
|
260
|
+
for (const finding of cited) {
|
|
261
|
+
if (findingScope(finding) !== SPEC_FINDING_SCOPE) {
|
|
262
|
+
throw new SpecError('plan.amendment-not-spec-scoped', `Finding ${finding.finding_id} is scoped ${findingScope(finding)}, not ${SPEC_FINDING_SCOPE}: it says the code is wrong. A new plan version is not how a diff defect gets fixed.`, { epicId, findingId: finding.finding_id, scope: findingScope(finding) });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const finding of cited) {
|
|
266
|
+
if (!(LEGAL_TRANSITIONS[finding.finding_status] ?? []).includes(AMEND_PENDING_STATUS)) {
|
|
267
|
+
throw new SpecError('plan.amendment-finding-closed', `Finding ${finding.finding_id} is already ${finding.finding_status} and cannot reach ${AMEND_PENDING_STATUS}. An amendment answering a closed finding answers nothing.`, { epicId, findingId: finding.finding_id, findingStatus: finding.finding_status });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// The last guard needs the diff, and the diff is only defined against the
|
|
271
|
+
// version this amendment would cut — so the version is CONSTRUCTED here and
|
|
272
|
+
// written a few lines below, once the guard has passed. That is what
|
|
273
|
+
// `draftNextVersion` is for: "everything validates before anything acts"
|
|
274
|
+
// only holds if this check can run before a plan file exists, and nothing in
|
|
275
|
+
// this codebase deletes a plan file (D-127).
|
|
276
|
+
const draft = draftNextVersion(plan, input.changes ?? {});
|
|
277
|
+
// D-21: a malformed `--changes` file can still produce a draft that reads
|
|
278
|
+
// fine structurally (every entry a plain object with a string task_id) but
|
|
279
|
+
// fails the plan's own schema/taxonomy rules — the exact check `smith plan
|
|
280
|
+
// validate` runs. Plans are immutable and nothing deletes one, so a
|
|
281
|
+
// schema-invalid version written to disk is unrecoverable; refusing it here,
|
|
282
|
+
// before the write, is what closes that loop rather than leaving `smith
|
|
283
|
+
// plan validate` to reject the file this tool just produced.
|
|
284
|
+
const draftValidation = validatePlan(draft, opts);
|
|
285
|
+
if (!draftValidation.valid) {
|
|
286
|
+
throw new SpecError('plan.amendment-invalid-draft', `Refusing to amend plan "${epicId}" v${plan.version}: v${draft.version} would fail its own validation (the same check \`smith plan validate\` runs), so nothing was written. ${draftValidation.errors
|
|
287
|
+
.map((e) => `${e.path}: ${e.message}`)
|
|
288
|
+
.join(' ')}`, { epicId, version: plan.version, nextVersion: draft.version, errors: draftValidation.errors });
|
|
289
|
+
}
|
|
290
|
+
const diff = diffPlans(plan, draft);
|
|
291
|
+
// The task ids this amendment makes the cited findings' discharge
|
|
292
|
+
// condition. `removed` and `carried` are not obligations: a carried task is
|
|
293
|
+
// untouched by this amendment, and a removed one will never land. The two
|
|
294
|
+
// rejected shapes are named apart in the message because the diff is not
|
|
295
|
+
// always empty here: `PlanChanges` has no `remove`, so the only way an id
|
|
296
|
+
// leaves a version is `draftNextVersion` dropping a completed task from the
|
|
297
|
+
// live backlog. That is a real `removed` entry the operator did not ask for,
|
|
298
|
+
// and telling them nothing changed would be arguing about the wrong thing.
|
|
299
|
+
//
|
|
300
|
+
// A superseded id only counts while the amended version still claims it. A
|
|
301
|
+
// supersede whose replacement carries a different id leaves the old one
|
|
302
|
+
// behind as a dead record — `diffPlans` calls that renamed away or retired,
|
|
303
|
+
// and still reports it superseded — and nothing will ever dispatch it. The
|
|
304
|
+
// replacement is in `added`, so the obligation survives the rename; keeping
|
|
305
|
+
// the dead id too would make the finding undischargeable, which is the
|
|
306
|
+
// failure this whole defect is about, reached from the other side.
|
|
307
|
+
const live = new Set(livePlanTasks(draft).map((t) => t.task_id));
|
|
308
|
+
const obligations = [
|
|
309
|
+
...new Set([...diff.added, ...diff.superseded.filter((id) => live.has(id))]),
|
|
310
|
+
];
|
|
311
|
+
if (obligations.length === 0) {
|
|
312
|
+
const shape = diff.removed.length > 0
|
|
313
|
+
? `would drop completed ${diff.removed.join(', ')} and carry the rest forward unchanged`
|
|
314
|
+
: 'would carry every task forward unchanged';
|
|
315
|
+
throw new SpecError('plan.amendment-without-obligation', `Refusing to amend plan "${epicId}" v${plan.version}: v${draft.version} ${shape}, adding no task and superseding none. Those two are the only changes that leave work to land, so ${cited.map((f) => f.finding_id).join(', ')} would have nothing to wait on and cutting the version would discharge the finding on the spot (D-127). Say which task the amendment adds or supersedes.`, {
|
|
316
|
+
epicId,
|
|
317
|
+
version: plan.version,
|
|
318
|
+
nextVersion: draft.version,
|
|
319
|
+
findingIds: [...findingIds],
|
|
320
|
+
diff,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
// Which named sites this version leaves no work to land on. Deliberately not
|
|
324
|
+
// a guard: the fix for a shape in one file legitimately lands in another, and
|
|
325
|
+
// refusing here would price the act of naming a site — pushing the next
|
|
326
|
+
// author toward the shorter list, which is the defect rather than the cure.
|
|
327
|
+
// Recorded so the closing review reads the scope question off the event
|
|
328
|
+
// instead of reconstructing it (D-123).
|
|
329
|
+
// Through `claimCoversPath`, not string equality: claims are globs, and a
|
|
330
|
+
// site under `src/bar/*.ts` is claimed whether or not it is spelled that way.
|
|
331
|
+
// That function is the one place this question gets answered (P9-15).
|
|
332
|
+
// `TaskSpecRecord.claims` is `unknown` and the plan's own schema is what
|
|
333
|
+
// guarantees it is a string array, so it is filtered rather than re-validated
|
|
334
|
+
// here — the same call `ownershipFromPlan` makes. A task with no claims
|
|
335
|
+
// contributes no coverage, which leaves its sites unclaimed rather than
|
|
336
|
+
// silently covered: the safe direction for a list whose job is to be read.
|
|
337
|
+
const obligated = new Set(obligations);
|
|
338
|
+
const claims = draft.tasks
|
|
339
|
+
.filter((t) => obligated.has(t.task_id) && Array.isArray(t.claims))
|
|
340
|
+
.flatMap((t) => t.claims);
|
|
341
|
+
const unclaimedSites = namedSites.filter((s) => !claims.some((c) => claimCoversPath(c, s)));
|
|
342
|
+
// Validation is done; from here the version exists and the record has to
|
|
343
|
+
// catch up to it, so nothing below is allowed to be conditional. The write
|
|
344
|
+
// goes through `nextVersion` rather than from `draft`: `draftNextVersion` is
|
|
345
|
+
// pure, so the two agree by construction, and there stays exactly one
|
|
346
|
+
// function in this codebase that puts a plan version on disk.
|
|
347
|
+
const amended = nextVersion(plan, input.changes ?? {}, opts);
|
|
348
|
+
await appendEvent({
|
|
349
|
+
session_id: ctx.sessionId,
|
|
350
|
+
actor: ctx.actor ?? 'system',
|
|
351
|
+
event_type: PLAN_AMENDED_EVENT,
|
|
352
|
+
task_id: planRefTaskId(epicId, amended.version),
|
|
353
|
+
plan_version: amended.version,
|
|
354
|
+
causal_parent: ctx.causalParent,
|
|
355
|
+
payload: {
|
|
356
|
+
epic_id: epicId,
|
|
357
|
+
version: amended.version,
|
|
358
|
+
previous_version: plan.version,
|
|
359
|
+
amends: cited.map((f) => ({
|
|
360
|
+
finding_id: f.finding_id,
|
|
361
|
+
criterion_ref: f.spec_ref?.criterion_ref ?? null,
|
|
362
|
+
})),
|
|
363
|
+
rationale,
|
|
364
|
+
sites: namedSites,
|
|
365
|
+
sites_unclaimed: unclaimedSites,
|
|
366
|
+
diff,
|
|
367
|
+
},
|
|
368
|
+
}, opts);
|
|
369
|
+
for (const finding of cited) {
|
|
370
|
+
await transition(finding.finding_id, AMEND_PENDING_STATUS, ctx, opts, {
|
|
371
|
+
amendsTaskIds: obligations,
|
|
372
|
+
amendsPlanVersion: amended.version,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
return { plan: amended, diff, sitesUnclaimed: unclaimedSites };
|
|
376
|
+
}
|