omp-conductor 0.17.0 → 0.17.1

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/REFERENCE.md CHANGED
@@ -1864,7 +1864,7 @@ policy instead of restating it — no threshold lives in two places.
1864
1864
 
1865
1865
  | Field | Values | Default | Means |
1866
1866
  | --- | --- | --- | --- |
1867
- | `requires` | `runs-settled`, `no-open-prs`, `queue-drained`, `base-branch-green`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. `runs-settled` reads each active run's PR fact at release time: a pushed run whose PR has merged counts as settled even when the settle sweep has not yet written the terminal row — so a hold-drained release does not wait an extra tick the operator reached the gate by holding. Live workers and unmerged/unknown PRs still refuse, and the message names which is which. `base-branch-green` requires the current live head's push-triggered workflow verdict for that routed repository to be green; pending, unknown, red, or no observation refuses release. Order and duplicates do not matter; the loader canonicalises. |
1867
+ | `requires` | `runs-settled`, `fleet-runs-settled`, `no-open-prs`, `queue-drained`, `base-branch-green`, `epic-children-closed` | `["runs-settled"]` | What must already have landed. `runs-settled` reads each active run of the released repo's PR fact at release time: a pushed run whose PR has merged counts as settled even when the settle sweep has not yet written the terminal row — so a hold-drained release does not wait an extra tick the operator reached the gate by holding. Runs in other routed repos never gate a repo-scoped release — they cannot invalidate the artifact being shipped; a genuinely suite-wide shape (a pin or manifest consuming several repos) opts back into project-wide strictness with the named `fleet-runs-settled` requirement. Live workers and unmerged/unknown PRs still refuse, the message names which is which and which runs are blocking by repo and issue. `base-branch-green` requires the current live head's push-triggered workflow verdict for that routed repository to be green; pending, unknown, red, or no observation refuses release. Order and duplicates do not matter; the loader canonicali…
1868
1868
  | `requiredChecks` | any check names | `[]` | Checks that must be green on the branch being released. Empty means every check it reports. |
1869
1869
  | `artefacts` | any names | `[]` | The packages or images this project releases. **Empty denies**: nothing has been authorised to ship. |
1870
1870
  | `environments` | any names | `[]` | Deploy targets. **Empty denies** every environment. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -325,6 +325,21 @@
325
325
  },
326
326
  "additionalProperties": false
327
327
  },
