@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,44 +1,19 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { relative } from 'node:path'
3
3
  import type { AuditedFolder } from '@/context/folders'
4
+ import { type BodyLine, bodyLines } from '@/markdown/scan'
5
+ import { renderedHeight } from '@/markdown/structure'
4
6
  import { isStubSeed } from '@/seed-marker'
5
7
 
6
- /** Checkpoints quoted from `standards/context.md`. Neither is a cap. */
7
- export const LENGTH_CHECKPOINT = 150
8
- export const RUN_CHECKPOINT = 40
9
-
10
- /**
11
- * Columns a source line wraps at when rendered.
12
- *
13
- * Nothing in this repository sets a line width and entries are authored one
14
- * line per bullet, so the rendered width is the viewer's rather than the file's.
15
- * The common terminal and diff width is the reproducible choice, and the report
16
- * legend states it so a reader can arrive at the same number by hand.
17
- */
18
- export const RENDER_WIDTH = 80
19
-
20
8
  /**
21
- * Characters a bullet averages before its list stops reading as a set of peers.
9
+ * Checkpoint quoted from the standard stating it. It is not a cap.
22
10
  *
23
- * The exemption below covers a flat catalog of one-liners and a stack of
24
- * paragraphs equally, and only the first is navigable. Measured across this
25
- * corpus the two shapes separate with nothing between roughly 100 and 170
26
- * characters a bullet, so the midpoint splits the population rather than a
27
- * continuum. It is a checkpoint like the two above, not a cap.
11
+ * Entry length rests on one entry per domain, which is a domain fact, so
12
+ * `standards/context.md` keeps it. Depth and bullet weight read the same over
13
+ * any markdown file, so they are stated at the attribute tier and measured by
14
+ * `aitk markdown audit` rather than here.
28
15
  */
29
- export const PEER_BULLET_CHECKPOINT = 130
30
-
31
- /**
32
- * Characters a bullet carries before it holds more than the decision itself.
33
- *
34
- * Unlike the peer-list checkpoint above, this corpus has no gap behind the
35
- * number. Bullet weight decays smoothly from a median near 170 with the
36
- * steepest relative fall across this boundary and nothing resembling two
37
- * populations, so the number is a judgment where that one was a measurement.
38
- * A bullet that reads well past it means the number is wrong rather than the
39
- * rule, which is why this reports and never gates.
40
- */
41
- export const BULLET_CHECKPOINT = 400
16
+ export const LENGTH_CHECKPOINT = 150
42
17
 
43
18
  /**
44
19
  * A table this size or larger whose first column mostly names artifacts reads
@@ -66,11 +41,7 @@ const CATALOG_NAMED_RATIO = 0.6
66
41
  */
67
42
  export const REQUIRED_SECTIONS: readonly string[] = ['Overview', 'Layout']
68
43
 
69
- const FRONTMATTER = /^---\n[\s\S]*?\n---\n?/
70
- const FENCE = /^\s*(```|~~~)/
71
- const HEADING = /^#{1,6}\s/
72
44
  const HEADING_TEXT = /^#{1,6}\s+(.+?)\s*$/
73
- const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
74
45
  const TABLE_ROW = /^\s*\|/
75
46
  const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
76
47
  const NAMED_CELL = /`[^`]+`|\[[^\]]+\]\([^)]+\)/
