@erclx/canon 5.70.0 → 5.72.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 (36) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -1
  4. package/claude/skills/canon-feedback/SKILL.md +27 -2
  5. package/claude/skills/canon-feedback/references/template.md +22 -0
  6. package/claude/skills/git-pr/references/task-number.md +13 -0
  7. package/claude/skills/markdown-craft/references/markdown.md +1 -0
  8. package/claude/skills/plan-intake-answer/REQUIREMENT.md +4 -3
  9. package/claude/skills/plan-intake-answer/SKILL.md +35 -2
  10. package/claude/skills/review-ui/SKILL.md +1 -1
  11. package/claude/skills/review-ui/references/tick.md +17 -5
  12. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +1 -0
  13. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -20
  14. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +10 -0
  15. package/claude/skills/role-orchestrator/references/orchestrator-talk-launch.md +30 -0
  16. package/claude/skills/role-orchestrator/scripts/poll.ts +1 -1
  17. package/claude/skills/role-reviewer/SKILL.md +4 -0
  18. package/claude/skills/task-board/references/tasks-record-lines.md +40 -0
  19. package/claude/skills/task-board/references/tasks.md +4 -8
  20. package/claude/skills/ui-film/references/pr-clip.md +26 -2
  21. package/package.json +1 -1
  22. package/src/commands/feedback.ts +83 -3
  23. package/src/commands/markdown.ts +6 -1
  24. package/src/commands/pr.ts +66 -21
  25. package/src/commands/tasks.ts +133 -0
  26. package/src/feedback/body.ts +50 -0
  27. package/src/feedback/github-format.ts +77 -0
  28. package/src/feedback/github.ts +51 -12
  29. package/src/markdown/colons.ts +102 -16
  30. package/src/pr/carry.ts +64 -0
  31. package/src/pr/evidence.ts +1 -1
  32. package/src/pr/lift.ts +29 -12
  33. package/src/pr/review-state.ts +2 -0
  34. package/src/tasks/archive.ts +54 -1
  35. package/src/tasks/record.ts +118 -0
  36. package/src/tasks/validate.ts +37 -0
@@ -2,34 +2,73 @@ import { execa } from 'execa'
2
2
  import { gitEnv } from '@/git/env'
3
3
  import { PROJECT_ROOT } from '@/roots/project'
4
4
  import {
5
+ buildCommentArgs,
5
6
  buildIssueArgs,
7
+ buildIssueReadArgs,
6
8
  failureDetail,
9
+ parseIssueRead,
7
10
  type CreateIssueOptions,
8
11
  type CreateIssueResult,
12
+ type IssueFailure,
13
+ type IssueRead,
9
14
  } from '@/feedback/github-format'
10
15
 
11
16
  const GH_TIMEOUT_MS = 30_000
12
17
 
