@erclx/canon 4.120.0 → 4.122.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/auto-ship/SKILL.md +29 -115
- package/claude/skills/auto-ship/references/draft-mark.md +30 -0
- package/claude/skills/auto-ship/references/failure-recovery.md +27 -0
- package/claude/skills/auto-ship/references/tier-failures.md +26 -0
- package/claude/skills/auto-ship/references/verb-absent.md +37 -0
- package/claude/skills/auto-ship/references/worktree-name.md +22 -0
- package/claude/skills/backlog-triage/REQUIREMENT.md +47 -0
- package/claude/skills/backlog-triage/SKILL.md +139 -0
- package/claude/skills/bash-script/SKILL.md +1 -1
- package/claude/skills/bash-script/references/patterns.md +0 -99
- package/claude/skills/bash-script/references/prompts.md +100 -0
- package/claude/skills/canon-rollout/SKILL.md +6 -6
- package/claude/skills/context-fold/REQUIREMENT.md +75 -0
- package/claude/skills/context-fold/SKILL.md +162 -0
- package/claude/skills/{docs-fold → context-fold}/references/anchor-sweep.md +1 -1
- package/claude/skills/{docs-fold → context-fold}/references/classify.md +4 -2
- package/claude/skills/context-fold/references/context-refresh.md +48 -0
- package/claude/skills/context-fold/references/promoted-pages.md +29 -0
- package/claude/skills/context-fold/references/receipt-sweep.md +43 -0
- package/claude/skills/context-fold/references/unusable-baseline.md +25 -0
- package/claude/skills/{docs-fold → context-fold}/references/wireframe-sweep.md +1 -1
- package/claude/skills/create-skill/SKILL.md +1 -1
- package/claude/skills/create-snippet/SKILL.md +1 -1
- package/claude/skills/create-standard/SKILL.md +1 -1
- package/claude/skills/design-extract/SKILL.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +2 -2
- package/claude/skills/docs-fold/REQUIREMENT.md +8 -58
- package/claude/skills/docs-fold/SKILL.md +3 -278
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/docs-sync/SKILL.md +2 -2
- package/claude/skills/draft-context/REQUIREMENT.md +2 -2
- package/claude/skills/draft-context/SKILL.md +4 -4
- package/claude/skills/draft-docs/REQUIREMENT.md +1 -1
- package/claude/skills/draft-docs/SKILL.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +1 -1
- package/claude/skills/draft-readme/SKILL.md +1 -1
- package/claude/skills/draft-wiki/SKILL.md +1 -1
- package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
- package/claude/skills/draft-wireframes/SKILL.md +3 -3
- package/claude/skills/git-issue/SKILL.md +1 -1
- package/claude/skills/git-issue/references/issue.md +1 -1
- package/claude/skills/git-pr/SKILL.md +9 -147
- package/claude/skills/git-pr/references/evidence.md +114 -0
- package/claude/skills/git-pr/references/labels.md +20 -0
- package/claude/skills/git-pr/references/task-number.md +26 -0
- package/claude/skills/git-ship/SKILL.md +1 -1
- package/claude/skills/git-stage/SKILL.md +1 -1
- package/claude/skills/memory-capture/REQUIREMENT.md +2 -2
- package/claude/skills/memory-capture/SKILL.md +6 -6
- package/claude/skills/memory-review/REQUIREMENT.md +1 -1
- package/claude/skills/memory-review/SKILL.md +5 -101
- package/claude/skills/memory-review/references/later-phases.md +108 -0
- package/claude/skills/plan-feature/REQUIREMENT.md +1 -1
- package/claude/skills/restate-plainly/REQUIREMENT.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +1 -1
- package/claude/skills/review-address/SKILL.md +5 -4
- package/claude/skills/review-branch/REQUIREMENT.md +1 -0
- package/claude/skills/review-branch/SKILL.md +6 -7
- package/claude/skills/review-craft/REQUIREMENT.md +43 -0
- package/claude/skills/review-craft/SKILL.md +50 -0
- package/claude/skills/review-craft/references/security.md +21 -0
- package/claude/skills/review-craft/references/ui.md +72 -0
- package/claude/skills/review-pr/REQUIREMENT.md +1 -0
- package/claude/skills/review-pr/SKILL.md +1 -1
- package/claude/skills/review-pr/references/post.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +2 -2
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/session-resume/REQUIREMENT.md +1 -1
- package/claude/skills/session-worktree/SKILL.md +4 -10
- package/claude/skills/standards-audit/SKILL.md +1 -1
- package/claude/skills/task-board/REQUIREMENT.md +1 -1
- package/claude/skills/task-board/SKILL.md +6 -80
- package/claude/skills/task-board/references/archive.md +54 -0
- package/claude/skills/task-board/references/decline.md +40 -0
- package/claude/skills/teach-workspace/SKILL.md +8 -105
- package/claude/skills/teach-workspace/references/lesson-craft.md +51 -1
- package/claude/skills/teach-workspace/references/promotion.md +41 -0
- package/claude/skills/write-human/REQUIREMENT.md +7 -4
- package/claude/skills/write-human/SKILL.md +4 -2
- package/claude/skills/write-human/references/machine-tells.md +1 -1
- package/claude/skills/write-human/references/source-material.md +5 -2
- package/docs/agents/architecture-record.md +26 -0
- package/docs/agents/audits.md +1 -1
- package/docs/agents/commands.md +87 -154
- package/docs/agents/context-audit-checks.md +2 -20
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +1 -1
- package/docs/agents/drift-surfaces.md +192 -0
- package/docs/agents/index.md +12 -4
- package/docs/agents/install-and-sync.md +2 -181
- package/docs/agents/markdown-audit-weight.md +73 -0
- package/docs/agents/markdown-audit.md +11 -104
- package/docs/agents/migrate.md +34 -0
- package/docs/agents/pr-local.md +6 -3
- package/docs/agents/records-stale.md +62 -0
- package/docs/agents/records.md +5 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/tasks-archive.md +81 -0
- package/docs/agents/tasks-plans.md +147 -0
- package/docs/agents/tasks-validate.md +131 -0
- package/docs/agents/tasks.md +6 -333
- package/docs/target-projects.md +4 -4
- package/docs/workflow/ai-workflow.md +33 -27
- package/governance/rules/claude/500-prose.md +1 -1
- package/governance/rules/claude/501-markdown.md +3 -3
- package/governance/rules/core/045-memory.md +1 -1
- package/package.json +1 -1
- package/src/autoship/paths.ts +3 -2
- package/src/claude/cases/setup.ts +1 -1
- package/src/claude/cases/workflow.ts +27 -1
- package/src/commands/audits.ts +2 -2
- package/src/commands/markdown.ts +7 -9
- package/src/commands/pr.ts +11 -4
- package/src/commands/records.ts +146 -0
- package/src/commands/sandbox.ts +6 -4
- package/src/context/classify/extract.ts +1 -1
- package/src/context/classify/ollama.ts +1 -1
- package/src/context/classify/run.ts +1 -1
- package/src/gate/measures.ts +5 -5
- package/src/gate/stages.ts +0 -1
- package/src/markdown/bans.ts +11 -68
- package/src/markdown/gate.ts +1 -1
- package/src/markdown/scan.ts +7 -88
- package/src/migrate/skill-names.ts +15 -2
- package/src/records/stale.ts +243 -0
- package/src/sandbox/expect.ts +1 -1
- package/src/tasks/archive.ts +28 -4
- package/src/tasks/record.ts +1 -1
- package/src/tasks/validate.ts +36 -3
- package/standards/docs.md +1 -1
- package/standards/index.md +1 -1
- package/standards/markdown.md +5 -7
- package/standards/memory.md +13 -1
- package/standards/pr.md +1 -1
- package/standards/publish.md +1 -1
- package/standards/readme.md +2 -2
- package/standards/tasks.md +1 -1
- package/tooling/astro/reference.md +1 -1
- package/tooling/base/configs/.husky/post-merge +1 -1
- package/tooling/base/configs/scripts/verify.sh +2 -2
- package/tooling/base/seeds/canon/context/development.md +1 -1
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +5 -6
- package/tooling/go/reference.md +1 -1
- package/tooling/nextjs/reference.md +1 -1
- package/tooling/php/reference.md +1 -1
- package/tooling/python/reference.md +1 -1
- package/tooling/vite-react/reference.md +1 -1
- package/tooling/web/reference.md +1 -1
package/src/gate/measures.ts
CHANGED
|
@@ -438,8 +438,8 @@ export const recordIdempotence: Measure = async (ctx) => {
|
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
/**
|
|
441
|
-
* A banned character
|
|
442
|
-
*
|
|
441
|
+
* A banned character is a fact rather than a threshold, so it fails the push
|
|
442
|
+
* while bullet, paragraph, and depth weight stay advisory.
|
|
443
443
|
*
|
|
444
444
|
* The whole corpus is measured rather than the changed files, because a
|
|
445
445
|
* `Do not use` bullet added to a standard bans a token retroactively and no
|
|
@@ -453,7 +453,7 @@ export const markdownBans: Measure = async (ctx) => {
|
|
|
453
453
|
const { exitCode } = await ctx.cli(['markdown', 'audit', '--json'])
|
|
454
454
|
|
|
455
455
|
if (exitCode === 0) {
|
|
456
|
-
return { emissions: [info('No banned character
|
|
456
|
+
return { emissions: [info('No banned character')] }
|
|
457
457
|
}
|
|
458
458
|
|
|
459
459
|
if (exitCode === 1) {
|
|
@@ -476,7 +476,7 @@ export const markdownBans: Measure = async (ctx) => {
|
|
|
476
476
|
return {
|
|
477
477
|
emissions: [output(frame.all)],
|
|
478
478
|
failure:
|
|
479
|
-
'Markdown prose carries a banned character,
|
|
479
|
+
'Markdown prose carries a banned character, or a relative link resolves to nothing on disk. Rewrite the sentence or fix the link, and reach for a code span only where the token is genuinely an identifier under discussion.',
|
|
480
480
|
}
|
|
481
481
|
}
|
|
482
482
|
|
|
@@ -1242,7 +1242,7 @@ export const pluginManifests: Measure = async (ctx) => {
|
|
|
1242
1242
|
return {
|
|
1243
1243
|
emissions: [],
|
|
1244
1244
|
unmeasured:
|
|
1245
|
-
'claude is on PATH and claude --version fails, so the install brought down no platform-native binary and no manifest was read. Raise or lower the pinned version at the Install Plugin CLI step in .github/workflows/verify.yml, and record the move in canon/context/ci.md.',
|
|
1245
|
+
'claude is on PATH and claude --version fails, so the install brought down no platform-native binary and no manifest was read. Raise or lower the pinned version at the Install Plugin CLI step in .github/workflows/verify.yml, and record the move in canon/context/ci/checks.md.',
|
|
1246
1246
|
}
|
|
1247
1247
|
}
|
|
1248
1248
|
|
package/src/gate/stages.ts
CHANGED
|
@@ -97,7 +97,6 @@ export const TEST_CORPORA_PATTERNS = [
|
|
|
97
97
|
'^standards/markdown\\.md$',
|
|
98
98
|
'^tooling/base/reference\\.md$',
|
|
99
99
|
'^tooling/web/configs/scripts/worktree-port\\.sh$',
|
|
100
|
-
'^\\.cspell/banned-spellings\\.txt$',
|
|
101
100
|
'^scripts/lib/worktree\\.sh$',
|
|
102
101
|
'^scripts/core/check-ignore-parity\\.sh$',
|
|
103
102
|
]
|
package/src/markdown/bans.ts
CHANGED
|
@@ -10,85 +10,28 @@ import type { BanSets } from '@/markdown/scan'
|
|
|
10
10
|
const CHARACTERS = ['—', ';'] as const
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
13
|
+
* The one closed set the audit measures, owned here rather than harvested
|
|
14
|
+
* from the standard stating it.
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
const WORDS = [
|
|
21
|
-
'seamless',
|
|
22
|
-
'robust',
|
|
23
|
-
'powerful',
|
|
24
|
-
'revolutionary',
|
|
25
|
-
'enhanced',
|
|
26
|
-
'allows',
|
|
27
|
-
'leverage',
|
|
28
|
-
'simply',
|
|
29
|
-
'just',
|
|
30
|
-
'easily',
|
|
31
|
-
'quickly',
|
|
32
|
-
'very',
|
|
33
|
-
'really',
|
|
34
|
-
] as const
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* British spellings of the American examples `markdown.md` lists.
|
|
38
|
-
*
|
|
39
|
-
* The set is carried whole rather than derived from a suffix rule, because a
|
|
40
|
-
* suffix pattern run over prose produced 46 of the 58 false positives measured
|
|
41
|
-
* during intake: `exercises`, `promises`, and `revised` all end in `-ise` and
|
|
42
|
-
* none is a British spelling. Matching whole words reaches none of them.
|
|
43
|
-
*
|
|
44
|
-
* `analyse` is absent because the standard's example is `analyze`, which ends
|
|
45
|
-
* in `-yze` rather than the `-ize` its rule states. The set records what the
|
|
46
|
-
* standard reaches rather than what a reader would extend it to, so adding the
|
|
47
|
-
* spelling here would widen the check past the prose it answers to.
|
|
48
|
-
*/
|
|
49
|
-
const SPELLINGS = [
|
|
50
|
-
'organise',
|
|
51
|
-
'summarise',
|
|
52
|
-
'recognise',
|
|
53
|
-
'behaviour',
|
|
54
|
-
'colour',
|
|
55
|
-
'centre',
|
|
56
|
-
] as const
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* The three closed sets the audit measures, owned here rather than harvested
|
|
60
|
-
* from the standards stating them.
|
|
61
|
-
*
|
|
62
|
-
* Parsing the prose was the alternative and it put a parser contract on two
|
|
63
|
-
* documents authored for people, which the prose standard had to carry a
|
|
64
|
-
* paragraph of its own to protect. The sets are a prior an author already
|
|
65
|
-
* knows rather than a filter: measured at `60fc97bf` on 2026-08-19, these 21
|
|
66
|
-
* terms ran across 483 markdown files for a clean exit, and every one of the
|
|
67
|
-
* 70 occurrences of a banned word sat inside the ban list itself or inside an
|
|
68
|
-
* example demonstrating the ban.
|
|
69
|
-
*
|
|
70
|
-
* The set is closed rather than extensible. Enumeration cannot close the gap
|
|
71
|
-
* it aims at, and each addition costs a false-positive class, since `just`,
|
|
72
|
-
* `allows`, and `very` have honest uses no literal match separates. A project
|
|
73
|
-
* wanting its own vocabulary is asking for a different feature than this one.
|
|
16
|
+
* Parsing the prose was the alternative and it put a parser contract on a
|
|
17
|
+
* document authored for people. The set is closed rather than extensible,
|
|
18
|
+
* since a character is the one class a literal match settles. A word is left
|
|
19
|
+
* to `write-human` as guidance and a spelling to cspell, because a word ban
|
|
20
|
+
* catches the token and misses the habit behind it.
|
|
74
21
|
*/
|
|
75
22
|
export const BAN_SETS: BanSets = {
|
|
76
23
|
characters: CHARACTERS,
|
|
77
|
-
words: WORDS,
|
|
78
|
-
spellings: SPELLINGS,
|
|
79
24
|
}
|
|
80
25
|
|
|
81
26
|
/**
|
|
82
|
-
* Names
|
|
27
|
+
* Names the set when it arrived empty, so a run measuring nothing says so.
|
|
83
28
|
*
|
|
84
29
|
* Empty is not the same state as finding no hit. A scan with no terms reports
|
|
85
30
|
* a clean file having looked for nothing, which is the silence the audit
|
|
86
|
-
* refused to ship back when a standard could go missing. The
|
|
87
|
-
* the package now, so the only way
|
|
31
|
+
* refused to ship back when a standard could go missing. The set ships with
|
|
32
|
+
* the package now, so the only way it empties is an edit to this file, and
|
|
88
33
|
* the guard is what keeps that edit loud rather than quiet.
|
|
89
34
|
*/
|
|
90
35
|
export function emptyBanSets(sets: BanSets = BAN_SETS): string[] {
|
|
91
|
-
return (['characters'
|
|
92
|
-
(name) => sets[name].length === 0,
|
|
93
|
-
)
|
|
36
|
+
return (['characters'] as const).filter((name) => sets[name].length === 0)
|
|
94
37
|
}
|
package/src/markdown/gate.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface GateInput {
|
|
|
21
21
|
/**
|
|
22
22
|
* Whether the audit found something that should fail the caller.
|
|
23
23
|
*
|
|
24
|
-
* A banned character
|
|
24
|
+
* A banned character and a relative link resolving to
|
|
25
25
|
* nothing on disk are both facts rather than a threshold, so either gates
|
|
26
26
|
* unconditionally and there is no widened mode to reach for. The standards
|
|
27
27
|
* decide what counts as banned, which keeps this answering how many rather
|
package/src/markdown/scan.ts
CHANGED
|
@@ -55,20 +55,9 @@ export interface BodyLine {
|
|
|
55
55
|
readonly text: string
|
|
56
56
|
/** True on a fence delimiter and on every line between one pair. */
|
|
57
57
|
readonly fenced: boolean
|
|
58
|
-
/**
|
|
59
|
-
* Which fenced block the line sits in, counted from one, and undefined
|
|
60
|
-
* outside one. Two blocks written with nothing between them are one
|
|
61
|
-
* contiguous run of fenced lines, so this is the only field telling them
|
|
62
|
-
* apart. Counting from one keeps the outside answer from reading as a block.
|
|
63
|
-
*
|
|
64
|
-
* The name carries the fence because `block` alone already names a run of
|
|
65
|
-
* `BodyLine` values in `src/markdown/structure.ts`, and one word meaning both
|
|
66
|
-
* an aggregate of lines and an index on each line reads as a bug.
|
|
67
|
-
*/
|
|
68
|
-
readonly fenceBlock: number | undefined
|
|
69
58
|
}
|
|
70
59
|
|
|
71
|
-
export type BanKind = 'character'
|
|
60
|
+
export type BanKind = 'character'
|
|
72
61
|
|
|
73
62
|
export interface BanFinding {
|
|
74
63
|
readonly line: number
|
|
@@ -80,30 +69,20 @@ export interface BanFinding {
|
|
|
80
69
|
|
|
81
70
|
export interface BanSets {
|
|
82
71
|
readonly characters: readonly string[]
|
|
83
|
-
readonly words: readonly string[]
|
|
84
|
-
readonly spellings: readonly string[]
|
|
85
72
|
}
|
|
86
73
|
|
|
87
74
|
/**
|
|
88
|
-
* Marks which lines sit inside a fence without dropping them
|
|
89
|
-
* each of those lines belongs to.
|
|
75
|
+
* Marks which lines sit inside a fence without dropping them.
|
|
90
76
|
*
|
|
91
77
|
* Each measure excludes a fence for its own reason and needs a different
|
|
92
78
|
* response. The depth measure skips a fenced line so an example cannot break
|
|
93
79
|
* the run around it, bullet folding treats one as a break so a bullet does not
|
|
94
80
|
* absorb the block below it, and the ban scan ignores it outright. Returning
|
|
95
81
|
* the mark rather than a filtered list is what lets one walk serve all three.
|
|
96
|
-
*
|
|
97
|
-
* The block index answers the consumer that has to decide per block rather
|
|
98
|
-
* than per contiguous run, since two adjacent blocks are indistinguishable
|
|
99
|
-
* through the mark alone. It is counted here because the walk already holds
|
|
100
|
-
* the opening delimiter, and a consumer parsing its own would be the second
|
|
101
|
-
* fence walker this repository consolidated away.
|
|
102
82
|
*/
|
|
103
83
|
function markFences(texts: readonly string[], offset: number): BodyLine[] {
|
|
104
84
|
const lines: BodyLine[] = []
|
|
105
85
|
let fence: string | undefined
|
|
106
|
-
let fenceBlock = 0
|
|
107
86
|
|
|
108
87
|
for (const [index, text] of texts.entries()) {
|
|
109
88
|
const match = FENCE.exec(text.trim())
|
|
@@ -117,15 +96,9 @@ function markFences(texts: readonly string[], offset: number): BodyLine[] {
|
|
|
117
96
|
} else if (match) {
|
|
118
97
|
fenced = true
|
|
119
98
|
fence = match[1]
|
|
120
|
-
fenceBlock += 1
|
|
121
99
|
}
|
|
122
100
|
|
|
123
|
-
lines.push({
|
|
124
|
-
number: offset + index + 1,
|
|
125
|
-
text,
|
|
126
|
-
fenced,
|
|
127
|
-
fenceBlock: fenced ? fenceBlock : undefined,
|
|
128
|
-
})
|
|
101
|
+
lines.push({ number: offset + index + 1, text, fenced })
|
|
129
102
|
}
|
|
130
103
|
|
|
131
104
|
return lines
|
|
@@ -227,47 +200,12 @@ export function visibleText(text: string): string {
|
|
|
227
200
|
return visible + drop(text.slice(read))
|
|
228
201
|
}
|
|
229
202
|
|
|
230
|
-
function escape(term: string): string {
|
|
231
|
-
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Bounds a banned word on a word character or a hyphen either side.
|
|
236
|
-
*
|
|
237
|
-
* `\b` sits after a hyphen, so a banned word ending a hyphenated compound
|
|
238
|
-
* reports from inside it: `allows` came back out of `auto-allows`. A compound
|
|
239
|
-
* is one word to a reader, and the ban is on the word rather than on a morpheme
|
|
240
|
-
* of it. Stripping hyphens before matching was the alternative and it joins the
|
|
241
|
-
* compound into a token neither half reaches.
|
|
242
|
-
*
|
|
243
|
-
* A spelling ban takes `wordBoundary` instead. This is not that rule with a
|
|
244
|
-
* wider fence, since the two bans target different things.
|
|
245
|
-
*/
|
|
246
|
-
function bannedWord(term: string): RegExp {
|
|
247
|
-
return new RegExp(`(?<![\\w-])${escape(term)}(?![\\w-])`, 'gi')
|
|
248
|
-
}
|
|
249
|
-
|
|
250
203
|
/**
|
|
251
|
-
*
|
|
204
|
+
* Finds every banned character outside a fence, a code span, and a link.
|
|
252
205
|
*
|
|
253
|
-
*
|
|
254
|
-
* A
|
|
255
|
-
*
|
|
256
|
-
* spelling as plainly as `behaviour` does, and rejecting a hyphen here would
|
|
257
|
-
* leave the usual spelling of that phrase unreported.
|
|
258
|
-
*/
|
|
259
|
-
function bannedSpelling(term: string): RegExp {
|
|
260
|
-
return new RegExp(`\\b${escape(term)}\\b`, 'gi')
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Finds every banned term outside a fence, a code span, and a link.
|
|
265
|
-
*
|
|
266
|
-
* A word ban matches in either casing, since the standard states each in
|
|
267
|
-
* lowercase and bans the word rather than a spelling of it. A closed set of
|
|
268
|
-
* whole words separates the check from the pattern that produced most of the
|
|
269
|
-
* intake's false positives: `exercises` and `promises` end in the banned
|
|
270
|
-
* suffix and are not the banned words, and a closed set never reaches them.
|
|
206
|
+
* Characters are the whole set because a character is the one class a literal
|
|
207
|
+
* match settles. A word carries honest uses no match separates, which is why
|
|
208
|
+
* word choice is guidance a reader applies rather than a finding this reports.
|
|
271
209
|
*/
|
|
272
210
|
export function scanBans(
|
|
273
211
|
lines: readonly BodyLine[],
|
|
@@ -275,19 +213,6 @@ export function scanBans(
|
|
|
275
213
|
): BanFinding[] {
|
|
276
214
|
const found: BanFinding[] = []
|
|
277
215
|
|
|
278
|
-
const patterns: { kind: BanKind; term: string; pattern: RegExp }[] = [
|
|
279
|
-
...bans.words.map((term) => ({
|
|
280
|
-
kind: 'word' as const,
|
|
281
|
-
term,
|
|
282
|
-
pattern: bannedWord(term),
|
|
283
|
-
})),
|
|
284
|
-
...bans.spellings.map((term) => ({
|
|
285
|
-
kind: 'spelling' as const,
|
|
286
|
-
term,
|
|
287
|
-
pattern: bannedSpelling(term),
|
|
288
|
-
})),
|
|
289
|
-
]
|
|
290
|
-
|
|
291
216
|
for (const line of lines) {
|
|
292
217
|
if (line.fenced) continue
|
|
293
218
|
const text = maskDisplayed(line.text)
|
|
@@ -299,12 +224,6 @@ export function scanBans(
|
|
|
299
224
|
column = text.indexOf(term, column + term.length)
|
|
300
225
|
}
|
|
301
226
|
}
|
|
302
|
-
|
|
303
|
-
for (const { kind, term, pattern } of patterns) {
|
|
304
|
-
for (const match of text.matchAll(pattern)) {
|
|
305
|
-
found.push({ line: line.number, column: match.index, kind, term })
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
227
|
}
|
|
309
228
|
|
|
310
229
|
return found.sort((a, b) => a.line - b.line || a.column - b.column)
|
|
@@ -21,13 +21,18 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
|
|
|
21
21
|
* reading in sequence with `draft-screencast` as one three-step pipeline with
|
|
22
22
|
* no prefix forcing that reading.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
24
|
+
* Two rows rename a skill rather than retiring a prefix. `ui-test` became
|
|
25
25
|
* `ui-checklist` when the skill stopped writing tests and shrank to the
|
|
26
26
|
* checklist a reviewer reads, so the old name described a job it no longer
|
|
27
27
|
* does. `claude-ui-test` is retargeted onto the same destination rather than
|
|
28
28
|
* left pointing at the retired name, which lands a target on the oldest
|
|
29
29
|
* spelling in one pass instead of two.
|
|
30
30
|
*
|
|
31
|
+
* `docs-fold` became `context-fold` because the skill folds a session into the
|
|
32
|
+
* context entries, the canonical records, and the task board, while "docs" in
|
|
33
|
+
* its name collided with `docs-sync`, which owns `docs/`. `claude-docs` is
|
|
34
|
+
* retargeted onto `context-fold` for the same reason `claude-ui-test` was.
|
|
35
|
+
*
|
|
31
36
|
* Every name takes two words. Ten of these would have landed as a bare single
|
|
32
37
|
* word under a plain strip, and a bare word such as `review` or `docs` is a
|
|
33
38
|
* substring of ordinary prose with no token left for a later sweep to find.
|
|
@@ -47,7 +52,8 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
|
47
52
|
'claude-autoship': 'auto-ship',
|
|
48
53
|
'claude-design-extract': 'design-extract',
|
|
49
54
|
'claude-diagram': 'draft-diagram',
|
|
50
|
-
'claude-docs': '
|
|
55
|
+
'claude-docs': 'context-fold',
|
|
56
|
+
'docs-fold': 'context-fold',
|
|
51
57
|
'claude-feature': 'plan-feature',
|
|
52
58
|
'claude-groundwork': 'plan-groundwork',
|
|
53
59
|
'claude-intake': 'plan-intake',
|
|
@@ -105,6 +111,11 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
|
|
|
105
111
|
* what shipped or what a session ran under whatever name was current then, so
|
|
106
112
|
* rewriting one makes it testify to a release or a run that never happened.
|
|
107
113
|
*
|
|
114
|
+
* The pointer left at `docs-fold` is excluded because it exists to answer under
|
|
115
|
+
* the old name, so a later run would move it onto the skill it points at. The
|
|
116
|
+
* exclusion is only safe once the real body has moved, since excluding the path
|
|
117
|
+
* ahead of the first sweep leaves that body behind.
|
|
118
|
+
*
|
|
108
119
|
* The record archives need no entry here. They are gitignored, so the tracked
|
|
109
120
|
* listing every sweep reads never reaches them.
|
|
110
121
|
*/
|
|
@@ -117,5 +128,7 @@ export const SKILL_NAME_RULES: RenameRules = defineRenameRules({
|
|
|
117
128
|
'CHANGELOG.md',
|
|
118
129
|
'src/migrate/skill-names.ts',
|
|
119
130
|
'src/migrate/skill-names.test.ts',
|
|
131
|
+
'claude/skills/docs-fold/SKILL.md',
|
|
132
|
+
'claude/skills/docs-fold/REQUIREMENT.md',
|
|
120
133
|
],
|
|
121
134
|
})
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
4
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
|
+
import {
|
|
6
|
+
CREATION_ROOT,
|
|
7
|
+
RECORD_ROOTS,
|
|
8
|
+
recordDir,
|
|
9
|
+
SCRATCH,
|
|
10
|
+
spell,
|
|
11
|
+
} from '@/record-root'
|
|
12
|
+
|
|
13
|
+
/** The window after which a reviewed entry is due again, in days. */
|
|
14
|
+
export const DEFAULT_REVIEW_DAYS = 30
|
|
15
|
+
|
|
16
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
17
|
+
|
|
18
|
+
const MEMORY_INDEX = 'index.md'
|
|
19
|
+
|
|
20
|
+
export interface StaleEntry {
|
|
21
|
+
/** The filename stem, which is what a review names an entry by. */
|
|
22
|
+
readonly name: string
|
|
23
|
+
readonly category: string | null
|
|
24
|
+
/** `YYYY-MM-DD`, or null when the entry carries no readable review date. */
|
|
25
|
+
readonly reviewed: string | null
|
|
26
|
+
/** The raw value of a `reviewed` field that is not a date, present only then. */
|
|
27
|
+
readonly invalidReviewed?: string
|
|
28
|
+
readonly due: boolean
|
|
29
|
+
/** Backticked paths the entry cites that the project root does not hold. */
|
|
30
|
+
readonly unresolved: readonly string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StaleReport {
|
|
34
|
+
readonly ok: true
|
|
35
|
+
readonly root: string
|
|
36
|
+
/** The pen read, relative to the root. */
|
|
37
|
+
readonly folder: string
|
|
38
|
+
readonly days: number
|
|
39
|
+
readonly total: number
|
|
40
|
+
readonly due: number
|
|
41
|
+
readonly entries: readonly StaleEntry[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const STALE_REFUSALS = ['no-folder'] as const
|
|
45
|
+
|
|
46
|
+
export type StaleRefusal = (typeof STALE_REFUSALS)[number]
|
|
47
|
+
|
|
48
|
+
export interface StaleRefused {
|
|
49
|
+
readonly ok: false
|
|
50
|
+
readonly reason: StaleRefusal
|
|
51
|
+
readonly message: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type StaleOutcome = StaleReport | StaleRefused
|
|
55
|
+
|
|
56
|
+
const REVIEWED_FIELD = /^reviewed:[ \t]*(.*?)[ \t]*$/m
|
|
57
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
58
|
+
const RAW_FRONTMATTER = /^---\n([\s\S]*?)\n---/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reads `reviewed` off the raw block rather than the parsed fields, for the
|
|
62
|
+
* reason `hasOpeningDate` in the validator gives: a YAML parser resolves a
|
|
63
|
+
* bare `YYYY-MM-DD` to a date on one schema and a string on another.
|
|
64
|
+
*
|
|
65
|
+
* A value that is not a calendar date reads as never reviewed and is carried
|
|
66
|
+
* back so the review can see it, rather than throwing over one hand edit.
|
|
67
|
+
*/
|
|
68
|
+
function readReviewed(
|
|
69
|
+
text: string,
|
|
70
|
+
): Pick<StaleEntry, 'reviewed' | 'invalidReviewed'> {
|
|
71
|
+
const block = RAW_FRONTMATTER.exec(text)?.[1]
|
|
72
|
+
const match = block ? REVIEWED_FIELD.exec(block) : null
|
|
73
|
+
if (!match) return { reviewed: null }
|
|
74
|
+
|
|
75
|
+
const value = match[1].replace(/^(['"])(.*)\1$/, '$2')
|
|
76
|
+
const parsed = Date.parse(`${value}T00:00:00Z`)
|
|
77
|
+
const isDate =
|
|
78
|
+
ISO_DATE.test(value) &&
|
|
79
|
+
Number.isFinite(parsed) &&
|
|
80
|
+
new Date(parsed).toISOString().slice(0, 10) === value
|
|
81
|
+
|
|
82
|
+
return isDate
|
|
83
|
+
? { reviewed: value }
|
|
84
|
+
: { reviewed: null, invalidReviewed: value }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Tokens that name a session record, scratch at either root, or a checkout,
|
|
89
|
+
* none of which a tree commits. The scratch spellings come from `spell` so the
|
|
90
|
+
* legacy root's variant is stated in one place.
|
|
91
|
+
*/
|
|
92
|
+
const UNTRACKED_PREFIXES = [
|
|
93
|
+
...RECORD_ROOTS.map((at) => `${at}/${spell(at, SCRATCH)}/`),
|
|
94
|
+
`${CREATION_ROOT}/`,
|
|
95
|
+
'.claude/worktrees/',
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
const INLINE_CODE = /`([^`\n]+)`/g
|
|
99
|
+
const LINE_ANCHOR = /:\d+(?:[-:]\d+)*$/
|
|
100
|
+
const FILE_EXTENSION = /\.[A-Za-z0-9]+$/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reduces one backticked token to the repository path it cites, or undefined
|
|
104
|
+
* when it cites none.
|
|
105
|
+
*
|
|
106
|
+
* A path is a token carrying a `/` and ending in a file extension once any
|
|
107
|
+
* line or heading anchor is stripped. A placeholder, an elided path, a glob, a
|
|
108
|
+
* variable, a URL, and a path outside the project all look like paths and
|
|
109
|
+
* resolve against nothing in the tree, so each is dropped rather than reported.
|
|
110
|
+
*/
|
|
111
|
+
function toCitedPath(token: string): string | undefined {
|
|
112
|
+
if (/\s/.test(token) || token.includes('://')) return undefined
|
|
113
|
+
if (/[<*{$]/.test(token) || token.includes('...')) return undefined
|
|
114
|
+
if (/^(?:\/|~|\.\.\/)/.test(token)) return undefined
|
|
115
|
+
|
|
116
|
+
const path = token
|
|
117
|
+
.replace(/#.*$/, '')
|
|
118
|
+
.replace(LINE_ANCHOR, '')
|
|
119
|
+
.replace(/^\.\//, '')
|
|
120
|
+
|
|
121
|
+
if (!path.includes('/') || !FILE_EXTENSION.test(path)) return undefined
|
|
122
|
+
if (UNTRACKED_PREFIXES.some((prefix) => path.startsWith(prefix)))
|
|
123
|
+
return undefined
|
|
124
|
+
|
|
125
|
+
return path
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Every distinct repository path a body cites in inline code, in order. */
|
|
129
|
+
export function citedPaths(body: string): string[] {
|
|
130
|
+
const paths = new Set<string>()
|
|
131
|
+
|
|
132
|
+
for (const match of body.matchAll(INLINE_CODE)) {
|
|
133
|
+
const path = toCitedPath(match[1])
|
|
134
|
+
if (path) paths.add(path)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return [...paths]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isDue(reviewed: string | null, days: number, now: number): boolean {
|
|
141
|
+
if (reviewed === null) return true
|
|
142
|
+
return now - Date.parse(`${reviewed}T00:00:00`) > days * DAY_MS
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readEntry(
|
|
146
|
+
root: string,
|
|
147
|
+
dir: string,
|
|
148
|
+
file: string,
|
|
149
|
+
days: number,
|
|
150
|
+
now: number,
|
|
151
|
+
): Promise<StaleEntry> {
|
|
152
|
+
const text = (await readFile(join(dir, file), 'utf8')).replaceAll(
|
|
153
|
+
'\r\n',
|
|
154
|
+
'\n',
|
|
155
|
+
)
|
|
156
|
+
const frontmatter = parseFrontmatter(text)
|
|
157
|
+
const review = readReviewed(text)
|
|
158
|
+
const body = text.slice(RAW_FRONTMATTER.exec(text)?.[0].length ?? 0)
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
name: file.replace(/\.md$/, ''),
|
|
162
|
+
category: readField(frontmatter, 'category') ?? null,
|
|
163
|
+
...review,
|
|
164
|
+
due: isDue(review.reviewed, days, now),
|
|
165
|
+
unresolved: citedPaths(body).filter(
|
|
166
|
+
(path) => !existsSync(join(root, path)),
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The order a review takes a batch in: due entries first, and among them the
|
|
173
|
+
* ones citing a tree that moved, then the longest unreviewed, then by name so
|
|
174
|
+
* two runs over one pen agree.
|
|
175
|
+
*/
|
|
176
|
+
function compareEntries(left: StaleEntry, right: StaleEntry): number {
|
|
177
|
+
if (left.due !== right.due) return left.due ? -1 : 1
|
|
178
|
+
|
|
179
|
+
const leftMoved = left.unresolved.length > 0
|
|
180
|
+
const rightMoved = right.unresolved.length > 0
|
|
181
|
+
if (leftMoved !== rightMoved) return leftMoved ? -1 : 1
|
|
182
|
+
|
|
183
|
+
if (left.reviewed !== right.reviewed) {
|
|
184
|
+
if (left.reviewed === null) return -1
|
|
185
|
+
if (right.reviewed === null) return 1
|
|
186
|
+
return left.reviewed < right.reviewed ? -1 : 1
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return left.name.localeCompare(right.name)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Reports each memory entry's review state and the paths it cites that the
|
|
194
|
+
* project no longer holds, ordered so a caller takes the first N as a batch.
|
|
195
|
+
*
|
|
196
|
+
* It reads the top level of the pen alone. `review/` holds receipts and
|
|
197
|
+
* `archive/` holds retirements, and neither is an entry a review would queue.
|
|
198
|
+
* Paths resolve against the project root rather than the entry's folder, since
|
|
199
|
+
* an entry cites the tree it was written about. It reports and never writes.
|
|
200
|
+
*
|
|
201
|
+
* `now` is a parameter so a test can pin the window against fixture dates.
|
|
202
|
+
*/
|
|
203
|
+
export async function staleMemory(
|
|
204
|
+
root: string,
|
|
205
|
+
days: number = DEFAULT_REVIEW_DAYS,
|
|
206
|
+
now: number = Date.now(),
|
|
207
|
+
): Promise<StaleOutcome> {
|
|
208
|
+
const dir = recordDir(root, 'memory')
|
|
209
|
+
|
|
210
|
+
if (!existsSync(dir)) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
reason: 'no-folder',
|
|
214
|
+
message: `No memory folder at ${dir}, so there are no entries to read.`,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const listed = await readdir(dir, { withFileTypes: true })
|
|
219
|
+
const files = listed
|
|
220
|
+
.filter(
|
|
221
|
+
(entry) =>
|
|
222
|
+
entry.isFile() &&
|
|
223
|
+
entry.name.endsWith('.md') &&
|
|
224
|
+
entry.name !== MEMORY_INDEX,
|
|
225
|
+
)
|
|
226
|
+
.map((entry) => entry.name)
|
|
227
|
+
|
|
228
|
+
const entries = (
|
|
229
|
+
await Promise.all(
|
|
230
|
+
files.map((file) => readEntry(root, dir, file, days, now)),
|
|
231
|
+
)
|
|
232
|
+
).toSorted(compareEntries)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
root,
|
|
237
|
+
folder: relative(root, dir),
|
|
238
|
+
days,
|
|
239
|
+
total: entries.length,
|
|
240
|
+
due: entries.filter((entry) => entry.due).length,
|
|
241
|
+
entries,
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/sandbox/expect.ts
CHANGED
|
@@ -178,7 +178,7 @@ function contentArray(value: unknown): ContentAssertion[] {
|
|
|
178
178
|
// A bare key written below a `[[content]]` header belongs to that table in
|
|
179
179
|
// TOML, not to the document, so a declaration listing `manual` or
|
|
180
180
|
// `max_turns` after its content blocks parses clean and silently asserts
|
|
181
|
-
// neither. The arm then named `claude/ui-test
|
|
181
|
+
// neither. The arm then named `claude/ui-test` (canon-keep-retired), now
|
|
182
182
|
// `claude/ui-checklist`, shipped that way: a turn ceiling that
|
|
183
183
|
// never ran and five manual entries that never reached the unchecked count,
|
|
184
184
|
// while `canon sandbox coverage` counted the arm as armed. Nothing at the
|
package/src/tasks/archive.ts
CHANGED
|
@@ -269,6 +269,30 @@ export function readPlanTarget(text: string): string | undefined {
|
|
|
269
269
|
return match[1] ?? match[2]
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Reads every target the first `Plan:` line outside a fenced sample carries,
|
|
274
|
+
* falling back to the bare-path form. `readPlanTarget` answers only a line
|
|
275
|
+
* holding exactly one pointer, so this is how a caller tells a line linking
|
|
276
|
+
* several plans apart from no line at all.
|
|
277
|
+
*/
|
|
278
|
+
export function readPlanTargets(text: string): readonly string[] {
|
|
279
|
+
const lines = text.split('\n')
|
|
280
|
+
const fenced = fenceMask(lines)
|
|
281
|
+
const line = lines.find(
|
|
282
|
+
(candidate, index) => !fenced[index] && candidate.startsWith('Plan:'),
|
|
283
|
+
)
|
|
284
|
+
if (line === undefined) return []
|
|
285
|
+
|
|
286
|
+
const rest = line.slice('Plan:'.length)
|
|
287
|
+
const links = [...rest.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)].map(
|
|
288
|
+
(match) => match[1],
|
|
289
|
+
)
|
|
290
|
+
if (links.length > 0) return links
|
|
291
|
+
|
|
292
|
+
const bare = /^[ \t]*(\S+)/.exec(rest)
|
|
293
|
+
return bare ? [bare[1]] : []
|
|
294
|
+
}
|
|
295
|
+
|
|
272
296
|
/**
|
|
273
297
|
* Builds a `Plan:` line as a markdown link whose text and target stay in step,
|
|
274
298
|
* the label taken from the target's filename with its extension dropped.
|
|
@@ -431,7 +455,7 @@ function isBulletFor(line: string, target: string): boolean {
|
|
|
431
455
|
|
|
432
456
|
/**
|
|
433
457
|
* Resolves the `Plan:` target against the board and against the project root
|
|
434
|
-
* both, which is how `
|
|
458
|
+
* both, which is how `context-fold` reads the same line. It accepts `../plans/x.md`
|
|
435
459
|
* and `.canon/plans/x.md` as one file, so a gate reading only the first form
|
|
436
460
|
* would pass the second and strand the plan this exists to protect.
|
|
437
461
|
*
|
|
@@ -485,7 +509,7 @@ function atOneRoot(path: string, plans: string[], root: string): string {
|
|
|
485
509
|
|
|
486
510
|
/**
|
|
487
511
|
* Names the other live tasks whose `Plan:` line lands on the same file. This is
|
|
488
|
-
* the rule `
|
|
512
|
+
* the rule `context-fold` applies before it archives a plan, held here so one
|
|
489
513
|
* question has one implementation: a plan another live task still cites is a
|
|
490
514
|
* plan the sweep is correct to leave, and a guard that read the folder instead
|
|
491
515
|
* refused every task sharing one plan and deadlocked the board against the
|
|
@@ -542,7 +566,7 @@ export type CitationOutcome = PlanCitations | ArchiveRefused
|
|
|
542
566
|
|
|
543
567
|
/**
|
|
544
568
|
* Answers where one task's plan sits and who else holds it, which is the whole
|
|
545
|
-
* of the last-live-citation rule. `
|
|
569
|
+
* of the last-live-citation rule. `context-fold` reads this rather than scanning
|
|
546
570
|
* the board itself, so the sweep that moves a plan and the gate that refuses a
|
|
547
571
|
* task archive cannot drift into disagreeing about which plan is free.
|
|
548
572
|
*
|
|
@@ -758,7 +782,7 @@ export async function archiveTask(
|
|
|
758
782
|
const from = join(dir, `${stem}.md`)
|
|
759
783
|
const text = await readFile(from, 'utf8')
|
|
760
784
|
|
|
761
|
-
// `
|
|
785
|
+
// `context-fold` ticks outcomes at ship time, before the merge, so an earlier
|
|
762
786
|
// slice merging after the last slice shipped would find every box ticked
|
|
763
787
|
// while the last slice is still open. Only the last number closes the task.
|
|
764
788
|
const last = readPullRequest(text).at(-1)
|