@erclx/canon 4.122.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 (42) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +5 -4
  3. package/claude/skills/auto-ship/references/tier-failures.md +7 -4
  4. package/claude/skills/backlog-triage/SKILL.md +1 -1
  5. package/claude/skills/draft-ready/SKILL.md +1 -1
  6. package/claude/skills/git-ship/SKILL.md +4 -3
  7. package/claude/skills/memory-capture/SKILL.md +14 -4
  8. package/claude/skills/memory-review/REQUIREMENT.md +4 -2
  9. package/claude/skills/memory-review/SKILL.md +17 -6
  10. package/claude/skills/memory-review/references/later-phases.md +6 -3
  11. package/claude/skills/memory-review/references/receipt-format.md +19 -3
  12. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  13. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  14. package/claude/skills/session-resume/SKILL.md +4 -3
  15. package/claude/skills/task-board/SKILL.md +1 -1
  16. package/claude/skills/task-board/references/archive.md +1 -1
  17. package/claude/skills/task-board/references/decline.md +1 -1
  18. package/docs/agents/tasks-archive.md +3 -3
  19. package/docs/agents/tasks-plans.md +5 -3
  20. package/docs/agents/tasks-validate.md +1 -1
  21. package/docs/agents/tasks.md +1 -1
  22. package/docs/workflow/ai-workflow.md +2 -2
  23. package/docs/workflow/operating-model.md +1 -1
  24. package/governance/rules/claude/555-tasks.md +2 -1
  25. package/package.json +1 -1
  26. package/src/commands/tasks.ts +39 -29
  27. package/src/gate/stages.ts +1 -0
  28. package/src/tasks/archive.ts +212 -105
  29. package/src/tasks/reach.ts +1 -1
  30. package/src/tasks/record.ts +59 -16
  31. package/src/tasks/validate.ts +1 -1
  32. package/standards/board.md +136 -0
  33. package/standards/commit.md +5 -0
  34. package/standards/groundwork.md +25 -36
  35. package/standards/index.md +2 -1
  36. package/standards/plan.md +20 -34
  37. package/standards/readme.md +8 -77
  38. package/standards/requirements.md +2 -2
  39. package/standards/tasks.md +18 -125
  40. package/standards/versioning.md +1 -1
  41. package/tooling/base/configs/commitlint.config.js +27 -1
  42. package/tooling/base/reference.md +1 -1
@@ -11,6 +11,7 @@ import {
11
11
  type DeclineOutcome,
12
12
  declineTask,
13
13
  type PlanCitations,
14
+ type PlanMove,
14
15
  planCitations,
15
16
  } from '@/tasks/archive'
