@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.
@@ -7,10 +7,12 @@
7
7
  * role prompt (personas.ts rolePrompt), so every runtime
8
8
  * gets them.
9
9
  * hooks.json a Claude Code settings fragment: a PreToolUse hook on
10
- * Bash and Read (pretooluse.mjs) and a Stop hook
11
- * (stop-hook.ts, the hand-back check). den-claude merges
12
- * it into the agent's own --settings file; Den never
13
- * writes the user's ~/.claude.
10
+ * Bash and Read (pretooluse.mjs), a Stop hook
11
+ * (stop-hook.ts, the hand-back check), and PreCompact
12
+ * and SessionStart (compact, resume) hooks
13
+ * (rotation-hook.ts, session rotation: den#4). den-claude
14
+ * merges it into the agent's own --settings file; Den
15
+ * never writes the user's ~/.claude.
14
16
  *
15
17
  * On by default for Den's default pack, off for a distribution's packs.
16
18
  * A distribution turns them off or on per pack (agents.default_pack, a
@@ -68,27 +70,56 @@ export interface HookCommands {
68
70
  pretooluse: string;
69
71
  /** The Stop hook's command line. */
70
72
  stop: string;
73
+ /** The PreCompact and SessionStart hooks' command line (den#4). */
74
+ rotation: string;
75
+ }
76
+
77
+ /**
78
+ * Where an agent's rotation state lives and its limit (den#4): its files
79
+ * dir, and the compactions after which it rotates (0: never).
80
+ */
81
+ export interface RotationArgs {
82
+ files: string;
83
+ after: number;
71
84
  }
72
85
 
73
86
  /** The command lines Den's hooks run: this node, Den's own scripts. */
74
87
  export function hookCommands(
75
88
  dir: string = GUARDRAILS_DIR,
76
89
  node: string = process.execPath,
90
+ rotation?: RotationArgs,
77
91
  ): HookCommands {
92
+ const more = (c: EngineCommand): EngineCommand =>
93
+ rotation
94
+ ? {
95
+ command: c.command,
96
+ args: [
97
+ ...c.args,
98
+ "--files",
99
+ rotation.files,
100
+ "--after",
101
+ String(rotation.after),
102
+ ],
103
+ }
104
+ : c;
78
105
  return {
79
106
  pretooluse: commandLine({
80
107
  command: node,
81
108
  args: [path.join(dir, "pretooluse.mjs")],
82
109
  }),
83
110
  stop: commandLine(
84
- scriptEntrypoint("src/shared/agents/guardrails/stop-hook.ts"),
111
+ more(scriptEntrypoint("src/shared/agents/guardrails/stop-hook.ts")),
112
+ ),
113
+ rotation: commandLine(
114
+ more(scriptEntrypoint("src/shared/agents/guardrails/rotation-hook.ts")),
85
115
  ),
86
116
  };
87
117
  }
88
118
 
89
119
  /**
90
120
  * The `hooks` block of the agent's settings file: hooks.json with each
91
- * `{pretooluse}` / `{stop}` placeholder replaced by its command line.
121
+ * `{pretooluse}` / `{stop}` / `{rotation}` placeholder replaced by its
122
+ * command line.
92
123
  */
93
124
  export function guardrailHooks(
94
125
  dir: string = GUARDRAILS_DIR,
@@ -100,7 +131,7 @@ export function guardrailHooks(
100
131
  const fill = (v: unknown): unknown => {
101
132
  if (typeof v === "string")
102
133
  return v.replace(
103
- /\{(pretooluse|stop)\}/g,
134
+ /\{(pretooluse|stop|rotation)\}/g,
104
135
  (_, k: keyof HookCommands) => cmds[k],
105
136
  );
106
137
  if (Array.isArray(v)) return v.map(fill);
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The handoff check (den#4, session rotation): is a handoff.md complete
3
+ * and plain? A session that rotates (after agents.rotate.after_compactions
4
+ * compactions, or once its pull request merged) writes one in its files
5
+ * dir; Den relaunches the role from it, in a fresh conversation whose
6
+ * first prompt is the handoff. `<cli> agents handoff-check <file|->` runs
7
+ * the check, and so does the Stop hook while a rotation is due.
8
+ *
9
+ * The template (guardrails/instructions.md):
10
+ *
11
+ * ## Objective
12
+ * <what the work is for, in a sentence or two>
13
+ * ## State
14
+ * <what is done and where it lives: branch, pull request, files>
15
+ * ## Open items
16
+ * <what is left, one per line; or none>
17
+ * ## How to QA
18
+ * <the steps to see it working>
19
+ * ## Next step
20
+ * <the one thing to do first>
21
+ *
22
+ * It rejects a handoff when:
23
+ *
24
+ * 1. A part is missing or empty. A part is a heading that is its label
25
+ * (`## Open items`) or a line that starts with its label and a colon
26
+ * (`Next step: open the PR`). Its content is the rest of the line and
27
+ * the lines after it, up to the next part or heading.
28
+ * 2. It has a bare ID: the hand-back check's rule (handback.ts bareIds).
29
+ * Code, links and code blocks are not scanned.
30
+ * 3. It is longer than HANDOFF_MAX_CHARS: it is the next session's first
31
+ * prompt, so it must stay short.
32
+ */
33
+ import { bareIds } from "./handback.js";
34
+
35
+ export const HANDOFF_PARTS = [
36
+ "Objective",
37
+ "State",
38
+ "Open items",
39
+ "How to QA",
40
+ "Next step",
41
+ ] as const;
42
+ export type HandoffPart = (typeof HANDOFF_PARTS)[number];
43
+
44
+ /** The longest handoff Den passes on (it becomes a first prompt). */
45
+ export const HANDOFF_MAX_CHARS = 20_000;
46
+
47
+ export interface HandoffResult {
48
+ ok: boolean;
49
+ /** One line each, plain, naming what to fix. */
50
+ problems: string[];
51
+ }
52
+
53
+ const LABEL = new RegExp(
54
+ `^\\s*(?:[-*+]\\s+)?(#{1,6}\\s+)?(?:\\*\\*|__)?(${HANDOFF_PARTS.map((p) =>
55
+ p.replace(/ /g, "\\s+"),
56
+ ).join("|")})(?:\\*\\*|__)?\\s*(:)?(?:\\*\\*|__)?\\s*(.*)$`,
57
+ "i",
58
+ );
59
+
60
+ function canonical(label: string): HandoffPart {
61
+ const l = label.replace(/\s+/g, " ").toLowerCase();
62
+ return HANDOFF_PARTS.find((p) => p.toLowerCase() === l)!;
63
+ }
64
+
65
+ /** Each part's content lines, by part. */
66
+ export function handoffParts(text: string): Map<HandoffPart, string[]> {
67
+ const found = new Map<HandoffPart, string[]>();
68
+ let cur: HandoffPart | null = null;
69
+ let fence = false;
70
+ for (const line of String(text ?? "").split(/\r?\n/)) {
71
+ const fenceLine = /^\s*(```|~~~)/.test(line);
72
+ if (fenceLine) fence = !fence;
73
+ const m = fence || fenceLine ? null : LABEL.exec(line);
74
+ // A label needs a colon, or to be the whole heading.
75
+ if (m && (m[3] || (m[1] && !m[4].trim()))) {
76
+ cur = canonical(m[2]);
77
+ const body = m[4].trim();
78
+ found.set(cur, body ? [body] : []);
79
+ continue;
80
+ }
81
+ if (!fence && /^\s*#{1,6}\s/.test(line)) {
82
+ // The document's own title, or another heading: no part's content.
83
+ cur = null;
84
+ continue;
85
+ }
86
+ if (cur && line.trim()) found.get(cur)!.push(line.trim());
87
+ }
88
+ return found;
89
+ }
90
+
91
+ /** Check a handoff. Pure: the text in, the problems out. */
92
+ export function checkHandoff(text: string): HandoffResult {
93
+ const s = String(text ?? "");
94
+ const problems: string[] = [];
95
+ if (!s.trim())
96
+ return {
97
+ ok: false,
98
+ problems: [
99
+ `The handoff is empty. Write ${HANDOFF_PARTS.map((p) => `"## ${p}"`).join(", ")}, each with its part filled in.`,
100
+ ],
101
+ };
102
+ const found = handoffParts(s);
103
+ for (const part of HANDOFF_PARTS) {
104
+ const body = found.get(part);
105
+ if (body === undefined) problems.push(`Missing "## ${part}". Add it.`);
106
+ else if (!body.join(" ").trim())
107
+ problems.push(`"## ${part}" is empty. Fill it in.`);
108
+ }
109
+ if (s.length > HANDOFF_MAX_CHARS)
110
+ problems.push(
111
+ `The handoff is ${s.length} characters long. Keep it under ${HANDOFF_MAX_CHARS}: it is the next session's first prompt.`,
112
+ );
113
+ for (const id of bareIds(s))
114
+ problems.push(
115
+ `"${id}" is not explained. Say what it is, e.g. "${id} (what it is)".`,
116
+ );
117
+ return { ok: problems.length === 0, problems };
118
+ }
@@ -4,7 +4,8 @@
4
4
  * same agents, and so an agent outlives the session it runs in.
5
5
  *
6
6
  * agents/<id>.json the record (0600, its directory 0700)
7
- * agent-files/<id>/ files its runtime wrote (0600): its MCP config
7
+ * agent-files/<id>/ files its runtime wrote (0600): its MCP config;
8
+ * and its rotation state and handoff.md (den#4)
8
9
  *
9
10
  * A record never holds the env or the argv: the role prompt, tokens and
10
11
  * anything else a launch passes stay out of it. An agent id is unique in a
@@ -19,6 +20,7 @@ import * as fs from "fs";
19
20
  import * as path from "path";
20
21
  import { WORKSPACE_NAME_RE } from "../agent-actor.js";
21
22
  import type { SessionRef } from "../sessions/types.js";
23
+ import type { RotationEntry } from "./rotation.js";
22
24
 
23
25
  export interface AgentRecord {
24
26
  id: string;
@@ -65,6 +67,11 @@ export interface AgentRecord {
65
67
  outcome?: AgentOutcome;
66
68
  /** Its tokens, from the runtime's own transcript (den#6). */
67
69
  usage?: AgentUsage;
70
+ /**
71
+ * Each time Den relaunched it from its handoff (den#4, session
72
+ * rotation), oldest first.
73
+ */
74
+ rotations?: RotationEntry[];
68
75
  }
69
76
 
70
77
  /** What an agent's run came to (den#6). Read locally; never sent. */
@@ -73,6 +80,11 @@ export interface AgentOutcome {
73
80
  prOpened?: string;
74
81
  /** That pull request merged in the run. */
75
82
  prMerged?: boolean;
83
+ /**
84
+ * The pull request gh said is MERGED (0.4.10): a merge confirmed once
85
+ * stays, and is not asked again.
86
+ */
87
+ prMergeConfirmed?: string;
76
88
  /** The last test run it made passed (true) or failed (false). */
77
89
  testsGreen?: boolean;
78
90
  /** From its start to its last activity, in milliseconds. */
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Session rotation (den#4, rule R2: one session per objective). A session
3
+ * that never ends re-reads its whole context on every call, so a session
4
+ * ends when its pull request merges or after a few compactions, and the
5
+ * role relaunches from a handoff in a fresh conversation.
6
+ *
7
+ * agent-files/<id>/rotation.json the compaction count of the agent's
8
+ * current conversation (the SessionStart
9
+ * "compact" hook's), and whether a rotation is due
10
+ * agent-files/<id>/handoff.md what the session wrote when it rotated
11
+ * (handoff.ts checks it)
12
+ * agent-files/<id>/handoffs/ handoffs Den relaunched from, kept
13
+ *
14
+ * Triggers (org config `agents.rotate`):
15
+ * - `after_compactions` (default 3): SessionStart "compact" counts each
16
+ * compaction that happened; at that many, a rotation is due;
17
+ * - the agent's pull request merged: the record's outcome
18
+ * (`prMerged`, den#6), which `<cli> usage` fills in.
19
+ * `standing: false` keeps standing personas (planner, orchestrator) out:
20
+ * they never rotate. Default true: only their context resets, they keep
21
+ * their name.
22
+ *
23
+ * A rotation is "ready" once it is due and handoff.md passes the check.
24
+ * Then Den declines a plain Resume and offers Relaunch from handoff.
25
+ * Everything here is this machine's files; nothing is sent.
26
+ */
27
+ import * as fs from "fs";
28
+ import * as path from "path";
29
+ import { checkHandoff } from "./handoff.js";
30
+ import { agentFilesDir, writePrivate, type AgentRecord } from "./records.js";
31
+
32
+ export const ROTATION_FILE = "rotation.json";
33
+ export const HANDOFF_FILE = "handoff.md";
34
+ export const HANDOFFS_DIR = "handoffs";
35
+
36
+ /** Why a session rotates. */
37
+ export type RotateReason = "compactions" | "pr-merged";
38
+
39
+ export interface RotationState {
40
+ /**
41
+ * The conversation counted: Claude Code's session_id, which is the
42
+ * record's resumeToken. A count for another conversation is stale.
43
+ */
44
+ conversation?: string;
45
+ compactions: number;
46
+ /** Rotate after this many compactions; 0: this agent never rotates. */
47
+ after: number;
48
+ /** The last compaction (ISO 8601) and what triggered it (manual, auto). */
49
+ lastCompactAt?: string;
50
+ lastTrigger?: string;
51
+ /**
52
+ * PreCompact's trigger, kept until SessionStart "compact" counts the
53
+ * compaction (SessionStart's input has no trigger).
54
+ */
55
+ pendingTrigger?: string;
56
+ /** Set when a trigger fired. */
57
+ due?: RotateReason;
58
+ dueAt?: string;
59
+ }
60
+
61
+ /** `agents.rotate` with its defaults applied. */
62
+ export interface RotateSettings {
63
+ /** Compactions before a session rotates (at least 1). */
64
+ afterCompactions: number;
65
+ /** Whether standing personas rotate too. */
66
+ standing: boolean;
67
+ }
68
+
69
+ export const DEFAULT_ROTATE: RotateSettings = {
70
+ afterCompactions: 3,
71
+ standing: true,
72
+ };
73
+
74
+ /** A rotation Den did (kept in the record, newest last). */
75
+ export interface RotationEntry {
76
+ /** When it relaunched (ISO 8601). */
77
+ at: string;
78
+ reason: RotateReason;
79
+ /** Compactions of the conversation that ended. */
80
+ compactions: number;
81
+ /** The conversation that ended (its resume token). */
82
+ conversation?: string;
83
+ }
84
+
85
+ /** The compactions after which `persona` rotates; 0: it never does. */
86
+ export function rotateAfter(
87
+ settings: RotateSettings,
88
+ persona: { standing?: boolean },
89
+ ): number {
90
+ if (persona.standing && !settings.standing) return 0;
91
+ return settings.afterCompactions;
92
+ }
93
+
94
+ export const rotationFile = (filesDir: string) =>
95
+ path.join(filesDir, ROTATION_FILE);
96
+ export const handoffFile = (filesDir: string) =>
97
+ path.join(filesDir, HANDOFF_FILE);
98
+
99
+ const count = (v: unknown) =>
100
+ typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : 0;
101
+
102
+ /** The rotation state in `filesDir`; null when there is none (or it is unreadable). */
103
+ export function readRotation(filesDir: string): RotationState | null {
104
+ let v: unknown;
105
+ try {
106
+ v = JSON.parse(fs.readFileSync(rotationFile(filesDir), "utf-8"));
107
+ } catch {
108
+ return null;
109
+ }
110
+ if (!v || typeof v !== "object" || Array.isArray(v)) return null;
111
+ const o = v as Record<string, unknown>;
112
+ const s: RotationState = {
113
+ compactions: count(o.compactions),
114
+ after: count(o.after),
115
+ };
116
+ if (typeof o.conversation === "string") s.conversation = o.conversation;
117
+ if (typeof o.lastCompactAt === "string") s.lastCompactAt = o.lastCompactAt;
118
+ if (typeof o.lastTrigger === "string") s.lastTrigger = o.lastTrigger;
119
+ if (typeof o.pendingTrigger === "string") s.pendingTrigger = o.pendingTrigger;
120
+ if (o.due === "compactions" || o.due === "pr-merged") s.due = o.due;
121
+ if (typeof o.dueAt === "string") s.dueAt = o.dueAt;
122
+ return s;
123
+ }
124
+
125
+ export function writeRotation(filesDir: string, s: RotationState): void {
126
+ writePrivate(rotationFile(filesDir), JSON.stringify(s, null, 2) + "\n");
127
+ }
128
+
129
+ /**
130
+ * A launch's rotation state. A new conversation starts at 0. A resume of
131
+ * the same one keeps its count and takes the current `after`.
132
+ */
133
+ export function launchRotation(
134
+ filesDir: string,
135
+ conversation: string | undefined,
136
+ after: number,
137
+ ): RotationState {
138
+ const prev = readRotation(filesDir);
139
+ const same =
140
+ prev && conversation !== undefined && prev.conversation === conversation;
141
+ const s: RotationState = same
142
+ ? { ...prev, after }
143
+ : {
144
+ ...(conversation !== undefined ? { conversation } : {}),
145
+ compactions: 0,
146
+ after,
147
+ };
148
+ if (after === 0) {
149
+ delete s.due;
150
+ delete s.dueAt;
151
+ }
152
+ writeRotation(filesDir, s);
153
+ return s;
154
+ }
155
+
156
+ /**
157
+ * One compaction that happened (SessionStart with source "compact"; never
158
+ * PreCompact, which runs even when nothing is compacted). `input` is the
159
+ * hook input: `session_id` and `trigger` (manual, auto) are read when present.
160
+ * A different conversation than the one counted starts the count again.
161
+ */
162
+ export function noteCompaction(
163
+ filesDir: string,
164
+ input: unknown,
165
+ after: number,
166
+ now: Date = new Date(),
167
+ ): RotationState {
168
+ const i = (input && typeof input === "object" ? input : {}) as Record<
169
+ string,
170
+ unknown
171
+ >;
172
+ const conversation =
173
+ typeof i.session_id === "string" && i.session_id ? i.session_id : undefined;
174
+ const prev = readRotation(filesDir);
175
+ const fresh =
176
+ !prev ||
177
+ (conversation !== undefined &&
178
+ prev.conversation !== undefined &&
179
+ prev.conversation !== conversation);
180
+ const s: RotationState = fresh
181
+ ? { compactions: 0, after }
182
+ : { ...prev, after };
183
+ if (conversation !== undefined) s.conversation = conversation;
184
+ s.compactions += 1;
185
+ s.lastCompactAt = now.toISOString();
186
+ const trigger =
187
+ typeof i.trigger === "string"
188
+ ? i.trigger
189
+ : fresh
190
+ ? undefined
191
+ : prev?.pendingTrigger;
192
+ if (trigger) s.lastTrigger = trigger.slice(0, 20);
193
+ delete s.pendingTrigger;
194
+ if (after > 0 && s.compactions >= after && !s.due) {
195
+ s.due = "compactions";
196
+ s.dueAt = now.toISOString();
197
+ }
198
+ writeRotation(filesDir, s);
199
+ return s;
200
+ }
201
+
202
+ /**
203
+ * PreCompact: keep its trigger (manual, auto) for the count. Nothing is
204
+ * counted; a state for another conversation is left alone.
205
+ */
206
+ export function notePendingCompaction(
207
+ filesDir: string,
208
+ conversation: string | undefined,
209
+ trigger: unknown,
210
+ after: number,
211
+ ): void {
212
+ if (typeof trigger !== "string") return;
213
+ const prev = readRotation(filesDir);
214
+ if (prev?.conversation && conversation && prev.conversation !== conversation)
215
+ return;
216
+ writeRotation(filesDir, {
217
+ ...(prev ?? { compactions: 0, after }),
218
+ ...(conversation ? { conversation } : {}),
219
+ pendingTrigger: trigger.slice(0, 20),
220
+ });
221
+ }
222
+
223
+ /** Mark a rotation due (its pull request merged), when the agent rotates. */
224
+ export function markDue(
225
+ filesDir: string,
226
+ reason: RotateReason,
227
+ conversation?: string,
228
+ now: Date = new Date(),
229
+ ): boolean {
230
+ const s = readRotation(filesDir);
231
+ if (!s || s.after === 0 || s.due) return false;
232
+ if (conversation && s.conversation && s.conversation !== conversation)
233
+ return false;
234
+ writeRotation(filesDir, { ...s, due: reason, dueAt: now.toISOString() });
235
+ return true;
236
+ }
237
+
238
+ /** Clear a rotation due for `reason` (a merge mark gh no longer confirms). */
239
+ export function clearDue(filesDir: string, reason: RotateReason): boolean {
240
+ const s = readRotation(filesDir);
241
+ if (!s || s.due !== reason) return false;
242
+ const { due: _d, dueAt: _a, ...rest } = s;
243
+ writeRotation(filesDir, rest);
244
+ return true;
245
+ }
246
+
247
+ /** Whether the state says a rotation is due, for this conversation. */
248
+ export function dueFor(
249
+ s: RotationState | null,
250
+ conversation?: string,
251
+ ): RotateReason | undefined {
252
+ if (!s || s.after === 0) return undefined;
253
+ if (conversation && s.conversation && s.conversation !== conversation)
254
+ return undefined;
255
+ if (s.due) return s.due;
256
+ return s.compactions >= s.after ? "compactions" : undefined;
257
+ }
258
+
259
+ export type HandoffStatus = "ok" | "missing" | "invalid";
260
+
261
+ /** handoff.md in `filesDir`: missing, or checked. */
262
+ export function readHandoff(filesDir: string): {
263
+ status: HandoffStatus;
264
+ text?: string;
265
+ problems: string[];
266
+ } {
267
+ let text: string;
268
+ try {
269
+ text = fs.readFileSync(handoffFile(filesDir), "utf-8");
270
+ } catch {
271
+ return { status: "missing", problems: [] };
272
+ }
273
+ const r = checkHandoff(text);
274
+ return { status: r.ok ? "ok" : "invalid", text, problems: r.problems };
275
+ }
276
+
277
+ /** An agent's rotation, as the Agents page and the CLI show it. */
278
+ export interface RotationInfo {
279
+ /** Why it rotates, when a trigger fired. */
280
+ due?: RotateReason;
281
+ compactions: number;
282
+ /** 0: this agent never rotates. */
283
+ after: number;
284
+ handoff: HandoffStatus;
285
+ /** What is wrong with handoff.md, when it is invalid. */
286
+ problems?: string[];
287
+ /** Due and handoff.md passes: Relaunch from handoff, not Resume. */
288
+ ready: boolean;
289
+ }
290
+
291
+ /**
292
+ * Agent `rec`'s rotation: null when it has no rotation state for its
293
+ * current conversation (it never ran under 0.4.9, or the state is stale).
294
+ */
295
+ export function rotationInfo(
296
+ stateDir: string,
297
+ rec: AgentRecord,
298
+ ): RotationInfo | null {
299
+ const files = agentFilesDir(stateDir, rec.id);
300
+ const s = readRotation(files);
301
+ if (!s) return null;
302
+ if (s.conversation && rec.resumeToken && s.conversation !== rec.resumeToken)
303
+ return null;
304
+ const due =
305
+ dueFor(s) ??
306
+ (s.after > 0 && rec.outcome?.prMerged ? "pr-merged" : undefined);
307
+ const h = due
308
+ ? readHandoff(files)
309
+ : { status: "missing" as const, problems: [] };
310
+ return {
311
+ ...(due ? { due } : {}),
312
+ compactions: s.compactions,
313
+ after: s.after,
314
+ handoff: h.status,
315
+ ...(h.status === "invalid" ? { problems: h.problems } : {}),
316
+ ready: Boolean(due) && h.status === "ok",
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Move handoff.md aside (handoffs/<tag>.md) once Den relaunched from it, so
322
+ * the next rotation needs a new one. Returns where it went.
323
+ */
324
+ export function archiveHandoff(filesDir: string, tag: string): string {
325
+ const dir = path.join(filesDir, HANDOFFS_DIR);
326
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
327
+ const safe = tag.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80);
328
+ const to = path.join(dir, `${safe}.md`);
329
+ fs.renameSync(handoffFile(filesDir), to);
330
+ return to;
331
+ }
332
+
333
+ /** Why, in words: "3 compactions", "PR merged". */
334
+ export function reasonText(reason: RotateReason, compactions: number): string {
335
+ return reason === "pr-merged"
336
+ ? "PR merged"
337
+ : `${compactions} ${compactions === 1 ? "compaction" : "compactions"}`;
338
+ }
339
+
340
+ /** What the hooks tell a session whose rotation is due. */
341
+ export function rotationInstruction(
342
+ filesDir: string,
343
+ reason: RotateReason,
344
+ compactions: number,
345
+ ): string {
346
+ const why =
347
+ reason === "pr-merged"
348
+ ? "its pull request merged"
349
+ : `it has compacted ${compactions} times`;
350
+ return [
351
+ `Den: this session is done, because ${why} (session rotation).`,
352
+ `Write your handoff now, to ${handoffFile(filesDir)}, with these headings, each filled in:`,
353
+ "## Objective, ## State, ## Open items, ## How to QA, ## Next step.",
354
+ "Write plainly and explain every ID. Then end your turn.",
355
+ "Den relaunches your role from it in a fresh conversation.",
356
+ ].join("\n");
357
+ }
358
+
359
+ /** The first prompt of a session relaunched from `handoff`. */
360
+ export function handoffPrompt(
361
+ handoff: string,
362
+ reason: RotateReason,
363
+ compactions: number,
364
+ ): string {
365
+ return [
366
+ `Den relaunched you from the handoff your last session wrote. That session ended after ${reasonText(reason, compactions)} (session rotation).`,
367
+ "Read it, then carry on from its Next step.",
368
+ "",
369
+ handoff.trim(),
370
+ ].join("\n");
371
+ }
@@ -89,6 +89,16 @@ export interface LaunchInput {
89
89
  resumeToken?: string;
90
90
  /** The CLI detect found (a setting, the login PATH, …); else searched again. */
91
91
  bin?: string;
92
+ /**
93
+ * Session rotation (den#4): the compactions after which this agent's
94
+ * session rotates (0: never). Den's hooks count them. Default 3.
95
+ */
96
+ rotateAfter?: number;
97
+ /**
98
+ * The new conversation's first prompt (a relaunch from handoff.md).
99
+ * Only a runtime with `firstPrompt: true` is given one.
100
+ */
101
+ firstPrompt?: string;
92
102
  }
93
103
 
94
104
  export interface LaunchOutput {
@@ -126,6 +136,11 @@ export interface Runtime {
126
136
  */
127
137
  find?: RuntimeFind;
128
138
  detect(env?: NodeJS.ProcessEnv, opts?: DetectOptions): RuntimeDetect;
139
+ /**
140
+ * It can start a conversation with a first prompt (LaunchInput
141
+ * firstPrompt): Relaunch from handoff needs it (den#4).
142
+ */
143
+ firstPrompt?: boolean;
129
144
  /** Throws LaunchError when this persona or this machine can't run it. */
130
145
  launch(input: LaunchInput): LaunchOutput;
131
146
  /** A new run that continues the last one; null when there is nothing to resume. */