@erclx/canon 4.121.0 → 4.123.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 (116) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +6 -5
  3. package/claude/skills/auto-ship/references/tier-failures.md +7 -4
  4. package/claude/skills/backlog-triage/REQUIREMENT.md +47 -0
  5. package/claude/skills/backlog-triage/SKILL.md +139 -0
  6. package/claude/skills/canon-rollout/SKILL.md +6 -6
  7. package/claude/skills/context-fold/REQUIREMENT.md +75 -0
  8. package/claude/skills/context-fold/SKILL.md +162 -0
  9. package/claude/skills/{docs-fold → context-fold}/references/anchor-sweep.md +1 -1
  10. package/claude/skills/{docs-fold → context-fold}/references/classify.md +4 -2
  11. package/claude/skills/context-fold/references/context-refresh.md +48 -0
  12. package/claude/skills/context-fold/references/promoted-pages.md +29 -0
  13. package/claude/skills/context-fold/references/receipt-sweep.md +43 -0
  14. package/claude/skills/context-fold/references/unusable-baseline.md +25 -0
  15. package/claude/skills/{docs-fold → context-fold}/references/wireframe-sweep.md +1 -1
  16. package/claude/skills/docs-fold/REQUIREMENT.md +8 -58
  17. package/claude/skills/docs-fold/SKILL.md +3 -278
  18. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  19. package/claude/skills/docs-sync/SKILL.md +1 -1
  20. package/claude/skills/draft-context/REQUIREMENT.md +2 -2
  21. package/claude/skills/draft-context/SKILL.md +3 -3
  22. package/claude/skills/draft-docs/REQUIREMENT.md +1 -1
  23. package/claude/skills/draft-ready/SKILL.md +1 -1
  24. package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
  25. package/claude/skills/draft-wireframes/SKILL.md +2 -2
  26. package/claude/skills/git-pr/SKILL.md +8 -146
  27. package/claude/skills/git-pr/references/evidence.md +114 -0
  28. package/claude/skills/git-pr/references/labels.md +20 -0
  29. package/claude/skills/git-pr/references/task-number.md +26 -0
  30. package/claude/skills/git-ship/SKILL.md +5 -4
  31. package/claude/skills/memory-capture/REQUIREMENT.md +2 -2
  32. package/claude/skills/memory-capture/SKILL.md +19 -9
  33. package/claude/skills/memory-review/REQUIREMENT.md +5 -3
  34. package/claude/skills/memory-review/SKILL.md +22 -107
  35. package/claude/skills/memory-review/references/later-phases.md +111 -0
  36. package/claude/skills/memory-review/references/receipt-format.md +19 -3
  37. package/claude/skills/plan-feature/REQUIREMENT.md +1 -1
  38. package/claude/skills/review-address/REQUIREMENT.md +1 -1
  39. package/claude/skills/review-address/SKILL.md +1 -1
  40. package/claude/skills/review-branch/SKILL.md +1 -1
  41. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  42. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
  43. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +4 -2
  44. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +2 -2
  45. package/claude/skills/session-resume/REQUIREMENT.md +1 -1
  46. package/claude/skills/session-resume/SKILL.md +4 -3
  47. package/claude/skills/session-worktree/SKILL.md +4 -10
  48. package/claude/skills/task-board/REQUIREMENT.md +1 -1
  49. package/claude/skills/task-board/SKILL.md +7 -81
  50. package/claude/skills/task-board/references/archive.md +54 -0
  51. package/claude/skills/task-board/references/decline.md +40 -0
  52. package/claude/skills/teach-workspace/SKILL.md +8 -105
  53. package/claude/skills/teach-workspace/references/lesson-craft.md +51 -1
  54. package/claude/skills/teach-workspace/references/promotion.md +41 -0
  55. package/docs/agents/architecture-record.md +26 -0
  56. package/docs/agents/commands.md +87 -154
  57. package/docs/agents/context-audit-checks.md +2 -20
  58. package/docs/agents/context-audit.md +1 -1
  59. package/docs/agents/context-classify.md +1 -1
  60. package/docs/agents/drift-surfaces.md +192 -0
  61. package/docs/agents/index.md +12 -4
  62. package/docs/agents/install-and-sync.md +2 -181
  63. package/docs/agents/markdown-audit-weight.md +73 -0
  64. package/docs/agents/markdown-audit.md +3 -90
  65. package/docs/agents/migrate.md +34 -0
  66. package/docs/agents/records-stale.md +62 -0
  67. package/docs/agents/records.md +5 -1
  68. package/docs/agents/sandbox.md +1 -1
  69. package/docs/agents/tasks-archive.md +81 -0
  70. package/docs/agents/tasks-plans.md +149 -0
  71. package/docs/agents/tasks-validate.md +131 -0
  72. package/docs/agents/tasks.md +7 -334
  73. package/docs/target-projects.md +3 -3
  74. package/docs/workflow/ai-workflow.md +34 -29
  75. package/docs/workflow/operating-model.md +1 -1
  76. package/governance/rules/claude/555-tasks.md +2 -1
  77. package/governance/rules/core/045-memory.md +1 -1
  78. package/package.json +1 -1
  79. package/src/claude/cases/setup.ts +1 -1
  80. package/src/claude/cases/workflow.ts +22 -1
  81. package/src/commands/records.ts +146 -0
  82. package/src/commands/sandbox.ts +6 -4
  83. package/src/commands/tasks.ts +39 -29
  84. package/src/context/classify/extract.ts +1 -1
  85. package/src/context/classify/ollama.ts +1 -1
  86. package/src/context/classify/run.ts +1 -1
  87. package/src/gate/measures.ts +1 -1
  88. package/src/gate/stages.ts +1 -0
  89. package/src/migrate/skill-names.ts +15 -2
  90. package/src/records/stale.ts +243 -0
  91. package/src/sandbox/expect.ts +1 -1
  92. package/src/tasks/archive.ts +223 -92
  93. package/src/tasks/reach.ts +1 -1
  94. package/src/tasks/record.ts +60 -17
  95. package/src/tasks/validate.ts +37 -4
  96. package/standards/board.md +136 -0
  97. package/standards/commit.md +5 -0
  98. package/standards/groundwork.md +25 -36
  99. package/standards/index.md +2 -1
  100. package/standards/memory.md +13 -1
  101. package/standards/plan.md +20 -34
  102. package/standards/readme.md +8 -77
  103. package/standards/requirements.md +2 -2
  104. package/standards/tasks.md +19 -126
  105. package/standards/versioning.md +1 -1
  106. package/tooling/astro/reference.md +1 -1
  107. package/tooling/base/configs/.husky/post-merge +1 -1
  108. package/tooling/base/configs/commitlint.config.js +27 -1
  109. package/tooling/base/reference.md +1 -1
  110. package/tooling/claude/reference.md +1 -1
  111. package/tooling/go/reference.md +1 -1
  112. package/tooling/nextjs/reference.md +1 -1
  113. package/tooling/php/reference.md +1 -1
  114. package/tooling/python/reference.md +1 -1
  115. package/tooling/vite-react/reference.md +1 -1
  116. package/tooling/web/reference.md +1 -1
