omp-conductor 0.3.18 → 0.3.19

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.
@@ -0,0 +1,202 @@
1
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { stateDir } from "./config.ts";
5
+ import type { ReleasePolicy } from "./types.ts";
6
+
7
+ export const RELEASE_POLICY_AUDIT_FILE = "release-policy-blocks.jsonl";
8
+
9
+ export type ReleaseShape =
10
+ | "git-tag"
11
+ | "git-push-tags"
12
+ | "package-publish"
13
+ | "github-release"
14
+ | "deploy";
15
+
16
+ export interface ReleaseBlock {
17
+ project: string;
18
+ source: "worker" | "orchestrator";
19
+ shape: ReleaseShape;
20
+ at: string;
21
+ }
22
+
23
+ export type ReleaseDecision = { block: true; reason: string };
24
+
25
+ const GIT_PUSH_TAG_SHAPE =
26
+ /(?:--tags\b|--follow-tags\b|refs\/tags\/|(?:^|\s)(?:tag\s+)?v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?=[:\s]|$))/;
27
+
28
+ function commandSegments(command: string): string[] {
29
+ return command
30
+ .split(/(?:&&|\|\||[;\n|])/)
31
+ .map((segment) => segment.trim())
32
+ .filter((segment) => segment.length > 0);
33
+ }
34
+
35
+ function stripCommandPrefix(segment: string): string {
36
+ return segment
37
+ .replace(/^env\s+/, "")
38
+ .replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*/, "")
39
+ .replace(/^sudo\s+/, "");
40
+ }
41
+
42
+ /** Recognise the explicit release/deploy command shapes this policy promises to gate. */
43
+ export function releaseShapeFromCommand(command: string): ReleaseShape | undefined {
44
+ for (const raw of commandSegments(command)) {
45
+ const segment = stripCommandPrefix(raw);
46
+ if (/^git(?:\s+-[Cc]\s+\S+)*\s+tag(?:\s|$)/.test(segment)) return "git-tag";
47
+ if (
48
+ /^git(?:\s+-[Cc]\s+\S+)*\s+push\b/.test(segment) &&
49
+ GIT_PUSH_TAG_SHAPE.test(segment)
50
+ ) {
51
+ return "git-push-tags";
52
+ }
53
+ if (/^(?:npm|pnpm|bun)\s+(?:publish|stage\s+publish)\b/.test(segment)) {
54
+ return "package-publish";
55
+ }
56
+ if (/^yarn\s+npm\s+publish\b/.test(segment)) return "package-publish";
57
+ if (/^gh\s+release\s+create\b/.test(segment)) return "github-release";
58
+ if (
59
+ /^(?:kubectl\s+(?:apply|create|replace)|helm\s+(?:install|upgrade)|terraform\s+apply|pulumi\s+up|(?:fly|vercel|wrangler)\s+deploy|docker\s+push)\b/.test(
60
+ segment,
61
+ )
62
+ ) {
63
+ return "deploy";
64
+ }
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ /** Release-shaped device/tool invocations that do not pass through a shell. */
70
+ export function releaseShapeFromTool(
71
+ toolName: string,
72
+ input: Record<string, unknown> | undefined,
73
+ ): ReleaseShape | undefined {
74
+ if (input === undefined) return undefined;
75
+ if (toolName === "bash") {
76
+ return typeof input.command === "string" ? releaseShapeFromCommand(input.command) : undefined;
77
+ }
78
+
79
+ const path = typeof input.path === "string" ? input.path : "";
80
+ if (toolName === "write" && path.startsWith("xd://")) {
81
+ if (/create[_-]release/i.test(path)) return "github-release";
82
+ if (/publish/i.test(path)) return "package-publish";
83
+ if (/deploy/i.test(path)) return "deploy";
84
+ }
85
+
86
+ const normalized = toolName.toLowerCase().replaceAll("-", "_");
87
+ if (/create_?release|release_?create/.test(normalized)) return "github-release";
88
+ if (/(?:^|_)publish(?:$|_)/.test(normalized)) return "package-publish";
89
+ if (/(?:^|_)deploy(?:$|_)/.test(normalized)) return "deploy";
90
+ return undefined;
91
+ }
92
+
93
+ export function releaseDecision(
94
+ policy: ReleasePolicy,
95
+ toolName: string,
96
+ input: Record<string, unknown>,
97
+ ): { shape: ReleaseShape; decision: ReleaseDecision } | undefined {
98
+ if (policy !== "none") return undefined;
99
+ const shape = releaseShapeFromTool(toolName, input);
100
+ if (shape === undefined) return undefined;
101
+ return {
102
+ shape,
103
+ decision: {
104
+ block: true,
105
+ reason:
106
+ `Blocked by releasePolicy=none (${shape}). ` +
107
+ "Only a human may change the project to operator-brief before release or deploy tools can run.",
108
+ },
109
+ };
110
+ }
111
+
112
+ interface ReleasePolicyPi {
113
+ on(
114
+ event: "tool_call",
115
+ handler: (
116
+ event: { toolName: string; input: Record<string, unknown> },
117
+ ctx: unknown,
118
+ ) => ReleaseDecision | undefined,
119
+ ): void;
120
+ }
121
+
122
+ /** Inline session extension used by workers and the embedded orchestrator. */
123
+ export function releasePolicyTripwire(
124
+ policy: ReleasePolicy,
125
+ onBlocked: (shape: ReleaseShape) => void = () => {},
126
+ ): (pi: ReleasePolicyPi) => void {
127
+ return (pi) => {
128
+ pi.on("tool_call", (event) => {
129
+ const blocked = releaseDecision(policy, event.toolName, event.input);
130
+ if (blocked === undefined) return undefined;
131
+ try {
132
+ onBlocked(blocked.shape);
133
+ } catch {
134
+ // Audit is evidence, not the gate. A full disk must not turn a deny into allow.
135
+ }
136
+ return blocked.decision;
137
+ });
138
+ };
139
+ }
140
+
141
+ export function recordReleaseBlock(
142
+ project: string,
143
+ source: ReleaseBlock["source"],
144
+ shape: ReleaseShape,
145
+ root = stateDir(),
146
+ now = new Date(),
147
+ ): void {
148
+ mkdirSync(root, { recursive: true });
149
+ const record: ReleaseBlock = { project, source, shape, at: now.toISOString() };
150
+ appendFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), `${JSON.stringify(record)}\n`, { mode: 0o600 });
151
+ }
152
+
153
+ export interface ReleaseDriftSummary {
154
+ count: number;
155
+ latest: ReleaseBlock;
156
+ }
157
+
158
+ /** Aggregate today's blocked attempts for the orchestrator's daily digest. */
159
+ export function releaseDriftToday(
160
+ project: string,
161
+ root = stateDir(),
162
+ now = new Date(),
163
+ ): ReleaseDriftSummary | undefined {
164
+ let text: string;
165
+ try {
166
+ text = readFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), "utf8");
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ const day = now.toISOString().slice(0, 10);
171
+ let count = 0;
172
+ let latest: ReleaseBlock | undefined;
173
+ for (const line of text.split("\n")) {
174
+ if (line.length === 0) continue;
175
+ try {
176
+ const value = JSON.parse(line) as Partial<ReleaseBlock>;
177
+ if (
178
+ value.project === project &&
179
+ typeof value.at === "string" &&
180
+ value.at.startsWith(day) &&
181
+ (value.source === "worker" || value.source === "orchestrator") &&
182
+ typeof value.shape === "string"
183
+ ) {
184
+ count += 1;
185
+ latest = value as ReleaseBlock;
186
+ }
187
+ } catch {
188
+ // One torn line does not hide later valid audit records.
189
+ }
190
+ }
191
+ return latest === undefined ? undefined : { count, latest };
192
+ }
193
+
194
+ export function releaseDriftDigestLine(project: string, root = stateDir(), now = new Date()): string | undefined {
195
+ const drift = releaseDriftToday(project, root, now);
196
+ if (drift === undefined) return undefined;
197
+ return (
198
+ `Release-policy drift today: ${drift.count} release/deploy tool call(s) were blocked ` +
199
+ `(latest: ${drift.latest.source} ${drift.latest.shape} at ${drift.latest.at}). ` +
200
+ "Include this divergence from releasePolicy=none in today's digest."
201
+ );
202
+ }
@@ -0,0 +1,285 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir, userInfo } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { configPath, stateDir } from "./config.ts";
5
+ import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
6
+ import { DEFAULT_FLEET_AGENT_NAME } from "./fleet.ts";
7
+ import {
8
+ DEFAULT_PORT,
9
+ healthCheck,
10
+ livingDaemon,
11
+ startDaemon,
12
+ stopDaemon,
13
+ type DaemonRecord,
14
+ type StopResult,
15
+ } from "./lifecycle.ts";
16
+ import {
17
+ readTickConfig,
18
+ TICK_CONFIG_FILE,
19
+ type TickConfig,
20
+ } from "./orchestrator-tick.ts";
21
+ import type { Caps, ProjectConfig } from "./types.ts";
22
+
23
+ export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
24
+ export const STAGED_SERVICE_NAME = "omp-conductor.service";
25
+ export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
26
+
27
+ export type PlannedWrite<T> = {
28
+ path: string;
29
+ action: "create" | "update" | "keep";
30
+ content: string;
31
+ value: T;
32
+ };
33
+
34
+ export interface HostRuntimePlan {
35
+ service: PlannedWrite<string>;
36
+ tick?: PlannedWrite<TickConfig>;
37
+ installCommands: readonly string[];
38
+ cliSource: "global" | "plugin";
39
+ }
40
+
41
+ export interface ServiceRuntime {
42
+ username: string;
43
+ home: string;
44
+ path: string;
45
+ bun: string;
46
+ cli?: string;
47
+ packageCli: string;
48
+ conductorHome: string;
49
+ telegramStateDir: string;
50
+ }
51
+
52
+ function systemdQuote(value: string): string {
53
+ if (/\r|\n/.test(value)) throw new Error("systemd values cannot contain newlines");
54
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
55
+ }
56
+
57
+ function shellQuote(value: string): string {
58
+ return `'${value.replaceAll("'", "'\\''")}'`;
59
+ }
60
+
61
+ function actionFor(path: string, content: string): PlannedWrite<string>["action"] {
62
+ if (!existsSync(path)) return "create";
63
+ try {
64
+ return readFileSync(path, "utf8") === content ? "keep" : "update";
65
+ } catch {
66
+ return "update";
67
+ }
68
+ }
69
+
70
+ function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
71
+ const home = homedir();
72
+ const bun = process.execPath;
73
+ const globalCli = Bun.which("omp-conductor");
74
+ const pathParts = [dirname(bun), ...(process.env["PATH"] ?? "").split(":")].filter(
75
+ (value, index, all) => value.length > 0 && all.indexOf(value) === index,
76
+ );
77
+ return {
78
+ username: userInfo().username,
79
+ home,
80
+ path: pathParts.join(":"),
81
+ bun,
82
+ ...(globalCli === null ? {} : { cli: globalCli }),
83
+ packageCli: join(import.meta.dir, "cli.ts"),
84
+ conductorHome: dirname(configPath()),
85
+ telegramStateDir,
86
+ };
87
+ }
88
+
89
+ export function renderDaemonService(
90
+ project: ProjectConfig,
91
+ caps: Caps,
92
+ runtime: ServiceRuntime,
93
+ ): string {
94
+ const command =
95
+ runtime.cli === undefined
96
+ ? [runtime.bun, runtime.packageCli, "daemon", "--project", project.name, "--port", String(DEFAULT_PORT)]
97
+ : [runtime.cli, "daemon", "--project", project.name, "--port", String(DEFAULT_PORT)];
98
+ const memoryMax = caps.maxConcurrentWorkers <= 1 ? "3G" : "5G";
99
+ return [
100
+ "[Unit]",
101
+ "Description=omp-conductor dispatch daemon",
102
+ "Documentation=https://github.com/TerrifiedBug/conductor",
103
+ "After=network-online.target",
104
+ "Wants=network-online.target",
105
+ "",
106
+ "[Service]",
107
+ "Type=simple",
108
+ `User=${runtime.username}`,
109
+ `Environment=${systemdQuote(`HOME=${runtime.home}`)}`,
110
+ `Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
111
+ `Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
112
+ `Environment=${systemdQuote(`OMP_TELEGRAM_STATE_DIR=${runtime.telegramStateDir}`)}`,
113
+ `WorkingDirectory=${systemdQuote(stateDir())}`,
114
+ `ExecStart=${command.map(systemdQuote).join(" ")}`,
115
+ "Restart=on-failure",
116
+ "SuccessExitStatus=0 143",
117
+ "MemoryAccounting=yes",
118
+ `MemoryMax=${memoryMax}`,
119
+ "",
120
+ "[Install]",
121
+ "WantedBy=multi-user.target",
122
+ "",
123
+ ].join("\n");
124
+ }
125
+
126
+ function tickSearchRoots(project: ProjectConfig): string[] {
127
+ const roots = [stateDir(), dirname(project.workspaceRoot), project.workspaceRoot];
128
+ return roots.filter((root, index) => roots.indexOf(root) === index);
129
+ }
130
+
131
+ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrite<TickConfig> {
132
+ let existing: { path: string; config: TickConfig } | undefined;
133
+ for (const root of tickSearchRoots(project)) {
134
+ const result = readTickConfig(root);
135
+ if (result.kind === "invalid") {
136
+ throw new Error(`tick config invalid at ${result.path}: ${result.problem}; fix or remove it before setup`);
137
+ }
138
+ if (result.kind === "ok") {
139
+ existing = { path: result.path, config: result.config };
140
+ break;
141
+ }
142
+ }
143
+
144
+ const path = existing?.path ?? join(project.workspaceRoot, TICK_CONFIG_FILE);
145
+ const config: TickConfig = existing === undefined
146
+ ? {
147
+ intervalSeconds: DEFAULT_TICK_INTERVAL_SECONDS,
148
+ armedFile: join(stateDir(), "armed"),
149
+ accessFile: join(telegramStateDir, "access.json"),
150
+ agentName: DEFAULT_FLEET_AGENT_NAME,
151
+ }
152
+ : {
153
+ ...existing.config,
154
+ armedFile: existing.config.armedFile ?? join(stateDir(), "armed"),
155
+ accessFile: existing.config.accessFile ?? join(telegramStateDir, "access.json"),
156
+ };
157
+ const content = `${JSON.stringify(config, null, 2)}\n`;
158
+ return { path, action: actionFor(path, content), content, value: config };
159
+ }
160
+
161
+ export function planHostRuntime(
162
+ project: ProjectConfig,
163
+ caps: Caps,
164
+ telegramStateDir: string,
165
+ runtime: ServiceRuntime = defaultServiceRuntime(telegramStateDir),
166
+ ): HostRuntimePlan {
167
+ const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
168
+ const serviceContent = renderDaemonService(project, caps, runtime);
169
+ const service: PlannedWrite<string> = {
170
+ path: servicePath,
171
+ action: actionFor(servicePath, serviceContent),
172
+ content: serviceContent,
173
+ value: serviceContent,
174
+ };
175
+ const installedPath = join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
176
+ return {
177
+ service,
178
+ ...(project.escalation.orchestrator === "external"
179
+ ? { tick: planTick(project, telegramStateDir) }
180
+ : {}),
181
+ installCommands: [
182
+ `sudo install -m 0644 ${shellQuote(servicePath)} ${shellQuote(installedPath)}`,
183
+ "sudo systemctl daemon-reload",
184
+ `sudo systemctl enable ${STAGED_SERVICE_NAME}`,
185
+ `sudo systemctl restart ${STAGED_SERVICE_NAME}`,
186
+ ],
187
+ cliSource: runtime.cli === undefined ? "plugin" : "global",
188
+ };
189
+ }
190
+
191
+ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
192
+ const lines = [
193
+ "host runtime",
194
+ ` service ${plan.service.action} ${plan.service.path}`,
195
+ ` daemon entry ${plan.cliSource === "global" ? "installed omp-conductor CLI" : "current installed plugin"}`,
196
+ ];
197
+ if (plan.tick !== undefined) {
198
+ lines.push(
199
+ ` heartbeat ${plan.tick.action} ${plan.tick.path}`,
200
+ ` interval ${plan.tick.value.intervalSeconds}s`,
201
+ ` arm gate ${plan.tick.value.armedFile}`,
202
+ ` channel gate ${plan.tick.value.accessFile}`,
203
+ );
204
+ } else {
205
+ lines.push(" heartbeat embedded orchestrator — no external tick config");
206
+ }
207
+ lines.push(" install staged only; the final result prints the systemd install commands");
208
+ return lines.join("\n");
209
+ }
210
+
211
+ function atomicWrite(path: string, content: string, mode: number): void {
212
+ mkdirSync(dirname(path), { recursive: true });
213
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
214
+ try {
215
+ writeFileSync(tmp, content, { mode });
216
+ chmodSync(tmp, mode);
217
+ renameSync(tmp, path);
218
+ chmodSync(path, mode);
219
+ } catch (err) {
220
+ rmSync(tmp, { force: true });
221
+ throw err;
222
+ }
223
+ }
224
+
225
+ export function writeHostRuntime(plan: HostRuntimePlan): string[] {
226
+ const written: string[] = [];
227
+ if (plan.service.action !== "keep") {
228
+ atomicWrite(plan.service.path, plan.service.content, 0o644);
229
+ written.push(plan.service.path);
230
+ }
231
+ if (plan.tick !== undefined && plan.tick.action !== "keep") {
232
+ atomicWrite(plan.tick.path, plan.tick.content, 0o600);
233
+ written.push(plan.tick.path);
234
+ }
235
+ return written;
236
+ }
237
+
238
+ export interface SetupSmokeResult {
239
+ mode: "temporary" | "existing";
240
+ status: StatusSnapshot;
241
+ daemon: DaemonRecord;
242
+ }
243
+
244
+ export interface SetupSmokeDeps {
245
+ paused(): boolean;
246
+ runOnce(project: string): Promise<void>;
247
+ living(): DaemonRecord | undefined;
248
+ health(port: number): Promise<{ ok: boolean; body?: string }>;
249
+ start(project: string): Promise<DaemonRecord>;
250
+ stop(): Promise<StopResult>;
251
+ status(project: string): StatusSnapshot;
252
+ }
253
+
254
+ const DEFAULT_SMOKE_DEPS: SetupSmokeDeps = {
255
+ paused: isPaused,
256
+ runOnce: async (project) => await runDaemon({ once: true, project }),
257
+ living: livingDaemon,
258
+ health: healthCheck,
259
+ start: async (project) => await startDaemon({ project }),
260
+ stop: stopDaemon,
261
+ status: statusSnapshot,
262
+ };
263
+
264
+ export async function runSetupSmoke(
265
+ project: string,
266
+ deps: SetupSmokeDeps = DEFAULT_SMOKE_DEPS,
267
+ ): Promise<SetupSmokeResult> {
268
+ if (!deps.paused()) throw new Error("setup smoke requires paused dispatch");
269
+ await deps.runOnce(project);
270
+ const existing = deps.living();
271
+ if (existing !== undefined) {
272
+ const health = await deps.health(existing.port);
273
+ if (!health.ok) throw new Error(`existing daemon on :${existing.port} did not answer /healthz`);
274
+ return { mode: "existing", status: deps.status(project), daemon: existing };
275
+ }
276
+
277
+ const daemon = await deps.start(project);
278
+ try {
279
+ const health = await deps.health(daemon.port);
280
+ if (!health.ok) throw new Error(`temporary daemon on :${daemon.port} did not answer /healthz`);
281
+ return { mode: "temporary", status: deps.status(project), daemon };
282
+ } finally {
283
+ await deps.stop();
284
+ }
285
+ }
package/src/setup.ts CHANGED
@@ -29,20 +29,22 @@ import {
29
29
  POLICY_BRIEF_NAME,
30
30
  composeOrchestrator,
31
31
  policyPathForRoot,
32
+ refreshComposedBrief,
32
33
  renderBriefTemplate,
33
- writeWithBackup,
34
34
  } from "./brief-upgrade.ts";
