@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/commands.md +2 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/records.md +32 -4
- package/docs/ai-workflow.md +3 -1
- package/package.json +1 -1
- package/src/cli.ts +2 -1
- package/src/commands/records.ts +145 -7
- package/src/records/size.ts +260 -0
package/docs/agents/commands.md
CHANGED
|
@@ -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
|
|
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 |
|
package/docs/agents/index.md
CHANGED
|
@@ -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
|
package/docs/agents/records.md
CHANGED
|
@@ -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 `
|
|
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
|
|
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
|
|
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
|
|
package/docs/ai-workflow.md
CHANGED
|
@@ -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.
|
|
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
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`,
|
package/src/commands/records.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { Command } from 'commander'
|
|
2
|
+
import { BACKED_FOLDERS, pullRecords, pushRecords } from '@/records/backup'
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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:
|
|
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
|
+
}
|