@@ -90,9 +90,9 @@ export interface ArchiveSuccess {
90
90
  readonly to: string
91
91
  readonly priorityRowRemoved: boolean
92
92
  readonly indexRegenerated: boolean
93
- /** Undefined when the task cited no live plan, or when another task still holds it. */
94
- readonly plan: PlanMove | undefined
95
- /** Undefined when the plan stays, or when no ready folder resolves and can move. */
93
+ /** Every live plan the task cited that no other live task still holds, in line order. */
94
+ readonly plans: readonly PlanMove[]
95
+ /** Undefined when no plan moves, or when no ready folder resolves and can move. */
96
96
  readonly ready: ReadyMove | undefined
97
97
  readonly closed: number
98
98
  readonly cut: number
@@ -115,8 +115,8 @@ export interface DeclineSuccess {
115
115
  readonly priorityRowRemoved: boolean
116
116
  readonly backlogRowRemoved: boolean
117
117
  readonly indexRegenerated: boolean
118
- /** Undefined when the task cited no live plan, or when another task still holds it. */
119
- readonly plan: PlanMove | undefined
118
+ /** Every live plan the task cited that no other live task still holds, in line order. */
119
+ readonly plans: readonly PlanMove[]
120
120
  }
121
121
 
122
122
  export interface DeclineRefused {
@@ -251,22 +251,78 @@ export function readPullRequest(text: string): readonly number[] {
251
251
  return match[1].split(',').map((entry) => Number(entry.trim().slice(1)))
252
252
  }
253
253
 
254
+ /** A markdown link on a `Plan:` line, its target captured. */
255
+ const PLAN_LINK_PATTERN = /\[[^\]]*\]\(([^)]+)\)/g
256
+
254
257
  /**
255
- * The `Plan:` line in either form, the link's target captured ahead of the bare
256
- * path. Padding is spaces and tabs rather than `\s`, which spans a newline, so
257
- * the match ends at the line and the retarget below cannot swallow the blank
258
- * line that follows it.
258
+ * Finds the first `Plan:` line outside a fenced sample. Every reader and both
259
+ * writers locate the line through this, so none of them can act on a sample a
260
+ * task displays while another reads the real line below it.
259
261
  */
