omp-conductor 0.19.7 → 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.
Files changed (71) hide show
  1. package/REFERENCE.md +10 -1
  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/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  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 +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  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 +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -1
package/src/to-spec.ts CHANGED
@@ -12,8 +12,9 @@
12
12
  * The five contract verdicts map onto it — PROMOTABLE → `promotable`;
13
13
  * ALREADY DONE and NEEDS DECOMPOSITION → `considered`; BLOCKED and NEEDS
14
14
  * PRODUCT DECISION → `blocked` — and a result that cannot be trusted
15
- * (malformed JSON, a schema violation, no authoritative source, or a stale
16
- * source) persists as `blocked` and never as `promotable`/`considered`.
15
+ * (no answer at all, malformed JSON, a schema violation, no authoritative
16
+ * source, or a stale source) persists as `blocked` and never as
17
+ * `promotable`/`considered`.
17
18
  * Reprocessing the same candidate is deterministic: the same input at the
18
19
  * same observation time produces the same row, and malformed output never
19
20
  * erases a prior valid (promotable/considered) result. Stale or source-less
@@ -34,6 +35,28 @@
34
35
  * returned before persisting, because the harness is permissive by design
35
36
  * and the store vets only what this module hands it.
36
37
  *
38
+ * Who stamps the observation time (#1000): the dispatcher, never the agent.
39
+ * A to-spec groomer runs with reading tools only and therefore has no clock,
40
+ * so the `source.freshAt` it used to be asked for was always a guess — and a
41
+ * guess landing ahead of the daemon's clock discarded the whole verdict as a
42
+ * clock lie, twice in one afternoon on verdicts that were otherwise correct.
43
+ * The schema still accepts the key (it is `.strict()`, so refusing an older
44
+ * prompt's output would recreate exactly that discard) but ignores its value:
45
+ * `parseToSpecResult` stamps the batch's launch time — or the settle time
46
+ * when no launch time is known — and that stamp is what persists.
47
+ *
48
+ * Sizing is evidence, not a feeling (#1041): a `PROMOTABLE` verdict carries
49
+ * `sizingEvidence`, and a `NEEDS DECOMPOSITION` verdict carries an ordered
50
+ * `decomposition`. #1035 was persisted `PROMOTABLE` with nine acceptance
51
+ * criteria across four lifecycle files totalling 28,812 lines; attempt 1
52
+ * exhausted 181 of its 180 turns. The verdict had asserted one-budget fit —
53
+ * what was missing was anything that could falsify the assertion. So the
54
+ * contract now asks for the falsifier itself (distinct behaviours, the
55
+ * production modules and test surfaces each needs, shared-file sequencing)
56
+ * and there is deliberately no numeric threshold anywhere: a line or
57
+ * criterion count that decided verdicts would be gamed by splitting prose,
58
+ * and the judgement is the groomer's.
59
+ *
37
60
  * One contract, every surface (#883): this Zod schema is the single source of
38
61
  * truth. `TO_SPEC_SCHEMA` generated from it stamps the native task launch,
39
62
  * `parseToSpecResult` persists against it, and the drift test pins the
@@ -65,6 +88,11 @@ export type ToSpecVerdict = (typeof TO_SPEC_VERDICTS)[number];
65
88
  * never read as promotable/considered. 24 hours: a groomed candidate must
66
89
  * have looked at the code within a day, and a re-grooming pass a day later
67
90
  * re-reads the live source anyway.
91
+ *
92
+ * Since #1000 the observation time is the dispatcher's own stamp, so this
93
+ * ceiling can only fire for a batch that genuinely ran longer than a day —
94
+ * a real signal about the batch rather than a verdict on the agent's
95
+ * arithmetic.
68
96
  */
69
97
  export const TO_SPEC_MAX_SOURCE_AGE_MS = 24 * 60 * 60 * 1000;
70
98
 
@@ -78,10 +106,47 @@ const ToSpecSourceSchema = z
78
106
  freshAt: z
79
107
  .number()
80
108
  .int()
81
- .describe("Epoch milliseconds when the source was observed — required so staleness is judgeable without trusting prose."),
109
+ .optional()
110
+ .describe(
111
+ "Ignored — do not send it. Conductor stamps the observation time itself from the batch window (#1000); a groomer has reading tools only and no clock, so any value here is a guess. The key is still accepted so an older prompt's output is never refused for sending one.",
112
+ ),
82
113
  })
83
114
  .strict()
84
- .describe("The authoritative source/ref/freshness every verdict must stand on.");
115
+ .describe("The authoritative source and ref every verdict must stand on; conductor stamps its freshness (#1000).");
116
+
117
+ /**
118
+ * One ordered child of a decomposition proposal (#1041). A `NEEDS
119
+ * DECOMPOSITION` verdict used to be free to say "split this up" in prose,
120
+ * which is a verdict nobody can act on: the operator still has to do the
121
+ * decomposition. Each child therefore carries exactly what filing it as an
122
+ * issue needs — a title, the exact write lane it owns, what it waits on, the
123
+ * silent fake it invites, and the commands that prove it — so the proposal is
124
+ * a filing instruction rather than an opinion.
125
+ */
126
+ const ToSpecDecompositionChildSchema = z
127
+ .object({
128
+ title: z.string().trim().min(1).describe("The child issue's title — one slice, stated as the behaviour it lands."),
129
+ writeLane: z
130
+ .array(z.string().trim().min(1))
131
+ .min(1)
132
+ .describe("The files/dirs this child alone writes — its `## Exact write lane`."),
133
+ dependsOn: z
134
+ .array(z.union([z.string().trim().min(1), z.number().int().min(1)]))
135
+ .describe(
136
+ "What this child waits on: an earlier child's title, or an existing issue number. Children sharing a file in their write lanes must be serialised here — two concurrent slices writing one core module is the collision the file lane exists to prevent. `[]` for the first child.",
137
+ ),
138
+ likelySilentFake: z
139
+ .string()
140
+ .trim()
141
+ .min(1)
142
+ .describe("The one thing most likely to be silently faked in this child, and how to prove it is not."),
143
+ proofCommands: z
144
+ .array(z.string().trim().min(1))
145
+ .min(1)
146
+ .describe("The focused commands that prove this child, each with its cwd when it matters."),
147
+ })
148
+ .strict()
149
+ .describe("One ordered child slice a decomposition proposal names (#1041).");
85
150
 
86
151
  /**
87
152
  * The one strict contract a grooming result must satisfy before it may
@@ -143,6 +208,21 @@ const ToSpecResultSchema = z
143
208
  .describe(
144
209
  'Open prerequisites this work is blocked on, as bare issue numbers (875) or strings ("875"); empty when none.',
145
210
  ),
211
+ sizingEvidence: z
212
+ .string()
213
+ .trim()
214
+ .min(1)
215
+ .optional()
216
+ .describe(
217
+ "Required for PROMOTABLE: the source-backed one-budget analysis — the distinct behaviours/state transitions this slice introduces, the production modules and focused test surfaces each of them needs, the shared-file sequencing between them, and why that fits one configured worker attempt. A small file count is not this evidence when the files are high-fanout lifecycle modules with large integration suites (#1041).",
218
+ ),
219
+ decomposition: z
220
+ .array(ToSpecDecompositionChildSchema)
221
+ .min(2)
222
+ .optional()
223
+ .describe(
224
+ "Required for NEEDS DECOMPOSITION and accepted on no other verdict: the ordered children this candidate splits into, first to last, each naming its title, write lane, dependencies, silent fake and proof commands. Children sharing a write-lane path must serialise through `dependsOn` (#1041).",
225
+ ),
146
226
  proposedBrief: z
147
227
  .string()
148
228
  .trim()
@@ -166,6 +246,15 @@ const ToSpecResultSchema = z
166
246
  if (value.reasonNotToPromote !== undefined) {
167
247
  ctx.addIssue({ code: "custom", message: "PROMOTABLE must not carry reasonNotToPromote" });
168
248
  }
249
+ // #1041: the one-budget claim is only a claim until something could
250
+ // falsify it. #1035 asserted it and burned 181 of 180 turns.
251
+ if (value.sizingEvidence === undefined) {
252
+ ctx.addIssue({
253
+ code: "custom",
254
+ message:
255
+ "PROMOTABLE requires sizingEvidence: the distinct behaviours, their production modules and test surfaces, the shared-file sequencing, and why that fits one worker attempt",
256
+ });
257
+ }
169
258
  } else {
170
259
  if (value.reasonNotToPromote === undefined) {
171
260
  ctx.addIssue({ code: "custom", message: `${verdict} requires reasonNotToPromote` });
@@ -180,6 +269,40 @@ const ToSpecResultSchema = z
180
269
  message: "ALREADY DONE requires evidence naming the file/symbol that already does the work",
181
270
  });
182
271
  }
272
+ // A decomposition proposal is a filing instruction or it is nothing
273
+ // (#1041): NEEDS DECOMPOSITION owes ordered children, and every other
274
+ // verdict owes none — children beside PROMOTABLE would mean the verdict
275
+ // contradicts itself.
276
+ if (verdict === "NEEDS DECOMPOSITION") {
277
+ if (value.decomposition === undefined) {
278
+ ctx.addIssue({
279
+ code: "custom",
280
+ message:
281
+ "NEEDS DECOMPOSITION requires decomposition: the ordered children, each with its title, write lane, dependsOn, likelySilentFake and proofCommands",
282
+ });
283
+ }
284
+ } else if (value.decomposition !== undefined) {
285
+ ctx.addIssue({ code: "custom", message: `decomposition is only valid with verdict "NEEDS DECOMPOSITION", not ${verdict}` });
286
+ }
287
+ // Two children writing one core module concurrently is the collision the
288
+ // file lane exists to prevent, so the later of any file-sharing pair must
289
+ // name what it waits on. Order is the array's order — child n may only
290
+ // depend on something before it.
291
+ const children = value.decomposition ?? [];
292
+ for (let i = 1; i < children.length; i++) {
293
+ const child = children[i]!;
294
+ if (child.dependsOn.length > 0) continue;
295
+ const own = new Set(child.writeLane.map((path) => path.trim()));
296
+ for (let j = 0; j < i; j++) {
297
+ const shared = children[j]!.writeLane.map((path) => path.trim()).find((path) => own.has(path));
298
+ if (shared === undefined) continue;
299
+ ctx.addIssue({
300
+ code: "custom",
301
+ message: `decomposition child ${i + 1} shares ${shared} with child ${j + 1} and must serialise on it through dependsOn`,
302
+ });
303
+ break;
304
+ }
305
+ }
183
306
  if (value.routing === MULTI_ROUTING && value.routingSplit === undefined) {
184
307
  ctx.addIssue({ code: "custom", message: 'routing "MULTI" requires routingSplit' });
185
308
  }
@@ -193,11 +316,27 @@ const ToSpecResultSchema = z
193
316
  * output with (`outputSchema` + `schemaMode: "strict"`, #772). */
194
317
  export const TO_SPEC_SCHEMA: object = z.toJSONSchema(ToSpecResultSchema) as object;
195
318
 
196
- export type ToSpecResult = z.infer<typeof ToSpecResultSchema>;
319
+ /**
320
+ * The validated result every reader gets. `source.freshAt` is a required
321
+ * number here even though the schema no longer asks the agent for one:
322
+ * {@link parseToSpecResult} stamps the dispatcher's own observation time onto
323
+ * every result it returns (#1000), so a persisted verdict always carries a
324
+ * freshness witness — just never the agent's guess.
325
+ */
326
+ export type ToSpecResult = Omit<z.infer<typeof ToSpecResultSchema>, "source"> & {
327
+ source: { name: string; ref: string; freshAt: number };
328
+ };
197
329
 
198
- /** Why a result could not be trusted; each persists as a blocked record. */
330
+ /** Why a result could not be trusted; each persists as a blocked record.
331
+ *
332
+ * `no-answer` is the session-level refusal (#1064): the pass produced no
333
+ * payload at all — the scout was killed at its turn ceiling, or the session
334
+ * ended without ever yielding. It is a property of the session, never of
335
+ * output, so it is decided by the pass before anything is parsed and is never
336
+ * `malformed`. */
199
337
  export type ToSpecFailure =
200
338
  | { kind: "malformed"; detail: string }
339
+ | { kind: "no-answer"; detail: string }
201
340
  | { kind: "missing-source"; detail: string }
202
341
  | { kind: "stale-source"; detail: string };
203
342
 
@@ -207,6 +346,28 @@ export type ParseToSpecOutcome = { ok: true; result: ToSpecResult } | { ok: fals
207
346
  export interface ToSpecEvidence {
208
347
  kind: "to-spec";
209
348
  result: ToSpecResult;
349
+ /**
350
+ * How the answer arrived (#1064): through the `yield` tool, or recovered
351
+ * from the session's text because it never yielded. `"text"` is the record's
352
+ * admission that the verdict did not come through the yield contract — the
353
+ * answer was complete and valid, so it stands, but a later reader can tell
354
+ * the two apart. Absent (or `"yield"`) means it came through the contract.
355
+ */
356
+ via?: "yield" | "text";
357
+ /**
358
+ * Whether the one bounded repair round produced the answer (#1064): the
359
+ * first answer parsed but violated the contract, the validation error was
360
+ * handed back, and the re-answer is what this record carries.
361
+ */
362
+ repaired?: boolean;
363
+ /**
364
+ * What the ready gate found missing when a valid PROMOTABLE verdict was
365
+ * refused mechanical promotion (#1041). A sibling of `result`, never a
366
+ * replacement for it: the verdict stays recoverable, so the candidate reads
367
+ * as groomed-but-rejected rather than ungroomed, and the tick can name what
368
+ * an operator must fix. Absent on every promoted or unjudged row.
369
+ */
370
+ readyGate?: { missing: string[]; checkedAt: number };
210
371
  }
211
372
 
212
373
  /** The row's `evidence` when a result was refused. */
@@ -217,12 +378,21 @@ export interface ToSpecFailureEvidence {
217
378
  input: string;
218
379
  }
219
380
 
220
- /** How much raw agent output a failure record may carry. */
221
- export const TO_SPEC_INPUT_SAMPLE_MAX = 2_000;
381
+ /**
382
+ * How much raw agent output a failure record may carry (#1064).
383
+ *
384
+ * Sized for the whole refused payload, not a sample of it: a complete verdict
385
+ * with a proposed brief and sizing analysis routinely exceeds 2 KB, and a
386
+ * discarded verdict should be recoverable from the row rather than
387
+ * re-derived at full session cost. 8 KB holds a full payload with headroom
388
+ * while still keeping a single refused row bounded.
389
+ */
390
+ export const TO_SPEC_INPUT_SAMPLE_MAX = 8_000;
222
391
 
223
392
  /** Refused results always land on the same store surface, named by failure. */
224
393
  const FAILURE_REASON: Record<ToSpecFailure["kind"], string> = {
225
394
  malformed: "malformed",
395
+ "no-answer": "no-answer",
226
396
  "missing-source": "missing-source",
227
397
  "stale-source": "stale-source",
228
398
  };
@@ -272,17 +442,82 @@ function extractJson(input: string): string {
272
442
  return (fenced?.[1] ?? input).trim();
273
443
  }
274
444
 
445
+ /**
446
+ * The three-way gate the scout runner decides a payload against (#1064):
447
+ * whether a string is a payload at all, and whether that payload already
448
+ * conforms to the contract or is a single re-answer away from it.
449
+ *
450
+ * - `conforming` — a JSON object that satisfies the strict contract. For
451
+ * text-recovered blocks this is the criterion that separates "a complete
452
+ * verdict written outside the yield tool" from mere narration.
453
+ * - `repairable` — a JSON object that violates the contract (the #1062
454
+ * shape: every required field present, one key misnamed). The failure
455
+ * carries the first validation issue, which is what the repair round hands
456
+ * back to the scout.
457
+ * - `not-a-payload` — prose, truncated JSON, a non-object: nothing a
458
+ * re-answer can repair, so the pass records no answer rather than grading
459
+ * it.
460
+ *
461
+ * Same parser as {@link parseToSpecResult}, deliberately: the runner probes
462
+ * with the one contract so its repair decision can never drift from what the
463
+ * persistence layer will accept — this is the one validator used twice, not a
464
+ * second, weaker one.
465
+ */
466
+ export type ToSpecPayloadGate =
467
+ | { state: "conforming"; body: string }
468
+ | { state: "repairable"; body: string; failure: ToSpecFailure }
469
+ | { state: "not-a-payload" };
470
+
471
+ export function gateToSpecPayload(payload: string): ToSpecPayloadGate {
472
+ const body = extractJson(payload);
473
+ if (body.length === 0) return { state: "not-a-payload" };
474
+ let raw: unknown;
475
+ try {
476
+ raw = JSON.parse(body);
477
+ } catch {
478
+ return { state: "not-a-payload" };
479
+ }
480
+ if (!isObject(raw)) return { state: "not-a-payload" };
481
+ const parsed = ToSpecResultSchema.safeParse(raw);
482
+ if (!parsed.success) {
483
+ // The field path rides in front of the message: zod says "Required" for a
484
+ // missing field and "Unrecognized key(s)…" for an extra one, and the
485
+ // repair round's whole value is that the scout sees WHICH field failed.
486
+ const first = parsed.error.issues[0];
487
+ const detail =
488
+ first === undefined
489
+ ? "schema violation"
490
+ : first.path.length === 0
491
+ ? first.message
492
+ : `${first.path.join(".")}: ${first.message}`;
493
+ return { state: "repairable", body, failure: { kind: "malformed", detail } };
494
+ }
495
+ return { state: "conforming", body };
496
+ }
497
+
275
498
  /**
276
499
  * Parse and validate a groomer's raw output against the to-spec contract.
277
- * `now` is explicit so staleness and determinism are testable: the same
278
- * input at the same observation time always yields the same outcome. On
279
- * failure the outcome says *why* in one of the three refusal classes:
280
- * unparseable/schema-breaking output is `malformed`, output that never names
281
- * an authoritative source (or lacks name/ref/freshAt) is `missing-source`,
282
- * and a source observed more than `TO_SPEC_MAX_SOURCE_AGE_MS` ago is
283
- * `stale-source`. A `freshAt` in the future is a clock lie, hence malformed.
500
+ * `now` is the settle time, explicit so staleness and determinism are
501
+ * testable: the same input over the same batch window always yields the same
502
+ * outcome. On failure the outcome says *why* in one of the three refusal
503
+ * classes: unparseable/schema-breaking output is `malformed`, output that
504
+ * never names an authoritative source (or lacks name/ref) is
505
+ * `missing-source`, and a batch whose window is wider than
506
+ * `TO_SPEC_MAX_SOURCE_AGE_MS` is `stale-source`.
507
+ *
508
+ * The observation time is the dispatcher's, never the agent's (#1000).
509
+ * `opts.launchedAt` is when the batch was launched — the conservative (older)
510
+ * end of its wall-clock window, so staleness is never understated — and
511
+ * `now` stands in when no launch time is known. Whatever the agent put in
512
+ * `source.freshAt` is overwritten by that stamp before the result is
513
+ * returned, so the persisted evidence carries the dispatcher's own witness
514
+ * and a guessed timestamp can no longer discard a correct verdict.
284
515
  */
285
- export function parseToSpecResult(input: string, now: number): ParseToSpecOutcome {
516
+ export function parseToSpecResult(
517
+ input: string,
518
+ now: number,
519
+ opts?: { launchedAt?: number },
520
+ ): ParseToSpecOutcome {
286
521
  const body = extractJson(input);
287
522
  if (body.length === 0) {
288
523
  return { ok: false, failure: { kind: "malformed", detail: "empty answer — no JSON found" } };
@@ -300,25 +535,31 @@ export function parseToSpecResult(input: string, now: number): ParseToSpecOutcom
300
535
  if (!parsed.success) {
301
536
  const detail = parsed.error.issues[0]?.message ?? "schema violation";
302
537
  const source = raw.source;
303
- if (!isObject(source) || source.name === undefined || source.ref === undefined || source.freshAt === undefined) {
538
+ // `freshAt` is no longer part of this test: the dispatcher stamps it, so
539
+ // a source is complete when it names what was read and at which ref.
540
+ if (!isObject(source) || source.name === undefined || source.ref === undefined) {
304
541
  return { ok: false, failure: { kind: "missing-source", detail: `no authoritative source — ${detail}` } };
305
542
  }
306
543
  return { ok: false, failure: { kind: "malformed", detail } };
307
544
  }
308
- const freshAt = parsed.data.source.freshAt;
309
- if (freshAt > now) {
310
- return { ok: false, failure: { kind: "malformed", detail: `source freshAt ${freshAt} lies in the future of ${now}` } };
311
- }
312
- if (now - freshAt > TO_SPEC_MAX_SOURCE_AGE_MS) {
545
+ // The launch bound when the dispatcher knows it and it precedes the settle
546
+ // time a launch stamped after `now` is clock skew, not evidence, so the
547
+ // settle time stands in rather than producing a negative age (#1000).
548
+ const launchedAt = opts?.launchedAt;
549
+ const observedAt =
550
+ launchedAt !== undefined && Number.isFinite(launchedAt) && launchedAt <= now ? launchedAt : now;
551
+ if (now - observedAt > TO_SPEC_MAX_SOURCE_AGE_MS) {
313
552
  return {
314
553
  ok: false,
315
554
  failure: {
316
555
  kind: "stale-source",
317
- detail: `source observed at ${freshAt} is ${now - freshAt}ms old — older than the ${TO_SPEC_MAX_SOURCE_AGE_MS}ms ceiling`,
556
+ detail:
557
+ `the batch that read the source launched at ${observedAt}, ${now - observedAt}ms before this item ` +
558
+ `settled — older than the ${TO_SPEC_MAX_SOURCE_AGE_MS}ms ceiling`,
318
559
  },
319
560
  };
320
561
  }
321
- return { ok: true, result: parsed.data };
562
+ return { ok: true, result: { ...parsed.data, source: { ...parsed.data.source, freshAt: observedAt } } };
322
563
  }
323
564
 
324
565
  /** What one grooming pass did to the row, so the caller can report it. */
@@ -330,11 +571,38 @@ export type ToSpecGroomingOutcome =
330
571
  export interface ToSpecGroomingRequest {
331
572
  project: string;
332
573
  issue: number;
333
- /** The agent's raw output, exactly as returned. */
574
+ /** The agent's structured answer, exactly as returned: the yield payload,
575
+ * or a conforming block recovered from its text. Empty when the pass
576
+ * produced no answer at all — which is its own refusal class, decided here
577
+ * before anything is parsed (#1064). */
334
578
  input: string;
579
+ /** How the answer arrived: through the yield tool, or recovered from the
580
+ * session's text because it never yielded. `"text"` marks the record so it
581
+ * stays visible that the verdict did not come through the yield contract;
582
+ * omitted reads as a yield, and an empty `input` with no `via` reads as no
583
+ * answer. */
584
+ via?: "yield" | "text";
585
+ /** The session's last narration, recorded as the refusal's context when no
586
+ * answer arrived — never parsed as a verdict (#1064). */
587
+ report?: string;
588
+ /** Whether the one bounded repair round produced the carried answer
589
+ * (#1064); recorded on the row so a repaired verdict is distinguishable. */
590
+ repaired?: boolean;
591
+ /** Whether the session was killed at its turn ceiling before it answered
592
+ * (#1064) — half of the no-answer shape naming. */
593
+ killedAtCeiling?: boolean;
594
+ /** The turn ceiling the pass ran under, and the turns it took: the other
595
+ * half of the no-answer shape naming (`turns >= maxTurns` reads as a cap
596
+ * kill even when the runner did not flag it). */
597
+ maxTurns?: number;
598
+ turns?: number;
335
599
  /** Observation time for staleness and the recorded row; `Date.now()` when
336
600
  * omitted (tests pass it to keep reprocessing deterministic). */
337
601
  now?: number;
602
+ /** When the batch that produced this output was launched, on the
603
+ * dispatcher's own clock (#1000). It becomes the source observation time in
604
+ * preference to `now`; omit it and the settle time stands in. */
605
+ launchedAt?: number;
338
606
  }
339
607
 
340
608
  function failureEvidence(failure: ToSpecFailure, input: string): string {
@@ -354,25 +622,56 @@ function failureEvidence(failure: ToSpecFailure, input: string): string {
354
622
  * `evidence` (`to-spec-failure`) — never as promotable/considered — except
355
623
  * when a prior valid result exists: malformed reprocessing then keeps that
356
624
  * prior row instead of erasing it. Nothing here touches labels or issues.
625
+ *
626
+ * `request.launchedAt` is the dispatcher's launch stamp for the batch (#1000);
627
+ * it, not anything the agent wrote, becomes the recorded source observation
628
+ * time.
357
629
  */
358
- export function recordToSpecGrooming(store: Store, request: ToSpecGroomingRequest): ToSpecGroomingOutcome {
359
- const now = request.now ?? Date.now();
360
- const parsed = parseToSpecResult(request.input, now);
361
- if (parsed.ok) {
362
- const evidence: ToSpecEvidence = { kind: "to-spec", result: parsed.result };
363
- store.upsertGrooming({
364
- project: request.project,
365
- issue: request.issue,
366
- verdict: mapStoreVerdict(parsed.result.verdict),
367
- reason: mapStoreReason(parsed.result.verdict),
368
- evidence: JSON.stringify(evidence),
369
- at: now,
370
- });
371
- return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
630
+ /**
631
+ * The reason a via-text verdict carries: the groomer's own label with a
632
+ * visible marker that the answer arrived outside the yield contract (#1064),
633
+ * so `status` reads `#13 promotable via text` instead of hiding the
634
+ * distinction in an evidence string. Yielded verdicts keep the bare label.
635
+ */
636
+ function mapStoreReasonWithVia(verdict: ToSpecVerdict, via: "yield" | "text" | undefined): string {
637
+ const label = mapStoreReason(verdict);
638
+ return via === "text" ? `${label} via text` : label;
639
+ }
640
+
641
+ /**
642
+ * The no-answer detail names which of the two session shapes produced it
643
+ * (#1064): a turn-ceiling kill (`killedAtCeiling`, or `turns >= maxTurns`
644
+ * when the runner did not flag it) or a session that ended without ever
645
+ * yielding.
646
+ */
647
+ function noAnswerDetail(request: ToSpecGroomingRequest): string {
648
+ const killed =
649
+ request.killedAtCeiling === true ||
650
+ (request.maxTurns !== undefined && request.turns !== undefined && request.turns >= request.maxTurns);
651
+ const turns = request.turns === undefined ? "" : ` after ${request.turns} turn(s)`;
652
+ if (killed) {
653
+ const ceiling = request.maxTurns === undefined ? "its turn ceiling" : `its ${request.maxTurns}-turn ceiling`;
654
+ return `the scout was killed at ${ceiling}${turns} and never yielded an answer`;
372
655
  }
656
+ return `the scout session ended${turns} without yielding an answer`;
657
+ }
658
+
659
+ /**
660
+ * Persist one refusal, or keep a prior valid verdict when the new pass
661
+ * produced something no-answer-like or malformed. `input` is what the failure
662
+ * record carries: the raw payload of the refused pass — or, for a no-answer
663
+ * pass, the session's narration as context (#1064).
664
+ */
665
+ function persistGroomingRefusal(
666
+ store: Store,
667
+ request: ToSpecGroomingRequest,
668
+ failure: ToSpecFailure,
669
+ input: string,
670
+ now: number,
671
+ ): ToSpecGroomingOutcome {
373
672
  const prior = store.grooming(request.project, request.issue);
374
673
  if (
375
- parsed.failure.kind === "malformed" &&
674
+ (failure.kind === "malformed" || failure.kind === "no-answer") &&
376
675
  prior !== undefined &&
377
676
  (prior.verdict === "promotable" || prior.verdict === "considered")
378
677
  ) {
@@ -382,13 +681,83 @@ export function recordToSpecGrooming(store: Store, request: ToSpecGroomingReques
382
681
  project: request.project,
383
682
  issue: request.issue,
384
683
  verdict: "blocked",
385
- reason: FAILURE_REASON[parsed.failure.kind],
386
- evidence: failureEvidence(parsed.failure, request.input),
684
+ reason: FAILURE_REASON[failure.kind],
685
+ evidence: failureEvidence(failure, input),
387
686
  at: now,
388
687
  });
389
688
  return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
390
689
  }
391
690
 
691
+ /**
692
+ * The only way a groomer's output becomes a durable grooming verdict: parse
693
+ * and validate, then upsert through the existing store — no second store, no
694
+ * `GroomingRecord` extension. Valid results map to the store's verdict
695
+ * surface with the full structured result serialized into `evidence`
696
+ * (`{"kind":"to-spec","result":…}`), which is what survives a restart. A
697
+ * refused result persists as `blocked` with the failure named in
698
+ * `evidence` (`to-spec-failure`) — never as promotable/considered — except
699
+ * when a prior valid result exists: malformed and no-answer reprocessing then
700
+ * keep that prior row instead of erasing it. Nothing here touches labels or
701
+ * issues.
702
+ *
703
+ * The refusal classes come from the whole pass, not just its output (#1064):
704
+ *
705
+ * - `no-answer` — the session produced no payload at all (no yield, no
706
+ * conforming text block): a turn-ceiling kill or a session end without a
707
+ * yield, recorded by shape, never as `malformed`. The narration is kept as
708
+ * context only. A text-carried block that fails validation is also
709
+ * `no-answer` — such a session never yielded, and the block is not an
710
+ * answer — except a batch-window staleness, which is a property of the
711
+ * pass rather than the answer.
712
+ * - `malformed` / `missing-source` — a *yielded* payload (or an unjudged
713
+ * one) that violates the contract, with the raw payload persisted so the
714
+ * discarded verdict stays recoverable.
715
+ * - `stale-source` — the batch window outlived the freshness ceiling.
716
+ *
717
+ * `request.launchedAt` is the dispatcher's launch stamp for the batch (#1000);
718
+ * it, not anything the agent wrote, becomes the recorded source observation
719
+ * time.
720
+ */
721
+ export function recordToSpecGrooming(store: Store, request: ToSpecGroomingRequest): ToSpecGroomingOutcome {
722
+ const now = request.now ?? Date.now();
723
+ if (request.input === "") {
724
+ return persistGroomingRefusal(
725
+ store,
726
+ request,
727
+ { kind: "no-answer", detail: noAnswerDetail(request) },
728
+ request.report ?? "",
729
+ now,
730
+ );
731
+ }
732
+ const parsed = parseToSpecResult(request.input, now, { launchedAt: request.launchedAt });
733
+ if (parsed.ok) {
734
+ const evidence: ToSpecEvidence = {
735
+ kind: "to-spec",
736
+ result: parsed.result,
737
+ ...(request.via === undefined ? {} : { via: request.via }),
738
+ ...(request.repaired === true ? { repaired: true } : {}),
739
+ };
740
+ store.upsertGrooming({
741
+ project: request.project,
742
+ issue: request.issue,
743
+ verdict: mapStoreVerdict(parsed.result.verdict),
744
+ reason: mapStoreReasonWithVia(parsed.result.verdict, request.via),
745
+ evidence: JSON.stringify(evidence),
746
+ at: now,
747
+ });
748
+ return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
749
+ }
750
+ // A text-carried answer that fails validation was never yielded and does
751
+ // not conform: that is a no-answer pass, with the block kept as the
752
+ // failure's context — never malformed (#1064). The one exception is a
753
+ // batch-window staleness, whose class is about the pass, not the answer.
754
+ const failure =
755
+ request.via === "text" && parsed.failure.kind !== "stale-source"
756
+ ? { kind: "no-answer" as const, detail: noAnswerDetail(request) }
757
+ : parsed.failure;
758
+ return persistGroomingRefusal(store, request, failure, request.input, now);
759
+ }
760
+
392
761
  /**
393
762
  * Recover the validated result from a record's evidence (the restart
394
763
  * round-trip). Returns undefined for anything that is not a `to-spec`
@@ -404,7 +773,15 @@ export function parseToSpecEvidence(evidence: string): ToSpecResult | undefined
404
773
  }
405
774
  if (!isObject(raw) || raw.kind !== "to-spec") return undefined;
406
775
  const parsed = ToSpecResultSchema.safeParse(raw.result);
407
- return parsed.success ? parsed.data : undefined;
776
+ if (!parsed.success) return undefined;
777
+ const freshAt = parsed.data.source.freshAt;
778
+ // A row with no stamped observation time cannot have its staleness judged,
779
+ // and freshness is precisely the field nobody may forge (#1000) — so the row
780
+ // reads as not-durably-groomed and becomes re-groomable, rather than
781
+ // carrying a fabricated timestamp that would read fresh forever. Every row
782
+ // `recordToSpecGrooming` writes is stamped; this is the hand-edited case.
783
+ if (freshAt === undefined) return undefined;
784
+ return { ...parsed.data, source: { ...parsed.data.source, freshAt } };
408
785
  }
409
786
 
410
787
  /**
@@ -428,7 +805,12 @@ export function parseToSpecFailureEvidence(evidence: string): ToSpecFailure | un
428
805
  if (!isObject(raw) || raw.kind !== "to-spec-failure") return undefined;
429
806
  const failure = raw.failure;
430
807
  if (!isObject(failure) || typeof failure.detail !== "string") return undefined;
431
- if (failure.kind !== "malformed" && failure.kind !== "missing-source" && failure.kind !== "stale-source") {
808
+ if (
809
+ failure.kind !== "malformed" &&
810
+ failure.kind !== "no-answer" &&
811
+ failure.kind !== "missing-source" &&
812
+ failure.kind !== "stale-source"
813
+ ) {
432
814
  return undefined;
433
815
  }
434
816
  return { kind: failure.kind, detail: failure.detail };