@erclx/canon 5.19.0 → 5.21.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 (135) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/references/verb-absent.md +1 -1
  3. package/claude/skills/create-skill/SKILL.md +1 -0
  4. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  5. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  6. package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
  7. package/claude/skills/draft-ready/SKILL.md +6 -4
  8. package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
  9. package/claude/skills/markdown-propose/SKILL.md +1 -1
  10. package/claude/skills/plan-feature/SKILL.md +1 -1
  11. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  12. package/claude/skills/record-screencast/REQUIREMENT.md +2 -1
  13. package/claude/skills/record-screencast/SKILL.md +2 -2
  14. package/claude/skills/role-worker/REQUIREMENT.md +3 -1
  15. package/claude/skills/role-worker/SKILL.md +5 -3
  16. package/claude/skills/session-resume/SKILL.md +1 -1
  17. package/claude/skills/task-board/REQUIREMENT.md +1 -0
  18. package/claude/skills/task-board/SKILL.md +6 -0
  19. package/claude/skills/task-board/references/decline.md +2 -0
  20. package/claude/skills/video-craft/REQUIREMENT.md +48 -0
  21. package/claude/skills/video-craft/SKILL.md +80 -0
  22. package/claude/skills/video-craft/references/adopted.md +66 -0
  23. package/docs/agents/canvas.md +4 -0
  24. package/docs/agents/commands.md +1 -0
  25. package/docs/agents/demo.md +1 -1
  26. package/docs/agents/index.md +1 -0
  27. package/docs/agents/install-and-sync.md +17 -3
  28. package/docs/agents/markdown-audit.md +1 -3
  29. package/docs/agents/rule-citations.md +4 -4
  30. package/docs/agents/sandbox.md +8 -7
  31. package/docs/agents/slides.md +120 -0
  32. package/docs/agents/tasks-validate.md +1 -1
  33. package/docs/agents/tasks.md +1 -1
  34. package/docs/target/migrations.md +1 -1
  35. package/docs/workflow/ai-workflow.md +1 -1
  36. package/docs/workflow/operating-model.md +9 -9
  37. package/docs/workflow/parallel-features.md +1 -1
  38. package/docs/workflow/skill-map.md +12 -13
  39. package/governance/renames.toml +24 -0
  40. package/governance/rules/lib/330-testing-py.md +1 -1
  41. package/governance/rules/lib/336-testing-php.md +1 -1
  42. package/governance/rules/{canon/640-architecture.md → standards/architecture.md} +3 -2
  43. package/governance/rules/standards/context.md +15 -0
  44. package/governance/rules/standards/design.md +13 -0
  45. package/governance/rules/standards/diagrams.md +17 -0
  46. package/governance/rules/standards/groundwork.md +13 -0
  47. package/governance/rules/standards/intake.md +12 -0
  48. package/governance/rules/standards/markdown.md +15 -0
  49. package/governance/rules/standards/memory.md +12 -0
  50. package/governance/rules/standards/mermaid.md +15 -0
  51. package/governance/rules/{canon/658-plan.md → standards/plan.md} +5 -9
  52. package/governance/rules/standards/readme.md +12 -0
  53. package/governance/rules/standards/ready.md +13 -0
  54. package/governance/rules/standards/requirements.md +13 -0
  55. package/governance/rules/standards/rule.md +13 -0
  56. package/governance/rules/standards/session.md +13 -0
  57. package/governance/rules/{claude/570-skill.md → standards/skill.md} +6 -10
  58. package/governance/rules/standards/standard.md +13 -0
  59. package/governance/rules/standards/tasks.md +15 -0
  60. package/governance/rules/{canon/661-teach.md → standards/teach.md} +3 -2
  61. package/governance/rules/standards/wiki.md +13 -0
  62. package/governance/rules/standards/wireframes.md +14 -0
  63. package/governance/rules/writing/800-prose.md +1 -1
  64. package/governance/stacks/base.toml +4 -2
  65. package/package.json +1 -1
  66. package/src/canvas/capture.ts +228 -15
  67. package/src/claude/cases/misc.ts +5 -0
  68. package/src/claude/cases/workflow.ts +4 -20
  69. package/src/claude/routing.ts +1 -1
  70. package/src/commands/canvas.ts +76 -2
  71. package/src/commands/gov.ts +19 -4
  72. package/src/commands/sandbox.ts +7 -0
  73. package/src/commands/slides.ts +13 -3
  74. package/src/commands/tasks.ts +46 -2
  75. package/src/gate/stages.ts +7 -1
  76. package/src/gov/citations.ts +1 -1
  77. package/src/gov/standard-rules.ts +179 -0
  78. package/src/migrate/skill-names.ts +0 -7
  79. package/src/sandbox/equivalence.ts +43 -14
  80. package/src/sandbox/stub-remote.ts +140 -0
  81. package/src/slides/convert/chart.ts +165 -0
  82. package/src/slides/convert/deck.ts +164 -0
  83. package/src/slides/convert/export.ts +217 -7
  84. package/src/slides/convert/master.ts +195 -0
  85. package/src/slides/convert/shapes.ts +1 -1
  86. package/src/slides/convert/walk.ts +187 -2
  87. package/src/tasks/record.ts +11 -1
  88. package/src/tasks/validate.ts +14 -0
  89. package/standards/architecture.md +5 -0
  90. package/standards/context.md +6 -0
  91. package/standards/design.md +3 -0
  92. package/standards/diagrams.md +8 -0
  93. package/standards/groundwork-spikes.md +66 -0
  94. package/standards/groundwork.md +5 -25
  95. package/standards/index.md +6 -2
  96. package/standards/intake.md +2 -0
  97. package/standards/markdown.md +6 -0
  98. package/standards/memory.md +2 -0
  99. package/standards/mermaid.md +6 -0
  100. package/standards/plan-lifecycle.md +36 -0
  101. package/standards/plan.md +13 -11
  102. package/standards/readme.md +2 -0
  103. package/standards/ready.md +4 -0
  104. package/standards/requirements.md +3 -0
  105. package/standards/rule.md +6 -1
  106. package/standards/session.md +4 -0
  107. package/standards/skill-paths.md +50 -0
  108. package/standards/skill.md +15 -23
  109. package/standards/standard.md +7 -1
  110. package/standards/tasks-decline.md +38 -0
  111. package/standards/tasks.md +9 -14
  112. package/standards/teach.md +5 -0
  113. package/standards/wiki.md +4 -0
  114. package/standards/wireframes.md +5 -0
  115. package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
  116. package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
  117. package/claude/skills/docs-fold/SKILL.md +0 -9
  118. package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
  119. package/claude/skills/standards-audit/SKILL.md +0 -99
  120. package/governance/rules/canon/610-context.md +0 -17
  121. package/governance/rules/canon/620-wireframes.md +0 -13
  122. package/governance/rules/canon/630-requirements.md +0 -12
  123. package/governance/rules/canon/650-design.md +0 -12
  124. package/governance/rules/canon/655-tasks.md +0 -13
  125. package/governance/rules/canon/656-groundwork.md +0 -11
  126. package/governance/rules/canon/657-intake.md +0 -11
  127. package/governance/rules/canon/659-memory.md +0 -11
  128. package/governance/rules/canon/660-diagrams.md +0 -19
  129. package/governance/rules/canon/662-session.md +0 -15
  130. package/governance/rules/canon/663-ready.md +0 -15
  131. package/governance/rules/claude/590-rule-authoring.md +0 -12
  132. package/governance/rules/writing/801-markdown.md +0 -14
  133. package/governance/rules/writing/802-mermaid.md +0 -14
  134. package/governance/rules/writing/880-readme.md +0 -11
  135. package/governance/rules/writing/891-standard-authoring.md +0 -12
