@erclx/canon 4.55.0 → 4.57.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 (37) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-cli/SKILL.md +7 -0
  3. package/claude/skills/claude-autoship/SKILL.md +17 -1
  4. package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +13 -4
  5. package/claude/skills/claude-orchestrate/references/orchestrator-parked.md +2 -2
  6. package/claude/skills/claude-pr-review/SKILL.md +1 -1
  7. package/claude/skills/claude-worktree/SKILL.md +9 -3
  8. package/claude/skills/context-draft/SKILL.md +1 -1
  9. package/claude/skills/docs-draft/SKILL.md +1 -1
  10. package/claude/skills/git-pr/SKILL.md +22 -5
  11. package/claude/skills/setup-init/SKILL.md +2 -1
  12. package/claude/skills/wireframe-draft/SKILL.md +2 -2
  13. package/docs/agents/commands.md +79 -79
  14. package/docs/agents/index.md +1 -1
  15. package/docs/agents/tasks.md +27 -1
  16. package/docs/target-projects.md +1 -1
  17. package/package.json +1 -1
  18. package/src/commands/labels.ts +51 -3
  19. package/src/commands/tasks.ts +97 -0
  20. package/src/gate/measures.ts +77 -2
  21. package/src/gate/stages.ts +11 -0
  22. package/src/labels/format.ts +58 -0
  23. package/src/shipped/references.ts +74 -7
  24. package/src/tasks/answers.ts +42 -11
  25. package/src/tasks/branch.ts +78 -0
  26. package/src/web/readme-citations.ts +90 -0
  27. package/standards/branch.md +3 -1
  28. package/standards/wireframes.md +4 -2
  29. package/tooling/base/configs/commitlint.config.js +26 -0
  30. package/tooling/nextjs/configs/eslint.config.js +90 -0
  31. package/tooling/nextjs/configs/next.config.ts +10 -0
  32. package/tooling/nextjs/configs/playwright.config.ts +27 -0
  33. package/tooling/nextjs/configs/vitest.config.ts +28 -0
  34. package/tooling/nextjs/manifest.toml +19 -0
  35. package/tooling/nextjs/reference.md +47 -0
  36. package/tooling/nextjs/seeds/.cspell/tech-stack.txt +3 -0
  37. package/tooling/web/configs/scripts/screenshot.sh +2 -1
@@ -2,6 +2,7 @@ import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { type LabelAuditRefusal, auditLabels } from '@/labels/audit'
4
4
  import { resolveScanInput } from '@/labels/event'
5
+ import { checkTitleFormat, type TitleFormatIssue } from '@/labels/format'
5
6
  import { MAP_REL } from '@/labels/map'
6
7
  import { scanPhaseLabels } from '@/labels/phase'
7
8
  import { scanTitleSpelling } from '@/labels/spelling'
@@ -22,6 +23,15 @@ interface ScanOptions {
22
23
  readonly json?: boolean
23
24
  }
24
25
 
26
+ /** What a reader does about each way `checkTitleFormat` graded a title as broken. */
27
+ const TITLE_FORMAT_MESSAGES: Record<TitleFormatIssue, string> = {
28
+ structure: 'does not match <type>(<scope>): <subject>',
29
+ 'casing-type': 'the type is not lowercase',
30
+ 'casing-scope': 'the scope is not lowercase',
31
+ 'casing-subject': 'the first word of the subject is not lowercase',
32
+ length: 'is over 72 characters',
33
+ }
34
+
25
35
  /** What a reader does about each way the audit produced no reading. */
