@erclx/aitk 0.63.1 → 0.64.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-docs/SKILL.md +3 -1
- package/claude/skills/claude-feature/SKILL.md +2 -0
- package/claude/skills/claude-memory-capture/SKILL.md +9 -2
- package/claude/skills/claude-memory-review/SKILL.md +5 -2
- package/claude/skills/claude-review/SKILL.md +2 -0
- package/claude/skills/claude-screencast/SKILL.md +2 -0
- package/claude/skills/claude-seed-sync/SKILL.md +2 -0
- package/claude/skills/claude-tasks/SKILL.md +3 -1
- package/claude/skills/claude-ui-test/SKILL.md +2 -0
- package/claude/skills/claude-ux-audit/SKILL.md +2 -0
- package/claude/skills/git-pr/SKILL.md +9 -3
- package/docs/agents/commands.md +5 -1
- package/docs/agents/context-audit-checks.md +9 -11
- package/docs/agents/context-audit.md +3 -1
- package/docs/agents/index.md +3 -2
- package/docs/agents/markdown-audit.md +70 -0
- package/docs/agents/tasks.md +51 -2
- package/docs/ai-workflow.md +1 -1
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/commands/context.ts +8 -115
- package/src/commands/markdown.ts +383 -0
- package/src/commands/records.ts +1 -18
- package/src/commands/tasks.ts +314 -19
- package/src/context/audit.ts +34 -309
- package/src/context/citations.ts +2 -30
- package/src/git-files.ts +31 -0
- package/src/markdown/bans.ts +241 -0
- package/src/markdown/files.ts +92 -0
- package/src/markdown/scan.ts +183 -0
- package/src/markdown/structure.ts +408 -0
- package/src/records/validate.ts +1 -37
- package/src/tasks/archive.ts +34 -3
- package/src/tasks/record.ts +311 -0
- package/src/worktree.ts +23 -0
- package/standards/context.md +2 -7
- package/standards/markdown.md +10 -2
- package/tooling/claude/seeds/CLAUDE.md +1 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import type { BodyLine } from '@/markdown/scan'
|
|
2
|
+
|
|
3
|
+
const HEADING = /^#{1,6}\s/
|
|
4
|
+
const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
|
|
5
|
+
const TABLE_ROW = /^\s*\|/
|
|
6
|
+
const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
|
|
7
|
+
const BLOCKQUOTE = /^\s*>/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A sentence ends on terminal punctuation followed by the start of another
|
|
11
|
+
* sentence or by the end of the paragraph.
|
|
12
|
+
*
|
|
13
|
+
* Requiring an opening capital after the space is what keeps a version pin and
|
|
14
|
+
* a decimal from each reading as two sentences, since neither is followed by
|
|
15
|
+
* one. An abbreviation ahead of a capitalized word still counts, which
|
|
16
|
+
* over-reports by one on the sentence that carries it and is why this measure
|
|
17
|
+
* reports rather than gates.
|
|
18
|
+
*/
|
|
19
|
+
const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+["'“(\[]*[A-Z]|\s*$)/g
|
|
20
|
+
|
|
21
|
+
const NUMBER_WORDS: Record<string, number> = {
|
|
22
|
+
one: 1,
|
|
23
|
+
two: 2,
|
|
24
|
+
three: 3,
|
|
25
|
+
four: 4,
|
|
26
|
+
five: 5,
|
|
27
|
+
six: 6,
|
|
28
|
+
seven: 7,
|
|
29
|
+
eight: 8,
|
|
30
|
+
nine: 9,
|
|
31
|
+
ten: 10,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Checkpoints as `standards/markdown.md` states them today.
|
|
36
|
+
*
|
|
37
|
+
* These are the fallback rather than the definition. Each is read out of the
|
|
38
|
+
* standard per run, and this stands in for one the standard no longer states in
|
|
39
|
+
* a shape the reader recognizes, so a rewording degrades a number rather than
|
|
40
|
+
* the whole check. The report's legend names every checkpoint that fell back,
|
|
41
|
+
* because a stale number quietly measuring the wrong thing is the failure mode
|
|
42
|
+
* a silent default would ship.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_CHECKPOINTS = {
|
|
45
|
+
run: 40,
|
|
46
|
+
peerBullet: 130,
|
|
47
|
+
bullet: 400,
|
|
48
|
+
paragraph: 400,
|
|
49
|
+
sentences: 4,
|
|
50
|
+
renderWidth: 80,
|
|
51
|
+
} as const
|
|
52
|
+
|
|
53
|
+
export type CheckpointName = keyof typeof DEFAULT_CHECKPOINTS
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Columns a source line wraps at when rendered.
|
|
57
|
+
*
|
|
58
|
+
* Nothing in this repository sets a line width and entries are authored one
|
|
59
|
+
* line per bullet, so the rendered width is the viewer's rather than the file's.
|
|
60
|
+
*/
|
|
61
|
+
export const RENDER_WIDTH: number = DEFAULT_CHECKPOINTS.renderWidth
|
|
62
|
+
|
|
63
|
+
export interface Checkpoints {
|
|
64
|
+
readonly run: number
|
|
65
|
+
readonly peerBullet: number
|
|
66
|
+
readonly bullet: number
|
|
67
|
+
readonly paragraph: number
|
|
68
|
+
readonly sentences: number
|
|
69
|
+
readonly renderWidth: number
|
|
70
|
+
/** Names that fell back, so the report can say which number is not the standard's. */
|
|
71
|
+
readonly fellBack: readonly CheckpointName[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface BulletFinding {
|
|
75
|
+
readonly line: number
|
|
76
|
+
/** Weight as folded, so a report says how far past the checkpoint it sits. */
|
|
77
|
+
readonly characters: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ParagraphFinding {
|
|
81
|
+
readonly line: number
|
|
82
|
+
readonly sentences: number
|
|
83
|
+
readonly characters: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface StructureReport {
|
|
87
|
+
readonly rel: string
|
|
88
|
+
/** Rendered lines at the render width, not source lines. */
|
|
89
|
+
readonly longestRun: number
|
|
90
|
+
/** First line of the longest run, or 0 when the file has no run at all. */
|
|
91
|
+
readonly longestRunLine: number
|
|
92
|
+
readonly heavyBullets: readonly BulletFinding[]
|
|
93
|
+
readonly heavyParagraphs: readonly ParagraphFinding[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const PATTERNS: Record<CheckpointName, RegExp> = {
|
|
97
|
+
run: /Past roughly (\d+) rendered lines/,
|
|
98
|
+
peerBullet: /averaging under roughly (\d+) characters/,
|
|
99
|
+
bullet: /Past roughly (\d+) characters in one top-level bullet/,
|
|
100
|
+
paragraph: /Past roughly (\d+) characters in one paragraph/,
|
|
101
|
+
sentences: /Keep paragraphs to ([a-z]+|\d+) sentences or fewer/,
|
|
102
|
+
renderWidth: /wrapping each source line at (\d+) columns/,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Reads each checkpoint out of the standard stating it.
|
|
107
|
+
*
|
|
108
|
+
* A number held in code is a second place the rule lives, and the two drift
|
|
109
|
+
* silently because nothing compares them. What this buys is that raising a
|
|
110
|
+
* checkpoint is an edit to the sentence a reader is pointed at, and what it
|
|
111
|
+
* costs is a reader of prose, which is bounded by falling back per checkpoint
|
|
112
|
+
* rather than per file.
|
|
113
|
+
*/
|
|
114
|
+
export function parseCheckpoints(markdown: string): Checkpoints {
|
|
115
|
+
const fellBack: CheckpointName[] = []
|
|
116
|
+
|
|
117
|
+
const read = (name: CheckpointName): number => {
|
|
118
|
+
const match = markdown.match(PATTERNS[name])
|
|
119
|
+
const raw = match?.[1]
|
|
120
|
+
const value = raw ? (NUMBER_WORDS[raw] ?? Number(raw)) : Number.NaN
|
|
121
|
+
|
|
122
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
123
|
+
fellBack.push(name)
|
|
124
|
+
return DEFAULT_CHECKPOINTS[name]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
run: read('run'),
|
|
132
|
+
peerBullet: read('peerBullet'),
|
|
133
|
+
bullet: read('bullet'),
|
|
134
|
+
paragraph: read('paragraph'),
|
|
135
|
+
sentences: read('sentences'),
|
|
136
|
+
renderWidth: read('renderWidth'),
|
|
137
|
+
fellBack,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Height a source line occupies once wrapped.
|
|
143
|
+
*
|
|
144
|
+
* A blank line renders as the gap it is rather than as nothing, which keeps it
|
|
145
|
+
* the distance the source measure already counted it as.
|
|
146
|
+
*/
|
|
147
|
+
export function renderedHeight(text: string, width = RENDER_WIDTH): number {
|
|
148
|
+
return Math.max(1, Math.ceil(text.length / width))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reports whether a run is the peer list the standard exempts.
|
|
153
|
+
*
|
|
154
|
+
* Every non-blank line has to be a list item at one indent. Prose mixed into
|
|
155
|
+
* the run or a nested level inside it ends the exemption, because either one
|
|
156
|
+
* means the block is no longer a flat set a reader can skim. Bullet count says
|
|
157
|
+
* nothing on its own, since a catalog of one-liners and a wall of paragraphs
|
|
158
|
+
* reach the same count and read nothing alike, so the average bullet decides.
|
|
159
|
+
*/
|
|
160
|
+
function isScannablePeerList(
|
|
161
|
+
run: readonly BodyLine[],
|
|
162
|
+
checkpoint: number,
|
|
163
|
+
): boolean {
|
|
164
|
+
const indents = new Set<number>()
|
|
165
|
+
let items = 0
|
|
166
|
+
let characters = 0
|
|
167
|
+
|
|
168
|
+
for (const line of run) {
|
|
169
|
+
const text = line.text.trim()
|
|
170
|
+
if (text === '') continue
|
|
171
|
+
|
|
172
|
+
const match = line.text.match(LIST_ITEM)
|
|
173
|
+
if (!match) return false
|
|
174
|
+
indents.add(match[1].length)
|
|
175
|
+
items++
|
|
176
|
+
characters += text.length
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (indents.size !== 1) return false
|
|
180
|
+
|
|
181
|
+
return characters / items < checkpoint
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Reports whether a run is a table, the second shape the checkpoint cannot fix.
|
|
186
|
+
*
|
|
187
|
+
* The peer list above is exempt because it is already navigable. A table is
|
|
188
|
+
* exempt for the other reason: the remedy does not exist. A heading dropped
|
|
189
|
+
* inside one splits the table into two tables rather than breaking the run.
|
|
190
|
+
*
|
|
191
|
+
* Every non-blank line has to be a row, and a delimiter is required rather than
|
|
192
|
+
* assumed. A run holding a table between paragraphs is genuinely mixed and a
|
|
193
|
+
* heading breaks it at a seam either side, and a stack of lines opening with a
|
|
194
|
+
* pipe and no delimiter renders as paragraph text.
|
|
195
|
+
*/
|
|
196
|
+
function isTableRun(run: readonly BodyLine[]): boolean {
|
|
197
|
+
let separators = 0
|
|
198
|
+
|
|
199
|
+
for (const line of run) {
|
|
200
|
+
if (line.text.trim() === '') continue
|
|
201
|
+
if (!TABLE_ROW.test(line.text)) return false
|
|
202
|
+
if (TABLE_SEPARATOR.test(line.text)) separators++
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return separators > 0
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Measures the longest run of lines no heading breaks, in rendered lines.
|
|
210
|
+
*
|
|
211
|
+
* Fenced lines are skipped rather than treated as breaks, per the standard:
|
|
212
|
+
* they leave the count without ending the run, so prose either side of an
|
|
213
|
+
* example still measures as the one stretch a reader scrolls through. Blank
|
|
214
|
+
* lines do count, since the checkpoint is about how far a reader travels
|
|
215
|
+
* between signposts and a blank line is distance like any other.
|
|
216
|
+
*
|
|
217
|
+
* Height is what a reader travels, and source lines only stand in for it while
|
|
218
|
+
* lines stay short. A file authored one line per bullet puts a paragraph on
|
|
219
|
+
* each, so a block of fifteen bullets measures as fifteen and renders past
|
|
220
|
+
* sixty. Wrapping every line at a stated width is what closes that gap.
|
|
221
|
+
*/
|
|
222
|
+
export function longestRun(
|
|
223
|
+
lines: readonly BodyLine[],
|
|
224
|
+
checkpoints: Checkpoints,
|
|
225
|
+
): { length: number; line: number } {
|
|
226
|
+
let longest = 0
|
|
227
|
+
let longestLine = 0
|
|
228
|
+
let run: BodyLine[] = []
|
|
229
|
+
|
|
230
|
+
const close = (): void => {
|
|
231
|
+
// The reported line is the run's first non-blank one, since that is what an
|
|
232
|
+
// editor should open. A run of nothing but blank lines is the gap between
|
|
233
|
+
// two headings rather than a stretch a reader travels, so it never counts.
|
|
234
|
+
const first = run.find((line) => line.text.trim() !== '')
|
|
235
|
+
|
|
236
|
+
if (
|
|
237
|
+
first &&
|
|
238
|
+
!isScannablePeerList(run, checkpoints.peerBullet) &&
|
|
239
|
+
!isTableRun(run)
|
|
240
|
+
) {
|
|
241
|
+
const height = run.reduce(
|
|
242
|
+
(sum, line) => sum + renderedHeight(line.text, checkpoints.renderWidth),
|
|
243
|
+
0,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if (height > longest) {
|
|
247
|
+
longest = height
|
|
248
|
+
longestLine = first.number
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
run = []
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
if (line.fenced) continue
|
|
256
|
+
|
|
257
|
+
if (HEADING.test(line.text)) {
|
|
258
|
+
close()
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
run.push(line)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
close()
|
|
266
|
+
|
|
267
|
+
return { length: longest, line: longestLine }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Finds the top-level bullets carrying more than a decision.
|
|
272
|
+
*
|
|
273
|
+
* A nested item is left out rather than folded into its parent, since the
|
|
274
|
+
* checkpoint asks what one bullet carries and a child carries its own. Lines
|
|
275
|
+
* continuing a bullet do fold in, so a heavy bullet cannot fall under the
|
|
276
|
+
* checkpoint by being wrapped across two source lines. A fenced line closes the
|
|
277
|
+
* open bullet, which keeps a bullet from absorbing the example below it.
|
|
278
|
+
*/
|
|
279
|
+
export function heavyBullets(
|
|
280
|
+
lines: readonly BodyLine[],
|
|
281
|
+
checkpoints: Checkpoints,
|
|
282
|
+
): BulletFinding[] {
|
|
283
|
+
const findings: BulletFinding[] = []
|
|
284
|
+
let open: BulletFinding | null = null
|
|
285
|
+
|
|
286
|
+
const close = (): void => {
|
|
287
|
+
if (open && open.characters > checkpoints.bullet) findings.push(open)
|
|
288
|
+
open = null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const line of lines) {
|
|
292
|
+
if (line.fenced) {
|
|
293
|
+
close()
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const item = line.text.match(LIST_ITEM)
|
|
298
|
+
const text = line.text.trim()
|
|
299
|
+
|
|
300
|
+
if (item) {
|
|
301
|
+
close()
|
|
302
|
+
if (item[1].length === 0) {
|
|
303
|
+
open = { line: line.number, characters: text.length }
|
|
304
|
+
}
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (text === '' || HEADING.test(line.text) || TABLE_ROW.test(line.text)) {
|
|
309
|
+
close()
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// The joining space a wrapped line would have carried, so folding two
|
|
314
|
+
// source lines measures what one unwrapped line would have.
|
|
315
|
+
if (open) open = { ...open, characters: open.characters + text.length + 1 }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
close()
|
|
319
|
+
|
|
320
|
+
return findings
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function countSentences(text: string): number {
|
|
324
|
+
return [...text.matchAll(SENTENCE_END)].length
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Finds the prose paragraphs past either half of the standard's checkpoint.
|
|
329
|
+
*
|
|
330
|
+
* Both halves are stated in the standard and both are read from it. The weight
|
|
331
|
+
* half was added there rather than borrowed from the bullet checkpoint, which
|
|
332
|
+
* governs a different construct: one number feeding both would move the
|
|
333
|
+
* paragraph rule whenever the bullet rule was changed, and an author reading
|
|
334
|
+
* the sentence cap would find no weight rule to read at all.
|
|
335
|
+
*
|
|
336
|
+
* The two numbers coincide today because paragraph and bullet weight measure
|
|
337
|
+
* one population, sharing a median near 170 characters with no gap behind
|
|
338
|
+
* either candidate. They are separate checkpoints regardless, so either moves
|
|
339
|
+
* without dragging the other.
|
|
340
|
+
*
|
|
341
|
+
* A paragraph is a run of consecutive prose lines. A heading, a list item, a
|
|
342
|
+
* table row, a blockquote, a blank line, and a fence each end one, so a bullet
|
|
343
|
+
* is measured by `heavyBullets` alone and never twice.
|
|
344
|
+
*/
|
|
345
|
+
export function heavyParagraphs(
|
|
346
|
+
lines: readonly BodyLine[],
|
|
347
|
+
checkpoints: Checkpoints,
|
|
348
|
+
): ParagraphFinding[] {
|
|
349
|
+
const findings: ParagraphFinding[] = []
|
|
350
|
+
let block: BodyLine[] = []
|
|
351
|
+
|
|
352
|
+
const close = (): void => {
|
|
353
|
+
if (block.length > 0) {
|
|
354
|
+
const text = block.map((line) => line.text.trim()).join(' ')
|
|
355
|
+
const sentences = countSentences(text)
|
|
356
|
+
|
|
357
|
+
if (
|
|
358
|
+
sentences > checkpoints.sentences ||
|
|
359
|
+
text.length > checkpoints.paragraph
|
|
360
|
+
) {
|
|
361
|
+
findings.push({
|
|
362
|
+
line: block[0].number,
|
|
363
|
+
sentences,
|
|
364
|
+
characters: text.length,
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
block = []
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
for (const line of lines) {
|
|
372
|
+
const text = line.text.trim()
|
|
373
|
+
const breaks =
|
|
374
|
+
line.fenced ||
|
|
375
|
+
text === '' ||
|
|
376
|
+
HEADING.test(line.text) ||
|
|
377
|
+
LIST_ITEM.test(line.text) ||
|
|
378
|
+
TABLE_ROW.test(line.text) ||
|
|
379
|
+
BLOCKQUOTE.test(line.text)
|
|
380
|
+
|
|
381
|
+
if (breaks) {
|
|
382
|
+
close()
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
block.push(line)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
close()
|
|
390
|
+
|
|
391
|
+
return findings
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function measureStructure(
|
|
395
|
+
rel: string,
|
|
396
|
+
lines: readonly BodyLine[],
|
|
397
|
+
checkpoints: Checkpoints,
|
|
398
|
+
): StructureReport {
|
|
399
|
+
const run = longestRun(lines, checkpoints)
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
rel,
|
|
403
|
+
longestRun: run.length,
|
|
404
|
+
longestRunLine: run.line,
|
|
405
|
+
heavyBullets: heavyBullets(lines, checkpoints),
|
|
406
|
+
heavyParagraphs: heavyParagraphs(lines, checkpoints),
|
|
407
|
+
}
|
|
408
|
+
}
|
package/src/records/validate.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
5
|
+
import { linesOutsideFences } from '@/markdown/scan'
|
|
5
6
|
|
|
6
7
|
export const RECORD_KINDS = ['plans', 'groundwork', 'intake', 'memory'] as const
|
|
7
8
|
|
|
@@ -142,43 +143,6 @@ const PLAN_REQUIRED: readonly PlanSection[] = [
|
|
|
142
143
|
'Questions',
|
|
143
144
|
]
|
|
144
145
|
|
|
145
|
-
const FENCE = /^(`{3,}|~{3,})/
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Drops every fenced block, so a quoted template is not read as content. A plan
|
|
149
|
-
* showing the shape it writes puts real-looking bullets and headings inside a
|
|
150
|
-
* fence, and scanning them reports the example rather than the plan.
|
|
151
|
-
*
|
|
152
|
-
* A closing fence has to match the opening character and be at least as long,
|
|
153
|
-
* which is what keeps a ```` block holding a ``` example from closing early. An
|
|
154
|
-
* unterminated fence swallows the rest of the document, which under-reports a
|
|
155
|
-
* malformed file rather than reporting its remainder as content.
|
|
156
|
-
*/
|
|
157
|
-
export function linesOutsideFences(text: string): string[] {
|
|
158
|
-
const kept: string[] = []
|
|
159
|
-
let fence: string | undefined
|
|
160
|
-
|
|
161
|
-
for (const line of text.split('\n')) {
|
|
162
|
-
const match = FENCE.exec(line.trim())
|
|
163
|
-
|
|
164
|
-
if (fence) {
|
|
165
|
-
const closes =
|
|
166
|
-
match && match[1][0] === fence[0] && match[1].length >= fence.length
|
|
167
|
-
if (closes) fence = undefined
|
|
168
|
-
continue
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (match) {
|
|
172
|
-
fence = match[1]
|
|
173
|
-
continue
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
kept.push(line)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return kept
|
|
180
|
-
}
|
|
181
|
-
|
|
182
146
|
/**
|
|
183
147
|
* A line standing alone as a bold label or an H2, whatever it names. A plan is
|
|
184
148
|
* free to carry a section of its own, so the split has to see one to close the
|
package/src/tasks/archive.ts
CHANGED
|
@@ -15,6 +15,12 @@ const PLANS_DIR = join('.claude', 'plans')
|
|
|
15
15
|
*/
|
|
16
16
|
export const RESERVED_STEMS = ['index', 'priority', 'session'] as const
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* `bad-input` describes the command line rather than the board, which is the
|
|
20
|
+
* split `record.ts` draws for the same reason. A caller naming two selectors
|
|
21
|
+
* answered as `ambiguous` sends a session to repair a task citation that is
|
|
22
|
+
* fine, so the three task verbs answer one mistake one way.
|
|
23
|
+
*/
|
|
18
24
|
export const ARCHIVE_REFUSALS = [
|
|
19
25
|
'no-board',
|
|
20
26
|
'no-match',
|
|
@@ -22,6 +28,7 @@ export const ARCHIVE_REFUSALS = [
|
|
|
22
28
|
'no-outcomes',
|
|
23
29
|
'open-outcomes',
|
|
24
30
|
'plan-unswept',
|
|
31
|
+
'bad-input',
|
|
25
32
|
] as const
|
|
26
33
|
|
|
27
34
|
export type ArchiveRefusal = (typeof ARCHIVE_REFUSALS)[number]
|
|
@@ -61,6 +68,26 @@ export function archiveDir(root: string): string {
|
|
|
61
68
|
return join(root, ARCHIVE_DIR)
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
export const OUTCOME_PATTERN = /^- \[([ xX])\] ?(.*)$/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Marks the lines sitting inside a fenced block, the fence delimiters included.
|
|
75
|
+
* A checkbox in a sample a task displays is not an outcome the task claims, and
|
|
76
|
+
* counting one shifts every position after it. Every reader of the outcome list
|
|
77
|
+
* shares this so none of them can disagree about what the list holds.
|
|
78
|
+
*/
|
|
79
|
+
export function fenceMask(lines: readonly string[]): boolean[] {
|
|
80
|
+
let inside = false
|
|
81
|
+
|
|
82
|
+
return lines.map((line) => {
|
|
83
|
+
if (/^\s*(?:```|~~~)/.test(line)) {
|
|
84
|
+
inside = !inside
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
return inside
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
64
91
|
/**
|
|
65
92
|
* Splits a task's outcome list by checkbox state. The board format puts every
|
|
66
93
|
* outcome at the top level of `## Outcomes`, so an anchored match is enough and
|
|
@@ -69,9 +96,13 @@ export function archiveDir(root: string): string {
|
|
|
69
96
|
export function readOutcomes(text: string): TaskOutcomes {
|
|
70
97
|
const open: string[] = []
|
|
71
98
|
const closed: string[] = []
|
|
99
|
+
const lines = text.split('\n')
|
|
100
|
+
const fenced = fenceMask(lines)
|
|
101
|
+
|
|
102
|
+
for (const [index, line] of lines.entries()) {
|
|
103
|
+
if (fenced[index]) continue
|
|
72
104
|
|
|
73
|
-
|
|
74
|
-
const match = /^- \[([ xX])\] ?(.*)$/.exec(line)
|
|
105
|
+
const match = OUTCOME_PATTERN.exec(line)
|
|
75
106
|
if (!match) continue
|
|
76
107
|
|
|
77
108
|
const [, box, body] = match
|
|
@@ -157,7 +188,7 @@ function isLivePlan(target: string, dir: string, root: string): boolean {
|
|
|
157
188
|
)
|
|
158
189
|
}
|
|
159
190
|
|
|
160
|
-
async function listTaskStems(dir: string): Promise<string[]> {
|
|
191
|
+
export async function listTaskStems(dir: string): Promise<string[]> {
|
|
161
192
|
const entries = await readdir(dir)
|
|
162
193
|
const reserved: readonly string[] = RESERVED_STEMS
|
|
163
194
|
|