omp-conductor 0.19.7 → 0.20.1

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.
Files changed (71) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -1
@@ -2147,17 +2147,27 @@ export async function ensureSetupArm(
2147
2147
  projectName: string,
2148
2148
  arm: typeof armTicks = armTicks,
2149
2149
  /**
2150
- * Where the arm proof's pending heartbeat goes while it waits (#861). Setup
2151
- * holds dispatch under its own fence across this call, so a silent wait here
2152
- * is the whole incident: the operator sees a stalled wizard and cannot tell
2153
- * a live handshake from a dead process.
2150
+ * Where the ceremony's one line goes. Setup holds dispatch under its own
2151
+ * fence across this call, so the send receipt has to be visible: an operator
2152
+ * who sees nothing cannot tell a sent challenge from a stalled wizard (#861).
2154
2153
  */
2155
2154
  progress?: (line: string) => void,
2156
2155
  ): Promise<string> {
2157
- const armed = await arm(projectName, progress === undefined ? {} : { progress });
2156
+ const result = await arm(projectName, progress === undefined ? {} : { progress });
2157
+ // Arming is two steps now: the wizard can send the challenge, but only the
2158
+ // operator's reply arms the heartbeat, and that reply lands in the console
2159
+ // session rather than here. Reporting "armed" off a sent challenge would be a
2160
+ // lie the operator only discovers when no tick ever fires.
2161
+ if (result.outcome === "challenge-sent") {
2162
+ return (
2163
+ `arming challenge sent to owner ${result.owner}, valid for ${result.validFor} — NOT yet armed. ` +
2164
+ `Finish it with: ${result.followUp}`
2165
+ );
2166
+ }
2167
+ const armed = result.armed[0]!;
2158
2168
  return armed.alreadyArmed
2159
- ? `existing heartbeat arm revalidated for owner ${armed.owner} at ${armed.path}`
2160
- : `heartbeat armed for owner ${armed.owner} at ${armed.path}`;
2169
+ ? `existing heartbeat arm revalidated for owner ${result.owner} at ${armed.path}`
2170
+ : `heartbeat armed for owner ${result.owner} at ${armed.path}`;
2161
2171
  }
2162
2172
 
2163
2173
  /**
@@ -2608,6 +2618,12 @@ interface SetupInventory {
2608
2618
  runtime: CapturedPathState[];
2609
2619
  /** The AGENTS.md brief link the apply will create/update, when planned. */
2610
2620
  briefLink?: CapturedPathState;
2621
+ /** The console floor and its AGENTS.md link, when the apply will write them.
2622
+ * Captured separately from {@link runtime} because the link is a symlink and
2623
+ * restores as one — and because a half-applied console left behind after a
2624
+ * failed apply is a session pointing at a brief this transaction rolled back. */
2625
+ consoleBrief?: CapturedPathState;
2626
+ consoleLink?: CapturedPathState;
2611
2627
  /** The run store: byte-consistent snapshot when it exists, absence on a first install. */
2612
2628
  store: StoreCapture;
2613
2629
  /** The daemon pidfile record the smoke/restart legs write. */
