@erclx/aitk 0.100.0 → 0.102.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/claude-orchestrate/SKILL.md +11 -4
- package/claude/skills/claude-orchestrate/references/orchestrator-poll.md +4 -4
- package/claude/skills/claude-orchestrate/references/orchestrator-sweep.md +1 -1
- package/claude/skills/claude-orchestrate/scripts/poll.sh +11 -10
- package/claude/skills/claude-pr-review/SKILL.md +16 -16
- package/claude/skills/claude-tasks/SKILL.md +10 -1
- package/claude/skills/claude-teach/REQUIREMENT.md +2 -1
- package/claude/skills/claude-teach/SKILL.md +40 -9
- package/claude/skills/session-resume/SKILL.md +1 -1
- package/docs/agents/commands.md +5 -0
- package/docs/agents/index.md +2 -1
- package/docs/agents/markdown-audit.md +21 -11
- package/docs/agents/tasks.md +8 -4
- package/docs/agents/teach.md +119 -0
- package/docs/ai-workflow.md +1 -1
- package/docs/operating-model.md +9 -8
- package/docs/target-projects.md +1 -1
- package/package.json +1 -1
- package/scripts/core/verify.sh +3 -0
- package/src/cli.ts +4 -0
- package/src/commands/markdown.ts +44 -28
- package/src/commands/tasks.ts +6 -3
- package/src/commands/teach.ts +650 -0
- package/src/markdown/bans.ts +71 -210
- package/src/markdown/structure.ts +9 -71
- package/src/records/validate.ts +8 -7
- package/src/tasks/archive.ts +9 -4
- package/src/tasks/validate.ts +103 -3
- package/src/teach/workspace.ts +797 -0
- package/standards/prose.md +0 -2
- package/standards/tasks.md +51 -8
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +43 -47
package/src/markdown/bans.ts
CHANGED
|
@@ -1,233 +1,94 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { linesOutsideFences } from '@/markdown/scan'
|
|
3
|
-
import { resolveStandard } from '@/standards/read'
|
|
1
|
+
import type { BanSets } from '@/markdown/scan'
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
|
-
*
|
|
4
|
+
* Single characters `markdown.md` bans under `## Punctuation`.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* position-based read would narrow the check and still print a count.
|
|
6
|
+
* The parenthetical-aside ban in that section is absent because it quotes a
|
|
7
|
+
* whole clause, and a literal match built from a clause reports the compliant
|
|
8
|
+
* text and reaches none of the violations.
|
|
12
9
|
*/
|
|
13
|
-
|
|
14
|
-
export const CHARACTER_BAN_HEADING = '## Punctuation'
|
|
15
|
-
|
|
16
|
-
/** Bullet lead-ins the two closed sets are stated behind. */
|
|
17
|
-
const BAN_LEAD = '- Do not use '
|
|
18
|
-
const SPELLING_LEAD = '- Use American English spelling'
|
|
19
|
-
|
|
20
|
-
const BACKTICKED = /`([^`]+)`/g
|
|
21
|
-
const WORD = /^[a-z]+$/
|
|
22
|
-
const SUFFIX = /^-[a-z]+$/
|
|
23
|
-
|
|
24
|
-
export interface BanReport {
|
|
25
|
-
/** Single characters the mechanics standard bans outright. */
|
|
26
|
-
readonly characters: readonly string[]
|
|
27
|
-
/** Single lowercase words the prose standard bans outright. */
|
|
28
|
-
readonly words: readonly string[]
|
|
29
|
-
/** Spellings derived from the prose standard's own examples. */
|
|
30
|
-
readonly spellings: readonly string[]
|
|
31
|
-
/**
|
|
32
|
-
* Paths of the standards read, in the order read. Repo-relative for a project
|
|
33
|
-
* copy, and spelled under `<aitk>/` for the package copy, so a reader can
|
|
34
|
-
* tell which root answered without joining a path that resolves nowhere.
|
|
35
|
-
*/
|
|
36
|
-
readonly sources: readonly string[]
|
|
37
|
-
/**
|
|
38
|
-
* Standards that resolved under none of the roots. Absent is a distinct state
|
|
39
|
-
* from empty: a scan with no terms finds nothing, and reporting that as a
|
|
40
|
-
* clean file claims the prose passed when nothing was looked for.
|
|
41
|
-
*/
|
|
42
|
-
readonly missing: readonly string[]
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function section(markdown: string, heading: string): string[] {
|
|
46
|
-
const lines = linesOutsideFences(markdown)
|
|
47
|
-
const start = lines.findIndex((line) => line.trim() === heading)
|
|
48
|
-
if (start === -1) return []
|
|
49
|
-
|
|
50
|
-
const body = lines.slice(start + 1)
|
|
51
|
-
const end = body.findIndex((line) => line.startsWith('## '))
|
|
52
|
-
|
|
53
|
-
return end === -1 ? body : body.slice(0, end)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function backticked(line: string): string[] {
|
|
57
|
-
return [...line.matchAll(BACKTICKED)].map((match) => match[1].trim())
|
|
58
|
-
}
|
|
10
|
+
const CHARACTERS = ['—', ';'] as const
|
|
59
11
|
|
|
60
12
|
/**
|
|
61
|
-
*
|
|
13
|
+
* Single lowercase words `prose.md` bans under `## Language`.
|
|
62
14
|
*
|
|
63
|
-
* A multi-word
|
|
64
|
-
* a pattern like `It's not X, it's Y` with a placeholder standing
|
|
65
|
-
* rest of the sentence,
|
|
66
|
-
* stay a reader's judgment
|
|
15
|
+
* A multi-word ban is absent by the same test the character set applies. The
|
|
16
|
+
* standard bans a pattern like `It's not X, it's Y` with a placeholder standing
|
|
17
|
+
* in for the rest of the sentence, so no literal match reaches it and the
|
|
18
|
+
* phrase bans stay a reader's judgment.
|
|
67
19
|
*/
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
80
35
|
|
|
81
36
|
/**
|
|
82
|
-
*
|
|
37
|
+
* British spellings of the American examples `prose.md` lists.
|
|
83
38
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
export function parseCharacterBans(markdown: string): string[] {
|
|
89
|
-
const terms: string[] = []
|
|
90
|
-
|
|
91
|
-
for (const line of section(markdown, CHARACTER_BAN_HEADING)) {
|
|
92
|
-
if (!line.startsWith(BAN_LEAD)) continue
|
|
93
|
-
for (const term of backticked(line)) {
|
|
94
|
-
if (
|
|
95
|
-
term.length === 1 &&
|
|
96
|
-
!/[a-z0-9]/i.test(term) &&
|
|
97
|
-
!terms.includes(term)
|
|
98
|
-
) {
|
|
99
|
-
terms.push(term)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return terms
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Derives the banned spellings by applying the standard's own suffix rules to
|
|
109
|
-
* the standard's own examples.
|
|
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.
|
|
110
43
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* listed American words into their British forms yields a closed set that
|
|
116
|
-
* matches whole words and reaches none of them, and an example added to the
|
|
117
|
-
* standard extends the check without a code edit.
|
|
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.
|
|
118
48
|
*/
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const examples = terms.filter((term) => WORD.test(term))
|
|
128
|
-
|
|
129
|
-
// Stated as `preferred` over `banned`, so the run of suffix terms pairs off
|
|
130
|
-
// in order. An odd count means the sentence was rewritten into a shape this
|
|
131
|
-
// cannot read, and pairing what is there would invent a rule from half of it.
|
|
132
|
-
if (suffixes.length === 0 || suffixes.length % 2 !== 0) return []
|
|
133
|
-
|
|
134
|
-
const pairs: { preferred: string; banned: string }[] = []
|
|
135
|
-
for (let index = 0; index < suffixes.length; index += 2) {
|
|
136
|
-
pairs.push({
|
|
137
|
-
preferred: suffixes[index].slice(1),
|
|
138
|
-
banned: suffixes[index + 1].slice(1),
|
|
139
|
-
})
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const spellings: string[] = []
|
|
143
|
-
for (const example of examples) {
|
|
144
|
-
const pair = pairs.find((each) => example.endsWith(each.preferred))
|
|
145
|
-
if (!pair) continue
|
|
146
|
-
|
|
147
|
-
const banned = `${example.slice(0, -pair.preferred.length)}${pair.banned}`
|
|
148
|
-
if (!spellings.includes(banned)) spellings.push(banned)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return spellings
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export interface StandardText {
|
|
155
|
-
/** Path of the copy read, so a report says which root won. */
|
|
156
|
-
readonly source: string
|
|
157
|
-
readonly text: string
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export interface Standards {
|
|
161
|
-
readonly markdown: StandardText | undefined
|
|
162
|
-
readonly prose: StandardText | undefined
|
|
163
|
-
/**
|
|
164
|
-
* Standards that resolved under none of the roots. Absent is a distinct state
|
|
165
|
-
* from empty: a scan with no terms finds nothing, and reporting that as a
|
|
166
|
-
* clean file claims the prose passed when nothing was looked for.
|
|
167
|
-
*/
|
|
168
|
-
readonly missing: readonly string[]
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
async function loadStandard(
|
|
172
|
-
root: string,
|
|
173
|
-
name: string,
|
|
174
|
-
): Promise<StandardText | undefined> {
|
|
175
|
-
const resolved = resolveStandard(root, name)
|
|
176
|
-
if (!resolved) return undefined
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
source: resolved.source,
|
|
180
|
-
text: await readFile(resolved.path, 'utf8'),
|
|
181
|
-
}
|
|
182
|
-
}
|
|
49
|
+
const SPELLINGS = [
|
|
50
|
+
'organise',
|
|
51
|
+
'summarise',
|
|
52
|
+
'recognise',
|
|
53
|
+
'behaviour',
|
|
54
|
+
'colour',
|
|
55
|
+
'centre',
|
|
56
|
+
] as const
|
|
183
57
|
|
|
184
58
|
/**
|
|
185
|
-
*
|
|
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.
|
|
186
69
|
*
|
|
187
|
-
* The
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* this one
|
|
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.
|
|
191
74
|
*/
|
|
192
|
-
export
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
])
|
|
197
|
-
|
|
198
|
-
return {
|
|
199
|
-
markdown,
|
|
200
|
-
prose,
|
|
201
|
-
missing: [
|
|
202
|
-
markdown ? undefined : 'markdown.md',
|
|
203
|
-
prose ? undefined : 'prose.md',
|
|
204
|
-
].filter((name): name is string => name !== undefined),
|
|
205
|
-
}
|
|
75
|
+
export const BAN_SETS: BanSets = {
|
|
76
|
+
characters: CHARACTERS,
|
|
77
|
+
words: WORDS,
|
|
78
|
+
spellings: SPELLINGS,
|
|
206
79
|
}
|
|
207
80
|
|
|
208
81
|
/**
|
|
209
|
-
*
|
|
82
|
+
* Names any set that arrived empty, so a run measuring nothing says so.
|
|
210
83
|
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
84
|
+
* Empty is not the same state as finding no hit. A scan with no terms reports
|
|
85
|
+
* 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 sets ship with
|
|
87
|
+
* the package now, so the only way one empties is an edit to this file, and
|
|
88
|
+
* the guard is what keeps that edit loud rather than quiet.
|
|
216
89
|
*/
|
|
217
|
-
export function
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
characters: markdown ? parseCharacterBans(markdown.text) : [],
|
|
222
|
-
words: prose ? parseWordBans(prose.text) : [],
|
|
223
|
-
spellings: prose ? parseSpellingBans(prose.text) : [],
|
|
224
|
-
sources: [markdown?.source, prose?.source].filter(
|
|
225
|
-
(source): source is string => source !== undefined,
|
|
226
|
-
),
|
|
227
|
-
missing: standards.missing,
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export async function loadBans(root: string): Promise<BanReport> {
|
|
232
|
-
return banReport(await loadStandards(root))
|
|
90
|
+
export function emptyBanSets(sets: BanSets = BAN_SETS): string[] {
|
|
91
|
+
return (['characters', 'words', 'spellings'] as const).filter(
|
|
92
|
+
(name) => sets[name].length === 0,
|
|
93
|
+
)
|
|
233
94
|
}
|
|
@@ -24,30 +24,17 @@ const BLOCKQUOTE = /^\s*>/
|
|
|
24
24
|
*/
|
|
25
25
|
const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+(?:["'“(\[]*[A-Z]|`)|\s*$)/g
|
|
26
26
|
|
|
27
|
-
const NUMBER_WORDS: Record<string, number> = {
|
|
28
|
-
one: 1,
|
|
29
|
-
two: 2,
|
|
30
|
-
three: 3,
|
|
31
|
-
four: 4,
|
|
32
|
-
five: 5,
|
|
33
|
-
six: 6,
|
|
34
|
-
seven: 7,
|
|
35
|
-
eight: 8,
|
|
36
|
-
nine: 9,
|
|
37
|
-
ten: 10,
|
|
38
|
-
}
|
|
39
|
-
|
|
40
27
|
/**
|
|
41
|
-
* Checkpoints
|
|
28
|
+
* Checkpoints the structural measures run against.
|
|
42
29
|
*
|
|
43
|
-
* These are the
|
|
44
|
-
*
|
|
45
|
-
* a
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
30
|
+
* These are the definition rather than a fallback. Reading each number out of
|
|
31
|
+
* the sentence `standards/markdown.md` states it in was the alternative, and it
|
|
32
|
+
* put a parser contract on a document authored for people, where a rewording
|
|
33
|
+
* degraded a number and the report had to carry a legend saying which one. The
|
|
34
|
+
* standard still states every number for a reader, and moving one is an edit to
|
|
35
|
+
* both.
|
|
49
36
|
*/
|
|
50
|
-
export const
|
|
37
|
+
export const CHECKPOINTS = {
|
|
51
38
|
run: 40,
|
|
52
39
|
peerBullet: 130,
|
|
53
40
|
bullet: 400,
|
|
@@ -56,15 +43,13 @@ export const DEFAULT_CHECKPOINTS = {
|
|
|
56
43
|
renderWidth: 80,
|
|
57
44
|
} as const
|
|
58
45
|
|
|
59
|
-
export type CheckpointName = keyof typeof DEFAULT_CHECKPOINTS
|
|
60
|
-
|
|
61
46
|
/**
|
|
62
47
|
* Columns a source line wraps at when rendered.
|
|
63
48
|
*
|
|
64
49
|
* Nothing in this repository sets a line width and entries are authored one
|
|
65
50
|
* line per bullet, so the rendered width is the viewer's rather than the file's.
|
|
66
51
|
*/
|
|
67
|
-
export const RENDER_WIDTH: number =
|
|
52
|
+
export const RENDER_WIDTH: number = CHECKPOINTS.renderWidth
|
|
68
53
|
|
|
69
54
|
export interface Checkpoints {
|
|
70
55
|
readonly run: number
|
|
@@ -73,8 +58,6 @@ export interface Checkpoints {
|
|
|
73
58
|
readonly paragraph: number
|
|
74
59
|
readonly sentences: number
|
|
75
60
|
readonly renderWidth: number
|
|
76
|
-
/** Names that fell back, so the report can say which number is not the standard's. */
|
|
77
|
-
readonly fellBack: readonly CheckpointName[]
|
|
78
61
|
}
|
|
79
62
|
|
|
80
63
|
export interface BulletFinding {
|
|
@@ -99,51 +82,6 @@ export interface StructureReport {
|
|
|
99
82
|
readonly heavyParagraphs: readonly ParagraphFinding[]
|
|
100
83
|
}
|
|
101
84
|
|
|
102
|
-
const PATTERNS: Record<CheckpointName, RegExp> = {
|
|
103
|
-
run: /Past roughly (\d+) rendered lines/,
|
|
104
|
-
peerBullet: /averaging under roughly (\d+) characters/,
|
|
105
|
-
bullet: /Past roughly (\d+) characters in one top-level bullet/,
|
|
106
|
-
paragraph: /Past roughly (\d+) characters in one paragraph/,
|
|
107
|
-
sentences: /Keep paragraphs to ([a-z]+|\d+) sentences or fewer/,
|
|
108
|
-
renderWidth: /wrapping each source line at (\d+) columns/,
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Reads each checkpoint out of the standard stating it.
|
|
113
|
-
*
|
|
114
|
-
* A number held in code is a second place the rule lives, and the two drift
|
|
115
|
-
* silently because nothing compares them. What this buys is that raising a
|
|
116
|
-
* checkpoint is an edit to the sentence a reader is pointed at, and what it
|
|
117
|
-
* costs is a reader of prose, which is bounded by falling back per checkpoint
|
|
118
|
-
* rather than per file.
|
|
119
|
-
*/
|
|
120
|
-
export function parseCheckpoints(markdown: string): Checkpoints {
|
|
121
|
-
const fellBack: CheckpointName[] = []
|
|
122
|
-
|
|
123
|
-
const read = (name: CheckpointName): number => {
|
|
124
|
-
const match = markdown.match(PATTERNS[name])
|
|
125
|
-
const raw = match?.[1]
|
|
126
|
-
const value = raw ? (NUMBER_WORDS[raw] ?? Number(raw)) : Number.NaN
|
|
127
|
-
|
|
128
|
-
if (!Number.isFinite(value) || value <= 0) {
|
|
129
|
-
fellBack.push(name)
|
|
130
|
-
return DEFAULT_CHECKPOINTS[name]
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return value
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
run: read('run'),
|
|
138
|
-
peerBullet: read('peerBullet'),
|
|
139
|
-
bullet: read('bullet'),
|
|
140
|
-
paragraph: read('paragraph'),
|
|
141
|
-
sentences: read('sentences'),
|
|
142
|
-
renderWidth: read('renderWidth'),
|
|
143
|
-
fellBack,
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
85
|
/**
|
|
148
86
|
* Height a source line occupies once wrapped.
|
|
149
87
|
*
|
package/src/records/validate.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { readdir, readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
5
|
import { linesOutsideFences } from '@/markdown/scan'
|
|
6
|
+
import {
|
|
7
|
+
TEACH_GLOSSARY,
|
|
8
|
+
TEACH_MISSION,
|
|
9
|
+
TEACH_RECORDS,
|
|
10
|
+
TEACH_REFERENCE,
|
|
11
|
+
TEACH_RESOURCES,
|
|
12
|
+
WORKSPACE_NAME,
|
|
13
|
+
} from '@/teach/workspace'
|
|
6
14
|
|
|
7
15
|
export const RECORD_KINDS = [
|
|
8
16
|
'plans',
|
|
@@ -568,14 +576,7 @@ async function checkDump(dir: string, slug: string): Promise<Finding[]> {
|
|
|
568
576
|
return [...findings, ...perCluster.flat()]
|
|
569
577
|
}
|
|
570
578
|
|
|
571
|
-
const TEACH_MISSION = 'MISSION.md'
|
|
572
|
-
const TEACH_RESOURCES = 'RESOURCES.md'
|
|
573
|
-
const TEACH_GLOSSARY = 'GLOSSARY.md'
|
|
574
|
-
const TEACH_REFERENCE = 'reference'
|
|
575
|
-
const TEACH_RECORDS = 'learning-records'
|
|
576
|
-
|
|
577
579
|
const TEACH_SUCCESS = /^##[ \t]+Success looks like[ \t]*$/
|
|
578
|
-
const WORKSPACE_NAME = /^\d{2}-[a-z0-9]+(-[a-z0-9]+)*$/
|
|
579
580
|
const NUMBERED_RECORD = /^\d{4}-[a-z0-9]+(-[a-z0-9]+)*\.md$/
|
|
580
581
|
/**
|
|
581
582
|
* A kebab slug that does not open with an ordinal. The lookahead rejects a
|
package/src/tasks/archive.ts
CHANGED
|
@@ -9,11 +9,16 @@ const PLANS_DIR = join('.claude', 'plans')
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Siblings that sit on the board without being tasks: the generated index, the
|
|
12
|
-
* hand-maintained ordering,
|
|
13
|
-
* one name per session. `validate` reads the
|
|
14
|
-
* count a sibling as a task the other does not.
|
|
12
|
+
* hand-maintained ordering, the unordered backlog beside it, and a handoff a
|
|
13
|
+
* session wrote before the file took one name per session. `validate` reads the
|
|
14
|
+
* same list, so neither verb can count a sibling as a task the other does not.
|
|
15
15
|
*/
|
|
16
|
-
export const RESERVED_STEMS = [
|
|
16
|
+
export const RESERVED_STEMS = [
|
|
17
|
+
'index',
|
|
18
|
+
'priority',
|
|
19
|
+
'backlog',
|
|
20
|
+
'session',
|
|
21
|
+
] as const
|
|
17
22
|
|
|
18
23
|
/** The pre-compaction handoff takes one file per session, so its stems vary. */
|
|
19
24
|
const SESSION_MAP_PREFIX = 'session-'
|
package/src/tasks/validate.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from '@/tasks/archive'
|
|
10
10
|
|
|
11
11
|
const ORDERING_FILE = 'priority.md'
|
|
12
|
+
const BACKLOG_FILE = 'backlog.md'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The readiness headings `.claude/standards/tasks.md` fixes. The names are the
|
|
@@ -60,6 +61,16 @@ export interface Untested {
|
|
|
60
61
|
readonly message: string
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A backlog line, which carries a pointer and nothing else. The backlog is
|
|
66
|
+
* explicitly unordered, so a line has no position to read and no columns to
|
|
67
|
+
* resolve.
|
|
68
|
+
*/
|
|
69
|
+
export interface BacklogRow {
|
|
70
|
+
readonly label: string
|
|
71
|
+
readonly stem: string | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
export interface BoardRow {
|
|
64
75
|
readonly group: BoardGroup
|
|
65
76
|
readonly label: string
|
|
@@ -74,6 +85,7 @@ export interface BoardRow {
|
|
|
74
85
|
export interface ValidateReport {
|
|
75
86
|
readonly ok: true
|
|
76
87
|
readonly rows: number
|
|
88
|
+
readonly backlog: number
|
|
77
89
|
readonly tasks: number
|
|
78
90
|
readonly findings: readonly Finding[]
|
|
79
91
|
readonly untested: readonly Untested[]
|
|
@@ -91,6 +103,10 @@ export function orderingPath(root: string): string {
|
|
|
91
103
|
return join(tasksDir(root), ORDERING_FILE)
|
|
92
104
|
}
|
|
93
105
|
|
|
106
|
+
export function backlogPath(root: string): string {
|
|
107
|
+
return join(tasksDir(root), BACKLOG_FILE)
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
/**
|
|
95
111
|
* Pulls the target out of a markdown link, which is how both the `Task` and the
|
|
96
112
|
* `Plan` column spell their pointer. A cell carrying prose instead of a link
|
|
@@ -225,6 +241,39 @@ export function readBoard(text: string): {
|
|
|
225
241
|
return { rows, groups }
|
|
226
242
|
}
|
|
227
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Parses the backlog into one row per bullet carrying a link. The backlog is a
|
|
246
|
+
* flat list rather than a table, so there is no header to resolve and no group
|
|
247
|
+
* to sit under, and a bullet holding prose instead of a pointer is skipped.
|
|
248
|
+
*
|
|
249
|
+
* Skipping it rather than reporting it is what keeps the intro paragraph and
|
|
250
|
+
* any explanatory bullet out of the findings. The task that bullet meant to
|
|
251
|
+
* name is still accounted for, because a stem no line reaches is reported by
|
|
252
|
+
* `checkMapping` as carrying no row on either surface.
|
|
253
|
+
*/
|
|
254
|
+
export function readBacklog(text: string): readonly BacklogRow[] {
|
|
255
|
+
const rows: BacklogRow[] = []
|
|
256
|
+
|
|
257
|
+
for (const line of text.split('\n')) {
|
|
258
|
+
if (!/^\s*[-*]\s/.test(line)) continue
|
|
259
|
+
|
|
260
|
+
const target = linkTarget(line)
|
|
261
|
+
if (!target) continue
|
|
262
|
+
|
|
263
|
+
// A pointer carrying a directory names something other than a sibling task,
|
|
264
|
+
// the way `citedStem` reads the same shape on the board.
|
|
265
|
+
const path = target.split('#')[0] ?? ''
|
|
266
|
+
if (path.includes('/')) continue
|
|
267
|
+
|
|
268
|
+
const stem = stemOf(path)
|
|
269
|
+
if (stem && isReservedStem(stem)) continue
|
|
270
|
+
|
|
271
|
+
rows.push({ label: linkText(line) || line.trim(), stem })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return rows
|
|
275
|
+
}
|
|
276
|
+
|
|
228
277
|
async function listTaskStems(dir: string): Promise<string[]> {
|
|
229
278
|
const entries = await readdir(dir)
|
|
230
279
|
|
|
@@ -247,13 +296,44 @@ function resolves(target: string, dir: string, root: string): boolean {
|
|
|
247
296
|
return existsSync(resolve(dir, path)) || existsSync(resolve(root, path))
|
|
248
297
|
}
|
|
249
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Accounts every task file against both surfaces the board spans. A task sits
|
|
301
|
+
* on `priority.md` when it would plausibly be planned soon and on `backlog.md`
|
|
302
|
+
* otherwise, so a file reached by neither is the dropped one this reports and a
|
|
303
|
+
* file reached by both claims two contradictory things about itself.
|
|
304
|
+
*/
|
|
250
305
|
function checkMapping(
|
|
251
306
|
rows: readonly BoardRow[],
|
|
307
|
+
backlog: readonly BacklogRow[],
|
|
252
308
|
stems: readonly string[],
|
|
253
309
|
dir: string,
|
|
254
310
|
): Finding[] {
|
|
255
311
|
const findings: Finding[] = []
|
|
256
312
|
const seen = new Map<string, number>()
|
|
313
|
+
const listed = new Set<string>()
|
|
314
|
+
|
|
315
|
+
for (const row of backlog) {
|
|
316
|
+
if (!row.stem) {
|
|
317
|
+
findings.push({
|
|
318
|
+
kind: 'task-unresolved',
|
|
319
|
+
group: undefined,
|
|
320
|
+
subject: row.label,
|
|
321
|
+
message: 'is a backlog line naming no task file.',
|
|
322
|
+
})
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
listed.add(row.stem)
|
|
327
|
+
|
|
328
|
+
if (!existsSync(join(dir, `${row.stem}.md`))) {
|
|
329
|
+
findings.push({
|
|
330
|
+
kind: 'task-unresolved',
|
|
331
|
+
group: undefined,
|
|
332
|
+
subject: row.stem,
|
|
333
|
+
message: 'has a backlog line and no task file.',
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
}
|
|
257
337
|
|
|
258
338
|
for (const row of rows) {
|
|
259
339
|
if (!row.stem) {
|
|
@@ -287,15 +367,26 @@ function checkMapping(
|
|
|
287
367
|
message: `carries ${count} rows. A task belongs to exactly one group.`,
|
|
288
368
|
})
|
|
289
369
|
}
|
|
370
|
+
|
|
371
|
+
if (listed.has(stem)) {
|
|
372
|
+
findings.push({
|
|
373
|
+
kind: 'row-duplicated',
|
|
374
|
+
group: undefined,
|
|
375
|
+
subject: stem,
|
|
376
|
+
message:
|
|
377
|
+
'carries a row on the board and a line on the backlog. A task sits on one surface.',
|
|
378
|
+
})
|
|
379
|
+
}
|
|
290
380
|
}
|
|
291
381
|
|
|
292
382
|
for (const stem of stems) {
|
|
293
|
-
if (!seen.has(stem)) {
|
|
383
|
+
if (!seen.has(stem) && !listed.has(stem)) {
|
|
294
384
|
findings.push({
|
|
295
385
|
kind: 'row-missing',
|
|
296
386
|
group: undefined,
|
|
297
387
|
subject: stem,
|
|
298
|
-
message:
|
|
388
|
+
message:
|
|
389
|
+
'is a task file with no row on the board and no line on the backlog.',
|
|
299
390
|
})
|
|
300
391
|
}
|
|
301
392
|
}
|
|
@@ -547,11 +638,19 @@ export async function validateBoard(root: string): Promise<ValidateOutcome> {
|
|
|
547
638
|
)
|
|
548
639
|
}
|
|
549
640
|
|
|
641
|
+
// An absent backlog reads as empty rather than refusing. A project that has
|
|
642
|
+
// never needed the second surface keeps every task on the board, which is the
|
|
643
|
+
// one-to-one mapping this check ran before the backlog existed.
|
|
644
|
+
const backlogFile = backlogPath(root)
|
|
645
|
+
const backlog = existsSync(backlogFile)
|
|
646
|
+
? readBacklog(await readFile(backlogFile, 'utf8'))
|
|
647
|
+
: []
|
|
648
|
+
|
|
550
649
|
const stems = await listTaskStems(dir)
|
|
551
650
|
const parked = await checkParked(rows, root)
|
|
552
651
|
|
|
553
652
|
const findings = [
|
|
554
|
-
...checkMapping(rows, stems, dir),
|
|
653
|
+
...checkMapping(rows, backlog, stems, dir),
|
|
555
654
|
...checkPlans(rows, dir, root),
|
|
556
655
|
...checkCollisions(rows),
|
|
557
656
|
...parked.findings,
|
|
@@ -560,6 +659,7 @@ export async function validateBoard(root: string): Promise<ValidateOutcome> {
|
|
|
560
659
|
return {
|
|
561
660
|
ok: true,
|
|
562
661
|
rows: rows.length,
|
|
662
|
+
backlog: backlog.length,
|
|
563
663
|
tasks: stems.length,
|
|
564
664
|
findings,
|
|
565
665
|
untested: parked.untested,
|