@erclx/canon 5.78.0 → 5.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/git-followup/SKILL.md +1 -0
  12. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  13. package/claude/skills/git-pr/SKILL.md +2 -0
  14. package/claude/skills/git-pr/references/final-command.md +34 -2
  15. package/claude/skills/git-pr/references/pr.md +1 -1
  16. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  17. package/claude/skills/git-ship/SKILL.md +16 -11
  18. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  19. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  20. package/claude/skills/plan-feature/references/plan.md +2 -0
  21. package/claude/skills/review-address/SKILL.md +2 -1
  22. package/claude/skills/review-pr/references/key-changes.md +2 -1
  23. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  24. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  25. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  27. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  28. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  29. package/claude/skills/role-worker/SKILL.md +1 -1
  30. package/claude/skills/task-board/SKILL.md +1 -0
  31. package/claude/skills/task-board/references/tasks.md +1 -0
  32. package/docs/workflow/ai-workflow.md +1 -1
  33. package/docs/workflow/parallel-features.md +2 -1
  34. package/package.json +1 -1
  35. package/src/commands/pr.ts +138 -15
  36. package/src/commands/sessions.ts +88 -6
  37. package/src/commands/tasks.ts +41 -2
  38. package/src/git/files.ts +5 -2
  39. package/src/indexes/frontmatter.ts +27 -6
  40. package/src/indexes/render.ts +21 -3
  41. package/src/markdown/colons.ts +14 -4
  42. package/src/markdown/heading-case.ts +9 -2
  43. package/src/pr/bijection.ts +8 -0
  44. package/src/pr/evidence.ts +38 -9
  45. package/src/pr/health.ts +63 -15
  46. package/src/pr/links.ts +160 -0
  47. package/src/pr/local.ts +17 -4
  48. package/src/pr/paths.ts +20 -2
  49. package/src/sessions/stop.ts +145 -0
  50. package/src/sessions/transfer/bundle.ts +1 -1
  51. package/src/take/encode.ts +16 -42
  52. package/src/take/frame-clock.ts +46 -0
  53. package/src/take/screencast.ts +114 -11
  54. package/src/tasks/holders.ts +198 -0
  55. package/src/tasks/validate.ts +148 -0
  56. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -5,7 +5,7 @@ description: What the git-ship CI watch does when the installed CLI predates the
5
5
 
6
6
  # CI watch fallback and refusals
7
7
 
8
- Applies to step 9 of `git-ship` once the watch wakes on anything but a `state` of `passing` or `failing` or a `conflicted: true` record, and to a target whose CLI carries no `canon pr checks`.
8
+ Applies to step 9 of `git-ship` once the watch wakes on anything but a `state` of `passing` or `failing` or a `conflicted: true` record, to a `failing` record whose failed runs were all cancelled, and to a target whose CLI carries no `canon pr checks`.
9
9
 
10
10
  ## The fallback watch
11
11
 
@@ -23,9 +23,33 @@ Empty output stops rather than waits, since `no checks reported on the '<branch>
23
23
 
24
24
  ## Refused and unsettled reads
25
25
 
26
- Neither of these is a pass, and neither emits `✅ Shipped`.
26
+ None of these is a pass, and none emits `✅ Shipped`. A `reason` and no `state` means the read was refused, and the watch sorts refusals into two kinds.
27
27
 
28
- - A `reason` and no `state` means the read was refused, such as `no-remote-branch` for a branch deleted on the remote. Stop and report that `reason`.
29
- - A watch that hit its timeout and printed no final record is CI unsettled after 60 minutes, so report it that way
28
+ | Kind | Reasons | What the watch did |
29
+ | --------- | ------------------------------------------------------------ | ---------------------------------------------------------------- |
30
+ | Permanent | `no-remote-branch`, `ambiguous-pull`, `no-branch` | Exited on the first one, since no amount of waiting changes them |
31
+ | Transient | Every other reason, such as `gh-failed` or `runs-unreadable` | Kept polling, since the next read can clear |
32
+
33
+ The set lives in the watch command rather than in the verb, so a refusal `canon pr checks` adds later defaults to transient and waits, which is the safe side.
34
+
35
+ - A permanent refusal ends the sequence. Stop and report that `reason`, such as `no-remote-branch` for a branch deleted on the remote.
36
+ - A transient refusal in the final record came from the read after the loop, since the loop never exits on one. Read `canon pr checks <number> --json` once more and route on that.
37
+ - A second refusal on that read stops the sequence with its `reason`
38
+ - A watch that hit its timeout and printed no final record is CI unsettled after 60 minutes, so report it that way. A transient refusal that never cleared ends here too.
30
39
 
31
40
  Do not auto-fix any of these. A stop report is one of the two exceptions to the no-text-between-steps rule.
41
+
42
+ ## A failing record with only cancelled runs
43
+
44
+ A run cancelled by a runner or a superseded workflow reads as `failing`, and a reviewer reads it as a real failure. This section decides whether a `failing` wake reruns or stops, and the rerun is the one exception to the no-auto-fix rule.
45
+
46
+ List the failed check runs on the record's `tip`, keeping the newest run per check name as `canon pr checks` does, and print each run id when every one of them was cancelled. The read goes through REST, since `gh pr checks` runs on GraphQL and a cloud session's proxy refuses it:
47
+
48
+ ```bash
49
+ gh api "repos/{owner}/{repo}/commits/<tip>/check-runs?per_page=100" --jq '[.check_runs | group_by(.name)[] | max_by(.id) | select(.conclusion | IN("failure", "timed_out", "cancelled", "action_required", "startup_failure"))] | if length > 0 and all(.conclusion == "cancelled") then [.[].details_url | capture("/runs/(?<id>[0-9]+)").id] | unique | .[] else empty end'
50
+ ```
51
+
52
+ - A non-zero exit means the read was refused, so stop and report the refusal rather than reading it as a real failure
53
+ - An empty print on a zero exit means at least one check really failed, so stop and report it with its URL
54
+ - Otherwise run `gh run rerun <id> --failed` once per printed id, then start the watch again from `### Watching CI`
55
+ - Rerun once per ship. A second `failing` wake on the same cancelled runs stops and reports them as cancelled, naming each URL.
@@ -41,7 +41,7 @@ Does not govern:
41
41
  ## Headings
