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.
@@ -43,13 +43,20 @@
43
43
  import { spawnSync } from "node:child_process";
44
44
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
45
45
  import { isAbsolute, join, resolve } from "node:path";
46
- import { findProject, loadConfig } from "./config.ts";
46
+ import { findProject, loadConfig, resolveReleasePolicy } from "./config.ts";
47
47
  import {
48
48
  briefPathForProject,
49
49
  policyPathForProject,
50
50
  refreshComposedBriefForProject,
51
51
  } from "./setup.ts";
52
- import { DEFAULT_REPORT_SCOPE, type ReportScope } from "./types.ts";
52
+ import {
53
+ recordReleaseBlock,
54
+ releaseDecision,
55
+ releaseDriftDigestLine,
56
+ type ReleaseDecision,
57
+ } from "./release-policy.ts";
58
+ import { DEFAULT_RELEASE_POLICY, DEFAULT_REPORT_SCOPE, type FrictionSignal, type ReportScope, type Store } from "./types.ts";
59
+ import { dbPath, openStore } from "./store.ts";
53
60
 
54
61
  /** The activation file. Absent means "this is not an orchestrator session". */
55
62
  export const TICK_CONFIG_FILE = ".conductor-tick.json";
@@ -63,6 +70,11 @@ export const TICK_CUSTOM_TYPE = "omp-conductor.tick";
63
70
  * misconfiguration worth refusing rather than obeying.
64
71
  */
65
72
  export const MIN_INTERVAL_SECONDS = 60;
73
+ /** Repetition threshold and windows for Learning-loop friction signals. */
74
+ export const FRICTION_MIN_OBSERVATIONS = 3;
75
+ export const FRICTION_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1_000;
76
+ export const FRICTION_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1_000;
77
+ const FRICTION_DIGEST_LIMIT = 3;
66
78
 
