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.
- package/README.md +775 -49
- package/package.json +1 -1
- package/src/approval-surface.ts +36 -1
- package/src/board.ts +30 -6
- package/src/briefs/orchestrator.md +114 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +164 -4
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1213 -78
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +80 -4
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +98 -25
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +555 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +863 -10
- package/src/unblock.ts +53 -3
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +227 -120
- package/systemd/omp-conductor.service.example +96 -8
package/src/orchestrator-tick.ts
CHANGED
|
@@ -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,
|
|
51
|
-
import {
|
|
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 {
|
|
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.
|
|
338
|
-
*
|
|
339
|
-
*
|
|
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.
|
|
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".
|
|
1157
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
|
|
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
|
|
1261
|
-
//
|
|
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.
|
|
1276
|
-
//
|
|
1277
|
-
const
|
|
1278
|
-
if (
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
package/src/orchestrator.ts
CHANGED
|
@@ -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 "./
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
355
|
-
): Promise<
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
/**
|
|
651
|
-
* what an unattended fleet may do
|
|
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
|
-
|
|
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);
|