@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.
Files changed (59) 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/create-rule/REQUIREMENT.md +2 -1
  12. package/claude/skills/create-rule/SKILL.md +8 -8
  13. package/claude/skills/create-snippet/REQUIREMENT.md +3 -0
  14. package/claude/skills/create-snippet/SKILL.md +2 -2
  15. package/claude/skills/git-pr/references/pr.md +3 -0
  16. package/claude/skills/git-ship/SKILL.md +1 -1
  17. package/claude/skills/git-split/references/pr.md +3 -0
  18. package/claude/skills/restate/REQUIREMENT.md +41 -0
  19. package/claude/skills/restate/SKILL.md +39 -0
  20. package/claude/skills/toolkit-feedback/SKILL.md +2 -2
  21. package/claude/skills/write-human/REQUIREMENT.md +1 -1
  22. package/claude/skills/write-human/SKILL.md +1 -1
  23. package/docs/agents/capture.md +3 -1
  24. package/docs/agents/commands.md +25 -21
  25. package/docs/agents/demo.md +82 -0
  26. package/docs/agents/index.md +2 -0
  27. package/docs/agents/install-and-sync.md +6 -2
  28. package/docs/agents/records.md +2 -2
  29. package/docs/agents/routing.md +61 -0
  30. package/docs/agents/tasks.md +1 -1
  31. package/docs/ai-workflow.md +8 -5
  32. package/docs/operating-model.md +13 -4
  33. package/governance/rules/claude/558-plan.md +1 -2
  34. package/governance/rules/lib/300-testing-ts.md +1 -0
  35. package/package.json +3 -2
  36. package/src/claude/routing.ts +283 -0
  37. package/src/cli.ts +4 -1
  38. package/src/commands/claude.ts +130 -1
  39. package/src/commands/demo.ts +373 -0
  40. package/src/commands/feedback.ts +10 -3
  41. package/src/commands/tasks.ts +1 -1
  42. package/src/demo/beats.ts +135 -0
  43. package/src/demo/compile.ts +295 -0
  44. package/src/demo/cursors.ts +55 -0
  45. package/src/demo/drive.ts +256 -0
  46. package/src/demo/pointer.ts +178 -0
  47. package/src/demo/theme.ts +112 -0
  48. package/src/gov/adapter.ts +1 -0
  49. package/src/records/backup.ts +34 -8
  50. package/src/snippets/adapter.ts +1 -0
  51. package/src/sync/engine.ts +25 -1
  52. package/src/tasks/archive.ts +11 -4
  53. package/standards/bundled/pr.md +3 -0
  54. package/standards/plan.md +1 -1
  55. package/standards/tasks.md +4 -4
  56. package/tooling/claude/manifest.toml +1 -1
  57. package/tooling/claude/reference.md +6 -5
  58. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +4 -1
  59. package/tooling/claude/seeds/CLAUDE.md +1 -1
@@ -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
+ }
@@ -54,6 +54,7 @@ export function createGovAdapter(root: string): SyncAdapter {
54
54
  locateSource: (file: InstalledFile) =>
55
55
  index.get(basename(file.path, '.md')),
56
56
  collectRetired: (target: string) => collectRetiredGov(target),
57
+ projectSubdir: 'project',
57
58
  stamp: { domain: 'governance', toolkitRoot: root },
58
59
  }
59
60
  }
@@ -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 ten are pushed into. The list is spelled out
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 eleven names against this list of ten.
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 eight a pathspec can name: on disk, or already in 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
  )
@@ -21,6 +21,7 @@ export function createSnippetsAdapter(root: string): SyncAdapter {
21
21
  unit: 'snippets',
22
22
  installedRoot: (target: string) => join(target, '.claude', 'snippets'),
23
23
  locateSource: (file: InstalledFile) => locateSource(sourceDir, file),
24
+ projectSubdir: 'project',
24
25
  stamp: { domain: 'snippets', toolkitRoot: root },
25
26
  }
26
27
  }
