@erclx/aitk 3.6.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.
Files changed (44) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-autoship/SKILL.md +5 -5
  3. package/claude/skills/claude-docs/SKILL.md +15 -10
  4. package/claude/skills/claude-memory-review/SKILL.md +7 -7
  5. package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
  6. package/claude/skills/claude-orchestrate/SKILL.md +2 -2
  7. package/claude/skills/claude-pr-review/SKILL.md +25 -7
  8. package/claude/skills/claude-review/SKILL.md +5 -3
  9. package/claude/skills/claude-screencast/SKILL.md +9 -4
  10. package/claude/skills/claude-tasks/SKILL.md +2 -2
  11. package/claude/skills/git-pr/references/pr.md +3 -0
  12. package/claude/skills/git-ship/SKILL.md +1 -1
  13. package/claude/skills/git-split/references/pr.md +3 -0
  14. package/claude/skills/toolkit-feedback/SKILL.md +2 -2
  15. package/docs/agents/capture.md +3 -1
  16. package/docs/agents/commands.md +4 -1
  17. package/docs/agents/demo.md +82 -0
  18. package/docs/agents/index.md +1 -0
  19. package/docs/agents/records.md +2 -2
  20. package/docs/agents/tasks.md +1 -1
  21. package/docs/ai-workflow.md +7 -5
  22. package/docs/operating-model.md +13 -4
  23. package/governance/rules/claude/558-plan.md +1 -2
  24. package/governance/rules/lib/300-testing-ts.md +1 -0
  25. package/package.json +3 -2
  26. package/src/cli.ts +4 -1
  27. package/src/commands/demo.ts +373 -0
  28. package/src/commands/feedback.ts +10 -3
  29. package/src/commands/tasks.ts +1 -1
  30. package/src/demo/beats.ts +135 -0
  31. package/src/demo/compile.ts +295 -0
  32. package/src/demo/cursors.ts +55 -0
  33. package/src/demo/drive.ts +256 -0
  34. package/src/demo/pointer.ts +178 -0
  35. package/src/demo/theme.ts +112 -0
  36. package/src/records/backup.ts +34 -8
  37. package/src/tasks/archive.ts +11 -4
  38. package/standards/bundled/pr.md +3 -0
  39. package/standards/plan.md +1 -1
  40. package/standards/tasks.md +4 -4
  41. package/tooling/claude/manifest.toml +1 -1
  42. package/tooling/claude/reference.md +6 -5
  43. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +4 -1
  44. package/tooling/claude/seeds/CLAUDE.md +1 -1
@@ -71,7 +71,7 @@ When features are independent, run them in parallel instead of sequentially. Use
71
71
 
72
72
  - Create a worktree per feature, then start a Claude Code session in each
73
73
  - Invoke `aitk:claude-feature` in each session. Plans land at the main worktree root as `.claude/plans/feature-<slug>.md`, one per feature, no collisions. Small features stay in chat and skip the file.
74
- - Implement, verify, and review each feature independently. `claude-review` and `claude-ui-test` write per-branch files at the main worktree root (`review-<slug>.md`, `ui-checklist-<slug>.md`), so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
74
+ - Implement, verify, and review each feature independently. `claude-review` and `claude-ui-test` write per-branch files at the main worktree root (`review/branch/review-<slug>.md`, `review/ui-checklist-<slug>.md`), so parallel sessions do not overwrite each other. The slug is the branch name with any leading type segment dropped, so `feat/jwt-expiration` and the plan at `feature-jwt-expiration.md` meet on one name
75
75
  - Ship each worktree separately with `aitk:git-ship`
76
76
  - For full autonomy per worktree, invoke `aitk:claude-autoship` instead of the manual chain. Approve the plan, walk away, come back to draft PRs.
77
77
 
@@ -93,15 +93,17 @@ An execution that picks other than the suggestion rewrites the `- Suggested:` li
93
93
 
94
94
  `aitk records validate plans` reports where a plan and that standard disagree: a filename that is not `feature-<slug>.md`, a missing required section, a files-to-touch entry naming no file, and a question carrying a suggestion with no answer slot. The same verb takes `groundwork`, `intake`, `memory`, and `teach`, which are governed the same way and were unreachable for the same reason. Nothing fires it automatically, because all five folders are gitignored and every check the repository runs reads changed files from git. It reports and never writes, since the folders are per-machine scratch with no history to recover a wrong repair from.
