@erclx/canon 5.7.0 → 5.9.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 (77) hide show
  1. package/README.md +4 -2
  2. package/claude/.claude-plugin/plugin.json +2 -2
  3. package/claude/skills/auto-ship/SKILL.md +2 -0
  4. package/claude/skills/draft-and-pick/REQUIREMENT.md +2 -1
  5. package/claude/skills/draft-and-pick/SKILL.md +9 -6
  6. package/claude/skills/git-followup/SKILL.md +1 -1
  7. package/claude/skills/git-pr/references/evidence.md +1 -1
  8. package/claude/skills/git-ship/REQUIREMENT.md +3 -2
  9. package/claude/skills/git-ship/SKILL.md +25 -2
  10. package/claude/skills/review-address/SKILL.md +6 -4
  11. package/claude/skills/review-craft/REQUIREMENT.md +8 -2
  12. package/claude/skills/review-craft/SKILL.md +63 -7
  13. package/claude/skills/review-craft/references/adopted.md +49 -0
  14. package/docs/agents/commands.md +2 -0
  15. package/docs/agents/hooks.md +56 -0
  16. package/docs/agents/index.md +1 -0
  17. package/docs/agents/install-and-sync.md +1 -1
  18. package/docs/agents/markdown-audit.md +1 -1
  19. package/docs/agents/pr-evidence.md +33 -17
  20. package/docs/agents/records-push.md +1 -1
  21. package/docs/agents/records-validate.md +1 -1
  22. package/docs/agents/scripting.md +6 -0
  23. package/docs/agents/tasks-archive.md +4 -2
  24. package/docs/agents/tasks.md +1 -1
  25. package/docs/agents/worktrees.md +2 -2
  26. package/docs/target/scenarios.md +2 -2
  27. package/package.json +2 -2
  28. package/src/claude/skills-audit.ts +1 -0
  29. package/src/cli.ts +2 -0
  30. package/src/commands/claude.ts +1 -1
  31. package/src/commands/docs.ts +51 -11
  32. package/src/commands/hooks.ts +67 -0
  33. package/src/commands/pr.ts +146 -76
  34. package/src/commands/standards.ts +33 -2
  35. package/src/commands/tasks.ts +45 -0
  36. package/src/commands/tooling.ts +3 -3
  37. package/src/commands/upgrade.ts +3 -2
  38. package/src/commands/worktrees.ts +4 -3
  39. package/src/docs/list.ts +103 -0
  40. package/src/docs/read.ts +63 -19
  41. package/src/gov/stacks.ts +1 -1
  42. package/src/help.ts +2 -0
  43. package/src/hooks/post-merge.ts +331 -0
  44. package/src/pr/evidence.ts +21 -0
  45. package/src/records/validate.ts +1 -38
  46. package/src/standards/list.ts +33 -0
  47. package/src/standards/read.ts +13 -0
  48. package/src/standards/scope.ts +56 -0
  49. package/src/sync/check.ts +38 -4
  50. package/src/tasks/archive.ts +168 -3
  51. package/src/tooling/inject.ts +4 -3
  52. package/src/tooling/manifest.ts +62 -8
  53. package/src/tooling/scan.ts +4 -3
  54. package/src/tooling/stamp.ts +11 -2
  55. package/src/tooling/subfolder.ts +7 -3
  56. package/tooling/astro/reference.md +1 -1
  57. package/tooling/base/configs/scripts/verify.json +3 -0
  58. package/tooling/base/configs/scripts/verify.sh +1 -110
  59. package/tooling/base/configs/scripts/verify.ts +378 -0
  60. package/tooling/base/manifest.toml +1 -0
  61. package/tooling/base/reference.md +5 -3
  62. package/tooling/go/configs/scripts/verify.json +10 -0
  63. package/tooling/go/reference.md +5 -4
  64. package/tooling/php/configs/scripts/verify.json +7 -0
  65. package/tooling/php/reference.md +5 -4
  66. package/tooling/python/configs/scripts/verify.json +4 -0
  67. package/tooling/python/reference.md +3 -3
  68. package/tooling/web/configs/scripts/verify.json +3 -0
  69. package/tooling/web/reference.md +3 -3
  70. package/scripts/docs/list.sh +0 -184
  71. package/scripts/lib/frontmatter.sh +0 -30
  72. package/scripts/standards/list.sh +0 -115
  73. package/src/commands/pass-through.ts +0 -39
  74. package/tooling/go/configs/scripts/verify.sh +0 -142
  75. package/tooling/php/configs/scripts/verify.sh +0 -147
  76. package/tooling/python/configs/scripts/verify.sh +0 -141
  77. package/tooling/web/configs/scripts/verify.sh +0 -153
