@erclx/canon 5.72.1 → 5.74.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 (79) hide show
  1. package/CHANGELOG.md +40 -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/app-drive/REQUIREMENT.md +2 -2
  5. package/claude/skills/app-drive/SKILL.md +22 -5
  6. package/claude/skills/app-drive/references/config.md +2 -1
  7. package/claude/skills/app-drive/references/drive-record.md +13 -0
  8. package/claude/skills/app-drive/references/isolate.md +1 -1
  9. package/claude/skills/build-in-slices/references/adopted.md +4 -2
  10. package/claude/skills/code-craft/references/adopted.md +9 -7
  11. package/claude/skills/codebase-layout/references/adopted.md +8 -6
  12. package/claude/skills/create-skill/references/skill-practice.md +5 -0
  13. package/claude/skills/deprecation-migration/references/adopted.md +5 -3
  14. package/claude/skills/design-pick/references/board.md +2 -2
  15. package/claude/skills/plan-feature/SKILL.md +3 -2
  16. package/claude/skills/plan-feature/references/plan.md +12 -2
  17. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  18. package/claude/skills/plan-intake/references/intake.md +3 -2
  19. package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
  20. package/claude/skills/pr-evidence/SKILL.md +2 -0
  21. package/claude/skills/pr-evidence/references/evidence.md +11 -4
  22. package/claude/skills/{ui-film/references/pr-clip.md → pr-evidence/references/reel.md} +21 -21
  23. package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
  24. package/claude/skills/proposal-critique/SKILL.md +126 -0
  25. package/claude/skills/review-craft/references/adopted.md +4 -4
  26. package/claude/skills/role-orchestrator/SKILL.md +3 -2
  27. package/claude/skills/role-orchestrator/references/orchestrator-filing.md +8 -0
  28. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +4 -1
  29. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +26 -2
  30. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +22 -3
  31. package/claude/skills/role-planner/SKILL.md +3 -1
  32. package/claude/skills/role-reviewer/SKILL.md +15 -4
  33. package/claude/skills/search-craft/references/adopted.md +9 -7
  34. package/claude/skills/systematic-debugging/REQUIREMENT.md +1 -0
  35. package/claude/skills/systematic-debugging/SKILL.md +21 -8
  36. package/claude/skills/systematic-debugging/references/adopted.md +15 -4
  37. package/claude/skills/teach-craft/references/adopted.md +8 -6
  38. package/claude/skills/test-craft/references/adopted.md +14 -12
  39. package/claude/skills/test-first/SKILL.md +1 -1
  40. package/claude/skills/test-first/references/adopted.md +9 -5
  41. package/claude/skills/ui-film/REQUIREMENT.md +5 -6
  42. package/claude/skills/ui-film/SKILL.md +21 -13
  43. package/claude/skills/ui-film/references/compose-craft.md +3 -2
  44. package/claude/skills/ui-film/references/handoff.md +12 -8
  45. package/claude/skills/ui-film/references/take-craft.md +10 -0
  46. package/claude/skills/write-human/references/adopted.md +8 -3
  47. package/docs/agents/records.md +1 -1
  48. package/docs/target/index.md +1 -0
  49. package/docs/target/outside-tools.md +39 -0
  50. package/docs/workflow/operating-model.md +8 -8
  51. package/docs/workflow/parallel-features.md +1 -1
  52. package/docs/workflow/skill-map.md +2 -1
  53. package/governance/ledger/canon/plan.md +20 -0
  54. package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
  55. package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
  56. package/governance/ledger/skills/search-craft/SKILL.md +54 -0
  57. package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
  58. package/governance/ledger/skills/test-first/SKILL.md +28 -0
  59. package/governance/ledger/skills/write-human/SKILL.md +62 -0
  60. package/governance/ledger/skills/write-human/density.md +36 -0
  61. package/governance/ledger/skills/write-human/machine-tells.md +30 -0
  62. package/governance/rules/canon/plan.md +1 -0
  63. package/package.json +1 -1
  64. package/scripts/ci/docs-only.sh +24 -0
  65. package/src/claude/cases/misc.ts +14 -0
  66. package/src/claude/skills-audit.ts +89 -8
  67. package/src/commands/intake.ts +4 -1
  68. package/src/commands/pr.ts +110 -13
  69. package/src/gate/measures.ts +1 -1
  70. package/src/gate/sequencer.ts +7 -1
  71. package/src/gate/stages.ts +36 -24
  72. package/src/intake/drive-check.ts +75 -4
  73. package/src/markdown/scope.ts +2 -0
  74. package/src/pr/checks.ts +75 -3
  75. package/src/pr/evidence.ts +62 -1
  76. package/src/pr/lift.ts +23 -2
  77. package/src/pr/merge.ts +13 -3
  78. package/src/records/backup.ts +1 -0
  79. package/src/targets/pulls.ts +42 -0
