omp-conductor 0.19.6 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/REFERENCE.md +27 -2
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7832
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/doctor.ts +17 -12
  49. package/src/escalate.ts +39 -21
  50. package/src/failure-class.ts +75 -1
  51. package/src/fleet.ts +1218 -304
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +428 -1681
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +72 -6
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +158 -7
  64. package/src/store.ts +646 -26
  65. package/src/to-spec.ts +194 -21
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +435 -15
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +384 -12
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +456 -1
@@ -1,48 +1,60 @@
1
1
  /**
2
2
  * Authenticated pending-challenge state for the arming handshake (conductor
3
- * #415, transaction reworked by #614, storage hardened by review of #896).
3
+ * #415, transaction reworked by #614, storage hardened by review of #896,
4
+ * targets recorded when the console took over verification in phase 1 of the
5
+ * orchestrator-workflow redesign).
4
6
  *
5
7
  * `armTicks` (fleet.ts) sends a short-lived `FLEET-…` code to the operator and
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.
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.
11
11
  *
12
- * This module is the bridge between the two roles without an import cycle:
13
- * `fleet.ts` imports `daemon.ts`, `daemon.ts` imports `orchestrator-tick.ts`,
14
- * so `orchestrator-tick.ts` can never import `fleet.ts`. Both already import
15
- * `config.ts`, so the state lives next to the other state under `stateDir()`
16
- * and both sides reach it through *this* leaf module.
12
+ * Nothing waits for the reply any more. The console session owns the operator
13
+ * DM, and no tick extension runs there — so the in-session acknowledgement
14
+ * wait `armTicks` used to hold could never be satisfied. What survives is the
15
+ * durable half: the record that says which code is live, until when, and which
16
+ * projects it arms.
17
+ *
18
+ * This module stays a leaf so both roles can reach the state without an import
19
+ * cycle: `fleet.ts` imports `daemon.ts`, `daemon.ts` imports
20
+ * `orchestrator-tick.ts`, so `orchestrator-tick.ts` can never import
21
+ * `fleet.ts`. Everything here reads only `config.ts`.
17
22
  *
18
23
  * The handshake keeps no shared mutable state at all — every file is named by
19
24
  * its own key, and every deletion is id-addressed, so two processes can
20
25
  * neither lose each other's updates nor delete each other's proofs:
21
26
  *
22
27
  * - `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.
28
+ * `{ project, id, hash, sentAt, expiresAt, targets?, owner? }`. Host-owned:
29
+ * only the arm ceremony writes it (record before the send, settle on
30
+ * consumption or send failure), always through the atomic tmp+rename write
31
+ * the admission ack uses. A re-armed project overwrites its own file —
32
+ * pruning only its own prior id's acknowledgement; a concurrent arm for a
33
+ * different project touches a different file.
29
34
  * - `arm-challenge-acks/<challenge id>.json` — one file per acknowledgement:
30
- * `{ challengeId, acknowledgedAt }`. The adapter's acknowledgement is a
35
+ * `{ challengeId, acknowledgedAt }`. The reply step's acknowledgement is a
31
36
  * 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
37
+ * another transaction's record, and `doctor` reads it to report a handshake
38
+ * that was answered but never settled. Files that outlive their transaction
39
+ * (a crash between the acknowledgement and the settle) are inert by
34
40
  * construction and removed only by age, never by membership in any
35
41
  * directory snapshot (see {@link gcAgedAcks}).
36
42
  *
43
+ * `targets` is why the reply step never re-derives what to arm: the challenge
44
+ * the operator answered named specific projects, and the config could have
45
+ * changed between the send and the reply. Arming what the record says is the
46
+ * only reading that matches what the operator was asked. It is optional purely
47
+ * for backward tolerance — a record written before this field existed still
48
+ * verifies (see {@link resolveArmReply}).
49
+ *
37
50
  * Only the sha-256 of the code is ever persisted — never the code, whose
38
- * plaintext appearance in the protected session transcript is already the
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.
51
+ * plaintext appearance in the chat is the whole proof and must not leak into a
52
+ * durable report, an issue comment, or a diagnostic line the way a new
53
+ * artifact could. The id is a random UUID cut with the challenge, so
54
+ * acknowledgement records are challenge-specific without carrying anything
55
+ * guessable. No `FLEET-` prefix is ever trusted: a reply is classified by
56
+ * hashing its whitespace-separated tokens against the pending record, so no
57
+ * lookalike can arm a fleet.
46
58
  */
