@erclx/canon 5.49.0 → 5.51.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 (86) hide show
  1. package/CHANGELOG.md +4024 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/fan-in.md +20 -6
  4. package/claude/skills/canon-cli/SKILL.md +9 -7
  5. package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
  6. package/claude/skills/canvas-board/SKILL.md +8 -9
  7. package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
  8. package/claude/skills/ci-workflow/SKILL.md +26 -1
  9. package/claude/skills/ci-workflow/references/workflows.md +28 -0
  10. package/claude/skills/context-fold/references/context.md +4 -3
  11. package/claude/skills/design-record/REQUIREMENT.md +33 -0
  12. package/claude/skills/design-record/SKILL.md +84 -0
  13. package/claude/skills/design-record/references/folder.md +69 -0
  14. package/claude/skills/design-taste/SKILL.md +3 -2
  15. package/claude/skills/git-followup/SKILL.md +1 -0
  16. package/claude/skills/git-pr/references/pr.md +1 -1
  17. package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
  18. package/claude/skills/markdown-craft/SKILL.md +6 -1
  19. package/claude/skills/markdown-craft/references/markdown.md +3 -3
  20. package/claude/skills/markdown-craft/references/tables.md +30 -0
  21. package/claude/skills/pick-by-eye/SKILL.md +10 -8
  22. package/claude/skills/plan-feature/SKILL.md +13 -1
  23. package/claude/skills/plan-feature/references/batches.md +1 -1
  24. package/claude/skills/session-worktree/SKILL.md +1 -1
  25. package/claude/skills/target-check/references/domains.md +8 -6
  26. package/docs/agents/index.md +2 -2
  27. package/docs/agents/markdown-audit.md +5 -2
  28. package/docs/agents/migrate.md +2 -0
  29. package/docs/agents/targets.md +8 -1
  30. package/docs/target/migrations.md +8 -1
  31. package/docs/target/projects.md +1 -1
  32. package/docs/workflow/parallel-features.md +1 -1
  33. package/docs/workflow/skill-map.md +2 -1
  34. package/docs/workflow/visual-design-workflow.md +2 -1
  35. package/governance/detect.toml +1 -1
  36. package/governance/ledger/tooling/ci-workflow.md +6 -0
  37. package/governance/ledger/ui/design-reference.md +8 -12
  38. package/governance/rules/tooling/ci-workflow.md +4 -0
  39. package/governance/rules/ui/design-reference.md +4 -6
  40. package/package.json +2 -1
  41. package/scripts/ci/check-browsers.sh +13 -0
  42. package/scripts/ci/delete-previews.sh +34 -0
  43. package/scripts/ci/publish-check.sh +29 -0
  44. package/scripts/ci/refresh-frames-pr.sh +63 -0
  45. package/scripts/ci/report-baseline.sh +23 -0
  46. package/scripts/ci/stack-issues.sh +24 -0
  47. package/scripts/ci/verify-npm-token.sh +14 -0
  48. package/src/claude/cases/misc.ts +5 -0
  49. package/src/cli/help.ts +1 -0
  50. package/src/cli.ts +2 -0
  51. package/src/commands/changelog.ts +146 -0
  52. package/src/commands/claude.ts +26 -1
  53. package/src/commands/gate.ts +57 -16
  54. package/src/commands/hooks.ts +7 -5
  55. package/src/commands/markdown.ts +98 -19
  56. package/src/commands/migrate.ts +77 -0
  57. package/src/commands/worktrees.ts +273 -0
  58. package/src/context/audit.ts +6 -5
  59. package/src/design/budget.ts +1 -1
  60. package/src/design/components.ts +1 -1
  61. package/src/design/document.ts +1 -1
  62. package/src/design/regen.ts +1 -1
  63. package/src/design/render.ts +1 -1
  64. package/src/gate/hand-suite.ts +52 -0
  65. package/src/gate/measures.ts +9 -2
  66. package/src/gate/sequencer.ts +17 -8
  67. package/src/gov/detect.ts +28 -10
  68. package/src/hooks/post-merge.ts +45 -6
  69. package/src/markdown/gated.ts +5 -7
  70. package/src/markdown/scope.ts +60 -10
  71. package/src/markdown/structure.ts +1 -1
  72. package/src/markdown/tables.ts +150 -0
  73. package/src/migrate/status.ts +128 -0
  74. package/src/tasks/validate.ts +15 -12
  75. package/src/version/changelog.ts +156 -0
  76. package/src/worktrees/members.ts +540 -0
  77. package/tooling/base/configs/.husky/post-merge +7 -6
  78. package/tooling/claude/reference.md +1 -0
  79. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
  80. package/tooling/claude/seeds/CLAUDE.md +9 -0
  81. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
  82. package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
  83. package/tooling/cloudflare/reference.md +7 -4
  84. package/tooling/web/reference.md +5 -0
  85. package/tooling/web/seeds/canon/design/index.md +23 -0
  86. /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
