@erclx/aitk 1.2.0 → 1.3.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,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -37,7 +37,8 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
37
37
  | `aitk teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
38
38
  | `aitk teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
39
39
  | `aitk records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
40
- | `aitk records push` | Commit the eight backed record folders and push them to a private records remote (`--json`) |
40
+ | `aitk records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
41
+ | `aitk records push` | Commit the nine backed record folders and push them to a private records remote (`--json`) |
41
42
  | `aitk records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
42
43
  | `aitk sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
43
44
  | `aitk comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
@@ -21,7 +21,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
21
21
  - [Markdown audit](markdown-audit.md): Running the audit over any markdown path, where its bans and checkpoints are read from, what each check reports, and why the ban half gates while the structural half reports
22
22
  - [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
23
23
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
24
- - [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, backing the folders to a private remote, and which root each kind defaults to
24
+ - [Records](records.md): Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
25
25
  - [Sandbox](sandbox.md): Scenario routing, the expectation scoring surface, and the coverage census over scenarios and skills
26
26
  - [Scripting](scripting.md): The runtime catalogs that replace hardcoded names, what each carries, and a headless invocation per domain
27
27
  - [Sessions](sessions.md): Resolving live peer sessions to the worktree and branch each holds, the liveness confidence field, the unresolved reasons, and what the read depends on
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Records
3
- description: Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, backing the folders to a private remote, and which root each kind defaults to
3
+ description: Validating the session records under .claude/ and the standards corpus, the per-kind checks, the refusal reasons, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
4
4
  ---
5
5
 
6
6
  # Records
@@ -72,6 +72,34 @@ aitk records validate plans --json | jq -r '.findings[] | "\(.kind): \(.subject)
72
72
 
73
73
  For the shapes each check enforces, see `.claude/standards/plan.md`, `.claude/standards/groundwork.md`, `.claude/standards/intake.md`, `.claude/standards/memory.md`, and `.claude/standards/standard.md`.
74
74
 
75
+ ## Size
76
+
77
+ `aitk records size` reports what each record folder holds and how much of it is recent. It reads the nine backed folders named under Push and pull, plus `.claude/.tmp`, and it gates nothing.
78
+
79
+ ```bash
80
+ aitk records size
81
+ aitk records size --json
82
+ ```
83
+
84
+ | Option | Behavior |
85
+ | --------------- | --------------------------------------------- |
86
+ | `--json` | Add a machine-readable record on stdout |
87
+ | `--root <path>` | Project root, defaulting to the main worktree |
88
+
89
+ The table carries one row per folder that exists, heaviest first, with the file count, the bytes, a count for each growth window, and the dates of the least and most recently written file. Those dates render in the machine's local time, which is the calendar day whoever wrote the file was living in, and the reading is per-machine already. Folders that do not exist are named on one line below it rather than printed as rows of zeros. The record a `--json` call emits carries every folder either way, each with a `present` flag, so a caller reading the record gets a stable set of keys and can tell an absent folder from one the reading skipped.
90
+
91
+ Ordering by weight is what makes the reading worth taking. A folder listed alphabetically hides behind its neighbors, and the row a reader came for is the one that grew.
92
+
93
+ The reading carries two windows rather than one, at 7 and 30 days. A single window cannot separate a folder growing steadily from one that took a single batch: a folder whose 7-day count is most of its 30-day count moved in one pass, and one where the two are proportional is growing at a rate.
94
+
95
+ Nothing fails on a number here. A record folder has no correct size, so the reading is a number to notice rather than a threshold to gate, and the point of the verb is that the next reading is taken by a command instead of by someone remembering to count the folder. The memory pen went from 44 entries to 236 between two readings taken by hand two weeks apart, which is the measurement this replaces.
96
+
97
+ `.claude/.tmp` is read here and skipped by a backup, because deletable without loss is not the same as empty. The routing handoffs and the memory archive both sit there and both accumulate. `.claude/.records.git` stays out because it is the backup history rather than a record, and `.claude/worktrees/` stays out because each entry is a checkout of the project with its own removal verb, and one of them outweighs every record folder combined.
98
+
99
+ The window counts read `mtime`, so what they report is a file written inside the window rather than one created there. An entry edited long after it landed reads as recent, which overstates growth and never understates it, and these folders are append-mostly so the two readings agree on nearly every file. The one reading that is wrong rather than early is a machine restored by `aitk records pull`, which resets the work tree hard and re-dates every file it writes, so a window taken there counts the restore. Nothing on the filesystem separates the two, since a restored file is new by every stamp it carries.
100
+
101
+ Exit codes: `0` the reading completed, `1` refused. The one refusal is `no-folder`, raised when the root holds no `.claude` directory at all.
102
+
75
103
  ## Push and pull
76
104
 
77
105
  `aitk records push` commits the backed record folders to a private remote and pushes them. `aitk records pull` fetches the other direction and writes them back. Both take `--json` and `--root` the way `validate` does, and both exit `0` on agreement and `1` on a refusal.
@@ -82,9 +110,9 @@ aitk records push --json
82
110
  aitk records pull
83
111
  ```