95
95
 
96
- `aitk records push` carries these folders off the disk they live on, and `aitk records pull` brings them back. Ten of them are backed: `groundwork`, `intake`, `memory`, `plans`, `plans-archive`, `review`, `review-archive`, `task-archive`, `tasks`, and `teach`. The history lives in a second git directory at `.claude/.records.git` with `.claude/` as its work tree, so every path a task file cites stays where it is.
96
+ `aitk records push` carries these folders off the disk they live on, and `aitk records pull` brings them back. Seven of them are backed: `groundwork`, `intake`, `memory`, `plans`, `review`, `tasks`, and `teach`, each carrying whatever it has archived inside it. The history lives in a second git directory at `.claude/.records.git` with `.claude/` as its work tree, so every path a task file cites stays where it is.
97
97
 
98
98
  A person points it at a private repository once and both verbs refuse until they have, and `push` refuses when that origin is also a remote of the project, since the payload is the memory pen and the groundwork trails. `.husky/post-merge` runs the push after its archive loop, on every merge rather than only on one that closed a task. See [records](agents/records.md) for the refusal table.
99
99
 
100
100
  `aitk records size` reports what each of these folders holds, heaviest first, along with `.claude/.tmp`. Each row carries the file count, the bytes, how many files were written in the last 7 and 30 days, and the dates of the least and most recently written one. Nothing fails on a number, because a record folder has no correct size. What the verb replaces is a reading somebody had to remember to take: the memory pen went from 44 entries to 236 between two counts made by hand a fortnight apart, and nothing reported the rate in between.
101
101
 
102
- A plan that ships is archived, never deleted. `aitk:claude-docs` moves it to `.claude/plans-archive/` and retargets the task file's `Plan:` line at the new location, so a completed task still leads to the reasoning behind it. Both folders are gitignored, which is why a deleted plan had no recovery path. A plan cited by more than one task stays put until the last of them closes, since moving it early would strand every other pointer.
102
+ A plan that ships is archived, never deleted. `aitk:claude-docs` moves it to `.claude/plans/archive/` and retargets the task file's `Plan:` line at the new location, so a completed task still leads to the reasoning behind it. An archive sits inside the record folder it archives rather than beside it, so one ignore entry and one backed-folder entry cover a record and everything it has retired. The folder is gitignored, which is why a deleted plan had no recovery path. A plan cited by more than one task stays put until the last of them closes, since moving it early would strand every other pointer.
103
103
 
104
- The sweep reads the whole board rather than the tasks the session touched. It is the one place the skill reaches past its own rule against editing a task file the session did not change, because a task that closed while an earlier run missed its archive is exactly what the sweep exists to clear. Reaching it is safe: the archive moves the plan and retargets the pointer in the same pass, so an untouched task ends up with a working link rather than a broken one.
104
+ A branch review report takes the other route and is swept rather than archived. `claude-review` writes it to `.claude/review/branch/`, the session addressing it reads it once, and the durable record of what a review found is the comment `claude-pr-review` posts on the pull request, so `claude-docs` deletes the report at ship and any report whose branch is gone. What that loses is a local-only review on a branch that never opened a pull request, which is why the report says so where a reader meets it.
105
+
106
+ The plans sweep reads the whole board rather than the tasks the session touched. It is the one place the skill reaches past its own rule against editing a task file the session did not change, because a task that closed while an earlier run missed its archive is exactly what the sweep exists to clear. Reaching it is safe: the archive moves the plan and retargets the pointer in the same pass, so an untouched task ends up with a working link rather than a broken one.
105
107
 
106
108
  `aitk:claude-docs` decides which task closed by reading the diff rather than the conversation. It resolves a merge base against `origin/main`, unions the committed diff with the working tree and untracked files, then matches unchecked outcomes on the board against what shipped. A task that shipped without ever being discussed still gets marked. Requirements, architecture, and design stay session-sourced, because a diff cannot carry a judgment.
