@erclx/canon 5.32.0 → 5.34.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 (250) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +1 -2
  3. package/claude/skills/api-design/SKILL.md +2 -3
  4. package/claude/skills/api-design/references/adopted.md +1 -1
  5. package/claude/skills/auto-ship/SKILL.md +1 -1
  6. package/claude/skills/auto-ship/references/worktree-name.md +1 -1
  7. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  8. package/claude/skills/backlog-triage/SKILL.md +1 -1
  9. package/claude/skills/canon-cli/REQUIREMENT.md +16 -13
  10. package/claude/skills/canon-cli/SKILL.md +75 -32
  11. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
  12. package/claude/skills/canon-feedback/SKILL.md +11 -29
  13. package/claude/skills/canon-feedback/references/template.md +53 -0
  14. package/claude/skills/{canvas → canvas-board}/REQUIREMENT.md +2 -2
  15. package/claude/skills/{canvas → canvas-board}/SKILL.md +6 -3
  16. package/claude/skills/{canvas → canvas-board}/references/canvas.md +26 -3
  17. package/{standards → claude/skills/canvas-board/references}/design.md +11 -2
  18. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  19. package/claude/skills/code-craft/SKILL.md +0 -1
  20. package/claude/skills/context-fold/REQUIREMENT.md +25 -33
  21. package/claude/skills/context-fold/SKILL.md +25 -70
  22. package/claude/skills/context-fold/references/classify.md +3 -3
  23. package/claude/skills/context-fold/references/completion-report.md +4 -4
  24. package/claude/skills/context-fold/references/context-refresh.md +5 -5
  25. package/{standards → claude/skills/context-fold/references}/context.md +16 -5
  26. package/claude/skills/context-fold/references/unusable-baseline.md +3 -5
  27. package/claude/skills/create-rule/SKILL.md +3 -3
  28. package/{standards → claude/skills/create-rule/references}/rule.md +15 -6
  29. package/claude/skills/create-skill/SKILL.md +2 -2
  30. package/{standards → claude/skills/create-skill/references}/skill.md +18 -15
  31. package/claude/skills/design-taste/REQUIREMENT.md +3 -3
  32. package/claude/skills/design-taste/SKILL.md +1 -1
  33. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  34. package/claude/skills/docs-sync/SKILL.md +3 -3
  35. package/{standards → claude/skills/docs-sync/references}/docs.md +11 -0
  36. package/{standards → claude/skills/docs-sync/references}/readme.md +9 -2
  37. package/claude/skills/draft-and-pick/SKILL.md +5 -5
  38. package/claude/skills/draft-doc/SKILL.md +6 -6
  39. package/claude/skills/draft-figure/REQUIREMENT.md +2 -2
  40. package/claude/skills/draft-figure/SKILL.md +2 -2
  41. package/claude/skills/draft-figure/references/hand-drawn.md +1 -1
  42. package/claude/skills/draft-figure/references/mermaid-path.md +1 -1
  43. package/claude/skills/draft-identity/REQUIREMENT.md +2 -2
  44. package/claude/skills/draft-identity/SKILL.md +2 -2
  45. package/claude/skills/draft-ready/SKILL.md +1 -1
  46. package/claude/skills/git-branch/SKILL.md +1 -1
  47. package/{standards → claude/skills/git-branch/references}/branch.md +1 -1
  48. package/claude/skills/git-commit/SKILL.md +2 -2
  49. package/claude/skills/git-followup/REQUIREMENT.md +1 -1
  50. package/claude/skills/git-followup/SKILL.md +1 -1
  51. package/claude/skills/git-issue/SKILL.md +1 -1
  52. package/claude/skills/git-issue/references/issue.md +4 -1
  53. package/claude/skills/git-pr/SKILL.md +13 -13
  54. package/claude/skills/git-pr/references/evidence.md +2 -2
  55. package/claude/skills/git-pr/references/final-command.md +38 -14
  56. package/claude/skills/git-pr/references/labels.md +5 -1
  57. package/{standards → claude/skills/git-pr/references}/pr.md +19 -9
  58. package/{standards → claude/skills/git-pr/references}/publish.md +4 -2
  59. package/claude/skills/git-pr/references/task-number.md +1 -1
  60. package/claude/skills/git-ship/SKILL.md +15 -14
  61. package/claude/skills/git-split/SKILL.md +3 -3
  62. package/claude/skills/git-stage/SKILL.md +1 -1
  63. package/claude/skills/plan-feature/REQUIREMENT.md +1 -1
  64. package/claude/skills/review-address/SKILL.md +1 -1
  65. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  66. package/claude/skills/review-craft/references/ui.md +1 -1
  67. package/claude/skills/review-pr/REQUIREMENT.md +1 -1
  68. package/claude/skills/review-pr/SKILL.md +3 -3
  69. package/claude/skills/review-pr/references/body-variants.md +2 -2
  70. package/claude/skills/review-pr/references/key-changes.md +3 -3
  71. package/claude/skills/review-pr/references/post.md +4 -2
  72. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  73. package/claude/skills/review-ui/SKILL.md +8 -6
  74. package/claude/skills/review-ui/references/driving.md +8 -8
  75. package/claude/skills/review-ui/references/post.md +53 -17
  76. package/claude/skills/role-orchestrator/REQUIREMENT.md +3 -0
  77. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  78. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -2
  79. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +8 -5
  80. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +32 -1
  81. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +2 -2
  82. package/claude/skills/role-orchestrator/references/orchestrator-resume.md +3 -0
  83. package/claude/skills/role-planner/REQUIREMENT.md +2 -1
  84. package/claude/skills/role-planner/SKILL.md +13 -2
  85. package/claude/skills/role-reviewer/SKILL.md +1 -1
  86. package/claude/skills/role-worker/REQUIREMENT.md +1 -0
  87. package/claude/skills/role-worker/SKILL.md +9 -1
  88. package/claude/skills/session-map/SKILL.md +1 -1
  89. package/claude/skills/session-resume/REQUIREMENT.md +2 -2
  90. package/claude/skills/session-worktree/SKILL.md +5 -5
  91. package/claude/skills/target-setup/SKILL.md +10 -4
  92. package/claude/skills/target-setup/references/detect.md +20 -3
  93. package/claude/skills/task-board/REQUIREMENT.md +7 -12
  94. package/claude/skills/task-board/SKILL.md +5 -17
  95. package/claude/skills/task-board/references/tasks.md +2 -2
  96. package/claude/skills/task-complete/REQUIREMENT.md +47 -0
  97. package/claude/skills/task-complete/SKILL.md +86 -0
  98. package/claude/skills/{task-board → task-complete}/references/archive.md +4 -4
  99. package/claude/skills/{context-fold → task-complete}/references/receipt-sweep.md +5 -5
  100. package/claude/skills/task-complete/references/unusable-baseline.md +19 -0
  101. package/claude/skills/teach-workspace/REQUIREMENT.md +6 -2
  102. package/claude/skills/teach-workspace/SKILL.md +8 -4
  103. package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
  104. package/claude/skills/{context-fold/references/promoted-pages.md → teach-workspace/references/promotion-fold.md} +3 -3
  105. package/claude/skills/teach-workspace/references/promotion.md +3 -3
  106. package/claude/skills/test-craft/REQUIREMENT.md +2 -3
  107. package/claude/skills/test-craft/SKILL.md +15 -3
  108. package/claude/skills/ui-checklist/SKILL.md +1 -1
  109. package/claude/skills/ux-audit/SKILL.md +1 -1
  110. package/claude/skills/ux-measure/SKILL.md +1 -1
  111. package/docs/agents/canvas.md +6 -2
  112. package/docs/agents/commands.md +4 -3
  113. package/docs/agents/context-audit-checks.md +4 -4
  114. package/docs/agents/gov-detect.md +47 -0
  115. package/docs/agents/index.md +7 -4
  116. package/docs/agents/indexes.md +1 -1
  117. package/docs/agents/install-and-sync.md +10 -11
  118. package/docs/agents/key-changes.md +5 -5
  119. package/docs/agents/label-coverage.md +11 -0
  120. package/docs/agents/migrate.md +1 -1
  121. package/docs/agents/pr-evidence-layout.md +70 -0
  122. package/docs/agents/pr-evidence.md +8 -9
  123. package/docs/agents/pr-frames.md +7 -2
  124. package/docs/agents/pr-lift.md +132 -0
  125. package/docs/agents/records-push.md +2 -2
  126. package/docs/agents/records-stale.md +12 -47
  127. package/docs/agents/records-validate.md +3 -7
  128. package/docs/agents/records.md +5 -27
  129. package/docs/agents/rule-citations.md +1 -1
  130. package/docs/agents/sandbox.md +4 -4
  131. package/docs/agents/scripting.md +2 -1
  132. package/docs/agents/skills-audit.md +1 -1
  133. package/docs/agents/tasks-plans.md +2 -2
  134. package/docs/agents/tasks.md +2 -2
  135. package/docs/target/migrations.md +10 -2
  136. package/docs/target/projects.md +4 -4
  137. package/docs/target/sync.md +1 -1
  138. package/docs/workflow/operating-model.md +2 -2
  139. package/docs/workflow/parallel-features.md +7 -7
  140. package/docs/workflow/skill-map-on-request.md +9 -9
  141. package/docs/workflow/skill-map.md +12 -11
  142. package/docs/workflow/visual-design-workflow.md +2 -2
  143. package/governance/detect.toml +201 -0
  144. package/governance/renames.toml +0 -11
  145. package/governance/rules/canon/context.md +13 -0
  146. package/governance/rules/claude/skill.md +17 -0
  147. package/governance/rules/code/000-code.md +1 -13
  148. package/governance/rules/code/010-testing.md +2 -30
  149. package/governance/rules/ui/400-ui.md +1 -1
  150. package/governance/rules/ui/480-canvas.md +13 -0
  151. package/governance/stacks/base.toml +2 -4
  152. package/package.json +4 -4
  153. package/scripts/core/install-check.sh +3 -3
  154. package/scripts/core/regen-hero.ts +0 -6
  155. package/scripts/lib/ui.sh +0 -168
  156. package/src/audits/catalog.ts +0 -1
  157. package/src/canvas/arrange.ts +66 -0
  158. package/src/canvas/capture.ts +20 -6
  159. package/src/canvas/content.ts +39 -0
  160. package/src/canvas/fit.ts +238 -0
  161. package/src/canvas/tokens.ts +28 -10
  162. package/src/capture/frame-values.ts +2 -32
  163. package/src/claude/cases/misc.ts +1 -1
  164. package/src/claude/cases/setup.ts +4 -0
  165. package/src/claude/cases/workflow.ts +6 -1
  166. package/src/claude/seeds.ts +1 -1
  167. package/src/claude/skills-audit.ts +2 -2
  168. package/src/cli/help.ts +3 -1
  169. package/src/cli/ui.ts +2 -3
  170. package/src/commands/canvas.ts +179 -1
  171. package/src/commands/claude.ts +1 -1
  172. package/src/commands/gov.ts +146 -16
  173. package/src/commands/labels.ts +326 -7
  174. package/src/commands/migrate.ts +6 -1
  175. package/src/commands/pr.ts +377 -2
  176. package/src/commands/records.ts +13 -399
  177. package/src/commands/sandbox.ts +3 -6
  178. package/src/commands/tasks.ts +3 -3
  179. package/src/context/audit.ts +5 -5
  180. package/src/context/folders.ts +1 -1
  181. package/src/design/budget.ts +1 -1
  182. package/src/design/components.ts +1 -1
  183. package/src/design/document.ts +1 -1
  184. package/src/design/regen.ts +1 -1
  185. package/src/design/render.ts +1 -1
  186. package/src/feedback/body.ts +26 -5
  187. package/src/gate/measures.ts +59 -45
  188. package/src/gate/sequencer.ts +56 -1
  189. package/src/gate/stages.ts +4 -11
  190. package/src/gov/detect.ts +301 -0
  191. package/src/gov/install.ts +2 -3
  192. package/src/gov/list.ts +23 -2
  193. package/src/gov/stacks.ts +10 -1
  194. package/src/hooks/post-merge.ts +1 -1
  195. package/src/labels/apply.ts +39 -0
  196. package/src/labels/format.ts +3 -3
  197. package/src/labels/phase.ts +3 -3
  198. package/src/labels/sections.ts +43 -0
  199. package/src/migrate/skill-names.ts +20 -1
  200. package/src/pr/evidence.ts +218 -45
  201. package/src/pr/frames.ts +23 -3
  202. package/src/pr/lift.ts +418 -0
  203. package/src/pr/paths.ts +4 -4
  204. package/src/pr/rest.ts +3 -0
  205. package/src/pr/review-scope.ts +76 -0
  206. package/src/records/backup.ts +16 -6
  207. package/src/records/stale-canonical.ts +31 -1
  208. package/src/records/validate.ts +2 -188
  209. package/src/sandbox/census.ts +1 -1
  210. package/src/sandbox/coverage.ts +2 -6
  211. package/src/sandbox/equivalence.ts +19 -51
  212. package/src/sandbox/expect.ts +4 -4
  213. package/src/sandbox/provision.ts +13 -36
  214. package/src/sandbox/scenario.ts +26 -48
  215. package/src/sandbox/tree.ts +2 -6
  216. package/src/standards/read.ts +14 -1
  217. package/src/sync/git.ts +1 -1
  218. package/src/tasks/archive.ts +5 -5
  219. package/src/tasks/branch.ts +3 -3
  220. package/src/tasks/label.ts +1 -1
  221. package/src/tasks/record.ts +2 -2
  222. package/src/tasks/validate.ts +1 -1
  223. package/standards/index.md +0 -13
  224. package/standards/standard.md +1 -7
  225. package/tooling/base/reference.md +1 -1
  226. package/tooling/base/seeds/.github/pull_request_template.md +2 -2
  227. package/tooling/claude/reference.md +1 -1
  228. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +9 -1
  229. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +171 -0
  230. package/tooling/claude/seeds/.claude/settings.json +9 -0
  231. package/governance/rules/standards/context.md +0 -14
  232. package/governance/rules/standards/design.md +0 -12
  233. package/governance/rules/standards/readme.md +0 -12
  234. package/governance/rules/standards/rule.md +0 -13
  235. package/governance/rules/standards/skill.md +0 -21
  236. package/governance/rules/standards/standard.md +0 -13
  237. package/scripts/config.sh +0 -11
  238. package/scripts/lib/gov.sh +0 -14
  239. package/scripts/lib/sandbox-fixtures.sh +0 -191
  240. package/scripts/lib/sandbox-git.sh +0 -125
  241. package/scripts/lib/sandbox-path.sh +0 -50
  242. package/scripts/sandbox-hook.sh +0 -99
  243. package/src/gov/standard-rules.ts +0 -179
  244. package/src/records/migrate.ts +0 -78
  245. package/src/records/stale.ts +0 -243
  246. package/src/sandbox/hooks.ts +0 -128
  247. /package/{standards → claude/skills/draft-figure/references}/figures.md +0 -0
  248. /package/{standards → claude/skills/git-branch/references}/slug.md +0 -0
  249. /package/{standards → claude/skills/git-commit/references}/commit.md +0 -0
  250. /package/{standards → claude/skills/git-commit/references}/versioning.md +0 -0
