omp-conductor 0.20.0 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.20.0",
3
+ "version": "0.20.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.",
package/src/admission.ts CHANGED
@@ -270,7 +270,9 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
270
270
  * optional bold/heading markers) whose rest carries the paths as
271
271
  * backtick-delimited spans (the brief form), with a bare
272
272
  * comma/space-separated fallback that keeps tokens that look like relative
273
- * paths.
273
+ * paths. The paths are the *leading* backticked run: a comma/space-separated
274
+ * list at the line's start, and any backticked path later on the line is
275
+ * prose, not a write target (#1073).
274
276
  * - The write-lane section (#825): a markdown heading "Exact write lane" (or
275
277
  * "Write lane" / "write-lane") whose immediately following bullet items
276
278
  * carry the paths — the package-floor decomposition format, where a groomed
@@ -278,7 +280,9 @@ function planUsageEscalation(project: string, plan: PlanUsageStatus): Escalation
278
280
  * the contiguous bullet run under that heading is read: a following
279
281
  * paragraph (like a "Read only:" caveat) or a later section ends it, so
280
282
  * read-only entry points, proof commands and acceptance bullets elsewhere in
281
- * the issue are never captured.
283
+ * the issue are never captured. Each bullet contributes its leading
284
+ * backticked path run alone — a filename named in a bullet's prose (say, to
285
+ * note that it does not exist) is prose, never a lane entry (#1073).
282
286
  *
283
287
  * Inline wins when both are present — the machine-shaped sentence has held
284
288
  * since #555, and the mediated label echo shows exactly which declaration
@@ -295,20 +299,59 @@ export function laneDeclaration(text: string): LaneDeclaration | undefined {
295
299
  return inlineLaneDeclaration(text) ?? sectionLaneDeclaration(text);
296
300
  }
297
301
 
302
+ /** One declared surface's paths (#1073). The canonical spelling is a leading
303
+ * run of backticked paths — a comma/space-separated list at the surface's
304
+ * start. A backticked path later in the same surface is prose, not part of
305
+ * the lane: a parenthetical like "(there is no `settle-pass.test.ts`)" is a
306
+ * sentence for the reader, and reading it as a write target makes a correct
307
+ * issue permanently refuse. A surface with no leading backticked path keeps
308
+ * the fallback: every backticked pathlike token, else bare
309
+ * comma/space-separated pathlike tokens (the unbackticked spelling). */
310
+ function writeLanePaths(text: string): string[] {
311
+ const fromRun = leadingBacktickedRun(text).filter(isPathLike);
312
+ if (fromRun.length > 0) return fromRun;
313
+ const backticked = [...text.matchAll(/`([^`]+)`/g)]
314
+ .map((m) => m[1]!.trim())
315
+ .filter(isPathLike);
316
+ return backticked.length > 0
317
+ ? [...new Set(backticked)]
318
+ : [...new Set(text.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike))];
319
+ }
320
+
321
+ /** The leading backticked run of a surface (or bullet): consecutive
322
+ * backtick-delimited tokens separated by commas or whitespace, starting at
323
+ * the first character. A run is a *list*; the first non-backtick,
324
+ * non-separator character ends it, so "`a.ts`, `b.ts`" yields both while
325
+ * "`a.ts` — see `b.ts`" yields only `a.ts`. Empty when the surface does not
326
+ * open with a backtick. */
327
+ function leadingBacktickedRun(text: string): string[] {
328
+ const trimmed = text.trim();
329
+ if (!trimmed.startsWith("`")) return [];
330
+ const run: string[] = [];
331
+ let pos = 0;
332
+ while (true) {
333
+ if (trimmed[pos] !== "`") break;
334
+ const close = trimmed.indexOf("`", pos + 1);
335
+ if (close < 0) break;
336
+ const token = trimmed.slice(pos + 1, close).trim();
337
+ if (token === "") break;
338
+ run.push(token);
339
+ pos = close + 1;
340
+ const sep = /^[,\s]+/.exec(trimmed.slice(pos));
341
+ if (sep === null) break;
342
+ pos += sep[0].length;
343
+ }
344
+ return run;
345
+ }
346
+
298
347
  /** The inline `File lane:`/`File-lane=` sentence grammar (see {@link laneDeclaration}). */
299
348
  function inlineLaneDeclaration(text: string): LaneDeclaration | undefined {
300
349
  const match = text.match(
301
- /^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*([^\n]*)$/im,
350
+ /^\s*(?:[#>*-]\s*)*file[- ]lane\s*[:=]\s*(?:(?:\*\*|\*|__|_)\s*)?([^\n]*)$/im,
302
351
  );
303
352
  if (match === null) return undefined;
304
353
  const rest = match[1] ?? "";
305
- const backticked = [...rest.matchAll(/`([^`]+)`/g)]
306
- .map((m) => m[1]!.trim())
307
- .filter(isPathLike);
308
- const files =
309
- backticked.length > 0
310
- ? [...new Set(backticked)]
311
- : [...new Set(rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike))];
354
+ const files = writeLanePaths(rest);
312
355
  if (files.length === 0) return undefined;
313
356
  return { files, source: match[0].trim() };
314
357
  }
@@ -351,10 +394,11 @@ function sectionLaneDeclaration(text: string): LaneDeclaration | undefined {
351
394
  const marker = line.match(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?/);
352
395
  if (marker === null) break;
353
396
  const rest = line.slice(marker[0].length);
354
- const backticked = [...rest.matchAll(/`([^`]+)`/g)]
355
- .map((m) => m[1]!.trim())
356
- .filter(isPathLike);
357
- files.push(...(backticked.length > 0 ? backticked : rest.split(/[,\s]+/).map((s) => s.trim()).filter(isPathLike)));
397
+ // One bullet contributes its leading backticked path run — what it
398
+ // declares it writes, not every backticked token its prose mentions.
399
+ // A bullet without a leading backticked path keeps the fallback
400
+ // (bare pathlike tokens), so unbackticked lanes are unaffected.
401
+ files.push(...writeLanePaths(rest));
358
402
  bullets.push(line.trim());
359
403
  }
360
404
  if (files.length === 0) return undefined;
@@ -5,9 +5,12 @@
5
5
  * orchestrator-workflow redesign).
6
6
  *
7
7
  * `armTicks` (fleet.ts) sends a short-lived `FLEET-…` code to the operator and
8
- * files the challenge here. Verification is a second, mechanical CLI step:
9
- * `omp-conductor arm --reply "<the operator's message>"` classifies the
10
- * message against these records and arms the projects the challenge named.
8
+ * files the challenge here. Verification classifies the operator's message
9
+ * against these records and arms the projects the challenge named — either
10
+ * mechanically in the orchestrator session, which owns the project topic the
11
+ * challenge is sent to (#1061), or through the CLI step
12
+ * `omp-conductor arm --reply "<the operator's message>"` from a console host
13
+ * whose DM the operator answered in.
11
14
  *
12
15
  * Nothing waits for the reply any more. The console session owns the operator
13
16
  * DM, and no tick extension runs there — so the in-session acknowledgement
@@ -498,3 +501,51 @@ export function observeArmChallenge(project: string | undefined): ArmChallengeSi
498
501
  ...(ack === undefined ? {} : { acknowledgedAt: ack.acknowledgedAt }),
499
502
  };
500
503
  }
504
+
505
+ /** One expired, unsettled ceremony, with the key the notice clears it under. */
506
+ export interface ExpiredArmChallenge {
507
+ /**
508
+ * The record's own state key — the project key or {@link FLEET_ARM_KEY} —
509
+ * passed straight back to {@link clearArmTransaction} after the notice, so
510
+ * the clear is addressed to the record that actually expired.
511
+ */
512
+ key: string;
513
+ /** The expired transaction's id. */
514
+ id: string;
515
+ /** The sighting, acknowledgement included when a reply was seen. */
516
+ sighting: ArmChallengeSighting;
517
+ }
518
+
519
+ /**
520
+ * Every pending challenge that is no longer a proof — the project's own record
521
+ * and the fleet-wide one, in the same consultation order as
522
+ * {@link resolveArmReply} — whose window has passed without being settled.
523
+ * An acknowledgement may already exist (the reply was seen but the settle
524
+ * never completed), which is still unsettled: the notice must name that
525
+ * reason, and {@link doctor} does the same. This is what lets the fleet
526
+ * session tell the operator a ceremony died instead of letting the record sit
527
+ * invisible until the next arm replaces it.
528
+ */
529
+ export function expiredArmChallenges(
530
+ project: string | undefined,
531
+ now: number,
532
+ ): ExpiredArmChallenge[] {
533
+ const keys = projectKey(project) === FLEET_ARM_KEY ? [FLEET_ARM_KEY] : [projectKey(project), FLEET_ARM_KEY];
534
+ const expired: ExpiredArmChallenge[] = [];
535
+ for (const key of keys) {
536
+ const pending = readPendingFor(key);
537
+ if (pending === undefined || pending.expiresAt > now) continue;
538
+ const ack = readArmAcknowledgement(pending.id);
539
+ expired.push({
540
+ key,
541
+ id: pending.id,
542
+ sighting: {
543
+ id: pending.id,
544
+ ...(typeof pending.sentAt === "number" ? { sentAt: pending.sentAt } : {}),
545
+ ...(typeof pending.expiresAt === "number" ? { expiresAt: pending.expiresAt } : {}),
546
+ ...(ack === undefined ? {} : { acknowledgedAt: ack.acknowledgedAt }),
547
+ },
548
+ });
549
+ }
550
+ return expired;
551
+ }
@@ -156,13 +156,18 @@ same turn — an approved answer is work to execute, not a proposal to re-open.
156
156
 
157
157
  ## Arm ceremony
158
158
 
159
- Ticks are gated on an operator-owned marker, and the console runs the ceremony
160
- that writes it. Two mechanical steps, and nothing waits anywhere:
159
+ Ticks are gated on an operator-owned marker, and something real always owns
160
+ the ceremony that writes it. Two mechanical halves, and nothing waits
161
+ anywhere:
161
162
 
162
163
  1. `omp-conductor arm [--project {{PROJECT}}]` — records the challenge, sends it
163
- to the operator, and returns immediately, naming the exact follow-up command.
164
- It writes no marker.
165
- 2. `omp-conductor arm --reply "<the operator's message, verbatim>" [--project
164
+ to the project topic (this fleet's own chat), and returns immediately,
165
+ naming the exact follow-up command. It writes no marker.
166
+ 2. A reply settles the ceremony. A reply sent in the project topic is consumed
167
+ mechanically by the orchestrator session itself — the challenge is sent to
168
+ the topic that session claims, so on a host with no console session the
169
+ ceremony still completes on its own. A reply sent here in the DM is yours:
170
+ `omp-conductor arm --reply "<the operator's message, verbatim>" [--project
166
171
  {{PROJECT}}]` — classifies that reply and, on a match, writes the marker for
167
172
  the targets the ceremony recorded.
168
173
 
@@ -4,10 +4,11 @@
4
4
  * `arm` files a challenge, sends it, and returns immediately naming the
5
5
  * follow-up command. `arm --reply "<the operator's message>"` verifies that
6
6
  * message and writes the arm marker for exactly the projects the challenge
7
- * recorded. Nothing waits anywhere: the operator's reply lands in the console
8
- * session, which runs no tick extension, so the in-session acknowledgement wait
9
- * this replaces could never be satisfied (phase 1 of the orchestrator-workflow
10
- * redesign).
7
+ * recorded. Nothing waits anywhere: a reply sent to the project topic is
8
+ * consumed mechanically by the orchestrator pane itself (#1061), and this
9
+ * command remains the console's route for a reply that landed in the operator
10
+ * DM — the in-session acknowledgement wait this replaced could never be
11
+ * satisfied, because the console session runs no tick extension.
11
12
  *
12
13
  * Both halves print copy-pasteable commands, because the reader is usually an
13
14
  * agent in a console pane rather than a human at a prompt.
@@ -95,7 +96,8 @@ function challengeReceipt(sent: ArmChallengeSent): string {
95
96
  return (
96
97
  `CHALLENGE SENT — a code went to owner ${sent.owner}, valid for ${sent.validFor} ` +
97
98
  `(challenge ${sent.challengeId}).\n` +
98
- `NOTHING IS ARMED YET. When the operator replies, run:\n` +
99
+ `NOTHING IS ARMED YET. A reply in the challenge's chat settles it automatically; ` +
100
+ `on a console host run:\n` +
99
101
  ` ${sent.followUp}\n` +
100
102
  `That reply will arm ${String(sent.targets.length)} project(s):\n` +
101
103
  sent.targets
@@ -245,14 +245,23 @@ export async function handleToSpecGrooming(
245
245
  at: Date.now(),
246
246
  });