260
- const PLAN_PATTERN = /^Plan:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
262
+ export function planLineIndex(lines: readonly string[]): number {
263
+ const fenced = fenceMask(lines)
264
+
265
+ return lines.findIndex(
266
+ (line, index) => !fenced[index] && line.startsWith('Plan:'),
267
+ )
268
+ }
269
+
270
+ interface PlanToken {
271
+ readonly target: string
272
+ readonly start: number
273
+ readonly end: number
274
+ }
261
275
 
262
276
  /**
263
- * Reads the `Plan:` target out of a markdown link, falling back to the older
264
- * bare-path form. The path is returned as written, relative to the board.
277
+ * The targets a located `Plan:` line carries, each with its span on the line.
278
+ * Links win over the bare form. A bare remainder yields its leading run of
279
+ * tokens reading as a path, one carrying `/` or ending in `.md`, and stops at
280
+ * the first that does not, so a path inside trailing prose never reads as a
281
+ * second plan. It falls back to the first token when that one is not a path.
282
+ */
283
+ function planTokens(line: string): readonly PlanToken[] {
284
+ const links = [...line.matchAll(PLAN_LINK_PATTERN)].map((match) => ({
285
+ target: match[1],
286
+ start: match.index,
287
+ end: match.index + match[0].length,
288
+ }))
289
+ if (links.length > 0) return links
290
+
291
+ const bare = [...line.slice('Plan:'.length).matchAll(/[^\s,]+/g)].map(
292
+ (match) => ({
293
+ target: match[0],
294
+ start: 'Plan:'.length + match.index,
295
+ end: 'Plan:'.length + match.index + match[0].length,
296
+ }),
297
+ )
298
+ const prose = bare.findIndex(
299
+ ({ target }) => !target.includes('/') && !target.endsWith('.md'),
300
+ )
301
+ const paths = prose === -1 ? bare : bare.slice(0, prose)
302
+
303
+ return paths.length > 0 ? paths : bare.slice(0, 1)
304
+ }
305
+
306
+ /**
307
+ * Reads every target the first `Plan:` line outside a fenced sample carries.
308
+ * The path is returned as written, relative to the board.
309
+ */
310
+ export function readPlanTargets(text: string): readonly string[] {
311
+ const lines = text.split('\n')
312
+ const index = planLineIndex(lines)
313
+ if (index === -1) return []
314
+
315
+ return planTokens(lines[index]).map(({ target }) => target)
316
+ }
317
+
318
+ /**
319
+ * The single target a task's `Plan:` line carries, or `undefined` for no line
320
+ * and for a line linking several plans alike. A caller that must tell those two
321
+ * apart reads `readPlanTargets` instead.
265
322
  */