42
42
 
43
43
  - H1 for document title, H2 for main sections, H3 for subsections, and no H4
44
- - Use sentence case for every heading. The audit reports a heading or a frontmatter `title` with a capitalized word after the first.
44
+ - Use sentence case for every heading. The audit reports a heading or `title` with a capital after the first word, past any task phase label.
45
45
  - Proper nouns and product names keep their casing, whatever the audit reports
46
46
  - Shape a record as H2 per section and H3 per item a reader looks up by name. Use a table only for a fixed-shape set.
47
47
 
@@ -133,7 +133,7 @@ A bullet's punctuation follows its sentence count, and a list opens unannounced.
133
133
  ## Punctuation
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
- - 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.
136
+ - Do not use a colon to join two clauses in a sentence. A colon follows a label of three words or fewer, or ends a line introducing a list.
137
137
  - The check misses a clause colon in a list of colon items, but it is wrong.
138
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.
139
139
 
@@ -173,8 +173,8 @@ When frontmatter carries a `title` or `description` used for catalog display:
173
173
  ## Links and file references
174
174
 
175
175
  - Use descriptive anchor text for links. Avoid `click here` or `read more`.
176
- - Wrap file references in backticks by default. Never repeat the path verbatim as a link label.
177
- - Use a labeled markdown link (`[label](path)`) only on rendered-for-human surfaces (`README.md`, `docs/`) and in an index file, whose rows exist to be followed
176
+ - Wrap file references in backticks by default. Never repeat the path verbatim as a link label, bar a Key changes code span.
177
+ - Use a labeled markdown link (`[label](path)`) only on human-read surfaces (`README.md`, `docs/`), an index file, and a pull request body's Key changes
178
178
  - A relative link resolves against the filesystem over the corpus the ban scan reads, and one carrying a placeholder in angle brackets, such as `<slug>`, is an exempt illustration
179
179
 
180
180
  ## Examples
@@ -218,6 +218,8 @@ Every outcome reaches the review with its proof named.
218
218
 
219
219
  - Name, for each outcome, the test or the command that proves it, as one bullet leading with the outcome and ending on the command in backticks.
220
220
  - Write a command a later session can run as given, from the project root. A description of what would be checked leaves the executing session to invent the check.
221
+ - Write each command as one command, with no pipe, `&&`, `;`, or command substitution.
222
+ - A permission mode can refuse a compound form, and a substituted check no longer matches the plan, so reach a filter through a verb's own argument or flag, or name a test that holds it.
221
223
  - Name the visual check where no test reaches, and say it is visual, so a reader can tell a proof from a look.
222
224
 
223
225
  ### Risks
@@ -138,7 +138,8 @@ gh api -X POST 'repos/{owner}/{repo}/issues/<number>/comments' -F body=@.canon/t
138
138
 
139
139
  After the push, read Watching CI in `${CLAUDE_SKILL_DIR}/../git-ship/SKILL.md` without invoking that skill, start its background watch, end the turn on its line, and run the rest of this step and Step 8 on the wake. Reuse a watch already running on this PR, since it reads the new tip.
140
140
 
141
- - On the wake, stop on `conflicted` naming a rebase, on a `reason` naming it, and on a timeout reporting CI unsettled
141
+ - On the wake, stop on `conflicted` naming a rebase, and on a timeout reporting CI unsettled
142
+ - Route a `reason` through the refused reads in `${CLAUDE_SKILL_DIR}/../git-ship/references/ci-watch.md`, which re-read a transient one once and stop on a permanent one naming it
142
143
  - If any check fails, skip the edit and report the failing check so it can be fixed first