107
109
 
@@ -111,7 +113,7 @@ The sweep reads the whole board rather than the tasks the session touched. It is
111
113
 
112
114
  `aitk tasks validate` checks what those rows claim against what the tree holds: every plan pointer resolves, every task file is named by a board row or a backlog line and never by both, no task sits in two groups, and no two rows marked ready touch the same file. One check across both surfaces is what lets a task move between the board and the backlog without the move reading as a dropped file. That fourth check is the half a reader cannot run by eye, and it is what keeps two workers from being handed colliding work. A fifth re-takes what a parked row waits on, reporting one whose cited task reached the trunk and one whose cited file nothing running still holds. A cited task settles the row by being archived, or by closing every outcome and naming a pull request the trunk carries, since the checkbox alone is marked while the branch is still in review. Both halves read a citation out of the blocker cell, so a row citing neither is reported as untested rather than counted clean, and so is a cited task the trunk could not answer for. It reports and never writes, because a row is the orchestrator's claim and a validator repairing one would assert the claim it exists to test. Nothing fires it automatically, since the board is gitignored per-machine scratch with no shared moment to hang a hook on, so the orchestrator's sweep calls it at the point the readiness claim is made and follows it with the parked re-test.
113
115
 
114
- `aitk:claude-tasks` owns the two operations that bracket a task's life. It creates the file, holding the filename convention and the frontmatter contract so a malformed write cannot break the index for every sibling, and it moves a shipped task to `.claude/task-archive/`. Creation is where the origin invariant is enforced: every task names a plan, a groundwork folder, an intake folder, or an issue, since a task with no origin is either lost context or work nobody decided to do.
116
+ `aitk:claude-tasks` owns the two operations that bracket a task's life. It creates the file, holding the filename convention and the frontmatter contract so a malformed write cannot break the index for every sibling, and it moves a shipped task to `.claude/tasks/archive/`. Creation is where the origin invariant is enforced: every task names a plan, a groundwork folder, an intake folder, or an issue, since a task with no origin is either lost context or work nobody decided to do.
115
117
 
116
118
  Archiving a task leaves its plan alone, because `aitk:claude-docs` owns the plans sweep and already holds the last-live-citation rule. That makes the order load-bearing, so the archive verb refuses the last task pointing at a live plan. The sweep only reaches tasks still in the live folder, and archiving that task first would strand the plan there with nothing citing it. A task whose plan a sibling still cites archives freely, because the sweep is correct to leave that plan where it is and a gate reading the folder instead would park every task sharing one plan behind a sweep that will never move it.
117
119
 
@@ -101,13 +101,22 @@ closed clean and then went stale still rebases when the skill is invoked. The
101
101
  re-read costs a full pass rather than a delta, since the prior reviewed commit no
102
102
  longer reaches the head, and `claude-pr-review` detects that itself.
103
103
 
104
- The heading carries the state rather than the pass number. A pass carrying a
105
- finding at any severity takes `## Review` and a pass carrying nothing takes
104
+ The heading carries the state rather than the pass number. A pass carrying
105
+ anything owed takes `## Review` and a pass carrying nothing takes
106
106
  `## Review closed`, so a thread can be scanned for what still owes work without
107
107
  opening a comment. One threshold governs the heading and the dispatch alike, and
108
108
  `claude-pr-review` is where it is stated, so every other surface cites that skill
109
- rather than restating the grades. The merge decision comes off the counts on the
110
- summary line, since an open heading now covers a minor as well as a critical.
109
+ rather than restating the grades.
110
+
111
+ The merge decision comes off the counts on the summary line, since an open
112
+ heading now covers a minor as well as a critical. One thing the pass posts sits
113
+ outside those counts and inside the threshold anyway. It reads the description's
114
+ Testing section against what the repository can drive, and asks about a box left
115
+ unchecked for a person where a harness already covers the run. The question
116
+ takes no severity, because whether a human is required is a judgment the author
117
+ may hold a reason for, and it still opens the heading and sends the dispatch,
118
+ because the author is the only party who can answer it.
119
+
111
120
  A minor the worker declines goes to the findings of the task the
112
121
  branch closes, since a thread does not survive the merge. The feedback
