@erclx/aitk 0.43.0 → 0.45.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 +15 -10
- package/claude/skills/claude-docs/SKILL.md +16 -2
- package/claude/skills/claude-memory-capture/REQUIREMENT.md +14 -4
- package/claude/skills/claude-memory-capture/SKILL.md +45 -16
- package/claude/skills/claude-memory-review/REQUIREMENT.md +8 -2
- package/claude/skills/claude-memory-review/SKILL.md +41 -25
- package/claude/skills/claude-orchestrate/SKILL.md +5 -0
- package/claude/skills/git-ship/SKILL.md +14 -11
- package/claude/skills/session-resume/SKILL.md +2 -2
- package/docs/agents/commands.md +36 -34
- package/docs/agents/index.md +2 -1
- package/docs/agents/indexes.md +3 -1
- package/docs/agents/scripting.md +4 -3
- package/docs/agents/skills-audit.md +55 -0
- package/docs/agents/tasks.md +33 -1
- package/docs/ai-workflow.md +5 -2
- package/package.json +1 -1
- package/scripts/core/verify.sh +8 -0
- package/snippets/claude/orchestrator-sweep.md +2 -0
- package/src/claude/seeds.ts +1 -0
- package/src/claude/skills-audit.ts +215 -0
- package/src/claude/skills-list.ts +3 -3
- package/src/commands/claude.ts +283 -5
- package/src/commands/context.ts +1 -4
- package/src/commands/tasks.ts +113 -0
- package/src/tasks/archive.ts +5 -3
- package/src/tasks/validate.ts +401 -0
- package/src/ui.ts +5 -0
- package/standards/tasks.md +5 -0
- package/tooling/claude/reference.md +11 -1
- package/tooling/claude/seeds/.claude/hooks/memory-index.sh +60 -0
- package/tooling/claude/seeds/.claude/memory/index.md +8 -0
- package/tooling/claude/seeds/.claude/settings.json +4 -0
- package/tooling/claude/seeds/CLAUDE.md +3 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import { RESERVED_STEMS, tasksDir } from '@/tasks/archive'
|
|
5
|
+
|
|
6
|
+
const ORDERING_FILE = 'priority.md'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The readiness headings `.claude/standards/tasks.md` fixes. The names are the
|
|
10
|
+
* contract rather than a suggestion, so a board grouping under names of its own
|
|
11
|
+
* reads as carrying no group at all and the run refuses instead of reporting a
|
|
12
|
+
* clean board it never parsed.
|
|
13
|
+
*/
|
|
14
|
+
export const BOARD_GROUPS = ['Run now', 'Up next', 'Needs a plan'] as const
|
|
15
|
+
|
|
16
|
+
export type BoardGroup = (typeof BOARD_GROUPS)[number]
|
|
17
|
+
|
|
18
|
+
export const VALIDATE_REFUSALS = [
|
|
19
|
+
'no-board',
|
|
20
|
+
'no-ordering',
|
|
21
|
+
'no-groups',
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
export type ValidateRefusal = (typeof VALIDATE_REFUSALS)[number]
|
|
25
|
+
|
|
26
|
+
export const FINDING_KINDS = [
|
|
27
|
+
'plan-unstated',
|
|
28
|
+
'plan-unresolved',
|
|
29
|
+
'task-unresolved',
|
|
30
|
+
'row-missing',
|
|
31
|
+
'row-duplicated',
|
|
32
|
+
'touches-unstated',
|
|
33
|
+
'touches-collided',
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
export type FindingKind = (typeof FINDING_KINDS)[number]
|
|
37
|
+
|
|
38
|
+
export interface Finding {
|
|
39
|
+
readonly kind: FindingKind
|
|
40
|
+
readonly group: BoardGroup | undefined
|
|
41
|
+
readonly subject: string
|
|
42
|
+
readonly message: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BoardRow {
|
|
46
|
+
readonly group: BoardGroup
|
|
47
|
+
readonly label: string
|
|
48
|
+
readonly stem: string | undefined
|
|
49
|
+
readonly plan: string | undefined
|
|
50
|
+
/** Absent when the group fixes no `Touches` column, empty when it read none. */
|
|
51
|
+
readonly touches: readonly string[] | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ValidateReport {
|
|
55
|
+
readonly ok: true
|
|
56
|
+
readonly rows: number
|
|
57
|
+
readonly tasks: number
|
|
58
|
+
readonly findings: readonly Finding[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ValidateRefused {
|
|
62
|
+
readonly ok: false
|
|
63
|
+
readonly reason: ValidateRefusal
|
|
64
|
+
readonly message: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type ValidateOutcome = ValidateReport | ValidateRefused
|
|
68
|
+
|
|
69
|
+
export function orderingPath(root: string): string {
|
|
70
|
+
return join(tasksDir(root), ORDERING_FILE)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pulls the target out of a markdown link, which is how both the `Task` and the
|
|
75
|
+
* `Plan` column spell their pointer. A cell carrying prose instead of a link
|
|
76
|
+
* yields nothing, and that absence is the finding rather than a parse failure.
|
|
77
|
+
*/
|
|
78
|
+
function linkTarget(cell: string): string | undefined {
|
|
79
|
+
const match = /\[[^\]]*\]\(([^)]+)\)/.exec(cell)
|
|
80
|
+
return match ? match[1].trim() : undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function linkText(cell: string): string {
|
|
84
|
+
const match = /\[([^\]]*)\]\([^)]+\)/.exec(cell)
|
|
85
|
+
return (match ? match[1] : cell).trim()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stemOf(target: string): string | undefined {
|
|
89
|
+
const name = target.split('/').pop()
|
|
90
|
+
if (!name || !name.endsWith('.md')) return undefined
|
|
91
|
+
return name.slice(0, -'.md'.length)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reads the file set out of a `Touches` cell written as prose. Paths are the
|
|
96
|
+
* backticked spans, which is the only marker the column carries, and a span
|
|
97
|
+
* naming a skill or a command rather than a file is dropped by the same test.
|
|
98
|
+
*
|
|
99
|
+
* An extension opens with a letter, which is what separates `commands.md` from
|
|
100
|
+
* a task version like `v40.2`. Reading the version as a path would collide two
|
|
101
|
+
* rows that merely cite the same task.
|
|
102
|
+
*/
|
|
103
|
+
export function readPaths(cell: string): string[] {
|
|
104
|
+
const spans = cell.match(/`[^`]+`/g) ?? []
|
|
105
|
+
const paths = spans
|
|
106
|
+
.map((span) => span.slice(1, -1).trim())
|
|
107
|
+
.filter(
|
|
108
|
+
(span) => span.includes('/') || /\.[A-Za-z][A-Za-z0-9]*$/.test(span),
|
|
109
|
+
)
|
|
110
|
+
.map((span) => span.replace(/^\.\//, '').replace(/\/+$/, ''))
|
|
111
|
+
|
|
112
|
+
return [...new Set(paths)]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Two paths touch the same work when they are equal or one contains the other.
|
|
117
|
+
* A row naming a directory and a row naming a file inside it collide, which a
|
|
118
|
+
* set intersection on the written strings alone would miss.
|
|
119
|
+
*/
|
|
120
|
+
function sharesPath(left: string, right: string): boolean {
|
|
121
|
+
return (
|
|
122
|
+
left === right ||
|
|
123
|
+
left.startsWith(`${right}/`) ||
|
|
124
|
+
right.startsWith(`${left}/`)
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function splitCells(line: string): string[] {
|
|
129
|
+
return line
|
|
130
|
+
.trim()
|
|
131
|
+
.replace(/^\|/, '')
|
|
132
|
+
.replace(/\|$/, '')
|
|
133
|
+
.split('|')
|
|
134
|
+
.map((cell) => cell.trim())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isSeparator(cells: readonly string[]): boolean {
|
|
138
|
+
return cells.every((cell) => /^:?-{3,}:?$/.test(cell))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function columnIndex(header: readonly string[], name: string): number {
|
|
142
|
+
return header.findIndex((cell) => cell.toLowerCase() === name)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isGroup(heading: string): heading is BoardGroup {
|
|
146
|
+
return (BOARD_GROUPS as readonly string[]).includes(heading)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parses the ordering file into rows keyed by readiness group. Columns are read
|
|
151
|
+
* from each table's own header rather than by position, so a group whose shape
|
|
152
|
+
* differs from this repository's is reported for what it lacks instead of
|
|
153
|
+
* having its second cell read as something it never was.
|
|
154
|
+
*/
|
|
155
|
+
export function readBoard(text: string): {
|
|
156
|
+
readonly rows: readonly BoardRow[]
|
|
157
|
+
readonly groups: readonly BoardGroup[]
|
|
158
|
+
} {
|
|
159
|
+
const rows: BoardRow[] = []
|
|
160
|
+
const groups: BoardGroup[] = []
|
|
161
|
+
|
|
162
|
+
let group: BoardGroup | undefined
|
|
163
|
+
let header: string[] | undefined
|
|
164
|
+
|
|
165
|
+
for (const line of text.split('\n')) {
|
|
166
|
+
const heading = /^##\s+(.+?)\s*$/.exec(line)
|
|
167
|
+
if (heading) {
|
|
168
|
+
const title = heading[1]
|
|
169
|
+
group = isGroup(title) ? title : undefined
|
|
170
|
+
header = undefined
|
|
171
|
+
if (group && !groups.includes(group)) groups.push(group)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!group || !line.trimStart().startsWith('|')) continue
|
|
176
|
+
|
|
177
|
+
const cells = splitCells(line)
|
|
178
|
+
if (isSeparator(cells)) continue
|
|
179
|
+
|
|
180
|
+
if (!header) {
|
|
181
|
+
header = cells
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const taskAt = columnIndex(header, 'task')
|
|
186
|
+
const planAt = columnIndex(header, 'plan')
|
|
187
|
+
const touchesAt = columnIndex(header, 'touches')
|
|
188
|
+
|
|
189
|
+
const task = taskAt >= 0 ? (cells[taskAt] ?? '') : ''
|
|
190
|
+
const target = linkTarget(task)
|
|
191
|
+
const plan = planAt >= 0 ? linkTarget(cells[planAt] ?? '') : undefined
|
|
192
|
+
|
|
193
|
+
rows.push({
|
|
194
|
+
group,
|
|
195
|
+
label: linkText(task) || task,
|
|
196
|
+
stem: target ? stemOf(target) : undefined,
|
|
197
|
+
plan,
|
|
198
|
+
touches: touchesAt >= 0 ? readPaths(cells[touchesAt] ?? '') : undefined,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { rows, groups }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function listTaskStems(dir: string): Promise<string[]> {
|
|
206
|
+
const entries = await readdir(dir)
|
|
207
|
+
const reserved: readonly string[] = RESERVED_STEMS
|
|
208
|
+
|
|
209
|
+
return entries
|
|
210
|
+
.filter((entry) => entry.endsWith('.md'))
|
|
211
|
+
.map((entry) => entry.slice(0, -'.md'.length))
|
|
212
|
+
.filter((stem) => !reserved.includes(stem))
|
|
213
|
+
.sort()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolves a pointer against the board and against the project root both, the
|
|
218
|
+
* way `claude-docs` reads the same line. A fragment is dropped first, since an
|
|
219
|
+
* anchor is part of the link and never part of the path.
|
|
220
|
+
*/
|
|
221
|
+
function resolves(target: string, dir: string, root: string): boolean {
|
|
222
|
+
const path = target.split('#')[0]
|
|
223
|
+
if (!path) return false
|
|
224
|
+
|
|
225
|
+
return existsSync(resolve(dir, path)) || existsSync(resolve(root, path))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function checkMapping(
|
|
229
|
+
rows: readonly BoardRow[],
|
|
230
|
+
stems: readonly string[],
|
|
231
|
+
dir: string,
|
|
232
|
+
): Finding[] {
|
|
233
|
+
const findings: Finding[] = []
|
|
234
|
+
const seen = new Map<string, number>()
|
|
235
|
+
|
|
236
|
+
for (const row of rows) {
|
|
237
|
+
if (!row.stem) {
|
|
238
|
+
findings.push({
|
|
239
|
+
kind: 'task-unresolved',
|
|
240
|
+
group: row.group,
|
|
241
|
+
subject: row.label,
|
|
242
|
+
message: 'names no task file, so the row points at nothing.',
|
|
243
|
+
})
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
seen.set(row.stem, (seen.get(row.stem) ?? 0) + 1)
|
|
248
|
+
|
|
249
|
+
if (!existsSync(join(dir, `${row.stem}.md`))) {
|
|
250
|
+
findings.push({
|
|
251
|
+
kind: 'task-unresolved',
|
|
252
|
+
group: row.group,
|
|
253
|
+
subject: row.stem,
|
|
254
|
+
message: 'has a row and no task file.',
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const [stem, count] of seen) {
|
|
260
|
+
if (count > 1) {
|
|
261
|
+
findings.push({
|
|
262
|
+
kind: 'row-duplicated',
|
|
263
|
+
group: undefined,
|
|
264
|
+
subject: stem,
|
|
265
|
+
message: `carries ${count} rows. A task belongs to exactly one group.`,
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const stem of stems) {
|
|
271
|
+
if (!seen.has(stem)) {
|
|
272
|
+
findings.push({
|
|
273
|
+
kind: 'row-missing',
|
|
274
|
+
group: undefined,
|
|
275
|
+
subject: stem,
|
|
276
|
+
message: 'is a task file with no row on the board.',
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return findings
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function checkPlans(
|
|
285
|
+
rows: readonly BoardRow[],
|
|
286
|
+
dir: string,
|
|
287
|
+
root: string,
|
|
288
|
+
): Finding[] {
|
|
289
|
+
const findings: Finding[] = []
|
|
290
|
+
|
|
291
|
+
for (const row of rows) {
|
|
292
|
+
if (row.group !== 'Run now') continue
|
|
293
|
+
|
|
294
|
+
if (!row.plan) {
|
|
295
|
+
findings.push({
|
|
296
|
+
kind: 'plan-unstated',
|
|
297
|
+
group: row.group,
|
|
298
|
+
subject: row.stem ?? row.label,
|
|
299
|
+
message:
|
|
300
|
+
'states no plan pointer, so the readiness claim cannot be checked.',
|
|
301
|
+
})
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!resolves(row.plan, dir, root)) {
|
|
306
|
+
findings.push({
|
|
307
|
+
kind: 'plan-unresolved',
|
|
308
|
+
group: row.group,
|
|
309
|
+
subject: row.stem ?? row.label,
|
|
310
|
+
message: `points at ${row.plan}, which resolves to no file.`,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return findings
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The half of the `## Run now` test a person cannot check by eye. Two rows a
|
|
320
|
+
* worker may be handed at once must touch disjoint files, and the `Touches`
|
|
321
|
+
* column is the only place either set is written down.
|
|
322
|
+
*/
|
|
323
|
+
function checkCollisions(rows: readonly BoardRow[]): Finding[] {
|
|
324
|
+
const findings: Finding[] = []
|
|
325
|
+
const ready = rows.filter((row) => row.group === 'Run now')
|
|
326
|
+
|
|
327
|
+
for (const row of ready) {
|
|
328
|
+
// An absent column and an unreadable one both leave the row untested by
|
|
329
|
+
// the loop below, so reporting only the second would pass a board whose
|
|
330
|
+
// `## Run now` table declares no file set at all.
|
|
331
|
+
if (!row.touches || row.touches.length === 0) {
|
|
332
|
+
findings.push({
|
|
333
|
+
kind: 'touches-unstated',
|
|
334
|
+
group: row.group,
|
|
335
|
+
subject: row.stem ?? row.label,
|
|
336
|
+
message: 'names no file, so its collisions cannot be read.',
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (let i = 0; i < ready.length; i += 1) {
|
|
342
|
+
for (let j = i + 1; j < ready.length; j += 1) {
|
|
343
|
+
const left = ready[i]
|
|
344
|
+
const right = ready[j]
|
|
345
|
+
const shared = (left.touches ?? []).filter((path) =>
|
|
346
|
+
(right.touches ?? []).some((other) => sharesPath(path, other)),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if (shared.length === 0) continue
|
|
350
|
+
|
|
351
|
+
findings.push({
|
|
352
|
+
kind: 'touches-collided',
|
|
353
|
+
group: 'Run now',
|
|
354
|
+
subject: `${left.stem ?? left.label} and ${right.stem ?? right.label}`,
|
|
355
|
+
message: `both touch ${shared.join(', ')}.`,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return findings
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function refuse(reason: ValidateRefusal, message: string): ValidateRefused {
|
|
364
|
+
return { ok: false, reason, message }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Reports what every board row claims against what the tree holds. It writes
|
|
369
|
+
* nothing: a row is a session's claim about readiness, and a validator that
|
|
370
|
+
* repaired one would be asserting the claim it exists to test.
|
|
371
|
+
*/
|
|
372
|
+
export async function validateBoard(root: string): Promise<ValidateOutcome> {
|
|
373
|
+
const dir = tasksDir(root)
|
|
374
|
+
if (!existsSync(dir)) {
|
|
375
|
+
return refuse('no-board', `No task board at ${dir}.`)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const ordering = orderingPath(root)
|
|
379
|
+
if (!existsSync(ordering)) {
|
|
380
|
+
return refuse('no-ordering', `No ordering file at ${ordering}.`)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const { rows, groups } = readBoard(await readFile(ordering, 'utf8'))
|
|
384
|
+
|
|
385
|
+
if (groups.length === 0) {
|
|
386
|
+
return refuse(
|
|
387
|
+
'no-groups',
|
|
388
|
+
`No readiness group in ${ORDERING_FILE}. Expected one of: ${BOARD_GROUPS.join(', ')}.`,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const stems = await listTaskStems(dir)
|
|
393
|
+
|
|
394
|
+
const findings = [
|
|
395
|
+
...checkMapping(rows, stems, dir),
|
|
396
|
+
...checkPlans(rows, dir, root),
|
|
397
|
+
...checkCollisions(rows),
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
return { ok: true, rows: rows.length, tasks: stems.length, findings }
|
|
401
|
+
}
|
package/src/ui.ts
CHANGED
|
@@ -59,6 +59,11 @@ export function pipeOutput(text: string): void {
|
|
|
59
59
|
)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** Counts a noun for a report line, where the plural is the bare `s` form. */
|
|
63
|
+
export function plural(count: number, noun: string): string {
|
|
64
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
export function frameError(message: string): void {
|
|
63
68
|
process.stderr.write(
|
|
64
69
|
`${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} ${message}\n${GREY}└${NC}\n`,
|
package/standards/tasks.md
CHANGED
|
@@ -26,12 +26,15 @@ Does not govern:
|
|
|
26
26
|
.claude/tasks/
|
|
27
27
|
├── index.md ← generated, never hand-edited
|
|
28
28
|
├── priority.md ← hand-maintained execution order
|
|
29
|
+
├── session.md ← optional, what a compaction is about to destroy
|
|
29
30
|
├── v09.0-sync-paths.md
|
|
30
31
|
└── v13.0-toolkit-drift.md
|
|
31
32
|
```
|
|
32
33
|
|
|
33
34
|
One file per task is what keeps the board safe under parallel sessions. Two sessions working different tasks never write the same file, which matters because a gitignored board has no history to recover a clobbered write from.
|
|
34
35
|
|
|
36
|
+
Three siblings sit in the folder without being tasks, and each earns its place by being governed somewhere. `index.md` and `priority.md` are governed here. `session.md` is the pre-compaction handoff, written by `orchestrator-handoff` and read by `orchestrator-resume`, and it is optional: a project running no orchestrator carries no such file. Anything reading the folder as a task list skips all three, so a name outside the set is a task whatever it holds.
|
|
37
|
+
|
|
35
38
|
`index.md` is generated from sibling frontmatter. The folder is gitignored, so the whole-repo index walk skips it and a hook passing the changed path regenerates it instead. Never hand-edit it.
|
|
36
39
|
|
|
37
40
|
The `claude-tasks` skill creates and archives task files. `claude-docs` marks outcomes `[x]` in an existing file and sweeps the plans those tasks cite. Neither does the other's job.
|
|
@@ -50,6 +53,8 @@ Readiness is three groups under fixed headings, `## Run now`, `## Up next`, and
|
|
|
50
53
|
|
|
51
54
|
Each group fixes its own columns, which follow from the test above it rather than from preference. Neither half of the `## Run now` test is checkable without the file set and the plan sitting beside the task. The blocker column under `## Up next` names whether a collision or a dependency holds the row. `## Needs a plan` states no file set at all, because a task with no plan has no bounded one to state. A group with no rows keeps its heading and its header row.
|
|
52
55
|
|
|
56
|
+
`aitk tasks validate` reads those columns and reports where a row's claim and the tree disagree: a plan pointer resolving to no file, a row and a task file that do not map one to one, a task in two groups, and two `## Run now` rows touching a path in common. Run it when the readiness claim is made rather than on a schedule, since the board is gitignored per-machine scratch and no shared moment exists to hang it on. It reports and never writes, so a session fixes the row it names.
|
|
57
|
+
|
|
53
58
|
```markdown
|
|
54
59
|
---
|
|
55
60
|
title: Priority
|
|
@@ -20,7 +20,7 @@ The claude stack installs the `.claude/` workflow directory into a project. Stat
|
|
|
20
20
|
├── plans/ ← execution detail for multi-step tasks, gitignored. `feature-*.md` entries swept by claude-docs.
|
|
21
21
|
├── review/ ← scratch for claude-review and claude-ui-test output, gitignored
|
|
22
22
|
├── .tmp/ ← ephemeral scratch space, gitignored
|
|
23
|
-
└── memory/ ← session
|
|
23
|
+
└── memory/ ← session facts no context entry owns, gitignored. `index.md` regenerated by a hook.
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
## Upgrading from a single-file board
|
|
@@ -33,6 +33,16 @@ Convert by hand, once per project:
|
|
|
33
33
|
2. Run `aitk indexes regen --no-stage --root . .claude/tasks/<any-task>.md` to build the catalog.
|
|
34
34
|
3. Delete `.claude/TASKS.md`, and swap its `.gitignore` entry for `.claude/tasks/`.
|
|
35
35
|
|
|
36
|
+
## Upgrading a hand-appended memory index
|
|
37
|
+
|
|
38
|
+
A project installed before the memory folder gained a generated index still holds `.claude/memory/MEMORY.md`, and its entries still carry `name` and `type` frontmatter. Nothing migrates it. `claude-memory-capture` stops appending rows once the new seed lands, so the old file freezes at whatever it held while the folder keeps growing past it.
|
|
39
|
+
|
|
40
|
+
Convert by hand, once per project:
|
|
41
|
+
|
|
42
|
+
1. Rewrite each entry's `name` key to `title` and its `type` key to a sentence-case `category`, quoting any `description` that opens with a backtick or a colon so the frontmatter parses.
|
|
43
|
+
2. Replace `MEMORY.md` with an `index.md` carrying `title` and `subtitle` frontmatter and nothing else.
|
|
44
|
+
3. Run `aitk indexes regen --no-stage --root . .claude/memory/index.md` to build the catalog, and compare its entry count against the file count before deleting anything.
|
|
45
|
+
|
|
36
46
|
## Upgrading from a single-file diagram set
|
|
37
47
|
|
|
38
48
|
A project installed before the diagram surface became a folder still holds `.claude/DIAGRAMS.md`. Unlike the board, this one migrates itself. The `claude-diagram` skill reads the flat file when `.claude/diagrams/` holds no entries, splits it by kind into the folder, and reports what it wrote. The old file stays on disk so the split can be compared against its source, and deleting it is a manual step once that check passes.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Regenerates .claude/memory/index.md after a memory file changes.
|
|
4
|
+
#
|
|
5
|
+
# The memory folder is gitignored, so the whole-repo walk in `bun run check`
|
|
6
|
+
# drops it and never regenerates this index. A positional path bypasses that
|
|
7
|
+
# filter, which makes this hook the only trigger that reaches the folder.
|
|
8
|
+
|
|
9
|
+
input=$(cat)
|
|
10
|
+
|
|
11
|
+
tool=$(printf '%s' "$input" | jq -r '.tool_name // empty')
|
|
12
|
+
case "$tool" in
|
|
13
|
+
Write | Edit | MultiEdit) ;;
|
|
14
|
+
*) exit 0 ;;
|
|
15
|
+
esac
|
|
16
|
+
|
|
17
|
+
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
|
|
18
|
+
[ -n "$file_path" ] || exit 0
|
|
19
|
+
|
|
20
|
+
case "$file_path" in
|
|
21
|
+
*/.claude/memory/*.md) ;;
|
|
22
|
+
*) exit 0 ;;
|
|
23
|
+
esac
|
|
24
|
+
|
|
25
|
+
case "$file_path" in
|
|
26
|
+
*/.claude/memory/index.md) exit 0 ;;
|
|
27
|
+
esac
|
|
28
|
+
|
|
29
|
+
# Report a missing CLI rather than exiting quietly. The path guard above already
|
|
30
|
+
# scopes this to a memory-file edit, so the message only fires where the stale
|
|
31
|
+
# index it warns about is the actual outcome.
|
|
32
|
+
if ! command -v aitk >/dev/null 2>&1; then
|
|
33
|
+
jq -nc --arg msg 'aitk is not on PATH, so .claude/memory/index.md was not regenerated and is now stale. Install the toolkit CLI or run aitk indexes regen by hand.' \
|
|
34
|
+
'{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# The walk-up boundary has to come from the path, not from the session. Shared
|
|
39
|
+
# scratch resolves at the main worktree root, so a session inside a linked
|
|
40
|
+
# worktree passes a path that sits outside its own project directory and the
|
|
41
|
+
# default boundary would reject it.
|
|
42
|
+
root="${file_path%/.claude/memory/*}"
|
|
43
|
+
[ -n "$root" ] || exit 0
|
|
44
|
+
|
|
45
|
+
# `--no-stage` because a hook has no business touching the index. On a project
|
|
46
|
+
# whose memory folder is not gitignored, the default auto-stage would silently
|
|
47
|
+
# add memory files to whatever commit is being assembled.
|
|
48
|
+
output=$(aitk indexes regen --no-stage --root "$root" "$file_path" 2>&1) && exit 0
|
|
49
|
+
|
|
50
|
+
# Regen failed, which on this folder means a memory file is missing `title` or
|
|
51
|
+
# `description`. Report it. Nothing else can: the folder is gitignored, so the
|
|
52
|
+
# whole-repo walk never reaches it and no gate stage will ever fail on a stale
|
|
53
|
+
# index. Staying quiet here is what makes the drift permanent.
|
|
54
|
+
errors=$(printf '%s\n' "$output" | grep '^ERROR: ' | head -5)
|
|
55
|
+
[ -n "$errors" ] || errors="$output"
|
|
56
|
+
|
|
57
|
+
msg="Memory index regen failed, so .claude/memory/index.md is now stale. Fix the frontmatter and save again. $errors"
|
|
58
|
+
jq -nc --arg msg "$msg" \
|
|
59
|
+
'{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$msg}}'
|
|
60
|
+
exit 0
|
|
@@ -72,9 +72,12 @@
|
|
|
72
72
|
## Memory
|
|
73
73
|
|
|
74
74
|
- Write all memory files to `.claude/memory/`, not `~/.claude/projects/`
|
|
75
|
+
- A fact about a domain goes to that domain's `.claude/context/` entry, not to memory. `claude-memory-capture` routes it there and `claude-docs` folds it in. Memory keeps only what no context entry owns.
|
|
75
76
|
- Save a feedback memory only when the same mistake happens twice in the session, or when the user explicitly corrects you. First-occurrence slips are noise.
|
|
76
77
|
- Keep feedback memories to 3 lines: the rule, a one-line Why, and a one-line How to apply. Capture the pattern, not the recovery narrative.
|
|
77
78
|
- Before creating a new memory file, check for an existing one on the same topic. Update rather than duplicate.
|
|
79
|
+
- Give every entry `title`, `description`, and a sentence-case `category`. Never hand-edit `.claude/memory/index.md`. A hook regenerates it from sibling frontmatter.
|
|
80
|
+
- Never delete a memory entry. `claude-memory-review` moves a retired one to `.claude/.tmp/memory-archive/`.
|
|
78
81
|
|
|
79
82
|
## Scratch
|
|
80
83
|
|