143
144
 
144
145
  When every finding is addressed and the state is `passing`, append the closing confirmation to the reply file Step 6 posted, so the thread carries one terminal state rather than a second comment. A rebase-only run appends the line `${CLAUDE_SKILL_DIR}/references/rebase-only.md` gives instead.
@@ -16,11 +16,12 @@ Branch on the record rather than on the exit code, which a shell function wrappi
16
16
  | Field | Reading |
17
17
  | ------------ | -------------------------------------------------------------------------------------- |
18
18
  | `unmet` | a `should-fix` finding under the `**PR body**` block the stale box takes, one per path |
19
+ | `mislinked` | a `should-fix` finding under the same block, one per link, since it opens another file |
19
20
  | `unnamed` | read for yourself and raise nothing off the count |
20
21
  | `unresolved` | reported nowhere, since it was judged in neither direction |
21
22
  | `incidental` | reported nowhere, since it owes no bullet |
22
23
 
23
- Each `unmet` finding quotes the `preview` bullet and the `head`.
24
+ Each `unmet` finding quotes the `preview` bullet and the `head`. The exit is 2 for a `mislinked` entry too, so a body exiting 2 with `unmet` empty is not clean.
24
25
 
25
26
  ### Before filing an unmet path
26
27
 
@@ -9,7 +9,7 @@ description: What the one warm control session owns, why it plans and dispatches
9
9
 
10
10
  Without this skill, the control session builds or merges, and the cross-feature picture goes with it. Plans go out unread against each other and a moved tree, the queue empties or fills with whatever is oldest, a parked row's blocker is never re-tested, and a second orchestrator writes a board neither can read.
11
11
 
12
- The channel fails at every hand-off. A finding or a reply stays in a thread nobody re-reads, a dispatch launches a branch other than the one the check cleared, a wave's cost grows unweighed, a report buries the human's decision, and a compaction loses the way back in.
12
+ The channel fails at every hand-off. A finding or a reply stays in a thread nobody re-reads, a dispatch launches a branch other than the one the check cleared, a wave's cost grows unweighed, a report buries the human's decision, a compaction loses the way back in, and a wave a usage limit stopped reads as finished.
13
13
 
14
14
  ## Must
15
15
 
@@ -42,7 +42,7 @@ The human asks for each side of a compaction, and this skill reads the runbook.
42
42
 
43
43
  - On a request to write the handoff, follow `${CLAUDE_SKILL_DIR}/references/orchestrator-handoff.md`. It sends the generic half to `canon:session-map` and adds decisions taken under delegated authority.
44
44
  - That map follows `${CLAUDE_SKILL_DIR}/../session-map/references/session.md`. Write nothing to the handoff that the board, a task file, or a groundwork folder already carries.
45
- - Hand launch: `${CLAUDE_SKILL_DIR}/references/orchestrator-hand-launch.md`
45
+ - Hand launch: `${CLAUDE_SKILL_DIR}/references/orchestrator-hand-launch.md`. Limit stops, on request or on this session's first turn after: `${CLAUDE_SKILL_DIR}/references/orchestrator-limit-stops.md`.
46
46
  - A lesson about how to work goes to the owning task's Findings, as a bold-labeled line per `${CLAUDE_SKILL_DIR}/../task-board/references/tasks.md`
47
47
  - On a request to resume after a compaction, read `${CLAUDE_SKILL_DIR}/references/orchestrator-resume.md`, which reads that file back with the board and the groundwork behind the live work
48
48
 
@@ -56,7 +56,7 @@ A board that is not moving is a third such moment. On a request to re-test the p
56
56
 
57
57
  That routing lives in this body and this skill is user-invoked, so a session that has dropped the body routes nothing and the request lands as ordinary conversation. Approaching a compaction, when the handoff matters, is when a long session is likeliest to have dropped it.
58
58
 
59
- Re-invoke `/canon:role-orchestrator` first whenever the session has run long or the ask goes unanswered. The three runbooks sit at `references/orchestrator-handoff.md`, `references/orchestrator-resume.md`, and `references/orchestrator-parked.md`, so a person who knows their plugin root can follow one without this skill loaded.
59
+ Re-invoke `/canon:role-orchestrator` first whenever the session has run long or the ask goes unanswered. The runbooks sit under `references/`, so a person who knows their plugin root can follow one without this skill loaded.
60
60
 
61
61
  ## Output
62
62
 
@@ -24,6 +24,8 @@ The `Controller:` line replaces the background template's message clauses, which
24
24
 
25
25
  The first wave carries the worker. The wave after its pull request opens carries the reviewer alone.
26
26
 
27
+ Once the operator confirms a worker launched, run `canon tasks promote <stem>`, the same call a background launch makes. Nothing else moves a hand-launched row out of `## Up next`.
28
+
27
29
  ````markdown
28
30
  # Dispatch prompts
29
31
 
@@ -18,7 +18,7 @@ Read the threshold off `review-pr`, which states it once and governs the heading
18
18
 
