@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.
Files changed (39) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-docs/SKILL.md +3 -1
  3. package/claude/skills/claude-feature/SKILL.md +2 -0
  4. package/claude/skills/claude-memory-capture/SKILL.md +9 -2
  5. package/claude/skills/claude-memory-review/SKILL.md +5 -2
  6. package/claude/skills/claude-review/SKILL.md +2 -0
  7. package/claude/skills/claude-screencast/SKILL.md +2 -0
  8. package/claude/skills/claude-seed-sync/SKILL.md +2 -0
  9. package/claude/skills/claude-tasks/SKILL.md +3 -1
  10. package/claude/skills/claude-ui-test/SKILL.md +2 -0
  11. package/claude/skills/claude-ux-audit/SKILL.md +2 -0
  12. package/claude/skills/git-pr/SKILL.md +9 -3
  13. package/docs/agents/commands.md +5 -1
  14. package/docs/agents/context-audit-checks.md +9 -11
  15. package/docs/agents/context-audit.md +3 -1
  16. package/docs/agents/index.md +3 -2
  17. package/docs/agents/markdown-audit.md +70 -0
  18. package/docs/agents/tasks.md +51 -2
  19. package/docs/ai-workflow.md +1 -1
  20. package/package.json +1 -1
  21. package/src/cli.ts +4 -0
  22. package/src/commands/context.ts +8 -115
  23. package/src/commands/markdown.ts +383 -0
  24. package/src/commands/records.ts +1 -18
  25. package/src/commands/tasks.ts +314 -19
  26. package/src/context/audit.ts +34 -309
  27. package/src/context/citations.ts +2 -30
  28. package/src/git-files.ts +31 -0
  29. package/src/markdown/bans.ts +241 -0
  30. package/src/markdown/files.ts +92 -0
  31. package/src/markdown/scan.ts +183 -0
  32. package/src/markdown/structure.ts +408 -0
  33. package/src/records/validate.ts +1 -37
  34. package/src/tasks/archive.ts +34 -3
  35. package/src/tasks/record.ts +311 -0
  36. package/src/worktree.ts +23 -0
  37. package/standards/context.md +2 -7
  38. package/standards/markdown.md +10 -2
  39. package/tooling/claude/seeds/CLAUDE.md +1 -0
@@ -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, depth, bullet weight, citations, provenance, and index drift',
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, depth, bullet, table, and',
86
- 'provenance findings are thresholds and stay advisory under both.',
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
+ }
@@ -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')