omp-conductor 0.18.1 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -316,7 +316,7 @@ Two words, and one of them takes a flag:
316
316
  | `stop` | paused | disarmed | stopped (systemctl-aware) | left running |
317
317
  | `stop --pane` | paused | disarmed | stopped | stopped + recovery pinned off |
318
318
 
319
- `resume` clears pause **and** any `stop --pane` recovery pin, and **never re-arms**. `arm` is proof-gated: it sends a Telegram challenge and writes the arm marker only after your reply appears as a *user* turn in the orchestrator transcript. `stop --pane` targets the configured conductor agent only — it does **not** run `systemctl stop herdr-fleet`. To bounce the daemon without stopping the fleet, use `restart`.
319
+ `resume` clears pause **and** any `stop --pane` recovery pin, and **never re-arms**. `arm` is proof-gated: it sends a Telegram challenge and writes the arm marker only after the orchestrator's inbound adapter has acknowledged your reply in conductor's own state — no transcript is read, so where (or whether) a session file lives does not matter (#614). `stop --pane` targets the configured conductor agent only — it does **not** run `systemctl stop herdr-fleet`. To bounce the daemon without stopping the fleet, use `restart`.
320
320
 
321
321
  **Removed in 0.15.0**, each exiting `2` with a pointer: `halt` (now `stop`), `pause` (use `hold`), `release-pane` (now part of `resume`), and `graph-setup` (now [`setup graph`](https://github.com/TerrifiedBug/conductor/blob/main/omp/REFERENCE.md#code-graph-discovery)). Dropping `pause` cost one real capability, "stop claiming but keep ticking", which is now `hold --keep-ticks` rather than a fifth verb. It matters because disarming is the expensive half of a hold: re-arming sends a Telegram challenge and blocks until you answer it in the chat, so stopping claims for ten minutes otherwise costs a manual round trip to get the heartbeat back — while the workers a hold deliberately leaves running have nothing shepherding them.
322
322
 
package/REFERENCE.md CHANGED
@@ -2317,7 +2317,7 @@ omp-conductor help
2317
2317
  | `hold [--keep-ticks] [--project NAME]` | fleet | Soft stop: pause claiming **and** disarm ticks. Daemon and pane stay up. Prefer this when the intent is "stop the conductor" without killing processes. `--keep-ticks` pauses claiming but leaves the arm marker, so the heartbeat keeps reporting and `resume` alone restores the fleet — no fresh arm challenge. See [Stop the conductor](README.md#stop-the-conductor-hold--stop). |
2318
2318
  | `drain start --until ISO\|DURATION [--reason TEXT]` / `drain status` / `drain cancel` | project | Start, inspect, or cancel the project's self-expiring admission fence (#484): a durable, bounded alternative to queue-label churn before a release. `start` writes the project's drain record through the landed `createDrain` — new claims pause while active runs settle, and admission resumes automatically at the absolute deadline even if the orchestrator dies. A successful mediated `conductor_release` also clears the drain once the terminal release act completes — version-bump preparation stays latched until the tag is actually cut — so the release window ends with the release itself rather than latching until the deadline (#791). `--until` takes an ISO instant or a relative duration (`90s`, `45m`, `2h`, `1d`) that must be bounded and in the future; a missing, unparseable, unbounded, or past expiry exits `2` before any state changes. `--reason` (1–500 characters) is persisted on the record. `status` reports inactive, or the active drain's creation time, absolute expiry, reason, and remaining active runs from the structured status snapshot. `cancel` removes only the named project's drain and is idempotent. The drain never touches the pause sentinel, the arm marker, or any queue label — it is a file record, not a hold. |
2319
2319
  | `stop [--pane] [--project NAME]` | fleet | Stop the conductor: pause claiming, disarm ticks, then stop the dispatch daemon (systemctl-aware). Pane stays up unless `--pane` is passed. `stop --pane` also pins herdr-conductor recovery off for the conductor agent only — it does **not** stop `herdr-fleet.service` or any other herdr session. Fail-closed: exits nonzero unless the agent is proven gone. To bounce the daemon without stopping the fleet, use `restart`. |
2320
- | `arm [--project NAME]` | fleet | Proof-gated: send a Telegram challenge and write this project's arm marker only after your reply appears as a user turn in the orchestrator transcript. The challenge names the project, so a host running two fleets is not ambiguous. Never auto-armed by `resume` / `hold`. |
2320
+ | `arm [--project NAME]` | fleet | Proof-gated: send a Telegram challenge and write this project's arm marker only after the orchestrator's inbound adapter has acknowledged your reply in conductor's own state — no transcript is read, so where (or whether) a session file lives does not matter (#614). The challenge names the project, so a host running two fleets is not ambiguous. Never auto-armed by `resume` / `hold`. |
2321
2321
  | `disarm [--project NAME]` | fleet | Remove this project's arm marker so its ticks skip; another project's ticks keep running. Also clears a pre-per-project shared `armed` marker while that marker is still what holds this fleet's gate open — otherwise the disarm would not disarm. Processes untouched. |
2322
2322
  | `tail <issue>` | project | Follow the newest run for that issue: the worker's assistant text as `assistant: …` and each tool it calls as `tool: <name>`, printed as they land. Workers are omp sessions inside the daemon rather than terminals, so this is the only way to watch one live — a herdr pane running it becomes an observation window. Starts from the top of the transcript, not the end, so attaching to a run that is already ten turns in shows those ten turns. Exits `1` with `no run recorded for #N` when the issue has never been dispatched, or `no transcript yet (state: …)` when the attempt has not opened one. Otherwise it runs until `Ctrl-C`, or until the run has finished and its transcript has been silent for five seconds, and prints `run ended: <state>`. |
2323
2323
  | `extend <issue> --turns N [--project NAME]` | project | Raise a live worker's effective turn ceiling through its owning daemon without restarting its session. If the latest run is failed, killed, orphaned, or blocked and has no live controller, store a one-shot ceiling for that issue's next claimed attempt instead. A next-attempt value must exceed the project base, every extension must stay at or below `workerMaxTurnsCeiling`, and live extensions remain monotonic. The pending value appears in `status`, is recorded in `ledger`, and is consumed atomically by one claim. |
package/agents/to-spec.md CHANGED
@@ -50,6 +50,9 @@ required and no extra keys are accepted:
50
50
  exact ref, and `freshAt` = epoch milliseconds when you actually observed it.
51
51
  Conductor refuses results whose source is older than 24 hours or missing
52
52
  name/ref/freshAt — an unsourced verdict is not grooming, it is prose.
53
+ - `evidence` — the files/symbols that prove the verdict. Required for
54
+ `ALREADY DONE`: name the symbol/file that already does the work, never a
55
+ title match. Welcome on every other verdict.
53
56
  - `laterWorkInvalidates` — boolean: did later work (an epic committed after
54
57
  this candidate was filed) retire its premise?
55
58
  - `laterWorkNote` — what you searched for that check and what you found. Even
@@ -63,8 +66,9 @@ required and no extra keys are accepted:
63
66
  implementing, and how to prove it is not.
64
67
  - `proofCommands` — the focused commands that prove the work, each with its
65
68
  `cwd` when it matters.
66
- - `fileLane` — the files and directories this slice writes; `dependencies` —
67
- open prerequisites, `[]` when none.
69
+ - `fileLane` — the files and directories this slice writes.
70
+ - `dependencies` — open prerequisite issue numbers, each a bare number
71
+ (`875`) or a string (`"875"`); `[]` when none.
68
72
  - `proposedBrief` — required iff `verdict` is `PROMOTABLE`: the brief a worker
69
73
  would be dispatched with, including the silent fake and the proof commands.
70
74
  - `reasonNotToPromote` — required for every other verdict: why this must not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
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.",
@@ -387,10 +387,15 @@
387
387
  "type": "integer",
388
388
  "minimum": 1,
389
389
  "maximum": 6
390
+ },
391
+ "adjudicator": {
392
+ "default": "task",
393
+ "type": "string",
394
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
390
395
  }
391
396
  },
392
397
  "additionalProperties": false,
393
- "description": "Review strictness and round ceiling for green PRs"
398
+ "description": "Review strictness, round ceiling and adjudicator role for green PRs"
394
399
  },
395
400
  "authority": {
396
401
  "type": "object",
@@ -1,69 +1,154 @@
1
1
  /**
2
2
  * Authenticated pending-challenge state for the arming handshake (conductor
3
- * #415).
3
+ * #415, transaction reworked by #614, storage hardened by review of #896).
4
4
  *
5
5
  * `armTicks` (fleet.ts) sends a short-lived `FLEET-…` code to the operator and
6
- * proves the reply by scanning the orchestrator session transcript. That reply
7
- * also lands in the orchestrator as an ordinary user turn, where the model once
8
- * ad-libbed pairing-safety prose because it had no trusted way to recognise it.
6
+ * waits for the orchestrator to acknowledge it. The acknowledgement is
7
+ * conductor-owned state: the orchestrator's inbound user-turn adapter
8
+ * (orchestrator-tick.ts) writes it the moment the real reply arrives as a user
9
+ * turn — never the model, and never a transcript scan, so the proof no longer
10
+ * depends on where (or whether) a session file lives.
9
11
  *
10
12
  * This module is the bridge between the two roles without an import cycle:
11
13
  * `fleet.ts` imports `daemon.ts`, `daemon.ts` imports `orchestrator-tick.ts`,
12
14
  * so `orchestrator-tick.ts` can never import `fleet.ts`. Both already import
13
- * `config.ts`, so the pending-challenge record lives next to the other state
14
- * under `stateDir()` and both sides reach it through *this* leaf module.
15
+ * `config.ts`, so the state lives next to the other state under `stateDir()`
16
+ * and both sides reach it through *this* leaf module.
17
+ *
18
+ * The handshake keeps no shared mutable state at all — every file is named by
19
+ * its own key, and every deletion is id-addressed, so two processes can
20
+ * neither lose each other's updates nor delete each other's proofs:
21
+ *
22
+ * - `arm-challenges/<project key>.json` — one pending challenge per project:
23
+ * `{ project, id, hash, sentAt, expiresAt }`. Host-owned: only `armTicks`
24
+ * writes it (record before the send, settle on consumption/timeout/send
25
+ * failure), always through the atomic tmp+rename write the admission ack
26
+ * uses. A re-armed project overwrites its own file — pruning only its own
27
+ * prior id's acknowledgement; a concurrent arm for a different project
28
+ * touches a different file.
29
+ * - `arm-challenge-acks/<challenge id>.json` — one file per acknowledgement:
30
+ * `{ challengeId, acknowledgedAt }`. The adapter's acknowledgement is a
31
+ * single-file create/overwrite via rename — it never reads or rewrites
32
+ * another transaction's record. Files that outlive their transaction (a
33
+ * crash between the adapter's write and the host's settle) are inert by
34
+ * construction and removed only by age, never by membership in any
35
+ * directory snapshot (see {@link gcAgedAcks}).
15
36
  *
16
37
  * Only the sha-256 of the code is ever persisted — never the code, whose
17
38
  * plaintext appearance in the protected session transcript is already the
18
- * backend proof and must not leak into a durable report, an issue comment, or a
19
- * diagnostic line the way a new artifact could. The orchestrator classifies a
20
- * reply by hashing its tokens against this record, so nothing on the east side
21
- * of the boundary trusts a `FLEET-` prefix.
39
+ * backend proof and must not leak into a durable report, an issue comment, or
40
+ * a diagnostic line the way a new artifact could. The id is a random UUID cut
41
+ * with the challenge, so acknowledgement records are challenge-specific
42
+ * without carrying anything guessable. Neither side trusts a `FLEET-` prefix:
43
+ * a reply is classified by hashing its whitespace-separated tokens against
44
+ * the pending record, so nothing on the east side of the boundary can arm a
45
+ * fleet with a lookalike.
22
46
  */
23
47
 
24
- import { createHash } from "node:crypto";
25
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
48
+ import { createHash, randomUUID } from "node:crypto";
49
+ import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
26
50
  import { dirname, join } from "node:path";
27
51
  import { stateDir } from "./config.ts";
28
52
 
29
- const ARM_CHALLENGE_FILE = "arm-challenge.json";
53
+ const ARM_CHALLENGES_DIR = "arm-challenges";
54
+ const ARM_ACKS_DIR = "arm-challenge-acks";
30
55
 
31
56
  interface PendingChallenge {
57
+ /** The project key this pending belongs to, mirrored for read-back checks. */
58
+ project: string;
59
+ /** Random transaction id, cut when this challenge was recorded. */
60
+ id: string;
32
61
  /** sha-256 hex of the challenge code — never the code itself. */
33
62
  hash: string;
63
+ /** Unix ms the challenge was recorded, just before the send. */
64
+ sentAt: number;
34
65
  /** Unix ms after which a matching reply is no longer an active proof. */
35
66
  expiresAt: number;
36
67
  }
37
68
 
38
- /** Keyed by project name; `""` is the pre-multi-project (un-named) spelling. */
39
- type PendingChallenges = Record<string, PendingChallenge>;
69
+ interface ArmAcknowledgement {
70
+ /** The exact challenge id this record satisfies. */
71
+ challengeId: string;
72
+ /** Unix ms the inbound adapter acknowledged the reply. */
73
+ acknowledgedAt: number;
74
+ }
75
+
76
+ function challengesDir(): string {
77
+ return join(stateDir(), ARM_CHALLENGES_DIR);
78
+ }
79
+
80
+ function acksDir(): string {
81
+ return join(stateDir(), ARM_ACKS_DIR);
82
+ }
40
83
 
41
- function challengesPath(): string {
42
- return join(stateDir(), ARM_CHALLENGE_FILE);
84
+ function ackPath(challengeId: string): string {
85
+ return join(acksDir(), `${challengeId}.json`);
43
86
  }
44
87
 
45
88
  function projectKey(project?: string): string {
46
89
  return project ?? "";
47
90
  }
48
91
 
49
- function readChallenges(): PendingChallenges {
92
+ /**
93
+ * Filesystem-safe, injective encoding of a project key. Raw names that need
94
+ * no escaping are used verbatim; everything else (including the empty key of
95
+ * an unstamped config) becomes `=` plus hex bytes — `=` never appears in the
96
+ * raw class, so the two namespaces cannot collide.
97
+ */
98
+ function encodeProjectKey(key: string): string {
99
+ if (/^[A-Za-z0-9._-]{1,64}$/.test(key) && key !== "." && key !== "..") return key;
100
+ return `=${[...Buffer.from(key, "utf8")].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
101
+ }
102
+
103
+ function pendingPath(key: string): string {
104
+ return join(challengesDir(), `${encodeProjectKey(key)}.json`);
105
+ }
106
+
107
+ /**
108
+ * Atomic durable write, exactly like the admission ack: content lands at a
109
+ * unique temp path and is renamed into place, so every reader — including the
110
+ * one-shot inbound adapter racing the host — sees either the old file or the
111
+ * new one, never a mid-truncate parse.
112
+ */
113
+ function writeFileAtomic(path: string, content: string): void {
114
+ mkdirSync(dirname(path), { recursive: true });
115
+ const tmp = `${path}.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`;
50
116
  try {
51
- const parsed: unknown = JSON.parse(readFileSync(challengesPath(), "utf8"));
52
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
53
- return parsed as PendingChallenges;
54
- }
55
- } catch {
56
- /* absent or unreadable — no active challenge */
117
+ // 0600 like the other conductor state an operator never shares; the
118
+ // records are hashes and random ids, but there is no reason to be less
119
+ // careful.
120
+ writeFileSync(tmp, content, { mode: 0o600 });
121
+ renameSync(tmp, path);
122
+ } catch (err) {
123
+ rmSync(tmp, { force: true });
124
+ throw err;
57
125
  }
58
- return {};
59
126
  }
60
127
 
61
- function writeChallenges(map: PendingChallenges): void {
62
- const path = challengesPath();
63
- mkdirSync(dirname(path), { recursive: true });
64
- // 0600 like the other conductor state an operator never shares; the record is
65
- // only a hash, but there is no reason to be less careful with it.
66
- writeFileSync(path, `${JSON.stringify(map)}\n`, { mode: 0o600 });
128
+ /**
129
+ * This project's pending challenge parsed from its own file, with every field
130
+ * validated. Anything absent, torn, garbage, pre-#614, or naming another
131
+ * project reads as none fail closed until the host's next transaction
132
+ * rewrites the file wholesale.
133
+ */
134
+ function readPendingFor(key: string): PendingChallenge | undefined {
135
+ let parsed: unknown;
136
+ try {
137
+ parsed = JSON.parse(readFileSync(pendingPath(key), "utf8"));
138
+ } catch {
139
+ return undefined;
140
+ }
141
+ if (parsed === null || typeof parsed !== "object") return undefined;
142
+ const pending = parsed as Partial<PendingChallenge>;
143
+ if (
144
+ pending.project !== key ||
145
+ typeof pending.id !== "string" ||
146
+ typeof pending.hash !== "string" ||
147
+ typeof pending.expiresAt !== "number"
148
+ ) {
149
+ return undefined;
150
+ }
151
+ return pending as PendingChallenge;
67
152
  }
68
153
 
69
154
  /** sha-256 hex of the challenge code — the persisted token, never the code. */
@@ -71,42 +156,150 @@ function challengeHash(code: string): string {
71
156
  return createHash("sha256").update(code).digest("hex");
72
157
  }
73
158
 
74
- /** Register a new active arming challenge, replacing any prior one for the project. */
75
- export function recordArmChallenge(project: string | undefined, code: string, expiresAt: number): void {
76
- const map = readChallenges();
77
- map[projectKey(project)] = { hash: challengeHash(code), expiresAt };
78
- writeChallenges(map);
159
+ /**
160
+ * Acknowledgement files outliving their transaction a crash between the
161
+ * adapter's write and the host's settle — are inert by construction: no
162
+ * waiter ever polls their id again, and ids are UUIDs, so no future
163
+ * transaction can collide with one. They are therefore not swept by
164
+ * membership (a directory listing captured before another process records is
165
+ * stale the moment it is taken, and acting on it deletes on-time proofs),
166
+ * only by age: a file older than {@link ACK_ORPHAN_GC_AFTER_MS} cannot belong
167
+ * to a live handshake under any configured window, so its mtime alone decides
168
+ * removal.
169
+ */
170
+ const ACK_ORPHAN_GC_AFTER_MS = 24 * 60 * 60 * 1000;
171
+
172
+ function gcAgedAcks(now: number): void {
173
+ let names: string[] = [];
174
+ try {
175
+ names = readdirSync(acksDir());
176
+ } catch {
177
+ return; /* no acknowledgements yet */
178
+ }
179
+ for (const name of names) {
180
+ if (!name.endsWith(".json")) continue;
181
+ const path = join(acksDir(), name);
182
+ try {
183
+ if (statSync(path).mtimeMs >= now - ACK_ORPHAN_GC_AFTER_MS) continue;
184
+ } catch {
185
+ continue; /* raced away */
186
+ }
187
+ rmSync(path, { force: true });
188
+ }
79
189
  }
80
190
 
81
- /** Drop the active challenge for a project once arming completed or timed out. */
82
- export function clearArmChallenge(project: string | undefined): void {
83
- const map = readChallenges();
191
+ /**
192
+ * Register a new active arming challenge for the project — overwriting the
193
+ * project's own pending file — and return the transaction id. Before the
194
+ * overwrite, the project's PRIOR id is read back and exactly that id's
195
+ * acknowledgement is pruned: project-local cleanup with no directory
196
+ * snapshot, so another project's handshake cannot lose its proof here no
197
+ * matter how the calls interleave.
198
+ */
199
+ export function recordArmChallenge(
200
+ project: string | undefined,
201
+ code: string,
202
+ sentAt: number,
203
+ expiresAt: number,
204
+ ): string {
84
205
  const key = projectKey(project);
85
- if (!(key in map)) return;
86
- delete map[key];
87
- writeChallenges(map);
206
+ // Read the prior record BEFORE the overwrite shadows it.
207
+ const prior = readPendingFor(key);
208
+ const id = randomUUID();
209
+ const record: PendingChallenge = { project: key, id, hash: challengeHash(code), sentAt, expiresAt };
210
+ writeFileAtomic(pendingPath(key), `${JSON.stringify(record)}\n`);
211
+ if (prior !== undefined) rmSync(ackPath(prior.id), { force: true });
212
+ gcAgedAcks(sentAt);
213
+ return id;
88
214
  }
89
215
 
90
216
  /**
91
- * Authenticated classification for the orchestrator's turn adapter: a reply is
92
- * an active arming proof iff the project has a non-expired pending challenge
93
- * and one of the reply's whitespace-separated tokens hashes to it. The code is
94
- * matched by hash matching `transcriptHasUserCode`'s leniency (the code
95
- * appears as a token) without ever trusting a `FLEET-` prefix or exposing the
96
- * code to the model's other reads. An unsolicited lookalike that matches no
97
- * active challenge, and a reply in the wrong project, both stay inert.
217
+ * The inbound adapter's acknowledgement (conductor #614): classify the reply
218
+ * against the project's non-expired pending challenge and, on a match,
219
+ * atomically write the challenge-id-specific acknowledgement record the host
220
+ * waits on. Returns whether the reply is an active arming proof, driving the
221
+ * existing deterministic UX path.
98
222
  *
99
- * Clears nothing: the host (`armTicks`) owns clearance once its transcript
100
- * proof lands, so the two sides cannot race for the record.
223
+ * Writes only this challenge's own file it never reads or rewrites any other
224
+ * record so a wrong token, wrong project, expired challenge, or a racing
225
+ * host settle all fail closed without touching anyone else's handshake, and
226
+ * two projects acknowledging concurrently cannot clobber each other.
101
227
  */
102
- export function isActiveArmProof(project: string | undefined, replyText: string, now: number): boolean {
103
- const pending = readChallenges()[projectKey(project)];
104
- if (pending === undefined || now >= pending.expiresAt) return false;
105
- const targetHash = pending.hash;
228
+ export function acknowledgeArmReply(project: string | undefined, replyText: string, now: number): boolean {
229
+ const pending = readPendingFor(projectKey(project));
230
+ if (pending === undefined) return false;
231
+ if (now >= pending.expiresAt) return false;
106
232
  // Challenge codes contain no whitespace, so tokenising on whitespace never
107
233
  // splits one; empty replies simply yield no token.
234
+ const targetHash = pending.hash;
235
+ let matched = false;
108
236
  for (const token of replyText.trim().split(/\s+/)) {
109
- if (token.length > 0 && challengeHash(token) === targetHash) return true;
237
+ if (token.length > 0 && challengeHash(token) === targetHash) {
238
+ matched = true;
239
+ break;
240
+ }
110
241
  }
111
- return false;
242
+ if (!matched) return false;
243
+ // Keyed by the challenge id, so replays overwrite the one record the single
244
+ // live waiter consumes; a stale id's file can never be created here.
245
+ const record: ArmAcknowledgement = { challengeId: pending.id, acknowledgedAt: now };
246
+ writeFileAtomic(ackPath(pending.id), `${JSON.stringify(record)}\n`);
247
+ return true;
248
+ }
249
+
250
+ /** The acknowledgement record for one exact challenge id, or undefined. */
251
+ export function readArmAcknowledgement(challengeId: string): ArmAcknowledgement | undefined {
252
+ let parsed: unknown;
253
+ try {
254
+ parsed = JSON.parse(readFileSync(ackPath(challengeId), "utf8"));
255
+ } catch {
256
+ return undefined; /* absent, torn or garbage — not an acknowledgement */
257
+ }
258
+ if (parsed === null || typeof parsed !== "object") return undefined;
259
+ const ack = parsed as { challengeId?: unknown; acknowledgedAt?: unknown };
260
+ if (ack.challengeId !== challengeId || typeof ack.acknowledgedAt !== "number") return undefined;
261
+ return { challengeId, acknowledgedAt: ack.acknowledgedAt };
262
+ }
263
+
264
+ /**
265
+ * Settle one arm transaction — consumption, timeout, or send failure: remove
266
+ * the project's pending file iff it still names this transaction, and drop
267
+ * its acknowledgement. Deletion is id-addressed only, so another project's
268
+ * records are untouchable by construction, and a same-project replacement
269
+ * (different id) survives the stale settle.
270
+ */
271
+ export function clearArmTransaction(project: string | undefined, challengeId: string): void {
272
+ const key = projectKey(project);
273
+ const pending = readPendingFor(key);
274
+ if (pending !== undefined && pending.id === challengeId) rmSync(pendingPath(key), { force: true });
275
+ rmSync(ackPath(challengeId), { force: true });
276
+ }
277
+
278
+ export interface ArmChallengeSighting {
279
+ /** The pending transaction's id — random, not the challenge code. */
280
+ id: string;
281
+ /** Unix ms the challenge was recorded, when the record carries it. */
282
+ sentAt?: number;
283
+ /** Unix ms the challenge expires, when the record carries it. */
284
+ expiresAt?: number;
285
+ /** Present once the inbound adapter acknowledged this challenge id. */
286
+ acknowledgedAt?: number;
287
+ }
288
+
289
+ /**
290
+ * Doctor's view of the project's open transaction, or undefined when none is
291
+ * recorded. An expired-but-uncleared record is reported, not hidden: it is
292
+ * inert (a reply past expiry is refused) but lingers until the project's next
293
+ * arm replaces it.
294
+ */
295
+ export function observeArmChallenge(project: string | undefined): ArmChallengeSighting | undefined {
296
+ const pending = readPendingFor(projectKey(project));
297
+ if (pending === undefined || typeof pending.id !== "string") return undefined;
298
+ const ack = readArmAcknowledgement(pending.id);
299
+ return {
300
+ id: pending.id,
301
+ ...(typeof pending.sentAt === "number" ? { sentAt: pending.sentAt } : {}),
302
+ ...(typeof pending.expiresAt === "number" ? { expiresAt: pending.expiresAt } : {}),
303
+ ...(ack === undefined ? {} : { acknowledgedAt: ack.acknowledgedAt }),
304
+ };
112
305
  }
@@ -254,18 +254,31 @@ Keep the queue worth draining.
254
254
  source/ref/freshness is refused as blocked, never groomed. The quality bar
255
255
  above does not move.
256
256
  - **Every to-spec agent answers the same return contract**, or its output is
257
- refused: the strict schema carries
258
- - verdict `ALREADY DONE` / `PROMOTABLE` / `NEEDS DECOMPOSITION` / `BLOCKED` /
257
+ refused: the strict schema carries exactly these fields — the generated task
258
+ schema and the persistence parser are that one contract, so an extra or
259
+ missing field is refused as malformed:
260
+ - `verdict` — `ALREADY DONE` / `PROMOTABLE` / `NEEDS DECOMPOSITION` / `BLOCKED` /
259
261
  `NEEDS PRODUCT DECISION`
260
- - routing — exactly one repo, or `MULTI` with the split
261
- - evidencethe file or symbol proving `ALREADY DONE`, never a title match
262
- - entry points — the 3-6 files to change or read first
263
- - existing tests covering the behaviour, by path
264
- - the one thing most likely to be silently faked
265
- - source — where the code was read: the clone/ref and how fresh it is. A
262
+ - `routing` — exactly one `owner/repo`, or `MULTI`
263
+ - `routingSplit`required iff `routing` is `MULTI`: what each slice goes to
264
+ - `source`where the code was read: the clone/ref and how fresh it is. A
266
265
  scout that cannot reach a source it trusts returns `BLOCKED` and says so;
267
266
  silent fallback to an unnamed source is the failure mode of delegated
268
267
  research — stale evidence reads exactly like good evidence.
268
+ - `evidence` — the files/symbols proving the verdict; required for
269
+ `ALREADY DONE` (the file or symbol that already does the work, never a
270
+ title match)
271
+ - `laterWorkInvalidates` — whether later work retired the premise
272
+ - `laterWorkNote` — what that check searched and found
273
+ - `entryPoints` — the 3-6 files to change or read first
274
+ - `existingTests` — existing tests covering the behaviour, by path
275
+ - `likelySilentFake` — the one thing most likely to be silently faked
276
+ - `proofCommands` — the focused commands that prove the work
277
+ - `fileLane` — the files and directories this slice writes
278
+ - `dependencies` — open prerequisite issue numbers, bare (`875`) or string
279
+ (`"875"`); `[]` when none
280
+ - `proposedBrief` — required iff `verdict` is `PROMOTABLE`
281
+ - `reasonNotToPromote` — required for every other verdict
269
282
  - **Disqualifying an issue is a successful grooming outcome.** Measured on this
270
283
  package's own fleet: four scouts over sixteen backlog issues promoted four and
271
284
  *disqualified six* that looked promotable from their titles — four written
@@ -44,6 +44,9 @@ required field and no extra keys. The fields are:
44
44
  the exact ref, and `freshAt` = epoch milliseconds when you actually observed
45
45
  it. Conductor refuses results whose source is older than 24 hours or missing
46
46
  name/ref/freshAt — an unsourced verdict is not grooming, it is prose.
47
+ - `evidence` — the files/symbols that prove the verdict. Required for
48
+ `ALREADY DONE`: name the symbol/file that already does the work, never a
49
+ title match. Welcome on every other verdict.
47
50
  - `laterWorkInvalidates` — boolean: did later work (an epic or issue committed
48
51
  after this candidate was filed) retire its premise?
49
52
  - `laterWorkNote` — what you searched for that check and what you found. Even
@@ -57,8 +60,9 @@ required field and no extra keys. The fields are:
57
60
  implementing, and how to prove it is not.
58
61
  - `proofCommands` — the focused commands that prove the work, each with its
59
62
  `cwd` when it matters.
60
- - `fileLane` — the files and directories this slice writes; `dependencies` —
61
- open prerequisites, `[]` when none.
63
+ - `fileLane` — the files and directories this slice writes.
64
+ - `dependencies` — open prerequisite issue numbers, each a bare number
65
+ (`875`) or a string (`"875"`); `[]` when none.
62
66
  - `proposedBrief` — required iff `verdict` is `PROMOTABLE`: the brief a worker
63
67
  would be dispatched with, including the silent fake and the proof commands.
64
68
  - `reasonNotToPromote` — required for every other verdict: why this must not