19
19
  Read the pull request's own draft flag rather than the state a worker reports, and report what the read returned and when rather than the state alone. `canon pr head --help` states why a reported field can lag and say nothing about it.
20
20
 
21
- The flag settles the question only once the worker's chain has run its undo, and nothing marks that moment. A read taken between the pull request opening and that call sees a ready pull request about to become a draft, and once told an operator the opposite of what the worker had said.
21
+ A chain's create drafts at once, so its pull request reads draft from the first read. Before a lift or a person readies it, a ready read means the create missed and the fallback mark has yet to run or has failed, so report it as that rather than as a settled state.
22
22
 
23
23
  ## The rebase handback
24
24
 
@@ -0,0 +1,47 @@
1
+ ---
2
+ title: Orchestrator limit stops runbook
3
+ description: Finding the sessions a usage limit stopped through the roster, and resuming each from its own record with one fixed message
4
+ ---
5
+
6
+ A usage limit stops every session on the account at once, and each one then reads `idle`, `shell`, or `waiting` as though it had finished. Run this pass on the operator's ask, or on this session's own first turn after a limit stop, once the limit has lifted.
7
+
8
+ ## Finding the rows
9
+
10
+ Read the roster and keep what this session can reach.
11
+
12
+ ```bash
13
+ canon sessions list --stopped --json
14
+ ```
15
+
16
+ - Keep this repository's rows and report the rest to the operator, since `session-relay` refuses a send across repositories
17
+ - Report `stopsUnread` when it is above zero, since those rows may be stopped and nothing will message them
18
+ - Report `foreignDead` when it is above zero, since this reader could not check those sessions at all
19
+ - Find each row's task with `grep -l <sessionId> .canon/tasks/*.md` at the main worktree root, since `canon tasks session` writes the id there
20
+ - Report a refusal of the flag as an older CLI behind the plugin, never as an empty list
21
+
22
+ ### What the watch cannot tell you
23
+
24
+ `orchestrator-watch.md` reads a worker gone idle as finished, so a limit-stopped worker reports that way there. Its `WORKER-STOPPED` line means a long `waiting` row and says nothing about a limit, so read the `stopped` field in full on every row this pass decides.
25
+
26
+ ## Sending
27
+
28
+ Each row takes the same message with its own record filled in.
29
+
30
+ ```plaintext
31
+ The usage limit lifted. Re-read <record> and continue. Re-run any command whose result you never read.
32
+ ```
33
+
34
+ The last sentence covers a session stopped between starting a command and reading what came back, such as a reviewer stopped mid-lift.
35
+
36
+ Fill `<record>` by the row's role, read off its name. A row with a task and a feature branch whose name states no role is a hand-launched worker.
37
+
38
+ | Row | `<record>` |
39
+ | -------- | ---------------------------------------------------------------------------------- |
40
+ | Worker | The plan its task links |
41
+ | Reviewer | The pull request its name carries, through `canon pr review-state <number> --json` |
42
+ | Planner | Its task |
43
+ | No task | Its branch, and list the row to the operator |
44
+
45
+ Send each row the message once through `canon:session-relay`, then run the roster read again.
46
+
47
+ Never send a row the message twice in one pass. A row still reading stopped on the second read either has not drained the message or stopped again, and a new stop can carry the same reset text as the one before. Report it with its reset to the operator and hold the rest of the wave.
@@ -18,9 +18,11 @@ Sweep the board as orchestrator after merging. Run this once per batch of merges
18
18
 
19
19
  Run `canon worktrees list` after the pull, the first point at which the merges this sweep followed are readable. It owns the reclaim rule and removes nothing, so read its rows and act on the ones you mean to. Never substitute a git ancestry check for it.
20
20
 
21
- Run `canon tasks validate` once the board is rewritten and before reporting it. It reports and never writes, so fix each row it names and run it again.
21
+ Run `canon tasks validate --live --json` once the board is rewritten and before reporting it. It reports and never writes, so fix each row it names and run it again.
22
22
 
23
23
  - It resolves every plan pointer and accounts every task file against the board and the backlog both
24
+ - `--live` reports a row outside `## Run now` that a live session or an open pull request holds, so run `canon tasks promote <stem>` on it
25
+ - Report a source the record's `live` field reads as `unread`, since that half of the check did not run
24
26
  - It reports the paths `## Run now` rows share as a candidate list for the conflict check, and re-takes the two blocker kinds a command can settle
25
27
  - A finding it reports is a board defect rather than a task finding, so it goes nowhere but the board
26
28
 
@@ -28,7 +28,7 @@ This body states the role, the boundaries, and the channel, and starts no step o
28
28
 
29
29
  The draft mark holds the merge until the reviews close, so this session preserves it.
30
30
 
31
- - The ship chain marks the pull request a draft, and only `canon pr lift` or the operator un-marks it
31
+ - The ship chain opens the pull request as a draft with a fallback mark behind the create, and only `canon pr lift` or the operator un-marks it
32
32
  - Report a flag reading ready once and leave it. It passed every gate the verb reads or a person readied it, which GitHub requires before a merge, so re-drafting fights them.
