@erclx/canon 4.100.0 → 4.101.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.
package/README.md CHANGED
@@ -99,7 +99,10 @@ Two of the outputs are documents you open rather than files you run. A `SLIDES.m
99
99
 
100
100
  The workflow this toolkit ships is the workflow that built it. Several Claude Code sessions run at once, each in its own git worktree on its own branch, and each opens its own pull request.
101
101
 
102
- ![The canon landing page scrolling from the dispatch section to the branch graph, three workers and a planner on disjoint file sets with the pull request each opened](demos/agent-view.gif)
102
+ <picture>
103
+ <source media="(prefers-color-scheme: dark)" srcset="demos/agent-view.gif">
104
+ <img src="demos/agent-view-light.gif" alt="The canon landing page scrolling from the dispatch section to the branch graph, three workers and a planner on disjoint file sets with the pull request each opened">
105
+ </picture>
103
106
 
104
107
  That recording is the landing page's own `dispatch` and `workers` sections, driven by `canon demo run` against a local build. The branch graph beside the pull request numbers is authored rather than read, because nothing on a build machine records which files four sessions held.
105
108
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.100.0",
4
+ "version": "4.101.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -26,6 +26,8 @@ The plan is committed, not scratch. Its timing is a starting point tuned by watc
26
26
 
27
27
  A plan can carry an optional `colorScheme` of `light` or `dark`, which the recording context starts in so a page following the system preference paints that scheme with no click beat of its own. An absent field keeps the engine's default, which is light, and `compile` never seeds it. Any other value makes the plan unreadable. A page keyed on a stored theme rather than the preference ignores the field.
28
28
 
29
+ A recording that has to match both themes takes one plan per scheme, committed side by side, rather than one plan run twice. The pair shares its beats and differs in `colorScheme`, `slug`, and the `output` paths. Neither plan clicks a theme toggle, since a stored theme overrides the preference. Nothing checks that the two plans keep the same beats, so an edit to one is made to both.
30
+
29
31
  `record-screencast` is the routed way to run both verbs from a draft path: it compiles only when no plan exists yet, then runs, stopping to report any field still unresolved rather than guessing one.
30
32
 
31
33
  | Option | Behavior |
@@ -292,6 +292,30 @@ Exit codes: `0` closed, `1` refused. The `reason` field adds `no-outcomes`, `out
292
292
 
293
293
  Both verbs exist because the write is an edit inside a file that already exists. `Edit` and `Write` refuse a main-root path from a linked worktree, and a shell stream editor is banned for in-place edits, so a verb resolving the board root in-process is the only route a skill body has. Creating a whole file needs no verb, because a heredoc through `Bash` writes it safely.
294
294
 
295
+ ## List
296
+
297
+ `canon tasks list` reports each live task file with its readiness, so a reader learns which file is parked and which is live without opening `priority.md` or `backlog.md`. It reads and never writes, and every file stays in the folder it is in.
298
+
299
+ ```bash
300
+ canon tasks list
301
+ canon tasks list --json
302
+ ```
303
+
304
+ | Option | Effect |
305
+ | --------------- | ------------------------------------------- |
306
+ | `--json` | Emit a machine-readable record on stdout |
307
+ | `--root <path>` | Board root, defaulting to the main worktree |
308
+
309
+ The record carries `tasks`, one entry per file with its `stem` and its `readiness`. Readiness is one of `Run now`, `Up next`, `Needs a plan`, `backlog`, `unplaced`, or `both`. The first three are the group names the board standard fixes, `backlog` is a line on `backlog.md`, `unplaced` is a file neither surface names, and `both` is a file both surfaces name, which `validate` reports as a finding and this verb does not judge.
310
+
311
+ The verb calls the parsers `validate` uses, so the two cannot disagree on what a surface names. It lists the live root only, never `archive/` or `declined/`, and skips the same siblings `validate` skips.
312
+
313
+ Exit codes: `0` derived, `1` refused with `no-board`.
314
+
315
+ ```bash
316
+ canon tasks list --json | jq -r '.tasks[] | select(.readiness == "backlog") | .stem'
317
+ ```
318
+
295
319
  ## Validate
296
320
 
297
321
  `canon tasks validate` reports what each row of `priority.md` claims against what the tree holds. It reads and never writes, because a row is a session's claim about readiness and a validator that repaired one would be asserting the claim it exists to test.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.100.0",
