@erclx/canon 4.7.0 → 4.8.1

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.
@@ -81,7 +81,9 @@ export function register(program: Command): void {
81
81
 
82
82
  tasks
83
83
  .command('archive')
84
- .description('Move a shipped task out of the board and clear its ordering')
84
+ .description(
85
+ 'Move a shipped task and its plan out of the board and clear its ordering',
86
+ )
85
87
  .argument('[task]', 'Task filename stem, as in v28.1-trigger-escalation')
86
88
  .helpOption('-h, --help', 'Show this help message')
87
89
  .option(
@@ -93,6 +95,10 @@ export function register(program: Command): void {
93
95
  .addHelpText(
94
96
  'after',
95
97
  [
98
+ '',
99
+ 'The task carries its plan with it when no other live task cites that',
100
+ 'plan, and the archived task keeps a working Plan: pointer at the new',
101
+ 'path. A plan several tasks share stays where it is.',
96
102
  '',
97
103
  'Exit codes:',
98
104
  ' 0 the task was archived',
@@ -812,6 +818,11 @@ function report(
812
818
  logStep('Archived')
813
819
  logRemove(relative(root, outcome.from))
814
820
  logAdd(relative(root, outcome.to))
821
+ if (outcome.plan) {
822
+ logRemove(relative(root, outcome.plan.from))
823
+ logAdd(relative(root, outcome.plan.to))
824
+ logInfo('retargeted the Plan: line')
825
+ }
815
826
  if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
816
827
  if (outcome.indexRegenerated) logInfo('regenerated index.md')
817
828
  outro()
@@ -839,5 +850,11 @@ function recordFor(
839
850
  to: relative(root, outcome.to),
840
851
  priorityRowRemoved: outcome.priorityRowRemoved,
841
852
  indexRegenerated: outcome.indexRegenerated,
853
+ plan: outcome.plan
854
+ ? {
855
+ from: relative(root, outcome.plan.from),
856
+ to: relative(root, outcome.plan.to),
857
+ }
858
+ : null,
842
859
  }
843
860
  }
package/src/demo/drive.ts CHANGED
@@ -20,15 +20,12 @@ declare global {
20
20
  * the demo feature adds lives here, and `src/commands/demo.ts` reaches it
21
21
  * through a dynamic import so no other command resolves the engine at startup.
22
22
  *
23
- * Unlike `@/capture/render`, this module ships. The capture command is excluded
24
- * from the published package because it regenerates images committed to this
25
- * repository, and that reason does not transfer to a command whose whole
26
- * purpose is running in someone else's project.
27
- *
28
23
  * It imports `playwright-core` rather than `@playwright/test`, which stays a
29
- * development dependency for the capture module. Shipping puts the import in
30
- * every target's dependency tree, and a target needs the driver rather than a
31
- * test runner and an assertion library. Both are pinned to one version rather
24
+ * development dependency the published tarball never carries. Shipping puts the
25
+ * import in every target's dependency tree, and a target needs the driver
26
+ * rather than a test runner and an assertion library. Every browser module here
27
+ * takes the same import for that reason, `@/capture/render` included since it
28
+ * started shipping too. Both are pinned to one version rather
32
29
  * than a range, because `bunx playwright install chromium` fetches the browser
33
30
  * revision the installed engine expects and a float would leave a target
34
31
  * resolving a binary its engine cannot launch.
@@ -23,19 +23,18 @@ import type {
23
23
  * `src/commands/driver.ts` reaches it through a dynamic import so no other
24
24
  * command resolves the engine at startup.
25
25
  *
26
- * Like `@/demo/drive` and `@/inventory/walk`, and unlike `@/capture/render`,
27
- * this module ships. A command whose whole purpose is measuring someone else's
28
- * page cannot stay toolkit-only, and nothing here reaches into `@/capture/`,
29
- * which `files` in `package.json` excludes from the published package, so the
30
- * two surfaces move independently.
26
+ * Like `@/demo/drive`, `@/inventory/walk`, and `@/capture/render`, this module
27
+ * ships. A command whose whole purpose is measuring someone else's page cannot
28
+ * stay toolkit-only, and nothing here reaches into `@/capture/`, so the two
29
+ * surfaces move independently.
31
30
  *
32
31
  * What separates it from `canon capture` is the axis it adds. A render answers
33
32
  * about a page as it loads, and every defect that exists only after a menu
34
33
  * opens, an answer is chosen, or the page scrolls is invisible to one. Probes
35
34
  * therefore run after a step rather than on arrival, and a run reaches the load
36
- * state by opening with a `wait` step of its own. That matters most where
37
- * capture does not run, since it is toolkit-only and this command is then the
38
- * only thing measuring the page at all.
35
+ * state by opening with a `wait` step of its own. That matters most where no
36
+ * capture runs against the page, since this command is then the only thing
37
+ * measuring it at all.
39
38
  */
40
39
 
41
40
  /** Each probe keyed by the name a caller writes in the run. */
@@ -100,7 +100,7 @@ export const SANDBOX_UNDECLARED_CEILING = 47
100
100
  export const AUDITS_BASELINE = '.claude/canon/baseline.json'
101
101
 
102
102
  export const HERO_STAMP_FAILURE =
103
- 'The hero set disagrees with the stamp written when the image was captured. Run canon capture assets/hero.html and commit all three files together.'
103
+ 'The hero set disagrees with the stamp written when the image was captured. Run canon capture assets/hero.html --selector .window and commit all three files together.'
104
104
 
105
105
  function parseJson(payload: string): unknown {
106
106
  try {
@@ -189,7 +189,7 @@ export const STAGES: readonly Stage[] = [
189
189
  kind: 'drift',
190
190
  pathspec: 'assets/hero.html',
191
191
  failure:
192
- 'Hero counts drifted. Run bun run check, then canon capture assets/hero.html, and commit assets/hero.html with assets/hero.png and assets/hero.stamp.',
192
+ 'Hero counts drifted. Run bun run check, then canon capture assets/hero.html --selector .window, and commit assets/hero.html with assets/hero.png and assets/hero.stamp.',
193
193
  },
194
194
  { kind: 'measure', measure: heroStamp },
195
195
  ],
@@ -15,9 +15,9 @@ import type { Subject } from '@/inventory/subjects'
15
15
  * and `src/commands/inventory.ts` reaches it through a dynamic import so no
16
16
  * other command resolves the engine at startup.
17
17
  *
18
- * Like `@/demo/drive` and unlike `@/capture/render`, this module ships, because
19
- * a command whose whole purpose is running inside someone else's project cannot
20
- * stay toolkit-only.
18
+ * Like `@/demo/drive`, `@/driver/drive`, and `@/capture/render`, this module
19
+ * ships, because a command whose whole purpose is running inside someone else's
20
+ * project cannot stay toolkit-only.
21
21
  */
22
22
 
23
23
  /**
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
3
- import { join, relative, resolve } from 'node:path'
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
4
4
  import { regenOne } from '@/indexes/regen'
5
5
  import { isUnder } from '@/paths'
6
6
  import { recordDir, recordDirs } from '@/record-root'
@@ -43,7 +43,6 @@ export const ARCHIVE_REFUSALS = [
43
43
  'ambiguous',
44
44
  'no-outcomes',
45
45
  'open-outcomes',
46
- 'plan-unswept',
47
46
  'bad-input',
48
47
  ] as const
49
48
 
@@ -53,6 +52,12 @@ export type TaskSelector =
53
52
  | { readonly kind: 'stem'; readonly stem: string }
54
53
  | { readonly kind: 'pull-request'; readonly number: number }
55
54
 
55
+ /** A plan carried into the archive alongside the task that was its last citation. */
56
+ export interface PlanMove {
57
+ readonly from: string
58
+ readonly to: string
59
+ }
60
+
56
61
  export interface ArchiveSuccess {
57
62
  readonly ok: true
58
63
  readonly stem: string
@@ -60,6 +65,8 @@ export interface ArchiveSuccess {
60
65
  readonly to: string
61
66
  readonly priorityRowRemoved: boolean
62
67
  readonly indexRegenerated: boolean
68
+ /** Undefined when the task cited no live plan, or when another task still holds it. */
69
+ readonly plan: PlanMove | undefined
63
70
  }
64
71
 
65
72
  export interface ArchiveRefused {
@@ -142,16 +149,42 @@ export function readPullRequest(text: string): number | undefined {
142
149
  return match ? Number(match[1]) : undefined
143
150
  }
144
151
 
152
+ /**
153
+ * The `Plan:` line in either form, the link's target captured ahead of the bare
154
+ * path. Padding is spaces and tabs rather than `\s`, which spans a newline, so
155
+ * the match ends at the line and the retarget below cannot swallow the blank
156
+ * line that follows it.
157
+ */
158
+ const PLAN_PATTERN = /^Plan:[ \t]*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))[ \t]*$/m
159
+
145
160
  /**
146
161
  * Reads the `Plan:` target out of a markdown link, falling back to the older
147
162
  * bare-path form. The path is returned as written, relative to the board.
148
163
  */
149
164
  export function readPlanTarget(text: string): string | undefined {
150
- const match = /^Plan:\s*(?:\[[^\]]*\]\(([^)]+)\)|(\S+))\s*$/m.exec(text)
165
+ const match = PLAN_PATTERN.exec(text)
151
166
  if (!match) return undefined
152
167
  return match[1] ?? match[2]
153
168
  }
154
169
 
170
+ /**
171
+ * Points the task's `Plan:` line at the plan's new home, as a markdown link
172
+ * whose text and target stay in step. The line is matched with the pattern the
173
+ * read above uses, so the archive rewrites exactly the line it parsed and never
174
+ * a second `Plan:` a task displays inside a fenced sample.
175
+ *
176
+ * The replacement is built by a function rather than passed as a string,
177
+ * because `$&` and its siblings are substitution sequences inside a replacement
178
+ * string. A plan filename carrying one would write a path nobody typed, and
179
+ * `.canon/plans/` is gitignored, so nothing recovers the pointer it replaced.
180
+ */
181
+ export function retargetPlanLine(text: string, target: string): string {
182
+ const name = basename(target)
183
+ const label = name.endsWith('.md') ? name.slice(0, -'.md'.length) : name
184
+
185
+ return text.replace(PLAN_PATTERN, () => `Plan: [${label}](${target})`)
186
+ }
187
+
155
188
  /**
156
189
  * Drops the archived task's row from the ordering table. Rows are matched by
157
190
  * the link they carry rather than by a line pattern, because a row holds links
@@ -476,28 +509,23 @@ export async function archiveTask(
476
509
  )
477
510
  }
478
511
 
479
- const planTarget = readPlanTarget(text)
480
- const livePlan = planTarget && resolveLivePlan(planTarget, dir, root)
481
-
482
- // A live plan is unswept only when nothing else on the board holds it. A plan
483
- // several tasks share stays live by design, so refusing on the folder alone
484
- // parked every one of those tasks behind a sweep that was right to decline.
485
- if (livePlan) {
486
- const shared = await otherTasksCitingPlan(dir, root, livePlan, stem)
512
+ const plan = await planToArchive(dir, root, stem, text)
513
+ const destination = archiveDir(root)
514
+ const to = join(destination, `${stem}.md`)
487
515
 
488
- if (shared.length === 0) {
489
- return refuse(
490
- 'plan-unswept',
491
- `${stem} is the last task pointing at a live plan. Run /claude-docs to sweep it first, then archive.`,
492
- [planTarget],
493
- )
494
- }
516
+ // The plan moves first so the line written below describes a file already at
517
+ // its new path. Writing the retarget first and failing the move would leave a
518
+ // pointer at a folder holding nothing, and `.canon/plans/` is gitignored, so
519
+ // no history recovers the target it named.
520
+ if (plan) {
521
+ await mkdir(dirname(plan.to), { recursive: true })
522
+ await rename(plan.from, plan.to)
495
523
  }
496
524
 
497
- const destination = archiveDir(root)
498
525
  await mkdir(destination, { recursive: true })
499
- const to = join(destination, `${stem}.md`)
500
526
  await rename(from, to)
527
+ if (plan)
528
+ await writeFile(to, retargetPlanLine(text, linkTo(destination, plan.to)))
501
529
 
502
530
  const priorityRowRemoved = await clearPriorityRow(dir, stem)
503
531
  const regen = await regenOne(dir, { dryRun: false })
@@ -509,9 +537,52 @@ export async function archiveTask(
509
537
  to,
510
538
  priorityRowRemoved,
511
539
  indexRegenerated: regen.action === 'written',
540
+ plan,
512
541
  }
513
542
  }
514
543
 
544
+ /**
545
+ * The plan this task carries into the archive with it, or nothing. The merge is
546
+ * what settles a plan, and the hook reaches this with nobody watching, so the
547
+ * move sits inside the archive rather than in a second call that could leave
548
+ * the task archived and the plan live.
549
+ *
550
+ * A plan another live task still cites stays where it is. Moving it on the
551
+ * first task to close strands every other pointer at a path that has gone, and
552
+ * the sibling has no history behind it to repair the line from.
553
+ *
554
+ * A target resolving to no file yields nothing too. A pointer somebody typed
555
+ * wrong is not a plan to move, and refusing the whole archive over it would
556
+ * park the board behind a repair the merge cannot make.
557
+ */
558
+ async function planToArchive(
559
+ dir: string,
560
+ root: string,
561
+ stem: string,
562
+ text: string,
563
+ ): Promise<PlanMove | undefined> {
564
+ const target = readPlanTarget(text)
565
+ const live = target && resolveLivePlan(target, dir, root)
566
+ if (!live || !existsSync(live)) return undefined
567
+
568
+ const shared = await otherTasksCitingPlan(dir, root, live, stem)
569
+ if (shared.length > 0) return undefined
570
+
571
+ return {
572
+ from: live,
573
+ to: join(recordDir(root, PLANS, ARCHIVE), basename(live)),
574
+ }
575
+ }
576
+
577
+ /**
578
+ * The archived plan as the archived task cites it. Both halves land a folder
579
+ * deeper than the live pair, so the link is measured between the two
580
+ * destinations rather than written as the `../plans/` the live task carried.
581
+ */
582
+ function linkTo(taskDir: string, plan: string): string {
583
+ return relative(taskDir, plan).split(sep).join('/')
584
+ }
585
+
515
586
  async function clearPriorityRow(dir: string, stem: string): Promise<boolean> {
516
587
  const path = join(dir, 'priority.md')
517
588
  if (!existsSync(path)) return false
@@ -5,6 +5,7 @@ import {
5
5
  archiveDir,
6
6
  isReservedStem,
7
7
  readOutcomes,
8
+ readPlanTarget,
8
9
  readPullRequest,
9
10
  tasksDir,
10
11
  } from '@/tasks/archive'
@@ -34,6 +35,8 @@ export type ValidateRefusal = (typeof VALIDATE_REFUSALS)[number]
34
35
  export const FINDING_KINDS = [
35
36
  'plan-unstated',
36
37
  'plan-unresolved',
38
+ 'plan-uncited',
39
+ 'plan-mismatched',
37
40
  'task-unresolved',
38
41
  'row-missing',
39
42
  'row-duplicated',
@@ -612,6 +615,78 @@ function checkPlans(
612
615
  return findings
613
616
  }
614
617
 
618
+ /**
619
+ * Compares the two places one task's plan is written down. The `## Run now` row
620
+ * carries a `Plan` column and the task file carries its own `Plan:` line, and
621
+ * the archive reads the second while an operator reads the first, so a pair
622
+ * that disagrees settles the wrong plan on the merge.
623
+ *
624
+ * Both sides resolve before they compare. A row writing `../plans/x.md` and a
625
+ * task writing `.canon/plans/x.md` name one file, and comparing the strings
626
+ * would report every such pair as a mismatch.
627
+ */
628
+ async function checkPlanAgreement(
629
+ rows: readonly BoardRow[],
630
+ dir: string,
631
+ root: string,
632
+ ): Promise<Finding[]> {
633
+ const ready = rows.filter((row) => row.group === 'Run now' && row.plan)
634
+
635
+ const found = await Promise.all(
636
+ ready.map(async (row) => planDisagreement(row, dir, root)),
637
+ )
638
+
639
+ return found.filter((finding): finding is Finding => finding !== undefined)
640
+ }
641
+
642
+ async function planDisagreement(
643
+ row: BoardRow,
644
+ dir: string,
645
+ root: string,
646
+ ): Promise<Finding | undefined> {
647
+ const subject = row.stem ?? row.label
648
+ if (!row.stem || !row.plan) return undefined
649
+
650
+ const file = join(dir, `${row.stem}.md`)
651
+ if (!existsSync(file)) return undefined
652
+
653
+ const target = readPlanTarget(await readFile(file, 'utf8'))
654
+ if (!target) {
655
+ return {
656
+ kind: 'plan-uncited',
657
+ group: 'Run now',
658
+ subject,
659
+ message: `is rowed against ${row.plan}, and the task file carries no Plan: line, so the archive settles no plan when it ships.`,
660
+ }
661
+ }
662
+
663
+ const rowed = planPath(row.plan, dir, root)
664
+ const cited = planPath(target, dir, root)
665
+ if (rowed === cited) return undefined
666
+
667
+ return {
668
+ kind: 'plan-mismatched',
669
+ group: 'Run now',
670
+ subject,
671
+ message: `is rowed against ${row.plan} and cites ${target} in its own Plan: line. One task names one plan.`,
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Where a plan pointer lands, resolved against the board and against the
677
+ * project root the way the archive resolves the same line. Neither base
678
+ * existing leaves the board-relative reading, so two pointers at one absent
679
+ * file still compare equal and the mismatch check reports nothing.
680
+ */
681
+ function planPath(target: string, dir: string, root: string): string {
682
+ const path = target.split('#')[0] || target
683
+ const fromBoard = resolve(dir, path)
684
+ if (existsSync(fromBoard)) return fromBoard
685
+
686
+ const fromRoot = resolve(root, path)
687
+ return existsSync(fromRoot) ? fromRoot : fromBoard
688
+ }
689
+
615
690
  /**
616
691
  * The half of the `## Run now` test a person cannot check by eye. Two rows a
617
692
  * worker may be handed at once must touch disjoint files, and the `Touches`
@@ -984,6 +1059,7 @@ export async function validateBoard(
984
1059
  ...shapeFindings,
985
1060
  ...checkMapping(rows, backlog, stems, dir),
986
1061
  ...checkPlans(rows, dir, root),
1062
+ ...(await checkPlanAgreement(rows, dir, root)),
987
1063
  ...checkCollisions(rows),
988
1064
  ...checkOrdinals(rows),
989
1065
  ...parked.findings,
@@ -47,7 +47,7 @@ The handoff takes one file per session for the reason a task does. A single shar
47
47
 
48
48
  The catalog is the one reader that filters nothing, so it carries a row per sibling alongside the tasks. That is what a folder catalog is for, and the handoffs are what make it worth stating: a board accumulates one row per session that ever wrote one, with nothing pruning them. Anything reading the catalog as the backlog therefore does its own filtering, and a reader that takes every row as a task reports the handoffs as queued work.
49
49
 
50
- The `claude-tasks` skill creates and archives task files. `claude-docs` marks outcomes `[x]` in an existing file and sweeps the plans those tasks cite. Neither does the other's job.
50
+ The `claude-tasks` skill creates and archives task files, and the archive carries the task's plan with it. `claude-docs` marks outcomes `[x]` in an existing file. Neither does the other's job.
51
51
 
52
52
  ## Ordering
53
53
 
@@ -247,8 +247,8 @@ The archive nests inside `.canon/tasks/` rather than sitting beside it as a flat
247
247
 
248
248
  One destination rather than a per-project choice is what lets the move happen without asking. It mirrors the plans archive at `.canon/plans/archive/`, sitting inside the folder it archives the same way, and it inherits the board's own ignore entry rather than needing one of its own. The cost is that an archived task does not appear in diffs, which is the cost the live board already carries.
249
249
 
250
- Archiving a task does not archive its plan. `claude-docs` owns the plans sweep and moves a plan only when the closing task is its last live citation, so the sweep runs before the archive rather than after it. The sweep finds its work by scanning the live folder, and a task archived first is beyond its reach for good, leaving the plan with no live task citing it and an archived task pointing at a path nothing will retarget.
250
+ Archiving a task archives its plan alongside it, when the closing task is that plan's last live citation. The archived task's `Plan:` line is retargeted at `../../plans/archive/feature-<slug>.md`, a folder deeper than the live task wrote it, so a completed task still leads to the reasoning behind it. A plan several tasks share stays live and the task archives anyway, since moving it on the first task to close strands every sibling's pointer at a path that has gone.
251
251
 
252
- The `claude-docs` sweep states that ordering in its own body rather than reading it back from a command, which is a duplication accepted with a reason rather than an oversight. A skill reaches a target the moment it merges and the CLI reaches one only when a release publishes, so a body calling a verb the installed `canon` predates gets no record back and sweeps nothing. The two spellings therefore have to agree by hand until a release carries the verb, and the failure they guard against is a plan stranded by the form its citation was written in.
252
+ One act rather than two is what makes the pair safe. The merge is the event that settles a plan, and a `post-merge` hook reaching the archive with nobody watching cannot act on a warning, so a second call after it would be a second failure point leaving the task archived and the plan live.
253
253
 
254
- A task with an open outcome stays on the board. Close it, or cut it from the task when the work is being abandoned, so what was dropped is recorded rather than inferred from an archived file. The sweep is gated on the same condition, so archiving around an open outcome also leaves the plan behind.
254
+ A task with an open outcome stays on the board, and so does its plan. Close it, or cut it from the task when the work is being abandoned, so what was dropped is recorded rather than inferred from an archived file.