@erclx/canon 5.73.0 → 5.75.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 (87) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/references/adopted.md +5 -3
  4. package/claude/skills/build-in-slices/references/adopted.md +4 -2
  5. package/claude/skills/code-craft/references/adopted.md +9 -7
  6. package/claude/skills/codebase-layout/references/adopted.md +8 -6
  7. package/claude/skills/create-rule/SKILL.md +5 -1
  8. package/claude/skills/create-rule/references/ledger.md +73 -0
  9. package/claude/skills/create-rule/references/rule.md +1 -1
  10. package/claude/skills/create-skill/references/skill-practice.md +6 -1
  11. package/claude/skills/deprecation-migration/references/adopted.md +5 -3
  12. package/claude/skills/design-pick/references/board.md +2 -2
  13. package/claude/skills/plan-feature/SKILL.md +2 -1
  14. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  15. package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
  16. package/claude/skills/pr-evidence/SKILL.md +2 -0
  17. package/claude/skills/pr-evidence/references/evidence.md +11 -4
  18. package/claude/skills/pr-evidence/references/reel.md +108 -0
  19. package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
  20. package/claude/skills/proposal-critique/SKILL.md +126 -0
  21. package/claude/skills/proposal-critique/references/adopted.md +44 -0
  22. package/claude/skills/review-craft/references/adopted.md +4 -4
  23. package/claude/skills/search-craft/references/adopted.md +9 -7
  24. package/claude/skills/systematic-debugging/references/adopted.md +4 -2
  25. package/claude/skills/teach-craft/references/adopted.md +8 -6
  26. package/claude/skills/test-craft/references/adopted.md +14 -12
  27. package/claude/skills/test-first/SKILL.md +1 -1
  28. package/claude/skills/test-first/references/adopted.md +9 -5
  29. package/claude/skills/ui-film/REQUIREMENT.md +4 -5
  30. package/claude/skills/ui-film/SKILL.md +21 -19
  31. package/claude/skills/ui-film/references/compose-craft.md +2 -2
  32. package/claude/skills/ui-film/references/handoff.md +2 -7
  33. package/claude/skills/write-human/references/adopted.md +8 -3
  34. package/docs/agents/audits.md +24 -24
  35. package/docs/agents/migrate.md +10 -10
  36. package/docs/migrations/by-verb.md +2 -0
  37. package/docs/target/index.md +1 -0
  38. package/docs/target/outside-tools.md +39 -0
  39. package/docs/workflow/skill-map.md +1 -0
  40. package/governance/detect.toml +4 -0
  41. package/governance/ledger/claude/rule.md +16 -0
  42. package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
  43. package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
  44. package/governance/ledger/skills/search-craft/SKILL.md +54 -0
  45. package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
  46. package/governance/ledger/skills/test-first/SKILL.md +28 -0
  47. package/governance/ledger/skills/write-human/SKILL.md +62 -0
  48. package/governance/ledger/skills/write-human/density.md +36 -0
  49. package/governance/ledger/skills/write-human/machine-tells.md +30 -0
  50. package/governance/rules/claude/rule.md +13 -0
  51. package/package.json +1 -1
  52. package/scripts/ci/docs-only.sh +24 -0
  53. package/scripts/ci/refresh-frames-pr.sh +11 -12
  54. package/src/audits/catalog.ts +7 -2
  55. package/src/capture/stamp.ts +3 -5
  56. package/src/claude/cases/misc.ts +14 -0
  57. package/src/claude/skills-audit.ts +154 -8
  58. package/src/cli/help.ts +1 -1
  59. package/src/commands/claude.ts +6 -2
  60. package/src/commands/gov.ts +12 -7
  61. package/src/commands/migrate.ts +6 -2
  62. package/src/commands/pr.ts +110 -13
  63. package/src/commands/take.ts +36 -7
  64. package/src/counts/catalogs.ts +3 -5
  65. package/src/gate/measures.ts +2 -145
  66. package/src/gate/sequencer.ts +7 -1
  67. package/src/gate/stages.ts +36 -47
  68. package/src/gov/audit.ts +59 -13
  69. package/src/markdown/scope.ts +4 -0
  70. package/src/migrate/session-defaults.ts +114 -4
  71. package/src/pr/checks.ts +75 -3
  72. package/src/pr/evidence.ts +62 -1
  73. package/src/pr/lift.ts +23 -2
  74. package/src/pr/merge.ts +13 -3
  75. package/src/take/encode.ts +9 -1
  76. package/src/take/film.ts +17 -2
  77. package/src/take/fixtures/busy.html +107 -0
  78. package/src/take/perform.ts +99 -25
  79. package/src/take/screencast.ts +4 -3
  80. package/src/take/stepper.ts +33 -20
  81. package/src/targets/pulls.ts +42 -0
  82. package/tooling/claude/reference.md +4 -2
  83. package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
  84. package/tooling/claude/seeds/.claude/settings.json +8 -0
  85. package/claude/skills/ui-film/references/pr-clip.md +0 -108
  86. package/scripts/core/regen-hero.ts +0 -113
  87. package/src/capture/frame-values.ts +0 -326