@@ -26,6 +26,7 @@ import {
26
26
  withMergeState,
27
27
  } from '@/pr/checks'
28
28
  import {
29
+ findEvidenceCaseCount,
29
30
  findEvidenceChecklist,
30
31
  findEvidenceCommentId,
31
32
  findEvidenceLocal,
@@ -173,20 +174,21 @@ const PULL_REFUSALS: Record<PullRefusal | HeadRefusal, string> = {
173
174
  type EvidenceRefusal =
174
175
  | 'gh-failed'
175
176
  | 'no-base'
176
- | 'unreadable-tree'
177
177
  | 'unreadable-changes'
178
178
  | 'unreadable-checklist'
179
+ | 'would-empty'
179
180
 
180
181
  const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
181
182
  'gh-failed':
182
183
  'gh could not answer for this repository. Name the pull request number.',
183
184
  'unreadable-checklist':
184
185
  'The file named by --checklist could not be read, or holds nothing. Rendering without it would drop the only copy, since the caller deletes the handoff once a post reports success.',
185
- 'no-base': 'No base resolves against the trunk. Fetch origin and re-run.',
186
- 'unreadable-tree':
187
- 'git could not read the tree at the base commit, so no path could be judged added or changed.',
186
+ 'no-base':
187
+ 'GitHub reported no merge base for this pull request, so no path could be judged added or changed.',
188
188
  'unreadable-changes':
189
- 'git could not list what this branch changed, so the set is unknown.',
189
+ 'GitHub could not list what this pull request changed, so the set is unknown.',
190
+ 'would-empty':
191
+ 'This render holds no cases and the marked comment holds some, so posting it would replace a comparison with an empty one. Edit the comment by hand if every case was removed on purpose.',
190
192
  }
191
193
 
192
194
  /** Why the read produced no comparison, ahead of the ones the compare owns. */
@@ -427,11 +429,13 @@ export function register(program: Command): void {
427
429
  'after',
428
430
  [
429
431
  '',
430
- 'Compares the merge base with the trunk against the current head, never',
431
- 'the previous push against the new one, so the comment never claims more',
432
- 'than the branch currently shows. A path counts as evidence when one of',
433
- 'its segments is literally `evidence` and the filename carries an image',
434
- 'extension (png, jpg, jpeg, gif, webp, avif, svg). A README, a capture',
432
+ "Compares the pull request's merge base against its head, both read over",
433
+ 'the API for the number named, so the record is the same from any',
434
+ 'checkout. It never compares the previous push against the new one, so',
435
+ 'the comment never claims more than the pull request currently shows.',
436
+ 'A path counts as evidence when one of its segments is literally',
437
+ '`evidence` and the filename carries an image extension (png, jpg,',
438
+ 'jpeg, gif, webp, avif, svg). A README, a capture',
435
439
  'script, or a raw data file kept beside the images is left out rather',
436
440
  'than rendered as a broken embed.',
437
441
  '',
@@ -440,6 +444,8 @@ export function register(program: Command): void {
440
444
  ' comment already exists and should be edited in place',
441
445
  ' no-evidence nothing in the diff carries an evidence/ segment, which',
442
446
  ' is an ordinary, silent no-op rather than a refusal',
447
+ ' would-empty the render holds no cases and the marked comment holds',
448
+ ' some, so no body is printed and a person edits the comment',
443
449
  '',
444
450
  "--preview puts the address on the body's first line. With no evidence",
445
451
  'in the diff, the body is that line and the marker alone, reported as',
@@ -1346,21 +1352,63 @@ async function runReviewState(
1346
1352
  }
1347
1353
 
1348
1354
  /**
1349
- * Every path `git ls-tree` reports for `ref`, or undefined when the read
1350
- * failed. One call for the whole tree rather than one `cat-file -e` per
1351
- * evidence path, since existence at base is checked once per changed path and
1352
- * a batch read is one round trip instead of many.
1355
+ * The paths a pull request changed that still exist at its head, each tagged
1356
+ * with whether the pull request added it, read through the paginated files
1357
+ * endpoint so the set is the same from any checkout and never capped at the
1358
+ * first view. `renamed` and `copied` count as added, since the new path has no
1359
+ * counterpart at the merge base. Returns undefined when the read fails, which
1360
+ * refuses rather than rendering a short set.
1353
1361
  */
1354
- async function listTreePaths(
1355
- root: string,
1356
- ref: string,
1357
- ): Promise<Set<string> | undefined> {
1358
- const result = await $`git -C ${root} ls-tree -r --name-only ${ref}`
1359
- .env(gitEnv())
1360
- .quiet()
1361
- .nothrow()
1362
- if (result.exitCode !== 0) return undefined
1363
- return new Set(result.text().split('\n').filter(Boolean))
1362
+ async function listPullFiles(
1363
+ cwd: string,
1364
+ number: number,
1365
+ ): Promise<ReadonlyMap<string, boolean> | undefined> {
1366
+ const stdout = await gh(cwd, [
1367
+ 'api',
1368
+ '--paginate',
1369
+ `repos/{owner}/{repo}/pulls/${number}/files`,
1370
+ '--jq',
1371
+ '.[] | "\(.status)\t\(.filename)"',
1372
+ ])
1373
+ if (stdout === null) return undefined
1374
+ const files = new Map<string, boolean>()
1375
+ for (const line of stdout.split('\n').filter(Boolean)) {
1376
+ const [status, path] = line.split('\t')
1377
+ if (status === undefined || path === undefined || status === 'removed') {
1378
+ continue
1379
+ }
1380
+ files.set(path, status !== 'modified' && status !== 'changed')
1381
+ }
1382
+ return files
1383
+ }
1384
+
1385
+ /**
1386
+ * The commit the pull request diverged from its base at, from the compare
1387
+ * endpoint, since a checkout on another branch resolves its own trunk tip
1388
+ * instead and the base image links would then describe the wrong commit.
1389
+ */
1390
+ async function readPullMergeBase(
1391
+ cwd: string,
1392
+ number: number,
1393
+ head: string,
1394
+ ): Promise<string | undefined> {
1395
+ const baseRef = await gh(cwd, [
1396
+ 'pr',
1397
+ 'view',
1398
+ String(number),
1399
+ '--json',
1400
+ 'baseRefName',
1401
+ '--jq',
1402
+ '.baseRefName',
1403
+ ])
1404
+ if (baseRef === null || baseRef.trim() === '') return undefined
1405
+ const sha = await gh(cwd, [
1406
+ 'api',
1407
+ `repos/{owner}/{repo}/compare/${baseRef.trim()}...${head}`,
1408
+ '--jq',
1409
+ '.merge_base_commit.sha',
1410
+ ])
1411
+ return sha === null || sha.trim() === '' ? undefined : sha.trim()
1364
1412
  }
1365
1413
 
1366
1414
  async function runEvidence(
@@ -1404,16 +1452,20 @@ async function runEvidence(
1404
1452
  )
1405
1453
  }
1406
1454
 
1407
- const base = await resolveBaseRef(root)
1408
- if (base === undefined) {
1409
- return refuseWith('no-base', EVIDENCE_REFUSALS['no-base'], emitJson, root)
1455
+ if (identity.number === undefined) {
1456
+ return refuseWith(
1457
+ 'gh-failed',
1458
+ EVIDENCE_REFUSALS['gh-failed'],
1459
+ emitJson,
1460
+ root,
1461
+ )
1410
1462
  }
1411
1463
 
1412
- const [changed, baseTree] = await Promise.all([
1413
- listChangedFiles(root, base),
1414
- listTreePaths(root, base),
1464
+ const [pullFiles, base] = await Promise.all([
1465
+ listPullFiles(root, identity.number),
1466
+ readPullMergeBase(root, identity.number, identity.head),
1415
1467
  ])
1416
- if (changed === undefined) {
1468
+ if (pullFiles === undefined) {
1417
1469
  return refuseWith(
1418
1470
  'unreadable-changes',
1419
1471
  EVIDENCE_REFUSALS['unreadable-changes'],
@@ -1421,61 +1473,57 @@ async function runEvidence(
1421
1473
  root,
1422
1474
  )
1423
1475
  }
1424
- if (baseTree === undefined) {
1425
- return refuseWith(
1426
- 'unreadable-tree',
1427
- EVIDENCE_REFUSALS['unreadable-tree'],
1428
- emitJson,
1429
- root,
1430
- )
1476
+ if (base === undefined) {
1477
+ return refuseWith('no-base', EVIDENCE_REFUSALS['no-base'], emitJson, root)
1431
1478
  }
1432
1479
 
1433
- const grouped = await groupEvidence(changed, async (path) =>
1434
- baseTree.has(path),
1480
+ const grouped = await groupEvidence(
1481
+ [...pullFiles.keys()],
1482
+ async (path) => pullFiles.get(path) === false,
1435
1483
  )
1436
1484
 
1485
+ let existingCases = 0
1437
1486
  let commentId: number | undefined
1438
1487
  let carriedPreview: string | undefined
1439
1488
  let carriedChecklist: string | undefined
1440
1489
  let carriedLocal: string | undefined
1441
- if (identity.number !== undefined) {
1442
- const commentsRow = await gh(root, [
1443
- 'pr',
1444
- 'view',
1445
- String(identity.number),
1446
- '--json',
1447
- 'comments',
1448
- ])
1449
- // An unread thread refuses rather than rendering, since a body built
1450
- // without it knows neither the comment to edit nor the preview address
1451
- // to carry, and posting it would duplicate the comment and drop the link.
1452
- // It refuses ahead of no-evidence too, so that record never reports an
1453
- // absent field it did not read.
1454
- let comments: readonly { url?: string; body: string }[] | undefined
1455
- if (commentsRow !== null) {
1456
- try {
1457
- comments = (
1458
- JSON.parse(commentsRow) as {
1459
- comments?: readonly { url?: string; body: string }[]
1460
- }
1461
- ).comments
1462
- } catch {
1463
- comments = undefined
1464
- }
1465
- }
1466
- if (comments === undefined) {
1467
- return refuseWith(
1468
- 'gh-failed',
1469
- EVIDENCE_REFUSALS['gh-failed'],
1470
- emitJson,
1471
- root,
1472
- )
1490
+ const commentsRow = await gh(root, [
1491
+ 'pr',
1492
+ 'view',
1493
+ String(identity.number),
1494
+ '--json',
1495
+ 'comments',
1496
+ ])
1497
+ // An unread thread refuses rather than rendering, since a body built
1498
+ // without it knows neither the comment to edit nor the preview address
1499
+ // to carry, and posting it would duplicate the comment and drop the link.
1500
+ // It refuses ahead of no-evidence too, so that record never reports an
1501
+ // absent field it did not read.
1502
+ let comments: readonly { url?: string; body: string }[] | undefined
1503
+ if (commentsRow !== null) {
1504
+ try {
1505
+ comments = (
1506
+ JSON.parse(commentsRow) as {
1507
+ comments?: readonly { url?: string; body: string }[]
1508
+ }
1509
+ ).comments
1510
+ } catch {
1511
+ comments = undefined
1473
1512
  }
1474
- commentId = findEvidenceCommentId(comments)
1475
- carriedPreview = findEvidencePreview(comments)
1476
- carriedChecklist = findEvidenceChecklist(comments)
1477
- carriedLocal = findEvidenceLocal(comments)
1478
1513
  }
1514
+ if (comments === undefined) {
1515
+ return refuseWith(
1516
+ 'gh-failed',
1517
+ EVIDENCE_REFUSALS['gh-failed'],
1518
+ emitJson,
1519
+ root,
1520
+ )
1521
+ }
1522
+ commentId = findEvidenceCommentId(comments)
1523
+ existingCases = findEvidenceCaseCount(comments)
1524
+ carriedPreview = findEvidencePreview(comments)
1525
+ carriedChecklist = findEvidenceChecklist(comments)
1526
+ carriedLocal = findEvidenceLocal(comments)
1479
1527
 
1480
1528
  // What the marked comment already shows a reviewer, kept apart from the
1481
1529
  // flags this call passed so a reader learns what is posted.
@@ -1543,6 +1591,28 @@ async function runEvidence(
1543
1591
 
1544
1592
  const caseCount = states.reduce((n, s) => n + s.items.length, 0)
1545
1593
 
1594
+ // A render with no cases cannot tell a removal from a short read, so it
1595
+ // never replaces a comment that carries some. The record keeps the comment
1596
+ // id and the carried fields so the caller can still edit by hand.
1597
+ if (caseCount === 0 && existingCases > 0) {
1598
+ logStep('Refused')
1599
+ logWarn(EVIDENCE_REFUSALS['would-empty'])
1600
+ outro()
1601
+ if (emitJson) {
1602
+ process.stdout.write(
1603
+ `${JSON.stringify({
1604
+ root,
1605
+ number: identity.number,
1606
+ reason: 'would-empty',
1607
+ message: EVIDENCE_REFUSALS['would-empty'],
1608
+ ...(commentId !== undefined && { commentId }),
1609
+ ...carried,
1610
+ })}\n`,
1611
+ )
1612
+ }
1613
+ return 1
1614
+ }
1615
+
1546
1616
  logStep('Scope')
1547
1617
  logInfo(
1548
1618
  `${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
@@ -1,11 +1,11 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
- import { registerPassThroughVerbs } from '@/commands/pass-through'
4
3
  import {
5
4
  auditExitCode,
6
5
  auditStandards,
7
6
  type StandardsAudit,
8
7
  } from '@/standards/audit'
8
+ import { listStandardEntries } from '@/standards/list'
9
9
  import { listStandards, readStandard, resolveStandard } from '@/standards/read'
10
10
  import {
11
11
  frameError,
@@ -19,6 +19,10 @@ import {
19
19
  plural,
20
20
  } from '@/ui'
21
21
 
22
+ interface StandardsListOptions {
23
+ readonly json?: boolean
24
+ }
25
+
22
26
  interface StandardsAuditOptions {
23
27
  readonly json?: boolean
24
28
  readonly arrivalsOnly?: boolean
@@ -57,7 +61,14 @@ export function register(program: Command): void {
57
61
  process.exitCode = print(name)
58
62
  })
59
63
 
60
- registerPassThroughVerbs(standards, 'standards', ['list'])
64
+ standards
65
+ .command('list')
66
+ .description('List the standards with their descriptions and jurisdiction')
67
+ .helpOption('-h, --help', 'Show this help message')
68
+ .option('--json', 'Emit machine-readable JSON')
69
+ .action((opts: StandardsListOptions) => {
70
+ process.exitCode = runStandardsList(opts)
71
+ })
61
72
 
62
73
  standards
63
74
  .command('audit')
@@ -95,6 +106,26 @@ export function register(program: Command): void {
95
106
  })
96
107
  }
97
108
 
109
+ /**
110
+ * Reads the working root first, the way `canon standards <name>` does, so a
111
+ * project's own standards list beside the package corpus. The `--json` record
112
+ * is the one thing on stdout and the frame opens only on the text path.
113
+ */
114
+ function runStandardsList(opts: StandardsListOptions): number {
115
+ const standards = listStandardEntries(process.cwd())
116
+
117
+ if (opts.json) {
118
+ process.stdout.write(`${JSON.stringify({ standards })}\n`)
119
+ return 0
120
+ }
121
+
122
+ intro('canon standards')
123
+ logStep('Standards')
124
+ for (const each of standards) logInfo(`${each.name} : ${each.description}`)
125
+ outro()
126
+ return 0
127
+ }
128
+
98
129
  /**
99
130
  * Writes the standard to stdout and every frame line to stderr, so a caller
100
131
  * capturing the output with `$(...)` receives the document alone.
@@ -10,9 +10,11 @@ import {
10
10
  type CitationOutcome,
11
11
  type DeclineOutcome,
12
12
  declineTask,
13
+ type InboundRelink,
13
14
  type PlanCitations,
14
15
  type PlanMove,
15
16
  planCitations,
17
+ type RelinkFailure,
16
18
  } from '@/tasks/archive'
17
19
  import {
18
20
  type LabelOutcome,
@@ -1493,6 +1495,7 @@ function report(
1493
1495
  logInfo('retargeted the Ready: line')
1494
1496
  }
1495
1497
  if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
1498
+ logRelinked(outcome.relinked, outcome.relinkFailed, root)
1496
1499
  if (outcome.indexRegenerated) logInfo('regenerated index.md')
1497
1500
  if (outcome.cut > 0) logInfo(`${outcome.cut} outcome(s) cut`)
1498
1501
  outro()
@@ -1508,6 +1511,43 @@ function logPlanMoves(plans: readonly PlanMove[], root: string): void {
1508
1511
  if (plans.length > 0) logInfo('retargeted the Plan: line')
1509
1512
  }
1510
1513
 
1514
+ function logRelinked(
1515
+ relinked: readonly InboundRelink[],
1516
+ failed: readonly RelinkFailure[],
1517
+ root: string,
1518
+ ): void {
1519
+ if (relinked.length > 0) {
1520
+ const links = relinked.reduce((sum, entry) => sum + entry.links, 0)
1521
+ logInfo(`repointed ${links} link(s) in ${relinked.length} file(s)`)
1522
+ }
1523
+
1524
+ for (const failure of failed) {
1525
+ logWarn(
1526
+ `could not repoint ${relative(root, failure.file)}: ${failure.message}`,
1527
+ )
1528
+ }
1529
+ }
1530
+
1531
+ function relinkFailedFor(
1532
+ failed: readonly RelinkFailure[],
1533
+ root: string,
1534
+ ): readonly { readonly file: string; readonly message: string }[] {
1535
+ return failed.map((failure) => ({
1536
+ file: relative(root, failure.file),
1537
+ message: failure.message,
1538
+ }))
1539
+ }
1540
+
1541
+ function relinkedFor(
1542
+ relinked: readonly InboundRelink[],
1543
+ root: string,
1544
+ ): readonly { readonly file: string; readonly links: number }[] {
1545
+ return relinked.map((entry) => ({
1546
+ file: relative(root, entry.file),
1547
+ links: entry.links,
1548
+ }))
1549
+ }
1550
+
1511
1551
  function planMovesFor(
1512
1552
  plans: readonly PlanMove[],
1513
1553
  root: string,
@@ -1547,6 +1587,8 @@ function recordFor(
1547
1587
  : null,
1548
1588
  closed: outcome.closed,
1549
1589
  cut: outcome.cut,
1590
+ relinked: relinkedFor(outcome.relinked, root),
1591
+ relinkFailed: relinkFailedFor(outcome.relinkFailed, root),
1550
1592
  }
1551
1593
  }
1552
1594
 
@@ -1639,6 +1681,7 @@ function reportDecline(
1639
1681
  logPlanMoves(outcome.plans, root)
1640
1682
  if (outcome.priorityRowRemoved) logInfo('cleared the ordering row')
1641
1683
  if (outcome.backlogRowRemoved) logInfo('cleared the backlog row')
1684
+ logRelinked(outcome.relinked, outcome.relinkFailed, root)
1642
1685
  if (outcome.indexRegenerated) logInfo('regenerated index.md')
1643
1686
  outro()
1644
1687
 
@@ -1667,5 +1710,7 @@ function declineRecordFor(
1667
1710
  backlogRowRemoved: outcome.backlogRowRemoved,
1668
1711
  indexRegenerated: outcome.indexRegenerated,
1669
1712
  plans: planMovesFor(outcome.plans, root),
1713
+ relinked: relinkedFor(outcome.relinked, root),
1714
+ relinkFailed: relinkFailedFor(outcome.relinkFailed, root),
1670
1715
  }
1671
1716
  }
@@ -19,7 +19,7 @@ import {
19
19
  } from '@/tooling/manifest'
20
20
  import { readReference, resolveReference } from '@/tooling/read'
21
21
  import { scan, type ScanResult } from '@/tooling/scan'
22
- import { recordToolingChain } from '@/tooling/stamp'
22
+ import { recordedStacks, recordToolingChain } from '@/tooling/stamp'
23
23
  import { subfolderPath } from '@/tooling/subfolder'
24
24
  import {
25
25
  intro,
@@ -228,7 +228,7 @@ export function register(program: Command): void {
228
228
  *
229
229
  * The frame opens after the `--json` return. The bash emitted a closing `└`
230
230
  * from its EXIT trap with no `┌` above it, because the hand-rolled
231
- * pass-through loop below skips the `intro` the shared helper carries.
231
+ * pass-through loop below opens no frame before it runs the script.
232
232
  */
233
233
  function runList(opts: ListOptions): number {
234
234
  // The warning is a frame-interior line, so it goes out after `intro` on the
@@ -485,7 +485,7 @@ async function stampChain(chain: Manifest[], target: string): Promise<void> {
485
485
  )
486
486
 
487
487
  if (recorded) {
488
- logInfo(`Recorded chain: ${chain.map((entry) => entry.name).join(' < ')}`)
488
+ logInfo(`Recorded chain: ${recordedStacks(chain).join(' < ')}`)
489
489
  return
490
490
  }
491
491
 
@@ -37,7 +37,7 @@ interface UpgradeRecord {
37
37
  readonly reason?: string
38
38
  /**
39
39
  * One rendered line for a caller that reports the outcome without parsing
40
- * the rest of the record, such as `.husky/post-merge`. `current` reuses
40
+ * the rest of the record, such as the base stack's post-merge hook. `current` reuses
41
41
  * `describeSkew` verbatim so its wording never drifts from the line `canon
42
42
  * sync --check` and `canon claude skills drift` already report.
43
43
  */
@@ -312,7 +312,8 @@ function emit(opts: UpgradeOptions, record: UpgradeRecord): void {
312
312
  /**
313
313
  * `message` is the one field carrying arbitrary text: `describeSkew`'s
314
314
  * `unknown` branch embeds a registry error verbatim, and a registry answering
315
- * with HTML produces one already carrying a double quote. `.husky/post-merge`
315
+ * with HTML produces one already carrying a double quote. The base stack's
316
+ * post-merge hook, and any hook installed before `canon hooks post-merge`,
316
317
  * reads this field with a pattern rather than a parser, so a quote makes it
317
318
  * through `JSON.stringify` as an escaped `\"` that the pattern stops at,
318
319
  * truncating the line it prints. Collapsing whitespace and swapping the quote
@@ -35,10 +35,11 @@ interface ReclaimCommandOptions {
35
35
  /**
36
36
  * What a caller reads instead of the frame.
37
37
  *
38
- * `.husky/post-merge` is the first caller that is not a person, and it branches
38
+ * The post-merge hooks are the callers that are not a person, and they branch
39
39
  * on a parsed field rather than on the exit code, since a shell profile
40
- * wrapping `canon` in a function flattens every status to zero. It reads the
41
- * record with a pattern rather than a parser, so every field it branches on is
40
+ * wrapping `canon` in a function flattens every status to zero. The base
41
+ * stack's hook, and any hook installed before `canon hooks post-merge`, reads
42
+ * the record with a pattern rather than a parser, so every field it branches on is
42
43
  * a number or a fixed word, and `outcomes` sits last so a greedy pattern
43
44
  * anchored on a scalar never reaches into it.
44
45
  */
@@ -0,0 +1,103 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import {
4
+ CONTEXT_ROOTS,
5
+ DOCS_ROOT,
6
+ walkEntries,
7
+ type WalkedEntry,
8
+ } from '@/docs/read'
9
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
10
+
11
+ /** The categories a target-facing doc declares, which no workflow doc does. */
12
+ const TARGET_CATEGORIES: readonly string[] = [
13
+ 'Agent surface',
14
+ 'Domain references',
15
+ ]
16
+
17
+ /** Domain context a target has no use for, so the catalog leaves it out. */
18
+ const INTERNAL_TOPICS: readonly string[] = ['ci', 'development', 'sandbox']
19
+
20
+ export interface DocEntry {
21
+ readonly name: string
22
+ readonly description: string
23
+ readonly category: string
24
+ readonly target: string
25
+ }
26
+
27
+ export interface DocsCatalog {
28
+ readonly docs: DocEntry[]
29
+ readonly context: DocEntry[]
30
+ /** False in a registry install, which ships `docs/` without a context root. */
31
+ readonly hasContext: boolean
32
+ }
33
+
34
+ /**
35
+ * A split folder is described by its generated index, which carries `subtitle`
36
+ * where a sibling file or a sub-area file carries `description`.
37
+ */
38
+ function describe(root: string, entry: WalkedEntry): [string, string] {
39
+ const fields = parseFrontmatter(readFileSync(join(root, entry.rel), 'utf8'))
40
+ const key = entry.kind === 'folder' ? 'subtitle' : 'description'
41
+
42
+ return [readField(fields, 'category') ?? '', readField(fields, key) ?? '']
43
+ }
44
+
45
+ function byName(a: DocEntry, b: DocEntry): number {
46
+ if (a.name === b.name) return a.target < b.target ? -1 : 1
47
+ return a.name < b.name ? -1 : 1
48
+ }
49
+
50
+ /**
51
+ * A folder declares its category on its own index, since the allowlist is what
52
+ * separates a target-facing doc from a workflow one and a split domain is not
53
+ * exempt from it. A sub-area file declares its own, which keeps a page's
54
+ * listing membership unchanged by the folder it moves into.
55
+ */
56
+ function collectDocs(root: string): DocEntry[] {
57
+ return walkEntries(root, DOCS_ROOT)
58
+ .flatMap((entry): DocEntry[] => {
59
+ const [category, description] = describe(root, entry)
60
+ if (!TARGET_CATEGORIES.includes(category)) return []
61
+
62
+ return [{ name: entry.name, description, category, target: entry.rel }]
63
+ })
64
+ .sort(byName)
65
+ }
66
+
67
+ /**
68
+ * Stops at the folder where `listTopics` goes on to name each sub-area file.
69
+ * This is the downstream catalog and that one answers what a caller could have
70
+ * typed, so a reachable name absent here is the same divergence the internal
71
+ * topics already carry. A name present at more than one surface root lists
72
+ * once, at the first.
73
+ */
74
+ function collectContext(root: string): DocEntry[] {
75
+ const seen = new Set<string>()
76
+ const entries: DocEntry[] = []
77
+
78
+ for (const dir of CONTEXT_ROOTS) {
79
+ for (const entry of walkEntries(root, dir)) {
80
+ if (entry.kind === 'leaf') continue
81
+ if (INTERNAL_TOPICS.includes(entry.name) || seen.has(entry.name)) continue
82
+
83
+ seen.add(entry.name)
84
+ const [, description] = describe(root, entry)
85
+ entries.push({
86
+ name: entry.name,
87
+ description,
88
+ category: '',
89
+ target: entry.rel,
90
+ })
91
+ }
92
+ }
93
+
94
+ return entries.sort(byName)
95
+ }
96
+
97
+ export function listDocs(root: string): DocsCatalog {
98
+ return {
99
+ docs: collectDocs(root),
100
+ context: collectContext(root),
101
+ hasContext: CONTEXT_ROOTS.some((dir) => existsSync(join(root, dir))),
102
+ }
103
+ }