@erclx/canon 5.70.0 → 5.71.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.71.0](https://github.com/erclx/canon/compare/v5.70.0...v5.71.0) (2026-10-27)
4
+
5
+
6
+ ### Features
7
+
8
+ * **markdown:** read colon lists as labels ([#2797](https://github.com/erclx/canon/issues/2797)) ([f733a5f](https://github.com/erclx/canon/commit/f733a5f8d74e63cf5e257557a4d227b3478ca04a))
9
+ * **tasks:** name the sessions behind a task on a Sessions line ([#2799](https://github.com/erclx/canon/issues/2799)) ([acf80c4](https://github.com/erclx/canon/commit/acf80c4ce9d2c1adbc3e1ea2dfde433a08d46b4e))
10
+
3
11
  ## [5.70.0](https://github.com/erclx/canon/compare/v5.69.0...v5.70.0) (2026-10-27)
4
12
 
5
13
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.70.0",
4
+ "version": "5.71.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -29,6 +29,19 @@ The verb resolves the board at the main worktree root in-process, the root `sess
29
29
  - A line already reading as a list of `#NNN` entries takes the new number appended, reported as `appended`, so a task shipped in slices keeps every pull request
30
30
  - The same write drops this branch from the `Pending branch:` line `canon tasks outcome` wrote, which lets the merge-time archive close the task
31
31
 
32
+ ## Record the session
33
+
34
+ Beside the number, name this session on the same task's `Sessions:` line. It is the one record that covers a hand-launched worker, which no dispatcher saw start. Read the id from the roster's own row for the caller, then hand it to the verb under the same plan:
35
+
36
+ ```bash
37
+ id=$(canon sessions list --self --json | jq -r '.sessions[0].sessionId // empty')
38
+ [ -n "$id" ] && canon tasks session "$id" --plan feature-<slug> --json
39
+ ```
40
+
41
+ - A dispatched worker is already listed, since its dispatcher recorded it at launch, and the verb reports `unchanged` rather than writing it twice
42
+ - An empty `id` means the roster could not name the caller, so skip the write and report that no session was recorded
43
+ - Skip the same three refusals as the number below, and report `bad-input` or `no-name`, since either is a defect in this call
44
+
32
45
  ## Refusals
33
46
 
34
47
  Skip silently when the record is `ok: false` and `reason` is `no-board`, `no-match`, or `ambiguous`, meaning no board, no task naming the plan, or more than one. A guessed write would compound each, since a wrong match archives the wrong task unattended once the branch merges. Report any other refusal rather than swallowing it.
@@ -134,6 +134,7 @@ A bullet's punctuation follows its sentence count, and a list opens unannounced.
134
134
 
135
135
  - Do not use em dashes (`—`) or semicolons (`;`). Rewrite or restructure the sentence to avoid them. A colon or a comma standing where the dash was is the same sentence, so split it or rewrite it.
136
136
  - Do not use a colon to join two clauses in the middle of a sentence. A colon belongs after a label or at the end of a line introducing a list.
137
+ - The check misses a clause colon in a list of colon items, but it is wrong.
137
138
  - Do not use parenthetical asides in prose (`the config (which is optional) controls...`). Split into its own sentence or drop it. Parentheses in rule definitions for grouping examples are fine.
138
139
 
139
140
  ## Language
@@ -86,6 +86,7 @@ The readout is the text `Created cloud session: <title>`, unanchored since a ter
86
86
  - On a failed create, report the typescript's last lines and fall back to the human-launch line in `orchestrator-dispatch.md`
87
87
  - The wrapper is a stopgap any change to the interactive create screen breaks, which is why the readout fails loudly instead of guessing
88
88
  - Record `session` beside the row's branch in this pass's launched record, since that and the pull request list are the only readings that know it exists
89
+ - Name it on the row's task with `canon tasks session "$session" <task> --name cloud-<project>-<slug> --json`, since a cloud worker has no board and the roster never lists it
89
90
  - Report the dispatch as the local shape does, naming the row, the plan, the repository, and the session id in place of a session name
90
91
 
91
92
  ## Recognize the pull request
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Orchestrator launch runbook
3
- description: The launch every dispatch kind shares, being the build, cloud build, review-address, planning, and talk templates, the session name and controller id each carries, why the command sits at position zero, and what the brief may carry
3
+ description: The launch every dispatch kind shares, being the build, cloud build, review-address, and planning templates with a pointer to the talk variant, the session name and controller id each carries, why the command sits at position zero, and what the brief may carry
4
4
  ---
5
5
 
6
6
  Run this once `orchestrator-dispatch.md` has cleared a row, or from its planning or review-address shape below when that runbook's checks do not bind. The model each template names comes from `## Pick the model` in that runbook.
@@ -79,6 +79,10 @@ The prompt carries pointers and standing context, and stops there. The branch an
79
79
 
80
80
  Report the dispatch as loudly as the human-launch line it replaces, naming the branch, the model, the task, and the session name, so a person reading the transcript can follow what fired without watching it happen.
81
81
 
82
+ ### Record the worker on its task
83
+
84
+ Once a build or review-address launch returns, run `canon tasks session <worker-id> <task> --json` with the sessionId the launch output carries. On `no-name`, the worker already exited, so rerun with `--name` and the name passed to `-n`.
85
+
82
86
  ## Dispatch to a cloud worker
83
87
 
84
88
  Read `${CLAUDE_SKILL_DIR}/references/orchestrator-cloud-launch.md` when `orchestrator-dispatch.md` picked cloud for the row. It holds the prompt that carries the plan as text and announces over GitHub, the create under `script`, and the readout that refuses to record a missing session id.
@@ -137,25 +141,7 @@ Hold all planning of a row any talk session has said by message it is planning,
137
141
 
138
142
  ## Dispatch a talk session
139
143
 
140
- A planner or worker the operator will talk with takes the same launch as its cold shape above, with a `-talk` suffix on its name and one sentence added to its brief. A cold session works alone and reports back, and a talk session stays open after its plan or pull request lands so the operator can keep shaping the row.
141
-
142
- ```bash
143
- claude --bg --model <model> -n "planner-<project>-<slug>-talk" "Run /canon:role-planner, then /canon:plan-feature <task>. This is a talk session: the operator will talk with you here, so run your role's talk clause, and message your controller when the operator ends the conversation. Your controller is the session whose sessionId is <dispatcher-id>. ..."
144
- claude --bg --model <model> -n "worker-<project>-<slug>-talk" "/canon:auto-ship <plan>
145
- This is a talk session: the operator will talk with you here, so run your role's talk clause, and message your controller when the operator ends the conversation. Your controller is the session whose sessionId is <dispatcher-id>. ..."
146
- ```
147
-
148
- The ellipsis stands for the rest of the cold template's message clauses, unchanged. The build variant keeps `/canon:auto-ship <plan>` at position zero with nothing before it, per the rule above.
149
-
150
- - Keep the role prefix and add the suffix, since `orchestrator-handoff.md` separates dispatched sessions by `worker-` and `planner-` and a new prefix reads as a hand launch
151
- - A talk session is retired only on the operator's word, stated once in `orchestrator-handoff.md`, so do not restate it in a launch report
152
- - Reviewers get no talk shape, since a reviewer's pass holds no operator dialogue
153
-
154
- ### Recording the talk session
155
-
156
- Read the session's `sessionId` off the launch output, carry it in this session's own context, and write it into the map's `## Talk sessions held open` at the next handoff. The client can rename a session and drop the suffix, so the id is the durable marker and the suffix is only for display.
157
-
158
- The map exists only once the handoff writes it, so this session's own launches, read with the prior map, are what the first wind-down reads talk sessions from.
144
+ Read `${CLAUDE_SKILL_DIR}/references/orchestrator-talk-launch.md` when the operator will talk with a planner or worker rather than leave it to report back. It states the `-talk` name suffix, the one sentence the brief adds, and where the session's id is kept for the handoff.
159
145
 
160
146
  ## Hand launch
161
147
 
@@ -21,6 +21,16 @@ claude --bg --model <model> -n "reviewer-<project>-<number>" "Run /canon:role-re
21
21
  - Append the cross-branch facts after the controller clause, per `### What the brief may carry` in `orchestrator-launch.md`
22
22
  - Check `canon sessions list --json` for a live `reviewer-<project>-<number>` before launching, and message it instead when one holds the pull request
23
23
 
24
+ ### Record the reviewer on the task
25
+
26
+ A reviewer writes no task file, so this session names it on the task the pull request closes, through the sessionId the launch output carries:
27
+
28
+ ```bash
29
+ canon tasks session <reviewer-id> <task> --json
30
+ ```
31
+
32
+ Record the UI reviewer below the same way after its launch. A `no-name` refusal means the reviewer exited before the read, so rerun with `--name` and the name the launch passed to `-n`.
33
+
24
34
  ### The channel clauses
25
35
 
26
36
  The channel clauses restate what `role-reviewer` holds, so a reviewer whose plugin cache predates that body still sends the dispatch itself. The worker reads the reviewer's sessionId off that message and sends its re-review request back to it, since a reviewer holds the trunk branch and no branch lookup finds it.
@@ -0,0 +1,30 @@
1
+ ---
2
+ title: Orchestrator talk launch
3
+ description: The talk variant of the planning and build launches, being the name suffix, the one added brief sentence, and where the talk session's id is recorded for the handoff
4
+ ---
5
+
6
+ Run this when the operator wants to talk with a planner or worker rather than leave it to report back. The rules every launch shares stay in `orchestrator-launch.md`.
7
+
8
+ # Talk launch
9
+
10
+ ## The launch
11
+
12
+ A planner or worker the operator will talk with takes the same launch as its cold shape in `orchestrator-launch.md`, with a `-talk` suffix on its name and one sentence added to its brief. A cold session works alone and reports back, and a talk session stays open after its plan or pull request lands so the operator can keep shaping the row.
13
+
14
+ ```bash
15
+ claude --bg --model <model> -n "planner-<project>-<slug>-talk" "Run /canon:role-planner, then /canon:plan-feature <task>. This is a talk session: the operator will talk with you here, so run your role's talk clause, and message your controller when the operator ends the conversation. Your controller is the session whose sessionId is <dispatcher-id>. ..."
16
+ claude --bg --model <model> -n "worker-<project>-<slug>-talk" "/canon:auto-ship <plan>
17
+ This is a talk session: the operator will talk with you here, so run your role's talk clause, and message your controller when the operator ends the conversation. Your controller is the session whose sessionId is <dispatcher-id>. ..."
18
+ ```
19
+
20
+ The ellipsis stands for the rest of the cold template's message clauses, unchanged. The build variant keeps `/canon:auto-ship <plan>` at position zero with nothing before it, per `### Position zero` in `orchestrator-launch.md`.
21
+
22
+ - Keep the role prefix and add the suffix, since `orchestrator-handoff.md` separates dispatched sessions by `worker-` and `planner-` and a new prefix reads as a hand launch
23
+ - A talk session is retired only on the operator's word, stated once in `orchestrator-handoff.md`, so do not restate it in a launch report
24
+ - Reviewers get no talk shape, since a reviewer's pass holds no operator dialogue
25
+
26
+ ## Recording the talk session
27
+
28
+ Read the session's `sessionId` off the launch output, carry it in this session's own context, and write it into the map's `## Talk sessions held open` at the next handoff. The client can rename a session and drop the suffix, so the id is the durable marker and the suffix is only for display.
29
+
30
+ The map exists only once the handoff writes it, so this session's own launches, read with the prior map, are what the first wind-down reads talk sessions from.
@@ -0,0 +1,40 @@
1
+ ---
2
+ title: Task record lines reference
3
+ description: The lines a verb writes on a task below its origin lines, being the Pull request line that lets a merge close its task, the Pending branch marker that holds that merge back, and the Sessions line naming who worked it
4
+ ---
5
+
6
+ # Task record lines reference
7
+
8
+ Applies to the lines a task carries under its origin lines that no session writes by hand. Each one has a `canon tasks` verb as its writer, and this file fixes what each line means and who calls that verb.
9
+
10
+ This is an attribute standard. It governs lines inside a task file rather than a file of its own, so it carries no template.
11
+
12
+ ## Scope
13
+
14
+ Governs the `Pull request:`, `Pending branch:`, and `Sessions:` lines.
15
+
16
+ Does not govern:
17
+
18
+ - The task file's layout, filename, format, origin lines, and archiving: `tasks.md`
19
+ - The `Declined:` line: `tasks-decline.md`
20
+
21
+ ## The `Pull request:` line
22
+
23
+ `Pull request:` lists every pull request that shipped part of the task as bare numbers, oldest first and comma-separated, as in `Pull request: #NNN, #NNN`. It is not an origin, so a task without one is well-formed, and `git-pr` appends to it when a pull request opens.
24
+
25
+ The line lets a merge close its own task. `canon tasks archive --pull-request <n>` finds the task by any listed number and archives it only on the last.
26
+
27
+ - One pull request, one task. Two tasks naming the same number refuse to archive rather than both moving.
28
+ - `Pending branch: <branch>` holds that merge back while a branch that ticked an outcome waits on its own number
29
+ - `canon tasks outcome` writes that line, skipping it when the branch's pull request is already listed, and `canon tasks pull-request` clears it
30
+
31
+ ## The `Sessions:` line
32
+
33
+ `Sessions:` names each session that built or reviewed the task, by roster name and full sessionId, as in `Sessions: <name> (<sessionId>), <name> (<sessionId>)`. A retro or a regression hunt reaches each transcript through `canon sessions export <sessionId>`, which takes the full id alone. It is not an origin either.
34
+
35
+ - `canon tasks session <sessionId> <task>` writes it, or `--plan <slug>` in place of the stem, so it is never hand-edited
36
+ - The verb reads the name off the live roster. A session that already exited takes `--name`, and so does a cloud session's `session_` id, since the roster never lists one.
37
+ - The id is the key. A repeat call for a listed id keeps the name first recorded, so two callers recording one worker write it once.
38
+ - The dispatcher records each worker and reviewer it launches, and `git-pr` records the session opening the pull request, which covers a hand launch
39
+
40
+ The line sits below the origin, `Pull request:`, and `Pending branch:` lines. `canon tasks validate` reports a line that does not parse as name and id pairs as `sessions-misshapen`.
@@ -29,6 +29,7 @@ Governs the task files under `.canon/tasks/`: folder layout, filenames, frontmat
29
29
  Does not govern:
30
30
 
31
31
  - **`tasks-decline.md`**: the declined folder, the `Declined:` line, and what declining does to a plan and a board row
32
+ - **`tasks-record-lines.md`**: the `Pull request:`, `Pending branch:`, and `Sessions:` lines a verb writes below the origin lines
32
33
  - **`board.md`**: execution order, readiness groups, the backlog, and the generated index beside the tasks
33
34
  - **`plan.md`**: the plan file a task cites, its sections, and its answer contract
34
35
  - **`versioning.md`**: phase-label format and which surfaces a label may appear on
@@ -112,6 +113,7 @@ Groundwork: [<slug>](../groundwork/<slug>/)
112
113
  Intake: [<slug>](../intake/<slug>/)
113
114
  Issue: #NNN
114
115
  Pull request: #NNN
116
+ Sessions: <name> (<sessionId>)
115
117
 
116
118
  Why this task exists and what it depends on.
117
119
 
@@ -165,15 +167,9 @@ Three rules cover the line itself:
165
167
  - Use `Intake:` rather than `Groundwork:` for an intake pass, so the line records which kind of pass produced the task
166
168
  - `Intake:` names the folder rather than an item, and item numbers belong in the task's `## Findings`
167
169
 
168
- ### The `Pull request:` line
170
+ ### The lines a verb writes
169
171
 
170
- `Pull request:` lists every pull request that shipped part of the task as bare numbers, oldest first and comma-separated, as in `Pull request: #NNN, #NNN`. It is not an origin, so a task without one is well-formed, and `git-pr` appends to it when a pull request opens.
171
-
172
- The line lets a merge close its own task. `canon tasks archive --pull-request <n>` finds the task by any listed number and archives it only on the last.
173
-
174
- - One pull request, one task. Two tasks naming the same number refuse to archive rather than both moving.
175
- - `Pending branch: <branch>` holds that merge back while a branch that ticked an outcome waits on its own number
176
- - `canon tasks outcome` writes that line, skipping it when the branch's pull request is already listed, and `canon tasks pull-request` clears it
172
+ `Pull request:` and `Sessions:` are not origins, so a task without them is well-formed. A verb writes each, and `${CLAUDE_SKILL_DIR}/references/tasks-record-lines.md` fixes what they mean and who calls the verb.
177
173
 
178
174
  ## What goes in
179
175
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.70.0",
4
+ "version": "5.71.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -167,7 +167,9 @@ export function register(program: Command): void {
167
167
  '',
168
168
  'A mid-sentence colon and a heading with a capital after its first word',
169
169
  'report the same way. A proper noun is the one case the second cannot',
170
- 'tell from title case, and the first is sometimes right.',
170
+ 'tell from title case, and the first is sometimes right. A list whose',
171
+ 'every item opens on a lead and a colon reads each lead as a label, and so',
172
+ 'does a lead made only of code spans, links, commas, and and, or, to.',
171
173
  '',
172
174
  'Length reports a whole document past the ceiling and never exits 2.',
173
175
  'The Document ceiling gate stage owns that verdict, since this audit also',
@@ -842,6 +844,9 @@ function reportColons(reports: readonly FileReport[]): void {
842
844
  logInfo(
843
845
  'A colon followed by more text in a prose line, outside a label, a code span, and a link.',
844
846
  )
847
+ logInfo(
848
+ 'A list where every item opens on a lead and a colon reads each lead as a label.',
849
+ )
845
850
  logInfo(
846
851
  'A heading, a table row, a fenced block, and a line-final colon are excluded. This never fails a run.',
847
852
  )
@@ -57,7 +57,10 @@ import {
57
57
  type RecordRefused,
58
58
  type RecordSelector,
59
59
  recordPullRequest,
60
+ recordSession,
61
+ type SessionOutcome,
60
62
  } from '@/tasks/record'
63
+ import { resolveSessions } from '@/sessions/resolve'
61
64
  import {
62
65
  type BoardGroup,
63
66
  type Finding,
@@ -142,6 +145,13 @@ interface PullRequestCommandOptions {
142
145
  readonly root?: string
143
146
  }
144
147
 
148
+ interface SessionCommandOptions {
149
+ readonly json?: boolean
150
+ readonly plan?: string
151
+ readonly name?: string
152
+ readonly root?: string
153
+ }
154
+
145
155
  interface PlanLinkCommandOptions {
146
156
  readonly add?: boolean
147
157
  readonly waiting?: string
@@ -617,6 +627,63 @@ export function register(program: Command): void {
617
627
  },
618
628
  )
619
629
 
630
+ tasks
631
+ .command('session')
632
+ .description('Record a session that worked a task on its Sessions: line')
633
+ .argument(
634
+ '<sessionId>',
635
+ 'Full sessionId, as canon sessions list --json carries it',
636
+ )
637
+ .argument('[task]', 'Task filename stem, as in v28.1-trigger-escalation')
638
+ .helpOption('-h, --help', 'Show this help message')
639
+ .option('--plan <slug>', 'Select the task whose Plan line names this plan')
640
+ .option(
641
+ '--name <name>',
642
+ 'Name to record, read off the live roster when omitted',
643
+ )
644
+ .option('--json', 'Emit a machine-readable record on stdout')
645
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
646
+ .addHelpText(
647
+ 'after',
648
+ [
649
+ '',
650
+ 'Refusals, read off `reason`:',
651
+ ' no-board the repository holds no `.canon/tasks/` board',
652
+ ' no-match no task matches the selector',
653
+ ' ambiguous more than one task matches the selector',
654
+ ' bad-input an id that is not a full sessionId, or an empty name',
655
+ ' no-name the roster lists no live session with the id, and no',
656
+ ' --name was passed',
657
+ '',
658
+ 'Exit codes:',
659
+ ' 0 the session was added, appended, or already listed',
660
+ ' 1 refused, with the reason on stderr or in the JSON record',
661
+ '',
662
+ 'It writes Sessions: <name> (<sessionId>) below the origin, Pull',
663
+ 'request:, and Pending branch: lines, and appends to the line when one',
664
+ 'exists. The id is the key, so a session already listed is reported',
665
+ 'unchanged under the name it was first recorded with. A cloud',
666
+ 'session_ id is accepted, and takes --name since the roster never',
667
+ 'lists it. Safe from a linked worktree, since it resolves the board',
668
+ 'root in-process.',
669
+ '',
670
+ 'Examples:',
671
+ ' canon tasks session 0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0 v28.1-trigger-escalation',
672
+ ' canon tasks session 0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0 --plan task-sessions-line --json',
673
+ ' canon tasks session session_01AbC v28.1-trigger-escalation --name cloud-canon-a',
674
+ '',
675
+ ].join('\n'),
676
+ )
677
+ .action(
678
+ async (
679
+ sessionId: string,
680
+ task: string | undefined,
681
+ opts: SessionCommandOptions,
682
+ ) => {
683
+ process.exitCode = await runSession(sessionId, task, opts)
684
+ },
685
+ )
686
+
620
687
  tasks
621
688
  .command('plan-link')
622
689
  .description("Write or correct a task's Plan: line to point at a plan")
@@ -1259,6 +1326,72 @@ async function runPullRequest(
1259
1326
  return reportPullRequest(outcome, emitJson, root)
1260
1327
  }
1261
1328
 
1329
+ async function runSession(
1330
+ sessionId: string,
1331
+ task: string | undefined,
1332
+ opts: SessionCommandOptions,
1333
+ ): Promise<number> {
1334
+ const emitJson = opts.json ?? false
1335
+ const selector = selectorFor(task, opts.plan)
1336
+
1337
+ if ('ok' in selector) {
1338
+ return reportRecord(
1339
+ 'canon tasks session',
1340
+ selector,
1341
+ emitJson,
1342
+ process.cwd(),
1343
+ )
1344
+ }
1345
+
1346
+ const root = opts.root ?? (await mainWorktreeRoot())
1347
+ const name = opts.name ?? (await rosterName(sessionId))
1348
+ const outcome = await recordSession(root, selector, sessionId, name)
1349
+
1350
+ return reportSession(outcome, emitJson, root)
1351
+ }
1352
+
1353
+ /** The live roster's name for an id, or undefined once the session has exited. */
1354
+ async function rosterName(sessionId: string): Promise<string | undefined> {
1355
+ const report = await resolveSessions()
1356
+ if (report.kind === 'absent') return undefined
1357
+
1358
+ return report.sessions.find((session) => session.sessionId === sessionId)
1359
+ ?.name
1360
+ }
1361
+
1362
+ function reportSession(
1363
+ outcome: SessionOutcome,
1364
+ emitJson: boolean,
1365
+ root: string,
1366
+ ): number {
1367
+ if (!outcome.ok) {
1368
+ return reportRecord('canon tasks session', outcome, emitJson, root)
1369
+ }
1370
+
1371
+ if (emitJson) {
1372
+ process.stdout.write(
1373
+ `${JSON.stringify({
1374
+ ok: true,
1375
+ root,
1376
+ task: outcome.stem,
1377
+ path: relative(root, outcome.path),
1378
+ sessionId: outcome.sessionId,
1379
+ name: outcome.name,
1380
+ action: outcome.action,
1381
+ })}\n`,
1382
+ )
1383
+ return 0
1384
+ }
1385
+
1386
+ intro('canon tasks session')
1387
+ logStep(outcome.action === 'unchanged' ? 'Already recorded' : 'Recorded')
1388
+ logInfo(`${outcome.stem} names ${outcome.name} (${outcome.sessionId})`)
1389
+ if (outcome.action !== 'unchanged') logAdd(relative(root, outcome.path))
1390
+ outro()
1391
+
1392
+ return 0
1393
+ }
1394
+
1262
1395
  const RENAME_PATTERN = /^Branch: renamed refs\/heads\/(.+) to refs\/heads\//
1263
1396
 
1264
1397
  /**
@@ -22,8 +22,14 @@ const QUOTE_MARKER = /^(?:\s*>\s?)+/
22
22
  /** A colon followed by whitespace and more text, ruling out `10:30` and a scheme. */
23
23
  const COLON = /:(?=\s+\S)/g
24
24
 
25
+ /** A colon followed by whitespace, which a masked code span leaves behind. */
26
+ const LABEL_COLON = /:\s/
27
+
25
28
  const BOLD_LABEL = /^(\*\*|__)[^*_]+\1$/
26
29
 
30
+ /** What may stand in a lead once its code spans and links are masked. */
31
+ const CONNECTIVE = /\b(?:and|or|to)\b|,/g
32
+
27
33
  /** Blanks a span while holding its width, so a column stays where it was. */
28
34
  function blank(match: string): string {
29
35
  return ' '.repeat(match.length)
@@ -32,14 +38,74 @@ function blank(match: string): string {
32
38
  /**
33
39
  * Whether the text before a line's first colon is a label rather than a
34
40
  * clause: the lead of a list item, or a prefix that is only emphasis and a
35
- * masked code span.
41
+ * masked code span. A list item's lead also reads as a label at any length
42
+ * when only code spans, links, commas, and `and`, `or`, or `to` stand in it,
43
+ * which is a file entry naming several paths.
36
44
  */
37
45
  function isLabel(prefix: string, listItem: boolean): boolean {
38
46
  if (BOLD_LABEL.test(prefix.trim())) return true
39
47
 
40
48
  const words = prefix.replace(/[*_]/g, ' ').trim()
41
49
  if (words === '') return true
42
- return listItem && words.split(/\s+/).length <= 4
50
+ if (!listItem) return false
51
+ if (words.replace(CONNECTIVE, ' ').trim() === '') return true
52
+ return words.split(/\s+/).length <= 4
53
+ }
54
+
55
+ interface Entry {
56
+ readonly line: BodyLine
57
+ readonly masked: string
58
+ readonly start: number
59
+ /** Width of the quote marker and the item's own leading whitespace. */
60
+ readonly indent: number
61
+ readonly quote: string
62
+ readonly isItem: boolean
63
+ /** Matches at or past the content start, the only ones a line can report. */
64
+ readonly colons: readonly RegExpExecArray[]
65
+ /** A colon stands past the content start, even with only a masked span after it. */
66
+ readonly labelColon: boolean
67
+ /** Items sharing one list at one indent, shared by reference. */
68
+ group?: Entry[]
69
+ }
70
+
71
+ /**
72
+ * Collects consecutive items at one indent into a list. A blank line, a
73
+ * heading, or a change of quote depth ends every open list, and a deeper child
74
+ * neither joins its parent's list nor ends it.
75
+ */
76
+ function groupItems(entries: readonly Entry[]): void {
77
+ const open = new Map<number, Entry[]>()
78
+ let quote = ''
79
+
80
+ for (const entry of entries) {
81
+ if (entry.line.fenced) continue
82
+
83
+ const empty = entry.masked.slice(entry.quote.length).trim() === ''
84
+ if (entry.quote !== quote || empty || HEADING.test(entry.line.text)) {
85
+ open.clear()
86
+ }
87
+ quote = entry.quote
88
+ if (!entry.isItem) continue
89
+
90
+ for (const level of open.keys()) {
91
+ if (level > entry.indent) open.delete(level)
92
+ }
93
+
94
+ const group = open.get(entry.indent) ?? []
95
+ open.set(entry.indent, group)
96
+ group.push(entry)
97
+ entry.group = group
98
+ }
99
+ }
100
+
101
+ /** Whether the item sits in a list of two or more where every item has a colon. */
102
+ function uniform(entry: Entry): boolean {
103
+ const group = entry.group
104
+ return (
105
+ group !== undefined &&
106
+ group.length >= 2 &&
107
+ group.every((item) => item.labelColon)
108
+ )
43
109
  }
44
110
 
45
111
  /**
@@ -47,30 +113,50 @@ function isLabel(prefix: string, listItem: boolean): boolean {
47
113
  *
48
114
  * A heading, a table row, a fence, a code span, a link, and a line-final colon
49
115
  * are left alone, and so is the first colon of a list item or of a line opening
50
- * on a bold or code label. The measure is advisory, because a colon is
51
- * sometimes right and the corpus count shows a gate would be a sweep nobody
52
- * asked for.
116
+ * on a bold or code label, and of every item in a list where each one carries a
117
+ * colon. The measure is advisory, because a colon is sometimes right and the
118
+ * corpus count shows a gate would be a sweep nobody asked for.
53
119
  */
54
120
  export function findColons(lines: readonly BodyLine[]): ColonFinding[] {
55
- const found: ColonFinding[] = []
56
-
57
- for (const line of lines) {
58
- if (line.fenced || HEADING.test(line.text) || TABLE_ROW.test(line.text)) {
59
- continue
60
- }
61
-
121
+ const entries: Entry[] = lines.map((line) => {
62
122
  const masked = maskDisplayed(line.text.replace(LINK, blank))
63
123
  const quote = QUOTE_MARKER.exec(masked)?.[0] ?? ''
64
124
  const marker = LIST_MARKER.exec(masked.slice(quote.length))?.[0] ?? ''
65
125
  const start = quote.length + marker.length
66
- let labelled = false
126
+ const skipped =
127
+ line.fenced || HEADING.test(line.text) || TABLE_ROW.test(line.text)
128
+
129
+ return {
130
+ line,
131
+ masked,
132
+ start,
133
+ indent: quote.length + marker.length - marker.trimStart().length,
134
+ quote: quote.replace(/\s/g, ''),
135
+ isItem: !skipped && marker !== '',
136
+ labelColon: !skipped && LABEL_COLON.test(masked.slice(start)),
137
+ colons: skipped
138
+ ? []
139
+ : [...masked.matchAll(COLON)].filter((match) => match.index >= start),
140
+ }
141
+ })
142
+
143
+ groupItems(entries)
67
144
 
68
- for (const match of masked.matchAll(COLON)) {
69
- if (match.index < start) continue
145
+ const found: ColonFinding[] = []
146
+
147
+ for (const entry of entries) {
148
+ const { line, masked, start } = entry
149
+ let labelled = false
70
150
 
151
+ for (const match of entry.colons) {
71
152
  if (!labelled) {
72
153
  labelled = true
73
- if (isLabel(masked.slice(start, match.index), marker !== '')) continue
154
+ if (
155
+ isLabel(masked.slice(start, match.index), entry.isItem) ||
156
+ (entry.isItem && uniform(entry))
157
+ ) {
158
+ continue
159
+ }
74
160
  }
75
161
 
76
162
  found.push({
@@ -325,6 +325,57 @@ export function readPendingBranches(text: string): readonly string[] {
325
325
  .filter((entry) => entry.length > 0)
326
326
  }
327
327
 
328
+ /**
329
+ * A local session's full sessionId, or the `session_` id a cloud create reads
330
+ * out. Only the full form reaches `canon sessions export`, so a short prefix is
331
+ * refused rather than written.
332
+ */
333
+ const SESSION_ID_SOURCE =
334
+ '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|session_[A-Za-z0-9]+'
335
+
336
+ export const SESSION_ID_PATTERN = new RegExp(`^(?:${SESSION_ID_SOURCE})$`)
337
+
338
+ export const SESSIONS_PREFIX = 'Sessions:'
339
+
340
+ /**
341
+ * One entry is a name and its parenthesized id, and the parse keys on that
342
+ * trailing id, so a roster name carrying a comma or its own parentheses still
343
+ * reads as one entry.
344
+ */
345
+ const SESSION_ENTRY_PATTERN = new RegExp(
346
+ `(.+?) \\((${SESSION_ID_SOURCE})\\)(?:, |$)`,
347
+ 'y',
348
+ )
349
+
350
+ export interface SessionEntry {
351
+ readonly name: string
352
+ readonly sessionId: string
353
+ }
354
+
355
+ /**
356
+ * Reads the `Sessions:` line `canon tasks session` writes. A line that does not
357
+ * parse whole reads as naming none, the way `readPullRequest` reads a line
358
+ * carrying anything besides its list.
359
+ */
360
+ export function readSessions(text: string): readonly SessionEntry[] {
361
+ const line = text
362
+ .split('\n')
363
+ .find((entry) => entry.startsWith(SESSIONS_PREFIX))
364
+ if (line === undefined) return []
365
+
366
+ const body = line.slice(SESSIONS_PREFIX.length).trim()
367
+ const entries: SessionEntry[] = []
368
+ const pattern = new RegExp(SESSION_ENTRY_PATTERN)
369
+
370
+ while (pattern.lastIndex < body.length) {
371
+ const match = pattern.exec(body)
372
+ if (!match) return []
373
+ entries.push({ name: match[1], sessionId: match[2] })
374
+ }
375
+
376
+ return entries
377
+ }
378
+
328
379
  /** A markdown link on a `Plan:` line, its target captured. */
329
380
  const PLAN_LINK_PATTERN = /\[[^\]]*\]\(([^)]+)\)/g
330
381
 
@@ -514,7 +565,8 @@ export function declineLine(reason: string, by: string, date: string): string {
514
565
  /**
515
566
  * Lines a `Declined:` line anchors after, mirroring `record.ts`'s
516
567
  * `ORIGIN_PREFIXES` with `Pull request:` folded in, since a decline can follow
517
- * a pull request that never merged.
568
+ * a pull request that never merged. `Sessions:` joins them, since the sessions
569
+ * that worked a task sit above whatever ended it.
518
570
  */
519
571
  const DECLINE_ANCHOR_PREFIXES = [
520
572
  'Plan:',
@@ -522,6 +574,7 @@ const DECLINE_ANCHOR_PREFIXES = [
522
574
  'Intake:',
523
575
  'Issue:',
524
576
  'Pull request:',
577
+ SESSIONS_PREFIX,
525
578
  ] as const
526
579
 
527
580
  function lastAnchorLine(lines: readonly string[]): number | undefined {
@@ -15,6 +15,9 @@ import {
15
15
  readPendingBranches,
16
16
  readPlanTargets,
17
17
  readPullRequest,
18
+ readSessions,
19
+ SESSION_ID_PATTERN,
20
+ SESSIONS_PREFIX,
18
21
  tasksDir,
19
22
  } from '@/tasks/archive'
20
23
  import { checkWaiting, type LinkPromotion, promoteOnLink } from '@/tasks/board'
@@ -34,6 +37,9 @@ const ORIGIN_PREFIXES = ['Plan:', 'Groundwork:', 'Intake:', 'Issue:'] as const
34
37
  *
35
38
  * `several-plans` answers a `Plan:` line that already links more than one plan.
36
39
  * `plan-link` cannot know which link to drop, so it writes nothing.
40
+ *
41
+ * `no-name` answers a session id the roster no longer lists, called with no
42
+ * name, since an id written alone reads as nobody to the next reader.
37
43
  */
38
44
  export const RECORD_REFUSALS = [
39
45
  'no-board',
@@ -44,6 +50,7 @@ export const RECORD_REFUSALS = [
44
50
  'no-plan',
45
51
  'several-plans',
46
52
  'bad-input',
53
+ 'no-name',
47
54
  ] as const
48
55
 
49
56
  export type RecordRefusal = (typeof RECORD_REFUSALS)[number]
@@ -88,6 +95,18 @@ export interface PlanRecorded {
88
95
 
89
96
  export type PlanOutcome = PlanRecorded | RecordRefused
90
97
 
98
+ export interface SessionRecorded {
99
+ readonly ok: true
100
+ readonly stem: string
101
+ readonly path: string
102
+ readonly sessionId: string
103
+ /** The name the line carries for the id, which a repeat call never renames. */
104
+ readonly name: string
105
+ readonly action: Exclude<LineAction, 'corrected'>
106
+ }
107
+
108
+ export type SessionOutcome = SessionRecorded | RecordRefused
109
+
91
110
  export interface OutcomesClosed {
92
111
  readonly ok: true
93
112
  readonly stem: string
@@ -228,6 +247,47 @@ export function clearPendingBranch(
228
247
  return { text: lines.join('\n'), changed: true }
229
248
  }
230
249
 
250
+ /**
251
+ * Names a session on the `Sessions:` line, keyed on the id, so a dispatcher
252
+ * and `git-pr` recording the same worker write it once. The line sits below
253
+ * every other line a task carries above its outcomes, so a pull request
254
+ * recorded later lands above it.
255
+ */
256
+ export function writeSessionsLine(
257
+ text: string,
258
+ name: string,
259
+ sessionId: string,
260
+ ): { readonly text: string; readonly action: SessionRecorded['action'] } {
261
+ const entry = `${name} (${sessionId})`
262
+ const lines = text.split('\n')
263
+ const existing = lines.findIndex((line) => line.startsWith(SESSIONS_PREFIX))
264
+
265
+ if (existing !== -1) {
266
+ if (lines[existing].includes(`(${sessionId})`)) {
267
+ return { text, action: 'unchanged' }
268
+ }
269
+ lines[existing] = `${lines[existing].trimEnd()}, ${entry}`
270
+ return { text: lines.join('\n'), action: 'appended' }
271
+ }
272
+
273
+ const line = `${SESSIONS_PREFIX} ${entry}`
274
+ const anchor = lastOriginLine(lines, [
275
+ ...ORIGIN_PREFIXES,
276
+ 'Pull request:',
277
+ PENDING_PREFIX,
278
+ ])
279
+ if (anchor !== undefined) {
280
+ lines.splice(anchor + 1, 0, line)
281
+ return { text: lines.join('\n'), action: 'added' }
282
+ }
283
+
284
+ const heading = lines.findIndex((entry) => entry.startsWith('# '))
285
+ if (heading === -1) return { text: `${line}\n${text}`, action: 'added' }
286
+
287
+ lines.splice(heading + 1, 0, '', line)
288
+ return { text: lines.join('\n'), action: 'added' }
289
+ }
290
+
231
291
  export type PlanLineWrite =
232
292
  | {
233
293
  readonly ok: true
@@ -566,6 +626,64 @@ export async function recordPlan(
566
626
  }
567
627
  }
568
628
 
629
+ /**
630
+ * Records a session that worked a task on its `Sessions:` line. The caller
631
+ * resolves `name` from the roster or takes it from `--name`, and an undefined
632
+ * name refuses rather than writing a bare id. The id is checked first, since a
633
+ * malformed one is a caller defect and every later check would hide it.
634
+ */
635
+ export async function recordSession(
636
+ root: string,
637
+ selector: RecordSelector,
638
+ sessionId: string,
639
+ name: string | undefined,
640
+ ): Promise<SessionOutcome> {
641
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
642
+ return refuse(
643
+ 'bad-input',
644
+ `Not a full sessionId: ${sessionId}. Pass the whole id canon sessions list --json carries.`,
645
+ [sessionId],
646
+ )
647
+ }
648
+
649
+ if (name === undefined) {
650
+ return refuse(
651
+ 'no-name',
652
+ `No live session carries ${sessionId}, so its name cannot be read. Pass --name.`,
653
+ [sessionId],
654
+ )
655
+ }
656
+
657
+ const trimmed = name.trim()
658
+ if (trimmed.length === 0 || /[\r\n]/.test(trimmed)) {
659
+ return refuse('bad-input', 'A session name is one non-empty line.', [name])
660
+ }
661
+
662
+ const opened = await openTask(root, selector)
663
+ if ('ok' in opened) return opened
664
+
665
+ const { stem, path } = opened
666
+ const written = writeSessionsLine(
667
+ await readFile(path, 'utf8'),
668
+ trimmed,
669
+ sessionId,
670
+ )
671
+ if (written.action !== 'unchanged') await writeFile(path, written.text)
672
+
673
+ const listed = readSessions(written.text).find(
674
+ (entry) => entry.sessionId === sessionId,
675
+ )
676
+
677
+ return {
678
+ ok: true,
679
+ stem,
680
+ path,
681
+ sessionId,
682
+ name: listed?.name ?? trimmed,
683
+ action: written.action,
684
+ }
685
+ }
686
+
569
687
  /**
570
688
  * Marks the named outcomes `[x]` in place. `task-complete` runs this against a
571
689
  * board it can read and cannot edit from a linked worktree, and the positions
@@ -10,7 +10,9 @@ import {
10
10
  readPendingBranches,
11
11
  readPlanTargets,
12
12
  readPullRequest,
13
+ readSessions,
13
14
  resolveLivePlan,
15
+ SESSIONS_PREFIX,
14
16
  tasksDir,
15
17
  } from '@/tasks/archive'
16
18
  import {
@@ -66,6 +68,7 @@ export const FINDING_KINDS = [
66
68
  'blocker-settled',
67
69
  'blocker-unresolved',
68
70
  'blocker-declined',
71
+ 'sessions-misshapen',
69
72
  ] as const
70
73
 
71
74
  export type FindingKind = (typeof FINDING_KINDS)[number]
@@ -841,6 +844,39 @@ async function planDisagreement(
841
844
  }
842
845
  }
843
846
 
847
+ /**
848
+ * Reports a `Sessions:` line that does not parse as name and id pairs, since
849
+ * the reader takes such a line as naming nobody and a retro would find the
850
+ * sessions gone. A row whose task file is gone is left to `task-unresolved`.
851
+ */
852
+ async function checkSessions(
853
+ rows: readonly BoardRow[],
854
+ dir: string,
855
+ ): Promise<Finding[]> {
856
+ const found = await Promise.all(
857
+ rows.map(async (row): Promise<Finding | undefined> => {
858
+ if (!row.stem) return undefined
859
+ const file = join(dir, `${row.stem}.md`)
860
+ if (!existsSync(file)) return undefined
861
+
862
+ const text = await readFile(file, 'utf8')
863
+ const line = text
864
+ .split('\n')
865
+ .find((entry) => entry.startsWith(SESSIONS_PREFIX))
866
+ if (line === undefined || readSessions(text).length > 0) return undefined
867
+
868
+ return {
869
+ kind: 'sessions-misshapen',
870
+ group: row.group,
871
+ subject: row.stem,
872
+ message: `carries "${line}", which does not read as name (sessionId) pairs, so no session it names can be recovered. Rewrite it through canon tasks session.`,
873
+ }
874
+ }),
875
+ )
876
+
877
+ return found.filter((finding): finding is Finding => finding !== undefined)
878
+ }
879
+
844
880
  /**
845
881
  * Tests the claim a group name makes about a plan. `## Needs a plan` says none
846
882
  * is live and `## Up next` says one is written, and neither group has a `Plan`
@@ -1638,6 +1674,7 @@ export async function validateBoard(
1638
1674
  ...checkMapping(rows, backlog, dir),
1639
1675
  ...(await groupRuleFindings(rows, root)),
1640
1676
  ...(await checkPlanAgreement(rows, dir, root)),
1677
+ ...(await checkSessions(rows, dir)),
1641
1678
  ...(await checkStaleTouches(rows, root)),
1642
1679
  ...checkCellWidth(rows),
1643
1680
  ...checkOrdering(rows),