@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.
Files changed (141) hide show
  1. package/.claude/agents/{auditor.md → bs-auditor.md} +8 -5
  2. package/.claude/agents/{coder.md → bs-coder.md} +8 -7
  3. package/.claude/agents/{grader.md → bs-grader.md} +16 -9
  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} +16 -5
  8. package/.claude/agents/{scribe.md → bs-scribe.md} +11 -2
  9. package/.claude/agents/{security-reviewer.md → bs-security-reviewer.md} +9 -6
  10. package/.claude/agents/{spec-reviewer.md → bs-spec-reviewer.md} +18 -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} +7 -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 +45 -31
  17. package/.claude/skills/bs/dispatch.md +133 -47
  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 +57 -16
  22. package/.claude/skills/bs/report.md +11 -11
  23. package/.claude/skills/bs/run.md +190 -52
  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 +143 -50
  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 +70 -13
  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 +245 -20
  35. package/factory/orchestrator/dist/autonomy.js +6 -2
  36. package/factory/orchestrator/dist/budgetAlarm.js +132 -37
  37. package/factory/orchestrator/dist/budgets.js +302 -84
  38. package/factory/orchestrator/dist/claims.js +9 -19
  39. package/factory/orchestrator/dist/cli.js +1087 -116
  40. package/factory/orchestrator/dist/cliName.js +27 -0
  41. package/factory/orchestrator/dist/crossFinding.js +20 -0
  42. package/factory/orchestrator/dist/crosscheck.js +136 -8
  43. package/factory/orchestrator/dist/daemon.js +352 -67
  44. package/factory/orchestrator/dist/db/projector.js +327 -54
  45. package/factory/orchestrator/dist/db/queries.js +1831 -94
  46. package/factory/orchestrator/dist/db/schema.js +57 -4
  47. package/factory/orchestrator/dist/declaredArtifactLine.js +18 -0
  48. package/factory/orchestrator/dist/delegation.js +86 -19
  49. package/factory/orchestrator/dist/diffstat.js +15 -8
  50. package/factory/orchestrator/dist/dispatchLint.js +108 -0
  51. package/factory/orchestrator/dist/effort.js +14 -2
  52. package/factory/orchestrator/dist/effortTiers.js +13 -0
  53. package/factory/orchestrator/dist/env.js +26 -0
  54. package/factory/orchestrator/dist/epic.js +494 -42
  55. package/factory/orchestrator/dist/epicBudget.js +93 -0
  56. package/factory/orchestrator/dist/epicWidth.js +22 -5
  57. package/factory/orchestrator/dist/errorIssues.js +182 -11
  58. package/factory/orchestrator/dist/escalation.js +2 -2
  59. package/factory/orchestrator/dist/events.js +164 -14
  60. package/factory/orchestrator/dist/feedback.js +169 -0
  61. package/factory/orchestrator/dist/findingContext.js +26 -0
  62. package/factory/orchestrator/dist/findings.js +108 -14
  63. package/factory/orchestrator/dist/gate.js +120 -10
  64. package/factory/orchestrator/dist/gh.js +19 -2
  65. package/factory/orchestrator/dist/git.js +20 -2
  66. package/factory/orchestrator/dist/goalCheck.js +2 -2
  67. package/factory/orchestrator/dist/harness.js +25 -17
  68. package/factory/orchestrator/dist/hookDecision.js +411 -39
  69. package/factory/orchestrator/dist/integration.js +20 -8
  70. package/factory/orchestrator/dist/issueReporter.js +81 -16
  71. package/factory/orchestrator/dist/judgePreflight.js +25 -4
  72. package/factory/orchestrator/dist/judgeRoles.js +47 -0
  73. package/factory/orchestrator/dist/judgeStopHook.js +239 -0
  74. package/factory/orchestrator/dist/judges.js +419 -21
  75. package/factory/orchestrator/dist/lessons.js +190 -11
  76. package/factory/orchestrator/dist/logCache.js +291 -0
  77. package/factory/orchestrator/dist/mcp.js +6 -6
  78. package/factory/orchestrator/dist/paths.js +20 -7
  79. package/factory/orchestrator/dist/plan.js +399 -33
  80. package/factory/orchestrator/dist/planQuorum.js +5 -2
  81. package/factory/orchestrator/dist/policy.js +1275 -30
  82. package/factory/orchestrator/dist/projectName.js +25 -0
  83. package/factory/orchestrator/dist/projects.js +12 -1
  84. package/factory/orchestrator/dist/promptCapture.js +306 -0
  85. package/factory/orchestrator/dist/promptHook.js +33 -0
  86. package/factory/orchestrator/dist/promptImport.js +281 -0
  87. package/factory/orchestrator/dist/prompts.js +13 -0
  88. package/factory/orchestrator/dist/providers/schema-validate.js +2 -2
  89. package/factory/orchestrator/dist/providers/types.js +1 -1
  90. package/factory/orchestrator/dist/queue.js +471 -30
  91. package/factory/orchestrator/dist/quorum.js +18 -5
  92. package/factory/orchestrator/dist/results.js +25 -7
  93. package/factory/orchestrator/dist/roadmap.js +10 -5
  94. package/factory/orchestrator/dist/run-cli.js +15 -10
  95. package/factory/orchestrator/dist/runner.js +2 -2
  96. package/factory/orchestrator/dist/scheduler.js +3 -3
  97. package/factory/orchestrator/dist/schemas.js +56 -8
  98. package/factory/orchestrator/dist/severity.js +8 -4
  99. package/factory/orchestrator/dist/spec.js +246 -12
  100. package/factory/orchestrator/dist/specChange.js +13 -9
  101. package/factory/orchestrator/dist/stack.js +6 -6
  102. package/factory/orchestrator/dist/storeRoot.js +90 -0
  103. package/factory/orchestrator/dist/symbols.js +261 -6
  104. package/factory/orchestrator/dist/taskEvents.js +192 -31
  105. package/factory/orchestrator/dist/taskStatus.js +14 -0
  106. package/factory/orchestrator/dist/taxonomy.js +11 -0
  107. package/factory/orchestrator/dist/testerAudit.js +31 -12
  108. package/factory/orchestrator/dist/testgate.js +20 -16
  109. package/factory/orchestrator/dist/uiuxGate.js +83 -0
  110. package/factory/orchestrator/dist/usage.js +113 -48
  111. package/factory/orchestrator/dist/waivers.js +99 -12
  112. package/factory/orchestrator/dist/waveBudget.js +2 -0
  113. package/factory/orchestrator/dist/waveConcurrency.js +143 -7
  114. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  115. package/factory/orchestrator/dist/workroot.js +1 -1
  116. package/factory/orchestrator/dist/worktree.js +76 -21
  117. package/factory/orchestrator/drizzle/0013_operator_feedback.sql +19 -0
  118. package/factory/orchestrator/drizzle/0014_misty_madripoor.sql +2 -0
  119. package/factory/orchestrator/drizzle/0015_gifted_omega_flight.sql +1 -0
  120. package/factory/orchestrator/drizzle/0016_milestones_project_default.sql +30 -0
  121. package/factory/orchestrator/drizzle/0017_events_raw_cli_session_id.sql +2 -0
  122. package/factory/orchestrator/drizzle/meta/_journal.json +35 -0
  123. package/factory/policies/budgets.yml +123 -33
  124. package/factory/policies/crosscheck.yml +51 -13
  125. package/factory/policies/delegation.yml +3 -3
  126. package/factory/policies/effort.yml +18 -10
  127. package/factory/policies/guardrails.yml +8 -6
  128. package/factory/policies/harness.yml +5 -5
  129. package/factory/policies/lessons.md +53 -2
  130. package/factory/policies/scheduler.yml +4 -4
  131. package/factory/policies/sensitive-paths.yml +1 -1
  132. package/factory/policies/severity.yml +13 -1
  133. package/factory/policies/stack.yml +6 -6
  134. package/factory/policies/taxonomy.yml +9 -3
  135. package/factory/policies/worktree.yml +15 -9
  136. package/factory/specs/roadmap.md +27 -3
  137. package/factory/specs/schema/event.schema.json +8 -1
  138. package/factory/specs/schema/result.schema.json +21 -8
  139. package/factory/specs/schema/task-spec.schema.json +12 -0
  140. package/package.json +16 -12
  141. package/.claude/agents/uiux.md +0 -133
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { readEnv } from './env.js';
4
5
  // factory/orchestrator/src/paths.ts -> repo root is three levels up.