@@ -121,6 +121,13 @@ export interface SyncAdapter {
121
121
  collectRetired?(target: string): RetiredSurface[]
122
122
  /** Dropped from the walk, so neither matching nor orphaned. */
123
123
  isExcluded?(file: InstalledFile): boolean
124
+ /**
125
+ * Top-level folder under `installedRoot` that is project-authored by
126
+ * location rather than by the name inference `locateSource` runs.
127
+ * Checked before `locateSource`, so a file here is orphaned even when its
128
+ * name also matches a toolkit source, and never enters the stamp.
129
+ */
130
+ readonly projectSubdir?: string
124
131
  /** Defaults to applying. */
125
132
  readonly nonInteractive?: NonInteractivePolicy
126
133
  /** Runs on a completed sync, including one with no changes. */
@@ -168,6 +175,11 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
168
175
  if (adapter.isExcluded?.(file) === true) continue
169
176
  walked.add(toStampKey(file.rel))
170
177
 
178
+ if (isProjectAuthored(adapter, file)) {
179
+ entries.push({ state: 'orphaned', rel: file.rel })
180
+ continue
181
+ }
182
+
171
183
  const source = adapter.locateSource(file)
172
184
 
173
185
  if (source === undefined || !existsSync(source)) {
@@ -439,6 +451,16 @@ function strandedByRelocation(
439
451
  return entries
440
452
  }
441
453
 
454
+ /**
455
+ * `Bun.Glob` reports `relToRoot` with `/` separators regardless of platform,
456
+ * so the declared subfolder is compared against the walk's first segment
457
+ * rather than through a path-aware join.
458
+ */
459
+ function isProjectAuthored(adapter: SyncAdapter, file: InstalledFile): boolean {
460
+ if (adapter.projectSubdir === undefined) return false
461
+ return file.relToRoot.split('/')[0] === adapter.projectSubdir
462
+ }
463
+
442
464
  function isInside(target: string, path: string): boolean {
443
465
  const rel = relative(target, path)
444
466
  return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
@@ -457,7 +479,8 @@ function hasUnattributedDrift(plan: SyncPlan): boolean {
457
479
  /**
458
480
  * Records what the toolkit placed, after the copies land, so a partial apply
459
481
  * that throws leaves the previous stamp rather than a claim the target does not
460
- * meet. Files with no source are project-authored and stay out.
482
+ * meet. A file with no source, or one orphaned by location, is
483
+ * project-authored and stays out.
461
484
  *
462
485
  * Reads the installed tree rather than the caller's file list, so a partial
463
486
  * install still stamps the domain's whole installed set.
@@ -474,6 +497,7 @@ export async function recordStamp(
474
497
 
475
498
  for (const file of listInstalled(adapter.installedRoot(target), target)) {
476
499
  if (adapter.isExcluded?.(file) === true) continue
500
+ if (isProjectAuthored(adapter, file)) continue
477
501
 
478
502
  const source = adapter.locateSource(file)
479
503
  if (source === undefined || !existsSync(source)) continue
@@ -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('.claude', 'task-archive')
7
+ const ARCHIVE_DIR = join(TASKS_DIR, 'archive')
8
8
  const PLANS_DIR = join('.claude', 'plans')
9
- const PLANS_ARCHIVE_DIR = join('.claude', 'plans-archive')
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)) return fromBoard
212
- if (isUnder(fromRoot, plans)) return fromRoot
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
 
@@ -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-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.
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
@@ -215,9 +215,9 @@ An intake folder answers that direction at folder scope rather than item scope,
215
215
 
216
216
  Phase-label format and where labels may appear are governed by `standards/versioning.md`.
217
217
 
218
- `Plan:` points at `../plans/feature-<slug>.md` while the task is open. Once the task ships and the plan is archived, it points at `../plans-archive/feature-<slug>.md`. Retarget both halves of the link rather than dropping it, so a completed task still leads to the reasoning behind it.
218
+ `Plan:` points at `../plans/feature-<slug>.md` while the task is open. Once the task ships and the plan is archived, it points at `../plans/archive/feature-<slug>.md`, and at `../../plans/archive/feature-<slug>.md` once the task itself is archived a folder deeper. Retarget both halves of the link rather than dropping it, so a completed task still leads to the reasoning behind it.
219
219
 
220
- A project that archived plans before the folder moved out of `.claude/.tmp/` holds closed tasks pointing at `../.tmp/plans-archive/`, and both forms resolve against the files each names, so leave those pointers where they are. Nothing migrates them, and a task retargeted without its plan moving leads nowhere.
220
+ A project that archived plans before the folder nested under `.claude/plans/` holds closed tasks pointing at `../plans-archive/`, or at `../.tmp/plans-archive/` from before the durable records left the scratch tree. Each form resolves against the files it names, so leave those pointers where they are. Nothing migrates them, and a task retargeted without its plan moving leads nowhere.
221
221
 
222
222
  One plan per task. A plan cited by two tasks is a misfile rather than a shape to design for, which is why the sweep counts citations before archiving: the count is a guard against the misfile stranding a pointer, not support for the shape.
223
223
 
@@ -247,11 +247,11 @@ The line is what lets a merge close its own task. Every merge on `main` is a squ
247
247
 
248
248
  ## Archiving
249
249
 
250
- Never delete a task file. A shipped task moves to `.claude/task-archive/` under its own name, and the live index regenerates without it. `aitk tasks archive` owns the move, the ordering-row removal, and the index regen as one unit.
250
+ Never delete a task file. A shipped task moves to `.claude/tasks/archive/` under its own name, and the live index regenerates without it. `aitk tasks archive` owns the move, the ordering-row removal, and the index regen as one unit.
251
251
 
252
252
  Two callers reach that command. The `claude-tasks` skill runs it inside a session, and the `post-merge` hook runs it unattended after a pull that merged the work. Both go through the command rather than moving the file themselves, so the two paths cannot drift into archiving differently. Every gate the command applies refuses with a non-zero exit rather than reporting, because a caller with nobody watching cannot act on a warning.
253
253
 
254
- One destination rather than a per-project choice is what lets the move happen without asking. It mirrors the plans archive at `.claude/plans-archive/` and stays gitignored, so an archived task does not start appearing in diffs. The cost is that the folder is unbacked, which is the same cost the plans archive already carries.
254
+ One destination rather than a per-project choice is what lets the move happen without asking. It mirrors the plans archive at `.claude/plans/archive/`, sitting inside the folder it archives the same way, and it inherits the board's own ignore entry rather than needing one of its own. The cost is that an archived task does not appear in diffs, which is the cost the live board already carries.
255
255
 
256
256
  The archive clears a row from `priority.md` and reads no other surface, which holds because a task reaches a merge by being planned and handed out, and both steps move it onto the board first. A task archived straight off the backlog therefore leaves its line standing, and the validator reports that line as naming a file that is gone rather than the board losing it silently.
257
257
 
@@ -8,4 +8,4 @@ runtime = ""
8
8
  scaffold = ""
9
9
 
10
10
  [gitignore]
11
- "# Claude" = [".claude/.records.git/", ".claude/.tmp/", ".claude/groundwork/", ".claude/intake/", ".claude/memory/", ".claude/plans/", ".claude/plans-archive/", ".claude/review/", ".claude/review-archive/", ".claude/task-archive/", ".claude/worktrees/", ".claude/tasks/", ".claude/teach/"]
11
+ "# Claude" = [".claude/.records.git/", ".claude/.tmp/", ".claude/groundwork/", ".claude/intake/", ".claude/memory/", ".claude/plans/", ".claude/review/", ".claude/worktrees/", ".claude/tasks/", ".claude/teach/"]
@@ -9,7 +9,7 @@ The claude stack installs the `.claude/` workflow directory into a project. Stat
9
9
  ```plaintext
10
10
  .claude/
11
11
  ├── CLAUDE.md ← seeded. Project context and rules, auto-loaded by Claude Code each session
12
- ├── tasks/ ← seeded then gitignored. One file per task plus a generated index.md, local scratch only
12
+ ├── tasks/ ← seeded then gitignored. One file per task plus a generated index.md, local scratch only. `archive/` holds the tasks that shipped.
13
13
  ├── REQUIREMENTS.md ← seeded. Project goals, non-goals, MVP scope
14
14
  ├── ARCHITECTURE.md ← seeded. Technical design decisions and open questions
15
15
  ├── DESIGN.md ← seeded. Visual intent and the decisions behind it
@@ -17,9 +17,8 @@ The claude stack installs the `.claude/` workflow directory into a project. Stat
17
17
  ├── diagrams/ ← seeded. Per-kind Mermaid views. `index.md` is the discovery anchor. `<kind>.md` files hold one diagram each, grouped by the `category` frontmatter field.
18
18
  ├── GOV.md ← retired. Removed by `aitk gov sync` if present from a prior install
19
19
  ├── settings.json ← seeded. Project-level Claude Code config (PreToolUse and PostToolUse hooks). User-level config installed separately via `aitk claude setup`.
20
- ├── plans/ ← execution detail for multi-step tasks, gitignored. `feature-*.md` entries swept by claude-docs.
21
- ├── review/ ← scratch for claude-review and claude-ui-test output, gitignored
22
- ├── review-archive/ ← memory-review receipts a triage took out of review/, gitignored
20
+ ├── plans/ ← execution detail for multi-step tasks, gitignored. `feature-*.md` entries swept by claude-docs into `archive/`.
21
+ ├── review/ ← gitignored, one subfolder per producer. `branch/` for claude-review, `feedback/` for aitk feedback, `memory/` for claude-memory-review with its own `archive/`, `design/` for aitk design render.
23
22
  ├── .tmp/ ← ephemeral scratch space, gitignored
24
23
  └── memory/ ← session facts no context entry owns, gitignored. `index.md` regenerated by a hook.
25
24
  ```
@@ -50,7 +49,9 @@ A project installed before the diagram surface became a folder still holds `.cla
50
49
 
51
50
  ## Gitignore
52
51
 
53
- - `# Claude`: `.claude/.records.git/`, `.claude/.tmp/`, `.claude/groundwork/`, `.claude/intake/`, `.claude/memory/`, `.claude/plans/`, `.claude/plans-archive/`, `.claude/review/`, `.claude/review-archive/`, `.claude/task-archive/`, `.claude/worktrees/`, `.claude/tasks/`, `.claude/teach/`
52
+ - `# Claude`: `.claude/.records.git/`, `.claude/.tmp/`, `.claude/groundwork/`, `.claude/intake/`, `.claude/memory/`, `.claude/plans/`, `.claude/review/`, `.claude/worktrees/`, `.claude/tasks/`, `.claude/teach/`
53
+
54
+ Each entry is a top-level record folder, and an archive sits inside the folder it archives, so one line covers a record and everything it retires.
54
55
 
55
56
  ## CLI
56
57
 
@@ -30,8 +30,11 @@ case "$file_path" in
30
30
  *) exit 0 ;;
31
31
  esac
32
32
 
33
+ # The board index covers the live folder alone. A shell pattern's wildcard
34
+ # crosses a separator, so the guard above matches an archived task as well and
35
+ # a regen fired on one would rebuild the index the archive was taken out of.
33
36
  case "$file_path" in
34
- */.claude/tasks/index.md) exit 0 ;;
37
+ */.claude/tasks/index.md | */.claude/tasks/archive/*) exit 0 ;;
35
38
  esac
36
39
 
37
40
  # Report a missing CLI rather than exiting quietly. The path guard above already
@@ -74,7 +74,7 @@
74
74
  - `.claude/tasks/` is gitignored local session scratch, one file per task. Edit freely. No staging or revert before commits.
75
75
  - Only create a task for work that spans multiple sessions or has real dependencies. Handle small edits immediately without a task entry.
76
76
  - Do not add tasks retroactively for work already completed. Completed work is visible in git.
77
- - When a task needs execution detail beyond its own file, create a plan in `.claude/plans/` and link to it from the task's intro paragraph. When that task ships, move its plan file to `.claude/plans-archive/`. Never delete it.
77
+ - When a task needs execution detail beyond its own file, create a plan in `.claude/plans/` and link to it from the task's intro paragraph. When that task ships, move its plan file to `.claude/plans/archive/`. Never delete it.
78
78
  - Write the plan in the same session as the task file. The session that executes the plan later inherits reasoning context it would otherwise have to re-derive.
79
79
 
80
80
  ## Memory