247
247
 
248
- // The scout's answer, unjudged, through the one validator. Its report stands
249
- // in when it produced no answer at all, so the refusal names what went wrong
250
- // (an unresolvable role, a harness that would not start) instead of recording
251
- // an empty string nobody can diagnose.
248
+ // The scout's answer, unjudged, through the one validator (#1064). Only the
249
+ // structured payload is graded: a session that produced no payload at all is
250
+ // its own refusal class (`no-answer`), with the narration recorded as
251
+ // context, and a payload recovered from the text carries its `via` marker so
252
+ // the record says it did not come through the yield contract. `raw: ""`
253
+ // therefore means "no answer", and the pass never grades `result.report` as
254
+ // if it were one.
252
255
  const outcome = recordToSpecGrooming(d.store, {
253
256
  project: project.name,
254
257
  issue,
255
- input: result.raw === "" ? result.report : result.raw,
258
+ input: result.raw,
259
+ via: result.via === "text" ? "text" : result.raw === "" ? undefined : "yield",
260
+ report: result.report,
261
+ repaired: result.repaired,
262
+ killedAtCeiling: result.killedAtCeiling,
263
+ maxTurns: TO_SPEC_MAX_TURNS,
264
+ turns: result.turns,
256
265
  launchedAt: batch.launchedAt,
257
266
  });
