@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,497 @@
|
|
|
1
|
+
import { SmithError } from './errors.js';
|
|
2
|
+
import { appendEvent, readLineageEvents } from './events.js';
|
|
3
|
+
import { listFindings, mintFindings, raiseFinding, transition, } from './findings.js';
|
|
4
|
+
import { diffPlans, draftNextVersion, latestPlanVersion, livePlanTasks, validatePlan, } from './plan.js';
|
|
5
|
+
import { compileSchemas, validateRecord } from './schemas.js';
|
|
6
|
+
import { amendPlan } from './spec.js';
|
|
7
|
+
import { loadTaxonomy } from './taxonomy.js';
|
|
8
|
+
/**
|
|
9
|
+
* The worker half of a living spec.
|
|
10
|
+
*
|
|
11
|
+
* D-33 gave the operator a legitimate way out of a spec defect: `amendPlan`
|
|
12
|
+
* cuts a new plan version against the spec-scoped finding that forced it, and
|
|
13
|
+
* every version stays immutable and on the event log. It left two gaps on the
|
|
14
|
+
* other side of the wall.
|
|
15
|
+
*
|
|
16
|
+
* The first is who may say it. Only a judge dispatched with `spec: {planVersion}`
|
|
17
|
+
* can mint a spec-scoped finding, so a coder that reads a criterion, builds
|
|
18
|
+
* against it and discovers halfway through that it assumes something untrue
|
|
19
|
+
* has no way to say so. Its choices were to build the wrong thing or to fail
|
|
20
|
+
* the task, and the dogfood log has both.
|
|
21
|
+
*
|
|
22
|
+
* The second is who writes the fix. `plan amend` requires the operator to
|
|
23
|
+
* hand-author the `--changes` JSON — the content of the amendment, which the
|
|
24
|
+
* worker that hit the wall knows and the operator does not. "Approve quickly"
|
|
25
|
+
* is not possible while approving means writing the diff yourself.
|
|
26
|
+
*
|
|
27
|
+
* This module is the bridge, and its shape is forced rather than chosen. A
|
|
28
|
+
* worker cannot emit an event — only the node that dispatched it can — so the
|
|
29
|
+
* worker's signal is a field returned in `structured_output`, exactly like
|
|
30
|
+
* `research_request` and for the same reason: a returned field is the only
|
|
31
|
+
* shape that survives the worker dying mid-flight, which is precisely what
|
|
32
|
+
* discovering a wrong assumption tends to produce.
|
|
33
|
+
*
|
|
34
|
+
* What the dispatcher records is a PROPOSAL. It is data, not a command:
|
|
35
|
+
* nothing here writes a plan file, and the only path to a new version is still
|
|
36
|
+
* `amendPlan`, with every guard it already has. What approval removes is the
|
|
37
|
+
* typing, not the checking.
|
|
38
|
+
*/
|
|
39
|
+
export class SpecChangeError extends SmithError {
|
|
40
|
+
}
|
|
41
|
+
/** A worker returned a spec diff. Recorded; nothing has moved. */
|
|
42
|
+
export const SPEC_CHANGE_PROPOSED_EVENT = 'spec-change-proposed';
|
|
43
|
+
/** An operator answered one. Approval is what calls `plan amend`. */
|
|
44
|
+
export const SPEC_CHANGE_DECIDED_EVENT = 'spec-change-decided';
|
|
45
|
+
/**
|
|
46
|
+
* The severity a proposal carries when the worker states none. S2 rather than
|
|
47
|
+
* S3 because the floor matters: `raiseFinding` only consults the waiver list
|
|
48
|
+
* for `S3-minor`/`S4-nit` (D-196), so a default inside that band would let a
|
|
49
|
+
* standing waiver silently swallow the anchor an amendment has to cite.
|
|
50
|
+
*/
|
|
51
|
+
const DEFAULT_SEVERITY = 'S2-major';
|
|
52
|
+
/**
|
|
53
|
+
* Fixed, not taken from the worker. Every proposal makes the same claim — the
|
|
54
|
+
* criterion does not describe the system it is about — and that claim is a
|
|
55
|
+
* correctness one regardless of which subsystem exposed it.
|
|
56
|
+
*/
|
|
57
|
+
const PROPOSAL_CATEGORY = 'correctness';
|
|
58
|
+
let cachedTaxonomy;
|
|
59
|
+
let cachedSchemas;
|
|
60
|
+
/** Same lazy pair every module that validates a record keeps, for the same
|
|
61
|
+
* reason: compiling the schema set is not free and the set never changes
|
|
62
|
+
* inside a process. `opts` still wins, so a test can hand in its own. */
|
|
63
|
+
function loadCachedTaxonomy() {
|
|
64
|
+
if (cachedTaxonomy === undefined)
|
|
65
|
+
cachedTaxonomy = loadTaxonomy();
|
|
66
|
+
return cachedTaxonomy;
|
|
67
|
+
}
|
|
68
|
+
function resolveSchemas(opts) {
|
|
69
|
+
if (opts.schemas !== undefined)
|
|
70
|
+
return opts.schemas;
|
|
71
|
+
if (cachedSchemas === undefined)
|
|
72
|
+
cachedSchemas = compileSchemas(loadCachedTaxonomy());
|
|
73
|
+
return cachedSchemas;
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Propose
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
/**
|
|
79
|
+
* Record a worker's spec diff as a proposal.
|
|
80
|
+
*
|
|
81
|
+
* Everything validates before anything acts. A proposal reaches an operator's
|
|
82
|
+
* queue only if it could actually be applied — the draft is built and run
|
|
83
|
+
* through the plan's own validator here rather than at approval, so an
|
|
84
|
+
* unappliable diff is refused while the worker that wrote it is still the one
|
|
85
|
+
* being told, not hours later when an operator tries to say yes.
|
|
86
|
+
*
|
|
87
|
+
* It writes no plan file. The version is cut by `plan amend` alone.
|
|
88
|
+
*/
|
|
89
|
+
export async function proposeSpecChange(input, ctx, opts = {}) {
|
|
90
|
+
const { plan, taskId, proposedBy, request } = input;
|
|
91
|
+
const epicId = plan.epic_id;
|
|
92
|
+
// The shape check first, and against the published schema rather than a
|
|
93
|
+
// second reading of it here. `request` arrives from a worker's
|
|
94
|
+
// `structured_output`, which is to say from a model: it is the least
|
|
95
|
+
// trustworthy input this module takes, and the field the guards below never
|
|
96
|
+
// reach on their own is `severity`, whose taxonomy membership the schema's
|
|
97
|
+
// `x-taxonomy` pointer resolves. Without this the first thing to notice an
|
|
98
|
+
// invented severity would be the finding schema, several steps later, and
|
|
99
|
+
// it would name the finding rather than the request that malformed it.
|
|
100
|
+
const shape = validateRecord(resolveSchemas(opts), opts.taxonomy ?? loadCachedTaxonomy(), 'spec-change-request', request);
|
|
101
|
+
if (!shape.valid) {
|
|
102
|
+
throw new SpecChangeError('spec-change.proposal-malformed', `Refusing a spec change proposal from ${proposedBy} on "${taskId}": the request does not match the spec-change-request schema. ${shape.errors.map((e) => `${e.path}: ${e.message}`).join(' ')}`, { taskId, proposedBy, errors: shape.errors });
|
|
103
|
+
}
|
|
104
|
+
const criterionRef = request.criterion_ref?.trim() ?? '';
|
|
105
|
+
if (criterionRef === '') {
|
|
106
|
+
throw new SpecChangeError('spec-change.proposal-without-criterion', `Refusing a spec change proposal from ${proposedBy} on "${taskId}": it names no criterion_ref. "The plan is wrong" has to say which clause — the same rule mintFindings enforces on spec-review evidence.`, { taskId, proposedBy });
|
|
107
|
+
}
|
|
108
|
+
const assumption = request.assumption?.trim() ?? '';
|
|
109
|
+
const evidence = request.evidence?.trim() ?? '';
|
|
110
|
+
if (assumption === '' || evidence === '') {
|
|
111
|
+
throw new SpecChangeError('spec-change.proposal-without-argument', `Refusing a spec change proposal against ${criterionRef}: it states ${assumption === '' ? 'no assumption' : 'no evidence'}. The operator is being asked to overturn a criterion, and the assumption plus what contradicts it is the whole of what they have to go on.`, { taskId, criterionRef, hasAssumption: assumption !== '', hasEvidence: evidence !== '' });
|
|
112
|
+
}
|
|
113
|
+
// D-123, asked at the one point in the pipeline where the answer is known.
|
|
114
|
+
// The worker has just read the code the wrong assumption's shape occurs in;
|
|
115
|
+
// the operator has not, and `plan amend` will demand this list from whoever
|
|
116
|
+
// is standing there at approval time.
|
|
117
|
+
const sites = [...new Set((request.sites ?? []).map((site) => site.trim()))].filter((site) => site !== '');
|
|
118
|
+
if (sites.length === 0) {
|
|
119
|
+
throw new SpecChangeError('spec-change.proposal-without-sites', `Refusing a spec change proposal against ${criterionRef}: it names no sites. Every place the wrong assumption's shape occurs, not only where you hit it — you are better placed to enumerate them now than an operator is later.`, { taskId, criterionRef });
|
|
120
|
+
}
|
|
121
|
+
// The same check `amendPlan` runs on its own draft (D-21), one step earlier.
|
|
122
|
+
// Plans are immutable and nothing deletes one, so the cost of finding this
|
|
123
|
+
// out after the write is unrecoverable; the cost of finding it out here is a
|
|
124
|
+
// refusal the worker can still answer.
|
|
125
|
+
const draft = draftNextVersion(plan, request.changes ?? {});
|
|
126
|
+
const validation = validatePlan(draft, opts);
|
|
127
|
+
if (!validation.valid) {
|
|
128
|
+
throw new SpecChangeError('spec-change.proposal-invalid-draft', `Refusing a spec change proposal against ${criterionRef}: v${draft.version} would fail its own validation, so it could never be approved. ${validation.errors.map((e) => `${e.path}: ${e.message}`).join(' ')}`, { taskId, criterionRef, errors: validation.errors });
|
|
129
|
+
}
|
|
130
|
+
const diff = diffPlans(plan, draft);
|
|
131
|
+
// Mirrors `amendPlan`'s obligation rule: added tasks, plus superseded ones
|
|
132
|
+
// that are still live in the draft. A proposal that obligates nothing would
|
|
133
|
+
// be approved into a finding that discharges the instant it is parked — an
|
|
134
|
+
// amendment nobody has to build, which is another way of saying no
|
|
135
|
+
// amendment at all.
|
|
136
|
+
const live = new Set(livePlanTasks(draft).map((t) => t.task_id));
|
|
137
|
+
const obligations = [
|
|
138
|
+
...new Set([...diff.added, ...diff.superseded.filter((id) => live.has(id))]),
|
|
139
|
+
];
|
|
140
|
+
if (obligations.length === 0) {
|
|
141
|
+
throw new SpecChangeError('spec-change.proposal-without-obligation', `Refusing a spec change proposal against ${criterionRef}: the diff adds no task and supersedes no live one, so approving it would obligate nobody to build anything. Say which task's spec changes.`, { taskId, criterionRef, diff });
|
|
142
|
+
}
|
|
143
|
+
const severity = request.severity ?? DEFAULT_SEVERITY;
|
|
144
|
+
const summary = proposalSummary(criterionRef, assumption);
|
|
145
|
+
const item = {
|
|
146
|
+
file_path: sites[0],
|
|
147
|
+
finding_category: PROPOSAL_CATEGORY,
|
|
148
|
+
severity,
|
|
149
|
+
summary,
|
|
150
|
+
failure_scenario: {
|
|
151
|
+
inputs: `${taskId} built against ${criterionRef}`,
|
|
152
|
+
expected: assumption,
|
|
153
|
+
actual: evidence,
|
|
154
|
+
},
|
|
155
|
+
criterion_ref: criterionRef,
|
|
156
|
+
};
|
|
157
|
+
const drafts = mintFindings([item], {
|
|
158
|
+
taskId,
|
|
159
|
+
foundBy: proposedBy,
|
|
160
|
+
...(input.proposedByProvider === undefined
|
|
161
|
+
? {}
|
|
162
|
+
: { foundByProvider: input.proposedByProvider }),
|
|
163
|
+
// What makes this finding spec-scoped, and so routable out of the task
|
|
164
|
+
// gate to `amendPlan` instead of back to a coder with nothing to change.
|
|
165
|
+
spec: { planVersion: plan.version },
|
|
166
|
+
}, opts);
|
|
167
|
+
const minted = drafts[0];
|
|
168
|
+
if (minted === undefined)
|
|
169
|
+
throw new Error('unreachable: one evidence item mints one draft');
|
|
170
|
+
const raised = await raiseFinding(minted, ctx, opts);
|
|
171
|
+
if (raised.suppressed) {
|
|
172
|
+
// `recordSpecReview` skips a suppressed raise, and is right to: a
|
|
173
|
+
// suppressed review finding is one the operator has already answered.
|
|
174
|
+
// Here the finding is not a record of the proposal — it is the anchor
|
|
175
|
+
// `amendPlan` cites, and a proposal without one can never be approved.
|
|
176
|
+
// Failing loudly beats queueing something permanently unapprovable.
|
|
177
|
+
throw new SpecChangeError('spec-change.proposal-suppressed', `Refusing a spec change proposal against ${criterionRef}: a standing waiver on fingerprint ${raised.fingerprint} suppressed the finding an approval would have to cite, so the proposal could never be applied. Deny the waiver, or raise the proposal's severity above the waivable band.`, { taskId, criterionRef, fingerprint: raised.fingerprint, severity });
|
|
178
|
+
}
|
|
179
|
+
const stored = await appendEvent({
|
|
180
|
+
session_id: ctx.sessionId,
|
|
181
|
+
actor: ctx.actor ?? 'system',
|
|
182
|
+
event_type: SPEC_CHANGE_PROPOSED_EVENT,
|
|
183
|
+
task_id: taskId,
|
|
184
|
+
plan_version: ctx.planVersion,
|
|
185
|
+
causal_parent: ctx.causalParent,
|
|
186
|
+
payload: {
|
|
187
|
+
epic_id: epicId,
|
|
188
|
+
base_version: plan.version,
|
|
189
|
+
proposed_by: proposedBy,
|
|
190
|
+
...(input.proposedByProvider === undefined
|
|
191
|
+
? {}
|
|
192
|
+
: { proposed_by_provider: input.proposedByProvider }),
|
|
193
|
+
finding_id: raised.finding.finding_id,
|
|
194
|
+
criterion_ref: criterionRef,
|
|
195
|
+
assumption,
|
|
196
|
+
evidence,
|
|
197
|
+
sites,
|
|
198
|
+
changes: request.changes ?? {},
|
|
199
|
+
diff,
|
|
200
|
+
blocking: request.blocking === true,
|
|
201
|
+
severity,
|
|
202
|
+
},
|
|
203
|
+
}, opts);
|
|
204
|
+
return {
|
|
205
|
+
proposalId: stored.event_id,
|
|
206
|
+
epicId,
|
|
207
|
+
taskId,
|
|
208
|
+
baseVersion: plan.version,
|
|
209
|
+
proposedBy,
|
|
210
|
+
...(input.proposedByProvider === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: { proposedByProvider: input.proposedByProvider }),
|
|
213
|
+
findingId: raised.finding.finding_id,
|
|
214
|
+
criterionRef,
|
|
215
|
+
assumption,
|
|
216
|
+
evidence,
|
|
217
|
+
sites,
|
|
218
|
+
changes: request.changes ?? {},
|
|
219
|
+
diff,
|
|
220
|
+
blocking: request.blocking === true,
|
|
221
|
+
severity,
|
|
222
|
+
status: 'open',
|
|
223
|
+
decision: null,
|
|
224
|
+
ts: stored.record.ts,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The finding's summary, and so — through `computeFingerprint` — the identity
|
|
229
|
+
* two proposals against the same wrong assumption share. Built from the
|
|
230
|
+
* criterion and the assumption rather than the evidence, so a second worker
|
|
231
|
+
* that hits the same wall from a different file dedups onto the first.
|
|
232
|
+
*/
|
|
233
|
+
function proposalSummary(criterionRef, assumption) {
|
|
234
|
+
return `${criterionRef} rests on a wrong assumption: ${assumption}`;
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Read
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
/**
|
|
240
|
+
* Fold the log into proposals. Pure, and deliberately unable to say `stale`:
|
|
241
|
+
* staleness is a fact about the plan files that exist right now, and a fold
|
|
242
|
+
* that quietly read the disk would make a re-fold of an old log disagree with
|
|
243
|
+
* the log.
|
|
244
|
+
*/
|
|
245
|
+
export function foldSpecChanges(events, filter = {}) {
|
|
246
|
+
const byId = new Map();
|
|
247
|
+
for (const stored of events) {
|
|
248
|
+
const { record } = stored;
|
|
249
|
+
if (record.event_type === SPEC_CHANGE_PROPOSED_EVENT) {
|
|
250
|
+
const payload = record.payload;
|
|
251
|
+
byId.set(stored.event_id, {
|
|
252
|
+
proposalId: stored.event_id,
|
|
253
|
+
epicId: payload.epic_id ?? '',
|
|
254
|
+
taskId: record.task_id ?? '',
|
|
255
|
+
baseVersion: payload.base_version ?? 0,
|
|
256
|
+
proposedBy: payload.proposed_by ?? 'unknown',
|
|
257
|
+
...(payload.proposed_by_provider === undefined
|
|
258
|
+
? {}
|
|
259
|
+
: { proposedByProvider: payload.proposed_by_provider }),
|
|
260
|
+
findingId: payload.finding_id ?? '',
|
|
261
|
+
criterionRef: payload.criterion_ref ?? '',
|
|
262
|
+
assumption: payload.assumption ?? '',
|
|
263
|
+
evidence: payload.evidence ?? '',
|
|
264
|
+
sites: [...(payload.sites ?? [])],
|
|
265
|
+
changes: payload.changes ?? {},
|
|
266
|
+
diff: payload.diff ?? { added: [], removed: [], superseded: [], carried: [] },
|
|
267
|
+
blocking: payload.blocking === true,
|
|
268
|
+
severity: payload.severity ?? DEFAULT_SEVERITY,
|
|
269
|
+
status: 'open',
|
|
270
|
+
decision: null,
|
|
271
|
+
ts: record.ts,
|
|
272
|
+
});
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (record.event_type === SPEC_CHANGE_DECIDED_EVENT) {
|
|
276
|
+
const payload = record.payload;
|
|
277
|
+
const target = payload.proposal_id ? byId.get(payload.proposal_id) : undefined;
|
|
278
|
+
// A decision naming no proposal this fold has seen is dropped rather
|
|
279
|
+
// than invented: the proposal is the record, and half a record read back
|
|
280
|
+
// as a whole one is worse than a gap.
|
|
281
|
+
if (target === undefined)
|
|
282
|
+
continue;
|
|
283
|
+
const decision = payload.decision === 'rejected' ? 'rejected' : 'approved';
|
|
284
|
+
target.status = decision;
|
|
285
|
+
target.decision = {
|
|
286
|
+
decision,
|
|
287
|
+
decidedBy: payload.decided_by ?? 'unknown',
|
|
288
|
+
rationale: payload.rationale ?? '',
|
|
289
|
+
planVersion: payload.plan_version ?? null,
|
|
290
|
+
ts: record.ts,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return [...byId.values()].filter((p) => (filter.epicId === undefined || p.epicId === filter.epicId) &&
|
|
295
|
+
(filter.taskId === undefined || p.taskId === filter.taskId) &&
|
|
296
|
+
(filter.status === undefined || p.status === filter.status));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Has the plan moved out from under this proposal? The diff was computed
|
|
300
|
+
* against `baseVersion`; if a later version exists, nobody has checked that it
|
|
301
|
+
* still applies, and `writePlanFile` would refuse the collision anyway with a
|
|
302
|
+
* message about version numbers rather than about the proposal.
|
|
303
|
+
*
|
|
304
|
+
* A missing plan directory casts no vote (D-126) — `latestPlanVersion` returns
|
|
305
|
+
* null, and a proposal held against a plan not yet on disk stays open.
|
|
306
|
+
*/
|
|
307
|
+
export function isStale(proposal, opts = {}) {
|
|
308
|
+
const latest = latestPlanVersion(proposal.epicId, opts);
|
|
309
|
+
return latest !== null && latest > proposal.baseVersion;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The operator's queue: the fold, with staleness applied, then filtered.
|
|
313
|
+
*
|
|
314
|
+
* The status filter runs last on purpose. Asking for `open` should not return
|
|
315
|
+
* a proposal the plan has already outrun, and asking for `stale` should return
|
|
316
|
+
* exactly those — neither is answerable before the disk has been consulted.
|
|
317
|
+
*/
|
|
318
|
+
export async function listSpecChanges(sessionId, filter = {}, opts = {}) {
|
|
319
|
+
const events = await readLineageEvents(sessionId, opts);
|
|
320
|
+
const { status, ...rest } = filter;
|
|
321
|
+
const folded = foldSpecChanges(events, rest);
|
|
322
|
+
const marked = folded.map((proposal) => proposal.status === 'open' && isStale(proposal, opts)
|
|
323
|
+
? { ...proposal, status: 'stale' }
|
|
324
|
+
: proposal);
|
|
325
|
+
return status === undefined ? marked : marked.filter((p) => p.status === status);
|
|
326
|
+
}
|
|
327
|
+
async function requireProposal(proposalId, opts, ctx) {
|
|
328
|
+
const listed = await listSpecChanges(ctx.sessionId, {}, opts);
|
|
329
|
+
const found = listed.find((p) => p.proposalId === proposalId);
|
|
330
|
+
if (found === undefined) {
|
|
331
|
+
throw new SpecChangeError('spec-change.unknown-proposal', `No spec change proposal "${proposalId}" on this session's lineage. \`smith plan proposals\` lists what is there.`, { proposalId, sessionId: ctx.sessionId });
|
|
332
|
+
}
|
|
333
|
+
return found;
|
|
334
|
+
}
|
|
335
|
+
function refuseIfDecided(proposal) {
|
|
336
|
+
if (proposal.decision !== null) {
|
|
337
|
+
throw new SpecChangeError('spec-change.already-decided', `Spec change proposal ${proposal.proposalId} was already ${proposal.decision.decision} by ${proposal.decision.decidedBy}. A decision is a fact on the log, not a draft — re-propose against the current plan version instead.`, { proposalId: proposal.proposalId, decision: proposal.decision.decision });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// Decide
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
/**
|
|
344
|
+
* Approve a proposal: cut the version the worker asked for.
|
|
345
|
+
*
|
|
346
|
+
* This adds no power. It delegates to `amendPlan`, which still demands a
|
|
347
|
+
* spec-scoped finding, a non-blank rationale, a site list, a draft that
|
|
348
|
+
* validates and a diff that obligates somebody — every one of those is
|
|
349
|
+
* satisfied from the proposal rather than relaxed. What the operator no longer
|
|
350
|
+
* has to do is type them.
|
|
351
|
+
*/
|
|
352
|
+
export async function approveSpecChange(input, ctx, opts = {}) {
|
|
353
|
+
const proposal = await requireProposal(input.proposalId, opts, ctx);
|
|
354
|
+
refuseIfDecided(proposal);
|
|
355
|
+
if (input.plan.epic_id !== proposal.epicId) {
|
|
356
|
+
throw new SpecChangeError('spec-change.approval-wrong-epic', `Spec change proposal ${proposal.proposalId} is against epic "${proposal.epicId}", but the plan supplied is "${input.plan.epic_id}". Amending the wrong epic from the right proposal is the one mistake this guard exists to make impossible.`, { proposalId: proposal.proposalId, expected: proposal.epicId, given: input.plan.epic_id });
|
|
357
|
+
}
|
|
358
|
+
// Fail closed on both axes of staleness, the same pair `specReviewBlockers`
|
|
359
|
+
// reads (D-125/D-126): the version the proposal was drafted against, and the
|
|
360
|
+
// versions that exist now. Without this the fallback is `writePlanFile`'s
|
|
361
|
+
// `plan.version-exists` — a correct refusal carrying a message about file
|
|
362
|
+
// names, at the point where the operator wanted to hear about their diff.
|
|
363
|
+
const latest = latestPlanVersion(proposal.epicId, opts);
|
|
364
|
+
const overtaken = latest !== null && latest > proposal.baseVersion;
|
|
365
|
+
if (input.plan.version !== proposal.baseVersion || overtaken) {
|
|
366
|
+
const current = overtaken ? latest : input.plan.version;
|
|
367
|
+
throw new SpecChangeError('spec-change.approval-stale', `Spec change proposal ${proposal.proposalId} was drafted against "${proposal.epicId}" v${proposal.baseVersion}, and the plan has since moved to v${current}. Its diff has not been checked against the newer version — re-propose against v${current} rather than applying it blind.`, {
|
|
368
|
+
proposalId: proposal.proposalId,
|
|
369
|
+
baseVersion: proposal.baseVersion,
|
|
370
|
+
planVersion: input.plan.version,
|
|
371
|
+
latestVersion: latest,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
// The point of the whole exercise. `amendPlan` refuses a blank rationale,
|
|
375
|
+
// and rightly — the diff records what moved and only the rationale records
|
|
376
|
+
// why. Approval stays one command by REUSING the worker's own recorded
|
|
377
|
+
// argument, not by relaxing that guard and not by inventing a sentence: the
|
|
378
|
+
// reason on the log is the reason the change was made.
|
|
379
|
+
const rationale = input.rationale?.trim() !== undefined && input.rationale.trim() !== ''
|
|
380
|
+
? input.rationale.trim()
|
|
381
|
+
: defaultRationale(proposal);
|
|
382
|
+
const amendment = await amendPlan({
|
|
383
|
+
plan: input.plan,
|
|
384
|
+
findingIds: [proposal.findingId],
|
|
385
|
+
rationale,
|
|
386
|
+
sites: proposal.sites,
|
|
387
|
+
changes: proposal.changes,
|
|
388
|
+
}, ctx, opts);
|
|
389
|
+
const stored = await appendEvent({
|
|
390
|
+
session_id: ctx.sessionId,
|
|
391
|
+
actor: ctx.actor ?? 'system',
|
|
392
|
+
event_type: SPEC_CHANGE_DECIDED_EVENT,
|
|
393
|
+
task_id: proposal.taskId,
|
|
394
|
+
plan_version: ctx.planVersion,
|
|
395
|
+
causal_parent: ctx.causalParent,
|
|
396
|
+
payload: {
|
|
397
|
+
proposal_id: proposal.proposalId,
|
|
398
|
+
epic_id: proposal.epicId,
|
|
399
|
+
decision: 'approved',
|
|
400
|
+
decided_by: input.decidedBy,
|
|
401
|
+
rationale,
|
|
402
|
+
plan_version: amendment.plan.version,
|
|
403
|
+
},
|
|
404
|
+
}, opts);
|
|
405
|
+
return {
|
|
406
|
+
proposal: {
|
|
407
|
+
...proposal,
|
|
408
|
+
status: 'approved',
|
|
409
|
+
decision: {
|
|
410
|
+
decision: 'approved',
|
|
411
|
+
decidedBy: input.decidedBy,
|
|
412
|
+
rationale,
|
|
413
|
+
planVersion: amendment.plan.version,
|
|
414
|
+
ts: stored.record.ts,
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
plan: amendment.plan,
|
|
418
|
+
diff: amendment.diff,
|
|
419
|
+
sitesUnclaimed: amendment.sitesUnclaimed,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The worker's argument, replayed as the amendment's reason.
|
|
424
|
+
*
|
|
425
|
+
* Nothing here is the machine's opinion: every clause is a field the worker
|
|
426
|
+
* wrote. That is what makes it safe to use unattended — an operator who wants
|
|
427
|
+
* to say something else passes `--rationale` and this is never reached.
|
|
428
|
+
*/
|
|
429
|
+
function defaultRationale(proposal) {
|
|
430
|
+
return `Approved ${proposal.proposalId}: ${proposal.proposedBy} on ${proposal.taskId} found that ${proposal.criterionRef} assumes "${proposal.assumption}", contradicted by ${proposal.evidence}.`;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Reject a proposal: the criterion stands.
|
|
434
|
+
*
|
|
435
|
+
* This refutes the finding, which is the strong reading and the reason the
|
|
436
|
+
* rationale is mandatory — the worker is being sent back to build against the
|
|
437
|
+
* criterion it just argued was wrong, and "no" without a reason is an
|
|
438
|
+
* instruction it cannot act on.
|
|
439
|
+
*
|
|
440
|
+
* There is a third answer this deliberately does not cover. An operator who
|
|
441
|
+
* agrees the criterion is wrong but dislikes the proposed shape does not
|
|
442
|
+
* reject: they run `plan amend` citing the same still-`raised` finding with
|
|
443
|
+
* their own `--changes`. That path needs no code here, and rejection stays
|
|
444
|
+
* unambiguous because of it.
|
|
445
|
+
*/
|
|
446
|
+
export async function rejectSpecChange(input, ctx, opts = {}) {
|
|
447
|
+
const proposal = await requireProposal(input.proposalId, opts, ctx);
|
|
448
|
+
refuseIfDecided(proposal);
|
|
449
|
+
const rationale = input.rationale?.trim() ?? '';
|
|
450
|
+
if (rationale === '') {
|
|
451
|
+
throw new SpecChangeError('spec-change.rejection-without-rationale', `Refusing to reject spec change proposal ${proposal.proposalId} without a rationale. The worker is being sent back to build against ${proposal.criterionRef} after arguing it is wrong; "no" on its own is not something it can act on.`, { proposalId: proposal.proposalId, criterionRef: proposal.criterionRef });
|
|
452
|
+
}
|
|
453
|
+
const stored = await appendEvent({
|
|
454
|
+
session_id: ctx.sessionId,
|
|
455
|
+
actor: ctx.actor ?? 'system',
|
|
456
|
+
event_type: SPEC_CHANGE_DECIDED_EVENT,
|
|
457
|
+
task_id: proposal.taskId,
|
|
458
|
+
plan_version: ctx.planVersion,
|
|
459
|
+
causal_parent: ctx.causalParent,
|
|
460
|
+
payload: {
|
|
461
|
+
proposal_id: proposal.proposalId,
|
|
462
|
+
epic_id: proposal.epicId,
|
|
463
|
+
decision: 'rejected',
|
|
464
|
+
decided_by: input.decidedBy,
|
|
465
|
+
rationale,
|
|
466
|
+
plan_version: null,
|
|
467
|
+
},
|
|
468
|
+
}, opts);
|
|
469
|
+
await refuteAnchor(proposal, ctx, opts);
|
|
470
|
+
return {
|
|
471
|
+
...proposal,
|
|
472
|
+
status: 'rejected',
|
|
473
|
+
decision: {
|
|
474
|
+
decision: 'rejected',
|
|
475
|
+
decidedBy: input.decidedBy,
|
|
476
|
+
rationale,
|
|
477
|
+
planVersion: null,
|
|
478
|
+
ts: stored.record.ts,
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Close the finding the proposal raised. Best-effort by design: a finding an
|
|
484
|
+
* operator has already waived or expired by hand is a decision that outranks
|
|
485
|
+
* this one, and a rejection that threw over it would leave the decision on the
|
|
486
|
+
* log with no way to record the same answer twice.
|
|
487
|
+
*/
|
|
488
|
+
async function refuteAnchor(proposal, ctx, opts) {
|
|
489
|
+
const findings = await listFindings(ctx.sessionId, {}, opts);
|
|
490
|
+
const anchor = findings.find((f) => f.finding_id === proposal.findingId);
|
|
491
|
+
if (anchor === undefined || anchor.finding_status !== 'raised')
|
|
492
|
+
return;
|
|
493
|
+
// The reason rides on `spec-change-decided`, where a reader looking for why
|
|
494
|
+
// the proposal died will actually be standing. Repeating it on the
|
|
495
|
+
// transition would be a second copy free to drift from the first.
|
|
496
|
+
await transition(proposal.findingId, 'refuted', ctx, opts);
|
|
497
|
+
}
|