13
- export async function createGithubIssue(
14
- opts: CreateIssueOptions,
15
- ): Promise<CreateIssueResult> {
18
+ /**
19
+ * Runs `gh` and returns its trimmed stdout, or the failure the operator reads.
20
+ * See src/worktrees/reclaim.ts for why gh needs the stripped environment.
21
+ */
22
+ async function runGh(
23
+ args: string[],
24
+ ): Promise<{ ok: true; stdout: string } | IssueFailure> {
16
25
  if (Bun.which('gh') === null) return { ok: false, reason: 'missing-binary' }
17
26
  try {
18
- // See src/worktrees/reclaim.ts for why gh needs the stripped environment.
19
- const result = await execa('gh', buildIssueArgs(opts), {
27
+ const result = await execa('gh', args, {
20
28
  cwd: PROJECT_ROOT,
21
29
  timeout: GH_TIMEOUT_MS,
22
30
  env: gitEnv(),
23
31
  extendEnv: false,
24
32
  })
25
- const url = result.stdout.trim()
26
- if (url) return { ok: true, url }
27
- return {
28
- ok: false,
29
- reason: 'command-failed',
30
- detail: 'gh exited zero and printed no issue URL',
31
- }
33
+ return { ok: true, stdout: result.stdout.trim() }
32
34
  } catch (error) {
33
35
  return { ok: false, reason: 'command-failed', detail: failureDetail(error) }
34
36
  }
35
37
  }
38
+
39
+ async function runForUrl(
40
+ args: string[],
41
+ emptyDetail: string,
42
+ ): Promise<CreateIssueResult> {
43
+ const result = await runGh(args)
44
+ if (!result.ok) return result
45
+ if (result.stdout) return { ok: true, url: result.stdout }
46
+ return { ok: false, reason: 'command-failed', detail: emptyDetail }
47
+ }
48
+
49
+ export function createGithubIssue(
50
+ opts: CreateIssueOptions,
51
+ ): Promise<CreateIssueResult> {
52
+ return runForUrl(
53
+ buildIssueArgs(opts),
54
+ 'gh exited zero and printed no issue URL',
55
+ )
56
+ }
57
+
58
+ export async function readFeedbackIssue(
59
+ issue: number,
60
+ ): Promise<IssueRead | IssueFailure> {
61
+ const result = await runGh(buildIssueReadArgs(issue))
62
+ if (!result.ok) return result
63
+ return parseIssueRead(result.stdout)
64
+ }
65
+
66
+ export function postIssueComment(
67
+ issue: number,
68
+ body: string,
69
+ ): Promise<CreateIssueResult> {
70
+ return runForUrl(
71
+ buildCommentArgs(issue, body),
72
+ 'gh exited zero and printed no comment URL',
73
+ )
74
+ }
@@ -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({
@@ -0,0 +1,64 @@
1
+ import { DESIGN_FOLDER, isEvidencePath } from '@/pr/evidence'
2
+ import type { ChangedRead } from '@/pr/lift'
3
+ import { DEFAULT_PAINTED } from '@/pr/painted'
4
+ import { coversHead } from '@/pr/review-scope'
5
+
6
+ /**
7
+ * The globs no checklist box can observe: tests, stories, snapshots, and
8
+ * markdown where a project keeps docs. The rule lists what is inert rather than
9
+ * what is driven, so a kind nobody listed, such as a `.json` data file, fails
10
+ * closed. Markdown elsewhere, such as an Astro page or content entry, renders,
11
+ * so it is driven like `.mdx`.
12
+ */
13
+ export const DEFAULT_INERT: readonly string[] = [
14
+ ...DEFAULT_PAINTED.unpainted,
15
+ '*.md',
16
+ 'canon/**/*.md',
17
+ '.claude/**/*.md',
18
+ '.github/**/*.md',
19
+ 'docs/**/*.md',
20
+ ]
21
+
22
+ /**
23
+ * An evidence capture and a design record are inert too, the two paths
24
+ * `readUncaptured` skips, since a recapture or a design note changes no
25
+ * behavior a box drives.
26
+ */
27
+ export function isInert(path: string): boolean {
28
+ return (
29
+ isEvidencePath(path) ||
30
+ path.startsWith(DESIGN_FOLDER) ||
31
+ DEFAULT_INERT.some((glob) => new Bun.Glob(glob).match(path))
32
+ )
33
+ }
34
+
35
+ /** Whether a UI verdict at one commit still holds at a later one. */
36
+ export type Carry =
37
+ | { readonly kind: 'covered' }
38
+ | { readonly kind: 'carried'; readonly carriedFrom: string }
39
+ | { readonly kind: 'moved'; readonly drivenPaths: readonly string[] }
40
+ | { readonly kind: 'not-ancestor' }
41
+ | { readonly kind: 'failed' }
42
+
43
+ /**
44
+ * Carries a verdict driven at `from` to `to` only across a gap that changed
45
+ * nothing a box drives. A box drives behavior, which lives in any source file
46
+ * and not only in a painted one, so every path outside the inert set moves the
47
+ * verdict. A `from` that `to` does not descend from never carries.
48
+ */
49
+ export async function readCarry(
50
+ readChanged: (from: string, to: string) => Promise<ChangedRead | undefined>,
51
+ from: string,
52
+ to: string,
53
+ ): Promise<Carry> {
54
+ if (coversHead(from, to)) return { kind: 'covered' }
55
+
56
+ const changed = await readChanged(from, to)
57
+ if (changed === undefined) return { kind: 'failed' }
58
+ if (!changed.isAncestor) return { kind: 'not-ancestor' }
59
+
60
+ const drivenPaths = changed.paths.filter((path) => !isInert(path))
61
+ return drivenPaths.length > 0
62
+ ? { kind: 'moved', drivenPaths }
63
+ : { kind: 'carried', carriedFrom: from }
64
+ }
@@ -607,7 +607,7 @@ export function readOwed(input: OwedInput): OwedStep[] {
607
607
  * Design references are redrawn rather than captured, so recapturing them
608
608
  * neither owes nor stands in for an evidence capture.
609
609
  */
610
- const DESIGN_FOLDER = 'canon/design/'
610
+ export const DESIGN_FOLDER = 'canon/design/'
611
611
 
612
612
  /**
613
613
  * The painted paths a pull request changed while no evidence image did, in a
package/src/pr/lift.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  resolveUiScope,
7
7
  type UiCounts,
8
8
  } from '@/pr/review-scope'
9
+ import { readCarry } from '@/pr/carry'
9
10
  import { DEFAULT_PAINTED, isPainted, type PaintedPatterns } from '@/pr/painted'
10
11
 
11
12
  /** How long `--wait` holds for a pending CI run, in seconds, when none is named. */
@@ -49,6 +50,8 @@ export interface GateResult {
49
50
  readonly carriedFrom?: string
50
51
  /** The painted paths that kept a UI close from carrying forward. */
51
52
  readonly paintedPaths?: readonly string[]
53
+ /** Every path a box can observe that kept a UI close from carrying forward. */
54
+ readonly drivenPaths?: readonly string[]
52
55
  }
53
56
 
54
57
  /** How an earlier commit sits against the head, and what changed since. */
@@ -125,6 +128,7 @@ export interface LiftRecord {
125
128
  readonly failed?: LiftRefusal
126
129
  readonly carriedFrom?: string
127
130
  readonly paintedPaths?: readonly string[]
131
+ readonly drivenPaths?: readonly string[]
128
132
  readonly isLifted: boolean
129
133
  /** Whether the caller can treat the pull request as ready afterwards. */
130
134
  readonly isOk: boolean
@@ -164,11 +168,13 @@ export function reviewGate(
164
168
  function carried(gates: readonly GateResult[]): {
165
169
  carriedFrom?: string
166
170
  paintedPaths?: readonly string[]
171
+ drivenPaths?: readonly string[]
167
172
  } {
168
173
  const ui = gates.find((gate) => gate.gate === 'ui')
169
174
  return {
170
175
  ...(ui?.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
171
176
  ...(ui?.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
177
+ ...(ui?.drivenPaths !== undefined && { drivenPaths: ui.drivenPaths }),
172
178
  }
173
179
  }
174
180
 
@@ -189,10 +195,11 @@ function droveNothing(counts: UiCounts | undefined): boolean {
189
195
  /**
190
196
  * Binds only where the evidence record carries a checklist to drive.
191
197
  *
192
- * A close behind the head still holds when it is an ancestor of the head and
193
- * no painted path changed since, since a push that paints nothing leaves the
194
- * checklist the reviewer drove as true as it was. A close that is not an
195
- * ancestor, which a force-push or a rebase leaves, never carries.
198
+ * A close behind the head still holds across the gap `readCarry` carries,
199
+ * one changing no path a box can observe. A box drives behavior, so a push to
200
+ * a non-painted source file moves the verdict too, and `drivenPaths` names
201
+ * every such path while `paintedPaths` keeps the painted subset. A close that
202
+ * is not an ancestor, which a force-push or a rebase leaves, never carries.
196
203
  */
197
204
  export async function uiGate(
198
205
  api: Pick<LiftApi, 'readChanged'>,
@@ -211,17 +218,27 @@ export async function uiGate(
211
218
  // Ahead of the head test so a close carried across commits is refused too.
212
219
  // A verdict whose count line did not parse reads as unchecked and goes on.
213
220
  if (droveNothing(scope.counts)) return fail('ui', 'ui-undriven')
214
- if (coversHead(scope.commit, head)) return pass('ui')
215
221
 
216
- const changed = await api.readChanged(scope.commit, head)
217
- if (changed === undefined) return fail('ui', 'gh-failed')
218
- if (!changed.isAncestor) return fail('ui', 'ui-behind')
222
+ const carry = await readCarry(
223
+ (from, to) => api.readChanged(from, to),
224
+ scope.commit,
225
+ head,
226
+ )
227
+ if (carry.kind === 'covered') return pass('ui')
228
+ if (carry.kind === 'failed') return fail('ui', 'gh-failed')
229
+ if (carry.kind === 'not-ancestor') return fail('ui', 'ui-behind')
230
+ if (carry.kind === 'carried') {
231
+ return { ...pass('ui'), carriedFrom: carry.carriedFrom }
232
+ }
219
233
 
220
- const paintedPaths = changed.paths.filter((path) => isPainted(path, painted))
221
- if (paintedPaths.length > 0) {
222
- return { ...fail('ui', 'ui-behind'), paintedPaths }
234
+ const paintedPaths = carry.drivenPaths.filter((path) =>
235
+ isPainted(path, painted),
236
+ )
237
+ return {
238
+ ...fail('ui', 'ui-behind'),
239
+ ...(paintedPaths.length > 0 && { paintedPaths }),
240
+ drivenPaths: carry.drivenPaths,
223
241
  }
224
- return { ...pass('ui'), carriedFrom: scope.commit }
225
242
  }
226
243
 
227
244
  function standingGate(standing: Standing): GateResult {
@@ -47,6 +47,7 @@ export interface UiState extends UiScope, FamilyState {
47
47
  readonly isBound: boolean
48
48
  readonly carriedFrom?: string
49
49
  readonly paintedPaths?: readonly string[]
50
+ readonly drivenPaths?: readonly string[]
50
51
  }
51
52
 
52
53
  export interface ReviewStateRecord {
@@ -119,6 +120,7 @@ async function readAt(
119
120
  isBound: hasChecklist,
120
121
  ...(ui.carriedFrom !== undefined && { carriedFrom: ui.carriedFrom }),
121
122
  ...(ui.paintedPaths !== undefined && { paintedPaths: ui.paintedPaths }),
123
+ ...(ui.drivenPaths !== undefined && { drivenPaths: ui.drivenPaths }),
122
124
  },
123
125
  },
124
126
  }
@@ -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