@erclx/canon 5.72.1 → 5.74.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/CHANGELOG.md +40 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/references/adopted.md +5 -3
- package/claude/skills/app-drive/REQUIREMENT.md +2 -2
- package/claude/skills/app-drive/SKILL.md +22 -5
- package/claude/skills/app-drive/references/config.md +2 -1
- package/claude/skills/app-drive/references/drive-record.md +13 -0
- package/claude/skills/app-drive/references/isolate.md +1 -1
- package/claude/skills/build-in-slices/references/adopted.md +4 -2
- package/claude/skills/code-craft/references/adopted.md +9 -7
- package/claude/skills/codebase-layout/references/adopted.md +8 -6
- package/claude/skills/create-skill/references/skill-practice.md +5 -0
- package/claude/skills/deprecation-migration/references/adopted.md +5 -3
- package/claude/skills/design-pick/references/board.md +2 -2
- package/claude/skills/plan-feature/SKILL.md +3 -2
- package/claude/skills/plan-feature/references/plan.md +12 -2
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/plan-intake/references/intake.md +3 -2
- package/claude/skills/pr-evidence/REQUIREMENT.md +1 -0
- package/claude/skills/pr-evidence/SKILL.md +2 -0
- package/claude/skills/pr-evidence/references/evidence.md +11 -4
- package/claude/skills/{ui-film/references/pr-clip.md → pr-evidence/references/reel.md} +21 -21
- package/claude/skills/proposal-critique/REQUIREMENT.md +34 -0
- package/claude/skills/proposal-critique/SKILL.md +126 -0
- package/claude/skills/review-craft/references/adopted.md +4 -4
- package/claude/skills/role-orchestrator/SKILL.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-filing.md +8 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +4 -1
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +26 -2
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +22 -3
- package/claude/skills/role-planner/SKILL.md +3 -1
- package/claude/skills/role-reviewer/SKILL.md +15 -4
- package/claude/skills/search-craft/references/adopted.md +9 -7
- package/claude/skills/systematic-debugging/REQUIREMENT.md +1 -0
- package/claude/skills/systematic-debugging/SKILL.md +21 -8
- package/claude/skills/systematic-debugging/references/adopted.md +15 -4
- package/claude/skills/teach-craft/references/adopted.md +8 -6
- package/claude/skills/test-craft/references/adopted.md +14 -12
- package/claude/skills/test-first/SKILL.md +1 -1
- package/claude/skills/test-first/references/adopted.md +9 -5
- package/claude/skills/ui-film/REQUIREMENT.md +5 -6
- package/claude/skills/ui-film/SKILL.md +21 -13
- package/claude/skills/ui-film/references/compose-craft.md +3 -2
- package/claude/skills/ui-film/references/handoff.md +12 -8
- package/claude/skills/ui-film/references/take-craft.md +10 -0
- package/claude/skills/write-human/references/adopted.md +8 -3
- package/docs/agents/records.md +1 -1
- package/docs/target/index.md +1 -0
- package/docs/target/outside-tools.md +39 -0
- package/docs/workflow/operating-model.md +8 -8
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +2 -1
- package/governance/ledger/canon/plan.md +20 -0
- package/governance/ledger/skills/build-in-slices/SKILL.md +55 -0
- package/governance/ledger/skills/proposal-critique/SKILL.md +66 -0
- package/governance/ledger/skills/search-craft/SKILL.md +54 -0
- package/governance/ledger/skills/systematic-debugging/SKILL.md +64 -0
- package/governance/ledger/skills/test-first/SKILL.md +28 -0
- package/governance/ledger/skills/write-human/SKILL.md +62 -0
- package/governance/ledger/skills/write-human/density.md +36 -0
- package/governance/ledger/skills/write-human/machine-tells.md +30 -0
- package/governance/rules/canon/plan.md +1 -0
- package/package.json +1 -1
- package/scripts/ci/docs-only.sh +24 -0
- package/src/claude/cases/misc.ts +14 -0
- package/src/claude/skills-audit.ts +89 -8
- package/src/commands/intake.ts +4 -1
- package/src/commands/pr.ts +110 -13
- package/src/gate/measures.ts +1 -1
- package/src/gate/sequencer.ts +7 -1
- package/src/gate/stages.ts +36 -24
- package/src/intake/drive-check.ts +75 -4
- package/src/markdown/scope.ts +2 -0
- package/src/pr/checks.ts +75 -3
- package/src/pr/evidence.ts +62 -1
- package/src/pr/lift.ts +23 -2
- package/src/pr/merge.ts +13 -3
- package/src/records/backup.ts +1 -0
- package/src/targets/pulls.ts +42 -0
|
@@ -119,7 +119,12 @@ export const BULLET_LEDGER_SKILLS: Readonly<Record<string, BulletLedgerScope>> =
|
|
|
119
119
|
},
|
|
120
120
|
[join(SHIPPED_CORPUS, 'teach-craft')]: { exempt: [], body: true },
|
|
121
121
|
[join(SHIPPED_CORPUS, 'test-craft')]: { exempt: [], body: true },
|
|
122
|
+
[join(SHIPPED_CORPUS, 'test-first')]: { exempt: [], body: true },
|
|
122
123
|
[join(SHIPPED_CORPUS, 'codebase-layout')]: { exempt: [], body: true },
|
|
124
|
+
[join(SHIPPED_CORPUS, 'search-craft')]: { exempt: [], body: true },
|
|
125
|
+
[join(SHIPPED_CORPUS, 'write-human')]: { exempt: [], body: true },
|
|
126
|
+
[join(SHIPPED_CORPUS, 'systematic-debugging')]: { exempt: [], body: true },
|
|
127
|
+
[join(SHIPPED_CORPUS, 'build-in-slices')]: { exempt: [], body: true },
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
/** Where a per-bullet ledger lives, as `<skill>/<file>.md` below it. */
|
|
@@ -161,6 +166,9 @@ export const PRACTICE_LEDGER = join('references', 'adopted.md')
|
|
|
161
166
|
*/
|
|
162
167
|
const EXTERNAL_LINK = /\]\(\s*https?:\/\/|<https?:\/\//
|
|
163
168
|
|
|
169
|
+
/** The address an inline link or an autolink cites, captured for comparison. */
|
|
170
|
+
const EXTERNAL_ADDRESS = /\]\(\s*(https?:\/\/[^)\s]+)|<(https?:\/\/[^>\s]+)>/g
|
|
171
|
+
|
|
164
172
|
/**
|
|
165
173
|
* A table cell opening on the marker the governance ledgers already use for a
|
|
166
174
|
* rule the toolkit chose, so one check reads every ledger alike.
|
|
@@ -277,6 +285,8 @@ interface ReferenceLedger {
|
|
|
277
285
|
readonly ledgerRel: string
|
|
278
286
|
/** Undefined when the ledger file is absent. */
|
|
279
287
|
readonly mismatches: readonly LedgerMismatch[] | undefined
|
|
288
|
+
/** Every source the ledger links, normalized by `sourceKey`. */
|
|
289
|
+
readonly sources: readonly string[]
|
|
280
290
|
}
|
|
281
291
|
|
|
282
292
|
interface SkillText {
|
|
@@ -453,13 +463,22 @@ async function readReferenceLedgers(
|
|
|
453
463
|
files.map(async ({ path, text }) => {
|
|
454
464
|
const ledgerRel = skillLedgerPath(folder, path)
|
|
455
465
|
const ledgerPath = join(root, ledgerRel)
|
|
456
|
-
const
|
|
457
|
-
?
|
|
458
|
-
ledgerGroups(text, path === 'SKILL.md'),
|
|
459
|
-
countLedgerRows(await Bun.file(ledgerPath).text()),
|
|
460
|
-
)
|
|
466
|
+
const ledger = existsSync(ledgerPath)
|
|
467
|
+
? await Bun.file(ledgerPath).text()
|
|
461
468
|
: undefined
|
|
462
|
-
|
|
469
|
+
const mismatches =
|
|
470
|
+
ledger === undefined
|
|
471
|
+
? undefined
|
|
472
|
+
: compareLedger(
|
|
473
|
+
ledgerGroups(text, path === 'SKILL.md'),
|
|
474
|
+
countLedgerRows(ledger),
|
|
475
|
+
)
|
|
476
|
+
return {
|
|
477
|
+
rel: join(skillRel, path),
|
|
478
|
+
ledgerRel,
|
|
479
|
+
mismatches,
|
|
480
|
+
sources: ledger === undefined ? [] : linkedSources(ledger),
|
|
481
|
+
}
|
|
463
482
|
}),
|
|
464
483
|
)
|
|
465
484
|
}
|
|
@@ -681,6 +700,7 @@ function practiceFindings(source: SkillSource): SkillFinding[] {
|
|
|
681
700
|
return [
|
|
682
701
|
...sections,
|
|
683
702
|
...ledgerFindings(source),
|
|
703
|
+
...duplicateFindings(source),
|
|
684
704
|
...source.referenceLedgers.flatMap(referenceLedgerFindings),
|
|
685
705
|
...source.staleExemptions.map((rel) => ({
|
|
686
706
|
rel,
|
|
@@ -710,17 +730,78 @@ function ledgerFindings(source: SkillSource): SkillFinding[] {
|
|
|
710
730
|
return [{ rel: source.rel, detail: `missing ledger: ${PRACTICE_LEDGER}` }]
|
|
711
731
|
}
|
|
712
732
|
|
|
713
|
-
const
|
|
714
|
-
|
|
733
|
+
const lines = bodyLines(source.ledger).filter((line) => !line.fenced)
|
|
734
|
+
const cites = lines
|
|
715
735
|
.map((line) => maskCodeSpans(line.text))
|
|
716
736
|
.some((text) => EXTERNAL_LINK.test(text) || HOUSE_CHOICE.test(text))
|
|
717
737
|
if (cites) return []
|
|
738
|
+
if (pointsAtLedgers(source, lines)) return []
|
|
718
739
|
|
|
719
740
|
return [
|
|
720
741
|
{ rel: source.rel, detail: `ledger cites nothing: ${PRACTICE_LEDGER}` },
|
|
721
742
|
]
|
|
722
743
|
}
|
|
723
744
|
|
|
745
|
+
/**
|
|
746
|
+
* A skill on the per-bullet list cites its sources in its ledger folder, so a
|
|
747
|
+
* record whose every link moved there is left pointing at the folder instead.
|
|
748
|
+
* A skill off the list has nowhere else for a source to live.
|
|
749
|
+
*/
|
|
750
|
+
function pointsAtLedgers(
|
|
751
|
+
source: SkillSource,
|
|
752
|
+
lines: readonly { text: string }[],
|
|
753
|
+
): boolean {
|
|
754
|
+
if (BULLET_LEDGER_SKILLS[source.rel] === undefined) return false
|
|
755
|
+
const folder = `${join(SKILL_LEDGERS, source.folder)}/`
|
|
756
|
+
return lines.some((line) => line.text.includes(folder))
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The source record keeps a source no ledger row cites, so a link in both
|
|
761
|
+
* places is a second copy that drifts on the first edit to either.
|
|
762
|
+
*/
|
|
763
|
+
function duplicateFindings(source: SkillSource): SkillFinding[] {
|
|
764
|
+
if (source.ledger === undefined) return []
|
|
765
|
+
if (BULLET_LEDGER_SKILLS[source.rel] === undefined) return []
|
|
766
|
+
|
|
767
|
+
const ledgered = new Map<string, string>()
|
|
768
|
+
for (const entry of source.referenceLedgers) {
|
|
769
|
+
for (const key of entry.sources) {
|
|
770
|
+
if (!ledgered.has(key)) ledgered.set(key, entry.ledgerRel)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return [...new Set(linkedSources(source.ledger))].flatMap((key) => {
|
|
775
|
+
const ledgerRel = ledgered.get(key)
|
|
776
|
+
if (ledgerRel === undefined) return []
|
|
777
|
+
return [
|
|
778
|
+
{
|
|
779
|
+
rel: join(source.rel, PRACTICE_LEDGER),
|
|
780
|
+
detail: `duplicate source: ${key} in ${ledgerRel}`,
|
|
781
|
+
},
|
|
782
|
+
]
|
|
783
|
+
})
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Every external address a text links outside fences and code spans. */
|
|
787
|
+
function linkedSources(text: string): string[] {
|
|
788
|
+
return bodyLines(text)
|
|
789
|
+
.filter((line) => !line.fenced)
|
|
790
|
+
.flatMap((line) =>
|
|
791
|
+
[...maskCodeSpans(line.text).matchAll(EXTERNAL_ADDRESS)].map((match) =>
|
|
792
|
+
sourceKey(match[1] ?? match[2] ?? ''),
|
|
793
|
+
),
|
|
794
|
+
)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* An anchor and a trailing slash reach the same page, so both drop. A commit
|
|
799
|
+
* pin names a different version, so the path stays as written.
|
|
800
|
+
*/
|
|
801
|
+
function sourceKey(address: string): string {
|
|
802
|
+
return address.replace(/#.*$/, '').replace(/\/+$/, '')
|
|
803
|
+
}
|
|
804
|
+
|
|
724
805
|
/**
|
|
725
806
|
* Asks only the shipped corpus for a family, since the family names the skill
|
|
726
807
|
* map group a skill's row sits under and an internal skill takes no row.
|
package/src/commands/intake.ts
CHANGED
|
@@ -186,6 +186,8 @@ export function register(program: Command): void {
|
|
|
186
186
|
' strayEvidence an evidence path outside the folder evidence/',
|
|
187
187
|
' missingEvidence an evidence path that holds no file',
|
|
188
188
|
' worktreeLeft the drive worktree the record names, still on disk',
|
|
189
|
+
' undrawn a decision in evidence/drive-log.md with no Drawn',
|
|
190
|
+
' path and no skipped reason',
|
|
189
191
|
'',
|
|
190
192
|
'A goal matches by its text with any bold label and trailing period',
|
|
191
193
|
'dropped. A changed path is named and never reverted.',
|
|
@@ -553,6 +555,7 @@ const FINDING_LABELS: Record<keyof DriveFindings, string> = {
|
|
|
553
555
|
strayEvidence: 'Evidence outside evidence/',
|
|
554
556
|
missingEvidence: 'Evidence holding no file',
|
|
555
557
|
worktreeLeft: 'Drive worktree still on disk',
|
|
558
|
+
undrawn: 'Decisions with nothing drawn',
|
|
556
559
|
}
|
|
557
560
|
|
|
558
561
|
function reportDriveCheck(
|
|
@@ -585,7 +588,7 @@ function reportDriveCheck(
|
|
|
585
588
|
if (outcome.reason === 'clean') {
|
|
586
589
|
logStep('Clean')
|
|
587
590
|
logInfo(
|
|
588
|
-
`${outcome.goals} goal(s) mapped, the checkout untouched, the evidence in place.`,
|
|
591
|
+
`${outcome.goals} goal(s) mapped, the checkout untouched, the evidence in place, every decision drawn.`,
|
|
589
592
|
)
|
|
590
593
|
outro()
|
|
591
594
|
return code
|
package/src/commands/pr.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
type CheckRunListing,
|
|
25
25
|
collapseChecks,
|
|
26
|
+
type RawStatus,
|
|
27
|
+
requiredChecks,
|
|
26
28
|
withMergeState,
|
|
27
29
|
} from '@/pr/checks'
|
|
28
30
|
import {
|
|
@@ -33,6 +35,7 @@ import {
|
|
|
33
35
|
findEvidenceLocal,
|
|
34
36
|
findEvidencePreview,
|
|
35
37
|
findEvidenceReel,
|
|
38
|
+
findEvidenceSkips,
|
|
36
39
|
type EvidenceItem,
|
|
37
40
|
type EvidenceReading,
|
|
38
41
|
groupEvidence,
|
|
@@ -171,6 +174,8 @@ interface EvidenceOptions extends ReadOptions {
|
|
|
171
174
|
readonly checklist?: string
|
|
172
175
|
readonly local?: string
|
|
173
176
|
readonly reel?: string
|
|
177
|
+
readonly localSkip?: string
|
|
178
|
+
readonly reelSkip?: string
|
|
174
179
|
readonly check?: boolean
|
|
175
180
|
}
|
|
176
181
|
|
|
@@ -433,7 +438,7 @@ type EvidenceRefusal =
|
|
|
433
438
|
|
|
434
439
|
const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
|
|
435
440
|
'check-writes':
|
|
436
|
-
'--check is a read and renders no body, so it takes none of --preview, --local, --checklist, or --reel. Run the check and the render as two calls.',
|
|
441
|
+
'--check is a read and renders no body, so it takes none of --preview, --local, --checklist, --reel, --local-skip, or --reel-skip. Run the check and the render as two calls.',
|
|
437
442
|
'gh-failed':
|
|
438
443
|
'gh could not answer for this repository. Name the pull request number.',
|
|
439
444
|
'unreadable-checklist':
|
|
@@ -775,6 +780,14 @@ export function register(program: Command): void {
|
|
|
775
780
|
'--reel <link>',
|
|
776
781
|
'Embed this reel under the address line, filmed at the head, from canon pr frames --reel',
|
|
777
782
|
)
|
|
783
|
+
.option(
|
|
784
|
+
'--local-skip <reason>',
|
|
785
|
+
'Name why no local address is posted, from the reason pr-evidence holds for canon pr local',
|
|
786
|
+
)
|
|
787
|
+
.option(
|
|
788
|
+
'--reel-skip <reason>',
|
|
789
|
+
'Name why no reel is embedded, from the reason pr-evidence holds for canon pr frames --reel',
|
|
790
|
+
)
|
|
778
791
|
.option(
|
|
779
792
|
'--check',
|
|
780
793
|
'Report which post-pull-request steps the thread is still owed, rendering nothing',
|
|
@@ -854,6 +867,17 @@ export function register(program: Command): void {
|
|
|
854
867
|
'current, and a passed one replaces it. Like --local it rides on a',
|
|
855
868
|
'checklist or a comparison and never opens a comment alone.',
|
|
856
869
|
'',
|
|
870
|
+
'--local-skip and --reel-skip each put one line under the addresses,',
|
|
871
|
+
'**No local preview:** or **No reel:** followed by the text passed, a',
|
|
872
|
+
'reason code and any one-line message joined to it, folded onto one',
|
|
873
|
+
'line. Without the flag, a skip line the marked comment already carries',
|
|
874
|
+
'is carried forward. A skip line is dropped whenever its address or',
|
|
875
|
+
'reel renders, passed or carried, so a later run that posts the link or',
|
|
876
|
+
'films the reel replaces it. A passed --local-skip drops a carried',
|
|
877
|
+
'local address, since that line names no head and would read as live,',
|
|
878
|
+
'while a carried reel names its head and stays beside --reel-skip. Like',
|
|
879
|
+
'--local, neither opens a comment alone.',
|
|
880
|
+
'',
|
|
857
881
|
"A render at a new head also resets the pull request body's UI pass",
|
|
858
882
|
'line, the one `canon pr tick` writes under ## Testing, to unchecked',
|
|
859
883
|
'naming that head. It touches no other body text, leaves a line already',
|
|
@@ -893,7 +917,8 @@ export function register(program: Command): void {
|
|
|
893
917
|
'request changed, and the marked comment carries no **Preview:**',
|
|
894
918
|
'address. A failed deploy reads the same as a skipped one.',
|
|
895
919
|
'The record carries `owed` and the same marked fields. --check refuses',
|
|
896
|
-
'with --preview, --local, --checklist,
|
|
920
|
+
'with --preview, --local, --checklist, --reel, --local-skip, or',
|
|
921
|
+
'--reel-skip as check-writes.',
|
|
897
922
|
'',
|
|
898
923
|
'Refusals, read off `reason`:',
|
|
899
924
|
' unreadable-checklist --checklist named a path that could not be read,',
|
|
@@ -917,6 +942,7 @@ export function register(program: Command): void {
|
|
|
917
942
|
' canon pr evidence 1341 --checklist .canon/tmp/handoff/ui-checklist/x.md --json',
|
|
918
943
|
' canon pr evidence 1341 --local http://localhost:5173 --json',
|
|
919
944
|
' canon pr evidence 1341 --reel <link from canon pr frames --reel> --json',
|
|
945
|
+
" canon pr evidence 1341 --reel-skip 'no-server: <message>' --json",
|
|
920
946
|
' canon pr evidence 1341 --check --json',
|
|
921
947
|
'',
|
|
922
948
|
].join('\n'),
|
|
@@ -960,7 +986,12 @@ export function register(program: Command): void {
|
|
|
960
986
|
' or needing eyes refuses `ui-undriven`, carried or not',
|
|
961
987
|
' evidence the evidence check reads settled',
|
|
962
988
|
' standing the head is fresh against the base, not stale or conflicted',
|
|
963
|
-
' ci every check run on the tip passed, waited on up to --wait',
|
|
989
|
+
' ci every required check run on the tip passed, waited on up to --wait',
|
|
990
|
+
'',
|
|
991
|
+
"The required checks are the ones the base branch's rules list. A",
|
|
992
|
+
'check outside them is waited on by nobody, and one that failed is',
|
|
993
|
+
'named in the record as `advisory` without blocking. Rules that do not',
|
|
994
|
+
'read, or that list no check, gate by every check run on the tip.',
|
|
964
995
|
'',
|
|
965
996
|
'Review state, UI state, evidence, and standing are read before the CI',
|
|
966
997
|
'wait, so a reviewer whose partner is still driving refuses at once. The',
|
|
@@ -1039,6 +1070,10 @@ export function register(program: Command): void {
|
|
|
1039
1070
|
'request already passed `canon pr lift`. It does not wait on checks or',
|
|
1040
1071
|
'delete the branch.',
|
|
1041
1072
|
'',
|
|
1073
|
+
"The checks gate by the ones the base branch's rules require, the way",
|
|
1074
|
+
'`canon pr lift` reads them. A failing check outside that set is named',
|
|
1075
|
+
'in the record as `advisory` and does not refuse the merge.',
|
|
1076
|
+
'',
|
|
1042
1077
|
'Reasons:',
|
|
1043
1078
|
' merged, dry-run (--dry-run)',
|
|
1044
1079
|
' pull-closed, pull-draft, missing-summary, missing-key-changes,',
|
|
@@ -2658,7 +2693,9 @@ async function runEvidence(
|
|
|
2658
2693
|
(opts.preview !== undefined ||
|
|
2659
2694
|
opts.local !== undefined ||
|
|
2660
2695
|
opts.checklist !== undefined ||
|
|
2661
|
-
opts.reel !== undefined
|
|
2696
|
+
opts.reel !== undefined ||
|
|
2697
|
+
opts.localSkip !== undefined ||
|
|
2698
|
+
opts.reelSkip !== undefined)
|
|
2662
2699
|
) {
|
|
2663
2700
|
return refuseWith(
|
|
2664
2701
|
'check-writes',
|
|
@@ -2868,8 +2905,17 @@ async function runEvidence(
|
|
|
2868
2905
|
}
|
|
2869
2906
|
|
|
2870
2907
|
const preview = opts.preview ?? (isUnserved ? undefined : carriedPreview)
|
|
2871
|
-
|
|
2908
|
+
// A carried local line names no head and reads as live, so a passed skip
|
|
2909
|
+
// drops it the way a passed address replaces it. A carried reel names the
|
|
2910
|
+
// head it was filmed at, so it stays beside a passed skip.
|
|
2911
|
+
const local =
|
|
2912
|
+
opts.local ?? (opts.localSkip === undefined ? carriedLocal : undefined)
|
|
2872
2913
|
const reel = nextEvidenceReel(opts.reel, identity.head, comments)
|
|
2914
|
+
const carriedSkips = findEvidenceSkips(comments)
|
|
2915
|
+
const skips = {
|
|
2916
|
+
local: opts.localSkip ?? carriedSkips.local,
|
|
2917
|
+
reel: opts.reelSkip ?? carriedSkips.reel,
|
|
2918
|
+
}
|
|
2873
2919
|
const unsettled = suppliedChecklist ?? carriedChecklist
|
|
2874
2920
|
const checklist =
|
|
2875
2921
|
unsettled === undefined
|
|
@@ -2886,6 +2932,7 @@ async function runEvidence(
|
|
|
2886
2932
|
checklist,
|
|
2887
2933
|
local,
|
|
2888
2934
|
reel,
|
|
2935
|
+
skips,
|
|
2889
2936
|
)
|
|
2890
2937
|
|
|
2891
2938
|
const caseCount = states.reduce((n, s) => n + s.items.length, 0)
|
|
@@ -4209,24 +4256,64 @@ async function readChecksGate(
|
|
|
4209
4256
|
number: string,
|
|
4210
4257
|
tip: string,
|
|
4211
4258
|
): Promise<ChecksGate | undefined> {
|
|
4212
|
-
|
|
4259
|
+
// A status read that fails leaves a required status context unseen, which
|
|
4260
|
+
// reads pending rather than passing, so it needs no refusal of its own.
|
|
4261
|
+
const [listed, pulled, combined] = await Promise.all([
|
|
4213
4262
|
gh(root, [
|
|
4214
4263
|
'api',
|
|
4215
4264
|
`repos/{owner}/{repo}/commits/${tip}/check-runs?per_page=100`,
|
|
4216
4265
|
]),
|
|
4217
4266
|
gh(root, ['api', `repos/{owner}/{repo}/pulls/${number}`]),
|
|
4267
|
+
gh(root, [
|
|
4268
|
+
'api',
|
|
4269
|
+
`repos/{owner}/{repo}/commits/${tip}/status?per_page=100`,
|
|
4270
|
+
]),
|
|
4218
4271
|
])
|
|
4219
4272
|
if (listed === null) return undefined
|
|
4220
4273
|
try {
|
|
4221
|
-
const
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4274
|
+
const row = pulled === null ? undefined : (JSON.parse(pulled) as RestPull)
|
|
4275
|
+
const required = await readRequiredChecks(root, row?.base?.ref)
|
|
4276
|
+
const statuses =
|
|
4277
|
+
combined === null
|
|
4278
|
+
? []
|
|
4279
|
+
: ((JSON.parse(combined) as { statuses?: readonly RawStatus[] })
|
|
4280
|
+
.statuses ?? [])
|
|
4225
4281
|
const reading = withMergeState(
|
|
4226
|
-
|
|
4227
|
-
collapseChecks(
|
|
4282
|
+
mergeStateOf(row?.mergeable_state),
|
|
4283
|
+
collapseChecks(
|
|
4284
|
+
tip,
|
|
4285
|
+
JSON.parse(listed) as CheckRunListing,
|
|
4286
|
+
required,
|
|
4287
|
+
statuses,
|
|
4288
|
+
),
|
|
4228
4289
|
)
|
|
4229
|
-
return {
|
|
4290
|
+
return {
|
|
4291
|
+
state: reading.state,
|
|
4292
|
+
isConflicted: reading.conflicted,
|
|
4293
|
+
...(reading.advisory !== undefined && { advisory: reading.advisory }),
|
|
4294
|
+
}
|
|
4295
|
+
} catch {
|
|
4296
|
+
return undefined
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
/**
|
|
4301
|
+
* The checks the base branch's rules require, so a check they leave out runs
|
|
4302
|
+
* without holding the gate. Undefined on any read that fails, which gates by
|
|
4303
|
+
* every check as the verbs did before the rules were read.
|
|
4304
|
+
*/
|
|
4305
|
+
async function readRequiredChecks(
|
|
4306
|
+
root: string,
|
|
4307
|
+
base: string | undefined,
|
|
4308
|
+
): Promise<ReadonlySet<string> | undefined> {
|
|
4309
|
+
if (base === undefined || base === '') return undefined
|
|
4310
|
+
const rules = await gh(root, [
|
|
4311
|
+
'api',
|
|
4312
|
+
`repos/{owner}/{repo}/rules/branches/${base}`,
|
|
4313
|
+
])
|
|
4314
|
+
if (rules === null) return undefined
|
|
4315
|
+
try {
|
|
4316
|
+
return requiredChecks(JSON.parse(rules))
|
|
4230
4317
|
} catch {
|
|
4231
4318
|
return undefined
|
|
4232
4319
|
}
|
|
@@ -4360,6 +4447,7 @@ async function runLift(
|
|
|
4360
4447
|
for (const gate of record.gates) {
|
|
4361
4448
|
if (!gate.isOk) logWarn(`${gate.gate}: ${gate.reason ?? 'not clear'}`)
|
|
4362
4449
|
}
|
|
4450
|
+
warnAdvisory(record.advisory)
|
|
4363
4451
|
outro()
|
|
4364
4452
|
|
|
4365
4453
|
if (emitJson) {
|
|
@@ -4371,6 +4459,14 @@ async function runLift(
|
|
|
4371
4459
|
return record.isOk ? 0 : 1
|
|
4372
4460
|
}
|
|
4373
4461
|
|
|
4462
|
+
function warnAdvisory(advisory: readonly string[] | undefined): void {
|
|
4463
|
+
for (const name of advisory ?? []) {
|
|
4464
|
+
logWarn(
|
|
4465
|
+
`advisory: ${name} failed, which the base's rules do not require, so it did not block`,
|
|
4466
|
+
)
|
|
4467
|
+
}
|
|
4468
|
+
}
|
|
4469
|
+
|
|
4374
4470
|
const MERGE_REFUSALS: Record<MergeRefusal | 'gh-missing', string> = {
|
|
4375
4471
|
'gh-missing': PULL_REFUSALS['gh-missing'],
|
|
4376
4472
|
'pull-closed': 'The pull request is closed, so there is nothing to merge.',
|
|
@@ -4516,6 +4612,7 @@ async function runMerge(
|
|
|
4516
4612
|
? `#${record.number} would merge with the message below`
|
|
4517
4613
|
: MERGE_REFUSALS[record.reason],
|
|
4518
4614
|
)
|
|
4615
|
+
warnAdvisory(record.advisory)
|
|
4519
4616
|
outro()
|
|
4520
4617
|
|
|
4521
4618
|
if (emitJson) {
|
package/src/gate/measures.ts
CHANGED
|
@@ -1111,7 +1111,7 @@ const VISUAL_ONLY_GLOBS: readonly string[] = ['tooling/web/configs/e2e/**']
|
|
|
1111
1111
|
|
|
1112
1112
|
/**
|
|
1113
1113
|
* `deploy-site.yml` and `pr-visual-checks.yml` each carry their own literal
|
|
1114
|
-
* copy of the
|
|
1114
|
+
* copy of the five path globs a landing-page change should trigger on, plus
|
|
1115
1115
|
* the `VISUAL_ONLY_GLOBS` the visual workflow adds for itself, per
|
|
1116
1116
|
* Question 2 in `.canon/plans/archive/feature-render-visibility.md`. A YAML
|
|
1117
1117
|
* anchor cannot cross files here, so the two copies are read live and compared
|
package/src/gate/sequencer.ts
CHANGED
|
@@ -386,7 +386,13 @@ async function runCheck(
|
|
|
386
386
|
}
|
|
387
387
|
|
|
388
388
|
const run = check.kind === 'cli' ? ctx.cli : ctx.run
|
|
389
|
-
const
|
|
389
|
+
const argv =
|
|
390
|
+
check.kind === 'command' &&
|
|
391
|
+
check.changedArgv !== undefined &&
|
|
392
|
+
ctx.changed !== undefined
|
|
393
|
+
? check.changedArgv(ctx.changed)
|
|
394
|
+
: check.argv
|
|
395
|
+
const result = await run(argv, env)
|
|
390
396
|
return {
|
|
391
397
|
emissions: [{ kind: 'output', text: result.all }],
|
|
392
398
|
failure: result.exitCode === 0 ? undefined : check.failure,
|
package/src/gate/stages.ts
CHANGED
|
@@ -45,6 +45,12 @@ export type Check =
|
|
|
45
45
|
| {
|
|
46
46
|
readonly kind: 'command'
|
|
47
47
|
readonly argv: readonly string[]
|
|
48
|
+
/**
|
|
49
|
+
* Builds the argv from the changed set when scoping is on. `argv` runs
|
|
50
|
+
* where it is off, so `--all` and every widening fallback keep the full
|
|
51
|
+
* command.
|
|
52
|
+
*/
|
|
53
|
+
readonly changedArgv?: (changed: readonly string[]) => readonly string[]
|
|
48
54
|
readonly failure: string
|
|
49
55
|
}
|
|
50
56
|
/** This checkout's own CLI, so a worktree gate never reads the main tree. */
|
|
@@ -92,33 +98,36 @@ export interface Stage {
|
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* their tests walk the tree whole, which is what reaches a rule or a skill a
|
|
100
|
-
* branch adds rather than edits.
|
|
101
|
+
* Files a scoped run hands to `vitest related`, which follows imports from
|
|
102
|
+
* them to the tests that reach them. A corpus a test reads from disk is not
|
|
103
|
+
* among them, so a Markdown-only push runs no tests locally and CI's full run
|
|
104
|
+
* is what catches a broken corpus reader.
|
|
101
105
|
*/
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
'^tooling/base/reference\\.md$',
|
|
108
|
-
'^tooling/web/configs/scripts/worktree-port\\.sh$',
|
|
109
|
-
]
|
|
106
|
+
const RELATED_SOURCES = /\.tsx?$|^commitlint\.config\.js$/
|
|
107
|
+
|
|
108
|
+
/** Files every test depends on without importing, so no related run sees them. */
|
|
109
|
+
const FULL_SUITE_TRIGGERS =
|
|
110
|
+
/^vitest\.(config|setup|global-setup)\.ts$|^tsconfig\.json$|^package\.json$/
|
|
110
111
|
|
|
111
112
|
const TESTS_SCOPE = new RegExp(
|
|
112
|
-
|
|
113
|
-
'^src/',
|
|
114
|
-
'^vitest\\.config\\.ts$',
|
|
115
|
-
'^tsconfig\\.json$',
|
|
116
|
-
'^package\\.json$',
|
|
117
|
-
'^commitlint\\.config\\.(js|test\\.ts)$',
|
|
118
|
-
...TEST_CORPORA_PATTERNS,
|
|
119
|
-
].join('|'),
|
|
113
|
+
`${RELATED_SOURCES.source}|${FULL_SUITE_TRIGGERS.source}`,
|
|
120
114
|
)
|
|
121
115
|
|
|
116
|
+
const FULL_SUITE = ['bun', 'run', 'test']
|
|
117
|
+
|
|
118
|
+
function relatedTests(changed: readonly string[]): readonly string[] {
|
|
119
|
+
if (changed.some((file) => FULL_SUITE_TRIGGERS.test(file))) return FULL_SUITE
|
|
120
|
+
return [
|
|
121
|
+
'bun',
|
|
122
|
+
'--bun',
|
|
123
|
+
'vitest',
|
|
124
|
+
'related',
|
|
125
|
+
'--run',
|
|
126
|
+
'--passWithNoTests',
|
|
127
|
+
...changed.filter((file) => RELATED_SOURCES.test(file)),
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
|
|
122
131
|
/**
|
|
123
132
|
* Every stage the gate runs, in the order it runs them.
|
|
124
133
|
*
|
|
@@ -590,14 +599,17 @@ export const STAGES: readonly Stage[] = [
|
|
|
590
599
|
id: 'tests',
|
|
591
600
|
label: 'Tests',
|
|
592
601
|
scope: TESTS_SCOPE,
|
|
593
|
-
skipped: 'Skipped, no TypeScript or
|
|
602
|
+
skipped: 'Skipped, no TypeScript or test config changes',
|
|
594
603
|
// Concurrent suites from a wave of worktrees starve each other's spawn
|
|
595
604
|
// and browser tests past their timeouts, so the box runs one at a time.
|
|
605
|
+
// A related run can still reach a spawn or browser test, so it holds the
|
|
606
|
+
// lock too.
|
|
596
607
|
lock: 'tests',
|
|
597
608
|
checks: [
|
|
598
609
|
{
|
|
599
610
|
kind: 'command',
|
|
600
|
-
argv:
|
|
611
|
+
argv: FULL_SUITE,
|
|
612
|
+
changedArgv: relatedTests,
|
|
601
613
|
failure: 'Tests failed',
|
|
602
614
|
},
|
|
603
615
|
],
|
|
@@ -16,6 +16,9 @@ export const DRIVE_RECORD = 'drive.json'
|
|
|
16
16
|
/** The one folder inside an intake folder that evidence may live in. */
|
|
17
17
|
export const EVIDENCE_DIR = 'evidence'
|
|
18
18
|
|
|
19
|
+
/** The log a person reads, which the `undrawn` axis parses by its labels. */
|
|
20
|
+
export const DRIVE_LOG = join(EVIDENCE_DIR, 'drive-log.md')
|
|
21
|
+
|
|
19
22
|
const SCRATCH = join('.canon', 'tmp', 'app-drive')
|
|
20
23
|
|
|
21
24
|
/**
|
|
@@ -63,6 +66,15 @@ export interface DriveFindings {
|
|
|
63
66
|
readonly missingEvidence: readonly string[]
|
|
64
67
|
/** The drive's worktree, when the record names one still on disk. */
|
|
65
68
|
readonly worktreeLeft: readonly string[]
|
|
69
|
+
/** Logged findings the operator decided with no drawn outcome. */
|
|
70
|
+
readonly undrawn: readonly string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DrawnReading {
|
|
74
|
+
/** Each decided finding's heading, as `<nn>. <title>`, with nothing drawn. */
|
|
75
|
+
readonly undrawn: readonly string[]
|
|
76
|
+
/** Every path a `Drawn` field cites, for the evidence tests. */
|
|
77
|
+
readonly paths: readonly string[]
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
export interface DriveChecked {
|
|
@@ -159,6 +171,57 @@ export function readGoals(source: string): string[] {
|
|
|
159
171
|
return goals
|
|
160
172
|
}
|
|
161
173
|
|
|
174
|
+
const FINDING_HEADING = /^##\s+(\d+\.\s.*?)\s*$/
|
|
175
|
+
const LOG_FIELD = /^[-*]\s+\*\*([^*]+):\*\*\s*(.*)$/
|
|
176
|
+
const DECISION_LABEL = 'Decision (operator)'
|
|
177
|
+
const DRAWN_LABEL = 'Drawn'
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reads the drive log's numbered findings for a decision with nothing drawn.
|
|
181
|
+
* A decision reading `see <nn>` was drawn under the finding it names, and a
|
|
182
|
+
* `ref-<nn>` heading or a finding with no decision carries nothing to draw. A
|
|
183
|
+
* `Drawn` field passes when it cites a path in a code span or reads
|
|
184
|
+
* `skipped, <reason>`, so a bare `skipped` still lists the finding. A skip
|
|
185
|
+
* cites no path, since its reason may code-span a flag or a format.
|
|
186
|
+
*/
|
|
187
|
+
export function readDrawn(source: string): DrawnReading {
|
|
188
|
+
const undrawn: string[] = []
|
|
189
|
+
const paths: string[] = []
|
|
190
|
+
let finding: string | undefined
|
|
191
|
+
let decision: string | undefined
|
|
192
|
+
let drawn: string | undefined
|
|
193
|
+
|
|
194
|
+
const close = () => {
|
|
195
|
+
if (finding !== undefined && decision && !/^see\s+\d+/i.test(decision)) {
|
|
196
|
+
const field = drawn ?? ''
|
|
197
|
+
if (/^skipped\b/i.test(field)) {
|
|
198
|
+
if (!/^skipped\s*,\s*\S/i.test(field)) undrawn.push(finding)
|
|
199
|
+
} else {
|
|
200
|
+
const cited = [...field.matchAll(/`([^`]+)`/g)].map((match) => match[1])
|
|
201
|
+
paths.push(...cited)
|
|
202
|
+
if (cited.length === 0) undrawn.push(finding)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
finding = decision = drawn = undefined
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const line of bodyLines(source)) {
|
|
209
|
+
if (line.fenced) continue
|
|
210
|
+
if (/^#{1,2}\s/.test(line.text)) {
|
|
211
|
+
close()
|
|
212
|
+
finding = FINDING_HEADING.exec(line.text)?.[1]
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
const field = LOG_FIELD.exec(line.text)
|
|
216
|
+
if (finding === undefined || !field) continue
|
|
217
|
+
if (field[1] === DECISION_LABEL) decision = field[2].trim()
|
|
218
|
+
if (field[1] === DRAWN_LABEL) drawn = field[2].trim()
|
|
219
|
+
}
|
|
220
|
+
close()
|
|
221
|
+
|
|
222
|
+
return { undrawn, paths }
|
|
223
|
+
}
|
|
224
|
+
|
|
162
225
|
function parseRecord(raw: string): DriveRecord | string {
|
|
163
226
|
let parsed: unknown
|
|
164
227
|
try {
|
|
@@ -271,8 +334,9 @@ export async function recordBaseline(
|
|
|
271
334
|
/**
|
|
272
335
|
* Checks a finished drive against its intake folder's `drive.json`: every
|
|
273
336
|
* goal mapped or skipped with a reason, the main checkout unchanged since the
|
|
274
|
-
* baseline, the evidence inside the folder's `evidence/`,
|
|
275
|
-
*
|
|
337
|
+
* baseline, the evidence inside the folder's `evidence/`, every decision in
|
|
338
|
+
* the drive log drawn, and the drive's worktree gone. A folder with no log
|
|
339
|
+
* reads as nothing undrawn. It reports what failed and never reverts a changed path,
|
|
276
340
|
* since only the operator knows which of them to keep.
|
|
277
341
|
*/
|
|
278
342
|
export async function checkDrive(
|
|
@@ -318,6 +382,12 @@ export async function checkDrive(
|
|
|
318
382
|
return refuse('unreadable-tree', `git cannot read the status of ${root}.`)
|
|
319
383
|
}
|
|
320
384
|
|
|
385
|
+
const logPath = join(folder, DRIVE_LOG)
|
|
386
|
+
const drawn: DrawnReading = existsSync(logPath)
|
|
387
|
+
? readDrawn(await readFile(logPath, 'utf8'))
|
|
388
|
+
: { undrawn: [], paths: [] }
|
|
389
|
+
const evidence = [...new Set([...record.evidence, ...drawn.paths])]
|
|
390
|
+
|
|
321
391
|
const entries = new Map(
|
|
322
392
|
record.goals.map((entry) => [normalizeGoal(entry.goal), entry]),
|
|
323
393
|
)
|
|
@@ -334,14 +404,15 @@ export async function checkDrive(
|
|
|
334
404
|
.filter((entry) => !known.has(normalizeGoal(entry.goal)))
|
|
335
405
|
.map((entry) => entry.goal),
|
|
336
406
|
changed: changedSince(baseline, current),
|
|
337
|
-
strayEvidence:
|
|
338
|
-
missingEvidence:
|
|
407
|
+
strayEvidence: evidence.filter((path) => !isEvidencePath(path)),
|
|
408
|
+
missingEvidence: evidence.filter(
|
|
339
409
|
(path) => isEvidencePath(path) && !existsSync(join(folder, path)),
|
|
340
410
|
),
|
|
341
411
|
worktreeLeft:
|
|
342
412
|
record.worktree !== undefined && existsSync(record.worktree)
|
|
343
413
|
? [record.worktree]
|
|
344
414
|
: [],
|
|
415
|
+
undrawn: drawn.undrawn,
|
|
345
416
|
}
|
|
346
417
|
const failed = Object.values(findings).some((list) => list.length > 0)
|
|
347
418
|
|
package/src/markdown/scope.ts
CHANGED
|
@@ -280,6 +280,8 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
280
280
|
'claude/skills/pr-evidence/REQUIREMENT.md',
|
|
281
281
|
'claude/skills/pr-evidence/SKILL.md',
|
|
282
282
|
'claude/skills/pr-evidence/references/evidence.md',
|
|
283
|
+
'claude/skills/proposal-critique/REQUIREMENT.md',
|
|
284
|
+
'claude/skills/proposal-critique/SKILL.md',
|
|
283
285
|
'claude/skills/review-address/REQUIREMENT.md',
|
|
284
286
|
'claude/skills/review-address/SKILL.md',
|
|
285
287
|
'claude/skills/review-address/references/post-review-findings.md',
|