47
59
 
48
60
  import { createHash, randomUUID } from "node:crypto";
@@ -53,6 +65,21 @@ import { stateDir } from "./config.ts";
53
65
  const ARM_CHALLENGES_DIR = "arm-challenges";
54
66
  const ARM_ACKS_DIR = "arm-challenge-acks";
55
67
 
68
+ /**
69
+ * One project a challenge arms: recorded with the challenge so the reply step
70
+ * arms exactly what the operator was asked about, never a re-derivation.
71
+ */
72
+ export interface ArmTarget {
73
+ /**
74
+ * The configured project name, absent only for a legacy unstamped
75
+ * single-project fleet — the same value `resolveArmState` needs to decide
76
+ * whether the pre-per-project shared marker still speaks for this project.
77
+ */
78
+ project?: string;
79
+ /** The arm marker path this project's heartbeat reads. */
80
+ armedFile: string;
81
+ }
82
+
56
83
  interface PendingChallenge {
57
84
  /** The project key this pending belongs to, mirrored for read-back checks. */
58
85
  project: string;
@@ -64,12 +91,24 @@ interface PendingChallenge {
64
91
  sentAt: number;
65
92
  /** Unix ms after which a matching reply is no longer an active proof. */
66
93
  expiresAt: number;
94
+ /**
95
+ * The projects this challenge arms. Absent on a record written before the
96
+ * field existed; callers fall back rather than crash.
97
+ */
98
+ targets?: ArmTarget[];
99
+ /**
100
+ * The chat the challenge was actually sent to, so the marker the reply step
101
+ * writes attributes the arm to the operator who was asked — not to whatever
102
+ * the paired channel happens to say minutes later. Absent on a pre-targets
103
+ * record.
104
+ */
105
+ owner?: string;
67
106
  }
68
107
 
69
108
  interface ArmAcknowledgement {
70
109
  /** The exact challenge id this record satisfies. */
71
110
  challengeId: string;
72
- /** Unix ms the inbound adapter acknowledged the reply. */
111
+ /** Unix ms the reply step classified the operator's message as this proof. */
73
112
  acknowledgedAt: number;
74
113
  }
75
114
 
@@ -125,6 +164,31 @@ function writeFileAtomic(path: string, content: string): void {
125
164
  }
126
165
  }
127
166
 
167
+ /**
168
+ * A recorded target list, validated element by element, or undefined when the
169
+ * record carries none. `invalid` is deliberately distinct from `absent`: a
170
+ * torn target list must fail the whole record closed (below) rather than
171
+ * silently degrade into the legacy fallback, which would arm a project the
172
+ * challenge may never have named.
173
+ */
174
+ function readTargets(raw: unknown): { kind: "absent" } | { kind: "invalid" } | { kind: "ok"; targets: ArmTarget[] } {
175
+ if (raw === undefined) return { kind: "absent" };
176
+ if (!Array.isArray(raw) || raw.length === 0) return { kind: "invalid" };
177
+ const targets: ArmTarget[] = [];
178
+ for (const entry of raw) {
179
+ if (entry === null || typeof entry !== "object") return { kind: "invalid" };
180
+ const target = entry as { project?: unknown; armedFile?: unknown };
181
+ if (typeof target.armedFile !== "string" || target.armedFile.length === 0) return { kind: "invalid" };
182
+ if (target.project !== undefined && typeof target.project !== "string") return { kind: "invalid" };
183
+ targets.push(
184
+ target.project === undefined
185
+ ? { armedFile: target.armedFile }
186
+ : { project: target.project, armedFile: target.armedFile },
187
+ );
188
+ }
189
+ return { kind: "ok", targets };
190
+ }
191
+
128
192
  /**
129
193
  * This project's pending challenge parsed from its own file, with every field
130
194
  * validated. Anything absent, torn, garbage, pre-#614, or naming another
@@ -144,11 +208,17 @@ function readPendingFor(key: string): PendingChallenge | undefined {
144
208
  pending.project !== key ||
145
209
  typeof pending.id !== "string" ||
146
210
  typeof pending.hash !== "string" ||
147
- typeof pending.expiresAt !== "number"
211
+ typeof pending.expiresAt !== "number" ||
212
+ (pending.owner !== undefined && typeof pending.owner !== "string")
148
213
  ) {
149
214
  return undefined;
150
215
  }
151
- return pending as PendingChallenge;
216
+ const targets = readTargets(pending.targets);
217
+ if (targets.kind === "invalid") return undefined;
218
+ return {
219
+ ...(pending as PendingChallenge),
220
+ ...(targets.kind === "ok" ? { targets: targets.targets } : {}),
221
+ };
152
222
  }
153
223
 
154
224
  /** sha-256 hex of the challenge code — the persisted token, never the code. */
