@erclx/aitk 3.11.2 → 3.12.1
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-groundwork/REQUIREMENT.md +1 -0
- package/claude/skills/claude-groundwork/SKILL.md +12 -12
- package/claude/skills/claude-intake/REQUIREMENT.md +1 -0
- package/claude/skills/claude-intake/SKILL.md +7 -7
- package/docs/agents/demo.md +6 -2
- package/docs/agents/install-and-sync.md +55 -28
- package/docs/agents/intake.md +3 -1
- package/docs/ai-workflow.md +2 -2
- package/docs/target-projects.md +7 -5
- package/governance/rules/claude/556-groundwork.md +5 -1
- package/governance/rules/claude/557-intake.md +5 -1
- package/package.json +1 -1
- package/src/commands/demo.ts +25 -0
- package/src/commands/gov.ts +10 -1
- package/src/commands/sync.ts +18 -5
- package/src/demo/compile.ts +36 -5
- package/src/demo/container.ts +63 -0
- package/src/demo/drive.ts +110 -7
- package/src/gov/adapter.ts +29 -0
- package/src/gov/install.ts +22 -1
- package/src/gov/stacks.ts +33 -1
- package/src/intake/folder.ts +49 -9
- package/src/sync/check.ts +53 -15
- package/src/sync/engine.ts +25 -0
- package/src/sync/stamp.ts +73 -24
- package/src/tooling/stamp.ts +1 -1
- package/standards/groundwork.md +10 -3
- package/standards/index.md +2 -2
- package/standards/intake.md +10 -3
package/src/demo/compile.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { Beat, Draft } from '@/demo/beats'
|
|
|
5
5
|
* separate artifacts on purpose: a beat carries no target, no wait condition,
|
|
6
6
|
* and no timing, and putting those four fields on every beat would destroy the
|
|
7
7
|
* property the draft was designed around. See
|
|
8
|
-
* `.claude/groundwork/demo-recorder/06-decision.md`.
|
|
8
|
+
* `.claude/groundwork/38-demo-recorder/06-decision.md`.
|
|
9
9
|
*
|
|
10
10
|
* A compiled plan is committed rather than scratch, because the timing below is
|
|
11
11
|
* a starting point the operator tunes and the draft cannot reproduce a tuned
|
|
@@ -13,11 +13,42 @@ import type { Beat, Draft } from '@/demo/beats'
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/** Hand-tuned in spike 2 against one fixture. Nothing establishes them in general. */
|
|
16
|
-
const
|
|
16
|
+
const POINTER_TRAVEL_MS = 400
|
|
17
17
|
const TYPE_DELAY_MS = 110
|
|
18
18
|
const HOLD_MS = 600
|
|
19
19
|
const FINAL_HOLD_MS = 1200
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A step is a round trip to the browser, so a fixed count reproduces the
|
|
23
|
+
* defect under a new name if it does not respond to the round-trip cost
|
|
24
|
+
* measured at drive time. The bounds guard the extremes a bad measurement
|
|
25
|
+
* could produce: too few steps teleports rather than glides, and a
|
|
26
|
+
* near-zero measured cost cannot be trusted enough to let the count run
|
|
27
|
+
* away.
|
|
28
|
+
*
|
|
29
|
+
* Measured on 2026-08-26 against a served fixture, post-navigation: 10
|
|
30
|
+
* rounds of `page.mouse.move` averaged 16.6 milliseconds a step, well short
|
|
31
|
+
* of either bound at the 400 millisecond default (about 24 steps). A
|
|
32
|
+
* machine landing under `POINTER_TRAVEL_MS / MAX_POINTER_STEPS`, 3.33
|
|
33
|
+
* milliseconds a step here, hits the cap and stops responding to a faster
|
|
34
|
+
* one still. Nothing measured here establishes where a real machine sits
|
|
35
|
+
* relative to that boundary in general.
|
|
36
|
+
*/
|
|
37
|
+
export const MIN_POINTER_STEPS = 6
|
|
38
|
+
export const MAX_POINTER_STEPS = 120
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pure on purpose: the round-trip cost comes from a real browser and can
|
|
42
|
+
* only be measured at drive time, so this takes it as an argument rather
|
|
43
|
+
* than measuring it itself, which is what keeps it testable against a
|
|
44
|
+
* stubbed cost.
|
|
45
|
+
*/
|
|
46
|
+
export function deriveSteps(travelMs: number, roundTripMs: number): number {
|
|
47
|
+
if (roundTripMs <= 0) return MAX_POINTER_STEPS
|
|
48
|
+
const steps = Math.round(travelMs / roundTripMs)
|
|
49
|
+
return Math.min(MAX_POINTER_STEPS, Math.max(MIN_POINTER_STEPS, steps))
|
|
50
|
+
}
|
|
51
|
+
|
|
21
52
|
const VIEWPORT = { width: 1280, height: 720 } as const
|
|
22
53
|
const ANNOTATIONS = {
|
|
23
54
|
durationMs: 900,
|
|
@@ -82,7 +113,7 @@ export interface DemoPlan {
|
|
|
82
113
|
readonly url: string
|
|
83
114
|
readonly viewport: { readonly width: number; readonly height: number }
|
|
84
115
|
readonly output: { readonly video: string; readonly still: string }
|
|
85
|
-
readonly pointer: { readonly
|
|
116
|
+
readonly pointer: { readonly travelMs: number; readonly typeDelayMs: number }
|
|
86
117
|
readonly annotations: typeof ANNOTATIONS
|
|
87
118
|
readonly steps: readonly DemoStep[]
|
|
88
119
|
}
|
|
@@ -103,7 +134,7 @@ export function compilePlan(draft: Draft, options: CompileOptions): DemoPlan {
|
|
|
103
134
|
video: `${options.outDir}/${options.slug}.webm`,
|
|
104
135
|
still: `${options.outDir}/${options.slug}.png`,
|
|
105
136
|
},
|
|
106
|
-
pointer: {
|
|
137
|
+
pointer: { travelMs: POINTER_TRAVEL_MS, typeDelayMs: TYPE_DELAY_MS },
|
|
107
138
|
annotations: ANNOTATIONS,
|
|
108
139
|
steps: draft.beats.map((beat, position) =>
|
|
109
140
|
compileStep(beat, {
|
|
@@ -218,7 +249,7 @@ export function parsePlan(text: string): PlanParse {
|
|
|
218
249
|
still: asText(output.still) || `demos/${slug || 'demo'}.png`,
|
|
219
250
|
},
|
|
220
251
|
pointer: {
|
|
221
|
-
|
|
252
|
+
travelMs: asNumber(pointer.travelMs, POINTER_TRAVEL_MS),
|
|
222
253
|
typeDelayMs: asNumber(pointer.typeDelayMs, TYPE_DELAY_MS),
|
|
223
254
|
},
|
|
224
255
|
annotations: ANNOTATIONS,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { join, parse } from 'node:path'
|
|
2
|
+
import { execa } from 'execa'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A post-step on the file `@/demo/drive` already wrote. It touches neither
|
|
6
|
+
* the compiler nor the driving code, which is why deferring it out of the
|
|
7
|
+
* feature that shipped first cost no rework.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const CONVERTER_BIN = 'ffmpeg'
|
|
11
|
+
export const INSTALL_CONVERTER = 'ffmpeg (see https://ffmpeg.org/download.html)'
|
|
12
|
+
|
|
13
|
+
export type ContainerResult =
|
|
14
|
+
| { status: 'converted'; mp4Path: string }
|
|
15
|
+
| { status: 'skipped'; reason: 'converter-missing' }
|
|
16
|
+
| { status: 'failed'; reason: string }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Writes mp4 beside the webm rather than instead of it, since both stated use
|
|
20
|
+
* cases are a `<video>` tag on a page the operator controls, where webm
|
|
21
|
+
* already plays, and the social platform case is what mp4 is for.
|
|
22
|
+
*
|
|
23
|
+
* A missing binary is reported as skipped rather than failed. The recording
|
|
24
|
+
* already succeeded, and failing the run over an optional step would discard
|
|
25
|
+
* a good artifact.
|
|
26
|
+
*/
|
|
27
|
+
export async function convertToMp4(
|
|
28
|
+
webmPath: string,
|
|
29
|
+
bin: string = CONVERTER_BIN,
|
|
30
|
+
): Promise<ContainerResult> {
|
|
31
|
+
const { dir, name } = parse(webmPath)
|
|
32
|
+
const mp4Path = join(dir, `${name}.mp4`)
|
|
33
|
+
|
|
34
|
+
const result = await execa(
|
|
35
|
+
bin,
|
|
36
|
+
[
|
|
37
|
+
'-y',
|
|
38
|
+
'-i',
|
|
39
|
+
webmPath,
|
|
40
|
+
'-c:v',
|
|
41
|
+
'libx264',
|
|
42
|
+
'-pix_fmt',
|
|
43
|
+
'yuv420p',
|
|
44
|
+
'-c:a',
|
|
45
|
+
'aac',
|
|
46
|
+
'-movflags',
|
|
47
|
+
'+faststart',
|
|
48
|
+
mp4Path,
|
|
49
|
+
],
|
|
50
|
+
{ reject: false },
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if (result.failed && result.code === 'ENOENT') {
|
|
54
|
+
return { status: 'skipped', reason: 'converter-missing' }
|
|
55
|
+
}
|
|
56
|
+
if (result.exitCode !== 0) {
|
|
57
|
+
return {
|
|
58
|
+
status: 'failed',
|
|
59
|
+
reason: result.stderr?.trim() || `ffmpeg exited ${result.exitCode}`,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { status: 'converted', mp4Path }
|
|
63
|
+
}
|
package/src/demo/drive.ts
CHANGED
|
@@ -3,10 +3,17 @@ import { tmpdir } from 'node:os'
|
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
import { chromium } from 'playwright-core'
|
|
5
5
|
import type { Browser, BrowserContext, Page } from 'playwright-core'
|
|
6
|
+
import { deriveSteps } from '@/demo/compile'
|
|
6
7
|
import type { DemoPlan, DemoStep } from '@/demo/compile'
|
|
7
8
|
import type { CursorSet } from '@/demo/pointer'
|
|
8
9
|
import { pointerSource } from '@/demo/pointer'
|
|
9
10
|
|
|
11
|
+
declare global {
|
|
12
|
+
interface Window {
|
|
13
|
+
__aitk_demo_caption__?: (text: string) => void
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
/**
|
|
11
18
|
* Drives a running application and records what it did. Every browser reference
|
|
12
19
|
* the demo feature adds lives here, and `src/commands/demo.ts` reaches it
|
|
@@ -34,6 +41,10 @@ const POINTER_SIZE = 32
|
|
|
34
41
|
*/
|
|
35
42
|
const START = { x: 8, y: 8 }
|
|
36
43
|
const SETTLE_MS = 250
|
|
44
|
+
/** Round trips sampled to price one, on the page a step is actually about to move across. */
|
|
45
|
+
const CALIBRATION_STEPS = 8
|
|
46
|
+
/** DOM id the caption bar installs under, read back by `drive.e2e.test.ts`. */
|
|
47
|
+
export const CAPTION_ID = '__aitk_demo_caption_bar__'
|
|
37
48
|
|
|
38
49
|
/**
|
|
39
50
|
* The two output paths arrive resolved rather than as a root this re-resolves
|
|
@@ -118,8 +129,10 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
118
129
|
await context.addInitScript({
|
|
119
130
|
content: pointerSource(options.cursors, POINTER_SIZE),
|
|
120
131
|
})
|
|
132
|
+
await context.addInitScript({ content: captionInitScript() })
|
|
121
133
|
const page = await context.newPage()
|
|
122
134
|
const video = page.video()
|
|
135
|
+
const pace: PointerPace = {}
|
|
123
136
|
|
|
124
137
|
// The opening navigate is skipped when the plan already starts with one,
|
|
125
138
|
// because a draft written around an opening verb compiles to a `navigate`
|
|
@@ -130,7 +143,7 @@ export async function drive(options: DriveOptions): Promise<DriveResult> {
|
|
|
130
143
|
}
|
|
131
144
|
|
|
132
145
|
for (const step of plan.steps) {
|
|
133
|
-
await runStep(page, plan, step)
|
|
146
|
+
await runStep(page, plan, step, pace)
|
|
134
147
|
// The first marked step wins. One file holds one frame, so a plan a
|
|
135
148
|
// person edited to mark several would otherwise write each over the last
|
|
136
149
|
// and keep whichever ran last, with nothing saying so.
|
|
@@ -187,10 +200,24 @@ async function launch(): Promise<Launch> {
|
|
|
187
200
|
}
|
|
188
201
|
}
|
|
189
202
|
|
|
190
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Holds the round trip once a step has measured it, so every `moveTo` after
|
|
205
|
+
* the first reuses the same reading rather than re-timing on every move.
|
|
206
|
+
*/
|
|
207
|
+
export interface PointerPace {
|
|
208
|
+
roundTripMs?: number
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Exported so `drive.e2e.test.ts` can drive one real step against a real
|
|
213
|
+
* caption and read it back, which is the integration a full `drive()` call
|
|
214
|
+
* cannot assert without decoding the video it writes.
|
|
215
|
+
*/
|
|
216
|
+
export async function runStep(
|
|
191
217
|
page: Page,
|
|
192
218
|
plan: DemoPlan,
|
|
193
219
|
step: DemoStep,
|
|
220
|
+
pace: PointerPace,
|
|
194
221
|
): Promise<void> {
|
|
195
222
|
switch (step.kind) {
|
|
196
223
|
case 'navigate':
|
|
@@ -198,33 +225,47 @@ async function runStep(
|
|
|
198
225
|
await page.mouse.move(START.x, START.y, { steps: 2 })
|
|
199
226
|
break
|
|
200
227
|
case 'click':
|
|
201
|
-
await moveTo(page, plan, step)
|
|
228
|
+
await moveTo(page, plan, step, pace)
|
|
202
229
|
await page.mouse.down()
|
|
203
230
|
await page.mouse.up()
|
|
204
231
|
break
|
|
205
232
|
case 'fill':
|
|
206
|
-
await moveTo(page, plan, step)
|
|
233
|
+
await moveTo(page, plan, step, pace)
|
|
207
234
|
await page.mouse.down()
|
|
208
235
|
await page.mouse.up()
|
|
209
236
|
await page.keyboard.type(step.text, { delay: plan.pointer.typeDelayMs })
|
|
210
237
|
break
|
|
211
238
|
case 'hover':
|
|
212
|
-
await moveTo(page, plan, step)
|
|
239
|
+
await moveTo(page, plan, step, pace)
|
|
213
240
|
break
|
|
214
241
|
case 'scroll':
|
|
215
242
|
await page.locator(step.target).first().scrollIntoViewIfNeeded()
|
|
216
243
|
await page.waitForTimeout(SETTLE_MS)
|
|
217
|
-
await moveTo(page, plan, step)
|
|
244
|
+
await moveTo(page, plan, step, pace)
|
|
218
245
|
break
|
|
219
246
|
case 'wait':
|
|
220
247
|
case 'hold':
|
|
221
248
|
break
|
|
222
249
|
}
|
|
223
250
|
|
|
251
|
+
// Set after the action rather than before it, so the caption shows for the
|
|
252
|
+
// hold that follows rather than for the page the action is about to leave.
|
|
253
|
+
await setCaption(page, step.caption)
|
|
224
254
|
if (step.waitFor) await page.locator(step.waitFor).first().waitFor()
|
|
225
255
|
await page.waitForTimeout(step.holdMs)
|
|
226
256
|
}
|
|
227
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Timed on the page a `moveTo` is actually about to move across, never on the
|
|
260
|
+
* blank page before it, since layout, paint, and page script are what a step
|
|
261
|
+
* pays the round trip against and a blank page has none of the three.
|
|
262
|
+
*/
|
|
263
|
+
async function calibrateRoundTrip(page: Page): Promise<number> {
|
|
264
|
+
const startedAt = Date.now()
|
|
265
|
+
await page.mouse.move(START.x, START.y, { steps: CALIBRATION_STEPS })
|
|
266
|
+
return (Date.now() - startedAt) / CALIBRATION_STEPS
|
|
267
|
+
}
|
|
268
|
+
|
|
228
269
|
/**
|
|
229
270
|
* Travel is the whole point of driving the engine's pointer rather than calling
|
|
230
271
|
* the element-clicking helper, which resolves a target and jumps to it. The
|
|
@@ -237,16 +278,78 @@ async function moveTo(
|
|
|
237
278
|
page: Page,
|
|
238
279
|
plan: DemoPlan,
|
|
239
280
|
step: DemoStep,
|
|
281
|
+
pace: PointerPace,
|
|
240
282
|
): Promise<void> {
|
|
283
|
+
pace.roundTripMs ??= await calibrateRoundTrip(page)
|
|
241
284
|
const locator = page.locator(step.target).first()
|
|
242
285
|
await locator.waitFor()
|
|
243
286
|
const box = await locator.boundingBox()
|
|
244
287
|
if (!box) throw new Error(`${step.target} has no box to point at`)
|
|
245
288
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, {
|
|
246
|
-
steps: plan.pointer.
|
|
289
|
+
steps: deriveSteps(plan.pointer.travelMs, pace.roundTripMs),
|
|
247
290
|
})
|
|
248
291
|
}
|
|
249
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Playwright's own `showActions` overlay names the API call it made, not the
|
|
295
|
+
* beat's narration, so a caption needs an element of its own rather than
|
|
296
|
+
* reusing that annotation. Runs alongside `pointerSource`, guarded the same
|
|
297
|
+
* way against a page that already carries one.
|
|
298
|
+
*/
|
|
299
|
+
export function captionInitScript(): string {
|
|
300
|
+
return `(() => {
|
|
301
|
+
if (window.__aitk_demo_caption__) return;
|
|
302
|
+
|
|
303
|
+
let label;
|
|
304
|
+
|
|
305
|
+
const install = () => {
|
|
306
|
+
if (label || !document.body) return;
|
|
307
|
+
const bar = document.createElement('div');
|
|
308
|
+
bar.id = '${CAPTION_ID}';
|
|
309
|
+
bar.setAttribute('aria-hidden', 'true');
|
|
310
|
+
bar.style.cssText = [
|
|
311
|
+
'position:fixed',
|
|
312
|
+
'left:0',
|
|
313
|
+
'right:0',
|
|
314
|
+
'bottom:32px',
|
|
315
|
+
'display:flex',
|
|
316
|
+
'justify-content:center',
|
|
317
|
+
'pointer-events:none',
|
|
318
|
+
'z-index:2147483647',
|
|
319
|
+
].join(';');
|
|
320
|
+
label = document.createElement('span');
|
|
321
|
+
label.style.cssText = [
|
|
322
|
+
'background:rgba(16,16,20,0.85)',
|
|
323
|
+
'color:#f4f4f5',
|
|
324
|
+
'font:600 20px/1.4 system-ui,sans-serif',
|
|
325
|
+
'padding:10px 22px',
|
|
326
|
+
'border-radius:8px',
|
|
327
|
+
'max-width:80vw',
|
|
328
|
+
'text-align:center',
|
|
329
|
+
'display:none',
|
|
330
|
+
].join(';');
|
|
331
|
+
bar.appendChild(label);
|
|
332
|
+
document.body.appendChild(bar);
|
|
333
|
+
window.__aitk_demo_caption__ = (text) => {
|
|
334
|
+
label.textContent = text || '';
|
|
335
|
+
label.style.display = text ? 'inline-block' : 'none';
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
if (document.readyState === 'loading') {
|
|
340
|
+
addEventListener('DOMContentLoaded', install, { once: true });
|
|
341
|
+
} else {
|
|
342
|
+
install();
|
|
343
|
+
}
|
|
344
|
+
})();`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function setCaption(page: Page, caption: string): Promise<void> {
|
|
348
|
+
await page.evaluate((text) => {
|
|
349
|
+
window.__aitk_demo_caption__?.(text)
|
|
350
|
+
}, caption)
|
|
351
|
+
}
|
|
352
|
+
|
|
250
353
|
function failed(reason: DriveRefusal, error: unknown): DriveFailure {
|
|
251
354
|
return {
|
|
252
355
|
status: 'failed',
|
package/src/gov/adapter.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { basename, join, relative, resolve } from 'node:path'
|
|
3
|
+
import { resolveMissingRules } from '@/gov/stacks'
|
|
3
4
|
import type { InstalledFile, RetiredSurface, SyncAdapter } from '@/sync/engine'
|
|
5
|
+
import { readStamp, stampedChain } from '@/sync/stamp'
|
|
4
6
|
|
|
5
7
|
const RETIRED_GOV_FILE = join('.claude', 'GOV.md')
|
|
6
8
|
|
|
@@ -54,11 +56,38 @@ export function createGovAdapter(root: string): SyncAdapter {
|
|
|
54
56
|
locateSource: (file: InstalledFile) =>
|
|
55
57
|
index.get(basename(file.path, '.md')),
|
|
56
58
|
collectRetired: (target: string) => collectRetiredGov(target),
|
|
59
|
+
collectMissing: (target: string) => collectMissingGov(root, target),
|
|
57
60
|
projectSubdir: 'project',
|
|
58
61
|
stamp: { domain: 'governance', toolkitRoot: root },
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Rules the target's recorded chain entitles it to and its tree does not
|
|
67
|
+
* hold. Reports as `notice` text through the same shape `collectRetired`
|
|
68
|
+
* already returns, since both are surfaces the file walk cannot see: one an
|
|
69
|
+
* absence to remove, this one an absence to add.
|
|
70
|
+
*/
|
|
71
|
+
function collectMissingGov(root: string, target: string): RetiredSurface[] {
|
|
72
|
+
const chain = stampedChain(readStamp(target), 'governance')
|
|
73
|
+
|
|
74
|
+
return resolveMissingRules(root, target, chain).map((source) => {
|
|
75
|
+
const dest = join(
|
|
76
|
+
target,
|
|
77
|
+
'.claude',
|
|
78
|
+
'rules',
|
|
79
|
+
source.subdir,
|
|
80
|
+
`${source.rule}.md`,
|
|
81
|
+
)
|
|
82
|
+
const rel = relative(target, dest)
|
|
83
|
+
return {
|
|
84
|
+
path: dest,
|
|
85
|
+
rel,
|
|
86
|
+
notice: `${rel} (listed by ${chain[0]}, not installed. Run aitk gov install ${chain[0]} to add it.)`,
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
62
91
|
function collectRetiredGov(target: string): RetiredSurface[] {
|
|
63
92
|
const path = join(target, RETIRED_GOV_FILE)
|
|
64
93
|
if (!existsSync(path)) return []
|
package/src/gov/install.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { dirname, join, relative } from 'node:path'
|
|
2
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
3
3
|
import { copyPreservingMode } from '@/copy'
|
|
4
4
|
|
|
5
5
|
export interface RuleSource {
|
|
@@ -21,6 +21,27 @@ export function installedRulesDir(target: string): string {
|
|
|
21
21
|
return join(target, '.claude', 'rules')
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Rule names a target already holds, read off the installed tree by basename
|
|
26
|
+
* rather than off a recorded stack, since a target may hold rules `--add`
|
|
27
|
+
* layered on that no stack lists.
|
|
28
|
+
*/
|
|
29
|
+
export function installedRuleNames(target: string): Set<string> {
|
|
30
|
+
const dir = installedRulesDir(target)
|
|
31
|
+
const names = new Set<string>()
|
|
32
|
+
if (!existsSync(dir)) return names
|
|
33
|
+
|
|
34
|
+
for (const rel of new Bun.Glob('**/*.md').scanSync({
|
|
35
|
+
cwd: dir,
|
|
36
|
+
onlyFiles: true,
|
|
37
|
+
dot: true,
|
|
38
|
+
})) {
|
|
39
|
+
names.add(basename(rel, '.md'))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return names
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
/**
|
|
25
46
|
* Mirrors `rule_subdir` in `scripts/lib/gov.sh`, which stays in bash for the
|
|
26
47
|
* sandbox loops. A rule sitting directly under `governance/rules/` has no
|
package/src/gov/stacks.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
installedRuleNames,
|
|
5
|
+
listRuleSourcePaths,
|
|
6
|
+
lookupRules,
|
|
7
|
+
type RuleSource,
|
|
8
|
+
rulesSourceDir,
|
|
9
|
+
} from '@/gov/install'
|
|
4
10
|
|
|
5
11
|
export interface GovStack {
|
|
6
12
|
readonly name: string
|
|
@@ -151,6 +157,32 @@ export function unreferencedRules(root: string): string[] {
|
|
|
151
157
|
.sort()
|
|
152
158
|
}
|
|
153
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Rules the target's recorded chain entitles it to that its installed tree
|
|
162
|
+
* does not hold. `resolveRules` already walks a stack's `extends` ancestors,
|
|
163
|
+
* so reading its leaf entry is enough; no second walk resolves the chain
|
|
164
|
+
* itself. A stack the toolkit no longer ships resolves to nothing rather than
|
|
165
|
+
* throwing, the same way `readNewRules`'s band fallback already treats it.
|
|
166
|
+
*/
|
|
167
|
+
export function resolveMissingRules(
|
|
168
|
+
root: string,
|
|
169
|
+
target: string,
|
|
170
|
+
chain: readonly string[],
|
|
171
|
+
): readonly RuleSource[] {
|
|
172
|
+
const stack = chain[0]
|
|
173
|
+
if (stack === undefined) return []
|
|
174
|
+
|
|
175
|
+
const resolution = resolveRules(root, stack)
|
|
176
|
+
if (!resolution.ok) return []
|
|
177
|
+
|
|
178
|
+
const { found } = lookupRules(root, resolution.rules)
|
|
179
|
+
const held = installedRuleNames(target)
|
|
180
|
+
|
|
181
|
+
return found
|
|
182
|
+
.filter((source) => !held.has(source.rule))
|
|
183
|
+
.sort((left, right) => left.rule.localeCompare(right.rule))
|
|
184
|
+
}
|
|
185
|
+
|
|
154
186
|
/**
|
|
155
187
|
* Layers `--add` names on top of a resolved stack. The bash trimmed a single
|
|
156
188
|
* leading and trailing space per entry; trimming fully is the same result for
|
package/src/intake/folder.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { join, relative } from 'node:path'
|
|
3
|
+
import { basename, join, relative } from 'node:path'
|
|
4
4
|
import {
|
|
5
5
|
INDEX_FILE,
|
|
6
6
|
type IntakeItem,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
export const INTAKE_REFUSALS = [
|
|
14
14
|
'no-intake',
|
|
15
15
|
'no-folder',
|
|
16
|
+
'ambiguous-slug',
|
|
16
17
|
'no-cluster',
|
|
17
18
|
'no-item',
|
|
18
19
|
'answered',
|
|
@@ -110,6 +111,36 @@ async function listSlugs(dir: string): Promise<string[]> {
|
|
|
110
111
|
.sort()
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
type SlugMatch =
|
|
115
|
+
| { readonly kind: 'matched'; readonly name: string }
|
|
116
|
+
| { readonly kind: 'ambiguous'; readonly names: readonly string[] }
|
|
117
|
+
| { readonly kind: 'none' }
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A folder carries a `<nn>-<slug>` name, but a caller names the topic alone.
|
|
121
|
+
* An exact match wins first, since it is what a name with no ordinal, or one
|
|
122
|
+
* already copied in full from a listing, resolves against. Otherwise the one
|
|
123
|
+
* entry whose name is an ordinal ahead of the given slug wins, which is what
|
|
124
|
+
* lets a topic keep working as its folder's identity gains a prefix. Two or
|
|
125
|
+
* more such entries is a collision the caller needs told apart from a typo,
|
|
126
|
+
* not a folder silently picked or silently missing.
|
|
127
|
+
*/
|
|
128
|
+
function matchSlug(names: readonly string[], slug: string): SlugMatch {
|
|
129
|
+
if (names.includes(slug)) return { kind: 'matched', name: slug }
|
|
130
|
+
|
|
131
|
+
const suffixed = names.filter(
|
|
132
|
+
(name) => name === `${extractOrdinal(name)}-${slug}`,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if (suffixed.length === 1) return { kind: 'matched', name: suffixed[0] }
|
|
136
|
+
if (suffixed.length > 1) return { kind: 'ambiguous', names: suffixed }
|
|
137
|
+
return { kind: 'none' }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractOrdinal(name: string): string {
|
|
141
|
+
return /^\d{2,}-/.exec(name)?.[0].slice(0, -1) ?? ''
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
async function openFolder(
|
|
114
145
|
root: string,
|
|
115
146
|
slug: string,
|
|
@@ -120,17 +151,22 @@ async function openFolder(
|
|
|
120
151
|
return refuse('no-intake', `No intake at ${relative(root, dir)}.`)
|
|
121
152
|
}
|
|
122
153
|
|
|
123
|
-
const
|
|
154
|
+
const names = await listSlugs(dir)
|
|
155
|
+
const match = matchSlug(names, slug)
|
|
156
|
+
|
|
157
|
+
if (match.kind === 'none') {
|
|
158
|
+
return refuse('no-folder', `No intake folder named ${slug}.`, names)
|
|
159
|
+
}
|
|
124
160
|
|
|
125
|
-
if (
|
|
161
|
+
if (match.kind === 'ambiguous') {
|
|
126
162
|
return refuse(
|
|
127
|
-
'
|
|
128
|
-
`
|
|
129
|
-
|
|
163
|
+
'ambiguous-slug',
|
|
164
|
+
`More than one intake folder matches ${slug}.`,
|
|
165
|
+
match.names,
|
|
130
166
|
)
|
|
131
167
|
}
|
|
132
168
|
|
|
133
|
-
return
|
|
169
|
+
return join(dir, match.name)
|
|
134
170
|
}
|
|
135
171
|
|
|
136
172
|
/** Counts per folder, which is what a session picks a folder to work from. */
|
|
@@ -180,7 +216,11 @@ export async function readFolder(
|
|
|
180
216
|
const opened = await openFolder(root, slug)
|
|
181
217
|
if (typeof opened !== 'string') return opened
|
|
182
218
|
|
|
183
|
-
return {
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
slug: basename(opened),
|
|
222
|
+
clusters: await readClusters(opened),
|
|
223
|
+
}
|
|
184
224
|
}
|
|
185
225
|
|
|
186
226
|
/**
|
|
@@ -272,7 +312,7 @@ export async function answerItems(
|
|
|
272
312
|
|
|
273
313
|
return {
|
|
274
314
|
ok: true,
|
|
275
|
-
slug,
|
|
315
|
+
slug: basename(opened),
|
|
276
316
|
cluster: name,
|
|
277
317
|
path,
|
|
278
318
|
answered: selections,
|