@erclx/canon 4.118.0 → 4.120.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 (103) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-rollout/SKILL.md +2 -2
  4. package/claude/skills/create-skill/SKILL.md +2 -1
  5. package/claude/skills/design-extract/SKILL.md +1 -1
  6. package/claude/skills/docs-fold/SKILL.md +2 -2
  7. package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
  8. package/claude/skills/draft-ready/SKILL.md +1 -1
  9. package/claude/skills/draft-screencast/SKILL.md +1 -1
  10. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  11. package/claude/skills/git-pr/SKILL.md +39 -7
  12. package/claude/skills/memory-capture/SKILL.md +3 -3
  13. package/claude/skills/memory-review/SKILL.md +3 -3
  14. package/claude/skills/plan-feature/SKILL.md +1 -1
  15. package/claude/skills/review-branch/SKILL.md +1 -1
  16. package/claude/skills/review-pr/SKILL.md +13 -242
  17. package/claude/skills/review-pr/references/close-out.md +30 -0
  18. package/claude/skills/review-pr/references/post.md +145 -0
  19. package/claude/skills/review-pr/references/scope.md +82 -0
  20. package/claude/skills/role-orchestrator/REQUIREMENT.md +9 -16
  21. package/claude/skills/role-orchestrator/SKILL.md +9 -153
  22. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +4 -193
  23. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +22 -0
  24. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +85 -0
  25. package/claude/skills/role-orchestrator/references/orchestrator-output.md +61 -0
  26. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -2
  27. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
  28. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +56 -0
  29. package/claude/skills/role-orchestrator/references/orchestrator-review-fallback.md +18 -0
  30. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +1 -1
  31. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  32. package/claude/skills/role-planner/SKILL.md +1 -1
  33. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  34. package/claude/skills/role-worker/SKILL.md +10 -41
  35. package/claude/skills/seed-sync/SKILL.md +1 -1
  36. package/claude/skills/session-compact/SKILL.md +1 -1
  37. package/claude/skills/session-map/SKILL.md +1 -1
  38. package/claude/skills/session-relay/REQUIREMENT.md +7 -1
  39. package/claude/skills/session-relay/SKILL.md +4 -7
  40. package/claude/skills/session-worktree/REQUIREMENT.md +5 -1
  41. package/claude/skills/session-worktree/SKILL.md +7 -1
  42. package/claude/skills/session-worktree/references/main-root-writes.md +24 -0
  43. package/claude/skills/task-board/SKILL.md +4 -3
  44. package/claude/skills/teach-workspace/SKILL.md +4 -4
  45. package/claude/skills/ui-checklist/SKILL.md +1 -1
  46. package/claude/skills/ux-audit/SKILL.md +1 -1
  47. package/claude/skills/ux-measure/SKILL.md +1 -1
  48. package/claude/skills/ux-walkthrough/references/relay.md +1 -1
  49. package/docs/agents/commands.md +4 -3
  50. package/docs/agents/counts.md +1 -1
  51. package/docs/agents/index.md +8 -3
  52. package/docs/agents/install-and-sync.md +11 -6
  53. package/docs/agents/pr-evidence.md +19 -3
  54. package/docs/agents/pr-local.md +102 -0
  55. package/docs/agents/pr-preview.md +2 -2
  56. package/docs/agents/records-push.md +61 -0
  57. package/docs/agents/records-validate.md +73 -0
  58. package/docs/agents/records.md +3 -119
  59. package/docs/agents/sessions-transfer.md +91 -0
  60. package/docs/agents/sessions.md +3 -82
  61. package/docs/agents/skills-audit.md +2 -2
  62. package/docs/agents/tasks.md +4 -4
  63. package/docs/agents/teach-authoring.md +112 -0
  64. package/docs/agents/teach.md +3 -103
  65. package/docs/target-projects.md +2 -2
  66. package/docs/workflow/ai-workflow.md +10 -10
  67. package/docs/workflow/visual-design-workflow.md +2 -2
  68. package/governance/renames.toml +13 -0
  69. package/governance/rules/claude/570-skill.md +2 -1
  70. package/governance/rules/core/085-worktrees.md +2 -8
  71. package/package.json +1 -1
  72. package/scripts/core/check-ignore-parity.sh +1 -1
  73. package/scripts/lib/sandbox-git.sh +1 -1
  74. package/src/claude/cases/workflow.ts +10 -0
  75. package/src/commands/pr.ts +324 -2
  76. package/src/commands/sync.ts +11 -3
  77. package/src/commands/tasks.ts +9 -8
  78. package/src/context/classify/extract.ts +2 -2
  79. package/src/counts/scan.ts +1 -1
  80. package/src/design/base.css +19 -19
  81. package/src/design/board.ts +18 -7
  82. package/src/design/neutral.ts +127 -0
  83. package/src/design/regen.ts +6 -4
  84. package/src/design/tokens.ts +6 -3
  85. package/src/gate/stages.ts +6 -6
  86. package/src/gov/adapter.ts +51 -1
  87. package/src/gov/install.ts +13 -3
  88. package/src/gov/renames.ts +76 -0
  89. package/src/gov/stacks.ts +5 -1
  90. package/src/pr/evidence.ts +42 -2
  91. package/src/pr/local.ts +125 -0
  92. package/src/sync/check.ts +19 -4
  93. package/src/sync/engine.ts +67 -3
  94. package/src/tasks/archive.ts +28 -7
  95. package/src/tasks/record.ts +16 -9
  96. package/src/tasks/validate.ts +3 -1
  97. package/standards/index.md +1 -0
  98. package/standards/skill-requirement.md +72 -0
  99. package/standards/skill.md +34 -152
  100. package/standards/tasks.md +5 -3
  101. package/tooling/astro/reference.md +1 -1
  102. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +32 -0
  103. package/governance/rules/core/091-channel.md +0 -13