@@ -182,14 +252,13 @@ export function looksLikeChallengeCode(token: string): boolean {
182
252
 
183
253
  /**
184
254
  * Acknowledgement files outliving their transaction — a crash between the
185
- * adapter's write and the host's settle — are inert by construction: no
186
- * waiter ever polls their id again, and ids are UUIDs, so no future
187
- * transaction can collide with one. They are therefore not swept by
188
- * membership (a directory listing captured before another process records is
189
- * stale the moment it is taken, and acting on it deletes on-time proofs),
190
- * only by age: a file older than {@link ACK_ORPHAN_GC_AFTER_MS} cannot belong
191
- * to a live handshake under any configured window, so its mtime alone decides
192
- * removal.
255
+ * classification and the settle — are inert by construction: nothing ever
256
+ * reads their id again, and ids are UUIDs, so no future transaction can
257
+ * collide with one. They are therefore not swept by membership (a directory
258
+ * listing captured before another process records is stale the moment it is
259
+ * taken, and acting on it deletes on-time proofs), only by age: a file older
260
+ * than {@link ACK_ORPHAN_GC_AFTER_MS} cannot belong to a live handshake under
261
+ * any configured window, so its mtime alone decides removal.
193
262
  */
194
263
  const ACK_ORPHAN_GC_AFTER_MS = 24 * 60 * 60 * 1000;
195
264
 
@@ -212,6 +281,17 @@ function gcAgedAcks(now: number): void {
212
281
  }
213
282
  }
214
283
 
284
+ /**
285
+ * What a challenge arms, recorded with it: the projects the operator was told
286
+ * about and the chat they were asked in.
287
+ */
288
+ export interface ArmChallengePlan {
289
+ /** Every project a matching reply arms — one for `arm --project X`, all of them for a fleet ceremony. */
290
+ targets: readonly ArmTarget[];
291
+ /** The chat the challenge is being sent to. */
292
+ owner: string;
293
+ }
294
+
215
295
  /**
216
296
  * Register a new active arming challenge for the project — overwriting the
217
297
  * project's own pending file — and return the transaction id. Before the
@@ -219,18 +299,31 @@ function gcAgedAcks(now: number): void {
219
299
  * acknowledgement is pruned: project-local cleanup with no directory
220
300
  * snapshot, so another project's handshake cannot lose its proof here no
221
301
  * matter how the calls interleave.
302
+ *
303
+ * `plan` is optional only so the pre-targets call shape stays valid for the
304
+ * surfaces (doctor's fixtures, the CLI's own tests) that record a challenge
305
+ * without meaning to arm anything through the reply step. The ceremony always
306
+ * passes one.
222
307
  */