266
323
  export function readPlanTarget(text: string): string | undefined {
267
- const match = PLAN_PATTERN.exec(text)
268
- if (!match) return undefined
269
- return match[1] ?? match[2]
324
+ const targets = readPlanTargets(text)
325
+ return targets.length === 1 ? targets[0] : undefined
270
326
  }
271
327
 
272
328
  /**
@@ -276,28 +332,50 @@ export function readPlanTarget(text: string): string | undefined {
276
332
  * one line shape rather than two.
277
333
  */
278
334
  export function planLine(target: string): string {
335
+ return `Plan: ${planLink(target)}`
336
+ }
337
+
338
+ function planLink(target: string): string {
279
339
  const name = basename(target)
280
340
  const label = name.endsWith('.md') ? name.slice(0, -'.md'.length) : name
281
341
 
282
- return `Plan: [${label}](${target})`
342
+ return `[${label}](${target})`
283
343
  }
284
344
 
285
345
  /**
286
- * Points the task's `Plan:` line at the plan's new home. The line is matched
287
- * with the pattern the read above uses, so the archive rewrites exactly the
288
- * line it parsed and never a second `Plan:` a task displays inside a fenced
289
- * sample.
346
+ * Points each moved link on the task's `Plan:` line at the plan's new home,
347
+ * keyed by the target as the line wrote it. A link the map does not name keeps
348
+ * its path, since a plan another task still cites stays live. A moved bare
349
+ * path is written back as a link, the shape `planLine` gives a single one.
290
350
  *
291
- * The replacement is built by a function rather than passed as a string,
292
- * because `$&` and its siblings are substitution sequences inside a replacement
293
- * string. A plan filename carrying one would write a path nobody typed, and
294
- * `.canon/plans/` is gitignored, so nothing recovers the pointer it replaced.
351
+ * The line is rebuilt by concatenation rather than a replacement string,
352
+ * because `$&` and its siblings are substitution sequences inside one. A plan
353
+ * filename carrying one would write a path nobody typed, and `.canon/plans/` is
354
+ * gitignored, so nothing recovers the pointer it replaced.
295
355
  */
