@erclx/canon 5.24.0 → 5.26.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 (185) hide show
  1. package/README.md +2 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  4. package/claude/skills/backlog-triage/SKILL.md +19 -6
  5. package/claude/skills/canvas/REQUIREMENT.md +49 -0
  6. package/claude/skills/canvas/SKILL.md +106 -0
  7. package/claude/skills/context-fold/REQUIREMENT.md +6 -2
  8. package/claude/skills/context-fold/SKILL.md +8 -6
  9. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  10. package/claude/skills/design-extract/REQUIREMENT.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/design-taste/SKILL.md +4 -2
  13. package/claude/skills/design-taste/references/adopted.md +40 -0
  14. package/claude/skills/design-taste/references/kinds.md +1 -1
  15. package/claude/skills/design-taste/references/systems.md +1 -7
  16. package/claude/skills/design-taste/references/tells.md +2 -24
  17. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  18. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  19. package/claude/skills/document-health/SKILL.md +21 -0
  20. package/claude/skills/document-health/references/axes.md +12 -0
  21. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  22. package/claude/skills/draft-doc/REQUIREMENT.md +1 -1
  23. package/claude/skills/draft-figure/REQUIREMENT.md +14 -3
  24. package/claude/skills/draft-figure/SKILL.md +24 -51
  25. package/claude/skills/draft-figure/references/architecture-set.md +35 -0
  26. package/claude/skills/draft-figure/references/hand-drawn.md +31 -0
  27. package/claude/skills/draft-figure/references/mermaid.md +55 -0
  28. package/claude/skills/draft-identity/SKILL.md +1 -1
  29. package/claude/skills/draft-screencast/SKILL.md +9 -7
  30. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  31. package/claude/skills/draft-slides/SKILL.md +31 -20
  32. package/claude/skills/git-followup/SKILL.md +17 -6
  33. package/claude/skills/git-issue/references/issue.md +10 -0
  34. package/claude/skills/git-pr/references/evidence.md +12 -2
  35. package/claude/skills/git-pr/references/labels.md +12 -0
  36. package/claude/skills/git-split/SKILL.md +19 -8
  37. package/claude/skills/index-lookup/SKILL.md +2 -2
  38. package/claude/skills/markdown-propose/references/format.md +9 -0
  39. package/claude/skills/memory-review/references/later-phases.md +7 -0
  40. package/claude/skills/review-address/SKILL.md +6 -13
  41. package/claude/skills/review-address/references/rest-reads.md +43 -0
  42. package/claude/skills/review-craft/references/ui.md +7 -1
  43. package/claude/skills/review-pr/REQUIREMENT.md +1 -0
  44. package/claude/skills/review-pr/SKILL.md +9 -3
  45. package/claude/skills/review-pr/references/close-out.md +2 -2
  46. package/claude/skills/review-pr/references/post.md +17 -5
  47. package/claude/skills/review-pr/references/scope.md +10 -8
  48. package/claude/skills/review-ui/SKILL.md +12 -4
  49. package/claude/skills/review-ui/references/post.md +4 -2
  50. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +38 -1
  51. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  52. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +4 -0
  53. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +4 -4
  54. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +6 -0
  55. package/claude/skills/role-orchestrator/scripts/poll.ts +90 -27
  56. package/claude/skills/role-orchestrator/scripts/repo.ts +3 -5
  57. package/claude/skills/role-orchestrator/scripts/watch.ts +4 -7
  58. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  59. package/claude/skills/search-craft/SKILL.md +3 -1
  60. package/claude/skills/search-craft/references/adopted.md +30 -0
  61. package/claude/skills/target-setup/references/detect.md +8 -0
  62. package/claude/skills/target-setup/references/indexes.md +13 -0
  63. package/claude/skills/target-setup/references/verify.md +8 -0
  64. package/claude/skills/test-first/SKILL.md +2 -0
  65. package/claude/skills/test-first/references/adopted.md +36 -0
  66. package/claude/skills/write-human/SKILL.md +1 -1
  67. package/claude/skills/write-human/references/machine-tells.md +13 -0
  68. package/docs/agents/canvas.md +4 -2
  69. package/docs/agents/capture.md +1 -1
  70. package/docs/agents/commands.md +47 -47
  71. package/docs/agents/context-audit-checks.md +2 -2
  72. package/docs/agents/index.md +2 -3
  73. package/docs/agents/key-changes.md +3 -3
  74. package/docs/agents/pr-evidence.md +11 -2
  75. package/docs/agents/pr-reads.md +5 -4
  76. package/docs/agents/records-stale.md +22 -9
  77. package/docs/agents/records.md +1 -1
  78. package/docs/agents/sandbox.md +1 -1
  79. package/docs/agents/skills-audit.md +10 -3
  80. package/docs/agents/slides.md +15 -3
  81. package/docs/target/projects.md +4 -4
  82. package/docs/workflow/ai-workflow.md +1 -2
  83. package/docs/workflow/index.md +1 -0
  84. package/docs/workflow/parallel-features.md +1 -1
  85. package/docs/workflow/skill-map-on-request.md +42 -0
  86. package/docs/workflow/skill-map.md +3 -36
  87. package/docs/workflow/visual-design-workflow.md +1 -0
  88. package/governance/renames.toml +0 -4
  89. package/governance/rules/standards/markdown.md +1 -0
  90. package/package.json +1 -2
  91. package/scripts/core/install-check.sh +1 -1
  92. package/scripts/lib/sandbox-dispatch.sh +1 -1
  93. package/scripts/lib/sandbox-git.sh +1 -9
  94. package/scripts/lib/sandbox-path.sh +1 -1
  95. package/scripts/sandbox-hook.sh +99 -0
  96. package/src/audits/catalog.ts +2 -2
  97. package/src/canvas/capture.ts +1 -9
  98. package/src/canvas/client/inspector/color-field.tsx +368 -0
  99. package/src/canvas/client/inspector/field.tsx +200 -0
  100. package/src/canvas/client/inspector/fields.css +292 -0
  101. package/src/canvas/client/inspector/section.tsx +27 -0
  102. package/src/canvas/client/inspector/values.ts +183 -0
  103. package/src/canvas/client/inspector.tsx +176 -160
  104. package/src/canvas/client/shell.css +0 -101
  105. package/src/capture/render.ts +45 -7
  106. package/src/capture/sources.ts +43 -0
  107. package/src/census/count.ts +1 -1
  108. package/src/claude/cases/misc.ts +5 -0
  109. package/src/claude/cases/workflow.ts +6 -1
  110. package/src/claude/seeds.ts +1 -2
  111. package/src/claude/skills-audit.ts +68 -14
  112. package/src/client-commands.ts +1 -1
  113. package/src/commands/claude.ts +27 -2
  114. package/src/commands/design.ts +2 -64
  115. package/src/commands/feedback.ts +3 -3
  116. package/src/commands/pr.ts +219 -202
  117. package/src/commands/records.ts +88 -6
  118. package/src/commands/sandbox.ts +50 -10
  119. package/src/commands/slides.ts +31 -77
  120. package/src/context/audit.ts +8 -7
  121. package/src/design/tokens.ts +2 -18
  122. package/src/docs/read.ts +1 -1
  123. package/src/{github.ts → feedback/github.ts} +1 -1
  124. package/src/gate/measures.ts +6 -4
  125. package/src/gov/citations.ts +1 -1
  126. package/src/gov/payload.ts +1 -1
  127. package/src/gov/superseded.ts +2 -2
  128. package/src/help.ts +2 -2
  129. package/src/markdown/ceiling.ts +1 -1
  130. package/src/migrate/skill-names.ts +7 -1
  131. package/src/pr/evidence.ts +25 -5
  132. package/src/pr/rest.ts +137 -0
  133. package/src/record-root.ts +1 -0
  134. package/src/records/backup.ts +1 -0
  135. package/src/records/push-guard.ts +1 -1
  136. package/src/records/stale-canonical.ts +146 -0
  137. package/src/records/stale.ts +1 -1
  138. package/src/sandbox/hooks.ts +128 -0
  139. package/src/sandbox/provision.ts +596 -0
  140. package/src/sandbox/tree.ts +103 -4
  141. package/src/secrets/marker.ts +2 -2
  142. package/src/secrets/scan.ts +1 -1
  143. package/src/shipped/references.ts +2 -2
  144. package/src/slides/convert/export.ts +2 -16
  145. package/src/slides/decks.ts +110 -0
  146. package/src/standards/read.ts +1 -1
  147. package/src/sync/seeds-report.ts +1 -1
  148. package/src/tasks/answers.ts +1 -1
  149. package/src/tasks/archive.ts +1 -1
  150. package/src/teach/nav.ts +1 -1
  151. package/src/tooling/inject.ts +1 -1
  152. package/src/web/readme-citations.ts +1 -1
  153. package/standards/architecture.md +1 -0
  154. package/standards/canvas.md +112 -0
  155. package/standards/context.md +2 -1
  156. package/standards/figures.md +0 -1
  157. package/standards/index.md +1 -1
  158. package/standards/markdown.md +1 -0
  159. package/standards/mermaid.md +1 -7
  160. package/standards/requirements.md +2 -0
  161. package/standards/skill-practice.md +2 -1
  162. package/standards/skill.md +1 -0
  163. package/tooling/claude/reference.md +0 -5
  164. package/claude/skills/draft-diagram/REQUIREMENT.md +0 -45
  165. package/claude/skills/draft-diagram/SKILL.md +0 -177
  166. package/docs/agents/design-board.md +0 -33
  167. package/governance/rules/standards/diagrams.md +0 -14
  168. package/governance/rules/standards/mermaid.md +0 -15
  169. package/scripts/core/check-gallery-exclusion.sh +0 -50
  170. package/scripts/manage-sandbox.sh +0 -472
  171. package/src/design/board.ts +0 -576
  172. package/src/slides/layouts.ts +0 -478
  173. package/src/slides/parse.ts +0 -84
  174. package/src/slides/render.ts +0 -110
  175. package/src/slides/styles.ts +0 -96
  176. package/standards/diagrams.md +0 -132
  177. package/tooling/claude/seeds/.claude/diagrams/index.md +0 -8
  178. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  179. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  180. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  181. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  182. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  183. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  184. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  185. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -1,6 +1,12 @@
