@monster-orange/den 0.4.22 → 0.4.24

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 CHANGED
@@ -4,6 +4,79 @@ 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.24] - 2026-11-10
8
+
9
+ ### Fixed
10
+
11
+ - **Visit opens in Safari (D27).** Visit, in the service panel and the
12
+ rail's ↗, is now a real link (`target=_blank`) instead of a button that
13
+ called `window.open`, which Safari's popup blocker stopped. The panel
14
+ also holds its 5 s repaint while a pointer is down in it, so a click
15
+ is never lost to a repaint between press and release.
16
+ - **Signing in from another machine (D29).** On an unclaimed Den with
17
+ `web.owner_github` set, "Sign in as @login" is the primary button. The
18
+ button that only works on the Den machine says so: "On <host> itself:
19
+ connect GitHub". With only `web.allowed_github_org`, the page says to
20
+ open it on that machine, or to set `web.owner_github`.
21
+ - **Rotation overrides are picked by name (D30).** Settings › Agents ›
22
+ Rotation's Add override form has searchable pickers (type to filter,
23
+ arrow keys, Enter) for the agent, persona, workspace or service, and a
24
+ choice for each value, never a typed id. The overrides table shows
25
+ names; an override for something that is gone is greyed, with Remove.
26
+ At 375 px the table stacks into cards, so no word breaks mid-letter.
27
+ - **doctor, where it runs.** In login mode (as installed), the server and
28
+ session host in the desktop session show ℹ, not ⚠. An older session
29
+ host still serving, with the service's own host standing by, is ℹ
30
+ "Nothing to do", not "unknown ✓". The version-manager node warning at
31
+ `service install` prints once, for the web job.
32
+
33
+ ## [0.4.23] - 2026-11-10
34
+
35
+ ### Added
36
+
37
+ - **Agents relaunch from their handoff by themselves (D28).** When an
38
+ agent's rotation is due (after `after_compactions` compactions, or its
39
+ pull request merged), Den asks it for its handoff at its next idle,
40
+ never mid-turn, and relaunches it once the handoff passes: a fresh
41
+ conversation with the same name, persona, workspace and folder. A toast
42
+ and the audit log say so, and the old conversation is kept. Den asks
43
+ twice at most, then falls back to asking you.
44
+ - **The rotation callout has buttons, running or stopped**, on the row and
45
+ in the pane bar: Relaunch now (asking for the handoff first if needed),
46
+ Write handoff, Keep this conversation (with "Don't ask again for this
47
+ agent") and Settings. It says what happens next and where the settings
48
+ come from. Before, a running agent got a message and nothing to press,
49
+ and Stop then Start showed the same message again.
50
+ - **Rotation settings at every scope.** `after_compactions` (a number or
51
+ `never`), `on_due` (`auto`, the default, `ask` or `off`) and
52
+ `on_pr_merged`. The most specific wins: agent, persona in a workspace,
53
+ persona, workspace, service, this install, org config. Settings › Agents
54
+ › Rotation and `<cli> agents rotate` set them; `<cli> agents rotate <id>
55
+ --show` names where each value comes from. `<cli> agents keep` and
56
+ `<cli> agents handoff` are the callout's buttons. A change applies to
57
+ running agents too.
58
+ - Resume (or Start) on a due agent follows `on_due`: `auto` relaunches it
59
+ from its handoff, `ask` refuses with the callout's choices, `off` goes
60
+ on with the same conversation.
61
+
62
+ ### Fixed
63
+
64
+ - **`web restart` with the service installed** restarts the launchd job
65
+ and names its pid and the service's log. Before, it started a plain
66
+ server, said "started", and that server died seconds later because
67
+ launchd had brought the job back onto the port. `web up` starts a job
68
+ that isn't answering; `web down` refuses and names `service uninstall`.
69
+ - The "port in use" message no longer says to kill whatever holds the
70
+ port, which killed the service. It names `service status` and `web
71
+ restart`.
72
+
73
+ ### Migrations
74
+
75
+ - None required. Rotation now relaunches by itself (`on_due: auto`). To
76
+ keep den#4's behavior (a person relaunches), set `on_due: ask` under
77
+ `agents.rotate` in the org config, or `<cli> agents rotate on_due ask`.
78
+ `agents.rotate.standing` still works; a persona override replaces it.
79
+
7
80
  ## [0.4.22] - 2026-11-10
8
81
 
9
82
  ### Added
@@ -1969,6 +1969,14 @@ terminal and agent) as launchd jobs, restarted if they exit:
1969
1969
  ./bin/acme service uninstall # stop them and remove their plists