84
112
 
85
- The backed folders are `groundwork`, `intake`, `memory`, `plans`, `plans-archive`, `review`, `task-archive`, and `tasks`, all under `.claude/`. They are the gitignored Claude group minus `.claude/.tmp`, which is deletable without loss, and `.claude/worktrees/`, whose contents belong to the project repository already. The list is a constant rather than configuration, matching the four folder names `validate` hardcodes.
113
+ The backed folders are `groundwork`, `intake`, `memory`, `plans`, `plans-archive`, `review`, `task-archive`, `tasks`, and `teach`, all under `.claude/`. They are the gitignored Claude group minus `.claude/.tmp`, which is deletable without loss, and `.claude/worktrees/`, whose contents belong to the project repository already. The list is a constant rather than configuration, matching the four folder names `validate` hardcodes.
86
114
 
87
- Records are gitignored by design, so the history lives in a second git directory at `.claude/.records.git` with `.claude/` as its work tree. Every path stays where it is, which is what a separate checkout could not do. The verbs stage the eight folders by explicit pathspec with `--force`, so nothing outside them can enter the index however the ignore rules read, and the project working tree and its index are never touched.
115
+ Records are gitignored by design, so the history lives in a second git directory at `.claude/.records.git` with `.claude/` as its work tree. Every path stays where it is, which is what a separate checkout could not do. The verbs stage the nine folders by explicit pathspec with `--force`, so nothing outside them can enter the index however the ignore rules read, and the project working tree and its index are never touched.
88
116
 
89
117
  ### Setup
90
118
 
@@ -113,7 +141,7 @@ Point it at a private repository, and at one that is not a remote of the project
113
141
  | `local-ahead` | `pull` found local commits that never reached the origin |
114
142
  | `git-failed` | A git call failed, with its stderr in the message |
115
143
 
116
- The two `pull` refusals exist because the directions are not symmetric. A push only adds, while a pull onto a machine holding work that never left it would discard that work. Resolve either by running `push` first, or by moving the local folders aside. A machine holding none of the eight has nothing to lose, so a restore onto a fresh checkout runs straight through.
144
+ The two `pull` refusals exist because the directions are not symmetric. A push only adds, while a pull onto a machine holding work that never left it would discard that work. Resolve either by running `push` first, or by moving the local folders aside. A machine holding none of the nine has nothing to lose, so a restore onto a fresh checkout runs straight through.
117
145
 
118
146
  ### When it runs
119
147
 
@@ -93,10 +93,12 @@ An execution that picks other than the suggestion rewrites the `- Suggested:` li
93
93
 
94
94
  `aitk records validate plans` reports where a plan and that standard disagree: a filename that is not `feature-<slug>.md`, a missing required section, a files-to-touch entry naming no file, and a question carrying a suggestion with no answer slot. The same verb takes `groundwork`, `intake`, `memory`, and `teach`, which are governed the same way and were unreachable for the same reason. Nothing fires it automatically, because all five folders are gitignored and every check the repository runs reads changed files from git. It reports and never writes, since the folders are per-machine scratch with no history to recover a wrong repair from.
95
95
 
96
- `aitk records push` carries these folders off the disk they live on, and `aitk records pull` brings them back. Eight of them are backed: `groundwork`, `intake`, `memory`, `plans`, `plans-archive`, `review`, `task-archive`, and `tasks`. The history lives in a second git directory at `.claude/.records.git` with `.claude/` as its work tree, so every path a task file cites stays where it is.
96
+ `aitk records push` carries these folders off the disk they live on, and `aitk records pull` brings them back. Nine of them are backed: `groundwork`, `intake`, `memory`, `plans`, `plans-archive`, `review`, `task-archive`, `tasks`, and `teach`. The history lives in a second git directory at `.claude/.records.git` with `.claude/` as its work tree, so every path a task file cites stays where it is.
97
97
 
98
98
  A person points it at a private repository once and both verbs refuse until they have, and `push` refuses when that origin is also a remote of the project, since the payload is the memory pen and the groundwork trails. `.husky/post-merge` runs the push after its archive loop, on every merge rather than only on one that closed a task. See [records](agents/records.md) for the refusal table.
99
99
 