296
- export function retargetPlanLine(text: string, target: string): string {
297
- return text.replace(PLAN_PATTERN, () => planLine(target))
356
+ export function retargetPlanLine(
357
+ text: string,
358
+ moves: ReadonlyMap<string, string>,
359
+ ): string {
360
+ const lines = text.split('\n')
361
+ const index = planLineIndex(lines)
362
+ if (index === -1) return text
363
+
364
+ const line = lines[index]
365
+ let rewritten = ''
366
+ let at = 0
367
+ for (const token of planTokens(line)) {
368
+ const to = moves.get(token.target)
369
+ if (to === undefined) continue
370
+ rewritten += line.slice(at, token.start) + planLink(to)
371
+ at = token.end
372
+ }
373
+ lines[index] = rewritten + line.slice(at)
374
+
375
+ return lines.join('\n')
298
376
  }
299
377
 
300
- /** The `Ready:` line in either form, matched the way `PLAN_PATTERN` matches `Plan:`. */
378
+ /** The `Ready:` line in either form, the link's target captured ahead of the bare path. */
301
379
  const READY_PATTERN = /^Ready:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
302
380
 
303
381
  /** A ready folder path as a plan's constraints write it, the folder name captured. */
@@ -431,7 +509,7 @@ function isBulletFor(line: string, target: string): boolean {
431
509
 
432
510
  /**
433
511
  * Resolves the `Plan:` target against the board and against the project root
434
- * both, which is how `docs-fold` reads the same line. It accepts `../plans/x.md`
512
+ * both, which is how `context-fold` reads the same line. It accepts `../plans/x.md`
435
513
  * and `.canon/plans/x.md` as one file, so a gate reading only the first form
436
514
  * would pass the second and strand the plan this exists to protect.
437
515
  *
@@ -484,8 +562,9 @@ function atOneRoot(path: string, plans: string[], root: string): string {
484
562
  }
485
563
 
486
564
  /**
487
- * Names the other live tasks whose `Plan:` line lands on the same file. This is
488
- * the rule `docs-fold` applies before it archives a plan, held here so one
565
+ * Names the other live tasks whose `Plan:` line lands on the same file through
566
+ * any of its links. This is
567
+ * the rule `context-fold` applies before it archives a plan, held here so one
489
568
  * question has one implementation: a plan another live task still cites is a
490
569
  * plan the sweep is correct to leave, and a guard that read the folder instead
491
570
  * refused every task sharing one plan and deadlocked the board against the
@@ -504,11 +583,13 @@ export async function otherTasksCitingPlan(
504
583
 
505
584
  const read = await Promise.all(
506
585
  stems.map(async (stem) => {
507
- const target = readPlanTarget(
586
+ const targets = readPlanTargets(
508
587
  await readFile(join(dir, `${stem}.md`), 'utf8'),
509
588
  )
510
- const resolved = target && resolveLivePlan(target, dir, root)
511
- return resolved === plan ? stem : undefined
589
+ const cites = targets.some(
590
+ (target) => resolveLivePlan(target, dir, root) === plan,
591
+ )
592
+ return cites ? stem : undefined
512
593
  }),
513
594
  )
514
595
 
@@ -518,10 +599,12 @@ export async function otherTasksCitingPlan(
518
599
  /**
519
600
  * Where a task's `Plan:` target resolves, which is what decides whether the
520
601
  * plan is the sweep's to move. `unstated` is a task carrying no line at all,
521
- * and it is distinct from a line resolving somewhere unexpected.
602
+ * and it is distinct from a line resolving somewhere unexpected. `several` is a
603
+ * line linking more than one plan, which answers no single location.
522
604
  */
523
605
  export const CITATION_LOCATIONS = [
524
606
  'unstated',
607
+ 'several',
525
608
  'live',
526
609
  'archived',
527
610
  'outside',
@@ -532,7 +615,10 @@ export type CitationLocation = (typeof CITATION_LOCATIONS)[number]
532
615
  export interface PlanCitations {
533
616
  readonly ok: true
534
617
  readonly stem: string
618
+ /** The one target the line carries. Undefined when it carries none or several. */
535
619
  readonly target: string | undefined
620
+ /** Every target the line carries, in line order. */
621
+ readonly targets: readonly string[]
536
622
  readonly location: CitationLocation
537
623
  /** Other live tasks landing on the same file. Empty unless `location` is `live`. */
538
624
  readonly citedBy: readonly string[]
@@ -542,7 +628,7 @@ export type CitationOutcome = PlanCitations | ArchiveRefused
542
628
 
543
629
  /**
544
630
  * Answers where one task's plan sits and who else holds it, which is the whole
545
- * of the last-live-citation rule. `docs-fold` reads this rather than scanning
631
+ * of the last-live-citation rule. `context-fold` reads this rather than scanning
546
632
  * the board itself, so the sweep that moves a plan and the gate that refuses a
547
633
  * task archive cannot drift into disagreeing about which plan is free.
548
634
  *
@@ -566,13 +652,17 @@ export async function planCitations(
566
652
  return refuse(unmatched.reason, unmatched.message, unmatched.detail)
567
653
  }
568
654
 
569
- const target = readPlanTarget(await readFile(join(dir, `${stem}.md`), 'utf8'))
570
- if (!target) {
655
+ const targets = readPlanTargets(
656
+ await readFile(join(dir, `${stem}.md`), 'utf8'),
657
+ )
658
+ const [target] = targets
659
+ if (target === undefined || targets.length > 1) {
571
660
  return {
572
661
  ok: true,
573
662
  stem,
574
663
  target: undefined,
575
- location: 'unstated',
664
+ targets,
665
+ location: target === undefined ? 'unstated' : 'several',
576
666
  citedBy: [],
577
667
  }
578
668
  }
@@ -587,13 +677,14 @@ export async function planCitations(
587
677
  )
588
678
  ? 'archived'
589
679
  : 'outside'
590
- return { ok: true, stem, target, location, citedBy: [] }
680
+ return { ok: true, stem, target, targets, location, citedBy: [] }
591
681
  }
592
682
 
593
683
  return {
594
684
  ok: true,
595
685
  stem,
596
686
  target,
687
+ targets,
597
688
  location: 'live',
598
689
  citedBy: await otherTasksCitingPlan(dir, root, live, stem),
599
690
  }
@@ -758,7 +849,7 @@ export async function archiveTask(
758
849
  const from = join(dir, `${stem}.md`)
759
850
  const text = await readFile(from, 'utf8')
760
851
 
761
- // `docs-fold` ticks outcomes at ship time, before the merge, so an earlier
852
+ // `context-fold` ticks outcomes at ship time, before the merge, so an earlier
762
853
  // slice merging after the last slice shipped would find every box ticked
763
854
  // while the last slice is still open. Only the last number closes the task.
764
855
  const last = readPullRequest(text).at(-1)
@@ -786,37 +877,34 @@ export async function archiveTask(
786
877
  )
787
878
  }
788
879
 
789
- const plan = await planToArchive(dir, root, stem, text)
790
- const ready = plan && (await readyToArchive(dir, root, text, plan))
880
+ const plans = await plansToArchive(dir, root, stem, text)
881
+ const ready =
882
+ plans.moves.length > 0 &&
883
+ (await readyToArchive(dir, root, text, plans.moves))
791
884
  const destination = archiveDir(root)
792
885
  const to = join(destination, `${stem}.md`)
793
886
 
794
- // The plan moves first so the line written below describes a file already at
795
- // its new path. Writing the retarget first and failing the move would leave a
796
- // pointer at a folder holding nothing, and `.canon/plans/` is gitignored, so
797
- // no history recovers the target it named. The folder follows the plan and
798
- // both precede the task, so a failed rename never leaves the task archived
887
+ // The plans move first so the line written below describes files already at
888
+ // their new paths. Writing the retarget first and failing a move would leave
889
+ // a pointer at a folder holding nothing, and `.canon/plans/` is gitignored, so
890
+ // no history recovers the target it named. The folder follows the plans and
891
+ // all precede the task, so a failed rename never leaves the task archived
799
892
  // with its folder live, which the unattended post-merge hook cannot repair.
800
- if (plan) {
801
- await mkdir(dirname(plan.to), { recursive: true })
802
- await rename(plan.from, plan.to)
803
- }
893
+ await movePlans(plans.moves)
804
894
 
805
895
  if (ready) {
806
896
  await mkdir(dirname(ready.to), { recursive: true })
807
897
  await rename(ready.from, ready.to)
808
- await writeFile(
809
- plan.to,
810
- retargetReadyPaths(await readFile(plan.to, 'utf8'), basename(ready.from)),
811
- )
898
+ await retargetReadyInPlans(plans.moves, basename(ready.from))
812
899
  }
813
900
 
814
901
  await mkdir(destination, { recursive: true })
815
902
  await rename(from, to)
816
- const rebased = rebaseRelativeLinks(text, dir, destination)
817
- const retargeted = plan
818
- ? retargetPlanLine(rebased, linkTo(destination, plan.to))
819
- : rebased
903
+ const retargeted = rebaseRelativeLinks(
904
+ retargetPlanLine(text, plans.links(dir)),
905
+ dir,
906
+ destination,
907
+ )
820
908
  await writeFile(
821
909
  to,
822
910
  ready
@@ -834,43 +922,71 @@ export async function archiveTask(
834
922
  to,
835
923
  priorityRowRemoved,
836
924
  indexRegenerated: regen.action === 'written',
837
- plan,
925
+ plans: plans.moves,
838
926
  ready: ready || undefined,
839
927
  closed: closed.length,
840
928
  cut: cut.length,
841
929
  }
842
930
  }
843
931
 
932
+ interface PlansToArchive {
933
+ readonly moves: readonly PlanMove[]
934
+ /** Each moved target as the line wrote it, mapped to its link from `taskDir`. */
935
+ readonly links: (taskDir: string) => ReadonlyMap<string, string>
936
+ }
937
+
844
938
  /**
845
- * The plan this task carries into the archive with it, or nothing. The merge is
846
- * what settles a plan, and the hook reaches this with nobody watching, so the
847
- * move sits inside the archive rather than in a second call that could leave
848
- * the task archived and the plan live.
939
+ * The plans this task carries into the archive with it, one per live target on
940
+ * its `Plan:` line. The merge is what settles a plan, and the hook reaches this
941
+ * with nobody watching, so the move sits inside the archive rather than in a
942
+ * second call that could leave the task archived and a plan live.
849
943
  *
850
944
  * A plan another live task still cites stays where it is. Moving it on the
851
945
  * first task to close strands every other pointer at a path that has gone, and
852
946
  * the sibling has no history behind it to repair the line from.
853
947
  *
854
- * A target resolving to no file yields nothing too. A pointer somebody typed
948
+ * A target resolving to no file moves nothing too. A pointer somebody typed
855
949
  * wrong is not a plan to move, and refusing the whole archive over it would
856
950
  * park the board behind a repair the merge cannot make.
857
951
  */
858
- async function planToArchive(
952
+ async function plansToArchive(
859
953
  dir: string,
860
954
  root: string,
861
955
  stem: string,
862
956
  text: string,
863
- ): Promise<PlanMove | undefined> {
864
- const target = readPlanTarget(text)
865
- const live = target && resolveLivePlan(target, dir, root)
866
- if (!live || !existsSync(live)) return undefined
957
+ ): Promise<PlansToArchive> {
958
+ const read = await Promise.all(
959
+ readPlanTargets(text).map(async (target) => {
960
+ const live = resolveLivePlan(target, dir, root)
961
+ if (!live || !existsSync(live)) return undefined
962
+
963
+ const shared = await otherTasksCitingPlan(dir, root, live, stem)
964
+ return shared.length > 0 ? undefined : { target, live }
965
+ }),
966
+ )
867
967
 
868
- const shared = await otherTasksCitingPlan(dir, root, live, stem)
869
- if (shared.length > 0) return undefined
968
+ const moves = new Map<string, PlanMove>()
969
+ const targets = new Map<string, string>()
970
+ for (const entry of read) {
971
+ if (!entry) continue
972
+ const to = join(recordDir(root, PLANS, ARCHIVE), basename(entry.live))
973
+ moves.set(entry.live, { from: entry.live, to })
974
+ targets.set(entry.target, to)
975
+ }
870
976
 
871
977
  return {
872
- from: live,
873
- to: join(recordDir(root, PLANS, ARCHIVE), basename(live)),
978
+ moves: [...moves.values()],
979
+ links: (taskDir) =>
980
+ new Map(
981
+ [...targets].map(([target, to]) => [target, linkTo(taskDir, to)]),
982
+ ),
983
+ }
984
+ }
985
+
986
+ async function movePlans(moves: readonly PlanMove[]): Promise<void> {
987
+ for (const move of moves) {
988
+ await mkdir(dirname(move.to), { recursive: true })
989
+ await rename(move.from, move.to)
874
990
  }
875
991
  }
876
992
 
@@ -879,7 +995,8 @@ async function planToArchive(
879
995
  * task's `Ready:` line, falling back to a folder path in the plan's own text for
880
996
  * a task written before the line was defined. It moves only with its plan,
881
997
  * since the folder is the plan's verbatim source and a plan another task still
882
- * cites still needs it, so the caller passes the plan `planToArchive` settled.
998
+ * cites still needs it, so the caller passes the plans `plansToArchive`
999
+ * settled. Their texts are searched in line order and the first folder wins.
883
1000
  *
884
1001
  * A target naming no folder, one already archived, or a destination taken
885
1002
  * yields nothing and refuses nothing, the way a mistyped `Plan:` does.
@@ -888,11 +1005,14 @@ async function readyToArchive(
888
1005
  dir: string,
889
1006
  root: string,
890
1007
  text: string,
891
- plan: PlanMove,
1008
+ plans: readonly PlanMove[],
892
1009
  ): Promise<ReadyMove | undefined> {
893
- const live =
894
- readyFolder(dir, root, text) ??
895
- readyFolder(dir, root, await readFile(plan.from, 'utf8'))
1010
+ const planTexts = await Promise.all(
1011
+ plans.map((plan) => readFile(plan.from, 'utf8')),
1012
+ )
1013
+ const live = [text, ...planTexts]
1014
+ .map((candidate) => readyFolder(dir, root, candidate))
1015
+ .find((folder) => folder !== undefined)
896
1016
  if (!live) return undefined
897
1017
 
898
1018
  const to = join(recordDir(root, READY, ARCHIVE), basename(live))
@@ -922,6 +1042,19 @@ function readyFolder(
922
1042
  )
923
1043
  }
924
1044
 
1045
+ async function retargetReadyInPlans(
1046
+ plans: readonly PlanMove[],
1047
+ folder: string,
1048
+ ): Promise<void> {
1049
+ await Promise.all(
1050
+ plans.map(async (plan) => {
1051
+ const text = await readFile(plan.to, 'utf8')
1052
+ const retargeted = retargetReadyPaths(text, folder)
1053
+ if (retargeted !== text) await writeFile(plan.to, retargeted)
1054
+ }),
1055
+ )
1056
+ }
1057
+
925
1058
  /** Points the plan's literal folder paths at `ready/archive/`, since the folder no longer sits where they say. */
926
1059
  function retargetReadyPaths(text: string, folder: string): string {
927
1060
  return text.replace(
@@ -1054,29 +1187,27 @@ export async function declineTask(
1054
1187
  const from = join(dir, `${stem}.md`)
1055
1188
  const text = await readFile(from, 'utf8')
1056
1189
 
1057
- const plan = await planToArchive(dir, root, stem, text)
1190
+ const plans = await plansToArchive(dir, root, stem, text)
1058
1191
  const destination = declinedDir(root)
1059
1192
  const to = join(destination, `${stem}.md`)
1060
1193
 
1061
- // The plan moves first, the same order archiveTask uses, so the retarget
1062
- // written below describes a file already at its new path.
1063
- if (plan) {
1064
- await mkdir(dirname(plan.to), { recursive: true })
1065
- await rename(plan.from, plan.to)
1066
- }
1194
+ // The plans move first, the same order archiveTask uses, so the retarget
1195
+ // written below describes files already at their new paths.
1196
+ await movePlans(plans.moves)
1067
1197
 
1068
1198
  await mkdir(destination, { recursive: true })
1069
1199
  await rename(from, to)
1070
1200
 
1071
1201
  const date = new Date().toISOString().slice(0, 10)
1072
1202
  const declined = insertDeclinedLine(
1073
- rebaseRelativeLinks(text, dir, destination),
1203
+ rebaseRelativeLinks(
1204
+ retargetPlanLine(text, plans.links(dir)),
1205
+ dir,
1206
+ destination,
1207
+ ),
1074
1208
  declineLine(reason, by, date),
1075
1209
  )
1076
- const final = plan
1077
- ? retargetPlanLine(declined, linkTo(destination, plan.to))
1078
- : declined
1079
- await writeFile(to, final)
1210
+ await writeFile(to, declined)
1080
1211
 
1081
1212
  const priorityRowRemoved = await clearPriorityRow(dir, stem)
1082
1213
  const backlogRowRemoved = await clearBacklogRow(dir, stem)
@@ -1090,6 +1221,6 @@ export async function declineTask(
1090
1221
  priorityRowRemoved,
1091
1222
  backlogRowRemoved,
1092
1223
  indexRegenerated: regen.action === 'written',
1093
- plan,
1224
+ plans: plans.moves,
1094
1225
  }
1095
1226
  }
@@ -11,7 +11,7 @@ const PLANS = 'plans'
11
11
  const MARKDOWN = '.md'
12
12
  const NONE_IDENTIFIED = 'None identified.'
13
13
 
14
- /** The one group a dispatch reads, per `standards/tasks.md`. */
14
+ /** The one group a dispatch reads, per `standards/board.md`. */
15
15
  const DISPATCH_GROUP = 'Run now'
16
16
 
17
17
  export const REACH_REFUSALS = [