package/src/sync/check.ts CHANGED
@@ -4,6 +4,7 @@ import { execa } from 'execa'
4
4
  import { gitEnv } from '@/git-env'
5
5
  import { createDesignAdapter, DESIGN_INSTALL_DIR } from '@/design/adapter'
6
6
  import { createGovAdapter, rulesSourceDir } from '@/gov/adapter'
7
+ import { successorResolver } from '@/gov/renames'
7
8
  import { loadGovStack, resolveMissingRules, resolveRules } from '@/gov/stacks'
8
9
  import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
9
10
  import {
@@ -80,6 +81,7 @@ export interface StateCounts {
80
81
  readonly stranded: number
81
82
  readonly missing: number
82
83
  readonly retired: number
84
+ readonly renamed: number
83
85
  }
84
86
 
85
87
  export interface DomainReport {
@@ -316,6 +318,7 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
316
318
  stranded: count(entries, 'stranded'),
317
319
  missing: count(entries, 'missing'),
318
320
  retired: count(entries, 'retired'),
321
+ renamed: count(entries, 'renamed'),
319
322
  }
320
323
  }
321
324
 
@@ -347,6 +350,8 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
347
350
  *
348
351
  * `retired` counts, since a sync deletes it mechanically. A target's CI goes
349
352
  * red the day a release retires a rule it holds, and one sync clears it.
353
+ * `renamed` counts on the same grounds, since the sync that moves it is
354
+ * equally mechanical.
350
355
  */
351
356
  export function hasDrift(report: CheckReport): boolean {
352
357
  if (report.unmigrated.length > 0) return true
@@ -357,7 +362,8 @@ export function hasDrift(report: CheckReport): boolean {
357
362
  domain.counts.customized +
358
363
  domain.counts.drifted +
359
364
  domain.counts.stranded +
360
- domain.counts.retired >
365
+ domain.counts.retired +
366
+ domain.counts.renamed >
361
367
  0,
362
368
  )
363
369
  }
