omp-conductor 0.3.24 → 0.4.0

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.
@@ -47,8 +47,13 @@
47
47
  import { spawnSync } from "node:child_process";
48
48
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
49
49
  import { dirname, isAbsolute, join, resolve } from "node:path";
50
- import { findProject, loadConfig, resolveReleasePolicy } from "./config.ts";
51
- import { hasBotToken, readApprovalSurface, TELEGRAM_APPROVAL_TOOL } from "./approval-surface.ts";
50
+ import { findProject, loadConfig, resolveReleaseGrants } from "./config.ts";
51
+ import {
52
+ bridgeTokenBound,
53
+ hasBotToken,
54
+ readApprovalSurface,
55
+ TELEGRAM_APPROVAL_TOOL,
56
+ } from "./approval-surface.ts";
52
57
  import {
53
58
  briefPathForProject,
54
59
  policyPathForProject,
@@ -56,11 +61,19 @@ import {
56
61
  } from "./setup.ts";
57
62
  import {
58
63
  recordReleaseBlock,
59
- releaseDecision,
60
64
  releaseDriftDigestLine,
65
+ releaseRefusal,
66
+ releaseShapeFromTool,
61
67
  type ReleaseDecision,
62
68
  } from "./release-policy.ts";
63
- import { DEFAULT_RELEASE_POLICY, DEFAULT_REPORT_SCOPE, type FrictionSignal, type ReportScope, type Store } from "./types.ts";
69
+ import {
70
+ DEFAULT_REPORT_SCOPE,
71
+ DENIED_RELEASE_GRANTS,
72
+ type FrictionSignal,
73
+ type ReportScope,
74
+ type ResolvedGrants,
75
+ type Store,
76
+ } from "./types.ts";
64
77
  import { dbPath, openStore } from "./store.ts";
65
78
 
66
79
  /** The activation file. Absent means "this is not an orchestrator session". */
@@ -334,12 +347,21 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
334
347
  * so it is never such a turn, and a session that believes otherwise reports
335
348
  * into a void: on 2026-08-06 the fleet this extension runs produced a release
336
349
  * report and two tier-2 escalations as end-of-turn text, and not one of the
337
- * three reached anybody. What makes a report real is a tool call the session
338
- * watched succeed, so the prompt says so on every tick rather than trusting a
339
- * brief that can drift, be edited, or be compacted away.
350
+ * three reached anybody.
351
+ *
352
+ * The clause used to name `telegram_send`, and that held but it was still an
353
+ * instruction where a mechanism was needed: the same miss recurs whenever the
354
+ * session is distracted, compacted or interrupted mid-report, and an unsent
355
+ * report is indistinguishable from a quiet tick. #123 moved delivery to the
356
+ * daemon, so the clause now names the handover instead. `omp-conductor report`
357
+ * persists the text before anything is sent and the daemon retries it until it
358
+ * lands; a handover that returns a report id has therefore *already* survived
359
+ * the failure mode this rule exists for, which a watched `telegram_send` never
360
+ * could. `telegram_send` remains the right call for an interactive reply to a
361
+ * person who is waiting — that is a conversation, not a report.
340
362
  */
341
363
  export const TICK_DELIVERY_RULE =
342
- "This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Deliver anything reportable this turn by calling the telegram_send tool and confirming success; never claim a report was sent otherwise.";
364
+ "This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Hand anything reportable this turn to the durable outbox by running `omp-conductor report --text \"<the whole report>\"` (add `--kind digest` for the daily digest) and confirming it printed a report id; the daemon then owns delivery and retries until it lands. Never claim a report was sent otherwise, and never use telegram_send for a report -- that path leaves no record that it went out.";
343
365
 
344
366
  /** Re-exported so the tick's own contract stays readable from one file: the
345
367
  * constant itself lives beside the check that decides whether it is callable. */
@@ -1054,6 +1076,15 @@ interface TickSession {
1054
1076
  * background noise.
1055
1077
  */
1056
1078
  approvalToolMissingLogged: boolean;
1079
+ /**
1080
+ * Whether omp-telegram could have bound a bot token when this session started.
1081
+ * False means its `telegram_ask` / `telegram_send` are dead for this session
1082
+ * however good `access.json` looks now, because the bridge resolves its token
1083
+ * once in `startBot()`. Defaults true so a session that never reaches
1084
+ * `session_start` — every unit test that calls `tick()` directly — keeps the
1085
+ * behaviour the access file describes.
1086
+ */
1087
+ bridgeTokenAtStart: boolean;
1057
1088
  /** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
1058
1089
  pendingSkips: number;
1059
1090
  }
@@ -1153,11 +1184,28 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
1153
1184
  // approved here is the one that should read last.
1154
1185
  //
1155
1186
  // No access file configured means no fleet channel to judge, so nothing is
1156
- // claimed: the channel gate above already treats that as "not the fleet". A
1157
- // missing bot token is likewise not this check's business — it fails the
1158
- // channel gate outright, so a tick that reaches here can already send, and the
1159
- // only open question is whether an answer can come back.
1160
- const approval = config.accessFile === undefined ? undefined : readApprovalSurface(config.accessFile);
1187
+ // claimed: the channel gate above already treats that as "not the fleet".
1188
+ //
1189
+ // Two facts have to hold and the access file carries only one. It says whether
1190
+ // a destination would resolve; it cannot say whether the bridge holds a token
1191
+ // to resolve it with, because omp-telegram binds that once in `startBot()`. A
1192
+ // token written out-of-band after this session started opens the channel gate
1193
+ // — conductor pages tier 2 itself, so that much is honest — while leaving
1194
+ // `telegram_ask` and `telegram_send` dead until `/telegram on`. Trusting the
1195
+ // file alone there puts the tick straight back to mandating a call its surface
1196
+ // cannot make, which is #114 exactly.
1197
+ const approval =
1198
+ config.accessFile === undefined
1199
+ ? undefined
1200
+ : session.bridgeTokenAtStart
1201
+ ? readApprovalSurface(config.accessFile)
1202
+ : ({
1203
+ kind: "missing",
1204
+ reason:
1205
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: omp-telegram had no bot token when this ` +
1206
+ "session started, so it bound none and its tools stay dead however complete the access file looks " +
1207
+ "now — run `/telegram on` in this session, or restart it, to rebind the bridge",
1208
+ } as const);
1161
1209
  if (approval?.kind === "missing") {
1162
1210
  content = `${content}\n${TICK_APPROVAL_UNAVAILABLE_RULE}`;
1163
1211
  if (!session.approvalToolMissingLogged) {
@@ -1253,12 +1301,13 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1253
1301
  const session: TickSession = {
1254
1302
  scopeFallbackLogged: false,
1255
1303
  approvalToolMissingLogged: false,
1304
+ bridgeTokenAtStart: true,
1256
1305
  pendingSkips: 0,
1257
1306
  };
1258
1307
  let releaseGateArmed = false;
1259
1308
  // An activation file makes this a fleet directory before Herdr can prove
1260
- // which pane owns it. The gate therefore starts closed and only honours an
1261
- // operator-brief policy after ownership is accepted.
1309
+ // which pane owns it. The gate therefore starts closed and only honours a
1310
+ // configured grant after ownership is accepted.
1262
1311
  let releaseAuthorityAccepted = false;
1263
1312
  const armReleaseGate = (): void => {
1264
1313
  if (releaseGateArmed) return;
@@ -1272,35 +1321,38 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1272
1321
  ) => ReleaseDecision | undefined,
1273
1322
  ): void;
1274
1323
  }).on("tool_call", (event) => {
1275
- // Most tool calls are ordinary tracker/file work. Detect shape first so
1276
- // they do not re-read config or emit policy diagnostics.
1277
- const candidate = releaseDecision(DEFAULT_RELEASE_POLICY, event.toolName, event.input);
1278
- if (candidate === undefined) return undefined;
1324
+ // Most tool calls are ordinary tracker/file work. Classify first so they
1325
+ // do not re-read config or emit policy diagnostics.
1326
+ const shape = releaseShapeFromTool(event.toolName, event.input);
1327
+ if (shape === undefined) return undefined;
1279
1328
  let projectName: string | undefined;
1280
- let policy = DEFAULT_RELEASE_POLICY;
1329
+ let grants: ResolvedGrants = DENIED_RELEASE_GRANTS;
1281
1330
  let external = true;
1282
1331
  try {
1283
1332
  const project = findProject(loadConfig());
1284
1333
  projectName = project.name;
1285
- policy = resolveReleasePolicy(project);
1334
+ grants = resolveReleaseGrants(project);
1286
1335
  external = project.escalation.orchestrator === "external";
1287
1336
  } catch (err) {
1288
1337
  // A missing/unreadable config cannot open a release gate. Log only when
1289
1338
  // a release-shaped call actually reaches this handler.
1290
1339
  pi.logger.error(
1291
- `[omp-conductor] release policy unreadable; enforcing ${DEFAULT_RELEASE_POLICY}: ${
1340
+ `[omp-conductor] release policy unreadable; denying every release shape: ${
1292
1341
  err instanceof Error ? err.message : String(err)
1293
1342
  }`,
1294
1343
  );
1295
1344
  }
1296
- if (releaseAuthorityAccepted && policy === "operator-brief") return undefined;
1345
+ // Per shape since #122: a fleet may hold `git-tag` and still be refused
1346
+ // `deploy`, so this asks about the shape in hand, not about one policy word.
1347
+ const refusal = releaseRefusal(grants, "orchestrator", shape);
1348
+ if (releaseAuthorityAccepted && refusal === undefined) return undefined;
1297
1349
  // Embedded orchestrators carry the same tripwire inline through
1298
1350
  // `createSession`; suppress this second copy only after this session has
1299
1351
  // proved it owns the external heartbeat.
1300
1352
  if (releaseAuthorityAccepted && !external) return undefined;
1301
1353
  if (projectName !== undefined) {
1302
1354
  try {
1303
- recordReleaseBlock(projectName, "orchestrator", candidate.shape);
1355
+ recordReleaseBlock(projectName, "orchestrator", shape);
1304
1356
  } catch (err) {
1305
1357
  pi.logger.error(
1306
1358
  `[omp-conductor] could not record release-policy block: ${
@@ -1309,7 +1361,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1309
1361
  );
1310
1362
  }
1311
1363
  }
1312
- return candidate.decision;
1364
+ // Before ownership is proved this session holds no grant at all, so a
1365
+ // covered shape still refuses — with the wording it would get from a
1366
+ // deny-all map rather than a claim about a grant it cannot yet use.
1367
+ return refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
1313
1368
  });
1314
1369
  };
1315
1370
 
@@ -1352,6 +1407,24 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1352
1407
 
1353
1408
  const config = result.config;
1354
1409
 
1410
+ // Whether omp-telegram could have bound a token when this session started,
1411
+ // which is a different question from whether one exists now and is the only
1412
+ // one the approval preflight can honestly ask. The bridge resolves its token
1413
+ // once, in `startBot()`, and rebinds only on `/telegram token` or
1414
+ // `/telegram on` — so a token written into `.env` out-of-band leaves
1415
+ // `telegram_ask` and `telegram_send` dead for the life of this session even
1416
+ // though the file now looks perfect. Sampling here, next to the same
1417
+ // `session_start` omp-telegram binds on, is as close as another package can
1418
+ // get to that fact.
1419
+ //
1420
+ // Conservative on the other transition: an operator who ran `/telegram on`
1421
+ // after start really does have a working bridge, and this snapshot will keep
1422
+ // saying otherwise until the session restarts. That costs a fallback
1423
+ // instruction the orchestrator can follow, where guessing the other way
1424
+ // costs an amendment recorded as approved that nobody ever answered.
1425
+ session.bridgeTokenAtStart =
1426
+ config.accessFile === undefined ? true : bridgeTokenBound(config.accessFile);
1427
+
1355
1428
  // Activation is a property of the directory, so every omp session started in
1356
1429
  // the fleet's cwd used to become a ticker — and with merge and release
1357
1430
  // delegated in config, a shell opened beside the orchestrator believed it
@@ -31,10 +31,10 @@ import { join } from "node:path";
31
31
 
32
32
  import { stateDir } from "./config.ts";
33
33
  import { formatEscalation } from "./escalate.ts";
34
+ import type { SessionBoundary } from "./credentials.ts";
34
35
  import { createSession, disposeSession } from "./omp.ts";
35
36
  import type { AgentSessionLike } from "./omp.ts";
36
- import type { ReleaseShape } from "./release-policy.ts";
37
- import type { Escalation, ReleasePolicy } from "./types.ts";
37
+ import type { Escalation, ReleaseShape, ResolvedGrants, SessionRole } from "./types.ts";
38
38
 
39
39
  /**
40
40
  * The session factory {@link startOrchestrator} uses. Named so the test seam
@@ -45,8 +45,13 @@ export type CreateSessionFn = (opts: {
45
45
  sessionDir?: string;
46
46
  model?: string;
47
47
  resume?: boolean;
48
- releasePolicy?: ReleasePolicy;
48
+ role: SessionRole;
49
+ releaseGrants?: ResolvedGrants;
49
50
  onReleaseBlocked?: (shape: ReleaseShape) => void;
51
+ boundary?: SessionBoundary;
52
+ socketPath?: string;
53
+ verbSocketPath?: string;
54
+ onChildLog?: (line: string) => void;
50
55
  }) => Promise<AgentSessionLike>;
51
56
 
52
57
  /**
@@ -79,8 +84,25 @@ export interface OrchestratorOpts {
79
84
  cwd: string;
80
85
  sessionDir?: string;
81
86
  model?: string;
82
- releasePolicy?: ReleasePolicy;
87
+ releaseGrants?: ResolvedGrants;
83
88
  onReleaseBlocked?: (shape: ReleaseShape) => void;
89
+ /**
90
+ * The OS principal this session runs as (#125). Its own, distinct from every
91
+ * worker slot: the orchestrator reads the state directory and its briefs, and
92
+ * must have no read or write access to any run checkout — a property the
93
+ * adversarial probe asserts rather than assumes.
94
+ */
95
+ boundary?: SessionBoundary;
96
+ /** Control socket for the session child. See {@link OrchestratorOpts.boundary}. */
97
+ socketPath?: string;
98
+ /**
99
+ * The orchestrator's own verb socket (#126) — a third, distinct one, never
100
+ * shared with a run. It is what makes "merge authority is the orchestrator's"
101
+ * a fact about the channel rather than a claim in a prompt.
102
+ */
103
+ verbSocketPath?: string;
104
+ /** Where the session child's stdout/stderr go. */
105
+ onChildLog?: (line: string) => void;
84
106
  /**
85
107
  * Standing orders — which repo, which labels, what the fleet is. Prepended to
86
108
  * the *first* injection rather than sent as its own prompt on startup: a
@@ -164,8 +186,13 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
164
186
  cwd: o.cwd,
165
187
  sessionDir,
166
188
  ...(o.model === undefined ? {} : { model: o.model }),
167
- ...(o.releasePolicy === undefined ? {} : { releasePolicy: o.releasePolicy }),
189
+ role: "orchestrator",
190
+ ...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
168
191
  ...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
192
+ ...(o.boundary === undefined ? {} : { boundary: o.boundary }),
193
+ ...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
194
+ ...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
195
+ ...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
169
196
  // The whole point of a persistent orchestrator: a daemon restart must not
170
197
  // reset what it knows it has already escalated, or the first tick after a
171
198
  // deploy re-litigates every parked issue from scratch.
package/src/plugin.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  writeMergedBrief,
23
23
  } from "./brief-upgrade.ts";
24
24
  import { configPath, expandHome, findProject, loadConfig, resolveCaps, saveConfig } from "./config.ts";
25
+ import { mechanismSatisfies, probeHost } from "./credentials.ts";
25
26
  import { hostRamBytes, recommendedMaxWorkers } from "./host.ts";
26
27
  import {
27
28
  isPaused,
@@ -51,8 +52,12 @@ import {
51
52
  } from "./setup-host.ts";
52
53
  import {
53
54
  AMEND_AREAS,
55
+ BASE_FRESHNESS_CHOICES,
56
+ BEHIND_BASE_CHOICES,
57
+ DRAFT_POLICY_CHOICES,
54
58
  ORCHESTRATOR_BRIEF_NAME,
55
59
  POLICY_BRIEF_NAME,
60
+ RELEASE_REQUIREMENT_CHOICES,
56
61
  REPORT_SCOPE_CHOICES,
57
62
  SETUP_DEFAULTS,
58
63
  amendChoices,
@@ -76,13 +81,22 @@ import {
76
81
  type SetupAnswers,
77
82
  } from "./setup.ts";
78
83
  import {
84
+ BASE_FRESHNESS,
85
+ BEHIND_BASE_ACTIONS,
86
+ CREDENTIAL_ISOLATIONS,
79
87
  DEFAULT_CAPS,
88
+ DRAFT_POLICIES,
89
+ RELEASE_REQUIREMENTS,
90
+ RELEASE_SHAPES,
80
91
  type Caps,
92
+ type CredentialIsolation,
81
93
  type ConductorConfig,
82
94
  type OrchestratorMode,
83
95
  type ProjectConfig,
84
- type ReleasePolicy,
96
+ type ProjectPolicy,
97
+ type ReleaseRequirement,
85
98
  type ReportScope,
99
+ type ResolvedGrants,
86
100
  } from "./types.ts";
87
101
 
88
102
  /**
@@ -319,6 +333,164 @@ async function askReportScope(ctx: CommandContext, current: ReportScope): Promis
319
333
  return choice.scope;
320
334
  }
321
335
 
336
+ /**
337
+ * One value out of a closed vocabulary, described in the operator's words.
338
+ *
339
+ * A select rather than a confirm, and the cursor starts on the configured value
340
+ * so Enter re-affirms it — the contract every prompt here has. It cannot be a
341
+ * confirm: a precondition's safe answer is "keep requiring it", and this
342
+ * harness's confirms always start on no, so a re-run that Entered through them
343
+ * would quietly relax the gate it was meant to leave alone.
344
+ *
345
+ * The label *is* the config value, so the answer the harness hands back needs no
346
+ * lookup table that could disagree with the vocabulary it was built from.
347
+ */
348
+ async function askLiteral<T extends string>(
349
+ ctx: CommandContext,
350
+ title: string,
351
+ values: readonly T[],
352
+ described: { readonly [K in T]: string },
353
+ current: T,
354
+ ): Promise<T> {
355
+ const at = values.findIndex((v) => v === current);
356
+ const picked = await ctx.ui.select(
357
+ title,
358
+ values.map((v) => ({ label: v, description: described[v] })),
359
+ { initialIndex: at === -1 ? 0 : at },
360
+ );
361
+ if (picked === undefined) throw new Cancelled();
362
+
363
+ const hit = values.find((v) => v === picked);
364
+ if (hit === undefined) {
365
+ // The harness answered with a label we never offered, which only happens if
366
+ // the dialog contract changed under us. Keeping the current value is the
367
+ // answer that changes nothing, and it is said out loud rather than assumed.
368
+ ctx.ui.notify(`Unrecognised choice "${picked}" — keeping "${current}".`, "warning");
369
+ return current;
370
+ }
371
+ return hit;
372
+ }
373
+
374
+ /** How an empty list is both shown and typed. A word, because a blank line in
375
+ * this wizard means "accept what you see", not "clear it". */
376
+ const EMPTY_LIST = "none";
377
+
378
+ /** A name list as the prompt shows it and reads it back — one spelling, so the
379
+ * pre-filled default and the value it round-trips to cannot drift. */
380
+ function formatNameList(names: readonly string[]): string {
381
+ return names.length === 0 ? EMPTY_LIST : names.join(", ");
382
+ }
383
+
384
+ function parseNameList(answer: string): string[] {
385
+ if (answer.trim().toLowerCase() === EMPTY_LIST) return [];
386
+ return answer
387
+ .split(",")
388
+ .map((name) => name.trim())
389
+ .filter((name) => name.length > 0);
390
+ }
391
+
392
+ /** Check names, artefacts, environments: open-ended lists this package cannot
393
+ * enumerate, so the only validation is the shape. */
394
+ async function askNameList(ctx: CommandContext, title: string, seed: readonly string[]): Promise<string[]> {
395
+ return parseNameList(await ask(ctx, title, formatNameList(seed)));
396
+ }
397
+
398
+ /**
399
+ * The `requires` set, typed rather than picked one confirm at a time.
400
+ *
401
+ * Validated in the dialog against the same array the loader validates against,
402
+ * and the complaint names every accepted value — an operator who mistyped a
403
+ * requirement they believed they had set would otherwise find out from a release
404
+ * that went ahead without it.
405
+ */
406
+ async function askReleaseRequirements(
407
+ ctx: CommandContext,
408
+ prior: readonly ReleaseRequirement[],
409
+ ): Promise<ReleaseRequirement[]> {
410
+ const accepted = RELEASE_REQUIREMENTS.join(", ");
411
+ // The vocabulary, spelled out where it is being asked for. Built from the same
412
+ // data the validator reads, so a fifth requirement is offered here the moment
413
+ // it exists rather than staying invisible to everyone who did not read #129.
414
+ ctx.ui.notify(
415
+ RELEASE_REQUIREMENTS.map((r) => `${r} — ${RELEASE_REQUIREMENT_CHOICES[r]}`).join("\n"),
416
+ "info",
417
+ );
418
+ const answered = await askValid(
419
+ ctx,
420
+ `Release — what must have landed first (any of ${accepted}, comma separated, or "${EMPTY_LIST}")`,
421
+ formatNameList(prior),
422
+ (value) => {
423
+ const unknown = parseNameList(value).filter((name) => !RELEASE_REQUIREMENTS.some((r) => r === name));
424
+ return unknown.length === 0 ? undefined : `Not a release requirement: ${unknown.join(", ")}. Accepted: ${accepted}.`;
425
+ },
426
+ );
427
+
428
+ const chosen = new Set(parseNameList(answered));
429
+ // The vocabulary's order, not the operator's: two fleets that require the same
430
+ // three things must read identically in the plan and in a refusal.
431
+ return RELEASE_REQUIREMENTS.filter((r) => chosen.has(r));
432
+ }
433
+
434
+ /**
435
+ * The gating conditions #126's verbs read (#129).
436
+ *
437
+ * Asked here rather than left to a hand-edit because the whole point of moving
438
+ * them out of POLICY.md is that they are config: a condition an operator can
439
+ * only reach by opening `config.json` is one that stays at its default while
440
+ * their prose says something else, which is the drift this key ended.
441
+ */
442
+ async function askPolicyPreconditions(ctx: CommandContext, prior: ProjectPolicy): Promise<ProjectPolicy> {
443
+ const merge = {
444
+ requiredChecks: await askNameList(
445
+ ctx,
446
+ `Merge — required checks (comma separated, "${EMPTY_LIST}" = every check the PR reports)`,
447
+ prior.merge.requiredChecks,
448
+ ),
449
+ baseFreshness: await askLiteral(
450
+ ctx,
451
+ "Merge — must the PR be level with its base?",
452
+ BASE_FRESHNESS,
453
+ BASE_FRESHNESS_CHOICES,
454
+ prior.merge.baseFreshness,
455
+ ),
456
+ drafts: await askLiteral(
457
+ ctx,
458
+ "Merge — draft pull requests",
459
+ DRAFT_POLICIES,
460
+ DRAFT_POLICY_CHOICES,
461
+ prior.merge.drafts,
462
+ ),
463
+ whenBehindBase: await askLiteral(
464
+ ctx,
465
+ "Merge — a green PR that fell behind its base",
466
+ BEHIND_BASE_ACTIONS,
467
+ BEHIND_BASE_CHOICES,
468
+ prior.merge.whenBehindBase,
469
+ ),
470
+ };
471
+
472
+ const release = {
473
+ requires: await askReleaseRequirements(ctx, prior.release.requires),
474
+ requiredChecks: await askNameList(
475
+ ctx,
476
+ `Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`,
477
+ prior.release.requiredChecks,
478
+ ),
479
+ artefacts: await askNameList(
480
+ ctx,
481
+ `Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`,
482
+ prior.release.artefacts,
483
+ ),
484
+ environments: await askNameList(
485
+ ctx,
486
+ `Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`,
487
+ prior.release.environments,
488
+ ),
489
+ };
490
+
491
+ return { merge, release };
492
+ }
493
+
322
494
  /**
323
495
  * Who merges and who releases. Two confirms rather than one four-way list:
324
496
  * these are independent grants — delegating merges is routine, delegating
@@ -349,18 +521,53 @@ async function askAuthority(
349
521
  return { merge: merge ? "orchestrator" : "human", release: release ? "orchestrator" : "human" };
350
522
  }
351
523
 
352
- async function askReleasePolicy(
524
+ /**
525
+ * What each shape means to the operator being asked about it, in their words
526
+ * rather than the classifier's. Declared as data over the closed enum so a sixth
527
+ * shape cannot be added without a question to ask about it — an unasked shape
528
+ * would silently take the deny default and read as a decision afterwards.
529
+ *
530
+ * No fleet vocabulary here on purpose (#122): every one of these is an act the
531
+ * package can recognise anywhere, not a step in one project's release topology.
532
+ */
533
+ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]: string } = {
534
+ "git-tag": "create git tags (`git tag v1.2.3`)",
535
+ "git-push-tags": "push tags to the remote (`git push --follow-tags`)",
536
+ "package-publish": "publish packages (`npm publish` and equivalents)",
537
+ "github-release": "create GitHub releases (`gh release create`)",
538
+ deploy:
539
+ "deploy — change what is running: kubectl/helm/terraform, a deploy device call, a rollout. " +
540
+ "This is the one grant that mutates a live environment rather than producing an artifact",
541
+ };
542
+
543
+ /**
544
+ * The mechanical tool gate, one confirm per shape.
545
+ *
546
+ * One binary question used to cover all five, which is how #122 happened: an
547
+ * operator who meant "it may cut a release" also granted "it may deploy to
548
+ * production", because there was one switch for both. Asking five times is the
549
+ * point — each answer is a different blast radius.
550
+ *
551
+ * No confirm can start on "yes", so a re-run that Enters through the wizard
552
+ * revokes rather than renews. The current grant is named in the question, so
553
+ * that revoke is never a surprise.
554
+ */
555
+ async function askReleaseGrants(
353
556
  ctx: CommandContext,
354
- prior: ReleasePolicy,
355
- ): Promise<ReleasePolicy> {
356
- const open = await ctx.ui.confirm(
357
- "Release tool gate",
358
- "Allow worker and orchestrator sessions to invoke release/deploy-shaped tools? Only enable this " +
359
- "when the operator brief contains the release procedure they must follow. Default: no, block " +
360
- "git tags, tag pushes, package publishing, GitHub release creation and deploy commands" +
361
- `${prior === "operator-brief" ? " currently enabled, answer no to close it" : ""}.`,
362
- );
363
- return open ? "operator-brief" : "none";
557
+ prior: ResolvedGrants,
558
+ ): Promise<ResolvedGrants> {
559
+ const grants = { ...prior };
560
+ for (const shape of RELEASE_SHAPES) {
561
+ const open = await ctx.ui.confirm(
562
+ `Release tool gate ${shape}`,
563
+ `Allow the orchestrator session to ${RELEASE_SHAPE_QUESTIONS[shape]}? Grant this only when the ` +
564
+ "operator brief carries the procedure it must follow. A worker session is refused this " +
565
+ "whatever you answer. Default: no" +
566
+ `${prior[shape] === "orchestrator" ? " — currently granted, answer no to take it back" : ""}.`,
567
+ );
568
+ grants[shape] = open ? "orchestrator" : "human";
569
+ }
570
+ return grants;
364
571
  }
