@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.
- package/CHANGELOG.md +41 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/references/adopted.md +5 -3
- package/claude/skills/build-in-slices/references/adopted.md +4 -2
- package/claude/skills/code-craft/references/adopted.md +9 -7
- package/claude/skills/codebase-layout/references/adopted.md +8 -6
- package/claude/skills/create-rule/SKILL.md +5 -1
- package/claude/skills/create-rule/references/ledger.md +73 -0
- package/claude/skills/create-rule/references/rule.md +1 -1
- package/claude/skills/create-skill/references/skill-practice.md +6 -1
- package/claude/skills/deprecation-migration/references/adopted.md +5 -3
- package/claude/skills/design-pick/references/board.md +2 -2
- package/claude/skills/plan-feature/SKILL.md +2 -1
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
- package/claude/skills/pr-evidence/SKILL.md +2 -0
- package/claude/skills/pr-evidence/references/evidence.md +11 -4
- package/claude/skills/pr-evidence/references/reel.md +108 -0
- package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
- package/claude/skills/proposal-critique/SKILL.md +126 -0
- package/claude/skills/proposal-critique/references/adopted.md +44 -0
- package/claude/skills/review-craft/references/adopted.md +4 -4
- package/claude/skills/search-craft/references/adopted.md +9 -7
- package/claude/skills/systematic-debugging/references/adopted.md +4 -2
- package/claude/skills/teach-craft/references/adopted.md +8 -6
- package/claude/skills/test-craft/references/adopted.md +14 -12
- package/claude/skills/test-first/SKILL.md +1 -1
- package/claude/skills/test-first/references/adopted.md +9 -5
- package/claude/skills/ui-film/REQUIREMENT.md +4 -5
- package/claude/skills/ui-film/SKILL.md +21 -19
- package/claude/skills/ui-film/references/compose-craft.md +2 -2
- package/claude/skills/ui-film/references/handoff.md +2 -7
- package/claude/skills/write-human/references/adopted.md +8 -3
- package/docs/agents/audits.md +24 -24
- package/docs/agents/migrate.md +10 -10
- package/docs/migrations/by-verb.md +2 -0
- package/docs/target/index.md +1 -0
- package/docs/target/outside-tools.md +39 -0
- package/docs/workflow/skill-map.md +1 -0
- package/governance/detect.toml +4 -0
- package/governance/ledger/claude/rule.md +16 -0
- package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
- package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
- package/governance/ledger/skills/search-craft/SKILL.md +54 -0
- package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
- package/governance/ledger/skills/test-first/SKILL.md +28 -0
- package/governance/ledger/skills/write-human/SKILL.md +62 -0
- package/governance/ledger/skills/write-human/density.md +36 -0
- package/governance/ledger/skills/write-human/machine-tells.md +30 -0
- package/governance/rules/claude/rule.md +13 -0
- package/package.json +1 -1
- package/scripts/ci/docs-only.sh +24 -0
- package/scripts/ci/refresh-frames-pr.sh +11 -12
- package/src/audits/catalog.ts +7 -2
- package/src/capture/stamp.ts +3 -5
- package/src/claude/cases/misc.ts +14 -0
- package/src/claude/skills-audit.ts +154 -8
- package/src/cli/help.ts +1 -1
- package/src/commands/claude.ts +6 -2
- package/src/commands/gov.ts +12 -7
- package/src/commands/migrate.ts +6 -2
- package/src/commands/pr.ts +110 -13
- package/src/commands/take.ts +36 -7
- package/src/counts/catalogs.ts +3 -5
- package/src/gate/measures.ts +2 -145
- package/src/gate/sequencer.ts +7 -1
- package/src/gate/stages.ts +36 -47
- package/src/gov/audit.ts +59 -13
- package/src/markdown/scope.ts +4 -0
- package/src/migrate/session-defaults.ts +114 -4
- package/src/pr/checks.ts +75 -3
- package/src/pr/evidence.ts +62 -1
- package/src/pr/lift.ts +23 -2
- package/src/pr/merge.ts +13 -3
- package/src/take/encode.ts +9 -1
- package/src/take/film.ts +17 -2
- package/src/take/fixtures/busy.html +107 -0
- package/src/take/perform.ts +99 -25
- package/src/take/screencast.ts +4 -3
- package/src/take/stepper.ts +33 -20
- package/src/targets/pulls.ts +42 -0
- package/tooling/claude/reference.md +4 -2
- package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
- package/tooling/claude/seeds/.claude/settings.json +8 -0
- package/claude/skills/ui-film/references/pr-clip.md +0 -108
- package/scripts/core/regen-hero.ts +0 -113
- package/src/capture/frame-values.ts +0 -326
package/src/gate/stages.ts
CHANGED
|
@@ -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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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:
|
|
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[] = [
|
|
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
|
-
/**
|
|
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
|
|
383
|
-
* ledger
|
|
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
|
-
|
|
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
|
-
|
|
542
|
+
const orphaned = report.orphans.filter(counts)
|
|
543
|
+
return failing.length + orphaned.length > 0 ? 2 : 0
|
|
498
544
|
}
|
package/src/markdown/scope.ts
CHANGED
|
@@ -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,
|
|
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 `
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
const { state, advisory } = rollupRequired(rows, required)
|
|
222
|
+
return {
|
|
223
|
+
...reading,
|
|
224
|
+
state: state ?? 'pending',
|
|
225
|
+
...(required !== undefined && { advisory }),
|
|
226
|
+
}
|
|
155
227
|
}
|
package/src/pr/evidence.ts
CHANGED
|
@@ -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('|', '|')
|
|
199
205
|
}
|
|
200
206
|
|
|
207
|
+
function unescapeCell(text: string): string {
|
|
208
|
+
return text
|
|
209
|
+
.replaceAll('|', '|')
|
|
210
|
+
.replaceAll('"', '"')
|
|
211
|
+
.replaceAll('>', '>')
|
|
212
|
+
.replaceAll('<', '<')
|
|
213
|
+
.replaceAll('&', '&')
|
|
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
|
-
...(
|
|
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
|