113
122
  becomes a durable artifact both sessions read, survives a session ending, and
@@ -2,7 +2,6 @@
2
2
  description: Route .claude/plans/ edits to the plan standard for sections, the answer contract, and the archive move
3
3
  paths:
4
4
  - '.claude/plans/**'
5
- - '.claude/plans-archive/**'
6
5
  ---
7
6
 
8
7
  # Plan standards
@@ -15,7 +14,7 @@ paths:
15
14
 
16
15
  ## Archiving
17
16
 
18
- - Move a shipped plan to `.claude/plans-archive/`. Never delete one.
17
+ - Move a shipped plan to `.claude/plans/archive/`. Never delete one.
19
18
  - Amend a plan in place when a decision changes. Do not append a second passage narrating the change.
20
19
 
21
20
  ## Authority
@@ -22,6 +22,7 @@ paths:
22
22
  - Use Playwright for end-to-end tests.
23
23
  - Place all Playwright tests within the `e2e/` directory.
24
24
  - Never place Playwright tests inside `src/`.
25
+ - Scope the three rules above to tests the Playwright runner executes. A Vitest test importing a browser driver to exercise project code stays beside its module, where Vitest looks for it.
25
26
 
26
27
  ## Timers and async
27
28
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.6.0",
4
+ "version": "3.7.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -46,6 +46,7 @@
46
46
  "dependencies": {
47
47
  "commander": "^13.1.0",
48
48
  "execa": "^9.5.2",
49
+ "playwright-core": "1.59.1",
49
50
  "pptxgenjs": "^4.0.1"
50
51
  },
51
52
  "devDependencies": {
@@ -53,7 +54,7 @@
53
54
  "@astrojs/react": "^5.0.4",
54
55
  "@commitlint/cli": "^20.4.1",
55
56
  "@commitlint/config-conventional": "^20.4.1",
56
- "@playwright/test": "^1.59.1",
57
+ "@playwright/test": "1.59.1",
57
58
  "@tailwindcss/vite": "^4.2.4",
58
59
  "@testing-library/jest-dom": "^6.9.1",
59
60
  "@types/bun": "^1.2.10",
package/src/cli.ts CHANGED
@@ -15,6 +15,7 @@ import { register as docs } from '@/commands/docs'
15
15
  import { register as design } from '@/commands/design'
16
16
  import { register as slides } from '@/commands/slides'
17
17
  import { register as capture } from '@/commands/capture'
18
+ import { register as demo } from '@/commands/demo'
18
19
  import { register as feedback } from '@/commands/feedback'
19
20
  import { register as transcripts } from '@/commands/transcripts'
20
21
  import { register as tasks } from '@/commands/tasks'
@@ -56,7 +57,8 @@ function showHelp(): void {
56
57
  `${GREY}│${NC} design [cmd] ${GREY}# Design system commands (render)${NC}`,
57
58
  `${GREY}│${NC} slides [cmd] ${GREY}# Slide deck commands (render, list)${NC}`,
58
59
  `${GREY}│${NC} capture [source] ${GREY}# Render HTML capture sources to PNG${NC}`,
59
- `${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .claude/review/${NC}`,
60
+ `${GREY}│${NC} demo [cmd] ${GREY}# Record a running app (compile, run)${NC}`,
61
+ `${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .claude/review/feedback/${NC}`,
60
62
  `${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
61
63
  `${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
62
64
  `${GREY}│${NC} intake [cmd] ${GREY}# Intake folders under .claude/intake/ (list, answer)${NC}`,
@@ -147,6 +149,7 @@ docs(program)
147
149
  design(program)
148
150
  slides(program)
149
151
  capture(program)
152
+ demo(program)
150
153
  feedback(program)
151
154
  transcripts(program)
152
155
  tasks(program)
@@ -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
+ }
@@ -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 reviewDir = join(PROJECT_ROOT, '.claude', 'review')
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', `.claude/review/${filename}`)
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',
@@ -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-archive/',
154
+ ' archived the target resolves inside .claude/plans/archive/',
155
155
  ' outside the target resolves somewhere else',
156
156
  '',
157
157
  'Exit codes:',