@@ -8,7 +8,6 @@ import {
8
8
  import {
9
9
  canonicalRecords,
10
10
  auditSet,
11
- captureStamps,
12
11
  clientCommandCitations,
13
12
  designProse,
14
13
  documentCeiling,
@@ -45,6 +44,12 @@ export type Check =
45
44
  | {
46
45
  readonly kind: 'command'
47
46
  readonly argv: readonly string[]
47
+ /**
48
+ * Builds the argv from the changed set when scoping is on. `argv` runs
49
+ * where it is off, so `--all` and every widening fallback keep the full
50
+ * command.
51
+ */
52
+ readonly changedArgv?: (changed: readonly string[]) => readonly string[]
48
53
  readonly failure: string
49
54
  }
50
55
  /** This checkout's own CLI, so a worktree gate never reads the main tree. */
@@ -92,33 +97,36 @@ export interface Stage {
92
97
  }
93
98
 
94
99
  /**
95
- * Corpora a `src/` test asserts over from outside `src/`. Nothing compares
96
- * this list to the tests reading those paths, so a test that starts reading a
97
- * new corpus joins it here in the same change, or a branch touching only that
98
- * corpus skips the Tests stage. The first four are directory prefixes because
99
- * their tests walk the tree whole, which is what reaches a rule or a skill a
100
- * branch adds rather than edits.
100
+ * Files a scoped run hands to `vitest related`, which follows imports from
101
+ * them to the tests that reach them. A corpus a test reads from disk is not
102
+ * among them, so a Markdown-only push runs no tests locally and CI's full run
103
+ * is what catches a broken corpus reader.
101
104
  */
102
- export const TEST_CORPORA_PATTERNS = [
103
- '^claude/skills/',
104
- '^governance/rules/',
105
- '^\\.claude/hooks/',
106
- '^tooling/claude/seeds/\\.claude/hooks/',
107
- '^tooling/base/reference\\.md$',
108
- '^tooling/web/configs/scripts/worktree-port\\.sh$',
109
- ]
105
+ const RELATED_SOURCES = /\.tsx?$|^commitlint\.config\.js$/
106
+
107
+ /** Files every test depends on without importing, so no related run sees them. */
108
+ const FULL_SUITE_TRIGGERS =
109
+ /^vitest\.(config|setup|global-setup)\.ts$|^tsconfig\.json$|^package\.json$/
110
110
 
111
111
  const TESTS_SCOPE = new RegExp(
112
- [
113
- '^src/',
114
- '^vitest\\.config\\.ts$',
115
- '^tsconfig\\.json$',
116
- '^package\\.json$',
117
- '^commitlint\\.config\\.(js|test\\.ts)$',
118
- ...TEST_CORPORA_PATTERNS,
119
- ].join('|'),
112
+ `${RELATED_SOURCES.source}|${FULL_SUITE_TRIGGERS.source}`,
120
113
  )
121
114
 
115
+ const FULL_SUITE = ['bun', 'run', 'test']
116
+
117
+ function relatedTests(changed: readonly string[]): readonly string[] {
118
+ if (changed.some((file) => FULL_SUITE_TRIGGERS.test(file))) return FULL_SUITE
119
+ return [
120
+ 'bun',
121
+ '--bun',
122
+ 'vitest',
123
+ 'related',
124
+ '--run',
125
+ '--passWithNoTests',
126
+ ...changed.filter((file) => RELATED_SOURCES.test(file)),
127
+ ]
128
+ }
129
+
122
130
  /**
123
131
  * Every stage the gate runs, in the order it runs them.
124
132
  *
@@ -182,28 +190,6 @@ export const STAGES: readonly Stage[] = [
182
190
  ],
183
191
  success: 'Consumed copies clean',
184
192
  },
185
- {
186
- // Only the markup is asserted for drift. The image beside it is a chromium
187
- // render whose bytes move with the browser version, so a drift check over it
188
- // would fail on a machine whose chromium differs rather than on a stale
189
- // count. The stamp measure below is what covers the image instead.
190
- //
191
- // The stage covers every frame under `assets/captures/` and keeps the
192
- // narrower name, because the label is spelled across the context entries and
193
- // the CI table and renaming it buys nothing the pathspec below does not
194
- // already say.
195
- id: 'hero',
196
- label: 'Hero',
197
- checks: [
198
- {
199
- kind: 'command',
200
- argv: ['bun', 'scripts/core/regen-hero.ts', '--check'],
201
- failure: 'Hero regen health check failed',
202
- },
203
- { kind: 'measure', measure: captureStamps },
204
- ],
205
- success: 'Hero clean',
206
- },
207
193
  {
208
194
  // `canon/DESIGN.md`, the web stylesheet, and the tab icon are written
209
195
  // from `src/design/tokens.ts`, and none is edited by hand. Three
@@ -590,14 +576,17 @@ export const STAGES: readonly Stage[] = [
590
576
  id: 'tests',
591
577
  label: 'Tests',
592
578
  scope: TESTS_SCOPE,
593
- skipped: 'Skipped, no TypeScript or asserted-corpus changes',
579
+ skipped: 'Skipped, no TypeScript or test config changes',
594
580
  // Concurrent suites from a wave of worktrees starve each other's spawn
595
581
  // and browser tests past their timeouts, so the box runs one at a time.
582
+ // A related run can still reach a spawn or browser test, so it holds the
583
+ // lock too.
596
584
  lock: 'tests',
597
585
  checks: [
598
586
  {
599
587
  kind: 'command',
600
- argv: ['bun', 'run', 'test'],
588
+ argv: FULL_SUITE,
589
+ changedArgv: relatedTests,
601
590
  failure: 'Tests failed',
602
591
  },
603
592
  ],
package/src/gov/audit.ts CHANGED
@@ -72,6 +72,13 @@ export const CORE_FOLDERS: readonly string[] = [
72
72
 
73
73
  const SHIPPED_RULES = join('governance', 'rules')
74
74
  const LEDGERS = join('governance', 'ledger')
75
+ const TARGET_RULES = join('.claude', 'rules', 'project')
76
+
77
+ /**
78
+ * Where a target keeps the ledgers behind its own rules. It sits outside
79
+ * `.claude/rules/`, where every `.md` loads as a rule, and nothing loads it.
80
+ */
81
+ export const TARGET_RULE_LEDGERS = join('canon', 'ledger', 'rules')
75
82
 
76
83
  /** Both corpora authored here, read when the tree is a canon checkout. */
77
84
  const CANON_CORPUS: readonly string[] = [
@@ -83,7 +90,7 @@ const CANON_CORPUS: readonly string[] = [
83
90
  * What a target authors. `.claude/rules/canon/` is installed from the toolkit
84
91
  * and fixed at its source, so a finding there is one the target cannot act on.
85
92
  */
86
- const TARGET_CORPUS: readonly string[] = [join('.claude', 'rules', 'project')]
93
+ const TARGET_CORPUS: readonly string[] = [TARGET_RULES]
87
94
 
88
95
  export interface ShapeFinding {
89
96
  readonly limit: RuleLimit
@@ -135,10 +142,18 @@ export type RuleAuditRefusal = 'no-rules' | 'no-path' | 'outside-root'
135
142
  export type RuleAuditReport =
136
143
  | {
137
144
  readonly kind: 'measured'
138
- /** `canon` reads both authored corpora and ledgers, `target` neither. */
145
+ /**
146
+ * `canon` reads both authored corpora and the toolkit's ledgers,
147
+ * `target` its project rules and any ledger the project wrote.
148
+ */
139
149
  readonly scope: 'canon' | 'target'
140
150
  readonly corpus: readonly string[]
141
151
  readonly rules: readonly RuleReading[]
152
+ /**
153
+ * Target ledgers whose rule is gone, read on a whole-corpus read alone,
154
+ * since a named path scopes the read to the files named.
155
+ */
156
+ readonly orphans: readonly string[]
142
157
  }
143
158
  | {
144
159
  readonly kind: 'unreadable'
@@ -336,7 +351,18 @@ export function compareLedger(
336
351
  function ledgerFor(
337
352
  file: string,
338
353
  touched: ReadonlySet<string>,
354
+ scope: 'canon' | 'target',
339
355
  ): { path: string; owed: boolean } | undefined {
356
+ // A project has no practice-folder list to owe a ledger against, so its
357
+ // ledger is checked where it exists and never owed.
358
+ if (scope === 'target') {
359
+ const prefix = `${TARGET_RULES}${sep}`
360
+ if (!file.startsWith(prefix)) return undefined
361
+ return {
362
+ path: join(TARGET_RULE_LEDGERS, file.slice(prefix.length)),
363
+ owed: false,
364
+ }
365
+ }
340
366
  const prefix = `${SHIPPED_RULES}${sep}`
341
367
  if (!file.startsWith(prefix)) return undefined
342
368
  const rest = file.slice(prefix.length)
@@ -352,8 +378,9 @@ async function readLedger(
352
378
  file: string,
353
379
  groups: readonly HeadingCount[],
354
380
  touched: ReadonlySet<string>,
381
+ scope: 'canon' | 'target',
355
382
  ): Promise<LedgerReading> {
356
- const ledger = ledgerFor(file, touched)
383
+ const ledger = ledgerFor(file, touched, scope)
357
384
  if (ledger === undefined) return { status: 'not-required' }
358
385
  const { path } = ledger
359
386
 
@@ -373,14 +400,29 @@ async function readLedger(
373
400
  }
374
401
  }
375
402
 
403
+ /**
404
+ * Target ledgers with no rule at the matching `.claude/rules/project/` path,
405
+ * so a rule moved or renamed away from its ledger is reported rather than
406
+ * dropping its check silently.
407
+ */
408
+ function findOrphanLedgers(root: string): string[] {
409
+ const dir = resolve(root, TARGET_RULE_LEDGERS)
410
+ if (!existsSync(dir)) return []
411
+ return listRuleFiles(dir)
412
+ .map((abs) => relative(dir, abs))
413
+ .filter((rest) => !existsSync(resolve(root, TARGET_RULES, rest)))
414
+ .map((rest) => join(TARGET_RULE_LEDGERS, rest))
415
+ }
416
+
376
417
  /**
377
418
  * Measures every rule this tree authors, or the files named.
378
419
  *
379
420
  * A tree holding `governance/rules/` is a canon checkout, read across both
380
421
  * authored corpora with a ledger asked of each outside-practice rule and of
381
422
  * each core rule in `touched`, and checked for any core rule carrying one.
382
- * Any other tree is a target, read at `.claude/rules/project/` alone with no
383
- * ledger asked, since ledgers never ship.
423
+ * Any other tree is a target, read at `.claude/rules/project/` alone, with
424
+ * each rule's ledger under `canon/ledger/rules/` checked where one exists and
425
+ * none owed, since the toolkit's ledgers never ship.
384
426
  *
385
427
  * `touched` holds paths relative to the root, and leaving it out reads no core
386
428
  * rule as touched.
@@ -457,11 +499,14 @@ export async function readRuleAudit(
457
499
  rules.push({
458
500
  file,
459
501
  ...shape,
460
- ledger: await readLedger(root, file, groups, touched),
502
+ ledger: await readLedger(root, file, groups, touched, scope),
461
503
  })
462
504
  }
463
505
 
464
- return { kind: 'measured', scope, corpus, rules }
506
+ const orphans =
507
+ scope === 'target' && paths.length === 0 ? findOrphanLedgers(root) : []
508
+
509
+ return { kind: 'measured', scope, corpus, rules, orphans }
465
510
  }
466
511
 
467
512
  export function hasFinding(reading: RuleReading): boolean {
@@ -487,12 +532,13 @@ export function auditExit(
487
532
  readonly exempt?: readonly string[]
488
533
  },
489
534
  ): number {
535
+ const counts = (file: string) =>
536
+ !options.gate ||
537
+ (options.gated.some((prefix) => file.startsWith(prefix)) &&
538
+ !options.exempt?.includes(file))
490
539
  const failing = report.rules.filter(
491
- (reading) =>
492
- hasFinding(reading) &&
493
- (!options.gate ||
494
- (options.gated.some((prefix) => reading.file.startsWith(prefix)) &&
495
- !options.exempt?.includes(reading.file))),
540
+ (reading) => hasFinding(reading) && counts(reading.file),
496
541
  )
497
- return failing.length > 0 ? 2 : 0
542
+ const orphaned = report.orphans.filter(counts)
543
+ return failing.length + orphaned.length > 0 ? 2 : 0
498
544
  }
@@ -158,6 +158,7 @@ export const GATED_PATHS: readonly string[] = [
158
158
  'claude/skills/context-fold/references/unusable-baseline.md',
159
159
  'claude/skills/create-rule/REQUIREMENT.md',
160
160
  'claude/skills/create-rule/SKILL.md',
161
+ 'claude/skills/create-rule/references/ledger.md',
161
162
  'claude/skills/create-rule/references/rule.md',
162
163
  'claude/skills/create-skill/REQUIREMENT.md',
163
164
  'claude/skills/create-skill/SKILL.md',
@@ -280,6 +281,9 @@ export const GATED_PATHS: readonly string[] = [
280
281
  'claude/skills/pr-evidence/REQUIREMENT.md',
281
282
  'claude/skills/pr-evidence/SKILL.md',
282
283
  'claude/skills/pr-evidence/references/evidence.md',
284
+ 'claude/skills/proposal-critique/REQUIREMENT.md',
285
+ 'claude/skills/proposal-critique/SKILL.md',
286
+ 'claude/skills/proposal-critique/references/adopted.md',
283
287
  'claude/skills/review-address/REQUIREMENT.md',
284
288
  'claude/skills/review-address/SKILL.md',
285
289
  'claude/skills/review-address/references/post-review-findings.md',
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Adds the session defaults the seeded `.claude/settings.json` carries to a
3
- * target that was seeded before them: the subagent deny, the memory default, and
4
- * the merge deny entries.
3
+ * target that was seeded before them: the subagent deny, the memory default,
4
+ * the merge deny entries, and the plan guard's PreToolUse wiring where the
5
+ * project holds the guard script.
5
6
  *
6
7
  * `canon claude init` copies a seed only when the file is absent, so a target
7
8
  * holding its own settings never sees a later seed change. This verb adds each
@@ -28,6 +29,26 @@ export const MERGE_DENIED: readonly string[] = [
28
29
  'Bash(gh pr merge:*)',
29
30
  ]
30
31
 
32
+ /** The command the seed wires, as the seeded settings spell it. */
33
+ export const PLAN_GUARD_COMMAND =
34
+ '"$CLAUDE_PROJECT_DIR"/.claude/hooks/plan-guard.sh'
35
+
36
+ const PLAN_GUARD_SCRIPT = 'plan-guard.sh'
37
+
38
+ /**
39
+ * Each tool the guard acts on, beside the seeded matcher that carries it. A
40
+ * guard wired by hand under any matcher covering the tool counts as wired.
41
+ */
42
+ const PLAN_GUARD_WIRING: readonly { tool: string; matcher: string }[] = [
43
+ { tool: 'Bash', matcher: 'Bash' },
44
+ { tool: 'Write', matcher: 'Write|Edit' },
45
+ ]
46
+
47
+ export interface SessionDefaultsOptions {
48
+ /** True when the project holds `.claude/hooks/plan-guard.sh` to wire. */
49
+ readonly planGuard?: boolean
50
+ }
51
+
31
52
  export interface SessionDefaultsPlan {
32
53
  readonly keys: readonly SettingsKey[]
33
54
  readonly next: SettingsRecord
@@ -66,6 +87,7 @@ function isRecord(value: unknown): value is SettingsRecord {
66
87
  */
67
88
  export function planSessionDefaults(
68
89
  current: SettingsRecord,
90
+ { planGuard = false }: SessionDefaultsOptions = {},
69
91
  ): SessionDefaultsPlan {
70
92
  const next: SettingsRecord = { ...current }
71
93
  const permissions = isRecord(current.permissions) ? current.permissions : {}
@@ -81,9 +103,16 @@ export function planSessionDefaults(
81
103
  const memoryAbsent = !('autoMemoryEnabled' in current)
82
104
  if (memoryAbsent) next.autoMemoryEnabled = false
83
105
 
106
+ const preToolUse = planGuard ? wirePlanGuard(current.hooks) : undefined
107
+ if (preToolUse !== undefined) {
108
+ const hooks = isRecord(current.hooks) ? current.hooks : {}
109
+ next.hooks = { ...hooks, PreToolUse: preToolUse }
110
+ }
111
+
84
112
  const keys: SettingsKey[] = [
85
113
  { label: 'permissions.deny', changed: missing.length > 0 },
86
114
  { label: 'autoMemoryEnabled', changed: memoryAbsent },
115
+ { label: 'hooks.PreToolUse', changed: preToolUse !== undefined },
87
116
  ]
88
117
  return {
89
118
  keys,
@@ -97,10 +126,72 @@ export function planSessionDefaults(
97
126
  }
98
127
  }
99
128
 
129
+ /**
130
+ * Claude Code reads an absent, empty, or `*` matcher as every tool, one of
131
+ * only name characters as exact names split on `|` or `,`, and any other as
132
+ * an unanchored regular expression, per its hooks reference.
133
+ */
134
+ function matcherCovers(matcher: unknown, tool: string): boolean {
135
+ if (matcher === undefined || matcher === '' || matcher === '*') return true
136
+ if (typeof matcher !== 'string') return false
137
+ if (/^[\w\s,|-]+$/.test(matcher)) {
138
+ return matcher.split(/[|,]/).some((name) => name.trim() === tool)
139
+ }
140
+ try {
141
+ return new RegExp(matcher).test(tool)
142
+ } catch {
143
+ return false
144
+ }
145
+ }
146
+
147
+ function runsPlanGuard(group: unknown, tool: string): boolean {
148
+ if (!isRecord(group) || !Array.isArray(group.hooks)) return false
149
+ if (!matcherCovers(group.matcher, tool)) return false
150
+ return group.hooks.some(
151
+ (hook) =>
152
+ isRecord(hook) &&
153
+ typeof hook.command === 'string' &&
154
+ hook.command.includes(PLAN_GUARD_SCRIPT),
155
+ )
156
+ }
157
+
158
+ /**
159
+ * Returns the PreToolUse list with the guard appended to each seeded matcher
160
+ * lacking it, or undefined when every tool already runs it. An existing group
161
+ * under the seeded matcher takes the entry, keeping its own hooks first.
162
+ */
163
+ function wirePlanGuard(hooks: unknown): unknown[] | undefined {
164
+ const current =
165
+ isRecord(hooks) && Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : []
166
+ const lacking = PLAN_GUARD_WIRING.filter(
167
+ ({ tool }) => !current.some((group) => runsPlanGuard(group, tool)),
168
+ )
169
+ if (lacking.length === 0) return undefined
170
+
171
+ const entry = { type: 'command', command: PLAN_GUARD_COMMAND }
172
+ const next = [...current]
173
+ for (const { matcher } of lacking) {
174
+ const at = next.findIndex(
175
+ (group) =>
176
+ isRecord(group) &&
177
+ group.matcher === matcher &&
178
+ Array.isArray(group.hooks),
179
+ )
180
+ const group = next[at]
181
+ if (isRecord(group) && Array.isArray(group.hooks)) {
182
+ next[at] = { ...group, hooks: [...group.hooks, entry] }
183
+ } else {
184
+ next.push({ matcher, hooks: [entry] })
185
+ }
186
+ }
187
+ return next
188
+ }
189
+
100
190
  /**
101
191
  * Refuses rather than plans whenever a write could replace something the
102
192
  * person owns: the toolkit's own settings, a file `JSON.parse` cannot read,
103
- * or a `permissions` or `deny` value of an unexpected shape.
193
+ * or a `permissions`, `deny`, `hooks`, or `PreToolUse` value of an
194
+ * unexpected shape.
104
195
  */
105
196
  export async function readSessionDefaults(
106
197
  root: string,
@@ -138,11 +229,30 @@ export async function readSessionDefaults(
138
229
  }
139
230
  }
140
231
 
232
+ const planGuard = existsSync(
233
+ join(root, '.claude', 'hooks', PLAN_GUARD_SCRIPT),
234
+ )
235
+ const { hooks } = read.value
236
+ if (planGuard && hooks !== undefined && !isRecord(hooks)) {
237
+ return { kind: 'refused', reason: `${path}: hooks is not an object` }
238
+ }
239
+ if (
240
+ planGuard &&
241
+ isRecord(hooks) &&
242
+ hooks.PreToolUse !== undefined &&
243
+ !Array.isArray(hooks.PreToolUse)
244
+ ) {
245
+ return {
246
+ kind: 'refused',
247
+ reason: `${path}: hooks.PreToolUse is not a list`,
248
+ }
249
+ }
250
+
141
251
  return {
142
252
  kind: 'planned',
143
253
  path,
144
254
  indent: read.indent,
145
- plan: planSessionDefaults(read.value),
255
+ plan: planSessionDefaults(read.value, { planGuard }),
146
256
  }
147
257
  }
148
258
 
package/src/pr/checks.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { type ChecksState, type RawCheck, rollup } from '@/targets/pulls'
1
+ import {
2
+ type ChecksState,
3
+ type RawCheck,
4
+ rollupRequired,
5
+ } from '@/targets/pulls'
2
6
 
3
7
  /** One row of `repos/{owner}/{repo}/commits/<sha>/check-runs`. */
4
8
  export interface RawCheckRun {
@@ -9,6 +13,12 @@ export interface RawCheckRun {
9
13
  readonly id?: number
10
14
  }
11
15
 
16
+ /** One row of `repos/{owner}/{repo}/commits/<sha>/status`, newest per context already. */
17
+ export interface RawStatus {
18
+ readonly context?: string
19
+ readonly state?: string
20
+ }
21
+
12
22
  /** What that endpoint returns, count and rows apart. */
13
23
  export interface CheckRunListing {
14
24
  readonly total_count?: number
@@ -27,6 +37,41 @@ export interface ChecksReading {
27
37
  readonly reported: number
28
38
  /** Runs `matched` carried for a check name this reading already kept a newer run for. */
29
39
  readonly collapsed: number
40
+ /**
41
+ * Failing checks outside the required set, present only when a required set
42
+ * was read, so a reading over every check keeps its old shape.
43
+ */
44
+ readonly advisory?: readonly string[]
45
+ }
46
+
47
+ /**
48
+ * Reads the check names a base branch's rules require, from
49
+ * `repos/{owner}/{repo}/rules/branches/<base>`.
50
+ *
51
+ * Undefined when the answer is not a rule list, which a repository without
52
+ * rulesets or a refused read returns, so the caller falls back to every check.
53
+ * Classic branch protection is not read, and falls back the same way.
54
+ */
55
+ export function requiredChecks(
56
+ rules: unknown,
57
+ ): ReadonlySet<string> | undefined {
58
+ if (!Array.isArray(rules)) return undefined
59
+
60
+ const names = new Set<string>()
61
+ for (const rule of rules as readonly RequiredRule[]) {
62
+ if (rule?.type !== 'required_status_checks') continue
63
+ for (const check of rule.parameters?.required_status_checks ?? []) {
64
+ if (typeof check.context === 'string') names.add(check.context)
65
+ }
66
+ }
67
+ return names
68
+ }
69
+
70
+ interface RequiredRule {
71
+ readonly type?: string
72
+ readonly parameters?: {
73
+ readonly required_status_checks?: readonly { readonly context?: unknown }[]
74
+ }
30
75
  }
31
76
 
32
77
  export interface ConflictedReading extends ChecksReading {
@@ -68,6 +113,7 @@ export function withMergeState(
68
113
  */
69
114
  function adapt(run: RawCheckRun): RawCheck {
70
115
  return {
116
+ ...(run.name !== undefined && { name: run.name }),
71
117
  ...(run.status !== undefined && { status: run.status.toUpperCase() }),
72
118
  ...(run.conclusion !== undefined &&
73
119
  run.conclusion !== null && {
@@ -125,10 +171,22 @@ function newestPerCheck(runs: readonly RawCheckRun[]): {
125
171
  * rolls up from `newestPerCheck`'s kept rows rather than from `matched`
126
172
  * directly, so a stale failing run left behind by a rerun no longer outvotes
127
173
  * the passing run beside it.
174
+ *
175
+ * A required set narrows only that last rollup. The foreign and partial
176
+ * listing guards still read every run, since either says the set in hand is
177
+ * incomplete whichever checks gate.
178
+ *
179
+ * A ruleset can require a commit status, which the check-run listing never
180
+ * carries, so the statuses join the rollup by `context` once a non-empty
181
+ * required set was read. An unread or empty set keeps them out, since the
182
+ * every-check fallback is the reading these verbs gave before rules were read
183
+ * and it saw no status.
128
184
  */
129
185
  export function collapseChecks(
130
186
  tip: string,
131
187
  listing: CheckRunListing,
188
+ required?: ReadonlySet<string>,
189
+ statuses: readonly RawStatus[] = [],
132
190
  ): ChecksReading {
133
191
  const runs = listing.check_runs ?? []
134
192
  const matched = runs.filter((run) => run.head_sha === tip)
@@ -139,6 +197,7 @@ export function collapseChecks(
139
197
  foreign: runs.length - matched.length,
140
198
  reported: listing.total_count ?? runs.length,
141
199
  collapsed,
200
+ ...(required !== undefined && { advisory: [] }),
142
201
  }
143
202
 
144
203
  if (reading.foreign > 0) return { ...reading, state: 'pending' }
@@ -149,7 +208,20 @@ export function collapseChecks(
149
208
  // sitting outside of, which is the false green keyed on a sha alone.
150
209
  if (reading.reported > runs.length) return { ...reading, state: 'pending' }
151
210
 
152
- if (matched.length === 0) return { ...reading, state: 'pending' }
211
+ const statusRows: RawCheck[] =
212
+ required === undefined || required.size === 0
213
+ ? []
214
+ : statuses.map((status) => ({
215
+ ...(status.context !== undefined && { name: status.context }),
216
+ state: (status.state ?? '').toUpperCase(),
217
+ }))
218
+ const rows = [...kept.map(adapt), ...statusRows]
219
+ if (rows.length === 0) return { ...reading, state: 'pending' }
153
220
 
154
- return { ...reading, state: rollup(kept.map(adapt)) ?? 'pending' }
221
+ const { state, advisory } = rollupRequired(rows, required)
222
+ return {
223
+ ...reading,
224
+ state: state ?? 'pending',
225
+ ...(required !== undefined && { advisory }),
226
+ }
155
227
  }
@@ -25,6 +25,12 @@ const REEL_PREFIX = '**Reel** filmed at'
25
25
  const REEL_LINE =
26
26
  /^\*\*Reel\*\* filmed at `([0-9a-f]{7,40})`:<br><img src="([^"]+)"/
27
27
 
28
+ /** How the line naming why no local address was posted opens. */
29
+ const LOCAL_SKIP_PREFIX = '**No local preview:**'
30
+
31
+ /** How the line naming why no reel was filmed opens. */
32
+ const REEL_SKIP_PREFIX = '**No reel:**'
33
+
28
34
  /** The width a reel is filmed at, which it asks for in the comment. */
29
35
  const REEL_WIDTH = 960
30
36
 
@@ -198,6 +204,15 @@ function escapeCell(text: string): string {
198
204
  .replaceAll('|', '&#124;')
199
205
  }
200
206
 
207
+ function unescapeCell(text: string): string {
208
+ return text
209
+ .replaceAll('&#124;', '|')
210
+ .replaceAll('&quot;', '"')
211
+ .replaceAll('&gt;', '>')
212
+ .replaceAll('&lt;', '<')
213
+ .replaceAll('&amp;', '&')
214
+ }
215
+
201
216
  function evidenceImage(url: string, alt: string, width?: number): string {
202
217
  const shown = Math.min(width ?? IMAGE_WIDTH, IMAGE_WIDTH)
203
218
  return `<img src="${url}" width="${shown}" alt="${alt}">`
@@ -449,6 +464,18 @@ function reelLine(reel: EvidenceReel): string {
449
464
  return `${REEL_PREFIX} \`${head}\`:<br><img src="${escapeCell(reel.link)}" width="${REEL_WIDTH}" alt="reel filmed at ${head}">`
450
465
  }
451
466
 
467
+ /** Why a render carries no local address or no reel, each a reason code and any one-line message the caller joined to it. */
468
+ export interface EvidenceSkips {
469
+ readonly local?: string
470
+ readonly reel?: string
471
+ }
472
+
473
+ /** A skip line, or nothing for an empty reason. A reason is folded onto one line, since a line break would end the address region early. */
474
+ function skipLines(prefix: string, reason: string | undefined): string[] {
475
+ const text = reason?.replace(/\s+/g, ' ').trim() ?? ''
476
+ return text === '' ? [] : [`${prefix} ${escapeCell(text)}`, '']
477
+ }
478
+
452
479
  export function evidenceMarker(head: string): string {
453
480
  return `${MARKER_PREFIX} head=${head} -->`
454
481
  }
@@ -480,6 +507,10 @@ export function evidenceMarker(head: string): string {
480
507
  *
481
508
  * A reel sits on its own line under the addresses, labeled with the head it
482
509
  * was filmed at, so a reel carried past a later push never reads as current.
510
+ *
511
+ * A skip names why the local address or the reel is missing, on its own line
512
+ * where that address or reel would sit. Each is dropped whenever its address
513
+ * or reel renders, carried or passed, so a skip never outlives its cause.
483
514
  */
484
515
  export function renderEvidenceBody(
485
516
  states: readonly EvidenceState[],
@@ -490,6 +521,7 @@ export function renderEvidenceBody(
490
521
  checklist?: string,
491
522
  local?: string,
492
523
  reel?: EvidenceReel,
524
+ skips: EvidenceSkips = {},
493
525
  ): string {
494
526
  const segments = [
495
527
  ...(preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`]),
@@ -499,7 +531,10 @@ export function renderEvidenceBody(
499
531
  EVIDENCE_HEADING,
500
532
  '',
501
533
  ...(segments.length === 0 ? [] : [segments.join(ADDRESS_SEPARATOR), '']),
502
- ...(reel === undefined ? [] : [reelLine(reel), '']),
534
+ ...(local === undefined ? skipLines(LOCAL_SKIP_PREFIX, skips.local) : []),
535
+ ...(reel === undefined
536
+ ? skipLines(REEL_SKIP_PREFIX, skips.reel)
537
+ : [reelLine(reel), '']),
503
538
  ]
504
539
  const closing =
505
540
  checklist === undefined
@@ -756,6 +791,32 @@ export function findEvidenceReel(
756
791
  return undefined
757
792
  }
758
793
 
794
+ /**
795
+ * The skips the marked comment already carries, read from the address region
796
+ * only, so a line quoted in a checklist is never taken for one. A re-render
797
+ * passing no skip carries these forward the way it carries an address.
798
+ */
799
+ export function findEvidenceSkips(
800
+ comments: readonly EvidenceComment[],
801
+ ): EvidenceSkips {
802
+ const marked = findMarked(comments)
803
+ if (marked === undefined) return {}
804
+ let local: string | undefined
805
+ let reel: string | undefined
806
+ for (const raw of marked.body.split('\n')) {
807
+ const line = raw.trim()
808
+ if (REGION_ENDS.some((end) => line.startsWith(end))) break
809
+ const ownSkip = readSegment(line, LOCAL_SKIP_PREFIX)
810
+ const reelSkip = readSegment(line, REEL_SKIP_PREFIX)
811
+ if (ownSkip !== undefined) local ??= unescapeCell(ownSkip)
812
+ if (reelSkip !== undefined) reel ??= unescapeCell(reelSkip)
813
+ }
814
+ return {
815
+ ...(local !== undefined && { local }),
816
+ ...(reel !== undefined && { reel }),
817
+ }
818
+ }
819
+
759
820
  /**
760
821
  * The reel a render embeds: a passed link, filmed at this head, replaces
761
822
  * whatever the comment carries, and with none passed the posted reel carries