@monster-orange/den 0.4.22 → 0.4.23

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.
@@ -56,6 +56,13 @@ export interface RotationState {
56
56
  /** Set when a trigger fired. */
57
57
  due?: RotateReason;
58
58
  dueAt?: string;
59
+ /** D28: how many times Den asked for the handoff, and the last time. */
60
+ asked?: number;
61
+ askedAt?: string;
62
+ /** D28: auto gave up asking (2 asks, still no handoff): ask mode. */
63
+ fellBack?: boolean;
64
+ /** D28 "Relaunch now" before a handoff: relaunch once it passes, whatever on_due says. */
65
+ relaunchWanted?: boolean;
59
66
  }
60
67
 
61
68
  /** `agents.rotate` with its defaults applied. */
@@ -80,6 +87,8 @@ export interface RotationEntry {
80
87
  compactions: number;
81
88
  /** The conversation that ended (its resume token). */
82
89
  conversation?: string;
90
+ /** D28: Den relaunched it by itself (on_due auto). */
91
+ auto?: boolean;
83
92
  }
84
93
 
85
94
  /** The compactions after which `persona` rotates; 0: it never does. */
@@ -119,6 +128,10 @@ export function readRotation(filesDir: string): RotationState | null {
119
128
  if (typeof o.pendingTrigger === "string") s.pendingTrigger = o.pendingTrigger;
120
129
  if (o.due === "compactions" || o.due === "pr-merged") s.due = o.due;
121
130
  if (typeof o.dueAt === "string") s.dueAt = o.dueAt;
131
+ if (count(o.asked)) s.asked = count(o.asked);
132
+ if (typeof o.askedAt === "string") s.askedAt = o.askedAt;
133
+ if (o.fellBack === true) s.fellBack = true;
134
+ if (o.relaunchWanted === true) s.relaunchWanted = true;
122
135
  return s;
123
136
  }
124
137
 
@@ -235,6 +248,51 @@ export function markDue(
235
248
  return true;
236
249
  }
237
250
 
251
+ /**
252
+ * D28 "Keep this conversation": the count starts again from 0 for the
253
+ * conversation it has, and nothing is due. Returns false with no state.
254
+ */
255
+ export function keepConversation(filesDir: string): boolean {
256
+ const s = readRotation(filesDir);
257
+ if (!s) return false;
258
+ writeRotation(filesDir, {
259
+ ...(s.conversation ? { conversation: s.conversation } : {}),
260
+ compactions: 0,
261
+ after: s.after,
262
+ });
263
+ return true;
264
+ }
265
+
266
+ /** D28: one more ask for the handoff, noted; `relaunch`: Relaunch now asked. */
267
+ export function noteAsked(
268
+ filesDir: string,
269
+ now: Date = new Date(),
270
+ relaunch = false,
271
+ ): RotationState | null {
272
+ const s = readRotation(filesDir);
273
+ if (!s) return null;
274
+ const next: RotationState = {
275
+ ...s,
276
+ asked: (s.asked ?? 0) + 1,
277
+ askedAt: now.toISOString(),
278
+ ...(relaunch ? { relaunchWanted: true } : {}),
279
+ };
280
+ writeRotation(filesDir, next);
281
+ return next;
282
+ }
283
+
284
+ /** D28: auto gave up asking; the callout's buttons take over. */
285
+ export function noteFellBack(filesDir: string): void {
286
+ const s = readRotation(filesDir);
287
+ if (s && !s.fellBack) writeRotation(filesDir, { ...s, fellBack: true });
288
+ }
289
+
290
+ /** D28: the limit the hook counts against, when the settings changed. */
291
+ export function syncAfter(filesDir: string, after: number): void {
292
+ const s = readRotation(filesDir);
293
+ if (s && s.after !== after) writeRotation(filesDir, { ...s, after });
294
+ }
295
+
238
296
  /** Clear a rotation due for `reason` (a merge mark gh no longer confirms). */
