@erclx/canon 4.122.0 → 4.124.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 (124) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +7 -8
  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/codebase-layout/REQUIREMENT.md +1 -1
  6. package/claude/skills/codebase-layout/SKILL.md +1 -1
  7. package/claude/skills/create-rule/SKILL.md +5 -2
  8. package/claude/skills/design-taste/SKILL.md +1 -1
  9. package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
  10. package/claude/skills/draft-ready/SKILL.md +1 -1
  11. package/claude/skills/git-ship/SKILL.md +4 -3
  12. package/claude/skills/memory-capture/SKILL.md +14 -4
  13. package/claude/skills/memory-review/REQUIREMENT.md +4 -2
  14. package/claude/skills/memory-review/SKILL.md +18 -7
  15. package/claude/skills/memory-review/references/later-phases.md +7 -4
  16. package/claude/skills/memory-review/references/receipt-format.md +19 -3
  17. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  18. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  19. package/claude/skills/session-resume/SKILL.md +4 -3
  20. package/claude/skills/task-board/SKILL.md +1 -1
  21. package/claude/skills/task-board/references/archive.md +1 -1
  22. package/claude/skills/task-board/references/decline.md +1 -1
  23. package/claude/skills/test-first/REQUIREMENT.md +1 -1
  24. package/docs/agents/counts.md +3 -3
  25. package/docs/agents/drift-surfaces.md +2 -2
  26. package/docs/agents/rule-citations.md +4 -4
  27. package/docs/agents/tasks-archive.md +3 -3
  28. package/docs/agents/tasks-plans.md +5 -3
  29. package/docs/agents/tasks-validate.md +1 -1
  30. package/docs/agents/tasks.md +1 -1
  31. package/docs/agents/test-order.md +1 -1
  32. package/docs/index.md +3 -0
  33. package/docs/target-migrations.md +93 -0
  34. package/docs/target-projects.md +4 -216
  35. package/docs/target-scenarios.md +56 -0
  36. package/docs/target-sync.md +93 -0
  37. package/docs/workflow/ai-workflow.md +4 -203
  38. package/docs/workflow/index.md +2 -0
  39. package/docs/workflow/operating-model.md +18 -47
  40. package/docs/workflow/parallel-features.md +77 -0
  41. package/docs/workflow/skill-map.md +148 -0
  42. package/governance/renames.toml +34 -0
  43. package/governance/rules/{claude/555-tasks.md → canon/655-tasks.md} +2 -1
  44. package/governance/rules/{claude/560-diagrams.md → canon/660-diagrams.md} +1 -1
  45. package/governance/rules/{claude/562-session.md → canon/662-session.md} +1 -1
  46. package/governance/rules/code/000-code.md +21 -0
  47. package/governance/rules/lib/300-testing-ts.md +0 -4
  48. package/governance/rules/lib/330-testing-py.md +0 -4
  49. package/governance/rules/{claude/500-prose.md → writing/800-prose.md} +1 -1
  50. package/governance/rules/{claude/501-markdown.md → writing/801-markdown.md} +1 -1
  51. package/governance/rules/{claude/502-mermaid.md → writing/802-mermaid.md} +1 -1
  52. package/governance/stacks/base.toml +7 -6
  53. package/governance/stacks/node.toml +1 -1
  54. package/governance/stacks/python.toml +1 -1
  55. package/package.json +1 -1
  56. package/scripts/core/install-check.sh +3 -5
  57. package/src/claude/routing.ts +1 -1
  58. package/src/commands/gov.ts +1 -1
  59. package/src/commands/tasks.ts +39 -29
  60. package/src/gate/measures.ts +1 -6
  61. package/src/gate/stages.ts +1 -0
  62. package/src/gov/citations.ts +1 -1
  63. package/src/gov/list.ts +1 -1
  64. package/src/gov/restated.ts +2 -2
  65. package/src/init/steps.ts +1 -1
  66. package/src/labels/spelling.ts +1 -1
  67. package/src/records/prune.ts +1 -1
  68. package/src/shipped/references.ts +1 -1
  69. package/src/tasks/archive.ts +212 -105
  70. package/src/tasks/reach.ts +1 -1
  71. package/src/tasks/record.ts +59 -16
  72. package/src/tasks/validate.ts +1 -1
  73. package/standards/board.md +136 -0
  74. package/standards/commit.md +5 -0
  75. package/standards/groundwork.md +25 -36
  76. package/standards/index.md +2 -1
  77. package/standards/plan.md +20 -34
  78. package/standards/readme.md +8 -77
  79. package/standards/requirements.md +2 -2
  80. package/standards/rule.md +4 -6
  81. package/standards/tasks.md +18 -125
  82. package/standards/versioning.md +1 -1
  83. package/tooling/base/configs/commitlint.config.js +27 -1
  84. package/tooling/base/reference.md +2 -2
  85. package/tooling/claude/seeds/.claude/hooks/path-form.sh +1 -1
  86. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +1 -1
  87. package/tooling/web/reference.md +1 -1
  88. package/governance/rules/core/000-constitution.md +0 -30
  89. package/governance/rules/core/020-concurrency.md +0 -22
  90. package/governance/rules/core/030-error-handling.md +0 -31
  91. package/governance/rules/core/040-performance.md +0 -20
  92. package/governance/rules/core/050-logging.md +0 -20
  93. package/governance/rules/core/060-naming.md +0 -19
  94. package/governance/rules/snippets/600-at-references.md +0 -9
  95. /package/governance/rules/{core/025-indexes.md → canon/601-indexes.md} +0 -0
  96. /package/governance/rules/{core/035-tasks.md → canon/602-tasks.md} +0 -0
  97. /package/governance/rules/{core/045-memory.md → canon/603-memory.md} +0 -0
  98. /package/governance/rules/{core/055-scratch.md → canon/604-scratch.md} +0 -0
  99. /package/governance/rules/{core/085-worktrees.md → canon/605-worktrees.md} +0 -0
  100. /package/governance/rules/{core/087-git.md → canon/606-git.md} +0 -0
  101. /package/governance/rules/{claude/510-context.md → canon/610-context.md} +0 -0
  102. /package/governance/rules/{claude/511-indexes.md → canon/611-indexes.md} +0 -0
  103. /package/governance/rules/{claude/520-wireframes.md → canon/620-wireframes.md} +0 -0
  104. /package/governance/rules/{claude/530-requirements.md → canon/630-requirements.md} +0 -0
  105. /package/governance/rules/{claude/540-architecture.md → canon/640-architecture.md} +0 -0
  106. /package/governance/rules/{claude/545-decisions.md → canon/645-decisions.md} +0 -0
  107. /package/governance/rules/{claude/550-design.md → canon/650-design.md} +0 -0
  108. /package/governance/rules/{claude/556-groundwork.md → canon/656-groundwork.md} +0 -0
  109. /package/governance/rules/{claude/557-intake.md → canon/657-intake.md} +0 -0
  110. /package/governance/rules/{claude/558-plan.md → canon/658-plan.md} +0 -0
  111. /package/governance/rules/{claude/559-memory.md → canon/659-memory.md} +0 -0
  112. /package/governance/rules/{claude/561-teach.md → canon/661-teach.md} +0 -0
  113. /package/governance/rules/{claude/563-ready.md → canon/663-ready.md} +0 -0
  114. /package/governance/rules/{core/005-behavior.md → claude/565-behavior.md} +0 -0
  115. /package/governance/rules/{core/015-output.md → claude/566-output.md} +0 -0
  116. /package/governance/rules/{core/070-planning.md → claude/567-planning.md} +0 -0
  117. /package/governance/rules/{core → code}/010-testing.md +0 -0
  118. /package/governance/rules/{ci → tooling}/700-ci-workflow.md +0 -0
  119. /package/governance/rules/{core/065-spelling.md → tooling/765-spelling.md} +0 -0
  120. /package/governance/rules/{core/075-dependencies.md → tooling/775-dependencies.md} +0 -0
  121. /package/governance/rules/{core/080-config-comments.md → tooling/780-config-comments.md} +0 -0
  122. /package/governance/rules/{core/090-code-comments.md → tooling/790-code-comments.md} +0 -0
  123. /package/governance/rules/{claude/580-readme.md → writing/880-readme.md} +0 -0
  124. /package/governance/rules/{claude/591-standard-authoring.md → writing/891-standard-authoring.md} +0 -0