@@ -12,6 +12,7 @@ import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
12
12
  import { creationRel, SCRATCH } from '@/record-root'
13
13
  import { createGovAdapter } from '@/gov/adapter'
14
14
  import { regenConsumedRules } from '@/gov/consumed'
15
+ import { regenStandardRules } from '@/gov/standard-rules'
15
16
  import { installRules, lookupRules } from '@/gov/install'
16
17
  import { buildGovCatalog, describeRule, describeStack } from '@/gov/list'
17
18
  import { buildRulesPayload, listRuleFiles } from '@/gov/payload'
@@ -190,15 +191,19 @@ export function register(program: Command): void {
190
191
 
191
192
  gov
192
193
  .command('regen')
193
- .description("Rebuild a repository's own .claude/rules/ from its record")
194
+ .description(
195
+ "Rebuild the standard-pointer rules and a repository's own .claude/rules/",
196
+ )
194
197
  .helpOption('-h, --help', 'Show this help message')
195
198
  .option('--root <path>', 'Repository root to regenerate', PROJECT_ROOT)
196
199
  .addHelpText(
197
200
  'after',
198
201
  [
199
202
  '',
200
- 'Reads internal/governance.toml and installs the stack it names, plus',
201
- 'any rules under internal/rules/. Unlike install and sync, this runs',
203
+ 'First writes governance/rules/standards/ from the paths: and rule:',
204
+ 'fields in each standard under standards/. Then reads',
205
+ 'internal/governance.toml and installs the stack it names, plus any',
206
+ 'rules under internal/rules/. Unlike install and sync, this runs',
202
207
  'against the toolkit root, whose .claude/rules/ is produced output.',
203
208
  '',
204
209
  ].join('\n'),
@@ -1114,7 +1119,17 @@ async function runRegen(
1114
1119
  if (mismatch !== undefined) logWarn(mismatch)
1115
1120
  }
1116
1121
 
1117
- const result = await regenConsumedRules(resolve(opts.root ?? PROJECT_ROOT))
1122
+ const root = resolve(opts.root ?? PROJECT_ROOT)
1123
+
1124
+ // The consumed copy installs from `governance/rules/`, so the band this
1125
+ // writes has to be fresh before that copy is built from it.
1126
+ const generated = await regenStandardRules(root)
1127
+ if (!generated.ok) {
1128
+ process.stderr.write(`Standard-rules regen failed: ${generated.reason}\n`)
1129
+ return 1
1130
+ }
1131
+
1132
+ const result = await regenConsumedRules(root)
1118
1133
 
1119
1134
  if (!result.ok) {
1120
1135
  process.stderr.write(`Consumed-rules regen failed: ${result.reason}\n`)
@@ -346,6 +346,7 @@ function runCoverage(options: CoverageOptions): void {
346
346
  interface EquivalenceOptions {
347
347
  readonly base: string
348
348
  readonly includeAnchor?: boolean
349
+ readonly stubRemote?: boolean
349
350
  readonly out?: string
350
351
  readonly masks: boolean
351
352
  readonly json?: boolean
@@ -372,6 +373,7 @@ async function runEquivalence(
372
373
  targets,
373
374
  base: options.base,
374
375
  includeAnchor: options.includeAnchor === true,
376
+ stubRemote: options.stubRemote === true,
375
377
  useMasks: options.masks,
376
378
  out: options.out,
377
379
  })
@@ -568,6 +570,10 @@ export function register(program: Command): void {
568
570
  '--include-anchor',
569
571
  'Also run the arms that push to the shared GitHub anchor, one at a time (attended)',
570
572
  )
573
+ .option(
574
+ '--stub-remote',
575
+ 'Run the anchor arms against a local bare repository and a stub gh, writing nothing to the shared anchor',
576
+ )
571
577
  .option('--out <dir>', 'Keep the provisioned trees and logs in this folder')
572
578
  .option(
573
579
  '--no-masks',
@@ -583,6 +589,7 @@ export function register(program: Command): void {
583
589
  ' canon sandbox equivalence claude:plan-feature --base origin/main',
584
590
  '',
585
591
  'Provisions offline only and never runs sandbox/run.sh or a claude binary.',
592
+ '--stub-remote also runs the anchor arms, and wins over --include-anchor.',
586
593
  'Exit codes: 0 when every compared arm is identical or red the same way',
587
594
  'on both sides, 1 on any difference, exit mismatch, or enumeration error.',
588
595
  ].join('\n'),
@@ -119,9 +119,11 @@ export function register(program: Command): void {
119
119
 
120
120
  /**
121
121
  * The converter loads on demand, so no other command resolves pptxgenjs,
122
- * jszip, or the token module at startup. A fallback or a refused link reports
123
- * one line each and keeps the exit at 0, as an unrecognized layout does on the
124
- * markdown path, since the deck is written either way.
122
+ * jszip, or the token module at startup. A fallback, a refused link or chart,
123
+ * or a token the master fell back on reports one line each and keeps the exit
124
+ * at 0, as an unrecognized layout does on the markdown path, since the deck is
125
+ * written either way. A malformed `deck.json` refuses before any slide is laid
126
+ * out and exits 1.
125
127
  */
126
128
  async function renderHtmlFolder(
127
129
  sourceDir: string,
@@ -156,6 +158,14 @@ async function renderHtmlFolder(
156
158
  `${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} link "${href}" left out: ${reason}\n`,
157
159
  )
158
160
  }
161
+ for (const { slide, selector, message } of result.refusedCharts) {
162
+ process.stderr.write(
163
+ `${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} chart left out: ${message}\n`,
164
+ )
165
+ }
166
+ for (const notice of result.notices) {
167
+ process.stderr.write(`${GREY}│${NC} ${RED}✗${NC} ${notice}\n`)
168
+ }
159
169
  if (result.mirrorPath) {
160
170
  process.stderr.write(
161
171
  `${GREY}│${NC} ${GREEN}✓${NC} mirrored to ${result.mirrorPath}\n`,
@@ -65,6 +65,7 @@ const EXIT_FINDINGS = 2
65
65
 
66
66
  /** Matches `trunk.ts`'s bound on a git subprocess this verb also shells out to. */
67
67
  const GIT_TIMEOUT_MS = 10_000
68
+ const GH_TIMEOUT_MS = 10_000
68
69
 
69
70
  const TRUNK_BRANCHES: readonly string[] = ['main', 'master']
70
71
 
@@ -522,7 +523,9 @@ export function register(program: Command): void {
522
523
  'Run from a branch other than main or master, a close that ticks a new',
523
524
  'box names that branch on a Pending branch: line, which holds the task',
524
525
  'against a merge-time archive until canon tasks pull-request records',
525
- "the branch's own number.",
526
+ "the branch's own number. The marker is skipped once gh lists a pull",
527
+ "request for the branch whose number is already on the task's Pull",
528
+ 'request: line, and a failed gh lookup writes it.',
526
529
  '',
527
530
  'Examples:',
528
531
  ' canon tasks outcome v28.1-trigger-escalation --close 1 --close 3',
@@ -728,6 +731,45 @@ async function workingBranch(): Promise<string | undefined> {
728
731
  return branch
729
732
  }
730
733
 
734
+ /**
735
+ * The pull request numbers whose head is `branch`, open or not. A failed run,
736
+ * an unreadable answer, or no `gh` at all returns an empty list, which writes
737
+ * the marker, the direction that holds a task open rather than archiving it.
738
+ */
739
+ async function branchPullNumbers(
740
+ branch: string | undefined,
741
+ ): Promise<readonly number[]> {
742
+ if (branch === undefined) return []
743
+
744
+ const result = await execa(
745
+ 'gh',
746
+ ['pr', 'list', '--head', branch, '--state', 'all', '--json', 'number'],
747
+ {
748
+ cwd: process.cwd(),
749
+ reject: false,
750
+ timeout: GH_TIMEOUT_MS,
751
+ env: gitEnv(),
752
+ extendEnv: false,
753
+ },
754
+ )
755
+ if (result.exitCode !== 0) return []
756
+
757
+ try {
758
+ const rows: unknown = JSON.parse(result.stdout)
759
+ if (!Array.isArray(rows)) return []
760
+
761
+ return rows
762
+ .map((row: unknown) =>
763
+ typeof row === 'object' && row !== null && 'number' in row
764
+ ? row.number
765
+ : undefined,
766
+ )
767
+ .filter((number): number is number => Number.isInteger(number))
768
+ } catch {
769
+ return []
770
+ }
771
+ }
772
+
731
773
  async function runPlanLink(
732
774
  task: string,
733
775
  plan: string,
@@ -789,11 +831,13 @@ async function runOutcome(
789
831
  }
790
832
 
791
833
  const root = opts.root ?? (await mainWorktreeRoot())
834
+ const branch = await workingBranch()
792
835
  const outcome = await closeOutcomes(
793
836
  root,
794
837
  selector,
795
838
  raw.map(Number),
796
- await workingBranch(),
839
+ branch,
840
+ await branchPullNumbers(branch),
797
841
  )
798
842
 
799
843
  return reportOutcome(outcome, emitJson, root)
@@ -192,11 +192,17 @@ export const STAGES: readonly Stage[] = [
192
192
  argv: ['gov', 'regen'],
193
193
  failure: 'Consumed-copy regen failed',
194
194
  },
195
+ {
196
+ kind: 'drift',
197
+ pathspec: 'governance/rules/standards',
198
+ failure:
199
+ 'Standard-pointer rules drifted. Run bun run check and commit governance/rules/standards and .claude/rules.',
200
+ },
195
201
  {
196
202
  kind: 'drift',
197
203
  pathspec: '.claude/rules',
198
204
  failure:
199
- 'Consumed copies drifted. Run bun run check and commit .claude/rules.',
205
+ 'Consumed copies drifted. Run bun run check and commit governance/rules/standards and .claude/rules.',
200
206
  },
201
207
  ],
202
208
  success: 'Consumed copies clean',
@@ -266,7 +266,7 @@ export function collectCitations(text: string): RawCitation[] {
266
266
  *
267
267
  * A rule spelling a whole path there is declaring that exact artifact, and a
268
268
  * body line naming it again is naming what the rule governs rather than
269
- * pointing a reader somewhere. `governance/rules/canon/660-diagrams.md` tells
269
+ * pointing a reader somewhere. `governance/rules/standards/diagrams.md` tells
270
270
  * its reader to convert a `.claude/DIAGRAMS.md` left by an older install, which
271
271
  * is correctly absent from this tree and correctly named in the rule.
272
272
  */
@@ -0,0 +1,179 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises'
3
+ import { basename, join, relative } from 'node:path'
4
+ import { listRuleSourcePaths, rulesSourceDir } from '@/gov/install'
5
+ import { parseFrontmatter } from '@/indexes/frontmatter'
6
+
7
+ const STANDARDS_REL = 'standards'
8
+
9
+ /** The band the generated rules ship in, named for what they point at. */
10
+ export const STANDARD_RULES_BAND = 'standards'
11
+
12
+ export type StandardRulesResult =
13
+ | { readonly ok: true; readonly written: readonly string[] }
14
+ | { readonly ok: false; readonly reason: string }
15
+
16
+ interface StandardRule {
17
+ readonly name: string
18
+ readonly sourceRel: string
19
+ readonly title: string
20
+ readonly description: string
21
+ readonly paths: readonly string[]
22
+ readonly bullets: readonly string[]
23
+ }
24
+
25
+ export function standardRulesDir(root: string): string {
26
+ return join(rulesSourceDir(root), STANDARD_RULES_BAND)
27
+ }
28
+
29
+ /**
30
+ * Generates one path-scoped rule per standard whose frontmatter carries
31
+ * `paths:`. The standard owns the glob and the operative bullets, so the rule
32
+ * is output, committed beside the authored bands so every reader of
33
+ * `governance/rules/` sees it as an ordinary file.
34
+ *
35
+ * Every standard is read and checked before anything is written, so a refusal
36
+ * leaves the previous output whole rather than half cleared.
37
+ */
38
+ export async function regenStandardRules(
39
+ root: string,
40
+ ): Promise<StandardRulesResult> {
41
+ const standardsDir = join(root, STANDARDS_REL)
42
+ if (!existsSync(standardsDir)) return { ok: true, written: [] }
43
+
44
+ const reserved = reservedNames(root)
45
+ const rules: StandardRule[] = []
46
+
47
+ for (const file of readdirSync(standardsDir).sort()) {
48
+ if (!file.endsWith('.md')) continue
49
+ const read = readStandard(root, file, reserved)
50
+ if (typeof read === 'string') return { ok: false, reason: read }
51
+ if (read !== undefined) rules.push(read)
52
+ }
53
+
54
+ const dir = standardRulesDir(root)
55
+ await rm(dir, { recursive: true, force: true })
56
+ if (rules.length > 0) await mkdir(dir, { recursive: true })
57
+
58
+ const written: string[] = []
59
+ for (const rule of rules) {
60
+ const dest = join(dir, `${rule.name}.md`)
61
+ await writeFile(dest, renderStandardRule(rule))
62
+ written.push(relative(root, dest))
63
+ }
64
+
65
+ return { ok: true, written }
66
+ }
67
+
68
+ /**
69
+ * Band folders and authored rule basenames. A bare-word rule name equal to a
70
+ * band would install the whole band through `--add`, and one equal to an
71
+ * authored rule would shadow it in the name index.
72
+ */
73
+ function reservedNames(root: string): {
74
+ readonly bands: ReadonlySet<string>
75
+ readonly rules: ReadonlySet<string>
76
+ } {
77
+ const bands = new Set<string>([STANDARD_RULES_BAND])
78
+ const rules = new Set<string>()
79
+
80
+ for (const rel of listRuleSourcePaths(root)) {
81
+ const segments = rel.split('/')
82
+ if (segments.length > 1) bands.add(segments[0])
83
+ if (segments[0] === STANDARD_RULES_BAND) continue
84
+ rules.add(basename(rel, '.md'))
85
+ }
86
+
87
+ return { bands, rules }
88
+ }
89
+
90
+ /** A rule to render, `undefined` for a standard with no `paths:`, or a reason. */
91
+ function readStandard(
92
+ root: string,
93
+ file: string,
94
+ reserved: ReturnType<typeof reservedNames>,
95
+ ): StandardRule | string | undefined {
96
+ const sourceRel = `${STANDARDS_REL}/${file}`
97
+ const fields = parseFrontmatter(
98
+ readFileSync(join(root, STANDARDS_REL, file), 'utf8'),
99
+ )?.fields
100
+ if (fields === undefined || fields.paths === undefined) return undefined
101
+
102
+ const name = basename(file, '.md')
103
+ if (!isStringList(fields.paths)) {
104
+ return `${sourceRel}: paths must be a list of strings`
105
+ }
106
+ if (fields.rule !== undefined && !isStringList(fields.rule)) {
107
+ return `${sourceRel}: rule must be a list of strings`
108
+ }
109
+ if (reserved.bands.has(name)) {
110
+ return `${sourceRel}: name collides with a rule band folder`
111
+ }
112
+ if (reserved.rules.has(name)) {
113
+ return `${sourceRel}: name collides with an authored rule`
114
+ }
115
+
116
+ return {
117
+ name,
118
+ sourceRel,
119
+ title: typeof fields.title === 'string' ? fields.title : name,
120
+ description:
121
+ typeof fields.description === 'string' ? fields.description : '',
122
+ paths: fields.paths,
123
+ bullets: fields.rule ?? [],
124
+ }
125
+ }
126
+
127
+ function isStringList(value: unknown): value is string[] {
128
+ return (
129
+ Array.isArray(value) && value.every((entry) => typeof entry === 'string')
130
+ )
131
+ }
132
+
133
+ function renderStandardRule(rule: StandardRule): string {
134
+ const scope = rule.description.replace(/\.$/, '')
135
+ const pointer = [
136
+ `Follow the ${rule.name} standard for ${lowerFirst(scope)}.`,
137
+ 'It is the single source.',
138
+ `Read it with \`canon standards ${rule.name}\`.`,
139
+ ].join(' ')
140
+
141
+ return [
142
+ '---',
143
+ `# Generated by \`canon gov regen\` from ${rule.sourceRel}. Edit that frontmatter, not this file.`,
144
+ `description: ${yamlScalar(rule.description)}`,
145
+ 'paths:',
146
+ ...rule.paths.map((glob) => ` - ${yamlQuoted(glob)}`),
147
+ '---',
148
+ '',
149
+ `# ${rule.title.replace(/\breference$/, 'standards')}`,
150
+ '',
151
+ '## Authority',
152
+ '',
153
+ ...[pointer, ...rule.bullets].map((bullet) => `- ${bullet}`),
154
+ '',
155
+ ].join('\n')
156
+ }
157
+
158
+ function lowerFirst(text: string): string {
159
+ return text.charAt(0).toLowerCase() + text.slice(1)
160
+ }
161
+
162
+ /** Plain when YAML would read it back unchanged, single-quoted otherwise. */
163
+ function yamlScalar(text: string): string {
164
+ const isPlain =
165
+ text !== '' &&
166
+ !/^[-?:,[\]{}#&*!|>'"%@`\s]/.test(text) &&
167
+ !/: |\s#|:$|\s$/.test(text)
168
+ return isPlain ? text : yamlQuoted(text)
169
+ }
170
+
171
+ /**
172
+ * Quoted the way Prettier's `singleQuote` leaves YAML, so the drift check
173
+ * never fights the formatter: single quotes unless the text holds one and
174
+ * double quotes would need no escape.
175
+ */
176
+ function yamlQuoted(text: string): string {
177
+ if (text.includes("'") && !/["\\]/.test(text)) return `"${text}"`
178
+ return `'${text.replaceAll("'", "''")}'`
179
+ }
@@ -110,11 +110,6 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
110
110
  * what shipped or what a session ran under whatever name was current then, so
111
111
  * rewriting one makes it testify to a release or a run that never happened.
112
112
  *
113
- * The pointer left at `docs-fold` is excluded because it exists to answer under
114
- * the old name, so a later run would move it onto the skill it points at. The
115
- * exclusion is only safe once the real body has moved, since excluding the path
116
- * ahead of the first sweep leaves that body behind.
117
- *
118
113
  * The record archives need no entry here. They are gitignored, so the tracked
119
114
  * listing every sweep reads never reaches them.
120
115
  */
@@ -127,7 +122,5 @@ export const SKILL_NAME_RULES: RenameRules = defineRenameRules({
127
122
  'CHANGELOG.md',
128
123
  'src/migrate/skill-names.ts',
129
124
  'src/migrate/skill-names.test.ts',
130
- 'claude/skills/docs-fold/SKILL.md',
131
- 'claude/skills/docs-fold/REQUIREMENT.md',
132
125
  ],
133
126
  })
@@ -17,6 +17,7 @@ import {
17
17
  import { tmpdir } from 'node:os'
18
18
  import { join } from 'node:path'
19
19
  import { collectCoverage, DEFAULT_ARM } from '@/sandbox/coverage'
20
+ import { createStubRemote, readStubManifest } from '@/sandbox/stub-remote'
20
21
 
21
22
  /** Holds fixture content rather than scenarios. Twin of the filter in `sandbox.ts`. */
22
23
  const FIXTURES_DIR = 'fixtures'
@@ -136,6 +137,8 @@ export interface EquivalenceOptions {
136
137
  readonly targets: readonly string[]
137
138
  readonly base: string
138
139
  readonly includeAnchor: boolean
140
+ /** Runs anchor arms against a local bare repository and a stub `gh`, never the shared anchor. */
141
+ readonly stubRemote?: boolean
139
142
  readonly useMasks: boolean
140
143
  readonly out?: string
141
144
  }
@@ -516,7 +519,15 @@ function configDirFor(dir: string): string {
516
519
  return `${dir}.config`
517
520
  }
518
521
 
519
- function provisionEnv(arm: Arm, dir: string): NodeJS.ProcessEnv {
522
+ function stubDirFor(dir: string): string {
523
+ return `${dir}.stub`
524
+ }
525
+
526
+ function provisionEnv(
527
+ arm: Arm,
528
+ dir: string,
529
+ stubbed = false,
530
+ ): NodeJS.ProcessEnv {
520
531
  const env: NodeJS.ProcessEnv = {
521
532
  ...process.env,
522
533
  CANON_NON_INTERACTIVE: '1',
@@ -524,6 +535,7 @@ function provisionEnv(arm: Arm, dir: string): NodeJS.ProcessEnv {
524
535
  CANON_SANDBOX_RUN_ID: PINNED_RUN_ID,
525
536
  CLAUDE_CONFIG_DIR: configDirFor(dir),
526
537
  }
538
+ if (stubbed) Object.assign(env, createStubRemote(stubDirFor(dir)))
527
539
  if (arm.arm === undefined) delete env.SANDBOX_SCENARIO
528
540
  else env.SANDBOX_SCENARIO = arm.arm
529
541
 
@@ -541,13 +553,14 @@ async function provision(
541
553
  arm: Arm,
542
554
  dir: string,
543
555
  logPath: string,
556
+ stubbed: boolean,
544
557
  ): Promise<Provisioned> {
545
558
  rmSync(dir, { recursive: true, force: true })
546
559
  rmSync(configDirFor(dir), { recursive: true, force: true })
547
560
  const exit = await runProcess(
548
561
  'bun',
549
562
  [join(root, 'src', 'cli.ts'), 'sandbox', `${arm.category}:${arm.command}`],
550
- { cwd: root, env: provisionEnv(arm, dir), logPath },
563
+ { cwd: root, env: provisionEnv(arm, dir, stubbed), logPath },
551
564
  )
552
565
  const log = readFileSync(logPath, 'utf8').split(root).join(ROOT_PLACEHOLDER)
553
566
 
@@ -592,8 +605,12 @@ async function checkHead(
592
605
  }
593
606
  }
594
607
 
595
- function withExit(manifest: Manifest, run: Provisioned): Manifest {
596
- const next = new Map(manifest)
608
+ function withExit(
609
+ manifest: Manifest,
610
+ run: Provisioned,
611
+ stub?: Manifest,
612
+ ): Manifest {
613
+ const next = new Map([...manifest, ...(stub ?? [])])
597
614
  next.set('exit', String(run.exit))
598
615
  next.set('log', run.log)
599
616
 
@@ -608,16 +625,20 @@ async function runArm(
608
625
  headRoot: string,
609
626
  masks: readonly Mask[],
610
627
  declared: boolean,
628
+ stubbed: boolean,
611
629
  ): Promise<ArmRecord> {
612
630
  const key = armKey(arm)
613
631
  const dir = join(workDir, 'arms', `arm-${index}`)
614
632
  const logPath = join(workDir, 'logs', `arm-${index}.log`)
615
633
 
616
- const base = await provision(baseRoot, arm, dir, logPath)
617
- const baseManifest = withExit(buildManifest(dir), base)
634
+ const stubManifest = (): Manifest | undefined =>
635
+ stubbed ? readStubManifest(stubDirFor(dir)) : undefined
618
636
 
619
- const head = await provision(headRoot, arm, dir, logPath)
620
- const headManifest = withExit(buildManifest(dir), head)
637
+ const base = await provision(baseRoot, arm, dir, logPath, stubbed)
638
+ const baseManifest = withExit(buildManifest(dir), base, stubManifest())
639
+
640
+ const head = await provision(headRoot, arm, dir, logPath, stubbed)
641
+ const headManifest = withExit(buildManifest(dir), head, stubManifest())
621
642
 
622
643
  // Kept beside the logs, so a reader of an `--out` folder can see what a
623
644
  // difference was rather than only that one exists.
@@ -643,6 +664,7 @@ async function runArm(
643
664
  : undefined
644
665
 
645
666
  rmSync(configDirFor(dir), { recursive: true, force: true })
667
+ rmSync(stubDirFor(dir), { recursive: true, force: true })
646
668
 
647
669
  return {
648
670
  arm: key,
@@ -770,8 +792,13 @@ export async function runEquivalence(
770
792
  try {
771
793
  const baseRoot = join(workDir, 'base')
772
794
  const baseCommit = git(options.root, ['rev-parse', options.base])
773
- const runnable = arms.filter((a) => !a.anchor || options.includeAnchor)
774
- const skipped = arms.filter((a) => a.anchor && !options.includeAnchor)
795
+ const stubRemote = options.stubRemote === true
796
+ const runnable = arms.filter(
797
+ (a) => !a.anchor || options.includeAnchor || stubRemote,
798
+ )
799
+ const skipped = arms.filter(
800
+ (a) => a.anchor && !options.includeAnchor && !stubRemote,
801
+ )
775
802
 
776
803
  const indexed = new Map(arms.map((a, i) => [armKey(a), i]))
777
804
  const run = (arm: Arm): Promise<ArmRecord> =>
@@ -783,18 +810,20 @@ export async function runEquivalence(
783
810
  options.root,
784
811
  masks,
785
812
  declaredKeys.has(armKey(arm)),
813
+ stubRemote && arm.anchor,
786
814
  )
787
815
 
788
816
  // Anchor arms force-push `main` and recreate a pull request on one shared
789
- // remote, so they run strictly one at a time.
817
+ // remote, so they run strictly one at a time. A stub remote is private to its
818
+ // arm, so those arms join the pool.
819
+ const shared = (a: Arm): boolean => a.anchor && !stubRemote
790
820
  const offline = await pool(
791
- runnable.filter((a) => !a.anchor),
821
+ runnable.filter((a) => !shared(a)),
792
822
  CONCURRENCY,
793
823
  (arm) => run(arm),
794
824
  )
795
825
  const anchored: ArmRecord[] = []
796
- for (const arm of runnable.filter((a) => a.anchor))
797
- anchored.push(await run(arm))
826
+ for (const arm of runnable.filter(shared)) anchored.push(await run(arm))
798
827
 
799
828
  const records: ArmRecord[] = [
800
829
  ...offline,