16
17
  import {
@@ -154,9 +155,9 @@ export function register(program: Command): void {
154
155
  'after',
155
156
  [
156
157
  '',
157
- 'The task carries its plan with it when no other live task cites that',
158
- 'plan, and the archived task keeps a working Plan: pointer at the new',
159
- 'path. A plan several tasks share stays where it is.',
158
+ 'The task carries each plan its Plan: line links when no other live',
159
+ 'task cites that plan, and the archived task keeps a working pointer',
160
+ 'at each new path. A plan several tasks share stays where it is.',
160
161
  '',
161
162
  'The ready folder the task names moves to .canon/ready/archive/ with its',
162
163
  'plan, and the Ready: line and the plan path to it are retargeted. A',
@@ -260,6 +261,7 @@ export function register(program: Command): void {
260
261
  '',
261
262
  'Locations:',
262
263
  ' unstated the task carries no Plan: line',
264
+ ' several the Plan: line links more than one plan, named in targets',
263
265
  ' live the target resolves inside .canon/plans/',
264
266
  ' archived the target resolves inside .canon/plans/archive/',
265
267
  ' outside the target resolves somewhere else',
@@ -448,12 +450,14 @@ export function register(program: Command): void {
448
450
  '',
449
451
  'Exit codes:',
450
452
  ' 0 the line was added, corrected, or already correct',
451
- ' 1 refused, with the reason on stderr or in the JSON record',
453
+ ' 1 refused, including several-plans for a line linking more than one',
454
+ ' plan, with the reason on stderr or in the JSON record',
452
455
  '',
453
456
  'It writes Plan: [<label>](<target>) right after the H1, corrects the',
454
- 'target in place when the line exists, and resolves both a bare slug',
455
- 'and a board-relative path the way canon tasks plan-answers does. Safe',
456
- 'from a linked worktree, since it resolves the board root in-process.',
457
+ 'target in place when the line exists and names the one it replaced,',
458
+ 'and resolves both a bare slug and a board-relative path the way canon',
459
+ 'tasks plan-answers does. Safe from a linked worktree, since it',
460
+ 'resolves the board root in-process.',
457
461
  '',
458
462
  'Examples:',
459
463
  ' canon tasks plan-link v28.1-trigger-escalation dispatch-answer-gate',
@@ -793,6 +797,7 @@ function reportPlanLink(
793
797
  path: relative(root, outcome.path),
794
798
  plan: outcome.plan,
795
799
  action: outcome.action,
800
+ replaced: outcome.replaced,
796
801
  })}\n`,
797
802
  )
798
803
  return 0
@@ -801,6 +806,7 @@ function reportPlanLink(
801
806
  intro('canon tasks plan-link')
802
807
  logStep(outcome.action === 'unchanged' ? 'Already recorded' : 'Recorded')
803
808
  logInfo(`${outcome.stem} names plan ${outcome.plan}`)
809
+ if (outcome.replaced) logInfo(`replaced ${outcome.replaced}`)
804
810
  if (outcome.action !== 'unchanged') logAdd(relative(root, outcome.path))
805
811
  outro()
806
812
 
@@ -975,6 +981,10 @@ function reportCitations(
975
981
  function describeCitations(outcome: PlanCitations): string {
976
982
  if (outcome.location === 'unstated') return 'carries no Plan: line.'
977
983
 
984
+ if (outcome.location === 'several') {
985
+ return `links ${outcome.targets.length} plans (${outcome.targets.join(', ')}), so no single plan is the sweep's to settle.`
986
+ }
987
+
978
988
  if (outcome.location === 'archived') {
979
989
  return `points at ${outcome.target}, which an earlier sweep already archived.`
980
990
  }
@@ -1396,11 +1406,7 @@ function report(
1396
1406
  logStep('Archived')
1397
1407
  logRemove(relative(root, outcome.from))
1398
1408
  logAdd(relative(root, outcome.to))
1399
- if (outcome.plan) {
1400
- logRemove(relative(root, outcome.plan.from))
1401
- logAdd(relative(root, outcome.plan.to))
1402
- logInfo('retargeted the Plan: line')
1403
- }
1409
+ logPlanMoves(outcome.plans, root)
1404
1410
  if (outcome.ready) {
1405
1411
  logRemove(relative(root, outcome.ready.from))
1406
1412
  logAdd(relative(root, outcome.ready.to))
@@ -1414,6 +1420,24 @@ function report(
1414
1420
  return 0
1415
1421
  }
1416
1422
 
1423
+ function logPlanMoves(plans: readonly PlanMove[], root: string): void {
1424
+ for (const plan of plans) {
1425
+ logRemove(relative(root, plan.from))
1426
+ logAdd(relative(root, plan.to))
1427
+ }
1428
+ if (plans.length > 0) logInfo('retargeted the Plan: line')
1429
+ }
1430
+
1431
+ function planMovesFor(
1432
+ plans: readonly PlanMove[],
1433
+ root: string,
1434
+ ): readonly { readonly from: string; readonly to: string }[] {
1435
+ return plans.map((plan) => ({
1436
+ from: relative(root, plan.from),
1437
+ to: relative(root, plan.to),
1438
+ }))
1439
+ }
1440
+
1417
1441
  function recordFor(
1418
1442
  outcome: ArchiveOutcome,
1419
1443
  root: string,
@@ -1434,12 +1458,7 @@ function recordFor(
1434
1458
  to: relative(root, outcome.to),
1435
1459
  priorityRowRemoved: outcome.priorityRowRemoved,
1436
1460
  indexRegenerated: outcome.indexRegenerated,
1437
- plan: outcome.plan
1438
- ? {
1439
- from: relative(root, outcome.plan.from),
1440
- to: relative(root, outcome.plan.to),
1441
- }
1442
- : null,
1461
+ plans: planMovesFor(outcome.plans, root),
1443
1462
  ready: outcome.ready
1444
1463
  ? {
1445
1464
  from: relative(root, outcome.ready.from),
@@ -1537,11 +1556,7 @@ function reportDecline(
1537
1556
  logStep('Declined')
1538
1557
  logRemove(relative(root, outcome.from))
1539
1558
  logAdd(relative(root, outcome.to))
1540
- if (outcome.plan) {
1541
- logRemove(relative(root, outcome.plan.from))
1542
- logAdd(relative(root, outcome.plan.to))
1543
- logInfo('retargeted the Plan: line')
1544
- }
1559
+ logPlanMoves(outcome.plans, root)
1545
1560
  if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
1546
1561
  if (outcome.backlogRowRemoved) logInfo('cleared the backlog row')
1547
1562
  if (outcome.indexRegenerated) logInfo('regenerated index.md')
@@ -1571,11 +1586,6 @@ function declineRecordFor(
1571
1586
  priorityRowRemoved: outcome.priorityRowRemoved,
1572
1587
  backlogRowRemoved: outcome.backlogRowRemoved,
1573
1588
  indexRegenerated: outcome.indexRegenerated,
1574
- plan: outcome.plan
1575
- ? {
1576
- from: relative(root, outcome.plan.from),
1577
- to: relative(root, outcome.plan.to),
1578
- }
1579
- : null,
1589
+ plans: planMovesFor(outcome.plans, root),
1580
1590
  }
1581
1591
  }
@@ -107,6 +107,7 @@ const TESTS_SCOPE = new RegExp(
107
107
  '^vitest\\.config\\.ts$',
108
108
  '^tsconfig\\.json$',
109
109
  '^package\\.json$',
110
+ '^commitlint\\.config\\.(js|test\\.ts)$',
110
111
  ...TEST_CORPORA_PATTERNS,
111
112
  ].join('|'),
112
113
  )
@@ -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,46 +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.
265
282
  */
266
- 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]
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)
270
304
  }
271
305
 
272
306
  /**
273
- * Reads every target the first `Plan:` line outside a fenced sample carries,
274
- * falling back to the bare-path form. `readPlanTarget` answers only a line
275
- * holding exactly one pointer, so this is how a caller tells a line linking
276
- * several plans apart from no line at all.
307
+ * Reads every target the first `Plan:` line outside a fenced sample carries.
308
+ * The path is returned as written, relative to the board.
277
309
  */
278
310
  export function readPlanTargets(text: string): readonly string[] {
279
311
  const lines = text.split('\n')
280
- const fenced = fenceMask(lines)
281
- const line = lines.find(
282
- (candidate, index) => !fenced[index] && candidate.startsWith('Plan:'),
283
- )
284
- if (line === undefined) return []
312
+ const index = planLineIndex(lines)
313
+ if (index === -1) return []
285
314
 
286
- const rest = line.slice('Plan:'.length)
287
- const links = [...rest.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)].map(
288
- (match) => match[1],
289
- )
290
- if (links.length > 0) return links
315
+ return planTokens(lines[index]).map(({ target }) => target)
316
+ }
291
317
 
292
- const bare = /^[ \t]*(\S+)/.exec(rest)
293
- return bare ? [bare[1]] : []
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.
322
+ */
323
+ export function readPlanTarget(text: string): string | undefined {
324
+ const targets = readPlanTargets(text)
325
+ return targets.length === 1 ? targets[0] : undefined
294
326
  }
295
327
 
296
328
  /**
@@ -300,28 +332,50 @@ export function readPlanTargets(text: string): readonly string[] {
300
332
  * one line shape rather than two.
301
333
  */
302
334
  export function planLine(target: string): string {
335
+ return `Plan: ${planLink(target)}`
336
+ }
337
+
338
+ function planLink(target: string): string {
303
339
  const name = basename(target)
304
340
  const label = name.endsWith('.md') ? name.slice(0, -'.md'.length) : name
305
341
 
306
- return `Plan: [${label}](${target})`
342
+ return `[${label}](${target})`
307
343
  }
308
344
 
309
345
  /**
310
- * Points the task's `Plan:` line at the plan's new home. The line is matched
311
- * with the pattern the read above uses, so the archive rewrites exactly the
312
- * line it parsed and never a second `Plan:` a task displays inside a fenced
313
- * 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.
314
350
  *
315
- * The replacement is built by a function rather than passed as a string,
316
- * because `$&` and its siblings are substitution sequences inside a replacement
317
- * string. A plan filename carrying one would write a path nobody typed, and
318
- * `.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.
319
355
  */
320
- export function retargetPlanLine(text: string, target: string): string {
321
- 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')
322
376
  }
323
377
 
324
- /** 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. */
325
379
  const READY_PATTERN = /^Ready:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
326
380
 
327
381
  /** A ready folder path as a plan's constraints write it, the folder name captured. */
@@ -508,7 +562,8 @@ function atOneRoot(path: string, plans: string[], root: string): string {
508
562
  }
509
563
 
510
564
  /**
511
- * Names the other live tasks whose `Plan:` line lands on the same file. This is
565
+ * Names the other live tasks whose `Plan:` line lands on the same file through
566
+ * any of its links. This is
512
567
  * the rule `context-fold` applies before it archives a plan, held here so one
513
568
  * question has one implementation: a plan another live task still cites is a
514
569
  * plan the sweep is correct to leave, and a guard that read the folder instead
@@ -528,11 +583,13 @@ export async function otherTasksCitingPlan(
528
583
 
529
584
  const read = await Promise.all(
530
585
  stems.map(async (stem) => {
531
- const target = readPlanTarget(
586
+ const targets = readPlanTargets(
532
587
  await readFile(join(dir, `${stem}.md`), 'utf8'),
533
588
  )
534
- const resolved = target && resolveLivePlan(target, dir, root)
535
- return resolved === plan ? stem : undefined
589
+ const cites = targets.some(
590
+ (target) => resolveLivePlan(target, dir, root) === plan,
591
+ )
592
+ return cites ? stem : undefined
536
593
  }),
537
594
  )
538
595
 
@@ -542,10 +599,12 @@ export async function otherTasksCitingPlan(
542
599
  /**
543
600
  * Where a task's `Plan:` target resolves, which is what decides whether the
544
601
  * plan is the sweep's to move. `unstated` is a task carrying no line at all,
545
- * 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.
546
604
  */
547
605
  export const CITATION_LOCATIONS = [
548
606
  'unstated',
607
+ 'several',
549
608
  'live',
550
609
  'archived',
551
610
  'outside',
@@ -556,7 +615,10 @@ export type CitationLocation = (typeof CITATION_LOCATIONS)[number]
556
615
  export interface PlanCitations {
557
616
  readonly ok: true
558
617
  readonly stem: string
618
+ /** The one target the line carries. Undefined when it carries none or several. */
559
619
  readonly target: string | undefined
620
+ /** Every target the line carries, in line order. */
621
+ readonly targets: readonly string[]
560
622
  readonly location: CitationLocation
561
623
  /** Other live tasks landing on the same file. Empty unless `location` is `live`. */
562
624
  readonly citedBy: readonly string[]
@@ -590,13 +652,17 @@ export async function planCitations(
590
652
  return refuse(unmatched.reason, unmatched.message, unmatched.detail)
591
653
  }
592
654
 
593
- const target = readPlanTarget(await readFile(join(dir, `${stem}.md`), 'utf8'))
594
- 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) {
595
660
  return {
596
661
  ok: true,
597
662
  stem,
598
663
  target: undefined,
599
- location: 'unstated',
664
+ targets,
665
+ location: target === undefined ? 'unstated' : 'several',
600
666
  citedBy: [],
601
667
  }
602
668
  }
@@ -611,13 +677,14 @@ export async function planCitations(
611
677
  )
612
678
  ? 'archived'
613
679
  : 'outside'
614
- return { ok: true, stem, target, location, citedBy: [] }
680
+ return { ok: true, stem, target, targets, location, citedBy: [] }
615
681
  }
616
682
 
617
683
  return {
618
684
  ok: true,
619
685
  stem,
620
686
  target,
687
+ targets,
621
688
  location: 'live',
622
689
  citedBy: await otherTasksCitingPlan(dir, root, live, stem),
623
690
  }
@@ -810,37 +877,34 @@ export async function archiveTask(
810
877
  )
811
878
  }
812
879
 
813
- const plan = await planToArchive(dir, root, stem, text)
814
- 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))
815
884
  const destination = archiveDir(root)
