@erclx/aitk 3.6.0 → 3.8.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/create-rule/REQUIREMENT.md +2 -1
- package/claude/skills/create-rule/SKILL.md +8 -8
- package/claude/skills/create-snippet/REQUIREMENT.md +3 -0
- package/claude/skills/create-snippet/SKILL.md +2 -2
- 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/restate/REQUIREMENT.md +41 -0
- package/claude/skills/restate/SKILL.md +39 -0
- package/claude/skills/toolkit-feedback/SKILL.md +2 -2
- package/claude/skills/write-human/REQUIREMENT.md +1 -1
- package/claude/skills/write-human/SKILL.md +1 -1
- package/docs/agents/capture.md +3 -1
- package/docs/agents/commands.md +25 -21
- package/docs/agents/demo.md +82 -0
- package/docs/agents/index.md +2 -0
- package/docs/agents/install-and-sync.md +6 -2
- package/docs/agents/records.md +2 -2
- package/docs/agents/routing.md +61 -0
- package/docs/agents/tasks.md +1 -1
- package/docs/ai-workflow.md +8 -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/claude/routing.ts +283 -0
- package/src/cli.ts +4 -1
- package/src/commands/claude.ts +130 -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/gov/adapter.ts +1 -0
- package/src/records/backup.ts +34 -8
- package/src/snippets/adapter.ts +1 -0
- package/src/sync/engine.ts +25 -1
- 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 +1 -1
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path'
|
|
3
|
+
import type { Command } from 'commander'
|
|
4
|
+
import { parseDraft } from '@/demo/beats'
|
|
5
|
+
import { compilePlan, parsePlan, unresolved } from '@/demo/compile'
|
|
6
|
+
import { DEFAULT_CURSORS } from '@/demo/cursors'
|
|
7
|
+
import { loadCursorTheme } from '@/demo/theme'
|
|
8
|
+
import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_OUT = 'demos'
|
|
11
|
+
const INSTALL_BROWSER = 'bunx playwright install chromium'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Holds wiring only. Every browser reference sits behind `loadDriver`, because
|
|
15
|
+
* `src/cli.ts` imports this module at startup and resolving the engine there
|
|
16
|
+
* would put a browser launch in front of every other command.
|
|
17
|
+
*/
|
|
18
|
+
type Driver = typeof import('@/demo/drive')
|
|
19
|
+
|
|
20
|
+
interface CompileOptions {
|
|
21
|
+
readonly out: string
|
|
22
|
+
readonly slug?: string
|
|
23
|
+
readonly force?: boolean
|
|
24
|
+
readonly json?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RunOptions {
|
|
28
|
+
readonly out?: string
|
|
29
|
+
readonly cursor?: string
|
|
30
|
+
readonly video: boolean
|
|
31
|
+
readonly still: boolean
|
|
32
|
+
readonly json?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function register(program: Command): void {
|
|
36
|
+
const demo = program
|
|
37
|
+
.command('demo')
|
|
38
|
+
.description('Drive a running application and record what it did')
|
|
39
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
40
|
+
|
|
41
|
+
demo
|
|
42
|
+
.command('compile')
|
|
43
|
+
.description('Turn a screencast draft into a plan a run can drive')
|
|
44
|
+
.argument('<draft>', 'Screencast draft written by claude-screencast')
|
|
45
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
46
|
+
.option('-o, --out <dir>', 'Directory the plan is written to', DEFAULT_OUT)
|
|
47
|
+
.option('-s, --slug <slug>', 'Plan name, defaulting to the draft filename')
|
|
48
|
+
.option('--force', 'Overwrite a plan that already exists')
|
|
49
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
50
|
+
.addHelpText(
|
|
51
|
+
'after',
|
|
52
|
+
[
|
|
53
|
+
'',
|
|
54
|
+
'The plan is committed rather than scratch. It carries the target, the',
|
|
55
|
+
'wait condition, and the timing a beat lacks, and the timing is a',
|
|
56
|
+
'starting point you tune, which is why a recompile refuses to overwrite.',
|
|
57
|
+
'',
|
|
58
|
+
'Exit codes:',
|
|
59
|
+
' 0 a plan was written',
|
|
60
|
+
' 1 refused, with the reason on stderr',
|
|
61
|
+
'',
|
|
62
|
+
'Examples:',
|
|
63
|
+
' aitk demo compile .claude/.tmp/screencast/inline-edit.md',
|
|
64
|
+
' aitk demo compile draft.md --out demos --force',
|
|
65
|
+
'',
|
|
66
|
+
].join('\n'),
|
|
67
|
+
)
|
|
68
|
+
.action(async (draft: string, opts: CompileOptions) => {
|
|
69
|
+
process.exitCode = runCompile(draft, opts)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
demo
|
|
73
|
+
.command('run')
|
|
74
|
+
.description('Drive the application a plan names and write the recording')
|
|
75
|
+
.argument('<plan>', 'Plan written by aitk demo compile')
|
|
76
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
77
|
+
.option('-o, --out <dir>', 'Directory to write into, overriding the plan')
|
|
78
|
+
.option(
|
|
79
|
+
'-c, --cursor <dir>',
|
|
80
|
+
'Cursor theme folder to draw the pointer from',
|
|
81
|
+
)
|
|
82
|
+
.option('--no-video', 'Skip the recording and write only the still')
|
|
83
|
+
.option('--no-still', 'Skip the still and write only the recording')
|
|
84
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
85
|
+
.addHelpText(
|
|
86
|
+
'after',
|
|
87
|
+
[
|
|
88
|
+
'',
|
|
89
|
+
'Needs a browser binary. Install it with:',
|
|
90
|
+
` ${INSTALL_BROWSER}`,
|
|
91
|
+
'',
|
|
92
|
+
'Exit codes:',
|
|
93
|
+
' 0 the recording and the still were written',
|
|
94
|
+
' 1 refused, with the reason on stderr',
|
|
95
|
+
'',
|
|
96
|
+
'Examples:',
|
|
97
|
+
' aitk demo run demos/inline-edit.json',
|
|
98
|
+
' aitk demo run demos/inline-edit.json --cursor ~/cursors/theme',
|
|
99
|
+
'',
|
|
100
|
+
].join('\n'),
|
|
101
|
+
)
|
|
102
|
+
.action(async (plan: string, opts: RunOptions) => {
|
|
103
|
+
process.exitCode = await runDrive(plan, opts)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function runCompile(draftPath: string, opts: CompileOptions): number {
|
|
108
|
+
intro('aitk demo compile')
|
|
109
|
+
|
|
110
|
+
const source = resolve(process.cwd(), draftPath)
|
|
111
|
+
if (!existsSync(source)) {
|
|
112
|
+
logStep('Draft')
|
|
113
|
+
logError(`${draftPath} not found`)
|
|
114
|
+
outro()
|
|
115
|
+
emit(opts.json, { draft: source, reason: 'draft-missing' })
|
|
116
|
+
return 1
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const parsed = parseDraft(readFileSync(source, 'utf8'))
|
|
120
|
+
if (parsed.status === 'failed') {
|
|
121
|
+
logStep('Draft')
|
|
122
|
+
logError(`${display(source)}: ${parsed.reason}`)
|
|
123
|
+
outro()
|
|
124
|
+
emit(opts.json, {
|
|
125
|
+
draft: source,
|
|
126
|
+
reason: 'draft-unreadable',
|
|
127
|
+
message: parsed.reason,
|
|
128
|
+
})
|
|
129
|
+
return 1
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const slug = opts.slug ?? basename(source, extname(source))
|
|
133
|
+
const target = resolve(process.cwd(), opts.out, `${slug}.json`)
|
|
134
|
+
|
|
135
|
+
if (existsSync(target) && !opts.force) {
|
|
136
|
+
logStep('Plan')
|
|
137
|
+
logError(`${display(target)} already exists`)
|
|
138
|
+
// Stated rather than implied, because the value at risk is timing the
|
|
139
|
+
// operator tuned by watching a recording and the draft cannot reproduce it.
|
|
140
|
+
logWarn('Pass --force to overwrite it, losing any timing tuned by hand.')
|
|
141
|
+
outro()
|
|
142
|
+
emit(opts.json, { plan: target, reason: 'plan-exists' })
|
|
143
|
+
return 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const plan = compilePlan(parsed.draft, { slug, outDir: opts.out })
|
|
147
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
148
|
+
writeFileSync(target, `${JSON.stringify(plan, null, 2)}\n`)
|
|
149
|
+
|
|
150
|
+
logStep('Draft')
|
|
151
|
+
logInfo(`${display(source)} ${plural(parsed.draft.beats.length, 'beat')}`)
|
|
152
|
+
|
|
153
|
+
logStep('Plan')
|
|
154
|
+
logInfo(display(target))
|
|
155
|
+
|
|
156
|
+
const outstanding = unresolved(plan)
|
|
157
|
+
logStep('Outstanding')
|
|
158
|
+
if (outstanding.length === 0) {
|
|
159
|
+
logInfo('Nothing to fill, so the plan runs as written.')
|
|
160
|
+
} else {
|
|
161
|
+
logWarn(
|
|
162
|
+
`${plural(outstanding.length, 'field')} the draft could not supply:`,
|
|
163
|
+
)
|
|
164
|
+
for (const field of outstanding) logWarn(` ${field}`)
|
|
165
|
+
}
|
|
166
|
+
outro()
|
|
167
|
+
|
|
168
|
+
emit(opts.json, {
|
|
169
|
+
draft: source,
|
|
170
|
+
plan: target,
|
|
171
|
+
beats: parsed.draft.beats.length,
|
|
172
|
+
unresolved: outstanding,
|
|
173
|
+
})
|
|
174
|
+
return 0
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function runDrive(planPath: string, opts: RunOptions): Promise<number> {
|
|
178
|
+
intro('aitk demo run')
|
|
179
|
+
|
|
180
|
+
const source = resolve(process.cwd(), planPath)
|
|
181
|
+
if (!existsSync(source)) {
|
|
182
|
+
logStep('Plan')
|
|
183
|
+
logError(`${planPath} not found`)
|
|
184
|
+
outro()
|
|
185
|
+
emit(opts.json, { plan: source, reason: 'plan-missing' })
|
|
186
|
+
return 1
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const parsed = parsePlan(readFileSync(source, 'utf8'))
|
|
190
|
+
if (parsed.status === 'failed') {
|
|
191
|
+
logStep('Plan')
|
|
192
|
+
logError(`${display(source)}: ${parsed.reason}`)
|
|
193
|
+
outro()
|
|
194
|
+
emit(opts.json, {
|
|
195
|
+
plan: source,
|
|
196
|
+
reason: 'plan-unreadable',
|
|
197
|
+
message: parsed.reason,
|
|
198
|
+
})
|
|
199
|
+
return 1
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!opts.video && !opts.still) {
|
|
203
|
+
logStep('Output')
|
|
204
|
+
logError('--no-video and --no-still together leave nothing to write')
|
|
205
|
+
outro()
|
|
206
|
+
emit(opts.json, { plan: source, reason: 'no-output-requested' })
|
|
207
|
+
return 1
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const outstanding = unresolved(parsed.plan)
|
|
211
|
+
if (outstanding.length) {
|
|
212
|
+
logStep('Plan')
|
|
213
|
+
logError(
|
|
214
|
+
`${display(source)} has ${plural(outstanding.length, 'field')} to fill`,
|
|
215
|
+
)
|
|
216
|
+
for (const field of outstanding) logWarn(` ${field}`)
|
|
217
|
+
outro()
|
|
218
|
+
emit(opts.json, {
|
|
219
|
+
plan: source,
|
|
220
|
+
reason: 'plan-unresolved',
|
|
221
|
+
unresolved: outstanding,
|
|
222
|
+
})
|
|
223
|
+
return 1
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
logStep('Plan')
|
|
227
|
+
logInfo(`${display(source)} ${plural(parsed.plan.steps.length, 'step')}`)
|
|
228
|
+
|
|
229
|
+
const cursors = resolveCursors(opts.cursor)
|
|
230
|
+
if (cursors.status === 'failed') {
|
|
231
|
+
logError(cursors.reason)
|
|
232
|
+
outro()
|
|
233
|
+
emit(opts.json, { plan: source, reason: 'cursor-unreadable' })
|
|
234
|
+
return 1
|
|
235
|
+
}
|
|
236
|
+
logInfo(cursors.label)
|
|
237
|
+
|
|
238
|
+
const driver = await loadDriver()
|
|
239
|
+
if (!driver) {
|
|
240
|
+
logStep('Browser')
|
|
241
|
+
logError('the browser engine is not installed in this project')
|
|
242
|
+
logWarn(`Install it with: ${INSTALL_BROWSER}`)
|
|
243
|
+
outro()
|
|
244
|
+
emit(opts.json, {
|
|
245
|
+
plan: source,
|
|
246
|
+
reason: 'engine-missing',
|
|
247
|
+
install: INSTALL_BROWSER,
|
|
248
|
+
})
|
|
249
|
+
return 1
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
logStep('Recording')
|
|
253
|
+
const result = await driver.drive({
|
|
254
|
+
plan: parsed.plan,
|
|
255
|
+
cursors: cursors.value,
|
|
256
|
+
...(opts.video
|
|
257
|
+
? { videoPath: outputPath(parsed.plan.output.video, opts.out) }
|
|
258
|
+
: {}),
|
|
259
|
+
...(opts.still
|
|
260
|
+
? { stillPath: outputPath(parsed.plan.output.still, opts.out) }
|
|
261
|
+
: {}),
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
if (result.status === 'failed') {
|
|
265
|
+
logError(result.message.split('\n')[0] ?? 'the run failed')
|
|
266
|
+
if (result.reason === 'browser-missing') {
|
|
267
|
+
logWarn(`Install the browser binary with: ${INSTALL_BROWSER}`)
|
|
268
|
+
}
|
|
269
|
+
outro()
|
|
270
|
+
emit(opts.json, {
|
|
271
|
+
plan: source,
|
|
272
|
+
reason: result.reason,
|
|
273
|
+
message: result.message,
|
|
274
|
+
...(result.reason === 'browser-missing'
|
|
275
|
+
? { install: INSTALL_BROWSER }
|
|
276
|
+
: {}),
|
|
277
|
+
})
|
|
278
|
+
return 1
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (result.videoPath) logInfo(display(result.videoPath))
|
|
282
|
+
if (result.stillPath) logInfo(`${display(result.stillPath)} still`)
|
|
283
|
+
logInfo(
|
|
284
|
+
`${result.steps} steps in ${Math.round(result.durationMs / 100) / 10}s`,
|
|
285
|
+
)
|
|
286
|
+
outro()
|
|
287
|
+
|
|
288
|
+
emit(opts.json, {
|
|
289
|
+
plan: source,
|
|
290
|
+
video: result.videoPath ?? null,
|
|
291
|
+
still: result.stillPath ?? null,
|
|
292
|
+
steps: result.steps,
|
|
293
|
+
durationMs: result.durationMs,
|
|
294
|
+
})
|
|
295
|
+
return 0
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
type CursorChoice =
|
|
299
|
+
| { status: 'ready'; value: typeof DEFAULT_CURSORS; label: string }
|
|
300
|
+
| { status: 'failed'; reason: string }
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A theme contributes per state rather than per folder, so a folder carrying an
|
|
304
|
+
* arrow and no hand still supplies its arrow and the bundled artwork covers the
|
|
305
|
+
* rest.
|
|
306
|
+
*/
|
|
307
|
+
function resolveCursors(dir: string | undefined): CursorChoice {
|
|
308
|
+
if (!dir) {
|
|
309
|
+
return {
|
|
310
|
+
status: 'ready',
|
|
311
|
+
value: DEFAULT_CURSORS,
|
|
312
|
+
label: 'pointer drawn from the bundled artwork',
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const loaded = loadCursorTheme(resolve(process.cwd(), dir), DEFAULT_CURSORS)
|
|
317
|
+
if (loaded.status === 'failed')
|
|
318
|
+
return { status: 'failed', reason: loaded.reason }
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
status: 'ready',
|
|
322
|
+
value: loaded.cursors,
|
|
323
|
+
label: `pointer drawn from ${display(resolve(process.cwd(), dir))} for ${loaded.states.join(', ')}`,
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Reports absence only when the module or its engine cannot be resolved, which
|
|
329
|
+
* is the case a target hits before installing the browser package. Any other
|
|
330
|
+
* import failure is a defect inside the driver and propagates, rather than
|
|
331
|
+
* being reported as a missing dependency.
|
|
332
|
+
*/
|
|
333
|
+
async function loadDriver(): Promise<Driver | undefined> {
|
|
334
|
+
try {
|
|
335
|
+
return await import('@/demo/drive')
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (isModuleNotFound(error)) return undefined
|
|
338
|
+
throw error
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function isModuleNotFound(error: unknown): boolean {
|
|
343
|
+
return (
|
|
344
|
+
typeof error === 'object' &&
|
|
345
|
+
error !== null &&
|
|
346
|
+
'code' in error &&
|
|
347
|
+
error.code === 'ERR_MODULE_NOT_FOUND'
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Resolves where one artifact lands. `--out` replaces the directory the plan
|
|
353
|
+
* names rather than acting as a root the plan's own directory hangs off, which
|
|
354
|
+
* would nest the output path inside itself on every run that passes both.
|
|
355
|
+
*/
|
|
356
|
+
function outputPath(planned: string, out: string | undefined): string {
|
|
357
|
+
const relativePath = out ? join(out, basename(planned)) : planned
|
|
358
|
+
return resolve(process.cwd(), relativePath)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function emit(json: boolean | undefined, record: unknown): void {
|
|
362
|
+
if (json) process.stdout.write(`${JSON.stringify(record)}\n`)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Keeps a path clickable in the operator's terminal. A path outside the project
|
|
367
|
+
* reports absolute, since a relative path to it is a run of `..` segments no
|
|
368
|
+
* editor resolves.
|
|
369
|
+
*/
|
|
370
|
+
function display(path: string): string {
|
|
371
|
+
const fromCwd = relative(process.cwd(), path)
|
|
372
|
+
return fromCwd.startsWith('..') ? path : fromCwd
|
|
373
|
+
}
|
package/src/commands/feedback.ts
CHANGED
|
@@ -32,13 +32,20 @@ function isToolkitSource(): boolean {
|
|
|
32
32
|
return existsSync(join(PROJECT_ROOT, '.claude'))
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* One producer, one subfolder. The review folder carries the output of four
|
|
37
|
+
* unrelated producers, and the filename prefix was doing the folder's job by
|
|
38
|
+
* hand, so each writes under its own name and the enclosing folder keeps the
|
|
39
|
+
* single ignore entry and the single backed-folder entry it already had.
|
|
40
|
+
*/
|
|
35
41
|
function writeLocal(body: string): string {
|
|
36
|
-
const
|
|
42
|
+
const relativeDir = join('.claude', 'review', 'feedback')
|
|
43
|
+
const reviewDir = join(PROJECT_ROOT, relativeDir)
|
|
37
44
|
mkdirSync(reviewDir, { recursive: true })
|
|
38
45
|
const filename = `feedback-${deriveSlug(body)}-${timestamp()}.md`
|
|
39
46
|
const filePath = join(reviewDir, filename)
|
|
40
47
|
writeFileSync(filePath, `${body}\n`, 'utf8')
|
|
41
|
-
frameSuccess('aitk feedback',
|
|
48
|
+
frameSuccess('aitk feedback', join(relativeDir, filename))
|
|
42
49
|
return filePath
|
|
43
50
|
}
|
|
44
51
|
|
|
@@ -46,7 +53,7 @@ export function register(program: Command): void {
|
|
|
46
53
|
program
|
|
47
54
|
.command('feedback')
|
|
48
55
|
.description(
|
|
49
|
-
'Write toolkit feedback from stdin to .claude/review/, or open a GitHub issue with --github',
|
|
56
|
+
'Write toolkit feedback from stdin to .claude/review/feedback/, or open a GitHub issue with --github',
|
|
50
57
|
)
|
|
51
58
|
.option(
|
|
52
59
|
'--github',
|
package/src/commands/tasks.ts
CHANGED
|
@@ -151,7 +151,7 @@ export function register(program: Command): void {
|
|
|
151
151
|
'Locations:',
|
|
152
152
|
' unstated the task carries no Plan: line',
|
|
153
153
|
' live the target resolves inside .claude/plans/',
|
|
154
|
-
' archived the target resolves inside .claude/plans
|
|
154
|
+
' archived the target resolves inside .claude/plans/archive/',
|
|
155
155
|
' outside the target resolves somewhere else',
|
|
156
156
|
'',
|
|
157
157
|
'Exit codes:',
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the human-facing draft `claude-screencast` writes. Nothing here knows
|
|
3
|
+
* about a browser: the draft is prose aimed at a person, and turning it into
|
|
4
|
+
* something executable is `@/demo/compile`'s job.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface Beat {
|
|
8
|
+
readonly index: number
|
|
9
|
+
readonly name: string
|
|
10
|
+
readonly onScreen: string
|
|
11
|
+
readonly action: string
|
|
12
|
+
readonly watchFor: string
|
|
13
|
+
readonly emphasis: string
|
|
14
|
+
readonly caption: string
|
|
15
|
+
readonly transitionOut?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Draft {
|
|
19
|
+
readonly title: string
|
|
20
|
+
readonly beats: readonly Beat[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DraftParse =
|
|
24
|
+
| { status: 'parsed'; draft: Draft }
|
|
25
|
+
| { status: 'failed'; reason: string }
|
|
26
|
+
|
|
27
|
+
const TITLE = /^#\s+Screencast:\s*(.+)$/m
|
|
28
|
+
const BEAT_SHEET = /^##\s+.*Beat sheet.*$/im
|
|
29
|
+
const BEAT_HEADING = /^###\s+Beat\s+(\d+)\s*:\s*(.*)$/i
|
|
30
|
+
const FIELD = /^-\s+([A-Za-z][A-Za-z\s]*?)\s*:\s*(.*)$/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Labels are matched case-insensitively with whitespace collapsed, because the
|
|
34
|
+
* draft is hand-edited between being written and being compiled and a
|
|
35
|
+
* capitalization change there is not a reason to refuse the whole file.
|
|
36
|
+
*/
|
|
37
|
+
const FIELDS: Record<string, keyof Beat> = {
|
|
38
|
+
'on screen': 'onScreen',
|
|
39
|
+
action: 'action',
|
|
40
|
+
'watch for': 'watchFor',
|
|
41
|
+
emphasis: 'emphasis',
|
|
42
|
+
caption: 'caption',
|
|
43
|
+
'transition out': 'transitionOut',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseDraft(markdown: string): DraftParse {
|
|
47
|
+
const sheet = beatSheetSection(markdown)
|
|
48
|
+
if (sheet === undefined) {
|
|
49
|
+
return {
|
|
50
|
+
status: 'failed',
|
|
51
|
+
reason: 'no "Beat sheet" section, so the draft carries no beats to run',
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const beats = readBeats(sheet)
|
|
56
|
+
if (!beats.length) {
|
|
57
|
+
return {
|
|
58
|
+
status: 'failed',
|
|
59
|
+
reason: 'the beat sheet holds no "### Beat" heading',
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
status: 'parsed',
|
|
65
|
+
draft: { title: TITLE.exec(markdown)?.[1]?.trim() ?? '', beats },
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns the lines between the beat sheet heading and the next `##`, so a
|
|
71
|
+
* `### Beat` heading elsewhere in the draft cannot be read as a beat.
|
|
72
|
+
*/
|
|
73
|
+
function beatSheetSection(markdown: string): string[] | undefined {
|
|
74
|
+
const lines = markdown.split('\n')
|
|
75
|
+
const start = lines.findIndex((line) => BEAT_SHEET.test(line))
|
|
76
|
+
if (start === -1) return undefined
|
|
77
|
+
|
|
78
|
+
const rest = lines.slice(start + 1)
|
|
79
|
+
const end = rest.findIndex((line) => /^##\s/.test(line))
|
|
80
|
+
return end === -1 ? rest : rest.slice(0, end)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readBeats(lines: string[]): Beat[] {
|
|
84
|
+
const beats: Beat[] = []
|
|
85
|
+
let open: Partial<Beat> | undefined
|
|
86
|
+
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
const heading = BEAT_HEADING.exec(line)
|
|
89
|
+
if (heading) {
|
|
90
|
+
if (open) beats.push(sealBeat(open))
|
|
91
|
+
open = {
|
|
92
|
+
index: Number(heading[1]),
|
|
93
|
+
name: (heading[2] ?? '').trim(),
|
|
94
|
+
}
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!open) continue
|
|
99
|
+
const field = FIELD.exec(line)
|
|
100
|
+
if (!field) continue
|
|
101
|
+
|
|
102
|
+
const key = FIELDS[normalizeLabel(field[1] ?? '')]
|
|
103
|
+
if (key) open = { ...open, [key]: (field[2] ?? '').trim() }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (open) beats.push(sealBeat(open))
|
|
107
|
+
return beats
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fills the five required fields with empty strings rather than leaving them
|
|
112
|
+
* undefined, so a downstream reader distinguishes "the operator left it blank"
|
|
113
|
+
* from "the label was misspelt" by the presence of the key alone.
|
|
114
|
+
*
|
|
115
|
+
* `transitionOut` stays optional, because the draft adds it only when it is
|
|
116
|
+
* not the default and an empty one would claim a decision nobody made.
|
|
117
|
+
*/
|
|
118
|
+
function sealBeat(open: Partial<Beat>): Beat {
|
|
119
|
+
const beat: Beat = {
|
|
120
|
+
index: open.index ?? 0,
|
|
121
|
+
name: open.name ?? '',
|
|
122
|
+
onScreen: open.onScreen ?? '',
|
|
123
|
+
action: open.action ?? '',
|
|
124
|
+
watchFor: open.watchFor ?? '',
|
|
125
|
+
emphasis: open.emphasis ?? '',
|
|
126
|
+
caption: open.caption ?? '',
|
|
127
|
+
}
|
|
128
|
+
return open.transitionOut === undefined
|
|
129
|
+
? beat
|
|
130
|
+
: { ...beat, transitionOut: open.transitionOut }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeLabel(label: string): string {
|
|
134
|
+
return label.trim().toLowerCase().replace(/\s+/g, ' ')
|
|
135
|
+
}
|