5
6
  const here = path.dirname(fileURLToPath(import.meta.url));
6
7
  /**
@@ -11,6 +12,16 @@ const here = path.dirname(fileURLToPath(import.meta.url));
11
12
  * assets ships in the tarball. `packaging.test.ts` holds that claim.
12
13
  */
13
14
  export const REPO_ROOT = path.resolve(here, '..', '..', '..');
15
+ /**
16
+ * Is `dir` this factory's own checkout, resolved rather than compared raw so
17
+ * a trailing slash or a relative path names the same answer. `audit.ts`
18
+ * (`auditedProjectName`) and `projects.ts` (`unwatchedProjects`) both need to
19
+ * tell "this clone, filed under its own directory name" from "a project that
20
+ * is genuinely named that" and used to each spell the comparison themselves.
21
+ */
22
+ export function isFactoryCheckout(dir) {
23
+ return path.resolve(dir) === path.resolve(REPO_ROOT);
24
+ }
14
25
  /**
15
26
  * Is this module running out of the blacksmith clone, or out of an installed
16
27
  * copy of the package?
@@ -19,7 +30,7 @@ export const REPO_ROOT = path.resolve(here, '..', '..', '..');
19
30
  * `package.json#files` could not ship it if it tried. A linked worktree spells
20
31
  * it as a file rather than a directory, which `existsSync` reads either way.
21
32
  */
22
- const IS_CLONE = existsSync(path.join(REPO_ROOT, '.git'));
33
+ export const IS_CLONE = existsSync(path.join(REPO_ROOT, '.git'));
23
34
  /**
24
35
  * Where the work root goes when the CLI is not standing in a clone.
25
36
  *
@@ -36,9 +47,10 @@ const WORK_DIR_NAME = '.blacksmith';
36
47
  *
37
48
  * Pure, and exported for the test: the constants below call it once at module
38
49
  * load, but the install case can only be stated by a test that passes its own
39
- * roots in. `env.SMITH_HOME` wins outright; otherwise a clone keeps writing
40
- * into itself, and an install writes beside the operator instead of into
41
- * `node_modules/@juzser/blacksmith`, which the next `npm i` replaces wholesale.
50
+ * roots in. `BS_HOME` (or its legacy `SMITH_HOME` fallback) wins outright;
51
+ * otherwise a clone keeps writing into itself, and an install writes beside
52
+ * the operator instead of into `node_modules/@juzser/blacksmith`, which the
53
+ * next `npm i` replaces wholesale.
42
54
  *
43
55
  * The layout *under* the root is identical in both cases on purpose. A path
44
56
  * like `state/events/<session>.jsonl` is spelled in runbooks, in agent
@@ -46,9 +58,10 @@ const WORK_DIR_NAME = '.blacksmith';
46
58
  * if only the anchor moves.
47
59
  */
