kaizen-agent 1.23.0 → 1.25.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/README.md +19 -242
- package/agents/kaizen-reviewer.md +3 -1
- package/cli/build-web.ts +16 -9
- package/cli/install.ts +27 -6
- package/cli/notes.test.ts +18 -4
- package/cli/pty.test.ts +133 -0
- package/cli/pty.ts +111 -0
- package/cli/settings.ts +2 -1
- package/cli/sources.test.ts +170 -25
- package/cli/sources.ts +152 -28
- package/cli/state.ts +33 -6
- package/cli/web.ts +124 -18
- package/commands/kaizen-discover.md +5 -0
- package/commands/kaizen-gather.md +1 -1
- package/package.json +4 -2
- package/skills/kaizen/README.md +1 -0
- package/skills/kaizen/SKILL.md +6 -2
- package/skills/kaizen/config.default.yml +8 -0
- package/skills/kaizen/discover.md +50 -0
- package/skills/kaizen/gather.md +22 -7
- package/skills/kaizen/settings.md +1 -1
- package/skills/kaizen/spec-build.md +4 -1
- package/skills/kaizen/spec-review.md +3 -1
- package/skills/kaizen/spec.md +2 -1
- package/skills/kaizen-help/SKILL.md +2 -1
- package/web/board.html +415 -100
- package/web/notebook.js +1 -1
- package/web/notes.html +77 -15
- package/web/term.css +285 -0
- package/web/term.js +39 -0
- package/web/term.ts +46 -0
package/README.md
CHANGED
|
@@ -2,269 +2,46 @@
|
|
|
2
2
|
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/banner.png" alt="kaizen" width="760">
|
|
3
3
|
</div>
|
|
4
4
|
|
|
5
|
-
<p align="center"><b>
|
|
5
|
+
<p align="center"><b>See the plan before your AI starts. Get a second opinion when it is done.</b></p>
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
<a href="https://github.com/hfadhlullah/kaizen/stargazers"><img alt="stars" src="https://img.shields.io/github/stars/hfadhlullah/kaizen?style=social"></a>
|
|
9
|
-
<a href="https://www.npmjs.com/package/kaizen-agent"><img alt="npm" src="https://img.shields.io/npm/v/kaizen-agent?color=2d50a5&label=npm"></a>
|
|
10
|
-
<a href="https://github.com/hfadhlullah/kaizen/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/npm/l/kaizen-agent?color=2d50a5"></a>
|
|
11
|
-
<img alt="dependencies" src="https://img.shields.io/badge/dependencies-0-2d50a5">
|
|
12
|
-
<img alt="works with" src="https://img.shields.io/badge/Claude%20Code%20%C2%B7%20Codex%20%C2%B7%20Antigravity%20%C2%B7%20OpenCode%20%C2%B7%20Cursor%20%C2%B7%20Gemini%20CLI-2d50a5">
|
|
13
|
-
</p>
|
|
7
|
+
**The problem:** an AI agent starts writing before you agree on what it should do, then grades its own work.
|
|
14
8
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
You ask an AI for something real: a feature, a launch email, a migration plan. It goes
|
|
18
|
-
quiet, then hands you a finished thing. Did it understand you? What did it change? What
|
|
19
|
-
is wrong that you won't notice until next week?
|
|
20
|
-
|
|
21
|
-
**kaizen adds two checkpoints:**
|
|
22
|
-
|
|
23
|
-
1. **Before:** you see the plan and say yes or no. Nothing is built until you approve.
|
|
24
|
-
2. **After:** a *second* agent reviews the work. It never saw the first one's reasoning, so it has nothing to defend. It tells you what's wrong, numbered and graded.
|
|
25
|
-
|
|
26
|
-
You decide what gets fixed.
|
|
27
|
-
|
|
28
|
-
<p align="center"><img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/demo.gif" alt="kaizen shows a plan, you approve, a second agent reports two findings" width="760"></p>
|
|
9
|
+
**Kaizen:** it plans first and waits for your approval, then an agent that never saw the work reviews it.
|
|
29
10
|
|
|
30
11
|
## Install
|
|
31
12
|
|
|
13
|
+
### macOS and Linux
|
|
14
|
+
|
|
32
15
|
```bash
|
|
33
|
-
# macOS · Linux · WSL
|
|
34
16
|
curl -fsSL https://bun.sh/install | bash && ~/.bun/bin/bunx kaizen-agent
|
|
35
17
|
```
|
|
36
18
|
|
|
37
|
-
|
|
38
|
-
# Windows PowerShell
|
|
39
|
-
irm bun.sh/install.ps1 | iex; & "$env:USERPROFILE\.bun\bin\bunx.exe" kaizen-agent
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Already have Bun? `bunx kaizen-agent`. It sets itself up for every agent you already use.
|
|
43
|
-
|
|
44
|
-
Then, in any project:
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
/kaizen <what you want done>
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
That's it. No init and no config.
|
|
51
|
-
|
|
52
|
-
kaizen needs an AI coding agent to run it, such as [Claude Code](https://docs.claude.com/en/docs/claude-code); install one first. Git is needed for the board's commit and push.
|
|
53
|
-
|
|
54
|
-
## Use it
|
|
55
|
-
|
|
56
|
-
There are three ways in, all on the same runs:
|
|
57
|
-
|
|
58
|
-
| | How | What you do there |
|
|
59
|
-
|---|---|---|
|
|
60
|
-
| **Your agent** | `/kaizen <request>` in Claude Code, Codex, Cursor, … | Start a run and answer it in the chat. All commands are under [Commands](#commands). |
|
|
61
|
-
| **The board** | `kaizen web` opens it in the browser; `kaizen` in a terminal. `kaizen web --shortcut` once adds a launcher: Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows | Every project's ideas and runs. Start a run, approve or revise a plan, answer its questions, pick findings to fix, then commit and push. |
|
|
62
|
-
| **Kaizen Bot** | The [desktop app](#download-the-app), or `bun start` in `bot/` | Chat with the board: ask what's waiting on you, hand teammates their asks. The app starts the board itself; from `bot/`, run `kaizen web` first. See [Kaizen Bot](#kaizen-bot). |
|
|
63
|
-
|
|
64
|
-
Settings for a project or for all of them: `kaizen settings`. New version: `kaizen upgrade`.
|
|
65
|
-
|
|
66
|
-
## See it
|
|
67
|
-
|
|
68
|
-
```
|
|
69
|
-
you /kaizen rewrite the onboarding email — the new pricing starts on the 1st
|
|
70
|
-
|
|
71
|
-
kaizen Here is the plan.
|
|
72
|
-
|
|
73
|
-
This is a customer email, not a policy page.
|
|
74
|
-
Done means: a customer knows what changes, when, and what to do — in one read.
|
|
75
|
-
Wrong means: a claim we cannot stand behind, or someone thinks they are being
|
|
76
|
-
charged more than they are.
|
|
77
|
-
|
|
78
|
-
What I will do:
|
|
79
|
-
1. Rewrite the opening so the date and the amount come first
|
|
80
|
-
2. Add one line on what happens if they do nothing
|
|
81
|
-
3. Keep the existing sign-off and legal footer untouched
|
|
82
|
-
|
|
83
|
-
Approve this plan? [ approve ] [ change something ]
|
|
84
|
-
|
|
85
|
-
you approve
|
|
86
|
-
|
|
87
|
-
kaizen Done, and reviewed. Two things found:
|
|
88
|
-
|
|
89
|
-
1. paragraph 2 high "Your price will not change" is not true for annual
|
|
90
|
-
plans renewing after the 1st. Fix: name both cases.
|
|
91
|
-
2. subject line low 14 words, cut off on mobile. Fix: lead with the date.
|
|
92
|
-
|
|
93
|
-
Fix them? [ both ] [ just 1 ] [ leave it ]
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
Nobody told the reviewer to hunt for untrue claims. It worked out that this was a customer
|
|
97
|
-
email and reviewed it as one.
|
|
98
|
-
|
|
99
|
-
<details>
|
|
100
|
-
<summary><b>Code</b>: a bug in checkout</summary>
|
|
101
|
-
|
|
102
|
-
```
|
|
103
|
-
you /kaizen the discount field lets you enter more than 100%
|
|
104
|
-
|
|
105
|
-
kaizen Done, and reviewed.
|
|
106
|
-
Verified: ran the suite — 47 passed, 0 failed. Before the change: 44 passed.
|
|
107
|
-
|
|
108
|
-
1. cart.py:12 medium A discount of exactly 100 now returns 0 instead of
|
|
109
|
-
being rejected. Fix: use > not >=.
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
"It should work" is never accepted. The builder has to show the run and the numbers.
|
|
113
|
-
|
|
114
|
-
</details>
|
|
115
|
-
|
|
116
|
-
<details>
|
|
117
|
-
<summary><b>Marketing</b>: a landing page for a launch</summary>
|
|
118
|
-
|
|
119
|
-
```
|
|
120
|
-
you /kaizen landing page copy for the Pro tier launch
|
|
121
|
-
|
|
122
|
-
kaizen Done, and reviewed. Three things found:
|
|
123
|
-
|
|
124
|
-
1. benefits 3 high "Unlimited exports" — the shipped limit is 500/month.
|
|
125
|
-
2. headline medium Names a competitor. Legal reviews those; flagging, not fixing.
|
|
126
|
-
3. CTA low "Get started" appears on the free signup too. Ambiguous.
|
|
127
|
-
```
|
|
19
|
+
Already have Bun? Run `bunx kaizen-agent`.
|
|
128
20
|
|
|
129
|
-
|
|
21
|
+
You also need an AI coding agent such as [Claude Code](https://docs.claude.com/en/docs/claude-code), and Git for the board's commit and push.
|
|
130
22
|
|
|
131
|
-
|
|
23
|
+
### Windows
|
|
132
24
|
|
|
133
|
-
|
|
134
|
-
<summary><b>Research</b>: a decision memo</summary>
|
|
135
|
-
|
|
136
|
-
```
|
|
137
|
-
you /kaizen should we move from per-seat to usage-based pricing? write it up
|
|
138
|
-
|
|
139
|
-
kaizen Done, and reviewed. Two things found:
|
|
140
|
-
|
|
141
|
-
1. section 3 high The recommendation rests on 4 accounts out of 60. That
|
|
142
|
-
is not stated where the conclusion is.
|
|
143
|
-
2. section 2 medium Churned accounts were excluded with no reason given —
|
|
144
|
-
they are the ones the model treats worst.
|
|
145
|
-
```
|
|
25
|
+
Windows support is still in beta. In PowerShell:
|
|
146
26
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
</details>
|
|
150
|
-
|
|
151
|
-
<details>
|
|
152
|
-
<summary><b>Operations</b>: a migration runbook</summary>
|
|
153
|
-
|
|
154
|
-
```
|
|
155
|
-
you /kaizen runbook for moving the database to the new region on Saturday
|
|
156
|
-
|
|
157
|
-
kaizen Done, and reviewed. Two things found:
|
|
158
|
-
|
|
159
|
-
1. step 7 critical No rollback after the DNS cutover, and it is not marked
|
|
160
|
-
as the point of no return. Fix: mark it, add the TTL wait.
|
|
161
|
-
2. step 3 high Says "restore the backup" but never says which backup or
|
|
162
|
-
how to confirm it is the right one.
|
|
27
|
+
```powershell
|
|
28
|
+
irm bun.sh/install.ps1 | iex; & "$env:USERPROFILE\.bun\bin\bunx.exe" kaizen-agent
|
|
163
29
|
```
|
|
164
30
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
## How it works
|
|
168
|
-
|
|
169
|
-
| | Stage | |
|
|
170
|
-
|---|---|---|
|
|
171
|
-
| 1 | **Plan** | Reads your request, writes a plan. Changes nothing. |
|
|
172
|
-
| 2 | **You approve** | Say yes, or say what to change. |
|
|
173
|
-
| 3 | **Build** | Does the work, then *proves* it: runs the tests, checks each claim, walks each step. |
|
|
174
|
-
| 4 | **Review** | A fresh agent checks the work and lists what's wrong. |
|
|
175
|
-
| 5 | **Fix** | Serious findings get fixed and rechecked. The rest comes to you, never silently dropped. |
|
|
31
|
+
Already have Bun? Run `bunx kaizen-agent`. As on macOS and Linux you need an AI coding agent; Git is only needed for the board's commit and push.
|
|
176
32
|
|
|
177
|
-
##
|
|
33
|
+
## Use the board
|
|
178
34
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
- **A board for all your work.** Run `kaizen` in a terminal, or `kaizen web` in the browser. Ideas, plans, builds and reviews across every project, updating live. Start a run, approve or revise a plan, answer its questions, pick which findings to fix, then commit and push, all without opening a terminal.
|
|
182
|
-
- **Requests come to you.** Paste a Google Sheet or Doc link and every request in it lands on the board as an idea.
|
|
183
|
-
- **Notes and voice, offline.** A markdown notebook beside the board, plus dictation on every field. Whisper runs in the page, so audio never leaves your machine.
|
|
184
|
-
- **Plain files, any agent.** Every run is a folder of markdown in `.kaizen/`. Start in Claude Code, finish in Codex, Antigravity, OpenCode, Cursor or Gemini CLI.
|
|
185
|
-
|
|
186
|
-
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/c858bb6d666a65780d09c06032c8819489b87da5/assets/board.png" alt="kaizen web board" width="760">
|
|
187
|
-
|
|
188
|
-
## Kaizen Bot
|
|
189
|
-
|
|
190
|
-
A chat front end for your board, with AI teammates for every division. Ask **Chief** what's
|
|
191
|
-
waiting on you, what's running, and what a plan or review says. Hand Sales Outbound,
|
|
192
|
-
Marketing, Customer Service or Account Manager their team's asks, and they turn them into board ideas
|
|
193
|
-
and runs. The board still does the real work: plan, approval, build, review.
|
|
194
|
-
|
|
195
|
-
- **Nothing happens behind your back.** An agent may read the board, add an idea or a note, remember a fact and draft a message. Starting a run, approving a plan or fixing findings waits on a card until you click Approve. It never turns on Yolo and never commits.
|
|
196
|
-
- **Nothing is ever sent.** Emails, posts and replies are drafts: approve one, then copy it or export them all as Markdown.
|
|
197
|
-
- **Teammates and routines.** One agent can ask another, and any agent can run a task on a schedule: *every weekday at 7, brief me on the board*.
|
|
198
|
-
|
|
199
|
-
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/bot.png" alt="Kaizen Bot: Account Manager holds a drafted email on a card until you approve it" width="760">
|
|
200
|
-
|
|
201
|
-
Kaizen Bot lives in the repo's `bot/` folder, not the npm package. With the board running (`kaizen web`):
|
|
202
|
-
|
|
203
|
-
```sh
|
|
204
|
-
git clone https://github.com/hfadhlullah/kaizen && cd kaizen/bot
|
|
205
|
-
cp .env.example .env # set PROVIDER, MODEL and that provider's key
|
|
206
|
-
bun start # http://127.0.0.1:7430
|
|
35
|
+
```bash
|
|
36
|
+
kaizen web
|
|
207
37
|
```
|
|
208
38
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
Kaizen Bot also comes as a desktop app, with no Bun or repo needed. Download yours from the
|
|
212
|
-
[latest release](https://github.com/hfadhlullah/kaizen/releases/latest):
|
|
213
|
-
|
|
214
|
-
| OS | File | Install |
|
|
215
|
-
|---|---|---|
|
|
216
|
-
| macOS | `Kaizen-Bot-macos-arm64.dmg` (Apple Silicon), `Kaizen-Bot-macos-x64.dmg` (Intel) | Open it, drag Kaizen Bot to Applications |
|
|
217
|
-
| Windows | `Kaizen-Bot-Setup-windows-x64.exe` | Run the setup; it asks for your provider and API key |
|
|
218
|
-
| Linux | `kaizen-bot-linux-x64`, `kaizen-bot-linux-arm64` | `chmod +x`, then run it |
|
|
219
|
-
|
|
220
|
-
The first time, it creates `~/.kaizen-bot/.env` for your provider and key. After that it
|
|
221
|
-
starts the board if `kaizen` is installed, and opens the chat in its own window; quit it from
|
|
222
|
-
Settings. The app is unsigned, so macOS and Windows warn on first open. How to get past that,
|
|
223
|
-
and how to uninstall, is in
|
|
224
|
-
[Download the app](https://github.com/hfadhlullah/kaizen/blob/main/bot/README.md#download-the-app).
|
|
225
|
-
|
|
226
|
-
Anthropic, OpenAI and Requesty are supported. Setup, privacy and settings are in the [Kaizen Bot README](https://github.com/hfadhlullah/kaizen/blob/main/bot/README.md).
|
|
227
|
-
|
|
228
|
-
## Commands
|
|
229
|
-
|
|
230
|
-
| Command | |
|
|
231
|
-
|---|---|
|
|
232
|
-
| `/kaizen <request>` | Start a run |
|
|
233
|
-
| `/kaizen-plan <request>` | Plan only |
|
|
234
|
-
| `/kaizen-auto <request>` | No stops; report at the end |
|
|
235
|
-
| `/kaizen-lite <request>` | Every stage in this session: cheaper |
|
|
236
|
-
| `/kaizen-full <request>` | Every stage its own cold agent: the independent review |
|
|
237
|
-
| `/kaizen-review [target]` | Review something that already exists |
|
|
238
|
-
| `/kaizen-approve` · `/kaizen-reject <reason>` | Answer whatever the run is waiting on |
|
|
239
|
-
| `/kaizen-run` · `/kaizen-abort` | Carry on with an approved plan, or drop the run |
|
|
240
|
-
| `/kaizen-status` · `/kaizen-backlog` | What's waiting, in flight, done, or left over |
|
|
241
|
-
| `/kaizen-gather` | Pull new requests from your sources onto the board |
|
|
242
|
-
| `/kaizen-config` | Change this project's settings |
|
|
243
|
-
| `/kaizen-help` | The full card |
|
|
244
|
-
|
|
245
|
-
In a terminal: `kaizen` (dashboard), `kaizen web` (board), `kaizen settings`, `kaizen upgrade`.
|
|
246
|
-
|
|
247
|
-
Stuck on 1.18.0 or older? If `kaizen upgrade` says *could not pull* and stays on the old version, run this once: `bunx kaizen-agent@latest`. Your old install is kept as `~/kaizen.old`; from then on `kaizen upgrade` works by itself.
|
|
248
|
-
|
|
249
|
-
## Bundled skills
|
|
250
|
-
|
|
251
|
-
Installed alongside kaizen; your agent uses them when a request fits. Everything they make lives in `~/.kaizen/`.
|
|
252
|
-
|
|
253
|
-
| Skill | What it makes |
|
|
254
|
-
|---|---|
|
|
255
|
-
| `story-deck` | A cinematic storytelling pitch deck (A5 `.pptx`): story first, the product last. Output in `~/.kaizen/decks/`. |
|
|
256
|
-
| `story-video` | A publish-ready video from a prompt, photos, screenshots, a deck or footage: narration, captions, sound design, music, mastering, 16:9 and 9:16, revisable. Output in `~/.kaizen/videos/`. Works with no accounts (free voice, synthesized sounds); an ElevenLabs key unlocks natural voices. |
|
|
257
|
-
|
|
258
|
-
Each skill has a `scripts/check.sh` that lists what it needs (Node, Python, ffmpeg, LibreOffice).
|
|
39
|
+
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/board-v2.png" alt="kaizen board" width="760">
|
|
259
40
|
|
|
260
|
-
|
|
41
|
+
The board opens in your browser. The side menu holds the board, notes, the icebox of gathered requests and settings, with a light and dark switch. From there you can start a run, approve or revise its plan, pick which review findings to fix, then commit and push.
|
|
261
42
|
|
|
262
|
-
|
|
263
|
-
- [Kaizen Bot](https://github.com/hfadhlullah/kaizen/blob/main/bot/README.md): the chat front end, its agents, routines and privacy
|
|
264
|
-
- [`spec.md`](https://github.com/hfadhlullah/kaizen/blob/main/skills/kaizen/spec.md): the contract every stage follows
|
|
265
|
-
- [`adapters.md`](https://github.com/hfadhlullah/kaizen/blob/main/skills/kaizen/adapters.md): running outside Claude Code, with Codex and Antigravity as the worked examples. OpenCode, Cursor and Gemini CLI load the same skill folder
|
|
266
|
-
- Found a bug? `kaizen issue`, or *Report an issue* on the board, opens a GitHub bug form with your system details filled in. Nothing is sent until you press Submit on GitHub.
|
|
43
|
+
To add a launcher (Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows), run `kaizen web --shortcut` once.
|
|
267
44
|
|
|
268
45
|
## License
|
|
269
46
|
|
|
270
|
-
MIT
|
|
47
|
+
MIT
|
|
@@ -77,7 +77,9 @@ Two checks are universal:
|
|
|
77
77
|
trust the report. A check that passed at that baseline and fails now is `high` at
|
|
78
78
|
minimum, whatever else the work achieved. If the builder recorded no baseline, that
|
|
79
79
|
absence is the finding: `03-impl.md: high: no baseline recorded, regression
|
|
80
|
-
unprovable. Run the existing suite and compare.`
|
|
80
|
+
unprovable. Run the existing suite and compare.` A `03-impl.md` with no
|
|
81
|
+
`## Preview` section, or none of its `### How to test` steps, is `medium`: the
|
|
82
|
+
user has no way to try the work.
|
|
81
83
|
|
|
82
84
|
Then the track's own checks:
|
|
83
85
|
|
package/cli/build-web.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// `bun run build:web`: bundles the notebook's script and CodeMirror into
|
|
2
|
-
// web/notebook.js,
|
|
2
|
+
// web/notebook.js, and the in-card terminal and xterm.js into web/term.js (+ its
|
|
3
|
+
// stylesheet, web/term.css), all committed, so an install or a checkout serves it with no
|
|
3
4
|
// install step and no network. The first line is a hash of what it was built from;
|
|
4
|
-
// cli/notes.test.ts fails when
|
|
5
|
+
// cli/notes.test.ts fails when a committed file no longer matches.
|
|
5
6
|
import { createHash } from "node:crypto";
|
|
6
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
8
|
import { join, dirname } from "node:path";
|
|
8
9
|
|
|
9
10
|
const root = dirname(import.meta.dir);
|
|
10
|
-
const SOURCES = ["web/notebook.ts", "web/notes-lib.ts"];
|
|
11
|
+
const SOURCES = ["web/notebook.ts", "web/notes-lib.ts", "web/term.ts"];
|
|
11
12
|
export const OUT = join(root, "web", "notebook.js");
|
|
13
|
+
export const TERM_OUT = join(root, "web", "term.js");
|
|
12
14
|
|
|
13
15
|
// The sources and the exact dependency versions: a CodeMirror bump is a rebuild too.
|
|
14
16
|
export function sourceHash() {
|
|
@@ -17,11 +19,16 @@ export function sourceHash() {
|
|
|
17
19
|
h.update(JSON.stringify(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).devDependencies ?? {}));
|
|
18
20
|
return h.digest("hex").slice(0, 16);
|
|
19
21
|
}
|
|
20
|
-
export const banner = () => `/* kaizen
|
|
22
|
+
export const banner = (what = "notebook", from = "web/notebook.ts") => `/* kaizen ${what} ${sourceHash()} -- generated by bun run build:web from ${from}; do not edit */`;
|
|
21
23
|
|
|
22
24
|
if (import.meta.main) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
for (const [src, out, what] of [["web/notebook.ts", OUT, "notebook"], ["web/term.ts", TERM_OUT, "terminal"]] as const) {
|
|
26
|
+
// The terminal is a classic script on the board's page: wrapped, or its top-level
|
|
27
|
+
// names collide with the board's own globals and the whole file is refused.
|
|
28
|
+
const r = await Bun.build({ entrypoints: [join(root, src)], target: "browser", minify: true, format: what === "terminal" ? "iife" : "esm" });
|
|
29
|
+
if (!r.success) { for (const l of r.logs) console.error(l); process.exit(1); }
|
|
30
|
+
writeFileSync(out, banner(what, src) + "\n" + await r.outputs[0]!.text());
|
|
31
|
+
console.log(` ${src.replace(".ts", ".js")} ${(Bun.file(out).size / 1024).toFixed(0)} KB`);
|
|
32
|
+
}
|
|
33
|
+
copyFileSync(join(root, "node_modules", "@xterm", "xterm", "css", "xterm.css"), join(root, "web", "term.css"));
|
|
27
34
|
}
|
package/cli/install.ts
CHANGED
|
@@ -79,7 +79,7 @@ const found = (r: string) =>
|
|
|
79
79
|
// Request sources. Dispatched on the first word alone and ahead of every flag
|
|
80
80
|
// check below, so a label or a link that happens to read `web` or `uninstall` is
|
|
81
81
|
// only ever a label.
|
|
82
|
-
if (Bun.argv[2] === "sources" || Bun.argv[2] === "gather") {
|
|
82
|
+
if (Bun.argv[2] === "sources" || Bun.argv[2] === "gather" || Bun.argv[2] === "icebox") {
|
|
83
83
|
const { locate, label } = await import("./state.ts");
|
|
84
84
|
const src = await import("./sources.ts");
|
|
85
85
|
const state = locate() ?? join(home, ".kaizen");
|
|
@@ -87,8 +87,27 @@ if (Bun.argv[2] === "sources" || Bun.argv[2] === "gather") {
|
|
|
87
87
|
const where = c.dim(`project ${label(state)}`);
|
|
88
88
|
const [, , cmd, op, url = "", ...rest] = Bun.argv;
|
|
89
89
|
const line = (r: { url: string; ok: boolean; note: string }) => ` ${r.ok ? c.green("ok ") : "fail"} ${r.url} ${c.dim(r.note)}`;
|
|
90
|
+
const waiting = c.dim(" new requests wait in the icebox — kaizen icebox");
|
|
90
91
|
try {
|
|
91
|
-
if (cmd === "
|
|
92
|
+
if (cmd === "icebox" && (op === "promote" || op === "dismiss")) {
|
|
93
|
+
// Ids only on the command line. The idea's text, which an agent may have written
|
|
94
|
+
// from outside text, comes from a file and never through a shell.
|
|
95
|
+
const ids = url.split(",").map((i) => i.trim()).filter(Boolean);
|
|
96
|
+
const at = rest.indexOf("--text-file");
|
|
97
|
+
if (at >= 0 && !rest[at + 1]) { console.log("\n --text-file needs a path\n"); process.exit(1); }
|
|
98
|
+
const text = at >= 0 ? readFileSync(rest[at + 1]!, "utf8") : undefined;
|
|
99
|
+
const why = op === "promote" ? src.promote(state, ids, text) : src.dismiss(state, ids);
|
|
100
|
+
if (why) { console.log(`\n ${why}\n`); process.exit(1); }
|
|
101
|
+
console.log(`\n ${op === "promote" ? "promoted to Ideas" : "dismissed"}: ${ids.join(", ")}\n ${where}\n`);
|
|
102
|
+
} else if (cmd === "icebox") {
|
|
103
|
+
const all = src.readIcebox(state);
|
|
104
|
+
const fresh = all.filter((s) => s.status === "new");
|
|
105
|
+
const names = new Map(src.readSources(state).map((s) => [s.url, s.label || s.url]));
|
|
106
|
+
console.log(`\n ${where} ${c.dim(`${fresh.length} waiting, ${all.length - fresh.length} promoted or dismissed`)}`);
|
|
107
|
+
console.log(fresh.length
|
|
108
|
+
? "\n" + fresh.map((s) => ` ${s.id} [${s.category || "Uncategorised"}] ${s.text} ${c.dim(`(${names.get(s.source) ?? s.source})`)}${s.notes ? "\n " + c.dim(s.notes.replace(/\n/g, " · ")) : ""}`).join("\n") + "\n"
|
|
109
|
+
: `\n the icebox is empty ${c.dim("— kaizen gather")}\n`);
|
|
110
|
+
} else if (cmd === "gather" && op === "--stdin") {
|
|
92
111
|
// What an agent read from a link kaizen could not: one request per line, an
|
|
93
112
|
// optional tab, then notes. Only for a link already listed.
|
|
94
113
|
if (!src.readSources(state).some((s) => s.url === url)) { console.log(`
|
|
@@ -98,20 +117,22 @@ if (Bun.argv[2] === "sources" || Bun.argv[2] === "gather") {
|
|
|
98
117
|
const [text = "", ...notes] = l.split("\t");
|
|
99
118
|
return { text, notes: notes.join(" ") };
|
|
100
119
|
});
|
|
101
|
-
console.log(line(src.record(state, url, src.ingest(state, url, items))));
|
|
120
|
+
console.log(line(src.record(state, url, src.ingest(state, url, items))) + "\n" + waiting);
|
|
102
121
|
} else if (cmd === "gather") {
|
|
103
122
|
const results = await src.gatherAll(state);
|
|
104
|
-
console.log(results.length ? results.map(line).join("\n") : `\n no sources yet ${c.dim("— kaizen sources add <link>")}\n`);
|
|
123
|
+
console.log(results.length ? results.map(line).join("\n") + "\n" + waiting : `\n no sources yet ${c.dim("— kaizen sources add <link>")}\n`);
|
|
105
124
|
if (results.some((r) => !r.ok)) process.exit(1);
|
|
106
125
|
} else if (op === "add" || op === "rm") {
|
|
107
|
-
const
|
|
126
|
+
const at = rest.indexOf("--category");
|
|
127
|
+
const category = at >= 0 ? rest.splice(at, 2)[1] : undefined;
|
|
128
|
+
const why = op === "add" ? src.addSource(state, url, rest.join(" "), category) : src.removeSource(state, url);
|
|
108
129
|
if (why) { console.log(`\n ${why}\n`); process.exit(1); }
|
|
109
130
|
console.log(`\n ${op === "add" ? "added" : "removed"} ${url}\n ${where}\n`);
|
|
110
131
|
} else {
|
|
111
132
|
const list = src.readSources(state);
|
|
112
133
|
console.log(`\n ${where}`);
|
|
113
134
|
console.log(list.length
|
|
114
|
-
? "\n" + list.map((s) => ` ${s.url}${s.label ? ` ${s.label}` : ""}\n ${c.dim(s.last ? `${s.last.at.slice(0, 16).replace("T", " ")} ${s.last.note}` : "not gathered yet")}`).join("\n") + "\n"
|
|
135
|
+
? "\n" + list.map((s) => ` ${s.url}${s.label ? ` ${s.label}` : ""}${s.category ? c.dim(` [${s.category}]`) : ""}\n ${c.dim(s.last ? `${s.last.at.slice(0, 16).replace("T", " ")} ${s.last.note}` : "not gathered yet")}`).join("\n") + "\n"
|
|
115
136
|
: `\n no sources yet ${c.dim("— kaizen sources add <link>")}\n`);
|
|
116
137
|
}
|
|
117
138
|
} catch (e) { console.log(`\n ${(e as Error).message}\n`); process.exit(1); }
|
package/cli/notes.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join, dirname } from "node:path";
|
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { safeName, listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
|
|
8
8
|
import { wikilinks, tags, resolve, safeUrl, tree, uncode, freshName, parseTable, boardLink, resolveRun, anchorWords } from "../web/notes-lib.ts";
|
|
9
|
-
import { sourceHash, OUT } from "./build-web.ts";
|
|
9
|
+
import { sourceHash, OUT, TERM_OUT } from "./build-web.ts";
|
|
10
10
|
|
|
11
11
|
let state = "";
|
|
12
12
|
beforeEach(() => { state = join(mkdtempSync(join(tmpdir(), "kaizen-notes-")), ".kaizen"); mkdirSync(state); });
|
|
@@ -215,7 +215,21 @@ test("the notebook's design tokens are the board's, line for line", () => {
|
|
|
215
215
|
expect(mascot("notes.html")).toBe(mascot("board.html")!);
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
test("
|
|
219
|
-
const
|
|
220
|
-
|
|
218
|
+
test("the side menu is the board's on the notebook, line for line", () => {
|
|
219
|
+
const between = (f: string, from: string, to: string) => {
|
|
220
|
+
const s = readFileSync(join(root, "web", f), "utf8");
|
|
221
|
+
const at = s.indexOf(from);
|
|
222
|
+
expect(at).toBeGreaterThan(-1);
|
|
223
|
+
return s.slice(at, s.indexOf(to, at) + to.length);
|
|
224
|
+
};
|
|
225
|
+
for (const [from, to] of [["/* side menu:", "/* end side menu */"], ['<nav class="rail"', "</script>"]] as const)
|
|
226
|
+
expect(between("notes.html", from, to)).toBe(between("board.html", from, to));
|
|
227
|
+
const syms = (f: string) => ["board", "note", "link", "gear", "side", "sun", "moon"].map((n) => readFileSync(join(root, "web", f), "utf8").split("\n").find((l) => l.startsWith(`<symbol id="i-${n}"`)));
|
|
228
|
+
expect(syms("notes.html")).toEqual(syms("board.html"));
|
|
229
|
+
expect(syms("board.html").every(Boolean)).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("web/notebook.js and web/term.js were built from the sources as they are (bun run build:web)", () => {
|
|
233
|
+
expect(readFileSync(OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen notebook ${sourceHash()} `);
|
|
234
|
+
expect(readFileSync(TERM_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen terminal ${sourceHash()} `);
|
|
221
235
|
});
|
package/cli/pty.test.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The in-card agent (agent.launch: card): the setting, and the real board running a
|
|
2
|
+
// fake agent in a pseudo-terminal, reached only from its own origin.
|
|
3
|
+
import { test, expect } from "bun:test";
|
|
4
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, realpathSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { launchMode, closeSessions, readSessions } from "./state.ts";
|
|
8
|
+
import { loginEnv } from "./pty.ts";
|
|
9
|
+
|
|
10
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "kaizen-pty-")));
|
|
11
|
+
|
|
12
|
+
test("agent.launch: card unless the config chain says terminal, comments between keys allowed", () => {
|
|
13
|
+
const p = join(root, "mode");
|
|
14
|
+
mkdirSync(join(p, ".kaizen"), { recursive: true });
|
|
15
|
+
// The global file is the real one, so only the project file is varied here.
|
|
16
|
+
writeFileSync(join(p, ".kaizen", "config.yml"), "agent:\n # where\n default: auto\n # the board\n launch: terminal\n");
|
|
17
|
+
expect(launchMode(p)).toBe("terminal");
|
|
18
|
+
writeFileSync(join(p, ".kaizen", "config.yml"), "agent:\n launch: card\n");
|
|
19
|
+
expect(launchMode(p)).toBe("card");
|
|
20
|
+
writeFileSync(join(p, ".kaizen", "config.yml"), "agent.launch: terminal\n");
|
|
21
|
+
expect(launchMode(p)).toBe("terminal");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test.skipIf(process.platform === "win32")("the board runs the agent in the card: output, input, origin check, and closing", async () => {
|
|
25
|
+
const homeDir = join(root, "home"), proj = join(homeDir, "a"), st = join(proj, ".kaizen"), bin = join(root, "bin");
|
|
26
|
+
for (const d of [st, join(homeDir, ".kaizen"), join(homeDir, ".claude"), bin]) mkdirSync(d, { recursive: true });
|
|
27
|
+
writeFileSync(join(homeDir, ".kaizen", "projects"), proj + "\n");
|
|
28
|
+
writeFileSync(join(st, "config.yml"), "agent:\n default: claude\n launch: card\n");
|
|
29
|
+
writeFileSync(join(st, "inbox.md"), "# Inbox\n\n- open: say hello\n");
|
|
30
|
+
// A stand-in for Claude Code: says how it was called, then echoes each line it is sent.
|
|
31
|
+
writeFileSync(join(bin, "claude"), '#!/bin/sh\necho "args:$*"\nwhile read x; do echo "got:$x"; done\n', { mode: 0o755 });
|
|
32
|
+
// Started as a launcher starts it: a bare PATH, the agent reachable only through
|
|
33
|
+
// the PATH the user's login shell builds.
|
|
34
|
+
const sh = join(root, "loginsh");
|
|
35
|
+
writeFileSync(sh, `#!/bin/sh\nPATH=${bin}:$PATH exec /bin/sh -c "$4"\n`, { mode: 0o755 });
|
|
36
|
+
const port = 20000 + Math.floor(Math.random() * 20000);
|
|
37
|
+
const repo = join(import.meta.dir, "..");
|
|
38
|
+
const proc = Bun.spawn([process.execPath, join(repo, "cli", "install.ts"), "web", "--port", String(port), "--no-open", "--foreground"], {
|
|
39
|
+
cwd: proj, stdout: "ignore", stderr: "ignore",
|
|
40
|
+
env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir, KAIZEN_HOME: repo, CLAUDE_CONFIG_DIR: join(homeDir, ".claude"),
|
|
41
|
+
PATH: "/usr/bin:/bin", SHELL: sh, DISPLAY: "", WAYLAND_DISPLAY: "", TERMINAL: "", TMUX: "" },
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
const at = `http://127.0.0.1:${port}`;
|
|
45
|
+
for (let i = 0; ; i++) { try { await fetch(at + "/state"); break; } catch { if (i > 100) throw new Error("board did not start"); await Bun.sleep(50); } }
|
|
46
|
+
|
|
47
|
+
// G-02: no terminal window anywhere (none could be found), yet the launch worked.
|
|
48
|
+
const r = await (await fetch(at + "/run", { method: "POST", body: JSON.stringify({ dir: st, text: "say hello" }) })).json() as any;
|
|
49
|
+
expect(r.ok).toBe(true);
|
|
50
|
+
expect(r.pty).toMatch(/^[\w-]+$/);
|
|
51
|
+
const card = ((await (await fetch(at + "/state")).json()) as any).cards.find((c: any) => c.text === "say hello");
|
|
52
|
+
expect(card.pty).toEqual({ key: r.pty, alive: true });
|
|
53
|
+
|
|
54
|
+
// G-01: no Origin, a foreign one, and another local port are all refused.
|
|
55
|
+
const url = `${at}/pty?key=${r.pty}`, up = { upgrade: "websocket", connection: "Upgrade", "sec-websocket-version": "13", "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==" };
|
|
56
|
+
expect((await fetch(url, { headers: up })).status).toBe(403);
|
|
57
|
+
expect((await fetch(url, { headers: { ...up, origin: "https://evil.example" } })).status).toBe(403);
|
|
58
|
+
expect((await fetch(url, { headers: { ...up, origin: `http://127.0.0.1:${port + 1}` } })).status).toBe(403);
|
|
59
|
+
expect((await fetch(`${at}/pty?key=nope`, { headers: { ...up, origin: at } })).status).toBe(404);
|
|
60
|
+
|
|
61
|
+
// The board's own origin: the screen so far, then typing goes through.
|
|
62
|
+
let seen = "";
|
|
63
|
+
const ws = new WebSocket(url.replace("http", "ws"), { headers: { origin: at } } as any);
|
|
64
|
+
const closed = new Promise((ok) => { ws.onclose = ok; });
|
|
65
|
+
ws.onmessage = (e) => { seen += String(e.data); };
|
|
66
|
+
await new Promise((ok, no) => { ws.onopen = ok; ws.onerror = no; });
|
|
67
|
+
const until = async (re: RegExp) => { for (let i = 0; i < 100 && !re.test(seen); i++) await Bun.sleep(30); expect(seen).toMatch(re); };
|
|
68
|
+
await until(/args:.*--session-id [\w-]+ .*\/kaizen say hello/);
|
|
69
|
+
ws.send(JSON.stringify({ t: "r", c: 100, r: 30 }));
|
|
70
|
+
ws.send(JSON.stringify({ t: "i", d: "ping\r" }));
|
|
71
|
+
await until(/got:ping/);
|
|
72
|
+
|
|
73
|
+
// G-03: the session record is the usual one, its PID file the agent's own, so a
|
|
74
|
+
// run that owns it closes it like a terminal launch.
|
|
75
|
+
const [s] = readSessions(st);
|
|
76
|
+
expect(s!.cmd).toBe("claude");
|
|
77
|
+
const pid = Number(readFileSync(join(st, "sessions", s!.key + ".pid"), "utf8"));
|
|
78
|
+
expect(process.kill(pid, 0)).toBe(true);
|
|
79
|
+
mkdirSync(join(st, "runs", "r1"), { recursive: true });
|
|
80
|
+
writeFileSync(join(st, "runs", "r1", "00-request.md"), "say hello\n");
|
|
81
|
+
expect(closeSessions(st, "r1")).toBe(1);
|
|
82
|
+
// The page tells an agent that ended from a board that went away by this reason.
|
|
83
|
+
expect(((await closed) as CloseEvent).reason).toBe("ended");
|
|
84
|
+
expect(existsSync(join(st, "sessions", s!.key + ".pid"))).toBe(false);
|
|
85
|
+
let gone = false;
|
|
86
|
+
for (let i = 0; i < 50 && !gone; i++) { try { process.kill(pid, 0); await Bun.sleep(30); } catch { gone = true; } }
|
|
87
|
+
expect(gone).toBe(true);
|
|
88
|
+
|
|
89
|
+
// Resume: refused while the run waits on a decision, then the run's own conversation
|
|
90
|
+
// continues in a new in-card session, and a second press is refused while it lives.
|
|
91
|
+
const resume = async () => { const x = await fetch(at + "/resume", { method: "POST", body: JSON.stringify({ dir: st, id: "r1" }) }); return { status: x.status, ...(await x.json() as any) }; };
|
|
92
|
+
writeFileSync(join(st, "runs", "r1", "state.json"), JSON.stringify({ id: "r1", stage: "plan", awaiting: "approvals.plan" }));
|
|
93
|
+
expect((await resume()).status).toBe(409);
|
|
94
|
+
writeFileSync(join(st, "runs", "r1", "state.json"), JSON.stringify({ id: "r1", stage: "build", awaiting: null }));
|
|
95
|
+
// Claude resumes by id only when the conversation's transcript is on disk.
|
|
96
|
+
const tr = join(homeDir, ".claude", "projects", proj.replace(/[^A-Za-z0-9]/g, "-"));
|
|
97
|
+
mkdirSync(tr, { recursive: true });
|
|
98
|
+
writeFileSync(join(tr, s!.sessionId + ".jsonl"), "{}\n");
|
|
99
|
+
const again = await resume();
|
|
100
|
+
expect(again).toMatchObject({ status: 200, ok: true });
|
|
101
|
+
seen = "";
|
|
102
|
+
const ws2 = new WebSocket(`${at}/pty?key=${again.pty}`.replace("http", "ws"), { headers: { origin: at } } as any);
|
|
103
|
+
ws2.onmessage = (e) => { seen += String(e.data); };
|
|
104
|
+
await new Promise((ok, no) => { ws2.onopen = ok; ws2.onerror = no; });
|
|
105
|
+
await until(new RegExp(`args:.*--resume ${s!.sessionId} .*/kaizen run r1: continue`));
|
|
106
|
+
expect((await resume()).status).toBe(409);
|
|
107
|
+
ws2.close();
|
|
108
|
+
} finally { proc.kill(); await proc.exited; }
|
|
109
|
+
}, 30_000);
|
|
110
|
+
|
|
111
|
+
test.skipIf(process.platform === "win32")("a board started from a launcher takes the login shell's PATH and exports, not its own bare ones", () => {
|
|
112
|
+
const bin = join(root, "shells");
|
|
113
|
+
mkdirSync(bin, { recursive: true });
|
|
114
|
+
// A profile that greets on stdout before the environment, as many do.
|
|
115
|
+
writeFileSync(join(bin, "fakesh"), '#!/bin/sh\necho "welcome back"\n[ "$1 $2 $3" = "-l -i -c" ] || exit 9\nFROM_PROFILE=yes PATH=/opt/brew/bin:$PATH PWD=/elsewhere exec /bin/sh -c "$4"\n', { mode: 0o755 });
|
|
116
|
+
writeFileSync(join(bin, "badsh"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
|
117
|
+
const e = loginEnv(join(bin, "fakesh"))!;
|
|
118
|
+
expect(e.FROM_PROFILE).toBe("yes");
|
|
119
|
+
expect(e.PATH.split(":")[0]).toBe("/opt/brew/bin");
|
|
120
|
+
expect(loginEnv(join(bin, "badsh"))).toBeNull();
|
|
121
|
+
expect(loginEnv(join(bin, "missing"))).toBeNull();
|
|
122
|
+
// Into a process started with a launcher's bare PATH and its own setting: the
|
|
123
|
+
// board's PATH first, the profile's added after it, the board's own variable not overridden,
|
|
124
|
+
// the shell's PWD not taken.
|
|
125
|
+
const pty = JSON.stringify(join(import.meta.dir, "pty.ts"));
|
|
126
|
+
const out = Bun.spawnSync([process.execPath, "-e", `import { adoptLoginEnv } from ${pty}; adoptLoginEnv(); console.log(JSON.stringify([process.env.PATH, process.env.FROM_PROFILE, process.env.KEEP, process.env.PWD]))`], {
|
|
127
|
+
env: { SHELL: join(bin, "fakesh"), PATH: "/usr/bin:/bin", KEEP: "mine", FROM_PROFILE: undefined as any, HOME: root },
|
|
128
|
+
}).stdout.toString();
|
|
129
|
+
const [path, from, keep, pwd] = JSON.parse(out);
|
|
130
|
+
expect(path.split(":").slice(0, 3)).toEqual(["/usr/bin", "/bin", "/opt/brew/bin"]);
|
|
131
|
+
expect([from, keep]).toEqual(["yes", "mine"]);
|
|
132
|
+
expect(pwd).not.toBe("/elsewhere");
|
|
133
|
+
});
|