package/src/pr/lift.ts ADDED
@@ -0,0 +1,418 @@
1
+ import type { ChecksState } from '@/targets/pulls'
2
+ import {
3
+ coversHead,
4
+ type ReviewRow,
5
+ resolveReviewScope,
6
+ resolveUiScope,
7
+ } from '@/pr/review-scope'
8
+
9
+ /** How long `--wait` holds for a pending CI run, in seconds, when none is named. */
10
+ export const DEFAULT_WAIT_SECONDS = 1200
11
+
12
+ /** The gap between two reads of the check runs while the verb waits on them. */
13
+ export const CHECKS_POLL_MS = 30_000
14
+
15
+ /** Why the verb did not lift, in the order the gates are read. */
16
+ export type LiftRefusal =
17
+ | 'pull-closed'
18
+ | 'review-missing'
19
+ | 'review-open'
20
+ | 'review-unmarked'
21
+ | 'review-behind'
22
+ | 'ui-missing'
23
+ | 'ui-open'
24
+ | 'ui-unmarked'
25
+ | 'ui-behind'
26
+ | 'evidence-owed'
27
+ | 'standing-stale'
28
+ | 'standing-conflict'
29
+ | 'standing-unknown'
30
+ | 'ci-pending'
31
+ | 'ci-failing'
32
+ | 'ci-conflicted'
33
+ | 'ci-timeout'
34
+ | 'head-moved'
35
+ | 'gh-failed'
36
+
37
+ /** The gates a pull request clears, named so a record can report each one. */
38
+ export type GateName = 'review' | 'ui' | 'evidence' | 'standing' | 'ci'
39
+
40
+ export interface GateResult {
41
+ readonly gate: GateName
42
+ readonly isOk: boolean
43
+ /** Absent when the gate passed. */
44
+ readonly reason?: LiftRefusal
45
+ }
46
+
47
+ /** How the head sits against the base the pull request targets. */
48
+ export type Standing = 'fresh' | 'stale' | 'conflict' | 'unknown'
49
+
50
+ export interface PullState {
51
+ readonly isDraft: boolean
52
+ readonly isOpen: boolean
53
+ /** The id the ready-for-review mutation takes. */
54
+ readonly nodeId: string
55
+ readonly branch: string
56
+ /** The head the pull request object reports, which lags the tip. */
57
+ readonly head: string
58
+ }
59
+
60
+ /** What the evidence check says about the comment the ship chain posts. */
61
+ export interface EvidenceGate {
62
+ readonly isSettled: boolean
63
+ readonly owed: readonly string[]
64
+ /** Whether the marked comment carries a visual checklist, which binds the UI gate. */
65
+ readonly hasChecklist: boolean
66
+ }
67
+
68
+ export interface ChecksGate {
69
+ readonly state: ChecksState
70
+ readonly isConflicted: boolean
71
+ }
72
+
73
+ /**
74
+ * Everything the verb reads or writes, injected so the gates and the wait run
75
+ * without a network. A read that failed returns undefined, and the verb
76
+ * refuses `gh-failed` rather than reading absence as a pass.
77
+ */
78
+ export interface LiftApi {
79
+ readPull(): Promise<PullState | undefined>
80
+ /** The branch tip from the remote, which is the authority for the head. */
81
+ readTip(branch: string): Promise<string | undefined>
82
+ readReviews(): Promise<readonly ReviewRow[] | undefined>
83
+ readEvidence(): Promise<EvidenceGate | undefined>
84
+ readStanding(head: string): Promise<Standing>
85
+ readChecks(tip: string): Promise<ChecksGate | undefined>
86
+ /** The one GraphQL call the verb makes. */
87
+ markReady(nodeId: string): Promise<boolean>
88
+ sleep(ms: number): Promise<void>
89
+ now(): number
90
+ }
91
+
92
+ export interface LiftOptions {
93
+ readonly isCheck: boolean
94
+ readonly waitSeconds: number
95
+ readonly pollMs?: number
96
+ }
97
+
98
+ export interface LiftRecord {
99
+ readonly reason: 'lifted' | 'already-ready' | 'clear' | LiftRefusal
100
+ readonly isLifted: boolean
101
+ /** Whether the caller can treat the pull request as ready afterwards. */
102
+ readonly isOk: boolean
103
+ readonly head?: string
104
+ readonly gates: readonly GateResult[]
105
+ readonly waitedSeconds: number
106
+ }
107
+
108
+ function pass(gate: GateName): GateResult {
109
+ return { gate, isOk: true }
110
+ }
111
+
112
+ function fail(gate: GateName, reason: LiftRefusal): GateResult {
113
+ return { gate, isOk: false, reason }
114
+ }
115
+
116
+ /**
117
+ * The code review gate. A pass with no marker is refused rather than lifted,
118
+ * since the commit it covers is a stamp GitHub wrote at submission, which the
119
+ * marker exists to replace.
120
+ */
121
+ function reviewGate(reviews: readonly ReviewRow[], head: string): GateResult {
122
+ const scope = resolveReviewScope({ reviews })
123
+ if (scope.state === 'none') return fail('review', 'review-missing')
124
+ if (scope.state === 'open') return fail('review', 'review-open')
125
+ if (scope.source !== 'marker') return fail('review', 'review-unmarked')
126
+ if (scope.commit === undefined || !coversHead(scope.commit, head)) {
127
+ return fail('review', 'review-behind')
128
+ }
129
+ return pass('review')
130
+ }
131
+
132
+ /** Binds only where the evidence record carries a checklist to drive. */
133
+ function uiGate(
134
+ reviews: readonly ReviewRow[],
135
+ head: string,
136
+ hasChecklist: boolean,
137
+ ): GateResult {
138
+ if (!hasChecklist) return pass('ui')
139
+
140
+ const scope = resolveUiScope({ reviews })
141
+ if (scope.state === 'none') return fail('ui', 'ui-missing')
142
+ if (scope.state === 'open') return fail('ui', 'ui-open')
143
+ if (scope.source !== 'marker') return fail('ui', 'ui-unmarked')
144
+ if (scope.commit === undefined || !coversHead(scope.commit, head)) {
145
+ return fail('ui', 'ui-behind')
146
+ }
147
+ return pass('ui')
148
+ }
149
+
150
+ function standingGate(standing: Standing): GateResult {
151
+ if (standing === 'fresh') return pass('standing')
152
+ return fail('standing', `standing-${standing}`)
153
+ }
154
+
155
+ type CiOutcome =
156
+ | { readonly kind: 'passing'; readonly waited: number }
157
+ | {
158
+ readonly kind: 'refused'
159
+ readonly reason: LiftRefusal
160
+ readonly waited: number
161
+ }
162
+
163
+ /**
164
+ * Reads the check runs until they conclude, the deadline passes, or a run can
165
+ * no longer start. A conflicted branch gets no merge ref, so no run ever
166
+ * starts and waiting on it is the one wait that cannot end.
167
+ */
168
+ async function waitForChecks(
169
+ api: LiftApi,
170
+ tip: string,
171
+ waitSeconds: number,
172
+ pollMs: number,
173
+ ): Promise<CiOutcome> {
174
+ const started = api.now()
175
+ const deadline = started + waitSeconds * 1000
176
+
177
+ for (;;) {
178
+ const reading = await api.readChecks(tip)
179
+ const waited = Math.round((api.now() - started) / 1000)
180
+ if (reading === undefined) {
181
+ return { kind: 'refused', reason: 'gh-failed', waited }
182
+ }
183
+ if (reading.state === 'passing') return { kind: 'passing', waited }
184
+ if (reading.state === 'failing') {
185
+ return { kind: 'refused', reason: 'ci-failing', waited }
186
+ }
187
+ if (reading.isConflicted) {
188
+ return { kind: 'refused', reason: 'ci-conflicted', waited }
189
+ }
190
+ if (api.now() + pollMs > deadline) {
191
+ return { kind: 'refused', reason: 'ci-timeout', waited }
192
+ }
193
+ await api.sleep(pollMs)
194
+ }
195
+ }
196
+
197
+ function refused(
198
+ reason: LiftRefusal,
199
+ gates: readonly GateResult[],
200
+ head: string | undefined,
201
+ waitedSeconds = 0,
202
+ ): LiftRecord {
203
+ return {
204
+ reason,
205
+ isLifted: false,
206
+ isOk: false,
207
+ ...(head !== undefined && { head }),
208
+ gates,
209
+ waitedSeconds,
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Lifts the draft mark when every gate reads clear at the head.
215
+ *
216
+ * The reads that need no wait all run before the CI wait, so a reviewer whose
217
+ * partner is still driving refuses at once rather than holding a turn through
218
+ * a CI run. The head and the reviews are read again after the wait and before
219
+ * the mutation, since a push during the wait moves the head off the commit
220
+ * every earlier gate cleared.
221
+ */
222
+ export async function lift(
223
+ api: LiftApi,
224
+ opts: LiftOptions,
225
+ ): Promise<LiftRecord> {
226
+ const pollMs = opts.pollMs ?? CHECKS_POLL_MS
227
+
228
+ const pull = await api.readPull()
229
+ if (pull === undefined) return refused('gh-failed', [], undefined)
230
+ if (!pull.isOpen) return refused('pull-closed', [], pull.head)
231
+ if (!pull.isDraft) {
232
+ return {
233
+ reason: 'already-ready',
234
+ isLifted: false,
235
+ isOk: true,
236
+ head: pull.head,
237
+ gates: [],
238
+ waitedSeconds: 0,
239
+ }
240
+ }
241
+
242
+ // The tip, not the object's head, since the object lags the ref by up to a
243
+ // minute after a push and a review marker names the commit it read.
244
+ const tip = await api.readTip(pull.branch)
245
+ if (tip === undefined) return refused('gh-failed', [], pull.head)
246
+
247
+ const [reviews, evidence, standing] = await Promise.all([
248
+ api.readReviews(),
249
+ api.readEvidence(),
250
+ api.readStanding(tip),
251
+ ])
252
+ if (reviews === undefined || evidence === undefined) {
253
+ return refused('gh-failed', [], tip)
254
+ }
255
+
256
+ const gates: GateResult[] = [
257
+ reviewGate(reviews, tip),
258
+ uiGate(reviews, tip, evidence.hasChecklist),
259
+ evidence.isSettled ? pass('evidence') : fail('evidence', 'evidence-owed'),
260
+ standingGate(standing),
261
+ ]
262
+ const blocked = gates.find((gate) => !gate.isOk)
263
+
264
+ if (opts.isCheck) {
265
+ const reading = await api.readChecks(tip)
266
+ if (reading === undefined) {
267
+ return refused('gh-failed', gates, tip)
268
+ }
269
+ const ci =
270
+ reading.state === 'passing'
271
+ ? pass('ci')
272
+ : reading.state === 'failing'
273
+ ? fail('ci', 'ci-failing')
274
+ : reading.isConflicted
275
+ ? fail('ci', 'ci-conflicted')
276
+ : fail('ci', 'ci-pending')
277
+ const all = [...gates, ci]
278
+ const first = all.find((gate) => !gate.isOk)
279
+ if (first?.reason !== undefined) return refused(first.reason, all, tip)
280
+ return {
281
+ reason: 'clear',
282
+ isLifted: false,
283
+ isOk: true,
284
+ head: tip,
285
+ gates: all,
286
+ waitedSeconds: 0,
287
+ }
288
+ }
289
+
290
+ if (blocked?.reason !== undefined) return refused(blocked.reason, gates, tip)
291
+
292
+ const ci = await waitForChecks(api, tip, opts.waitSeconds, pollMs)
293
+ if (ci.kind === 'refused') {
294
+ return refused(ci.reason, [...gates, fail('ci', ci.reason)], tip, ci.waited)
295
+ }
296
+ const cleared = [...gates, pass('ci')]
297
+
298
+ // The re-read: whatever the gates cleared above was cleared at `tip`.
299
+ const [nowPull, nowTip, nowReviews] = await Promise.all([
300
+ api.readPull(),
301
+ api.readTip(pull.branch),
302
+ api.readReviews(),
303
+ ])
304
+ if (
305
+ nowPull === undefined ||
306
+ nowTip === undefined ||
307
+ nowReviews === undefined
308
+ ) {
309
+ return refused('gh-failed', cleared, tip, ci.waited)
310
+ }
311
+ if (!nowPull.isDraft) {
312
+ return {
313
+ reason: 'already-ready',
314
+ isLifted: false,
315
+ isOk: true,
316
+ head: nowTip,
317
+ gates: cleared,
318
+ waitedSeconds: ci.waited,
319
+ }
320
+ }
321
+ if (nowTip !== tip) return refused('head-moved', cleared, nowTip, ci.waited)
322
+
323
+ const reread = [
324
+ reviewGate(nowReviews, nowTip),
325
+ uiGate(nowReviews, nowTip, evidence.hasChecklist),
326
+ ].find((gate) => !gate.isOk)
327
+ if (reread?.reason !== undefined) {
328
+ return refused(reread.reason, cleared, nowTip, ci.waited)
329
+ }
330
+
331
+ // Standing is read again too, since a sibling merging during the wait can
332
+ // leave a branch that read fresh before it stale.
333
+ const laterStanding = standingGate(await api.readStanding(nowTip))
334
+ if (laterStanding.reason !== undefined) {
335
+ return refused(laterStanding.reason, cleared, nowTip, ci.waited)
336
+ }
337
+
338
+ const isMarked = await api.markReady(pull.nodeId)
339
+ if (!isMarked) return refused('gh-failed', cleared, nowTip, ci.waited)
340
+
341
+ return {
342
+ reason: 'lifted',
343
+ isLifted: true,
344
+ isOk: true,
345
+ head: nowTip,
346
+ gates: cleared,
347
+ waitedSeconds: ci.waited,
348
+ }
349
+ }
350
+
351
+ /** One git invocation, answering its stdout or undefined when it failed. */
352
+ export type GitRunner = (args: readonly string[]) => Promise<string | undefined>
353
+
354
+ function nonEmptyLines(output: string): string[] {
355
+ return output.split('\n').filter((line) => line !== '')
356
+ }
357
+
358
+ /**
359
+ * Reads where a head stands against the base, ported from the orchestrator
360
+ * poll's own read so the lift and the poll answer one question the same way.
361
+ *
362
+ * merge-tree answers locally, because `mergeable_state` reads unknown while
363
+ * GitHub computes it, which is exactly when a verb asks. Only a change to a
364
+ * path the branch writes makes it stale, since re-testing after every merge to
365
+ * the base is a merge queue's cost without its batching. A head or base that
366
+ * does not resolve, which a fork or a deleted head leaves, reads `unknown`.
367
+ */
368
+ export async function readBaseStanding(
369
+ git: GitRunner,
370
+ number: number,
371
+ head: string,
372
+ ref: string,
373
+ ): Promise<Standing> {
374
+ // The base is fetched as well as the head. A tracking ref is only as current
375
+ // as the last fetch, so a sibling merging since then would read as fresh.
376
+ const baseBranch = ref.startsWith('origin/')
377
+ ? ref.slice('origin/'.length)
378
+ : ref
379
+ await git(['fetch', '-q', 'origin', baseBranch])
380
+ await git(['fetch', '-q', 'origin', `pull/${number}/head`])
381
+
382
+ const hasHead =
383
+ (await git(['cat-file', '-e', `${head}^{commit}`])) !== undefined
384
+ const hasRef = (await git(['rev-parse', '--verify', '-q', ref])) !== undefined
385
+ if (!hasHead || !hasRef) return 'unknown'
386
+
387
+ const merged = await git(['merge-tree', '--write-tree', ref, head])
388
+ if (merged === undefined) return 'conflict'
389
+
390
+ const forkPoint = (await git(['merge-base', ref, head]))?.trim()
391
+ if (forkPoint === undefined || forkPoint === '') return 'unknown'
392
+
393
+ const written = await git([
394
+ 'diff',
395
+ '--name-only',
396
+ '--no-renames',
397
+ forkPoint,
398
+ head,
399
+ ])
400
+ if (written === undefined) return 'unknown'
401
+
402
+ const paths = nonEmptyLines(written)
403
+ if (paths.length === 0) return 'fresh'
404
+
405
+ const moved = await git([
406
+ '--literal-pathspecs',
407
+ 'diff',
408
+ '--name-only',
409
+ '--no-renames',
410
+ forkPoint,
411
+ ref,
412
+ '--',
413
+ ...paths,
414
+ ])
415
+ if (moved === undefined) return 'unknown'
416
+
417
+ return nonEmptyLines(moved).length > 0 ? 'stale' : 'fresh'
418
+ }
package/src/pr/paths.ts CHANGED
@@ -3,12 +3,12 @@ import { findBacktickSpans } from '@/markdown/backticks'
3
3
  /**
4
4
  * The heading whose bullets state what a branch changed.
5
5
  *
6
- * Read alone, and never widened to a sibling section. `## Technical Context`
6
+ * Read alone, and never widened to a sibling section. `## Technical context`
7
7
  * legitimately names files a branch never touched, such as an install stamp
8
8
  * inside a target, so a reader that took the whole body would manufacture a
9
9
  * finding out of every argument the author made for the change.
10
10
  */
11
- export const KEY_CHANGES = 'Key Changes'
11
+ export const KEY_CHANGES = 'Key changes'
12
12
 
13
13
  /** What one bullet claimed, kept with its bullet so a finding can quote it. */
14
14
  export interface PathClaim {
@@ -86,7 +86,7 @@ const BARE_LINE = /^:\d+(?:-\d+)?$/
86
86
  * `canon markdown audit .claude/rules --json` carries a slash and names no file.
87
87
  * Angle brackets answer a second, since `.canon/plans/feature-<slug>.md`
88
88
  * describes a shape rather than naming a file. A glob and a caret describe a
89
- * shape too, `^src/` being a grep pattern one body spelled in Key Changes, and
89
+ * shape too, `^src/` being a grep pattern one body spelled in Key changes, and
90
90
  * a leading anchor names something outside this repository.
91
91
  *
92
92
  * Deliberately not shared with `classifySpan` in `@/gov/citations`, which asks
@@ -307,7 +307,7 @@ function resolveSpan(span: string): ResolvedSpan | undefined {
307
307
  }
308
308
 
309
309
  /**
310
- * Every path the `## Key Changes` section claims a change to.
310
+ * Every path the `## Key changes` section claims a change to.
311
311
  *
312
312
  * `roots` names the entries the tree holds at its top level, which is what
313
313
  * separates a whole path from one written partially. It is passed in rather
package/src/pr/rest.ts CHANGED
@@ -17,6 +17,9 @@ export interface RestPull {
17
17
  readonly head?: { readonly ref?: string; readonly sha?: string }
18
18
  readonly base?: { readonly ref?: string }
19
19
  readonly mergeable_state?: string
20
+ readonly state?: string
21
+ readonly draft?: boolean
22
+ readonly node_id?: string
20
23
  }
21
24
 
22
25
  /** One row of `repos/{owner}/{repo}/pulls/<n>/reviews`. */
@@ -131,6 +131,82 @@ function markerOf(
131
131
  return { commit, readAt }
132
132
  }
133
133
 
134
+ /** The two headings `review-ui` posts a verdict under. */
135
+ export const UI_HEADINGS = ['## UI review', '## UI review closed'] as const
136
+
137
+ export type UiHeading = (typeof UI_HEADINGS)[number]
138
+
139
+ export interface UiScope {
140
+ readonly heading?: UiHeading
141
+ readonly state: ReviewState
142
+ /** The head the verdict covered, a full or abbreviated sha. */
143
+ readonly commit?: string
144
+ /** `marker` is the verdict's own record, `fallback` is GitHub's stamp. */
145
+ readonly source: ScopeSource
146
+ }
147
+
148
+ /**
149
+ * The marker `review-ui` writes as the last line of every verdict. It names the
150
+ * head the pass drove, since the submission stamp moves onto a commit pushed
151
+ * while the pass was driving.
152
+ */
153
+ const UI_MARKER = /^<!--\s*review-ui:\s*head=([0-9a-f]{7,40})\s*-->$/
154
+
155
+ function lastLine(body: string): string | undefined {
156
+ const lines = body.split('\n')
157
+
158
+ let index = lines.length - 1
159
+ while (index >= 0 && (lines[index] ?? '').trim() === '') index -= 1
160
+ if (index < 0) return undefined
161
+
162
+ return (lines[index] ?? '').trim()
163
+ }
164
+
165
+ /**
166
+ * Resolves the newest UI verdict and the head it covered.
167
+ *
168
+ * Read off `reviews` like the code review family, since `review-ui` posts each
169
+ * verdict as a pull request review. The first line is matched for equality so
170
+ * `## UI review closed` is never taken for the open heading by prefix.
171
+ */
172
+ export function resolveUiScope(listing: ReviewListing): UiScope {
173
+ let newest: { heading: UiHeading; row: ReviewRow } | undefined
174
+
175
+ for (const row of listing.reviews ?? []) {
176
+ const first = ((row.body ?? '').split('\n')[0] ?? '').replace(/\r$/, '')
177
+ const heading = UI_HEADINGS.find((candidate) => candidate === first)
178
+ if (heading !== undefined) newest = { heading, row }
179
+ }
180
+
181
+ if (newest === undefined) return { state: 'none', source: 'none' }
182
+
183
+ const { heading, row } = newest
184
+ const state: ReviewState = heading === '## UI review' ? 'open' : 'closed'
185
+ const line = lastLine(row.body ?? '')
186
+ const match = line === undefined ? null : UI_MARKER.exec(line)
187
+ const marked = match?.[1]
188
+
189
+ if (marked !== undefined) {
190
+ return { heading, state, commit: marked, source: 'marker' }
191
+ }
192
+
193
+ const oid = row.commit?.oid
194
+ return {
195
+ heading,
196
+ state,
197
+ ...(oid === undefined || oid === '' ? {} : { commit: oid }),
198
+ source: 'fallback',
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Whether a covered commit names the head. A marker may carry a short sha, so
204
+ * coverage is a prefix match on the head.
205
+ */
206
+ export function coversHead(commit: string, head: string): boolean {
207
+ return commit !== '' && head !== '' && head.startsWith(commit)
208
+ }
209
+
134
210
  /**
135
211
  * Resolves what the newest review pass covered.
136
212
  *
@@ -30,7 +30,7 @@ import { type BlockedPath, guardPayload } from '@/records/push-guard'
30
30
  * rather than named here. That is what keeps this list at one line per surface
31
31
  * as archives spread, which a sibling-per-archive layout could not.
32
32
  *
33
- * `RECORD_KINDS` in `validate.ts` overlaps this on five names and carries one
33
+ * `RECORD_KINDS` in `validate.ts` overlaps this on four names and carries one
34
34
  * more that no backup reaches. The two lists differ on purpose: one is what a
35
35
  * standard governs, this is what a disk loss would take, and `standards` is
36
36
  * tracked so a backup would carry a second copy of committed files.
@@ -39,7 +39,6 @@ export const BACKED_FOLDERS = [
39
39
  'diagrams',
40
40
  'groundwork',
41
41
  'intake',
42
- 'memory',
43
42
  'plans',
44
43
  'proposals',
45
44
  'review',
@@ -51,6 +50,15 @@ export const BACKED_FOLDERS = [
51
50
  'walkthroughs',
52
51
  ] as const
53
52
 
53
+ /**
54
+ * Folders a retired kind left at a legacy `.claude` root that the allowlist
55
+ * must still read, so a half-finished move that strands one still trips
56
+ * `split-roots`. Absent from `BACKED_FOLDERS` because nothing writes them, and
57
+ * absent from `RETIRED_FOLDERS` because that list stages a deletion and this
58
+ * folder is an archive to keep.
59
+ */
60
+ const LEGACY_RECORD_FOLDERS = ['memory'] as const
61
+
54
62
  /**
55
63
  * Names that have left `BACKED_FOLDERS` and whose removal still has to reach a
56
64
  * records history once.
@@ -124,7 +132,9 @@ function isExcluded(name: string): boolean {
124
132
  */
125
133
  function foldersAt(dir: string): string[] {
126
134
  if (basename(dir) === '.claude') {
127
- return BACKED_FOLDERS.filter((folder) => existsSync(join(dir, folder)))
135
+ return [...BACKED_FOLDERS, ...LEGACY_RECORD_FOLDERS].filter((folder) =>
136
+ existsSync(join(dir, folder)),
137
+ )
128
138
  }
129
139
 
130
140
  const entries = existsSync(dir)
@@ -378,7 +388,7 @@ async function enclosingRemotes(
378
388
  * Clears the four gates both verbs share and returns the records remote URL.
379
389
  *
380
390
  * The last two are the ones the payload depends on. This repository is public,
381
- * so a records branch on any of its remotes serves the memory pen and the
391
+ * so a records branch on any of its remotes serves the plans and the
382
392
  * groundwork trails to anyone who fetches all refs. Comparing the configured
383
393
  * URL against every remote of the enclosing repository is what keeps a
384
394
  * misconfigured `origin` from publishing them, and refusing when that list
@@ -416,7 +426,7 @@ async function resolveRemote(
416
426
  if (!enclosing) {
417
427
  return refuse(
418
428
  'remote-unreadable',
419
- `Cannot read the remotes of the project at ${root}, so the records origin cannot be checked against them. Records carry the memory pen and the groundwork trails, and an unchecked origin risks publishing them.`,
429
+ `Cannot read the remotes of the project at ${root}, so the records origin cannot be checked against them. Records carry the plans and the groundwork trails, and an unchecked origin risks publishing them.`,
420
430
  )
421
431
  }
422
432
 
@@ -424,7 +434,7 @@ async function resolveRemote(
424
434
  if (enclosing.identities.includes(url)) {
425
435
  return refuse(
426
436
  'remote-shared',
427
- `The records origin ${remote.text} is a remote of this project. Records carry the memory pen and the groundwork trails, so they need a repository of their own.`,
437
+ `The records origin ${remote.text} is a remote of this project. Records carry the plans and the groundwork trails, so they need a repository of their own.`,
428
438
  )
429
439
  }
430
440
 
@@ -3,7 +3,6 @@ import { existsSync } from 'node:fs'
3
3
  import { readFile } from 'node:fs/promises'
4
4
  import { relative } from 'node:path'
5
5
  import { gitEnv } from '@/git/env'
6
- import { readReviewed } from '@/records/stale'
7
6
  import { surfaceDir } from '@/roots/surface'
8
7
 
9
8
  /** The record whose drift is identity rather than a stale path. */
@@ -22,6 +21,37 @@ export interface CanonicalDoc {
22
21
  readonly latestRelease: string | null
23
22
  }
24
23
 
24
+ const REVIEWED_FIELD = /^reviewed:[ \t]*(.*?)[ \t]*$/m
25
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
26
+ const RAW_FRONTMATTER = /^---\n([\s\S]*?)\n---/
27
+
28
+ /**
29
+ * Reads `reviewed` off the raw block rather than the parsed fields, for the
30
+ * reason `hasOpeningDate` in the validator gives: a YAML parser resolves a
31
+ * bare `YYYY-MM-DD` to a date on one schema and a string on another.
32
+ *
33
+ * A value that is not a calendar date reads as never reviewed and is carried
34
+ * back so the review can see it, rather than throwing over one hand edit.
35
+ */
36
+ function readReviewed(
37
+ text: string,
38
+ ): Pick<CanonicalDoc, 'reviewed' | 'invalidReviewed'> {
39
+ const block = RAW_FRONTMATTER.exec(text)?.[1]
40
+ const match = block ? REVIEWED_FIELD.exec(block) : null
41
+ if (!match) return { reviewed: null }
42
+
43
+ const value = match[1].replace(/^(['"])(.*)\1$/, '$2')
44
+ const parsed = Date.parse(`${value}T00:00:00Z`)
45
+ const isDate =
46
+ ISO_DATE.test(value) &&
47
+ Number.isFinite(parsed) &&
48
+ new Date(parsed).toISOString().slice(0, 10) === value
49
+
50
+ return isDate
51
+ ? { reviewed: value }
52
+ : { reviewed: null, invalidReviewed: value }
53
+ }
54
+
25
55
  export interface CanonicalReport {
26
56
  readonly ok: true
27
57
  readonly root: string