@erclx/aitk 3.5.0 → 3.7.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-autoship/SKILL.md +5 -5
- package/claude/skills/claude-docs/SKILL.md +15 -10
- package/claude/skills/claude-memory-review/SKILL.md +7 -7
- package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
- package/claude/skills/claude-orchestrate/SKILL.md +2 -2
- package/claude/skills/claude-pr-review/SKILL.md +25 -7
- package/claude/skills/claude-review/SKILL.md +5 -3
- package/claude/skills/claude-screencast/SKILL.md +9 -4
- package/claude/skills/claude-tasks/SKILL.md +2 -2
- package/claude/skills/decision-escalate/REQUIREMENT.md +3 -3
- package/claude/skills/decision-escalate/SKILL.md +4 -6
- package/claude/skills/git-pr/references/pr.md +3 -0
- package/claude/skills/git-ship/SKILL.md +1 -1
- package/claude/skills/git-split/references/pr.md +3 -0
- package/claude/skills/toolkit-feedback/SKILL.md +2 -2
- package/docs/agents/capture.md +3 -1
- package/docs/agents/commands.md +4 -1
- package/docs/agents/demo.md +82 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/records.md +2 -2
- package/docs/agents/tasks.md +1 -1
- package/docs/ai-workflow.md +7 -5
- package/docs/operating-model.md +13 -4
- package/governance/rules/claude/558-plan.md +1 -2
- package/governance/rules/lib/300-testing-ts.md +1 -0
- package/package.json +3 -2
- package/src/cli.ts +4 -1
- package/src/commands/demo.ts +373 -0
- package/src/commands/feedback.ts +10 -3
- package/src/commands/tasks.ts +1 -1
- package/src/demo/beats.ts +135 -0
- package/src/demo/compile.ts +295 -0
- package/src/demo/cursors.ts +55 -0
- package/src/demo/drive.ts +256 -0
- package/src/demo/pointer.ts +178 -0
- package/src/demo/theme.ts +112 -0
- package/src/records/backup.ts +34 -8
- package/src/tasks/archive.ts +11 -4
- package/standards/bundled/pr.md +3 -0
- package/standards/plan.md +1 -1
- package/standards/tasks.md +4 -4
- package/tooling/claude/manifest.toml +1 -1
- package/tooling/claude/reference.md +6 -5
- package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +4 -1
- package/tooling/claude/seeds/CLAUDE.md +4 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { chromium } from 'playwright-core'
|
|
5
|
+
import type { Browser, BrowserContext, Page } from 'playwright-core'
|
|
6
|
+
import type { DemoPlan, DemoStep } from '@/demo/compile'
|
|
7
|
+
import type { CursorSet } from '@/demo/pointer'
|
|
8
|
+
import { pointerSource } from '@/demo/pointer'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Drives a running application and records what it did. Every browser reference
|
|
12
|
+
* the demo feature adds lives here, and `src/commands/demo.ts` reaches it
|
|
13
|
+
* through a dynamic import so no other command resolves the engine at startup.
|
|
14
|
+
*
|
|
15
|
+
* Unlike `@/capture/render`, this module ships. The capture command is excluded
|
|
16
|
+
* from the published package because it regenerates images committed to this
|
|
17
|
+
* repository, and that reason does not transfer to a command whose whole
|
|
18
|
+
* purpose is running in someone else's project.
|
|
19
|
+
*
|
|
20
|
+
* It imports `playwright-core` rather than `@playwright/test`, which stays a
|
|
21
|
+
* development dependency for the capture module. Shipping puts the import in
|
|
22
|
+
* every target's dependency tree, and a target needs the driver rather than a
|
|
23
|
+
* test runner and an assertion library. Both are pinned to one version rather
|
|
24
|
+
* than a range, because `bunx playwright install chromium` fetches the browser
|
|
25
|
+
* revision the installed engine expects and a float would leave a target
|
|
26
|
+
* resolving a binary its engine cannot launch.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const POINTER_SIZE = 32
|
|
30
|
+
/**
|
|
31
|
+
* Where the pointer starts. Any position inside the viewport works, since the
|
|
32
|
+
* point is giving it one move to install and paint before it travels to the
|
|
33
|
+
* first target. A corner keeps that first move out of the way of the content.
|
|
34
|
+
*/
|
|
35
|
+
const START = { x: 8, y: 8 }
|
|
36
|
+
const SETTLE_MS = 250
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The two output paths arrive resolved rather than as a root this re-resolves
|
|
40
|
+
* against, because the plan already carries a directory and resolving it twice
|
|
41
|
+
* nests the whole path inside itself.
|
|
42
|
+
*
|
|
43
|
+
* An absent path means the caller asked for that artifact not to be produced.
|
|
44
|
+
*/
|
|
45
|
+
export interface DriveOptions {
|
|
46
|
+
readonly plan: DemoPlan
|
|
47
|
+
readonly cursors: CursorSet
|
|
48
|
+
readonly videoPath?: string
|
|
49
|
+
readonly stillPath?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type DriveResult =
|
|
53
|
+
| {
|
|
54
|
+
status: 'recorded'
|
|
55
|
+
videoPath?: string
|
|
56
|
+
stillPath?: string
|
|
57
|
+
steps: number
|
|
58
|
+
durationMs: number
|
|
59
|
+
}
|
|
60
|
+
| { status: 'failed'; reason: DriveRefusal; message: string }
|
|
61
|
+
|
|
62
|
+
export type DriveRefusal = 'browser-missing' | 'drive-failed'
|
|
63
|
+
|
|
64
|
+
interface DriveFailure {
|
|
65
|
+
readonly status: 'failed'
|
|
66
|
+
readonly reason: DriveRefusal
|
|
67
|
+
readonly message: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Launch = { status: 'launched'; value: Browser } | DriveFailure
|
|
71
|
+
|
|
72
|
+
export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
73
|
+
const { plan } = options
|
|
74
|
+
|
|
75
|
+
const browser = await launch()
|
|
76
|
+
if (browser.status === 'failed') return browser
|
|
77
|
+
|
|
78
|
+
let videoDir: string | undefined
|
|
79
|
+
let context: BrowserContext
|
|
80
|
+
const started = Date.now()
|
|
81
|
+
try {
|
|
82
|
+
// Created after the launch, so a target with no browser binary does not
|
|
83
|
+
// leave an empty directory behind for a run that never started, and inside
|
|
84
|
+
// the try so a failure here closes the browser rather than leaking it.
|
|
85
|
+
videoDir = options.videoPath
|
|
86
|
+
? mkdtempSync(join(tmpdir(), 'aitk-demo-'))
|
|
87
|
+
: undefined
|
|
88
|
+
|
|
89
|
+
context = await browser.value.newContext({
|
|
90
|
+
viewport: plan.viewport,
|
|
91
|
+
// Pointed the opposite way from a test. A recording wants the motion the
|
|
92
|
+
// interface was designed with, where a test wants it suppressed.
|
|
93
|
+
reducedMotion: 'no-preference',
|
|
94
|
+
...(videoDir
|
|
95
|
+
? {
|
|
96
|
+
recordVideo: {
|
|
97
|
+
dir: videoDir,
|
|
98
|
+
size: plan.viewport,
|
|
99
|
+
showActions: {
|
|
100
|
+
duration: plan.annotations.durationMs,
|
|
101
|
+
position: plan.annotations.position,
|
|
102
|
+
fontSize: plan.annotations.fontSize,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
107
|
+
})
|
|
108
|
+
} catch (error) {
|
|
109
|
+
await browser.value.close()
|
|
110
|
+
if (videoDir) rmSync(videoDir, { recursive: true, force: true })
|
|
111
|
+
return failed('drive-failed', error)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let stillPath: string | undefined
|
|
115
|
+
let videoPath: string | undefined
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await context.addInitScript({
|
|
119
|
+
content: pointerSource(options.cursors, POINTER_SIZE),
|
|
120
|
+
})
|
|
121
|
+
const page = await context.newPage()
|
|
122
|
+
const video = page.video()
|
|
123
|
+
|
|
124
|
+
// The opening navigate is skipped when the plan already starts with one,
|
|
125
|
+
// because a draft written around an opening verb compiles to a `navigate`
|
|
126
|
+
// step for the same URL and the second load is a visible reload.
|
|
127
|
+
if (plan.steps[0]?.kind !== 'navigate') {
|
|
128
|
+
await page.goto(plan.url)
|
|
129
|
+
await page.mouse.move(START.x, START.y, { steps: 2 })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const step of plan.steps) {
|
|
133
|
+
await runStep(page, plan, step)
|
|
134
|
+
// The first marked step wins. One file holds one frame, so a plan a
|
|
135
|
+
// person edited to mark several would otherwise write each over the last
|
|
136
|
+
// and keep whichever ran last, with nothing saying so.
|
|
137
|
+
if (step.still && options.stillPath && !stillPath) {
|
|
138
|
+
stillPath = options.stillPath
|
|
139
|
+
mkdirSync(dirname(stillPath), { recursive: true })
|
|
140
|
+
await page.screenshot({ path: stillPath })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await context.close()
|
|
145
|
+
|
|
146
|
+
if (video && options.videoPath) {
|
|
147
|
+
videoPath = options.videoPath
|
|
148
|
+
mkdirSync(dirname(videoPath), { recursive: true })
|
|
149
|
+
await video.saveAs(videoPath)
|
|
150
|
+
// The engine keeps the auto-named recording beside the saved copy, so a
|
|
151
|
+
// run that skipped this would leave two files for every demo.
|
|
152
|
+
await video.delete()
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return failed('drive-failed', error)
|
|
156
|
+
} finally {
|
|
157
|
+
await browser.value.close()
|
|
158
|
+
if (videoDir) rmSync(videoDir, { recursive: true, force: true })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
status: 'recorded',
|
|
163
|
+
...(videoPath ? { videoPath } : {}),
|
|
164
|
+
...(stillPath ? { stillPath } : {}),
|
|
165
|
+
steps: plan.steps.length,
|
|
166
|
+
durationMs: Date.now() - started,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Separates a browser binary that was never installed from every other launch
|
|
172
|
+
* failure, because the first is a setup step the operator has to run and the
|
|
173
|
+
* second is a defect. A target inherits that setup step, which is the stated
|
|
174
|
+
* cost of shipping this command outside the toolkit.
|
|
175
|
+
*/
|
|
176
|
+
async function launch(): Promise<Launch> {
|
|
177
|
+
try {
|
|
178
|
+
return { status: 'launched', value: await chromium.launch() }
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const text = error instanceof Error ? error.message : String(error)
|
|
181
|
+
return failed(
|
|
182
|
+
/executable doesn't exist|playwright install/i.test(text)
|
|
183
|
+
? 'browser-missing'
|
|
184
|
+
: 'drive-failed',
|
|
185
|
+
error,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function runStep(
|
|
191
|
+
page: Page,
|
|
192
|
+
plan: DemoPlan,
|
|
193
|
+
step: DemoStep,
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
switch (step.kind) {
|
|
196
|
+
case 'navigate':
|
|
197
|
+
await page.goto(step.target || plan.url)
|
|
198
|
+
await page.mouse.move(START.x, START.y, { steps: 2 })
|
|
199
|
+
break
|
|
200
|
+
case 'click':
|
|
201
|
+
await moveTo(page, plan, step)
|
|
202
|
+
await page.mouse.down()
|
|
203
|
+
await page.mouse.up()
|
|
204
|
+
break
|
|
205
|
+
case 'fill':
|
|
206
|
+
await moveTo(page, plan, step)
|
|
207
|
+
await page.mouse.down()
|
|
208
|
+
await page.mouse.up()
|
|
209
|
+
await page.keyboard.type(step.text, { delay: plan.pointer.typeDelayMs })
|
|
210
|
+
break
|
|
211
|
+
case 'hover':
|
|
212
|
+
await moveTo(page, plan, step)
|
|
213
|
+
break
|
|
214
|
+
case 'scroll':
|
|
215
|
+
await page.locator(step.target).first().scrollIntoViewIfNeeded()
|
|
216
|
+
await page.waitForTimeout(SETTLE_MS)
|
|
217
|
+
await moveTo(page, plan, step)
|
|
218
|
+
break
|
|
219
|
+
case 'wait':
|
|
220
|
+
case 'hold':
|
|
221
|
+
break
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (step.waitFor) await page.locator(step.waitFor).first().waitFor()
|
|
225
|
+
await page.waitForTimeout(step.holdMs)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Travel is the whole point of driving the engine's pointer rather than calling
|
|
230
|
+
* the element-clicking helper, which resolves a target and jumps to it. The
|
|
231
|
+
* step count is what separates a cursor that glides from one that teleports.
|
|
232
|
+
*
|
|
233
|
+
* Resolving through a bounding box assumes the target is in the viewport, so a
|
|
234
|
+
* target below the fold needs a `scroll` step ahead of it.
|
|
235
|
+
*/
|
|
236
|
+
async function moveTo(
|
|
237
|
+
page: Page,
|
|
238
|
+
plan: DemoPlan,
|
|
239
|
+
step: DemoStep,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const locator = page.locator(step.target).first()
|
|
242
|
+
await locator.waitFor()
|
|
243
|
+
const box = await locator.boundingBox()
|
|
244
|
+
if (!box) throw new Error(`${step.target} has no box to point at`)
|
|
245
|
+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
|
|
246
|
+
steps: plan.pointer.steps,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function failed(reason: DriveRefusal, error: unknown): DriveFailure {
|
|
251
|
+
return {
|
|
252
|
+
status: 'failed',
|
|
253
|
+
reason,
|
|
254
|
+
message: error instanceof Error ? error.message : String(error),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pointer the recording shows. The browser engine's own annotation draws a
|
|
3
|
+
* dot at the moment of a click and paints no cursor, so a run without this
|
|
4
|
+
* looks like the pointer teleports between targets.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here touches a browser. `pointerSource` returns the script text that
|
|
7
|
+
* `@/demo/drive` installs before navigation, which keeps the hotspot arithmetic
|
|
8
|
+
* and the state table testable without launching anything.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface CursorHotspot {
|
|
12
|
+
readonly width: number
|
|
13
|
+
readonly height: number
|
|
14
|
+
readonly hotspotX: number
|
|
15
|
+
readonly hotspotY: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Cursor {
|
|
19
|
+
readonly image: string
|
|
20
|
+
readonly hotspot: CursorHotspot
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type CursorSet = Readonly<Record<string, Cursor>>
|
|
24
|
+
|
|
25
|
+
const HEADER_BYTES = 6
|
|
26
|
+
const ENTRY_BYTES = 16
|
|
27
|
+
const CURSOR_TYPE = 2
|
|
28
|
+
/** The format stores 256 as a zero, since the field is one byte wide. */
|
|
29
|
+
const SIZE_256 = 256
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reads the directory of a Windows cursor resource and returns the hotspot of
|
|
33
|
+
* its largest entry. A resource carries several sizes with a hotspot each, and
|
|
34
|
+
* taking the largest is what matches the artwork the recorder draws at.
|
|
35
|
+
*
|
|
36
|
+
* Only the header and directory are read. The pixel payload is left to the
|
|
37
|
+
* browser, which spike 3 measured as decoding the format directly with no
|
|
38
|
+
* conversion step and no image tooling on the machine.
|
|
39
|
+
*/
|
|
40
|
+
export function cursorHotspot(bytes: Uint8Array): CursorHotspot | undefined {
|
|
41
|
+
if (bytes.byteLength < HEADER_BYTES + ENTRY_BYTES) return undefined
|
|
42
|
+
|
|
43
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
44
|
+
if (view.getUint16(2, true) !== CURSOR_TYPE) return undefined
|
|
45
|
+
|
|
46
|
+
const count = view.getUint16(4, true)
|
|
47
|
+
if (count === 0) return undefined
|
|
48
|
+
|
|
49
|
+
let largest: CursorHotspot | undefined
|
|
50
|
+
for (let index = 0; index < count; index += 1) {
|
|
51
|
+
const at = HEADER_BYTES + index * ENTRY_BYTES
|
|
52
|
+
if (at + ENTRY_BYTES > bytes.byteLength) break
|
|
53
|
+
|
|
54
|
+
const entry: CursorHotspot = {
|
|
55
|
+
width: bytes[at] === 0 ? SIZE_256 : (bytes[at] ?? 0),
|
|
56
|
+
height: bytes[at + 1] === 0 ? SIZE_256 : (bytes[at + 1] ?? 0),
|
|
57
|
+
hotspotX: view.getUint16(at + 4, true),
|
|
58
|
+
hotspotY: view.getUint16(at + 6, true),
|
|
59
|
+
}
|
|
60
|
+
if (!largest || entry.width * entry.height > largest.width * largest.height)
|
|
61
|
+
largest = entry
|
|
62
|
+
}
|
|
63
|
+
return largest
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Converts a hotspot stated against the source artwork into an offset in the
|
|
68
|
+
* size the pointer is drawn at. Skipping this puts the artwork's top left where
|
|
69
|
+
* the click lands instead of its tip, which offsets every action by roughly a
|
|
70
|
+
* third of a cursor.
|
|
71
|
+
*/
|
|
72
|
+
export function scaleHotspot(
|
|
73
|
+
hotspot: CursorHotspot,
|
|
74
|
+
drawnSize: number,
|
|
75
|
+
): { x: number; y: number } {
|
|
76
|
+
const scale = drawnSize / hotspot.width
|
|
77
|
+
return {
|
|
78
|
+
x: hotspot.hotspotX * scale,
|
|
79
|
+
y: hotspot.hotspotY * (drawnSize / hotspot.height),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface PointerState {
|
|
84
|
+
readonly image: string
|
|
85
|
+
readonly x: number
|
|
86
|
+
readonly y: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Returns the script installed before navigation. It is a string rather than a
|
|
91
|
+
* function reference because the cursor payload is data resolved on this side,
|
|
92
|
+
* and passing artwork through an argument would still need serializing.
|
|
93
|
+
*
|
|
94
|
+
* The element inherits the page's world, so a site with its own element at this
|
|
95
|
+
* id, a stacking context that outranks it, or a style rule reaching it will
|
|
96
|
+
* interfere. That is the cost of drawing the pointer inside the page rather
|
|
97
|
+
* than reaching for a desktop recorder.
|
|
98
|
+
*/
|
|
99
|
+
export function pointerSource(cursors: CursorSet, size: number): string {
|
|
100
|
+
const states: Record<string, PointerState> = {}
|
|
101
|
+
for (const [name, cursor] of Object.entries(cursors)) {
|
|
102
|
+
const offset = scaleHotspot(cursor.hotspot, size)
|
|
103
|
+
states[name] = { image: cursor.image, x: offset.x, y: offset.y }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const config = JSON.stringify({ size, states })
|
|
107
|
+
|
|
108
|
+
return `(() => {
|
|
109
|
+
const CONFIG = ${config};
|
|
110
|
+
const ID = '__aitk_demo_pointer__';
|
|
111
|
+
if (window[ID]) return;
|
|
112
|
+
window[ID] = true;
|
|
113
|
+
|
|
114
|
+
let node;
|
|
115
|
+
let state = 'default';
|
|
116
|
+
let pressed = false;
|
|
117
|
+
let x = -9999;
|
|
118
|
+
let y = -9999;
|
|
119
|
+
|
|
120
|
+
const install = () => {
|
|
121
|
+
if (node || !document.body) return;
|
|
122
|
+
node = document.createElement('img');
|
|
123
|
+
node.id = ID;
|
|
124
|
+
node.setAttribute('aria-hidden', 'true');
|
|
125
|
+
node.style.cssText = [
|
|
126
|
+
'position:fixed',
|
|
127
|
+
'left:0',
|
|
128
|
+
'top:0',
|
|
129
|
+
'width:' + CONFIG.size + 'px',
|
|
130
|
+
'height:' + CONFIG.size + 'px',
|
|
131
|
+
'z-index:2147483647',
|
|
132
|
+
'pointer-events:none',
|
|
133
|
+
'user-select:none',
|
|
134
|
+
'will-change:transform',
|
|
135
|
+
'transition:transform 90ms ease-out',
|
|
136
|
+
].join(';');
|
|
137
|
+
document.body.appendChild(node);
|
|
138
|
+
paint();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const paint = () => {
|
|
142
|
+
if (!node) return;
|
|
143
|
+
const cursor = CONFIG.states[state] || CONFIG.states.default;
|
|
144
|
+
if (!cursor) return;
|
|
145
|
+
if (node.getAttribute('src') !== cursor.image) node.setAttribute('src', cursor.image);
|
|
146
|
+
const scale = pressed ? 0.88 : 1;
|
|
147
|
+
node.style.transform =
|
|
148
|
+
'translate(' + (x - cursor.x) + 'px,' + (y - cursor.y) + 'px) scale(' + scale + ')';
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const stateAt = (target) => {
|
|
152
|
+
if (!(target instanceof Element)) return 'default';
|
|
153
|
+
const tag = target.tagName;
|
|
154
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return 'text';
|
|
155
|
+
const style = getComputedStyle(target).cursor;
|
|
156
|
+
if (style === 'pointer' && CONFIG.states.pointer) return 'pointer';
|
|
157
|
+
if (style === 'text' && CONFIG.states.text) return 'text';
|
|
158
|
+
return 'default';
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
addEventListener('mousemove', (event) => {
|
|
162
|
+
x = event.clientX;
|
|
163
|
+
y = event.clientY;
|
|
164
|
+
state = stateAt(event.target);
|
|
165
|
+
install();
|
|
166
|
+
paint();
|
|
167
|
+
}, true);
|
|
168
|
+
|
|
169
|
+
addEventListener('mousedown', () => { pressed = true; paint(); }, true);
|
|
170
|
+
addEventListener('mouseup', () => { pressed = false; paint(); }, true);
|
|
171
|
+
|
|
172
|
+
if (document.readyState === 'loading') {
|
|
173
|
+
addEventListener('DOMContentLoaded', install, { once: true });
|
|
174
|
+
} else {
|
|
175
|
+
install();
|
|
176
|
+
}
|
|
177
|
+
})();`
|
|
178
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { cursorHotspot, type Cursor, type CursorSet } from '@/demo/pointer'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads a cursor theme folder so `--cursor` can point at one. Spike 3 measured
|
|
7
|
+
* the browser decoding a Windows cursor resource directly, so nothing here
|
|
8
|
+
* converts anything: the bytes go into a data URI and the header supplies the
|
|
9
|
+
* hotspot.
|
|
10
|
+
*
|
|
11
|
+
* Three of the nineteen states a theme carries are read. A drag, a resize, or a
|
|
12
|
+
* wait shows the default arrow where a real session would show something else,
|
|
13
|
+
* and the two animated states have no still frame to draw at all.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const CURSOR_MIME = 'image/x-icon'
|
|
17
|
+
const EXTENSION = /\.cur$/i
|
|
18
|
+
|
|
19
|
+
/** Ordered per state, most specific first, so an exact name beats a longer one. */
|
|
20
|
+
const NAMES: Record<string, readonly string[]> = {
|
|
21
|
+
default: ['arrow', 'normal', 'default'],
|
|
22
|
+
pointer: ['link', 'hand', 'pointer'],
|
|
23
|
+
text: ['ibeam', 'beam', 'text'],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CursorFiles = Partial<Record<string, string>>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Picks one file per state out of a directory listing. Matching runs on the
|
|
30
|
+
* name rather than on the contents because a theme states its intent there, and
|
|
31
|
+
* a shorter match wins so `Arrow.cur` beats `Arrow Alternate.cur`.
|
|
32
|
+
*/
|
|
33
|
+
export function matchCursorFiles(files: readonly string[]): CursorFiles {
|
|
34
|
+
const cursors = files.filter((file) => EXTENSION.test(file))
|
|
35
|
+
const matched: CursorFiles = {}
|
|
36
|
+
|
|
37
|
+
for (const [state, names] of Object.entries(NAMES)) {
|
|
38
|
+
const found = names
|
|
39
|
+
.flatMap((name) =>
|
|
40
|
+
cursors.filter((file) => file.toLowerCase().includes(name)),
|
|
41
|
+
)
|
|
42
|
+
.sort((left, right) => left.length - right.length)[0]
|
|
43
|
+
if (found) matched[state] = found
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return matched
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ThemeLoad =
|
|
50
|
+
| { status: 'loaded'; cursors: CursorSet; states: string[] }
|
|
51
|
+
| { status: 'failed'; reason: string }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Falls back to the bundled artwork per state rather than per theme, so a
|
|
55
|
+
* folder carrying an arrow and no hand still contributes its arrow.
|
|
56
|
+
*/
|
|
57
|
+
export function loadCursorTheme(dir: string, fallback: CursorSet): ThemeLoad {
|
|
58
|
+
let listing: string[]
|
|
59
|
+
try {
|
|
60
|
+
listing = readdirNames(dir)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return {
|
|
63
|
+
status: 'failed',
|
|
64
|
+
reason: `${dir} could not be read: ${message(error)}`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const matched = matchCursorFiles(listing)
|
|
69
|
+
const states = Object.keys(matched)
|
|
70
|
+
if (!states.length) {
|
|
71
|
+
return {
|
|
72
|
+
status: 'failed',
|
|
73
|
+
reason: `${dir} holds no .cur file named after a pointer, link, or text state`,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cursors: Record<string, Cursor> = { ...fallback }
|
|
78
|
+
for (const [state, file] of Object.entries(matched)) {
|
|
79
|
+
if (!file) continue
|
|
80
|
+
const loaded = readCursor(join(dir, file))
|
|
81
|
+
if (loaded) cursors[state] = loaded
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { status: 'loaded', cursors, states }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readCursor(path: string): Cursor | undefined {
|
|
88
|
+
let bytes: Uint8Array
|
|
89
|
+
try {
|
|
90
|
+
bytes = readFileSync(path)
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const hotspot = cursorHotspot(bytes)
|
|
96
|
+
if (!hotspot) return undefined
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
image: `data:${CURSOR_MIME};base64,${Buffer.from(bytes).toString('base64')}`,
|
|
100
|
+
hotspot,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readdirNames(dir: string): string[] {
|
|
105
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
106
|
+
.filter((entry) => entry.isFile())
|
|
107
|
+
.map((entry) => entry.name)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function message(error: unknown): string {
|
|
111
|
+
return error instanceof Error ? error.message : String(error)
|
|
112
|
+
}
|
package/src/records/backup.ts
CHANGED
|
@@ -8,14 +8,19 @@ import { gitEnv } from '@/git-env'
|
|
|
8
8
|
* group the claude manifest ships, minus three: `.claude/.tmp`, which is
|
|
9
9
|
* defined as deletable without loss, `.claude/worktrees/`, whose contents
|
|
10
10
|
* belong to the enclosing repository already, and `.claude/.records.git/`,
|
|
11
|
-
* which is the history the other
|
|
11
|
+
* which is the history the other seven are pushed into. The list is spelled out
|
|
12
12
|
* rather than read off that group so adding an ignore entry cannot silently
|
|
13
13
|
* enlarge the payload.
|
|
14
14
|
*
|
|
15
15
|
* The manifest group is the one this reads rather than the enclosing
|
|
16
16
|
* repository's own `.gitignore`, which spreads the same entries across two
|
|
17
17
|
* headers and carries `.claude/README.md` that no target receives. Subtracting
|
|
18
|
-
* three from that file instead yields
|
|
18
|
+
* three from that file instead yields eight names against this list of seven.
|
|
19
|
+
*
|
|
20
|
+
* Each entry is a top-level record folder and every archive sits inside the one
|
|
21
|
+
* it archives, so the three former archive entries are covered by their parents
|
|
22
|
+
* rather than named here. That is what keeps this list at one line per surface
|
|
23
|
+
* as archives spread, which a sibling-per-archive layout could not.
|
|
19
24
|
*
|
|
20
25
|
* `RECORD_KINDS` in `validate.ts` overlaps this on five names and carries one
|
|
21
26
|
* more that no backup reaches. The two lists differ on purpose: one is what a
|
|
@@ -27,14 +32,31 @@ export const BACKED_FOLDERS = [
|
|
|
27
32
|
'intake',
|
|
28
33
|
'memory',
|
|
29
34
|
'plans',
|
|
30
|
-
'plans-archive',
|
|
31
35
|
'review',
|
|
32
|
-
'review-archive',
|
|
33
|
-
'task-archive',
|
|
34
36
|
'tasks',
|
|
35
37
|
'teach',
|
|
36
38
|
] as const
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Names that have left `BACKED_FOLDERS` and whose removal still has to reach a
|
|
42
|
+
* records history once.
|
|
43
|
+
*
|
|
44
|
+
* Dropping a name from the list above stops it entering the pathspec, so `add`
|
|
45
|
+
* never stages its deletion, the remote keeps the folder forever, and a `pull`
|
|
46
|
+
* onto another machine restores it beside whatever replaced it. These three are
|
|
47
|
+
* the archives that moved inside the records they archive, so the same files
|
|
48
|
+
* are already on the remote under their new paths.
|
|
49
|
+
*
|
|
50
|
+
* Retire a name here once no records history still carries it. Nothing measures
|
|
51
|
+
* that, so the cost of leaving one is three pathspec entries that match nothing
|
|
52
|
+
* and are filtered out before `add` ever sees them.
|
|
53
|
+
*/
|
|
54
|
+
const RETIRED_FOLDERS = [
|
|
55
|
+
'plans-archive',
|
|
56
|
+
'review-archive',
|
|
57
|
+
'task-archive',
|
|
58
|
+
] as const
|
|
59
|
+
|
|
38
60
|
/** Holds the records history beside the folders it tracks, ignored by the enclosing repository. */
|
|
39
61
|
const RECORDS_GIT_DIR = join('.claude', '.records.git')
|
|
40
62
|
|
|
@@ -233,14 +255,18 @@ async function resolveRemote(root: string): Promise<string | BackupRefused> {
|
|
|
233
255
|
}
|
|
234
256
|
|
|
235
257
|
/**
|
|
236
|
-
* The subset of the
|
|
237
|
-
* records index.
|
|
258
|
+
* The subset of the backed and retired names a pathspec can name: on disk, or
|
|
259
|
+
* already in the records index.
|
|
238
260
|
*
|
|
239
261
|
* A pathspec matching neither fails the whole `add`, which is why the subset
|
|
240
262
|
* exists. The index half is what covers a folder deleted in full. Reading disk
|
|
241
263
|
* alone drops it from the pathspec, so its deletion never stages, the remote
|
|
242
264
|
* keeps it forever, and a later `pull` restores it past the gate that refuses
|
|
243
265
|
* every other unpushed deletion.
|
|
266
|
+
*
|
|
267
|
+
* The retired names are the same case one level up, where the folder left the
|
|
268
|
+
* backed list rather than the disk, and the index is the only side that still
|
|
269
|
+
* knows it existed.
|
|
244
270
|
*/
|
|
245
271
|
async function scopedFolders(root: string): Promise<string[]> {
|
|
246
272
|
const tracked = await records(root, ['ls-files'])
|
|
@@ -248,7 +274,7 @@ async function scopedFolders(root: string): Promise<string[]> {
|
|
|
248
274
|
tracked.ok ? tracked.text.split('\n').filter(Boolean).map(topSegment) : [],
|
|
249
275
|
)
|
|
250
276
|
|
|
251
|
-
return BACKED_FOLDERS.filter(
|
|
277
|
+
return [...BACKED_FOLDERS, ...RETIRED_FOLDERS].filter(
|
|
252
278
|
(folder) =>
|
|
253
279
|
existsSync(join(root, WORK_TREE, folder)) || indexed.has(folder),
|
|
254
280
|
)
|
package/src/tasks/archive.ts
CHANGED
|
@@ -4,9 +4,9 @@ import { join, relative, resolve, sep } from 'node:path'
|
|
|
4
4
|
import { regenOne } from '@/indexes/regen'
|
|
5
5
|
|
|
6
6
|
const TASKS_DIR = join('.claude', 'tasks')
|
|
7
|
-
const ARCHIVE_DIR = join(
|
|
7
|
+
const ARCHIVE_DIR = join(TASKS_DIR, 'archive')
|
|
8
8
|
const PLANS_DIR = join('.claude', 'plans')
|
|
9
|
-
const PLANS_ARCHIVE_DIR = join(
|
|
9
|
+
const PLANS_ARCHIVE_DIR = join(PLANS_DIR, 'archive')
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Siblings that sit on the board without being tasks: the generated index, the
|
|
@@ -198,6 +198,10 @@ function isUnder(path: string, dir: string): boolean {
|
|
|
198
198
|
* count below compares two tasks by where their targets land and not by the
|
|
199
199
|
* strings they wrote. A target outside the live plans folder yields nothing,
|
|
200
200
|
* which is an archived plan or a pointer into somewhere else entirely.
|
|
201
|
+
*
|
|
202
|
+
* The archive sits inside the folder it archives, so containment alone reads an
|
|
203
|
+
* archived plan as live. Subtracting it is what keeps a closed task from being
|
|
204
|
+
* counted as a citation the sweep has yet to make.
|
|
201
205
|
*/
|
|
202
206
|
export function resolveLivePlan(
|
|
203
207
|
target: string,
|
|
@@ -205,11 +209,14 @@ export function resolveLivePlan(
|
|
|
205
209
|
root: string,
|
|
206
210
|
): string | undefined {
|
|
207
211
|
const plans = join(root, PLANS_DIR)
|
|
212
|
+
const archive = join(root, PLANS_ARCHIVE_DIR)
|
|
208
213
|
const fromBoard = resolve(dir, target)
|
|
209
214
|
const fromRoot = resolve(root, target)
|
|
210
215
|
|
|
211
|
-
if (isUnder(fromBoard, plans)
|
|
212
|
-
|
|
216
|
+
if (isUnder(fromBoard, plans) && !isUnder(fromBoard, archive)) {
|
|
217
|
+
return fromBoard
|
|
218
|
+
}
|
|
219
|
+
if (isUnder(fromRoot, plans) && !isUnder(fromRoot, archive)) return fromRoot
|
|
213
220
|
return undefined
|
|
214
221
|
}
|
|
215
222
|
|
package/standards/bundled/pr.md
CHANGED
|
@@ -54,6 +54,9 @@ Does not govern:
|
|
|
54
54
|
- Quote the count or output the run reported, never a figure carried from elsewhere.
|
|
55
55
|
- Leave a box unchecked only when a human is required, and name which human and why on the same line.
|
|
56
56
|
- Human-only covers visual or aesthetic judgment, anything needing credentials or a live third-party service, anything needing a second machine or a fresh OS, and judgment about whether a boundary or an abstraction reads correctly. The agent runs everything else.
|
|
57
|
+
- What makes a human required is a capability the agent lacks, never the cost of the run. Authorizing a spend is the operator's and performing the run is not, so an arm the repository ships a harness for gets driven once the operator has cleared the spend, and the box records what it returned.
|
|
58
|
+
- A tool refusal that actually fired is a capability gap, and the line says which refusal rather than naming the cost behind it. A refusal predicted and never met is not one.
|
|
59
|
+
- A live agent session is not a human. A box reading `needs a live session driving the skill` names the thing writing the description, so that run is owed rather than blocked.
|
|
57
60
|
- Put a request for the reviewer under `## For the reviewer`. It is a request rather than unfinished testing, so it never appears as an unchecked Testing box.
|
|
58
61
|
|
|
59
62
|
## Formatting
|
package/standards/plan.md
CHANGED
|
@@ -123,7 +123,7 @@ This contract inverts the one an intake folder keeps, where an empty slot means
|
|
|
123
123
|
- Write the plan before implementation starts, and treat it as the scope of the run that executes it.
|
|
124
124
|
- Keep every plan at one root. A plan copied into each parallel working tree forks, and the copies answer the same question differently.
|
|
125
125
|
- Amend the plan in place when a decision changes mid-flight. Do not append a second passage narrating the change, which leaves a reader to work out which of two answers is current. An execution-time deviation from a suggestion is one such amendment, and the contract above fixes which line takes it.
|
|
126
|
-
- Move the plan to `.claude/plans
|
|
126
|
+
- Move the plan to `.claude/plans/archive/` when the work it describes ships. Never delete it, because the plan is where the rejected alternative is written down and nothing else records it.
|
|
127
127
|
- Write the plan in the same session that opens the task it serves. The session executing it later inherits reasoning it would otherwise re-derive.
|
|
128
128
|
|
|
129
129
|
## Anti-patterns
|