26
36
  const REFUSALS: Record<LabelAuditRefusal, string> = {
27
37
  // An answer rather than a fault. A project declaring no map is labelled
@@ -142,11 +152,19 @@ export function register(program: Command): void {
142
152
  'a target project carries none, rather than reaching the network or',
143
153
  'forcing a new dependency.',
144
154
  '',
155
+ "It also grades the title alone against standards/pr.md's ## Title",
156
+ 'section: the `<type>(<scope>): <subject>` structure, lowercase casing',
157
+ 'for the type, the scope, and the first subject word, and a 72-',
158
+ 'character length cap. A review carries no title of its own, so this',
159
+ 'check is skipped there rather than graded against the forced empty',
160
+ 'string.',
161
+ '',
145
162
  'Exit codes:',
146
- ' 0 none of the four found',
163
+ ' 0 none of the five found',
147
164
  ' 1 refused, with the reason on stderr or in the JSON record',
148
165
  ' 2 the title or body carries a phase label, a board identifier, a',
149
- ' session link, or a title word no dictionary holds',
166
+ ' session link, a title word no dictionary holds, or a title',
167
+ " breaking standards/pr.md's format, casing, or length",
150
168
  '',
151
169
  'Examples:',
152
170
  ' canon labels scan --event "$GITHUB_EVENT_PATH"',
@@ -269,6 +287,12 @@ async function runScan(opts: ScanOptions): Promise<number> {
269
287
 
270
288
  const result = scanPhaseLabels(resolved)
271
289
  const spelling = await scanTitleSpelling(resolved.title, process.cwd())
290
+ // A review carries no title of its own, so `resolved.title` is forced
291
+ // empty and grading it would fail as `structure` for the wrong reason.
292
+ const titleFormat =
293
+ resolved.source === 'pull-request'
294
+ ? checkTitleFormat(resolved.title)
295
+ : undefined
272
296
 
273
297
  logStep(resolved.source === 'review' ? 'Review comment' : 'Pull request')
274
298
  logInfo(
@@ -339,6 +363,28 @@ async function runScan(opts: ScanOptions): Promise<number> {
339
363
  for (const word of unspelledWords) logWarn(word)
340
364
  }
341
365
 
366
+ const titleFormatIssues = titleFormat?.issues ?? []
367
+
368
+ logStep(
369
+ titleFormat === undefined
370
+ ? 'Title format not checked'
371
+ : titleFormat.conforms
372
+ ? 'Clean'
373
+ : 'Title format issue found',
374
+ )
375
+ if (titleFormat === undefined) {
376
+ logInfo('a review comment carries no title, so there is no format to grade')
377
+ } else if (titleFormat.conforms) {
378
+ logInfo(
379
+ 'the title matches <type>(<scope>): <subject> and its casing and length rules',
380
+ )
381
+ } else {
382
+ logWarn(
383
+ `${plural(titleFormatIssues.length, 'title format issue')} against standards/pr.md's ## Title section.`,
384
+ )
385
+ for (const issue of titleFormatIssues) logWarn(TITLE_FORMAT_MESSAGES[issue])
386
+ }
387
+
342
388
  outro()
343
389
 
344
390
  if (emitJson) {
@@ -351,6 +397,7 @@ async function runScan(opts: ScanOptions): Promise<number> {
351
397
  sessionLinks: result.sessionLinks,
352
398
  unspelledWords,
353
399
  spellingChecked,
400
+ titleFormatIssues,
354
401
  })}\n`,
355
402
  )
356
403
  }
@@ -358,7 +405,8 @@ async function runScan(opts: ScanOptions): Promise<number> {
358
405
  return result.phaseLabels.length === 0 &&
359
406
  result.boardReferences.length === 0 &&
360
407
  result.sessionLinks.length === 0 &&
361
- unspelledWords.length === 0
408
+ unspelledWords.length === 0 &&
409
+ titleFormatIssues.length === 0
362
410
  ? 0
363
411
  : 2
364
412
  }
@@ -1,6 +1,7 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { type AnswersOutcome, planAnswers } from '@/tasks/answers'
4
+ import { type BranchOutcome, planBranch } from '@/tasks/branch'
4
5
  import {
5
6
  type ArchiveOutcome,
6
7
  archiveTask,
@@ -62,6 +63,11 @@ interface AnswersCommandOptions {
62
63
  readonly root?: string
63
64
  }
64
65
 
66
+ interface BranchCommandOptions {
67
+ readonly json?: boolean
68
+ readonly root?: string
69
+ }
70
+
65
71
  interface PullRequestCommandOptions {
66
72
  readonly json?: boolean
67
73
  readonly plan?: string
@@ -230,6 +236,42 @@ export function register(program: Command): void {
230
236
  process.exitCode = await runAnswers(plan, opts)
231
237
  })
232
238
 
239
+ tasks
240
+ .command('plan-branch')
241
+ .description('Derive the branch name a dispatch and a worker both take')
242
+ .argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
243
+ .helpOption('-h, --help', 'Show this help message')
244
+ .option('--json', 'Emit a machine-readable record on stdout')
245
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
246
+ .addHelpText(
247
+ 'after',
248
+ [
249
+ '',
250
+ 'Exit codes:',
251
+ ' 0 the branch is derived and conforms to standards/branch.md',
252
+ ' 1 refused as no-plan, archived, or bad-input',
253
+ ' 2 derived, and conforms is false because the slug breaks a cap',
254
+ '',
255
+ 'It reports type, slug, branch, words, and conforms. The type is fixed',
256
+ 'at feat, since a derivation two sides grade differently is the defect',
257
+ 'this closes, and git-branch renames a wrong type later in the chain.',
258
+ 'Branch on conforms rather than on the exit code, which a shell',
259
+ 'function wrapping canon can flatten to zero.',
260
+ '',
261
+ 'The dispatch collision check and the worker worktree entry both call',
262
+ 'it, so the branch a gate clears is the branch a session takes.',
263
+ '',
264
+ 'Examples:',
265
+ ' canon tasks plan-branch dispatch-answer-gate',
266
+ ' canon tasks plan-branch .canon/plans/feature-dispatch-answer-gate.md',
267
+ ' canon tasks plan-branch dispatch-answer-gate --json',
268
+ '',
269
+ ].join('\n'),
270
+ )
271
+ .action(async (plan: string, opts: BranchCommandOptions) => {
272
+ process.exitCode = await runBranch(plan, opts)
273
+ })
274
+
233
275
  tasks
234
276
  .command('pull-request')
235
277
  .description('Record a pull request number on the task a branch closes')
@@ -732,6 +774,61 @@ function reportAnswers(
732
774
  return EXIT_FINDINGS
733
775
  }
734
776
 
777
+ async function runBranch(
778
+ plan: string,
779
+ opts: BranchCommandOptions,
780
+ ): Promise<number> {
781
+ const root = opts.root ?? (await mainWorktreeRoot())
782
+ const outcome = planBranch(root, plan)
783
+
784
+ return reportBranch(outcome, opts.json ?? false, root)
785
+ }
786
+
787
+ function reportBranch(
788
+ outcome: BranchOutcome,
789
+ emitJson: boolean,
790
+ root: string,
791
+ ): number {
792
+ if (!outcome.ok) {
793
+ if (emitJson) {
794
+ process.stdout.write(
795
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
796
+ )
797
+ return 1
798
+ }
799
+
800
+ intro('canon tasks plan-branch')
801
+ logStep('Refused')
802
+ logError(outcome.message)
803
+ outro()
804
+ return 1
805
+ }
806
+
807
+ if (emitJson) {
808
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
809
+ return outcome.conforms ? 0 : EXIT_FINDINGS
810
+ }
811
+
812
+ intro('canon tasks plan-branch')
813
+ logStep(outcome.branch)
814
+
815
+ if (outcome.conforms) {
816
+ logInfo(`derived from ${outcome.plan}, ${outcome.words} words.`)
817
+ outro()
818
+ return 0
819
+ }
820
+
821
+ logWarn(
822
+ `derived from ${outcome.plan}, ${outcome.words} words and ${outcome.branch.length} characters.`,
823
+ )
824
+ logError(
825
+ 'The slug breaks a cap in standards/branch.md, so hand the row to a person rather than renaming it.',
826
+ )
827
+ outro()
828
+
829
+ return EXIT_FINDINGS
830
+ }
831
+
735
832
  function reportValidation(
736
833
  outcome: ValidateOutcome,
737
834
  emitJson: boolean,
@@ -15,6 +15,10 @@ import {
15
15
  SHIPPED_CORPORA,
16
16
  type ShippedReference,
17
17
  } from '@/shipped/references'
18
+ import {
19
+ README_PARAPHRASE_MARKER,
20
+ readmeCitationsIn,
21
+ } from '@/web/readme-citations'
18
22
 
19
23
  export interface CommandResult {
20
24
  readonly exitCode: number
@@ -496,6 +500,8 @@ function describeShippedReference(reference: ShippedReference): string {
496
500
  return 'a pull request number that resolves elsewhere for a reader in a target'
497
501
  case 'docs-path':
498
502
  return 'a path into this repository that a registry install never carries'
503
+ case 'standards-path':
504
+ return 'a bare standards/ path that has nothing to expand it in an installed plugin cache'
499
505
  case 'phase-label':
500
506
  return 'a phase label that names a board no target holds'
501
507
  }
@@ -554,8 +560,8 @@ export const shippedReferences: Measure = async (ctx) => {
554
560
  ),
555
561
  failure:
556
562
  found.length === 1
557
- ? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
558
- : `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
563
+ ? `One reference in the shipped corpora resolves wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark the line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`
564
+ : `${found.length} references in the shipped corpora resolve wrong for a reader in a target. Qualify a cross-repository citation as owner/repo#123 or owner/repo@abc1234, cite a docs page through canon docs <name>, rewrite a bare standards/ path under claude/skills/ as \${CLAUDE_SKILL_DIR}/../../standards/<name>.md, state a same-repository citation or a phase label as a fact instead and relocate the evidence to the owning .claude/context/ entry, or mark each line ${REFERENCE_MARKER}: <reason> where the bare form is the point.`,
559
565
  }
560
566
  }
561
567
 
@@ -622,6 +628,75 @@ export const clientCommandCitations = async (
622
628
  }
623
629
  }
