superwiki 0.1.4 → 0.1.6
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-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +57 -7
- package/commands/doctor.md +5 -0
- package/package.json +1 -1
- package/skills/sw-doctor/SKILL.md +64 -0
- package/skills/sw-implement/SKILL.md +8 -4
- package/skills/sw-init/assets/agents-block.md +6 -0
- package/skills/sw-init/assets/sw.mjs +477 -153
- package/skills/sw-init/scripts/init.mjs +16 -1
- package/skills/sw-lint/SKILL.md +1 -0
- package/skills/sw-plan/SKILL.md +6 -2
- package/skills/sw-stats/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -23,13 +23,13 @@ It is built to be cheap for the agent:
|
|
|
23
23
|
|
|
24
24
|
- **Little to read.** One small index, one file per task, and a script that answers "what is ready?", "what blocks this?" or "is anything broken?" without the agent reading the vault.
|
|
25
25
|
- **Work in clean contexts.** Planning, implementing and reviewing run in subagents, each on the model you choose for it. The main session only keeps the task's status true, so it stays small.
|
|
26
|
-
- **Cost you can see.** `sw-stats` shows what a session used, per agent.
|
|
26
|
+
- **Cost you can see.** `sw-stats` shows what a session used, per agent; `sw-doctor` shows what every session carries before it starts, and what can go.
|
|
27
27
|
|
|
28
28
|
Measured on a real project with 165 tasks, converted from a single markdown index:
|
|
29
29
|
|
|
30
30
|
| | Before | After |
|
|
31
31
|
| --- | --- | --- |
|
|
32
|
-
| Read at the start of every session | 197 KB index |
|
|
32
|
+
| Read at the start of every session | 197 KB index | 7.7 KB index (the 97 open tasks, a line each) + 2.7 KB of rules |
|
|
33
33
|
| Read to start one task | the index, then the task's section | one file, 2 KB at the median |
|
|
34
34
|
| Marking a task done | a status cell, plus a ✅ at every reference to it (median 12 places) | one frontmatter line |
|
|
35
35
|
|
|
@@ -39,7 +39,7 @@ Measured on a real project with 165 tasks, converted from a single markdown inde
|
|
|
39
39
|
|
|
40
40
|
```text
|
|
41
41
|
docs/
|
|
42
|
-
index.md catalog of the wiki, one line per page
|
|
42
|
+
index.md the open tasks, then the catalog of the wiki, one line per page
|
|
43
43
|
log.md append-only history
|
|
44
44
|
raw/ your sources, never modified
|
|
45
45
|
wiki/ pages the agent writes
|
|
@@ -50,6 +50,31 @@ docs/
|
|
|
50
50
|
|
|
51
51
|
`AGENTS.md` gets a short block of rules so the agent maintains the vault in every session, with or without a command.
|
|
52
52
|
|
|
53
|
+
You can follow the work without the viewer: `index.md` opens with the task list, written from the task files. Each open task is a line that links to its file; finished ones are listed by id.
|
|
54
|
+
|
|
55
|
+
```markdown
|
|
56
|
+
## Tasks
|
|
57
|
+
|
|
58
|
+
ready 9 · in progress 1 · blocked 18 · done 20
|
|
59
|
+
|
|
60
|
+
**In progress**
|
|
61
|
+
|
|
62
|
+
- [[B-20]] Portfolio sync · M5
|
|
63
|
+
|
|
64
|
+
**Ready**
|
|
65
|
+
|
|
66
|
+
- [[F-01]] Frontend skeleton and guards · M0
|
|
67
|
+
- [[B-18]] Valuation · M4
|
|
68
|
+
|
|
69
|
+
**Blocked**
|
|
70
|
+
|
|
71
|
+
- [[B-21]] Journal and thesis gates · M5 · waits on B-20
|
|
72
|
+
|
|
73
|
+
**Done (20)** [[B-01]] [[B-02]] [[B-03]] ...
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A task's status still lives only in its own file. The list is a view: the agent rewrites it with `sw.mjs board` whenever a task changes, and `lint` says when it has fallen behind.
|
|
77
|
+
|
|
53
78
|
## Install
|
|
54
79
|
|
|
55
80
|
Requires Node 18 or newer.
|
|
@@ -139,7 +164,7 @@ Agents that load `SKILL.md` folders from `~/.agents/skills` pick the skills up f
|
|
|
139
164
|
|
|
140
165
|
- the skills name Claude Code, Codex and Copilot tools when they dispatch subagents; elsewhere they fall back to doing the work in the main session, and say so;
|
|
141
166
|
- `sw-config` writes agent files only for `claude`, `codex` and `copilot`, so a per-role model cannot be set;
|
|
142
|
-
- `sw-stats`
|
|
167
|
+
- `sw-stats` and `sw-doctor` read the session records of those three tools only.
|
|
143
168
|
|
|
144
169
|
Everything else (the vault, the CLI, the viewer, ingest, lint, explain, triage) depends only on Node and on the agent following the skill text.
|
|
145
170
|
|
|
@@ -157,7 +182,7 @@ npx superwiki@latest install claude # update: same command, newest release
|
|
|
157
182
|
npx superwiki uninstall all
|
|
158
183
|
```
|
|
159
184
|
|
|
160
|
-
After an update, run `sw-init` again in each project: it replaces `docs/.sw/sw.mjs`, the templates and `docs/viewer.html` with the new version and keeps your content.
|
|
185
|
+
After an update, run `sw-init` again in each project: it replaces `docs/.sw/sw.mjs`, the templates and `docs/viewer.html` with the new version and keeps your content. Until then the project keeps the script it was set up with, and a skill that needs a newer command says so.
|
|
161
186
|
|
|
162
187
|
A project's `docs/` folder is plain markdown and keeps working as an Obsidian vault without Superwiki.
|
|
163
188
|
|
|
@@ -175,6 +200,7 @@ A project's `docs/` folder is plain markdown and keeps working as an Obsidian va
|
|
|
175
200
|
| `sw-lint` | structural checks by script, semantic review on request |
|
|
176
201
|
| `sw-visualize` | open the viewer |
|
|
177
202
|
| `sw-stats` | what the current session has cost: tokens, context, steps and tool calls, per agent |
|
|
203
|
+
| `sw-doctor` | what a session carries before any work, and what to remove to make every step cheaper |
|
|
178
204
|
| `sw-config` | the model each tool uses for planning, implementing and reviewing; task areas |
|
|
179
205
|
|
|
180
206
|
### Examples
|
|
@@ -203,6 +229,7 @@ Shown as typed in Claude Code. In Codex, write `$sw-plan` instead of `/sw-plan`.
|
|
|
203
229
|
/sw-lint check links, frontmatter and task dependencies
|
|
204
230
|
/sw-visualize open the task board and the wiki in the browser
|
|
205
231
|
/sw-stats what this session has cost so far, per agent
|
|
232
|
+
/sw-doctor what fills the context before any work, and what can go
|
|
206
233
|
```
|
|
207
234
|
|
|
208
235
|
You do not have to type a command. The rules `sw-init` adds to `AGENTS.md` tell the agent which skill fits, so a plain request should reach the same skill:
|
|
@@ -232,6 +259,24 @@ total 149 - - 20.9M 95% 43k
|
|
|
232
259
|
|
|
233
260
|
`first` and `peak` are the tokens sent with one request. `sent` is that, summed over every step: each step sends the whole context again, which is why a long session in one context is expensive. In Copilot CLI the token columns fill in once the session has closed.
|
|
234
261
|
|
|
262
|
+
### What a session starts with
|
|
263
|
+
|
|
264
|
+
Every agent above began at 59k to 79k tokens before it had read anything, and paid for that on each of its steps. `sw-doctor` shows what that start is made of, from the same session record, and proposes what to switch off for this project. The same session:
|
|
265
|
+
|
|
266
|
+
```text
|
|
267
|
+
context at session start claude fe4cbd6c-8abf-4db6-9755-469dc7321dc7
|
|
268
|
+
first request: 79k tokens
|
|
269
|
+
part size holds
|
|
270
|
+
rule and memory files 30 KB memory/MEMORY.md 18 KB, my-app/AGENTS.md 11 KB, ...
|
|
271
|
+
skill list 29 KB 213: marketing-skills 41, (none) 37, claude-seo 25, claude-ads 23, +11 more
|
|
272
|
+
tool names (loaded on demand) 17 KB 381: claude_ai_higgsfield 116, claude_ai_meta_ads 98, +9 more
|
|
273
|
+
agent list 14 KB 46: claude-seo 18, (none) 12, claude-ads 10, +4 more
|
|
274
|
+
MCP server instructions 8.3 KB claude.ai higgsfield 2.0 KB, notebooklm 2.0 KB, ...
|
|
275
|
+
session-start hooks 3.3 KB
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Here most of the skills, agents and tools came from advertising and SEO plugins that this project never uses. The skill proposes changes and asks before making any; it touches project-local settings only, and never uninstalls a plugin or deletes a memory. Sizes are characters of text: a skill cannot run your agent's own context command (`/context` in Claude Code and Copilot CLI, `/status` in Codex), which shows the same in tokens.
|
|
279
|
+
|
|
235
280
|
### The CLI
|
|
236
281
|
|
|
237
282
|
The skills call a small script that answers questions without the agent reading the vault. You can run it yourself, from the project root:
|
|
@@ -243,8 +288,10 @@ node docs/.sw/sw.mjs check P-15 # can it start or finish, what is op
|
|
|
243
288
|
node docs/.sw/sw.mjs explain P-15 # dependencies, what it unblocks, plan, area guide
|
|
244
289
|
node docs/.sw/sw.mjs search sync timeout # where something is mentioned
|
|
245
290
|
node docs/.sw/sw.mjs next-id P # next free id in an area
|
|
246
|
-
node docs/.sw/sw.mjs
|
|
291
|
+
node docs/.sw/sw.mjs board # rewrite the task list in index.md from the task files
|
|
292
|
+
node docs/.sw/sw.mjs lint # broken links, bad frontmatter, dependency errors, a stale task list
|
|
247
293
|
node docs/.sw/sw.mjs stats # tokens, context and steps of the agent session here
|
|
294
|
+
node docs/.sw/sw.mjs doctor # what that session carried before it read anything
|
|
248
295
|
node docs/.sw/sw.mjs serve --open # the viewer, reading files live
|
|
249
296
|
node docs/.sw/sw.mjs snapshot # or: freeze the vault into docs/viewer.html, no server
|
|
250
297
|
```
|
|
@@ -260,7 +307,10 @@ Edit sources in `src/`:
|
|
|
260
307
|
| File | What it is |
|
|
261
308
|
| --- | --- |
|
|
262
309
|
| `src/core.js` | the vault model, derived task state, lint and search; shared by the CLI and the viewer |
|
|
263
|
-
| `src/
|
|
310
|
+
| `src/board.js` | the task list in `index.md` |
|
|
311
|
+
| `src/sessions.js` | finds the record an agent keeps of a session |
|
|
312
|
+
| `src/stats.js` | reduces a session record to cost per agent |
|
|
313
|
+
| `src/doctor.js` | reduces a session record to what the session started with |
|
|
264
314
|
| `src/cli.js` | the commands |
|
|
265
315
|
| `src/viewer.html` | the viewer |
|
|
266
316
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sw-doctor
|
|
3
|
+
description: Use when the user wants to make agent sessions lighter or cheaper to start, asks what fills the context window before any work (rules, memory, skills, plugins, MCP tools), why a session starts with so many tokens, or invokes sw-doctor or sw:doctor.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# sw-doctor
|
|
7
|
+
|
|
8
|
+
Shows what a session carries before it has read anything, and proposes what to remove. Rule and memory files, the lists of skills, agents and tools, and what plugins and hooks add are sent again with every step of every agent, subagents included, so a smaller start makes every step cheaper.
|
|
9
|
+
|
|
10
|
+
You cannot run the tool's own context command: it is typed by the user. A script reads the same blocks from the record the tool keeps of this session. You read nothing yourself and write no report.
|
|
11
|
+
|
|
12
|
+
Run from the project root:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
node docs/.sw/sw.mjs doctor
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
It reports the session it runs in. `--session <id or its first characters>` picks another session of this project, `--tool claude|codex|copilot` looks only at one tool's sessions.
|
|
19
|
+
|
|
20
|
+
## Steps
|
|
21
|
+
|
|
22
|
+
1. **Show the table as printed**, in a code block. Sizes are characters of text, not tokens.
|
|
23
|
+
2. **Propose changes**, largest saving first, at most five. For each: the part and its size from the table, what to change, and where. Use the table under "What to propose". Propose only what the output supports, and say plainly when a part cannot be made smaller.
|
|
24
|
+
3. **Ask before changing anything**, one proposal at a time. Which plugin or server a project needs is the user's call: say what a group appears to be for and let them decide.
|
|
25
|
+
4. **Apply what the user approved**, within the limits under "Limits".
|
|
26
|
+
5. **Have it measured.** A change takes effect in a new session. Ask the user to open one and run the tool's own command there: `/context` in Claude Code and Copilot CLI, `/status` in Codex. Compare its figures with the ones from before; a setting that changed nothing is reported as such and taken out again.
|
|
27
|
+
|
|
28
|
+
If the user pastes the output of that command, use its token figures in place of the character sizes; the parts are the same.
|
|
29
|
+
|
|
30
|
+
## What to propose
|
|
31
|
+
|
|
32
|
+
| In the table | Propose | What it saves |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `rule and memory files`: one file is most of the part | name the file. A rules file (`AGENTS.md`, `CLAUDE.md`): move sections that are needed only for some work into a document the agent opens on demand, and leave a one-line pointer. A memory index: one line per entry holding one lesson; dates, counts and status belong in the entry's own file | its full size |
|
|
35
|
+
| `agent list`: groups that have nothing to do with this project | switch those plugins off for this project (how: "Limits"). The group label is the plugin's name; `(none)` is the user's own and built-in entries | the lines of those agents |
|
|
36
|
+
| `skill list`: groups that have nothing to do with this project | the same switch. Say what to expect: the tool gives the list a fixed budget and shortens descriptions to fit, so fewer skills usually means fuller descriptions for the rest, not a smaller list. Worth doing so the agent picks skills better; not a token saving | little or nothing |
|
|
37
|
+
| `tool names (loaded on demand)`: servers the project does not use | the user disables those MCP servers for this project. Only names are loaded, so the saving is the size shown, not the size of the tool definitions | the names of those tools |
|
|
38
|
+
| `MCP server instructions` | goes away with the server; no separate change | with the server |
|
|
39
|
+
| `session-start hooks` | name the hook and the plugin it belongs to, if the user can say; it goes away with that plugin | its full size |
|
|
40
|
+
| Codex: `skills instructions` is large | skills are listed from `~/.agents/skills` and from plugins; remove or move the ones this user does not use | not measured |
|
|
41
|
+
| Copilot CLI: `custom instruction` is large | the project's instruction files; same advice as for a rules file | its full size |
|
|
42
|
+
| `first request` is far above what the parts add up to | the rest is the tool's own system prompt and built-in tool definitions. Say so; it cannot be changed from here | nothing |
|
|
43
|
+
|
|
44
|
+
Give a saving as the size in the table. Do not turn it into a token or money figure unless the user pasted token numbers.
|
|
45
|
+
|
|
46
|
+
## Limits
|
|
47
|
+
|
|
48
|
+
- **Project-local settings only.** Never edit the user's global settings, and never uninstall a plugin or remove a server: switching it off for this project is enough and is undone by deleting a line.
|
|
49
|
+
- **Claude Code**:
|
|
50
|
+
- a plugin off: in `.claude/settings.local.json` (personal, normally git-ignored; keep what is already there), `"enabledPlugins": { "<plugin>@<marketplace>": false }`. The full key is in `~/.claude/settings.json` under `enabledPlugins`; read only that key.
|
|
51
|
+
- an MCP server or a claude.ai connector off: the user does it with `/mcp` in the session. Do not edit `~/.claude.json`, and do not set environment variables for it in the project's settings: that was tried and changed nothing.
|
|
52
|
+
- plugins synced from the user's claude.ai account are not in that settings file; they are removed in the account's settings.
|
|
53
|
+
- **Codex and Copilot CLI**: propose, and let the user change their configuration; this skill does not edit it.
|
|
54
|
+
- **Memory and rule files are the user's.** Shorten an index or move a section only when asked, keep a copy of the previous version next to the file, and never delete an entry.
|
|
55
|
+
- If the project's own rules forbid a change, they win.
|
|
56
|
+
|
|
57
|
+
## If it fails
|
|
58
|
+
|
|
59
|
+
| Output | Do |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `unknown command doctor` | the project's `docs/.sw/sw.mjs` is older than this skill; offer to run sw-init, which updates it |
|
|
62
|
+
| `no agent session record found` | say so and ask the user to run the tool's context command and paste the output; work from that |
|
|
63
|
+
| a note that the session has not made a request yet | another session of this project is newer than yours; run again with `--session` and the id of the one you mean |
|
|
64
|
+
| a note that Copilot has not counted its tool definitions | pass it on; the other parts are valid |
|
|
@@ -25,7 +25,10 @@ Run commands from the project root. `<skill-dir>` is the directory this SKILL.md
|
|
|
25
25
|
| `plan: none` | fine for a small task: one area, three "Done when" items or fewer, nothing open in its notes, a few files. For anything larger, recommend sw-plan first and let the user choose |
|
|
26
26
|
| `review: required (...)` | remember it for step 7 |
|
|
27
27
|
|
|
28
|
-
3. **Mark it started** before any work:
|
|
28
|
+
3. **Mark it started** before any work:
|
|
29
|
+
- in the frontmatter of `docs/tasks/<ID>.md`, `status: in-progress` and `started:` today;
|
|
30
|
+
- in `docs/log.md`, a new entry `## [date] task | <ID> started`, in the layout its last entries use;
|
|
31
|
+
- `node docs/.sw/sw.mjs board`, so the task list in `index.md` shows it.
|
|
29
32
|
4. **Checks that need the environment.** If the task has a plan, look for `needs:` in it: `grep -n 'needs:' docs/plans/<ID>-plan.md`. Each hit is a check that starts services or changes data. Ask the user which of them may run; without a yes, none.
|
|
30
33
|
5. **Dispatch the implementer** (how: "Dispatching" below). Its prompt is the task id, the project root if it is not your working directory, and which `needs:` checks it may run. Do not paste the plan into the prompt; it reads the files.
|
|
31
34
|
6. **Judge the report.** Its `Requirements:` list must name every "Done when" item and every scope, state or constraint item of the task; compare it with the task file.
|
|
@@ -36,7 +39,7 @@ Run commands from the project root. `<skill-dir>` is the directory this SKILL.md
|
|
|
36
39
|
- `Verdict: pass`: go on. Pass `important` and `minor` findings to the user in your report; they do not block.
|
|
37
40
|
- `Verdict: changes needed`: dispatch the implementer again with the blocking findings, word for word, then the reviewer again with the files changed since. After two rounds that still end in `changes needed`, stop and put the findings to the user.
|
|
38
41
|
- Do not review the change yourself in place of the reviewer, and do not argue a blocking finding away. If you think a finding is wrong, say so to the user and let them decide.
|
|
39
|
-
8. **Record the outcome
|
|
42
|
+
8. **Record the outcome**, then run `node docs/.sw/sw.mjs board`.
|
|
40
43
|
|
|
41
44
|
| Outcome | Task file | Log entry |
|
|
42
45
|
| --- | --- | --- |
|
|
@@ -50,7 +53,7 @@ Run commands from the project root. `<skill-dir>` is the directory this SKILL.md
|
|
|
50
53
|
10. **File what else was learned.** These are separate offers: act on each only when the user says yes to that one.
|
|
51
54
|
- A report held a decision or constraint the wiki should keep: offer a wiki page (`type: decision` or `concept`), added to `index.md`.
|
|
52
55
|
- The task fixed a problem whose cause is now known, or the review caught a defect worth remembering: offer a `type: lesson` page (Symptom, Cause, Fix, How to notice it earlier); sw-triage finds these later.
|
|
53
|
-
- A report named follow-up work: offer to create the tasks.
|
|
56
|
+
- A report named follow-up work, or the review left `important` findings open: offer to create the tasks. A finding that lives only in the log is forgotten.
|
|
54
57
|
11. **Report** to the user, in this order. Commit only if the user asks.
|
|
55
58
|
- the outcome;
|
|
56
59
|
- each requirement with its evidence, and anything that differs from the task;
|
|
@@ -77,6 +80,7 @@ The same table serves both roles: `sw-implementer` with `implementer.md`, `sw-re
|
|
|
77
80
|
- Accepting a `differs` item on the user's behalf. A sensible alternative is still not what the task asked for.
|
|
78
81
|
- Skipping the review on a task that requires it, or doing it yourself in the same context that judged the implementation.
|
|
79
82
|
- Starting work before the task file says `in-progress`. If the session dies, nobody knows the task was touched.
|
|
80
|
-
- Letting a subagent edit the task file or the
|
|
83
|
+
- Letting a subagent edit the task file, the log or the task list. One writer for status: you.
|
|
84
|
+
- Editing the task list in `index.md` by hand. It is written from the task files; change the task file and run `board`.
|
|
81
85
|
- Reading the plan or the code "to follow along". The subagents already paid for that.
|
|
82
86
|
- Running the next task in the same session out of momentum.
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
`docs/` is a wiki you write and keep current, and an Obsidian vault the user reads.
|
|
5
5
|
|
|
6
|
+
{{#tasks}}
|
|
7
|
+
- `docs/index.md`: the open tasks, then the catalog, one line per wiki page. Read it first, then open only the pages you need.
|
|
8
|
+
{{/tasks}}
|
|
9
|
+
{{^tasks}}
|
|
6
10
|
- `docs/index.md`: catalog, one line per wiki page. Read it first, then open only the pages you need.
|
|
11
|
+
{{/tasks}}
|
|
7
12
|
- `docs/log.md`: append-only. Add `## [YYYY-MM-DD] <kind> | <title>` at the end; read it with `tail`, never whole.
|
|
8
13
|
- `docs/raw/`: sources. Read, never modify.
|
|
9
14
|
- `docs/wiki/`: flat, one page per topic, frontmatter `type:` and one-line `summary:`.
|
|
@@ -27,6 +32,7 @@ Wiki:
|
|
|
27
32
|
Tasks:
|
|
28
33
|
|
|
29
34
|
- A task's status lives only in its frontmatter. Before you start: `status: in-progress` and `started:`. When its "Done when" list is met: `status: done` and `finished:`. Each change of status gets a `task` entry in `log.md`.
|
|
35
|
+
- The task list in `index.md` is written from the task files. After you add a task or change a task's status, title, milestone or dependencies, run `node docs/.sw/sw.mjs board`. Never edit that list by hand.
|
|
30
36
|
- Do not start a task while any of its `deps` is not done.
|
|
31
37
|
- If `explain <ID>` names an area guide (`docs/wiki/guide-<area>.md`), read it before you change code for the task: where things are, patterns, how to verify. Afterwards add the facts it was missing, one line each.
|
|
32
38
|
- Work that belongs to no task (a quick fix, a small request) needs no task file. Append one `change` entry to `log.md` instead: what changed and why, in a line.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Generated by scripts/build.mjs from src/core.js, src/stats.js, src/cli.js. Do not edit.
|
|
2
|
+
// Generated by scripts/build.mjs from src/core.js, src/board.js, src/sessions.js, src/stats.js, src/doctor.js, src/cli.js. Do not edit.
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { basename, dirname, join, resolve as resolvePath, sep } from 'node:path';
|
|
@@ -322,21 +322,87 @@ export function guideFor(vault, area) {
|
|
|
322
322
|
return vault.pages.find(p => p.folder === 'wiki' && p.data.type === 'guide' && key(p.data.area ?? '') === key(area)) || null;
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
325
|
+
// The task board: the open tasks as a section of index.md, so a person can follow the work in the
|
|
326
|
+
// vault itself, without the viewer. It is a view, written from the task files by `sw.mjs board`
|
|
327
|
+
// and never edited by hand; a task's status still lives only in its own frontmatter.
|
|
328
|
+
// Open tasks get a line each. Finished ones are listed by id only, so the section stays small as
|
|
329
|
+
// a project grows: index.md is the one file every session reads.
|
|
329
330
|
|
|
330
|
-
|
|
331
|
+
const BOARD_START = '<!-- sw:board:start (written by `sw.mjs board`; do not edit) -->';
|
|
332
|
+
const BOARD_END = '<!-- sw:board:end -->';
|
|
333
|
+
const BOARD_BLOCK = /<!-- sw:board:start[^\n]*-->\n[\s\S]*?<!-- sw:board:end -->/;
|
|
334
|
+
const REFRESH = 'run `node docs/.sw/sw.mjs board`';
|
|
335
|
+
|
|
336
|
+
// Sections in reading order: what is being worked on, what can start, what waits.
|
|
337
|
+
const OPEN_STATES = [['progress', 'In progress'], ['ready', 'Ready'], ['blocked', 'Blocked']];
|
|
338
|
+
|
|
339
|
+
function openLine(vault, task) {
|
|
340
|
+
// A dependency that does not exist is lint's finding, not something a task waits on.
|
|
341
|
+
const waiting = task.openDeps.filter(id => taskOf(vault, id));
|
|
342
|
+
return [
|
|
343
|
+
`- [[${task.id}]] ${task.title}`,
|
|
344
|
+
...(task.milestone ? [task.milestone] : []),
|
|
345
|
+
...(task.state === 'blocked' && waiting.length ? [`waits on ${waiting.join(', ')}`] : []),
|
|
346
|
+
].join(' · ');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The board for this vault, markers included.
|
|
350
|
+
export function taskBoard(vault) {
|
|
351
|
+
const { total } = summary(vault);
|
|
352
|
+
const counts = [
|
|
353
|
+
`ready ${total.ready}`, `in progress ${total.progress}`, `blocked ${total.blocked}`, `done ${total.done}`,
|
|
354
|
+
...(total.cancelled ? [`cancelled ${total.cancelled}`] : []),
|
|
355
|
+
];
|
|
356
|
+
const sections = OPEN_STATES
|
|
357
|
+
.map(([state, heading]) => [heading, tasksIn(vault, state)])
|
|
358
|
+
.filter(([, tasks]) => tasks.length)
|
|
359
|
+
.map(([heading, tasks]) => `**${heading}**\n\n${tasks.map(task => openLine(vault, task)).join('\n')}`);
|
|
360
|
+
// Finished tasks are looked up, not worked through: by id, not in running order.
|
|
361
|
+
const done = tasksIn(vault, 'done').map(task => task.id).sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).map(id => `[[${id}]]`);
|
|
362
|
+
const paragraphs = [
|
|
363
|
+
'## Tasks',
|
|
364
|
+
total.total ? counts.join(' · ') : 'No tasks yet.',
|
|
365
|
+
...sections,
|
|
366
|
+
...(done.length ? [`**Done (${done.length})** ${done.join(' ')}`] : []),
|
|
367
|
+
];
|
|
368
|
+
return `${BOARD_START}\n${paragraphs.join('\n\n')}\n${BOARD_END}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export const boardIn = indexText => (String(indexText).match(BOARD_BLOCK) || [null])[0];
|
|
372
|
+
|
|
373
|
+
// index.md with this board in it: in place of the one it has, otherwise right under the title.
|
|
374
|
+
export function indexWithBoard(indexText, board) {
|
|
375
|
+
const text = String(indexText);
|
|
376
|
+
if (BOARD_BLOCK.test(text)) return text.replace(BOARD_BLOCK, () => board);
|
|
377
|
+
const title = text.match(/^# .*\n?/);
|
|
378
|
+
if (!title) return `${board}\n\n${text}`;
|
|
379
|
+
const rest = text.slice(title[0].length).replace(/^\n+/, '');
|
|
380
|
+
return `${title[0].trimEnd()}\n\n${board}\n${rest ? `\n${rest}` : ''}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// A lint finding when index.md does not show the tasks as they are now, else null.
|
|
384
|
+
export function boardFinding(vault) {
|
|
385
|
+
if (!vault.index) return null; // a missing index is reported on its own
|
|
386
|
+
const current = boardIn(vault.index.body);
|
|
387
|
+
if (!current && !vault.tasks.size) return null; // a vault without tasks needs no board
|
|
388
|
+
if (current === taskBoard(vault)) return null;
|
|
389
|
+
return current
|
|
390
|
+
? { level: 'warn', code: 'stale-board', path: 'index.md', message: `the task list is out of date; ${REFRESH}` }
|
|
391
|
+
: { level: 'warn', code: 'missing-board', path: 'index.md', message: `the tasks are not listed; ${REFRESH}` };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Finds the record an agent tool keeps of the session working in a project.
|
|
395
|
+
// Claude Code, Codex and Copilot CLI each write every session to disk in their own layout; this
|
|
396
|
+
// lists a project's sessions as { tool, id, modified, current, source } and picks one. What a
|
|
397
|
+
// record means is left to the readers in stats.js and doctor.js.
|
|
398
|
+
|
|
399
|
+
export const SESSION_TOOLS = ['claude', 'codex', 'copilot'];
|
|
331
400
|
|
|
332
|
-
const MAIN = 'main';
|
|
333
401
|
// Codex keeps every project's sessions in one tree; only the most recent files are opened.
|
|
334
402
|
const CODEX_FILES_SCANNED = 100;
|
|
335
403
|
|
|
336
|
-
// ---------- Reading records ----------
|
|
337
|
-
|
|
338
404
|
// A record still being written can end in half a line; lines that do not parse are skipped.
|
|
339
|
-
function jsonLines(path) {
|
|
405
|
+
export function jsonLines(path) {
|
|
340
406
|
const records = [];
|
|
341
407
|
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
342
408
|
if (!line) continue;
|
|
@@ -347,7 +413,7 @@ function jsonLines(path) {
|
|
|
347
413
|
return records;
|
|
348
414
|
}
|
|
349
415
|
|
|
350
|
-
function jsonFile(path) {
|
|
416
|
+
export function jsonFile(path) {
|
|
351
417
|
try {
|
|
352
418
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
353
419
|
} catch {
|
|
@@ -368,9 +434,111 @@ function rootForms(root) {
|
|
|
368
434
|
|
|
369
435
|
const isWithin = (roots, dir) => Boolean(dir) && roots.some(root => dir === root || dir.startsWith(root + sep));
|
|
370
436
|
|
|
437
|
+
// ---------- Claude Code ----------
|
|
438
|
+
// ~/.claude/projects/<working directory, non-alphanumerics as dashes>/<session>.jsonl, and next to
|
|
439
|
+
// it <session>/subagents/agent-<id>.jsonl with a .meta.json naming the agent type.
|
|
440
|
+
// source: { path, subagentDir }
|
|
441
|
+
|
|
442
|
+
const claudeHome = () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
443
|
+
|
|
444
|
+
function claudeSessions(root) {
|
|
445
|
+
const sessions = [];
|
|
446
|
+
for (const form of rootForms(root)) {
|
|
447
|
+
const dir = join(claudeHome(), 'projects', form.replace(/[^A-Za-z0-9]/g, '-'));
|
|
448
|
+
if (!existsSync(dir)) continue;
|
|
449
|
+
for (const name of readdirSync(dir)) {
|
|
450
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
451
|
+
const id = name.slice(0, -'.jsonl'.length);
|
|
452
|
+
const path = join(dir, name);
|
|
453
|
+
sessions.push({
|
|
454
|
+
tool: 'claude',
|
|
455
|
+
id,
|
|
456
|
+
modified: modifiedAt(path),
|
|
457
|
+
current: id === process.env.CLAUDE_CODE_SESSION_ID,
|
|
458
|
+
source: { path, subagentDir: join(dir, id, 'subagents') },
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return sessions;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------- Codex ----------
|
|
466
|
+
// ~/.codex/sessions/<year>/<month>/<day>/rollout-*.jsonl. The first line is the session's meta:
|
|
467
|
+
// its working directory and, for a subagent, the session that spawned it and its role.
|
|
468
|
+
// source: { files: [{ meta, records }] }, one file per agent
|
|
469
|
+
|
|
470
|
+
const codexHome = () => process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
471
|
+
|
|
472
|
+
function rolloutFiles(dir, found = []) {
|
|
473
|
+
if (!existsSync(dir)) return found;
|
|
474
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
475
|
+
const path = join(dir, entry.name);
|
|
476
|
+
if (entry.isDirectory()) rolloutFiles(path, found);
|
|
477
|
+
else if (entry.name.endsWith('.jsonl')) found.push({ path, modified: modifiedAt(path) });
|
|
478
|
+
}
|
|
479
|
+
return found;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function codexSessions(root) {
|
|
483
|
+
const roots = rootForms(root);
|
|
484
|
+
const recent = rolloutFiles(join(codexHome(), 'sessions')).sort((a, b) => b.modified - a.modified).slice(0, CODEX_FILES_SCANNED);
|
|
485
|
+
const byId = new Map();
|
|
486
|
+
for (const { path, modified } of recent) {
|
|
487
|
+
const records = jsonLines(path);
|
|
488
|
+
const meta = records[0]?.type === 'session_meta' ? records[0].payload : null;
|
|
489
|
+
if (!meta || !isWithin(roots, meta.cwd)) continue;
|
|
490
|
+
const id = meta.session_id || meta.id;
|
|
491
|
+
if (!byId.has(id)) byId.set(id, { modified: 0, files: [] });
|
|
492
|
+
const group = byId.get(id);
|
|
493
|
+
group.modified = Math.max(group.modified, modified);
|
|
494
|
+
group.files.push({ meta, records });
|
|
495
|
+
}
|
|
496
|
+
return [...byId].map(([id, { modified, files }]) => ({ tool: 'codex', id, modified, current: false, source: { files } }));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---------- Copilot CLI ----------
|
|
500
|
+
// ~/.copilot/session-state/<session>/events.jsonl, with the working directory in workspace.yaml.
|
|
501
|
+
// source: { path }
|
|
502
|
+
|
|
503
|
+
const copilotHome = () => join(homedir(), '.copilot');
|
|
504
|
+
|
|
505
|
+
function copilotSessions(root) {
|
|
506
|
+
const roots = rootForms(root);
|
|
507
|
+
const base = join(copilotHome(), 'session-state');
|
|
508
|
+
if (!existsSync(base)) return [];
|
|
509
|
+
const sessions = [];
|
|
510
|
+
for (const id of readdirSync(base)) {
|
|
511
|
+
const path = join(base, id, 'events.jsonl');
|
|
512
|
+
const workspace = join(base, id, 'workspace.yaml');
|
|
513
|
+
if (!existsSync(path) || !existsSync(workspace)) continue;
|
|
514
|
+
const cwd = (readFileSync(workspace, 'utf8').match(/^cwd: (.*)$/m) || [])[1];
|
|
515
|
+
if (!isWithin(roots, cwd)) continue;
|
|
516
|
+
sessions.push({ tool: 'copilot', id, modified: modifiedAt(path), current: false, source: { path } });
|
|
517
|
+
}
|
|
518
|
+
return sessions;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------- Choosing ----------
|
|
522
|
+
|
|
523
|
+
const LISTERS = { claude: claudeSessions, codex: codexSessions, copilot: copilotSessions };
|
|
524
|
+
|
|
525
|
+
// The session to report: the one named by `id` (a prefix is enough), else the session this command
|
|
526
|
+
// runs in when the tool says which one that is, else the most recently written one.
|
|
527
|
+
export function findSession(root, { tool, id } = {}) {
|
|
528
|
+
let sessions = (tool ? [tool] : SESSION_TOOLS).flatMap(name => LISTERS[name](root));
|
|
529
|
+
if (id) sessions = sessions.filter(session => session.id.startsWith(id));
|
|
530
|
+
sessions.sort((a, b) => b.modified - a.modified);
|
|
531
|
+
return (!id && sessions.find(session => session.current)) || sessions[0] || null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Session statistics: what an agent session has cost so far, as one row per agent: the main
|
|
535
|
+
// session and each subagent it started. Reads the session record found by sessions.js.
|
|
536
|
+
|
|
537
|
+
const MAIN = 'main';
|
|
538
|
+
|
|
371
539
|
// ---------- The summary being built ----------
|
|
372
540
|
|
|
373
|
-
const
|
|
541
|
+
const newStats = session => ({ tool: session.tool, id: session.id, agents: [], tools: {}, note: '' });
|
|
374
542
|
|
|
375
543
|
// Token fields stay null when the record does not hold them, so "unknown" never prints as 0.
|
|
376
544
|
const newAgent = name => ({
|
|
@@ -389,9 +557,9 @@ function addStep(agent, { context, cached, output }) {
|
|
|
389
557
|
agent.output = plus(agent.output, output);
|
|
390
558
|
}
|
|
391
559
|
|
|
392
|
-
function addToolCall(
|
|
560
|
+
function addToolCall(stats, agent, name) {
|
|
393
561
|
agent.toolCalls++;
|
|
394
|
-
|
|
562
|
+
stats.tools[name] = (stats.tools[name] || 0) + 1;
|
|
395
563
|
}
|
|
396
564
|
|
|
397
565
|
// Widens the agent's working period to include this record.
|
|
@@ -406,33 +574,8 @@ function touch(agent, timestamp) {
|
|
|
406
574
|
const byStart = (a, b) => (a.start ?? 0) - (b.start ?? 0);
|
|
407
575
|
|
|
408
576
|
// ---------- Claude Code ----------
|
|
409
|
-
// ~/.claude/projects/<working directory, non-alphanumerics as dashes>/<session>.jsonl, and next to
|
|
410
|
-
// it <session>/subagents/agent-<id>.jsonl with a .meta.json naming the agent type.
|
|
411
|
-
|
|
412
|
-
const claudeHome = () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
413
|
-
|
|
414
|
-
function claudeSessions(root) {
|
|
415
|
-
const sessions = [];
|
|
416
|
-
for (const form of rootForms(root)) {
|
|
417
|
-
const dir = join(claudeHome(), 'projects', form.replace(/[^A-Za-z0-9]/g, '-'));
|
|
418
|
-
if (!existsSync(dir)) continue;
|
|
419
|
-
for (const name of readdirSync(dir)) {
|
|
420
|
-
if (!name.endsWith('.jsonl')) continue;
|
|
421
|
-
const id = name.slice(0, -'.jsonl'.length);
|
|
422
|
-
const path = join(dir, name);
|
|
423
|
-
sessions.push({
|
|
424
|
-
tool: 'claude',
|
|
425
|
-
id,
|
|
426
|
-
modified: modifiedAt(path),
|
|
427
|
-
current: id === process.env.CLAUDE_CODE_SESSION_ID,
|
|
428
|
-
read: () => readClaude(id, path, join(dir, id, 'subagents')),
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
return sessions;
|
|
433
|
-
}
|
|
434
577
|
|
|
435
|
-
function
|
|
578
|
+
function claudeAgent(stats, name, path) {
|
|
436
579
|
const agent = newAgent(name);
|
|
437
580
|
// A reply is written as one line per content block. Each line repeats the reply's usage, and
|
|
438
581
|
// only the last one has the final output count, so the last line of a reply is the one kept.
|
|
@@ -442,7 +585,7 @@ function readClaudeAgent(session, name, path) {
|
|
|
442
585
|
const message = record.type === 'assistant' && record.message;
|
|
443
586
|
if (!message) continue;
|
|
444
587
|
for (const block of message.content || []) {
|
|
445
|
-
if (block.type === 'tool_use') addToolCall(
|
|
588
|
+
if (block.type === 'tool_use') addToolCall(stats, agent, block.name);
|
|
446
589
|
}
|
|
447
590
|
if (message.usage) replies.set(message.id, message);
|
|
448
591
|
}
|
|
@@ -458,61 +601,31 @@ function readClaudeAgent(session, name, path) {
|
|
|
458
601
|
return agent;
|
|
459
602
|
}
|
|
460
603
|
|
|
461
|
-
function
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
604
|
+
function claudeStats(session) {
|
|
605
|
+
const { path, subagentDir } = session.source;
|
|
606
|
+
const stats = newStats(session);
|
|
607
|
+
stats.agents.push(claudeAgent(stats, MAIN, path));
|
|
608
|
+
if (!existsSync(subagentDir)) return stats;
|
|
465
609
|
const subagents = readdirSync(subagentDir)
|
|
466
610
|
.filter(name => name.endsWith('.jsonl'))
|
|
467
611
|
.map(name => {
|
|
468
612
|
const meta = jsonFile(join(subagentDir, name.replace(/\.jsonl$/, '.meta.json')));
|
|
469
|
-
return
|
|
613
|
+
return claudeAgent(stats, meta.agentType || 'subagent', join(subagentDir, name));
|
|
470
614
|
});
|
|
471
|
-
|
|
472
|
-
return
|
|
615
|
+
stats.agents.push(...subagents.sort(byStart));
|
|
616
|
+
return stats;
|
|
473
617
|
}
|
|
474
618
|
|
|
475
619
|
// ---------- Codex ----------
|
|
476
|
-
// ~/.codex/sessions/<year>/<month>/<day>/rollout-*.jsonl. The first line is the session's meta:
|
|
477
|
-
// its working directory and, for a subagent, the session that spawned it and its role.
|
|
478
620
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
function rolloutFiles(dir, found = []) {
|
|
482
|
-
if (!existsSync(dir)) return found;
|
|
483
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
484
|
-
const path = join(dir, entry.name);
|
|
485
|
-
if (entry.isDirectory()) rolloutFiles(path, found);
|
|
486
|
-
else if (entry.name.endsWith('.jsonl')) found.push({ path, modified: modifiedAt(path) });
|
|
487
|
-
}
|
|
488
|
-
return found;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function codexSessions(root) {
|
|
492
|
-
const roots = rootForms(root);
|
|
493
|
-
const recent = rolloutFiles(join(codexHome(), 'sessions')).sort((a, b) => b.modified - a.modified).slice(0, CODEX_FILES_SCANNED);
|
|
494
|
-
const byId = new Map();
|
|
495
|
-
for (const { path, modified } of recent) {
|
|
496
|
-
const records = jsonLines(path);
|
|
497
|
-
const meta = records[0]?.type === 'session_meta' ? records[0].payload : null;
|
|
498
|
-
if (!meta || !isWithin(roots, meta.cwd)) continue;
|
|
499
|
-
const id = meta.session_id || meta.id;
|
|
500
|
-
if (!byId.has(id)) byId.set(id, { modified: 0, files: [] });
|
|
501
|
-
const group = byId.get(id);
|
|
502
|
-
group.modified = Math.max(group.modified, modified);
|
|
503
|
-
group.files.push({ meta, records });
|
|
504
|
-
}
|
|
505
|
-
return [...byId].map(([id, { modified, files }]) => ({ tool: 'codex', id, modified, current: false, read: () => readCodex(id, files) }));
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function readCodexAgent(session, name, records) {
|
|
621
|
+
function codexAgent(stats, name, records) {
|
|
509
622
|
const agent = newAgent(name);
|
|
510
623
|
let lastTotal = null;
|
|
511
624
|
for (const record of records) {
|
|
512
625
|
touch(agent, record.timestamp);
|
|
513
626
|
const payload = record.payload || {};
|
|
514
627
|
if (record.type === 'turn_context') agent.model = payload.model || agent.model;
|
|
515
|
-
if (record.type === 'response_item' && /_call$/.test(payload.type || '')) addToolCall(
|
|
628
|
+
if (record.type === 'response_item' && /_call$/.test(payload.type || '')) addToolCall(stats, agent, payload.name || payload.type);
|
|
516
629
|
if (record.type !== 'event_msg' || payload.type !== 'token_count' || !payload.info) continue;
|
|
517
630
|
// The count is also repeated when only the rate limits change; a new request moves the total.
|
|
518
631
|
const total = payload.info.total_token_usage?.total_tokens;
|
|
@@ -524,41 +637,22 @@ function readCodexAgent(session, name, records) {
|
|
|
524
637
|
return agent;
|
|
525
638
|
}
|
|
526
639
|
|
|
527
|
-
function
|
|
528
|
-
const
|
|
640
|
+
function codexStats(session) {
|
|
641
|
+
const stats = newStats(session);
|
|
529
642
|
const subagents = [];
|
|
530
|
-
for (const { meta, records } of files) {
|
|
531
|
-
if (meta.thread_source === 'subagent') subagents.push(
|
|
532
|
-
else
|
|
643
|
+
for (const { meta, records } of session.source.files) {
|
|
644
|
+
if (meta.thread_source === 'subagent') subagents.push(codexAgent(stats, meta.agent_role || meta.agent_nickname || 'subagent', records));
|
|
645
|
+
else stats.agents.push(codexAgent(stats, MAIN, records));
|
|
533
646
|
}
|
|
534
|
-
|
|
535
|
-
return
|
|
647
|
+
stats.agents.push(...subagents.sort(byStart));
|
|
648
|
+
return stats;
|
|
536
649
|
}
|
|
537
650
|
|
|
538
651
|
// ---------- Copilot CLI ----------
|
|
539
|
-
// ~/.copilot/session-state/<session>/events.jsonl, with the working directory in workspace.yaml.
|
|
540
652
|
// Events of a subagent carry its agentId. Token counts are written only when the session closes.
|
|
541
653
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
function copilotSessions(root) {
|
|
545
|
-
const roots = rootForms(root);
|
|
546
|
-
const base = join(copilotHome(), 'session-state');
|
|
547
|
-
if (!existsSync(base)) return [];
|
|
548
|
-
const sessions = [];
|
|
549
|
-
for (const id of readdirSync(base)) {
|
|
550
|
-
const events = join(base, id, 'events.jsonl');
|
|
551
|
-
const workspace = join(base, id, 'workspace.yaml');
|
|
552
|
-
if (!existsSync(events) || !existsSync(workspace)) continue;
|
|
553
|
-
const cwd = (readFileSync(workspace, 'utf8').match(/^cwd: (.*)$/m) || [])[1];
|
|
554
|
-
if (!isWithin(roots, cwd)) continue;
|
|
555
|
-
sessions.push({ tool: 'copilot', id, modified: modifiedAt(events), current: false, read: () => readCopilot(id, events) });
|
|
556
|
-
}
|
|
557
|
-
return sessions;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function readCopilot(id, path) {
|
|
561
|
-
const session = newSession('copilot', id);
|
|
654
|
+
function copilotStats(session) {
|
|
655
|
+
const stats = newStats(session);
|
|
562
656
|
const agents = new Map();
|
|
563
657
|
const agentOf = key => {
|
|
564
658
|
if (!agents.has(key)) agents.set(key, newAgent(key));
|
|
@@ -566,7 +660,7 @@ function readCopilot(id, path) {
|
|
|
566
660
|
};
|
|
567
661
|
agentOf(MAIN);
|
|
568
662
|
let closed = false;
|
|
569
|
-
for (const event of jsonLines(path)) {
|
|
663
|
+
for (const event of jsonLines(session.source.path)) {
|
|
570
664
|
const data = event.data || {};
|
|
571
665
|
const agent = agentOf(event.agentId || MAIN);
|
|
572
666
|
touch(agent, event.timestamp);
|
|
@@ -576,7 +670,7 @@ function readCopilot(id, path) {
|
|
|
576
670
|
agent.steps++;
|
|
577
671
|
agent.model = data.model || agent.model;
|
|
578
672
|
} else if (event.type === 'tool.execution_start') {
|
|
579
|
-
addToolCall(
|
|
673
|
+
addToolCall(stats, agent, data.toolName);
|
|
580
674
|
} else if (event.type === 'session.shutdown' && data.agentMetrics) {
|
|
581
675
|
// A resumed session closes more than once; each close reports the run that ended with it.
|
|
582
676
|
closed = true;
|
|
@@ -590,24 +684,14 @@ function readCopilot(id, path) {
|
|
|
590
684
|
}
|
|
591
685
|
}
|
|
592
686
|
}
|
|
593
|
-
|
|
594
|
-
if (!closed)
|
|
595
|
-
return
|
|
687
|
+
stats.agents = [...agents.values()];
|
|
688
|
+
if (!closed) stats.note = 'Copilot writes token counts when the session closes; until then /usage shows them.';
|
|
689
|
+
return stats;
|
|
596
690
|
}
|
|
597
691
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const LISTERS = { claude: claudeSessions, codex: codexSessions, copilot: copilotSessions };
|
|
692
|
+
const STATS_READERS = { claude: claudeStats, codex: codexStats, copilot: copilotStats };
|
|
601
693
|
|
|
602
|
-
|
|
603
|
-
// runs in when the tool says which one that is, else the most recently written one.
|
|
604
|
-
export function sessionStats(root, { tool, id } = {}) {
|
|
605
|
-
let sessions = (tool ? [tool] : STATS_TOOLS).flatMap(name => LISTERS[name](root));
|
|
606
|
-
if (id) sessions = sessions.filter(session => session.id.startsWith(id));
|
|
607
|
-
sessions.sort((a, b) => b.modified - a.modified);
|
|
608
|
-
const chosen = (!id && sessions.find(session => session.current)) || sessions[0];
|
|
609
|
-
return chosen ? chosen.read() : null;
|
|
610
|
-
}
|
|
694
|
+
export const sessionStats = session => STATS_READERS[session.tool](session);
|
|
611
695
|
|
|
612
696
|
// ---------- Printing ----------
|
|
613
697
|
|
|
@@ -615,7 +699,7 @@ const COLUMNS = ['agent', 'model', 'steps', 'first', 'peak', 'sent', 'cached', '
|
|
|
615
699
|
const TEXT_COLUMNS = 2; // agent and model align left; the numbers after them align right
|
|
616
700
|
const LEGEND = 'first, peak: tokens sent with one request. sent: that, summed over every step. cached: the share of sent read from the cache.';
|
|
617
701
|
|
|
618
|
-
function
|
|
702
|
+
export function tokenCount(n) {
|
|
619
703
|
if (n == null) return '-';
|
|
620
704
|
if (n < 1000) return String(n);
|
|
621
705
|
if (n < 1e6) return `${Math.round(n / 1000)}k`;
|
|
@@ -658,33 +742,238 @@ function totalOf(agents) {
|
|
|
658
742
|
}
|
|
659
743
|
|
|
660
744
|
const cells = agent => [
|
|
661
|
-
agent.name, agent.model, String(agent.steps),
|
|
662
|
-
|
|
745
|
+
agent.name, agent.model, String(agent.steps), tokenCount(agent.first), tokenCount(agent.peak), tokenCount(agent.sent), cachedShare(agent),
|
|
746
|
+
tokenCount(agent.output), String(agent.toolCalls), agent.start == null ? '' : String(minutes(agent.start, agent.end)),
|
|
663
747
|
];
|
|
664
748
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const
|
|
749
|
+
// Rows of text cells as aligned lines: the first `textColumns` align left, the rest right.
|
|
750
|
+
export function alignedRows(rows, textColumns) {
|
|
751
|
+
const widths = rows[0].map((_, column) => Math.max(...rows.map(row => row[column].length)));
|
|
752
|
+
const pad = (cell, column) => (column < textColumns ? cell.padEnd(widths[column]) : cell.padStart(widths[column]));
|
|
668
753
|
return rows.map(row => row.map(pad).join(' ').trimEnd());
|
|
669
754
|
}
|
|
670
755
|
|
|
671
|
-
export function formatStats(
|
|
672
|
-
const { agents } =
|
|
756
|
+
export function formatStats(stats) {
|
|
757
|
+
const { agents } = stats;
|
|
673
758
|
const rows = [COLUMNS, ...agents.map(cells)];
|
|
674
759
|
if (agents.length > 1) rows.push(cells(totalOf(agents)));
|
|
675
|
-
const calls = Object.entries(
|
|
760
|
+
const calls = Object.entries(stats.tools).sort((a, b) => b[1] - a[1]).map(([name, count]) => `${name} ${count}`);
|
|
676
761
|
return [
|
|
677
|
-
['session',
|
|
678
|
-
...
|
|
762
|
+
['session', stats.tool, stats.id, sessionPeriod(agents)].filter(Boolean).join(' '),
|
|
763
|
+
...alignedRows(rows, TEXT_COLUMNS),
|
|
679
764
|
`tool calls ${calls.join(' ') || 'none'}`,
|
|
680
765
|
LEGEND,
|
|
681
|
-
...(
|
|
766
|
+
...(stats.note ? [stats.note] : []),
|
|
767
|
+
].join('\n');
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Start context: what an agent session carries before it has read anything. Rule and memory
|
|
771
|
+
// files, the lists of skills, agents and tools, and what plugins and hooks add are sent again with
|
|
772
|
+
// every step of every agent, so this is the cheapest place to make a session lighter.
|
|
773
|
+
// Reads the session record found by sessions.js and reduces it to parts with a size and a source.
|
|
774
|
+
|
|
775
|
+
// The command that shows the same context in tokens, in each tool.
|
|
776
|
+
const CONTEXT_COMMANDS = { claude: '/context', codex: '/status', copilot: '/context' };
|
|
777
|
+
const SMALL_PART_CHARS = 512; // smaller parts are summed into "other"
|
|
778
|
+
const SOURCES_SHOWN = 6;
|
|
779
|
+
const UNGROUPED = '(none)';
|
|
780
|
+
const NOT_STARTED = 'This session has not made a request yet; its context is recorded with the first one.';
|
|
781
|
+
|
|
782
|
+
// ---------- The report being built ----------
|
|
783
|
+
|
|
784
|
+
const newReport = session => ({ tool: session.tool, id: session.id, firstRequest: null, parts: [], note: '' });
|
|
785
|
+
|
|
786
|
+
// A part's sources are either texts with a size or groups with a count: { label, amount }.
|
|
787
|
+
// `chars` is null for a part the record counts in tokens instead.
|
|
788
|
+
const newPart = (name, measure) => ({ name, chars: 0, tokens: null, count: null, measure, sources: [] });
|
|
789
|
+
|
|
790
|
+
// A part made of texts, each from a named source.
|
|
791
|
+
function sized(name, texts) {
|
|
792
|
+
const part = newPart(name, 'size');
|
|
793
|
+
for (const [label, text] of texts) {
|
|
794
|
+
part.chars += text.length;
|
|
795
|
+
part.sources.push({ label, amount: text.length });
|
|
796
|
+
}
|
|
797
|
+
return part;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// A part that is one list of names, grouped by where each name comes from.
|
|
801
|
+
function listed(name, names, text, groupOf) {
|
|
802
|
+
const part = newPart(name, 'count');
|
|
803
|
+
part.chars = text.length;
|
|
804
|
+
part.count = names.length;
|
|
805
|
+
const groups = new Map();
|
|
806
|
+
for (const item of names) groups.set(groupOf(item), (groups.get(groupOf(item)) || 0) + 1);
|
|
807
|
+
part.sources = [...groups].map(([label, amount]) => ({ label, amount }));
|
|
808
|
+
return part;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// "plugin:skill" and "plugin:agent" belong to the plugin; "mcp__server__tool" to the server.
|
|
812
|
+
const pluginOf = name => (name.includes(':') ? name.slice(0, name.indexOf(':')) : UNGROUPED);
|
|
813
|
+
const serverOf = name => (name.match(/^mcp__(.+?)__/) || [])[1] || UNGROUPED;
|
|
814
|
+
|
|
815
|
+
// "memory/MEMORY.md": the last folder says which of several same-named files this is.
|
|
816
|
+
const shortPath = path => `${basename(dirname(path))}/${basename(path)}`;
|
|
817
|
+
|
|
818
|
+
// ---------- Claude Code ----------
|
|
819
|
+
// Before the first reply the record holds one "attachment" per block of context the session was
|
|
820
|
+
// given: the instruction files, the skill, agent and tool lists, server instructions, hook output.
|
|
821
|
+
|
|
822
|
+
function claudePart(attachment) {
|
|
823
|
+
switch (attachment.type) {
|
|
824
|
+
case 'instructions':
|
|
825
|
+
return sized('rule and memory files', (attachment.files || []).map(file => [shortPath(file.path), file.content || '']));
|
|
826
|
+
case 'skill_listing':
|
|
827
|
+
return listed('skill list', attachment.names || [], attachment.content || '', pluginOf);
|
|
828
|
+
case 'agent_listing_delta':
|
|
829
|
+
return listed('agent list', attachment.addedTypes || [], (attachment.addedLines || []).join('\n'), pluginOf);
|
|
830
|
+
case 'deferred_tools_delta':
|
|
831
|
+
return listed('tool names (loaded on demand)', attachment.addedNames || [], (attachment.addedLines || []).join('\n'), serverOf);
|
|
832
|
+
case 'mcp_instructions_delta':
|
|
833
|
+
return sized('MCP server instructions', (attachment.addedNames || []).map((name, i) => [name, attachment.addedBlocks?.[i] || '']));
|
|
834
|
+
case 'hook_additional_context':
|
|
835
|
+
return sized('session-start hooks', [[attachment.hookName || 'hook', [attachment.content].flat().join('\n')]]);
|
|
836
|
+
default:
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function claudeStart(session) {
|
|
842
|
+
const report = newReport(session);
|
|
843
|
+
for (const record of jsonLines(session.source.path)) {
|
|
844
|
+
if (record.type === 'attachment') {
|
|
845
|
+
const part = claudePart(record.attachment || {});
|
|
846
|
+
if (part) report.parts.push(part);
|
|
847
|
+
}
|
|
848
|
+
const usage = record.type === 'assistant' && record.message?.usage;
|
|
849
|
+
if (!usage) continue;
|
|
850
|
+
report.firstRequest = (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
if (report.firstRequest == null) report.note = NOT_STARTED;
|
|
854
|
+
return report;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ---------- Codex ----------
|
|
858
|
+
// The session meta holds the base instructions. The messages before the first request hold the
|
|
859
|
+
// rest, one tagged block each: <skills_instructions>, <plugins_instructions>, the AGENTS.md text.
|
|
860
|
+
|
|
861
|
+
function codexBlockName(text) {
|
|
862
|
+
if (text.startsWith('# AGENTS.md')) return 'AGENTS.md';
|
|
863
|
+
const tag = (text.match(/^<([a-z_ ]+)>/) || [])[1];
|
|
864
|
+
return tag ? tag.replace(/_/g, ' ') : null; // untagged text is the user's own request
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function codexStart(session) {
|
|
868
|
+
const report = newReport(session);
|
|
869
|
+
const main = session.source.files.find(file => file.meta.thread_source !== 'subagent') || session.source.files[0];
|
|
870
|
+
report.parts.push(sized('base instructions', [['codex', main.meta.base_instructions?.text || '']]));
|
|
871
|
+
for (const record of main.records) {
|
|
872
|
+
const payload = record.payload || {};
|
|
873
|
+
if (record.type === 'response_item' && payload.type === 'message') {
|
|
874
|
+
for (const block of payload.content || []) {
|
|
875
|
+
const name = codexBlockName(block.text || '');
|
|
876
|
+
if (name) report.parts.push(sized(name, [[payload.role, block.text]]));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (record.type !== 'event_msg' || payload.type !== 'token_count' || !payload.info) continue;
|
|
880
|
+
report.firstRequest = payload.info.last_token_usage?.input_tokens ?? null;
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
if (report.firstRequest == null) report.note = NOT_STARTED;
|
|
884
|
+
return report;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// ---------- Copilot CLI ----------
|
|
888
|
+
// The first system message holds the context as tagged blocks (<tools>, <custom_instruction>).
|
|
889
|
+
// A usage checkpoint, written later in the session, counts the tool definitions in tokens.
|
|
890
|
+
|
|
891
|
+
function copilotStart(session) {
|
|
892
|
+
const report = newReport(session);
|
|
893
|
+
const events = jsonLines(session.source.path);
|
|
894
|
+
const system = events.find(event => event.type === 'system.message')?.data?.content || '';
|
|
895
|
+
for (const [, tag, body] of system.matchAll(/<([a-z_]+)>(.*?)<\/\1>/gs)) report.parts.push(sized(tag.replace(/_/g, ' '), [['system', body]]));
|
|
896
|
+
const checkpoint = events.find(event => event.type === 'session.usage_checkpoint')?.data?.promptCacheBreakState?.[0]?.models;
|
|
897
|
+
const usage = checkpoint && Object.values(checkpoint)[0];
|
|
898
|
+
if (usage?.tool_tokens == null) {
|
|
899
|
+
report.note = 'Copilot counts its tool definitions at the first usage checkpoint; this session has not reached one.';
|
|
900
|
+
return report;
|
|
901
|
+
}
|
|
902
|
+
report.parts.push({ ...newPart('tool definitions', 'count'), chars: null, tokens: usage.tool_tokens, count: usage.tool_count ?? null });
|
|
903
|
+
return report;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const START_READERS = { claude: claudeStart, codex: codexStart, copilot: copilotStart };
|
|
907
|
+
|
|
908
|
+
// ---------- Tidying ----------
|
|
909
|
+
|
|
910
|
+
// Blocks with the same name become one part.
|
|
911
|
+
function mergedByName(parts) {
|
|
912
|
+
const byName = new Map();
|
|
913
|
+
for (const part of parts) {
|
|
914
|
+
const known = byName.get(part.name);
|
|
915
|
+
if (!known) {
|
|
916
|
+
byName.set(part.name, part);
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
known.chars += part.chars;
|
|
920
|
+
known.sources.push(...part.sources);
|
|
921
|
+
}
|
|
922
|
+
return [...byName.values()];
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Largest first; parts too small to matter are summed into "other", which comes last.
|
|
926
|
+
function ranked(parts) {
|
|
927
|
+
const other = newPart('other', 'size');
|
|
928
|
+
const kept = [];
|
|
929
|
+
for (const part of parts) {
|
|
930
|
+
if (part.chars == null || part.chars >= SMALL_PART_CHARS) kept.push(part);
|
|
931
|
+
else other.chars += part.chars;
|
|
932
|
+
}
|
|
933
|
+
kept.sort((a, b) => (b.chars ?? 0) - (a.chars ?? 0));
|
|
934
|
+
return other.chars ? [...kept, other] : kept;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
export function startContext(session) {
|
|
938
|
+
const report = START_READERS[session.tool](session);
|
|
939
|
+
report.parts = ranked(mergedByName(report.parts));
|
|
940
|
+
for (const part of report.parts) part.sources.sort((a, b) => b.amount - a.amount);
|
|
941
|
+
return report;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// ---------- Printing ----------
|
|
945
|
+
|
|
946
|
+
const kilobytes = chars => `${(chars / 1024).toFixed(chars < 10 * 1024 ? 1 : 0)} KB`;
|
|
947
|
+
const sizeOf = part => (part.chars == null ? `${tokenCount(part.tokens)} tokens` : kilobytes(part.chars));
|
|
948
|
+
|
|
949
|
+
// "213: marketing-skills 41, claude-seo 25, +9 more" for a list, "MEMORY.md 18 KB, AGENTS.md 11 KB"
|
|
950
|
+
// for texts. A part that is a single text says nothing its name and size have not said.
|
|
951
|
+
function sourcesOf(part) {
|
|
952
|
+
if (part.measure === 'size' && part.sources.length < 2) return '';
|
|
953
|
+
const amount = source => (part.measure === 'size' ? kilobytes(source.amount) : String(source.amount));
|
|
954
|
+
const shown = part.sources.slice(0, SOURCES_SHOWN).map(source => `${source.label} ${amount(source)}`);
|
|
955
|
+
const hidden = part.sources.length - shown.length;
|
|
956
|
+
const list = shown.join(', ') + (hidden > 0 ? `, +${hidden} more` : '');
|
|
957
|
+
return part.count == null ? list : [String(part.count), list].filter(Boolean).join(': ');
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export function formatStartContext(report) {
|
|
961
|
+
const rows = [['part', 'size', 'holds'], ...report.parts.map(part => [part.name, sizeOf(part), sourcesOf(part)])];
|
|
962
|
+
const [nameWidth, sizeWidth] = [0, 1].map(column => Math.max(...rows.map(row => row[column].length)));
|
|
963
|
+
return [
|
|
964
|
+
`context at session start ${report.tool} ${report.id}`,
|
|
965
|
+
...(report.firstRequest == null ? [] : [`first request: ${tokenCount(report.firstRequest)} tokens`]),
|
|
966
|
+
...rows.map(([name, size, holds]) => `${name.padEnd(nameWidth)} ${size.padStart(sizeWidth)} ${holds}`.trimEnd()),
|
|
967
|
+
`sizes are characters of text; ${CONTEXT_COMMANDS[report.tool]} shows this session's context in tokens`,
|
|
968
|
+
...(report.note ? [report.note] : []),
|
|
682
969
|
].join('\n');
|
|
683
970
|
}
|
|
684
971
|
|
|
685
972
|
// Superwiki CLI. Lives in a project at docs/.sw/sw.mjs and prints short answers,
|
|
686
973
|
// so agents do not have to read the vault to get them.
|
|
687
974
|
|
|
975
|
+
const SESSION_FLAGS = `[--session <id>] [--tool ${SESSION_TOOLS.join('|')}]`;
|
|
976
|
+
|
|
688
977
|
const HELP = `sw <command> [--docs <dir>] [--json]
|
|
689
978
|
|
|
690
979
|
status task counts per area and wiki page count
|
|
@@ -693,9 +982,11 @@ const HELP = `sw <command> [--docs <dir>] [--json]
|
|
|
693
982
|
explain <ID> a task's dependencies, what it blocks and unblocks, its plan and linked pages
|
|
694
983
|
search <words> pages and log entries that mention the words, best match first
|
|
695
984
|
next-id <AREA> next free task id for an area (numbers are never reused)
|
|
985
|
+
board rewrite the task list in docs/index.md from the task files
|
|
696
986
|
lint structural checks; exit code 1 on errors
|
|
697
987
|
stats what the agent session here has cost so far: steps, context and tokens per agent
|
|
698
|
-
|
|
988
|
+
doctor what that session carried before it read anything: rule files, skill and tool lists
|
|
989
|
+
both take ${SESSION_FLAGS} to look at another session of this project
|
|
699
990
|
serve [--open] start (or reuse) a local viewer at http://127.0.0.1:<port>/ that reads the files live
|
|
700
991
|
snapshot write docs/.sw/data.js so docs/viewer.html opens as a file, frozen at this moment`;
|
|
701
992
|
|
|
@@ -756,7 +1047,7 @@ export function vaultData(docs) {
|
|
|
756
1047
|
// "<" is escaped so page text can never close the script element the viewer loads this with.
|
|
757
1048
|
export const dataScript = data => `window.SW_DATA = ${JSON.stringify(data).replace(/</g, '\\u003c')};\n`;
|
|
758
1049
|
|
|
759
|
-
// ----------
|
|
1050
|
+
// ---------- Vault commands ----------
|
|
760
1051
|
// Each command gets { docs, vault, args, flags } and returns { data, text, code? } or
|
|
761
1052
|
// { error, code? }. `data` is what --json prints; `text` is the default output.
|
|
762
1053
|
|
|
@@ -907,8 +1198,25 @@ function nextIdCommand({ vault, args }) {
|
|
|
907
1198
|
return { data: { id }, text: id };
|
|
908
1199
|
}
|
|
909
1200
|
|
|
1201
|
+
// The task list in index.md is a view of the task files; this writes it again from them.
|
|
1202
|
+
function boardCommand({ docs, vault }) {
|
|
1203
|
+
if (!existsSync(join(docs, 'tasks'))) return { error: 'this vault has no task module (docs/tasks); sw-init --tasks adds it', code: 1 };
|
|
1204
|
+
const path = join(docs, 'index.md');
|
|
1205
|
+
if (!existsSync(path)) return { error: 'docs/index.md is missing; run sw-init', code: 1 };
|
|
1206
|
+
const before = readFileSync(path, 'utf8');
|
|
1207
|
+
const after = indexWithBoard(before, taskBoard(vault));
|
|
1208
|
+
const changed = after !== before;
|
|
1209
|
+
if (changed) writeFileSync(path, after);
|
|
1210
|
+
const { total } = summary(vault);
|
|
1211
|
+
return {
|
|
1212
|
+
data: { changed, ready: total.ready, inProgress: total.progress, blocked: total.blocked, done: total.done },
|
|
1213
|
+
text: `board: docs/index.md ${changed ? 'updated' : 'unchanged'} ready ${total.ready} in-progress ${total.progress} blocked ${total.blocked} done ${total.done}`,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
|
|
910
1217
|
function lintCommand({ vault }) {
|
|
911
|
-
const
|
|
1218
|
+
const board = boardFinding(vault);
|
|
1219
|
+
const findings = [...lint(vault), ...(board ? [board] : [])];
|
|
912
1220
|
const errors = findings.filter(f => f.level === 'error').length;
|
|
913
1221
|
return {
|
|
914
1222
|
data: findings,
|
|
@@ -920,17 +1228,6 @@ function lintCommand({ vault }) {
|
|
|
920
1228
|
};
|
|
921
1229
|
}
|
|
922
1230
|
|
|
923
|
-
// Agent sessions are recorded by the folder they ran in: the project root, which holds docs/.
|
|
924
|
-
function stats({ docs, flags }) {
|
|
925
|
-
if (flags.tool && !STATS_TOOLS.includes(flags.tool)) {
|
|
926
|
-
return { error: `usage: sw stats [--session <id>] [--tool ${STATS_TOOLS.join('|')}]` };
|
|
927
|
-
}
|
|
928
|
-
const root = dirname(docs);
|
|
929
|
-
const session = sessionStats(root, { tool: flags.tool, id: flags.session });
|
|
930
|
-
if (!session) return { error: `no ${flags.tool || 'agent'} session record found for ${root}`, code: 1 };
|
|
931
|
-
return { data: session, text: formatStats(session) };
|
|
932
|
-
}
|
|
933
|
-
|
|
934
1231
|
function snapshot({ docs }) {
|
|
935
1232
|
const data = vaultData(docs);
|
|
936
1233
|
mkdirSync(join(docs, '.sw'), { recursive: true });
|
|
@@ -939,6 +1236,31 @@ function snapshot({ docs }) {
|
|
|
939
1236
|
return { data: { files: data.files.length }, text };
|
|
940
1237
|
}
|
|
941
1238
|
|
|
1239
|
+
// ---------- Session commands ----------
|
|
1240
|
+
// These read the record an agent tool keeps of a session, not the vault. Tools file a session
|
|
1241
|
+
// under the folder it ran in: the project root, which holds docs/.
|
|
1242
|
+
|
|
1243
|
+
function sessionArg(command, { docs, flags }) {
|
|
1244
|
+
if (flags.tool && !SESSION_TOOLS.includes(flags.tool)) return { error: `usage: sw ${command} ${SESSION_FLAGS}` };
|
|
1245
|
+
const root = dirname(docs);
|
|
1246
|
+
const session = findSession(root, { tool: flags.tool, id: flags.session });
|
|
1247
|
+
return session || { error: `no ${flags.tool || 'agent'} session record found for ${root}`, code: 1 };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function statsCommand(ctx) {
|
|
1251
|
+
const session = sessionArg('stats', ctx);
|
|
1252
|
+
if (session.error) return session;
|
|
1253
|
+
const stats = sessionStats(session);
|
|
1254
|
+
return { data: stats, text: formatStats(stats) };
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function doctorCommand(ctx) {
|
|
1258
|
+
const session = sessionArg('doctor', ctx);
|
|
1259
|
+
if (session.error) return session;
|
|
1260
|
+
const report = startContext(session);
|
|
1261
|
+
return { data: report, text: formatStartContext(report) };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
942
1264
|
// ---------- Viewer server ----------
|
|
943
1265
|
// A file:// page cannot read local files unless the user picks a folder. Served from localhost it
|
|
944
1266
|
// can: every Refresh asks this process, which reads the files as they are now.
|
|
@@ -1037,8 +1359,10 @@ const COMMANDS = {
|
|
|
1037
1359
|
explain: { run: explain, needsVault: true },
|
|
1038
1360
|
search: { run: searchCommand, needsVault: true },
|
|
1039
1361
|
'next-id': { run: nextIdCommand, needsVault: true },
|
|
1362
|
+
board: { run: boardCommand, needsVault: true },
|
|
1040
1363
|
lint: { run: lintCommand, needsVault: true },
|
|
1041
|
-
stats: { run:
|
|
1364
|
+
stats: { run: statsCommand, needsVault: false },
|
|
1365
|
+
doctor: { run: doctorCommand, needsVault: false },
|
|
1042
1366
|
snapshot: { run: snapshot, needsVault: false },
|
|
1043
1367
|
serve: { run: serve, needsVault: false },
|
|
1044
1368
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Scaffolds docs/ as a Superwiki vault. Safe to re-run: user content is kept, tool files are
|
|
3
3
|
// replaced with this version, and the report says which was which.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
5
6
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -11,6 +12,7 @@ const MANAGED_BLOCK = /<!-- sw:start[\s\S]*?<!-- sw:end -->/;
|
|
|
11
12
|
// What a vault consists of at the top of docs/. Anything else there belongs to someone else.
|
|
12
13
|
const VAULT_ENTRIES = ['index.md', 'log.md', 'raw', 'wiki', 'tasks', 'plans', 'viewer.html'];
|
|
13
14
|
const ROLES = ['plan', 'implement', 'review'];
|
|
15
|
+
const NEW_INDEX = '# Index\n\n## Wiki\n\nCatalog of the wiki: one line per page, `- [[file-name]]: summary`, grouped by type.\n';
|
|
14
16
|
|
|
15
17
|
const HELP = `init.mjs [--root <dir>] [--tasks | --no-tasks] [--areas "M=Mobile,B=Backend"]
|
|
16
18
|
|
|
@@ -116,7 +118,7 @@ function writeVault(docs, tasks, report) {
|
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
const today = new Date().toISOString().slice(0, 10);
|
|
119
|
-
report.keep(join(docs, 'index.md'),
|
|
121
|
+
report.keep(join(docs, 'index.md'), NEW_INDEX);
|
|
120
122
|
report.keep(join(docs, 'log.md'), `# Log\n\nAppend-only. Entry format: \`## [YYYY-MM-DD] kind | title\`.\n\n## [${today}] init | Superwiki vault created\n`);
|
|
121
123
|
|
|
122
124
|
// The viewer snapshot and the local server's address are per-machine and regenerated on demand.
|
|
@@ -128,6 +130,18 @@ function writeVault(docs, tasks, report) {
|
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
// The task list in index.md is the one part of that file the tool owns. The vault's own CLI
|
|
134
|
+
// writes it, so an upgraded vault gets the list in the format of the version just installed.
|
|
135
|
+
function writeTaskBoard(docs, report) {
|
|
136
|
+
const index = join(docs, 'index.md');
|
|
137
|
+
const cli = join(docs, '.sw', 'sw.mjs');
|
|
138
|
+
if (!existsSync(cli)) return; // already reported as missing
|
|
139
|
+
const before = readFileSync(index, 'utf8');
|
|
140
|
+
const run = spawnSync(process.execPath, [cli, 'board', '--docs', docs], { encoding: 'utf8' });
|
|
141
|
+
if (run.status !== 0) return report.line('note', `the task list in docs/index.md was not written: ${run.stderr.trim()}`);
|
|
142
|
+
report.line(readFileSync(index, 'utf8') === before ? 'unchanged' : 'updated', 'docs/index.md (task list)');
|
|
143
|
+
}
|
|
144
|
+
|
|
131
145
|
function writeAgentRules(root, tasks, report) {
|
|
132
146
|
const block = schemaBlock(tasks);
|
|
133
147
|
const agentsPath = join(root, 'AGENTS.md');
|
|
@@ -174,6 +188,7 @@ function main(argv) {
|
|
|
174
188
|
const hadTaskFiles = !previous && existsSync(join(docs, 'tasks'));
|
|
175
189
|
const report = createReport(root);
|
|
176
190
|
writeVault(docs, tasks, report);
|
|
191
|
+
if (tasks) writeTaskBoard(docs, report);
|
|
177
192
|
const config = buildConfig(previous, { root, tasks, areas: options.areas });
|
|
178
193
|
report.write(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
179
194
|
writeAgentRules(root, tasks, report);
|
package/skills/sw-lint/SKILL.md
CHANGED
|
@@ -17,6 +17,7 @@ Two passes. The first is a script and costs almost nothing. The second reads pag
|
|
|
17
17
|
|
|
18
18
|
| Finding | Fix |
|
|
19
19
|
|---|---|
|
|
20
|
+
| `stale-board`, `missing-board` | `node docs/.sw/sw.mjs board`: it rewrites the task list in `index.md` from the task files. Never edit that list by hand |
|
|
20
21
|
| `not-in-index` | add `- [[page]]: summary` to `index.md`, using the page's `summary:` |
|
|
21
22
|
| `missing-field` `summary` | copy it from the page's line in `index.md`; if there is none, read the page and write it |
|
|
22
23
|
| `missing-field` `type` | read that page, write the field |
|
package/skills/sw-plan/SKILL.md
CHANGED
|
@@ -14,7 +14,10 @@ Needs the task module (`docs/tasks/`). If it is missing, say so and offer sw-ini
|
|
|
14
14
|
1. **Pick the task.**
|
|
15
15
|
- Id given: `node docs/.sw/sw.mjs check <ID>`, then read `docs/tasks/<ID>.md`. Status `done` or `cancelled`: stop and ask what the user wants. A plan already exists: this run revises it; say so. Open deps do not prevent planning.
|
|
16
16
|
- No id, existing work: `node docs/.sw/sw.mjs ready`, and let the user choose.
|
|
17
|
-
- New work: agree on title, area and dependencies with the user, get the id from `node docs/.sw/sw.mjs next-id <AREA
|
|
17
|
+
- New work: agree on title, area and dependencies with the user, and get the id from `node docs/.sw/sw.mjs next-id <AREA>`. Then:
|
|
18
|
+
- write `docs/tasks/<ID>.md` from `docs/.sw/templates/task.md` with `status: todo`, a "Goal" and a "Done when" list;
|
|
19
|
+
- append `## [date] task | <ID> created` to `docs/log.md`;
|
|
20
|
+
- run `node docs/.sw/sw.mjs board`, so the task list in `index.md` shows it.
|
|
18
21
|
2. **Does it need a plan?** Judge from the task file alone. A task is small when all of these hold: one area, three "Done when" items or fewer, nothing left open in its notes, and the change it describes is confined to a few files. A small task needs no plan: say so and offer `sw-implement <ID>` directly. Go on with planning only if the user wants a plan anyway, or the task is not small.
|
|
19
22
|
3. **Clarify.** Ask the user only what the task file leaves open about scope or intent, one question at a time, each with your recommendation. Add the answers to the task's "Notes" now. Do not read code to find questions; the planner surfaces the technical ones. Skip this when nothing is open.
|
|
20
23
|
4. **Dispatch the planner.** Its prompt is: the task id, today's date, the project root if it is not your working directory, and any feedback from an earlier round. It writes the plan file as a draft and returns a short message.
|
|
@@ -36,7 +39,7 @@ Needs the task module (`docs/tasks/`). If it is missing, say so and offer sw-ini
|
|
|
36
39
|
- in the area guide, if `node docs/.sw/sw.mjs explain <ID>` names one, the planner's `Guide:` lines, one line per fact;
|
|
37
40
|
- in `docs/log.md`, a new entry `## [date] plan | <ID>`, in the layout the log's last entries use.
|
|
38
41
|
|
|
39
|
-
Then run `node docs/.sw/sw.mjs lint`.
|
|
42
|
+
Then run `node docs/.sw/sw.mjs lint`. If it reports `stale-board`, a task's title, milestone or dependencies changed along the way: run `node docs/.sw/sw.mjs board`.
|
|
40
43
|
7. **Stop.** Do not start implementing. Tell the user the plan is approved and that sw-implement `<ID>` runs it.
|
|
41
44
|
|
|
42
45
|
## Common mistakes
|
|
@@ -46,3 +49,4 @@ Needs the task module (`docs/tasks/`). If it is missing, say so and offer sw-ini
|
|
|
46
49
|
- Editing the plan yourself. If something in it is wrong, send it back to the planner.
|
|
47
50
|
- Setting the task to `in-progress`. Planning does not change status.
|
|
48
51
|
- Planning several tasks in one plan file. One task, one plan; shared design goes to a `type: decision` wiki page that the plans link.
|
|
52
|
+
- Adding the new task to the list in `index.md` by hand. `board` writes that list from the task files.
|
package/skills/sw-stats/SKILL.md
CHANGED
|
@@ -24,7 +24,7 @@ It reports the session it runs in. For another session of this project, add `--s
|
|
|
24
24
|
| --- | --- |
|
|
25
25
|
| one agent's `sent` is most of the total | which agent, and its share. That is where the session's cost is |
|
|
26
26
|
| an agent's `peak` is several times its `first` | its context grew during the work; `steps` times a large context is what makes `sent` large |
|
|
27
|
-
| `first` is large for `main`
|
|
27
|
+
| `first` is large, for `main` or for the subagents | every agent starts heavy before it reads anything: rules, memory, and the lists of skills and tools. sw-doctor shows what fills it and what can go |
|
|
28
28
|
| `cached` is well below the others for one agent | much of what it sent was not served from the cache, which costs more per token. Long pauses do that |
|
|
29
29
|
| `main` has many `steps` or a `peak` far above its `first` | work is running in the main session that a subagent could do in a clean context |
|
|
30
30
|
|
|
@@ -36,7 +36,7 @@ So that you can answer a question about them:
|
|
|
36
36
|
|
|
37
37
|
- `steps`: model requests. `tools`: tool calls. `min`: minutes between the agent's first and last record.
|
|
38
38
|
- `first`, `peak`: tokens sent with the first request and with the largest one.
|
|
39
|
-
- `sent`: tokens sent, summed over every step. A step
|
|
39
|
+
- `sent`: tokens sent, summed over every step. A step sends the whole context again, so this is far larger than `peak`.
|
|
40
40
|
- `cached`: the share of `sent` that was read from the cache.
|
|
41
41
|
- `output`: tokens the model wrote.
|
|
42
42
|
- `-`: the tool's record does not hold that number.
|