@@ -119,7 +119,12 @@ export const BULLET_LEDGER_SKILLS: Readonly<Record<string, BulletLedgerScope>> =
119
119
  },
120
120
  [join(SHIPPED_CORPUS, 'teach-craft')]: { exempt: [], body: true },
121
121
  [join(SHIPPED_CORPUS, 'test-craft')]: { exempt: [], body: true },
122
+ [join(SHIPPED_CORPUS, 'test-first')]: { exempt: [], body: true },
122
123
  [join(SHIPPED_CORPUS, 'codebase-layout')]: { exempt: [], body: true },
124
+ [join(SHIPPED_CORPUS, 'search-craft')]: { exempt: [], body: true },
125
+ [join(SHIPPED_CORPUS, 'write-human')]: { exempt: [], body: true },
126
+ [join(SHIPPED_CORPUS, 'systematic-debugging')]: { exempt: [], body: true },
127
+ [join(SHIPPED_CORPUS, 'build-in-slices')]: { exempt: [], body: true },
123
128
  }
124
129
 
125
130
  /** Where a per-bullet ledger lives, as `<skill>/<file>.md` below it. */
@@ -161,6 +166,9 @@ export const PRACTICE_LEDGER = join('references', 'adopted.md')
161
166
  */
162
167
  const EXTERNAL_LINK = /\]\(\s*https?:\/\/|<https?:\/\//
163
168
 
169
+ /** The address an inline link or an autolink cites, captured for comparison. */
170
+ const EXTERNAL_ADDRESS = /\]\(\s*(https?:\/\/[^)\s]+)|<(https?:\/\/[^>\s]+)>/g
171
+
164
172
  /**
165
173
  * A table cell opening on the marker the governance ledgers already use for a
166
174
  * rule the toolkit chose, so one check reads every ledger alike.
@@ -277,6 +285,8 @@ interface ReferenceLedger {
277
285
  readonly ledgerRel: string
278
286
  /** Undefined when the ledger file is absent. */
279
287
  readonly mismatches: readonly LedgerMismatch[] | undefined
288
+ /** Every source the ledger links, normalized by `sourceKey`. */
289
+ readonly sources: readonly string[]
280
290
  }
281
291
 
282
292
  interface SkillText {
@@ -453,13 +463,22 @@ async function readReferenceLedgers(
453
463
  files.map(async ({ path, text }) => {
454
464
  const ledgerRel = skillLedgerPath(folder, path)
455
465
  const ledgerPath = join(root, ledgerRel)
456
- const mismatches = existsSync(ledgerPath)
457
- ? compareLedger(
458
- ledgerGroups(text, path === 'SKILL.md'),
459
- countLedgerRows(await Bun.file(ledgerPath).text()),
460
- )
466
+ const ledger = existsSync(ledgerPath)
467
+ ? await Bun.file(ledgerPath).text()
461
468
  : undefined
462
- return { rel: join(skillRel, path), ledgerRel, mismatches }
469
+ const mismatches =
470
+ ledger === undefined
471
+ ? undefined
472
+ : compareLedger(
473
+ ledgerGroups(text, path === 'SKILL.md'),
474
+ countLedgerRows(ledger),
475
+ )
476
+ return {
477
+ rel: join(skillRel, path),
478
+ ledgerRel,
479
+ mismatches,
480
+ sources: ledger === undefined ? [] : linkedSources(ledger),
481
+ }
463
482
  }),
464
483
  )
465
484
  }
