@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,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.4.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
@@ -46,6 +46,19 @@ Help skips the banner. The `Usage:` line sits directly on `├`. Help writes to
46
46
 
47
47
  `--json` and `--names` keep stdout clean and machine-readable. The frame still renders on stderr (open, banner, close) so the stream discipline is consistent across modes. Consumers that only read stdout see pure data.
48
48
 
49
+ ## Color
50
+
51
+ Escape sequences reach a destination that renders them and nowhere else. The question is asked per stream rather than once for the process, so a run piping its data while keeping a terminal on stderr still gets color on the frame.
52
+
53
+ Either condition alone turns color off:
54
+
55
+ - `NO_COLOR` is set to any non-empty value, whatever that value says
56
+ - The destination is not a terminal, which covers a pipe, a file, and a captured session
57
+
58
+ The frame survives both. `┌`, `│`, `├`, `└`, and the `✓ ! + - ✗` marks are structure rather than color, and they are what lets a captured run still read as one block. A caller wanting neither the frame nor the color reads `--json` instead.
59
+
60
+ Terminal control is a separate question this section does not cover. The cursor and key sequences an interactive prompt writes run only where a terminal already exists.
61
+
49
62
  ## Process exit
50
63
 
51
64
  A command action sets `process.exitCode` and returns. Calling `process.exit()` there ends the process before a stdout write drains, which truncates piped output at the 64K pipe buffer while still reporting the right exit code. Redirecting to a file hides the truncation, so it surfaces only through a pipe, which is what a check has to use to catch it.
@@ -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.
@@ -24,6 +24,8 @@ The toolkit seed in `tooling/claude/seeds/.claude/DESIGN.md` ships a token-table
24
24
 
25
25
  The `aitk:claude-design-extract` skill drafts the file, sourcing tokens from a project's existing prose and CLI UI surfaces, or proposing them from `.claude/REQUIREMENTS.md` and a `## Personality` paragraph when no UI code exists yet. `aitk design render` writes an HTML plus CSS preview to `.claude/review/design/` for eyeballing the current system without leaving Claude Code. See `.claude/context/design.md`.
26
26
 
27
+ A cell no source anchors ends in `? verify`, and the preview shows that marker beside the value rather than folding it in, so a swatch and a font sample stay built from the value alone. A confidence line above the sections names how many cells are anchored against how many are tagged, which is what tells a reader whether they are looking at a record of the code or a proposal about it. It reads the columns a source could anchor and leaves out the row names, so the ratio is not diluted by cells no tag could ever reach. The proposal path tags nearly all of them, so that count reads low on day one by design.
28
+
27
29
  ### Tools
28
30
 
29
31
  - None beyond Claude Code itself
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.4.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/cli.ts CHANGED
@@ -28,12 +28,12 @@ import { register as sessions } from '@/commands/sessions'
28
28
  import { register as audits } from '@/commands/audits'
29
29
  import { register as upgrade } from '@/commands/upgrade'
30
30
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
31
-
32
- const GREY = '\x1b[0;90m'
33
- const WHITE = '\x1b[1;37m'
34
- const NC = '\x1b[0m'
31
+ import { palette } from '@/ui'
35
32
 