1970
1970
  ```
1971
1971
 
1972
+ With the service installed, `web` works through launchd. `web restart`
1973
+ restarts the job (`launchctl kickstart -k`) and names its new pid and the
1974
+ service's log. `web up` starts the job when it isn't answering. `web down`
1975
+ refuses, because launchd would only start it again: use `service
1976
+ uninstall` to stop it. Under `always`, restarting needs sudo, so `web
1977
+ restart` prints the `sudo launchctl kickstart -k system/<label>` line for
1978
+ you to run.
1979
+
1972
1980
  ### Login or always
1973
1981
 
1974
1982
  | `--mode` | Jobs | At logout |
@@ -3296,10 +3304,12 @@ report. It works on den-claude for personas with Den's hooks on.
3296
3304
  the turn once while the handoff is missing or fails the check. The
3297
3305
  instruction: write `handoff.md` in the agent's files folder, then end
3298
3306
  the turn.
3299
- 4. Den relaunches the role. **Relaunch from handoff** starts a fresh
3300
- conversation (a new conversation id) with the same persona, workspace,
3301
- folder and grants. Its first prompt is the handoff. The handoff moves to
3302
- `agent-files/<id>/handoffs/`, and the record notes the rotation.
3307
+ 4. Den relaunches the role, by itself unless the settings say otherwise
3308
+ (`on_due`, below). A relaunch starts a fresh conversation (a new
3309
+ conversation id) with the same name, persona, workspace, folder and
3310
+ grants. Its first prompt is the handoff. The handoff moves to
3311
+ `agent-files/<id>/handoffs/`, the record notes the rotation, and the
3312
+ old conversation is kept.
3303
3313
 
3304
3314
  **The handoff** has five parts, each a heading, each filled in. Write it
3305
3315
  plainly, and explain every ID, as in a hand-back. It must stay under 20,000
@@ -3332,38 +3342,89 @@ agent's `handoff.md` (by its id), a file, or stdin: exit 0 when
3332
3342
  it passes, 1 with each missing or empty part and each unexplained ID. A
3333
3343
  part can also be a line such as `Next step: open the PR`.
3334
3344
 
3335
- **Resume after a rotation.** Once a rotation is due and `handoff.md`
3336
- passes, Den declines a plain Resume of that conversation:
3337
-
3338
- - The Agents page shows the row as "Ready to relaunch (3 compactions)" or
3339
- "Ready to relaunch (PR merged)". Its buttons are Relaunch from handoff
3340
- (the primary one), Start fresh and Remove.
3341
- - `<cli> agents resume <id>` refuses (exit 1) and prints
3342
- `Relaunch from handoff: <cli> agents relaunch <id>`. The resume route
3343
- answers 409 with code `rotated`.
3344
- - `<cli> agents relaunch <id> [-w <ws>]` and
3345
- `POST /api/workspaces/<ws>/agents/<id>/relaunch` relaunch it. A running
3346
- rotated agent is stopped first.
3347
- - With `handoff.md` missing or failing the check, the row says so, and
3348
- Resume and Start fresh stay. Resume the agent to write or fix it.
3349
- Relaunch says what is wrong (409 `no_handoff`) and names Start fresh.
3350
- - After a restart, a standing persona that rotated relaunches on its own,
3351
- as a standing persona resumes on its own.
3352
-
3353
- **Settings**, in the org config:
3345
+ **What happens when a rotation is due** (`on_due`):
3346
+
3347
+ - `auto` (the default). At the agent's next idle, never mid-turn, Den
3348
+ types the instruction into its session: write the handoff. Once the
3349
+ handoff passes the check and the agent is idle, Den relaunches it. The
3350
+ page shows a toast ("Relaunched <id> from its handoff after 3
3351
+ compactions. The old conversation <id> is kept.") and the audit log
3352
+ records it. Den asks at most twice, two minutes apart. If the handoff
3353
+ still fails, it falls back to `ask`.
3354
+ - `ask`. Nothing happens by itself. The agent's callout has the buttons.
3355
+ - `off`. Nothing happens, and no callout shows. Resume goes on with the
3356
+ same conversation.
3357
+
3358
+ **The callout** shows on a due agent's row (running or stopped) and in its
3359
+ pane bar. It says what happens next and where the settings come from
3360
+ ("Rotates after 3 compactions (persona planner); when due: relaunch by
3361
+ itself (default)"). Its buttons:
3362
+
3363
+ - **Relaunch now**: from the handoff when it passes. Otherwise Den asks
3364
+ for it first (a stopped agent resumes to write it) and relaunches it
3365
+ once it passes, whatever `on_due` says.
3366
+ - **Write handoff**: on a running agent without one, types the
3367
+ instruction. It is refused mid-turn.
3368
+ - **Keep this conversation**: the compaction count starts again. With
3369
+ "Don't ask again for this agent", that agent's `after_compactions` is
3370
+ set to `never`. A person's only.
3371
+ - **Settings**: Settings › Agents › Rotation.
3372
+
3373
+ **Resume and Start on a due agent** follow `on_due`. Under `auto`, Resume
3374
+ relaunches it from its handoff once the handoff passes. Under `ask`,
3375
+ `<cli> agents resume <id>` refuses (exit 1) and names
3376
+ `<cli> agents relaunch <id>`, and the resume route answers 409 with code
3377
+ `rotated`. `<cli> agents relaunch <id> [-w <ws>]` and
3378
+ `POST /api/workspaces/<ws>/agents/<id>/relaunch` relaunch it at any time
3379
+ (a running agent is stopped first; a missing or failing handoff is a 409
3380
+ `no_handoff`). After a restart, a standing persona that rotated relaunches
3381
+ on its own.
3382
+
3383
+ **Settings.** Three keys:
3384
+
3385
+ | Key | Values | Default |
3386
+ | ------------------- | ---------------------- | ------- |
3387
+ | `after_compactions` | 1 to 100, or `never` | 3 |
3388
+ | `on_due` | `auto`, `ask` or `off` | `auto` |
3389
+ | `on_pr_merged` | `true` or `false` | `true` |
3390
+
3391
+ Each key comes from the most specific scope that sets it: the agent, the
3392
+ persona in a workspace, the persona, the workspace, the service (the
3393
+ workspace service whose folder holds the agent's folder), this install,
3394
+ the org config, then Den's default. A change applies to running agents
3395
+ too, at their next look (about every 15 seconds).
3396
+
3397
+ The org's values go in the org config:
3354
3398
 
3355
3399
  ```yaml