4
+ "version": "4.101.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -14,6 +14,7 @@ import {
14
14
  planCitations,
15
15
  } from '@/tasks/archive'
16
16
  import { type LabelOutcome, nextLabel } from '@/tasks/label'
17
+ import { listTasks } from '@/tasks/list'
17
18
  import {
18
19
  type Claim,
19
20
  type Holder,
@@ -114,6 +115,11 @@ interface OutcomeCommandOptions {
114
115
  readonly root?: string
115
116
  }
116
117
 
118
+ interface ListCommandOptions {
119
+ readonly json?: boolean
120
+ readonly root?: string
121
+ }
122
+
117
123
  interface NextLabelCommandOptions {
118
124
  readonly json?: boolean
119
125
  readonly root?: string
@@ -522,6 +528,37 @@ export function register(program: Command): void {
522
528
  .action(async (opts: NextLabelCommandOptions) => {
523
529
  process.exitCode = await runNextLabel(opts)
524
530
  })
531
+
532
+ tasks
533
+ .command('list')
534
+ .description('Report each live task file with its readiness')
535
+ .helpOption('-h, --help', 'Show this help message')
536
+ .option('--json', 'Emit a machine-readable record on stdout')
537
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
538
+ .addHelpText(
539
+ 'after',
540
+ [
541
+ '',
542
+ 'Readiness is one of Run now, Up next, Needs a plan, backlog, unplaced,',
543
+ 'or both, read from priority.md and backlog.md through the parsers',
544
+ 'validate uses. It lists the live root only, never archive/ or declined/.',
545
+ '',
546
+ 'Exit codes:',
547
+ ' 0 the list is derived',
548
+ ' 1 refused with no-board',
549
+ '',
550
+ 'It reports and never writes, and judges nothing: a file named on both',
551
+ 'surfaces reads as both, and validate owns the finding.',
552
+ '',
553
+ 'Examples:',
554
+ ' canon tasks list',
555
+ ' canon tasks list --json',
556
+ '',
557
+ ].join('\n'),
558
+ )
559
+ .action(async (opts: ListCommandOptions) => {
560
+ process.exitCode = await runList(opts)
561
+ })
525
562
  }
526
563
 
527
564
  function collectPosition(value: string, previous: string[]): string[] {
@@ -792,6 +829,38 @@ function reportOutcome(
792
829
  return 0
793
830
  }
794
831
 
832
+ async function runList(opts: ListCommandOptions): Promise<number> {
833
+ const root = opts.root ?? (await mainWorktreeRoot())
834
+ const outcome = await listTasks(root)
835
+
836
+ if (!outcome.ok) {
837
+ if (opts.json) {
838
+ process.stdout.write(
839
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
840
+ )
841
+ return 1
842
+ }
843
+
844
+ intro('canon tasks list')
845
+ logStep('Refused')
846
+ logError(outcome.message)
847
+ outro()
848
+ return 1
849
+ }
850
+
851
+ if (opts.json) {
852
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
853
+ return 0
854
+ }
855
+
856
+ intro('canon tasks list')
857
+ logStep(`${outcome.tasks.length} task files`)
858
+ for (const task of outcome.tasks) logInfo(`${task.readiness}: ${task.stem}`)
859
+ outro()
860
+
861
+ return 0
862
+ }
863
+
795
864
  async function runNextLabel(opts: NextLabelCommandOptions): Promise<number> {
796
865
  const root = opts.root ?? (await mainWorktreeRoot())
797
866
  const outcome = await nextLabel(root)
@@ -0,0 +1,98 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { listTaskStems, tasksDir } from '@/tasks/archive'
4
+ import {
5
+ BOARD_GROUPS,
6
+ type BoardGroup,
7
+ backlogPath,
8
+ orderingPath,
9
+ readBacklog,
10
+ readBoard,
11
+ } from '@/tasks/validate'
12
+
13
+ /**
14
+ * The three board group names plus the states outside them. A file named on
15
+ * both surfaces is `validate`'s finding, so it reports as `both` rather than
16
+ * being judged here.
17
+ */
18
+ export const READINESS_VALUES = [
19
+ ...BOARD_GROUPS,
20
+ 'backlog',
21
+ 'unplaced',
22
+ 'both',
23
+ ] as const
24
+
25
+ export type Readiness = (typeof READINESS_VALUES)[number]
26
+
27
+ export interface ListedTask {
28
+ readonly stem: string
29
+ readonly readiness: Readiness
30
+ }
31
+
32
+ export interface TaskList {
33
+ readonly ok: true
34
+ readonly tasks: readonly ListedTask[]
35
+ }
36
+
37
+ export interface ListRefused {
38
+ readonly ok: false
39
+ readonly reason: 'no-board'
40
+ readonly message: string
41
+ }
42
+
43
+ export type ListOutcome = TaskList | ListRefused
44
+
45
+ /**
46
+ * Reports each live task file's readiness from the board and the backlog. It
47
+ * reads through the parsers `validate` exports rather than reading either file
48
+ * a second time, so the two verbs cannot disagree on what a surface names. An
49
+ * absent ordering file or backlog reads as empty, since a file in neither
50
+ * surface is reported `unplaced` rather than refused.
51
+ */
52
+ export async function listTasks(root: string): Promise<ListOutcome> {
53
+ const dir = tasksDir(root)
54
+ if (!existsSync(dir)) {
55
+ return {
56
+ ok: false,
57
+ reason: 'no-board',
58
+ message: `No task board at ${dir}.`,
59
+ }
60
+ }
61
+
62
+ const ordering = orderingPath(root)
63
+ const { rows } = existsSync(ordering)
64
+ ? readBoard(await readFile(ordering, 'utf8'))
65
+ : { rows: [] }
66
+
67
+ const backlogFile = backlogPath(root)
68
+ const backlog = existsSync(backlogFile)
69
+ ? readBacklog(await readFile(backlogFile, 'utf8'))
70
+ : []
71
+
72
+ const groupOf = new Map<string, BoardGroup>()
73
+ for (const row of rows) {
74
+ if (row.stem) groupOf.set(row.stem, row.group)
75
+ }
76
+ const parked = new Set(backlog.flatMap((row) => (row.stem ? [row.stem] : [])))
77
+
78
+ const stems = await listTaskStems(dir)
79
+
80
+ return {
81
+ ok: true,
82
+ tasks: stems.map((stem) => ({
83
+ stem,
84
+ readiness: readinessOf(groupOf.get(stem), parked.has(stem)),
85
+ })),
86
+ }
87
+ }
88
+
89
+ function readinessOf(
90
+ group: BoardGroup | undefined,
91
+ isParked: boolean,
92
+ ): Readiness {
93
+ if (group && isParked) return 'both'
94
+ if (group) return group
95
+ if (isParked) return 'backlog'
96
+
97
+ return 'unplaced'
98
+ }
@@ -37,6 +37,8 @@ Does not govern:
37
37
  └── v13.0-toolkit-drift.md # canon-allow-reference: illustrates the vXX.Y-slug filename this section defines
38
38
  ```
39
39
 
40
+ `canon tasks list` reports each file's readiness from those two surfaces, so a reader never infers it from the folder.
41
+
40
42
  One file per task is what keeps the board safe under parallel sessions. Two sessions working different tasks never write the same file, which matters because a gitignored board has no history to recover a clobbered write from.
41
43
 
42
44
  Siblings sit in the folder without being tasks, and each earns its place by being governed somewhere. `index.md`, `priority.md`, and `backlog.md` are governed here. Every `session-` file is a pre-compaction handoff governed by `session.md`, and each is optional: a project whose sessions never approach a compaction carries none. Anything filtering the folder to tasks skips all of them, so a name outside the set is a task whatever it holds.
@@ -137,10 +137,15 @@ async function settle(
137
137
  for (const frame of document.querySelectorAll('iframe')) {
138
138
  try {
139
139
  const inner = frame.contentDocument
140
- if (inner !== null && inner.readyState !== 'complete') return false
140
+ if (inner === null) continue
141
+ if (inner.readyState !== 'complete') return false
142
+ // A frame's own fonts load after its `readyState` reads complete, and
143
+ // the swap reflows its text. The outer document's fonts never cover
144
+ // them.
145
+ if (inner.fonts.status !== 'loaded') return false
141
146
  } catch {
142
- // Cross-origin, so there is no readyState to read and nothing here
143
- // can wait on it.
147
+ // Cross-origin, so there is nothing here to read and nothing to
148
+ // wait on.
144
149
  }
145
150
  }
146
151
  return true