624
630
 
631
+ /**
632
+ * Every `README.md:` anchor in `web/src/content/copy.ts` whose quoted phrase no
633
+ * longer appears in the current `README.md`, plus any leftover bare
634
+ * `README.md:<n>` citation the retired line-number convention would leave
635
+ * behind.
636
+ *
637
+ * Scoped to the one file carrying the anchors rather than walked across the
638
+ * tree, since `web/src/content/copy.ts` is the only place this repository
639
+ * writes one. A quoted phrase is checked with a plain substring test against
640
+ * the whole `README.md` text rather than against the cited line, which is the
641
+ * property that lets the anchor survive `README.md` growing or shrinking
642
+ * above it: the phrase fails only when the content itself moved or changed,
643
+ * never when a line number did.
644
+ */
645
+ export const readmeCitations: Measure = async (ctx) => {
646
+ const copyPath = 'web/src/content/copy.ts'
647
+ const readmePath = 'README.md'
648
+ const copyFile = join(ctx.root, copyPath)
649
+ const readmeFile = join(ctx.root, readmePath)
650
+
651
+ if (!existsSync(copyFile) || !existsSync(readmeFile)) {
652
+ return {
653
+ emissions: [],
654
+ unmeasured: `${copyPath} or ${readmePath} is absent, so no citation was checked.`,
655
+ }
656
+ }
657
+
658
+ const readmeText = readFileSync(readmeFile, 'utf8')
659
+ const citations = readmeCitationsIn(copyPath, readFileSync(copyFile, 'utf8'))
660
+
661
+ if (citations.length === 0) {
662
+ return {
663
+ emissions: [],
664
+ unmeasured: `${copyPath} carries no README.md citation, so nothing was checked.`,
665
+ }
666
+ }
667
+
668
+ const bad = citations.filter(
669
+ (citation) =>
670
+ citation.kind === 'bare' ||
671
+ (citation.kind === 'quoted' &&
672
+ citation.phrases.some((phrase) => !readmeText.includes(phrase))),
673
+ )
674
+
675
+ if (bad.length === 0) {
676
+ return {
677
+ emissions: [
678
+ info(
679
+ `${citations.length} README.md citation(s) in ${copyPath} verified against the current text`,
680
+ ),
681
+ ],
682
+ }
683
+ }
684
+
685
+ return {
686
+ emissions: bad.map((citation) =>
687
+ warn(
688
+ citation.kind === 'bare'
689
+ ? `${citation.file}:${citation.line} carries ${citation.text}, a bare line number that cannot detect a shifted line`
690
+ : `${citation.file}:${citation.line} carries ${citation.text}, whose quoted phrase no longer appears in README.md`,
691
+ ),
692
+ ),
693
+ failure:
694
+ bad.length === 1
695
+ ? `One README.md citation in ${copyPath} failed. Quote a verbatim phrase from the current README.md, or mark the line ${README_PARAPHRASE_MARKER}: <reason> where the string condenses rather than quotes.`
696
+ : `${bad.length} README.md citations in ${copyPath} failed. Quote a verbatim phrase from the current README.md, or mark each line ${README_PARAPHRASE_MARKER}: <reason> where the string condenses rather than quotes.`,
697
+ }
698
+ }
699
+
625
700
  /**
626
701
  * `canon sandbox coverage` moves only when a person runs it, so a scenario added
627
702
  * with no expectation ships unnoticed.
@@ -5,6 +5,7 @@ import {
5
5
  markdownBans,
6
6
  type Measure,
7
7
  pluginManifests,
8
+ readmeCitations,
8
9
  recordIdempotence,
9
10
  sandboxCoverage,
10
11
  seedStandards,
@@ -390,6 +391,16 @@ export const STAGES: readonly Stage[] = [
390
391
  skipped: 'No shipped corpus changed, so no reference was read',
391
392
  checks: [{ kind: 'measure', measure: shippedReferences }],
392
393
  },
394
+ {
395
+ // Scoped to the two files a citation can drift between, so an edit to
396
+ // either one runs the check. web/src/content/copy.ts carries the anchors
397
+ // and README.md is the text they quote from.
398
+ id: 'readme-citations',
399
+ label: 'README citations',
400
+ scope: /^(web\/src\/content\/copy\.ts|README\.md)$/,
401
+ skipped: 'Neither copy.ts nor README.md changed, so no citation was read',
402
+ checks: [{ kind: 'measure', measure: readmeCitations }],
403
+ },
393
404
  {
394
405
  id: 'seed-standards',
395
406
  label: 'Seed standards',
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Which rule in `standards/pr.md`'s `## Title` section a title breaks.
3
+ *
4
+ * `structure` stands alone rather than beside the other three, because a
5
+ * title failing the shape match has no parsed `<type>`, `<scope>`, or
6
+ * `<subject>` to grade for casing, and `length` is read off the raw string
7
+ * regardless of whether it parses.
8
+ */
9
+ export type TitleFormatIssue =
10
+ | 'structure'
11
+ | 'casing-type'
12
+ | 'casing-scope'
13
+ | 'casing-subject'
14
+ | 'length'
15
+
16
+ export interface TitleFormatCheck {
17
+ readonly conforms: boolean
18
+ readonly issues: readonly TitleFormatIssue[]
19
+ }
20
+
21
+ const TITLE_MAX_LENGTH = 72
22
+
23
+ // Casing-agnostic on purpose. Casing is graded separately once the shape
24
+ // matches, so `type` and `scope` accept either case here and `structure`
25
+ // reports only a title with no `<type>(<scope>): <subject>` shape at all.
26
+ const TITLE_SHAPE = /^([A-Za-z]+)\(([A-Za-z0-9][\w.-]*)\): (.+)$/
27
+ const LEADING_LETTERS = /^[A-Za-z]+/
28
+
29
+ /**
30
+ * Grades a pull request title, or a commit subject sharing the same form,
31
+ * against `standards/pr.md`'s structure, casing, and length rules.
32
+ *
33
+ * Does not check `<type>` against `standards/commit.md`'s fixed enum.
34
+ * `pr.md`'s own `## Title` section states format, casing, and length only,
35
+ * and names no type list of its own to check against.
36
+ */
37
+ export function checkTitleFormat(title: string): TitleFormatCheck {
38
+ const match = TITLE_SHAPE.exec(title)
39
+
40
+ if (match === null) {
41
+ return { conforms: false, issues: ['structure'] }
42
+ }
43
+
44
+ const [, type, scope, subject] = match
45
+ const issues: TitleFormatIssue[] = []
46
+
47
+ if (type !== type.toLowerCase()) issues.push('casing-type')
48
+ if (scope !== scope.toLowerCase()) issues.push('casing-scope')
49
+
50
+ const leadingWord = LEADING_LETTERS.exec(subject)?.[0]
51
+ if (leadingWord !== undefined && leadingWord !== leadingWord.toLowerCase()) {
52
+ issues.push('casing-subject')
53
+ }
54
+
55
+ if (title.length > TITLE_MAX_LENGTH) issues.push('length')
56
+
57
+ return { conforms: issues.length === 0, issues }
58
+ }
@@ -148,6 +148,37 @@ const SAME_REPOSITORY =
148
148
  */
