@erclx/aitk 1.2.0 → 1.4.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.
@@ -1,12 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { ensureYtDlp, fetchOne } from '@/transcripts/fetch'
4
-
5
- const GREY = '\x1b[0;90m'
6
- const WHITE = '\x1b[1;37m'
7
- const RED = '\x1b[0;31m'
8
- const GREEN = '\x1b[0;32m'
9
- const NC = '\x1b[0m'
4
+ import { palette } from '@/ui'
10
5
 
11
6
  interface TranscriptOptions {
12
7
  out: string
@@ -24,6 +19,7 @@ export function register(program: Command): void {
24
19
  )
25
20
  .action(async (url: string, opts: TranscriptOptions) => {
26
21
  const outDir = resolve(process.cwd(), opts.out)
22
+ const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
27
23
  process.stderr.write(
28
24
  `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}aitk transcripts${NC}\n`,
29
25
  )
@@ -9,6 +9,7 @@ import {
9
9
  logStep,
10
10
  logWarn,
11
11
  outro,
12
+ palette,
12
13
  select,
13
14
  } from '@/ui'
14
15
  import {
@@ -20,9 +21,6 @@ import {
20
21
  WIKI_INDEX_REL,
21
22
  } from '@/wiki/init'
22
23
 
23
- const GREEN = '\x1b[0;32m'
24
- const NC = '\x1b[0m'
25
-
26
24
  export function register(program: Command): void {
27
25
  const wiki = program
28
26
  .command('wiki')
@@ -59,6 +57,7 @@ async function runInit(target: string): Promise<number> {
59
57
  }
60
58
 
61
59
  const plan = planWikiInit(resolved)
60
+ const { GREEN, NC } = palette(process.stderr)
62
61
 
63
62
  logStep(`Scanning ${WIKI_DIR_REL}`)
64
63
  if (plan.changes.includes('dir')) logAdd(WIKI_DIR_REL)
@@ -1,6 +1,19 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
 
3
- export type Row = Record<string, string>
3
+ /**
4
+ * One table cell, split into the value a renderer emits and whether the record
5
+ * marked it as unsourced.
6
+ *
7
+ * The marker sits inside the cell rather than in a trailing column, because a
8
+ * trailing marker breaks the table parse. Splitting it out here is what keeps a
9
+ * swatch or a font sample built from the value alone.
10
+ */
11
+ export interface Cell {
12
+ value: string
13
+ tagged: boolean
14
+ }
15
+
16
+ export type Row = Record<string, Cell>
4
17
 
5
18
  export interface DesignDoc {
6
19
  personality: string
@@ -12,6 +25,11 @@ export interface DesignDoc {
12
25
  iconography: string
13
26
  }
14
27
 
28
+ const VERIFY_TAG = /\s*\?\s*verify\s*$/
29
+
30
+ /** A cell whose whole content is one balanced code span and nothing else. */
31
+ const CODE_SPAN = /^`([^`]*)`$/
32
+
15
33
  export function parseDesignDoc(path: string): DesignDoc {
16
34
  const raw = readFileSync(path, 'utf8')
17
35
  const sections = splitSections(raw)
@@ -58,22 +76,41 @@ function table(body: string | undefined): Row[] {
58
76
  if (!body) return []
59
77
  const rows = body.split('\n').filter((l) => l.trim().startsWith('|'))
60
78
  if (rows.length < 2) return []
61
- const headers = splitRow(rows[0])
79
+ const headers = splitRow(rows[0]).map((c) => c.value)
62
80
  const data = rows.slice(2)
63
81
  return data.map((line) => {
64
82
  const cells = splitRow(line)
65
83
  const row: Row = {}
66
84
  headers.forEach((h, i) => {
67
- row[h] = (cells[i] ?? '').trim()
85
+ row[h] = cells[i] ?? emptyCell()
68
86
  })
69
87
  return row
70
88
  })
71
89
  }
72
90
 
73
- function splitRow(line: string): string[] {
91
+ function emptyCell(): Cell {
92
+ return { value: '', tagged: false }
93
+ }
94
+
95
+ function splitRow(line: string): Cell[] {
74
96
  return line
75
97
  .replace(/^\s*\|/, '')
76
98
  .replace(/\|\s*$/, '')
77
99
  .split('|')
78
- .map((c) => c.trim().replace(/\s*\?\s*verify\s*$/, ''))
100
+ .map(parseCell)
101
+ }
102
+
103
+ /**
104
+ * The tag is tested against the cell with any surrounding code span removed,
105
+ * since a value wrapping itself in backticks puts one after the tag and an
106
+ * end-anchored test misses it there. The span is restored around the clean
107
+ * value so an untagged cell and a tagged one carry the same formatting.
108
+ */
109
+ function parseCell(raw: string): Cell {
110
+ const trimmed = raw.trim()
111
+ const span = trimmed.match(CODE_SPAN)
112
+ const inner = span ? span[1] : trimmed
113
+ if (!VERIFY_TAG.test(inner)) return { value: trimmed, tagged: false }
114
+ const value = inner.replace(VERIFY_TAG, '')
115
+ return { value: span ? `\`${value}\`` : value, tagged: true }
79
116
  }
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import type { DesignDoc, Row } from '@/design/parse'
3
+ import type { Cell, DesignDoc, Row } from '@/design/parse'
4
4
  import { parseDesignDoc } from '@/design/parse'
5
5
 
6
6
  export interface RenderResult {
@@ -28,29 +28,99 @@ function slug(s: string): string {
28
28
  .replace(/(^-|-$)/g, '')
29
29
  }
30
30
 
31
+ function cell(row: Row, key: string): Cell {
32
+ return row[key] ?? { value: '', tagged: false }
33
+ }
34
+
35
+ /** The value alone. Every swatch, sample, and custom property is built from it. */
36
+ function val(row: Row, key: string): string {
37
+ return cell(row, key).value
38
+ }
39
+
40
+ /** The marker, rendered beside a value rather than inside it. */
41
+ function mark(row: Row, key: string): string {
42
+ return cell(row, key).tagged
43
+ ? ' <span class="verify" title="No source anchors this value">? verify</span>'
44
+ : ''
45
+ }
46
+
47
+ /** A displayed cell: its escaped text, then its marker when it carries one. */
48
+ function cellText(row: Row, key: string): string {
49
+ return escape(val(row, key)) + mark(row, key)
50
+ }
51
+
52
+ interface Confidence {
53
+ tagged: number
54
+ total: number
55
+ }
56
+
57
+ /**
58
+ * The columns the confidence ratio reads, fixed by `standards/design.md`. The
59
+ * first column of each table names its row, and `Multiplier` and `When used`
60
+ * restate what the row already carries, so none of them is something a source
61
+ * could anchor and none belongs in the denominator.
62
+ */
63
+ const ANCHORABLE = {
64
+ borders: ['Radius', 'Width'],
65
+ color: ['Intent', 'Value'],
66
+ spacing: ['Value'],
67
+ typography: ['Family', 'Weight', 'Size', 'Line height'],
68
+ } as const
69
+
70
+ /**
71
+ * A cell counts when it carries a tag, or when it holds a value in a column a
72
+ * source could anchor. A blank the record left unfilled is neither, and so is a
73
+ * row name. Counting a tagged cell whichever column it sits in is what keeps a
74
+ * marker the preview draws from sitting outside the ratio printed beside it.
75
+ */
76
+ function confidence(doc: DesignDoc): Confidence {
77
+ const tables: ReadonlyArray<readonly [Row[], readonly string[]]> = [
78
+ [doc.color, ANCHORABLE.color],
79
+ [doc.typography, ANCHORABLE.typography],
80
+ [doc.spacing, ANCHORABLE.spacing],
81
+ [doc.borders, ANCHORABLE.borders],
82
+ ]
83
+ let tagged = 0
84
+ let total = 0
85
+ for (const [rows, columns] of tables) {
86
+ for (const row of rows) {
87
+ for (const [key, c] of Object.entries(row)) {
88
+ if (!c.tagged && (!c.value || !columns.includes(key))) continue
89
+ total += 1
90
+ if (c.tagged) tagged += 1
91
+ }
92
+ }
93
+ }
94
+ return { tagged, total }
95
+ }
96
+
31
97
  function buildCss(doc: DesignDoc): string {
32
98
  const lines: string[] = [':root {']
33
99
  for (const row of doc.color) {
34
- if (row['Value']) {
35
- lines.push(` --color-${slug(row['Role'])}: ${row['Value']};`)
100
+ if (val(row, 'Value')) {
101
+ lines.push(` --color-${slug(val(row, 'Role'))}: ${val(row, 'Value')};`)
36
102
  }
37
103
  }
38
104
  for (const row of doc.spacing) {
39
- if (row['Value']) {
40
- lines.push(` --space-${slug(row['Step'])}: ${row['Value']};`)
105
+ if (val(row, 'Value')) {
106
+ lines.push(` --space-${slug(val(row, 'Step'))}: ${val(row, 'Value')};`)
41
107
  }
42
108
  }
43
109
  for (const row of doc.typography) {
44
- if (row['Size']) {
45
- lines.push(` --type-${slug(row['Role'])}-size: ${row['Size']};`)
110
+ if (val(row, 'Size')) {
111
+ lines.push(
112
+ ` --type-${slug(val(row, 'Role'))}-size: ${val(row, 'Size')};`,
113
+ )
46
114
  }
47
- if (row['Line height']) {
48
- lines.push(` --type-${slug(row['Role'])}-lh: ${row['Line height']};`)
115
+ if (val(row, 'Line height')) {
116
+ lines.push(
117
+ ` --type-${slug(val(row, 'Role'))}-lh: ${val(row, 'Line height')};`,
118
+ )
49
119
  }
50
120
  }
51
121
  for (const row of doc.borders) {
52
- if (row['Radius']) {
53
- lines.push(` --radius-${slug(row['Role'])}: ${row['Radius']};`)
122
+ if (val(row, 'Radius')) {
123
+ lines.push(` --radius-${slug(val(row, 'Role'))}: ${val(row, 'Radius')};`)
54
124
  }
55
125
  }
56
126
  lines.push('}')
@@ -75,6 +145,14 @@ function buildHtml(doc: DesignDoc): string {
75
145
  sectionLine('Motion', doc.motion),
76
146
  sectionLine('Iconography', doc.iconography),
77
147
  ]
148
+ const { tagged, total } = confidence(doc)
149
+ const verifyStyle = tagged
150
+ ? '\n .verify { color: #a4471c; font-size: 12px; font-weight: 600; margin-left: 0.35rem; white-space: nowrap; }'
151
+ : ''
152
+ const verb = tagged === 1 ? 'carries' : 'carry'
153
+ const summary = tagged
154
+ ? `\n<p class="note">${total - tagged} of ${total} cells are anchored to a source. The other ${tagged} ${verb} <code>? verify</code>, so nothing anchors them yet.</p>`
155
+ : ''
78
156
  return `<!doctype html>
79
157
  <html lang="en">
80
158
  <head>
@@ -91,12 +169,12 @@ function buildHtml(doc: DesignDoc): string {
91
169
  .swatch { display: inline-block; width: 1.5rem; height: 1.5rem; border-radius: 4px; border: 1px solid #ddd; vertical-align: middle; margin-right: 0.5rem; }
92
170
  .bar { display: inline-block; height: 1rem; background: #888; border-radius: 2px; vertical-align: middle; }
93
171
  .note { color: #666; font-size: 13px; margin-top: 0.5rem; }
94
- .empty { color: #999; font-style: italic; }
172
+ .empty { color: #999; font-style: italic; }${verifyStyle}
95
173
  </style>
96
174
  </head>
97
175
  <body>
98
176
  <h1>Design tokens</h1>
99
- <p class="note">Generated from <code>.claude/DESIGN.md</code> by <code>aitk design render</code>. Token preview only, not a screen mock.</p>
177
+ <p class="note">Generated from <code>.claude/DESIGN.md</code> by <code>aitk design render</code>. Token preview only, not a screen mock.</p>${summary}
100
178
  ${sections.join('\n')}
101
179
  </body>
102
180
  </html>
@@ -112,11 +190,12 @@ function sectionColor(rows: Row[]): string {
112
190
  if (!rows.length) return ''
113
191
  const body = rows
114
192
  .map((r) => {
115
- const swatch = r['Value']
116
- ? `<span class="swatch" style="background:${escape(r['Value'])}"></span>`
193
+ const value = val(r, 'Value')
194
+ const swatch = value
195
+ ? `<span class="swatch" style="background:${escape(value)}"></span>`
117
196
  : '<span class="swatch"></span>'
118
- const value = r['Value'] || '<span class="empty">unset</span>'
119
- return `<tr><td>${swatch}${escape(r['Role'] ?? '')}</td><td>${escape(r['Intent'] ?? '')}</td><td><code>${value}</code></td></tr>`
197
+ const shown = value ? escape(value) : '<span class="empty">unset</span>'
198
+ return `<tr><td>${swatch}${cellText(r, 'Role')}</td><td>${cellText(r, 'Intent')}</td><td><code>${shown}</code>${mark(r, 'Value')}</td></tr>`
120
199
  })
121
200
  .join('\n')
122
201
  return `<h2>Color</h2>\n<table><thead><tr><th>Role</th><th>Intent</th><th>Value</th></tr></thead><tbody>${body}</tbody></table>`
@@ -126,12 +205,12 @@ function sectionTypography(rows: Row[]): string {
126
205
  if (!rows.length) return ''
127
206
  const body = rows
128
207
  .map((r) => {
129
- const family = r['Family'] || 'system-ui'
130
- const weight = r['Weight'] || '400'
131
- const size = r['Size'] || '16px'
132
- const lh = r['Line height'] || '1.4'
208
+ const family = val(r, 'Family') || 'system-ui'
209
+ const weight = val(r, 'Weight') || '400'
210
+ const size = val(r, 'Size') || '16px'
211
+ const lh = val(r, 'Line height') || '1.4'
133
212
  const sample = `<span style="font-family:${escape(family)};font-weight:${escape(weight)};font-size:${escape(size)};line-height:${escape(lh)}">The quick brown fox</span>`
134
- return `<tr><td>${escape(r['Role'] ?? '')}</td><td>${escape(family)}</td><td>${escape(weight)}</td><td>${escape(size)}</td><td>${escape(lh)}</td><td>${sample}</td></tr>`
213
+ return `<tr><td>${cellText(r, 'Role')}</td><td>${escape(family)}${mark(r, 'Family')}</td><td>${escape(weight)}${mark(r, 'Weight')}</td><td>${escape(size)}${mark(r, 'Size')}</td><td>${escape(lh)}${mark(r, 'Line height')}</td><td>${sample}</td></tr>`
135
214
  })
136
215
  .join('\n')
137
216
  return `<h2>Typography</h2>\n<table><thead><tr><th>Role</th><th>Family</th><th>Weight</th><th>Size</th><th>Line height</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
@@ -141,11 +220,11 @@ function sectionSpacing(rows: Row[]): string {
141
220
  if (!rows.length) return ''
142
221
  const body = rows
143
222
  .map((r) => {
144
- const value = r['Value'] || ''
223
+ const value = val(r, 'Value')
145
224
  const bar = value
146
225
  ? `<span class="bar" style="width:${escape(value)}"></span>`
147
226
  : '<span class="empty">unset</span>'
148
- return `<tr><td>${escape(r['Step'] ?? '')}</td><td>${escape(r['Multiplier'] ?? '')}</td><td><code>${escape(value || 'unset')}</code></td><td>${bar}</td></tr>`
227
+ return `<tr><td>${cellText(r, 'Step')}</td><td>${cellText(r, 'Multiplier')}</td><td><code>${escape(value || 'unset')}</code>${mark(r, 'Value')}</td><td>${bar}</td></tr>`
149
228
  })
150
229
  .join('\n')
151
230
  return `<h2>Spacing</h2>\n<table><thead><tr><th>Step</th><th>Multiplier</th><th>Value</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
@@ -155,10 +234,10 @@ function sectionBorders(rows: Row[]): string {
155
234
  if (!rows.length) return ''
156
235
  const body = rows
157
236
  .map((r) => {
158
- const radius = r['Radius'] || '0'
159
- const width = r['Width'] || '1px'
237
+ const radius = val(r, 'Radius') || '0'
238
+ const width = val(r, 'Width') || '1px'
160
239
  const sample = `<span style="display:inline-block;width:2rem;height:1.5rem;background:#eee;border:${escape(width)} solid #888;border-radius:${escape(radius)};vertical-align:middle"></span>`
161
- return `<tr><td>${escape(r['Role'] ?? '')}</td><td><code>${escape(radius)}</code></td><td><code>${escape(width)}</code></td><td>${escape(r['When used'] ?? '')}</td><td>${sample}</td></tr>`
240
+ return `<tr><td>${cellText(r, 'Role')}</td><td><code>${escape(radius)}</code>${mark(r, 'Radius')}</td><td><code>${escape(width)}</code>${mark(r, 'Width')}</td><td>${cellText(r, 'When used')}</td><td>${sample}</td></tr>`
162
241
  })
163
242
  .join('\n')
164
243
  return `<h2>Borders</h2>\n<table><thead><tr><th>Role</th><th>Radius</th><th>Width</th><th>When used</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
@@ -0,0 +1,260 @@
1
+ import { existsSync, type Stats } from 'node:fs'
2
+ import { readdir, stat } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { BACKED_FOLDERS } from '@/records/backup'
5
+
6
+ /**
7
+ * The folders a size reading covers, relative to `.claude/`.
8
+ *
9
+ * It is the backed set plus `.tmp`, which a backup skips because it is
10
+ * deletable without loss and a reading covers because deletable is not the same
11
+ * as empty: the routing handoffs and the memory archive both sit there and both
12
+ * accumulate. `.records.git` stays out because it is the backup history rather
13
+ * than a record, and `worktrees/` stays out because each entry there is a
14
+ * checkout of the enclosing repository with its own removal verb, and one of
15
+ * them outweighs every record folder combined.
16
+ */
17
+ export const SIZED_FOLDERS = [...BACKED_FOLDERS, '.tmp'] as const
18
+
19
+ /**
20
+ * The windows a reading reports, in days.
21
+ *
22
+ * There are two rather than one, because a single window cannot separate a
23
+ * folder that grows steadily from one that took a single batch. A folder whose
24
+ * 7-day count is most of its 30-day count moved in one pass, and one where the
25
+ * two are proportional is growing at a rate.
26
+ */
27
+ export const GROWTH_WINDOWS = [7, 30] as const
28
+
29
+ const DAY_MS = 24 * 60 * 60 * 1000
30
+
31
+ export interface WindowCount {
32
+ readonly days: number
33
+ readonly files: number
34
+ }
35
+
36
+ export interface FolderSize {
37
+ /** Relative to `.claude/`, which is the name a reader opens. */
38
+ readonly folder: string
39
+ readonly present: boolean
40
+ readonly files: number
41
+ readonly bytes: number
42
+ /** `YYYY-MM-DD` of the least and most recently modified file, absent when the folder holds none. */
43
+ readonly oldest?: string
44
+ readonly newest?: string
45
+ readonly touched: readonly WindowCount[]
46
+ }
47
+
48
+ export interface SizeReport {
49
+ readonly ok: true
50
+ readonly root: string
51
+ readonly folders: readonly FolderSize[]
52
+ readonly files: number
53
+ readonly bytes: number
54
+ }
55
+
56
+ export const SIZE_REFUSALS = ['no-folder'] as const
57
+
58
+ export type SizeRefusal = (typeof SIZE_REFUSALS)[number]
59
+
60
+ export interface SizeRefused {
61
+ readonly ok: false
62
+ readonly reason: SizeRefusal
63
+ readonly message: string
64
+ }
65
+
66
+ export type SizeOutcome = SizeReport | SizeRefused
67
+
68
+ interface Walked {
69
+ files: number
70
+ bytes: number
71
+ oldest?: number
72
+ newest?: number
73
+ touched: number[]
74
+ }
75
+
76
+ /**
77
+ * Renders the calendar date the writer saw, which is the local one.
78
+ *
79
+ * `toISOString` renders in UTC, so a file written after 17:00 at `-0700` dates
80
+ * to the following day and a reader comparing the column against their own
81
+ * memory of writing it finds the two disagree. The reading is per-machine
82
+ * already, since these folders are gitignored and hold whatever that disk holds,
83
+ * so a local date is the answer consistent with the rest of the report.
84
+ */
85
+ function day(ms: number): string {
86
+ const at = new Date(ms)
87
+ const month = String(at.getMonth() + 1).padStart(2, '0')
88
+ const date = String(at.getDate()).padStart(2, '0')
89
+ return `${at.getFullYear()}-${month}-${date}`
90
+ }
91
+
92
+ /**
93
+ * Adds one file to the running totals.
94
+ *
95
+ * The window counts read `mtime`, so what they report is a file written inside
96
+ * the window rather than one created there. An entry edited long after it
97
+ * landed counts as recent, which overstates growth and never understates it.
98
+ * That is the safe direction for a number whose whole job is to be noticed, and
99
+ * these folders are append-mostly, so the two readings agree on nearly every
100
+ * file.
101
+ *
102
+ * The one reading that is wrong rather than early is a machine restored by
103
+ * `aitk records pull`, which resets the work tree hard and re-dates every file
104
+ * it writes. A window taken there counts the restore. Nothing separates the two
105
+ * from the filesystem, since a restored file is new by every stamp it carries,
106
+ * so the caveat is published rather than corrected.
107
+ */
108
+ function absorb(into: Walked, bytes: number, mtime: number, now: number): void {
109
+ into.files += 1
110
+ into.bytes += bytes
111
+ into.oldest = into.oldest === undefined ? mtime : Math.min(into.oldest, mtime)
112
+ into.newest = into.newest === undefined ? mtime : Math.max(into.newest, mtime)
113
+
114
+ GROWTH_WINDOWS.forEach((days, index) => {
115
+ if (now - mtime <= days * DAY_MS) into.touched[index] += 1
116
+ })
117
+ }
118
+
119
+ /**
120
+ * Reads one entry, or undefined when it left between the listing and the read.
121
+ *
122
+ * These folders are written by whatever sessions are running, so a path listed
123
+ * a moment ago can be gone by the time it is read. A vanished file is a file
124
+ * the folder no longer holds, which is the answer the count wants, and letting
125
+ * `ENOENT` out would fail the whole reading over one deleted scratch file.
126
+ * Every other error propagates, since a permission or IO failure would
127
+ * undercount with nothing said.
128
+ */
129
+ async function readSize(path: string): Promise<Stats | undefined> {
130
+ try {
131
+ return await stat(path)
132
+ } catch (error) {
133
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
134
+ throw error
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Walks one folder, counting files and never following a symlink.
140
+ *
141
+ * `isFile()` answers false for a link, so a folder holding one reports it
142
+ * nowhere rather than counting whatever sits behind it. The corpus symlinks
143
+ * point out of `.claude/` and into the authoring roots, where a second reading
144
+ * of the same bytes would be the wrong answer twice over.
145
+ */
146
+ async function walk(dir: string, into: Walked, now: number): Promise<void> {
147
+ const entries = await readdir(dir, { withFileTypes: true }).catch(
148
+ (error: NodeJS.ErrnoException) => {
149
+ if (error.code === 'ENOENT') return []
150
+ throw error
151
+ },
152
+ )
153
+
154
+ for (const entry of entries) {
155
+ const path = join(dir, entry.name)
156
+
157
+ if (entry.isDirectory()) {
158
+ await walk(path, into, now)
159
+ continue
160
+ }
161
+ if (!entry.isFile()) continue
162
+
163
+ const info = await readSize(path)
164
+ if (info) absorb(into, info.size, info.mtimeMs, now)
165
+ }
166
+ }
167
+
168
+ async function measure(
169
+ root: string,
170
+ folder: string,
171
+ now: number,
172
+ ): Promise<FolderSize> {
173
+ const path = join(root, '.claude', folder)
174
+ const empty = GROWTH_WINDOWS.map((days) => ({ days, files: 0 }))
175
+
176
+ if (!existsSync(path)) {
177
+ return { folder, present: false, files: 0, bytes: 0, touched: empty }
178
+ }
179
+
180
+ const walked: Walked = {
181
+ files: 0,
182
+ bytes: 0,
183
+ touched: GROWTH_WINDOWS.map(() => 0),
184
+ }
185
+ await walk(path, walked, now)
186
+
187
+ return {
188
+ folder,
189
+ present: true,
190
+ files: walked.files,
191
+ bytes: walked.bytes,
192
+ oldest: walked.oldest === undefined ? undefined : day(walked.oldest),
193
+ newest: walked.newest === undefined ? undefined : day(walked.newest),
194
+ touched: GROWTH_WINDOWS.map((days, index) => ({
195
+ days,
196
+ files: walked.touched[index],
197
+ })),
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Reports what each record folder holds and how much of it is recent.
203
+ *
204
+ * Every folder is reported whether or not it exists, since a caller reading the
205
+ * record wants a stable set of keys, and a folder absent from the output is
206
+ * indistinguishable from one the reading skipped.
207
+ *
208
+ * `now` is a parameter rather than a call inside the walk so a test can pin the
209
+ * windows against fixture timestamps.
210
+ */
211
+ export async function sizeRecords(
212
+ root: string,
213
+ now: number = Date.now(),
214
+ ): Promise<SizeOutcome> {
215
+ if (!existsSync(join(root, '.claude'))) {
216
+ return {
217
+ ok: false,
218
+ reason: 'no-folder',
219
+ message: `No .claude directory at ${root}, so there are no record folders to read.`,
220
+ }
221
+ }
222
+
223
+ // Each folder is walked independently, and the report is ordered by the
224
+ // caller rather than by arrival, so `Promise.all` keeps the input order while
225
+ // the ten walks overlap.
226
+ const folders = await Promise.all(
227
+ SIZED_FOLDERS.map((folder) => measure(root, folder, now)),
228
+ )
229
+
230
+ return {
231
+ ok: true,
232
+ root,
233
+ folders,
234
+ files: folders.reduce((total, entry) => total + entry.files, 0),
235
+ bytes: folders.reduce((total, entry) => total + entry.bytes, 0),
236
+ }
237
+ }
238
+
239
+ const UNITS = ['B', 'K', 'M', 'G'] as const
240
+
241
+ /**
242
+ * Renders a byte count at three significant figures or fewer.
243
+ *
244
+ * The reading is a prompt to go and look rather than an accounting figure, so
245
+ * an exact byte count buys nothing and costs a reader the comparison between
246
+ * two rows.
247
+ */
248
+ export function formatBytes(bytes: number): string {
249
+ let value = bytes
250
+ let unit = 0
251
+
252
+ while (value >= 1024 && unit < UNITS.length - 1) {
253
+ value /= 1024
254
+ unit += 1
255
+ }
256
+
257
+ const rendered =
258
+ unit === 0 || value >= 10 ? Math.round(value) : value.toFixed(1)
259
+ return `${rendered}${UNITS[unit]}`
260
+ }
@@ -22,12 +22,10 @@ import {
22
22
  logStep,
23
23
  logWarn,
24
24
  outro,
25
+ palette,
25
26
  select,
26
27
  } from '@/ui'
27
28
 
28
- const GREEN = '\x1b[0;32m'
29
- const NC = '\x1b[0m'
30
-
31
29
  /**
32
30
  * One installed file in three path flavours: absolute, relative to the
33
31
  * domain's installed root, and relative to the target. Adapters match on
@@ -304,6 +302,7 @@ export async function runDomainSync(
304
302
  }
305
303
 
306
304
  const plan = planSync(adapter, resolved)
305
+ const { GREEN, GREY, NC } = palette(process.stderr)
307
306
 
308
307
  if (
309
308
  !existsSync(adapter.installedRoot(resolved)) &&
@@ -358,7 +357,7 @@ export async function runDomainSync(
358
357
 
359
358
  outro()
360
359
  process.stderr.write(
361
- `${GREEN}✓ Sync complete${NC} \x1b[0;90m(${count} ${adapter.unit})${NC}\n`,
360
+ `${GREEN}✓ Sync complete${NC} ${GREY}(${count} ${adapter.unit})${NC}\n`,
362
361
  )
363
362
  return 0
364
363
  }
@@ -19,13 +19,11 @@ import {
19
19
  logStep,
20
20
  logWarn,
21
21
  outro,
22
+ palette,
22
23
  pipeOutput,
23
24
  select,
24
25
  } from '@/ui'
25
26
 
26
- const GREEN = '\x1b[0;32m'
27
- const NC = '\x1b[0m'
28
-
29
27
  const PROTECTED_BRANCHES: readonly string[] = ['main', 'master']
30
28
 
31
29
  export type WorkflowChoice = 'pr' | 'commit' | 'cancel'
@@ -195,6 +193,7 @@ function promptChoice(canOpenPullRequest: boolean): Promise<WorkflowChoice> {
195
193
  }
196
194
 
197
195
  function succeed(message: string): number {
196
+ const { GREEN, NC } = palette(process.stderr)
198
197
  outro()
199
198
  process.stderr.write(`\n${GREEN}✓ ${message}${NC}\n`)
200
199
  return 0