816
885
  const to = join(destination, `${stem}.md`)
817
886
 
818
- // The plan moves first so the line written below describes a file already at
819
- // its new path. Writing the retarget first and failing the move would leave a
820
- // pointer at a folder holding nothing, and `.canon/plans/` is gitignored, so
821
- // no history recovers the target it named. The folder follows the plan and
822
- // 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
823
892
  // with its folder live, which the unattended post-merge hook cannot repair.
824
- if (plan) {
825
- await mkdir(dirname(plan.to), { recursive: true })
826
- await rename(plan.from, plan.to)
827
- }
893
+ await movePlans(plans.moves)
828
894
 
829
895
  if (ready) {
830
896
  await mkdir(dirname(ready.to), { recursive: true })
831
897
  await rename(ready.from, ready.to)
832
- await writeFile(
833
- plan.to,
834
- retargetReadyPaths(await readFile(plan.to, 'utf8'), basename(ready.from)),
835
- )
898
+ await retargetReadyInPlans(plans.moves, basename(ready.from))
836
899
  }
837
900
 
838
901
  await mkdir(destination, { recursive: true })
839
902
  await rename(from, to)
840
- const rebased = rebaseRelativeLinks(text, dir, destination)
841
- const retargeted = plan
842
- ? retargetPlanLine(rebased, linkTo(destination, plan.to))
843
- : rebased
903
+ const retargeted = rebaseRelativeLinks(
904
+ retargetPlanLine(text, plans.links(dir)),
905
+ dir,
906
+ destination,
907
+ )
844
908
  await writeFile(
845
909
  to,
846
910
  ready
@@ -858,43 +922,71 @@ export async function archiveTask(
858
922
  to,
859
923
  priorityRowRemoved,
860
924
  indexRegenerated: regen.action === 'written',
861
- plan,
925
+ plans: plans.moves,
862
926
  ready: ready || undefined,
863
927
  closed: closed.length,
864
928
  cut: cut.length,
865
929
  }
866
930
  }