258
267
  const ran = result.model === undefined ? "" : ` by ${result.model}`;
@@ -261,9 +270,10 @@ export async function handleToSpecGrooming(
261
270
  // stores and what the stats lane renders.
262
271
  const cost =
263
272
  result.turns > 0 && result.spendUsd === 0 ? "unmetered" : `$${result.spendUsd.toFixed(2)}`;
273
+ const repairNote = result.repaired === true ? " (repaired)" : "";
264
274
  log(
265
275
  `#${issue} groomed ${outcome.record.verdict}(${outcome.record.reason})${ran} in batch ${batch.id} — ` +
266
- `${result.turns} turn(s), ${cost}`,
276
+ `${result.turns} turn(s), ${cost}${repairNote}`,
267
277
  );
268
278
  if (outcome.kind !== "persisted" || outcome.record.verdict !== "promotable") return;
269
279
  await promoteGroomedVerdict(d, outcome.record);
@@ -33,7 +33,7 @@ import { reconcileOrphanedRuns } from "../settlement.ts";
33
33
  import { dbPath, openStore, utcDay } from "../store.ts";
34
34
  import { checkTelegramFreshness } from "../telegram-freshness.ts";
35
35
  import { GraphqlBreaker, makeTracker } from "../tracker/github.ts";
36
- import { RELEASE_SHAPES, type ProjectConfig, type ResolvedGrants, type RunRecord } from "../types.ts";
36
+ import { RELEASE_SHAPES, type Escalation, type ProjectConfig, type ResolvedGrants, type RunRecord } from "../types.ts";
37
37
  import { runCommand } from "../upgrade-verify.ts";
38
38
  import { inspectSurfaces } from "../upgrade.ts";
39
39
  import { sharedUsageSource } from "../usage.ts";
@@ -203,6 +203,54 @@ export async function runDispatchLoop(o: DispatchLoopOptions): Promise<void> {
203
203
  }
204
204
  }