@@ -95,25 +66,21 @@ const PROVENANCE: readonly { kind: ProvenanceKind; pattern: RegExp }[] = [
95
66
  *
96
67
  * `standards/context.md` opens its scope by handing diagrams and wireframes to
97
68
  * `diagrams.md` and `wireframes.md`, so a marker reported in either would cite
98
- * a rule that entry's own standard routes elsewhere. The length, depth, and
99
- * table checkpoints are quoted from the same standard and keep reaching every
100
- * audited folder, because a threshold on how far a reader travels generalizes
101
- * across entry types while a rule about what an entry may say does not.
69
+ * a rule that entry's own standard routes elsewhere. The length and table
70
+ * checkpoints are quoted from the same standard and keep reaching every audited
71
+ * folder, because a threshold on how far a reader travels generalizes across
72
+ * entry types while a rule about what an entry may say does not.
102
73
  *
103
- * Bullet weight sits on this side of the split with provenance, which the
104
- * measure alone would not predict. What decides it is whether the remedy is
105
- * actionable: subdividing a run and splitting a file mean something in any
106
- * entry, while moving an incident out of a bullet and keeping the decision
107
- * means nothing in a folder whose entries carry no decisions to keep. No
108
- * diagram entry declares `## Decisions` or `## Gotchas`, so the scope of the
109
- * rule is what narrows the finding rather than the shape of the number.
74
+ * What gates here is a rule only this standard states. Bullet weight does not,
75
+ * since `standards/markdown.md` owns that checkpoint across document types and
76
+ * its remedy sends the overflow to prose, which any entry type can act on.
77
+ * `standards/context.md` specializes that remedy for an entry carrying
78
+ * decisions, and specializing a rule narrows the advice rather than the measure.
110
79
  *
111
- * Restating the exclusion in the sibling standards was the alternative. It
112
- * duplicates one knowledge item across three surfaces, which the root
113
- * instruction file forbids, and pointing is not available because the surface
114
- * they would point at is the one disclaiming them. Should a diagram entry ever
115
- * accumulate narration, the escalation is an attribute standard owning the rule
116
- * across document types, not restoring this reach without an owner.
80
+ * Restating the exclusion in the sibling standards was the alternative for what
81
+ * gates here. It duplicates one knowledge item across three surfaces, which the
82
+ * root instruction file forbids, and pointing is not available because the
83
+ * surface they would point at is the one disclaiming them.
117
84
  */
118
85
  export const PROVENANCE_FOLDER = 'context'
119
86
 
@@ -124,12 +91,6 @@ export interface TableFinding {
124
91
  readonly rows: number
125
92
  }
126
93
 
127
- export interface BulletFinding {
128
- readonly line: number
129
- /** Weight as folded, so a report says how far past the checkpoint it sits. */
130
- readonly characters: number
131
- }
132
-
133
94
  export interface ProvenanceFinding {
134
95
  readonly line: number
135
96
  readonly kind: ProvenanceKind
@@ -141,26 +102,20 @@ export interface EntryReport {
141
102
  readonly rel: string
142
103
  /**
143
104
  * Rendered lines across the whole file, counting frontmatter and fenced
144
- * blocks. Both this and `longestRun` measure in the same unit, since the two
145
- * checkpoints they feed sit in one section of the standard and a reader
146
- * compares them.
105
+ * blocks. It shares `renderedHeight` with the depth checkpoint in
106
+ * `src/markdown/structure.ts`, since the two sit in one section of the
107
+ * standard and a reader compares them.
147
108
  *
148
- * The exclusions differ on purpose. `longestRun` skips a fence so an example
149
- * cannot break the run around it, and a file measure has no run to protect.
150
- * Excluding fences here would change which entries report by one and would
151
- * not reach the case that motivates it: the most fenced entry in the corpus
152
- * runs 20 percent fenced and sits past the checkpoint either way.
109
+ * Fences are counted here and excluded there. The depth measure skips one so
110
+ * an example cannot break the run around it, and a file measure has no run to
111
+ * protect. Excluding them here would change which entries report by one and
112
+ * would not reach the case that motivates it: the most fenced entry in the
113
+ * corpus runs 20 percent fenced and sits past the checkpoint either way.
153
114
  */
154
115
  readonly lines: number
155
- /** Rendered lines at `RENDER_WIDTH`, not source lines. */
156
- readonly longestRun: number
157
- /** First line of the longest run, or 0 when the entry has no run at all. */
158
- readonly longestRunLine: number
159
116
  readonly catalogTables: readonly TableFinding[]
160
117
  /** Empty for an entry no standard bans a change narrative in. */
161
118
  readonly provenance: readonly ProvenanceFinding[]
162
- /** Empty for the same reason `provenance` is, and under the same folder. */
163
- readonly heavyBullets: readonly BulletFinding[]
164
119
  /**
165
120
  * Required sections this entry declares, in the standard's order, and empty
166
121
  * outside the folder whose standard names them. What the folder is short of
@@ -186,163 +141,6 @@ export interface SectionFinding {
186
141
  readonly missing: readonly string[]
187
142
  }
188
143
 
189
- interface BodyLine {
190
- readonly number: number
191
- readonly text: string
192
- }
193
-
194
- /**
195
- * Drops the frontmatter while keeping every surviving line's original number,
196
- * so a finding points at the line an editor opens rather than at an offset into
197
- * the body.
198
- */
199
- function bodyLines(source: string): BodyLine[] {
200
- const match = source.match(FRONTMATTER)
201
- const offset = match ? match[0].split('\n').length - 1 : 0
202
-
203
- return source
204
- .slice(match ? match[0].length : 0)
205
- .replace(/\n$/, '')
206
- .split('\n')
207
- .map((text, index) => ({ number: offset + index + 1, text }))
208
- }
209
-
210
- /**
211
- * Reports whether a run is the peer list the standard exempts.
212
- *
213
- * Every non-blank line has to be a list item at one indent. Prose mixed into
214
- * the run or a nested level inside it ends the exemption, because either one
215
- * means the block is no longer a flat set a reader can skim. Bullet count says
216
- * nothing on its own, since a catalog of one-liners and a wall of paragraphs
217
- * reach the same count and read nothing alike, so the average bullet is what
218
- * decides whether the set is still skimmable.
219
- */
220
- function isScannablePeerList(run: readonly BodyLine[]): boolean {
221
- const indents = new Set<number>()
222
- let items = 0
223
- let characters = 0
224
-
225
- for (const line of run) {
226
- const text = line.text.trim()
227
- if (text === '') continue
228
-
229
- const match = line.text.match(LIST_ITEM)
230
- if (!match) return false
231
- indents.add(match[1].length)
232
- items++
233
- characters += text.length
234
- }
235
-
236
- if (indents.size !== 1) return false
237
-
238
- return characters / items < PEER_BULLET_CHECKPOINT
239
- }
240
-
241
- /**
242
- * Reports whether a run is a table, the second shape the checkpoint cannot fix.
243
- *
244
- * The peer list above is exempt because it is already navigable. A table is
245
- * exempt for the other reason: the remedy does not exist. A heading dropped
246
- * inside one splits the table into two tables rather than breaking the run, so
247
- * a catalog renders as an unbroken stretch by construction and no edit short of
248
- * rewriting it as a list clears the report.
249
- *
250
- * Every non-blank line has to be a row. A run holding a table between
251
- * paragraphs is genuinely mixed, and a heading breaks it at a seam either side,
252
- * so testing whether the run holds a table would hide the case the checkpoint
253
- * exists for.
254
- *
255
- * A delimiter is required rather than assumed, matching the table scan below. A
256
- * stack of lines opening with a pipe and no delimiter renders as paragraph text
257
- * and would otherwise earn the exemption on its punctuation.
258
- */
259
- function isTableRun(run: readonly BodyLine[]): boolean {
260
- let separators = 0
261
-
262
- for (const line of run) {
263
- if (line.text.trim() === '') continue
264
- if (!TABLE_ROW.test(line.text)) return false
265
- if (TABLE_SEPARATOR.test(line.text)) separators++
266
- }
267
-
268
- return separators > 0
269
- }
270
-
271
- /**
272
- * Height a source line occupies once wrapped.
273
- *
274
- * A blank line renders as the gap it is rather than as nothing, which keeps it
275
- * the distance the source measure already counted it as.
276
- */
277
- function renderedHeight(text: string): number {
278
- return Math.max(1, Math.ceil(text.length / RENDER_WIDTH))
279
- }
280
-
281
- /**
282
- * Measures the longest run of lines no heading breaks, in rendered lines.
283
- *
284
- * Fenced blocks are skipped rather than treated as breaks, per the standard:
285
- * they leave the count without ending the run, so prose either side of an
286
- * example still measures as the one stretch a reader scrolls through. Blank
287
- * lines do count, since the checkpoint is about how far a reader travels
288
- * between signposts and a blank line is distance like any other. A hand reader
289
- * measuring without them lands one or two lines lower, which the report legend
290
- * states.
291
- *
292
- * Height is what a reader travels, and source lines only stand in for it while
293
- * lines stay short. An entry authored one line per bullet puts a paragraph on
294
- * each, so a block of fifteen bullets measures as fifteen and renders past
295
- * sixty. Wrapping every line at a stated width is what closes that gap.
296
- */
297
- function longestRun(lines: readonly BodyLine[]): {
298
- length: number
299
- line: number
300
- } {
301
- let longest = 0
302
- let longestLine = 0
303
- let run: BodyLine[] = []
304
- let fenced = false
305
-
306
- const close = (): void => {
307
- // The reported line is the run's first non-blank one, since that is what an
308
- // editor should open. A run of nothing but blank lines is the gap between
309
- // two headings rather than a stretch a reader travels, so it never counts.
310
- const first = run.find((line) => line.text.trim() !== '')
311
-
312
- if (first && !isScannablePeerList(run) && !isTableRun(run)) {
313
- const height = run.reduce(
314
- (sum, line) => sum + renderedHeight(line.text),
315
- 0,
316
- )
317
-
318
- if (height > longest) {
319
- longest = height
320
- longestLine = first.number
321
- }
322
- }
323
- run = []
324
- }
325
-
326
- for (const line of lines) {
327
- if (FENCE.test(line.text)) {
328
- fenced = !fenced
329
- continue
330
- }
331
- if (fenced) continue
332
-
333
- if (HEADING.test(line.text)) {
334
- close()
335
- continue
336
- }
337
-
338
- run.push(line)
339
- }
340
-
341
- close()
342
-
343
- return { length: longest, line: longestLine }
344
- }
345
-
346
144
  function firstCell(row: string): string {
347
145
  return row.split('|').slice(1)[0] ?? ''
348
146
  }
@@ -355,20 +153,15 @@ function firstCell(row: string): string {
355
153
  * catalog that gains a row per artifact, and a first column carrying a path,
356
154
  * command, or link is what separates the two without reading the prose.
357
155
  */
358
- function catalogTables(lines: readonly BodyLine[]): TableFinding[] {
156
+ function catalogTables(entry: readonly BodyLine[]): TableFinding[] {
359
157
  const findings: TableFinding[] = []
360
- let fenced = false
158
+ const lines = entry.filter((line) => !line.fenced)
361
159
  let index = 0
362
160
 
363
161
  while (index < lines.length) {
364
162
  const line = lines[index]
365
163
 
366
- if (FENCE.test(line.text)) {
367
- fenced = !fenced
368
- index++
369
- continue
370
- }
371
- if (fenced || !TABLE_ROW.test(line.text)) {
164
+ if (!TABLE_ROW.test(line.text)) {
372
165
  index++
373
166
  continue
374
167
  }
@@ -400,60 +193,6 @@ function catalogTables(lines: readonly BodyLine[]): TableFinding[] {
400
193
  return findings
401
194
  }
402
195
 
403
- /**
404
- * Finds the top-level bullets carrying more than a decision.
405
- *
406
- * A nested item is left out rather than folded into its parent, since the
407
- * checkpoint asks what one bullet carries and a child carries its own. Lines
408
- * continuing a bullet do fold in, so a heavy bullet cannot fall under the
409
- * checkpoint by being wrapped across two source lines. Fenced blocks are
410
- * skipped for the reason the scans above skip them: a sample an entry displays
411
- * is not a claim it makes.
412
- */
413
- function heavyBullets(lines: readonly BodyLine[]): BulletFinding[] {
414
- const findings: BulletFinding[] = []
415
- let open: BulletFinding | null = null
416
- let fenced = false
417
-
418
- const close = (): void => {
419
- if (open && open.characters > BULLET_CHECKPOINT) findings.push(open)
420
- open = null
421
- }
422
-
423
- for (const line of lines) {
424
- if (FENCE.test(line.text)) {
425
- fenced = !fenced
426
- close()
427
- continue
428
- }
429
- if (fenced) continue
430
-
431
- const item = line.text.match(LIST_ITEM)
432
- const text = line.text.trim()
433
-
434
- if (item) {
435
- close()
436
- if (item[1].length === 0) {
437
- open = { line: line.number, characters: text.length }
438
- }
439
- continue
440
- }
441
-
442
- if (text === '' || HEADING.test(line.text) || TABLE_ROW.test(line.text)) {
443
- close()
444
- continue
445
- }
446
-
447
- // The joining space a wrapped line would have carried, so folding two
448
- // source lines measures what one unwrapped line would have.
449
- if (open) open = { ...open, characters: open.characters + text.length + 1 }
450
- }
451
-
452
- close()
453
-
454
- return findings
455
- }
456
-
457
196
  /**
458
197
  * Finds the markers narrating a change rather than describing the domain.
459
198
  *
@@ -468,14 +207,9 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
468
207
  // holding a date and two change numbers reports them in an order the reader
469
208
  // cannot find by scanning left to right.
470
209
  const found: { finding: ProvenanceFinding; column: number }[] = []
471
- let fenced = false
472
210
 
473
211
  for (const line of lines) {
474
- if (FENCE.test(line.text)) {
475
- fenced = !fenced
476
- continue
477
- }
478
- if (fenced) continue
212
+ if (line.fenced) continue
479
213
 
480
214
  for (const { kind, pattern } of PROVENANCE) {
481
215
  for (const match of line.text.matchAll(pattern)) {
@@ -508,14 +242,9 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
508
242
  */
509
243
  function declaredSections(lines: readonly BodyLine[]): string[] {
510
244
  const found = new Set<string>()
511
- let fenced = false
512
245
 
513
246
  for (const line of lines) {
514
- if (FENCE.test(line.text)) {
515
- fenced = !fenced
516
- continue
517
- }
518
- if (fenced) continue
247
+ if (line.fenced) continue
519
248
 
520
249
  const match = line.text.match(HEADING_TEXT)
521
250
  if (match && REQUIRED_SECTIONS.includes(match[1])) found.add(match[1])
@@ -537,7 +266,6 @@ export function measureEntry(
537
266
  governsContent = true,
538
267
  ): EntryReport {
539
268
  const lines = bodyLines(source)
540
- const run = longestRun(lines)
541
269
 
542
270
  return {
543
271
  rel,
@@ -545,11 +273,8 @@ export function measureEntry(
545
273
  .replace(/\n$/, '')
546
274
  .split('\n')
547
275
  .reduce((sum, text) => sum + renderedHeight(text), 0),
548
- longestRun: run.length,
549
- longestRunLine: run.line,
550
276
  catalogTables: catalogTables(lines),
551
277
  provenance: governsContent ? provenance(lines) : [],
552
- heavyBullets: governsContent ? heavyBullets(lines) : [],
553
278
  sections: governsContent ? declaredSections(lines) : [],
554
279
  stub: isStubSeed(source),
555
280
  }
@@ -1,8 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { resolve } from 'node:path'
4
- import { $ } from 'bun'
5
- import { gitEnv } from '@/git-env'
4
+ import { listRepositoryFiles } from '@/git-files'
6
5
 
7
6
  /**
8
7
  * Suppresses citation checking for the source line carrying it.
@@ -81,33 +80,6 @@ export function collectCitations(
81
80
  return found
82
81
  }
83
82
 
84
- /**
85
- * Lists the files a citation can live in: tracked, plus untracked files git
86
- * does not ignore. The untracked half is what keeps a new entry's references
87
- * checked on the branch that adds it rather than one push later.
88
- *
89
- * Returns undefined when git cannot answer, which the caller reports rather
90
- * than smoothing into an empty list. An empty list resolves every one of its
91
- * zero citations, so a degraded git would otherwise turn the push gate into an
92
- * unconditional pass with output indistinguishable from a clean tree.
93
- */
94
- async function listFiles(root: string): Promise<string[] | undefined> {
95
- const run = async (args: string[]): Promise<string[] | undefined> => {
96
- const result = await $`git -C ${root} ${args}`
97
- .env(gitEnv())
98
- .quiet()
99
- .nothrow()
100
- if (result.exitCode !== 0) return undefined
101
- return result.text().split('\n').filter(Boolean)
102
- }
103
-
104
- const tracked = await run(['ls-files'])
105
- const untracked = await run(['ls-files', '--others', '--exclude-standard'])
106
- if (!tracked || !untracked) return undefined
107
-
108
- return [...new Set([...tracked, ...untracked])].sort()
109
- }
110
-
111
83
  /**
112
84
  * `unavailable` is a distinct state from a clean scan.
113
85
  *
@@ -136,7 +108,7 @@ export async function auditCitations(
136
108
  folders: readonly string[],
137
109
  ): Promise<CitationReport> {
138
110
  const pattern = citationPattern(folders)
139
- const listed = await listFiles(root)
111
+ const listed = await listRepositoryFiles(root)
140
112
  if (!listed) return { kind: 'unavailable' }
141
113
 
142
114
  const candidates = listed.filter((rel) => !isFixture(rel))
@@ -0,0 +1,31 @@
1
+ import { $ } from 'bun'
2
+ import { gitEnv } from '@/git-env'
3
+
4
+ /**
5
+ * Lists the files under `root`: tracked, plus untracked files git does not
6
+ * ignore. The untracked half is what keeps a file added on this branch in scope
7
+ * rather than one push later.
8
+ *
9
+ * Returns undefined when git cannot answer, which every caller reports rather
10
+ * than smoothing into an empty list. An empty list passes each of its zero
11
+ * files, so a degraded git would otherwise be indistinguishable from a clean
12
+ * tree in the output.
13
+ */
14
+ export async function listRepositoryFiles(
15
+ root: string,
16
+ ): Promise<string[] | undefined> {
17
+ const run = async (args: string[]): Promise<string[] | undefined> => {
18
+ const result = await $`git -C ${root} ${args}`
19
+ .env(gitEnv())
20
+ .quiet()
21
+ .nothrow()
22
+ if (result.exitCode !== 0) return undefined
23
+ return result.text().split('\n').filter(Boolean)
24
+ }
25
+
26
+ const tracked = await run(['ls-files'])
27
+ const untracked = await run(['ls-files', '--others', '--exclude-standard'])
28
+ if (!tracked || !untracked) return undefined
29
+
30
+ return [...new Set([...tracked, ...untracked])].sort()
31
+ }