867
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
+
868
938
  /**
869
- * The plan this task carries into the archive with it, or nothing. The merge is
870
- * what settles a plan, and the hook reaches this with nobody watching, so the
871
- * move sits inside the archive rather than in a second call that could leave
872
- * 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.
873
943
  *
874
944
  * A plan another live task still cites stays where it is. Moving it on the
875
945
  * first task to close strands every other pointer at a path that has gone, and
876
946
  * the sibling has no history behind it to repair the line from.
877
947
  *
878
- * 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
879
949
  * wrong is not a plan to move, and refusing the whole archive over it would
880
950
  * park the board behind a repair the merge cannot make.
881
951
  */
882
- async function planToArchive(
952
+ async function plansToArchive(
883
953
  dir: string,
884
954
  root: string,
885
955
  stem: string,
886
956
  text: string,
887
- ): Promise<PlanMove | undefined> {
888
- const target = readPlanTarget(text)
889
- const live = target && resolveLivePlan(target, dir, root)
890
- 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
891
962
 
892
- const shared = await otherTasksCitingPlan(dir, root, live, stem)
893
- if (shared.length > 0) return undefined
963
+ const shared = await otherTasksCitingPlan(dir, root, live, stem)
964
+ return shared.length > 0 ? undefined : { target, live }
965
+ }),
966
+ )
967
+
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
+ }
894
976
 