@@ -2690,6 +2706,18 @@ function captureInventory(
2690
2706
  (plan.runtime.briefLink.action === "create" || plan.runtime.briefLink.action === "update")
2691
2707
  ? capturePathState(plan.runtime.briefLink.path)
2692
2708
  : undefined;
2709
+ // The console workspace: same "only what will be written" rule, and the link
2710
+ // is captured only when the plan will touch it — a `skip` is an operator's
2711
+ // own file the apply never writes, so it has no pre-entry state to restore.
2712
+ const consoleBrief =
2713
+ plan.runtime.console !== undefined && plan.runtime.console.brief.action !== "keep"
2714
+ ? capturePathState(plan.runtime.console.brief.path)
2715
+ : undefined;
2716
+ const consoleLink =
2717
+ plan.runtime.console !== undefined &&
2718
+ (plan.runtime.console.link.action === "create" || plan.runtime.console.link.action === "update")
2719
+ ? capturePathState(plan.runtime.console.link.path)
2720
+ : undefined;
2693
2721
  const record = capturePathState(recordPath());
2694
2722
  const armMarker =
2695
2723
  plan.project.escalation.orchestrator === "external"
@@ -2702,6 +2730,8 @@ function captureInventory(
2702
2730
  ...briefs,
2703
2731
  ...runtime,
2704
2732
  ...(briefLink === undefined ? [] : [briefLink]),
2733
+ ...(consoleBrief === undefined ? [] : [consoleBrief]),
2734
+ ...(consoleLink === undefined ? [] : [consoleLink]),
2705
2735
  record,
2706
2736
  ...(armMarker === undefined ? [] : [armMarker]),
2707
2737
  ];
@@ -2719,6 +2749,9 @@ function captureInventory(
2719
2749
  const dirs = new Set<string>();
2720
2750
  if (answers.writeOrchestratorBrief) dirs.add(dirname(briefPathForProject(plan.project)));
2721
2751
  if (briefLink !== undefined) dirs.add(dirname(briefLink.path));
2752
+ // The console cwd, created by the brief write or the link.
2753
+ if (consoleBrief !== undefined) dirs.add(dirname(consoleBrief.path));
2754
+ if (consoleLink !== undefined) dirs.add(dirname(consoleLink.path));
2722
2755
  if (plan.runtime.tick !== undefined && plan.runtime.tick.action !== "keep") {
2723
2756
  dirs.add(dirname(plan.runtime.tick.path));
2724
2757
  }
@@ -2729,6 +2762,8 @@ function captureInventory(
2729
2762
  briefs,
2730
2763
  runtime,
2731
2764
  ...(briefLink === undefined ? {} : { briefLink }),
2765
+ ...(consoleBrief === undefined ? {} : { consoleBrief }),
2766
+ ...(consoleLink === undefined ? {} : { consoleLink }),
2732
2767
  store: captureStore(dbPath()),
2733
2768
  record,
2734
2769
  ...(armMarker === undefined ? {} : { armMarker }),
@@ -2794,6 +2829,11 @@ async function restoreInventory(
2794
2829
  // operator's regular file that appeared mid-flight survives — reported as a
2795
2830
  // failure, because the prior state is then not coherent.
2796
2831
  restore(inventory.briefLink, { preserveRegularFile: true });
2832
+ // The console floor, then its link, on the same terms: the floor is an
2833
+ // ordinary file, and the link is a symlink that never displaces a regular
2834
+ // file that appeared mid-flight.
2835
+ restore(inventory.consoleBrief);
2836
+ restore(inventory.consoleLink, { preserveRegularFile: true });
2797
2837
 
2798
2838
  // The run store: replace from the byte-consistent snapshot (or remove it
2799
2839
  // entirely on a first-install rollback).
@@ -3195,6 +3235,14 @@ export async function setup(
3195
3235
  : d.runtime.briefLink.action === "keep"
3196
3236
  ? `Keeps the brief link at ${d.runtime.briefLink.path}.`
3197
3237
  : `${d.runtime.briefLink.action === "create" ? "Creates" : "Replaces"} the brief link at ${d.runtime.briefLink.path} -> ${d.runtime.briefLink.target}.`,
3238
+ // The console workspace is a mutation like the rest, so consent names it:
3239
+ // an operator who picked "Apply" must have been told a second session's
3240
+ // cwd and brief appear under the state root.
3241
+ d.runtime.console === undefined
3242
+ ? ""
3243
+ : d.runtime.console.brief.action === "keep"
3244
+ ? `Keeps the operator console brief at ${d.runtime.console.brief.path}.`
3245
+ : `${d.runtime.console.brief.action === "create" ? "Creates" : "Updates"} the operator console workspace at ${d.runtime.console.cwd} (its brief, and an AGENTS.md link to it).`,
3198
3246
  answers.writeOrchestratorBrief
3199
3247
  ? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
3200
3248
  : "",
package/src/setup.ts CHANGED
@@ -21,12 +21,14 @@
21
21
  * `src/gh.ts` exposing `gh()` (throwing) over `ghTry()` (classifying).
22
22
  */
23
23
 
24
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
- import { dirname, join } from "node:path";
26
+ import { dirname, join, relative } from "node:path";
27
27
  import { parse as parseYaml } from "yaml";
28
28
  import {
29
+ AGENTS_BRIEF_NAME,
29
30
  COMPOSE_BANNER,
31
+ CONSOLE_BRIEF_NAME,
30
32
  ORCHESTRATOR_BRIEF_NAME,
31
33
  POLICY_BRIEF_NAME,
32
34
  composeOrchestrator,
@@ -51,6 +53,7 @@ import {
51
53
  } from "./config.ts";
52
54
  import { graphProjectPath, graphRepos } from "./graph.ts";
53
55
  import { modelRolesIn, ompSettingsOverlay } from "./omp-settings.ts";
56
+ import { shellQuote } from "./shell.ts";
54
57
  import {
55
58
  CONFIG_VERSION,
56
59
  DEFAULT_ARM_PROOF,
@@ -168,6 +171,12 @@ export interface SetupAnswers {
168
171
  * to survive an amend of some other area (#369).
169
172
  */
170
173
  groomBelow?: GroomTrigger;
174
+ /**
175
+ * The OMP model role the daemon's own grooming scouts run under (#1041).
176
+ * Hand-edited like {@link groomBelow} — the wizard never asks — so it exists
177
+ * here only to survive an amend of some other area (#369).
178
+ */
179
+ groomRole?: string;
171
180
  /**
172
181
  * Ordered fallback worker models and the provider-failure threshold that
173
182
  * starts them. The wizard never asks for either — they are hand-edited —
@@ -583,7 +592,7 @@ export const PROMOTION_DUTIES: { readonly [K in AuthorityHolder]: string } = {
583
592
  "operator can take it back by editing this paragraph.",
584
593
  };
585
594
 
586
- export { ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
595
+ export { AGENTS_BRIEF_NAME, CONSOLE_BRIEF_NAME, ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME };
587
596
 
588
597
  /** Shipped floor template — duties, tiers, hard boundaries, Learning loop. */
589
598
  const ORCHESTRATOR_TEMPLATE_PATH = join(import.meta.dir, "briefs", "orchestrator.md");
@@ -591,6 +600,13 @@ const ORCHESTRATOR_TEMPLATE_PATH = join(import.meta.dir, "briefs", "orchestrator
591
600
  /** Shipped POLICY.md scaffold — Releases, Project context, Reporting, Amendments. */
592
601
  const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md");
593
602
 
603
+ /**
604
+ * Shipped console floor — the operator console's role, delivery contract,
605
+ * authority and boundaries. Render-only: it has no `POLICY.md` half, is never
606
+ * composed, and is never classified by `inspectBriefLayout`.
607
+ */
608
+ const CONSOLE_TEMPLATE_PATH = join(import.meta.dir, "briefs", "console.md");
609
+
594
610
  /**
595
611
  * `repo` writes labels and closes issues; `project` moves cards on the board.
596
612
  * Both are load-bearing for an unattended loop, so a missing one is reported
@@ -1014,6 +1030,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
1014
1030
  : {}),
1015
1031
  // A documented hand-edited key, so an unrelated amend must not delete it.
1016
1032
  ...(a.groomBelow === undefined ? {} : { groomBelow: a.groomBelow }),
1033
+ // Same contract for the daemon's grooming-scout role: hand-edited, and an
1034
+ // amend that dropped it would silently move grooming back to the harness
1035
+ // default model (#1041).
1036
+ ...(a.groomRole === undefined ? {} : { groomRole: a.groomRole }),
1017
1037
  // The failover chain is hand-edited too: dropping it on an unrelated amend
1018
1038
  // would pin every run of the project onto a dead provider again (#286).
1019
1039
  ...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }),
@@ -1256,6 +1276,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
1256
1276
  // what keeps the rewritten config identical to the one that was read.
1257
1277
  if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
1258
1278
  if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow;
1279
+ if (p.groomRole !== undefined) answers.groomRole = p.groomRole;
1259
1280
  if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks];
1260
1281
  if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold;
1261
1282
  if (p.workerEscalationModel !== undefined) answers.workerEscalationModel = p.workerEscalationModel;
@@ -1365,6 +1386,205 @@ export function renderPolicyForProject(p: ProjectConfig): string {
1365
1386
  return renderBriefTemplate(shippedPolicyTemplate(), briefVarsForProject(p));
1366
1387
  }
1367
1388
 
1389
+ // ------------------------------------------------------- the operator console
1390
+ //
1391
+ // The console is the second session of the two-session split: the tick brain
1392
+ // keeps its pane and never receives an operator turn, and this one owns the
1393
+ // Telegram conversation 24/7. Everything below provisions its workspace, and
1394
+ // the whole point of the shape is what it does NOT do:
1395
+ //
1396
+ // - **No tick config.** The tick extension activates only when
1397
+ // `.conductor-tick.json` exists in the session cwd, so a console cwd
1398
+ // without one is inert by construction rather than by a runtime check that
1399
+ // could be got wrong. Nothing here ever writes that file.
1400
+ // - **No POLICY.md, no compose, no layout classification.** The floor is
1401
+ // rendered and written; there is no operator half to merge, so
1402
+ // `refreshComposedBrief` and `inspectBriefLayout` never see this file.
1403
+ // - **Derived, never stamped.** The cwd is a function of the state dir and
1404
+ // the project name, in TypeScript here and in the same shape in
1405
+ // `recover.sh`. A config key would be a second copy of the answer, and the
1406
+ // copy nobody updated is the one a pane would `cd` into.
1407
+
1408
+ /**
1409
+ * The console session's own directory: `<stateDir>/console/<project>`.
1410
+ *
1411
+ * Under the state root rather than beside the project's worktrees because the
1412
+ * console is a fleet surface, not a checkout — and because a directory the
1413
+ * worker machinery never touches cannot be force-removed by a re-claim.
1414
+ */
1415
+ export function consoleCwdForProject(p: ProjectConfig): string {
1416
+ return join(stateDir(), "console", p.name);
1417
+ }
1418
+
1419
+ /** The rendered console floor inside {@link consoleCwdForProject}. */
1420
+ export function consoleBriefPathForProject(p: ProjectConfig): string {
1421
+ return join(consoleCwdForProject(p), CONSOLE_BRIEF_NAME);
1422
+ }
1423
+
1424
+ /** Shipped console floor template, placeholders and all. */
1425
+ export function shippedConsoleTemplate(): string {
1426
+ return readFileSync(CONSOLE_TEMPLATE_PATH, "utf8");
1427
+ }
1428
+
1429
+ /**
1430
+ * Rendered console floor for a configured project.
1431
+ *
1432
+ * The same `briefVarsForProject` coordinates the orchestrator floor gets, so a
1433
+ * placeholder either template names is filled from one table — a console that
1434
+ * shipped a literal `{{PROJECT}}` to a session would be reading a template,
1435
+ * not a brief.
1436
+ */
1437
+ export function renderConsoleForProject(p: ProjectConfig): string {
1438
+ return renderBriefTemplate(shippedConsoleTemplate(), briefVarsForProject(p));
1439
+ }
1440
+
1441
+ /**
1442
+ * The shell line that launches the console pane, produced here so
1443
+ * `omp-conductor console` and `recover.sh` cannot print two different commands
1444
+ * for one surface.
1445
+ *
1446
+ * `dmOwner` is the Telegram DM force-claim, and it is a host-level judgement,
1447
+ * not a preference: one state dir may have exactly one DM owner, so a host
1448
+ * with several fleet cwds launches its consoles without it and lets inbound
1449
+ * messages arrive by forum-topic routing instead. Nothing else in this package
1450
+ * sets `OMP_TELEGRAM_DM_OWNER`.
1451
+ */
1452
+ export function consolePaneCommand(opts: { cwd: string; dmOwner: boolean }): string {
1453
+ // Quoted because the reader copies what a surface prints: a state root under
1454
+ // a path with a space or a `$` must still `cd` where it says.
1455
+ return `cd ${shellQuote(opts.cwd)} && ${opts.dmOwner ? "OMP_TELEGRAM_DM_OWNER=1 " : ""}omp`;
1456
+ }
1457
+
1458
+ /**
1459
+ * The `AGENTS.md` link in the console cwd and what provisioning will do about
1460
+ * it, with the same never-clobber discipline the fleet pane's link gets: an
1461
+ * already-correct symlink is kept, a stale one is replaced, and a **regular
1462
+ * file** is an operator's own brief — reported and left untouched.
1463
+ */
1464
+ export interface ConsoleLinkPlan {
1465
+ path: string;
1466
+ /** Relative target inside the symlink — `CONSOLE.md`, its own directory. */
1467
+ target: string;
1468
+ action: "create" | "update" | "keep" | "skip";
1469
+ /** Why {@link action} is `skip`; present only then. */
1470
+ skippedReason?: string;
1471
+ }
1472
+
1473
+ /** What provisioning the console workspace will write, before it writes it. */
1474
+ export interface ConsolePlan {
1475
+ /** The directory the console pane runs in. Created by the write. */
1476
+ cwd: string;
1477
+ /** The rendered floor: `create` on a fresh cwd, `keep` when already current. */
1478
+ brief: { path: string; content: string; action: "create" | "update" | "keep" };
1479
+ link: ConsoleLinkPlan;
1480
+ }
1481
+
1482
+ /**
1483
+ * Plans the console workspace for a project. Pure: reads the filesystem,
1484
+ * writes nothing, so `setup host` can show the whole thing before consent and
1485
+ * `omp-conductor console` can report what it did.
1486
+ */
1487
+ export function planConsole(p: ProjectConfig): ConsolePlan {
1488
+ const cwd = consoleCwdForProject(p);
1489
+ const briefPath = consoleBriefPathForProject(p);
1490
+ const content = renderConsoleForProject(p);
1491
+ const linkPath = join(cwd, AGENTS_BRIEF_NAME);
1492
+ // Relative, and inside the same directory, so the whole console cwd stays
1493
+ // movable — the same reason the fleet pane's link is relative.
1494
+ const target = relative(dirname(linkPath), briefPath);
1495
+ // An unreadable existing brief is planned as `update`: rewriting it is the
1496
+ // repair, and a plan that reported `keep` on a file it could not read would
1497
+ // leave the console without a floor.
1498
+ let live: string | undefined;
1499
+ try {
1500
+ live = readFileSync(briefPath, "utf8");
1501
+ } catch {
1502
+ live = undefined;
1503
+ }
1504
+ return {
1505
+ cwd,
1506
+ brief: {
1507
+ path: briefPath,
1508
+ content,
1509
+ action: live === undefined ? (existsSync(briefPath) ? "update" : "create") : live === content ? "keep" : "update",
1510
+ },
1511
+ link: planConsoleLink(linkPath, target),
1512
+ };
1513
+ }
1514
+
1515
+ function planConsoleLink(path: string, target: string): ConsoleLinkPlan {
1516
+ let st;
1517
+ try {
1518
+ st = lstatSync(path);
1519
+ } catch {
1520
+ // ENOENT, and any unreadable parent, both collapse to a best-effort
1521
+ // create; a filesystem that refuses the link surfaces at write time.
1522
+ return { path, target, action: "create" };
1523
+ }
1524
+ if (st.isSymbolicLink()) {
1525
+ return { path, target, action: readlinkSync(path, "utf8") === target ? "keep" : "update" };
1526
+ }
1527
+ return {
1528
+ path,
1529
+ target,
1530
+ action: "skip",
1531
+ skippedReason: `an existing file at ${path} is not ours to overwrite; left untouched`,
1532
+ };
1533
+ }
1534
+
1535
+ /** What {@link writeConsole} wrote, plus anything it could not. */
1536
+ export interface ConsoleWrite {
1537
+ wrote: string[];
1538
+ /** Best-effort failures the caller surfaces; provisioning continues. */
1539
+ warnings: string[];
1540
+ }
1541
+
1542
+ /**
1543
+ * Materialises a planned console workspace. Idempotent: a `keep` brief and a
1544
+ * `keep` link touch nothing, so re-running `omp-conductor console` or
1545
+ * `setup host` rewrites nothing and reports an empty write.
1546
+ *
1547
+ * The link is re-`lstat`ed here rather than trusted from the plan, because the
1548
+ * entry can change across the consent gap: an operator who drops their own
1549
+ * `AGENTS.md` where a stale symlink was planned for replacement must keep it.
1550
+ */
1551
+ export function writeConsole(plan: ConsolePlan): ConsoleWrite {
1552
+ const wrote: string[] = [];
1553
+ const warnings: string[] = [];
1554
+ mkdirSync(plan.cwd, { recursive: true });
1555
+ if (plan.brief.action !== "keep") {
1556
+ writeFileSync(plan.brief.path, plan.brief.content);
1557
+ wrote.push(plan.brief.path);
1558
+ }
1559
+ if (plan.link.action === "create" || plan.link.action === "update") {
1560
+ const { path, target } = plan.link;
1561
+ try {
1562
+ let existing;
1563
+ try {
1564
+ existing = lstatSync(path);
1565
+ } catch {
1566
+ existing = undefined;
1567
+ }
1568
+ if (existing !== undefined && !existing.isSymbolicLink()) {
1569
+ warnings.push(
1570
+ `${path} is a regular file, not ours to overwrite; console AGENTS.md link skipped`,
1571
+ );
1572
+ } else if (existing !== undefined && readlinkSync(path, "utf8") === target) {
1573
+ // Already correct, however the plan read it.
1574
+ } else {
1575
+ if (existing !== undefined) rmSync(path, { force: true });
1576
+ symlinkSync(target, path);
1577
+ wrote.push(path);
1578
+ }
1579
+ } catch (err) {
1580
+ warnings.push(
1581
+ `could not link ${path} -> ${target}: ${err instanceof Error ? err.message : String(err)}`,
1582
+ );
1583
+ }
1584
+ }
1585
+ return { wrote, warnings };
1586
+ }
1587
+
1368
1588
  /**
1369
1589
  * Composed session brief: rendered floor + shared policy (when present) +
1370
1590
  * POLICY scaffold (or a caller's policy).
@@ -1811,6 +2031,12 @@ export function summarisePlan(
1811
2031
  ` ${"cap escalation".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerEscalationModel.trim()} (hand-edited)`,
1812
2032
  );
1813
2033
  }
2034
+ // The daemon spends real money grooming unattended (#1041), so which role it
2035
+ // spends it under belongs on the plan a human approves — hand-edited, like
2036
+ // the cap-escalation tier above it, and silent when nobody answered.
2037
+ if (a.groomRole !== undefined && a.groomRole.trim().length > 0) {
2038
+ lines.push(` ${"groom role".padEnd(CAPS_PLAN_KEY_PAD)}${a.groomRole.trim()} (hand-edited)`);
2039
+ }
1814
2040
 
1815
2041
  lines.push("", "escalation");
1816
2042
  if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {