@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +7 -0
- package/claude/skills/claude-autoship/SKILL.md +17 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +13 -4
- package/claude/skills/claude-orchestrate/references/orchestrator-parked.md +2 -2
- package/claude/skills/claude-pr-review/SKILL.md +1 -1
- package/claude/skills/claude-worktree/SKILL.md +9 -3
- package/claude/skills/context-draft/SKILL.md +1 -1
- package/claude/skills/docs-draft/SKILL.md +1 -1
- package/claude/skills/git-pr/SKILL.md +22 -5
- package/claude/skills/setup-init/SKILL.md +2 -1
- package/claude/skills/wireframe-draft/SKILL.md +2 -2
- package/docs/agents/commands.md +79 -79
- package/docs/agents/index.md +1 -1
- package/docs/agents/tasks.md +27 -1
- package/docs/target-projects.md +1 -1
- package/package.json +1 -1
- package/src/commands/labels.ts +51 -3
- package/src/commands/tasks.ts +97 -0
- package/src/gate/measures.ts +77 -2
- package/src/gate/stages.ts +11 -0
- package/src/labels/format.ts +58 -0
- package/src/shipped/references.ts +74 -7
- package/src/tasks/answers.ts +42 -11
- package/src/tasks/branch.ts +78 -0
- package/src/web/readme-citations.ts +90 -0
- package/standards/branch.md +3 -1
- package/standards/wireframes.md +4 -2
- package/tooling/base/configs/commitlint.config.js +26 -0
- package/tooling/nextjs/configs/eslint.config.js +90 -0
- package/tooling/nextjs/configs/next.config.ts +10 -0
- package/tooling/nextjs/configs/playwright.config.ts +27 -0
- package/tooling/nextjs/configs/vitest.config.ts +28 -0
- package/tooling/nextjs/manifest.toml +19 -0
- package/tooling/nextjs/reference.md +47 -0
- package/tooling/nextjs/seeds/.cspell/tech-stack.txt +3 -0
- package/tooling/web/configs/scripts/screenshot.sh +2 -1
package/src/commands/labels.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
}
|
package/src/commands/tasks.ts
CHANGED
|
@@ -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,
|
package/src/gate/measures.ts
CHANGED
|
@@ -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.
|
package/src/gate/stages.ts
CHANGED
|
@@ -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:
|
|
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`
|
|
212
|
-
* a filesystem, since resolving against this
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* caller
|
|
216
|
-
*
|
|
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,
|
package/src/tasks/answers.ts
CHANGED
|
@@ -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
|
-
*
|
|
150
|
-
*
|
|
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
|
-
*
|
|
155
|
-
* the
|
|
156
|
-
*
|
|
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
|
|
169
|
+
export function resolvePlanReference(
|
|
159
170
|
root: string,
|
|
160
171
|
reference: string,
|
|
161
|
-
):
|
|
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
|
-
|
|
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:
|
|
227
|
+
plan: resolved.plan,
|
|
197
228
|
launchable: open.length === 0,
|
|
198
229
|
open,
|
|
199
230
|
}
|