36
33
  function showHelp(): void {
34
+ // The help text is the one framed surface written to stdout, so it asks
35
+ // about that stream rather than the stderr every other writer here uses.
36
+ const { GREY, NC, WHITE } = palette(process.stdout)
37
37
  const lines = [
38
38
  `${GREY}┌${NC}`,
39
39
  `${GREY}├${NC} ${WHITE}Usage:${NC} aitk [command]`,
@@ -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`,
@@ -44,16 +44,13 @@ import {
44
44
  logStep,
45
45
  logWarn,
46
46
  outro,
47
+ palette,
47
48
  pipeOutput,
48
49
  plural,
49
50
  select,
50
51
  } from '@/ui'
51
52
  import { describeSkew, readSkew, type SkewReport } from '@/version/skew'
52
53
 
53
- const GREEN = '\x1b[0;32m'
54
- const GREY = '\x1b[0;90m'
55
- const NC = '\x1b[0m'
56
-
57
54
  interface SeedsListOptions {
58
55
  readonly json?: boolean
59
56
  readonly names?: boolean
@@ -266,6 +263,7 @@ export function register(program: Command): void {
266
263
  }
267
264
 
268
265
  function succeed(message: string): number {
266
+ const { GREEN, NC } = palette(process.stderr)
269
267
  outro()
270
268
  process.stderr.write(`${GREEN}✓ ${message}${NC}\n`)
271
269
  return 0
@@ -472,6 +470,7 @@ async function runSeedsList(opts: SeedsListOptions): Promise<number> {
472
470
  return 0
473
471
  }
474
472
 
473
+ const { GREY, NC } = palette(process.stderr)
475
474
  intro('aitk claude')
476
475
  logStep('Seed docs')
477
476
  for (const listing of listings) {
@@ -531,6 +530,7 @@ async function runSkillsDrift(
531
530
  // is the moment a skew warning is worth most, since an old binary is one
532
531
  // reason the cache and the CLI disagree in the first place.
533
532
  if (skew.state === 'behind') {
533
+ const { GREY, NC } = palette(process.stderr)
534
534
  process.stderr.write(`${GREY}${describeSkew(skew)}${NC}\n`)
535
535
  }
536
536
  }
@@ -2,12 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { renderDesignDoc } from '@/design/render'
5
-
6
- const GREY = '\x1b[0;90m'
7
- const WHITE = '\x1b[1;37m'
8
- const RED = '\x1b[0;31m'
9
- const GREEN = '\x1b[0;32m'
10
- const NC = '\x1b[0m'
5
+ import { palette } from '@/ui'
11
6
 
12
7
  export function register(program: Command): void {
13
8
  const design = program
@@ -22,6 +17,7 @@ export function register(program: Command): void {
22
17
  .action((opts: { source: string; out: string }) => {
23
18
  const sourcePath = resolve(process.cwd(), opts.source)
24
19
  const outDir = resolve(process.cwd(), opts.out)
20
+ const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
25
21
  if (!existsSync(sourcePath)) {
26
22
  process.stderr.write(
27
23
  `${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} ${opts.source} not found\n${GREY}└${NC}\n`,
@@ -4,10 +4,7 @@ import type { Command } from 'commander'
4
4
  import { deriveSlug, deriveTitle } from '@/commands/feedback-format'
5
5
  import { PROJECT_ROOT } from '@/project-root'
6
6
  import { createGithubIssue } from '@/github'
7
- import { frameError, frameSuccess } from '@/ui'
8
-
9
- const YELLOW = '\x1b[0;33m'
10
- const NC = '\x1b[0m'
7
+ import { frameError, frameSuccess, palette } from '@/ui'
11
8
 
12
9
  function readStdin(): Promise<string> {
13
10
  return new Promise((resolveStream, rejectStream) => {
@@ -88,6 +85,7 @@ export function register(program: Command): void {
88
85
  process.exitCode = 1
89
86
  return
90
87
  }
88
+ const { NC, YELLOW } = palette(process.stderr)
91
89
  process.stderr.write(
92
90
  `${YELLOW}! gh unavailable, wrote local scratch instead${NC}\n`,
93
91
  )
@@ -30,12 +30,10 @@ import {
30
30
  logStep,
31
31
  logWarn,
32
32
  outro,
33
+ palette,
33
34
  select,
34
35
  } from '@/ui'
35
36
 
36
- const GREEN = '\x1b[0;32m'
37
- const NC = '\x1b[0m'
38
-
39
37
  const PAYLOAD_REL = join('.claude', '.tmp', 'gov', 'rules.md')
40
38
  const RULES_REL = join('.claude', 'rules')
41
39
 
@@ -461,6 +459,7 @@ async function runInstall(
461
459
  )
462
460
  }
463
461
 
462
+ const { GREEN, NC } = palette(process.stderr)
464
463
  outro()
465
464
  process.stderr.write(`${GREEN}✓ Rules installed${NC}\n`)
466
465
  return 0
@@ -510,6 +509,7 @@ async function runBuild(target: string): Promise<number> {
510
509
  await writeFile(output, buildRulesPayload(files))
511
510
  logAdd(PAYLOAD_REL)
512
511
 
512
+ const { GREEN, NC } = palette(process.stderr)
513
513
  outro()
514
514
  process.stderr.write(
515
515
  `${GREEN}✓ Rules built (${files.length} rules → ${PAYLOAD_REL})${NC}\n`,
@@ -6,11 +6,7 @@ import { type InitFlags, parseSkip, planInit } from '@/init/plan'
6
6
  import { runDomains } from '@/init/run'
7
7
  import { buildSteps } from '@/init/steps'
8
8
  import { resolveTarget } from '@/target'
9
- import { intro, logInfo, logStep, logWarn, outro, select } from '@/ui'
10
-
11
- const GREEN = '\x1b[0;32m'
12
- const YELLOW = '\x1b[0;33m'
13
- const NC = '\x1b[0m'
9
+ import { intro, logInfo, logStep, logWarn, outro, palette, select } from '@/ui'
14
10
 
15
11
  interface InitOptions {
16
12
  /** Always present: the option falls back to `DEFAULT_STACK`. */
@@ -107,6 +103,8 @@ async function runInit(
107
103
  outro()
108
104
  process.stderr.write('\n')
109
105
 
106
+ const { GREEN, NC, YELLOW } = palette(process.stderr)
107
+
110
108
  if (failed.length === 0) {
111
109
  process.stderr.write(
112
110
  `${GREEN}✓ Project initialized (${plan.total} domains)${NC}\n`,
@@ -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) {
@@ -5,12 +5,7 @@ import { LAYOUTS } from '@/slides/layouts'
5
5
  import { openDeck } from '@/slides/open'
6
6
  import { renderSlidesDoc } from '@/slides/render'
7
7
  import type { Variant } from '@/slides/styles'
8
- import { intro, outro } from '@/ui'
9
-
10
- const GREY = '\x1b[0;90m'
11
- const GREEN = '\x1b[0;32m'
12
- const RED = '\x1b[0;31m'
13
- const NC = '\x1b[0m'
8
+ import { intro, outro, palette } from '@/ui'
14
9
 
15
10
  export function register(program: Command): void {
16
11
  const slides = program
@@ -44,6 +39,7 @@ export function register(program: Command): void {
44
39
  }
45
40
  const variant = parseVariant(opts.variant)
46
41
  const mirror = resolveMirror(opts.mirror)
42
+ const { GREEN, GREY, NC, RED } = palette(process.stderr)
47
43
  intro('Render slides')
48
44
  const result = await renderSlidesDoc(sourcePath, outDir, {
49
45
  variant,
@@ -81,6 +77,7 @@ export function register(program: Command): void {
81
77
  process.stdout.write(`${JSON.stringify(LAYOUTS)}\n`)
82
78
  return
83
79
  }
80
+ const { GREEN, GREY, NC } = palette(process.stderr)
84
81
  intro('Slide layouts')
85
82
  for (const layout of LAYOUTS) {
86
83
  process.stderr.write(
@@ -121,6 +118,7 @@ function fail(message: string): never {
121
118
  */
122
119
  function reportFailure(error: unknown): void {
123
120
  if (!(error instanceof SlidesError)) throw error
121
+ const { GREY, NC, RED } = palette(process.stderr)
124
122
  process.stderr.write(
125
123
  `${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} ${error.message}\n${GREY}└${NC}\n`,
126
124
  )
@@ -21,12 +21,10 @@ import {
21
21
  logStep,
22
22
  logWarn,
23
23
  outro,
24
+ palette,
24
25
  select,
25
26
  } from '@/ui'
26
27
 
27
- const GREEN = '\x1b[0;32m'
28
- const NC = '\x1b[0m'
29
-
30
28
  const SNIPPETS_REL = '.claude/snippets'
31
29
 
32
30
  const PASS_THROUGH_VERBS = ['create'] as const
@@ -180,6 +178,7 @@ async function runInstall(
180
178
  }
181
179
  await recordStamp(createSnippetsAdapter(PROJECT_ROOT), resolved, new Date())
182
180
 
181
+ const { GREEN, NC } = palette(process.stderr)
183
182
  outro()
184
183
  process.stderr.write(`${GREEN}✓ Snippets installed${NC}\n`)
185
184
  return 0
@@ -21,13 +21,10 @@ import {
21
21
  logStep,
22
22
  logWarn,
23
23
  outro,
24
+ palette,
24
25
  select,
25
26
  } from '@/ui'
26
27
 
27
- const GREEN = '\x1b[0;32m'
28
- const GREY = '\x1b[0;90m'
29
- const NC = '\x1b[0m'
30
-
31
28
  interface InstallOptions {
32
29
  /** Always present: the option falls back to `ALL_SELECTION`. */
33
30
  readonly only: string
@@ -196,6 +193,7 @@ async function runInstall(target: string, selection: string): Promise<number> {
196
193
  await recordStamp(createStandardsAdapter(PROJECT_ROOT), resolved, new Date())
197
194
 
198
195
  outro()
196
+ const { GREEN, GREY, NC } = palette(process.stderr)
199
197
  process.stderr.write(
200
198
  `\n${GREEN}✓ Standards installed${NC} ${GREY}(${files.length} files)${NC}\n`,
201
199
  )
@@ -29,13 +29,10 @@ import {
29
29
  logStep,
30
30
  logWarn,
31
31
  outro,
32
+ palette,
32
33
  } from '@/ui'
33
34
  import { describeSkew } from '@/version/skew'
34
35
 
35
- const GREY = '\x1b[0;90m'
36
- const YELLOW = '\x1b[0;33m'
37
- const NC = '\x1b[0m'
38
-
39
36
  const SYNC_ARGS: Record<SyncDomain, readonly string[]> = {
40
37
  standards: ['standards', 'sync'],
41
38
  snippets: ['snippets', 'sync'],
@@ -180,6 +177,7 @@ function renderCheck(report: CheckReport): void {
180
177
  )
181
178
  if (uncovered.length === 0) return
182
179
 
180
+ const { GREY, NC } = palette(process.stderr)
183
181
  process.stderr.write(
184
182
  `${GREY}Unstamped: ${uncovered.join(', ')}. Run the matching sync to record one.${NC}\n`,
185
183
  )
@@ -338,6 +336,7 @@ async function runSync(target: string): Promise<number> {
338
336
  if (typeof resolved === 'number') return resolved
339
337
 
340
338
  const git = createGitRunner(resolved)
339
+ const { GREY, NC, YELLOW } = palette(process.stderr)
341
340
 
342
341
  logStep('Checking working tree')
343
342
  if (!isTreeClean(await git.status([]))) {
@@ -28,12 +28,10 @@ import {
28
28
  logStep,
29
29
  logWarn,
30
30
  outro,
31
+ palette,
31
32
  select,
32
33
  } from '@/ui'
33
34
 
34
- const GREEN = '\x1b[0;32m'
35
- const NC = '\x1b[0m'
36
-
37
35
  const PASS_THROUGH_VERBS = ['ref', 'create', 'verify'] as const
38
36
 
39
37
  interface SyncOptions {
@@ -256,6 +254,7 @@ async function runSync(
256
254
  report(result, includeReferences)
257
255
 
258
256
  const mode = resolveWriteMode(opts)
257
+ const { GREEN, NC } = palette(process.stderr)
259
258
 
260
259
  if (result.totalChanges === 0) {
261
260
  // The stamp is a write like any other, so a run with no authority to write