@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
package/src/commands/context.ts
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import {
|
|
4
|
-
BULLET_CHECKPOINT,
|
|
5
4
|
type EntryReport,
|
|
6
5
|
governsContent,
|
|
7
6
|
LENGTH_CHECKPOINT,
|
|
8
7
|
measureFolders,
|
|
9
8
|
missingSections,
|
|
10
|
-
PEER_BULLET_CHECKPOINT,
|
|
11
9
|
PROVENANCE_FOLDER,
|
|
12
|
-
RENDER_WIDTH,
|
|
13
10
|
REQUIRED_SECTIONS,
|
|
14
|
-
RUN_CHECKPOINT,
|
|
15
11
|
type SectionFinding,
|
|
16
12
|
} from '@/context/audit'
|
|
17
13
|
import { auditCitations, type CitationReport } from '@/context/citations'
|
|
@@ -23,6 +19,7 @@ import {
|
|
|
23
19
|
} from '@/context/folders'
|
|
24
20
|
import { isGating } from '@/context/gate'
|
|
25
21
|
import { auditIndexes, type FolderDrift } from '@/context/index-drift'
|
|
22
|
+
import { RENDER_WIDTH } from '@/markdown/structure'
|
|
26
23
|
import {
|
|
27
24
|
frameError,
|
|
28
25
|
intro,
|
|
@@ -57,7 +54,7 @@ export function register(program: Command): void {
|
|
|
57
54
|
context
|
|
58
55
|
.command('audit')
|
|
59
56
|
.description(
|
|
60
|
-
'Report required sections, entry length,
|
|
57
|
+
'Report required sections, entry length, citations, catalog tables, provenance, and index drift',
|
|
61
58
|
)
|
|
62
59
|
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
63
60
|
.helpOption('-h, --help', 'Show this help message')
|
|
@@ -82,8 +79,11 @@ export function register(program: Command): void {
|
|
|
82
79
|
'',
|
|
83
80
|
'An unresolved citation always gates. --gate widens the gate to the',
|
|
84
81
|
'other two findings that are facts rather than judgments: a missing',
|
|
85
|
-
'required section and index drift. Length,
|
|
86
|
-
'
|
|
82
|
+
'required section and index drift. Length, table, and provenance',
|
|
83
|
+
'findings are thresholds and stay advisory under both.',
|
|
84
|
+
'',
|
|
85
|
+
'Depth and bullet weight are stated over every markdown file rather',
|
|
86
|
+
'than over a context entry, so `aitk markdown audit` measures them.',
|
|
87
87
|
'',
|
|
88
88
|
'Examples:',
|
|
89
89
|
' aitk context audit',
|
|
@@ -192,8 +192,6 @@ async function runAudit(
|
|
|
192
192
|
reportCitations(citations, cited)
|
|
193
193
|
reportSections(sections, folders)
|
|
194
194
|
reportLength(entries)
|
|
195
|
-
reportDepth(entries)
|
|
196
|
-
reportBullets(entries, folders)
|
|
197
195
|
reportTables(entries)
|
|
198
196
|
reportProvenance(entries, folders)
|
|
199
197
|
reportDrift(drift)
|
|
@@ -221,11 +219,7 @@ async function runAudit(
|
|
|
221
219
|
indexDrift: drift,
|
|
222
220
|
checkpoints: {
|
|
223
221
|
lines: LENGTH_CHECKPOINT,
|
|
224
|
-
run: RUN_CHECKPOINT,
|
|
225
|
-
runCountsBlankLines: true,
|
|
226
222
|
renderWidth: RENDER_WIDTH,
|
|
227
|
-
peerBullet: PEER_BULLET_CHECKPOINT,
|
|
228
|
-
bullet: BULLET_CHECKPOINT,
|
|
229
223
|
provenanceFolder: PROVENANCE_FOLDER,
|
|
230
224
|
requiredSections: REQUIRED_SECTIONS,
|
|
231
225
|
},
|
|
@@ -395,7 +389,7 @@ function reportLength(entries: readonly EntryReport[]): void {
|
|
|
395
389
|
`Entries measure rendered lines at ${RENDER_WIDTH} columns, counting frontmatter and fenced blocks.`,
|
|
396
390
|
)
|
|
397
391
|
logInfo(
|
|
398
|
-
'A reference-heavy entry therefore ranks by its examples, which the depth check excludes.',
|
|
392
|
+
'A reference-heavy entry therefore ranks by its examples, which the depth check in `aitk markdown audit` excludes.',
|
|
399
393
|
)
|
|
400
394
|
|
|
401
395
|
const over = entries
|
|
@@ -415,107 +409,6 @@ function reportLength(entries: readonly EntryReport[]): void {
|
|
|
415
409
|
)
|
|
416
410
|
}
|
|
417
411
|
|
|
418
|
-
/**
|
|
419
|
-
* Names the render width and the blank-line convention on every run.
|
|
420
|
-
*
|
|
421
|
-
* The standard settles heading level and fenced blocks and stops there, so a
|
|
422
|
-
* hand reader who drops blank lines lands a line or two below this number.
|
|
423
|
-
* Stating both is what keeps the two measurements reconcilable, and the width
|
|
424
|
-
* matters more than the blank lines because a number counted in rendered lines
|
|
425
|
-
* cannot be reproduced without it.
|
|
426
|
-
*/
|
|
427
|
-
function reportDepth(entries: readonly EntryReport[]): void {
|
|
428
|
-
logStep('Depth')
|
|
429
|
-
logInfo(
|
|
430
|
-
`Runs measure rendered lines at ${RENDER_WIDTH} columns and count blank lines.`,
|
|
431
|
-
)
|
|
432
|
-
logInfo(
|
|
433
|
-
`Fenced blocks are excluded, and so are peer lists averaging under ${PEER_BULLET_CHECKPOINT} characters a bullet.`,
|
|
434
|
-
)
|
|
435
|
-
logInfo(
|
|
436
|
-
'A run that is entirely table rows is excluded too, since a heading inside a table splits the table rather than the run.',
|
|
437
|
-
)
|
|
438
|
-
|
|
439
|
-
const over = entries
|
|
440
|
-
.filter((entry) => entry.longestRun > RUN_CHECKPOINT)
|
|
441
|
-
.sort((a, b) => b.longestRun - a.longestRun)
|
|
442
|
-
|
|
443
|
-
if (over.length === 0) {
|
|
444
|
-
logInfo(`No run past the ${RUN_CHECKPOINT}-line checkpoint.`)
|
|
445
|
-
return
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
logWarn(`${over.length} past the ${RUN_CHECKPOINT}-line checkpoint`)
|
|
449
|
-
pipeOutput(
|
|
450
|
-
over
|
|
451
|
-
.map(
|
|
452
|
-
(entry) =>
|
|
453
|
-
`${entry.rel}:${entry.longestRunLine} ${entry.longestRun} rendered lines unbroken`,
|
|
454
|
-
)
|
|
455
|
-
.join('\n'),
|
|
456
|
-
)
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Groups by entry, and states its reach on every run, for the reasons the
|
|
461
|
-
* provenance report does both.
|
|
462
|
-
*
|
|
463
|
-
* Bullets past the checkpoint cluster in a handful of entries and the heaviest
|
|
464
|
-
* entries carry them a dozen at a time, so a flat list of bullets buries the
|
|
465
|
-
* entry holding one. What a reader acts on is which file to open.
|
|
466
|
-
*/
|
|
467
|
-
function reportBullets(
|
|
468
|
-
entries: readonly EntryReport[],
|
|
469
|
-
folders: readonly AuditedFolder[],
|
|
470
|
-
): void {
|
|
471
|
-
logStep('Bullets')
|
|
472
|
-
|
|
473
|
-
const governed = folders.filter(governsContent)
|
|
474
|
-
if (governed.length === 0) {
|
|
475
|
-
logInfo(
|
|
476
|
-
`Out of scope. The rule is stated in the standard governing .claude/${PROVENANCE_FOLDER}/, and no audited folder is that one.`,
|
|
477
|
-
)
|
|
478
|
-
return
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
logInfo(
|
|
482
|
-
`Covers .claude/${PROVENANCE_FOLDER}/ alone, since moving an incident out of a bullet needs a decision to keep.`,
|
|
483
|
-
)
|
|
484
|
-
logInfo(
|
|
485
|
-
'Top-level bullets measure characters, folding in continuation lines.',
|
|
486
|
-
)
|
|
487
|
-
logInfo(
|
|
488
|
-
'Nested items and fenced blocks are excluded. Weight is a judgment, never a defect.',
|
|
489
|
-
)
|
|
490
|
-
|
|
491
|
-
const carrying = entries
|
|
492
|
-
.filter((entry) => entry.heavyBullets.length > 0)
|
|
493
|
-
.sort((a, b) => b.heavyBullets.length - a.heavyBullets.length)
|
|
494
|
-
|
|
495
|
-
if (carrying.length === 0) {
|
|
496
|
-
logInfo(`No bullet past the ${BULLET_CHECKPOINT}-character checkpoint.`)
|
|
497
|
-
return
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
const total = carrying.reduce(
|
|
501
|
-
(sum, entry) => sum + entry.heavyBullets.length,
|
|
502
|
-
0,
|
|
503
|
-
)
|
|
504
|
-
logWarn(
|
|
505
|
-
`${plural(total, 'bullet')} past the ${BULLET_CHECKPOINT}-character checkpoint across ${carrying.length} ${carrying.length === 1 ? 'entry' : 'entries'}`,
|
|
506
|
-
)
|
|
507
|
-
pipeOutput(
|
|
508
|
-
carrying
|
|
509
|
-
.map(
|
|
510
|
-
(entry) =>
|
|
511
|
-
`${entry.rel} ${plural(entry.heavyBullets.length, 'bullet')}\n${entry.heavyBullets
|
|
512
|
-
.map((found) => ` :${found.line} ${found.characters} characters`)
|
|
513
|
-
.join('\n')}`,
|
|
514
|
-
)
|
|
515
|
-
.join('\n'),
|
|
516
|
-
)
|
|
517
|
-
}
|
|
518
|
-
|
|
519
412
|
function reportTables(entries: readonly EntryReport[]): void {
|
|
520
413
|
logStep('Tables')
|
|
521
414
|
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import type { Command } from 'commander'
|
|
4
|
+
import { type BanReport, banReport, loadStandards } from '@/markdown/bans'
|
|
5
|
+
import { resolveMarkdown } from '@/markdown/files'
|
|
6
|
+
import { type BanFinding, bodyLines, scanBans } from '@/markdown/scan'
|
|
7
|
+
import {
|
|
8
|
+
type Checkpoints,
|
|
9
|
+
measureStructure,
|
|
10
|
+
parseCheckpoints,
|
|
11
|
+
type StructureReport,
|
|
12
|
+
} from '@/markdown/structure'
|
|
13
|
+
import {
|
|
14
|
+
intro,
|
|
15
|
+
logInfo,
|
|
16
|
+
logStep,
|
|
17
|
+
logWarn,
|
|
18
|
+
outro,
|
|
19
|
+
pipeOutput,
|
|
20
|
+
plural,
|
|
21
|
+
} from '@/ui'
|
|
22
|
+
|
|
23
|
+
interface AuditCommandOptions {
|
|
24
|
+
readonly json?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface FileReport {
|
|
28
|
+
readonly rel: string
|
|
29
|
+
readonly bans: readonly BanFinding[]
|
|
30
|
+
readonly structure: StructureReport
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function register(program: Command): void {
|
|
34
|
+
const markdown = program
|
|
35
|
+
.command('markdown')
|
|
36
|
+
.description('Report markdown files against the attribute standards')
|
|
37
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
38
|
+
|
|
39
|
+
markdown
|
|
40
|
+
.command('audit')
|
|
41
|
+
.description(
|
|
42
|
+
'Report the character bans, the word bans, and bullet, paragraph, and depth weight',
|
|
43
|
+
)
|
|
44
|
+
.argument(
|
|
45
|
+
'[path...]',
|
|
46
|
+
'Markdown files, directories, or globs, defaulting to every markdown file git lists',
|
|
47
|
+
)
|
|
48
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
49
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
50
|
+
.addHelpText(
|
|
51
|
+
'after',
|
|
52
|
+
[
|
|
53
|
+
'',
|
|
54
|
+
'Exit codes:',
|
|
55
|
+
' 0 the audit completed',
|
|
56
|
+
' 1 refused, with the reason on stderr',
|
|
57
|
+
'',
|
|
58
|
+
'Every finding reports and none gates. A banned character is a fact and',
|
|
59
|
+
'will gate once the corpus has been measured and fixed, which is the',
|
|
60
|
+
'follow-up rather than this command.',
|
|
61
|
+
'',
|
|
62
|
+
'Bans and checkpoints are read from markdown.md and prose.md, resolved',
|
|
63
|
+
'under .claude/standards/ then standards/. No folder has to resolve and',
|
|
64
|
+
'no index.md has to exist, so .claude/rules/, governance/, and snippets/',
|
|
65
|
+
'are in reach.',
|
|
66
|
+
'',
|
|
67
|
+
'Examples:',
|
|
68
|
+
' aitk markdown audit',
|
|
69
|
+
' aitk markdown audit --json',
|
|
70
|
+
' aitk markdown audit .claude/rules governance',
|
|
71
|
+
' aitk markdown audit docs/agents/commands.md',
|
|
72
|
+
" aitk markdown audit 'snippets/**/*.md'",
|
|
73
|
+
'',
|
|
74
|
+
].join('\n'),
|
|
75
|
+
)
|
|
76
|
+
.action(async (paths: string[], opts: AuditCommandOptions) => {
|
|
77
|
+
process.exitCode = await runAudit(paths, opts)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runAudit(
|
|
82
|
+
paths: string[],
|
|
83
|
+
opts: AuditCommandOptions,
|
|
84
|
+
): Promise<number> {
|
|
85
|
+
const root = process.cwd()
|
|
86
|
+
const scope = await resolveMarkdown(root, paths)
|
|
87
|
+
|
|
88
|
+
if (scope.kind === 'unavailable') {
|
|
89
|
+
return refuse(
|
|
90
|
+
'git could not list the tree, so no corpus was built. Run inside a git repository.',
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (scope.files.length === 0) {
|
|
95
|
+
return refuse(
|
|
96
|
+
paths.length === 0
|
|
97
|
+
? 'No markdown file in the tree.'
|
|
98
|
+
: `No markdown file matched: ${scope.unmatched.join(', ')}`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const standards = await loadStandards(root)
|
|
103
|
+
const bans = banReport(standards)
|
|
104
|
+
const checkpoints = parseCheckpoints(standards.markdown?.text ?? '')
|
|
105
|
+
|
|
106
|
+
const reports: FileReport[] = await Promise.all(
|
|
107
|
+
scope.files.map(async (rel) => {
|
|
108
|
+
const lines = bodyLines(await readFile(resolve(root, rel), 'utf8'))
|
|
109
|
+
return {
|
|
110
|
+
rel,
|
|
111
|
+
bans: scanBans(lines, bans),
|
|
112
|
+
structure: measureStructure(rel, lines, checkpoints),
|
|
113
|
+
}
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
intro('aitk markdown audit')
|
|
118
|
+
reportScope(scope.files, scope.unmatched)
|
|
119
|
+
reportBans(reports, bans)
|
|
120
|
+
reportBullets(reports, checkpoints)
|
|
121
|
+
reportParagraphs(reports, checkpoints)
|
|
122
|
+
reportDepth(reports, checkpoints)
|
|
123
|
+
outro()
|
|
124
|
+
|
|
125
|
+
if (opts.json) {
|
|
126
|
+
process.stdout.write(
|
|
127
|
+
`${JSON.stringify({
|
|
128
|
+
root,
|
|
129
|
+
files: scope.files,
|
|
130
|
+
unmatchedPaths: scope.unmatched,
|
|
131
|
+
bans: {
|
|
132
|
+
characters: bans.characters,
|
|
133
|
+
words: bans.words,
|
|
134
|
+
spellings: bans.spellings,
|
|
135
|
+
sources: bans.sources,
|
|
136
|
+
missingStandards: bans.missing,
|
|
137
|
+
},
|
|
138
|
+
checkpoints: {
|
|
139
|
+
run: checkpoints.run,
|
|
140
|
+
peerBullet: checkpoints.peerBullet,
|
|
141
|
+
bullet: checkpoints.bullet,
|
|
142
|
+
paragraph: checkpoints.paragraph,
|
|
143
|
+
sentences: checkpoints.sentences,
|
|
144
|
+
renderWidth: checkpoints.renderWidth,
|
|
145
|
+
fellBack: checkpoints.fellBack,
|
|
146
|
+
},
|
|
147
|
+
entries: reports.map((report) => ({
|
|
148
|
+
path: report.rel,
|
|
149
|
+
bans: report.bans,
|
|
150
|
+
longestRun: report.structure.longestRun,
|
|
151
|
+
longestRunLine: report.structure.longestRunLine,
|
|
152
|
+
heavyBullets: report.structure.heavyBullets,
|
|
153
|
+
heavyParagraphs: report.structure.heavyParagraphs,
|
|
154
|
+
})),
|
|
155
|
+
})}\n`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function refuse(message: string): number {
|
|
163
|
+
intro('aitk markdown audit')
|
|
164
|
+
logStep('Refused')
|
|
165
|
+
logWarn(message)
|
|
166
|
+
outro()
|
|
167
|
+
return 1
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function reportScope(
|
|
171
|
+
files: readonly string[],
|
|
172
|
+
unmatched: readonly string[],
|
|
173
|
+
): void {
|
|
174
|
+
logStep('Scope')
|
|
175
|
+
logInfo(`${plural(files.length, 'markdown file')}`)
|
|
176
|
+
|
|
177
|
+
if (unmatched.length === 0) return
|
|
178
|
+
|
|
179
|
+
logWarn(`Matched no markdown file: ${unmatched.join(', ')}`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Names what the closed sets reach and what they leave to a reader.
|
|
184
|
+
*
|
|
185
|
+
* The two standards state bans in three shapes and only two of them are a
|
|
186
|
+
* closed set. A phrase ban carries a placeholder standing in for the rest of
|
|
187
|
+
* the sentence and every voice rule is a judgment, so a report listing hits
|
|
188
|
+
* without naming those would read as a verdict on the whole standard.
|
|
189
|
+
*/
|
|
190
|
+
function reportBans(reports: readonly FileReport[], bans: BanReport): void {
|
|
191
|
+
logStep('Bans')
|
|
192
|
+
|
|
193
|
+
if (bans.missing.length > 0) {
|
|
194
|
+
logWarn(
|
|
195
|
+
`Not measured. Found neither copy of: ${bans.missing.join(', ')}. Looked under .claude/standards/ then standards/.`,
|
|
196
|
+
)
|
|
197
|
+
if (bans.sources.length === 0) return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
logInfo(
|
|
201
|
+
`${plural(bans.characters.length, 'character')}, ${plural(bans.words.length, 'word')}, and ${plural(bans.spellings.length, 'spelling')} read from ${bans.sources.join(' and ')}`,
|
|
202
|
+
)
|
|
203
|
+
logInfo(
|
|
204
|
+
'Frontmatter, fenced blocks, code spans, and link destinations are excluded.',
|
|
205
|
+
)
|
|
206
|
+
logInfo(
|
|
207
|
+
'Phrase bans and every voice rule are patterns rather than closed sets, and stay a judgment for the reader.',
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
const carrying = reports
|
|
211
|
+
.filter((report) => report.bans.length > 0)
|
|
212
|
+
.sort((a, b) => b.bans.length - a.bans.length)
|
|
213
|
+
|
|
214
|
+
if (carrying.length === 0) {
|
|
215
|
+
logInfo('No banned character, word, or spelling.')
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const total = carrying.reduce((sum, report) => sum + report.bans.length, 0)
|
|
220
|
+
logWarn(`${plural(total, 'hit')} across ${plural(carrying.length, 'file')}`)
|
|
221
|
+
pipeOutput(
|
|
222
|
+
carrying
|
|
223
|
+
.map(
|
|
224
|
+
(report) =>
|
|
225
|
+
`${report.rel} ${plural(report.bans.length, 'hit')}\n${report.bans
|
|
226
|
+
.map(
|
|
227
|
+
(found) =>
|
|
228
|
+
` :${found.line}:${found.column + 1} ${found.kind} ${found.term}`,
|
|
229
|
+
)
|
|
230
|
+
.join('\n')}`,
|
|
231
|
+
)
|
|
232
|
+
.join('\n'),
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function reportBullets(
|
|
237
|
+
reports: readonly FileReport[],
|
|
238
|
+
checkpoints: Checkpoints,
|
|
239
|
+
): void {
|
|
240
|
+
logStep('Bullets')
|
|
241
|
+
logInfo(
|
|
242
|
+
'Top-level bullets measure characters, folding in continuation lines.',
|
|
243
|
+
)
|
|
244
|
+
logInfo(
|
|
245
|
+
'Nested items and fenced blocks are excluded. Weight is a judgment, never a defect.',
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const carrying = reports
|
|
249
|
+
.filter((report) => report.structure.heavyBullets.length > 0)
|
|
250
|
+
.sort(
|
|
251
|
+
(a, b) =>
|
|
252
|
+
b.structure.heavyBullets.length - a.structure.heavyBullets.length,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if (carrying.length === 0) {
|
|
256
|
+
logInfo(`No bullet past the ${checkpoints.bullet}-character checkpoint.`)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const total = carrying.reduce(
|
|
261
|
+
(sum, report) => sum + report.structure.heavyBullets.length,
|
|
262
|
+
0,
|
|
263
|
+
)
|
|
264
|
+
logWarn(
|
|
265
|
+
`${plural(total, 'bullet')} past the ${checkpoints.bullet}-character checkpoint across ${plural(carrying.length, 'file')}`,
|
|
266
|
+
)
|
|
267
|
+
pipeOutput(
|
|
268
|
+
carrying
|
|
269
|
+
.map(
|
|
270
|
+
(report) =>
|
|
271
|
+
`${report.rel} ${plural(report.structure.heavyBullets.length, 'bullet')}\n${report.structure.heavyBullets
|
|
272
|
+
.map((found) => ` :${found.line} ${found.characters} characters`)
|
|
273
|
+
.join('\n')}`,
|
|
274
|
+
)
|
|
275
|
+
.join('\n'),
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* States both halves of the checkpoint on every run.
|
|
281
|
+
*
|
|
282
|
+
* The standard states a sentence cap and a weight, and a report naming only the
|
|
283
|
+
* first would leave a reader unable to tell why a two-sentence paragraph is
|
|
284
|
+
* listed.
|
|
285
|
+
*/
|
|
286
|
+
function reportParagraphs(
|
|
287
|
+
reports: readonly FileReport[],
|
|
288
|
+
checkpoints: Checkpoints,
|
|
289
|
+
): void {
|
|
290
|
+
logStep('Paragraphs')
|
|
291
|
+
logInfo(
|
|
292
|
+
`Prose paragraphs report past ${checkpoints.sentences} sentences or past ${checkpoints.paragraph} characters.`,
|
|
293
|
+
)
|
|
294
|
+
logInfo(
|
|
295
|
+
'The standard states both. Weight matches the bullet checkpoint today and moves independently of it.',
|
|
296
|
+
)
|
|
297
|
+
logInfo(
|
|
298
|
+
'Bullets, headings, tables, quotes, and fenced blocks each end a paragraph, so a bullet is measured once.',
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
const carrying = reports
|
|
302
|
+
.filter((report) => report.structure.heavyParagraphs.length > 0)
|
|
303
|
+
.sort(
|
|
304
|
+
(a, b) =>
|
|
305
|
+
b.structure.heavyParagraphs.length - a.structure.heavyParagraphs.length,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if (carrying.length === 0) {
|
|
309
|
+
logInfo('No paragraph past either checkpoint.')
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const total = carrying.reduce(
|
|
314
|
+
(sum, report) => sum + report.structure.heavyParagraphs.length,
|
|
315
|
+
0,
|
|
316
|
+
)
|
|
317
|
+
logWarn(
|
|
318
|
+
`${plural(total, 'paragraph')} past a checkpoint across ${plural(carrying.length, 'file')}`,
|
|
319
|
+
)
|
|
320
|
+
pipeOutput(
|
|
321
|
+
carrying
|
|
322
|
+
.map(
|
|
323
|
+
(report) =>
|
|
324
|
+
`${report.rel} ${plural(report.structure.heavyParagraphs.length, 'paragraph')}\n${report.structure.heavyParagraphs
|
|
325
|
+
.map(
|
|
326
|
+
(found) =>
|
|
327
|
+
` :${found.line} ${found.sentences} sentences, ${found.characters} characters`,
|
|
328
|
+
)
|
|
329
|
+
.join('\n')}`,
|
|
330
|
+
)
|
|
331
|
+
.join('\n'),
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Names the render width and the blank-line convention on every run.
|
|
337
|
+
*
|
|
338
|
+
* The standard settles heading level and fenced blocks and stops there, so a
|
|
339
|
+
* hand reader who drops blank lines lands a line or two below this number.
|
|
340
|
+
* Stating both keeps the two measurements reconcilable, and the width matters
|
|
341
|
+
* more, since a number counted in rendered lines cannot be reproduced without
|
|
342
|
+
* it.
|
|
343
|
+
*/
|
|
344
|
+
function reportDepth(
|
|
345
|
+
reports: readonly FileReport[],
|
|
346
|
+
checkpoints: Checkpoints,
|
|
347
|
+
): void {
|
|
348
|
+
logStep('Depth')
|
|
349
|
+
logInfo(
|
|
350
|
+
`Runs measure rendered lines at ${checkpoints.renderWidth} columns and count blank lines.`,
|
|
351
|
+
)
|
|
352
|
+
logInfo(
|
|
353
|
+
`Fenced blocks are excluded, and so are peer lists averaging under ${checkpoints.peerBullet} characters a bullet.`,
|
|
354
|
+
)
|
|
355
|
+
logInfo(
|
|
356
|
+
'A run that is entirely table rows is excluded too, since a heading inside a table splits the table rather than the run.',
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if (checkpoints.fellBack.length > 0) {
|
|
360
|
+
logWarn(
|
|
361
|
+
`Read no number from the standard for: ${checkpoints.fellBack.join(', ')}. Measured against the shipped default instead.`,
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const over = reports
|
|
366
|
+
.filter((report) => report.structure.longestRun > checkpoints.run)
|
|
367
|
+
.sort((a, b) => b.structure.longestRun - a.structure.longestRun)
|
|
368
|
+
|
|
369
|
+
if (over.length === 0) {
|
|
370
|
+
logInfo(`No run past the ${checkpoints.run}-line checkpoint.`)
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
logWarn(`${over.length} past the ${checkpoints.run}-line checkpoint`)
|
|
375
|
+
pipeOutput(
|
|
376
|
+
over
|
|
377
|
+
.map(
|
|
378
|
+
(report) =>
|
|
379
|
+
`${report.structure.rel}:${report.structure.longestRunLine} ${report.structure.longestRun} rendered lines unbroken`,
|
|
380
|
+
)
|
|
381
|
+
.join('\n'),
|
|
382
|
+
)
|
|
383
|
+
}
|
package/src/commands/records.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { $ } from 'bun'
|
|
2
1
|
import type { Command } from 'commander'
|
|
3
2
|
import {
|
|
4
3
|
type Finding,
|
|
@@ -8,6 +7,7 @@ import {
|
|
|
8
7
|
validateRecords,
|
|
9
8
|
} from '@/records/validate'
|
|
10
9
|
import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
|
|
10
|
+
import { mainWorktreeRoot } from '@/worktree'
|
|
11
11
|
|
|
12
12
|
/** Returned when a record carries a finding, which is the gating result. */
|
|
13
13
|
const EXIT_FINDINGS = 2
|
|
@@ -17,23 +17,6 @@ interface ValidateCommandOptions {
|
|
|
17
17
|
readonly root?: string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
* The session-record folders are shared scratch at the main worktree root, and
|
|
22
|
-
* `git worktree list` puts that root first. Trusting the working directory would
|
|
23
|
-
* validate a linked worktree's empty folder and report it clean.
|
|
24
|
-
*/
|
|
25
|
-
async function mainWorktreeRoot(): Promise<string> {
|
|
26
|
-
const result = await $`git worktree list --porcelain`.quiet().nothrow()
|
|
27
|
-
if (result.exitCode !== 0) return process.cwd()
|
|
28
|
-
|
|
29
|
-
const line = result.stdout
|
|
30
|
-
.toString()
|
|
31
|
-
.split('\n')
|
|
32
|
-
.find((entry) => entry.startsWith('worktree '))
|
|
33
|
-
|
|
34
|
-
return line ? line.slice('worktree '.length).trim() : process.cwd()
|
|
35
|
-
}
|
|
36
|
-
|
|
37
20
|
export function register(program: Command): void {
|
|
38
21
|
const records = program
|
|
39
22
|
.command('records')
|