895
977
  return {
896
- from: live,
897
- 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)
898
990
  }
899
991
  }
900
992
 
@@ -903,7 +995,8 @@ async function planToArchive(
903
995
  * task's `Ready:` line, falling back to a folder path in the plan's own text for
904
996
  * a task written before the line was defined. It moves only with its plan,
905
997
  * since the folder is the plan's verbatim source and a plan another task still
906
- * 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.
907
1000
  *
908
1001
  * A target naming no folder, one already archived, or a destination taken
909
1002
  * yields nothing and refuses nothing, the way a mistyped `Plan:` does.
@@ -912,11 +1005,14 @@ async function readyToArchive(
912
1005
  dir: string,
913
1006
  root: string,
914
1007
  text: string,
915
- plan: PlanMove,
1008
+ plans: readonly PlanMove[],
916
1009
  ): Promise<ReadyMove | undefined> {
917
- const live =
918
- readyFolder(dir, root, text) ??
919
- 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)
920
1016
  if (!live) return undefined
921
1017
 
922
1018
  const to = join(recordDir(root, READY, ARCHIVE), basename(live))
@@ -946,6 +1042,19 @@ function readyFolder(
946
1042
  )
947
1043
  }
948
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
+
949
1058
  /** Points the plan's literal folder paths at `ready/archive/`, since the folder no longer sits where they say. */