365
572
 
366
573
  /**
@@ -647,14 +854,55 @@ const askWorkerModel: AreaAsker = async (ctx, a) => {
647
854
  return next;
648
855
  };
649
856
 
650
- /** Both grants, asked together because they are the two questions that decide
651
- * what an unattended fleet may do without asking anybody. */
857
+ /** The two ownership questions, then the mechanical gate one shape at a time:
858
+ * together they are what decides what an unattended fleet may do unasked. */
652
859
  const askAuthorityArea: AreaAsker = async (ctx, a) => ({
653
860
  ...a,
654
861
  authority: await askAuthority(ctx, a.authority),
655
- releasePolicy: await askReleasePolicy(ctx, a.releasePolicy),
862
+ releaseGrants: await askReleaseGrants(ctx, a.releaseGrants),
656
863
  });
657
864
 
865
+ /** What a merge and a release must satisfy. Asked straight after the grants:
866
+ * who may act, then under what conditions (#129). */
867
+ const askPolicy: AreaAsker = async (ctx, a) => ({ ...a, policy: await askPolicyPreconditions(ctx, a.policy) });
868
+
869
+ /**
870
+ * Whether model-executed code gets its own OS principal (#125).
871
+ *
872
+ * The host is probed before the question is asked, and the answer the box can
873
+ * actually honour is named in the option itself. That matters because the two
874
+ * failure modes are asymmetric: choosing `none` gets a fleet that dispatches
875
+ * and is unprotected, while choosing `per-run` on a host with no mechanism gets
876
+ * a fleet that refuses every issue. An operator should not have to discover
877
+ * which one they picked by watching the queue stall.
878
+ */
879
+ const askCredentials: AreaAsker = async (ctx, a) => {
880
+ const probe = await probeHost({ slots: a.caps.maxConcurrentWorkers ?? DEFAULT_CAPS.maxConcurrentWorkers });
881
+ // Each option says whether THIS host can honour it, asked of the same
882
+ // predicate the dispatch gate uses, so the wizard cannot promise a boundary
883
+ // the daemon will then refuse to build.
884
+ const offer = (isolation: CredentialIsolation, claim: string): string =>
885
+ mechanismSatisfies(isolation, probe.mechanism)
886
+ ? `${claim} — this host can build it with ${probe.mechanism}`
887
+ : `${claim} — UNAVAILABLE here (${probe.reasons.join("; ") || "no mechanism found"}); dispatch would refuse every issue`;
888
+ const described: { readonly [K in CredentialIsolation]: string } = {
889
+ "per-run": offer("per-run", "each session runs under its own OS principal; it cannot reach the daemon's credentials"),
890
+ "group-mode": offer(
891
+ "group-mode",
892
+ "same uid as the daemon, cross-run separation by group and mode only; bounds accidents, does NOT contain a bash escape",
893
+ ),
894
+ none: "sessions run as the daemon's user; env scrubbing only, which same-uid code defeats in one line",
895
+ };
896
+ const isolation = await askLiteral(
897
+ ctx,
898
+ "Credential isolation for worker and orchestrator sessions",
899
+ CREDENTIAL_ISOLATIONS,
900
+ described,
901
+ a.credentials.isolation,
902
+ );
903
+ return { ...a, credentials: { ...a.credentials, isolation } };
904
+ };
905
+
658
906
  /** How a stuck run reaches a human, and who triages it when it does. */
659
907
  const askEscalation: AreaAsker = async (ctx, a) => {
660
908
  const telegram = detectTelegram();
@@ -710,6 +958,8 @@ const AREA_ASKERS: { readonly [K in AmendAreaId]: AreaAsker } = {
710
958
  caps: async (ctx, a) => await askWorkerModel(ctx, await askCaps(ctx, a)),
711
959
  graph: askGraph,
712
960
  authority: askAuthorityArea,
961
+ policy: askPolicy,
962
+ credentials: askCredentials,
713
963
  escalation: askEscalation,
714
964
  reporting: askReporting,
715
965
  brief: askBrief,
@@ -799,6 +1049,8 @@ async function collectAnswers(
799
1049
  a = await askGraph(ctx, a);
800
1050
  a = await askCaps(ctx, a);
801
1051
  a = await askAuthorityArea(ctx, a);
1052
+ a = await askPolicy(ctx, a);
1053
+ a = await askCredentials(ctx, a);
802
1054
  a = await askWorkerModel(ctx, a);
803
1055
  a = await askEscalation(ctx, a);
804
1056
  a = await askReporting(ctx, a);