223
308
  export function recordArmChallenge(
224
309
  project: string | undefined,
225
310
  code: string,
226
311
  sentAt: number,
227
312
  expiresAt: number,
313
+ plan?: ArmChallengePlan,
228
314
  ): string {
229
315
  const key = projectKey(project);
230
316
  // Read the prior record BEFORE the overwrite shadows it.
231
317
  const prior = readPendingFor(key);
232
318
  const id = randomUUID();
233
- const record: PendingChallenge = { project: key, id, hash: challengeHash(code), sentAt, expiresAt };
319
+ const record: PendingChallenge = {
320
+ project: key,
321
+ id,
322
+ hash: challengeHash(code),
323
+ sentAt,
324
+ expiresAt,
325
+ ...(plan === undefined ? {} : { targets: [...plan.targets], owner: plan.owner }),
326
+ };
234
327
  writeFileAtomic(pendingPath(key), `${JSON.stringify(record)}\n`);
235
328
  if (prior !== undefined) rmSync(ackPath(prior.id), { force: true });
236
329
  gcAgedAcks(sentAt);
@@ -238,35 +331,53 @@ export function recordArmChallenge(
238
331
  }
239
332
 
240
333
  /**
241
- * The inbound adapter's acknowledgement (conductor #614): classify the reply
242
- * against the project's non-expired pending challenge and, on a match,
243
- * atomically write the challenge-id-specific acknowledgement record the host
244
- * waits on. Returns whether the reply is an active arming proof, driving the
245
- * existing deterministic UX path.
246
- *
247
- * Writes only this challenge's own file — it never reads or rewrites any other
248
- * record — so a wrong token, wrong project, expired challenge, or a racing
249
- * host settle all fail closed without touching anyone else's handshake, and
250
- * two projects acknowledging concurrently cannot clobber each other.
251
- */
252
- /**
253
- * What an inbound reply was, so the adapter can answer it (#991).
334
+ * What a reply was, so the console can answer it (#991, retained now that the
335
+ * console not a tick session does the answering).
254
336
  *
255
- * Before this, a non-matching or expired code was simply ignored: the waiter
256
- * kept waiting and the operator had no idea they had been heard. The fastest
257
- * way to be sure you held the current code was to scroll the chat, which is
258
- * exactly the work conductor-owned verification was supposed to end.
337
+ * Before this, a non-matching or expired code was simply ignored: the operator
338
+ * had no idea they had been heard, and the fastest way to be sure you held the
339
+ * current code was to scroll the chat. That is exactly the work
340
+ * conductor-owned verification exists to end.
259
341
  *
260
342
  * - `matched` — an active proof; the acknowledgement is written.
261
343
  * - `expired` — a code that hashes to a pending record whose window has closed.
262
344
  * - `unknown` — a challenge-shaped token that matches nothing readable here.
263
- * - `none` — no challenge-shaped token at all: ordinary chat, answer nothing.
345
+ * - `none` — no challenge-shaped token at all: ordinary chat, arm nothing.
264
346
  */
265
347
  export type ArmReplyVerdict = "matched" | "expired" | "unknown" | "none";
266
348
 
349
+ /** The transaction a matching reply proved, and what the challenge said it arms. */
350
+ export interface ArmReplyMatch {
351
+ /**
352
+ * The state key the matched record lives under — a project key (possibly the
353
+ * empty key of an unstamped config) or {@link FLEET_ARM_KEY}. Passed straight
354
+ * back to {@link clearArmTransaction}, so the settle is addressed to the
355
+ * record that was actually consumed.
356
+ */
357
+ key: string;
358
+ /** The matched transaction's id; every deletion is addressed by it. */
359
+ id: string;
360
+ /**
361
+ * The projects the challenge recorded. Absent on a record written before
362
+ * targets existed — the caller falls back rather than crashing.
363
+ */
364
+ targets?: ArmTarget[];
365
+ /** The chat the challenge was sent to. Absent on a pre-targets record. */
366
+ owner?: string;
367
+ }
368
+
267
369
  /**
268
- * Classify one inbound reply against this session's own records, writing the
269
- * acknowledgement on a match.
370
+ * Discriminated so a caller cannot reach for `match` on a verdict that has
371
+ * none: only `matched` carries the transaction, and only `matched` may write a
372
+ * marker.
373
+ */
374
+ export type ArmReplyResolution =
375
+ | { verdict: "matched"; match: ArmReplyMatch }
376
+ | { verdict: Exclude<ArmReplyVerdict, "matched">; match?: undefined };
377
+
378
+ /**
379
+ * Classify one operator reply against this host's own records and, on a match,
380
+ * write the challenge-id-specific acknowledgement.
270
381
  *
271
382
  * Two records are consulted and no others: this project's, and the fleet-wide
272
383
  * one (#991). That bound is the non-disclosure property — a reply that matches
@@ -274,25 +385,35 @@ export type ArmReplyVerdict = "matched" | "expired" | "unknown" | "none";
274
385
  * challenge, because no other project's record is ever read.
275
386
  *
276
387
  * Fail-closed ordering is unchanged: the project's own active challenge wins,
277
- * then the fleet ceremony, and an expired record is never a proof.
388
+ * then the fleet ceremony, and an expired record is never a proof. The write
389
+ * touches only the matched challenge's own file, so a wrong token, a wrong
390
+ * project, an expired challenge or a racing settle all fail closed without
391
+ * touching anyone else's handshake.
392
+ *
393
+ * The acknowledgement record is no longer something a waiter polls — nothing
394
+ * waits any more. It is kept because it is the only durable evidence that a
395
+ * reply was seen, which is what `doctor`'s `arm-ack` finding reports when a
396
+ * ceremony was answered but never settled.
278
397
  */
279
- export function classifyArmReply(
398
+ export function resolveArmReply(
280
399
  project: string | undefined,
281
400
  replyText: string,
282
401
  now: number,
283
- ): ArmReplyVerdict {
402
+ ): ArmReplyResolution {
284
403
  // Challenge codes contain no whitespace, so tokenising on whitespace never
285
404
  // splits one; empty replies simply yield no token.
286
405
  const tokens = replyText.trim().split(/\s+/).filter((token) => token.length > 0);
287
- if (tokens.length === 0) return "none";
288
- const candidates = [readPendingFor(projectKey(project)), readPendingFor(FLEET_ARM_KEY)].filter(
289
- (pending): pending is PendingChallenge => pending !== undefined,
290
- );
406
+ if (tokens.length === 0) return { verdict: "none" };
407
+ const candidates: { key: string; pending: PendingChallenge }[] = [];
408
+ for (const key of [projectKey(project), FLEET_ARM_KEY]) {
409
+ const pending = readPendingFor(key);
410
+ if (pending !== undefined) candidates.push({ key, pending });
411
+ }
291
412
 
292
413
  let expired = false;
293
414
  for (const token of tokens) {
294
415
  const hash = challengeHash(token);
295
- for (const pending of candidates) {
416
+ for (const { key, pending } of candidates) {
296
417
  if (pending.hash !== hash) continue;
297
418
  if (now >= pending.expiresAt) {
298
419
  // Keep looking: a fresh record for the same code is a proof, and only
@@ -300,27 +421,25 @@ export function classifyArmReply(
300
421
  expired = true;
301
422
  continue;
302
423
  }
303
- // Keyed by the challenge id, so replays overwrite the one record the
304
- // single live waiter consumes; a stale id's file can never be created
305
- // here.
424
+ // Keyed by the challenge id, so replays overwrite the one record this
425
+ // transaction owns; a stale id's file can never be created here.
306
426
  const record: ArmAcknowledgement = { challengeId: pending.id, acknowledgedAt: now };
307
427
  writeFileAtomic(ackPath(pending.id), `${JSON.stringify(record)}\n`);
308
- return "matched";
428
+ return {
429
+ verdict: "matched",
430
+ match: {
431
+ key,
432
+ id: pending.id,
433
+ ...(pending.targets === undefined ? {} : { targets: pending.targets }),
434
+ ...(pending.owner === undefined ? {} : { owner: pending.owner }),
435
+ },
436
+ };
309
437
  }
310
438
  }
311
- if (expired) return "expired";
312
- // Shape only, and only to decide whether the operator gets an answer. A token
313
- // that is not challenge-shaped is ordinary chat.
314
- return tokens.some((token) => looksLikeChallengeCode(token)) ? "unknown" : "none";
315
- }
316
-
317
- /**
318
- * The inbound adapter's acknowledgement (conductor #614), kept as the boolean
319
- * the availability gate already reads. {@link classifyArmReply} is the same
320
- * pass with the non-matching cases named.
321
- */
322
- export function acknowledgeArmReply(project: string | undefined, replyText: string, now: number): boolean {
323
- return classifyArmReply(project, replyText, now) === "matched";
439
+ if (expired) return { verdict: "expired" };
440
+ // Shape only, and only to decide how the operator is answered. A token that
441
+ // is not challenge-shaped is ordinary chat.
442
+ return { verdict: tokens.some((token) => looksLikeChallengeCode(token)) ? "unknown" : "none" };
324
443
  }
325
444
 
326
445
  /** The acknowledgement record for one exact challenge id, or undefined. */