@erclx/aitk 0.89.1 → 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-tasks/SKILL.md +1 -1
- 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/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
|
@@ -97,7 +97,7 @@ Do not close while an open question quietly fails an outcome. Resolve it, or rec
|
|
|
97
97
|
|
|
98
98
|
## Output
|
|
99
99
|
|
|
100
|
-
Emit the full relative path from the project root for every file written or updated.
|
|
100
|
+
Emit the full relative path from the project root for every file written or updated. A bare filename names a file the reader cannot open.
|
|
101
101
|
|
|
102
102
|
Open and resume:
|
|
103
103
|
|
|
@@ -101,7 +101,14 @@ A file the pass only read gets no line, which is what keeps the block short.
|
|
|
101
101
|
|
|
102
102
|
**Open questions:** <N> awaiting your call
|
|
103
103
|
|
|
104
|
-
Next: answer the `You:` slots
|
|
104
|
+
Next: /claude-intake-answer to answer the `You:` slots from here, or type them
|
|
105
|
+
into the files, then /claude-tasks to promote what is ready
|
|
105
106
|
```
|
|
106
107
|
|
|
107
108
|
Use `📂 Resumed` in place of `📂 Opened` on a resume pass.
|
|
109
|
+
|
|
110
|
+
## Answering what this pass wrote
|
|
111
|
+
|
|
112
|
+
The slots this pass leaves empty are answered by editing each cluster file, or from chat through `claude-intake-answer`, which walks the unread items in batches and writes each selection back onto the item it answers. Name that route in the closing line so the operator finds it where they look for it.
|
|
113
|
+
|
|
114
|
+
Do not invoke it from here. It is operator-triggered, and a pass that files a dump and answers it in the same run decides items on silence.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: claude-intake-answer
|
|
3
|
+
description: Scope boundary for answering a filed intake from chat, and the write-back contract that keeps the file rather than the conversation as the record
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Claude intake answer requirement
|
|
7
|
+
|
|
8
|
+
## Gap
|
|
9
|
+
|
|
10
|
+
Without this skill, an operator answers a filed intake by opening each cluster file and typing under each item, so a dump spanning six clusters is six files navigated in turn and the cost scales with how well the pass split the domains. The folders that go unanswered are the large ones, which are the ones a breadth pass exists to produce.
|
|
11
|
+
|
|
12
|
+
A session asked to help with that fails in shapes the intake standard already names. It reads an empty slot as agreement and reports items decided that nobody reached. It writes an answer into the conversation and never into the file, so the record still reads as unread and the next session re-asks. It types the answer into `00-overview.md`, where retrieval walks item headings and finds nothing. It overwrites a slot the operator already filled, discarding a decision already made.
|
|
13
|
+
|
|
14
|
+
The write itself fails a fourth way that no prose rule prevents. Every worker runs in a linked worktree where the file-editing tools refuse a main-root path, and a stream editor expands an unescaped ampersand in the replacement to the whole match and exits zero when it matches nothing. A body instructing either route reports success and loses the answer.
|
|
15
|
+
|
|
16
|
+
The batching fails a fifth way. A folder holding thirty unread items put as thirty questions is unanswerable, and put as one flat batch it hides which cluster the operator is in and leaves a file half answered when attention runs out.
|
|
17
|
+
|
|
18
|
+
## Must
|
|
19
|
+
|
|
20
|
+
- Offer every item carrying an empty answer slot, not only the ones carrying an open question, since the standard's one-token accept exists for a verdict with no question attached
|
|
21
|
+
- Batch by cluster in the folder's own numbering, so the operator has a stopping point that leaves whole clusters unread rather than a file half answered
|
|
22
|
+
- Cap a batch at four items, which is what a structured question tool takes
|
|
23
|
+
- Rank the item's own suggestion or verdict first and give every option what it costs, since the pass already made a recommendation and burying it asks the operator to re-derive it
|
|
24
|
+
- Land every selection through a verb that resolves the root in-process and rewrites the whole line, which is the only route that works from a linked worktree
|
|
25
|
+
- Write one cluster per call, since concurrent calls against one file race on the read and keep only the last answer
|
|
26
|
+
- Carry the cluster and the label together, because items are labeled per file and a label alone names an item in every cluster at once
|
|
27
|
+
- Pass a label exactly as its heading spells it, letter suffix included, since a pass that splits a finding after the fact labels the halves rather than renumbering the file
|
|
28
|
+
- Report what stays unread by count after each cluster
|
|
29
|
+
|
|
30
|
+
## Must not
|
|
31
|
+
|
|
32
|
+
- Fill a slot the operator did not answer, including the remainder of a batch they abandoned
|
|
33
|
+
- Infer an answer from the conversation having happened, which is the inversion the intake standard states against the plan standard
|
|
34
|
+
- Overwrite a slot already carrying an answer, which is a decision already made
|
|
35
|
+
- Write anywhere in the folder but an item's answer slot, and never into the index, which carries none
|
|
36
|
+
|
|
37
|
+
## Guards
|
|
38
|
+
|
|
39
|
+
- No intake folder at all, stop and name the skill that files one
|
|
40
|
+
- The named folder carrying no unread item, stop rather than re-asking answered items
|
|
41
|
+
|
|
42
|
+
## Out of scope
|
|
43
|
+
|
|
44
|
+
- Filing a dump and writing the items, which is `claude-intake` and owns every other write into the folder
|
|
45
|
+
- Promoting an answered item onto the board, which is `claude-tasks` and runs after the answers land
|
|
46
|
+
- The item format, the answer contract, and retrieval, which `standards/intake.md` owns and this skill cites
|
|
47
|
+
- The comparable answer slots in groundwork and feature plans, which carry their own contracts and are a separate measurement
|
|
48
|
+
- Deciding when to fire. The skill is user-invoked through `disable-model-invocation`, so answering is the operator's call rather than a description match.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: claude-intake-answer
|
|
3
|
+
description: Walks an intake folder's unread items and puts them as batched questions in chat, writing each selection back onto the item it answers. Use when asked to "answer the intake", "work through the open items", "answer my intake questions", "go through the dump", or "let me answer these from here". Do NOT use to file a dump or write new items, which is `claude-intake`.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Claude intake answer
|
|
8
|
+
|
|
9
|
+
Put an intake folder's unread items as batched questions, then land each selection in the slot it answers.
|
|
10
|
+
|
|
11
|
+
A folder is answered by opening each cluster file and typing under the item, which on a dump spanning six clusters means navigating to each in turn. This walks the same items in chat and writes the answers back, so the file rather than the conversation stays the record.
|
|
12
|
+
|
|
13
|
+
Read `.claude/standards/intake.md` from the project root before writing anything, or `${CLAUDE_SKILL_DIR}/../../standards/intake.md` when the project does not have it. It holds the item format and the answer contract this skill is bound by.
|
|
14
|
+
|
|
15
|
+
## Guards
|
|
16
|
+
|
|
17
|
+
- If `aitk intake list --json` reports no folder, stop: `❌ No intake to answer. Run /claude-intake to file a dump first.`
|
|
18
|
+
- If the named folder has no unread item, stop: `❌ Every item in <slug> carries an answer. Nothing to ask.`
|
|
19
|
+
- Never fill a slot the operator did not answer. An abandoned batch leaves every unreached item unread, which is what the empty slot already means.
|
|
20
|
+
- Never infer an answer from the conversation having happened. A selection reaches the file through the verb or not at all.
|
|
21
|
+
- Do not promote an item, edit a verdict, or write outside the answer slots. Promoting runs through `claude-tasks` after the answers land.
|
|
22
|
+
|
|
23
|
+
## Step 1: pick the folder
|
|
24
|
+
|
|
25
|
+
Run `aitk intake list --json` and read the per-folder counts. With one folder carrying unread items, take it. With several, put the folders as one question carrying each slug and its unread count, and let the operator pick.
|
|
26
|
+
|
|
27
|
+
Never guess from the topic of the conversation. A session resuming against the wrong folder answers items nobody asked about.
|
|
28
|
+
|
|
29
|
+
## Step 2: collect one cluster
|
|
30
|
+
|
|
31
|
+
Run `aitk intake list <slug> --unread --json`. It returns the unread items grouped by the cluster file holding them, each carrying its label, title, open question, and suggestion.
|
|
32
|
+
|
|
33
|
+
Work one cluster at a time, in the order the folder numbers them. A dump of six clusters holding five items each is thirty questions, and a surface putting all of them at once is unusable. The cluster boundary is also where an operator who runs out of attention stops cleanly, leaving whole clusters unread rather than one file half answered.
|
|
34
|
+
|
|
35
|
+
## Step 3: put the batch
|
|
36
|
+
|
|
37
|
+
Cap each batch at four items, which is what a structured question tool takes. A cluster carrying more than four unread items takes several batches in file order.
|
|
38
|
+
|
|
39
|
+
Every item with an empty slot is offered, not only the ones carrying an open question. An item with a verdict and no question is still asking whether the verdict stands, and the standard's one-token accept exists for exactly that case.
|
|
40
|
+
|
|
41
|
+
Shape each question from what the item carries:
|
|
42
|
+
|
|
43
|
+
- The item's label and title name the question, so the operator knows which item is being asked about
|
|
44
|
+
- An item carrying `Open:` puts that question with its `Suggested:` line ranked first and marked as the suggestion
|
|
45
|
+
- An item carrying no `Open:` puts its verdict, with accepting it ranked first as `ok` and the alternatives drawn from what the verdict could otherwise be
|
|
46
|
+
|
|
47
|
+
Give every option what it means and what it costs. An option with no stated cost is not an option, since the operator picks it without knowing what the other one buys.
|
|
48
|
+
|
|
49
|
+
Send the whole batch in one turn. When the session runs on a surface carrying a structured question tool, such as `AskUserQuestion` in Claude Code, send it through one call with one entry per item. Otherwise write it as a numbered list in one message with the suggestion marked.
|
|
50
|
+
|
|
51
|
+
## Step 4: write the batch back
|
|
52
|
+
|
|
53
|
+
Land every answered item in the cluster with one call:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
aitk intake answer <slug> --cluster <file> --set <label>=<answer> --set <label>=<answer>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
One call per cluster, never one per item. Items are labeled per cluster file, so the label alone does not identify an item and the cluster travels with it. Four separate calls against one file also race on the read and drop every answer but the last.
|
|
60
|
+
|
|
61
|
+
Pass the label exactly as the heading spells it, including a letter suffix such as `3a`. Write the operator's selection as the answer, using `ok` for a verdict accepted as it stands.
|
|
62
|
+
|
|
63
|
+
The verb refuses an item that already carries an answer rather than overwriting it, and refuses the whole batch when any item in it is filled. A filled slot is a decision already made. On that refusal, drop the named item from the batch and send the rest.
|
|
64
|
+
|
|
65
|
+
An item the operator left unanswered is omitted from the call entirely. Do not pass it with an empty value, which writes a slot that reads as answered.
|
|
66
|
+
|
|
67
|
+
## Step 5: continue or stop
|
|
68
|
+
|
|
69
|
+
After each cluster, state how many clusters still carry unread items and continue to the next. Stop when the operator says to stop, and report what is left rather than pressing on.
|
|
70
|
+
|
|
71
|
+
Answers are on disk as each cluster completes, so a session ending mid-folder loses nothing.
|
|
72
|
+
|
|
73
|
+
## Output
|
|
74
|
+
|
|
75
|
+
Report per cluster as it lands, then close with the folder's state:
|
|
76
|
+
|
|
77
|
+
```plaintext
|
|
78
|
+
✅ <N> answered in <file>
|
|
79
|
+
<label>. <answer>
|
|
80
|
+
|
|
81
|
+
📋 <N> cluster(s) still carrying unread items in <slug>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Close the run with the path so the reader can open what changed:
|
|
85
|
+
|
|
86
|
+
```plaintext
|
|
87
|
+
✅ <N> answered across <N> cluster(s) in <slug>
|
|
88
|
+
<M> item(s) left unread
|
|
89
|
+
.claude/intake/<slug>/
|
|
90
|
+
```
|
|
@@ -97,7 +97,7 @@ The command drops the task's row from `.claude/tasks/priority.md` and leaves pro
|
|
|
97
97
|
|
|
98
98
|
## Output
|
|
99
99
|
|
|
100
|
-
Emit the full relative path from the project root for every file written or moved.
|
|
100
|
+
Emit the full relative path from the project root for every file written or moved. A bare filename names a file the reader cannot open.
|
|
101
101
|
|
|
102
102
|
Create:
|
|
103
103
|
|
|
@@ -58,7 +58,7 @@ cat <<'EOF' | aitk feedback
|
|
|
58
58
|
EOF
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
Report the printed path back to the user on its own line
|
|
61
|
+
Report the printed path back to the user on its own line, in the form the project's instruction file sets under `## Output`.
|
|
62
62
|
|
|
63
63
|
For a durable, cross-machine report instead of local scratch, add `--github`. The CLI opens a GitHub issue on the toolkit repo and prints the issue URL. It needs `gh` authenticated, and falls back to local scratch with a warning when `gh` is absent.
|
|
64
64
|
|
|
@@ -16,7 +16,7 @@ Two smaller failures follow the fetch. A video with no captions produces a file
|
|
|
16
16
|
## Must
|
|
17
17
|
|
|
18
18
|
- Run the CLI, which owns the fetch, the caption cleanup, and the frontmatter
|
|
19
|
-
- Surface the written path as a full relative path
|
|
19
|
+
- Surface the written path as a full relative path, in the form the project's instruction file sets under `## Output`
|
|
20
20
|
- Report a video with no captions as a fact about the video, since the file still exists and carries its metadata
|
|
21
21
|
- Name the missing external binary and stop, leaving the install to the user
|
|
22
22
|
|
|
@@ -26,7 +26,7 @@ aitk transcripts <url>
|
|
|
26
26
|
|
|
27
27
|
- Pass `--keep-timestamps` when the user wants `[mm:ss]` markers per line instead of prose.
|
|
28
28
|
- Pass `--out <dir>` to override the output directory. The default is `transcripts/` in the current directory.
|
|
29
|
-
- The written file path prints to stdout. Surface it back to the user as a full relative path
|
|
29
|
+
- The written file path prints to stdout. Surface it back to the user as a full relative path, in the form the project's instruction file sets under `## Output`.
|
|
30
30
|
|
|
31
31
|
## After the fetch
|
|
32
32
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -30,6 +30,8 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
30
30
|
| `aitk tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
|
|
31
31
|
| `aitk tasks outcome` | Mark outcomes `[x]` on a task by position, repeating `--close` (`--json`) |
|
|
32
32
|
| `aitk tasks validate` | Report board rows whose plan, task file, group, or file set does not hold (`--json`) |
|
|
33
|
+
| `aitk intake list` | Report intake folder counts, or one folder's items, keeping what is unread with `--unread` (`--json`) |
|
|
34
|
+
| `aitk intake answer` | Write selections into one cluster's answer slots, repeating `--set <item>=<answer>` (`--json`) |
|
|
33
35
|
| `aitk records validate` | Report a session record against the standard governing it, per kind (`--json`) |
|
|
34
36
|
| `aitk records push` | Commit the eight backed record folders and push them to a private records remote (`--json`) |
|
|
35
37
|
| `aitk records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
|
|
@@ -54,6 +56,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
54
56
|
| `design` | `render` |
|
|
55
57
|
| `slides` | `render`, `list` |
|
|
56
58
|
| `tasks` | `archive`, `validate` |
|
|
59
|
+
| `intake` | `list`, `answer` |
|
|
57
60
|
| `comments` | `scan` |
|
|
58
61
|
| `context` | `audit` |
|
|
59
62
|
| `markdown` | `audit` |
|
package/docs/agents/index.md
CHANGED
|
@@ -16,6 +16,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
16
16
|
- [Docs](docs.md): How aitk docs resolves the toolkit's own reference surface from an install root, and how a split domain is named
|
|
17
17
|
- [Indexes](indexes.md): Flags, exit codes, and JSON shape for aitk indexes regen, plus when it auto-stages what it rewrote
|
|
18
18
|
- [Install and sync](install-and-sync.md): What each install and sync verb writes, refuses, or leaves alone, and how drift is attributed in a target project
|
|
19
|
+
- [Intake](intake.md): Reading intake folder counts and items, the three read states an item can be in, landing a batch of selections in one cluster, the refusal reasons, and why a call is scoped to one file
|
|
19
20
|
- [Markdown audit](markdown-audit.md): Running the audit over any markdown path, where its bans and checkpoints are read from, what each check reports, and why the ban half gates while the structural half reports
|
|
20
21
|
- [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
|
|
21
22
|
- [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Intake
|
|
3
|
+
description: Reading intake folder counts and items, the three read states an item can be in, landing a batch of selections in one cluster, the refusal reasons, and why a call is scoped to one file
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Intake
|
|
7
|
+
|
|
8
|
+
## List
|
|
9
|
+
|
|
10
|
+
`aitk intake list` reports the intake folders under `.claude/intake/`, or the items one folder holds. It reads and never writes, because an answer belongs to the operator and a verb that filled one would decide what the folder exists to ask.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
aitk intake list
|
|
14
|
+
aitk intake list toolkit-overview --unread --json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Option | Behavior |
|
|
18
|
+
| --------------- | ------------------------------------------------------------------------- |
|
|
19
|
+
| `--unread` | Keep only what is unread: folders carrying one, or a folder's empty slots |
|
|
20
|
+
| `--json` | Emit a machine-readable record on stdout |
|
|
21
|
+
| `--root <path>` | Intake root, defaulting to the main worktree |
|
|
22
|
+
|
|
23
|
+
With no slug it reports per-folder counts. With one it reports every item grouped by the cluster file holding it, each carrying its label, title, open question, suggestion, and answer.
|
|
24
|
+
|
|
25
|
+
An item sits in one of three read states, and the counts keep them apart:
|
|
26
|
+
|
|
27
|
+
| State | What it means |
|
|
28
|
+
| --------- | ------------------------------------------------------- |
|
|
29
|
+
| unread | The slot is present and empty, so nobody has reached it |
|
|
30
|
+
| answered | The slot carries text, which is a decision already made |
|
|
31
|
+
| malformed | The item carries no slot, so no verb here can answer it |
|
|
32
|
+
|
|
33
|
+
The third is counted apart from both rather than folded into either. Counted as answered it hides a file that needs fixing behind a folder reading as fully worked through, and counted as unread it joins a list whose every entry `answer` then refuses.
|
|
34
|
+
|
|
35
|
+
The index is skipped, since it points at items and answers nothing itself. So is any item a file displays inside a fence, which is how a folder copying the item format into its own overview is read as the sample it is rather than as an item offering a slot no reader owns.
|
|
36
|
+
|
|
37
|
+
## Answer
|
|
38
|
+
|
|
39
|
+
`aitk intake answer` writes selections into the answer slots of one cluster file.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
aitk intake answer toolkit-overview --cluster 05-coverage.md --set 3=ok
|
|
43
|
+
aitk intake answer toolkit-overview --cluster 11-intake-skill.md --set 3d=ok --set 9="not worth it" --json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| Option | Behavior |
|
|
47
|
+
| --------------------- | ----------------------------------------------------- |
|
|
48
|
+
| `--cluster <file>` | Cluster file the items live in, with or without `.md` |
|
|
49
|
+
| `--set <item=answer>` | Answer to land on an item, repeatable |
|
|
50
|
+
| `--json` | Emit a machine-readable record on stdout |
|
|
51
|
+
| `--root <path>` | Intake root, defaulting to the main worktree |
|
|
52
|
+
|
|
53
|
+
Items are labeled per cluster file, so a selection names the cluster and the label together. A label alone names an item in every cluster at once.
|
|
54
|
+
|
|
55
|
+
A label may carry a letter suffix, as in `3a` beside `3`, which is how a pass records a finding split after the fact rather than renumbering every item below it. Pass it exactly as the heading spells it.
|
|
56
|
+
|
|
57
|
+
One call writes one cluster. A call per selection is the alternative, and several of those against the same file read it before any of them writes, so every answer but the last is lost with nothing reporting it. The selections split on the first `=`, so an answer carrying its own survives intact.
|
|
58
|
+
|
|
59
|
+
An item already carrying an answer refuses rather than being overwritten, and one filled item refuses the whole batch, so a partly applied write never lands. Drop the named item and send the rest.
|
|
60
|
+
|
|
61
|
+
Exit codes: `0` every named item now carries its answer, `1` refused. The `reason` field carries `no-intake`, `no-folder`, `no-cluster`, `no-item`, `answered`, or `bad-input`.
|
|
62
|
+
|
|
63
|
+
`bad-input` covers a malformed command line: no cluster, no selection, a selection that parses to no label and answer, an empty answer, an answer carrying a line break, or two answers for one item. It is separate from the reasons describing the folder, so a caller that mistyped a flag is not sent to repair a file that is fine.
|
|
64
|
+
|
|
65
|
+
An empty answer refuses rather than writing an empty slot. The slot means unread while it is empty, so writing one back would report an item as answered that nobody decided.
|
|
66
|
+
|
|
67
|
+
An answer occupies one line, and one carrying a line break refuses before anything is read. Writing it splices a bare continuation into the item that matches none of the patterns the reader tests, so the slot reads back as the text before the break while the item counts as answered, and the refusal on an already-answered item then leaves hand-editing the file as the only correction. The whole batch refuses, so a good selection beside a broken one never lands half applied.
|
|
68
|
+
|
|
69
|
+
The folder is shared scratch at the main worktree root, so `--root` defaults to the first entry of `git worktree list` rather than the working directory. The write is an edit inside a file that already exists, which `Edit` and `Write` refuse from a linked worktree and a shell stream editor may not do, so the verb resolves the root in-process and rewrites whole lines.
|
|
70
|
+
|
|
71
|
+
Skills branch on the reason rather than on the exit code:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
aitk intake list toolkit-overview --unread --json | jq -r '.clusters[] | .cluster'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For the folder layout, the item format, the answer contract, and retrieval, see `.claude/standards/intake.md`.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { register as capture } from '@/commands/capture'
|
|
|
20
20
|
import { register as feedback } from '@/commands/feedback'
|
|
21
21
|
import { register as transcripts } from '@/commands/transcripts'
|
|
22
22
|
import { register as tasks } from '@/commands/tasks'
|
|
23
|
+
import { register as intake } from '@/commands/intake'
|
|
23
24
|
import { register as comments } from '@/commands/comments'
|
|
24
25
|
import { register as context } from '@/commands/context'
|
|
25
26
|
import { register as markdown } from '@/commands/markdown'
|
|
@@ -53,6 +54,7 @@ function showHelp(): void {
|
|
|
53
54
|
`${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .claude/review/${NC}`,
|
|
54
55
|
`${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
|
|
55
56
|
`${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
|
|
57
|
+
`${GREY}│${NC} intake [cmd] ${GREY}# Intake folders under .claude/intake/ (list, answer)${NC}`,
|
|
56
58
|
`${GREY}│${NC} comments [cmd] ${GREY}# Measure comment density and trend (scan)${NC}`,
|
|
57
59
|
`${GREY}│${NC} context [cmd] ${GREY}# Report context folder health (audit)${NC}`,
|
|
58
60
|
`${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
|
|
@@ -87,6 +89,7 @@ function showHelp(): void {
|
|
|
87
89
|
`${GREY}│${NC} pbpaste | aitk feedback`,
|
|
88
90
|
`${GREY}│${NC} aitk transcripts https://youtu.be/VIDEO_ID`,
|
|
89
91
|
`${GREY}│${NC} aitk tasks archive --pull-request 673 --json`,
|
|
92
|
+
`${GREY}│${NC} aitk intake list toolkit-overview --unread --json`,
|
|
90
93
|
`${GREY}│${NC} aitk comments scan src --json`,
|
|
91
94
|
`${GREY}│${NC} aitk context audit --json`,
|
|
92
95
|
`${GREY}│${NC} aitk markdown audit .claude/rules --json`,
|
|
@@ -143,6 +146,7 @@ capture(program)
|
|
|
143
146
|
feedback(program)
|
|
144
147
|
transcripts(program)
|
|
145
148
|
tasks(program)
|
|
149
|
+
intake(program)
|
|
146
150
|
comments(program)
|
|
147
151
|
context(program)
|
|
148
152
|
markdown(program)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { bodyLines } from '@/markdown/scan'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The index points at items and answers nothing itself, so it carries no
|
|
5
|
+
* answer slot for this verb to reach. It also displays the item format inside a
|
|
6
|
+
* fence, which the fence walk already masks, so skipping it by name is about
|
|
7
|
+
* what the file is rather than about the block it holds.
|
|
8
|
+
*/
|
|
9
|
+
export const INDEX_FILE = '00-overview.md'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* An item heading, labeled per cluster file rather than per folder.
|
|
13
|
+
*
|
|
14
|
+
* The label carries an optional letter suffix because a pass that splits one
|
|
15
|
+
* finding after the fact numbers the halves `3a` and `3b` rather than
|
|
16
|
+
* renumbering every item below them. A pattern accepting digits alone parses
|
|
17
|
+
* such a file without complaint and drops those items, leaving them
|
|
18
|
+
* unanswerable through this verb with nothing reporting the gap.
|
|
19
|
+
*/
|
|
20
|
+
const HEADING = /^###\s+(\d+[a-z]*)\.\s*(.*)$/i
|
|
21
|
+
|
|
22
|
+
/** A bolded field bullet, which is every line an item carries. */
|
|
23
|
+
const FIELD = /^-\s+\*\*([^*]+):\*\*\s*(.*)$/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Any heading, which ends the item above it.
|
|
27
|
+
*
|
|
28
|
+
* A numbered `###` is tested first and opens the next item, so what reaches
|
|
29
|
+
* here is a heading that is not an item and therefore closes the one open. The
|
|
30
|
+
* narrower test for `##` alone let a `### Notes` block stay inside the item
|
|
31
|
+
* above it, and the stray slot such a block carries then displaced the real
|
|
32
|
+
* one, leaving the item reading as answered while its own slot sat empty and
|
|
33
|
+
* sending a write into the wrong section.
|
|
34
|
+
*/
|
|
35
|
+
const SECTION = /^#{1,6}\s/
|
|
36
|
+
|
|
37
|
+
export interface IntakeItem {
|
|
38
|
+
/** The label as the heading spells it, such as `3` or `3a`. */
|
|
39
|
+
readonly label: string
|
|
40
|
+
readonly title: string
|
|
41
|
+
/** Line the heading sits on, 1-based against the whole file. */
|
|
42
|
+
readonly line: number
|
|
43
|
+
/** Line the answer slot sits on, absent when the item carries no slot. */
|
|
44
|
+
readonly answerLine: number | undefined
|
|
45
|
+
/** Text in the slot, absent when the slot is empty and the item is unread. */
|
|
46
|
+
readonly answer: string | undefined
|
|
47
|
+
readonly open: string | undefined
|
|
48
|
+
readonly suggested: string | undefined
|
|
49
|
+
readonly worth: string | undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Draft {
|
|
53
|
+
label: string
|
|
54
|
+
title: string
|
|
55
|
+
line: number
|
|
56
|
+
answerLine: number | undefined
|
|
57
|
+
answer: string | undefined
|
|
58
|
+
open: string | undefined
|
|
59
|
+
suggested: string | undefined
|
|
60
|
+
worth: string | undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function seal(draft: Draft): IntakeItem {
|
|
64
|
+
return { ...draft }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reads every item a cluster file holds, in file order.
|
|
69
|
+
*
|
|
70
|
+
* The walk runs over `bodyLines` rather than a raw split so the item format
|
|
71
|
+
* block a folder copies into its own files parses as the sample it is. A
|
|
72
|
+
* heading counted out of a fence shifts nothing on its own, but it offers an
|
|
73
|
+
* answer slot no reader owns and the write-back would land inside the sample.
|
|
74
|
+
*/
|
|
75
|
+
export function readItems(text: string): IntakeItem[] {
|
|
76
|
+
const items: IntakeItem[] = []
|
|
77
|
+
let draft: Draft | undefined
|
|
78
|
+
|
|
79
|
+
for (const line of bodyLines(text)) {
|
|
80
|
+
if (line.fenced) continue
|
|
81
|
+
|
|
82
|
+
const heading = HEADING.exec(line.text)
|
|
83
|
+
|
|
84
|
+
if (heading) {
|
|
85
|
+
if (draft) items.push(seal(draft))
|
|
86
|
+
draft = {
|
|
87
|
+
label: heading[1].toLowerCase(),
|
|
88
|
+
title: heading[2].trim(),
|
|
89
|
+
line: line.number,
|
|
90
|
+
answerLine: undefined,
|
|
91
|
+
answer: undefined,
|
|
92
|
+
open: undefined,
|
|
93
|
+
suggested: undefined,
|
|
94
|
+
worth: undefined,
|
|
95
|
+
}
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!draft) continue
|
|
100
|
+
|
|
101
|
+
if (SECTION.test(line.text)) {
|
|
102
|
+
items.push(seal(draft))
|
|
103
|
+
draft = undefined
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const field = FIELD.exec(line.text)
|
|
108
|
+
if (!field) continue
|
|
109
|
+
|
|
110
|
+
const value = field[2].trim()
|
|
111
|
+
|
|
112
|
+
switch (field[1].trim().toLowerCase()) {
|
|
113
|
+
case 'you':
|
|
114
|
+
draft.answerLine = line.number
|
|
115
|
+
draft.answer = value === '' ? undefined : value
|
|
116
|
+
break
|
|
117
|
+
case 'open':
|
|
118
|
+
draft.open = value
|
|
119
|
+
break
|
|
120
|
+
case 'suggested':
|
|
121
|
+
draft.suggested = value
|
|
122
|
+
break
|
|
123
|
+
case 'worth it':
|
|
124
|
+
draft.worth = value
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (draft) items.push(seal(draft))
|
|
130
|
+
|
|
131
|
+
return items
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Puts a selection in one item's slot, leaving every other line as it was.
|
|
136
|
+
*
|
|
137
|
+
* The rewrite replaces the whole line rather than patching inside it, which is
|
|
138
|
+
* the reason this is a verb at all. A stream editor expands an unescaped
|
|
139
|
+
* ampersand in the replacement to the whole match and exits zero on a
|
|
140
|
+
* non-match, so an answer carrying one would rewrite the line it anchored to
|
|
141
|
+
* and a missed slot would report success with the answer lost.
|
|
142
|
+
*
|
|
143
|
+
* The answer occupies one line, and the caller owes that guarantee. A line
|
|
144
|
+
* break splices a bare continuation into the item matching none of the patterns
|
|
145
|
+
* the reader tests, so the slot reads back as the text before the break while
|
|
146
|
+
* the item counts as answered, which puts correcting it behind the refusal on
|
|
147
|
+
* an item that already carries one.
|
|
148
|
+
*/
|
|
149
|
+
export function writeAnswerLine(
|
|
150
|
+
text: string,
|
|
151
|
+
answerLine: number,
|
|
152
|
+
answer: string,
|
|
153
|
+
): string {
|
|
154
|
+
const lines = text.split('\n')
|
|
155
|
+
lines[answerLine - 1] = `- **You:** ${answer}`
|
|
156
|
+
return lines.join('\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** An item nobody has reached, which is an empty slot rather than a missing one. */
|
|
160
|
+
export function isUnread(item: IntakeItem): boolean {
|
|
161
|
+
return item.answerLine !== undefined && item.answer === undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* An item carrying no slot at all, which the format says ships on every one.
|
|
166
|
+
*
|
|
167
|
+
* Such an item is neither unread nor answered, and counting it as either hides
|
|
168
|
+
* it: as answered it drops out of the work a reader is told remains, and as
|
|
169
|
+
* unread it joins a list whose every entry the answer verb then refuses. It is
|
|
170
|
+
* reported on its own so the file gets fixed.
|
|
171
|
+
*/
|
|
172
|
+
export function isMalformed(item: IntakeItem): boolean {
|
|
173
|
+
return item.answerLine === undefined
|
|
174
|
+
}
|
package/standards/intake.md
CHANGED
|
@@ -89,6 +89,7 @@ Where an item touches a task already on the board, say so in the index rather th
|
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
- `Problem:`, `Fix:`, `Worth it:`, and the empty `You:` slot ship on every item. The other two are conditional.
|
|
92
|
+
- Number items per cluster file, and give a finding split after the fact a letter suffix on the number it came from, as in `3a` beside `3`. Renumbering the items below it instead moves every label a reader or an answer already cited.
|
|
92
93
|
- `Suggested:` is required whenever `Open:` is present. A bare question invites a bare answer, and `ok` against two defensible options carries no information. Where the answer is the operator's preference rather than a technical call, say so in that form rather than inventing a default.
|
|
93
94
|
- `Overlaps:` never replaces `Worth it:`. The items where a live board task might be the thing that is wrong are exactly the ones whose verdict matters most.
|
|
94
95
|
|
|
@@ -106,10 +107,18 @@ That inverts the plan file's contract, where a blank answer slot means accept th
|
|
|
106
107
|
|
|
107
108
|
Never fill a `You:` slot, and never infer a disposition from an empty one. On a resume pass, report unread items by count rather than deciding them.
|
|
108
109
|
|
|
110
|
+
A slot is filled two ways. The operator types into the cluster file, or answers in chat and a verb lands the selection on the item. Both put the answer on the item, which is what keeps retrieval working, and neither lets a session decide one. An answer given in conversation and never written back leaves the item unread, since the file rather than the conversation is the record.
|
|
111
|
+
|
|
112
|
+
An item already carrying an answer is refused rather than overwritten, whichever route the second answer arrives by. A filled slot is a decision already made, and revising one is the operator editing their own line.
|
|
113
|
+
|
|
109
114
|
## Retrieval
|
|
110
115
|
|
|
111
116
|
Answers live on items, so one pass over the folder reports every touched slot.
|
|
112
117
|
|
|
118
|
+
A session with the toolkit CLI on PATH reads the folder through `aitk intake list`, which reports per-folder counts bare and one folder's items with `--json`, and takes `--unread` to keep only the empty slots. It is the surface under test, and it skips the index and every fenced sample, which the greps below cannot do.
|
|
119
|
+
|
|
120
|
+
The greps stay for a reader without the CLI, and they overcount by whatever the folder displays in a fence.
|
|
121
|
+
|
|
113
122
|
```bash
|
|
114
123
|
awk '/^### /{h=FILENAME": "$0} /^- \*\*You:\*\*./{print h; print " "$0}' *.md
|
|
115
124
|
```
|
package/standards/skill.md
CHANGED
|
@@ -208,7 +208,7 @@ Without this skill, a session <observed failure>, <observed failure>.
|
|
|
208
208
|
|
|
209
209
|
### Output and tuning
|
|
210
210
|
|
|
211
|
-
- Skill success lines emit the full relative path from the project root (`<dir>/<file>`) for any file written, updated, or deleted.
|
|
211
|
+
- Skill success lines emit the full relative path from the project root (`<dir>/<file>`) for any file written, updated, or deleted. A bare filename names a file the reader cannot open. The `## Output` section of the project's instruction file sets the form that path takes, so a skill body states which path is emitted and leaves the form to that section.
|
|
212
212
|
- Codify a skill's posted or generated output as a fenced template, and keep the body consistent with every capability the frontmatter description names.
|
|
213
213
|
- When a skill gathers user input or pre-seeds a template, attach a concrete proposed default to every question, derived from project context. Accept "use defaults" as a bulk-confirm.
|
|
214
214
|
- Separate correctness axes (routing, sourcing, escalation, decline) from shape axes (line count, formatting, variant sprawl) when tuning a skill. Tighten only on correctness regressions. Do not convert soft caps to hard caps for aesthetic drift when correctness passes.
|