@erclx/aitk 0.89.0 → 0.90.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-groundwork/SKILL.md +1 -1
- package/claude/skills/claude-intake/SKILL.md +8 -1
- package/claude/skills/claude-intake-answer/REQUIREMENT.md +48 -0
- package/claude/skills/claude-intake-answer/SKILL.md +90 -0
- package/claude/skills/claude-orchestrate/SKILL.md +5 -3
- package/claude/skills/claude-tasks/SKILL.md +1 -1
- package/claude/skills/claude-worktree/REQUIREMENT.md +8 -0
- package/claude/skills/claude-worktree/SKILL.md +22 -0
- package/claude/skills/toolkit-feedback/SKILL.md +1 -1
- package/claude/skills/youtube-transcripts/REQUIREMENT.md +1 -1
- package/claude/skills/youtube-transcripts/SKILL.md +1 -1
- package/docs/agents/commands.md +3 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/intake.md +77 -0
- package/docs/operating-model.md +4 -2
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/commands/intake.ts +406 -0
- package/src/intake/folder.ts +280 -0
- package/src/intake/items.ts +174 -0
- package/standards/intake.md +9 -0
- package/standards/skill.md +1 -1
- package/tooling/astro/configs/astro.config.mjs +11 -0
- package/tooling/astro/configs/playwright.config.ts +4 -3
- package/tooling/astro/manifest.toml +5 -3
- package/tooling/astro/reference.md +3 -3
- package/tooling/vite-react/configs/playwright.config.ts +4 -3
- package/tooling/vite-react/configs/vite.config.ts +10 -0
- package/tooling/vite-react/manifest.toml +4 -2
- package/tooling/vite-react/reference.md +4 -4
- package/tooling/web/configs/scripts/worktree-port.sh +36 -0
- package/tooling/web/manifest.toml +3 -3
- package/tooling/web/reference.md +12 -0
|
@@ -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
|
+
}
|