239
297
  export function clearDue(filesDir: string, reason: RotateReason): boolean {
240
298
  const s = readRotation(filesDir);
@@ -274,8 +332,31 @@ export function readHandoff(filesDir: string): {
274
332
  return { status: r.ok ? "ok" : "invalid", text, problems: r.problems };
275
333
  }
276
334
 
335
+ /**
336
+ * D28: an agent's effective rotation settings (rotate-settings.ts), when
337
+ * the caller resolved them; without, the state's own limit and den#4's
338
+ * rules apply.
339
+ */
340
+ export interface RotationPolicy {
341
+ /** 0: never rotates. */
342
+ afterCompactions: number;
343
+ onDue: "auto" | "ask" | "off";
344
+ onPrMerged: boolean;
345
+ /** Where each value comes from, in words ("persona planner"). */
346
+ from?: { after_compactions: string; on_due: string; on_pr_merged: string };
347
+ }
348
+
277
349
  /** An agent's rotation, as the Agents page and the CLI show it. */
278
350
  export interface RotationInfo {
351
+ /** D28: what happens when it is due, and where the settings come from. */
352
+ onDue?: RotationPolicy["onDue"];
353
+ from?: RotationPolicy["from"];
354
+ /** D28: Den asked it for its handoff this many times (auto). */
355
+ asked?: number;
356
+ /** D28: auto fell back to ask (the handoff still failed after 2 asks). */
357
+ fellBack?: boolean;
358
+ /** D28: Relaunch now was asked before the handoff: Den relaunches once it passes. */
359
+ relaunchWanted?: boolean;
279
360
  /** Why it rotates, when a trigger fired. */
280
361
  due?: RotateReason;
281
362
  compactions: number;
@@ -295,20 +376,44 @@ export interface RotationInfo {
295
376
  export function rotationInfo(
296
377
  stateDir: string,
297
378
  rec: AgentRecord,
379
+ policy?: RotationPolicy,
298
380
  ): RotationInfo | null {
299
381
  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)
382
+ const read = readRotation(files);
383
+ if (!read) return null;
384
+ if (
385
+ read.conversation &&
386
+ rec.resumeToken &&
387
+ read.conversation !== rec.resumeToken
388
+ )
303
389
  return null;
390
+ // D28: the limit in force now, not the one the launch counted against.
391
+ const s: RotationState = policy
392
+ ? { ...read, after: policy.afterCompactions }
393
+ : read;
394
+ if (
395
+ policy &&
396
+ s.after > 0 &&
397
+ s.compactions < s.after &&
398
+ s.due === "compactions"
399
+ ) {
400
+ delete s.due;
401
+ }
402
+ if (policy && !policy.onPrMerged && s.due === "pr-merged") delete s.due;
403
+ const merged = policy ? policy.onPrMerged : true;
304
404
  const due =
305
405
  dueFor(s) ??
306
- (s.after > 0 && rec.outcome?.prMerged ? "pr-merged" : undefined);
406
+ (s.after > 0 && merged && rec.outcome?.prMerged ? "pr-merged" : undefined);
307
407
  const h = due
308
408
  ? readHandoff(files)
309
409
  : { status: "missing" as const, problems: [] };
310
410
  return {
311
411
  ...(due ? { due } : {}),
412
+ ...(policy ? { onDue: policy.onDue } : {}),
413
+ ...(policy?.from ? { from: policy.from } : {}),
414
+ ...(read.asked ? { asked: read.asked } : {}),
415
+ ...(read.fellBack ? { fellBack: true } : {}),
416
+ ...(read.relaunchWanted ? { relaunchWanted: true } : {}),
312
417
  compactions: s.compactions,
313
418
  after: s.after,
314
419
  handoff: h.status,
@@ -346,7 +451,7 @@ export function rotationInstruction(
346
451
  const why =
347
452
  reason === "pr-merged"
348
453
  ? "its pull request merged"
349
- : `it has compacted ${compactions} times`;
454
+ : `it has compacted ${compactions === 1 ? "once" : `${compactions} times`}`;
350
455
  return [
351
456
  `Den: this session is done, because ${why} (session rotation).`,
352
457
  `Write your handoff now, to ${handoffFile(filesDir)}, with these headings, each filled in:`,
@@ -21,6 +21,7 @@ import { ConfigError, isConfigError } from "./config-error.js";
21
21
  import { validateCatalog, type CatalogEntry } from "./agents/catalog.js";
22
22
  import type { Guardrails } from "./agents/guardrails.js";
23
23
  import { DEFAULT_ROTATE, type RotateSettings } from "./agents/rotation.js";
24
+ import { checkRotateValue, type OrgRotate } from "./agents/rotate-settings.js";
24
25
  import {
25
26
  checkCategoriesConfig,
26
27
  checkRolesConfig,
@@ -965,6 +966,8 @@ export interface ResolvedAgentsConfig {
965
966
  runtimeCatalog: CatalogEntry[];
966
967
  /** agents.rotate with its defaults (den#4); unset: DEFAULT_ROTATE. */
967
968
  rotate?: RotateSettings;
969
+ /** D28: agents.rotate as written, the org scope. */
970
+ rotateOrg?: OrgRotate;
968
971
  /** agents.categories (AG-10.5): the distribution's, after Den's. */
969
972
  categories?: Category[];
970
973
  /** agents.roles (AG-11.1): new roles and Den roles' preset extensions. */
@@ -972,32 +975,41 @@ export interface ResolvedAgentsConfig {
972
975
  }
973
976
 
974
977
  /** `agents.rotate` checked; any other key, or a bad value, throws naming it. */
975
- function rotateBlock(v: unknown): RotateSettings {
978
+ /**
979
+ * `agents.rotate` checked, both ways: den#4's settings (afterCompactions 0
980
+ * for never) and, for D28's scopes, the org's values as written.
981
+ */
982
+ export function rotateOrgBlock(v: unknown): {
983
+ settings: RotateSettings;
984
+ org: OrgRotate;
985
+ } {
976
986
  const out: RotateSettings = { ...DEFAULT_ROTATE };
977
- if (v === undefined || v === null) return out;
987
+ const org: OrgRotate = {};
988
+ if (v === undefined || v === null) return { settings: out, org };
978
989
  if (!isMapping(v))
979
990
  throw new Error(
980
- "agents.rotate: must be a mapping ({ after_compactions: <n>, standing: true|false })",
991
+ "agents.rotate: must be a mapping ({ after_compactions: <n>|never, on_due: auto|ask|off, on_pr_merged: true|false })",
981
992
  );
982
993
  for (const [k, x] of Object.entries(v)) {
983
- if (k === "after_compactions") {
984
- if (typeof x !== "number" || !Number.isInteger(x) || x < 1 || x > 100)
985
- throw new Error(
986
- `agents.rotate.after_compactions: must be a whole number from 1 to 100 (got ${JSON.stringify(x)})`,
987
- );
988
- out.afterCompactions = x;
994
+ if (k === "after_compactions" || k === "on_due" || k === "on_pr_merged") {
995
+ const bad = checkRotateValue(k, x);
996
+ if (bad) throw new Error(`agents.rotate.${bad}`);
997
+ (org as Record<string, unknown>)[k] = x;
998
+ if (k === "after_compactions")
999
+ out.afterCompactions = x === "never" ? 0 : (x as number);
989
1000
  } else if (k === "standing") {
990
1001
  if (typeof x !== "boolean")
991
1002
  throw new Error(
992
1003
  `agents.rotate.standing: must be true or false (got ${JSON.stringify(x)})`,
993
1004
  );
994
1005
  out.standing = x;
1006
+ org.standing = x;
995
1007
  } else
996
1008
  throw new Error(
997
- `agents.rotate.${k}: unknown key (takes after_compactions, standing)`,
1009
+ `agents.rotate.${k}: unknown key (takes after_compactions, on_due, on_pr_merged)`,
998
1010
  );
999
1011
  }
1000
- return out;
1012
+ return { settings: out, org };
1001
1013
  }
1002
1014
 
1003
1015
  /** An agent id or workspace name: what WORKSPACE_NAME_RE accepts. */
@@ -1060,7 +1072,10 @@ export function readAgentsConfig(
1060
1072
  raw.runtime_catalog,
1061
1073
  "agents.runtime_catalog",
1062
1074
  ),
1063
- rotate: rotateBlock(raw.rotate),
1075
+ ...(() => {
1076
+ const r = rotateOrgBlock(raw.rotate);
1077
+ return { rotate: r.settings, rotateOrg: r.org };
1078
+ })(),
1064
1079
  };
1065
1080
  out.categories = checkCategoriesConfig(raw.categories);
1066
1081
  out.roles = checkRolesConfig(raw.roles, [