@@ -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
  }
@@ -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 = [
@@ -9,7 +9,8 @@ import {
9
9
  listTaskStems,
10
10
  OUTCOME_PATTERN,
11
11
  planLine,
12
- readPlanTarget,
12
+ planLineIndex,
13
+ readPlanTargets,
13
14
  readPullRequest,
14
15
  tasksDir,
15
16
  } from '@/tasks/archive'
@@ -26,6 +27,9 @@ const ORIGIN_PREFIXES = ['Plan:', 'Groundwork:', 'Intake:', 'Issue:'] as const
26
27
  * a guessed write would archive the wrong task. A caller that passed a
27
28
  * malformed argument has a defect nobody would otherwise hear about, so it
28
29
  * reports rather than joining the swallowed set.
30
+ *
31
+ * `several-plans` answers a `Plan:` line that already links more than one plan.
32
+ * `plan-link` cannot know which link to drop, so it writes nothing.
29
33
  */
30
34
  export const RECORD_REFUSALS = [
31
35
  'no-board',
@@ -34,6 +38,7 @@ export const RECORD_REFUSALS = [
34
38
  'no-outcomes',
35
39
  'out-of-range',
36
40
  'no-plan',
41
+ 'several-plans',
37
42
  'bad-input',
38
43
  ] as const
39
44
 
@@ -68,6 +73,8 @@ export interface PlanRecorded {
68
73
  readonly path: string
69
74
  readonly plan: string
70
75
  readonly action: LineAction
76
+ /** The target the line named before a correction. Undefined unless it differed. */
77
+ readonly replaced: string | undefined
71
78
  }
72
79
 
73
80
  export type PlanOutcome = PlanRecorded | RecordRefused
@@ -149,31 +156,60 @@ function lastOriginLine(lines: readonly string[]): number | undefined {
149
156
  return found
150
157
  }
151
158
 
159
+ export type PlanLineWrite =
160
+ | {
161
+ readonly ok: true
162
+ readonly text: string
163
+ readonly action: LineAction
164
+ readonly replaced: string | undefined
165
+ }
166
+ | { readonly ok: false; readonly targets: readonly string[] }
167
+
152
168
  /**
153
169
  * Places the `Plan:` line right after the H1, and corrects the target in place
154
170
  * when the line exists, since a task runs under one plan. `Plan:` is the first
155
171
  * origin line a task carries, so it anchors on the heading itself rather than
156
172
  * on the last origin line above it.
173
+ *
174
+ * A line already linking several plans is refused whole, even when one of them
175
+ * is the target, since overwriting it drops links the caller never named and
176
+ * reporting it unchanged hides a line that breaks the one-plan rule.
157
177
  */
158
- export function writePlanLine(
159
- text: string,
160
- target: string,
161
- ): { readonly text: string; readonly action: LineAction } {
178
+ export function writePlanLine(text: string, target: string): PlanLineWrite {
162
179
  const line = planLine(target)
163
180
  const lines = text.split('\n')
164
- const existing = lines.findIndex((entry) => entry.startsWith('Plan:'))
181
+ const existing = planLineIndex(lines)
165
182
 
166
183
  if (existing !== -1) {
167
- if (lines[existing] === line) return { text, action: 'unchanged' }
184
+ const targets = readPlanTargets(lines[existing])
185
+ if (targets.length > 1) return { ok: false, targets }
186
+
187
+ const [previous] = targets
188
+ const replaced = previous !== target ? previous : undefined
189
+ if (lines[existing] === line) {
190
+ return { ok: true, text, action: 'unchanged', replaced }
191
+ }
168
192
  lines[existing] = line
169
- return { text: lines.join('\n'), action: 'corrected' }
193
+ return { ok: true, text: lines.join('\n'), action: 'corrected', replaced }
170
194
  }
171
195
 
172
196
  const heading = lines.findIndex((entry) => entry.startsWith('# '))
173
- if (heading === -1) return { text: `${line}\n${text}`, action: 'added' }
197
+ if (heading === -1) {
198
+ return {
199
+ ok: true,
200
+ text: `${line}\n${text}`,
201
+ action: 'added',
202
+ replaced: undefined,
203
+ }
204
+ }
174
205
 
175
206
  lines.splice(heading + 1, 0, '', line)
176
- return { text: lines.join('\n'), action: 'added' }
207
+ return {
208
+ ok: true,
209
+ text: lines.join('\n'),
210
+ action: 'added',
211
+ replaced: undefined,
212
+ }
177
213
  }
178
214
 
179
215
  /**
@@ -230,14 +266,12 @@ async function matchByPlan(
230
266
  const read = await Promise.all(
231
267
  stems.map(async (stem) => ({
232
268
  stem,
233
- target: readPlanTarget(await readFile(join(dir, `${stem}.md`), 'utf8')),
269
+ targets: readPlanTargets(await readFile(join(dir, `${stem}.md`), 'utf8')),
234
270
  })),
235
271
  )
236
272
 
237
273
  return read
238
- .filter(
239
- (entry) => entry.target !== undefined && planKey(entry.target) === key,
240
- )
274
+ .filter((entry) => entry.targets.some((target) => planKey(target) === key))
241
275
  .map(({ stem }) => stem)
242
276
  }
243
277
 
@@ -345,11 +379,20 @@ export async function recordPlan(
345
379
  }
346
380
 
347
381
  const target = linkTo(dir, plan)
348
- const { text, action } = writePlanLine(await readFile(path, 'utf8'), target)
382
+ const written = writePlanLine(await readFile(path, 'utf8'), target)
383
+
384
+ if (!written.ok) {
385
+ return refuse(
386
+ 'several-plans',
387
+ `${opened.stem} already links ${written.targets.length} plans on its Plan: line, so plan-link cannot tell which one to replace. Edit the line by hand to name one plan.`,
388
+ written.targets,
389
+ )
390
+ }
349
391
 
392
+ const { text, action, replaced } = written
350
393
  if (action !== 'unchanged') await writeFile(path, text)
351
394
 
352
- return { ok: true, stem: opened.stem, path, plan: target, action }
395
+ return { ok: true, stem: opened.stem, path, plan: target, action, replaced }
353
396
  }
354
397
 
355
398
  /**