@monster-orange/den 0.4.8 → 0.4.10
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/CHANGELOG.md +105 -0
- package/README.md +7 -7
- package/docs/distributions.md +128 -1
- package/package.json +1 -1
- package/src/shared/agents/den-claude.ts +38 -3
- package/src/shared/agents/guardrails/hooks.json +23 -0
- package/src/shared/agents/guardrails/instructions.md +23 -0
- package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
- package/src/shared/agents/guardrails/stop-hook.ts +48 -2
- package/src/shared/agents/guardrails.ts +38 -7
- package/src/shared/agents/handoff.ts +118 -0
- package/src/shared/agents/records.ts +13 -1
- package/src/shared/agents/rotation.ts +371 -0
- package/src/shared/agents/runtime.ts +15 -0
- package/src/shared/den-config.ts +42 -1
- package/src/shared/git/gh-pr-state.ts +44 -0
- package/src/shared/usage/report.ts +117 -4
- package/src/shared/usage/transcript.ts +62 -5
- package/src/ui/cli/agent-ops.ts +196 -7
- package/src/ui/cli/agents-command.ts +75 -11
- package/src/ui/cli/usage-command.ts +95 -11
- package/src/ui/server/agent-routes.ts +19 -2
- package/src/ui/server/public/agents-page.js +78 -13
- package/src/ui/server/public/i18n/es.js +12 -0
- package/src/ui/server/public/i18n/ja.js +11 -0
- package/src/ui/server/public/shell.js +13 -0
- package/src/ui/server/route-classes.ts +6 -0
- package/src/ui/server/session-routes.ts +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,111 @@ All notable changes to Den are documented here. Each release lists the
|
|
|
4
4
|
config and manifest changes a distribution must make to move to it under
|
|
5
5
|
**Migrations**.
|
|
6
6
|
|
|
7
|
+
## [0.4.10] - 2026-11-07
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **A confirmed merge stays confirmed.** Once `gh pr view` says an agent's
|
|
12
|
+
pull request is MERGED, its record keeps that (`prMergeConfirmed`), and
|
|
13
|
+
`usage` doesn't ask GitHub about it again. A later run that can't check
|
|
14
|
+
no longer drops it. A merged flag from before 0.4.10, with no
|
|
15
|
+
confirmation, is checked again.
|
|
16
|
+
- **`usage agents` counts only real asks.** "Asked GitHub for the state of
|
|
17
|
+
N pull requests" counts the times gh ran. When gh is not installed, it
|
|
18
|
+
says "Couldn't check N merges: gh is not installed." instead.
|
|
19
|
+
- **The PR column** shows the confirmed pull request, with "(merged)", when
|
|
20
|
+
the agent merged one it didn't open.
|
|
21
|
+
- **A bare `gh pr merge`** (the branch's own pull request) is confirmed by
|
|
22
|
+
the number gh gives: the check is now `gh pr view --json number,state`.
|
|
23
|
+
With no number, it is not marked; an empty ref is never a key.
|
|
24
|
+
- **Dropping a mark drops its rotation.** When gh no longer confirms a
|
|
25
|
+
merged flag, a "PR merged" rotation it made due is cleared too. A
|
|
26
|
+
rotation due after compactions is left alone.
|
|
27
|
+
|
|
28
|
+
### Migrations
|
|
29
|
+
|
|
30
|
+
None.
|
|
31
|
+
|
|
32
|
+
## [0.4.9] - 2026-11-07
|
|
33
|
+
|
|
34
|
+
A session ends when its pull request merges, or after 3 compactions. The
|
|
35
|
+
role then relaunches from a handoff, in a fresh conversation (den#4, the
|
|
36
|
+
session-rotation issue). Sessions that never end re-read huge contexts on
|
|
37
|
+
every call. This keeps each session to one objective.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **Den counts compactions.** A `SessionStart` hook (source `compact`),
|
|
42
|
+
alongside Den's other hooks for default-pack Claude Code agents, counts
|
|
43
|
+
each compaction of the agent's conversation once it happened. A
|
|
44
|
+
`/compact` that does nothing is not counted. The count is in
|
|
45
|
+
`<state dir>/agent-files/<id>/rotation.json`, and starts at 0 for each
|
|
46
|
+
new conversation.
|
|
47
|
+
- **Two triggers.** A rotation is due after `agents.rotate.after_compactions`
|
|
48
|
+
compactions (default 3), or once the agent's pull request merged (the
|
|
49
|
+
`prMerged` outcome from `usage agents`). A merge counts only when the
|
|
50
|
+
agent ran `gh pr merge` itself, it exited 0, and `gh pr view` then says
|
|
51
|
+
MERGED. `echo gh pr merge 1` or `gh pr merge --help` never counts.
|
|
52
|
+
- **The agent is told.** The compaction summary keeps the instruction, a
|
|
53
|
+
`SessionStart` hook adds it after the compaction, and the `Stop` hook
|
|
54
|
+
blocks once until the handoff is written. The shared instructions say
|
|
55
|
+
what to do: write `handoff.md` in the agent's files folder, then end the
|
|
56
|
+
turn.
|
|
57
|
+
- **The handoff check.** A handoff has five parts: Objective, State, Open
|
|
58
|
+
items, How to QA, Next step. Each must be there and filled in, and every
|
|
59
|
+
ID explained. `<cli> agents handoff-check <id|file|->` runs the check (on an
|
|
60
|
+
agent's own handoff.md, by its id) and names what is missing.
|
|
61
|
+
- **Relaunch from handoff.** A fresh conversation (a new conversation id),
|
|
62
|
+
with the same persona, workspace, folder and grants. Its first prompt is
|
|
63
|
+
the handoff. The handoff is then kept in `handoffs/`, and the record
|
|
64
|
+
notes the rotation. `<cli> agents relaunch <id>`, and
|
|
65
|
+
`POST /api/workspaces/<ws>/agents/<id>/relaunch`.
|
|
66
|
+
- **The Agents page.** A rotated agent reads "Ready to relaunch (3
|
|
67
|
+
compactions)" or "Ready to relaunch (PR merged)". Its buttons are
|
|
68
|
+
Relaunch from handoff (the primary one), Start fresh and Remove. With no
|
|
69
|
+
handoff, or one that fails the check, the row says so and keeps Resume
|
|
70
|
+
and Start fresh. A running agent shows "Handoff due" or "Ready to
|
|
71
|
+
relaunch" as a tag.
|
|
72
|
+
- **`agents.rotate` in the org config**: `after_compactions` (1 to 100,
|
|
73
|
+
default 3) and `standing` (default true). With `standing: false`,
|
|
74
|
+
standing personas such as the planner never rotate.
|
|
75
|
+
- **Usage shows rotation.** `usage agents` adds each agent's compactions
|
|
76
|
+
and rotations. `usage report` adds `long_session_context_k`,
|
|
77
|
+
`never_ending_reread_pct` and `rotations`.
|
|
78
|
+
|
|
79
|
+
### Changed
|
|
80
|
+
|
|
81
|
+
- **Resume refuses a rotated conversation.** Once a rotation is due and the
|
|
82
|
+
handoff passes, `agents resume <id>` exits 1 with
|
|
83
|
+
`Relaunch from handoff: <cli> agents relaunch <id>`. The resume route
|
|
84
|
+
answers 409 with code `rotated`. Resume all lists it as not resumed.
|
|
85
|
+
- **A standing persona that rotated relaunches on its own** after a
|
|
86
|
+
restart, as it resumed on its own before.
|
|
87
|
+
- **The `Stop` hook and the new hooks take the agent's files folder** on
|
|
88
|
+
their command line (`--files <dir> --after <n>`).
|
|
89
|
+
|
|
90
|
+
### Metrics (before → after)
|
|
91
|
+
|
|
92
|
+
- Peak tokens per call in a long session: 365–510k → <150k.
|
|
93
|
+
- Share of context re-reads from never-ending sessions: 59% → <15%.
|
|
94
|
+
- How it is measured: `usage report` on this machine.
|
|
95
|
+
`long_session_context_k` is the highest average tokens per call of any
|
|
96
|
+
session. `never_ending_reread_pct` is the share of cache re-reads from
|
|
97
|
+
sessions that compacted more than `agents.rotate.after_compactions`
|
|
98
|
+
times. Compare a week's report before and after.
|
|
99
|
+
- On the test fixture (15 sessions with 16 compactions each, and 127 that
|
|
100
|
+
end within 3): 399k → 139k per call, and 59% → 0% of re-reads. The same
|
|
101
|
+
calls re-read fewer tokens.
|
|
102
|
+
|
|
103
|
+
### Migrations
|
|
104
|
+
|
|
105
|
+
- None needed. Rotation is on for Den's default pack, with 3 compactions.
|
|
106
|
+
To change the number, set `agents.rotate.after_compactions`. To keep
|
|
107
|
+
standing personas from rotating, set `agents.rotate.standing: false`.
|
|
108
|
+
`rotate` is now a reserved key under `agents:`, so no agent can have the
|
|
109
|
+
id `rotate`. Agents started before 0.4.9 start counting at their next
|
|
110
|
+
start or resume.
|
|
111
|
+
|
|
7
112
|
## [0.4.8] - 2026-11-07
|
|
8
113
|
|
|
9
114
|
Stopped agents stay in the list, and come back where they were (AG-14, the
|
package/README.md
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<picture>
|
|
13
|
-
<source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.
|
|
14
|
-
<img src="docs/images/0.4.
|
|
13
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.10/hero-dark.png">
|
|
14
|
+
<img src="docs/images/0.4.10/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
|
|
15
15
|
</picture>
|
|
16
16
|
|
|
17
17
|
## Set up by your agent
|
|
@@ -38,7 +38,7 @@ It doesn't replace your tools. Your repos, Docker, your test runners, your secre
|
|
|
38
38
|
yours. Den connects them and turns on good practice from the start.
|
|
39
39
|
|
|
40
40
|
<p align="center">
|
|
41
|
-
<img src="docs/images/0.4.
|
|
41
|
+
<img src="docs/images/0.4.10/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
|
|
42
42
|
</p>
|
|
43
43
|
|
|
44
44
|
## How it helps
|
|
@@ -68,10 +68,10 @@ yours. Den connects them and turns on good practice from the start.
|
|
|
68
68
|
lives in your own repo.
|
|
69
69
|
|
|
70
70
|
<p align="center">
|
|
71
|
-
<img src="docs/images/0.4.
|
|
72
|
-
<img src="docs/images/0.4.
|
|
73
|
-
<img src="docs/images/0.4.
|
|
74
|
-
<img src="docs/images/0.4.
|
|
71
|
+
<img src="docs/images/0.4.10/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
|
|
72
|
+
<img src="docs/images/0.4.10/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
|
|
73
|
+
<img src="docs/images/0.4.10/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
|
|
74
|
+
<img src="docs/images/0.4.10/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
|
|
75
75
|
</p>
|
|
76
76
|
|
|
77
77
|
## Get started
|
package/docs/distributions.md
CHANGED
|
@@ -2401,6 +2401,11 @@ Den never writes your `~/.claude`; your own hooks still run.
|
|
|
2401
2401
|
going (Claude Code's `decision: "block"`). It never blocks twice in a
|
|
2402
2402
|
row (`stop_hook_active`), so it can't loop. Den adds no `SubagentStop` hook: a subagent reports to its parent
|
|
2403
2403
|
agent, not to a person.
|
|
2404
|
+
- `PreCompact`, and `SessionStart` after a compaction or on resume
|
|
2405
|
+
(`rotation-hook.ts`), count the conversation's compactions (on
|
|
2406
|
+
`SessionStart` after a compaction only) and ask for a handoff once a
|
|
2407
|
+
rotation is due. See Session rotation, below. While one is
|
|
2408
|
+
due, the `Stop` hook also blocks once until `handoff.md` passes.
|
|
2404
2409
|
|
|
2405
2410
|
**The hand-back template**, which the check enforces:
|
|
2406
2411
|
|
|
@@ -2454,6 +2459,108 @@ A persona of yours that replaces a default one (same id) takes your
|
|
|
2454
2459
|
pack's setting, not Den's pack's. Anything but `true` or `false`, or an
|
|
2455
2460
|
unknown key, is an error naming the key.
|
|
2456
2461
|
|
|
2462
|
+
### Session rotation: one session per objective
|
|
2463
|
+
|
|
2464
|
+
A session that never ends re-reads its whole context on every call. So a
|
|
2465
|
+
session ends when its pull request merges, or after a few compactions. Its
|
|
2466
|
+
role then relaunches in a fresh conversation, from a handoff the session
|
|
2467
|
+
wrote. This is rule R2 (one session per objective) of the agent-loop
|
|
2468
|
+
report. It works on den-claude for personas with Den's hooks on.
|
|
2469
|
+
|
|
2470
|
+
**How it works.**
|
|
2471
|
+
|
|
2472
|
+
1. Den's hooks count compactions. A `SessionStart` hook with source
|
|
2473
|
+
`compact` (`guardrails/rotation-hook.ts`) adds one to
|
|
2474
|
+
`<stateDir>/agent-files/<id>/rotation.json` after every compaction,
|
|
2475
|
+
manual or automatic. It fires only once a compaction happened.
|
|
2476
|
+
`PreCompact` does not count: it also runs for a `/compact` that does
|
|
2477
|
+
nothing ("Not enough messages to compact"). The count belongs to one
|
|
2478
|
+
conversation, and starts at 0 for a new one.
|
|
2479
|
+
2. A trigger fires. Either the count reaches
|
|
2480
|
+
`agents.rotate.after_compactions` (default 3), or the agent's pull
|
|
2481
|
+
request merged (the `prMerged` outcome that `<cli> usage agents` saves).
|
|
2482
|
+
A merge is never taken from text. The agent must have run `gh pr merge`
|
|
2483
|
+
as a command (after any `cd` or `VAR=value` prefix; not `--help`, not
|
|
2484
|
+
`echo gh pr merge`), and it must have exited 0. Then `usage` asks
|
|
2485
|
+
`gh pr view <ref> --json number,state` in the agent's folder, and only
|
|
2486
|
+
`MERGED` counts. With no gh, or on any error, it is not marked. That
|
|
2487
|
+
query is the one thing `usage` asks outside this machine; it says so
|
|
2488
|
+
when it does.
|
|
2489
|
+
3. Den tells the agent. The `PreCompact` hook's output asks the compaction
|
|
2490
|
+
summary to keep the instruction. A `SessionStart` hook (after a
|
|
2491
|
+
compaction, or on resume) adds it to the context. The `Stop` hook blocks
|
|
2492
|
+
the turn once while the handoff is missing or fails the check. The
|
|
2493
|
+
instruction: write `handoff.md` in the agent's files folder, then end
|
|
2494
|
+
the turn.
|
|
2495
|
+
4. Den relaunches the role. **Relaunch from handoff** starts a fresh
|
|
2496
|
+
conversation (a new conversation id) with the same persona, workspace,
|
|
2497
|
+
folder and grants. Its first prompt is the handoff. The handoff moves to
|
|
2498
|
+
`agent-files/<id>/handoffs/`, and the record notes the rotation.
|
|
2499
|
+
|
|
2500
|
+
**The handoff** has five parts, each a heading, each filled in. Write it
|
|
2501
|
+
plainly, and explain every ID, as in a hand-back. It must stay under 20,000
|
|
2502
|
+
characters, since it becomes a first prompt.
|
|
2503
|
+
|
|
2504
|
+
```markdown
|
|
2505
|
+
## Objective
|
|
2506
|
+
|
|
2507
|
+
<what the work is for>
|
|
2508
|
+
|
|
2509
|
+
## State
|
|
2510
|
+
|
|
2511
|
+
<what is done, and where it is: branch, pull request, files>
|
|
2512
|
+
|
|
2513
|
+
## Open items
|
|
2514
|
+
|
|
2515
|
+
<what is left; or none>
|
|
2516
|
+
|
|
2517
|
+
## How to QA
|
|
2518
|
+
|
|
2519
|
+
<the steps to see it working>
|
|
2520
|
+
|
|
2521
|
+
## Next step
|
|
2522
|
+
|
|
2523
|
+
<the one thing to do first>
|
|
2524
|
+
```
|
|
2525
|
+
|
|
2526
|
+
`<cli> agents handoff-check <id|file|-> [--json]` runs the check, on an
|
|
2527
|
+
agent's `handoff.md` (by its id), a file, or stdin: exit 0 when
|
|
2528
|
+
it passes, 1 with each missing or empty part and each unexplained ID. A
|
|
2529
|
+
part can also be a line such as `Next step: open the PR`.
|
|
2530
|
+
|
|
2531
|
+
**Resume after a rotation.** Once a rotation is due and `handoff.md`
|
|
2532
|
+
passes, Den declines a plain Resume of that conversation:
|
|
2533
|
+
|
|
2534
|
+
- The Agents page shows the row as "Ready to relaunch (3 compactions)" or
|
|
2535
|
+
"Ready to relaunch (PR merged)". Its buttons are Relaunch from handoff
|
|
2536
|
+
(the primary one), Start fresh and Remove.
|
|
2537
|
+
- `<cli> agents resume <id>` refuses (exit 1) and prints
|
|
2538
|
+
`Relaunch from handoff: <cli> agents relaunch <id>`. The resume route
|
|
2539
|
+
answers 409 with code `rotated`.
|
|
2540
|
+
- `<cli> agents relaunch <id> [-w <ws>]` and
|
|
2541
|
+
`POST /api/workspaces/<ws>/agents/<id>/relaunch` relaunch it. A running
|
|
2542
|
+
rotated agent is stopped first.
|
|
2543
|
+
- With `handoff.md` missing or failing the check, the row says so, and
|
|
2544
|
+
Resume and Start fresh stay. Resume the agent to write or fix it.
|
|
2545
|
+
Relaunch says what is wrong (409 `no_handoff`) and names Start fresh.
|
|
2546
|
+
- After a restart, a standing persona that rotated relaunches on its own,
|
|
2547
|
+
as a standing persona resumes on its own.
|
|
2548
|
+
|
|
2549
|
+
**Settings**, in the org config:
|
|
2550
|
+
|
|
2551
|
+
```yaml
|
|
2552
|
+
agents:
|
|
2553
|
+
rotate:
|
|
2554
|
+
after_compactions: 3 # 1 to 100; the default is 3
|
|
2555
|
+
standing: true # standing personas rotate too (the default)
|
|
2556
|
+
```
|
|
2557
|
+
|
|
2558
|
+
`standing` is for standing personas, such as the planner and the
|
|
2559
|
+
orchestrator. With `true` (the default), they rotate like the rest: only
|
|
2560
|
+
their context resets, and they keep their id and workspace. With `false`,
|
|
2561
|
+
they never rotate. A setting takes effect at the agent's next start or
|
|
2562
|
+
relaunch. An unknown key, or a bad value, is an error naming the key.
|
|
2563
|
+
|
|
2457
2564
|
### Runtimes
|
|
2458
2565
|
|
|
2459
2566
|
`<cli> doctor` lists the runtime kind. Den knows the runtimes in its
|
|
@@ -2654,13 +2761,33 @@ request it opened, whether it merged, whether its last test run passed,
|
|
|
2654
2761
|
and its wall-clock time. It matches transcripts to agents by the
|
|
2655
2762
|
conversation id Den saved at start. It prints the share of agents with
|
|
2656
2763
|
both. Transcripts never leave the machine, and their text is never kept:
|
|
2657
|
-
only counts.
|
|
2764
|
+
only counts. "Whether it merged" is checked: when an agent ran
|
|
2765
|
+
`gh pr merge`, `usage` asks `gh pr view <ref> --json number,state` to confirm it.
|
|
2766
|
+
That query, the pull request's number or URL, is the only thing it asks
|
|
2767
|
+
outside, and the output says when it asked (or that it couldn't, because
|
|
2768
|
+
gh is not installed). A merge gh confirmed is kept in the record
|
|
2769
|
+
(`prMergeConfirmed`, the pull request's ref or, for a bare `gh pr merge`,
|
|
2770
|
+
the number gh gave) and not asked about again. A mark gh no longer
|
|
2771
|
+
confirms is dropped, with the "PR merged" rotation it made due.
|
|
2658
2772
|
|
|
2659
2773
|
`<cli> usage report [--days 7] [--json]` writes a weekly summary to
|
|
2660
2774
|
`<state dir>/usage/<date>.json` and `.md`, compared with the last one:
|
|
2661
2775
|
calls, cache re-reads, polls, sleeps, compactions, corrections and the
|
|
2662
2776
|
attributed share.
|
|
2663
2777
|
|
|
2778
|
+
For session rotation, `usage agents` also shows each agent's compactions
|
|
2779
|
+
(in its current conversation) and rotations (how many times Den relaunched
|
|
2780
|
+
it from a handoff). A merged pull request makes the agent's rotation due.
|
|
2781
|
+
`usage report` adds three numbers:
|
|
2782
|
+
|
|
2783
|
+
- `long_session_context_k`: the highest average tokens per call of any
|
|
2784
|
+
session in the window, in thousands. A call's tokens are its input,
|
|
2785
|
+
cache reads and cache writes.
|
|
2786
|
+
- `never_ending_reread_pct`: the share of cache re-reads that came from
|
|
2787
|
+
never-ending sessions. A never-ending session compacted more often than
|
|
2788
|
+
`agents.rotate.after_compactions`.
|
|
2789
|
+
- `rotations`: relaunches from a handoff, over the agents listed.
|
|
2790
|
+
|
|
2664
2791
|
Den reads Claude Code's transcripts (`$CLAUDE_CONFIG_DIR/projects`, else
|
|
2665
2792
|
`~/.claude/projects`). A plugin can add a reader for another runtime with
|
|
2666
2793
|
the `usage` kind (server tier). A runtime with no reader shows "no usage
|
package/package.json
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* alongside any hooks the user has; Den never writes ~/.claude.
|
|
22
22
|
* - Each launch picks a fresh conversation id (--session-id); resume
|
|
23
23
|
* continues it with --resume <id>, in the same cwd, with the same MCP.
|
|
24
|
+
* A relaunch from handoff (den#4) is a launch whose first prompt, the
|
|
25
|
+
* last argument, is the handoff.
|
|
26
|
+
* - Session rotation (den#4): the hooks include PreCompact and
|
|
27
|
+
* SessionStart (rotation-hook.ts) with the agent's files dir and its
|
|
28
|
+
* limit (LaunchInput rotateAfter), so its compactions are counted there.
|
|
24
29
|
* - Permission prompts stay in the terminal (approvals: "terminal"):
|
|
25
30
|
* --permission-mode default, unless the persona's `permission_mode` sets
|
|
26
31
|
* one of Claude Code's modes; bypassPermissions warns at every start. The
|
|
@@ -45,7 +50,8 @@ import * as fs from "fs";
|
|
|
45
50
|
import * as path from "path";
|
|
46
51
|
import { configuredRuntimeBin } from "../den-config.js";
|
|
47
52
|
import { rolePrompt } from "./personas.js";
|
|
48
|
-
import { guardrailHooks } from "./guardrails.js";
|
|
53
|
+
import { GUARDRAILS_DIR, guardrailHooks, hookCommands } from "./guardrails.js";
|
|
54
|
+
import { DEFAULT_ROTATE } from "./rotation.js";
|
|
49
55
|
import {
|
|
50
56
|
LaunchError,
|
|
51
57
|
mcpConfigJson,
|
|
@@ -227,7 +233,16 @@ function common(input: LaunchInput): {
|
|
|
227
233
|
} {
|
|
228
234
|
const file = mcpConfigPath(input);
|
|
229
235
|
const mode = permissionMode(input);
|
|
230
|
-
|
|
236
|
+
// den#4: the rotation hooks count this agent's compactions in its files dir.
|
|
237
|
+
const hooks = input.persona.guardrails?.hooks
|
|
238
|
+
? guardrailHooks(
|
|
239
|
+
GUARDRAILS_DIR,
|
|
240
|
+
hookCommands(GUARDRAILS_DIR, process.execPath, {
|
|
241
|
+
files: input.filesDir,
|
|
242
|
+
after: input.rotateAfter ?? DEFAULT_ROTATE.afterCompactions,
|
|
243
|
+
}),
|
|
244
|
+
)
|
|
245
|
+
: null;
|
|
231
246
|
const settings = input.stateDir || hooks ? settingsPath(input) : null;
|
|
232
247
|
return {
|
|
233
248
|
argv: [
|
|
@@ -287,20 +302,40 @@ function binFor(input: LaunchInput): string {
|
|
|
287
302
|
return d.path;
|
|
288
303
|
}
|
|
289
304
|
|
|
305
|
+
/**
|
|
306
|
+
* The first prompt as claude's last argument, or nothing. An agent wrote
|
|
307
|
+
* the text (its handoff), so it is never allowed to start with "-": that
|
|
308
|
+
* would be read as a flag. One that does gets a word in front.
|
|
309
|
+
*/
|
|
310
|
+
export function firstPromptArg(text: string | undefined): string[] {
|
|
311
|
+
const t = text?.trim();
|
|
312
|
+
if (!t) return [];
|
|
313
|
+
return [t.startsWith("-") ? `Prompt: ${t}` : t];
|
|
314
|
+
}
|
|
315
|
+
|
|
290
316
|
export const denClaude: Runtime = {
|
|
291
317
|
id: DEN_CLAUDE_ID,
|
|
292
318
|
label: DEN_CLAUDE_CATALOG.name,
|
|
293
319
|
vendor: DEN_CLAUDE_CATALOG.vendor,
|
|
294
320
|
catalog: DEN_CLAUDE_CATALOG,
|
|
295
321
|
denTools: true,
|
|
322
|
+
firstPrompt: true,
|
|
296
323
|
find: DEN_CLAUDE_FIND,
|
|
297
324
|
detect,
|
|
298
325
|
launch(input): LaunchOutput {
|
|
299
326
|
const bin = binFor(input);
|
|
300
327
|
const id = input.resumeToken ?? randomUUID();
|
|
301
328
|
const c = common(input);
|
|
329
|
+
// A relaunch from handoff (den#4): the handoff is the first prompt,
|
|
330
|
+
// the last argument (firstPromptArg: never read as a flag).
|
|
302
331
|
return {
|
|
303
|
-
argv: [
|
|
332
|
+
argv: [
|
|
333
|
+
bin,
|
|
334
|
+
"--session-id",
|
|
335
|
+
id,
|
|
336
|
+
...c.argv,
|
|
337
|
+
...firstPromptArg(input.firstPrompt),
|
|
338
|
+
],
|
|
304
339
|
env: {},
|
|
305
340
|
files: c.files,
|
|
306
341
|
resumeToken: id,
|
|
@@ -22,6 +22,29 @@
|
|
|
22
22
|
}
|
|
23
23
|
]
|
|
24
24
|
}
|
|
25
|
+
],
|
|
26
|
+
"PreCompact": [
|
|
27
|
+
{
|
|
28
|
+
"hooks": [
|
|
29
|
+
{
|
|
30
|
+
"type": "command",
|
|
31
|
+
"command": "{rotation}",
|
|
32
|
+
"timeout": 10
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"SessionStart": [
|
|
38
|
+
{
|
|
39
|
+
"matcher": "compact|resume",
|
|
40
|
+
"hooks": [
|
|
41
|
+
{
|
|
42
|
+
"type": "command",
|
|
43
|
+
"command": "{rotation}",
|
|
44
|
+
"timeout": 10
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
25
48
|
]
|
|
26
49
|
}
|
|
27
50
|
}
|
|
@@ -37,3 +37,26 @@ a numbered yes/no question is sent back to you to fix.
|
|
|
37
37
|
Write "Decisions: none" when there are none, and "URL: none" (with a short
|
|
38
38
|
reason) when the work has no page to open. Every message you end a turn
|
|
39
39
|
with is read by a person, hand-back or not: explain each ID in it.
|
|
40
|
+
|
|
41
|
+
### When your session rotates
|
|
42
|
+
|
|
43
|
+
A session ends when its pull request merges, or after a few compactions
|
|
44
|
+
(three unless your team set another number). Den tells you when that
|
|
45
|
+
happens. Then write a handoff to the file Den names (`handoff.md` in your
|
|
46
|
+
agent files folder) and end your turn. Den checks it, and relaunches your
|
|
47
|
+
role from it in a fresh conversation. Use these headings, each filled in:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
## Objective
|
|
51
|
+
<what the work is for, in a sentence or two>
|
|
52
|
+
## State
|
|
53
|
+
<what is done, and where it is: branch, pull request, files>
|
|
54
|
+
## Open items
|
|
55
|
+
<what is left, one per line; or none>
|
|
56
|
+
## How to QA
|
|
57
|
+
<the steps to see it working>
|
|
58
|
+
## Next step
|
|
59
|
+
<the one thing to do first>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Write it plainly. Explain every ID in it, as in a hand-back.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Den's session-rotation hook for Claude Code sessions it launches
|
|
3
|
+
* (den#4). One script for two events, run with the agent's own files dir
|
|
4
|
+
* and its limit:
|
|
5
|
+
*
|
|
6
|
+
* rotation-hook.ts --files <agent files dir> --after <n> (0: never rotates)
|
|
7
|
+
*
|
|
8
|
+
* Hook contract (https://code.claude.com/docs/en/hooks):
|
|
9
|
+
* - PreCompact: JSON on stdin with the common fields (`session_id`,
|
|
10
|
+
* `transcript_path`, `cwd`, `hook_event_name`) and `trigger` (manual or
|
|
11
|
+
* auto) and `custom_instructions`. Exit 0's stdout becomes instructions
|
|
12
|
+
* for the compaction summary. It runs before Claude Code decides to
|
|
13
|
+
* compact ("Not enough messages to compact" still runs it), so it never
|
|
14
|
+
* counts. When this compaction would make a rotation due, its stdout
|
|
15
|
+
* asks the summary to keep the instruction to write the handoff.
|
|
16
|
+
* - SessionStart, with `source` "compact" (after a compaction that
|
|
17
|
+
* happened) or "resume". "compact" counts the compaction in
|
|
18
|
+
* rotation.json (rotation.ts). While a rotation is due, it prints
|
|
19
|
+
* `{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":…}}`,
|
|
20
|
+
* which Claude Code adds to the context: write handoff.md, then end
|
|
21
|
+
* the turn.
|
|
22
|
+
*
|
|
23
|
+
* The parser is tolerant: a field it doesn't find is left out, never an
|
|
24
|
+
* error. Any error lets Claude Code carry on (exit 0, nothing printed):
|
|
25
|
+
* the hook never blocks a compaction.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
dueFor,
|
|
29
|
+
noteCompaction,
|
|
30
|
+
notePendingCompaction,
|
|
31
|
+
readHandoff,
|
|
32
|
+
readRotation,
|
|
33
|
+
rotationInstruction,
|
|
34
|
+
} from "../rotation.js";
|
|
35
|
+
|
|
36
|
+
export interface RotationHookArgs {
|
|
37
|
+
files: string;
|
|
38
|
+
after: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `--files <dir> --after <n>`; null when either is missing. */
|
|
42
|
+
export function parseHookArgs(
|
|
43
|
+
argv: readonly string[],
|
|
44
|
+
): RotationHookArgs | null {
|
|
45
|
+
const at = (flag: string) => {
|
|
46
|
+
const i = argv.indexOf(flag);
|
|
47
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
48
|
+
};
|
|
49
|
+
const files = at("--files");
|
|
50
|
+
const after = Number(at("--after") ?? "");
|
|
51
|
+
if (!files || !Number.isInteger(after) || after < 0) return null;
|
|
52
|
+
return { files, after };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The hook's stdout for one input ("" prints nothing). */
|
|
56
|
+
export function rotationHookOutput(
|
|
57
|
+
input: unknown,
|
|
58
|
+
args: RotationHookArgs,
|
|
59
|
+
now: Date = new Date(),
|
|
60
|
+
): string {
|
|
61
|
+
if (!input || typeof input !== "object") return "";
|
|
62
|
+
const i = input as Record<string, unknown>;
|
|
63
|
+
const event = i.hook_event_name;
|
|
64
|
+
const conversation =
|
|
65
|
+
typeof i.session_id === "string" && i.session_id ? i.session_id : undefined;
|
|
66
|
+
if (event === "PreCompact" || (event === undefined && "trigger" in i)) {
|
|
67
|
+
// Not counted here: the compaction may not happen.
|
|
68
|
+
const prev = readRotation(args.files);
|
|
69
|
+
notePendingCompaction(args.files, conversation, i.trigger, args.after);
|
|
70
|
+
const counted =
|
|
71
|
+
prev &&
|
|
72
|
+
(!conversation ||
|
|
73
|
+
!prev.conversation ||
|
|
74
|
+
prev.conversation === conversation)
|
|
75
|
+
? prev
|
|
76
|
+
: null;
|
|
77
|
+
const next = (counted?.compactions ?? 0) + 1;
|
|
78
|
+
const due =
|
|
79
|
+
dueFor(counted, conversation) ??
|
|
80
|
+
(args.after > 0 && next >= args.after ? "compactions" : undefined);
|
|
81
|
+
if (!due) return "";
|
|
82
|
+
return [
|
|
83
|
+
"Keep this instruction, word for word, at the top of the summary:",
|
|
84
|
+
rotationInstruction(
|
|
85
|
+
args.files,
|
|
86
|
+
due,
|
|
87
|
+
Math.max(next, counted?.compactions ?? 0),
|
|
88
|
+
),
|
|
89
|
+
].join("\n");
|
|
90
|
+
}
|
|
91
|
+
if (event === "SessionStart" || (event === undefined && "source" in i)) {
|
|
92
|
+
if (i.source !== "compact" && i.source !== "resume") return "";
|
|
93
|
+
const s =
|
|
94
|
+
i.source === "compact"
|
|
95
|
+
? noteCompaction(args.files, i, args.after, now)
|
|
96
|
+
: readRotation(args.files);
|
|
97
|
+
const due = dueFor(s, conversation);
|
|
98
|
+
if (!due || readHandoff(args.files).status === "ok") return "";
|
|
99
|
+
return JSON.stringify({
|
|
100
|
+
hookSpecificOutput: {
|
|
101
|
+
hookEventName: "SessionStart",
|
|
102
|
+
additionalContext: rotationInstruction(args.files, due, s!.compactions),
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function main(): Promise<void> {
|
|
110
|
+
const args = parseHookArgs(process.argv.slice(2));
|
|
111
|
+
let text = "";
|
|
112
|
+
for await (const chunk of process.stdin) text += String(chunk);
|
|
113
|
+
if (!args) return;
|
|
114
|
+
let out = "";
|
|
115
|
+
try {
|
|
116
|
+
out = rotationHookOutput(JSON.parse(text), args);
|
|
117
|
+
} catch {
|
|
118
|
+
out = "";
|
|
119
|
+
}
|
|
120
|
+
if (out) process.stdout.write(`${out}\n`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.argv[1] && /rotation-hook\.[jt]s$/.test(process.argv[1]))
|
|
124
|
+
main().then(
|
|
125
|
+
() => process.exit(0),
|
|
126
|
+
() => process.exit(0),
|
|
127
|
+
);
|
|
@@ -13,17 +13,63 @@
|
|
|
13
13
|
* When Claude is already continuing because of a stop hook
|
|
14
14
|
* (stop_hook_active), it lets the turn end, so it never loops. Any error
|
|
15
15
|
* lets the turn end too.
|
|
16
|
+
*
|
|
17
|
+
* Session rotation (den#4): run with `--files <agent files dir> --after
|
|
18
|
+
* <n>`, while a rotation is due (rotation.ts) and handoff.md is missing or
|
|
19
|
+
* fails the handoff check, it blocks once with what to write. Once the
|
|
20
|
+
* handoff passes, the turn ends as usual.
|
|
16
21
|
*/
|
|
17
22
|
import { bareIds, checkHandback, looksLikeHandback } from "../handback.js";
|
|
23
|
+
import {
|
|
24
|
+
dueFor,
|
|
25
|
+
readHandoff,
|
|
26
|
+
readRotation,
|
|
27
|
+
rotationInstruction,
|
|
28
|
+
} from "../rotation.js";
|
|
29
|
+
import { parseHookArgs, type RotationHookArgs } from "./rotation-hook.js";
|
|
30
|
+
|
|
31
|
+
/** A due rotation's block, or "" (no rotation due, or the handoff passes). */
|
|
32
|
+
function rotationBlock(
|
|
33
|
+
input: { session_id?: unknown },
|
|
34
|
+
args: RotationHookArgs,
|
|
35
|
+
): string {
|
|
36
|
+
const s = readRotation(args.files);
|
|
37
|
+
const conversation =
|
|
38
|
+
typeof input.session_id === "string" ? input.session_id : undefined;
|
|
39
|
+
const due = dueFor(s, conversation);
|
|
40
|
+
if (!due) return "";
|
|
41
|
+
const h = readHandoff(args.files);
|
|
42
|
+
if (h.status === "ok") return "";
|
|
43
|
+
return JSON.stringify({
|
|
44
|
+
decision: "block",
|
|
45
|
+
reason: [
|
|
46
|
+
rotationInstruction(args.files, due, s!.compactions),
|
|
47
|
+
...(h.status === "invalid"
|
|
48
|
+
? [
|
|
49
|
+
"Den checked the handoff there and it is not ready. Fix these:",
|
|
50
|
+
...h.problems.map((p) => `- ${p}`),
|
|
51
|
+
]
|
|
52
|
+
: []),
|
|
53
|
+
].join("\n"),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
18
56
|
|
|
19
57
|
/** The hook's stdout for one Stop input ("" lets the turn end). */
|
|
20
|
-
export function stopDecision(
|
|
58
|
+
export function stopDecision(
|
|
59
|
+
input: unknown,
|
|
60
|
+
rotation?: RotationHookArgs | null,
|
|
61
|
+
): string {
|
|
21
62
|
if (!input || typeof input !== "object") return "";
|
|
22
63
|
const i = input as {
|
|
23
64
|
stop_hook_active?: unknown;
|
|
24
65
|
last_assistant_message?: unknown;
|
|
66
|
+
session_id?: unknown;
|
|
25
67
|
};
|
|
26
68
|
if (i.stop_hook_active === true) return "";
|
|
69
|
+
if (rotation) {
|
|
70
|
+
const block = rotationBlock(i, rotation);
|
|
71
|
+
if (block) return block;
|
|
72
|
+
}
|
|
27
73
|
const msg = i.last_assistant_message;
|
|
28
74
|
if (typeof msg !== "string") return "";
|
|
29
75
|
// A hand-back is checked whole. Any other final message is what the
|
|
@@ -52,7 +98,7 @@ async function main(): Promise<void> {
|
|
|
52
98
|
for await (const chunk of process.stdin) text += String(chunk);
|
|
53
99
|
let out = "";
|
|
54
100
|
try {
|
|
55
|
-
out = stopDecision(JSON.parse(text));
|
|
101
|
+
out = stopDecision(JSON.parse(text), parseHookArgs(process.argv.slice(2)));
|
|
56
102
|
} catch {
|
|
57
103
|
out = "";
|
|
58
104
|
}
|