35
- import { configPath, resolveCaps, stateDir } from "./config.ts";
35
+ import { configPath, resolveCaps, resolveReleasePolicy, stateDir } from "./config.ts";
36
36
  import { graphProjectPath, graphRepos } from "./graph.ts";
37
37
  import {
38
38
  CONFIG_VERSION,
39
39
  DEFAULT_AUTHORITY,
40
40
  DEFAULT_CAPS,
41
+ DEFAULT_RELEASE_POLICY,
41
42
  DEFAULT_REPORT_SCOPE,
42
43
  type Caps,
43
44
  type ConductorConfig,
44
45
  type OrchestratorMode,
45
46
  type ProjectConfig,
47
+ type ReleasePolicy,
46
48
  type ReportScope,
47
49
  type RepoTarget,
48
50
  } from "./types.ts";
@@ -71,6 +73,8 @@ export interface SetupAnswers {
71
73
  fallbackToIssueComment: boolean;
72
74
  /** How loud the supervising orchestrator session should be. */
73
75
  reportScope: ReportScope;
76
+ /** Mechanical gate for release/deploy-shaped tool calls. */
77
+ releasePolicy: ReleasePolicy;
74
78
  /**
75
79
  * Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
76
80
  * part of the config — the brief is the operator's file, and the conductor
@@ -131,6 +135,8 @@ export const SETUP_DEFAULTS = {
131
135
  defaultBranch: "main",
132
136
  /** Both authorities start with the human; the wizard asks to move each one. */
133
137
  authority: DEFAULT_AUTHORITY,
138
+ /** Mechanical release/deploy gate stays closed until explicitly opened. */
139
+ releasePolicy: DEFAULT_RELEASE_POLICY,
134
140
  /** The daemon runs its own triage session unless an operator already runs one. */
135
141
  orchestratorMode: "embedded",
136
142
  } as const;