3356
3400
  agents:
3357
3401
  rotate:
3358
- after_compactions: 3 # 1 to 100; the default is 3
3359
- standing: true # standing personas rotate too (the default)
3402
+ after_compactions: 3 # 1 to 100, or never
3403
+ on_due: auto # auto, ask or off
3404
+ on_pr_merged: true
3360
3405
  ```
3361
3406
 
3362
- `standing` is for standing personas, such as the planner and the
3363
- orchestrator. With `true` (the default), they rotate like the rest: only
3364
- their context resets, and they keep their id and workspace. With `false`,
3365
- they never rotate. A setting takes effect at the agent's next start or
3366
- relaunch. An unknown key, or a bad value, is an error naming the key.
3407
+ This install's values and every override are set in Settings › Agents ›
3408
+ Rotation, or from the command line, and kept in
3409
+ `<stateDir>/agent-rotation.json`:
3410
+
3411
+ ```bash
3412
+ <cli> agents rotate # every value and override
3413
+ <cli> agents rotate on_due ask # this install
3414
+ <cli> agents rotate --persona planner after_compactions never
3415
+ <cli> agents rotate --persona planner -w notes after_compactions 5 # that persona in notes
3416
+ <cli> agents rotate --workspace notes on_due off
3417
+ <cli> agents rotate --service shop on_pr_merged false
3418
+ <cli> agents rotate --agent art --unset after_compactions
3419
+ <cli> agents rotate art --show # its values, and where each comes from
3420
+ <cli> agents keep <id> [--never] # Keep this conversation
3421
+ <cli> agents handoff <id> # Write handoff
3422
+ ```
3423
+
3424
+ `agents rotate` and `agents keep` are refused inside an agent's session.
3425
+ den#4's `standing: false` still works: standing personas never rotate,
3426
+ unless a persona or agent override says otherwise. Set it per persona
3427
+ instead (`--persona planner after_compactions never`).
3367
3428
 
3368
3429
  ### Runtimes
3369
3430
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.22",
3
+ "version": "0.4.24",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -110,8 +110,9 @@ pick_node() {
110
110
  done
111
111
  # Warn when installing; `print` writes the plist and nothing else.
112
112
  if [ "${1:-}" = install ]; then
113
- echo "⚠ the service will run $node, a version manager's node: it stops starting" >&2
114
- echo " when that version is removed. Re-run install after switching, or set DEN_SERVICE_NODE." >&2
113
+ echo "⚠ the web job will run $node, a version manager's node: it stops starting" >&2
114
+ echo " when that version is removed (the session host runs Den's own copy of node)." >&2
115
+ echo " Re-run install after switching, or set DEN_SERVICE_NODE." >&2
115
116
  fi
116
117
  echo "$node"
117
118
  }
@@ -165,7 +166,8 @@ host_exe() { # $1 node
165
166
  }
166
167
 
167
168
  write_plist() { # $1 = output file, $2 = web|host, $3 = mode
168
- NODE="$(pick_node "$MODE")"
169
+ # Warned once, for the web job: the host job runs Den's node copy.
170
+ NODE="$(pick_node "$([ "$2" = web ] && echo "$MODE")")"
169
171
  TSX="$(find_tsx)" || { echo "✗ tsx not installed: run npm ci in $DIST" >&2; exit 1; }
170
172
  local exe="$NODE" label="$LABEL" log="web.log"
171
173
  if [ "$2" = host ]; then
@@ -59,6 +59,11 @@ export function rotationHookOutput(
59
59
  now: Date = new Date(),
60
60
  ): string {
61
61
  if (!input || typeof input !== "object") return "";
62
+ // D28: the limit in force is the one Den keeps in rotation.json (it
63
+ // follows the settings); the launch's --after is the first one.
64
+ const stored = readRotation(args.files);
65
+ if (stored && stored.after !== args.after)
66
+ args = { ...args, after: stored.after };
62
67
  const i = input as Record<string, unknown>;
63
68
  const event = i.hook_event_name;
64
69
  const conversation =
@@ -0,0 +1,309 @@
1
+ /**
2
+ * D28: session-rotation settings and where each comes from.
3
+ *
4
+ * after_compactions a whole number (1–100), or "never"; default 3
5
+ * on_due auto | ask | off; default auto
6
+ * auto: Den asks the agent for its handoff at its next
7
+ * idle and relaunches it from it by itself
8
+ * ask: the callout's buttons (Relaunch now, Write
9
+ * handoff, Keep this conversation, Settings)
10
+ * off: nothing happens; the count is still shown
11
+ * on_pr_merged true | false; default true
12
+ *
13
+ * Scopes, the most specific wins, each key on its own:
14
+ * agent › persona in a workspace › persona › workspace › service (the
15
+ * workspace service whose folder holds the agent's) › install (Settings ›
16
+ * Agents › Rotation, this machine) › org (`agents.rotate` in the org
17
+ * config) › Den's default.
18
+ *
19
+ * The install value and every override live in
20
+ * `<stateDir>/agent-rotation.json` (0600; never under agents/, where every
21
+ * file is an agent's record). The org value is the org config's.
22
+ */
23
+ import * as fs from "fs";
24
+ import * as path from "path";
25
+ import { writePrivate } from "./records.js";
26
+
27
+ export type OnDue = "auto" | "ask" | "off";
28
+
29
+ /** One scope's settings, as written: any key may be absent. */
30
+ export interface RotateValues {
31
+ after_compactions?: number | "never";
32
+ on_due?: OnDue;
33
+ on_pr_merged?: boolean;
34
+ }
35
+
36
+ export type RotateKey = keyof RotateValues;
37
+ export const ROTATE_KEYS: readonly RotateKey[] = [
38
+ "after_compactions",
39
+ "on_due",
40
+ "on_pr_merged",
41
+ ];
42
+
43
+ /** The scopes an override can be set at (most specific first). */
44
+ export const OVERRIDE_SCOPES = [
45
+ "agent",
46
+ "persona-workspace",
47
+ "persona",
48
+ "workspace",
49
+ "service",
50
+ ] as const;
51
+ export type OverrideScope = (typeof OVERRIDE_SCOPES)[number];
52
+ export type RotateScope = OverrideScope | "install" | "org" | "default";
53
+
54
+ export const ROTATE_DEFAULTS: Required<RotateValues> = {
55
+ after_compactions: 3,
56
+ on_due: "auto",
57
+ on_pr_merged: true,
58
+ };
59
+
60
+ /** `<stateDir>/agent-rotation.json`. */
61
+ export interface RotateFile {
62
+ install?: RotateValues;
63
+ overrides?: Partial<Record<OverrideScope, Record<string, RotateValues>>>;
64
+ }
65
+
66
+ /** Who an agent is, for resolving its settings. */
67
+ export interface RotateSubject {
68
+ agent: string;
69
+ persona: string;
70
+ workspace: string;
71
+ /** The workspace service whose folder holds the agent's, if any. */
72
+ service?: string;
73
+ /** A standing persona (planner, orchestrator): `standing: false` in the org config keeps it from rotating. */
74
+ standing?: boolean;
75
+ }
76
+
77
+ /** Where a value came from: "default", or a scope and its target. */
78
+ export interface RotateSource {
79
+ scope: RotateScope;
80
+ /** The agent id, persona, "persona@workspace", workspace or service. */
81
+ target?: string;
82
+ }
83
+
84
+ export interface EffectiveRotate {
85
+ /** 0: never rotates. */
86
+ afterCompactions: number;
87
+ onDue: OnDue;
88
+ onPrMerged: boolean;
89
+ source: Record<RotateKey, RotateSource>;
90
+ }
91
+
92
+ /** The org config's `agents.rotate`, checked (den-config.ts). */
93
+ export interface OrgRotate extends RotateValues {
94
+ /** Dropped by D28 (persona scope replaces it); still honoured. */
95
+ standing?: boolean;
96
+ }
97
+
98
+ export const rotateFilePath = (stateDir: string) =>
99
+ path.join(stateDir, "agent-rotation.json");
100
+
101
+ /**
102
+ * One value checked: the problem in words, or null. `after_compactions`
103
+ * takes 1–100 or "never" (0 also means never, from the CLI).
104
+ */
105
+ export function checkRotateValue(key: string, v: unknown): string | null {
106
+ if (key === "after_compactions")
107
+ return v === "never" ||
108
+ (typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 100)
109
+ ? null
110
+ : `after_compactions: a whole number from 1 to 100, or never (got ${JSON.stringify(v)})`;
111
+ if (key === "on_due")
112
+ return v === "auto" || v === "ask" || v === "off"
113
+ ? null
114
+ : `on_due: auto, ask or off (got ${JSON.stringify(v)})`;
115
+ if (key === "on_pr_merged")
116
+ return typeof v === "boolean"
117
+ ? null
118
+ : `on_pr_merged: true or false (got ${JSON.stringify(v)})`;
119
+ return `${key}: unknown key (takes ${ROTATE_KEYS.join(", ")})`;
120
+ }
121
+
122
+ /** A command-line value as the key takes it ("3", "never", "ask", "false"). */
123
+ export function parseRotateValue(key: string, raw: string): unknown {
124
+ if (key === "after_compactions")
125
+ return raw === "never" || raw === "0"
126
+ ? "never"
127
+ : /^\d+$/.test(raw)
128
+ ? Number(raw)
129
+ : raw;
130
+ if (key === "on_pr_merged")
131
+ return raw === "true" ? true : raw === "false" ? false : raw;
132
+ return raw;
133
+ }
134
+
135
+ /** Only the valid keys of `v` (a bad file never breaks an agent). */
136
+ function cleanValues(v: unknown): RotateValues {
137
+ const out: RotateValues = {};
138
+ if (!v || typeof v !== "object" || Array.isArray(v)) return out;
139
+ for (const k of ROTATE_KEYS) {
140
+ const x = (v as Record<string, unknown>)[k];
141
+ if (x !== undefined && checkRotateValue(k, x) === null)
142
+ (out as Record<string, unknown>)[k] = x;
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /** agent-rotation.json, cleaned; {} when absent or unreadable. */
148
+ export function readRotateFile(stateDir: string): RotateFile {
149
+ let v: unknown;
150
+ try {
151
+ v = JSON.parse(fs.readFileSync(rotateFilePath(stateDir), "utf-8"));
152
+ } catch {
153
+ return {};
154
+ }
155
+ if (!v || typeof v !== "object" || Array.isArray(v)) return {};
156
+ const o = v as Record<string, unknown>;
157
+ const out: RotateFile = {};
158
+ const inst = cleanValues(o.install);
159
+ if (Object.keys(inst).length) out.install = inst;
160
+ if (o.overrides && typeof o.overrides === "object") {
161
+ const ov: RotateFile["overrides"] = {};
162
+ for (const scope of OVERRIDE_SCOPES) {
163
+ const byTarget = (o.overrides as Record<string, unknown>)[scope];
164
+ if (!byTarget || typeof byTarget !== "object") continue;
165
+ for (const [target, vals] of Object.entries(byTarget)) {
166
+ const c = cleanValues(vals);
167
+ if (Object.keys(c).length) (ov[scope] ??= {})[target] = c;
168
+ }
169
+ }
170
+ if (Object.keys(ov).length) out.overrides = ov;
171
+ }
172
+ return out;
173
+ }
174
+
175
+ export function writeRotateFile(stateDir: string, f: RotateFile): void {
176
+ fs.mkdirSync(path.dirname(rotateFilePath(stateDir)), {
177
+ recursive: true,
178
+ mode: 0o700,
179
+ });
180
+ writePrivate(rotateFilePath(stateDir), JSON.stringify(f, null, 2) + "\n");
181
+ }
182
+
183
+ /**
184
+ * Set (or with `value` undefined, clear) one key at one scope. `scope`
185
+ * "install" takes no target. Throws, naming the problem, on a bad key or
186
+ * value. Returns the file as written.
187
+ */
188
+ export function setRotateValue(
189
+ stateDir: string,
190
+ scope: OverrideScope | "install",
191
+ target: string | undefined,
192
+ key: string,
193
+ value: unknown,
194
+ ): RotateFile {
195
+ if (value !== undefined) {
196
+ const bad = checkRotateValue(key, value);
197
+ if (bad) throw new Error(bad);
198
+ } else if (!(ROTATE_KEYS as readonly string[]).includes(key))
199
+ throw new Error(checkRotateValue(key, undefined)!);
200
+ if (scope !== "install" && !target)
201
+ throw new Error(`a ${scope} override needs the ${scope} it is for`);
202
+ const f = readRotateFile(stateDir);
203
+ const k = key as RotateKey;
204
+ const apply = (vals: RotateValues | undefined): RotateValues => {
205
+ const next: RotateValues = { ...(vals ?? {}) };
206
+ if (value === undefined) delete next[k];
207
+ else (next as Record<string, unknown>)[k] = value;
208
+ return next;
209
+ };
210
+ if (scope === "install") {
211
+ const next = apply(f.install);
212
+ if (Object.keys(next).length) f.install = next;
213
+ else delete f.install;
214
+ } else {
215
+ const ov = (f.overrides ??= {});
216
+ const byTarget = (ov[scope] ??= {});
217
+ const next = apply(byTarget[target!]);
218
+ if (Object.keys(next).length) byTarget[target!] = next;
219
+ else delete byTarget[target!];
220
+ if (!Object.keys(byTarget).length) delete ov[scope];
221
+ if (!Object.keys(ov).length) delete f.overrides;
222
+ }
223
+ writeRotateFile(stateDir, f);
224
+ return f;
225
+ }
226
+
227
+ /** The target an override scope uses for `s`, or undefined. */
228
+ export function targetFor(
229
+ scope: OverrideScope,
230
+ s: RotateSubject,
231
+ ): string | undefined {
232
+ switch (scope) {
233
+ case "agent":
234
+ return s.agent;
235
+ case "persona-workspace":
236
+ return `${s.persona}@${s.workspace}`;
237
+ case "persona":
238
+ return s.persona;
239
+ case "workspace":
240
+ return s.workspace;
241
+ case "service":
242
+ return s.service;
243
+ }
244
+ }
245
+
246
+ /** Agent `s`'s settings, each key from the most specific scope that sets it. */
247
+ export function resolveRotate(
248
+ s: RotateSubject,
249
+ file: RotateFile,
250
+ org: OrgRotate | undefined,
251
+ ): EffectiveRotate {
252
+ const layers: { source: RotateSource; values: RotateValues }[] = [];
253
+ for (const scope of OVERRIDE_SCOPES) {
254
+ const target = targetFor(scope, s);
255
+ const values = target ? file.overrides?.[scope]?.[target] : undefined;
256
+ if (values) layers.push({ source: { scope, target }, values });
257
+ }
258
+ if (file.install)
259
+ layers.push({ source: { scope: "install" }, values: file.install });
260
+ if (org) {
261
+ const values: RotateValues = { ...org };
262
+ delete (values as OrgRotate).standing;
263
+ // den#4's `standing: false`: standing personas never rotate.
264
+ if (org.standing === false && s.standing)
265
+ values.after_compactions = "never";
266
+ layers.push({ source: { scope: "org" }, values });
267
+ }
268
+ const pick = <K extends RotateKey>(k: K) => {
269
+ for (const l of layers)
270
+ if (l.values[k] !== undefined)
271
+ return {
272
+ v: l.values[k] as Required<RotateValues>[K],
273
+ source: l.source,
274
+ };
275
+ return {
276
+ v: ROTATE_DEFAULTS[k],
277
+ source: { scope: "default" } as RotateSource,
278
+ };
279
+ };
280
+ const a = pick("after_compactions");
281
+ const o = pick("on_due");
282
+ const p = pick("on_pr_merged");
283
+ return {
284
+ afterCompactions: a.v === "never" ? 0 : a.v,
285
+ onDue: o.v,
286
+ onPrMerged: p.v,
287
+ source: {
288
+ after_compactions: a.source,
289
+ on_due: o.source,
290
+ on_pr_merged: p.source,
291
+ },
292
+ };
293
+ }
294
+
295
+ /** "persona planner", "agent art", "this install", "org config", "default". */
296
+ export function sourceText(src: RotateSource): string {
297
+ switch (src.scope) {
298
+ case "default":
299
+ return "default";
300
+ case "install":
301
+ return "this install";
302
+ case "org":
303
+ return "org config";
304
+ case "persona-workspace":
305
+ return `persona ${src.target!.replace("@", " in ")}`;
306
+ default:
307
+ return `${src.scope} ${src.target}`;
308
+ }
309
+ }