328
+ "arm": {
329
+ "type": "object",
330
+ "properties": {
331
+ "proof": {
332
+ "default": "challenge",
333
+ "type": "string",
334
+ "enum": [
335
+ "challenge",
336
+ "claim-only"
337
+ ]
338
+ }
339
+ },
340
+ "additionalProperties": false,
341
+ "description": "How `arm` proves a human just approved arming"
342
+ },
328
343
  "authority": {
329
344
  "type": "object",
330
345
  "properties": {
@@ -433,6 +448,7 @@
433
448
  "type": "string",
434
449
  "enum": [
435
450
  "runs-settled",
451
+ "fleet-runs-settled",
436
452
  "no-open-prs",
437
453
  "queue-drained",
438
454
  "base-branch-green",
package/src/admission.ts CHANGED
@@ -19,6 +19,7 @@ import { log, errText, safeEscalate } from "./log.ts";
19
19
  import type {
20
20
  Caps,
21
21
  Escalation,
22
+ IssueComment,
22
23
  IssueSnapshot,
23
24
  OpenCloser,
24
25
  ProjectConfig,
@@ -28,7 +29,7 @@ import type {
28
29
  AdmissionHoldReason,
29
30
  } from "./types.ts";
30
31
  import { readPlanUsage, type PlanUsageStatus, type UsageSource } from "./usage.ts";
31
- import type { CriticalBaseProbe, CriticalBaseVerdict, RunLaneProbe } from "./gitops.ts";
32
+ import type { CriticalBaseProbe, CriticalBaseVerdict, LaneFile, LaneSource, RunLaneProbe } from "./gitops.ts";
32
33
  import { repoSlugFor } from "./gitops.ts";
33
34
  import { branchName, type Routed } from "./routing.ts";
34
35
  import { parseDependsOn } from "./depends-on.ts";
@@ -77,10 +78,16 @@ export function hasFailedAttemptBudget(failures: number, maxAttempts: number): b
77
78
  return failures < maxAttempts;
78
79
  }
79
80
 
80
- /** A candidate cleared for dispatch, with the attempt number it will run as. */
81
+ /** A candidate cleared for dispatch, with the attempt number it will run as.
82
+ * `lane` is the effective file-lane declaration admission resolved for it
83
+ * (#608): the exact snapshot the overlap gate enforced. Dispatch renders this
84
+ * value into the worker brief, so a changed or failed second comment read can
85
+ * neither hide nor reword the lane admission held — the gate and the
86
+ * worker-visible brief are one value, not two reads of the same thread. */
81
87
  export interface Admission {
82
88
  r: Routed;
83
89
  attempt: number;
90
+ lane?: FileLane;
84
91
  }
85
92
 
86
93
  export interface AdmissionHold {
@@ -174,27 +181,82 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
174
181
  * files an issue declares it will touch (#555).
175
182
  *
176
183
  * The orchestrator already writes exactly this list into every promotion brief
177
- * in prose; this parses that same sentence out of the issue body so the
178
- * interlock is load-bearing rather than advisory. A body line beginning with
179
- * "file lane" (case-insensitive, optional bold/heading markers) is accepted,
180
- * and paths are read as backtick-delimited spans (the brief form) with a bare
181
- * comma/space-separated fallback that keeps tokens that look like relative
182
- * paths. Anything else including an absent line, which is the default — is
183
- * an empty lane: the issue is admitted exactly as today (`fail open`), and the
184
- * gate never refuse work for wanting a lane. Exported so the format is pinned
185
- * independent of admission.
184
+ * in prose; this parses that same sentence out of the issue body or out of a
185
+ * pre-dispatch comment, which is the same sentence on a supported brief
186
+ * surface since #517 so the interlock is load-bearing rather than advisory.
187
+ * A line beginning with "file lane" (case-insensitive, optional
188
+ * bold/heading markers) is accepted, and paths are read as backtick-delimited
189
+ * spans (the brief form) with a bare comma/space-separated fallback that keeps
190
+ * tokens that look like relative paths. A line whose tokens are not pathlike
191
+ * (like `File lane: none`) is not a declaration. Anything else including an
192
+ * absent declaration, which is the default — is an empty lane: the issue is
193
+ * admitted exactly as today (`fail open`), and the gate never refuses work for
194
+ * wanting a lane. This is the one grammar for all surfaces; there is no second
195
+ * comment-only spelling.
186
196
  */
187
- export function declaredLane(body: string): string[] {
188
- const match = body.match(
197
+ export function laneDeclaration(text: string): LaneDeclaration | undefined {
198
+ const match = text.match(
189
199
  /^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*([^\n]*)$/im,
190
200
  );
191
- if (match === null) return [];
201
+ if (match === null) return undefined;
192
202
  const rest = match[1] ?? "";
193
203
  const backticked = [...rest.matchAll(/`([^`]+)`/g)]
194
204
  .map((m) => m[1]!.trim())
195
205
  .filter(isPathLike);
196
- if (backticked.length > 0) return [...new Set(backticked)];
197
- return [...new Set(rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike))];
206
+ const files =
207
+ backticked.length > 0
208
+ ? [...new Set(backticked)]
209
+ : [...new Set(rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike))];
210
+ if (files.length === 0) return undefined;
211
+ return { files, source: match[0].trim() };
212
+ }
213
+
214
+ /** One parsed `File lane:` declaration: the paths and the verbatim source
215
+ * line, so a renderer can reproduce the declaration itself rather than a
216
+ * summary of it. Exported for the brief's guarantee that the gate's effective
217
+ * lane is always visible to the worker (#608). */
218
+ export interface LaneDeclaration {
219
+ files: string[];
220
+ /** The declaration line verbatim, as written on the surface it came from. */
221
+ source: string;
222
+ }
223
+
224
+ /**
225
+ * The effective file lane as both admission and the worker brief must read it
226
+ * (#608): the latest `File lane:` declaration among the issue body and every
227
+ * comment, in the tracker's oldest-first order. This is the "later correction
228
+ * visibly supersedes" contract applied across both surfaces at once, and it is
229
+ * the single source of truth the gate enforces and the brief renders — so a
230
+ * declaration can never control admission while staying invisible to the
231
+ * worker. `at` records which surface won (`"body"`, or the 0-based comment
232
+ * index), letting the brief reproduce the declaration verbatim when the
233
+ * winning comment sits beyond its rendered discussion budget.
234
+ */
235
+ export interface FileLane extends LaneDeclaration {
236
+ at: "body" | number;
237
+ }
238
+
239
+ /** Who holds one file's lane this admission pass, and which read proved it.
240
+ * `"declared"` is the same-pass half: an admitted candidate's own lane
241
+ * occupies for the rest of the pass without any probe read. */
242
+ export type LaneHolder = { issue: number; source: LaneSource | "declared" };
243
+
244
+ /** Resolves the effective lane across the body and the whole comment thread. */
245
+ export function effectiveLane(body: string, comments: IssueComment[]): FileLane | undefined {
246
+ let current: FileLane | undefined;
247
+ const bodyDecl = laneDeclaration(body);
248
+ if (bodyDecl !== undefined) current = { ...bodyDecl, at: "body" };
249
+ for (let i = 0; i < comments.length; i++) {
250
+ const decl = laneDeclaration(comments[i]!.body);
251
+ if (decl !== undefined) current = { ...decl, at: i };
252
+ }
253
+ return current;
254
+ }
255
+
256
+ /** The paths of an issue body's declared lane (empty for no declaration).
257
+ * Exported so the format is pinned independent of admission. */
258
+ export function declaredLane(body: string): string[] {
259
+ return laneDeclaration(body)?.files ?? [];
198
260
  }
199
261
 
200
262
  /** A plausible relative path: it has a `.` extension or a directory separator. */
@@ -271,14 +333,14 @@ export async function admitCandidates(
271
333
  // and one on the web repo are different files. Built lazily and once, only
272
334
  // when the first candidate that declares a lane reaches the gate, so a queue
273
335
  // of laneless issues pays nothing for it.
274
- let laneOccupancy: Map<string, Map<string, number>> | undefined;
275
- const ensureLaneOccupancy = async (): Promise<Map<string, Map<string, number>>> => {
336
+ let laneOccupancy: Map<string, Map<string, LaneHolder>> | undefined;
337
+ const ensureLaneOccupancy = async (): Promise<Map<string, Map<string, LaneHolder>>> => {
276
338
  if (laneOccupancy !== undefined) return laneOccupancy;
277
- const occupied = new Map<string, Map<string, number>>();
339
+ const occupied = new Map<string, Map<string, LaneHolder>>();
278
340
  for (const run of activeRuns) {
279
341
  if (d.probeWorktreeLane === undefined) break;
280
342
  const base = project.routing.repos[run.repo]?.defaultBranch ?? "main";
281
- let files: string[];
343
+ let files: LaneFile[];
282
344
  try {
283
345
  files = await d.probeWorktreeLane({
284
346
  worktree: run.worktree,
@@ -291,20 +353,64 @@ export async function admitCandidates(
291
353
  // well-formed issue because one probe could not be answered.
292
354
  files = [];
293
355
  }
294
- for (const file of files) {
295
- if (occupied.get(run.repo)?.has(file) === true) continue;
356
+ for (const { file, source } of files) {
296
357
  let perRepo = occupied.get(run.repo);
297
358
  if (perRepo === undefined) {
298
359
  perRepo = new Map();
299
360
  occupied.set(run.repo, perRepo);
300
361
  }
301
- perRepo.set(file, run.issue);
362
+ // First read wins: a file the run both commits and edits in its
363
+ // worktree is tagged by its live uncommitted half.
364
+ if (!perRepo.has(file)) perRepo.set(file, { issue: run.issue, source });
302
365
  }
303
366
  }
304
367
  laneOccupancy = occupied;
305
368
  return occupied;
306
369
  };
307
370
 
371
+ // The candidate half of the file-lane interlock (#555): the machine-readable
372
+ // lane an issue declares. Since #517 a promotion brief may live in a
373
+ // pre-dispatch comment rather than the body, so the lane is read from both
374
+ // surfaces — the same two the worker brief renders — through the tracker's
375
+ // existing comment port, never a second comment reader. Body and comments
376
+ // share the one `laneDeclaration` grammar, and the brief's "later correction
377
+ // visibly supersedes" contract holds: the effective lane is the latest
378
+ // declaration among the body and the WHOLE thread, so a correction posted as
379
+ // a comment replaces an earlier body lane instead of widening it (#608).
380
+ //
381
+ // Reading every comment — not just the ones the brief's discussion budget
382
+ // renders — is deliberate: this is the same `effectiveLane` the brief
383
+ // renders, and the dispatch side guarantees the winning declaration appears
384
+ // in the brief verbatim even when it sits beyond the budget. The gate and
385
+ // the worker therefore agree on one lane, which is the #608 defect's shape.
386
+ //
387
+ // Comments are read at most once per issue per pass — the lane feeds both
388
+ // the gate and the same-pass sibling occupancy below, so the cache is what
389
+ // stops one candidate costing two comment reads. Unreadable comments fail
390
+ // open to the body declaration (a body lane stays load-bearing), and with no
391
+ // readable declaration at all the #555 fail-open admission is unchanged.
392
+ //
393
+ // The resolved `FileLane` — not just its paths — is what an admitted
394
+ // candidate carries into dispatch (#608): the gate and the brief must agree
395
+ // on the *same declaration* (paths, verbatim source line, and which surface
396
+ // it came from), so dispatch renders this cached value rather than reading
397
+ // the thread a second time and hoping it did not change.
398
+ const laneCache = new Map<number, FileLane | undefined>();
399
+ const laneFor = async (r: Routed): Promise<FileLane | undefined> => {
400
+ const issue = r.issue.number;
401
+ if (laneCache.has(issue)) return laneCache.get(issue);
402
+ let lane: FileLane | undefined;
403
+ try {
404
+ lane = effectiveLane(r.issue.body, await tracker.listComments(issue));
405
+ } catch (err) {
406
+ const bodyDecl = laneDeclaration(r.issue.body);
407
+ lane = bodyDecl === undefined ? undefined : { ...bodyDecl, at: "body" };
408
+ log(`#${issue} comments unreadable at admission; the body's file lane stands: ${errText(err)}`);
409
+ }
410
+ laneCache.set(issue, lane);
411
+ return lane;
412
+ };
413
+
308
414
  // The plan allowance is a fleet-wide question, so it is asked once per pass
309
415
  // and answers for every candidate — unlike every gate below it, which is
310
416
  // per-issue. It sits here rather than beside the spend cap in `tick` for one
@@ -505,25 +611,30 @@ export async function admitCandidates(
505
611
  }
506
612
  if (verdict.state === "unknown") {
507
613
  // Fail closed: a branch that cannot be *proven* to contain the marker
508
- // is refused, and the reason names the unverifiable marker so the
509
- // operator can fix the fetch or the marker rather than guess.
510
- hold(issue, "stale-base");
614
+ // is refused. This is a verification/lookup failure the mirror fetch
615
+ // failed, the marker did not resolve, or no probe is wired — never
616
+ // evidence that the branch predates the marker. Hold with a distinct
617
+ // reason so status and the friction rollup do not read a provider
618
+ // outage as branch staleness, and prescribe no branch change: the next
619
+ // admission pass re-runs the probe and admits the unchanged branch
620
+ // once verification succeeds.
621
+ hold(issue, "critical-base-verify-error");
511
622
  log(
512
- `#${issue} held (stale-base): continuation branch ${branch} could not be verified ` +
623
+ `#${issue} held (critical-base-verify-error): continuation branch ${branch} could not be verified ` +
513
624
  `against critical-base marker(s) ${markers.join(", ")} (${verdict.error})`,
514
625
  );
515
626
  await safeEscalate(d, {
516
627
  tier: 1,
517
628
  project: project.name,
518
629
  issue,
519
- summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (stale-base)`,
630
+ summary: `#${issue} continuation branch could not be verified against a critical base safety commit and is held (critical-base-verify-error)`,
520
631
  detail: [
521
632
  r.issue.title,
522
633
  r.issue.url,
523
634
  `The retained branch ${branch} could not be verified against critical-base marker(s) ${markers.join(", ")}: ${verdict.error}`,
524
- "Recovery: merge current base into the branch so it contains the marker, and the next",
525
- "admission pass re-admits it automatically without losing the branch's work; or review",
526
- "the branch by hand and clear the hold once the fix is present.",
635
+ "The branch is not claimed to predate the marker: this is a verification failure.",
636
+ "No branch change is prescribed. The next admission pass retries the critical-base",
637
+ "verification automatically and admits the unchanged branch once the probe succeeds.",
527
638
  ].join("\n"),
528
639
  });
529
640
  continue;
@@ -795,18 +906,23 @@ export async function admitCandidates(
795
906
  // a live run's *actual* lane is held until that run's work has merged, so
796
907
  // no second worker is sent at files another worker is still writing. The
797
908
  // gate is the mechanical version of the prose rule that failed three times
798
- // in one day. Only the machine-readable lane participates: a candidate
799
- // without one is admitted exactly as today (fail open), and a candidate's
800
- // own retained run never holds it that is the continuation it continues.
801
- const lane = declaredLane(r.issue.body);
802
- if (lane.length > 0) {
909
+ // in one day. Only the machine-readable lane participates read from the
910
+ // body and pre-dispatch comments alike, so the gate sees the same surface
911
+ // the worker brief renders (#608)and a candidate without one is
912
+ // admitted exactly as today (fail open), while a candidate's own retained
913
+ // run never holds it — that is the continuation it continues.
914
+ const lane = await laneFor(r);
915
+ if (lane !== undefined && lane.files.length > 0) {
803
916
  const occupied = await ensureLaneOccupancy();
804
917
  const perRepo = occupied.get(r.repo.name);
805
918
  let blocked = false;
806
- for (const file of lane) {
919
+ for (const file of lane.files) {
807
920
  const holder = perRepo?.get(file);
808
- if (holder !== undefined && holder !== issue) {
809
- const detail = `${file} held by run #${holder}`;
921
+ if (holder !== undefined && holder.issue !== issue) {
922
+ // The source names the probe read that proved the occupancy, so a
923
+ // hold reads as authored work — worktree or branch — rather than the
924
+ // base-reconciliation noise #684 filters out of the probe entirely.
925
+ const detail = `${file} held by run #${holder.issue} (${holder.source})`;
810
926
  hold(issue, "file-lane", detail);
811
927
  log(`#${issue} held (file-lane): ${detail}`);
812
928
  blocked = true;
@@ -816,20 +932,20 @@ export async function admitCandidates(
816
932
  if (blocked) continue;
817
933
  }
818
934
 
819
- admitted.push({ r, attempt: priorRuns + 1 });
935
+ admitted.push({ r, attempt: priorRuns + 1, lane });
820
936
  liveByRepo.set(r.repo.name, (liveByRepo.get(r.repo.name) ?? 0) + 1);
821
937
  // Same-pass sibling occupancy for the file-lane gate: once admitted, a
822
938
  // candidate's declared lane occupies for the rest of the pass, so a later
823
939
  // overlapping candidate is held rather than both clearing in one tick.
824
- if (lane.length > 0) {
940
+ if (lane !== undefined && lane.files.length > 0) {
825
941
  const occupied = await ensureLaneOccupancy();
826
942
  let perRepo = occupied.get(r.repo.name);
827
943
  if (perRepo === undefined) {
828
944
  perRepo = new Map();
829
945
  occupied.set(r.repo.name, perRepo);
830
946
  }
831
- for (const file of lane) {
832
- if (!perRepo.has(file)) perRepo.set(file, issue);
947
+ for (const file of lane.files) {
948
+ if (!perRepo.has(file)) perRepo.set(file, { issue, source: "declared" });
833
949
  }
834
950
  }
835
951
  if (parent !== undefined) {
@@ -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);
@@ -98,6 +98,8 @@ These are the exact gates for `{{REPO}}`:
98
98
 
99
99
  {{GATES}}
100
100
 
101
+ {{SHARED_HOST_NOTICE}}
102
+
101
103
  Run every one of them, from the directory listed, over the **whole tree** — not
102
104
  just the directory you edited. Linting only the source dir is how an error in a
103
105
  migration, a config file or a script reaches the runners.
@@ -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
  }
@@ -22,10 +22,12 @@
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,
@@ -239,6 +241,21 @@ const escalationSchema = z
239
241
  })
240
242
  .strict();
241
243
 
244
+ /**
245
+ * The per-project arming gate (conductor #613): how `arm` proves a human just
246
+ * approved dispatch. Absent or a legacy config without the key loads as
247
+ * `challenge` — the authenticated round-trip, which is what existing installs
248
+ * already run. `claim-only` arms on the shared live-plumbing verdict with no
249
+ * Telegram send or wait, so an unattended recovery can re-arm a project that
250
+ * opted in.
251
+ */
252
+ const armSchema = z
253
+ .object({
254
+ proof: z.enum([...ARM_PROOFS]).default(DEFAULT_ARM_PROOF),
255
+ })
256
+ .strict()
257
+ .describe("How `arm` proves a human just approved arming");
258
+
242
259
  const releasePolicySchema = z.union([
243
260
  releasePolicyLegacyEnum,
244
261
  z.record(z.string(), authorityHolderEnum),
@@ -330,6 +347,9 @@ const projectSchema = z
330
347
  // overlay); anything else is dropped by the loader, like `workerModel`.
331
348
  workerAdvisor: z.unknown().optional(),
332
349
  escalation: escalationSchema.optional(),
350
+ // How `arm` proves a human approved arming (#613); absent loads as
351
+ // `challenge`, preserving today's authenticated round-trip.
352
+ arm: armSchema.optional(),
333
353
  authority: authoritySchema.optional(),
334
354
  releasePolicy: releasePolicySchema.optional(),
335
355
  policy: projectPolicySchema.optional(),
package/src/config.ts CHANGED
@@ -17,10 +17,12 @@ import { homedir } from "node:os";
17
17
  import { dirname, isAbsolute, join } from "node:path";
18
18
  import { backupTimestamp, copyToUniqueBackup } from "./backups.ts";
19
19
  import {
20
+ ARM_PROOFS,
20
21
  AUTHORITY_HOLDERS,
21
22
  BASE_FRESHNESS,
22
23
  BEHIND_BASE_ACTIONS,
23
24
  CONFIG_VERSION,
25
+ DEFAULT_ARM_PROOF,
24
26
  DEFAULT_AUTHORITY,
25
27
  DEFAULT_CAPS,
26
28
  DEFAULT_PROJECT_POLICY,
@@ -55,6 +57,7 @@ import {
55
57
  type WeeklyAvailability,
56
58
  type RepoTarget,
57
59
  type ResolvedGrants,
60
+ type ArmProof,
58
61
  } from "./types.ts";
59
62
  import {
60
63
  AUTHORITY_HOLDER_LIST,
@@ -313,6 +316,20 @@ export function resolvePolicy(p: ProjectConfig): ProjectPolicy {
313
316
  return clonePolicy(p.policy ?? DEFAULT_PROJECT_POLICY);
314
317
  }
315
318
 
319
+ /**
320
+ * How this project's `arm` proves a human just approved arming (#613),
321
+ * complete.
322
+ *
323
+ * The loader always materialises `arm` (finalizeProject), so this is only ever
324
+ * the fallback for a `ProjectConfig` that never went through it — a hand-built
325
+ * one in a test, or a config written before the key existed. Absent resolves
326
+ * to {@link DEFAULT_ARM_PROOF} (`challenge`): the authenticated round-trip,
327
+ * which is the behaviour every existing install already has.
328
+ */
329
+ export function resolveArmProof(p: ProjectConfig): ArmProof {
330
+ return p.arm?.proof === "claim-only" ? "claim-only" : DEFAULT_ARM_PROOF;
331
+ }
332
+
316
333
  /**
317
334
  * A policy with no array shared with its source.
318
335
  *
@@ -835,6 +852,9 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
835
852
  return `reporting.availability must be an object`;
836
853
  }
837
854
  if (rel.length === 1 && relStr === "escalation") return `escalation must be an object`;
855
+ if (rel.length === 1 && relStr === "arm") {
856
+ return `arm must be an object with a "proof" of ${ARM_PROOF_LIST}`;
857
+ }
838
858
  if (rel.length === 1 && relStr === "authority") {
839
859
  return `authority must be an object with "merge", "release" and "promotion" of ${AUTHORITY_HOLDER_LIST}`;
840
860
  }
@@ -995,6 +1015,9 @@ function capProblem(key: string, found: string): string {
995
1015
  const POLICY_MERGE_KEYS = quoteList(Object.keys(clonePolicy(DEFAULT_PROJECT_POLICY).merge));
996
1016
  const POLICY_RELEASE_KEYS = quoteList(Object.keys(clonePolicy(DEFAULT_PROJECT_POLICY).release));
997
1017
 
1018
+ /** The closed `arm.proof` vocabulary, for the not-an-object wording. */
1019
+ const ARM_PROOF_LIST = quoteList(ARM_PROOFS);
1020
+
998
1021
  // ---------------------------------------------------------------------------
999
1022
  // residue: cross-field coherence, migrations, defaults, path expansion
1000
1023
  // ---------------------------------------------------------------------------
@@ -1062,6 +1085,7 @@ function finalizeProject(
1062
1085
 
1063
1086
  const stateLabels = p["stateLabels"] as Raw | undefined;
1064
1087
  const escalation = finalizeEscalation(p["escalation"] as Raw | undefined);
1088
+ const arm = finalizeArm(p["arm"] as Raw | undefined);
1065
1089
  const authority = finalizeAuthority(p["authority"] as Raw | undefined);
1066
1090
  const releasePolicy = finalizeReleasePolicy(p["releasePolicy"], label, problems);
1067
1091
  const strandedTagRepos =
@@ -1183,6 +1207,7 @@ function finalizeProject(
1183
1207
  ...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }),
1184
1208
  ...(effectiveOmpSettings === undefined ? {} : { ompSettings: effectiveOmpSettings }),
1185
1209
  escalation,
1210
+ arm,
1186
1211
  authority,
1187
1212
  releasePolicy,
1188
1213
  policy,
@@ -1194,6 +1219,18 @@ function finalizeProject(
1194
1219
  };
1195
1220
  }
1196
1221
 
1222
+ /**
1223
+ * The project's arming gate (#613), complete. A config written before the key
1224
+ * existed — or one that never answered — materialises as `challenge`, the
1225
+ * authenticated round-trip every existing install already runs, so an upgrade
1226
+ * changes no behaviour. zod has already rejected anything that is not a
1227
+ * `"challenge"` / `"claim-only"` string, so this only fills the absent case.
1228
+ */
1229
+ function finalizeArm(parsed: Raw | undefined): ProjectConfig["arm"] {
1230
+ if (parsed === undefined) return { proof: DEFAULT_ARM_PROOF };
1231
+ return { proof: (parsed["proof"] as ArmProof | undefined) ?? DEFAULT_ARM_PROOF };
1232
+ }
1233
+
1197
1234
  function finalizeEscalation(parsed: Raw | undefined): ProjectConfig["escalation"] {
1198
1235
  if (parsed === undefined) {
1199
1236
  return { fallbackToIssueComment: true, orchestrator: "embedded" };