149
149
  const DOCS_PATH = /(?<![\w./-])docs\/[^\s`)\]]*\.md\b/g
150
150
 
151
+ /**
152
+ * A bare `standards/<name>.md` citation from a body under `claude/skills/`,
153
+ * which `598-authoring-layout.md` fixes as the broken form there:
154
+ * `${CLAUDE_SKILL_DIR}` is what resolves off the `claude/standards` symlink
155
+ * in every plugin cache, and a raw path has nothing to expand it.
156
+ *
157
+ * Scoped to that one corpus rather than every `isShippedCorpus` reads,
158
+ * because `docs/agents/` and `docs/workflow/` carry the identical
159
+ * `standards/<name>.md` shape correctly: a docs page resolves from this
160
+ * checkout's own root rather than through a skill's `${CLAUDE_SKILL_DIR}`,
161
+ * so the same string is the fix in one corpus and the defect in the other.
162
+ * Widening the pattern to every corpus turns it into a mass false-positive
163
+ * over the dozens of correct citations those two folders carry.
164
+ *
165
+ * The same leading-boundary discipline as `DOCS_PATH` excludes the already
166
+ * correct form: a `/` sits ahead of `standards` in
167
+ * `${CLAUDE_SKILL_DIR}/../../standards/<name>.md`, which the negative
168
+ * lookbehind rejects, so a citation already rewritten to the resolving form
169
+ * does not fail this check a second time.
170
+ *
171
+ * Reported only where `isStandardsPathResolvable` confirms the match
172
+ * resolves against this checkout, the same gate `DOCS_PATH` runs. A body
173
+ * illustrating the shape a project's own `standards/<name>.md` takes writes
174
+ * the identical placeholder token this pattern matches, such as
175
+ * `standards/<slug>.md` in `create-standard/SKILL.md` or `standards/<name>.md`
176
+ * in `migration-standards-drop/SKILL.md`, and neither resolves to a real
177
+ * file. Four such placeholders surfaced across three files the first time
178
+ * this pattern ran unresolved, measured 2026-09-06.
179
+ */
180
+ const STANDARDS_PATH = /(?<![\w./-])standards\/[^\s`)\]]*\.md\b/g
181
+
151
182
  /**
152
183
  * A phase-label-shaped token: exactly two numeric groups, with a negative
153
184
  * lookahead rejecting a third.
@@ -173,7 +204,12 @@ export interface ShippedReference {
173
204
  readonly file: string
174
205
  /** One-based, matching the `file:line` form a reader clicks. */
