@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 +8 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/git-pr/references/task-number.md +13 -0
- package/claude/skills/markdown-craft/references/markdown.md +1 -0
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +1 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -20
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +10 -0
- package/claude/skills/role-orchestrator/references/orchestrator-talk-launch.md +30 -0
- package/claude/skills/task-board/references/tasks-record-lines.md +40 -0
- package/claude/skills/task-board/references/tasks.md +4 -8
- package/package.json +1 -1
- package/src/commands/markdown.ts +6 -1
- package/src/commands/tasks.ts +133 -0
- package/src/markdown/colons.ts +102 -16
- package/src/tasks/archive.ts +54 -1
- package/src/tasks/record.ts +118 -0
- package/src/tasks/validate.ts +37 -0
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
170
|
+
### The lines a verb writes
|
|
169
171
|
|
|
170
|
-
`Pull request:`
|
|
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
package/src/commands/markdown.ts
CHANGED
|
@@ -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
|
)
|
package/src/commands/tasks.ts
CHANGED
|
@@ -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
|
/**
|
package/src/markdown/colons.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
51
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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 (
|
|
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({
|
package/src/tasks/archive.ts
CHANGED
|
@@ -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 {
|
package/src/tasks/record.ts
CHANGED
|
@@ -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
|
package/src/tasks/validate.ts
CHANGED
|
@@ -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),
|