@juzser/blacksmith 0.4.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.
Files changed (131) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +5 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +4 -4
  4. package/.claude/agents/{merger.md → bs-merger.md} +4 -4
  5. package/.claude/agents/{planner.md → bs-planner.md} +20 -6
  6. package/.claude/agents/{researcher.md → bs-researcher.md} +6 -5
  7. package/.claude/agents/{reviewer.md → bs-reviewer.md} +13 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +6 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +15 -10
  11. package/.claude/agents/{tester.md → bs-tester.md} +40 -12
  12. package/.claude/agents/bs-uiux.md +257 -0
  13. package/.claude/agents/{verifier.md → bs-verifier.md} +4 -4
  14. package/.claude/agents/{wave-runner.md → bs-wave-runner.md} +14 -5
  15. package/.claude/skills/bs/SKILL.md +99 -21
  16. package/.claude/skills/bs/audit.md +34 -27
  17. package/.claude/skills/bs/dispatch.md +100 -50
  18. package/.claude/skills/bs/lessons.md +28 -14
  19. package/.claude/skills/bs/mcp.md +4 -4
  20. package/.claude/skills/bs/new.md +3 -3
  21. package/.claude/skills/bs/plan.md +27 -18
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +181 -49
  24. package/.claude/skills/bs/status.md +14 -7
  25. package/.claude/skills/bs/ui.md +21 -5
  26. package/.claude/skills/bs/waivers.md +78 -2
  27. package/.claude/skills/bs/wave.md +109 -53
  28. package/README.md +53 -32
  29. package/factory/orchestrator/dist/agentNames.js +43 -0
  30. package/factory/orchestrator/dist/agents-registry.js +11 -4
  31. package/factory/orchestrator/dist/agentsSync.js +48 -14
  32. package/factory/orchestrator/dist/artifacts.js +31 -0
  33. package/factory/orchestrator/dist/attribution.js +4 -0
  34. package/factory/orchestrator/dist/audit.js +125 -17
  35. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  36. package/factory/orchestrator/dist/budgets.js +302 -84
  37. package/factory/orchestrator/dist/claims.js +9 -19
  38. package/factory/orchestrator/dist/cli.js +790 -100
  39. package/factory/orchestrator/dist/cliName.js +27 -0
  40. package/factory/orchestrator/dist/crosscheck.js +136 -8
  41. package/factory/orchestrator/dist/daemon.js +154 -36
  42. package/factory/orchestrator/dist/db/projector.js +278 -29
  43. package/factory/orchestrator/dist/db/queries.js +1831 -94
  44. package/factory/orchestrator/dist/db/schema.js +57 -4
  45. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  46. package/factory/orchestrator/dist/delegation.js +86 -19
  47. package/factory/orchestrator/dist/diffstat.js +15 -8
  48. package/factory/orchestrator/dist/dispatchLint.js +13 -37
  49. package/factory/orchestrator/dist/effort.js +14 -2
  50. package/factory/orchestrator/dist/effortTiers.js +13 -0
  51. package/factory/orchestrator/dist/env.js +26 -0
  52. package/factory/orchestrator/dist/epic.js +323 -38
  53. package/factory/orchestrator/dist/epicBudget.js +93 -0
  54. package/factory/orchestrator/dist/epicWidth.js +22 -5
  55. package/factory/orchestrator/dist/errorIssues.js +10 -4
  56. package/factory/orchestrator/dist/escalation.js +2 -2
  57. package/factory/orchestrator/dist/events.js +105 -3
  58. package/factory/orchestrator/dist/feedback.js +169 -0
  59. package/factory/orchestrator/dist/findingContext.js +26 -0
  60. package/factory/orchestrator/dist/findings.js +83 -12
  61. package/factory/orchestrator/dist/gate.js +120 -10
  62. package/factory/orchestrator/dist/goalCheck.js +2 -2
  63. package/factory/orchestrator/dist/harness.js +25 -17
  64. package/factory/orchestrator/dist/hookDecision.js +92 -3
  65. package/factory/orchestrator/dist/integration.js +9 -7
  66. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  67. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  68. package/factory/orchestrator/dist/judgeStopHook.js +90 -32
  69. package/factory/orchestrator/dist/judges.js +419 -21
  70. package/factory/orchestrator/dist/lessons.js +190 -11
  71. package/factory/orchestrator/dist/mcp.js +6 -6
  72. package/factory/orchestrator/dist/paths.js +10 -7
  73. package/factory/orchestrator/dist/plan.js +269 -33
  74. package/factory/orchestrator/dist/planQuorum.js +5 -2
  75. package/factory/orchestrator/dist/policy.js +1275 -30
  76. package/factory/orchestrator/dist/projectName.js +25 -0
  77. package/factory/orchestrator/dist/promptCapture.js +306 -0
  78. package/factory/orchestrator/dist/promptHook.js +33 -0
  79. package/factory/orchestrator/dist/promptImport.js +281 -0
  80. package/factory/orchestrator/dist/prompts.js +13 -0
  81. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  82. package/factory/orchestrator/dist/providers/types.js +1 -1
  83. package/factory/orchestrator/dist/queue.js +4 -4
  84. package/factory/orchestrator/dist/quorum.js +18 -5
  85. package/factory/orchestrator/dist/results.js +25 -7
  86. package/factory/orchestrator/dist/roadmap.js +10 -5
  87. package/factory/orchestrator/dist/run-cli.js +15 -10
  88. package/factory/orchestrator/dist/runner.js +2 -2
  89. package/factory/orchestrator/dist/schemas.js +56 -8
  90. package/factory/orchestrator/dist/severity.js +8 -4
  91. package/factory/orchestrator/dist/spec.js +177 -13
  92. package/factory/orchestrator/dist/specChange.js +13 -9
  93. package/factory/orchestrator/dist/stack.js +6 -6
  94. package/factory/orchestrator/dist/storeRoot.js +90 -0
  95. package/factory/orchestrator/dist/symbols.js +261 -6
  96. package/factory/orchestrator/dist/taskEvents.js +179 -27
  97. package/factory/orchestrator/dist/taskStatus.js +14 -0
  98. package/factory/orchestrator/dist/testerAudit.js +31 -12
  99. package/factory/orchestrator/dist/testgate.js +16 -13
  100. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  101. package/factory/orchestrator/dist/usage.js +97 -40
  102. package/factory/orchestrator/dist/waivers.js +39 -5
  103. package/factory/orchestrator/dist/waveBudget.js +2 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  105. package/factory/orchestrator/dist/workroot.js +1 -1
  106. package/factory/orchestrator/dist/worktree.js +61 -19
  107. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  108. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  109. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  110. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  111. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  112. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  113. package/factory/policies/budgets.yml +123 -33
  114. package/factory/policies/crosscheck.yml +51 -13
  115. package/factory/policies/delegation.yml +3 -3
  116. package/factory/policies/effort.yml +18 -10
  117. package/factory/policies/guardrails.yml +8 -6
  118. package/factory/policies/harness.yml +5 -5
  119. package/factory/policies/lessons.md +53 -2
  120. package/factory/policies/scheduler.yml +4 -4
  121. package/factory/policies/sensitive-paths.yml +1 -1
  122. package/factory/policies/severity.yml +13 -1
  123. package/factory/policies/stack.yml +6 -6
  124. package/factory/policies/taxonomy.yml +9 -3
  125. package/factory/policies/worktree.yml +15 -9
  126. package/factory/specs/roadmap.md +7 -7
  127. package/factory/specs/schema/event.schema.json +8 -1
  128. package/factory/specs/schema/result.schema.json +21 -8
  129. package/factory/specs/schema/task-spec.schema.json +12 -0
  130. package/package.json +16 -12
  131. 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, raiseFinding, SPEC_FINDING_SCOPE, transition, } from './findings.js';