175
206
  readonly line: number
176
- readonly kind: 'pull-request' | 'commit' | 'docs-path' | 'phase-label'
207
+ readonly kind:
208
+ | 'pull-request'
209
+ | 'commit'
210
+ | 'docs-path'
211
+ | 'standards-path'
212
+ | 'phase-label'
177
213
  /** The reference as written, so a report names the token to qualify. */
178
214
  readonly text: string
179
215
  /**
@@ -202,18 +238,37 @@ function isDocsPathResolvable(
202
238
  return existsSync(join(root, path))
203
239
  }
204
240
 
241
+ /**
242
+ * Whether `file` sits in the one corpus `STANDARDS_PATH` gates.
243
+ *
244
+ * `REQUIREMENT.md` is excluded for the reason `598-authoring-layout.md`
245
+ * leaves it alone: a maintainer or an audit command reads that file rather
246
+ * than a session loading it, so the resolver rule this pattern enforces
247
+ * never applies there.
248
+ */
249
+ function isStandardsPathScope(file: string): boolean {
250
+ return file.startsWith('claude/skills/') && !file.endsWith('/REQUIREMENT.md')
251
+ }
252
+
253
+ /** Whether a `STANDARDS_PATH` match names a real file in this checkout. */
254
+ function isStandardsPathResolvable(path: string, root: string): boolean {
255
+ return existsSync(join(root, path))
256
+ }
257
+
205
258
  /**
206
259
  * Every reference in one shipped file that no marker mutes.
207
260
  *
208
261
  * The corpus walk is deliberately absent, which lets most of the shape be
209
262
  * tested against a string rather than against a fixture. That is the seam
210
263
  * `headingCitationsIn` draws in `src/claude/skills-headings.ts` and `citationsIn`
211
- * draws in `skills-reach.ts`. `DOCS_PATH` is the one pattern that still needs
212
- * a filesystem, since resolving against this checkout is the only thing that
213
- * separates its two readings, so it takes `root` as the one caller-supplied
214
- * exception to that rule. `root` is required rather than defaulted, since a
215
- * caller that dropped it silently would report zero docs-path findings
216
- * rather than raising, which is the wrong failure direction for a gate.
264
+ * draws in `skills-reach.ts`. `DOCS_PATH` and `STANDARDS_PATH` are the two
265
+ * patterns that still need a filesystem, since resolving against this
266
+ * checkout is the only thing that separates a real citation from an
267
+ * illustration for either, so `referencesIn` takes `root` as the one
268
+ * caller-supplied exception to that rule. `root` is required rather than
269
+ * defaulted, since a caller that dropped it silently would report zero
270
+ * findings for both rather than raising, which is the wrong failure
271
+ * direction for a gate.
217
272
  *
218
273
  * The unit is the match rather than the line, unlike those two, because one
219
274
  * line here can carry three separate tokens each needing its own repair and a
@@ -272,6 +327,18 @@ export function referencesIn(
272
327
  })
273
328
  }
274
329
 
330
+ if (isStandardsPathScope(file)) {
331
+ for (const match of line.matchAll(STANDARDS_PATH)) {
332
+ if (!isStandardsPathResolvable(match[0], root)) continue
333
+ references.push({
334
+ file,
335
+ line: index + 1,
336
+ kind: 'standards-path',
337
+ text: match[0],
338
+ })
339
+ }
340
+ }
341
+
275
342
  for (const match of line.matchAll(PHASE_LABEL)) {
276
343
  references.push({
277
344
  file,
@@ -47,6 +47,18 @@ export interface PlanAnswers {
47
47
 
48
48
  export type AnswersOutcome = PlanAnswers | AnswersRefused
49
49
 
50
+ /**
51
+ * A reference that named a live plan, carrying the absolute path a reader opens
52
+ * and the root-relative spelling a record reports.
53
+ */
54
+ export interface PlanResolved {
55
+ readonly ok: true
56
+ readonly path: string
57
+ readonly plan: string
58
+ }
59
+
60
+ export type ResolveOutcome = PlanResolved | AnswersRefused
61
+
50
62
  /**
51
63
  * The spellings a caller reaches a plan by, in the order they are tried. A bare
52
64
  * slug names the live folder outright, and a path is resolved against the
@@ -146,19 +158,18 @@ function openQuestions(lines: readonly string[]): OpenQuestion[] {
146
158
  }
147
159
 
148
160
  /**
149
- * Answers whether a plan is launchable, which is whether it still waits on the
150
- * operator for a call only they can make. It reads the question block through
151
- * the same `readQuestions` the plan validator runs, so the gate and the
152
- * conformance check cannot drift into disagreeing about what a question is.
161
+ * Resolves a caller's reference to a live plan, refusing an empty reference, a
162
+ * reference that names no file, and one that lands in the plans archive.
153
163
  *
154
- * It reports and never writes. Holding the row, naming the slot, and reaching
155
- * the operator belong to the dispatcher, which is where the decision already
156
- * sits.
164
+ * Both `planAnswers` and `planBranch` answer the same reference, so the
165
+ * resolution and the three refusals sit here rather than in each of them. Two
166
+ * verbs reading one reference through two copies of this ladder is the defect
167
+ * the branch derivation was filed against, one layer down.
157
168
  */
