@erclx/aitk 0.89.1 → 0.91.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.
@@ -0,0 +1,406 @@
1
+ import { relative } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import {
4
+ type AnswerOutcome,
5
+ answerItems,
6
+ type IntakeRefused,
7
+ type ListOutcome,
8
+ listFolders,
9
+ type ReadOutcome,
10
+ readFolder,
11
+ type Selection,
12
+ } from '@/intake/folder'
13
+ import { isUnread } from '@/intake/items'
14
+ import {
15
+ intro,
16
+ logAdd,
17
+ logError,
18
+ logInfo,
19
+ logStep,
20
+ logWarn,
21
+ outro,
22
+ pipeOutput,
23
+ } from '@/ui'
24
+ import { mainWorktreeRoot } from '@/worktree'
25
+
26
+ /**
27
+ * A selection as the command line spells it, splitting on the first `=`. The
28
+ * label accepts a letter suffix, matching the headings a cluster file carries,
29
+ * and everything after the separator is the answer, so one carrying its own
30
+ * `=` survives intact.
31
+ */
32
+ const SELECTION = /^(\d+[a-z]*)=([\s\S]*)$/i
33
+
34
+ interface ListCommandOptions {
35
+ readonly json?: boolean
36
+ readonly unread?: boolean
37
+ readonly root?: string
38
+ }
39
+
40
+ interface AnswerCommandOptions {
41
+ readonly cluster?: string
42
+ readonly json?: boolean
43
+ readonly root?: string
44
+ readonly set?: readonly string[]
45
+ }
46
+
47
+ export function register(program: Command): void {
48
+ const intake = program
49
+ .command('intake')
50
+ .description('Read and answer intake folders in .claude/intake/')
51
+ .helpOption('-h, --help', 'Show this help message')
52
+
53
+ intake
54
+ .command('list')
55
+ .description('List intake folders, or the items one folder holds')
56
+ .argument('[slug]', 'Intake folder name, as in toolkit-overview')
57
+ .helpOption('-h, --help', 'Show this help message')
58
+ .option(
59
+ '--unread',
60
+ 'Keep only what is unread, as folders carrying one or as empty slots',
61
+ )
62
+ .option('--json', 'Emit a machine-readable record on stdout')
63
+ .option('--root <path>', 'Intake root, defaulting to the main worktree')
64
+ .addHelpText(
65
+ 'after',
66
+ [
67
+ '',
68
+ 'Exit codes:',
69
+ ' 0 the folders or items were listed',
70
+ ' 1 refused, with the reason on stderr or in the JSON record',
71
+ '',
72
+ 'With no slug it reports per-folder counts. With one it reports every',
73
+ 'item grouped by the cluster file holding it. An empty answer slot',
74
+ 'means unread and never agreement, so a count is the report and no',
75
+ 'verb here decides one.',
76
+ '',
77
+ 'An item carrying no answer slot is counted apart from both, since the',
78
+ 'answer verb cannot reach it and folding it into either count hides a',
79
+ 'file that needs fixing.',
80
+ '',
81
+ 'Examples:',
82
+ ' aitk intake list',
83
+ ' aitk intake list toolkit-overview --unread --json',
84
+ '',
85
+ ].join('\n'),
86
+ )
87
+ .action(async (slug: string | undefined, opts: ListCommandOptions) => {
88
+ process.exitCode = await runList(slug, opts)
89
+ })
90
+
91
+ intake
92
+ .command('answer')
93
+ .description("Write selections into a cluster's answer slots")
94
+ .argument('<slug>', 'Intake folder name, as in toolkit-overview')
95
+ .helpOption('-h, --help', 'Show this help message')
96
+ .option('--cluster <file>', 'Cluster file the items live in')
97
+ .option(
98
+ '--set <item=answer>',
99
+ 'Answer to land on an item, repeatable',
100
+ collectSelection,
101
+ [] as string[],
102
+ )
103
+ .option('--json', 'Emit a machine-readable record on stdout')
104
+ .option('--root <path>', 'Intake root, defaulting to the main worktree')
105
+ .addHelpText(
106
+ 'after',
107
+ [
108
+ '',
109
+ 'Exit codes:',
110
+ ' 0 every named item now carries its answer',
111
+ ' 1 refused, with the reason on stderr or in the JSON record',
112
+ '',
113
+ 'Items are numbered per cluster file, so a selection names the cluster',
114
+ 'and the number together. One call writes one cluster, which is what',
115
+ 'keeps a batch from racing on the file every selection shares.',
116
+ '',
117
+ 'An item already carrying an answer is refused rather than overwritten.',
118
+ 'A filled slot is a decision already made.',
119
+ '',
120
+ 'Examples:',
121
+ ' aitk intake answer toolkit-overview --cluster 05-coverage.md --set 3=ok',
122
+ ' aitk intake answer toolkit-overview --cluster 05-coverage.md --set 3=ok --set 4="not worth it" --json',
123
+ '',
124
+ ].join('\n'),
125
+ )
126
+ .action(async (slug: string, opts: AnswerCommandOptions) => {
127
+ process.exitCode = await runAnswer(slug, opts)
128
+ })
129
+ }
130
+
131
+ function collectSelection(value: string, previous: string[]): string[] {
132
+ return [...previous, value]
133
+ }
134
+
135
+ async function runList(
136
+ slug: string | undefined,
137
+ opts: ListCommandOptions,
138
+ ): Promise<number> {
139
+ const emitJson = opts.json ?? false
140
+ const root = opts.root ?? (await mainWorktreeRoot())
141
+
142
+ if (slug === undefined) {
143
+ return reportList(
144
+ await listFolders(root),
145
+ emitJson,
146
+ root,
147
+ opts.unread ?? false,
148
+ )
149
+ }
150
+
151
+ return reportFolder(
152
+ await readFolder(root, slug),
153
+ emitJson,
154
+ root,
155
+ opts.unread ?? false,
156
+ )
157
+ }
158
+
159
+ async function runAnswer(
160
+ slug: string,
161
+ opts: AnswerCommandOptions,
162
+ ): Promise<number> {
163
+ const emitJson = opts.json ?? false
164
+
165
+ if (!opts.cluster) {
166
+ return reportRefusal(
167
+ 'aitk intake answer',
168
+ {
169
+ ok: false,
170
+ reason: 'bad-input',
171
+ message: 'No cluster named. Pass --cluster <file>.',
172
+ detail: [],
173
+ },
174
+ emitJson,
175
+ process.cwd(),
176
+ )
177
+ }
178
+
179
+ const raw = opts.set ?? []
180
+
181
+ if (raw.length === 0) {
182
+ return reportRefusal(
183
+ 'aitk intake answer',
184
+ {
185
+ ok: false,
186
+ reason: 'bad-input',
187
+ message: 'No selection given. Pass --set <item>=<answer>.',
188
+ detail: [],
189
+ },
190
+ emitJson,
191
+ process.cwd(),
192
+ )
193
+ }
194
+
195
+ const selections: Selection[] = []
196
+ const invalid: string[] = []
197
+
198
+ for (const entry of raw) {
199
+ const match = SELECTION.exec(entry)
200
+ const answer = match?.[2].trim()
201
+
202
+ if (!match || !answer) {
203
+ invalid.push(entry)
204
+ continue
205
+ }
206
+
207
+ selections.push({ label: match[1].toLowerCase(), answer })
208
+ }
209
+
210
+ if (invalid.length > 0) {
211
+ return reportRefusal(
212
+ 'aitk intake answer',
213
+ {
214
+ ok: false,
215
+ reason: 'bad-input',
216
+ message: `Not an item and answer: ${invalid.join(', ')}`,
217
+ detail: invalid,
218
+ },
219
+ emitJson,
220
+ process.cwd(),
221
+ )
222
+ }
223
+
224
+ const duplicated = selections
225
+ .map((selection) => selection.label)
226
+ .filter((label, index, all) => all.indexOf(label) !== index)
227
+
228
+ if (duplicated.length > 0) {
229
+ return reportRefusal(
230
+ 'aitk intake answer',
231
+ {
232
+ ok: false,
233
+ reason: 'bad-input',
234
+ message: `Two answers for item ${[...new Set(duplicated)].join(', ')}.`,
235
+ detail: [],
236
+ },
237
+ emitJson,
238
+ process.cwd(),
239
+ )
240
+ }
241
+
242
+ const root = opts.root ?? (await mainWorktreeRoot())
243
+ const outcome = await answerItems(root, slug, opts.cluster, selections)
244
+
245
+ return reportAnswer(outcome, emitJson, root)
246
+ }
247
+
248
+ function reportRefusal(
249
+ title: string,
250
+ refused: IntakeRefused,
251
+ emitJson: boolean,
252
+ root: string,
253
+ ): number {
254
+ // The framed branch reaches stderr through logError, so the bare write is
255
+ // what keeps the JSON mode from reporting the reason on stdout alone.
256
+ if (emitJson) {
257
+ process.stderr.write(`${refused.message}\n`)
258
+ process.stdout.write(
259
+ `${JSON.stringify({
260
+ ok: false,
261
+ root,
262
+ reason: refused.reason,
263
+ message: refused.message,
264
+ detail: refused.detail,
265
+ })}\n`,
266
+ )
267
+ return 1
268
+ }
269
+
270
+ intro(title)
271
+ logStep('Refused')
272
+ logError(refused.message)
273
+ if (refused.detail.length > 0) pipeOutput(refused.detail.join('\n'))
274
+ outro()
275
+
276
+ return 1
277
+ }
278
+
279
+ function reportList(
280
+ outcome: ListOutcome,
281
+ emitJson: boolean,
282
+ root: string,
283
+ unreadOnly: boolean,
284
+ ): number {
285
+ if (!outcome.ok) {
286
+ return reportRefusal('aitk intake list', outcome, emitJson, root)
287
+ }
288
+
289
+ // Malformed items are counted over every folder rather than the filtered
290
+ // set, since a folder whose only defect is an item nobody can answer carries
291
+ // no unread count to survive the filter and is exactly what the warning is
292
+ // for.
293
+ const listed = unreadOnly
294
+ ? outcome.folders.filter((folder) => folder.unread > 0)
295
+ : outcome.folders
296
+
297
+ if (emitJson) {
298
+ process.stdout.write(
299
+ `${JSON.stringify({ ok: true, root, folders: listed })}\n`,
300
+ )
301
+ return 0
302
+ }
303
+
304
+ intro('aitk intake list')
305
+ logStep(listed.length > 0 ? 'Folders' : 'No folders')
306
+
307
+ for (const folder of listed) {
308
+ logInfo(
309
+ `${folder.slug}: ${folder.items} item(s), ${folder.open} open, ${folder.unread} unread`,
310
+ )
311
+ }
312
+
313
+ const malformed = outcome.folders.filter((folder) => folder.malformed > 0)
314
+
315
+ if (malformed.length > 0) {
316
+ logStep('Carrying no answer slot')
317
+ for (const folder of malformed) {
318
+ logWarn(
319
+ `${folder.slug}: ${folder.malformed} item(s), which none of these verbs can answer`,
320
+ )
321
+ }
322
+ }
323
+
324
+ outro()
325
+
326
+ return 0
327
+ }
328
+
329
+ function reportFolder(
330
+ outcome: ReadOutcome,
331
+ emitJson: boolean,
332
+ root: string,
333
+ unreadOnly: boolean,
334
+ ): number {
335
+ if (!outcome.ok) {
336
+ return reportRefusal('aitk intake list', outcome, emitJson, root)
337
+ }
338
+
339
+ const clusters = outcome.clusters
340
+ .map((cluster) => ({
341
+ cluster: cluster.cluster,
342
+ items: unreadOnly ? cluster.items.filter(isUnread) : cluster.items,
343
+ }))
344
+ .filter((cluster) => cluster.items.length > 0)
345
+
346
+ if (emitJson) {
347
+ process.stdout.write(
348
+ `${JSON.stringify({ ok: true, root, slug: outcome.slug, clusters })}\n`,
349
+ )
350
+ return 0
351
+ }
352
+
353
+ intro('aitk intake list')
354
+
355
+ for (const cluster of clusters) {
356
+ logStep(cluster.cluster)
357
+ for (const item of cluster.items) {
358
+ const state = isUnread(item) ? 'unread' : (item.answer ?? 'no slot')
359
+ logInfo(`${item.label}. ${item.title} (${state})`)
360
+ }
361
+ }
362
+
363
+ if (clusters.length === 0) {
364
+ logStep(unreadOnly ? 'Nothing unread' : 'No items')
365
+ }
366
+
367
+ outro()
368
+
369
+ return 0
370
+ }
371
+
372
+ function reportAnswer(
373
+ outcome: AnswerOutcome,
374
+ emitJson: boolean,
375
+ root: string,
376
+ ): number {
377
+ if (!outcome.ok) {
378
+ return reportRefusal('aitk intake answer', outcome, emitJson, root)
379
+ }
380
+
381
+ if (emitJson) {
382
+ process.stdout.write(
383
+ `${JSON.stringify({
384
+ ok: true,
385
+ root,
386
+ slug: outcome.slug,
387
+ cluster: outcome.cluster,
388
+ path: relative(root, outcome.path),
389
+ answered: outcome.answered,
390
+ })}\n`,
391
+ )
392
+ return 0
393
+ }
394
+
395
+ intro('aitk intake answer')
396
+ logStep('Answered')
397
+
398
+ for (const entry of outcome.answered) {
399
+ logInfo(`${entry.label}. ${entry.answer}`)
400
+ }
401
+
402
+ logAdd(relative(root, outcome.path))
403
+ outro()
404
+
405
+ return 0
406
+ }
@@ -0,0 +1,280 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { join, relative } from 'node:path'
4
+ import {
5
+ INDEX_FILE,
6
+ type IntakeItem,
7
+ isMalformed,
8
+ isUnread,
9
+ readItems,
10
+ writeAnswerLine,
11
+ } from '@/intake/items'
12
+
13
+ export const INTAKE_REFUSALS = [
14
+ 'no-intake',
15
+ 'no-folder',
16
+ 'no-cluster',
17
+ 'no-item',
18
+ 'answered',
19
+ 'bad-input',
20
+ ] as const
21
+
22
+ export type IntakeRefusal = (typeof INTAKE_REFUSALS)[number]
23
+
24
+ export interface IntakeRefused {
25
+ readonly ok: false
26
+ readonly reason: IntakeRefusal
27
+ readonly message: string
28
+ readonly detail: readonly string[]
29
+ }
30
+
31
+ export interface FolderSummary {
32
+ readonly slug: string
33
+ readonly items: number
34
+ readonly open: number
35
+ readonly unread: number
36
+ /** Items carrying no answer slot, which no route here can answer. */
37
+ readonly malformed: number
38
+ }
39
+
40
+ export interface ClusterItems {
41
+ readonly cluster: string
42
+ readonly items: readonly IntakeItem[]
43
+ }
44
+
45
+ export interface FolderListed {
46
+ readonly ok: true
47
+ readonly folders: readonly FolderSummary[]
48
+ }
49
+
50
+ export interface FolderRead {
51
+ readonly ok: true
52
+ readonly slug: string
53
+ readonly clusters: readonly ClusterItems[]
54
+ }
55
+
56
+ export interface AnswerWritten {
57
+ readonly ok: true
58
+ readonly slug: string
59
+ readonly cluster: string
60
+ readonly path: string
61
+ readonly answered: readonly Selection[]
62
+ }
63
+
64
+ export type ListOutcome = FolderListed | IntakeRefused
65
+ export type ReadOutcome = FolderRead | IntakeRefused
66
+ export type AnswerOutcome = AnswerWritten | IntakeRefused
67
+
68
+ export interface Selection {
69
+ readonly label: string
70
+ readonly answer: string
71
+ }
72
+
73
+ function refuse(
74
+ reason: IntakeRefusal,
75
+ message: string,
76
+ detail: readonly string[] = [],
77
+ ): IntakeRefused {
78
+ return { ok: false, reason, message, detail }
79
+ }
80
+
81
+ export function intakeDir(root: string): string {
82
+ return join(root, '.claude', 'intake')
83
+ }
84
+
85
+ /**
86
+ * Cluster files in read order, which is the numbering the folder carries. The
87
+ * index is dropped because it answers nothing, so every remaining file is one a
88
+ * selection can land in.
89
+ */
90
+ export async function listClusters(dir: string): Promise<string[]> {
91
+ const entries = await readdir(dir, { withFileTypes: true })
92
+
93
+ return entries
94
+ .filter(
95
+ (entry) =>
96
+ entry.isFile() &&
97
+ entry.name.endsWith('.md') &&
98
+ entry.name !== INDEX_FILE,
99
+ )
100
+ .map((entry) => entry.name)
101
+ .sort()
102
+ }
103
+
104
+ async function listSlugs(dir: string): Promise<string[]> {
105
+ const entries = await readdir(dir, { withFileTypes: true })
106
+
107
+ return entries
108
+ .filter((entry) => entry.isDirectory())
109
+ .map((entry) => entry.name)
110
+ .sort()
111
+ }
112
+
113
+ async function openFolder(
114
+ root: string,
115
+ slug: string,
116
+ ): Promise<string | IntakeRefused> {
117
+ const dir = intakeDir(root)
118
+
119
+ if (!existsSync(dir)) {
120
+ return refuse('no-intake', `No intake at ${relative(root, dir)}.`)
121
+ }
122
+
123
+ const folder = join(dir, slug)
124
+
125
+ if (!existsSync(folder)) {
126
+ return refuse(
127
+ 'no-folder',
128
+ `No intake folder named ${slug}.`,
129
+ await listSlugs(dir),
130
+ )
131
+ }
132
+
133
+ return folder
134
+ }
135
+
136
+ /** Counts per folder, which is what a session picks a folder to work from. */
137
+ export async function listFolders(root: string): Promise<ListOutcome> {
138
+ const dir = intakeDir(root)
139
+
140
+ if (!existsSync(dir)) {
141
+ return refuse('no-intake', `No intake at ${relative(root, dir)}.`)
142
+ }
143
+
144
+ const slugs = await listSlugs(dir)
145
+
146
+ const folders = await Promise.all(
147
+ slugs.map(async (slug) => {
148
+ const clusters = await readClusters(join(dir, slug))
149
+ const items = clusters.flatMap((cluster) => cluster.items)
150
+
151
+ return {
152
+ slug,
153
+ items: items.length,
154
+ open: items.filter((item) => item.open !== undefined).length,
155
+ unread: items.filter(isUnread).length,
156
+ malformed: items.filter(isMalformed).length,
157
+ }
158
+ }),
159
+ )
160
+
161
+ return { ok: true, folders }
162
+ }
163
+
164
+ async function readClusters(folder: string): Promise<ClusterItems[]> {
165
+ const names = await listClusters(folder)
166
+
167
+ return Promise.all(
168
+ names.map(async (cluster) => ({
169
+ cluster,
170
+ items: readItems(await readFile(join(folder, cluster), 'utf8')),
171
+ })),
172
+ )
173
+ }
174
+
175
+ /** Every item in a folder, grouped by the cluster file that holds it. */
176
+ export async function readFolder(
177
+ root: string,
178
+ slug: string,
179
+ ): Promise<ReadOutcome> {
180
+ const opened = await openFolder(root, slug)
181
+ if (typeof opened !== 'string') return opened
182
+
183
+ return { ok: true, slug, clusters: await readClusters(opened) }
184
+ }
185
+
186
+ /**
187
+ * Lands a batch of selections in one cluster file.
188
+ *
189
+ * The batch is scoped to a cluster and applied in one read-modify-write because
190
+ * the alternative is a call per selection, and four of those against the same
191
+ * file race on the read and drop every answer but the last.
192
+ */
193
+ export async function answerItems(
194
+ root: string,
195
+ slug: string,
196
+ cluster: string,
197
+ selections: readonly Selection[],
198
+ ): Promise<AnswerOutcome> {
199
+ const opened = await openFolder(root, slug)
200
+ if (typeof opened !== 'string') return opened
201
+
202
+ const name = cluster.endsWith('.md') ? cluster : `${cluster}.md`
203
+
204
+ if (name === INDEX_FILE) {
205
+ return refuse(
206
+ 'no-cluster',
207
+ `${INDEX_FILE} is the index and carries no answer slot.`,
208
+ )
209
+ }
210
+
211
+ const path = join(opened, name)
212
+
213
+ if (!existsSync(path)) {
214
+ return refuse(
215
+ 'no-cluster',
216
+ `No cluster named ${name} in ${slug}.`,
217
+ await listClusters(opened),
218
+ )
219
+ }
220
+
221
+ const broken = selections.filter((selection) =>
222
+ /[\r\n]/.test(selection.answer),
223
+ )
224
+
225
+ if (broken.length > 0) {
226
+ return refuse(
227
+ 'bad-input',
228
+ `An answer is one line, so item ${broken.map((entry) => entry.label).join(', ')} cannot carry a line break.`,
229
+ broken.map((entry) => entry.label),
230
+ )
231
+ }
232
+
233
+ let text = await readFile(path, 'utf8')
234
+ const items = readItems(text)
235
+
236
+ const find = (label: string) =>
237
+ items.find((item) => item.label === label.toLowerCase())
238
+
239
+ const missing = selections.filter(
240
+ (selection) => find(selection.label)?.answerLine === undefined,
241
+ )
242
+
243
+ if (missing.length > 0) {
244
+ return refuse(
245
+ 'no-item',
246
+ `${name} carries no answer slot for item ${missing.map((entry) => entry.label).join(', ')}.`,
247
+ items.map((item) => `${item.label}. ${item.title}`),
248
+ )
249
+ }
250
+
251
+ const filled = selections.filter(
252
+ (selection) => find(selection.label)?.answer !== undefined,
253
+ )
254
+
255
+ if (filled.length > 0) {
256
+ return refuse(
257
+ 'answered',
258
+ `${name} item ${filled.map((entry) => entry.label).join(', ')} already carries an answer.`,
259
+ filled.map(
260
+ (entry) => `${entry.label}. ${find(entry.label)?.answer ?? ''}`,
261
+ ),
262
+ )
263
+ }
264
+
265
+ for (const selection of selections) {
266
+ const item = find(selection.label)
267
+ if (item?.answerLine === undefined) continue
268
+ text = writeAnswerLine(text, item.answerLine, selection.answer)
269
+ }
270
+
271
+ await writeFile(path, text)
272
+
273
+ return {
274
+ ok: true,
275
+ slug,
276
+ cluster: name,
277
+ path,
278
+ answered: selections,
279
+ }
280
+ }