omp-conductor 0.17.0 → 0.18.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.
Files changed (51) hide show
  1. package/REFERENCE.md +12 -8
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +40 -1
  4. package/src/admission.ts +263 -44
  5. package/src/ask.ts +39 -3
  6. package/src/availability.ts +27 -1
  7. package/src/backups.ts +2 -2
  8. package/src/briefs/orchestrator.md +1 -0
  9. package/src/briefs/worker.md +38 -19
  10. package/src/command-help.ts +8 -1
  11. package/src/command-manifest.ts +5 -2
  12. package/src/commands/arm.ts +6 -3
  13. package/src/commands/message.ts +32 -4
  14. package/src/commands/watch.ts +62 -3
  15. package/src/config-schema.ts +53 -0
  16. package/src/config.ts +97 -1
  17. package/src/daemon.ts +1479 -1483
  18. package/src/decisions.ts +51 -6
  19. package/src/depends-on.ts +261 -1
  20. package/src/diff-flags.ts +350 -0
  21. package/src/digest-schedule.ts +37 -0
  22. package/src/doctor.ts +310 -22
  23. package/src/escalate.ts +560 -57
  24. package/src/failure-class.ts +71 -15
  25. package/src/fleet.ts +189 -34
  26. package/src/gitops.ts +103 -24
  27. package/src/graph-health.ts +20 -7
  28. package/src/graph.ts +313 -68
  29. package/src/lifecycle.ts +43 -7
  30. package/src/omp.ts +42 -0
  31. package/src/orchestrator-tick.ts +430 -162
  32. package/src/release-policy.ts +177 -5
  33. package/src/routing.ts +11 -3
  34. package/src/session-host.ts +16 -0
  35. package/src/settlement.ts +1728 -0
  36. package/src/setup-host.ts +193 -4
  37. package/src/setup-install.ts +91 -30
  38. package/src/setup-wizard.ts +1257 -78
  39. package/src/setup.ts +153 -6
  40. package/src/status-render.ts +36 -4
  41. package/src/store.ts +411 -17
  42. package/src/tracker/github.ts +607 -12
  43. package/src/types.ts +331 -5
  44. package/src/upgrade.ts +50 -19
  45. package/src/verbs/actions.ts +66 -18
  46. package/src/verbs/protocol.ts +45 -0
  47. package/src/verbs/server.ts +270 -13
  48. package/src/worker.ts +239 -6
  49. package/src/worktree.ts +115 -8
  50. package/systemd/omp-conductor-recover.sh +73 -0
  51. package/systemd/recover-unit-test.sh +61 -0