@@ -465,6 +471,7 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
465
471
  : {}),
466
472
  escalation,
467
473
  authority: { ...a.authority },
474
+ releasePolicy: a.releasePolicy,
468
475
  reporting: { scope: a.reportScope },
469
476
  // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
470
477
  // uninstall, and neither can land in a repo the daemon then tries to commit.
@@ -521,6 +528,7 @@ export function defaultAnswers(projectName: string): SetupAnswers {
521
528
  caps: {},
522
529
  fallbackToIssueComment: true,
523
530
  authority: { ...SETUP_DEFAULTS.authority },
531
+ releasePolicy: SETUP_DEFAULTS.releasePolicy,
524
532
  orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
525
533
  reportScope: DEFAULT_REPORT_SCOPE,
526
534
  writeOrchestratorBrief: false,
@@ -566,6 +574,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
566
574
  caps: { ...p.caps },
567
575
  fallbackToIssueComment: p.escalation.fallbackToIssueComment,
568
576
  authority: { ...p.authority },
577
+ releasePolicy: resolveReleasePolicy(p),
569
578
  orchestratorMode: p.escalation.orchestrator,
570
579
  reportScope: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
571
580
  writeOrchestratorBrief: false,
@@ -683,25 +692,16 @@ export function writeOrchestratorBrief(a: SetupAnswers): string {
683
692
  * Returns false when POLICY.md is missing (caller should migrate or set up).
684
693
  */
685
694
  export function refreshComposedBriefForProject(p: ProjectConfig): boolean {
686
- const policyPath = policyPathForProject(p);
687
- if (!existsSync(policyPath)) return false;
688
- const orchestratorPath = briefPathForProject(p);
689
- mkdirSync(dirname(orchestratorPath), { recursive: true });
690
- writeFileSync(
691
- orchestratorPath,
692
- composeOrchestrator(renderFloorForProject(p), readFileSync(policyPath, "utf8")),
693
- );
694
- return true;
695
+ return refreshComposedBrief({
696
+ policyPath: policyPathForProject(p),
697
+ orchestratorPath: briefPathForProject(p),
698
+ floor: renderFloorForProject(p),
699
+ });
695
700
  }
696
701
 
697
702
  /** @internal test helper — expose compose banner for assertions. */
698
703
  export const BRIEF_COMPOSE_BANNER = COMPOSE_BANNER;
699
704
 
700
- /** Backup-aware POLICY write used by migrate paths that already computed text. */
701
- export function writePolicyFile(path: string, content: string): string | undefined {
702
- return writeWithBackup(path, content);
703
- }
704
-
705
705
  /**
706
706
  * What can be told about an omp-telegram install without opening a socket.
707
707
  *
@@ -910,6 +910,7 @@ export function summarisePlan(
910
910
  lines.push(
911
911
  "",
912
912
  `authority merge=${a.authority.merge} release=${a.authority.release}`,
913
+ `tool gate releasePolicy=${a.releasePolicy}`,
913
914
  delegated
914
915
  ? " the brief tells that session so, and it must spell the procedure out before acting"
915
916
  : " humans do both; workers and the conductor stop at a green PR",
@@ -1013,7 +1014,7 @@ export const AMEND_AREAS: {
1013
1014
  // The model rides with the caps because it is the other per-worker knob, and
1014
1015
  // an area no menu offers is a setting only a full re-interview can reach.
1015
1016
  name: "caps & worker model",
1016
- asks: "concurrency, spend, turns, wall clock, attempts per issue — then the worker model",
1017
+ asks: "concurrency, spend, turns, wall clock, failed attempts, continuations — then the worker model",
1017
1018
  describe: (p) => {
1018
1019
  const c = resolveCaps(p, DEFAULT_CAPS);
1019
1020
  const answered = Object.keys(p.caps).length > 0;
@@ -1022,7 +1023,9 @@ export const AMEND_AREAS: {
1022
1023
  return (
1023
1024
  `${c.maxConcurrentWorkers} workers, ${c.workerMaxTurns} turns, ` +
1024
1025
  `${Math.round(c.workerWallClockMs / 60000)}m, ${spend}, ` +
1025
- `${c.maxAttemptsPerIssue} attempts${answered ? "" : " (all defaults)"} ` +
1026
+ `${c.maxAttemptsPerIssue} failed attempt${c.maxAttemptsPerIssue === 1 ? "" : "s"}, ` +
1027
+ `${c.maxContinuationsPerIssue} continuation${c.maxContinuationsPerIssue === 1 ? "" : "s"}` +
1028
+ `${answered ? "" : " (all defaults)"} — ` +
1026
1029
  `${p.workerModel === undefined ? "harness default model" : `model ${p.workerModel}`}`
1027
1030
  );
1028
1031
  },
@@ -1039,8 +1042,9 @@ export const AMEND_AREAS: {
1039
1042
  },
1040
1043
  authority: {
1041
1044
  name: "authority",
1042
- asks: "who lands green PRs, and who cuts releases",
1043
- describe: (p) => `merge=${p.authority.merge}, release=${p.authority.release}`,
1045
+ asks: "who lands green PRs, who cuts releases, and whether release/deploy tools are mechanically open",
1046
+ describe: (p) =>
1047
+ `merge=${p.authority.merge}, release=${p.authority.release}, releasePolicy=${resolveReleasePolicy(p)}`,
1044
1048
  },
1045
1049
  escalation: {
1046
1050
  name: "escalation & triage",