33
33
  - Leave the lift to the reviewers, who run `canon pr lift` on their close, with the controlling session as fallback
34
34
  - Refuse an instruction to ready the pull request directly, whoever sends it, since a direct ready skips every gate the verb reads
@@ -65,6 +65,7 @@ Run `canon tasks next-label --claim --json` and take its `label` field. The verb
65
65
 
66
66
  Write `.canon/tasks/vXX.Y-<slug>.md` under the claimed label, reading the file first if a session ever stubbed it, following the format in `${CLAUDE_SKILL_DIR}/references/tasks.md`. Write it immediately, since Claude Code's tool permission dialog is the confirmation gate.
67
67
 
68
+ - Double any apostrophe in the title before writing it inside the single-quoted `title:` value, since a lone one fails the YAML parse
68
69
  - Include a link line only when the file or folder it names exists, since a link to an unwritten plan is the broken pointer the archive rules prevent
69
70
  - Write `Plan:`, `Groundwork:`, and `Intake:` as markdown links relative to `.canon/tasks/`, as in `Plan: [feature-<slug>](../plans/feature-<slug>.md)`
70
71
  - Leave `Issue:` a bare `#NNN`. The older bare-path form still parses, but it leaves the board in two shapes for every later reader.
@@ -76,6 +76,7 @@ description: Record what a target installed and report the delta against the too
76
76
  ```
77
77
 
78
78
  - `title`: the phase label and the task title, matching the H1. Quote it, since a leading `vX.Y:` reads as a key to a YAML parser.
79
+ - Double any apostrophe inside the single quotes, as in `'vX.Y: Trim the Board''s ad page'`. A lone one fails the parse, which `canon tasks validate` reports as `frontmatter-invalid`.
79
80
  - `description`: what the task achieves, in one line. A session reads this in the index to decide whether to open the file.
80
81
 
81
82
  ## File format
@@ -57,7 +57,7 @@ Run independent features in parallel, one worktree and one session each. See [pa
57
57
 
58
58
  On a mature stack, approve the plan and invoke `canon:auto-ship` to implement, verify, and ship in one session.
59
59
 
60
- - It stops on a verify failure after one fix attempt, or on a hook failure, and you resume with `/git-ship`
60
+ - It stops on a verify failure after one fix attempt, or on a hook failure, and you resume with `/git-ship --draft`, which keeps the pull request a draft
61
61
  - Skip it for auth, migrations, security-sensitive changes, or an uncertain plan
62
62
 
63
63
  ### Review
@@ -55,7 +55,8 @@ Merge order matters only when two branches touch the same file. Expect to rebase
55
55
 
56
56
  - A row's cell count must match its header, and a `## Needs a plan` row stating its position is checked against where it sits
57
57
  - Every plan pointer resolves, every task file is named once, and every ready row names its files
58
- - A parked row is reported when its cited task reached the trunk, whether the cell links it or names its bare label
58
+ - A task file whose frontmatter block will not parse as YAML is reported with the parser's message, as with a lone apostrophe inside the single-quoted title
59
+ - A parked row is reported when its cited task reached the trunk, whether the cell links it or names its bare label, and with `--live` when a live session or an open pull request holds it
59
60
  - A group header must be the columns the group owns, a `Touches` cell must match its plan, and a `Waiting on` cell renders at most 120 characters
60
61
 
61
62
  `canon:task-board` creates a task file, and `canon:task-complete` marks outcomes. The `post-merge` hook archives the task a merged pull request closed, with its plan, and a `post-rewrite` hook does the same for a rebase pull. Both archive a closed task whose merge carried a PR number, and a slice merged before then moves only its own plan.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.78.0",