158
- export async function planAnswers(
169
+ export function resolvePlanReference(
159
170
  root: string,
160
171
  reference: string,
161
- ): Promise<AnswersOutcome> {
172
+ ): ResolveOutcome {
162
173
  if (reference.trim().length === 0) {
163
174
  return refuse('bad-input', 'No plan named. Pass a plan path or its slug.')
164
175
  }
@@ -188,12 +199,32 @@ export async function planAnswers(
188
199
  )
189
200
  }
190
201
 
191
- const sections = splitPlanSections(await readFile(path, 'utf8'))
202
+ return { ok: true, path, plan: relative(root, path) }
203
+ }
204
+
205
+ /**
206
+ * Answers whether a plan is launchable, which is whether it still waits on the
207
+ * operator for a call only they can make. It reads the question block through
208
+ * the same `readQuestions` the plan validator runs, so the gate and the
209
+ * conformance check cannot drift into disagreeing about what a question is.
210
+ *
211
+ * It reports and never writes. Holding the row, naming the slot, and reaching
212
+ * the operator belong to the dispatcher, which is where the decision already
213
+ * sits.
214
+ */
215
+ export async function planAnswers(
216
+ root: string,
217
+ reference: string,
218
+ ): Promise<AnswersOutcome> {
219
+ const resolved = resolvePlanReference(root, reference)
220
+ if (!resolved.ok) return resolved
221
+
222
+ const sections = splitPlanSections(await readFile(resolved.path, 'utf8'))
192
223
  const open = openQuestions(sections.get('Questions') ?? [])
193
224
 
194
225
  return {
195
226
  ok: true,
196
- plan: relative(root, path),
227
+ plan: resolved.plan,
197
228
  launchable: open.length === 0,
198
229
  open,
199
230
  }