205
205
 
206
+ /** What the daemon knows about its tier-1 orchestrator at diversion time. */
207
+ export interface Tier1DiversionFacts {
208
+ /** The project's configured tier-1 transport is the external orchestrator
209
+ * session: issue comments are the *configured* destination, not a
210
+ * fallback. */
211
+ external: boolean;
212
+ /** Why the daemon-owned orchestrator session failed to start, when it did. */
213
+ startError?: string;
214
+ /** Whether the daemon-owned orchestrator session is alive right now
215
+ * (`OrchestratorHandle.alive()` — event-driven, false once the session
216
+ * exited). */
217
+ alive: boolean;
218
+ /** Whether the daemon-owned orchestrator session is mid-turn right now
219
+ * (`OrchestratorHandle.busy()`). */
220
+ busy: boolean;
221
+ }
222
+
223
+ /**
224
+ * The one-line record of a tier-1 escalation that landed on the issue-comment
225
+ * fallback instead of the orchestrator injection (#1068).
226
+ *
227
+ * "while the orchestrator was down" is reserved for what it actually means —
228
+ * a session that failed to start or one that has exited. The #1062 shape was
229
+ * neither: the orchestrator was mid-tick, its session did not take the
230
+ * injection, and a reader of "diverted … while the orchestrator was down"
231
+ * went looking for a dead session that did not exist. When the session is
232
+ * alive the line says where the escalation was delivered and why it was not
233
+ * injected; when the project runs an external orchestrator the comments *are*
234
+ * the configured transport and the line says so instead of calling it a
235
+ * diversion.
236
+ */
237
+ export function tier1DiversionLine(e: Escalation, facts: Tier1DiversionFacts): string {
238
+ const ref = escalationIssueRef(e.issue);
239
+ if (facts.external) {
240
+ return `orchestrator: tier-1 escalation on ${ref} posted as an issue comment (external orchestrator — the configured tier-1 transport)`;
241
+ }
242
+ if (facts.startError !== undefined || !facts.alive) {
243
+ return (
244
+ `orchestrator: tier-1 escalation on ${ref} diverted to issue comments while the orchestrator was down` +
245
+ (facts.startError !== undefined ? ` (start failed: ${facts.startError})` : " (session exited)")
246
+ );
247
+ }
248
+ return (
249
+ `orchestrator: tier-1 escalation on ${ref} posted as an issue comment — the orchestrator session ` +
250
+ `is alive but did not take the injection${facts.busy ? " (mid-turn)" : ""}`
251
+ );
252
+ }
253
+
206
254
  export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