package/src/ask.ts CHANGED
@@ -93,7 +93,11 @@ export interface AskRequest {
93
93
  timeoutSeconds?: number;
94
94
  /** What is waiting on the answer, for the decision row and the digest. */
95
95
  blocks?: string;
96
- /** The option applied by `auto-proceed`; required exactly for that outcome. */
96
+ /**
97
+ * The option applied by `auto-proceed`; required exactly for that outcome.
98
+ * When {@link AskRequest.options} are supplied it must name one of their
99
+ * labels — the label as delivered, never an index.
100
+ */
97
101
  recommended?: string;
98
102
  /** The choices shown to the operator, with {@link AskRequest.recommended} named. */
99
103
  options?: AskOption[];
@@ -115,6 +119,13 @@ function isInterruptCategory(value: unknown): value is InterruptCategory {
115
119
  * {@link MAX_QUESTION_LINES} / {@link MAX_QUESTION_CHARACTERS}, and refuses
116
120
  * rather than truncating: a truncated question reads as complete and has lost
117
121
  * its options, which is strictly worse than a rejection the caller must fix.
122
+ *
123
+ * The refusal is terminal for the call (#741): nothing is recorded or
124
+ * delivered, and the caller must not resubmit the trimmed question in the same
125
+ * turn — the rationale belongs in the issue or the event ledger, and a
126
+ * decision that still needs a human answer is raised as a fresh ask on a later
127
+ * tick. A re-issued ask therefore always starts from the accepted arguments of
128
+ * a new call, never from a rewrite of a refused one.
118
129
  */
119
130
  export function validateQuestionShape(question: string): QuestionShape {
120
131
  const lines = question.split("\n").length;
@@ -151,7 +162,13 @@ export function parseAskRequest(raw: unknown): AskParse {
151
162
  }
152
163
  const shape = validateQuestionShape(question.trim());
153
164
  if (!shape.ok) {
154
- return { ok: false, problem: `conductor_ask ${shape.problem}` };
165
+ return {
166
+ ok: false,
167
+ problem:
168
+ `conductor_ask ${shape.problem} Do not re-issue the trimmed question in this turn: if the ` +
169
+ "decision still needs a human answer, say so in the report and raise it as a fresh ask in a " +
170
+ "later tick.",
171
+ };
155
172
  }
156
173
 
157
174
  const onTimeoutRaw = input["on-timeout"];
@@ -225,6 +242,25 @@ export function parseAskRequest(raw: unknown): AskParse {
225
242
  options = parsedOptions;
226
243
  }
227
244
 
245
+ // A recommendation must name a supplied option. `recommended` is the option
246
+ // label exactly as the operator reads it — never an index (that is
247
+ // telegram_ask's convention, not this tool's) and never free text when a
248
+ // menu exists. Refused at parse time so the incoherent ask leaves no trace:
249
+ // on `auto-proceed` its text becomes the recorded auto-applied answer, and a
250
+ // value that named no option would resolve a decision row to something that
251
+ // was never a choice (#740).
252
+ if (options !== undefined && recommended !== undefined) {
253
+ const labels = options.map((option) => option.label);
254
+ if (!labels.includes(recommended)) {
255
+ return {
256
+ ok: false,
257
+ problem:
258
+ `conductor_ask recommended "${recommended}" names no option — it must be one of: ${labels.join(", ")}. ` +
259
+ "It is the option label exactly as delivered to the operator, not an index",
260
+ };
261
+ }
262
+ }
263
+
228
264
  const categoryRaw = input["category"];
229
265
  if (categoryRaw !== undefined && !isInterruptCategory(categoryRaw)) {
230
266
  return {
@@ -274,7 +310,7 @@ export function askParameterSchema(): Record<string, unknown> {
274
310
  recommended: {
275
311
  type: "string",
276
312
  description:
277
- "The option applied on auto-proceed. Required when on-timeout is auto-proceed, because the row must record what was auto-applied.",
313
+ "The option applied on auto-proceed; required when on-timeout is auto-proceed because the row must record what was auto-applied. When options are supplied this must be one of their labels — the label as delivered to the operator, not an index.",
278
314
  },
279
315
  blocks: {
280
316
  type: "string",
@@ -91,17 +91,35 @@ export function availabilityDisposition(
91
91
  return "availability";
92
92
  }
93
93
 
94
+ /** A question-kind category. Every ask surface — `message --category
95
+ * decision-needed`, a `QUESTION:`-marked message, and `conductor_ask`'s
96
+ * default — resolves to this one category, so treating it as
97
+ * availability-deferred covers the whole question pathway at once (#596). */
98
+ export const QUESTION_KIND: InterruptCategory = "decision-needed";
99
+
94
100
  /**
95
101
  * Decide one tier-2 category. `digest` means the category policy itself defers
96
102
  * it; `availability` means it was otherwise interruptible and may be released
97
103
  * when the configured window next opens.
104
+ *
105
+ * A question is availability-deferred, never digest-deferred (#596): the
106
+ * category policy decides what may interrupt the operator's phone, but a
107
+ * question's deferral is literally "the operator is not available" — it waits
108
+ * for the window and the working-hours catch-up can release it at the next
109
+ * opening. With no window configured the next opening is now, so a question
110
+ * delivers rather than being silently bound to the daily digest. The
111
+ * `interruptOn` list never applies to a question's deferral.
98
112
  */
99
113
  export function interruptDisposition(
100
114
  policy: ReportingPolicy | undefined,
101
115
  category: InterruptCategory,
102
116
  at: number,
103
117
  ): InterruptDisposition {
104
- if (policy !== undefined && !policy.interruptOn.includes(category)) return "digest";
118
+ if (
119
+ policy !== undefined &&
120
+ category !== QUESTION_KIND &&
121
+ !policy.interruptOn.includes(category)
122
+ ) return "digest";
105
123
  return availabilityDisposition(policy?.availability, category, at);
106
124
  }
107
125
 
@@ -148,6 +166,14 @@ export function formatZonedMinute(at: number, timezone: string): string {
148
166
  return `${local.date} ${local.clock} ${timezone} (${new Date(at).toISOString()})`;
149
167
  }
150
168
 
169
+ /** Compact local timestamp for a one-line future, e.g. "2026-08-24 09:00 UTC":
170
+ * the same instant as {@link formatZonedMinute} without the ISO tail, so a
171
+ * held-notice line read on a phone says plainly when the window opens. */
172
+ export function formatNextWindowOpening(at: number, timezone: string): string {
173
+ const local = localMinute(at, timezone);
174
+ return `${local.date} ${local.clock} ${timezone}`;
175
+ }
176
+
151
177
  /** One prompt sentence; the runtime gate, not this prose, owns the decision. */
152
178
  export function availabilityPrompt(policy: ReportingPolicy | undefined, now: number): string {
153
179
  const state = availabilityState(policy, now);
package/src/backups.ts CHANGED
@@ -18,8 +18,8 @@ import {
18
18
  import { join } from "node:path";
19
19
 
20
20
  /** A timestamp stem safe to embed in a file name. */
21
- export function backupTimestamp(): string {
22
- return new Date().toISOString().replace(/[:.]/g, "-");
21
+ export function backupTimestamp(at: number = Date.now()): string {
22
+ return new Date(at).toISOString().replace(/[:.]/g, "-");
23
23
  }
24
24
 
25
25
  /**
@@ -66,6 +66,7 @@ For each one, pick exactly one of three outcomes:
66
66
  question that unblocks it. Do not guess: a wrong answer costs a worker's whole
67
67
  budget and lands a wrong PR, while an unanswered question costs a delay.
68
68
  {{MERGE_DUTY}}
69
+ {{REVIEW_DUTY}}
69
70
 
70
71
  **Then check for orphans.** A worker is a process, and processes die: a daemon
71
72
  restart, a host reboot, a kill. The `agent:in-progress` label survives that death
@@ -47,7 +47,7 @@ files are canonical; your priors are not.
47
47
 
48
48
  {{ACCEPTANCE_CRITERIA}}
49
49
 
50
- {{ISSUE_COMMENTS}}## How to work
50
+ {{ISSUE_COMMENTS}}{{FILE_LANE}}## How to work
51
51
 
52
52
  1. **Understand before editing — and ask the graph before you grep.** Your turns
53
53
  are mostly spent finding code, not writing it, and running out of turns
@@ -76,6 +76,18 @@ files are canonical; your priors are not.
76
76
  genuinely cannot be done small, stop and escalate rather than ballooning.
77
77
  5. **Fix the root cause, never the symptom.** Do not suppress a warning, delete an
78
78
  assertion, or special-case an input to make a check pass.
79
+ 6. **Prefer the structured edit tool for file changes, and the structured
80
+ search tool over shelling out.** A structured edit is one call: anchored and
81
+ verified, and a stale anchor fails loudly instead of silently editing the
82
+ wrong line. The shell equivalent is three or four — compose the script,
83
+ escape it correctly, run it, then read the file back to confirm it did what
84
+ was intended — and a mis-escaped `sed -i` pattern silently edits nothing or
85
+ the wrong line. So do not hand-roll edits through `sed -i`, `python3`
86
+ heredocs, `node -e` or shell redirection except where no structured tool can
87
+ express the change (a binary file, a generated artefact). The same holds for
88
+ finding code: use the structured search tool rather than shelling out to
89
+ grep, for the same reason the graph line exists — it is cheaper per call and
90
+ its output is already scoped.
79
91
 
80
92
  ## Tests — read this carefully
81
93
 
@@ -98,6 +110,8 @@ These are the exact gates for `{{REPO}}`:
98
110
 
99
111
  {{GATES}}
100
112
 
113
+ {{SHARED_HOST_NOTICE}}
114
+
101
115
  Run every one of them, from the directory listed, over the **whole tree** — not
102
116
  just the directory you edited. Linting only the source dir is how an error in a
103
117
  migration, a config file or a script reaches the runners.
@@ -208,24 +222,29 @@ Escalating is a successful outcome. Guessing is not.
208
222
 
209
223
  ## Your final report
210
224
 
211
- End with exactly these seven lines, evidence onlyno narration:
212
-
225
+ End your run by yielding the settlement through the `yield` tool one
226
+ structured call, and the schema is the contract: the harness showed it to you
227
+ at session start. The expected shape:
228
+
229
+ ```json
230
+ {
231
+ "status": "green",
232
+ "prUrl": "https://github.com/.../pull/N",
233
+ "headSha": "<the 40-char head you watched go green>",
234
+ "summary": "What you changed and why — the narrative a reviewer reads.",
235
+ "proof": ["bun test omp/src/worker.test.ts"]
236
+ }
213
237
  ```
214
- issue: {{TRACKER_REPO}}#{{ISSUE_NUMBER}}
215
- pr: <url or "none">
216
- head: <40-character head SHA or "none">
217
- state: pushed-green | blocked | failed
218
- gates: <exact commands run and their results>
219
- changed: <the settlement derives this from the PR diff — omit the line>
220
- next: <nothing | the specific decision needed>
221
- ```
222
-
223
- The `changed:` line is not yours to write from memory: the settlement replaces
224
- it with the actual file list from the PR's diff. Omit it, or write it wrongly —
225
- the settled report carries the diff's list either way. The narrative in your
226
- report (what you changed and why, above these lines) is the part only you can
227
- write, and it is the part a reviewer reads.
228
238
 
229
- Never report success you have not observed. "Should pass CI" is not a state, and
230
- `pushed-green` means you watched the checks go green — not that you expect them
239
+ Call it as `yield({ result: { data: <the object> } })` with no `type` — the
240
+ usual terminal yield. `status: "green"` means you pushed and **watched the
241
+ checks go green**, and it requires both `prUrl` and `headSha`. Use `blocked`
242
+ (with `blockers`) when a decision or credential is missing, `failed` when the
243
+ run could not complete. The dispatcher renders your yielded settlement into
244
+ the stored report, so `summary` is what a reviewer reads and `proof` is the
245
+ evidence; the `changed:` file list is derived from the PR's own diff, never
246
+ written by you.
247
+
248
+ Never report success you have not observed. "Should pass CI" is not a state,
249
+ and `green` means you watched the checks go green — not that you expect them
231
250
  to.
@@ -168,9 +168,16 @@ export const COMMAND_DETAILS = ` setup interview, then write config.json, th
168
168
  condition the daemon checks for you; a met watch wakes the next tick
169
169
  with its note, exactly as a met question does, but it is listed under
170
170
  its own heading and never under "Open operator decisions", and it has
171
- no seven-day expiry. \`watch list\` shows open watches.
171
+ no seven-day expiry. \`watch list\` shows open watches; \`watch
172
+ withdraw <id>\` ends one with a recorded reason — the verb that
173
+ creates a watch is the verb that ends it (watches are decision rows,
174
+ so \`decision withdraw <id>\` also works). A watch whose PR condition
175
+ can no longer be observed — the PR merged or closed before the
176
+ condition was seen — is withdrawn by the daemon itself with the
177
+ reason recorded.
172
178
  watch add --note TEXT [--blocks TEXT] [--resolves-when COND]
173
179
  watch list
180
+ watch withdraw <id> [--reason TEXT]
174
181
  intake keep a raw idea durably before it becomes anything: record it now
175
182
  with \`omp-conductor intake "<text>"\`, list what is still pending,
176
183
  dismiss what turned out to be nothing. Backed by the sqlite store,
@@ -390,24 +390,27 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
390
390
  },
391
391
  {
392
392
  name: "watch",
393
- description: "record or list orchestrator-only conditions and carry notes",
393
+ description: "record, list, or withdraw orchestrator-only conditions and carry notes",
394
394
  scope: "project",
395
395
  usage: [
396
396
  "watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]",
397
397
  "watch list [--project NAME] [--json]",
398
+ "watch withdraw <id> [--reason TEXT] [--project NAME]",
398
399
  ],
399
400
  subcommands: [
400
401
  { name: "add", description: "record a watch" },
401
402
  { name: "list", description: "list open watches" },
403
+ { name: "withdraw", description: "withdraw an obsolete watch" },
402
404
  ],
403
405
  flags: [
404
406
  value("--note", "note carried when the watch resolves"),
405
407
  value("--blocks", "what the watch blocks"),
406
408
  value("--resolves-when", "automatic resolution condition"),
409
+ value("--reason", "withdrawal reason"),
407
410
  toggle("--json", "print watch list as stable JSON"),
408
411
  project(),
409
412
  ],
410
- positionals: [{ name: "action" }],
413
+ positionals: [{ name: "action" }, { name: "id" }],
411
414
  },
412
415
  {
413
416
  name: "intake",
@@ -12,14 +12,17 @@ import { withProgress } from "../ui/progress.ts";
12
12
 
13
13
  export async function armCommand(ctx: CommandContext): Promise<void> {
14
14
  for (const project of ctx.targetProjects()) {
15
+ // Proof-neutral wording: `claim-only` performs no Telegram send, so the
16
+ // progress line cannot promise a challenge that never goes out (#613). The
17
+ // result line names the proof that actually armed it.
15
18
  const r = await withProgress(
16
- "arm: sending inbound Telegram challenge…",
17
- "Inbound Telegram round-trip proved",
19
+ "arm: verifying the arming proof…",
20
+ "Arming proof verified",
18
21
  () => armTicks(project.name),
19
22
  { plainMessage: true },
20
23
  );
21
24
  process.stdout.write(
22
- `ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
25
+ `ARMED — ${r.proof === "claim-only" ? "claim-only plumbing verdict proved" : `inbound round-trip proved with owner ${r.owner}`}; ticks are now live.\n` +
23
26
  `marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
24
27
  );
25
28
  }
@@ -15,10 +15,11 @@
15
15
 
16
16
  import type { CommandContext } from "./context.ts";
17
17
  import { randomUUID } from "node:crypto";
18
+ import { availabilityState, formatNextWindowOpening } from "../availability.ts";
18
19
  import { findProject, loadConfig } from "../config.ts";
19
20
  import { deliverOperatorMessage, operatorMessageCategory, type OperatorMessageOutcome } from "../reports.ts";
20
21
  import { dbPath, openStore } from "../store.ts";
21
- import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory } from "../types.ts";
22
+ import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory, type ProjectConfig } from "../types.ts";
22
23
  import { validateQuestionShape } from "../ask.ts";
23
24
 
24
25
  /** The floor's "this needs an answer" marker, as every other classifier reads it. */
@@ -96,8 +97,35 @@ export async function messageCommand(ctx: CommandContext): Promise<void> {
96
97
  ? // Not "into the topic": a stale topic degrades to the flat chat with
97
98
  // its own warning on stderr, and this line must not contradict it.
98
99
  `message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
99
- : `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
100
- `${outcome.reason === "availability" ? "outside the availability window" : "a digest-only category"})\n` +
101
- "nothing was sent; the daemon releases it with the next digest or working-hours catch-up\n",
100
+ : heldLine(project, outcome),
101
+ );
102
+ }
103
+
104
+ /** The held-notice line names the future that releases it, in words an
105
+ * operator can act on: the working-hours window opening, or the daily digest.
106
+ * The earlier spelling — "the next digest or working-hours catch-up" — read
107
+ * the same for both and hid exactly the 23-hour hold that #596 is about. */
108
+ function heldLine(
109
+ project: ProjectConfig,
110
+ outcome: Extract<OperatorMessageOutcome, { kind: "held" }>,
111
+ ): string {
112
+ if (outcome.reason === "availability") {
113
+ const state = availabilityState(project.reporting, Date.now());
114
+ const opening =
115
+ state.mode === "quiet" && state.nextTransitionAt !== undefined && state.timezone !== undefined
116
+ ? ` at ${formatNextWindowOpening(state.nextTransitionAt, state.timezone)}`
117
+ : "";
118
+ return (
119
+ `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
120
+ `held until the working-hours window opens${opening})\n` +
121
+ "nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n"
122
+ );
123
+ }
124
+ const digestAt = project.reporting?.digest.at;
125
+ const when = digestAt === undefined ? "the next digest" : `the ${digestAt} digest`;
126
+ return (
127
+ `held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
128
+ `digest-only — held until ${when})\n` +
129
+ `nothing was sent; the daemon releases it with ${when}\n`
102
130
  );
103
131
  }
@@ -10,6 +10,12 @@
10
10
  * next tick should read, neither of which ever needs an operator answer. It is
11
11
  * distinguished durably by its `kind`, never by whether it carries a
12
12
  * condition — a real question may carry one too.
13
+ *
14
+ * Watches are decision rows with `kind === "watch"`, so `watch withdraw`
15
+ * delegates to the same store resolution `decision withdraw` uses rather than
16
+ * inventing a second mechanism: both verbs write the same durable terminal
17
+ * state with a recorded reason, and a watch id remains acceptable to `decision
18
+ * withdraw` for anyone who learned that vocabulary first (#664).
13
19
  */
14
20
 
15
21
  import type { CommandContext } from "./context.ts";
@@ -17,8 +23,25 @@ import { findProject, loadConfig } from "../config.ts";
17
23
  import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
18
24
  import { dbPath, openStore } from "../store.ts";
19
25
 
26
+ const WATCH_USAGE = `omp-conductor watch — set a condition or carry note for the orchestrator itself.
27
+
28
+ usage:
29
+ omp-conductor watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]
30
+ omp-conductor watch list [--project NAME] [--json]
31
+ omp-conductor watch withdraw <id> [--reason TEXT] [--project NAME]
32
+
33
+ add records a row the daemon checks for you and the next tick reads, with no
34
+ operator answer needed. list shows open watches, oldest first. withdraw ends
35
+ one with a recorded reason — the verb that creates a watch is the verb that
36
+ ends it. A watch whose PR condition can no longer be observed (the PR merged
37
+ or closed first) is withdrawn by the daemon itself.`;
38
+
20
39
  export async function watchCommand(ctx: CommandContext): Promise<void> {
21
40
  const sub = ctx.argv[1];
41
+ if (sub === "--help" || sub === "-h") {
42
+ process.stdout.write(WATCH_USAGE);
43
+ return;
44
+ }
22
45
  const project = findProject(loadConfig(), ctx.projectFlag);
23
46
  const store = openStore(dbPath());
24
47
  try {
@@ -45,7 +68,9 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
45
68
  at: Date.now(),
46
69
  });
47
70
  const wake = condition === undefined ? "read by the next tick" : "the daemon wakes the next tick when it is met";
48
- process.stdout.write(`watch ${watch.id} added — ${wake} (no operator answer needed)\n`);
71
+ process.stdout.write(
72
+ `watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id}\n`,
73
+ );
49
74
  return;
50
75
  }
51
76
 
@@ -70,14 +95,48 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
70
95
  for (const watch of watches) {
71
96
  process.stdout.write(
72
97
  `${watch.id} ${watch.ageHours}h blocks:${watch.blocks ?? "-"} ` +
73
- `condition:${watch.condition ?? "-"} ${watch.note}\n`,
98
+ `condition:${watch.condition ?? "-"} ${watch.note} (end: omp-conductor watch withdraw ${watch.id})\n`,
99
+ );
100
+ }
101
+ return;
102
+ }
103
+
104
+ if (sub === "withdraw") {
105
+ const id = ctx.argv[2];
106
+ if (id === undefined || id.startsWith("--")) {
107
+ process.stderr.write("omp-conductor: watch withdraw needs the watch id\n");
108
+ process.exit(2);
109
+ }
110
+ const row = store.decision(id);
111
+ if (row !== undefined && row.kind !== "watch") {
112
+ // The same underlying row, but a question answers to a human: the
113
+ // watch verb must not silently close an operator decision.
114
+ process.stderr.write(
115
+ `omp-conductor: watch withdraw targets watches — ${id} is an operator decision; use decision withdraw\n`,
116
+ );
117
+ process.exit(2);
118
+ }
119
+ const reason = ctx.flag("reason")?.trim();
120
+ const ok = store.resolveDecision(
121
+ id,
122
+ "withdrawn",
123
+ reason === undefined || reason.length === 0 ? "withdrawn" : reason,
124
+ Date.now(),
125
+ );
126
+ if (!ok) {
127
+ // A watch that is not open is a different mistake from an id that
128
+ // never existed, and the operator can only act on one of them.
129
+ process.stderr.write(
130
+ `omp-conductor: no open watch ${id} for ${project.name} — it was already withdrawn, or the id is wrong\n`,
74
131
  );
132
+ process.exit(1);
75
133
  }
134
+ process.stdout.write(`watch ${id} withdrawn\n`);
76
135
  return;
77
136
  }
78
137
 
79
138
  process.stderr.write(
80
- `omp-conductor: unknown watch subcommand "${sub}" — expected add or list\n`,
139
+ `omp-conductor: unknown watch subcommand "${sub}" — expected add, list or withdraw\n`,
81
140
  );
82
141
  process.exit(2);
83
142
  } finally {
@@ -22,13 +22,17 @@
22
22
 
23
23
  import { z } from "zod";
24
24
  import {
25
+ ARM_PROOFS,
25
26
  AUTHORITY_HOLDERS,
26
27
  BASE_FRESHNESS,
27
28
  BEHIND_BASE_ACTIONS,
28
29
  CONFIG_VERSION,
30
+ DEFAULT_ARM_PROOF,
29
31
  DEFAULT_AUTHORITY,
30
32
  DEFAULT_CAPS,
31
33
  DEFAULT_PROJECT_POLICY,
34
+ DEFAULT_REVIEW_MAX_ROUNDS,
35
+ DEFAULT_REVIEW_STRICTNESS,
32
36
  DRAFT_POLICIES,
33
37
  INTERRUPT_CATEGORIES,
34
38
  LEGACY_RELEASE_POLICIES,
@@ -37,6 +41,9 @@ import {
37
41
  RELEASE_REQUIREMENTS,
38
42
  RELEASE_SHAPES,
39
43
  REPORT_SCOPES,
44
+ REVIEW_MAX_ROUNDS_MAX,
45
+ REVIEW_MAX_ROUNDS_MIN,
46
+ REVIEW_STRICTNESS,
40
47
  WEEKDAYS,
41
48
  DIGEST_CADENCES,
42
49
  } from "./types.ts";
@@ -63,6 +70,7 @@ const RELEASE_REQUIREMENT_LIST = quoteList(RELEASE_REQUIREMENTS);
63
70
  const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
64
71
  const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
65
72
  const LEGACY_RELEASE_POLICY_LIST = quoteList(LEGACY_RELEASE_POLICIES);
73
+ const REVIEW_STRICTNESS_LIST = quoteList(REVIEW_STRICTNESS);
66
74
 
67
75
  // ---------------------------------------------------------------------------
68
76
  // Closed vocabularies — every one built from the exported `as const` array in
@@ -81,6 +89,7 @@ const releaseRequirementEnum = z.enum([...RELEASE_REQUIREMENTS]);
81
89
  const releaseShapeEnum = z.enum([...RELEASE_SHAPES]);
82
90
  const orchestratorModeEnum = z.enum([...ORCHESTRATOR_MODES]);
83
91
  const releasePolicyLegacyEnum = z.enum([...LEGACY_RELEASE_POLICIES]);
92
+ const reviewStrictnessEnum = z.enum([...REVIEW_STRICTNESS]);
84
93
 
85
94
  /** The 24-hour `HH:MM` shape `digest.at` / `availability.start/end` take. */
86
95
  const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
@@ -239,6 +248,42 @@ const escalationSchema = z
239
248
  })
240
249
  .strict();
241
250
 
251
+ /**
252
+ * The per-project arming gate (conductor #613): how `arm` proves a human just
253
+ * approved dispatch. Absent or a legacy config without the key loads as
254
+ * `challenge` — the authenticated round-trip, which is what existing installs
255
+ * already run. `claim-only` arms on the shared live-plumbing verdict with no
256
+ * Telegram send or wait, so an unattended recovery can re-arm a project that
257
+ * opted in.
258
+ */
259
+ const armSchema = z
260
+ .object({
261
+ proof: z.enum([...ARM_PROOFS]).default(DEFAULT_ARM_PROOF),
262
+ })
263
+ .strict()
264
+ .describe("How `arm` proves a human just approved arming");
265
+
266
+ /**
267
+ * The per-project review policy (#678): how strictly green PRs are reviewed
268
+ * and returned, and the hard ceiling on review rounds per PR lifecycle. Absent
269
+ * or a legacy config without the key loads as `medium` with
270
+ * {@link DEFAULT_REVIEW_MAX_ROUNDS} rounds — the recommended default for a new
271
+ * project, materialised deterministically for every existing one, with the
272
+ * schema bounds and the runtime defaults read from the same constants.
273
+ */
274
+ const reviewSchema = z
275
+ .object({
276
+ strictness: reviewStrictnessEnum.default(DEFAULT_REVIEW_STRICTNESS),
277
+ maxRounds: z
278
+ .number()
279
+ .int()
280
+ .min(REVIEW_MAX_ROUNDS_MIN)
281
+ .max(REVIEW_MAX_ROUNDS_MAX)
282
+ .default(DEFAULT_REVIEW_MAX_ROUNDS),
283
+ })
284
+ .strict()
285
+ .describe("Review strictness and round ceiling for green PRs");
286
+
242
287
  const releasePolicySchema = z.union([
243
288
  releasePolicyLegacyEnum,
244
289
  z.record(z.string(), authorityHolderEnum),
@@ -287,6 +332,7 @@ const stateLabelsSchema = z
287
332
  inProgress: z.unknown(),
288
333
  blocked: z.unknown(),
289
334
  failed: z.unknown(),
335
+ backlog: z.unknown(),
290
336
  })
291
337
  .partial();
292
338
 
@@ -330,6 +376,12 @@ const projectSchema = z
330
376
  // overlay); anything else is dropped by the loader, like `workerModel`.
331
377
  workerAdvisor: z.unknown().optional(),
332
378
  escalation: escalationSchema.optional(),
379
+ // How `arm` proves a human approved arming (#613); absent loads as
380
+ // `challenge`, preserving today's authenticated round-trip.
381
+ arm: armSchema.optional(),
382
+ // Review strictness and the round ceiling (#678); absent loads as the
383
+ // documented migration default.
384
+ review: reviewSchema.optional(),
333
385
  authority: authoritySchema.optional(),
334
386
  releasePolicy: releasePolicySchema.optional(),
335
387
  policy: projectPolicySchema.optional(),
@@ -428,4 +480,5 @@ export {
428
480
  RELEASE_SHAPE_LIST,
429
481
  ORCHESTRATOR_MODE_LIST,
430
482
  LEGACY_RELEASE_POLICY_LIST,
483
+ REVIEW_STRICTNESS_LIST,
431
484
  };