@@ -488,8 +494,14 @@ export function parseNewSkills(paths: string): string[] {
488
494
  * chain a target consumed survives nowhere else. `installRules` copies each
489
495
  * rule into the subdirectory it was authored in, which is what makes the band
490
496
  * folders legible as evidence.
497
+ *
498
+ * `successorOf` adds the name a held rule was renamed to, which the next sync
499
+ * installs, so the fallback does not report it as new.
491
500
  */
492
- export function readInstalledRules(target: string): {
501
+ export function readInstalledRules(
502
+ target: string,
503
+ successorOf?: (name: string) => string | undefined,
504
+ ): {
493
505
  held: Set<string>
494
506
  bands: Set<string>
495
507
  } {
@@ -506,7 +518,10 @@ export function readInstalledRules(target: string): {
506
518
  const posix = rel.split(sep).join('/')
507
519
  const boundary = posix.indexOf('/')
508
520
 
509
- held.add(basename(posix, '.md'))
521
+ const name = basename(posix, '.md')
522
+ held.add(name)
523
+ const successor = successorOf?.(name)
524
+ if (successor !== undefined) held.add(successor)
510
525
  if (boundary > 0) bands.add(posix.slice(0, boundary))
511
526
  }
512
527
 
@@ -640,7 +655,7 @@ export async function readNewRules(
640
655
  'governance/rules/',
641
656
  ])
642
657
 
643
- const { held, bands } = readInstalledRules(target)
658
+ const { held, bands } = readInstalledRules(target, successorResolver(root))
644
659
  return selectNewRules(paths, held, bands.union(baseBands(root)))
645
660
  }
646
661
 
@@ -70,6 +70,11 @@ export type SyncChange =
70
70
  * sync deletes it. A stamped file the toolkit no longer installs to is
71
71
  * stranded, which is a relocation waiting on a decision.
72
72
  *
73
+ * `renamed` is the fourth sourceless state and the only one that installs.
74
+ * It is a retired file whose rule the adapter declares still ships under a
75
+ * new name, so the sync deletes the old file and copies the rule to the new
76
+ * one.
77
+ *
73
78
  * `missing` is the one state the walk cannot produce on its own, since the
74
79
  * walk only iterates files that exist. It comes from `collectMissing`
75
80
  * instead, an adapter naming an entitled file the target does not hold.
@@ -81,6 +86,7 @@ export type EntryState =
81
86
  | 'drifted'
82
87
  | 'orphaned'
83
88
  | 'retired'
89
+ | 'renamed'
84
90
  | 'stranded'
85
91
  | 'missing'
86
92
 
@@ -165,9 +171,20 @@ export interface SyncAdapter {
165
171
  * no source is a leftover of a rule the toolkit retired or renamed rather
166
172
  * than something a project wrote. Setting it turns that file into a
167
173
  * `retired` entry and queues its delete, edited or not, whatever the stamp
168
- * holds. Unset, the file stays `orphaned` and untouched.
174
+ * holds, or a `renamed` one when `locateSuccessor` names where it ships
175
+ * now. Unset, the file stays `orphaned` and untouched.
169
176
  */
170
177
  readonly ownsInstalledRoot?: boolean
178
+ /**
179
+ * Where a sourceless file's rule ships now, when the toolkit declares it
180
+ * renamed rather than retired. Reached only on a root the adapter owns and
181
+ * never past the newer-install guard. Unset, or returning nothing, leaves
182
+ * the file `retired`.
183
+ */
184
+ locateSuccessor?(
185
+ file: InstalledFile,
186
+ target: string,
187
+ ): { readonly source: string; readonly dest: string } | undefined
171
188
  /** Defaults to applying. */
172
189
  readonly nonInteractive?: NonInteractivePolicy
173
190
  /** Runs on a completed sync, including one with no changes. */
@@ -224,6 +241,7 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
224
241
  const hashes = stampedHashes(stamp, adapter.stamp?.domain)
225
242
  const newerInstall = newerInstallingVersion(stamp, adapter.stamp?.domain)
226
243
  const walked = new Set<string>()
244
+ const pendingCopies = new Set<string>()
227
245
 
228
246
  for (const file of listInstalled(
229
247
  adapter.installedRoot(target),
@@ -243,16 +261,44 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
243
261
  if (source === undefined || !existsSync(source)) {
244
262
  if (adapter.ownsInstalledRoot !== true) {
245
263
  entries.push(misplacedOrphan(adapter, target, file))
246
- } else if (newerInstall !== undefined) {
264
+ continue
265
+ }
266
+
267
+ if (newerInstall !== undefined) {
247
268
  entries.push(heldForNewerInstall(file, newerInstall))
248
- } else {
269
+ continue
270
+ }
271
+
272
+ const successor = adapter.locateSuccessor?.(file, target)
273
+ if (successor === undefined) {
249
274
  entries.push({
250
275
  state: 'retired',
251
276
  rel: file.rel,
252
277
  notice: `${file.rel} (no longer shipped by the toolkit, removing)`,
253
278
  })
254
279
  changes.push({ kind: 'delete', dest: file.path, rel: file.rel })
280
+ continue
255
281
  }
282
+
283
+ const destRel = relative(target, successor.dest)
284
+ entries.push({
285
+ state: 'renamed',
286
+ rel: file.rel,
287
+ notice: renamedNotice(file, destRel, attribute(hashes, file)),
288
+ })
289
+
290
+ // A second predecessor of one successor, or a successor the target
291
+ // already holds, still loses its old file but must not overwrite.
292
+ if (!existsSync(successor.dest) && !pendingCopies.has(successor.dest)) {
293
+ pendingCopies.add(successor.dest)
294
+ changes.push({
295
+ kind: 'copy',
296
+ source: successor.source,
297
+ dest: successor.dest,
298
+ rel: destRel,
299
+ })
300
+ }
301
+ changes.push({ kind: 'delete', dest: file.path, rel: file.rel })
256
302
  continue
257
303
  }
258
304
 
@@ -470,6 +516,8 @@ function report(adapter: SyncAdapter, plan: SyncPlan): void {
470
516
  logWarn(`${entry.rel} (locally customized)`)
471
517
  else if (entry.state === 'retired')
472
518
  logWarn(entry.notice ?? `${entry.rel} (no longer shipped by the toolkit)`)
519
+ else if (entry.state === 'renamed')
520
+ logWarn(entry.notice ?? `${entry.rel} (renamed by the toolkit)`)
473
521
  else if (entry.state === 'stranded')
474
522
  logWarn(`${entry.rel} (installed here by an older toolkit, now moved)`)
475
523
  else if (entry.state === 'missing')
@@ -617,6 +665,22 @@ function newerInstallingVersion(
617
665
  return { stamped, running }
618
666
  }
619
667
 
668
+ /**
669
+ * The new name gets the toolkit's source rather than the old file's content,
670
+ * so a local edit is dropped the way a retire drops one. With no stamp the
671
+ * edit cannot be proven either way, so the line hedges rather than asserts.
672
+ */
673
+ function renamedNotice(
674
+ file: InstalledFile,
675
+ destRel: string,
676
+ state: EntryState,
677
+ ): string {
678
+ const base = `${file.rel} (renamed to ${destRel} by the toolkit, moving`
679
+ if (state === 'customized') return `${base}. Local edits were not carried.)`
680
+ if (state === 'drifted') return `${base}. Any local edits were not carried.)`
681
+ return `${base})`
682
+ }
683
+
620
684
  function heldForNewerInstall(
621
685
  file: InstalledFile,
622
686
  versions: { readonly stamped: string; readonly running: string },
@@ -46,6 +46,7 @@ export const ARCHIVE_REFUSALS = [
46
46
  'ambiguous',
47
47
  'no-outcomes',
48
48
  'open-outcomes',
49
+ 'earlier-slice',
49
50
  'bad-input',
50
51
  ] as const
51
52
 
@@ -235,12 +236,19 @@ export function readOutcomes(text: string): TaskOutcomes {
235
236
 
236
237
  /**
237
238
  * Reads the `Pull request:` line `git-pr` writes when a pull request opens.
238
- * The number is a bare `#NNN` the way `Issue:` is, since neither is a path and
239
- * a full URL would write the remote into a gitignored file.
239
+ * Each number is a bare `#NNN` the way `Issue:` is, since neither is a path and
240
+ * a full URL would write the remote into a gitignored file. A task shipped in
241
+ * slices lists one per slice, oldest first, so the line reads as a list. A line
242
+ * carrying anything besides that list reads as naming none, rather than as the
243
+ * numbers that happened to parse.
240
244
  */
241
- export function readPullRequest(text: string): number | undefined {
242
- const match = /^Pull request:\s*#(\d+)\s*$/m.exec(text)
243
- return match ? Number(match[1]) : undefined
245
+ export function readPullRequest(text: string): readonly number[] {
246
+ const match = /^Pull request:[ \t]*(#\d+(?:[ \t]*,[ \t]*#\d+)*)[ \t]*$/m.exec(
247
+ text,
248
+ )
249
+ if (!match) return []
250
+
251
+ return match[1].split(',').map((entry) => Number(entry.trim().slice(1)))
244
252
  }
245
253
 
246
254
  /**
@@ -628,11 +636,13 @@ async function matchByPullRequest(
628
636
  const read = await Promise.all(
629
637
  stems.map(async (stem) => ({
630
638
  stem,
631
- number: readPullRequest(await readFile(join(dir, `${stem}.md`), 'utf8')),
639
+ numbers: readPullRequest(await readFile(join(dir, `${stem}.md`), 'utf8')),
632
640
  })),
633
641
  )
634
642
 
635
- return read.filter((entry) => entry.number === number).map(({ stem }) => stem)
643
+ return read
644
+ .filter((entry) => entry.numbers.includes(number))
645
+ .map(({ stem }) => stem)
636
646
  }
637
647
 
638
648
  /**
@@ -748,6 +758,17 @@ export async function archiveTask(
748
758
  const from = join(dir, `${stem}.md`)
749
759
  const text = await readFile(from, 'utf8')
750
760
 
761
+ // `docs-fold` ticks outcomes at ship time, before the merge, so an earlier
762
+ // slice merging after the last slice shipped would find every box ticked
763
+ // while the last slice is still open. Only the last number closes the task.
764
+ const last = readPullRequest(text).at(-1)
765
+ if (selector.kind === 'pull-request' && last !== selector.number) {
766
+ return refuse(
767
+ 'earlier-slice',
768
+ `${stem} lists #${last} after #${selector.number}, so the task closes when #${last} merges.`,
769
+ )
770
+ }
771
+
751
772
  const { open, closed, cut } = readOutcomes(text)
752
773
 
753
774
  if (open.length > 0) {
@@ -10,6 +10,7 @@ import {
10
10
  OUTCOME_PATTERN,
11
11
  planLine,
12
12
  readPlanTarget,
13
+ readPullRequest,
13
14
  tasksDir,
14
15
  } from '@/tasks/archive'
15
16
 
@@ -49,7 +50,7 @@ export type RecordSelector =
49
50
  | { readonly kind: 'stem'; readonly stem: string }
50
51
  | { readonly kind: 'plan'; readonly plan: string }
51
52
 
52
- export type LineAction = 'added' | 'corrected' | 'unchanged'
53
+ export type LineAction = 'added' | 'appended' | 'corrected' | 'unchanged'
53
54
 
54
55
  export interface PullRequestRecorded {
55
56
  readonly ok: true
@@ -101,9 +102,10 @@ function planKey(reference: string): string {
101
102
 
102
103
  /**
103
104
  * Places the `Pull request:` line under the origin lines the task already
104
- * carries, and corrects the number in place when the line exists. A task with
105
- * no origin line takes it under the H1, which is the only other anchor the
106
- * board format guarantees.
105
+ * carries. A task shipped in slices lists every pull request, so a new number
106
+ * appends to an existing line rather than replacing it, and a line that does
107
+ * not read as a list is replaced whole. A task with no origin line takes it
108
+ * under the H1, which is the only other anchor the board format guarantees.
107
109
  */
108
110
  export function writePullRequestLine(
109
111
  text: string,
@@ -114,9 +116,14 @@ export function writePullRequestLine(
114
116
  const existing = lines.findIndex((entry) => entry.startsWith('Pull request:'))
115
117
 
116
118
  if (existing !== -1) {
117
- if (lines[existing] === line) return { text, action: 'unchanged' }
118
- lines[existing] = line
119
- return { text: lines.join('\n'), action: 'corrected' }
119
+ const listed = readPullRequest(lines[existing])
120
+ if (listed.includes(number)) return { text, action: 'unchanged' }
121
+ if (listed.length === 0) {
122
+ lines[existing] = line
123
+ return { text: lines.join('\n'), action: 'corrected' }
124
+ }
125
+ lines[existing] = `${lines[existing].trimEnd()}, #${number}`
126
+ return { text: lines.join('\n'), action: 'appended' }
120
127
  }
121
128
 
122
129
  const origin = lastOriginLine(lines)
@@ -143,8 +150,8 @@ function lastOriginLine(lines: readonly string[]): number | undefined {
143
150
  }
144
151
 
145
152
  /**
146
- * Places the `Plan:` line right after the H1, mirroring
147
- * `writePullRequestLine`'s add/correct/unchanged shape. `Plan:` is the first
153
+ * Places the `Plan:` line right after the H1, and corrects the target in place
154
+ * when the line exists, since a task runs under one plan. `Plan:` is the first
148
155
  * origin line a task carries, so it anchors on the heading itself rather than
149
156
  * on the last origin line above it.
150
157
  */
@@ -1071,7 +1071,9 @@ async function checkCitedTask(
1071
1071
  const { open, closed } = readOutcomes(text)
1072
1072
  if (open.length > 0 || closed.length === 0) return nothing()
1073
1073
 
1074
- const pullRequest = readPullRequest(text)
1074
+ // The last number is the newest slice, and an earlier one closed without
1075
+ // merging never lands, so only the last says whether the work reached trunk.
1076
+ const pullRequest = readPullRequest(text).at(-1)
1075
1077
  if (pullRequest === undefined) {
1076
1078
  return untestedRow(
1077
1079
  group,
@@ -30,6 +30,7 @@ Reference docs for consistent authoring across the toolkit and target projects.
30
30
  - [Requirements reference](requirements.md): Shape and content rules for canon/REQUIREMENTS.md
31
31
  - [Governance rule reference](rule.md): Rule frontmatter, body shape, and voice for .claude/rules files
32
32
  - [Session map reference](session.md): Filename and location, the sections a handoff carries, the write and read procedures, and how a role extends it
33
+ - [Skill requirement reference](skill-requirement.md): Shape and content rules for the REQUIREMENT.md beside a skill
33
34
  - [Claude skill reference](skill.md): Claude skill structure and authoring rules
34
35
  - [Slug reference](slug.md): Transform from a git branch name to a slug, and the three responses to an empty result
35
36
  - [Snippet reference](snippets.md): Snippet reference and authoring conventions
@@ -0,0 +1,72 @@
1
+ ---
2
+ title: Skill requirement reference
3
+ description: Shape and content rules for the REQUIREMENT.md beside a skill
4
+ ---
5
+
6
+ # Skill requirement reference
7
+
8
+ Applies to the `REQUIREMENT.md` beside each `SKILL.md`. It states what the skill is for, so a proposed change can be argued against something and the corpus can be read to decide whether a skill should exist at all.
9
+
10
+ ## Scope
11
+
12
+ Governs `skills/<name>/REQUIREMENT.md`: why it exists, what it must answer, and its sections.
13
+
14
+ Does not govern:
15
+
16
+ - The skill folder, `SKILL.md`, and the rule that every skill carries a requirement: `skill.md`
17
+ - Voice, rhythm, and sentence construction: the `write-human` skill
18
+ - Punctuation, formatting, and word choice: `markdown.md`
19
+
20
+ ## What a working requirement looks like
21
+
22
+ A requirement works when a proposed change to the skill can be settled by reading it alone, without re-deriving what the skill is for:
23
+
24
+ - Does this change close a gap the file states?
25
+ - Does a line already in the body trace to one of those gaps?
26
+ - Does this behavior belong to this skill or to a sibling the file names?
27
+
28
+ A requirement that leaves any of the three open is non-conforming regardless of whether it satisfies every shape rule below. The third question is what makes a requirement worth more than a restated description, because a boundary stated only in prose is never checked against the skill on the other side of it.
29
+
30
+ ## Purpose
31
+
32
+ - Treat the file as the compressed statement of what the skill is for. A skill body is procedural by design, so its purpose sits spread across its steps, and a reader gets this file before opening the body whether or not the skill's scope is contested.
33
+ - Read it before editing the skill. When a change closes no gap the requirement states, change the requirement first or drop the change.
34
+ - Write the gaps from what the skill is for, then compare the body against them. A requirement derived from the body records the body's accidents as the requirement, and misinforms a reader who reads it in place of the body.
35
+
36
+ ## Content
37
+
38
+ - State each gap as an observed failure, not an intent. "Without this skill a session invents its own filename" can be shown wrong. "This skill helps manage tasks" cannot fail, so it constrains nothing.
39
+ - Trace everything under `## Must` to a stated gap. A `Must` with no gap behind it is the padding the file exists to prevent.
40
+ - Keep it high level. A requirement that outgrows one file has stopped being a requirement, and `references/` already holds detail.
41
+ - Use `name` and `description` frontmatter, matching `SKILL.md` so the pair is consistent.
42
+
43
+ ## Template
44
+
45
+ ```markdown
46
+ ---
47
+ name: <skill-name>
48
+ description: <one line, distinct from the SKILL.md description>
49
+ ---
50
+
51
+ # <Skill name> requirement
52
+
53
+ ## Gap
54
+
55
+ Without this skill, a session <observed failure>, <observed failure>.
56
+
57
+ ## Must
58
+
59
+ - <behavior that closes a named gap>
60
+
61
+ ## Must not
62
+
63
+ - <behavior that would be wrong even though it closes a gap>
64
+
65
+ ## Guards
66
+
67
+ - <the refusal condition and its message>
68
+
69
+ ## Out of scope
70
+
71
+ - <the adjacent thing this deliberately does not cover, and what covers it>
72
+ ```