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