207
255
  // A `--once` tick is still a dispatcher: it settles rows, projects labels,
208
256
  // admits and launches workers, so it must hold the same exclusive daemon/
@@ -391,8 +439,12 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
391
439
  // the outage diverted (#288).
392
440
  store.bumpOrchestratorDiverted(project.name, 1);
393
441
  projectLog(
394
- `orchestrator: tier-1 escalation on ${escalationIssueRef(e.issue)} diverted to issue comments ` +
395
- `while the orchestrator was down`,
442
+ tier1DiversionLine(e, {
443
+ external: project.escalation.orchestrator === "external",
444
+ startError: orchestratorStartError,
445
+ alive: orchestrator?.alive() ?? false,
446
+ busy: orchestrator?.busy() ?? false,
447
+ }),
396
448
  );
397
449
  },
398
450
  );
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { infraLogSignature, infraSignatureVersion } from "../failure-class.ts";
18
18
  import { errText, log, safeEscalate } from "../log.ts";
19
+ import { GhPrMissingError } from "../tracker/github.ts";
19
20
  import type { BaseHealth, RunRecord, SettlementFlag, WorkflowRun } from "../types.ts";
20
21
  import { cleanupRetainedWorktree, mirrorPathFor, type RetainedWorktreeCleanup } from "../worktree.ts";