@@ -681,6 +700,7 @@ function practiceFindings(source: SkillSource): SkillFinding[] {
681
700
  return [
682
701
  ...sections,
683
702
  ...ledgerFindings(source),
703
+ ...duplicateFindings(source),
684
704
  ...source.referenceLedgers.flatMap(referenceLedgerFindings),
685
705
  ...source.staleExemptions.map((rel) => ({
686
706
  rel,
@@ -710,17 +730,78 @@ function ledgerFindings(source: SkillSource): SkillFinding[] {
710
730
  return [{ rel: source.rel, detail: `missing ledger: ${PRACTICE_LEDGER}` }]
711
731
  }
712
732
 
713
- const cites = bodyLines(source.ledger)
714
- .filter((line) => !line.fenced)
733
+ const lines = bodyLines(source.ledger).filter((line) => !line.fenced)
734
+ const cites = lines
715
735
  .map((line) => maskCodeSpans(line.text))
716
736
  .some((text) => EXTERNAL_LINK.test(text) || HOUSE_CHOICE.test(text))
717
737
  if (cites) return []
738
+ if (pointsAtLedgers(source, lines)) return []
718
739
 
719
740
  return [
720
741
  { rel: source.rel, detail: `ledger cites nothing: ${PRACTICE_LEDGER}` },
721
742
  ]
722
743
  }
723
744
 
745
+ /**
746
+ * A skill on the per-bullet list cites its sources in its ledger folder, so a
747
+ * record whose every link moved there is left pointing at the folder instead.
748
+ * A skill off the list has nowhere else for a source to live.
749
+ */
750
+ function pointsAtLedgers(
751
+ source: SkillSource,
752
+ lines: readonly { text: string }[],
753
+ ): boolean {
754
+ if (BULLET_LEDGER_SKILLS[source.rel] === undefined) return false
755
+ const folder = `${join(SKILL_LEDGERS, source.folder)}/`
756
+ return lines.some((line) => line.text.includes(folder))
757
+ }
758
+
759
+ /**
760
+ * The source record keeps a source no ledger row cites, so a link in both
761
+ * places is a second copy that drifts on the first edit to either.
762
+ */
763
+ function duplicateFindings(source: SkillSource): SkillFinding[] {
764
+ if (source.ledger === undefined) return []
765
+ if (BULLET_LEDGER_SKILLS[source.rel] === undefined) return []
766
+
767
+ const ledgered = new Map<string, string>()
768
+ for (const entry of source.referenceLedgers) {
769
+ for (const key of entry.sources) {
770
+ if (!ledgered.has(key)) ledgered.set(key, entry.ledgerRel)
771
+ }
772
+ }
773
+
774
+ return [...new Set(linkedSources(source.ledger))].flatMap((key) => {
775
+ const ledgerRel = ledgered.get(key)
776
+ if (ledgerRel === undefined) return []
777
+ return [
778
+ {
779
+ rel: join(source.rel, PRACTICE_LEDGER),
780
+ detail: `duplicate source: ${key} in ${ledgerRel}`,
781
+ },
782
+ ]
783
+ })
784
+ }
785
+
786
+ /** Every external address a text links outside fences and code spans. */
787
+ function linkedSources(text: string): string[] {
788
+ return bodyLines(text)
789
+ .filter((line) => !line.fenced)
790
+ .flatMap((line) =>
791
+ [...maskCodeSpans(line.text).matchAll(EXTERNAL_ADDRESS)].map((match) =>
792
+ sourceKey(match[1] ?? match[2] ?? ''),
793
+ ),
794
+ )
795
+ }
796
+
797
+ /**
798
+ * An anchor and a trailing slash reach the same page, so both drop. A commit
799
+ * pin names a different version, so the path stays as written.
800
+ */
801
+ function sourceKey(address: string): string {
802
+ return address.replace(/#.*$/, '').replace(/\/+$/, '')
803
+ }
804
+
724
805
  /**
725
806
  * Asks only the shipped corpus for a family, since the family names the skill
726
807
  * map group a skill's row sits under and an internal skill takes no row.
@@ -186,6 +186,8 @@ export function register(program: Command): void {
186
186
  ' strayEvidence an evidence path outside the folder evidence/',
187
187
  ' missingEvidence an evidence path that holds no file',
188
188
  ' worktreeLeft the drive worktree the record names, still on disk',
189
+ ' undrawn a decision in evidence/drive-log.md with no Drawn',
190
+ ' path and no skipped reason',
189
191
  '',
190
192
  'A goal matches by its text with any bold label and trailing period',
191
193
  'dropped. A changed path is named and never reverted.',
@@ -553,6 +555,7 @@ const FINDING_LABELS: Record<keyof DriveFindings, string> = {
553
555
  strayEvidence: 'Evidence outside evidence/',
554
556
  missingEvidence: 'Evidence holding no file',
555
557
  worktreeLeft: 'Drive worktree still on disk',
558
+ undrawn: 'Decisions with nothing drawn',
556
559
  }
557
560
 
558
561
  function reportDriveCheck(
@@ -585,7 +588,7 @@ function reportDriveCheck(
585
588
  if (outcome.reason === 'clean') {
586
589
  logStep('Clean')
587
590
  logInfo(
588
- `${outcome.goals} goal(s) mapped, the checkout untouched, the evidence in place.`,
591
+ `${outcome.goals} goal(s) mapped, the checkout untouched, the evidence in place, every decision drawn.`,
589
592
  )
590
593
  outro()
591
594
  return code
@@ -23,6 +23,8 @@ import {
23
23
  import {
24
24
  type CheckRunListing,
25
25
  collapseChecks,
26
+ type RawStatus,
27
+ requiredChecks,
26
28
  withMergeState,
27
29
  } from '@/pr/checks'
28
30
  import {
@@ -33,6 +35,7 @@ import {
33
35
  findEvidenceLocal,
34
36
  findEvidencePreview,
35
37
  findEvidenceReel,
38
+ findEvidenceSkips,
36
39
  type EvidenceItem,
37
40
  type EvidenceReading,
38
41
  groupEvidence,
@@ -171,6 +174,8 @@ interface EvidenceOptions extends ReadOptions {
171
174
  readonly checklist?: string
172
175
  readonly local?: string
173
176
  readonly reel?: string
177
+ readonly localSkip?: string
178
+ readonly reelSkip?: string
174
179
  readonly check?: boolean
175
180
  }
176
181
 
@@ -433,7 +438,7 @@ type EvidenceRefusal =
433
438
 
434
439
  const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
435
440
  'check-writes':
436
- '--check is a read and renders no body, so it takes none of --preview, --local, --checklist, or --reel. Run the check and the render as two calls.',
441
+ '--check is a read and renders no body, so it takes none of --preview, --local, --checklist, --reel, --local-skip, or --reel-skip. Run the check and the render as two calls.',
437
442
  'gh-failed':
438
443
  'gh could not answer for this repository. Name the pull request number.',
439
444
  'unreadable-checklist':
@@ -775,6 +780,14 @@ export function register(program: Command): void {
775
780
  '--reel <link>',
776
781
  'Embed this reel under the address line, filmed at the head, from canon pr frames --reel',
777
782
  )
783
+ .option(
784
+ '--local-skip <reason>',
785
+ 'Name why no local address is posted, from the reason pr-evidence holds for canon pr local',
786
+ )
787
+ .option(
788
+ '--reel-skip <reason>',
789
+ 'Name why no reel is embedded, from the reason pr-evidence holds for canon pr frames --reel',
790
+ )
778
791
  .option(
779
792
  '--check',
780
793
  'Report which post-pull-request steps the thread is still owed, rendering nothing',
@@ -854,6 +867,17 @@ export function register(program: Command): void {
854
867
  'current, and a passed one replaces it. Like --local it rides on a',
855
868
  'checklist or a comparison and never opens a comment alone.',
856
869
  '',
870
+ '--local-skip and --reel-skip each put one line under the addresses,',
871
+ '**No local preview:** or **No reel:** followed by the text passed, a',
872
+ 'reason code and any one-line message joined to it, folded onto one',
873
+ 'line. Without the flag, a skip line the marked comment already carries',
874
+ 'is carried forward. A skip line is dropped whenever its address or',
875
+ 'reel renders, passed or carried, so a later run that posts the link or',
876
+ 'films the reel replaces it. A passed --local-skip drops a carried',
877
+ 'local address, since that line names no head and would read as live,',
878
+ 'while a carried reel names its head and stays beside --reel-skip. Like',
879
+ '--local, neither opens a comment alone.',
880
+ '',
857
881
  "A render at a new head also resets the pull request body's UI pass",
858
882
  'line, the one `canon pr tick` writes under ## Testing, to unchecked',
859
883
  'naming that head. It touches no other body text, leaves a line already',
@@ -893,7 +917,8 @@ export function register(program: Command): void {
893
917
  'request changed, and the marked comment carries no **Preview:**',
894
918
  'address. A failed deploy reads the same as a skipped one.',
895
919
  'The record carries `owed` and the same marked fields. --check refuses',
896
- 'with --preview, --local, --checklist, or --reel as check-writes.',
920
+ 'with --preview, --local, --checklist, --reel, --local-skip, or',
921
+ '--reel-skip as check-writes.',
897
922
  '',
898
923
  'Refusals, read off `reason`:',
899
924
  ' unreadable-checklist --checklist named a path that could not be read,',
@@ -917,6 +942,7 @@ export function register(program: Command): void {
917
942
  ' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
918
943
  ' canon pr evidence 1341 --local http://localhost:5173 --json',
919
944
  ' canon pr evidence 1341 --reel <link from canon pr frames --reel> --json',
945
+ " canon pr evidence 1341 --reel-skip 'no-server: <message>' --json",
920
946
  ' canon pr evidence 1341 --check --json',
921
947
  '',
922
948
  ].join('\n'),
@@ -960,7 +986,12 @@ export function register(program: Command): void {
960
986
  ' or needing eyes refuses `ui-undriven`, carried or not',
961
987
  ' evidence the evidence check reads settled',
962
988
  ' standing the head is fresh against the base, not stale or conflicted',
963
- ' ci every check run on the tip passed, waited on up to --wait',
989
+ ' ci every required check run on the tip passed, waited on up to --wait',
990
+ '',
991
+ "The required checks are the ones the base branch's rules list. A",
992
+ 'check outside them is waited on by nobody, and one that failed is',
993
+ 'named in the record as `advisory` without blocking. Rules that do not',
994
+ 'read, or that list no check, gate by every check run on the tip.',
964
995
  '',
965
996
  'Review state, UI state, evidence, and standing are read before the CI',
966
997
  'wait, so a reviewer whose partner is still driving refuses at once. The',
@@ -1039,6 +1070,10 @@ export function register(program: Command): void {
1039
1070
  'request already passed `canon pr lift`. It does not wait on checks or',
1040
1071
  'delete the branch.',
1041
1072
  '',
1073
+ "The checks gate by the ones the base branch's rules require, the way",
1074
+ '`canon pr lift` reads them. A failing check outside that set is named',
1075
+ 'in the record as `advisory` and does not refuse the merge.',
1076
+ '',
1042
1077
  'Reasons:',
1043
1078
  ' merged, dry-run (--dry-run)',
1044
1079
  ' pull-closed, pull-draft, missing-summary, missing-key-changes,',
@@ -2658,7 +2693,9 @@ async function runEvidence(
2658
2693
  (opts.preview !== undefined ||
2659
2694
  opts.local !== undefined ||
2660
2695
  opts.checklist !== undefined ||
2661
- opts.reel !== undefined)
2696
+ opts.reel !== undefined ||
2697
+ opts.localSkip !== undefined ||
2698
+ opts.reelSkip !== undefined)
2662
2699
  ) {
2663
2700
  return refuseWith(
2664
2701
  'check-writes',
@@ -2868,8 +2905,17 @@ async function runEvidence(
2868
2905
  }
2869
2906
 
2870
2907
  const preview = opts.preview ?? (isUnserved ? undefined : carriedPreview)
2871
- const local = opts.local ?? carriedLocal
2908
+ // A carried local line names no head and reads as live, so a passed skip
2909
+ // drops it the way a passed address replaces it. A carried reel names the
2910
+ // head it was filmed at, so it stays beside a passed skip.
2911
+ const local =
2912
+ opts.local ?? (opts.localSkip === undefined ? carriedLocal : undefined)
2872
2913
  const reel = nextEvidenceReel(opts.reel, identity.head, comments)
2914
+ const carriedSkips = findEvidenceSkips(comments)
2915
+ const skips = {
2916
+ local: opts.localSkip ?? carriedSkips.local,
2917
+ reel: opts.reelSkip ?? carriedSkips.reel,
2918
+ }
2873
2919
  const unsettled = suppliedChecklist ?? carriedChecklist
2874
2920
  const checklist =
2875
2921
  unsettled === undefined
@@ -2886,6 +2932,7 @@ async function runEvidence(
2886
2932
  checklist,
2887
2933
  local,
2888
2934
  reel,
2935
+ skips,
2889
2936
  )
2890
2937
 
2891
2938
  const caseCount = states.reduce((n, s) => n + s.items.length, 0)
@@ -4209,24 +4256,64 @@ async function readChecksGate(
4209
4256
  number: string,
4210
4257
  tip: string,
4211
4258
  ): Promise<ChecksGate | undefined> {
4212
- const [listed, pulled] = await Promise.all([
4259
+ // A status read that fails leaves a required status context unseen, which
4260
+ // reads pending rather than passing, so it needs no refusal of its own.
4261
+ const [listed, pulled, combined] = await Promise.all([
4213
4262
  gh(root, [
4214
4263
  'api',
4215
4264
  `repos/{owner}/{repo}/commits/${tip}/check-runs?per_page=100`,
4216
4265
  ]),
4217
4266
  gh(root, ['api', `repos/{owner}/{repo}/pulls/${number}`]),
4267
+ gh(root, [
4268
+ 'api',
4269
+ `repos/{owner}/{repo}/commits/${tip}/status?per_page=100`,
4270
+ ]),
4218
4271
  ])
4219
4272
  if (listed === null) return undefined
4220
4273
  try {
4221
- const mergeState =
4222
- pulled === null
4223
- ? undefined
4224
- : mergeStateOf((JSON.parse(pulled) as RestPull).mergeable_state)
4274
+ const row = pulled === null ? undefined : (JSON.parse(pulled) as RestPull)
4275
+ const required = await readRequiredChecks(root, row?.base?.ref)
4276
+ const statuses =
4277
+ combined === null
4278
+ ? []
4279
+ : ((JSON.parse(combined) as { statuses?: readonly RawStatus[] })
4280
+ .statuses ?? [])
4225
4281
  const reading = withMergeState(
4226
- mergeState,
4227
- collapseChecks(tip, JSON.parse(listed) as CheckRunListing),
4282
+ mergeStateOf(row?.mergeable_state),
4283
+ collapseChecks(
4284
+ tip,
4285
+ JSON.parse(listed) as CheckRunListing,
4286
+ required,
4287
+ statuses,
4288
+ ),
4228
4289
  )
4229
- return { state: reading.state, isConflicted: reading.conflicted }
4290
+ return {
4291
+ state: reading.state,
4292
+ isConflicted: reading.conflicted,
4293
+ ...(reading.advisory !== undefined && { advisory: reading.advisory }),
4294
+ }
4295
+ } catch {
4296
+ return undefined
4297
+ }
4298
+ }
4299
+
4300
+ /**
4301
+ * The checks the base branch's rules require, so a check they leave out runs
4302
+ * without holding the gate. Undefined on any read that fails, which gates by
4303
+ * every check as the verbs did before the rules were read.
4304
+ */
4305
+ async function readRequiredChecks(
4306
+ root: string,
4307
+ base: string | undefined,
4308
+ ): Promise<ReadonlySet<string> | undefined> {
4309
+ if (base === undefined || base === '') return undefined
4310
+ const rules = await gh(root, [
4311
+ 'api',
4312
+ `repos/{owner}/{repo}/rules/branches/${base}`,
4313
+ ])
4314
+ if (rules === null) return undefined
4315
+ try {
4316
+ return requiredChecks(JSON.parse(rules))
4230
4317
  } catch {
4231
4318
  return undefined
4232
4319
  }
@@ -4360,6 +4447,7 @@ async function runLift(
4360
4447
  for (const gate of record.gates) {
4361
4448
  if (!gate.isOk) logWarn(`${gate.gate}: ${gate.reason ?? 'not clear'}`)
4362
4449
  }
4450
+ warnAdvisory(record.advisory)
4363
4451
  outro()
4364
4452
 
4365
4453
  if (emitJson) {
@@ -4371,6 +4459,14 @@ async function runLift(
4371
4459
  return record.isOk ? 0 : 1
4372
4460
  }
4373
4461
 
4462
+ function warnAdvisory(advisory: readonly string[] | undefined): void {
4463
+ for (const name of advisory ?? []) {
4464
+ logWarn(
4465
+ `advisory: ${name} failed, which the base's rules do not require, so it did not block`,
4466
+ )
4467
+ }
4468
+ }
4469
+
4374
4470
  const MERGE_REFUSALS: Record<MergeRefusal | 'gh-missing', string> = {
4375
4471
  'gh-missing': PULL_REFUSALS['gh-missing'],
4376
4472
  'pull-closed': 'The pull request is closed, so there is nothing to merge.',
@@ -4516,6 +4612,7 @@ async function runMerge(
4516
4612
  ? `#${record.number} would merge with the message below`
4517
4613
  : MERGE_REFUSALS[record.reason],
4518
4614
  )
4615
+ warnAdvisory(record.advisory)
4519
4616
  outro()
4520
4617
 
4521
4618
  if (emitJson) {
@@ -1111,7 +1111,7 @@ const VISUAL_ONLY_GLOBS: readonly string[] = ['tooling/web/configs/e2e/**']
1111
1111
 
1112
1112
  /**
1113
1113
  * `deploy-site.yml` and `pr-visual-checks.yml` each carry their own literal
1114
- * copy of the eight path globs a landing-page change should trigger on, plus
1114
+ * copy of the five path globs a landing-page change should trigger on, plus
1115
1115
  * the `VISUAL_ONLY_GLOBS` the visual workflow adds for itself, per
1116
1116
  * Question 2 in `.canon/plans/archive/feature-render-visibility.md`. A YAML
1117
1117
  * anchor cannot cross files here, so the two copies are read live and compared
@@ -386,7 +386,13 @@ async function runCheck(
386
386
  }
387
387
 
388
388
  const run = check.kind === 'cli' ? ctx.cli : ctx.run
389
- const result = await run(check.argv, env)
389
+ const argv =
390
+ check.kind === 'command' &&
391
+ check.changedArgv !== undefined &&
392
+ ctx.changed !== undefined
393
+ ? check.changedArgv(ctx.changed)
394
+ : check.argv
395
+ const result = await run(argv, env)
390
396
  return {
391
397
  emissions: [{ kind: 'output', text: result.all }],
392
398
  failure: result.exitCode === 0 ? undefined : check.failure,
@@ -45,6 +45,12 @@ export type Check =
45
45
  | {
46
46
  readonly kind: 'command'
47
47
  readonly argv: readonly string[]
48
+ /**
49
+ * Builds the argv from the changed set when scoping is on. `argv` runs
50
+ * where it is off, so `--all` and every widening fallback keep the full
51
+ * command.
52
+ */
53
+ readonly changedArgv?: (changed: readonly string[]) => readonly string[]
48
54
  readonly failure: string
49
55
  }
50
56
  /** This checkout's own CLI, so a worktree gate never reads the main tree. */
@@ -92,33 +98,36 @@ export interface Stage {
92
98
  }
93
99
 
94
100
  /**
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.
101
+ * Files a scoped run hands to `vitest related`, which follows imports from
102
+ * them to the tests that reach them. A corpus a test reads from disk is not
103
+ * among them, so a Markdown-only push runs no tests locally and CI's full run
104
+ * is what catches a broken corpus reader.
101
105
  */
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
- ]
106
+ const RELATED_SOURCES = /\.tsx?$|^commitlint\.config\.js$/
107
+
108
+ /** Files every test depends on without importing, so no related run sees them. */
109
+ const FULL_SUITE_TRIGGERS =
110
+ /^vitest\.(config|setup|global-setup)\.ts$|^tsconfig\.json$|^package\.json$/
110
111
 
111
112
  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('|'),
113
+ `${RELATED_SOURCES.source}|${FULL_SUITE_TRIGGERS.source}`,
120
114
  )
121
115
 
116
+ const FULL_SUITE = ['bun', 'run', 'test']
117
+
118
+ function relatedTests(changed: readonly string[]): readonly string[] {
119
+ if (changed.some((file) => FULL_SUITE_TRIGGERS.test(file))) return FULL_SUITE
120
+ return [
121
+ 'bun',
122
+ '--bun',
123
+ 'vitest',
124
+ 'related',
125
+ '--run',
126
+ '--passWithNoTests',
127
+ ...changed.filter((file) => RELATED_SOURCES.test(file)),
128
+ ]
129
+ }
130
+
122
131
  /**
123
132
  * Every stage the gate runs, in the order it runs them.
124
133
  *
@@ -590,14 +599,17 @@ export const STAGES: readonly Stage[] = [
590
599
  id: 'tests',
591
600
  label: 'Tests',
592
601
  scope: TESTS_SCOPE,
593
- skipped: 'Skipped, no TypeScript or asserted-corpus changes',
602
+ skipped: 'Skipped, no TypeScript or test config changes',
594
603
  // Concurrent suites from a wave of worktrees starve each other's spawn
595
604
  // and browser tests past their timeouts, so the box runs one at a time.
605
+ // A related run can still reach a spawn or browser test, so it holds the
606
+ // lock too.
596
607
  lock: 'tests',
597
608
  checks: [
598
609
  {
599
610
  kind: 'command',
600
- argv: ['bun', 'run', 'test'],
611
+ argv: FULL_SUITE,
612
+ changedArgv: relatedTests,
601
613
  failure: 'Tests failed',
602
614
  },
603
615
  ],
@@ -16,6 +16,9 @@ export const DRIVE_RECORD = 'drive.json'
16
16
  /** The one folder inside an intake folder that evidence may live in. */
17
17
  export const EVIDENCE_DIR = 'evidence'
18
18
 
19
+ /** The log a person reads, which the `undrawn` axis parses by its labels. */
20
+ export const DRIVE_LOG = join(EVIDENCE_DIR, 'drive-log.md')
21
+
19
22
  const SCRATCH = join('.canon', 'tmp', 'app-drive')
20
23
 
21
24
  /**
@@ -63,6 +66,15 @@ export interface DriveFindings {
63
66
  readonly missingEvidence: readonly string[]
64
67
  /** The drive's worktree, when the record names one still on disk. */
65
68
  readonly worktreeLeft: readonly string[]
69
+ /** Logged findings the operator decided with no drawn outcome. */
70
+ readonly undrawn: readonly string[]
71
+ }
72
+
73
+ export interface DrawnReading {
74
+ /** Each decided finding's heading, as `<nn>. <title>`, with nothing drawn. */
75
+ readonly undrawn: readonly string[]
76
+ /** Every path a `Drawn` field cites, for the evidence tests. */
77
+ readonly paths: readonly string[]
66
78
  }
67
79
 
68
80
  export interface DriveChecked {
@@ -159,6 +171,57 @@ export function readGoals(source: string): string[] {
159
171
  return goals
160
172
  }
161
173
 
174
+ const FINDING_HEADING = /^##\s+(\d+\.\s.*?)\s*$/
175
+ const LOG_FIELD = /^[-*]\s+\*\*([^*]+):\*\*\s*(.*)$/
176
+ const DECISION_LABEL = 'Decision (operator)'
177
+ const DRAWN_LABEL = 'Drawn'
178
+
179
+ /**
180
+ * Reads the drive log's numbered findings for a decision with nothing drawn.
181
+ * A decision reading `see <nn>` was drawn under the finding it names, and a
182
+ * `ref-<nn>` heading or a finding with no decision carries nothing to draw. A
183
+ * `Drawn` field passes when it cites a path in a code span or reads
184
+ * `skipped, <reason>`, so a bare `skipped` still lists the finding. A skip
185
+ * cites no path, since its reason may code-span a flag or a format.
186
+ */
187
+ export function readDrawn(source: string): DrawnReading {
188
+ const undrawn: string[] = []
189
+ const paths: string[] = []
190
+ let finding: string | undefined
191
+ let decision: string | undefined
192
+ let drawn: string | undefined
193
+
194
+ const close = () => {
195
+ if (finding !== undefined && decision && !/^see\s+\d+/i.test(decision)) {
196
+ const field = drawn ?? ''
197
+ if (/^skipped\b/i.test(field)) {
198
+ if (!/^skipped\s*,\s*\S/i.test(field)) undrawn.push(finding)
199
+ } else {
200
+ const cited = [...field.matchAll(/`([^`]+)`/g)].map((match) => match[1])
201
+ paths.push(...cited)
202
+ if (cited.length === 0) undrawn.push(finding)
203
+ }
204
+ }
205
+ finding = decision = drawn = undefined
206
+ }
207
+
208
+ for (const line of bodyLines(source)) {
209
+ if (line.fenced) continue
210
+ if (/^#{1,2}\s/.test(line.text)) {
211
+ close()
212
+ finding = FINDING_HEADING.exec(line.text)?.[1]
213
+ continue
214
+ }
215
+ const field = LOG_FIELD.exec(line.text)
216
+ if (finding === undefined || !field) continue
217
+ if (field[1] === DECISION_LABEL) decision = field[2].trim()
218
+ if (field[1] === DRAWN_LABEL) drawn = field[2].trim()
219
+ }
220
+ close()
221
+
222
+ return { undrawn, paths }
223
+ }
224
+
162
225
  function parseRecord(raw: string): DriveRecord | string {
163
226
  let parsed: unknown
164
227
  try {
@@ -271,8 +334,9 @@ export async function recordBaseline(
271
334
  /**
272
335
  * Checks a finished drive against its intake folder's `drive.json`: every
273
336
  * goal mapped or skipped with a reason, the main checkout unchanged since the
274
- * baseline, the evidence inside the folder's `evidence/`, and the drive's
275
- * worktree gone. It reports what failed and never reverts a changed path,
337
+ * baseline, the evidence inside the folder's `evidence/`, every decision in
338
+ * the drive log drawn, and the drive's worktree gone. A folder with no log
339
+ * reads as nothing undrawn. It reports what failed and never reverts a changed path,
276
340
  * since only the operator knows which of them to keep.
277
341
  */
278
342
  export async function checkDrive(
@@ -318,6 +382,12 @@ export async function checkDrive(
318
382
  return refuse('unreadable-tree', `git cannot read the status of ${root}.`)
319
383
  }
320
384
 
385
+ const logPath = join(folder, DRIVE_LOG)
386
+ const drawn: DrawnReading = existsSync(logPath)
387
+ ? readDrawn(await readFile(logPath, 'utf8'))
388
+ : { undrawn: [], paths: [] }
389
+ const evidence = [...new Set([...record.evidence, ...drawn.paths])]
390
+
321
391
  const entries = new Map(
322
392
  record.goals.map((entry) => [normalizeGoal(entry.goal), entry]),
323
393
  )
@@ -334,14 +404,15 @@ export async function checkDrive(
334
404
  .filter((entry) => !known.has(normalizeGoal(entry.goal)))
335
405
  .map((entry) => entry.goal),
336
406
  changed: changedSince(baseline, current),
337
- strayEvidence: record.evidence.filter((path) => !isEvidencePath(path)),
338
- missingEvidence: record.evidence.filter(
407
+ strayEvidence: evidence.filter((path) => !isEvidencePath(path)),
408
+ missingEvidence: evidence.filter(
339
409
  (path) => isEvidencePath(path) && !existsSync(join(folder, path)),
340
410
  ),
341
411
  worktreeLeft:
342
412
  record.worktree !== undefined && existsSync(record.worktree)
343
413
  ? [record.worktree]
344
414
  : [],
415
+ undrawn: drawn.undrawn,
345
416
  }
346
417
  const failed = Object.values(findings).some((list) => list.length > 0)
347
418
 
@@ -280,6 +280,8 @@ export const GATED_PATHS: readonly string[] = [
280
280
  'claude/skills/pr-evidence/REQUIREMENT.md',
281
281
  'claude/skills/pr-evidence/SKILL.md',
282
282
  'claude/skills/pr-evidence/references/evidence.md',
283
+ 'claude/skills/proposal-critique/REQUIREMENT.md',
284
+ 'claude/skills/proposal-critique/SKILL.md',
283
285
  'claude/skills/review-address/REQUIREMENT.md',
284
286
  'claude/skills/review-address/SKILL.md',
285
287
  'claude/skills/review-address/references/post-review-findings.md',