@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
@@ -1,7 +1,9 @@
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';
@@ -10,18 +12,11 @@ import { epicOfTaskId } from './taskId.js';
10
12
  import { loadTaxonomy, TaxonomyError, validateRequiredDimensions, } from './taxonomy.js';
11
13
  export class PlanError extends SmithError {
12
14
  }
13
- /**
14
- * How much judgment an epic buys, cheapest first — factory/policies/effort.yml
15
- * declares what each tier means; this is only the closed list of names, kept
16
- * here beside the `PlanFile.effort` field it types. effort.ts is the semantic
17
- * owner and re-exports these; it cannot own the list itself, because plan.ts
18
- * would then have to import it to validate a plan and effort.ts already
19
- * (transitively) imports plan.ts.
20
- */
21
- export const EFFORT_TIERS = ['small', 'medium', 'huge'];
22
- export function isEffortTier(value) {
23
- return typeof value === 'string' && EFFORT_TIERS.includes(value);
24
- }
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 };
25
20
  /**
26
21
  * Pseudo task-id ref for a PLAN-level (not task-level) event: planQuorum.ts's
27
22
  * runPlanQuorum() stamps it onto the dispatch_decision/judge-verdict/
@@ -104,6 +99,24 @@ function planFilePath(epicId, version, opts) {
104
99
  const dir = opts.specsDir ?? SPECS_ACTIVE_DIR;
105
100
  return path.join(dir, epicId, `plan-v${version}.json`);
106
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
+ }
107
120
  const PLAN_FILE_PATTERN = /^plan-v(\d+)\.json$/;
108
121
  /**
109
122
  * The highest plan version written for an epic, or null when the epic has no
@@ -289,6 +302,105 @@ function describeAllowed(err) {
289
302
  * of what an edge must carry, which could then drift from taxonomy.yml's.
290
303
  */
291
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
+ }
292
404
  /**
293
405
  * A `keeps_exports` promise is a file the task swears to keep the exports of,
294
406
  * and the post-run verifier reads it back against the task's diff. Three
@@ -373,6 +485,11 @@ export function validatePlan(plan, opts = {}) {
373
485
  for (const issue of unkeptPromises(t, result.valid ? [] : result.errors)) {
374
486
  errors.push({ path: `/tasks/${t.task_id}${issue.path}`, message: issue.message });
375
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
+ }
376
493
  if (t.plan_version !== plan.version) {
377
494
  errors.push({
378
495
  path: `/tasks/${t.task_id}/plan_version`,
@@ -385,7 +502,7 @@ export function validatePlan(plan, opts = {}) {
385
502
  for (const field of unreadTaskBudgetFields(budget)) {
386
503
  errors.push({
387
504
  path: `/tasks/${t.task_id}/budget/${field}`,
388
- 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).`,
389
506
  });
390
507
  }
391
508
  // D-7: the ref is what result.schema.json defers structured_output's shape
@@ -525,7 +642,72 @@ function readAddedList(epicId, version, added) {
525
642
  });
526
643
  return added;
527
644
  }
528
- 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 = []) {
529
711
  const newVersion = prev.version + 1;
530
712
  const supersede = readSupersedeMap(prev.epic_id, newVersion, changes.supersede);
531
713
  const carried = [];
@@ -539,11 +721,8 @@ export function draftNextVersion(prev, changes) {
539
721
  }
540
722
  carried.push({ ...t, plan_version: newVersion });
541
723
  }
542
- const replacements = Object.values(supersede).map((t) => ({ ...t, plan_version: newVersion }));
543
- const added = readAddedList(prev.epic_id, newVersion, changes.added).map((t) => ({
544
- ...t,
545
- plan_version: newVersion,
546
- }));
724
+ carried.push(...loggedStubs);
725
+ const { replacements, added } = oneLiveRecordPerId(prev, newVersion, carried, supersede, readAddedList(prev.epic_id, newVersion, changes.added));
547
726
  const tasks = [...carried, ...replacements, ...added];
548
727
  // An edge may only name a task this version declares -- `validatePlan`'s own
549
728
  // rule. Dropping a completed task from the live backlog is what breaks it:
@@ -552,21 +731,69 @@ export function draftNextVersion(prev, changes) {
552
731
  // no longer mentions. Plans are immutable and nothing deletes one, so that
553
732
  // left no way out.
554
733
  //
555
- // Dropping the edge loses nothing: the only ids that leave are completed and
556
- // not superseded, so the ordering constraint is already discharged, and a
557
- // superseded task is carried forward (as `superseded`) and keeps its edges.
558
- // Applied to the CARRIED edges only -- an edge this amendment adds is
559
- // something the author wrote, and deleting it silently would answer a typo
560
- // with a plan that no longer says what they asked for. `validatePlan` names
561
- // 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).
562
752
  const declared = new Set(tasks.map((t) => t.task_id));
563
- 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
+ }
564
791
  return {
565
792
  epic_id: prev.epic_id,
566
793
  version: newVersion,
567
794
  status: 'active',
568
795
  tasks,
569
- edges: [...carriedEdges, ...(changes.newEdges ?? [])],
796
+ edges: [...carriedEdges, ...newEdges],
570
797
  ...(prev.project ? { project: prev.project } : {}),
571
798
  ...(prev.effort ? { effort: prev.effort } : {}),
572
799
  };
@@ -581,8 +808,8 @@ export function draftNextVersion(prev, changes) {
581
808
  * is not: a caller that could write half of a version could write a version
582
809
  * no event explains.
583
810
  */
584
- export function nextVersion(prev, changes, opts = {}) {
585
- const newPlan = draftNextVersion(prev, changes);
811
+ export function nextVersion(prev, changes, opts = {}, loggedStubs = []) {
812
+ const newPlan = draftNextVersion(prev, changes, loggedStubs);
586
813
  writePlanFile(newPlan, opts);
587
814
  return newPlan;
588
815
  }
@@ -596,7 +823,7 @@ export function nextVersion(prev, changes, opts = {}) {
596
823
  * dead copies are set aside. Indexing the whole task list into a Map by id
597
824
  * silently answers with whichever record happened to be written last.
598
825
  */
599
- function liveSpec(plan, id) {
826
+ export function liveSpec(plan, id) {
600
827
  let found;
601
828
  for (const t of plan.tasks) {
602
829
  if (t.task_id === id && t.task_status !== 'superseded')
@@ -666,6 +893,12 @@ function specSignature(t) {
666
893
  * keyed by task_id and a replacement that keeps its id — the shape every real
667
894
  * amendment has used — leaves the plan holding a dead record and a live one
668
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.
669
902
  */
670
903
  export function diffPlans(vA, vB) {
671
904
  const aIds = new Set(vA.tasks.map((t) => t.task_id));
@@ -674,9 +907,11 @@ export function diffPlans(vA, vB) {
674
907
  const superseded = [];
675
908
  const carried = [];
676
909
  const removed = [];
910
+ const deadOnArrival = [];
677
911
  for (const id of bIds) {
678
- if (!aIds.has(id))
679
- added.push(id);
912
+ if (aIds.has(id))
913
+ continue;
914
+ (liveSpec(vB, id) === undefined ? deadOnArrival : added).push(id);
680
915
  }
681
916
  for (const id of aIds) {
682
917
  if (!bIds.has(id)) {
@@ -692,5 +927,6 @@ export function diffPlans(vA, vB) {
692
927
  : liveB !== undefined && specSignature(liveA) === specSignature(liveB);
693
928
  (same ? carried : superseded).push(id);
694
929
  }
930
+ superseded.push(...deadOnArrival);
695
931
  return { added, removed, superseded, carried };
696
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
  });