950
1059
  function retargetReadyPaths(text: string, folder: string): string {
951
1060
  return text.replace(
@@ -1078,29 +1187,27 @@ export async function declineTask(
1078
1187
  const from = join(dir, `${stem}.md`)
1079
1188
  const text = await readFile(from, 'utf8')
1080
1189
 
1081
- const plan = await planToArchive(dir, root, stem, text)
1190
+ const plans = await plansToArchive(dir, root, stem, text)
1082
1191
  const destination = declinedDir(root)
1083
1192
  const to = join(destination, `${stem}.md`)
1084
1193
 
1085
- // The plan moves first, the same order archiveTask uses, so the retarget
1086
- // written below describes a file already at its new path.
1087
- if (plan) {
1088
- await mkdir(dirname(plan.to), { recursive: true })
1089
- await rename(plan.from, plan.to)
1090
- }
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)
1091
1197
 
1092
1198
  await mkdir(destination, { recursive: true })
1093
1199
  await rename(from, to)
1094
1200
 
1095
1201
  const date = new Date().toISOString().slice(0, 10)
1096
1202
  const declined = insertDeclinedLine(
1097
- rebaseRelativeLinks(text, dir, destination),
1203
+ rebaseRelativeLinks(
1204
+ retargetPlanLine(text, plans.links(dir)),
1205
+ dir,
1206
+ destination,
1207
+ ),
1098
1208
  declineLine(reason, by, date),
1099
1209
  )
1100
- const final = plan
1101
- ? retargetPlanLine(declined, linkTo(destination, plan.to))
1102
- : declined
1103
- await writeFile(to, final)
1210
+ await writeFile(to, declined)
1104
1211
 
1105
1212
  const priorityRowRemoved = await clearPriorityRow(dir, stem)
1106
1213
  const backlogRowRemoved = await clearBacklogRow(dir, stem)
@@ -1114,6 +1221,6 @@ export async function declineTask(
1114
1221
  priorityRowRemoved,
1115
1222
  backlogRowRemoved,
1116
1223
  indexRegenerated: regen.action === 'written',
1117
- plan,
1224
+ plans: plans.moves,
1118
1225
  }
1119
1226
  }