@juzser/blacksmith 0.3.0 → 0.5.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 → bs-auditor.md} +8 -5
- package/.claude/agents/{coder.md → bs-coder.md} +8 -7
- package/.claude/agents/{grader.md → bs-grader.md} +16 -9
- package/.claude/agents/{merger.md → bs-merger.md} +4 -4
- package/.claude/agents/{planner.md → bs-planner.md} +20 -6
- package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
- package/.claude/agents/{reviewer.md → bs-reviewer.md} +16 -5
- package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
- package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
- package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -10
- package/.claude/agents/{tester.md → bs-tester.md} +40 -12
- package/.claude/agents/bs-uiux.md +257 -0
- package/.claude/agents/{verifier.md → bs-verifier.md} +7 -4
- package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
- package/.claude/skills/bs/SKILL.md +99 -21
- package/.claude/skills/bs/audit.md +45 -31
- package/.claude/skills/bs/dispatch.md +133 -47
- package/.claude/skills/bs/lessons.md +28 -14
- package/.claude/skills/bs/mcp.md +4 -4
- package/.claude/skills/bs/new.md +3 -3
- package/.claude/skills/bs/plan.md +57 -16
- package/.claude/skills/bs/report.md +11 -11
- package/.claude/skills/bs/run.md +190 -52
- package/.claude/skills/bs/status.md +14 -7
- package/.claude/skills/bs/ui.md +21 -5
- package/.claude/skills/bs/waivers.md +78 -2
- package/.claude/skills/bs/wave.md +143 -50
- package/README.md +53 -32
- package/factory/orchestrator/dist/agentNames.js +43 -0
- package/factory/orchestrator/dist/agents-registry.js +11 -4
- package/factory/orchestrator/dist/agentsSync.js +70 -13
- package/factory/orchestrator/dist/artifacts.js +31 -0
- package/factory/orchestrator/dist/attribution.js +4 -0
- package/factory/orchestrator/dist/audit.js +245 -20
- package/factory/orchestrator/dist/autonomy.js +6 -2
- package/factory/orchestrator/dist/budgetAlarm.js +132 -37
- package/factory/orchestrator/dist/budgets.js +302 -84
- package/factory/orchestrator/dist/claims.js +9 -19
- package/factory/orchestrator/dist/cli.js +1087 -116
- package/factory/orchestrator/dist/cliName.js +27 -0
- package/factory/orchestrator/dist/crossFinding.js +20 -0
- package/factory/orchestrator/dist/crosscheck.js +136 -8
- package/factory/orchestrator/dist/daemon.js +352 -67
- package/factory/orchestrator/dist/db/projector.js +327 -54
- package/factory/orchestrator/dist/db/queries.js +1831 -94
- package/factory/orchestrator/dist/db/schema.js +57 -4
- package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
- package/factory/orchestrator/dist/delegation.js +86 -19
- package/factory/orchestrator/dist/diffstat.js +15 -8
- package/factory/orchestrator/dist/dispatchLint.js +108 -0
- package/factory/orchestrator/dist/effort.js +14 -2
- package/factory/orchestrator/dist/effortTiers.js +13 -0
- package/factory/orchestrator/dist/env.js +26 -0
- package/factory/orchestrator/dist/epic.js +494 -42
- package/factory/orchestrator/dist/epicBudget.js +93 -0
- package/factory/orchestrator/dist/epicWidth.js +22 -5
- package/factory/orchestrator/dist/errorIssues.js +182 -11
- package/factory/orchestrator/dist/escalation.js +2 -2
- package/factory/orchestrator/dist/events.js +164 -14
- package/factory/orchestrator/dist/feedback.js +169 -0
- package/factory/orchestrator/dist/findingContext.js +26 -0
- package/factory/orchestrator/dist/findings.js +108 -14
- package/factory/orchestrator/dist/gate.js +120 -10
- package/factory/orchestrator/dist/gh.js +19 -2
- package/factory/orchestrator/dist/git.js +20 -2
- package/factory/orchestrator/dist/goalCheck.js +2 -2
- package/factory/orchestrator/dist/harness.js +25 -17
- package/factory/orchestrator/dist/hookDecision.js +411 -39
- package/factory/orchestrator/dist/integration.js +20 -8
- package/factory/orchestrator/dist/issueReporter.js +81 -16
- package/factory/orchestrator/dist/judgePreflight.js +25 -4
- package/factory/orchestrator/dist/judgeRoles.js +47 -0
- package/factory/orchestrator/dist/judgeStopHook.js +239 -0
- package/factory/orchestrator/dist/judges.js +419 -21
- package/factory/orchestrator/dist/lessons.js +190 -11
- package/factory/orchestrator/dist/logCache.js +291 -0
- package/factory/orchestrator/dist/mcp.js +6 -6
- package/factory/orchestrator/dist/paths.js +20 -7
- package/factory/orchestrator/dist/plan.js +399 -33
- package/factory/orchestrator/dist/planQuorum.js +5 -2
- package/factory/orchestrator/dist/policy.js +1275 -30
- package/factory/orchestrator/dist/projectName.js +25 -0
- package/factory/orchestrator/dist/projects.js +12 -1
- package/factory/orchestrator/dist/promptCapture.js +306 -0
- package/factory/orchestrator/dist/promptHook.js +33 -0
- package/factory/orchestrator/dist/promptImport.js +281 -0
- package/factory/orchestrator/dist/prompts.js +13 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
- package/factory/orchestrator/dist/providers/types.js +1 -1
- package/factory/orchestrator/dist/queue.js +471 -30
- package/factory/orchestrator/dist/quorum.js +18 -5
- package/factory/orchestrator/dist/results.js +25 -7
- package/factory/orchestrator/dist/roadmap.js +10 -5
- package/factory/orchestrator/dist/run-cli.js +15 -10
- package/factory/orchestrator/dist/runner.js +2 -2
- package/factory/orchestrator/dist/scheduler.js +3 -3
- package/factory/orchestrator/dist/schemas.js +56 -8
- package/factory/orchestrator/dist/severity.js +8 -4
- package/factory/orchestrator/dist/spec.js +246 -12
- package/factory/orchestrator/dist/specChange.js +13 -9
- package/factory/orchestrator/dist/stack.js +6 -6
- package/factory/orchestrator/dist/storeRoot.js +90 -0
- package/factory/orchestrator/dist/symbols.js +261 -6
- package/factory/orchestrator/dist/taskEvents.js +192 -31
- package/factory/orchestrator/dist/taskStatus.js +14 -0
- package/factory/orchestrator/dist/taxonomy.js +11 -0
- package/factory/orchestrator/dist/testerAudit.js +31 -12
- package/factory/orchestrator/dist/testgate.js +20 -16
- package/factory/orchestrator/dist/uiuxGate.js +83 -0
- package/factory/orchestrator/dist/usage.js +113 -48
- package/factory/orchestrator/dist/waivers.js +99 -12
- package/factory/orchestrator/dist/waveBudget.js +2 -0
- package/factory/orchestrator/dist/waveConcurrency.js +143 -7
- package/factory/orchestrator/dist/waveSchedule.js +84 -0
- package/factory/orchestrator/dist/workroot.js +1 -1
- package/factory/orchestrator/dist/worktree.js +76 -21
- package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
- package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
- package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
- package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
- package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
- package/factory/policies/budgets.yml +123 -33
- package/factory/policies/crosscheck.yml +51 -13
- package/factory/policies/delegation.yml +3 -3
- package/factory/policies/effort.yml +18 -10
- package/factory/policies/guardrails.yml +8 -6
- package/factory/policies/harness.yml +5 -5
- package/factory/policies/lessons.md +53 -2
- package/factory/policies/scheduler.yml +4 -4
- package/factory/policies/sensitive-paths.yml +1 -1
- package/factory/policies/severity.yml +13 -1
- package/factory/policies/stack.yml +6 -6
- package/factory/policies/taxonomy.yml +9 -3
- package/factory/policies/worktree.yml +15 -9
- package/factory/specs/roadmap.md +27 -3
- package/factory/specs/schema/event.schema.json +8 -1
- package/factory/specs/schema/result.schema.json +21 -8
- package/factory/specs/schema/task-spec.schema.json +12 -0
- package/package.json +16 -12
- package/.claude/agents/uiux.md +0 -133
|
@@ -6,8 +6,14 @@ import {
|
|
|
6
6
|
// since D-127 that is reachable only once the amendment's task ids land, so
|
|
7
7
|
// a guard here that asked about the terminal status would be asking about a
|
|
8
8
|
// transition this module never attempts.
|
|
9
|
-
AMEND_PENDING_STATUS, findingScope, LEGAL_TRANSITIONS, listFindings, mintFindings,
|
|
10
|
-
|
|
9
|
+
AMEND_PENDING_STATUS, findingScope, LEGAL_TRANSITIONS, listFindings, mintFindings,
|
|
10
|
+
// #221: the vocabulary for "still awaiting something" this codebase already
|
|
11
|
+
// has one of — the same set epic.ts's close gate and `smith crossfind run`
|
|
12
|
+
// read. Reused here rather than re-deriving "open" from LEGAL_TRANSITIONS a
|
|
13
|
+
// second time.
|
|
14
|
+
OPEN_FINDING_STATUSES, raiseFinding, SPEC_FINDING_SCOPE, transition, } from './findings.js';
|
|
15
|
+
import { bareTaskId, diffPlans, draftNextVersion, livePlanTasks, nextVersion, PlanError, planRefTaskId, resolveTaskId, validatePlan, } from './plan.js';
|
|
16
|
+
import { readAddedTasks, supersededStubFromLog } from './taskEvents.js';
|
|
11
17
|
import { RESERVED_TASK_ID } from './worktree.js';
|
|
12
18
|
/**
|
|
13
19
|
* P9-9/D-33. Every judge in this factory returns findings against a diff, and
|
|
@@ -68,6 +74,131 @@ export function latestSpecReview(events, epicId) {
|
|
|
68
74
|
}
|
|
69
75
|
return latest;
|
|
70
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Old task id -> the replacement that superseded it, folded across every
|
|
79
|
+
* `plan-version-created` event this epic has (D-<successor-chain>). Not a
|
|
80
|
+
* last-wins fold like `latestSpecReview`: a task superseded in v2 and its own
|
|
81
|
+
* successor superseded again in v4 contributes two independent entries to
|
|
82
|
+
* the same map, and epic.ts's `resolveSupersededRow` walks the chain — each
|
|
83
|
+
* amendment only ever needs to know its own one hop.
|
|
84
|
+
*
|
|
85
|
+
* Only the rename-supersede case is ever present here: `amendPlan` records a
|
|
86
|
+
* pairing only when the replacement's `task_id` differs from the id it
|
|
87
|
+
* replaces (the same-id case needs no successor — the fold's own row already
|
|
88
|
+
* carries the work forward under the id everyone already has).
|
|
89
|
+
*
|
|
90
|
+
* Events on the log from before this field shipped carry no `successors` key
|
|
91
|
+
* at all — only the plan `diff` `amendPlan` has always written. For exactly
|
|
92
|
+
* that shape (no `successors` key, present or absent notwithstanding), fall
|
|
93
|
+
* back to inferring the pairing from `diff.superseded`/`diff.added`, but only
|
|
94
|
+
* when it is completely unambiguous: one id on each side, and they differ.
|
|
95
|
+
* Any other shape (0 or ≥2 ids on either side, a missing/malformed diff)
|
|
96
|
+
* infers nothing — the same fail-closed answer an unreadable `successors`
|
|
97
|
+
* gets. A present `successors` — even an explicit `{}` — always wins over
|
|
98
|
+
* inference; the fallback only ever runs when the key is absent entirely.
|
|
99
|
+
*/
|
|
100
|
+
export function taskSuccessors(events, epicId) {
|
|
101
|
+
const successors = new Map();
|
|
102
|
+
for (const event of events) {
|
|
103
|
+
if (event.record.event_type !== PLAN_AMENDED_EVENT)
|
|
104
|
+
continue;
|
|
105
|
+
const payload = event.record.payload;
|
|
106
|
+
if (payload.epic_id !== epicId)
|
|
107
|
+
continue;
|
|
108
|
+
const entries = payload.successors;
|
|
109
|
+
if (entries !== undefined) {
|
|
110
|
+
if (entries === null || typeof entries !== 'object')
|
|
111
|
+
continue;
|
|
112
|
+
for (const [oldId, newId] of Object.entries(entries)) {
|
|
113
|
+
if (typeof newId === 'string')
|
|
114
|
+
successors.set(oldId, newId);
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// Legacy fallback (no `successors` key at all) — see doc comment above.
|
|
119
|
+
const diff = payload.diff;
|
|
120
|
+
if (diff === null || typeof diff !== 'object')
|
|
121
|
+
continue;
|
|
122
|
+
const supersededIds = diff.superseded;
|
|
123
|
+
const addedIds = diff.added;
|
|
124
|
+
if (!Array.isArray(supersededIds) || !Array.isArray(addedIds))
|
|
125
|
+
continue;
|
|
126
|
+
if (supersededIds.length !== 1 || addedIds.length !== 1)
|
|
127
|
+
continue;
|
|
128
|
+
const [oldId] = supersededIds;
|
|
129
|
+
const [newId] = addedIds;
|
|
130
|
+
if (typeof oldId === 'string' && typeof newId === 'string' && oldId !== newId) {
|
|
131
|
+
successors.set(oldId, newId);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return successors;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Walks `taskSuccessors`' one-hop map forward from `from` and returns the
|
|
138
|
+
* ordered bare hop ids from the one right after `from` through `target`
|
|
139
|
+
* inclusive — or `undefined` if `target` never turns up on that chain.
|
|
140
|
+
* `worktree create --from` (cli.ts's `worktree.not-a-successor` guard) used
|
|
141
|
+
* to ask `successors.get(from)` alone, which only ever names the *direct*
|
|
142
|
+
* successor: a plan amended twice before the intermediate successor is ever
|
|
143
|
+
* cut — A superseded by B in v6, B superseded by C in v7, B never cut —
|
|
144
|
+
* leaves `taskSuccessors` with two independent one-hop entries (A -> B,
|
|
145
|
+
* B -> C) and no direct A -> C entry at all, so cutting C `--from A` was
|
|
146
|
+
* refused despite the log naming C as A's transitive successor.
|
|
147
|
+
*
|
|
148
|
+
* Every hop the walk passes through on the way to `target` comes back too,
|
|
149
|
+
* not just the yes/no answer: `createTaskWorktree` (worktree.ts) branches
|
|
150
|
+
* `--from`'s target directly off `from`'s own branch, so if some *hop in
|
|
151
|
+
* between* was already cut as its own branch, skipping straight from `from`
|
|
152
|
+
* to `target` silently drops that hop's commits. Answering that needs git
|
|
153
|
+
* state this module deliberately never touches, so the walk hands its own
|
|
154
|
+
* intermediate hops back and leaves the git-aware check — and which hop to
|
|
155
|
+
* refuse on — to the caller (cli.ts's guard, paired with worktree.ts's
|
|
156
|
+
* branch-existence check).
|
|
157
|
+
*
|
|
158
|
+
* Keys are normalized to their bare form once (`successors` entries can be
|
|
159
|
+
* bare or `${epicId}/${id}` at any hop, D-46/P9-29), so each hop after that
|
|
160
|
+
* is one lookup against the normalized map — the same "check both
|
|
161
|
+
* spellings" a single-hop lookup did, done once instead of re-derived every
|
|
162
|
+
* hop. A cycle — a successor id already visited — stops the walk and
|
|
163
|
+
* reports no match, the same fail-closed answer as a chain that simply runs
|
|
164
|
+
* out; a malformed log must never spin.
|
|
165
|
+
*
|
|
166
|
+
* Directional only: this walks forward from `from`, value to value, never a
|
|
167
|
+
* hop's key. A `target` that is a predecessor of `from` is therefore never
|
|
168
|
+
* found, however far back it sits — `--from` claims a task's own future, not
|
|
169
|
+
* its past.
|
|
170
|
+
*/
|
|
171
|
+
export function successorChainPath(epicId, from, target, successors) {
|
|
172
|
+
const bareSuccessors = new Map([...successors].map(([oldId, newId]) => [bareTaskId(epicId, oldId), newId]));
|
|
173
|
+
const targetBare = bareTaskId(epicId, target);
|
|
174
|
+
const visited = new Set([bareTaskId(epicId, from)]);
|
|
175
|
+
let currentId = bareTaskId(epicId, from);
|
|
176
|
+
const path = [];
|
|
177
|
+
for (;;) {
|
|
178
|
+
const nextId = bareSuccessors.get(currentId);
|
|
179
|
+
if (nextId === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
const nextBare = bareTaskId(epicId, nextId);
|
|
182
|
+
path.push(nextBare);
|
|
183
|
+
if (nextBare === targetBare)
|
|
184
|
+
return path;
|
|
185
|
+
if (visited.has(nextBare))
|
|
186
|
+
return undefined;
|
|
187
|
+
visited.add(nextBare);
|
|
188
|
+
currentId = nextBare;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Answers whether `target` sits anywhere on `from`'s forward successor
|
|
193
|
+
* chain — not only at the first hop. A thin boolean wrapper over
|
|
194
|
+
* `successorChainPath`; callers that also need to know *which* hops sit
|
|
195
|
+
* between `from` and `target` (cli.ts's `worktree create --from` guard, to
|
|
196
|
+
* refuse skipping past a hop already cut as its own branch) want that
|
|
197
|
+
* function directly instead.
|
|
198
|
+
*/
|
|
199
|
+
export function isTaskInSuccessorChain(epicId, from, target, successors) {
|
|
200
|
+
return successorChainPath(epicId, from, target, successors) !== undefined;
|
|
201
|
+
}
|
|
71
202
|
/**
|
|
72
203
|
* The epic gate refusing to certify a spec nobody re-read once the code
|
|
73
204
|
* existed. Mirrors integrationBlockers exactly, including the fail-closed
|
|
@@ -95,11 +226,26 @@ export function latestSpecReview(events, epicId) {
|
|
|
95
226
|
* a required parameter for the reason `integration` and `mcp` are required on
|
|
96
227
|
* summarizeEpic — a defaulted one is a forgotten argument that manufactures a
|
|
97
228
|
* green.
|
|
229
|
+
*
|
|
230
|
+
* `effort` is the one legal way an epic reaches this gate with no review on
|
|
231
|
+
* record at all: `.claude/skills/bs/run.md` step 13 lets tier `small` skip
|
|
232
|
+
* the closing review while the plan is still v1 — never amended, so never
|
|
233
|
+
* shown a defect a review would have caught. That is not a second escape
|
|
234
|
+
* hatch grown here; it is that rule, read. The skip is narrow on purpose: it
|
|
235
|
+
* only ever waives an *absent* review. A plan at v2+, or a plan version this
|
|
236
|
+
* function cannot read, still blocks regardless of tier, and a review that
|
|
237
|
+
* IS on record is still walked through every staleness check below even
|
|
238
|
+
* under `small` — the tier says "you may have skipped it," never "you may
|
|
239
|
+
* trust it once stale." Like `planVersion`, `effort` is a required parameter
|
|
240
|
+
* for the same reason: a defaulted one would silently reinstate the escape
|
|
241
|
+
* hatch this function has never had.
|
|
98
242
|
*/
|
|
99
|
-
export function specReviewBlockers(epicId, status, planVersion) {
|
|
100
|
-
const branch =
|
|
101
|
-
const { review, headSha } = status;
|
|
243
|
+
export function specReviewBlockers(epicId, status, planVersion, effort) {
|
|
244
|
+
const { review, headSha, branch } = status;
|
|
102
245
|
if (review === null) {
|
|
246
|
+
if (effort.closingSpecReview === 'when-plan-amended' && planVersion === 1) {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
103
249
|
return [
|
|
104
250
|
`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
251
|
];
|
|
@@ -190,6 +336,7 @@ export async function recordSpecReview(input, ctx, opts = {}) {
|
|
|
190
336
|
: { reviewed_by_provider: input.reviewedByProvider }),
|
|
191
337
|
finding_ids: findingIds,
|
|
192
338
|
finding_count: findingIds.length,
|
|
339
|
+
...(input.noFindings === true ? { attested_by: 'operator' } : {}),
|
|
193
340
|
},
|
|
194
341
|
}, opts);
|
|
195
342
|
return {
|
|
@@ -202,6 +349,55 @@ export async function recordSpecReview(input, ctx, opts = {}) {
|
|
|
202
349
|
ts: stored.record.ts,
|
|
203
350
|
};
|
|
204
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* Resolve every `changes.supersede` key to the task it names, before anything
|
|
354
|
+
* is drafted: the plan's own task, or one only the epic's log added.
|
|
355
|
+
*
|
|
356
|
+
* A key the plan does not list used to match nothing in `draftNextVersion` and
|
|
357
|
+
* its replacement was appended anyway, so a follow-up the gate minted (a
|
|
358
|
+
* `task-added` and no plan record) stayed live beside the task meant to
|
|
359
|
+
* replace it, and a typo did the same. The key now goes through
|
|
360
|
+
* `resolveTaskId` with the log's ids as `alsoKnown`, so a bare and a qualified
|
|
361
|
+
* spelling both work and a key naming nothing is `plan.unknown-task`, refused
|
|
362
|
+
* before a plan file exists. A key resolving to a plan task is rewritten to
|
|
363
|
+
* that task's own id. A key resolving to a log-only task yields the dead
|
|
364
|
+
* record `draftNextVersion` carries for it (`supersededStubFromLog`).
|
|
365
|
+
*
|
|
366
|
+
* The log is read lineage-wide and narrowed to this epic, the way the
|
|
367
|
+
* follow-up splice in `wave next` narrows it: another epic's row is not a
|
|
368
|
+
* task of this plan.
|
|
369
|
+
*
|
|
370
|
+
* Exported for `proposeSpecChange`, whose pre-check drafts the same version
|
|
371
|
+
* approval will cut: a draft built from unresolved keys would record a diff
|
|
372
|
+
* that approval then contradicts.
|
|
373
|
+
*/
|
|
374
|
+
export async function resolveSupersedeKeys(plan, changes, ctx, opts) {
|
|
375
|
+
const supersede = changes.supersede;
|
|
376
|
+
// A shape no map-shaped read can make is `draftNextVersion`'s to refuse,
|
|
377
|
+
// with its own message.
|
|
378
|
+
if (typeof supersede !== 'object' || supersede === null || Array.isArray(supersede)) {
|
|
379
|
+
return { changes, loggedStubs: [] };
|
|
380
|
+
}
|
|
381
|
+
const keys = Object.keys(supersede);
|
|
382
|
+
if (keys.length === 0)
|
|
383
|
+
return { changes, loggedStubs: [] };
|
|
384
|
+
const logged = (await readAddedTasks({ sessionId: ctx.sessionId }, opts)).filter((t) => t.epicId === plan.epic_id);
|
|
385
|
+
const planIds = new Set(plan.tasks.map((t) => t.task_id));
|
|
386
|
+
const resolved = {};
|
|
387
|
+
const loggedStubs = new Map();
|
|
388
|
+
for (const [key, replacement] of Object.entries(supersede)) {
|
|
389
|
+
const id = resolveTaskId(plan, key, logged.map((t) => t.taskId));
|
|
390
|
+
if (id in resolved) {
|
|
391
|
+
throw new PlanError('plan.ambiguous-task', `changes.supersede names "${id}" twice (as "${key}" and under another spelling); one task is superseded once.`, { epicId: plan.epic_id, version: plan.version, typed: key, candidates: [id] });
|
|
392
|
+
}
|
|
393
|
+
resolved[id] = replacement;
|
|
394
|
+
const added = planIds.has(id) ? undefined : logged.find((t) => t.taskId === id);
|
|
395
|
+
if (added !== undefined) {
|
|
396
|
+
loggedStubs.set(id, supersededStubFromLog(added, plan.epic_id, plan.version + 1));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return { changes: { ...changes, supersede: resolved }, loggedStubs: [...loggedStubs.values()] };
|
|
400
|
+
}
|
|
205
401
|
/**
|
|
206
402
|
* The one legitimate way to change an immutable plan.
|
|
207
403
|
*
|
|
@@ -273,7 +469,8 @@ export async function amendPlan(input, ctx, opts = {}) {
|
|
|
273
469
|
// `draftNextVersion` is for: "everything validates before anything acts"
|
|
274
470
|
// only holds if this check can run before a plan file exists, and nothing in
|
|
275
471
|
// this codebase deletes a plan file (D-127).
|
|
276
|
-
const
|
|
472
|
+
const { changes, loggedStubs } = await resolveSupersedeKeys(plan, input.changes ?? {}, ctx, opts);
|
|
473
|
+
const draft = draftNextVersion(plan, changes, loggedStubs);
|
|
277
474
|
// D-21: a malformed `--changes` file can still produce a draft that reads
|
|
278
475
|
// fine structurally (every entry a plain object with a string task_id) but
|
|
279
476
|
// fails the plan's own schema/taxonomy rules — the exact check `smith plan
|
|
@@ -281,9 +478,9 @@ export async function amendPlan(input, ctx, opts = {}) {
|
|
|
281
478
|
// schema-invalid version written to disk is unrecoverable; refusing it here,
|
|
282
479
|
// before the write, is what closes that loop rather than leaving `smith
|
|
283
480
|
// plan validate` to reject the file this tool just produced.
|
|
284
|
-
const draftValidation = validatePlan(draft, opts);
|
|
481
|
+
const draftValidation = validatePlan(draft, { ...opts, previous: plan });
|
|
285
482
|
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 \`
|
|
483
|
+
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 \`bs plan validate\` runs), so nothing was written. ${draftValidation.errors
|
|
287
484
|
.map((e) => `${e.path}: ${e.message}`)
|
|
288
485
|
.join(' ')}`, { epicId, version: plan.version, nextVersion: draft.version, errors: draftValidation.errors });
|
|
289
486
|
}
|
|
@@ -304,9 +501,12 @@ export async function amendPlan(input, ctx, opts = {}) {
|
|
|
304
501
|
// replacement is in `added`, so the obligation survives the rename; keeping
|
|
305
502
|
// the dead id too would make the finding undischargeable, which is the
|
|
306
503
|
// failure this whole defect is about, reached from the other side.
|
|
504
|
+
// `added` goes through the same filter: `diffPlans` already reports an id
|
|
505
|
+
// that arrives dead (a log-only task's stub) as superseded, and the filter
|
|
506
|
+
// keeps any dead id from becoming an obligation or covering a named site.
|
|
307
507
|
const live = new Set(livePlanTasks(draft).map((t) => t.task_id));
|
|
308
508
|
const obligations = [
|
|
309
|
-
...new Set([...diff.added, ...diff.superseded.filter((id) => live.has(id))
|
|
509
|
+
...new Set([...diff.added, ...diff.superseded].filter((id) => live.has(id))),
|
|
310
510
|
];
|
|
311
511
|
if (obligations.length === 0) {
|
|
312
512
|
const shape = diff.removed.length > 0
|
|
@@ -339,12 +539,43 @@ export async function amendPlan(input, ctx, opts = {}) {
|
|
|
339
539
|
.filter((t) => obligated.has(t.task_id) && Array.isArray(t.claims))
|
|
340
540
|
.flatMap((t) => t.claims);
|
|
341
541
|
const unclaimedSites = namedSites.filter((s) => !claims.some((c) => claimCoversPath(c, s)));
|
|
542
|
+
// #221: every open finding this amendment does not cite, but whose id or
|
|
543
|
+
// fingerprint shows up in an added or superseded task's objective text —
|
|
544
|
+
// the tell that someone meant to carry it and did not name it in
|
|
545
|
+
// `findingIds`. `cited` is deliberately excluded: a finding already in
|
|
546
|
+
// `findingIds` is being discharged, not merely mentioned in passing, and a
|
|
547
|
+
// finding that is not open needs no warning because nothing is waiting on
|
|
548
|
+
// it. Read-only, computed off `all` (already fetched above) and the draft's
|
|
549
|
+
// own changes — nothing here can fail, so it costs nothing to compute
|
|
550
|
+
// before the guards below start writing.
|
|
551
|
+
const citedIds = new Set(findingIds);
|
|
552
|
+
const openUncited = all.filter((f) => OPEN_FINDING_STATUSES.has(f.finding_status) && !citedIds.has(f.finding_id));
|
|
553
|
+
const changedSpecs = [
|
|
554
|
+
...(changes.added ?? []),
|
|
555
|
+
...Object.values(changes.supersede ?? {}),
|
|
556
|
+
];
|
|
557
|
+
const changedObjectives = changedSpecs
|
|
558
|
+
.map((spec) => spec.objective)
|
|
559
|
+
.filter((objective) => typeof objective === 'string');
|
|
560
|
+
const warnings = openUncited
|
|
561
|
+
.filter((finding) => changedObjectives.some((objective) => objective.includes(finding.finding_id) || objective.includes(finding.fingerprint)))
|
|
562
|
+
.map((finding) => `${finding.finding_id} is open (${finding.finding_status}) and is named in this amendment's objective text, but is not in --findings; it will stay open unless a later amendment cites it.`);
|
|
342
563
|
// Validation is done; from here the version exists and the record has to
|
|
343
564
|
// catch up to it, so nothing below is allowed to be conditional. The write
|
|
344
565
|
// goes through `nextVersion` rather than from `draft`: `draftNextVersion` is
|
|
345
566
|
// pure, so the two agree by construction, and there stays exactly one
|
|
346
567
|
// function in this codebase that puts a plan version on disk.
|
|
347
|
-
const amended = nextVersion(plan,
|
|
568
|
+
const amended = nextVersion(plan, changes, opts, loggedStubs);
|
|
569
|
+
// Old id -> replacement id, for the rename-supersede case only: a same-id
|
|
570
|
+
// supersede needs no pairing (the row already carries the work forward
|
|
571
|
+
// under the id everyone has). epic.ts's resolveSupersededRow/summarizeEpic
|
|
572
|
+
// is the reader — this is the one place an old task_id and its replacement
|
|
573
|
+
// are ever paired (PlanChanges.supersede), so it is recorded here or lost.
|
|
574
|
+
const successors = {};
|
|
575
|
+
for (const [oldId, replacement] of Object.entries(changes.supersede ?? {})) {
|
|
576
|
+
if (replacement.task_id !== oldId)
|
|
577
|
+
successors[oldId] = replacement.task_id;
|
|
578
|
+
}
|
|
348
579
|
await appendEvent({
|
|
349
580
|
session_id: ctx.sessionId,
|
|
350
581
|
actor: ctx.actor ?? 'system',
|
|
@@ -364,13 +595,16 @@ export async function amendPlan(input, ctx, opts = {}) {
|
|
|
364
595
|
sites: namedSites,
|
|
365
596
|
sites_unclaimed: unclaimedSites,
|
|
366
597
|
diff,
|
|
598
|
+
successors,
|
|
367
599
|
},
|
|
368
600
|
}, opts);
|
|
601
|
+
let lastEventId = '';
|
|
369
602
|
for (const finding of cited) {
|
|
370
|
-
await transition(finding.finding_id, AMEND_PENDING_STATUS, ctx, opts, {
|
|
603
|
+
const transitioned = await transition(finding.finding_id, AMEND_PENDING_STATUS, ctx, opts, {
|
|
371
604
|
amendsTaskIds: obligations,
|
|
372
605
|
amendsPlanVersion: amended.version,
|
|
373
606
|
});
|
|
607
|
+
lastEventId = transitioned.event_id;
|
|
374
608
|
}
|
|
375
|
-
return { plan: amended, diff, sitesUnclaimed: unclaimedSites };
|
|
609
|
+
return { plan: amended, diff, sitesUnclaimed: unclaimedSites, warnings, event_id: lastEventId };
|
|
376
610
|
}
|
|
@@ -3,7 +3,7 @@ import { appendEvent, readLineageEvents } from './events.js';
|
|
|
3
3
|
import { listFindings, mintFindings, raiseFinding, transition, } from './findings.js';
|
|
4
4
|
import { diffPlans, draftNextVersion, latestPlanVersion, livePlanTasks, validatePlan, } from './plan.js';
|
|
5
5
|
import { compileSchemas, validateRecord } from './schemas.js';
|
|
6
|
-
import { amendPlan } from './spec.js';
|
|
6
|
+
import { amendPlan, resolveSupersedeKeys } from './spec.js';
|
|
7
7
|
import { loadTaxonomy } from './taxonomy.js';
|
|
8
8
|
/**
|
|
9
9
|
* The worker half of a living spec.
|
|
@@ -121,21 +121,24 @@ export async function proposeSpecChange(input, ctx, opts = {}) {
|
|
|
121
121
|
// The same check `amendPlan` runs on its own draft (D-21), one step earlier.
|
|
122
122
|
// Plans are immutable and nothing deletes one, so the cost of finding this
|
|
123
123
|
// out after the write is unrecoverable; the cost of finding it out here is a
|
|
124
|
-
// refusal the worker can still answer.
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
// refusal the worker can still answer. The supersede keys resolve first, as
|
|
125
|
+
// `amendPlan` resolves them, so a key naming a task only the log added
|
|
126
|
+
// drafts the same version — and records the same diff — approval will.
|
|
127
|
+
const { changes, loggedStubs } = await resolveSupersedeKeys(plan, request.changes ?? {}, ctx, opts);
|
|
128
|
+
const draft = draftNextVersion(plan, changes, loggedStubs);
|
|
129
|
+
const validation = validatePlan(draft, { ...opts, previous: plan });
|
|
127
130
|
if (!validation.valid) {
|
|
128
131
|
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
132
|
}
|
|
130
133
|
const diff = diffPlans(plan, draft);
|
|
131
|
-
// Mirrors `amendPlan`'s obligation rule: added tasks
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
+
// Mirrors `amendPlan`'s obligation rule: added and superseded tasks that are
|
|
135
|
+
// still live in the draft. A proposal that obligates nothing would be
|
|
136
|
+
// approved into a finding that discharges the instant it is parked — an
|
|
134
137
|
// amendment nobody has to build, which is another way of saying no
|
|
135
138
|
// amendment at all.
|
|
136
139
|
const live = new Set(livePlanTasks(draft).map((t) => t.task_id));
|
|
137
140
|
const obligations = [
|
|
138
|
-
...new Set([...diff.added, ...diff.superseded.filter((id) => live.has(id))
|
|
141
|
+
...new Set([...diff.added, ...diff.superseded].filter((id) => live.has(id))),
|
|
139
142
|
];
|
|
140
143
|
if (obligations.length === 0) {
|
|
141
144
|
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 });
|
|
@@ -328,7 +331,7 @@ async function requireProposal(proposalId, opts, ctx) {
|
|
|
328
331
|
const listed = await listSpecChanges(ctx.sessionId, {}, opts);
|
|
329
332
|
const found = listed.find((p) => p.proposalId === proposalId);
|
|
330
333
|
if (found === undefined) {
|
|
331
|
-
throw new SpecChangeError('spec-change.unknown-proposal', `No spec change proposal "${proposalId}" on this session's lineage. \`
|
|
334
|
+
throw new SpecChangeError('spec-change.unknown-proposal', `No spec change proposal "${proposalId}" on this session's lineage. \`bs plan proposals\` lists what is there.`, { proposalId, sessionId: ctx.sessionId });
|
|
332
335
|
}
|
|
333
336
|
return found;
|
|
334
337
|
}
|
|
@@ -417,6 +420,7 @@ export async function approveSpecChange(input, ctx, opts = {}) {
|
|
|
417
420
|
plan: amendment.plan,
|
|
418
421
|
diff: amendment.diff,
|
|
419
422
|
sitesUnclaimed: amendment.sitesUnclaimed,
|
|
423
|
+
event_id: stored.event_id,
|
|
420
424
|
};
|
|
421
425
|
}
|
|
422
426
|
/**
|
|
@@ -167,7 +167,7 @@ function reportFor(field, value, source) {
|
|
|
167
167
|
field,
|
|
168
168
|
value,
|
|
169
169
|
support: 'refused',
|
|
170
|
-
note: `\`
|
|
170
|
+
note: `\`bs new\` refuses: factory/scaffold/base is TypeScript, and scaffolding it under a ${value} answer would hand you a project you did not ask for.`,
|
|
171
171
|
};
|
|
172
172
|
}
|
|
173
173
|
if (field === 'frontend') {
|
|
@@ -176,7 +176,7 @@ function reportFor(field, value, source) {
|
|
|
176
176
|
field,
|
|
177
177
|
value,
|
|
178
178
|
support: 'honoured',
|
|
179
|
-
note: '`
|
|
179
|
+
note: '`bs new --ui` refuses until this names a framework.',
|
|
180
180
|
};
|
|
181
181
|
}
|
|
182
182
|
return value === 'vue'
|
|
@@ -185,7 +185,7 @@ function reportFor(field, value, source) {
|
|
|
185
185
|
field,
|
|
186
186
|
value,
|
|
187
187
|
support: 'refused',
|
|
188
|
-
note: `\`
|
|
188
|
+
note: `\`bs new --ui\` refuses: the UI template is Vue, and there is no ${value} template to layer instead.`,
|
|
189
189
|
};
|
|
190
190
|
}
|
|
191
191
|
if (field === 'styling') {
|
|
@@ -202,7 +202,7 @@ function reportFor(field, value, source) {
|
|
|
202
202
|
field,
|
|
203
203
|
value,
|
|
204
204
|
support: 'refused',
|
|
205
|
-
note: `\`
|
|
205
|
+
note: `\`bs new --ui\` refuses: the UI template wires plain CSS or Tailwind, not ${value}.`,
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
208
|
if (field === 'design_system') {
|
|
@@ -242,7 +242,7 @@ function reportFor(field, value, source) {
|
|
|
242
242
|
field,
|
|
243
243
|
value,
|
|
244
244
|
support: 'refused',
|
|
245
|
-
note: `design_system_source ${source} does not exist, so \`
|
|
245
|
+
note: `design_system_source ${source} does not exist, so \`bs new --ui\` would vendor nothing while claiming ${value}.`,
|
|
246
246
|
};
|
|
247
247
|
}
|
|
248
248
|
const hardcoded = HARDCODED[field];
|
|
@@ -258,7 +258,7 @@ function reportFor(field, value, source) {
|
|
|
258
258
|
field,
|
|
259
259
|
value,
|
|
260
260
|
support: 'recorded',
|
|
261
|
-
note: `The templates hardcode ${hardcoded.value} in ${hardcoded.where}. The agents read ${value}; \`
|
|
261
|
+
note: `The templates hardcode ${hardcoded.value} in ${hardcoded.where}. The agents read ${value}; \`bs new\` still scaffolds ${hardcoded.value}.`,
|
|
262
262
|
};
|
|
263
263
|
}
|
|
264
264
|
return {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a project's Blacksmith store lives, found from a working directory.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the dashboard's store registry and the orchestrator's own tools, so
|
|
5
|
+
* "is this checkout a store" has one answer. Read-only: nothing here creates or
|
|
6
|
+
* writes a file.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
const isDir = (p) => {
|
|
11
|
+
try {
|
|
12
|
+
return statSync(p).isDirectory();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const realOr = (p) => {
|
|
19
|
+
try {
|
|
20
|
+
return realpathSync(p);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return p;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Where a `.git` file (a linked worktree) really leads: the main clone, but
|
|
28
|
+
* only when that clone's `.git/worktrees/<name>` exists as a directory and is
|
|
29
|
+
* the very place the pointer names. A pointer is text anyone can write, so it
|
|
30
|
+
* is not trusted on its own; otherwise the worktree itself is the top.
|
|
31
|
+
*/
|
|
32
|
+
export function mainCloneOf(dotGitFile, dir) {
|
|
33
|
+
try {
|
|
34
|
+
const gitdir = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGitFile, 'utf8'))?.[1]?.trim();
|
|
35
|
+
if (!gitdir)
|
|
36
|
+
return dir;
|
|
37
|
+
const target = path.resolve(dir, gitdir);
|
|
38
|
+
const marker = `${path.sep}.git${path.sep}worktrees${path.sep}`;
|
|
39
|
+
const at = target.indexOf(marker);
|
|
40
|
+
if (at <= 0)
|
|
41
|
+
return dir;
|
|
42
|
+
const top = target.slice(0, at);
|
|
43
|
+
const name = target.slice(at + marker.length);
|
|
44
|
+
if (name === '' || name.includes(path.sep))
|
|
45
|
+
return dir;
|
|
46
|
+
const entry = path.join(top, '.git', 'worktrees', name);
|
|
47
|
+
if (!isDir(path.join(top, '.git')) || !isDir(entry))
|
|
48
|
+
return dir;
|
|
49
|
+
return realpathSync(entry) === realpathSync(target) ? top : dir;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Unreadable or dangling pointer: treat the worktree itself as the top.
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The git toplevel above `start`, or null. A `.git` directory marks a clone; a
|
|
58
|
+
* `.git` file (a linked worktree) is followed to the main clone, because the
|
|
59
|
+
* state home belongs to the project, not to one checkout of it.
|
|
60
|
+
*/
|
|
61
|
+
export function gitTop(start) {
|
|
62
|
+
let dir = path.resolve(start);
|
|
63
|
+
for (;;) {
|
|
64
|
+
const dotGit = path.join(dir, '.git');
|
|
65
|
+
if (isDir(dotGit))
|
|
66
|
+
return dir;
|
|
67
|
+
if (existsSync(dotGit))
|
|
68
|
+
return mainCloneOf(dotGit, dir);
|
|
69
|
+
const parent = path.dirname(dir);
|
|
70
|
+
if (parent === dir)
|
|
71
|
+
return null;
|
|
72
|
+
dir = parent;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A real store marker: `state/events` is a directory that lives inside `root`,
|
|
77
|
+
* not a file and not a link leading out of the tree.
|
|
78
|
+
*/
|
|
79
|
+
export function hasEvents(root) {
|
|
80
|
+
const events = path.join(root, 'state', 'events');
|
|
81
|
+
return isDir(events) && realOr(events) === path.join(realOr(root), 'state', 'events');
|
|
82
|
+
}
|
|
83
|
+
/** `<top>/.blacksmith` first (a BS_HOME layout), else `<top>` itself (a clone). */
|
|
84
|
+
export function storeRootAt(top) {
|
|
85
|
+
for (const root of [path.join(top, '.blacksmith'), top]) {
|
|
86
|
+
if (hasEvents(root))
|
|
87
|
+
return { root, label: path.basename(top) };
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|