1
1
  /** The path segment that marks an image as evidence a pull request should compare. */
2
2
  const EVIDENCE_SEGMENT = 'evidence'
3
3
 
4
+ /** The most images a comment carries and still opens its states, since past it the page is a wall of screenshots. */
5
+ const EVIDENCE_OPEN_LIMIT = 6
6
+
7
+ /** Length of the commit names the visible line shows, the width `git` abbreviates to. */
8
+ const SHORT_SHA_LENGTH = 7
9
+
4
10
  /** Prefix of the trailing marker this module writes and reads back. */
5
11
  const MARKER_PREFIX = '<!-- pr-evidence:'
6
12
 
@@ -120,10 +126,14 @@ export function evidenceMarker(head: string): string {
120
126
  }
121
127
 
122
128
  /**
123
- * Renders the whole comment body: one collapsed `<details>` block per state,
124
- * a Base/Head row per case, and the trailing marker naming the head this body
125
- * describes. Every image URL is pinned to a commit sha rather than a branch,
126
- * so the comment keeps showing what it claimed even after the branch moves.
129
+ * Renders the whole comment body: one `<details>` block per state, a Base/Head
130
+ * row per case, and the trailing marker naming the head this body describes.
131
+ * Every state opens while the comment carries at most `EVIDENCE_OPEN_LIMIT`
132
+ * images, one per row for the head plus one per row not added, and every state
133
+ * closes past it, so a comment is never half open. A visible line under
134
+ * `## Evidence` names the base and head as short shas. Every image URL is
135
+ * pinned to a commit sha rather than a branch, so the comment keeps showing
136
+ * what it claimed even after the branch moves.
127
137
  *
128
138
  * A preview address opens the body, and with no states it is the whole body
129
139
  * apart from the marker, so a pull request whose screenshots did not change
@@ -157,6 +167,14 @@ export function renderEvidenceBody(
157
167
  return [...opening, ...closing, evidenceMarker(head)].join('\n')
158
168
  }
159
169
 
170
+ const imageCount = states.reduce(
171
+ (total, entry) =>
172
+ total + entry.items.reduce((sum, item) => sum + (item.added ? 1 : 2), 0),
173
+ 0,
174
+ )
175
+ const opener =
176
+ imageCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
177
+
160
178
  const sections = states.map((entry) => {
161
179
  const rows = entry.items.map((item) => {
162
180
  const before = item.added
@@ -167,7 +185,7 @@ export function renderEvidenceBody(
167
185
  })
168
186
 
169
187
  return [
170
- '<details>',
188
+ opener,
171
189
  `<summary>${entry.state === '' ? 'evidence' : entry.state} (${entry.items.length})</summary>`,
172
190
  '',
173
191
  '| Case | Base | Head |',
@@ -182,6 +200,8 @@ export function renderEvidenceBody(
182
200
  ...opening,
183
201
  '## Evidence',
184
202
  '',
203
+ `**Base:** \`${base.slice(0, SHORT_SHA_LENGTH)}\` · **Head:** \`${head.slice(0, SHORT_SHA_LENGTH)}\``,
204
+ '',
185
205
  ...sections,
186
206
  '',
187
207
  ...closing,
package/src/pr/rest.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The REST shapes `canon pr` reads a pull request through, put into the shapes
3
+ * its readers already take.
4
+ *
5
+ * A cloud session's GitHub proxy refuses GraphQL and serves REST, and every
6
+ * `gh pr view` runs on GraphQL. The verbs therefore read the pulls, reviews,
7
+ * and issue-comments endpoints, and this module is the one boundary where
8
+ * those payloads meet the field names the rest of `src/pr/` was written
9
+ * against.
10
+ */
11
+ import type { ReviewRow } from '@/pr/review-scope'
12
+
13
+ /** One row of `repos/{owner}/{repo}/pulls/<n>` or of the pulls listing. */
14
+ export interface RestPull {
15
+ readonly number?: number
16
+ readonly body?: string | null
17
+ readonly head?: { readonly ref?: string; readonly sha?: string }
18
+ readonly base?: { readonly ref?: string }
19
+ readonly mergeable_state?: string
20
+ }
21
+
22
+ /** One row of `repos/{owner}/{repo}/pulls/<n>/reviews`. */
23
+ export interface RestReview {
24
+ readonly body?: string | null
25
+ readonly commit_id?: string | null
26
+ readonly submitted_at?: string | null
27
+ }
28
+
29
+ /** One row of `repos/{owner}/{repo}/issues/<n>/comments`. */
30
+ export interface RestComment {
31
+ readonly html_url?: string
32
+ readonly body?: string | null
33
+ }
34
+
35
+ /** The head branch, reported head, and base of the pull request a caller named. */
36
+ export interface PullIdentity {
37
+ readonly number: number | undefined
38
+ readonly branch: string
39
+ readonly head?: string
40
+ readonly base?: string
41
+ readonly mergeState?: string
42
+ }
43
+
44
+ export type BranchPull =
45
+ | { readonly kind: 'found'; readonly number: number }
46
+ | {
47
+ readonly kind: 'refused'
48
+ readonly reason: 'gh-failed' | 'ambiguous-pull'
49
+ }
50
+
51
+ /**
52
+ * Spells `mergeable_state` the way GraphQL's `mergeStateStatus` spells it.
53
+ *
54
+ * The two vocabularies hold the same values in different cases, so the
55
+ * mapping is a case change. `unknown` stays `UNKNOWN`, which `withMergeState`
56
+ * already reads as not conflicted while GitHub computes the state lazily.
57
+ */
58
+ export function mergeStateOf(state: string | undefined): string | undefined {
59
+ return state === undefined || state === '' ? undefined : state.toUpperCase()
60
+ }
61
+
62
+ export function identityOf(row: RestPull): PullIdentity {
63
+ const mergeState = mergeStateOf(row.mergeable_state)
64
+ return {
65
+ number: row.number,
66
+ branch: row.head?.ref ?? '',
67
+ ...(row.head?.sha !== undefined && { head: row.head.sha }),
68
+ ...(row.base?.ref !== undefined && { base: row.base.ref }),
69
+ ...(mergeState !== undefined && { mergeState }),
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The one open pull request a branch carries.
75
+ *
76
+ * Two open pull requests on one head against different bases refuse rather
77
+ * than resolving to the first, since the listing's order says nothing about
78
+ * which one the caller meant.
79
+ */
80
+ export function selectBranchPull(rows: readonly RestPull[]): BranchPull {
81
+ const numbers = rows
82
+ .map((row) => row.number)
83
+ .filter((number): number is number => number !== undefined)
84
+ if (numbers.length > 1) return { kind: 'refused', reason: 'ambiguous-pull' }
85
+ const [number] = numbers
86
+ return number === undefined
87
+ ? { kind: 'refused', reason: 'gh-failed' }
88
+ : { kind: 'found', number }
89
+ }
90
+
91
+ /**
92
+ * A pending review carries no `submitted_at`, which reads as null the way the
93
+ * GraphQL listing reported it.
94
+ */
95
+ export function reviewRowOf(review: RestReview): ReviewRow {
96
+ return {
97
+ body: review.body ?? '',
98
+ commit:
99
+ review.commit_id === undefined || review.commit_id === null
100
+ ? null
101
+ : { oid: review.commit_id },
102
+ submittedAt: review.submitted_at ?? null,
103
+ }
104
+ }
105
+
106
+ /** `html_url` ends in the same `#issuecomment-<id>` anchor GraphQL's `url` did. */
107
+ export function commentRowOf(comment: RestComment): {
108
+ readonly url?: string
109
+ readonly body: string
110
+ } {
111
+ return {
112
+ ...(comment.html_url !== undefined && { url: comment.html_url }),
113
+ body: comment.body ?? '',
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Parses the output of `gh api --paginate --jq '.[] | @json'`, one compact
119
+ * object per line across every page.
120
+ *
121
+ * A filter that reshapes each row makes `--paginate` emit one value per page
122
+ * rather than one merged array, so a line per row is the form that survives
123
+ * paging. A line that does not parse refuses the whole listing, since a list
124
+ * short by one review reads as a thread missing its newest pass.
125
+ */
126
+ export function parseJsonLines<T>(stdout: string): T[] | undefined {
127
+ const rows: T[] = []
128
+ for (const line of stdout.split('\n')) {
129
+ if (line.trim() === '') continue
130
+ try {
131
+ rows.push(JSON.parse(line) as T)
132
+ } catch {
133
+ return undefined
134
+ }
135
+ }
136
+ return rows
137
+ }
@@ -76,6 +76,7 @@ export const RECORD_ENTRIES: readonly string[] = [
76
76
  'proposals',
77
77
  'ready',
78
78
  'review',
79
+ 'slides',
79
80
  'tasks',
80
81
  'teach',
81
82
  'transcripts',
@@ -43,6 +43,7 @@ export const BACKED_FOLDERS = [
43
43
  'plans',
44
44
  'proposals',
45
45
  'review',
46
+ 'slides',
46
47
  'tasks',
47
48
  'teach',
48
49
  'transcripts',
@@ -1,6 +1,6 @@
1
1
  import { lstat, readFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
- import { isBinary } from '@/binary'
3
+ import { isBinary } from '@/sweep/binary'
4
4
  import { scanText } from '@/secrets/scan'
5
5
 
6
6
  /**
@@ -0,0 +1,146 @@
1
+ import { $ } from 'bun'
2
+ import { existsSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { relative } from 'node:path'
5
+ import { gitEnv } from '@/git-env'
6
+ import { readReviewed } from '@/records/stale'
7
+ import { surfaceDir } from '@/surface-root'
8
+
9
+ /** The two records whose drift is identity rather than a stale path. */
10
+ const CANONICAL_DOCS = ['REQUIREMENTS.md', 'ARCHITECTURE.md'] as const
11
+
12
+ export interface CanonicalDoc {
13
+ /** Relative to the root, at whichever surface root carries the doc. */
14
+ readonly path: string
15
+ /** `YYYY-MM-DD`, or null when the doc carries no readable review date. */
16
+ readonly reviewed: string | null
17
+ /** The raw value of a `reviewed` field that is not a date, present only then. */
18
+ readonly invalidReviewed?: string
19
+ /** Release tags merged into HEAD after the reviewed day, or null when never reviewed. */
20
+ readonly releasesSince: number | null
21
+ /** The newest release tag merged into HEAD, or null when there is none. */
22
+ readonly latestRelease: string | null
23
+ }
24
+
25
+ export interface CanonicalReport {
26
+ readonly ok: true
27
+ readonly root: string
28
+ /** False when HEAD has merged no tag, so a zero count measured nothing. */
29
+ readonly tagged: boolean
30
+ readonly docs: readonly CanonicalDoc[]
31
+ }
32
+
33
+ export interface CanonicalRefused {
34
+ readonly ok: false
35
+ readonly reason: 'unreadable-tags'
36
+ readonly message: string
37
+ }
38
+
39
+ export type CanonicalOutcome = CanonicalReport | CanonicalRefused
40
+
41
+ interface Release {
42
+ readonly tag: string
43
+ readonly day: string
44
+ }
45
+
46
+ /**
47
+ * A tag ending in a dotted version, bare or behind a component prefix such as
48
+ * `canon-v2.1.0`. An eval, deploy, or snapshot tag on the trunk names no
49
+ * release and would otherwise inflate the count.
50
+ */
51
+ const RELEASE_TAG = /(?:^|[-/@_])v?\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$/
52
+
53
+ /**
54
+ * A repository before its first commit has no HEAD for `--merged` to resolve,
55
+ * which git reports as a failed read. It has no tags either, so it reads as an
56
+ * empty history rather than an unreadable one.
57
+ */
58
+ async function isUnborn(root: string): Promise<boolean> {
59
+ const [gitDir, head] = await Promise.all([
60
+ $`git -C ${root} rev-parse --git-dir`.env(gitEnv()).quiet().nothrow(),
61
+ $`git -C ${root} rev-parse --verify -q HEAD`
62
+ .env(gitEnv())
63
+ .quiet()
64
+ .nothrow(),
65
+ ])
66
+ return gitDir.exitCode === 0 && head.exitCode !== 0
67
+ }
68
+
69
+ /**
70
+ * Every release tag HEAD has merged, newest first, with the day it was created.
71
+ *
72
+ * The creator date is the tagging date on an annotated tag and the commit date
73
+ * on a lightweight one, so a history mixing both counts by two clocks.
74
+ */
75
+ async function mergedReleases(root: string): Promise<Release[] | undefined> {
76
+ // Interpolated rather than inline, since Bun's shell parses the bare
77
+ // parentheses in `%(refname)` as syntax of its own.
78
+ const args = [
79
+ '--merged',
80
+ 'HEAD',
81
+ '--sort=-creatordate',
82
+ '--format=%(creatordate:short) %(refname:short)',
83
+ 'refs/tags',
84
+ ]
85
+ const result = await $`git -C ${root} for-each-ref ${args}`
86
+ .env(gitEnv())
87
+ .quiet()
88
+ .nothrow()
89
+ if (result.exitCode !== 0) return (await isUnborn(root)) ? [] : undefined
90
+
91
+ return result.stdout
92
+ .toString()
93
+ .split('\n')
94
+ .filter((line) => line.trim().length > 0)
95
+ .map((line) => {
96
+ const [day, tag] = line.trim().split(' ')
97
+ return { day, tag }
98
+ })
99
+ .filter((release) => RELEASE_TAG.test(release.tag))
100
+ }
101
+
102
+ /**
103
+ * Reports when each canonical doc was last reviewed and how many releases
104
+ * have shipped since, read from an optional `reviewed:` frontmatter field.
105
+ *
106
+ * The review point is a date rather than a commit, since a commit written on a
107
+ * feature branch never reaches a trunk that squash-merges. A release tagged on
108
+ * the reviewed day itself is not counted, as the review is read as covering
109
+ * that day. It reports and never writes.
110
+ */
111
+ export async function staleCanonical(root: string): Promise<CanonicalOutcome> {
112
+ const releases = await mergedReleases(root)
113
+
114
+ if (releases === undefined) {
115
+ return {
116
+ ok: false,
117
+ reason: 'unreadable-tags',
118
+ message: `Could not read the release tags at ${root}, so no count can be taken.`,
119
+ }
120
+ }
121
+
122
+ const latestRelease = releases[0]?.tag ?? null
123
+ const present = CANONICAL_DOCS.map((entry) => surfaceDir(root, entry)).filter(
124
+ (path) => existsSync(path),
125
+ )
126
+
127
+ const docs = await Promise.all(
128
+ present.map(async (path): Promise<CanonicalDoc> => {
129
+ const text = (await readFile(path, 'utf8')).replaceAll('\r\n', '\n')
130
+ const review = readReviewed(text)
131
+ const { reviewed } = review
132
+
133
+ return {
134
+ path: relative(root, path),
135
+ ...review,
136
+ releasesSince:
137
+ reviewed === null
138
+ ? null
139
+ : releases.filter((release) => release.day > reviewed).length,
140
+ latestRelease,
141
+ }
142
+ }),
143
+ )
144
+
145
+ return { ok: true, root, tagged: releases.length > 0, docs }
146
+ }
@@ -65,7 +65,7 @@ const RAW_FRONTMATTER = /^---\n([\s\S]*?)\n---/
65
65
  * A value that is not a calendar date reads as never reviewed and is carried
66
66
  * back so the review can see it, rather than throwing over one hand edit.
67
67
  */
68
- function readReviewed(
68
+ export function readReviewed(
69
69
  text: string,
70
70
  ): Pick<StaleEntry, 'reviewed' | 'invalidReviewed'> {
71
71
  const block = RAW_FRONTMATTER.exec(text)?.[1]
@@ -0,0 +1,128 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { PROJECT_ROOT } from '@/project-root'
6
+
7
+ /**
8
+ * The bash entry a scenario's hooks run through. Scenarios and the library they
9
+ * call stay bash, so this contract is the whole of what the TypeScript harness
10
+ * knows about one: which hooks it declares and what `use_config` exports.
11
+ */
12
+ const HOOK_SCRIPT = join(PROJECT_ROOT, 'scripts', 'sandbox-hook.sh')
13
+
14
+ const ANCHOR_RECORD = '@anchor='
15
+
16
+ export interface Probe {
17
+ readonly exports: Readonly<Record<string, string>>
18
+ readonly isAnchor: boolean
19
+ }
20
+
21
+ export type ProbeResult =
22
+ | ({ readonly ok: true } & Probe)
23
+ | { readonly ok: false; readonly status: number }
24
+
25
+ /**
26
+ * Reads the NUL-separated `NAME=value` pairs the probe prints. NUL rather than a
27
+ * newline, since an exported value can hold one. The anchor record closes every
28
+ * well-formed probe, so output missing it is a probe that did not finish.
29
+ */
30
+ export function parseProbe(stdout: string): Probe {
31
+ const { exports, anchor } = parsePairs(stdout)
32
+ if (anchor === undefined)
33
+ throw new Error('the probe printed no anchor record')
34
+
35
+ return { exports, isAnchor: anchor === '1' }
36
+ }
37
+
38
+ function parsePairs(text: string): {
39
+ exports: Record<string, string>
40
+ anchor: string | undefined
41
+ } {
42
+ const exports: Record<string, string> = {}
43
+ let anchor: string | undefined
44
+
45
+ for (const record of text.split('\0')) {
46
+ if (record.startsWith(ANCHOR_RECORD)) {
47
+ anchor = record.slice(ANCHOR_RECORD.length)
48
+ continue
49
+ }
50
+ const split = record.indexOf('=')
51
+ if (split > 0) exports[record.slice(0, split)] = record.slice(split + 1)
52
+ }
53
+
54
+ return { exports, anchor }
55
+ }
56
+
57
+ /**
58
+ * Runs `use_config` and `use_anchor` in a child and returns what they exported.
59
+ * Stderr is inherited by default, so a hook that fails narrates into the same
60
+ * frame the harness writes. A non-zero status passes through rather than being
61
+ * flattened, since the harness exits with it.
62
+ */
63
+ export function probeScenario(
64
+ file: string,
65
+ env: NodeJS.ProcessEnv,
66
+ stderr: 'inherit' | 'pipe' = 'inherit',
67
+ ): ProbeResult {
68
+ const result = spawnSync('bash', [HOOK_SCRIPT, 'probe', file], {
69
+ env,
70
+ encoding: 'utf8',
71
+ stdio: ['ignore', 'pipe', stderr],
72
+ })
73
+ const status = result.status ?? 1
74
+ if (status !== 0) return { ok: false, status }
75
+
76
+ return { ok: true, ...parseProbe(result.stdout) }
77
+ }
78
+
79
+ /**
80
+ * How `stage_setup` ended. The retired dispatcher ran it in its own shell, so
81
+ * an `exit` there ended provisioning through the frame-closing trap, and an
82
+ * `exec` replaced the harness outright, leaving its program's frame as the last
83
+ * one written. The harness reproduces both from this.
84
+ */
85
+ export type StageEnding = 'returned' | 'exited' | 'replaced'
86
+
87
+ export interface StageResult {
88
+ readonly status: number
89
+ readonly ending: StageEnding
90
+ readonly exports: Readonly<Record<string, string>>
91
+ }
92
+
93
+ const EXITED_RECORD = '@exited'
94
+
95
+ /**
96
+ * Runs `stage_setup` inside the tree with every stream inherited, so a scenario
97
+ * prompting through `select_or_route_scenario` still reaches a terminal and its
98
+ * narration lands in order with the harness's own.
99
+ *
100
+ * The exports `stage_setup` changed come back through a file rather than a
101
+ * pipe. Stdout belongs to the scenario, and a background process a scenario
102
+ * starts would hold an extra pipe open past the child's exit.
103
+ */
104
+ export function stageScenario(
105
+ file: string,
106
+ env: NodeJS.ProcessEnv,
107
+ stdio: 'inherit' | 'ignore' = 'inherit',
108
+ ): StageResult {
109
+ const scratch = mkdtempSync(join(tmpdir(), 'canon-sandbox-hook-'))
110
+ const report = join(scratch, 'exports')
111
+
112
+ try {
113
+ const result = spawnSync('bash', [HOOK_SCRIPT, 'stage', file, report], {
114
+ env,
115
+ stdio,
116
+ })
117
+ const status = result.status ?? 1
118
+ if (!existsSync(report)) return { status, ending: 'replaced', exports: {} }
119
+
120
+ const text = readFileSync(report, 'utf8')
121
+ if (text.split('\0').includes(EXITED_RECORD))
122
+ return { status, ending: 'exited', exports: {} }
123
+
124
+ return { status, ending: 'returned', exports: parsePairs(text).exports }
125
+ } finally {
126
+ rmSync(scratch, { recursive: true, force: true })
127
+ }
128
+ }