21
22
  import { githubRepo, type Deps, type RetainedCleanupCursor } from "./deps.ts";
@@ -393,8 +394,24 @@ export async function cleanupRetainedRuns(
393
394
  terminal = issue === "closed";
394
395
  }
395
396
  } catch (err) {
396
- log(`#${run.issue} retained cleanup deferred: tracker state failed (${errText(err)})`);
397
- continue;
397
+ // The tracker throws exactly one classified error: `GhPrMissingError`,
398
+ // an individual REST 404 it corroborated with a same-repository
399
+ // pulls-list read, so the claimed PR definitively does not exist
400
+ // (#779). That is a fact, not "could not tell": retrying can never
401
+ // conjure the PR, so the missing PR is terminal and the retained row
402
+ // settles in this same pass like a closed one (#1065). The row's
403
+ // phantom URL is cleared with the settlement so a repeat pass neither
404
+ // asks GitHub about it again nor logs about it again. Everything else —
405
+ // a rate limit, a 5xx, a lost network — keeps the deferred retry below,
406
+ // unchanged.
407
+ if (err instanceof GhPrMissingError) {
408
+ store.updateRun(run.id, { prUrl: null });
409
+ log(`#${run.issue} retained cleanup settled: PR ${run.prUrl} does not exist`);
410
+ terminal = true;
411
+ } else {
412
+ log(`#${run.issue} retained cleanup deferred: tracker state failed (${errText(err)})`);
413
+ continue;
414
+ }
398
415
  }
399
416
  if (!terminal) continue;
400
417
 
package/src/daemon.ts CHANGED
@@ -113,8 +113,8 @@ export { daemonHttpResponse, turnLimitResponse, workerControlResponse } from "./
113
113
 
114
114
  export { mineIntakeSignals, runDbSnapshotCadence, tick, upgradeVerifyDepsFor } from "./daemon/tick.ts";
115
115
 
116
- export type { DispatchLoopOptions, DispatchPace } from "./daemon/runtime.ts";
117
- export { createDispatchPace, orchestratorStandingOrders, runDaemon, runDispatchLoop } from "./daemon/runtime.ts";
116
+ export type { DispatchLoopOptions, DispatchPace, Tier1DiversionFacts } from "./daemon/runtime.ts";
117
+ export { createDispatchPace, orchestratorStandingOrders, runDaemon, runDispatchLoop, tier1DiversionLine } from "./daemon/runtime.ts";
118
118
 
119
119
  // ------------------------------------------------------------------- pause
120
120
  // The sentinel itself lives in `pause.ts` (#938): this module imports