10
- import { diffPlans, draftNextVersion, livePlanTasks, nextVersion, planRefTaskId, validatePlan, } from './plan.js';
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
@@ -127,6 +133,72 @@ export function taskSuccessors(events, epicId) {
127
133
  }
128
134
  return successors;
129
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
+ }
130
202
  /**
131
203
  * The epic gate refusing to certify a spec nobody re-read once the code
132
204
  * existed. Mirrors integrationBlockers exactly, including the fail-closed
@@ -154,11 +226,26 @@ export function taskSuccessors(events, epicId) {
154
226
  * a required parameter for the reason `integration` and `mcp` are required on
155
227
  * summarizeEpic — a defaulted one is a forgotten argument that manufactures a
156
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.
157
242
  */
158
- export function specReviewBlockers(epicId, status, planVersion) {
159
- const branch = `smith/${epicId}/${RESERVED_TASK_ID}`;
160
- const { review, headSha } = status;
243
+ export function specReviewBlockers(epicId, status, planVersion, effort) {
244
+ const { review, headSha, branch } = status;
161
245
  if (review === null) {
246
+ if (effort.closingSpecReview === 'when-plan-amended' && planVersion === 1) {
247
+ return [];
248
+ }
162
249
  return [
163
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).`,
164
251
  ];
@@ -249,6 +336,7 @@ export async function recordSpecReview(input, ctx, opts = {}) {
249
336
  : { reviewed_by_provider: input.reviewedByProvider }),
250
337
  finding_ids: findingIds,
251
338
  finding_count: findingIds.length,
339
+ ...(input.noFindings === true ? { attested_by: 'operator' } : {}),
252
340
  },
253
341
  }, opts);
254
342
  return {
@@ -261,6 +349,55 @@ export async function recordSpecReview(input, ctx, opts = {}) {
261
349
  ts: stored.record.ts,
262
350
  };
263
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
+ }
264
401
  /**
265
402
  * The one legitimate way to change an immutable plan.
266
403
  *
@@ -332,7 +469,8 @@ export async function amendPlan(input, ctx, opts = {}) {
332
469
  // `draftNextVersion` is for: "everything validates before anything acts"
333
470
  // only holds if this check can run before a plan file exists, and nothing in
334
471
  // this codebase deletes a plan file (D-127).
335
- const draft = draftNextVersion(plan, input.changes ?? {});
472
+ const { changes, loggedStubs } = await resolveSupersedeKeys(plan, input.changes ?? {}, ctx, opts);
473
+ const draft = draftNextVersion(plan, changes, loggedStubs);
336
474
  // D-21: a malformed `--changes` file can still produce a draft that reads
337
475
  // fine structurally (every entry a plain object with a string task_id) but
338
476
  // fails the plan's own schema/taxonomy rules — the exact check `smith plan
@@ -340,9 +478,9 @@ export async function amendPlan(input, ctx, opts = {}) {
340
478
  // schema-invalid version written to disk is unrecoverable; refusing it here,
341
479
  // before the write, is what closes that loop rather than leaving `smith
342
480
  // plan validate` to reject the file this tool just produced.
343
- const draftValidation = validatePlan(draft, opts);
481
+ const draftValidation = validatePlan(draft, { ...opts, previous: plan });
344
482
  if (!draftValidation.valid) {
345
- 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
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
346
484
  .map((e) => `${e.path}: ${e.message}`)
347
485
  .join(' ')}`, { epicId, version: plan.version, nextVersion: draft.version, errors: draftValidation.errors });
348
486
  }
@@ -363,9 +501,12 @@ export async function amendPlan(input, ctx, opts = {}) {
363
501
  // replacement is in `added`, so the obligation survives the rename; keeping
364
502
  // the dead id too would make the finding undischargeable, which is the
365
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.
366
507
  const live = new Set(livePlanTasks(draft).map((t) => t.task_id));
367
508
  const obligations = [
368
- ...new Set([...diff.added, ...diff.superseded.filter((id) => live.has(id))]),
509
+ ...new Set([...diff.added, ...diff.superseded].filter((id) => live.has(id))),
369
510
  ];
370
511
  if (obligations.length === 0) {
371
512
  const shape = diff.removed.length > 0
@@ -398,19 +539,40 @@ export async function amendPlan(input, ctx, opts = {}) {
398
539
  .filter((t) => obligated.has(t.task_id) && Array.isArray(t.claims))
399
540
  .flatMap((t) => t.claims);
400
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.`);
401
563
  // Validation is done; from here the version exists and the record has to
402
564
  // catch up to it, so nothing below is allowed to be conditional. The write
403
565
  // goes through `nextVersion` rather than from `draft`: `draftNextVersion` is
404
566
  // pure, so the two agree by construction, and there stays exactly one
405
567
  // function in this codebase that puts a plan version on disk.
406
- const amended = nextVersion(plan, input.changes ?? {}, opts);
568
+ const amended = nextVersion(plan, changes, opts, loggedStubs);
407
569
  // Old id -> replacement id, for the rename-supersede case only: a same-id
408
570
  // supersede needs no pairing (the row already carries the work forward
409
571
  // under the id everyone has). epic.ts's resolveSupersededRow/summarizeEpic
410
572
  // is the reader — this is the one place an old task_id and its replacement
411
573
  // are ever paired (PlanChanges.supersede), so it is recorded here or lost.
412
574
  const successors = {};
413
- for (const [oldId, replacement] of Object.entries(input.changes?.supersede ?? {})) {
575
+ for (const [oldId, replacement] of Object.entries(changes.supersede ?? {})) {
414
576
  if (replacement.task_id !== oldId)
415
577
  successors[oldId] = replacement.task_id;
416
578
  }
@@ -436,11 +598,13 @@ export async function amendPlan(input, ctx, opts = {}) {
436
598
  successors,
437
599
  },
438
600
  }, opts);
601
+ let lastEventId = '';
439
602
  for (const finding of cited) {
440
- await transition(finding.finding_id, AMEND_PENDING_STATUS, ctx, opts, {
603
+ const transitioned = await transition(finding.finding_id, AMEND_PENDING_STATUS, ctx, opts, {
441
604
  amendsTaskIds: obligations,
442
605
  amendsPlanVersion: amended.version,
443
606
  });
607
+ lastEventId = transitioned.event_id;
444
608
  }
445
- return { plan: amended, diff, sitesUnclaimed: unclaimedSites };
609
+ return { plan: amended, diff, sitesUnclaimed: unclaimedSites, warnings, event_id: lastEventId };
446
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
- const draft = draftNextVersion(plan, request.changes ?? {});
126
- const validation = validatePlan(draft, opts);
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, 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
+ // 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. \`smith plan proposals\` lists what is there.`, { proposalId, sessionId: ctx.sessionId });
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: `\`smith new\` refuses: factory/scaffold/base is TypeScript, and scaffolding it under a ${value} answer would hand you a project you did not ask for.`,
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: '`smith new --ui` refuses until this names a framework.',
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: `\`smith new --ui\` refuses: the UI template is Vue, and there is no ${value} template to layer instead.`,
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: `\`smith new --ui\` refuses: the UI template wires plain CSS or Tailwind, not ${value}.`,
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 \`smith new --ui\` would vendor nothing while claiming ${value}.`,
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}; \`smith new\` still scaffolds ${hardcoded.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
+ }