@erclx/canon 4.4.0 → 4.6.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/claude/skills/claude-markdown-propose/REQUIREMENT.md +0 -1
- package/docs/agents/audits.md +6 -6
- package/docs/agents/commands.md +3 -0
- package/docs/agents/context-audit.md +2 -2
- package/docs/agents/driver.md +103 -0
- package/docs/agents/index.md +2 -1
- package/docs/agents/records.md +17 -7
- package/docs/agents/tasks.md +1 -1
- package/package.json +1 -1
- package/scripts/core/check-ignore-parity.sh +9 -3
- package/src/audits/baseline.ts +1 -1
- package/src/browser/engine.ts +50 -7
- package/src/cli.ts +4 -0
- package/src/commands/claude.ts +7 -1
- package/src/commands/context.ts +3 -3
- package/src/commands/design.ts +6 -1
- package/src/commands/driver.ts +266 -0
- package/src/commands/feedback.ts +5 -1
- package/src/commands/gov.ts +2 -1
- package/src/commands/slides.ts +6 -1
- package/src/context/citations.ts +16 -5
- package/src/context/folders.ts +18 -8
- package/src/driver/drive.ts +184 -0
- package/src/driver/probes/details.ts +116 -0
- package/src/driver/probes/diagram.ts +260 -0
- package/src/driver/probes/focus.ts +121 -0
- package/src/driver/probes/viewport.ts +81 -0
- package/src/driver/steps.ts +266 -0
- package/src/gate/measures.ts +1 -1
- package/src/intake/folder.ts +2 -1
- package/src/inventory/walk.ts +6 -18
- package/src/record-root.ts +134 -0
- package/src/records/backup.ts +40 -22
- package/src/records/size.ts +12 -7
- package/src/records/validate.ts +35 -17
- package/src/tasks/answers.ts +14 -7
- package/src/tasks/archive.ts +31 -21
- package/src/teach/workspace.ts +2 -1
- package/tooling/claude/manifest.toml +1 -1
- package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +8 -1
- package/tooling/claude/seeds/.claude/hooks/memory-index.sh +28 -11
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +12 -3
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +4 -0
- package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +28 -10
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core'
|
|
2
|
+
import {
|
|
3
|
+
type ViewportSpec,
|
|
4
|
+
type ViewportRefusal,
|
|
5
|
+
resolveViewports,
|
|
6
|
+
} from '@/driver/probes/viewport'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The interaction vocabulary a caller writes a run in, and the runner that
|
|
10
|
+
* performs one step of it.
|
|
11
|
+
*
|
|
12
|
+
* Adapted from `@/demo/drive`'s `runStep` with the pointer travel, the video
|
|
13
|
+
* context, and the caption bar dropped. Driving for a recording and driving for
|
|
14
|
+
* a measurement want opposite things from the same actions: a recording pays
|
|
15
|
+
* for visible motion between two points, and a measurement wants the page in
|
|
16
|
+
* the next state as directly as the engine can put it there, since every
|
|
17
|
+
* millisecond of travel is a millisecond a probe is not reading.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** How long a transition is given to finish before anything measures the result. */
|
|
21
|
+
const SETTLE_MS = 250
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The probes a step can ask for. A fixed catalog rather than the pluggable
|
|
25
|
+
* subject system `@/inventory/subjects` carries, because every lesson behind
|
|
26
|
+
* these was a probe running at the wrong moment or trusting the wrong signal,
|
|
27
|
+
* rather than a probe a project needed to author for itself.
|
|
28
|
+
*/
|
|
29
|
+
export const PROBE_NAMES = [
|
|
30
|
+
'focus',
|
|
31
|
+
'details',
|
|
32
|
+
'diagram-geometry',
|
|
33
|
+
'diagram-strokes',
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
export type ProbeName = (typeof PROBE_NAMES)[number]
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One thing a probe measured and judged wrong. The step and the viewport are
|
|
40
|
+
* filled in by the orchestrator rather than by the probe, since a probe runs
|
|
41
|
+
* with no knowledge of which pass it is on and a finding is unreadable without
|
|
42
|
+
* both. A `measured` row carries what a reader needs to check the judgment
|
|
43
|
+
* without rerunning the probe.
|
|
44
|
+
*/
|
|
45
|
+
export interface Finding {
|
|
46
|
+
readonly probe: ProbeName
|
|
47
|
+
readonly selector: string
|
|
48
|
+
readonly detail: string
|
|
49
|
+
readonly measured: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A finding once the run has said where it happened. */
|
|
53
|
+
export interface PlacedFinding extends Finding {
|
|
54
|
+
readonly step: string
|
|
55
|
+
readonly viewport: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface StepCommon {
|
|
59
|
+
/** What the step did, carried onto every finding it produced. */
|
|
60
|
+
readonly name: string
|
|
61
|
+
/** Overrides the run-level list for this step alone. */
|
|
62
|
+
readonly probes?: readonly ProbeName[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type DriverStep = StepCommon &
|
|
66
|
+
(
|
|
67
|
+
| { readonly kind: 'click'; readonly target: string }
|
|
68
|
+
| { readonly kind: 'scroll'; readonly target: string }
|
|
69
|
+
| { readonly kind: 'fill'; readonly target: string; readonly text: string }
|
|
70
|
+
| { readonly kind: 'tab'; readonly count: number }
|
|
71
|
+
| { readonly kind: 'wait'; readonly ms: number }
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
export interface DriverPlan {
|
|
75
|
+
/** Runs after every step that names no list of its own. */
|
|
76
|
+
readonly probes: readonly ProbeName[]
|
|
77
|
+
readonly viewports: readonly {
|
|
78
|
+
readonly width: number
|
|
79
|
+
readonly height: number
|
|
80
|
+
}[]
|
|
81
|
+
readonly steps: readonly DriverStep[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type PlanRefusal =
|
|
85
|
+
| 'unreadable-plan'
|
|
86
|
+
| 'no-steps'
|
|
87
|
+
| 'no-probes'
|
|
88
|
+
| 'unknown-probe'
|
|
89
|
+
| 'bad-step'
|
|
90
|
+
| ViewportRefusal
|
|
91
|
+
|
|
92
|
+
export type PlanRead =
|
|
93
|
+
| { readonly kind: 'read'; readonly plan: DriverPlan }
|
|
94
|
+
| {
|
|
95
|
+
readonly kind: 'refused'
|
|
96
|
+
readonly reason: PlanRefusal
|
|
97
|
+
readonly detail: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function refused(reason: PlanRefusal, detail: string): PlanRead {
|
|
101
|
+
return { kind: 'refused', reason, detail }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reads a run out of a JSON document. A file rather than a config key at the
|
|
106
|
+
* project root, and JSON rather than the TOML `canon inventory` declares its
|
|
107
|
+
* routes in, because a route catalog is state a project holds and an
|
|
108
|
+
* interaction sequence is a script written for one question.
|
|
109
|
+
*/
|
|
110
|
+
export function readDriverPlan(source: string): PlanRead {
|
|
111
|
+
let document: unknown
|
|
112
|
+
try {
|
|
113
|
+
document = JSON.parse(source)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return refused(
|
|
116
|
+
'unreadable-plan',
|
|
117
|
+
error instanceof Error ? error.message : String(error),
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof document !== 'object' || document === null) {
|
|
122
|
+
return refused('unreadable-plan', 'the document is not an object')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const record = document as Record<string, unknown>
|
|
126
|
+
|
|
127
|
+
const viewports = resolveViewports(
|
|
128
|
+
record.viewport as ViewportSpec | undefined,
|
|
129
|
+
)
|
|
130
|
+
if (viewports.kind === 'refused') {
|
|
131
|
+
return refused(viewports.reason, viewports.detail)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const probes = readProbeList(record.probes)
|
|
135
|
+
if (typeof probes === 'string') return refused('unknown-probe', probes)
|
|
136
|
+
if (probes.length === 0) {
|
|
137
|
+
return refused(
|
|
138
|
+
'no-probes',
|
|
139
|
+
'probes names no probe, so every step would run and measure nothing',
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!Array.isArray(record.steps) || record.steps.length === 0) {
|
|
144
|
+
return refused('no-steps', 'steps carries no entry, so nothing is driven')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const steps: DriverStep[] = []
|
|
148
|
+
for (const [index, entry] of record.steps.entries()) {
|
|
149
|
+
const step = readStep(entry, index)
|
|
150
|
+
if (typeof step === 'string') return refused('bad-step', step)
|
|
151
|
+
if (step.probes) {
|
|
152
|
+
const named = readProbeList(step.probes)
|
|
153
|
+
if (typeof named === 'string') return refused('unknown-probe', named)
|
|
154
|
+
// Refused for the same reason the run-level list is. An empty list here
|
|
155
|
+
// silently runs the step and measures nothing, and a pass reporting zero
|
|
156
|
+
// findings is indistinguishable from one that looked.
|
|
157
|
+
if (named.length === 0) {
|
|
158
|
+
return refused(
|
|
159
|
+
'no-probes',
|
|
160
|
+
`${step.name} names an empty probe list, so the step would run and measure nothing`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
steps.push(step)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
kind: 'read',
|
|
169
|
+
plan: { probes, viewports: viewports.viewports, steps },
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Returns the parsed list, or the message naming what failed. */
|
|
174
|
+
function readProbeList(value: unknown): ProbeName[] | string {
|
|
175
|
+
if (value === undefined) return [...PROBE_NAMES]
|
|
176
|
+
if (!Array.isArray(value)) return 'probes is not a list'
|
|
177
|
+
|
|
178
|
+
const names: ProbeName[] = []
|
|
179
|
+
for (const entry of value) {
|
|
180
|
+
if (!isProbeName(entry)) {
|
|
181
|
+
return `no probe named ${String(entry)}. This build ships: ${PROBE_NAMES.join(', ')}.`
|
|
182
|
+
}
|
|
183
|
+
names.push(entry)
|
|
184
|
+
}
|
|
185
|
+
return names
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isProbeName(value: unknown): value is ProbeName {
|
|
189
|
+
return typeof value === 'string' && PROBE_NAMES.includes(value as ProbeName)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Returns the parsed step, or the message naming what failed. */
|
|
193
|
+
function readStep(entry: unknown, index: number): DriverStep | string {
|
|
194
|
+
const at = `step ${index + 1}`
|
|
195
|
+
if (typeof entry !== 'object' || entry === null)
|
|
196
|
+
return `${at} is not an object`
|
|
197
|
+
|
|
198
|
+
const step = entry as Record<string, unknown>
|
|
199
|
+
const name = typeof step.name === 'string' ? step.name : undefined
|
|
200
|
+
if (!name) return `${at} carries no name, and a finding is attributed by it`
|
|
201
|
+
|
|
202
|
+
const probes = step.probes as readonly ProbeName[] | undefined
|
|
203
|
+
const common = { name, ...(probes ? { probes } : {}) }
|
|
204
|
+
const target = typeof step.target === 'string' ? step.target : undefined
|
|
205
|
+
|
|
206
|
+
switch (step.kind) {
|
|
207
|
+
case 'click':
|
|
208
|
+
if (!target) return `${at} is a click with no target`
|
|
209
|
+
return { ...common, kind: 'click', target }
|
|
210
|
+
case 'scroll':
|
|
211
|
+
if (!target) return `${at} is a scroll with no target`
|
|
212
|
+
return { ...common, kind: 'scroll', target }
|
|
213
|
+
case 'fill':
|
|
214
|
+
if (!target) return `${at} is a fill with no target`
|
|
215
|
+
if (typeof step.text !== 'string') return `${at} is a fill with no text`
|
|
216
|
+
return { ...common, kind: 'fill', target, text: step.text }
|
|
217
|
+
case 'tab':
|
|
218
|
+
return {
|
|
219
|
+
...common,
|
|
220
|
+
kind: 'tab',
|
|
221
|
+
count: typeof step.count === 'number' ? step.count : 1,
|
|
222
|
+
}
|
|
223
|
+
case 'wait':
|
|
224
|
+
if (typeof step.ms !== 'number') return `${at} is a wait with no ms`
|
|
225
|
+
return { ...common, kind: 'wait', ms: step.ms }
|
|
226
|
+
default:
|
|
227
|
+
return `${at} names kind ${String(step.kind)}, which is not click, scroll, fill, tab, or wait`
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The probes this step asks for, which is the run's list unless it names one. */
|
|
232
|
+
export function stepProbes(
|
|
233
|
+
plan: DriverPlan,
|
|
234
|
+
step: DriverStep,
|
|
235
|
+
): readonly ProbeName[] {
|
|
236
|
+
return step.probes ?? plan.probes
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Performs one step and lets whatever it started settle. The settle is the
|
|
241
|
+
* step's rather than each probe's, because a transition runs once and every
|
|
242
|
+
* probe after it would otherwise pay for the same wait again.
|
|
243
|
+
*/
|
|
244
|
+
export async function runStep(page: Page, step: DriverStep): Promise<void> {
|
|
245
|
+
switch (step.kind) {
|
|
246
|
+
case 'click':
|
|
247
|
+
await page.locator(step.target).first().click()
|
|
248
|
+
break
|
|
249
|
+
case 'scroll':
|
|
250
|
+
await page.locator(step.target).first().scrollIntoViewIfNeeded()
|
|
251
|
+
break
|
|
252
|
+
case 'fill':
|
|
253
|
+
await page.locator(step.target).first().fill(step.text)
|
|
254
|
+
break
|
|
255
|
+
case 'tab':
|
|
256
|
+
for (let press = 0; press < step.count; press += 1) {
|
|
257
|
+
await page.keyboard.press('Tab')
|
|
258
|
+
}
|
|
259
|
+
break
|
|
260
|
+
case 'wait':
|
|
261
|
+
await page.waitForTimeout(step.ms)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await page.waitForTimeout(SETTLE_MS)
|
|
266
|
+
}
|
package/src/gate/measures.ts
CHANGED
|
@@ -97,7 +97,7 @@ export const SANDBOX_UNDECLARED_CEILING = 47
|
|
|
97
97
|
* rather than derived, because this stage only ever names the file in a remedy
|
|
98
98
|
* a reader has to be able to open, and `canon audits run` owns writing it.
|
|
99
99
|
*/
|
|
100
|
-
export const AUDITS_BASELINE = '.claude/
|
|
100
|
+
export const AUDITS_BASELINE = '.claude/canon/baseline.json'
|
|
101
101
|
|
|
102
102
|
export const HERO_STAMP_FAILURE =
|
|
103
103
|
'The hero set disagrees with the stamp written when the image was captured. Run canon capture assets/hero.html and commit all three files together.'
|
package/src/intake/folder.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readItems,
|
|
10
10
|
writeAnswerLine,
|
|
11
11
|
} from '@/intake/items'
|
|
12
|
+
import { recordDir } from '@/record-root'
|
|
12
13
|
|
|
13
14
|
export const INTAKE_REFUSALS = [
|
|
14
15
|
'no-intake',
|
|
@@ -80,7 +81,7 @@ function refuse(
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export function intakeDir(root: string): string {
|
|
83
|
-
return
|
|
84
|
+
return recordDir(root, 'intake')
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
/**
|
package/src/inventory/walk.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { chromium } from 'playwright-core'
|
|
2
2
|
import type { Browser } from 'playwright-core'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
enterKeyboardModality,
|
|
5
|
+
isBrowserMissing,
|
|
6
|
+
isServerUnreachable,
|
|
7
|
+
} from '@/browser/engine'
|
|
4
8
|
import { routeUrl } from '@/inventory/config'
|
|
5
9
|
import type { Reading } from '@/inventory/group'
|
|
6
10
|
import type { Subject } from '@/inventory/subjects'
|
|
@@ -61,19 +65,6 @@ function failed(reason: WalkRefusal, error: unknown): WalkResult {
|
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
/**
|
|
65
|
-
* Separates a server nobody started from a page that failed for its own
|
|
66
|
-
* reasons. The first is the precondition this command cannot create, and
|
|
67
|
-
* reporting it as an empty listing would say the site gives no answers when
|
|
68
|
-
* nothing was ever asked.
|
|
69
|
-
*/
|
|
70
|
-
function isServerUnreachable(error: unknown): boolean {
|
|
71
|
-
const text = error instanceof Error ? error.message : String(error)
|
|
72
|
-
return /ERR_CONNECTION_REFUSED|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_RESET|ERR_EMPTY_RESPONSE/i.test(
|
|
73
|
-
text,
|
|
74
|
-
)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
68
|
export async function walk(options: WalkOptions): Promise<WalkResult> {
|
|
78
69
|
const started = Date.now()
|
|
79
70
|
|
|
@@ -98,10 +89,7 @@ export async function walk(options: WalkOptions): Promise<WalkResult> {
|
|
|
98
89
|
waitUntil: 'domcontentloaded',
|
|
99
90
|
})
|
|
100
91
|
|
|
101
|
-
|
|
102
|
-
// a `:focus-visible` ring is the treatment a pointer never reveals and
|
|
103
|
-
// programmatic focus alone does not match it.
|
|
104
|
-
await page.keyboard.press('Tab')
|
|
92
|
+
await enterKeyboardModality(page)
|
|
105
93
|
|
|
106
94
|
const rows = await page.evaluate(options.subject.read, options.query)
|
|
107
95
|
for (const row of rows) readings.push({ route, ...row })
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The roots a session record folder is read at, in precedence order.
|
|
6
|
+
*
|
|
7
|
+
* `.canon/` wins because a tree that carries it has been migrated, and reading
|
|
8
|
+
* `.claude/` there would answer from the copy the move left behind. A tree that
|
|
9
|
+
* carries neither is every tree today, which is what keeps this branch a no-op
|
|
10
|
+
* until the move lands.
|
|
11
|
+
*
|
|
12
|
+
* The shape is `readStamp`'s: order the spellings, take the first that exists,
|
|
13
|
+
* and stand the creation default in when none does.
|
|
14
|
+
*/
|
|
15
|
+
export const RECORD_ROOTS = ['.canon', '.claude'] as const
|
|
16
|
+
|
|
17
|
+
export type RecordRoot = (typeof RECORD_ROOTS)[number]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The root a record folder is created at.
|
|
21
|
+
*
|
|
22
|
+
* It disagrees with the read precedence above on purpose, and the disagreement
|
|
23
|
+
* is the whole of this branch. Creating under `.canon/` before the move would
|
|
24
|
+
* write records to a root whose ignore line may not have reached a target yet,
|
|
25
|
+
* and it would split one project's records across two roots with no verb able
|
|
26
|
+
* to reconcile them. The move flips this line and nothing else.
|
|
27
|
+
*/
|
|
28
|
+
export const CREATION_ROOT: RecordRoot = '.claude'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The deletable scratch folder, named at the spelling `.claude/` gives it.
|
|
32
|
+
*
|
|
33
|
+
* It is the one folder whose name differs by root. Inside a dotted root the
|
|
34
|
+
* leading dot hides nothing already hidden and costs a bare `ls` that omits the
|
|
35
|
+
* folder, so the move drops it. Every other record folder keeps its name,
|
|
36
|
+
* `.records.git` included, where the dot marks the mechanism apart from a
|
|
37
|
+
* payload rather than hiding it.
|
|
38
|
+
*/
|
|
39
|
+
export const SCRATCH = '.tmp'
|
|
40
|
+
|
|
41
|
+
/** The scratch folder's name under `.canon/`. */
|
|
42
|
+
const CANON_SCRATCH = 'tmp'
|
|
43
|
+
|
|
44
|
+
/** How a root spells a folder name. Only the scratch folder differs. */
|
|
45
|
+
function spell(root: RecordRoot, folder: string): string {
|
|
46
|
+
return root === '.canon' && folder === SCRATCH ? CANON_SCRATCH : folder
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The root a folder resolves at: the first that carries it, and the creation
|
|
51
|
+
* default when neither does.
|
|
52
|
+
*
|
|
53
|
+
* Presence is read on the record folder itself rather than on the full path, so
|
|
54
|
+
* an archive or a payload that does not exist yet still resolves beside the
|
|
55
|
+
* records it belongs to rather than at the creation default.
|
|
56
|
+
*/
|
|
57
|
+
function rootOf(root: string, folder: string): RecordRoot {
|
|
58
|
+
return (
|
|
59
|
+
RECORD_ROOTS.find((candidate) =>
|
|
60
|
+
existsSync(join(root, candidate, spell(candidate, folder))),
|
|
61
|
+
) ?? CREATION_ROOT
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where a record folder is read.
|
|
67
|
+
*
|
|
68
|
+
* `folder` is the record folder itself and `rest` is whatever sits inside it,
|
|
69
|
+
* so a caller spells no root and no folder-name variant of its own. A caller
|
|
70
|
+
* that spells `.claude` by hand is the one thing the move has to find, and one
|
|
71
|
+
* that calls this is one the move never has to open again.
|
|
72
|
+
*/
|
|
73
|
+
export function recordDir(
|
|
74
|
+
root: string,
|
|
75
|
+
folder: string,
|
|
76
|
+
...rest: string[]
|
|
77
|
+
): string {
|
|
78
|
+
const at = rootOf(root, folder)
|
|
79
|
+
|
|
80
|
+
return join(root, at, spell(at, folder), ...rest)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every root a record folder would be read at, in precedence order, whether or
|
|
85
|
+
* not it is on disk.
|
|
86
|
+
*
|
|
87
|
+
* Containment tests take this rather than `recordDir`, since a path written
|
|
88
|
+
* against the root a tree no longer uses is still a path into that folder and
|
|
89
|
+
* reading it as outside would report a shipped plan as still live.
|
|
90
|
+
*/
|
|
91
|
+
export function recordDirs(
|
|
92
|
+
root: string,
|
|
93
|
+
folder: string,
|
|
94
|
+
...rest: string[]
|
|
95
|
+
): string[] {
|
|
96
|
+
return RECORD_ROOTS.map((candidate) =>
|
|
97
|
+
join(root, candidate, spell(candidate, folder), ...rest),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Where a record folder is created, which is the creation default always. */
|
|
102
|
+
export function creationDir(
|
|
103
|
+
root: string,
|
|
104
|
+
folder: string,
|
|
105
|
+
...rest: string[]
|
|
106
|
+
): string {
|
|
107
|
+
return join(root, CREATION_ROOT, spell(CREATION_ROOT, folder), ...rest)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The creation destination relative to the project root, which is the form a
|
|
112
|
+
* message displays and an option default carries.
|
|
113
|
+
*/
|
|
114
|
+
export function creationRel(folder: string, ...rest: string[]): string {
|
|
115
|
+
return join(CREATION_ROOT, spell(CREATION_ROOT, folder), ...rest)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The record root itself, for a caller whose subject is the root rather than a
|
|
120
|
+
* folder inside it.
|
|
121
|
+
*
|
|
122
|
+
* A half-migrated tree resolves here on the root that exists rather than on the
|
|
123
|
+
* folders under it, which is what makes the backup history and its work tree
|
|
124
|
+
* one answer. Splitting them would let a push resolve a history at one root and
|
|
125
|
+
* stage a work tree at the other, which stages the deletion of every folder the
|
|
126
|
+
* move relocated.
|
|
127
|
+
*/
|
|
128
|
+
export function recordRoot(root: string): string {
|
|
129
|
+
return join(
|
|
130
|
+
root,
|
|
131
|
+
RECORD_ROOTS.find((candidate) => existsSync(join(root, candidate))) ??
|
|
132
|
+
CREATION_ROOT,
|
|
133
|
+
)
|
|
134
|
+
}
|
package/src/records/backup.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { join, resolve } from 'node:path'
|
|
2
|
+
import { join, relative, resolve } from 'node:path'
|
|
3
3
|
import { $ } from 'bun'
|
|
4
4
|
import { gitEnv } from '@/git-env'
|
|
5
|
+
import { recordRoot } from '@/record-root'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* The folders a backup carries, relative to
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* The folders a backup carries, relative to the record root `workTree` resolves
|
|
9
|
+
* rather than to `.claude/` specifically, since the same nine names sit under
|
|
10
|
+
* whichever root a tree holds. Most of them are the `# Claude` group the claude
|
|
11
|
+
* manifest ships, minus three: the scratch folder, which is defined as deletable
|
|
12
|
+
* without loss, `worktrees/`, whose contents belong to the enclosing repository
|
|
13
|
+
* already, and `.records.git/`, which is the history the rest are pushed into.
|
|
14
|
+
* The list is spelled out rather than read off that group so adding an ignore
|
|
15
|
+
* entry cannot silently enlarge the payload.
|
|
14
16
|
*
|
|
15
17
|
* `diagrams` is the one name the manifest group does not carry, so a target
|
|
16
18
|
* tracks it where this repository ignores it. That is the second reason to
|
|
@@ -65,10 +67,25 @@ const RETIRED_FOLDERS = [
|
|
|
65
67
|
'task-archive',
|
|
66
68
|
] as const
|
|
67
69
|
|
|
68
|
-
/**
|
|
69
|
-
const
|
|
70
|
+
/** The history directory's own name, which keeps its dot at either record root. */
|
|
71
|
+
const RECORDS_GIT_NAME = '.records.git'
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The tree a backup stages, which is the record root itself.
|
|
75
|
+
*
|
|
76
|
+
* It resolves the root rather than each folder under it, so the history and the
|
|
77
|
+
* work tree are one answer. Resolving them apart would let a half-migrated tree
|
|
78
|
+
* open a history at one root and stage a work tree at the other, which stages
|
|
79
|
+
* the deletion of every folder the move relocated and pushes it.
|
|
80
|
+
*/
|
|
81
|
+
function workTree(root: string): string {
|
|
82
|
+
return recordRoot(root)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Holds the records history beside the folders it tracks, ignored by the enclosing repository. */
|
|
86
|
+
function recordsGitDir(root: string): string {
|
|
87
|
+
return join(workTree(root), RECORDS_GIT_NAME)
|
|
88
|
+
}
|
|
72
89
|
|
|
73
90
|
/** Both directions name the branch, so a machine whose `init.defaultBranch` differs still lands on it. */
|
|
74
91
|
const RECORDS_BRANCH = 'main'
|
|
@@ -150,11 +167,11 @@ interface GitResult {
|
|
|
150
167
|
* work tree.
|
|
151
168
|
*/
|
|
152
169
|
async function records(root: string, args: string[]): Promise<GitResult> {
|
|
153
|
-
const gitDir = resolve(root
|
|
154
|
-
const
|
|
170
|
+
const gitDir = resolve(recordsGitDir(root))
|
|
171
|
+
const tree = resolve(workTree(root))
|
|
155
172
|
|
|
156
173
|
const result =
|
|
157
|
-
await $`git -C ${
|
|
174
|
+
await $`git -C ${tree} --git-dir=${gitDir} --work-tree=${tree} ${args}`
|
|
158
175
|
.env(gitEnv())
|
|
159
176
|
.quiet()
|
|
160
177
|
.nothrow()
|
|
@@ -237,13 +254,15 @@ async function enclosingRemoteUrls(
|
|
|
237
254
|
* cannot be read is what keeps a failed comparison from reading as a pass.
|
|
238
255
|
*/
|
|
239
256
|
async function resolveRemote(root: string): Promise<string | BackupRefused> {
|
|
240
|
-
|
|
257
|
+
const gitDir = recordsGitDir(root)
|
|
258
|
+
|
|
259
|
+
if (!existsSync(gitDir)) {
|
|
241
260
|
return refuse(
|
|
242
261
|
'no-repository',
|
|
243
262
|
[
|
|
244
|
-
`No records history at ${
|
|
245
|
-
` git --git-dir=${
|
|
246
|
-
` git --git-dir=${
|
|
263
|
+
`No records history at ${relative(root, gitDir)}. Create it once, against a private repository:`,
|
|
264
|
+
` git --git-dir=${gitDir} init`,
|
|
265
|
+
` git --git-dir=${gitDir} remote add origin <private-repo-url>`,
|
|
247
266
|
].join('\n'),
|
|
248
267
|
)
|
|
249
268
|
}
|
|
@@ -254,7 +273,7 @@ async function resolveRemote(root: string): Promise<string | BackupRefused> {
|
|
|
254
273
|
'no-remote',
|
|
255
274
|
[
|
|
256
275
|
'The records history has no origin. Point it at a private repository:',
|
|
257
|
-
` git --git-dir=${
|
|
276
|
+
` git --git-dir=${gitDir} remote add origin <private-repo-url>`,
|
|
258
277
|
].join('\n'),
|
|
259
278
|
)
|
|
260
279
|
}
|
|
@@ -299,8 +318,7 @@ async function scopedFolders(root: string): Promise<string[]> {
|
|
|
299
318
|
)
|
|
300
319
|
|
|
301
320
|
return [...BACKED_FOLDERS, ...RETIRED_FOLDERS].filter(
|
|
302
|
-
(folder) =>
|
|
303
|
-
existsSync(join(root, WORK_TREE, folder)) || indexed.has(folder),
|
|
321
|
+
(folder) => existsSync(join(workTree(root), folder)) || indexed.has(folder),
|
|
304
322
|
)
|
|
305
323
|
}
|
|
306
324
|
|
|
@@ -311,7 +329,7 @@ function topSegment(path: string): string {
|
|
|
311
329
|
/** What a report names, which is the folders a reader can go and open. */
|
|
312
330
|
function presentFolders(root: string): string[] {
|
|
313
331
|
return BACKED_FOLDERS.filter((folder) =>
|
|
314
|
-
existsSync(join(root,
|
|
332
|
+
existsSync(join(workTree(root), folder)),
|
|
315
333
|
)
|
|
316
334
|
}
|
|
317
335
|
|
package/src/records/size.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { existsSync, type Stats } from 'node:fs'
|
|
2
2
|
import { readdir, stat } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
+
import { RECORD_ROOTS, recordDir, SCRATCH } from '@/record-root'
|
|
4
5
|
import { BACKED_FOLDERS } from '@/records/backup'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* The folders a size reading covers,
|
|
8
|
+
* The folders a size reading covers, named at the record root they sit under.
|
|
8
9
|
*
|
|
9
|
-
* It is the backed set plus
|
|
10
|
+
* It is the backed set plus the scratch folder, which a backup skips because it is
|
|
10
11
|
* deletable without loss and a reading covers because deletable is not the same
|
|
11
12
|
* as empty: the routing handoffs and the memory archive both sit there and both
|
|
12
13
|
* accumulate. `.records.git` stays out because it is the backup history rather
|
|
@@ -14,7 +15,7 @@ import { BACKED_FOLDERS } from '@/records/backup'
|
|
|
14
15
|
* checkout of the enclosing repository with its own removal verb, and one of
|
|
15
16
|
* them outweighs every record folder combined.
|
|
16
17
|
*/
|
|
17
|
-
export const SIZED_FOLDERS = [...BACKED_FOLDERS,
|
|
18
|
+
export const SIZED_FOLDERS = [...BACKED_FOLDERS, SCRATCH] as const
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* The windows a reading reports, in days.
|
|
@@ -34,7 +35,7 @@ export interface WindowCount {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export interface FolderSize {
|
|
37
|
-
/** Relative to
|
|
38
|
+
/** Relative to the record root, which is the name a reader opens. */
|
|
38
39
|
readonly folder: string
|
|
39
40
|
readonly present: boolean
|
|
40
41
|
readonly files: number
|
|
@@ -170,7 +171,7 @@ async function measure(
|
|
|
170
171
|
folder: string,
|
|
171
172
|
now: number,
|
|
172
173
|
): Promise<FolderSize> {
|
|
173
|
-
const path =
|
|
174
|
+
const path = recordDir(root, folder)
|
|
174
175
|
const empty = GROWTH_WINDOWS.map((days) => ({ days, files: 0 }))
|
|
175
176
|
|
|
176
177
|
if (!existsSync(path)) {
|
|
@@ -212,11 +213,15 @@ export async function sizeRecords(
|
|
|
212
213
|
root: string,
|
|
213
214
|
now: number = Date.now(),
|
|
214
215
|
): Promise<SizeOutcome> {
|
|
215
|
-
|
|
216
|
+
// Either root answers, so a migrated tree is read rather than refused. The
|
|
217
|
+
// roots are tested rather than the folders under them, since a project that
|
|
218
|
+
// holds the root and no records yet is empty rather than absent and the
|
|
219
|
+
// per-folder `present` flags already say which of the ten it carries.
|
|
220
|
+
if (!RECORD_ROOTS.some((name) => existsSync(join(root, name)))) {
|
|
216
221
|
return {
|
|
217
222
|
ok: false,
|
|
218
223
|
reason: 'no-folder',
|
|
219
|
-
message: `No .
|
|
224
|
+
message: `No ${RECORD_ROOTS.join(' or ')} directory at ${root}, so there are no record folders to read.`,
|
|
220
225
|
}
|
|
221
226
|
}
|
|
222
227
|
|