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.
- package/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +255 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +258 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +115 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +422 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +788 -0
- package/src/daemon/settle-pass.ts +606 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +135 -9
- package/src/doctor.ts +2 -2
- package/src/failure-class.ts +257 -2
- package/src/fleet.ts +295 -176
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +689 -1670
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +107 -11
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +169 -14
- package/src/store.ts +618 -28
- package/src/to-spec.ts +426 -44
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +434 -18
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +570 -1
package/src/setup-wizard.ts
CHANGED
|
@@ -2147,17 +2147,27 @@ export async function ensureSetupArm(
|
|
|
2147
2147
|
projectName: string,
|
|
2148
2148
|
arm: typeof armTicks = armTicks,
|
|
2149
2149
|
/**
|
|
2150
|
-
* Where the
|
|
2151
|
-
*
|
|
2152
|
-
*
|
|
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
|
|
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 ${
|
|
2160
|
-
: `heartbeat armed for owner ${
|
|
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) {
|