@@ -17,6 +17,14 @@ import {
17
17
  type WorktreeVerdict,
18
18
  } from '@/worktrees/reclaim'
19
19
  import { mainWorktreeRoot } from '@/git/worktree'
20
+ import {
21
+ type Half,
22
+ type MemberOutcome,
23
+ type MemberRefusal,
24
+ type MembersRefusal,
25
+ type MembersReport,
26
+ retireMembers,
27
+ } from '@/worktrees/members'
20
28
  import { machineListenerReader } from '@/listeners/machine'
21
29
  import { repairBareFlag } from '@/worktrees/bare-flag'
22
30
  import { type StopReport, stopListeners } from '@/worktrees/stop'
@@ -40,6 +48,31 @@ interface ReclaimCommandOptions {
40
48
  readonly json?: boolean
41
49
  }
42
50
 
51
+ interface RetireMembersCommandOptions {
52
+ readonly pullRequest: string
53
+ readonly dryRun?: boolean
54
+ readonly json?: boolean
55
+ }
56
+
57
+ /**
58
+ * What the post-merge hook reads after an integrator merges. Every field it
59
+ * branches on is a number or a fixed word, and `members` sits last, for the
60
+ * reason `ReclaimRecord` states.
61
+ */
62
+ export interface RetireMembersRecord {
63
+ readonly pullRequest: number
64
+ readonly reason: MembersRefusal | null
65
+ readonly detail: string | null
66
+ readonly dryRun: boolean
67
+ /** Members with a half deleted, or that a dry run would delete, and none kept or failed. */
68
+ readonly retired: number
69
+ /** Members with a half kept and none failed. */
70
+ readonly kept: number
71
+ /** Members with a half whose deletion failed. */
72
+ readonly failed: number
73
+ readonly members: readonly MemberOutcome[]
74
+ }
75
+
43
76
  /**
44
77
  * What a caller reads instead of the frame.
45
78
  *
@@ -145,6 +178,59 @@ const CONDITIONS = [
145
178
  'keeps a directory rather than removing one.',
146
179
  ]
147
180
 
181
+ const MEMBERS_REFUSALS: Record<MembersRefusal, string> = {
182
+ 'gh-missing': 'gh is not on the path, so the pull request could not be read',
183
+ 'gh-failed': 'the pull request read failed',
184
+ 'sessions-unreadable': 'the session roster could not be read',
185
+ 'sessions-unchecked':
186
+ 'some session records come from another pid namespace and no socket answers for them',
187
+ 'not-merged': 'the pull request has not merged',
188
+ 'no-members': 'the body carries no "Fan-in members:" line',
189
+ 'trunk-named': 'the line names the branch the pull request merged into',
190
+ 'head-named': "the line names the integrator's own branch",
191
+ 'open-pull-request': 'a named branch has an open pull request of its own',
192
+ 'head-unreadable':
193
+ 'refs/pull/<n>/head could not be fetched, or names another commit than the merged head',
194
+ }
195
+
196
+ const MEMBER_REFUSALS: Record<MemberRefusal, string> = {
197
+ 'not-in-merged-head':
198
+ "its tip is not an ancestor of the integrator's merged head, so it holds work the merge did not carry",
199
+ 'remote-unreadable': 'origin could not be read for this branch',
200
+ 'worktree-kept':
201
+ 'its worktree was kept, and git refuses to delete a branch a worktree holds',
202
+ 'main-worktree': 'it is checked out in the main worktree',
203
+ 'current-worktree': 'its worktree is the one this command runs in',
204
+ 'detached-head': 'its worktree has a detached HEAD',
205
+ 'uncommitted-changes': 'its worktree holds uncommitted work',
206
+ 'unreadable-worktree': 'its worktree could not be read',
207
+ 'held-by-session': 'a live session holds its worktree',
208
+ }
209
+
210
+ const RETIRE_CONDITIONS = [
211
+ 'Reads the "Fan-in members:" line of a merged integrator pull request,',
212
+ "whose backticked branches are the members, and deletes each one's",
213
+ 'origin branch, worktree, and local branch. Nothing outside that line',
214
+ 'is read.',
215
+ '',
216
+ "A member half is merged when its tip is an ancestor of the integrator's",
217
+ 'merged head, fetched from refs/pull/<n>/head, rather than of the trunk,',
218
+ 'which a squash merge never makes it. Origin and local are judged apart,',
219
+ 'so an unpushed commit keeps the local branch and a commit pushed after',
220
+ 'the merge keeps the origin branch. The worktree takes the same reading',
221
+ '`canon worktrees list` gives, with the members counted as merged.',
222
+ '',
223
+ 'These refuse the whole run, and nothing is deleted:',
224
+ ...Object.entries(MEMBERS_REFUSALS).map(
225
+ ([reason, meaning]) => ` ${reason.padEnd(22)}${meaning}`,
226
+ ),
227
+ '',
228
+ 'These keep one half of one member, named in its "reasons":',
229
+ ...Object.entries(MEMBER_REFUSALS).map(
230
+ ([reason, meaning]) => ` ${reason.padEnd(22)}${meaning}`,
231
+ ),
232
+ ]
233
+
148
234
  export function register(program: Command): void {
149
235
  const worktrees = program
150
236
  .command('worktrees')
@@ -240,6 +326,42 @@ export function register(program: Command): void {
240
326
  process.exitCode = await runReclaim(opts)
241
327
  })
242
328
 
329
+ worktrees
330
+ .command('retire-members')
331
+ .description(
332
+ 'Delete the fan-in members a merged integrator pull request names, unless --dry-run',
333
+ )
334
+ .helpOption('-h, --help', 'Show this help message')
335
+ .requiredOption(
336
+ '--pull-request <number>',
337
+ 'The merged integrator pull request',
338
+ )
339
+ .option('--dry-run', 'Report what would be deleted without deleting it')
340
+ .option('--json', 'Add a machine-readable record on stdout')
341
+ .addHelpText(
342
+ 'after',
343
+ [
344
+ '',
345
+ 'Exit codes:',
346
+ ' 0 every member was retired or kept, or the body names none',
347
+ ' 1 refused, or a deletion failed, with the reason on stderr',
348
+ '',
349
+ ...RETIRE_CONDITIONS,
350
+ '',
351
+ 'The post-merge hook runs this once per merged pull request number,',
352
+ 'and CANON_SKIP_RECLAIM turns it off there. Run it by hand for a',
353
+ 'merge the hook missed.',
354
+ '',
355
+ 'Examples:',
356
+ ' canon worktrees retire-members --pull-request 2570 --dry-run',
357
+ ' canon worktrees retire-members --pull-request 2570 --json',
358
+ '',
359
+ ].join('\n'),
360
+ )
361
+ .action(async (opts: RetireMembersCommandOptions) => {
362
+ process.exitCode = await runRetireMembers(opts)
363
+ })
364
+
243
365
  worktrees
244
366
  .command('repair-bare-flag', { hidden: true })
245
367
  .description('Clear a core.bare flag that worktree entry left set')
@@ -563,3 +685,154 @@ export function describe(verdict: WorktreeVerdict): string {
563
685
  ` Reclaimable. Pull request #${verdict.pullRequest} merged it, ${tree}, and no session holds it.`,
564
686
  ].join('\n')
565
687
  }
688
+
689
+ function halves(member: MemberOutcome): readonly Half[] {
690
+ return member.worktree === null
691
+ ? [member.origin, member.local]
692
+ : [member.origin, member.worktree, member.local]
693
+ }
694
+
695
+ /**
696
+ * Builds the record from the report, separate from the run so the counts are
697
+ * testable without a repository. A member already gone everywhere counts
698
+ * nowhere, so a run repeated by hand does not report a retirement it did not
699
+ * make.
700
+ */
701
+ export function retireMembersRecord(
702
+ pullRequest: number,
703
+ dryRun: boolean,
704
+ report: MembersReport,
705
+ ): RetireMembersRecord {
706
+ if (report.kind === 'refused') {
707
+ return {
708
+ pullRequest,
709
+ reason: report.reason,
710
+ detail: report.detail,
711
+ dryRun,
712
+ retired: 0,
713
+ kept: 0,
714
+ failed: 0,
715
+ members: [],
716
+ }
717
+ }
718
+
719
+ const states = report.members.map((member) =>
720
+ halves(member).map((half) => half.state),
721
+ )
722
+ const failed = states.filter((each) => each.includes('failed')).length
723
+ const kept = states.filter(
724
+ (each) => !each.includes('failed') && each.includes('kept'),
725
+ ).length
726
+ const retired = states.filter(
727
+ (each) =>
728
+ !each.includes('failed') &&
729
+ !each.includes('kept') &&
730
+ each.some((state) => state === 'deleted' || state === 'would-delete'),
731
+ ).length
732
+
733
+ return {
734
+ pullRequest,
735
+ reason: null,
736
+ detail: null,
737
+ dryRun,
738
+ retired,
739
+ kept,
740
+ failed,
741
+ members: report.members,
742
+ }
743
+ }
744
+
745
+ /**
746
+ * A kept half is a refusal doing its job and a body naming no members is the
747
+ * ordinary merge, so neither fails the run.
748
+ */
749
+ export function retireMembersExit(record: RetireMembersRecord): number {
750
+ if (record.reason !== null && record.reason !== 'no-members') return 1
751
+ return record.failed > 0 ? 1 : 0
752
+ }
753
+
754
+ function describeHalf(name: string, half: Half): string {
755
+ if (half.state === 'kept') {
756
+ const reasons = half.reasons.map((reason) => MEMBER_REFUSALS[reason])
757
+ return ` ${name}: kept, since ${reasons.join('; ')}.`
758
+ }
759
+ const state = half.state.replace('-', ' ')
760
+ return half.detail === null
761
+ ? ` ${name}: ${state}.`
762
+ : ` ${name}: ${state}. ${half.detail}`
763
+ }
764
+
765
+ async function runRetireMembers(
766
+ opts: RetireMembersCommandOptions,
767
+ ): Promise<number> {
768
+ const dryRun = opts.dryRun ?? false
769
+ const pullRequest = Number(opts.pullRequest)
770
+
771
+ intro(
772
+ dryRun
773
+ ? 'canon worktrees retire-members (dry-run)'
774
+ : 'canon worktrees retire-members',
775
+ )
776
+
777
+ if (!Number.isInteger(pullRequest) || pullRequest <= 0) {
778
+ logStep('Refused')
779
+ logWarn(
780
+ `--pull-request takes a pull request number, and "${opts.pullRequest}" is not one.`,
781
+ )
782
+ outro()
783
+ return 1
784
+ }
785
+
786
+ // The reading stays on the caller's directory, which is what refuses the
787
+ // worktree it stands in, and every write runs from the main root.
788
+ const report = await retireMembers(pullRequest, {
789
+ cwd: process.cwd(),
790
+ root: await mainWorktreeRoot(),
791
+ dryRun,
792
+ })
793
+ const record = retireMembersRecord(pullRequest, dryRun, report)
794
+
795
+ if (report.kind === 'refused') {
796
+ logStep(report.reason === 'no-members' ? 'Members' : 'Refused')
797
+ if (report.reason === 'no-members') logInfo(report.detail)
798
+ else {
799
+ logWarn(`Nothing was deleted: ${MEMBERS_REFUSALS[report.reason]}.`)
800
+ logInfo(report.detail)
801
+ }
802
+ } else {
803
+ logStep('Members')
804
+ pipeOutput(
805
+ report.members
806
+ .map((member) =>
807
+ [
808
+ member.branch,
809
+ describeHalf('origin', member.origin),
810
+ ...(member.worktree === null
811
+ ? []
812
+ : [
813
+ describeHalf(
814
+ `worktree ${member.worktree.path}`,
815
+ member.worktree,
816
+ ),
817
+ ]),
818
+ describeHalf('local', member.local),
819
+ ].join('\n'),
820
+ )
821
+ .join('\n'),
822
+ )
823
+ if (record.failed > 0) {
824
+ logWarn(
825
+ `${plural(record.failed, 'member')} could not be fully deleted. Read the reason above and clear it by hand.`,
826
+ )
827
+ }
828
+ if (dryRun) {
829
+ logWarn(
830
+ 'Nothing was deleted. Run this again without --dry-run to delete.',
831
+ )
832
+ }
833
+ }
834
+
835
+ outro()
836
+ if (opts.json === true) process.stdout.write(`${JSON.stringify(record)}\n`)
837
+ return retireMembersExit(record)
838
+ }
@@ -108,13 +108,14 @@ export const OTHER_FOLDERS = 'Other folders'
108
108
  export const SCRIPTS_HEADING = 'Scripts'
109
109
 
110
110
  /**
111
- * Whether an entry path is the development entry, being the flat entry, the
112
- * overview of a split one a target may still carry, or this repository's
113
- * `local-loop` entry. The standard exempts the
114
- * `Scripts` H3 for that entry alone, so the path is what tells.
111
+ * Whether an entry path is the development entry, being the flat entry or any
112
+ * entry directly under a `development/` folder, since a split folder names its
113
+ * scripts entry by project, as `local-loop` here and `local` in another. The
114
+ * standard exempts the `Scripts` H3 for that entry alone, so the path is what
115
+ * tells.
115
116
  */
116
117
  function isDevelopmentEntry(rel: string): boolean {
117
- return /(?:^|\/)development(?:\/(?:overview|local-loop))?\.md$/.test(rel)
118
+ return /(?:^|\/)development(?:\/[^/]+)?\.md$/.test(rel)
118
119
  }
119
120
 
120
121
  /**
@@ -1,7 +1,7 @@
1
1
  import { canonicalHeading } from '@/design/parse'
2
2
 
3
3
  /**
4
- * The prose budget `claude/skills/canvas-board/references/design.md` sets for a design record, and the
4
+ * The prose budget `claude/skills/design-record/references/design.md` sets for a design record, and the
5
5
  * counter the `design` gate stage reads it through.
6
6
  *
7
7
  * The limits live here and in the standard, never in the stage, so the stage
@@ -15,7 +15,7 @@
15
15
  * `buildDesignCss`'s `components` option, which is what widening that option
16
16
  * from a boolean to `boolean | readonly Component[]` bought.
17
17
  *
18
- * It does not live in `canon/DESIGN.md`. `claude/skills/canvas-board/references/design.md` keeps CSS class
18
+ * It does not live in `canon/DESIGN.md`. `claude/skills/design-record/references/design.md` keeps CSS class
19
19
  * names out of that record and says they live in code, which is here.
20
20
  */
21
21
 
@@ -94,7 +94,7 @@ function borderRows(tokens: readonly BorderToken[]): string[][] {
94
94
  * and every reason behind them lives in the entry.
95
95
  */
96
96
  const LEAD = [
97
- 'Authoring guidance: `claude/skills/canvas-board/references/design.md`. Rendered from `src/design/tokens.ts` by `canon design regen`, so edit the module and never this file.',
97
+ 'Authoring guidance: `claude/skills/design-record/references/design.md`. Rendered from `src/design/tokens.ts` by `canon design regen`, so edit the module and never this file.',
98
98
  'Why each value was chosen lives in `canon/context/features/design.md`.',
99
99
  ].join(' ')
100
100
 
@@ -16,7 +16,7 @@ import { NEUTRAL_TOKENS } from '@/design/neutral'
16
16
  * gate stage is what keeps silently out of it.
17
17
  */
18
18
 
19
- /** The rendered record, at the path `claude/skills/canvas-board/references/design.md` fixes for it. */
19
+ /** The rendered record, at the path `claude/skills/design-record/references/design.md` fixes for it. */
20
20
  export const DESIGN_DOCUMENT = join('canon', 'DESIGN.md')
21
21
 
22
22
  /**
@@ -105,7 +105,7 @@ interface Confidence {
105
105
  }
106
106
 
107
107
  /**
108
- * The columns the confidence ratio reads, fixed by `claude/skills/canvas-board/references/design.md`. The
108
+ * The columns the confidence ratio reads, fixed by `claude/skills/design-record/references/design.md`. The
109
109
  * first column of each table names its row, and `Multiplier` and `When used`
110
110
  * restate what the row already carries, so none of them is something a source
111
111
  * could anchor and none belongs in the denominator.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Names the machine lock a gate holds while a stage's commands run, so the
3
+ * suite it spawns knows not to wait on its own parent.
4
+ */
5
+ export const HELD_LOCK_VARIABLE = 'CANON_HELD_LOCK'
6
+
7
+ /**
8
+ * The vitest invocation a hand-run lock decision reads.
9
+ */
10
+ export interface VitestRun {
11
+ /** The CLI arguments after the vitest entry, as `process.argv.slice(2)`. */
12
+ readonly args: readonly string[]
13
+ readonly ci: boolean
14
+ readonly watch: boolean
15
+ /**
16
+ * The lock a parent gate already holds for this run, from `HELD_LOCK_VARIABLE`.
17
+ * A gate's own suite waiting on that lock would wait on its parent forever.
18
+ */
19
+ readonly heldLock: string | undefined
20
+ }
21
+
22
+ const SUBCOMMANDS = new Set(['run'])
23
+
24
+ /** `-t` and its long forms filter by test name, which scopes the run. */
25
+ function isNameFilter(arg: string): boolean {
26
+ return (
27
+ arg === '-t' ||
28
+ arg.startsWith('-t=') ||
29
+ arg === '--testNamePattern' ||
30
+ arg.startsWith('--testNamePattern=') ||
31
+ arg === '--test-name-pattern' ||
32
+ arg.startsWith('--test-name-pattern=')
33
+ )
34
+ }
35
+
36
+ /**
37
+ * Whether a hand-run vitest invocation runs the whole suite and so queues on
38
+ * the machine lock the gate's Tests stage holds.
39
+ *
40
+ * A scoped run is seconds of work, and queueing it behind a full suite costs
41
+ * more than any collision it causes. Vitest exposes no setup-time API naming
42
+ * the CLI filters, so any bare argument other than the subcommand reads as a
43
+ * file filter. That includes the value of a flag spelled `--flag value`, which
44
+ * misreads such a run as scoped. The misread skips a lock rather than queueing
45
+ * a scoped run, which is the side to err on.
46
+ */
47
+ export function locksHandSuite(run: VitestRun): boolean {
48
+ if (run.ci || run.watch || run.heldLock !== undefined) return false
49
+
50
+ const args = SUBCOMMANDS.has(run.args[0] ?? '') ? run.args.slice(1) : run.args
51
+ return !args.some((arg) => isNameFilter(arg) || !arg.startsWith('-'))
52
+ }
@@ -56,7 +56,14 @@ export interface CommandResult {
56
56
  readonly spawnError?: string
57
57
  }
58
58
 
59
- export type RunCommand = (argv: readonly string[]) => Promise<CommandResult>
59
+ /**
60
+ * Spawns one command. `env` adds variables to the child's environment, which a
61
+ * locked stage uses to tell its child which lock the gate already holds.
62
+ */
63
+ export type RunCommand = (
64
+ argv: readonly string[],
65
+ env?: Readonly<Record<string, string>>,
66
+ ) => Promise<CommandResult>
60
67
 
61
68
  export interface MeasureContext {
62
69
  readonly root: string
@@ -331,7 +338,7 @@ export const designProse: Measure = async (ctx) => {
331
338
  if (failures.length > 0) {
332
339
  return {
333
340
  emissions: [],
334
- failure: `${failures.join(' ')} Cut to the budget in claude/skills/canvas-board/references/design.md, moving the why to the context entry.`,
341
+ failure: `${failures.join(' ')} Cut to the budget in claude/skills/design-record/references/design.md, moving the why to the context entry.`,
335
342
  }
336
343
  }
337
344
  if (emissions.length === 0)
@@ -8,6 +8,7 @@ import type {
8
8
  MeasureContext,
9
9
  RunCommand,
10
10
  } from '@/gate/measures'
11
+ import { HELD_LOCK_VARIABLE } from '@/gate/hand-suite'
11
12
  import type { Check, Stage } from '@/gate/stages'
12
13
  import {
13
14
  acquireSuiteLock,
@@ -248,7 +249,7 @@ async function executeStage(
248
249
  }
249
250
 
250
251
  // CI runs one suite per runner, so a lock there queues behind nothing.
251
- if (stage.lock === undefined || ctx.ci) return runChecks(stage, ctx, [])
252
+ if (stage.lock === undefined || ctx.ci) return runChecks(stage, ctx, [], {})
252
253
 
253
254
  const held = await acquireSuiteLock({
254
255
  path: machineLockPath(stage.lock),
@@ -266,8 +267,10 @@ async function executeStage(
266
267
  ]
267
268
 
268
269
  try {
270
+ // The suite's own global setup takes this lock for a hand run, so the
271
+ // child is told its parent holds it rather than waiting on the parent.
269
272
  const outcome = withLoadLine(
270
- await runChecks(stage, ctx, queued),
273
+ await runChecks(stage, ctx, queued, { [HELD_LOCK_VARIABLE]: stage.lock }),
271
274
  stage,
272
275
  ctx,
273
276
  )
@@ -310,11 +313,12 @@ async function runChecks(
310
313
  stage: Stage,
311
314
  ctx: GateContext,
312
315
  leading: readonly Emission[],
316
+ env: Readonly<Record<string, string>>,
313
317
  ): Promise<StageOutcome> {
314
318
  const emissions: Emission[] = [...leading]
315
319
 
316
320
  for (const check of stage.checks) {
317
- const outcome = await runCheck(check, ctx)
321
+ const outcome = await runCheck(check, ctx, env)
318
322
  emissions.push(...outcome.emissions)
319
323
 
320
324
  if (outcome.failure !== undefined) {
@@ -363,7 +367,11 @@ interface CheckOutcome {
363
367
  readonly unmeasured?: string
364
368
  }
365
369
 
366
- async function runCheck(check: Check, ctx: GateContext): Promise<CheckOutcome> {
370
+ async function runCheck(
371
+ check: Check,
372
+ ctx: GateContext,
373
+ env: Readonly<Record<string, string>>,
374
+ ): Promise<CheckOutcome> {
367
375
  if (check.kind === 'measure') {
368
376
  const report = await check.measure(ctx)
369
377
  return {
@@ -378,7 +386,7 @@ async function runCheck(check: Check, ctx: GateContext): Promise<CheckOutcome> {
378
386
  }
379
387
 
380
388
  const run = check.kind === 'cli' ? ctx.cli : ctx.run
381
- const result = await run(check.argv)
389
+ const result = await run(check.argv, env)
382
390
  return {
383
391
  emissions: [{ kind: 'output', text: result.all }],
384
392
  failure: result.exitCode === 0 ? undefined : check.failure,
@@ -492,7 +500,7 @@ export function exitCodeFor(results: readonly StageResult[]): number {
492
500
  * repository the hook points at rather than the tree being verified.
493
501
  */
494
502
  export function commandRunner(root: string): RunCommand {
495
- return async (argv) => spawn(argv[0], argv.slice(1), root)
503
+ return async (argv, env) => spawn(argv[0], argv.slice(1), root, env)
496
504
  }
497
505
 
498
506
  /**
@@ -504,19 +512,20 @@ export function commandRunner(root: string): RunCommand {
504
512
  */
505
513
  export function cliRunner(root: string): RunCommand {
506
514
  const cli = join(PROJECT_ROOT, 'src', 'cli.ts')
507
- return async (argv) => spawn(process.execPath, [cli, ...argv], root)
515
+ return async (argv, env) => spawn(process.execPath, [cli, ...argv], root, env)
508
516
  }
509
517
 
510
518
  async function spawn(
511
519
  file: string,
512
520
  args: readonly string[],
513
521
  cwd: string,
522
+ env: Readonly<Record<string, string>> = {},
514
523
  ): Promise<CommandResult> {
515
524
  const result = await execa(file, [...args], {
516
525
  cwd,
517
526
  reject: false,
518
527
  all: true,
519
- env: { ...gitEnv(), CANON_NON_INTERACTIVE: '1' },
528
+ env: { ...gitEnv(), CANON_NON_INTERACTIVE: '1', ...env },
520
529
  extendEnv: false,
521
530
  })
522
531
 
package/src/gov/detect.ts CHANGED
@@ -99,25 +99,35 @@ function walkFiles(target: string): string[] {
99
99
  return files.sort()
100
100
  }
101
101
 
102
+ interface Candidates {
103
+ readonly paths: readonly string[]
104
+ readonly folders: readonly string[]
105
+ }
106
+
102
107
  /**
103
108
  * The paths a file glob is matched against: every file to depth two, plus
104
109
  * every folder on the way to any listed file, so an evidence folder such as
105
- * `.github/workflows` matches without naming the file inside it.
110
+ * `.github/workflows` matches without naming the file inside it. `folders`
111
+ * holds the folders alone, which a pattern ending in `/` is matched against.
106
112
  */
107
- async function candidatePaths(target: string): Promise<string[]> {
113
+ async function candidatePaths(target: string): Promise<Candidates> {
108
114
  const listed = (await listRepositoryFiles(target)) ?? walkFiles(target)
109
115
  const paths = new Set<string>()
116
+ const folders = new Set<string>()
110
117
 
111
118
  for (const file of listed) {
112
119
  let dir = dirname(file)
113
120
  while (dir !== '.') {
114
- if (depth(dir) <= MAX_DIRS) paths.add(dir)
121
+ if (depth(dir) <= MAX_DIRS) {
122
+ paths.add(dir)
123
+ folders.add(dir)
124
+ }
115
125
  dir = dirname(dir)
116
126
  }
117
127
  if (depth(file) <= MAX_DIRS) paths.add(file)
118
128
  }
119
129
 
120
- return [...paths].sort()
130
+ return { paths: [...paths].sort(), folders: [...folders].sort() }
121
131
  }
122
132
 
123
133
  /** A PEP 508 requirement's distribution name, normalized per PEP 503. */
@@ -231,6 +241,11 @@ function readDependencies(
231
241
  return deps
232
242
  }
233
243
 
244
+ /** A pattern ending in `/` matches folders only, so a file at that depth misses. */
245
+ function isFolderPattern(pattern: string): boolean {
246
+ return pattern.endsWith('/')
247
+ }
248
+
234
249
  /** A glob carrying no wildcard names one path rather than a pattern. */
235
250
  function isLiteral(pattern: string): boolean {
236
251
  return !/[*?[\]{}]/.test(pattern)
@@ -245,7 +260,7 @@ function matchEntry(
245
260
  entry: DetectEntry,
246
261
  target: string,
247
262
  deps: ReadonlyMap<string, readonly string[]>,
248
- paths: readonly string[],
263
+ candidates: Candidates,
249
264
  ): Evidence[] {
250
265
  const evidence: Evidence[] = []
251
266
 
@@ -255,9 +270,12 @@ function matchEntry(
255
270
  }
256
271
 
257
272
  for (const pattern of entry.files) {
258
- const glob = new Bun.Glob(pattern)
273
+ const folderOnly = isFolderPattern(pattern)
274
+ const glob = new Bun.Glob(folderOnly ? pattern.slice(0, -1) : pattern)
259
275
  const hit =
260
- paths.find((path) => glob.match(path)) ??
276
+ (folderOnly ? candidates.folders : candidates.paths).find((path) =>
277
+ glob.match(path),
278
+ ) ??
261
279
  (isLiteral(pattern) && existsSync(join(target, pattern))
262
280
  ? pattern
263
281
  : undefined)
@@ -287,13 +305,13 @@ export async function detectRules(
287
305
  }
288
306
 
289
307
  const live = indexRuleSources(root)
290
- const paths = await candidatePaths(target)
291
- const deps = readDependencies(target, paths)
308
+ const candidates = await candidatePaths(target)
309
+ const deps = readDependencies(target, candidates.paths)
292
310
 
293
311
  const proposals = loadDetectEntries(root)
294
312
  .filter((entry) => live.has(entry.rule) && !covered.has(entry.rule))
295
313
  .flatMap((entry) => {
296
- const evidence = matchEntry(entry, target, deps, paths)
314
+ const evidence = matchEntry(entry, target, deps, candidates)
297
315
  return evidence.length === 0 ? [] : [{ rule: entry.rule, evidence }]
298
316
  })
299
317