67
79
  /**
68
80
  * How often to re-ask who owns the fleet tick after herdr failed to answer.
@@ -324,6 +336,35 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
324
336
  export const TICK_DELIVERY_RULE =
325
337
  "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.";
326
338
 
339
+ function frictionLabel(kind: FrictionSignal["kind"]): string {
340
+ if (kind.startsWith("admission:")) return `admission hold ${kind.slice("admission:".length)}`;
341
+ if (kind === "feedback:escalation-should-digest") return "escalations classified as digest material";
342
+ if (kind === "feedback:report-noise") return "tick reports classified as noise";
343
+ return "tick reports classified as surprising";
344
+ }
345
+
346
+ /** Bounded evidence for the existing approval protocol — never an automatic edit. */
347
+ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string {
348
+ const shown = signals.slice(0, FRICTION_DIGEST_LIMIT);
349
+ const lines = [
350
+ "Repeated friction observed over the last 7 days (evidence only; not permission to edit policy):",
351
+ ...shown.map((signal) => {
352
+ const issues =
353
+ signal.issues.length === 0 ? "" : `; issues ${signal.issues.map((issue) => `#${issue}`).join(", ")}`;
354
+ const samples = signal.samples.length === 0 ? "" : `; examples: ${signal.samples.join(" | ")}`;
355
+ return (
356
+ `- ${frictionLabel(signal.kind)} — ${signal.observations} observations, ` +
357
+ `${signal.occurrences} affected${issues}${samples}`
358
+ );
359
+ }),
360
+ ];
361
+ if (signals.length > shown.length) lines.push(`- ${signals.length - shown.length} more signal(s) deferred`);
362
+ lines.push(
363
+ "After the tick duties, investigate at most one signal. Use the existing Learning loop only if the recurring cause has a safe POLICY.md remedy; otherwise leave policy unchanged and report or file the underlying product/infra issue through the existing rules.",
364
+ );
365
+ return lines.join("\n");
366
+ }
367
+
327
368
  /**
328
369
  * The scope this tick carries, where the brief actually lives, and — when the
329
370
  * config could not answer — why.
@@ -982,23 +1023,66 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
982
1023
  // expects ORCHESTRATOR.md / AGENTS.md to track the installed package.
983
1024
  refreshComposedBriefBestEffort();
984
1025
 
985
- // A configured message owns the whole contract, reporting and delivery clauses
986
- // included: an operator who wrote their own prompt did not ask for ours
987
- // appended to it.
1026
+ const scope = resolveTickScope();
1027
+ // A configured message owns the ordinary reporting and delivery clauses.
1028
+ // Mechanical evidence is different: release-policy drift and repeated
1029
+ // operational friction must not disappear because an operator customized the
1030
+ // ordinary heartbeat wording.
988
1031
  let content = currentMessage(ctx.cwd, config);
989
1032
  if (content === undefined) {
990
- const scope = resolveTickScope();
991
1033
  if (scope.fallback !== undefined && !session.scopeFallbackLogged) {
992
1034
  session.scopeFallbackLogged = true;
993
1035
  pi.logger.info(`[omp-conductor] tick reporting scope: using ${DEFAULT_REPORT_SCOPE} — ${scope.fallback}`);
994
1036
  }
995
1037
  content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${TICK_SCOPE_CONSTRAINTS[scope.scope]}\n${TICK_DELIVERY_RULE}`;
996
1038
  }
1039
+ let frictionStore: Store | undefined;
1040
+ let frictionSignals: FrictionSignal[] = [];
1041
+ const now = Date.now();
1042
+ if (scope.projectName !== undefined) {
1043
+ const drift = releaseDriftDigestLine(scope.projectName);
1044
+ if (drift !== undefined) content = `${content}\n${drift}`;
1045
+ if (existsSync(dbPath())) {
1046
+ try {
1047
+ frictionStore = openStore(dbPath());
1048
+ frictionSignals = frictionStore.pendingFriction(
1049
+ scope.projectName,
1050
+ now - FRICTION_LOOKBACK_MS,
1051
+ FRICTION_MIN_OBSERVATIONS,
1052
+ now - FRICTION_COOLDOWN_MS,
1053
+ );
1054
+ if (frictionSignals.length > 0) content = `${content}\n${formatFrictionDigest(frictionSignals)}`;
1055
+ } catch (err) {
1056
+ frictionStore?.close();
1057
+ frictionStore = undefined;
1058
+ pi.logger.error(
1059
+ `[omp-conductor] friction digest unavailable: ${err instanceof Error ? err.message : String(err)}`,
1060
+ );
1061
+ }
1062
+ }
1063
+ }
997
1064
 
998
- pi.sendMessage(
999
- { customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
1000
- { triggerTurn: true, deliverAs: "followUp" },
1001
- );
1065
+ try {
1066
+ pi.sendMessage(
1067
+ { customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
1068
+ { triggerTurn: true, deliverAs: "followUp" },
1069
+ );
1070
+ if (scope.projectName !== undefined && frictionSignals.length > 0) {
1071
+ try {
1072
+ frictionStore?.markFrictionSurfaced(
1073
+ scope.projectName,
1074
+ frictionSignals.map((signal) => signal.kind),
1075
+ now,
1076
+ );
1077
+ } catch (err) {
1078
+ pi.logger.error(
1079
+ `[omp-conductor] friction digest cooldown could not be recorded: ${err instanceof Error ? err.message : String(err)}`,
1080
+ );
1081
+ }
1082
+ }
1083
+ } finally {
1084
+ frictionStore?.close();
1085
+ }
1002
1086
  pi.logger.info(`[omp-conductor] tick sent: ${decision.reason}`, { reason: decision.reason });
1003
1087
  // An empty queue at send time is the proof the previous tick was consumed, so
1004
1088
  // this is the only place either the counter or the marker is cleared.
@@ -1056,6 +1140,63 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1056
1140
  // counter is about this session's own queue. A second session in the same
1057
1141
  // process starts with both at zero.
1058
1142
  const session: TickSession = { scopeFallbackLogged: false, pendingSkips: 0 };
1143
+ let releaseGateArmed = false;
1144
+ // An activation file makes this a fleet directory before Herdr can prove
1145
+ // which pane owns it. The gate therefore starts closed and only honours an
1146
+ // operator-brief policy after ownership is accepted.
1147
+ let releaseAuthorityAccepted = false;
1148
+ const armReleaseGate = (): void => {
1149
+ if (releaseGateArmed) return;
1150
+ releaseGateArmed = true;
1151
+ (pi as TickApi & {
1152
+ on(
1153
+ event: "tool_call",
1154
+ handler: (
1155
+ event: { toolName: string; input: Record<string, unknown> },
1156
+ ctx: unknown,
1157
+ ) => ReleaseDecision | undefined,
1158
+ ): void;
1159
+ }).on("tool_call", (event) => {
1160
+ // Most tool calls are ordinary tracker/file work. Detect shape first so
1161
+ // they do not re-read config or emit policy diagnostics.
1162
+ const candidate = releaseDecision(DEFAULT_RELEASE_POLICY, event.toolName, event.input);
1163
+ if (candidate === undefined) return undefined;
1164
+ let projectName: string | undefined;
1165
+ let policy = DEFAULT_RELEASE_POLICY;
1166
+ let external = true;
1167
+ try {
1168
+ const project = findProject(loadConfig());
1169
+ projectName = project.name;
1170
+ policy = resolveReleasePolicy(project);
1171
+ external = project.escalation.orchestrator === "external";
1172
+ } catch (err) {
1173
+ // A missing/unreadable config cannot open a release gate. Log only when
1174
+ // a release-shaped call actually reaches this handler.
1175
+ pi.logger.error(
1176
+ `[omp-conductor] release policy unreadable; enforcing ${DEFAULT_RELEASE_POLICY}: ${
1177
+ err instanceof Error ? err.message : String(err)
1178
+ }`,
1179
+ );
1180
+ }
1181
+ if (releaseAuthorityAccepted && policy === "operator-brief") return undefined;
1182
+ // Embedded orchestrators carry the same tripwire inline through
1183
+ // `createSession`; suppress this second copy only after this session has
1184
+ // proved it owns the external heartbeat.
1185
+ if (releaseAuthorityAccepted && !external) return undefined;
1186
+ if (projectName !== undefined) {
1187
+ try {
1188
+ recordReleaseBlock(projectName, "orchestrator", candidate.shape);
1189
+ } catch (err) {
1190
+ pi.logger.error(
1191
+ `[omp-conductor] could not record release-policy block: ${
1192
+ err instanceof Error ? err.message : String(err)
1193
+ }`,
1194
+ );
1195
+ }
1196
+ }
1197
+ return candidate.decision;
1198
+ });
1199
+ };
1059
1200
 
1060
1201
  pi.on("session_start", (_event, ctx) => {
1061
1202
  if (decided) return;
@@ -1081,6 +1222,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1081
1222
  return;
1082
1223
  }
1083
1224
 
1225
+ // Present but invalid still identifies a fleet directory. Install the
1226
+ // fail-closed handler before validation or ownership can return early.
1227
+ armReleaseGate();
1228
+
1084
1229
  if (result.kind === "invalid") {
1085
1230
  const detail = `${result.path}: ${result.problem}`;
1086
1231
  pi.logger.error(`[omp-conductor] orchestrator tick disabled — ${detail}`);
@@ -1138,6 +1283,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1138
1283
  return;
1139
1284
  }
1140
1285
  if (next.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${next.note}`);
1286
+ releaseAuthorityAccepted = true;
1141
1287
  armTickHeartbeat(pi, ctx, config, session);
1142
1288
  pi.logger.info(`[omp-conductor] orchestrator tick active: ownership resolved on retry`, { agentName });
1143
1289
  }, retryMs);
@@ -1151,6 +1297,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
1151
1297
  return;
1152
1298
  }
1153
1299
  if (ownership.note !== undefined) pi.logger.error(`[omp-conductor] tick ownership: ${ownership.note}`);
1300
+ releaseAuthorityAccepted = true;
1154
1301
  armTickHeartbeat(pi, ctx, config, session);
1155
1302
  decided = true;
1156
1303
  // Both gates are named at startup: "why is it not ticking?" is answered by
@@ -33,7 +33,8 @@ import { stateDir } from "./config.ts";
33
33
  import { formatEscalation } from "./escalate.ts";
34
34
  import { createSession, disposeSession } from "./omp.ts";
35
35
  import type { AgentSessionLike } from "./omp.ts";
36
- import type { Escalation } from "./types.ts";
36
+ import type { ReleaseShape } from "./release-policy.ts";
37
+ import type { Escalation, ReleasePolicy } from "./types.ts";
37
38
 
38
39
  /**
39
40
  * The session factory {@link startOrchestrator} uses. Named so the test seam
@@ -44,6 +45,8 @@ export type CreateSessionFn = (opts: {
44
45
  sessionDir?: string;
45
46
  model?: string;
46
47
  resume?: boolean;
48
+ releasePolicy?: ReleasePolicy;
49
+ onReleaseBlocked?: (shape: ReleaseShape) => void;
47
50
  }) => Promise<AgentSessionLike>;
48
51
 
49
52
  /**
@@ -76,6 +79,8 @@ export interface OrchestratorOpts {
76
79
  cwd: string;
77
80
  sessionDir?: string;
78
81
  model?: string;
82
+ releasePolicy?: ReleasePolicy;
83
+ onReleaseBlocked?: (shape: ReleaseShape) => void;
79
84
  /**
80
85
  * Standing orders — which repo, which labels, what the fleet is. Prepended to
81
86
  * the *first* injection rather than sent as its own prompt on startup: a
@@ -159,6 +164,8 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
159
164
  cwd: o.cwd,
160
165
  sessionDir,
161
166
  ...(o.model === undefined ? {} : { model: o.model }),
167
+ ...(o.releasePolicy === undefined ? {} : { releasePolicy: o.releasePolicy }),
168
+ ...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
162
169
  // The whole point of a persistent orchestrator: a daemon restart must not
163
170
  // reset what it knows it has already escalated, or the first tick after a
164
171
  // deploy re-litigates every parked issue from scratch.
package/src/plugin.ts CHANGED
@@ -21,12 +21,12 @@ import {
21
21
  repairPolicyBannerCrumbs,
22
22
  writeMergedBrief,
23
23
  } from "./brief-upgrade.ts";
24
- import { configPath, expandHome, findProject, loadConfig, saveConfig } from "./config.ts";
24
+ import { configPath, expandHome, findProject, loadConfig, resolveCaps, saveConfig } from "./config.ts";
25
25
  import { hostRamBytes, recommendedMaxWorkers } from "./host.ts";
26
26
  import {
27
- armConductor,
28
27
  isPaused,
29
- previewQueue,
28
+ prepareConductor,
29
+ previewProject,
30
30
  setPaused,
31
31
  type QueuePreview,
32
32
  } from "./daemon.ts";
@@ -40,8 +40,15 @@ import {
40
40
  releaseHold,
41
41
  renderStatus,
42
42
  } from "./fleet.ts";
43
+ import { restartDaemon } from "./lifecycle.ts";
43
44
 
44
45
  import { defaultGraphRoot } from "./graph.ts";
46
+ import {
47
+ formatHostRuntimePlan,
48
+ planHostRuntime,
49
+ runSetupSmoke,
50
+ writeHostRuntime,
51
+ } from "./setup-host.ts";
45
52
  import {
46
53
  AMEND_AREAS,
47
54
  ORCHESTRATOR_BRIEF_NAME,
@@ -74,6 +81,7 @@ import {
74
81
  type ConductorConfig,
75
82
  type OrchestratorMode,
76
83
  type ProjectConfig,
84
+ type ReleasePolicy,
77
85
  type ReportScope,
78
86
  } from "./types.ts";
79
87
 
@@ -341,6 +349,20 @@ async function askAuthority(
341
349
  return { merge: merge ? "orchestrator" : "human", release: release ? "orchestrator" : "human" };
342
350
  }
343
351
 
352
+ async function askReleasePolicy(
353
+ 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";
364
+ }
365
+
344
366
  /**
345
367
  * Where the session that triages escalations lives. Phrased as a fact about the
346
368
  * host rather than a preference, because that is what it is: answering yes when
@@ -571,7 +593,8 @@ const askCaps: AreaAsker = async (ctx, a) => {
571
593
  `Defaults: ${workersDefault} workers, ` +
572
594
  `${spendLabel}, ${DEFAULT_CAPS.workerMaxTurns} turns and ` +
573
595
  `${Math.round(DEFAULT_CAPS.workerWallClockMs / 60000)} min per worker, ` +
574
- `${DEFAULT_CAPS.maxAttemptsPerIssue} attempts per issue.${smallHostNote} Change them?`,
596
+ `${DEFAULT_CAPS.maxAttemptsPerIssue} failed attempts and ` +
597
+ `${DEFAULT_CAPS.maxContinuationsPerIssue} operational continuations per issue.${smallHostNote} Change them?`,
575
598
  );
576
599
  if (!tuneCaps) {
577
600
  if (
@@ -603,9 +626,14 @@ const askCaps: AreaAsker = async (ctx, a) => {
603
626
  );
604
627
  caps.maxAttemptsPerIssue = await askNumber(
605
628
  ctx,
606
- "Attempts per issue before it escalates",
629
+ "Failed implementation attempts per issue before escalation",
607
630
  caps.maxAttemptsPerIssue ?? DEFAULT_CAPS.maxAttemptsPerIssue,
608
631
  );
632
+ caps.maxContinuationsPerIssue = await askNumber(
633
+ ctx,
634
+ "Operational continuations per issue before escalation",
635
+ caps.maxContinuationsPerIssue ?? DEFAULT_CAPS.maxContinuationsPerIssue,
636
+ );
609
637
  return { ...a, caps };
610
638
  };
611
639
 
@@ -621,7 +649,11 @@ const askWorkerModel: AreaAsker = async (ctx, a) => {
621
649
 
622
650
  /** Both grants, asked together because they are the two questions that decide
623
651
  * what an unattended fleet may do without asking anybody. */