100
+ `aitk records size` reports what each of these folders holds, heaviest first, along with `.claude/.tmp`. Each row carries the file count, the bytes, how many files were written in the last 7 and 30 days, and the dates of the least and most recently written one. Nothing fails on a number, because a record folder has no correct size. What the verb replaces is a reading somebody had to remember to take: the memory pen went from 44 entries to 236 between two counts made by hand a fortnight apart, and nothing reported the rate in between.
101
+
100
102
  A plan that ships is archived, never deleted. `aitk:claude-docs` moves it to `.claude/plans-archive/` and retargets the task file's `Plan:` line at the new location, so a completed task still leads to the reasoning behind it. Both folders are gitignored, which is why a deleted plan had no recovery path. A plan cited by more than one task stays put until the last of them closes, since moving it early would strand every other pointer.
101
103
 
102
104
  The sweep reads the whole board rather than the tasks the session touched. It is the one place the skill reaches past its own rule against editing a task file the session did not change, because a task that closed while an earlier run missed its archive is exactly what the sweep exists to clear. Reaching it is safe: the archive moves the plan and retargets the pointer in the same pass, so an untouched task ends up with a working link rather than a broken one.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/cli.ts CHANGED
@@ -61,7 +61,7 @@ function showHelp(): void {
61
61
  `${GREY}│${NC} comments [cmd] ${GREY}# Measure comment density and trend (scan)${NC}`,
62
62
  `${GREY}│${NC} context [cmd] ${GREY}# Report context folder health (audit)${NC}`,
63
63
  `${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
64
- `${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, push, pull)${NC}`,
64
+ `${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, size, push, pull)${NC}`,
65
65
  `${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
66
66
  `${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
67
67
  `${GREY}│${NC} upgrade ${GREY}# Reinstall the CLI globally with the manager that installed it${NC}`,
@@ -101,6 +101,7 @@ function showHelp(): void {
101
101
  `${GREY}│${NC} aitk context audit --json`,
102
102
  `${GREY}│${NC} aitk markdown audit .claude/rules --json`,
103
103
  `${GREY}│${NC} aitk records validate plans`,
104
+ `${GREY}│${NC} aitk records size --json`,
104
105
  `${GREY}│${NC} aitk records push --json`,
105
106
  `${GREY}│${NC} aitk sessions list --json`,
106
107
  `${GREY}│${NC} aitk audits run --json`,
@@ -1,11 +1,13 @@
1
1
  import type { Command } from 'commander'
2
+ import { BACKED_FOLDERS, pullRecords, pushRecords } from '@/records/backup'
2
3
  import {
3
- BACKED_FOLDERS,
4
- type PullOutcome,
5
- pullRecords,
6
- type PushOutcome,
7
- pushRecords,
8
- } from '@/records/backup'
4
+ type FolderSize,
5
+ formatBytes,
6
+ GROWTH_WINDOWS,
7
+ SIZED_FOLDERS,
8
+ type SizeOutcome,
9
+ sizeRecords,
10
+ } from '@/records/size'
9
11
  import {
10
12
  type Finding,
11
13
  isRecordKind,
@@ -23,6 +25,7 @@ import {
23
25
  logWarn,
24
26
  outro,
25
27
  pipeOutput,
28
+ plural,
26
29
  } from '@/ui'
27
30
  import { currentWorktreeRoot, mainWorktreeRoot } from '@/worktree'
28
31
 
@@ -87,6 +90,41 @@ export function register(program: Command): void {
87
90
  process.exitCode = await runValidate(kind, opts)
88
91
  })
89
92
 
93
+ records
94
+ .command('size')
95
+ .description('Report what each record folder holds and how much is recent')
96
+ .helpOption('-h, --help', 'Show this help message')
97
+ .option('--json', 'Add a machine-readable record on stdout')
98
+ .option('--root <path>', 'Project root, defaulting to the main worktree')
99
+ .addHelpText(
100
+ 'after',
101
+ [
102
+ '',
103
+ 'Folders read under .claude/:',
104
+ ` ${SIZED_FOLDERS.join(', ')}`,
105
+ '',
106
+ 'Exit codes:',
107
+ ' 0 the reading completed',
108
+ ' 1 refused, with the reason on stderr or in the JSON record',
109
+ '',
110
+ 'It gates nothing. A record folder has no correct size, so the reading is',
111
+ 'a number to notice rather than a threshold to fail, and a session takes it',
112
+ `by running this rather than by counting the folder. The ${GROWTH_WINDOWS.join(
113
+ ' and ',
114
+ )} day`,
115
+ 'counts read mtime, so a file rewritten long after it landed reads as recent,',
116
+ 'and a machine restored by records pull reads its whole tree as one week old.',
117
+ '',
118
+ 'Examples:',
119
+ ' aitk records size',
120
+ ' aitk records size --json',
121
+ '',
122
+ ].join('\n'),
123
+ )
124
+ .action(async (opts: BackupCommandOptions) => {
125
+ process.exitCode = await runSize(opts)
126
+ })
127
+
90
128
  records
91
129
  .command('push')
92
130
  .description(
@@ -149,6 +187,99 @@ function backupHelp(verb: 'push' | 'pull'): string {
149
187
  ].join('\n')
150
188
  }
151
189
 
190
+ async function runSize(opts: BackupCommandOptions): Promise<number> {
191
+ const root = opts.root ?? (await mainWorktreeRoot())
192
+ const outcome = await sizeRecords(root)
193
+
194
+ if (!outcome.ok)
195
+ return reportRefusal('aitk records size', outcome, opts.json ?? false)
196
+
197
+ if (opts.json ?? false) {
198
+ process.stdout.write(`${JSON.stringify(outcome)}\n`)
199
+ return 0
200
+ }
201
+
202
+ reportSize(outcome)
203
+ return 0
204
+ }
205
+
206
+ /** Widest cell in the column, so a row lines up against the header as well. */
207
+ function columnWidth(header: string, cells: readonly string[]): number {
208
+ return Math.max(header.length, ...cells.map((cell) => cell.length))
209
+ }
210
+
211
+ function sizeRow(entry: FolderSize): string[] {
212
+ return [
213
+ entry.folder,
214
+ String(entry.files),
215
+ formatBytes(entry.bytes),
216
+ ...entry.touched.map((window) => String(window.files)),
217
+ entry.oldest ?? '',
218
+ entry.newest ?? '',
219
+ ]
220
+ }
221
+
222
+ /**
223
+ * Renders the present folders as a table, heaviest first.
224
+ *
225
+ * The order is what makes the reading worth taking. A folder listed
226
+ * alphabetically hides behind its neighbors, and the one that grew is the row
227
+ * a reader came for, so it leads.
228
+ */
229
+ function reportSize(outcome: Extract<SizeOutcome, { ok: true }>): void {
230
+ const present = outcome.folders
231
+ .filter((entry) => entry.present)
232
+ .toSorted((left, right) => right.files - left.files)
233
+ const absent = outcome.folders
234
+ .filter((entry) => !entry.present)
235
+ .map((entry) => entry.folder)
236
+
237
+ intro('aitk records size')
238
+ logStep('Folders')
239
+
240
+ if (present.length === 0) {
241
+ logInfo('none of the record folders exist yet')
242
+ } else {
243
+ const headers = [
244
+ 'folder',
245
+ 'files',
246
+ 'size',
247
+ ...GROWTH_WINDOWS.map((days) => `${days}d`),
248
+ 'oldest',
249
+ 'newest',
250
+ ]
251
+ const rows = present.map(sizeRow)
252
+ const widths = headers.map((header, column) =>
253
+ columnWidth(
254
+ header,
255
+ rows.map((row) => row[column]),
256
+ ),
257
+ )
258
+
259
+ // The name column reads as a list and the rest as numbers, so one is
260
+ // left-aligned and the others are not.
261
+ const render = (cells: readonly string[]): string =>
262
+ cells
263
+ .map((cell, column) =>
264
+ column === 0
265
+ ? cell.padEnd(widths[column])
266
+ : cell.padStart(widths[column]),
267
+ )
268
+ .join(' ')
269
+ .trimEnd()
270
+
271
+ pipeOutput([render(headers), ...rows.map(render)].join('\n'))
272
+ }
273
+
274
+ if (absent.length > 0) logInfo(`absent: ${absent.join(', ')}`)
275
+
276
+ logStep('Total')
277
+ logInfo(
278
+ `${plural(outcome.files, 'file')}, ${formatBytes(outcome.bytes)} across ${plural(present.length, 'folder')}`,
279
+ )
280
+ outro()
281
+ }
282
+
152
283
  async function runPush(opts: BackupCommandOptions): Promise<number> {
153
284
  const root = opts.root ?? (await mainWorktreeRoot())
154
285
  const outcome = await pushRecords(root)
@@ -197,9 +328,16 @@ async function runPull(opts: BackupCommandOptions): Promise<number> {
197
328
  return 0
198
329
  }
199
330
 
331
+ /**
332
+ * Reports a refusal from any of the three verbs that carry one.
333
+ *
334
+ * The parameter is structural rather than the union of their outcome types,
335
+ * because the three refusal vocabularies are separate lists and naming them all
336
+ * here would grow with every verb added.
337
+ */
200
338
  function reportRefusal(
201
339
  banner: string,
202
- outcome: Extract<PushOutcome | PullOutcome, { ok: false }>,
340
+ outcome: { readonly reason: string; readonly message: string },
203
341
  emitJson: boolean,
204
342
  ): number {
205
343
  if (emitJson) {
@@ -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
+ }