48
60
  export function resolveWorkRoot(repoRoot, cwd, env, isClone) {
49
- const declared = env.SMITH_HOME?.trim();
50
- // An empty `SMITH_HOME=` is how an unset variable gets spelled by accident in
51
- // a shell profile or a CI matrix; resolved literally it would mean cwd.
61
+ const declared = readEnv(env, 'BS_HOME')?.trim();
62
+ // An empty `BS_HOME=`/`SMITH_HOME=` is how an unset variable gets spelled by
63
+ // accident in a shell profile or a CI matrix; resolved literally it would
64
+ // mean cwd.
52
65
  if (declared)
53
66
  return path.resolve(cwd, declared);
54
67
  return isClone ? repoRoot : path.join(cwd, WORK_DIR_NAME);
@@ -1,26 +1,22 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
3
4
  import { unreadTaskBudgetFields } from './budgets.js';
4
5
  import { claimCoversPath } from './claims.js';
6
+ import { EFFORT_TIERS, isEffortTier } from './effortTiers.js';
5
7
  import { SmithError } from './errors.js';
6
8
  import { topoSort } from './graph.js';
7
9
  import { SPECS_ACTIVE_DIR } from './paths.js';
8
10
  import { compileSchemas, validateRecord, } from './schemas.js';
11
+ import { epicOfTaskId } from './taskId.js';
9
12
  import { loadTaxonomy, TaxonomyError, validateRequiredDimensions, } from './taxonomy.js';
10
13
  export class PlanError extends SmithError {
11
14
  }
12
- /**
13
- * How much judgment an epic buys, cheapest first — factory/policies/effort.yml
14
- * declares what each tier means; this is only the closed list of names, kept
15
- * here beside the `PlanFile.effort` field it types. effort.ts is the semantic
16
- * owner and re-exports these; it cannot own the list itself, because plan.ts
17
- * would then have to import it to validate a plan and effort.ts already
18
- * (transitively) imports plan.ts.
19
- */
20
- export const EFFORT_TIERS = ['small', 'medium', 'huge'];
21
- export function isEffortTier(value) {
22
- return typeof value === 'string' && EFFORT_TIERS.includes(value);
23
- }
15
+ // The closed list of effort tier names, re-exported beside the
16
+ // `PlanFile.effort` field it types. It lives in the leaf module
17
+ // effortTiers.ts because budgets.ts sizes its caps by it too, and plan.ts
18
+ // already imports budgets.ts.
19
+ export { EFFORT_TIERS, isEffortTier };
24
20
  /**
25
21
  * Pseudo task-id ref for a PLAN-level (not task-level) event: planQuorum.ts's
26
22
  * runPlanQuorum() stamps it onto the dispatch_decision/judge-verdict/
@@ -103,6 +99,24 @@ function planFilePath(epicId, version, opts) {
103
99
  const dir = opts.specsDir ?? SPECS_ACTIVE_DIR;
104
100
  return path.join(dir, epicId, `plan-v${version}.json`);
105
101
  }
102
+ /**
103
+ * The specs dir implied by a plan file's own location on disk -- the inverse
104
+ * of `planFilePath`: `<specsDir>/<epicId>/plan-vN.json` names `<specsDir>` as
105
+ * its grandparent, when the plan sits in that shape at all. `null` when it
106
+ * does not (a flat fixture, or a directory that merely happens to share the
107
+ * epic's version number), because a plan filed anywhere else names no specs
108
+ * dir a caller should guess at.
109
+ *
110
+ * `cli.ts`'s `plan amend` handler is the only caller (#219): unlike every
111
+ * other reader of `--specs-dir`, amend WRITES the version it derives a path
112
+ * for, so falling back to `SPECS_ACTIVE_DIR` when the plan being amended
113
+ * already lives somewhere else plants the new version in the wrong tree
114
+ * entirely -- beside no other version of the plan it just amended.
115
+ */
116
+ export function impliedSpecsDir(planPath, epicId) {
117
+ const dir = path.dirname(path.resolve(planPath));
118
+ return path.basename(dir) === epicId ? path.dirname(dir) : null;
119
+ }
106
120
  const PLAN_FILE_PATTERN = /^plan-v(\d+)\.json$/;
107
121
  /**
108
122
  * The highest plan version written for an epic, or null when the epic has no
@@ -133,6 +147,122 @@ export function loadPlan(epicId, version, opts = {}) {
133
147
  }
134
148
  return JSON.parse(readFileSync(filePath, 'utf8'));
135
149
  }
150
+ /**
151
+ * A task ref's project, read off its own epic's latest plan version -- the
152
+ * D-246 precedent (`db/projector.ts`'s `planProjectResolver`, for the board)
153
+ * restated here so `errorIssues.ts` can answer the same question without
154
+ * importing anything database-shaped (`test/cliBoot.test.ts` pins `cli.ts`'s
155
+ * built module graph off drizzle/sqlite entirely, and `cli.ts` statically
156
+ * imports the error-issue reporter).
157
+ *
158
+ * Never throws and never guesses: an id with no epic segment, an epic with
159
+ * no plan on disk, and an unreadable or unparseable plan file all answer
160
+ * `null` alike, on purpose -- "cannot tell" and "the plan has no project" are
161
+ * the same non-answer to a caller that must never turn either one into this
162
+ * factory's own name. Answers cache by epic id for the resolver's lifetime,
163
+ * since one `issues report` run reads the same epic's plan for every row
164
+ * that epic contributed.
165
+ */
166
+ export function planProjectResolverForTaskRef(opts = {}) {
167
+ const cache = new Map();
168
+ return (taskRef) => {
169
+ const epicId = epicOfTaskId(taskRef);
170
+ if (epicId === null)
171
+ return null;
172
+ const cached = cache.get(epicId);
173
+ if (cached !== undefined)
174
+ return cached;
175
+ let project = null;
176
+ try {
177
+ const version = latestPlanVersion(epicId, opts);
178
+ if (version !== null)
179
+ project = loadPlan(epicId, version, opts).project ?? null;
180
+ }
181
+ catch {
182
+ // Unreadable or unparseable plan file: no answer, not a crash.
183
+ }
184
+ cache.set(epicId, project);
185
+ return project;
186
+ };
187
+ }
188
+ /**
189
+ * Whether `opts.specsDir` names THIS checkout's own specs/active tree --
190
+ * absent (so `planFilePath`/`latestPlanVersion` fall back to
191
+ * `SPECS_ACTIVE_DIR` themselves) or an explicit path that resolves to the
192
+ * same directory. Only then is a plan found there, naming no project,
193
+ * evidence of "self": `--specs-dir` is also how a caller points this
194
+ * resolver at a DIFFERENT checkout's specs tree entirely -- `issueInputs`'s
195
+ * own doc comment names "the daemon's own event log and a target checkout's
196
+ * specs tree" as an intended use -- and a plan found THERE naming no project
197
+ * is that other project's own silence, not this factory's (S2-b).
198
+ */
199
+ function isSelfSpecsDir(opts) {
200
+ return opts.specsDir === undefined || path.resolve(opts.specsDir) === SPECS_ACTIVE_DIR;
201
+ }
202
+ /**
203
+ * The self-fallback half of `planProjectResolverForTaskRefOrSelf`, on its
204
+ * own: a plan found on disk for the epic that simply declares no `project`
205
+ * field IS this factory's own project, by construction -- only this
206
+ * checkout's own epics have a plan under `specs/active` at all, so finding
207
+ * one there is itself the "self" signal a foreign epic's plan (which lives
208
+ * in the foreign project's own specs tree, never this one) can never
209
+ * produce. An epic with no plan on disk anywhere, or a bare ref with no
210
+ * epic segment, answers `null` -- "cannot tell whose project this is" --
211
+ * because plan.ts must not be the module that hardcodes this factory's
212
+ * name; the caller supplies it.
213
+ *
214
+ * That "only this checkout's own epics" premise holds only while `opts`
215
+ * actually points at this checkout's own specs/active tree
216
+ * (`isSelfSpecsDir`, S2-b): a caller that redirected `--specs-dir` at some
217
+ * OTHER checkout's specs tree gets no self-fallback at all, `null` only,
218
+ * because a plan found there naming no project is that other checkout's own
219
+ * silence, not this factory's.
220
+ *
221
+ * Exported as its own resolver, separate from `planProjectResolverForTaskRef`
222
+ * (S2-c): production callers (`cli.ts`'s `issueInputs`) hand the two to
223
+ * `withSessionFallback`/`foldErrorEvents` as DISTINCT tiers -- the strict
224
+ * resolver first, then a session's own unanimous stamp, and only then this
225
+ * guess -- so a session-mate's real stamp naming a different project
226
+ * outranks this weakest signal rather than losing to it, which is what
227
+ * happened when the two were folded into one resolver and tried together as
228
+ * a single first tier.
229
+ */
230
+ export function planSelfFallbackForTaskRef(selfProject, opts = {}) {
231
+ const selfEligible = isSelfSpecsDir(opts);
232
+ const hasPlanCache = new Map();
233
+ return (taskRef) => {
234
+ if (!selfEligible)
235
+ return null;
236
+ const epicId = epicOfTaskId(taskRef);
237
+ if (epicId === null)
238
+ return null;
239
+ let hasPlan = hasPlanCache.get(epicId);
240
+ if (hasPlan === undefined) {
241
+ hasPlan = latestPlanVersion(epicId, opts) !== null;
242
+ hasPlanCache.set(epicId, hasPlan);
243
+ }
244
+ return hasPlan ? selfProject : null;
245
+ };
246
+ }
247
+ /**
248
+ * `planProjectResolverForTaskRef` composed with `planSelfFallbackForTaskRef`
249
+ * as a single resolver, direct answer first and self-fallback second, for
250
+ * callers that want the OLD combined behavior (both tiers tried back to
251
+ * back, with nothing -- such as a session stamp -- able to sit between
252
+ * them). Kept for callers that do not need the S2-c tier split; `cli.ts`'s
253
+ * `issueInputs` no longer uses this, it hands the two halves to
254
+ * `withSessionFallback` separately instead.
255
+ */
256
+ export function planProjectResolverForTaskRefOrSelf(selfProject, opts = {}) {
257
+ const resolve = planProjectResolverForTaskRef(opts);
258
+ const selfFallback = planSelfFallbackForTaskRef(selfProject, opts);
259
+ return (taskRef) => {
260
+ const direct = resolve(taskRef);
261
+ if (direct !== null)
262
+ return direct;
263
+ return selfFallback(taskRef);
264
+ };
265
+ }
136
266
  /**
137
267
  * Reduce an `output_schema_ref` to the name the compiled schema set knows it
138
268
  * by. `compileSchemas` keys on filename-minus-`.schema.json`, so a path, a
@@ -172,6 +302,105 @@ function describeAllowed(err) {
172
302
  * of what an edge must carry, which could then drift from taxonomy.yml's.
173
303
  */
174
304
  const GLOB_CHARS = /[*?[{]/;
305
+ /** Extensions that make a claimed path a UI-visible surface (U2 D3). */
306
+ const UI_EXTENSION_RE = /\.(tsx|jsx|vue|svelte|css|scss|sass|less|html)$/i;
307
+ /**
308
+ * Whether a task spec is flagged as changing what a user sees. An absent
309
+ * `ui_affecting` means false at the gate (U2 D3) — work already running must
310
+ * not be blocked by a flag it predates.
311
+ */
312
+ export function isUiAffecting(spec) {
313
+ return spec.ui_affecting === true;
314
+ }
315
+ /**
316
+ * `plan.ui-flag-missing`: a task that claims a UI-extension path must say,
317
+ * one way or the other, whether it is UI-affecting. An absent flag reads as
318
+ * false everywhere else in the factory (U2 D3), but silently reading false
319
+ * here would let a visibly UI-shaped task skip the uiux judge turn by
320
+ * omission rather than by a reviewed `ui_affecting: false`. Only the
321
+ * absence of the key is an error; an explicit `false` passes.
322
+ */
323
+ function uiFlagMissing(t) {
324
+ if (t.ui_affecting !== undefined)
325
+ return [];
326
+ const claims = Array.isArray(t.claims)
327
+ ? t.claims.filter((c) => typeof c === 'string')
328
+ : [];
329
+ const firstMatch = claims.find((c) => UI_EXTENSION_RE.test(c));
330
+ if (firstMatch === undefined)
331
+ return [];
332
+ return [
333
+ {
334
+ path: '/ui_affecting',
335
+ message: `Task "${t.task_id}" claims UI-extension path "${firstMatch}" but has no ui_affecting flag (plan.ui-flag-missing). Set ui_affecting: true or false explicitly.`,
336
+ },
337
+ ];
338
+ }
339
+ /**
340
+ * Order-insensitive structural equality for the plain-JSON-shaped values a
341
+ * task record is made of (strings, numbers, booleans, null, arrays, plain
342
+ * objects). Arrays compare positionally — `claims` order is meaningful — and
343
+ * objects compare by key set and value, regardless of key order, since a
344
+ * record rebuilt via `{ ...t, ... }` and one authored by hand may list the
345
+ * same fields in different orders without differing in content.
346
+ */
347
+ function deepEqual(a, b) {
348
+ if (a === b)
349
+ return true;
350
+ if (Array.isArray(a) || Array.isArray(b)) {
351
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
352
+ return false;
353
+ return a.every((v, i) => deepEqual(v, b[i]));
354
+ }
355
+ if (isPlainObject(a) || isPlainObject(b)) {
356
+ if (!isPlainObject(a) || !isPlainObject(b))
357
+ return false;
358
+ const aKeys = Object.keys(a);
359
+ const bKeys = Object.keys(b);
360
+ if (aKeys.length !== bKeys.length)
361
+ return false;
362
+ return aKeys.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
363
+ }
364
+ return false;
365
+ }
366
+ /**
367
+ * Whether `t` is exempt from `plan.ui-flag-missing` because the previous
368
+ * plan version already carried this exact task_id with no `ui_affecting` key,
369
+ * as a verbatim carry-forward. The operator's binding decision (S2 fix round
370
+ * 2): work already in flight must not be blocked by a flag that predates it,
371
+ * so a task `draftNextVersion` carries forward unchanged is grandfathered
372
+ * rather than stamped `ui_affecting: false` — that would claim a decision
373
+ * nobody made.
374
+ *
375
+ * "Unchanged" means the whole record, not just `claims`: `draftNextVersion`
376
+ * rewrites only `plan_version` (every carried task) and `task_status` (the
377
+ * dead `superseded` record a supersede leaves behind) — so a record that
378
+ * matches the previous one on every other field is something the plan editor
379
+ * itself carried forward, never content a human or a supersede replacement
380
+ * wrote. A supersede replacement that keeps the old task_id (D-121) writes
381
+ * new content under that id — a changed description or deps included, even
382
+ * when the claims happen to match (S1 fix round 3) — so it fails this
383
+ * comparison and still needs the flag.
384
+ *
385
+ * A task_id can hold several rows (dead `superseded` records plus the live
386
+ * one), so a row is exempt when ANY previous row of that id matches it. A
387
+ * supersede replacement that restores the exact content of an existing row
388
+ * is therefore exempt too: that content already stood unflagged.
389
+ *
390
+ * `added` tasks and v1 tasks have no previous record to match, so they are
391
+ * never exempt.
392
+ */
393
+ function isGrandfatheredUiFlag(t, previous) {
394
+ if (previous === undefined)
395
+ return false;
396
+ const { plan_version: _pv2, task_status: _ts2, ...rest } = t;
397
+ return previous.tasks.some((prevTask) => {
398
+ if (prevTask.task_id !== t.task_id || prevTask.ui_affecting !== undefined)
399
+ return false;
400
+ const { plan_version: _pv1, task_status: _ts1, ...prevRest } = prevTask;
401
+ return deepEqual(prevRest, rest);
402
+ });
403
+ }
175
404
  /**
176
405
  * A `keeps_exports` promise is a file the task swears to keep the exports of,
177
406
  * and the post-run verifier reads it back against the task's diff. Three
@@ -256,6 +485,11 @@ export function validatePlan(plan, opts = {}) {
256
485
  for (const issue of unkeptPromises(t, result.valid ? [] : result.errors)) {
257
486
  errors.push({ path: `/tasks/${t.task_id}${issue.path}`, message: issue.message });
258
487
  }
488
+ if (!isGrandfatheredUiFlag(t, opts.previous)) {
489
+ for (const issue of uiFlagMissing(t)) {
490
+ errors.push({ path: `/tasks/${t.task_id}${issue.path}`, message: issue.message });
491
+ }
492
+ }
259
493
  if (t.plan_version !== plan.version) {
260
494
  errors.push({
261
495
  path: `/tasks/${t.task_id}/plan_version`,
@@ -268,7 +502,7 @@ export function validatePlan(plan, opts = {}) {
268
502
  for (const field of unreadTaskBudgetFields(budget)) {
269
503
  errors.push({
270
504
  path: `/tasks/${t.task_id}/budget/${field}`,
271
- message: `Budget field "${field}" has no mechanical reader: nothing in the factory can enforce it per task, so declaring it here states a limit the task will never be held to. The turn ceiling is the role template's maxTurns (.claude/agents/<role>.md), enforced by the harness per role; the dispatch prompt restates that number (agent-interviews.md M-4, corrected 2026-09-11).`,
505
+ message: `Budget field "${field}" has no mechanical reader: nothing in the factory can enforce it per task, so declaring it here states a limit the task will never be held to. The turn ceiling is the role template's maxTurns (.claude/agents/bs-<role>.md), enforced by the harness per role; the dispatch prompt restates that number (agent-interviews.md M-4, corrected 2026-09-11).`,
272
506
  });
273
507
  }
274
508
  // D-7: the ref is what result.schema.json defers structured_output's shape
@@ -408,7 +642,72 @@ function readAddedList(epicId, version, added) {
408
642
  });
409
643
  return added;
410
644
  }
411
- export function draftNextVersion(prev, changes) {
645
+ /**
646
+ * The replacement and added records a draft lands, at most one live record
647
+ * under any id. `liveSpec` reads the last live record under an id, so a second
648
+ * one fails nothing: it quietly wins, and ingest sees one task where the
649
+ * author wrote two.
650
+ *
651
+ * Several supersede keys folding into one task is the legitimate way to name
652
+ * one id twice. Their replacements are identical, so one copy is carried.
653
+ * Anything else that lands a second live record is refused: two keys with
654
+ * different records under one id, or a replacement or added id that a carried
655
+ * task still holds live, that a replacement already declares, or that an
656
+ * earlier added entry declares. Dead records may share an id freely; a task
657
+ * superseded under its own id (D-121) already leaves one of each.
658
+ */
659
+ function oneLiveRecordPerId(prev, version, carried, supersede, addedList) {
660
+ const refuse = (taskId, why, details) => new PlanError('plan.duplicate-live-task', `Refusing to draft plan "${prev.epic_id}" v${version}: ${why}`, { epicId: prev.epic_id, version, taskId, ...details });
661
+ const ownId = (taskId) => `Give the new task an id of its own, or supersede "${taskId}" in the same amendment.`;
662
+ const liveCarried = new Set(carried.filter((t) => t.task_status !== 'superseded').map((t) => t.task_id));
663
+ const replacements = [];
664
+ const keyOf = new Map();
665
+ for (const [key, t] of Object.entries(supersede)) {
666
+ const record = { ...t, plan_version: version };
667
+ const firstKey = keyOf.get(record.task_id);
668
+ if (firstKey === undefined) {
669
+ if (liveCarried.has(record.task_id)) {
670
+ throw refuse(record.task_id, `changes.supersede["${key}"] replaces into "${record.task_id}", which v${prev.version} still holds live and this amendment does not supersede. ${ownId(record.task_id)}`, { key });
671
+ }
672
+ keyOf.set(record.task_id, key);
673
+ replacements.push(record);
674
+ continue;
675
+ }
676
+ const first = replacements.find((r) => r.task_id === record.task_id);
677
+ if (!isDeepStrictEqual(first, record)) {
678
+ throw refuse(record.task_id, `changes.supersede["${firstKey}"] and changes.supersede["${key}"] both replace into "${record.task_id}" with different records. Keys fold into one task only when they carry the same replacement.`, { keys: [firstKey, key] });
679
+ }
680
+ }
681
+ const added = [];
682
+ const addedAt = new Map();
683
+ addedList.forEach((t, index) => {
684
+ const where = liveCarried.has(t.task_id)
685
+ ? `v${prev.version} still holds it live. ${ownId(t.task_id)}`
686
+ : keyOf.has(t.task_id)
687
+ ? `changes.supersede["${keyOf.get(t.task_id)}"] already replaces into it. Give the new task an id of its own.`
688
+ : addedAt.has(t.task_id)
689
+ ? `changes.added[${addedAt.get(t.task_id)}] already declares it. Give the new task an id of its own.`
690
+ : undefined;
691
+ if (where !== undefined) {
692
+ throw refuse(t.task_id, `changes.added[${index}] declares "${t.task_id}", and ${where}`, {
693
+ index,
694
+ });
695
+ }
696
+ addedAt.set(t.task_id, index);
697
+ added.push({ ...t, plan_version: version });
698
+ });
699
+ return { replacements, added };
700
+ }
701
+ export function draftNextVersion(prev, changes,
702
+ /**
703
+ * Dead records for tasks the plan never listed — ones only the event log
704
+ * added — that `changes.supersede` retires. Built by the caller from the
705
+ * log (`supersededStubFromLog`), because this function does no I/O. Each
706
+ * must already be `superseded` at the new version; they are carried beside
707
+ * the plan's own records so ingest has something to emit
708
+ * `task-superseded` from.
709
+ */
710
+ loggedStubs = []) {
412
711
  const newVersion = prev.version + 1;
413
712
  const supersede = readSupersedeMap(prev.epic_id, newVersion, changes.supersede);
414
713
  const carried = [];
@@ -422,11 +721,8 @@ export function draftNextVersion(prev, changes) {
422
721
  }
423
722
  carried.push({ ...t, plan_version: newVersion });
424
723
  }
425
- const replacements = Object.values(supersede).map((t) => ({ ...t, plan_version: newVersion }));
426
- const added = readAddedList(prev.epic_id, newVersion, changes.added).map((t) => ({
427
- ...t,
428
- plan_version: newVersion,
429
- }));
724
+ carried.push(...loggedStubs);
725
+ const { replacements, added } = oneLiveRecordPerId(prev, newVersion, carried, supersede, readAddedList(prev.epic_id, newVersion, changes.added));
430
726
  const tasks = [...carried, ...replacements, ...added];
431
727
  // An edge may only name a task this version declares -- `validatePlan`'s own
432
728
  // rule. Dropping a completed task from the live backlog is what breaks it:
@@ -435,21 +731,69 @@ export function draftNextVersion(prev, changes) {
435
731
  // no longer mentions. Plans are immutable and nothing deletes one, so that
436
732
  // left no way out.
437
733
  //
438
- // Dropping the edge loses nothing: the only ids that leave are completed and
439
- // not superseded, so the ordering constraint is already discharged, and a
440
- // superseded task is carried forward (as `superseded`) and keeps its edges.
441
- // Applied to the CARRIED edges only -- an edge this amendment adds is
442
- // something the author wrote, and deleting it silently would answer a typo
443
- // with a plan that no longer says what they asked for. `validatePlan` names
444
- // that one instead.
734
+ // Dropping the edge loses nothing: the only ids that leave outright are
735
+ // completed and not superseded, so the ordering constraint is already
736
+ // discharged. Applied to the CARRIED edges only -- an edge this amendment
737
+ // adds is something the author wrote, and deleting it silently would answer
738
+ // a typo with a plan that no longer says what they asked for. `validatePlan`
739
+ // names that one instead.
740
+ //
741
+ // A superseded task is carried forward too (as `superseded`), which used to
742
+ // read as "so it keeps its edges" -- true only when the replacement keeps
743
+ // the old task_id (D-121's usual case; the id maps to itself below). A
744
+ // supersede that changes the id moves the *work* to the new id, but the old
745
+ // one stays in `tasks` only as a dead record: `livePlanTasks` (and so
746
+ // `computeNextWave`'s done set, waveNext.ts) drops it, so it can never
747
+ // become terminal. An edge still naming it would defer its dependent
748
+ // forever -- `dependency-pending` on an id nothing will ever finish. Moving
749
+ // both ends of a carried edge through the old-id -> replacement-id map
750
+ // before the declared-check is what keeps the dependency resolvable under
751
+ // its new name instead of quietly wedging it (#234).
445
752
  const declared = new Set(tasks.map((t) => t.task_id));
446
- const carriedEdges = prev.edges.filter((e) => declared.has(e.task) && declared.has(e.dependsOn));
753
+ const rewireTo = new Map();
754
+ for (const [oldId, replacement] of Object.entries(supersede)) {
755
+ if (replacement.task_id !== oldId)
756
+ rewireTo.set(oldId, replacement.task_id);
757
+ }
758
+ const rewire = (id) => rewireTo.get(id) ?? id;
759
+ // taskEvents.ts's `edgeKey` (see its doc comment at edgesAlreadyRecorded)
760
+ // treats task+dependsOn+edge_type as the triple that identifies an edge: an
761
+ // `artifact` handoff and a `claim-order` handoff between the same two tasks
762
+ // are two different claims and both belong in the log as two events. Not
763
+ // reused directly -- taskEvents.ts imports plan.ts, so importing back would
764
+ // make a cycle -- but the dedup below has to match its shape or it silently
765
+ // merges two edges that the rest of the system has always kept distinct.
766
+ const edgeKey = (task, dependsOn, edgeType) => `${task}\u0000${dependsOn}\u0000${edgeType}`;
767
+ const newEdges = changes.newEdges ?? [];
768
+ const newEdgeKeys = new Set(newEdges.map((e) => edgeKey(e.task, e.dependsOn, e.edge_type)));
769
+ const seenCarried = new Set();
770
+ const carriedEdges = [];
771
+ for (const e of prev.edges) {
772
+ const rewiredTask = rewire(e.task);
773
+ const rewiredDependsOn = rewire(e.dependsOn);
774
+ if (!declared.has(rewiredTask) || !declared.has(rewiredDependsOn))
775
+ continue;
776
+ if (rewiredTask === rewiredDependsOn)
777
+ continue; // rewiring both ends onto the same replacement is not a dependency
778
+ const key = edgeKey(rewiredTask, rewiredDependsOn, e.edge_type);
779
+ // An edge this amendment adds wins over a carried edge rewired onto the
780
+ // same (task, dependsOn, edge_type) triple, and a rewire that collapses
781
+ // two carried edges onto the same triple keeps only the first -- either
782
+ // way, no duplicate. Two edges on the same pair with different edge_type
783
+ // are not a duplicate at all (edgeKey's triple, not just the pair).
784
+ if (newEdgeKeys.has(key) || seenCarried.has(key))
785
+ continue;
786
+ seenCarried.add(key);
787
+ carriedEdges.push(rewiredTask === e.task && rewiredDependsOn === e.dependsOn
788
+ ? e
789
+ : { ...e, task: rewiredTask, dependsOn: rewiredDependsOn });
790
+ }
447
791
  return {
448
792
  epic_id: prev.epic_id,
449
793
  version: newVersion,
450
794
  status: 'active',
451
795
  tasks,
452
- edges: [...carriedEdges, ...(changes.newEdges ?? [])],
796
+ edges: [...carriedEdges, ...newEdges],
453
797
  ...(prev.project ? { project: prev.project } : {}),
454
798
  ...(prev.effort ? { effort: prev.effort } : {}),
455
799
  };
@@ -464,8 +808,8 @@ export function draftNextVersion(prev, changes) {
464
808
  * is not: a caller that could write half of a version could write a version
465
809
  * no event explains.
466
810
  */
467
- export function nextVersion(prev, changes, opts = {}) {
468
- const newPlan = draftNextVersion(prev, changes);
811
+ export function nextVersion(prev, changes, opts = {}, loggedStubs = []) {
812
+ const newPlan = draftNextVersion(prev, changes, loggedStubs);
469
813
  writePlanFile(newPlan, opts);
470
814
  return newPlan;
471
815
  }
@@ -479,7 +823,7 @@ export function nextVersion(prev, changes, opts = {}) {
479
823
  * dead copies are set aside. Indexing the whole task list into a Map by id
480
824
  * silently answers with whichever record happened to be written last.
481
825
  */
482
- function liveSpec(plan, id) {
826
+ export function liveSpec(plan, id) {
483
827
  let found;
484
828
  for (const t of plan.tasks) {
485
829
  if (t.task_id === id && t.task_status !== 'superseded')
@@ -509,6 +853,19 @@ export function livePlanTasks(plan) {
509
853
  }
510
854
  return live;
511
855
  }
856
+ /**
857
+ * The plan's tasks in the shape `resolveFindingOwner` reads: only the live
858
+ * (D-126) ones, and only the `claims` field ownership resolution needs. This
859
+ * is `livePlanTasks` restated as claims rather than full specs, so a caller
860
+ * asking "does some task in this plan still claim that file?" (`audit
861
+ * resolve`) gets the same live/superseded rule `epic verdict` does, not the
862
+ * raw `plan.tasks` array that also holds every amendment left behind.
863
+ */
864
+ export function planClaimedTasks(plan) {
865
+ return livePlanTasks(plan)
866
+ .filter((task) => Array.isArray(task.claims))
867
+ .map((task) => ({ task_id: task.task_id, claims: task.claims }));
868
+ }
512
869
  /**
513
870
  * What makes two records the same *spec*: everything except the version stamp
514
871
  * and the workflow status. `nextVersion` rewrites `plan_version` on every
@@ -536,6 +893,12 @@ function specSignature(t) {
536
893
  * keyed by task_id and a replacement that keeps its id — the shape every real
537
894
  * amendment has used — leaves the plan holding a dead record and a live one
538
895
  * under the same key.
896
+ *
897
+ * An id new to B is `added` only if B holds it live. One that arrives already
898
+ * dead is a task B retired without A ever recording it — a task only the event
899
+ * log added, which `draftNextVersion` carries as a superseded stub — so it is
900
+ * `superseded`, after the ids A knew. Reading it as added would make a dead
901
+ * record look like new work to land.
539
902
  */
540
903
  export function diffPlans(vA, vB) {
541
904
  const aIds = new Set(vA.tasks.map((t) => t.task_id));
@@ -544,9 +907,11 @@ export function diffPlans(vA, vB) {
544
907
  const superseded = [];
545
908
  const carried = [];
546
909
  const removed = [];
910
+ const deadOnArrival = [];
547
911
  for (const id of bIds) {
548
- if (!aIds.has(id))
549
- added.push(id);
912
+ if (aIds.has(id))
913
+ continue;
914
+ (liveSpec(vB, id) === undefined ? deadOnArrival : added).push(id);
550
915
  }
551
916
  for (const id of aIds) {
552
917
  if (!bIds.has(id)) {
@@ -562,5 +927,6 @@ export function diffPlans(vA, vB) {
562
927
  : liveB !== undefined && specSignature(liveA) === specSignature(liveB);
563
928
  (same ? carried : superseded).push(id);
564
929
  }
930
+ superseded.push(...deadOnArrival);
565
931
  return { added, removed, superseded, carried };
566
932
  }
@@ -1,5 +1,5 @@
1
- import { loadBudgetPolicy } from './budgets.js';
2
1
  import { loadCrosscheckPolicy, } from './crosscheck.js';
2
+ import { budgetPolicyForPlan } from './epicBudget.js';
3
3
  import { appendEvent } from './events.js';
4
4
  import { livePlanTasks, loadPlan, PlanError, planRefTaskId, } from './plan.js';
5
5
  import { enabledExternalProviders, nativeProviderName, runQuorumCase, } from './quorum.js';
@@ -329,7 +329,10 @@ export async function runPlanQuorum(input, ctx, opts = {}) {
329
329
  });
330
330
  }
331
331
  const policy = input.crosscheck?.policy ?? loadCrosscheckPolicy();
332
- const epicCapTokens = input.epicCapTokens ?? loadBudgetPolicy().epic.capTokens;
332
+ // The cap for the tier this plan runs at: its own `effort` after the
333
+ // security floor (default_tier when it names none).
334
+ const epicCapTokens = input.epicCapTokens ??
335
+ budgetPolicyForPlan(plan, { securityPolicy: policy.planQuorum }).epic.capTokens;
333
336
  const triggers = evaluatePlanQuorumTriggers(plan, policy.planQuorum, epicCapTokens, {
334
337
  plannerConfidence: input.plannerConfidence,
335
338
  });