624
- const askAuthorityArea: AreaAsker = async (ctx, a) => ({ ...a, authority: await askAuthority(ctx, a.authority) });
652
+ const askAuthorityArea: AreaAsker = async (ctx, a) => ({
653
+ ...a,
654
+ authority: await askAuthority(ctx, a.authority),
655
+ releasePolicy: await askReleasePolicy(ctx, a.releasePolicy),
656
+ });
625
657
 
626
658
  /** How a stuck run reaches a human, and who triages it when it does. */
627
659
  const askEscalation: AreaAsker = async (ctx, a) => {
@@ -795,20 +827,6 @@ function formatPreview(p: QueuePreview): string[] {
795
827
  return lines;
796
828
  }
797
829
 
798
- /**
799
- * The dry run needs a saved config to read, and before the confirm there may
800
- * not be one — that is the normal first run, not a failure. So the reason is
801
- * reported inline and the wizard carries on; the post-write preview is the one
802
- * that always has something to say.
803
- */
804
- async function tryPreview(project: string): Promise<string[]> {
805
- try {
806
- return formatPreview(await previewQueue(project));
807
- } catch (err) {
808
- const message = err instanceof Error ? err.message : String(err);
809
- return [` (not available yet: ${message.split("\n")[0] ?? message})`];
810
- }
811
- }
812
830
 
813
831
  /** What the whole conversation produced: the answers, and which area an amend
814
832
  * narrowed it to. `amend` absent means every question was asked. */
@@ -817,6 +835,17 @@ export interface CollectedSetup {
817
835
  amend?: { area: AmendAreaId; before: ProjectConfig };
818
836
  }
819
837
 
838
+
839
+ export async function ensureSetupArm(
840
+ projectName: string,
841
+ arm: typeof armTicks = armTicks,
842
+ ): Promise<string> {
843
+ const armed = await arm(projectName);
844
+ return armed.alreadyArmed
845
+ ? `existing heartbeat arm revalidated for owner ${armed.owner} at ${armed.path}`
846
+ : `heartbeat armed for owner ${armed.owner} at ${armed.path}`;
847
+ }
848
+
820
849
  /**
821
850
  * The whole conversation, from the amend question to the last prompt, and not one
822
851
  * byte further: no `gh`, no dry run, nothing written.
@@ -845,11 +874,10 @@ export async function collectSetup(
845
874
  /**
846
875
  * The onboarding wizard, and — for a project it already knows — the amend.
847
876
  *
848
- * The invariant that makes this safe to run against a live tracker: nothing is
849
- * written or created before the confirm below returns true. Reading the config,
850
- * asking questions, `checkTokenScopes`, `planLabels` and `previewQueue` are all
851
- * reads. The four mutations `createMissingLabels`, `saveConfig`,
852
- * `writeOrchestratorBrief`, `armConductor` — all live after it. Keep it that way.
877
+ * The invariant that makes this safe against a live tracker: no mutation occurs
878
+ * before the consent below. Config, tracker, and host-runtime planning are
879
+ * read-only. The paused state, labels, config, brief, runtime files, smoke, and
880
+ * arm proof all follow the same consent gate.
853
881
  *
854
882
  * An amend changes which questions are asked and what the summary leads with,
855
883
  * and nothing else: the same answers, the same `buildConfig`, the same single
@@ -876,8 +904,46 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
876
904
  const { answers, amend } = collected;
877
905
 
878
906
  const scopes = await checkTokenScopes();
907
+ if (!scopes.ok) {
908
+ ctx.ui.notify(
909
+ `Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
910
+ `Run \`gh auth refresh -s repo,project\`, then run setup again.`,
911
+ "error",
912
+ );
913
+ return;
914
+ }
879
915
  const labels = await planLabels(answers.trackerRepo, answers);
880
916
  const telegram = detectTelegram();
917
+ const nextConfig = buildConfig(answers, existing);
918
+ const project = findProject(nextConfig, answers.projectName);
919
+ if (
920
+ project.escalation.orchestrator === "external" &&
921
+ !answers.writeOrchestratorBrief &&
922
+ (!existsSync(briefPathForProject(project)) || !existsSync(policyPathForProject(project)))
923
+ ) {
924
+ ctx.ui.notify(
925
+ `Setup stopped before writing anything. External orchestration needs ${ORCHESTRATOR_BRIEF_NAME} and ${POLICY_BRIEF_NAME}. ` +
926
+ `Run setup again and approve the brief write.`,
927
+ "error",
928
+ );
929
+ return;
930
+ }
931
+ const runtime = planHostRuntime(
932
+ project,
933
+ resolveCaps(project, nextConfig.defaults),
934
+ telegram.stateDir,
935
+ );
936
+ let queuePreview: string[];
937
+ try {
938
+ queuePreview = formatPreview(await previewProject(project));
939
+ } catch (err) {
940
+ const message = err instanceof Error ? err.message : String(err);
941
+ ctx.ui.notify(
942
+ `Setup stopped before writing anything because the proposed queue could not be read: ${message}`,
943
+ "error",
944
+ );
945
+ return;
946
+ }
881
947
 
882
948
  ctx.ui.notify(
883
949
  [
@@ -886,10 +952,10 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
886
952
  ...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers)]),
887
953
  summarisePlan(answers, scopes, labels, telegram),
888
954
  "",
889
- existing === undefined
890
- ? "Dry run: available once the config is written."
891
- : "Dry run against the CURRENTLY SAVED config:",
892
- ...(existing === undefined ? [] : await tryPreview(answers.projectName)),
955
+ formatHostRuntimePlan(runtime),
956
+ "",
957
+ "Dry run against the PROPOSED config:",
958
+ ...queuePreview,
893
959
  "",
894
960
  "Nothing has been changed yet.",
895
961
  ].join("\n"),
@@ -903,14 +969,22 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
903
969
  toCreate.length > 0
904
970
  ? `Creates ${toCreate.length} label(s) in ${answers.trackerRepo}: ${toCreate.join(", ")}.`
905
971
  : "Creates no labels.",
906
- `Writes ${path}, creates the state database and clears the pause flag.`,
907
- scopes.missing.length > 0
908
- ? `WARNING: the gh token is missing ${scopes.missing.join(", ")} — the daemon will fail to label issues.`
909
- : "",
972
+ `Writes ${path}, prepares a paused state database, then runs a paused daemon smoke.`,
973
+ runtime.service.action === "keep"
974
+ ? `Keeps the staged systemd unit at ${runtime.service.path}.`
975
+ : `${runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${runtime.service.path}.`,
976
+ runtime.tick === undefined
977
+ ? ""
978
+ : runtime.tick.action === "keep"
979
+ ? `Keeps the external heartbeat config at ${runtime.tick.path}.`
980
+ : `${runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${runtime.tick.path}.`,
910
981
  answers.writeOrchestratorBrief
911
982
  ? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
912
983
  : "",
913
- "Issues are only claimed once the daemon runs.",
984
+ project.escalation.orchestrator === "external"
985
+ ? "Dispatch stays paused until the existing arm marker or a new inbound Telegram proof makes the heartbeat live."
986
+ : "Dispatch resumes after the smoke succeeds.",
987
+ "Issues are only claimed after every setup gate succeeds.",
914
988
  ]
915
989
  .filter((s) => s.length > 0)
916
990
  .join(" "),
@@ -920,25 +994,79 @@ async function setup(ctx: CommandContext, projectArg: string | undefined): Promi
920
994
  return;
921
995
  }
922
996
 
923
- // Labels first: a config pointing at labels that do not exist is a daemon
924
- // that starts and then fails on its first claim.
997
+ // Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
998
+ // partially applied setup unable to claim work.
999
+ prepareConductor();
925
1000
  const created = await createMissingLabels(answers.trackerRepo, labels);
926
- saveConfig(buildConfig(answers, existing));
1001
+ saveConfig(nextConfig);
927
1002
  const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers) : undefined;
928
- armConductor();
1003
+ const runtimeFiles = writeHostRuntime(runtime);
1004
+ const smoke = await runSetupSmoke(project.name);
1005
+ let smokeLine =
1006
+ `paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
1007
+ `stored status for ${smoke.status.project}`;
1008
+ let restartVia: "systemctl" | "cli" | undefined;
1009
+ if (smoke.mode === "existing") {
1010
+ if (smoke.status.liveWorkers > 0) {
1011
+ ctx.ui.notify(
1012
+ [
1013
+ `Setup files are updated, but ${smoke.status.liveWorkers} live worker(s) still use the old daemon config.`,
1014
+ "Dispatch remains paused. Let those workers finish.",
1015
+ `Then run \`omp-conductor restart --project ${project.name}\`.`,
1016
+ project.escalation.orchestrator === "external"
1017
+ ? `Run \`omp-conductor arm --project ${project.name}\` if ticks are disarmed, then run \`omp-conductor resume\`.`
1018
+ : "Then run `omp-conductor resume`.",
1019
+ ].join("\n"),
1020
+ "warning",
1021
+ );
1022
+ return;
1023
+ }
1024
+ const restarted = await restartDaemon({ project: project.name });
1025
+ restartVia = restarted.via;
1026
+ smokeLine =
1027
+ `existing /healthz and stored status; restarted through ${restarted.via}; ` +
1028
+ `new /healthz on :${restarted.record.port}`;
1029
+ }
1030
+
1031
+ let armLine = "embedded orchestrator — no heartbeat arm marker";
1032
+ if (project.escalation.orchestrator === "external") {
1033
+ ctx.ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
1034
+ try {
1035
+ armLine = await ensureSetupArm(project.name);
1036
+ } catch (err) {
1037
+ ctx.ui.notify(
1038
+ [
1039
+ "Setup files passed the paused daemon smoke, but the fleet remains held.",
1040
+ err instanceof Error ? err.message : String(err),
1041
+ `Start the external orchestrator in ${project.workspaceRoot}, then run \`omp-conductor arm --project ${project.name}\`.`,
1042
+ "After the arm proof succeeds, run `omp-conductor resume`.",
1043
+ ].join("\n"),
1044
+ "warning",
1045
+ );
1046
+ return;
1047
+ }
1048
+ }
1049
+ setPaused(false);
929
1050
 
930
1051
  ctx.ui.notify(
931
1052
  [
932
- created.length > 0 ? `Created label(s): ${created.join(", ")}` : "No labels needed creating.",
933
- `Wrote ${path} and armed the conductor.`,
1053
+ created.length > 0 ? `Created label(s): ${created.join(", ")}` : "All required labels already existed.",
1054
+ `Wrote ${path}; dispatch is ready.`,
934
1055
  briefPath === undefined
935
- ? "No orchestrator brief written — the conductor stops at green PRs; merges and releases stay human."
936
- : `Wrote ${briefPath} + POLICY.md edit POLICY.md (Releases/Reporting); floor refreshes each tick.`,
1056
+ ? "Kept the existing orchestrator brief."
1057
+ : `Wrote ${briefPath} + POLICY.md. Edit POLICY.md for Releases and Reporting.`,
1058
+ runtimeFiles.length === 0
1059
+ ? "Host runtime files were already current."
1060
+ : `Wrote host runtime file(s): ${runtimeFiles.join(", ")}`,
1061
+ `Smoke passed: ${smokeLine}.`,
1062
+ `Heartbeat: ${armLine}.`,
937
1063
  "",
938
- "Dry run against the config just written:",
939
- ...(await tryPreview(answers.projectName)),
1064
+ "On a systemd host, install and start the supervised daemon:",
1065
+ ...(restartVia === "cli" ? [" omp-conductor stop"] : []),
1066
+ ...runtime.installCommands.map((command) => ` ${command}`),
940
1067
  "",
941
- "Start the loop with `omp-conductor daemon`, or `omp-conductor daemon --once` for a single tick.",
1068
+ "Without systemd, run `omp-conductor start`.",
1069
+ "Use the documented toy-issue drill to prove one complete worker path.",
942
1070
  ].join("\n"),
943
1071
  "info",
944
1072
  );