4
+ "version": "5.79.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": {
@@ -113,6 +113,7 @@ import {
113
113
  type LocalRefusal,
114
114
  type LocalRunner,
115
115
  } from '@/pr/local'
116
+ import { linkKeyChanges } from '@/pr/links'
116
117
  import { KEY_CHANGES } from '@/pr/paths'
117
118
  import {
118
119
  findDeployWorkflow,
@@ -161,6 +162,7 @@ const GH_TIMEOUT_MS = 30_000
161
162
  const UNNAMED_PRINT_LIMIT = 10
162
163
 
163
164
  interface KeyChangesOptions {
165
+ readonly link?: boolean
164
166
  readonly body?: string
165
167
  readonly base?: string
166
168
  readonly root?: string
@@ -279,7 +281,7 @@ const LOCAL_REFUSALS: Record<
279
281
  'Nothing listening inside this worktree served an HTML page. Start the dev server here and re-run to get a link.',
280
282
  unhealthy:
281
283
  'A page answered inside this worktree, but a health path it declares failed. Start what the page reaches and re-run to get a link.',
282
- 'invalid-config': `${LOCAL_CONFIG_REL} cannot be read, so no health path was checked and no link is given. Fix or delete it and re-run.`,
284
+ 'invalid-config': `${LOCAL_CONFIG_REL} cannot be read, so no health path was checked and no link is given. Fix or delete it and re-run. health takes paths starting with /, and page-only takes folders relative to the repository.`,
283
285
  'no-listener-reader':
284
286
  'This machine offers neither lsof nor /proc, so no listening socket could be read. Nothing is posted.',
285
287
  'gh-failed':
@@ -503,13 +505,17 @@ export function register(program: Command): void {
503
505
  .helpOption('-h, --help', 'Show this help message')
504
506
  .option(
505
507
  '--body <path>',
506
- 'Read the body from a file rather than the API, ignoring any number',
508
+ 'Read the body from a file rather than the API, ignoring any number except the one --link needs',
507
509
  )
508
510
  .option(
509
511
  '--base <ref>',
510
512
  'Far side of the range when --body supplies the body',
511
513
  )
512
514
  .option('--root <path>', 'Repository to read, defaulting to the cwd')
515
+ .option(
516
+ '--link',
517
+ `Print the body with each ## ${KEY_CHANGES} path linked to its diff, needing a number`,
518
+ )
513
519
  .option('--json', 'Add a machine-readable record on stdout')
514
520
  .addHelpText(
515
521
  'after',
@@ -538,6 +544,13 @@ export function register(program: Command): void {
538
544
  'points the change at a locative the path does not name. Read the bullet on',
539
545
  "the record's preview before filing an unmet path as a stale claim.",
540
546
  '',
547
+ '--link prints the body and nothing else, with every path the section',
548
+ 'names that the diff changes written as a link to its row in the Files',
549
+ 'tab. The anchor is `diff-` and the SHA-256 of the path. Pass the pull',
550
+ 'request number, since the link needs it. It also rewrites the anchor',
551
+ 'of a link opening another file, and a plain run reports that link as',
552
+ "mislinked when its label is a changed path and its anchor hashes another's.",
553
+ '',
541
554
  'Refusals, read off `reason`:',
542
555
  ' no-section the body carries no `## Key changes` heading, so it',
543
556
  ' claims nothing',
@@ -555,14 +568,18 @@ export function register(program: Command): void {
555
568
  ' gh-missing `gh` is not on the path, so no pull request could be',
556
569
  ' resolved',
557
570
  ' gh-failed `gh` could not answer for this repository or branch',
571
+ ' no-number --link was given with no pull request number',
572
+ ' no-repo-url --link could not read the repository address',
573
+ ' link-json --link was given with --json, though it prints the body',
558
574
  '',
559
575
  'Exit codes:',
560
- ' 0 every claimed path is in the diff',
576
+ ' 0 every claimed path is in the diff, and every link opens its own file',
561
577
  ' 1 refused, with the reason on stderr or in the JSON record',
562
- ' 2 at least one claimed path is absent from the diff',
578
+ ' 2 a claimed path is absent from the diff, or a link opens another file',
563
579
  '',
564
580
  'Examples:',
565
581
  ' canon pr key-changes',
582
+ ' canon pr key-changes 1265 --link --body .canon/tmp/body.md',
566
583
  ' canon pr key-changes 1265 --json',
567
584
  ' canon pr key-changes --body .canon/tmp/body.md --base origin/main',
568
585
  '',
@@ -1301,6 +1318,15 @@ export function register(program: Command): void {
1301
1318
  "refusal names the lowest page port's first failing path. An absent",
1302
1319
  'file or an empty list checks the page alone.',
1303
1320
  '',
1321
+ 'A page served by a static server answers none of those paths, so',
1322
+ 'page-only = ["web"] lists repository-relative folders whose',
1323
+ 'listeners skip them and are found on the page probe alone. The match',
1324
+ "reads the listener's working directory on whole path segments, so web",
1325
+ 'does not cover webapp, and a server started from the root with a',
1326
+ 'working-directory flag still takes the health check. Keep health',
1327
+ 'beside page-only until the binary a project runs carries this key,',
1328
+ 'since an older one refuses a file holding page-only alone.',
1329
+ '',
1304
1330
  'A process in a sibling worktree, in the main checkout seen from a',
1305
1331
  'linked worktree, or in a linked worktree seen from the main checkout',
1306
1332
  'is never reported, since its server shows another branch. Sockets are',
@@ -1326,8 +1352,9 @@ export function register(program: Command): void {
1326
1352
  ' unhealthy a page answered but a declared health path did',
1327
1353
  ' not, with `port`, `path`, and `status`, which',
1328
1354
  ' is null on a timeout',
1329
- ' invalid-config canon/config/local.toml is not valid TOML, or',
1330
- ' health is not a list of paths starting with /',
1355
+ ' invalid-config canon/config/local.toml is not valid TOML,',
1356
+ ' health is not a list of paths starting with /,',
1357
+ ' or page-only is not a list of relative folders',
1331
1358
  ' removed --remove dropped the line',
1332
1359
  ' no-comment --remove found no marked comment, a no-op',
1333
1360
  ' no-line --remove found no address line, a no-op',
@@ -1865,6 +1892,8 @@ async function runKeyChanges(
1865
1892
  const root = resolve(opts.root ?? process.cwd())
1866
1893
  const emitJson = opts.json ?? false
1867
1894
 
1895
+ if (opts.link === true) return runKeyChangesLink(number, opts, root)
1896
+
1868
1897
  intro('canon pr key-changes')
1869
1898
 
1870
1899
  const source =
@@ -1949,6 +1978,14 @@ async function runKeyChanges(
1949
1978
  for (const claim of report.unresolved) logInfo(claim.path)
1950
1979
  }
1951
1980
 
1981
+ if (report.mislinked.length > 0) {
1982
+ logStep('Mislinked')
1983
+ logWarn(
1984
+ `${plural(report.mislinked.length, 'link')} open another file's diff than the path they name. Re-run with --link to print the body with them corrected.`,
1985
+ )
1986
+ for (const link of report.mislinked) logWarn(link.path)
1987
+ }
1988
+
1952
1989
  outro()
1953
1990
 
1954
1991
  if (emitJson) {
@@ -1966,11 +2003,71 @@ async function runKeyChanges(
1966
2003
  incidental: report.incidental,
1967
2004
  unresolved: report.unresolved,
1968
2005
  evidenceUnread: report.evidenceUnread,
2006
+ mislinked: report.mislinked,
1969
2007
  })}\n`,
1970
2008
  )
1971
2009
  }
1972
2010
 
1973
- return report.unmet.length === 0 ? 0 : 2
2011
+ return report.unmet.length === 0 && report.mislinked.length === 0 ? 0 : 2
2012
+ }
2013
+
2014
+ /**
2015
+ * Prints the body with each changed path in `## Key changes` linked to its
2016
+ * diff, and nothing else, so stdout pipes straight into a body file.
2017
+ *
2018
+ * Skips the frame `intro` opens, which would land in the piped body. Refusals
2019
+ * keep the framed shape on stdout like every other mode.
2020
+ */
2021
+ async function runKeyChangesLink(
2022
+ number: string | undefined,
2023
+ opts: KeyChangesOptions,
2024
+ root: string,
2025
+ ): Promise<number> {
2026
+ const emitJson = opts.json ?? false
2027
+
2028
+ if (emitJson) {
2029
+ return refuseWith(
2030
+ 'link-json',
2031
+ '--link prints the body itself, so it takes no --json record.',
2032
+ emitJson,
2033
+ root,
2034
+ )
2035
+ }
2036
+
2037
+ const source =
2038
+ opts.body === undefined
2039
+ ? await readFromApi(root, number)
2040
+ : await readFromFile(root, process.cwd(), opts.body, opts.base)
2041
+ if (source.kind === 'refused') return refuse(source.reason, emitJson, root)
2042
+
2043
+ const pull = number ?? source.source.number?.toString()
2044
+ if (pull === undefined) {
2045
+ return refuseWith(
2046
+ 'no-number',
2047
+ 'The link needs the pull request number, so pass it as the argument.',
2048
+ emitJson,
2049
+ root,
2050
+ )
2051
+ }
2052
+
2053
+ const url = await readRepoUrl(root)
2054
+ if (url === undefined) {
2055
+ return refuseWith(
2056
+ 'no-repo-url',
2057
+ 'gh could not read the repository address, so no link was built.',
2058
+ emitJson,
2059
+ root,
2060
+ )
2061
+ }
2062
+
2063
+ process.stdout.write(
2064
+ linkKeyChanges(
2065
+ source.source.body,
2066
+ source.source.changed,
2067
+ `${url}/pull/${pull}/files`,
2068
+ ),
2069
+ )
2070
+ return 0
1974
2071
  }
1975
2072
 
1976
2073
  type IdentityRead =
@@ -2494,14 +2591,18 @@ interface PullFiles {
2494
2591
  readonly present: ReadonlyMap<string, boolean>
2495
2592
  /** Each path the pull request removed, which a deploy's push filter still counts. */
2496
2593
  readonly removed: readonly string[]
2594
+ /** Each renamed path mapped to the path it sat at before the pull request. */
2595
+ readonly renamedFrom: ReadonlyMap<string, string>
2497
2596
  }
2498
2597
 
2499
2598
  /**
2500
2599
  * The paths a pull request changed, read through the paginated files endpoint
2501
2600
  * so the set is the same from any checkout and never capped at the first
2502
- * view. `renamed` and `copied` count as added, since the new path has no
2503
- * counterpart at the merge base. Returns undefined when the read fails, which
2504
- * refuses rather than rendering a short set.
2601
+ * view. `copied` counts as added, since a copy leaves its source in place and
2602
+ * has no single base. `renamed` counts as added in `present` too, so the
2603
+ * changed set stays the same, and `renamedFrom` carries the old path of each
2604
+ * rename for a reader that compares against the base. Returns undefined when
2605
+ * the read fails, which refuses rather than rendering a short set.
2505
2606
  */
2506
2607
  async function listPullFiles(
2507
2608
  cwd: string,
@@ -2512,18 +2613,22 @@ async function listPullFiles(
2512
2613
  '--paginate',
2513
2614
  `repos/{owner}/{repo}/pulls/${number}/files`,
2514
2615
  '--jq',
2515
- '.[] | [.status, .filename] | @tsv',
2616
+ '.[] | [.status, .filename, (.previous_filename // "")] | @tsv',
2516
2617
  ])
2517
2618
  if (stdout === null) return undefined
2518
2619
  const present = new Map<string, boolean>()
2519
2620
  const removed: string[] = []
2621
+ const renamedFrom = new Map<string, string>()
2520
2622
  for (const line of stdout.split('\n').filter(Boolean)) {
2521
- const [status, path] = line.split('\t')
2623
+ const [status, path, previous] = line.split('\t')
2522
2624
  if (status === undefined || path === undefined) continue
2523
2625
  if (status === 'removed') removed.push(path)
2524
2626
  else present.set(path, status !== 'modified' && status !== 'changed')
2627
+ if (status === 'renamed' && previous !== undefined && previous !== '') {
2628
+ renamedFrom.set(path, previous)
2629
+ }
2525
2630
  }
2526
- return { present, removed }
2631
+ return { present, removed, renamedFrom }
2527
2632
  }
2528
2633
 
2529
2634
  function everyChangedPath(files: PullFiles): string[] {
@@ -2671,13 +2776,14 @@ async function measureItem(
2671
2776
  head: string,
2672
2777
  ): Promise<MeasuredItem> {
2673
2778
  const headRead = await readImage(cwd, item.path, head)
2779
+ const basePath = item.basePath ?? item.path
2674
2780
  const baseRead =
2675
2781
  item.added || base === undefined
2676
2782
  ? undefined
2677
- : await readImage(cwd, item.path, base)
2783
+ : await readImage(cwd, basePath, base)
2678
2784
  const unread = [
2679
2785
  ...(headRead.kind === 'failed' ? [`head:${item.path}`] : []),
2680
- ...(baseRead?.kind === 'failed' ? [`base:${item.path}`] : []),
2786
+ ...(baseRead?.kind === 'failed' ? [`base:${basePath}`] : []),
2681
2787
  ]
2682
2788
  const headBytes = headRead.kind === 'read' ? headRead.bytes : undefined
2683
2789
  const baseBytes = baseRead?.kind === 'read' ? baseRead.bytes : undefined
@@ -2691,6 +2797,9 @@ async function measureItem(
2691
2797
  ...(headBytes !== undefined &&
2692
2798
  baseBytes !== undefined && {
2693
2799
  withinTolerance: isWithinTolerance(baseBytes, headBytes),
2800
+ ...(item.basePath !== undefined && {
2801
+ identical: Buffer.from(baseBytes).equals(headBytes),
2802
+ }),
2694
2803
  }),
2695
2804
  },
2696
2805
  unread,
@@ -2811,6 +2920,7 @@ async function runEvidence(
2811
2920
  const grouped = await groupEvidence(
2812
2921
  [...pullFiles.present.keys()],
2813
2922
  async (path) => pullFiles.present.get(path) === false,
2923
+ pullFiles.renamedFrom,
2814
2924
  )
2815
2925
  const uncaptured = readUncaptured(
2816
2926
  everyChangedPath(pullFiles),
@@ -3412,6 +3522,7 @@ async function runLocal(opts: LocalOptions): Promise<number> {
3412
3522
  worktree,
3413
3523
  machineLocalRunner(),
3414
3524
  declaration.kind === 'paths' ? declaration.paths : [],
3525
+ declaration.pageOnly,
3415
3526
  )
3416
3527
  if (result.kind === 'refused' && result.reason === 'unhealthy') {
3417
3528
  const { port, path, status } = result
@@ -4028,6 +4139,18 @@ async function readRepoName(root: string): Promise<string | undefined> {
4028
4139
  }
4029
4140
  }
4030
4141
 
4142
+ /** The repository's web address off the REST repository read, for the same reason as `readRepoName`. */
4143
+ async function readRepoUrl(root: string): Promise<string | undefined> {
4144
+ const row = await gh(root, ['api', 'repos/{owner}/{repo}'])
4145
+ if (row === null) return undefined
4146
+ try {
4147
+ const url = (JSON.parse(row) as { html_url?: unknown }).html_url
4148
+ return typeof url === 'string' && url !== '' ? url : undefined
4149
+ } catch {
4150
+ return undefined
4151
+ }
4152
+ }
4153
+
4031
4154
  async function runFrames(
4032
4155
  number: string | undefined,
4033
4156
  opts: FramesOptions,