package/src/diff-flags.ts CHANGED
@@ -438,20 +438,125 @@ const ASSERTION =
438
438
  /^(?:await\s+)?(?:expect|assert|assert_[a-z_]+|assertEquals?|assertTrue|assertFalse|assertThat|assertRaises|assertRaisesRegex|self\.assert[A-Za-z]*|should|chai\.|t\.(?:Error|Fatal)f?|require\.[A-Z][A-Za-z]*|Expect)\s*[.(]/;
439
439
 
440
440
  /**
441
- * A named timeout and its value. Only a *raised* one is a finding — a brand-new
442
- * timeout on a new test is not a weakening — so the value is compared against
443
- * the same key on the pre-image side and silence is the answer whenever the key
444
- * appears on only one side.
441
+ * A named timeout and its value, but only on the test runner's own timeout
442
+ * surface ({@link onRunnerSurface}) a matching key handed to the code under
443
+ * test is a domain parameter, not a runner deadline (#1062). Only a *raised*
444
+ * one is a finding — a brand-new timeout on a new test is not a weakening — so
445
+ * the value is compared against the same key on the pre-image side and silence
446
+ * is the answer whenever the key appears on only one side.
445
447
  */
446
448
  const TIMEOUT =
447
- /\b(timeout|timeoutMs|timeout_ms|timeoutSeconds|deadline|maxDuration|wallClock(?:Ms)?|setTimeout|jest\.setTimeout|retries)\b\s*[:=(]\s*(\d[\d_]*)/gi;
449
+ /\b(timeout|timeoutMs|timeout_ms|timeoutSeconds|deadline|maxDuration|wallClock(?:Ms)?|setTimeout|jest\.setTimeout|retries)\b\s*([:=(])\s*(\d[\d_]*)/gi;
450
+
451
+ /** Identifiers that name the test runner itself when a timeout-shaped key is
452
+ * called on or passed to them. `t` is the test context (vitest, node:test, a
453
+ * Go `*testing.T` helper); `pytest` names the Python runner module. */
454
+ const RUNNER_BINDINGS: Record<string, true> = {
455
+ test: true,
456
+ it: true,
457
+ describe: true,
458
+ context: true,
459
+ suite: true,
460
+ bench: true,
461
+ jest: true,
462
+ t: true,
463
+ pytest: true,
464
+ };
465
+
466
+ /**
467
+ * Whether a {@link TIMEOUT} match sits on the runner's own timeout surface of
468
+ * its line, as opposed to an argument handed to the code under test.
469
+ *
470
+ * The audit sees hunks, not files, so the judgement is structural on the
471
+ * changed line alone, and anything it cannot vouch for stays silent: a flag is
472
+ * advisory, and a false positive costs the trust in every flag after it
473
+ * (measured: #614/#896 read a raised `timeoutMs` passed to `arm(...)` — the
474
+ * challenge window of the code under test — as a weakened test).
475
+ *
476
+ * Two positions qualify, both on the runner's own call:
477
+ *
478
+ * - the key is called on the runner — `jest.setTimeout(...)`,
479
+ * `t.timeout(...)`, `test.setTimeout(...)`, `pytest.mark.timeout(...)` —
480
+ * the dotted receiver before the key starts with a runner binding;
481
+ * - the key is a property of an object literal that is a direct argument of a
482
+ * runner call — the trailing per-test configuration: `test("x", fn, {
483
+ * timeout: 45_000 })`, `test.use({ retries: 3 })`,
484
+ * `describe.configure({ retries: 3 })`.
485
+ *
486
+ * Everything else is the code under test: `await arm({ timeoutMs: 60_000 })`,
487
+ * `const opts = { timeout: 45000 }`, `server.setTimeout(30_000)`. A shape
488
+ * whose decisive frame is on another line — `}, { timeout: 45000 });` after a
489
+ * multi-line `test(` — cannot be vouched for from the line alone and also
490
+ * stays silent.
491
+ */
492
+ function onRunnerSurface(code: string, match: RegExpMatchArray): boolean {
493
+ const key = match[1]?.toLowerCase() ?? "";
494
+ if (RUNNER_BINDINGS[key.split(".")[0] ?? ""] === true) return true;
495
+
496
+ const before = code.slice(0, match.index);
497
+ // The dotted receiver the key is called on: `t.timeout(5000)` reads `t.`,
498
+ // `pytest.mark.timeout(500)` reads `pytest.mark.`. No receiver — a bare
499
+ // `timeout: 5000` property — falls through to the config-object rule.
500
+ let at = before.length - 1;
501
+ while (at >= 0 && /[A-Za-z0-9_$.]/.test(before[at] ?? "")) at--;
502
+ const receiver = before.slice(at + 1).replace(/\.$/, "").split(".")[0];
503
+ if (RUNNER_BINDINGS[receiver ?? ""] === true) return true;
504
+
505
+ // A property key (`key: value`) needs the object around it to be the
506
+ // runner's own configuration; a `key = value` assignment or a bare
507
+ // `key(5000)` call on the line is never that.
508
+ if (match[2] !== ":") return false;
509
+ return inRunnerConfigObject(before);
510
+ }
511
+
512
+ type RunnerFrame = { kind: "call"; base: string | undefined } | { kind: "obj" };
513
+
514
+ /** Whether the key sits in an object literal that is a direct argument of a
515
+ * call on a runner binding — the trailing per-test configuration:
516
+ * `test("x", fn, { timeout: 45_000 })`, `test.use({ retries: 3 })`,
517
+ * `describe.configure({ retries: 3 })`. The frames are walked over the line
518
+ * prefix only, with strings already stripped by {@link splitCode}, so the
519
+ * object's nesting inside the call — a direct argument versus a property of
520
+ * a nested object or of the callback's own body — decides the verdict. */
521
+ function inRunnerConfigObject(before: string): boolean {
522
+ const frames: RunnerFrame[] = [];
523
+ for (let at = 0; at < before.length; at++) {
524
+ const ch = before[at];
525
+ if (ch === "(") {
526
+ // The dotted name the call was opened on, if any: `test.use(` reads
527
+ // `test.use`. No name — an arrow's parameter list, or a call on a
528
+ // previous call's result — means the object below it is not a runner
529
+ // surface.
530
+ let end = at;
531
+ while (end > 0 && /[A-Za-z0-9_$.]/.test(before[end - 1] ?? "")) end--;
532
+ const chain = before.slice(end, at);
533
+ frames.push({
534
+ kind: "call",
535
+ base: chain.length > 0 && !chain.endsWith(".") ? chain : undefined,
536
+ });
537
+ continue;
538
+ }
539
+ if (ch === "{" || ch === "[") {
540
+ frames.push({ kind: "obj" });
541
+ continue;
542
+ }
543
+ if (ch === ")" || ch === "}" || ch === "]") frames.pop();
544
+ }
545
+ const object = frames.at(-1);
546
+ if (object?.kind !== "obj") return false;
547
+ const enclosing = frames.at(-2);
548
+ if (enclosing?.kind !== "call") return false;
549
+ const firstSegment = enclosing.base?.split(".")[0];
550
+ return firstSegment !== undefined && RUNNER_BINDINGS[firstSegment] === true;
551
+ }
448
552
 
449
553
  function timeouts(text: string): { key: string; value: number }[] {
450
554
  const found: { key: string; value: number }[] = [];
451
555
  for (const match of text.matchAll(TIMEOUT)) {
452
556
  const key = match[1]?.toLowerCase();
453
- const raw = match[2]?.replaceAll("_", "");
557
+ const raw = match[3]?.replaceAll("_", "");
454
558
  if (key === undefined || raw === undefined) continue;
559
+ if (!onRunnerSurface(text, match)) continue;
455
560
  const value = Number(raw);
456
561
  if (Number.isSafeInteger(value)) found.push({ key, value });
457
562
  }
package/src/doctor.ts CHANGED
@@ -1345,8 +1345,8 @@ function armAckProbe(probes: Probes, p: ProjectConfig): Finding {
1345
1345
  if (sighting.expiresAt !== undefined && now >= sighting.expiresAt) {
1346
1346
  return warnFinding(
1347
1347
  "arm-ack",
1348
- `[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused), but it lingers until this project's next arm replaces it`,
1349
- `no action needed; the next \`arm\` for this project settles it — re-run doctor afterwards to confirm`,
1348
+ `[${p.name}] an arming challenge from ${window} expired without being settled — it is inert (replies past expiry are refused); the fleet session notifies the operator and clears it once its notice goes out, or the next arm replaces it`,
1349
+ `the code is dead no reply to it can arm. Re-run the ceremony: \`omp-conductor arm --project ${p.name}\``,
1350
1350
  );
1351
1351
  }
1352
1352
  if (sighting.acknowledgedAt !== undefined) {