@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,11 +2,20 @@ import { Option, type Command } from 'commander'
2
2
  import {
3
3
  deriveDomainLabels,
4
4
  deriveTitle,
5
+ followUpProblem,
5
6
  missingField,
6
7
  missingFieldMessage,
7
8
  } from '@/feedback/body'
8
- import { createGithubIssue } from '@/feedback/github'
9
- import { issueFailureMessage } from '@/feedback/github-format'
9
+ import {
10
+ createGithubIssue,
11
+ postIssueComment,
12
+ readFeedbackIssue,
13
+ } from '@/feedback/github'
14
+ import {
15
+ commentFailureMessage,
16
+ issueFailureMessage,
17
+ issueReadFailureMessage,
18
+ } from '@/feedback/github-format'
10
19
  import { frameError, frameSuccess } from '@/cli/ui'
11
20
 
12
21
  function readStdin(): Promise<string> {
@@ -20,6 +29,61 @@ function readStdin(): Promise<string> {
20
29
  })
21
30
  }
22
31
 
32
+ /**
33
+ * Triage lists open `feedback` issues only, so a comment anywhere else is
34
+ * never read. Both checks run before the post, since a public comment cannot
35
+ * be taken back by a later refusal.
36
+ */
37
+ async function postFollowUp(issue: number, body: string): Promise<void> {
38
+ const problem = followUpProblem(body)
39
+ if (problem) {
40
+ frameError(problem)
41
+ process.exitCode = 1
42
+ return
43
+ }
44
+
45
+ // An absent gh can post nothing, so the block goes out for a hand paste. A
46
+ // failed read is a refusal to repair, since the number may name no issue.
47
+ const read = await readFeedbackIssue(issue)
48
+ if (!read.ok && read.reason === 'missing-binary') {
49
+ frameError(commentFailureMessage(read, issue))
50
+ process.stdout.write(`${body}\n`)
51
+ process.exitCode = 1
52
+ return
53
+ }
54
+ if (!read.ok) {
55
+ frameError(issueReadFailureMessage(read, issue))
56
+ process.exitCode = 1
57
+ return
58
+ }
59
+ if (read.state !== 'OPEN') {
60
+ frameError(
61
+ `Issue #${issue} is ${read.state.toLowerCase()}, and triage reads open feedback issues only. File a new report instead.`,
62
+ )
63
+ process.exitCode = 1
64
+ return
65
+ }
66
+ if (!read.labels.includes('feedback')) {
67
+ frameError(
68
+ `Issue #${issue} carries no feedback label, so it is not a toolkit feedback report. Check the number and re-run.`,
69
+ )
70
+ process.exitCode = 1
71
+ return
72
+ }
73
+
74
+ const result = await postIssueComment(issue, body)
75
+ if (result.ok) {
76
+ frameSuccess('canon feedback', result.url)
77
+ process.stdout.write(`${result.url}\n`)
78
+ return
79
+ }
80
+ frameError(commentFailureMessage(result, issue))
81
+ process.stdout.write(`${body}\n`)
82
+ process.exitCode = 1
83
+ }
84
+
85
+ const ISSUE_NUMBER = /^[1-9]\d*$/
86
+
23
87
  export function register(program: Command): void {
24
88
  program
25
89
  .command('feedback')
@@ -27,10 +91,21 @@ export function register(program: Command): void {
27
91
  .description(
28
92
  'Open a GitHub issue on the toolkit repo from a feedback block on stdin',
29
93
  )
94
+ .option(
95
+ '--on <issue>',
96
+ 'Post a follow-up block as a comment on an open feedback issue instead',
97
+ )
30
98
  // A cached plugin still pipes `--github`, and commander rejects an unknown
31
99
  // option, so the flag stays accepted and hidden for one release.
32
100
  .addOption(new Option('--github').hideHelp())
33
- .action(async () => {
101
+ .action(async (opts: { on?: string }) => {
102
+ if (opts.on !== undefined && !ISSUE_NUMBER.test(opts.on)) {
103
+ frameError(
104
+ `--on takes an issue number, such as --on 2801, not "${opts.on}".`,
105
+ )
106
+ process.exitCode = 1
107
+ return
108
+ }
34
109
  if (process.stdin.isTTY) {
35
110
  frameError(
36
111
  'No feedback on stdin. Pipe a markdown block: pbpaste | canon feedback',
@@ -45,6 +120,11 @@ export function register(program: Command): void {
45
120
  return
46
121
  }
47
122
 
123
+ if (opts.on !== undefined) {
124
+ await postFollowUp(Number(opts.on), body)
125
+ return
126
+ }
127
+
48
128
  // A report missing its surface is no more useful in the tracker than
49
129
  // anywhere else, so validation runs before any `gh` call.
50
130
  const absent = missingField(body)
@@ -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
  )
@@ -98,6 +98,7 @@ import {
98
98
  } from '@/git/status'
99
99
  import { machineListenerReader } from '@/listeners/machine'
100
100
  import { loadPaintedPatterns } from '@/pr/painted'
101
+ import { readCarry } from '@/pr/carry'
101
102
  import { LOCAL_CONFIG_REL, readHealthPaths } from '@/pr/health'
102
103
  import {
103
104
  dropAddressLine,
@@ -228,6 +229,7 @@ type TickReason =
228
229
  | TickRefusal
229
230
  | 'count-mismatch'
230
231
  | 'stale-head'
232
+ | 'redrive-owed'
231
233
  | 'bad-boxes'
232
234
  | 'no-comment'
233
235
  | 'body-refused'
@@ -236,7 +238,9 @@ const TICK_REFUSALS: Record<TickReason | 'gh-failed', string> = {
236
238
  'body-refused':
237
239
  'The boxes are ticked on the evidence comment, but gh could not edit the pull request body, so its UI pass line still shows the earlier result. Re-run the tick to retry the body.',
238
240
  'stale-head':
239
- 'The head named is not the branch tip on the remote, so a tick would claim a pass on a commit that is no longer the pull request. Re-drive the checklist at the tip.',
241
+ 'The head named is not the branch tip on the remote, and the tip does not descend from it or the gap could not be read, so a tick would claim a pass on a commit that is no longer the pull request. Re-drive the checklist at the tip.',
242
+ 'redrive-owed':
243
+ 'A push since the driven head changed a path a box can observe, so the pass does not carry to the tip. Nothing was written. Re-drive the checklist at the tip rather than retrying the tick there.',
240
244
  'bad-boxes':
241
245
  '--boxes takes box numbers as a comma-separated list of positive whole numbers, such as 1,3, and --head takes the commit that was driven.',
242
246
  'count-mismatch':
@@ -700,8 +704,9 @@ export function register(program: Command): void {
700
704
  '',
701
705
  '`ui` also carries the newest `## UI review` pass (`heading`, `state`,',
702
706
  '`commit`, `source`), `isBound`, which is whether the evidence comment',
703
- 'carries a checklist, and `carriedFrom` or `paintedPaths` when the lift',
704
- 'carried a close forward or refused to. An unbound family reads covered.',
707
+ 'carries a checklist, and `carriedFrom` when the lift carried a close',
708
+ 'forward, or `drivenPaths` and the painted subset `paintedPaths` when it',
709
+ 'refused to. An unbound family reads covered.',
705
710
  '',
706
711
  'Both families post as pull request reviews, so this reads',
707
712
  '`pulls/<n>/reviews` alone, as the lift does. Issue comments are not',
@@ -944,11 +949,15 @@ export function register(program: Command): void {
944
949
  ' ui the newest `## UI review` is closed at the head, bound only',
945
950
  ' where `canon pr evidence --check` carries a checklist. A close',
946
951
  ' on an earlier commit still holds when it is an ancestor of the',
947
- ' head and no painted path changed since, and the record names',
948
- ' it as `carriedFrom`. A painted change refuses `ui-behind` and',
949
- ' lists the paths as `paintedPaths`. A close whose count line',
950
- ' shows boxes not driven and none passed, failed, or needing',
951
- ' eyes refuses `ui-undriven`, carried or not',
952
+ ' head and every path changed since is one no box can observe,',
953
+ ' being tests, stories, snapshots, markdown in the root and in',
954
+ ' `canon/`, `.claude/`, `.github/`, and `docs/`, evidence captures,',
955
+ ' and `canon/design/`, and the record names it as `carriedFrom`.',
956
+ ' Any other change refuses `ui-behind`, listing the paths as',
957
+ ' `drivenPaths` and the painted subset as `paintedPaths`, since',
958
+ ' a box drives behavior a `.ts` file holds too. A close whose',
959
+ ' count line shows boxes not driven and none passed, failed,',
960
+ ' or needing eyes refuses `ui-undriven`, carried or not',
952
961
  ' evidence the evidence check reads settled',
953
962
  ' standing the head is fresh against the base, not stale or conflicted',
954
963
  ' ci every check run on the tip passed, waited on up to --wait',
@@ -1295,14 +1304,23 @@ export function register(program: Command): void {
1295
1304
  'driver can run is ticked, counts taste boxes apart, and is the only',
1296
1305
  'body text this verb writes, so hand edits elsewhere survive.',
1297
1306
  '',
1298
- 'Before writing it reads the newest UI verdict. One that covers the tip',
1299
- 'and carries a count line must match the ticks this call leaves at the',
1300
- 'tip, so a re-drive passes every box that passed, since a new head',
1301
- 'clears the older ticks. Any other verdict leaves the check unrun.',
1307
+ 'A --head behind the remote tip carries to the tip when every path',
1308
+ 'changed since is one no box can observe, being tests, stories,',
1309
+ 'snapshots, markdown in the root and in `canon/`, `.claude/`,',
1310
+ '`.github/`, and `docs/`, evidence captures, and `canon/design/`. The',
1311
+ 'boxes are then stamped at the tip and the record names `carriedFrom`.',
1312
+ 'Any other changed path, markdown elsewhere included, owes a re-drive.',
1313
+ '',
1314
+ 'Before writing it reads the newest UI verdict. One that covers the tip,',
1315
+ 'or the driven head on a carry, and carries a count line must match the',
1316
+ 'ticks this call leaves at the tip, so a re-drive passes every box that',
1317
+ 'passed, since a new head clears the older ticks. Any other verdict',
1318
+ 'leaves the check unrun.',
1302
1319
  '',
1303
1320
  'Read `reason` on the JSON record:',
1304
1321
  ' ticked the boxes were ticked and the body line written, with the',
1305
- ' tip in `head` and `countCheck` as matched or unchecked',
1322
+ ' tip in `head`, `countCheck` as matched or unchecked, and',
1323
+ ' `carriedFrom` when --head was carried to the tip',
1306
1324
  ' count-mismatch the verdict passed a different number of boxes than',
1307
1325
  ' this call leaves ticked at the tip, nothing written,',
1308
1326
  ' `passed` and `ticked` carry the two counts. Fewer ticks',
@@ -1310,7 +1328,10 @@ export function register(program: Command): void {
1310
1328
  ' operator an untick of a box that has since failed',
1311
1329
  ' body-refused the comment is ticked but gh could not edit the body,',
1312
1330
  ' exit 1 with `commentTicked`. Re-run to retry the body.',
1313
- ' stale-head --head is not the remote branch tip, nothing written',
1331
+ ' redrive-owed a push since --head changed a path a box can observe,',
1332
+ ' nothing written, with `driven`, `head`, and `drivenPaths`',
1333
+ ' stale-head --head is behind the tip with no readable linear gap,',
1334
+ ' as a force-push or a rebase leaves, nothing written',
1314
1335
  ' taste-box a named box ends in (taste), nothing written',
1315
1336
  ' no-box a named number is past the checklist, nothing written',
1316
1337
  ' no-checklist the evidence comment carries no checklist',
@@ -3475,9 +3496,9 @@ const POSITIVE_WHOLE = /^[1-9]\d*$/
3475
3496
  const MIN_TICK_HEAD_LENGTH = 7
3476
3497
 
3477
3498
  /**
3478
- * Ticks the named boxes on the marked comment. It refuses a head that is not
3479
- * the remote tip and a taste box before writing, so both rules hold as checks
3480
- * rather than as prose a driver can talk itself out of.
3499
+ * Ticks the named boxes on the marked comment. It refuses a head the tip moved
3500
+ * past in a way a box can observe, and a taste box, before writing, so both
3501
+ * rules hold as checks rather than as prose a driver can talk itself out of.
3481
3502
  */
3482
3503
  async function runTick(
3483
3504
  number: string | undefined,
@@ -3518,7 +3539,26 @@ async function runTick(
3518
3539
  root,
3519
3540
  )
3520
3541
  }
3521
- if (!reading.tip.startsWith(head)) return refuseTick('stale-head')
3542
+ const carry = await readCarry(
3543
+ (from, to) => readChangedPaths(gitRunner(root), from, to),
3544
+ head,
3545
+ reading.tip,
3546
+ )
3547
+ if (carry.kind === 'moved') {
3548
+ return refuseWith(
3549
+ 'redrive-owed',
3550
+ TICK_REFUSALS['redrive-owed'],
3551
+ emitJson,
3552
+ root,
3553
+ { driven: head, head: reading.tip, drivenPaths: carry.drivenPaths },
3554
+ )
3555
+ }
3556
+ // A gap that could not be read refuses as a rewrite does, which is what the
3557
+ // verb answered for every moved tip before the carry.
3558
+ if (carry.kind === 'not-ancestor' || carry.kind === 'failed') {
3559
+ return refuseTick('stale-head')
3560
+ }
3561
+ const carriedFrom = carry.kind === 'carried' ? carry.carriedFrom : undefined
3522
3562
 
3523
3563
  const comments = await listComments(root, String(identity.number))
3524
3564
  if (comments === undefined) return refuseTick('gh-failed')
@@ -3534,8 +3574,9 @@ async function runTick(
3534
3574
  if (result.kind === 'refused') return refuseTick(result.reason)
3535
3575
 
3536
3576
  // Read ahead of the write so a short list refuses before it leaves the
3537
- // comment half ticked. A verdict that does not cover the tip, carries no
3538
- // count line, or cannot be read leaves the check unrun and says so.
3577
+ // comment half ticked. A verdict that covers neither the tip nor the head a
3578
+ // carry came from, carries no count line, or cannot be read leaves the check
3579
+ // unrun and says so.
3539
3580
  const ticked = countTicksAtHead(
3540
3581
  readChecklistBoxes(findEvidenceChecklist([{ body: result.body }]) ?? ''),
3541
3582
  reading.tip,
@@ -3547,7 +3588,10 @@ async function runTick(
3547
3588
  const isCounted =
3548
3589
  verdict.counts !== undefined &&
3549
3590
  verdict.commit !== undefined &&
3550
- coversHead(verdict.commit, reading.tip)
3591
+ (coversHead(verdict.commit, reading.tip) ||
3592
+ (carriedFrom !== undefined &&
3593
+ (coversHead(verdict.commit, carriedFrom) ||
3594
+ coversHead(carriedFrom, verdict.commit))))
3551
3595
  if (isCounted && verdict.counts.passed !== ticked) {
3552
3596
  return refuseWith(
3553
3597
  'count-mismatch',
@@ -3622,6 +3666,7 @@ async function runTick(
3622
3666
  reason: 'ticked',
3623
3667
  countCheck,
3624
3668
  head: reading.tip,
3669
+ ...(carriedFrom !== undefined && { carriedFrom }),
3625
3670
  boxes: numbers.map(Number),
3626
3671
  commentId,
3627
3672
  })}\n`,
@@ -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
  /**
@@ -94,6 +94,56 @@ export function missingFieldMessage(field: string): string {
94
94
  return `Feedback report is missing its "### ${field}" section. Every report needs ${REQUIRED_FIELDS.map((name) => `### ${name}`).join(', ')}.`
95
95
  }
96
96
 
97
+ /**
98
+ * The fields a follow-up comment carries. A follow-up reports what happened
99
+ * after filing, so it names no surface and proposes no fix of its own. It says
100
+ * whether the body's fix still stands, since triage reads the body as the
101
+ * request and a silent overturn leaves it acting on the old one.
102
+ */
103
+ export const FOLLOW_UP_FIELDS = [
104
+ 'Observed',
105
+ 'Changes the fix',
106
+ 'Links',
107
+ ] as const
108
+
109
+ const YES_OR_NO = /^(?:yes|no)\b/i
110
+
111
+ /**
112
+ * A URL, or an issue reference such as `#2801`, `frameboard#156`, or
113
+ * `erclx/canon#2801`. A bare name is what the hand-written follow-up carried,
114
+ * and nothing a reader can open from it.
115
+ */
116
+ const REFERENCE = /https?:\/\/\S+|(?:^|[\s(])(?:[\w.-]+\/)?[\w.-]*#\d+\b/
117
+
118
+ export function missingFollowUpField(body: string): string | undefined {
119
+ const sections = parseSections(body)
120
+ for (const field of FOLLOW_UP_FIELDS) {
121
+ const value = sections.get(field.toLowerCase())
122
+ if (!value) return field
123
+ }
124
+ return undefined
125
+ }
126
+
127
+ /**
128
+ * The sentence a refused follow-up reads, or `undefined` when it carries every
129
+ * field in a checkable shape. One problem at a time, as `missingField` does.
130
+ */
131
+ export function followUpProblem(body: string): string | undefined {
132
+ const absent = missingFollowUpField(body)
133
+ if (absent) {
134
+ return `Follow-up is missing its "### ${absent}" section. Every follow-up needs ${FOLLOW_UP_FIELDS.map((name) => `### ${name}`).join(', ')}.`
135
+ }
136
+ const changes = readField(body, 'Changes the fix') ?? ''
137
+ if (!YES_OR_NO.test(changes)) {
138
+ return 'Follow-up "### Changes the fix" must open with yes or no, so triage can tell whether the issue body still holds.'
139
+ }
140
+ const links = readField(body, 'Links') ?? ''
141
+ if (!REFERENCE.test(links)) {
142
+ return 'Follow-up "### Links" carries no URL or issue reference. Write each as a URL or as owner/repo#number.'
143
+ }
144
+ return undefined
145
+ }
146
+
97
147
  function surfaceField(body: string): string | undefined {
98
148
  return readField(body, 'Surface')?.split('\n')[0]?.trim()
99
149
  }
@@ -37,6 +37,61 @@ export function buildIssueArgs(opts: CreateIssueOptions): string[] {
37
37
  return args
38
38
  }
39
39
 
40
+ export function buildCommentArgs(issue: number, body: string): string[] {
41
+ return ['issue', 'comment', String(issue), '--body', body, '--repo', REPO]
42
+ }
43
+
44
+ export function buildIssueReadArgs(issue: number): string[] {
45
+ return [
46
+ 'issue',
47
+ 'view',
48
+ String(issue),
49
+ '--json',
50
+ 'state,labels',
51
+ '--repo',
52
+ REPO,
53
+ ]
54
+ }
55
+
56
+ export interface IssueRead {
57
+ readonly ok: true
58
+ readonly state: string
59
+ readonly labels: readonly string[]
60
+ }
61
+
62
+ /**
63
+ * Reads the `gh issue view --json state,labels` record. A record of any other
64
+ * shape is a failed read rather than an issue with no labels, since the caller
65
+ * refuses on a missing label and that refusal would then name the wrong cause.
66
+ */
67
+ export function parseIssueRead(stdout: string): IssueRead | IssueFailure {
68
+ let record: unknown
69
+ try {
70
+ record = JSON.parse(stdout)
71
+ } catch {
72
+ return {
73
+ ok: false,
74
+ reason: 'command-failed',
75
+ detail: 'gh printed no JSON issue record',
76
+ }
77
+ }
78
+ const { state, labels } = (record ?? {}) as {
79
+ state?: unknown
80
+ labels?: unknown
81
+ }
82
+ if (typeof state !== 'string' || !Array.isArray(labels)) {
83
+ return {
84
+ ok: false,
85
+ reason: 'command-failed',
86
+ detail: 'gh printed an issue record with no state or labels',
87
+ }
88
+ }
89
+ const names = labels
90
+ .map((label: unknown) => (label as { name?: unknown } | null)?.name)
91
+ .filter((name): name is string => typeof name === 'string')
92
+ return { ok: true, state, labels: names }
93
+ }
94
+
40
95
  /**
41
96
  * Collapses to one line. Both callers write the detail inside a framed error or
42
97
  * a colored warning, and a newline in the middle of either breaks the frame and
@@ -75,3 +130,25 @@ export function issueFailureMessage(failure: IssueFailure): string {
75
130
  }
76
131
  return `gh could not file the issue: ${failure.detail ?? 'no diagnostic on stderr'}. File it manually at ${ISSUE_URL} instead.`
77
132
  }
133
+
134
+ export function commentFailureMessage(
135
+ failure: IssueFailure,
136
+ issue: number,
137
+ ): string {
138
+ const target = `https://github.com/${REPO}/issues/${issue}`
139
+ if (failure.reason === 'missing-binary') {
140
+ return `gh is not installed, so no follow-up was posted. Install gh, or comment at ${target}`
141
+ }
142
+ return `gh could not post the follow-up: ${failure.detail ?? 'no diagnostic on stderr'}. Comment manually at ${target} instead.`
143
+ }
144
+
145
+ /**
146
+ * A read that `gh` ran and failed is most often a number naming no issue, so
147
+ * the sentence asks for the number rather than pointing a paste at it.
148
+ */
149
+ export function issueReadFailureMessage(
150
+ failure: IssueFailure,
151
+ issue: number,
152
+ ): string {
153
+ return `gh could not read issue #${issue}: ${failure.detail ?? 'no diagnostic on stderr'}. Check the number and re-run.`
154
+ }