@coreplane/switchboard 1.254.1 → 1.256.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 (66) hide show
  1. package/dist/assets/.dockerignore +3 -0
  2. package/dist/assets/Dockerfile +12 -1
  3. package/dist/assets/config/config.example.yaml +6 -1
  4. package/dist/assets/deploy/cloudflare/worker.ts +39 -23
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +552 -5
  6. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +5 -0
  7. package/dist/assets/deploy/cloudflare-resident/Dockerfile +13 -1
  8. package/dist/assets/deploy/cloudflare-resident/drain.ts +55 -1
  9. package/dist/assets/deploy/cloudflare-resident/levels.ts +84 -0
  10. package/dist/assets/deploy/cloudflare-resident/prepare-commit-msg +17 -0
  11. package/dist/assets/deploy/cloudflare-resident/worker.ts +358 -42
  12. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -1
  13. package/dist/assets/deploy/cloudflare-sandbox/prepare-commit-msg +17 -0
  14. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +21 -4
  15. package/dist/assets/deploy/hooks/prepare-commit-msg +17 -0
  16. package/dist/assets/deploy/secrets.manifest.json +12 -0
  17. package/dist/assets/package-lock.json +3 -3
  18. package/dist/assets/package.json +1 -1
  19. package/dist/assets/source.json +3 -3
  20. package/dist/assets/src/agents/registry.ts +52 -0
  21. package/dist/assets/src/core/budgets.ts +35 -2
  22. package/dist/assets/src/core/coordinator/contract.ts +6 -0
  23. package/dist/assets/src/core/coordinator/driver.ts +59 -6
  24. package/dist/assets/src/core/costs.ts +39 -16
  25. package/dist/assets/src/core/pipelineStanding.ts +62 -0
  26. package/dist/assets/src/core/plane/decide.ts +389 -22
  27. package/dist/assets/src/core/refusal.ts +3 -0
  28. package/dist/assets/src/core/reviewVerdict.ts +64 -2
  29. package/dist/assets/src/core/runEvents.ts +31 -12
  30. package/dist/assets/src/core/runLedger/sessionLog.ts +128 -0
  31. package/dist/assets/src/core/runLedger/types.ts +3 -0
  32. package/dist/assets/src/core/runRecord.ts +24 -7
  33. package/dist/assets/src/core/ship/contract.ts +20 -30
  34. package/dist/assets/src/core/ship/coordinator.ts +519 -126
  35. package/dist/assets/src/core/trace/workerTrace.ts +3 -0
  36. package/dist/assets/src/execution/sandboxErrors.ts +77 -6
  37. package/dist/assets/web/dist/.vite/manifest.json +61 -60
  38. package/dist/assets/web/dist/assets/CostsPage-BaeWnm-o.js +1 -0
  39. package/dist/assets/web/dist/assets/{DeliveryPage-ngPsO2to.js → DeliveryPage-BBAyLwPq.js} +1 -1
  40. package/dist/assets/web/dist/assets/{HomePage-DvxTHzPx.js → HomePage-BEiStFwW.js} +1 -1
  41. package/dist/assets/web/dist/assets/PendingTurnRow-DGINv9XT.js +1 -0
  42. package/dist/assets/web/dist/assets/{PlanePage-DpWfiX4C.js → PlanePage-JEj-lqgz.js} +1 -1
  43. package/dist/assets/web/dist/assets/{ResidentDetailPage-DG86v39Y.js → ResidentDetailPage-jYhEeeyu.js} +1 -1
  44. package/dist/assets/web/dist/assets/{ResidentsIndexPage-x6p689VH.js → ResidentsIndexPage-DvFmlV4U.js} +1 -1
  45. package/dist/assets/web/dist/assets/RunFoldRow-DvWzR1JQ.js +1 -0
  46. package/dist/assets/web/dist/assets/RunRoutePage-CvZ-TOT3.js +9 -0
  47. package/dist/assets/web/dist/assets/{RunsIndexPage-CuzFchcn.js → RunsIndexPage-JqVSDOok.js} +1 -1
  48. package/dist/assets/web/dist/assets/{ScheduledPage-BuLmfcbG.js → ScheduledPage-Viim-bus.js} +1 -1
  49. package/dist/assets/web/dist/assets/{SettingsPage-BujWkdU_.js → SettingsPage-CYUy8McC.js} +1 -1
  50. package/dist/assets/web/dist/assets/{StatusDot-C8Bc0pTX.js → StatusDot-Dv6UMaPy.js} +1 -1
  51. package/dist/assets/web/dist/assets/{Tooltip-BWwJx27K.js → Tooltip-Bd-5Rypv.js} +1 -1
  52. package/dist/assets/web/dist/assets/UnitRoutePage-B81wEnKN.js +1 -0
  53. package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +1 -0
  54. package/dist/assets/web/dist/assets/{dist-CpnyQGOb.js → dist-Nl3uaxrP.js} +1 -1
  55. package/dist/assets/web/dist/assets/indexRow-DborJPFp.js +1 -0
  56. package/dist/assets/web/dist/assets/{main-Comxmwi4.js → main-DRxWlffc.js} +2 -2
  57. package/dist/assets/web/dist/assets/{sseReplay-IzTdD4-3.js → sseReplay-DE6wv1Ua.js} +6 -6
  58. package/dist/cli.js +4400 -3079
  59. package/package.json +1 -1
  60. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +0 -1
  61. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +0 -1
  62. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +0 -1
  63. package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +0 -9
  64. package/dist/assets/web/dist/assets/UnitRoutePage-B9kjA1AT.js +0 -1
  65. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +0 -1
  66. package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +0 -1
@@ -33,6 +33,7 @@
33
33
 
34
34
  import { normalizeHead } from "./reviewedHead.js";
35
35
  import { redactSecrets } from "./redact.js";
36
+ import { shows, type Verbosity } from "./verbosity.js";
36
37
 
37
38
  export type ReviewVerdictKind = "approve" | "request_changes";
38
39
 
@@ -108,6 +109,14 @@ export interface Finding {
108
109
  line?: number;
109
110
  /** One line naming the issue; the full explanation lives in the prose. */
110
111
  title: string;
112
+ /** The finding's remedy is a receipt only a person can produce — a replay
113
+ * that needs a provider credential no sandbox holds, a procedure a person
114
+ * runs live — so no fix round can address it. Set by the reviewer beside the
115
+ * severity through `submit_verdict`; ship's coordinator reads this flag,
116
+ * never prose, and a round whose actionable findings all carry it ends
117
+ * `held` (docs/reference/specs/agent-ship.md item 9). Anything but the
118
+ * literal `true` is dropped and the finding stands as actionable. */
119
+ humanGated?: true;
111
120
  /** Machine provenance: true only on a check finding the ship round's checks
112
121
  * step itself appended (ship/coordinator.ts `checkFinding`) — never set from
113
122
  * a reviewer's input, whatever id the reviewer chose. */
@@ -222,6 +231,9 @@ function parseFinding(
222
231
  // dropping the whole finding over a bad line would also drop the severity
223
232
  // that the approve→request_changes downgrade keys on.
224
233
  if (typeof r.line === "number" && Number.isInteger(r.line) && r.line >= 1) finding.line = r.line;
234
+ // Fail-open on the flag alone: a malformed humanGated never drops the
235
+ // finding — it stands as actionable, which is the conservative reading.
236
+ if (r.humanGated === true) finding.humanGated = true;
225
237
  return { finding };
226
238
  }
227
239
 
@@ -237,11 +249,42 @@ export function verdictLine(verdict: ReviewVerdict | undefined): string {
237
249
  return summary ? `${token} ${summary}` : token;
238
250
  }
239
251
 
252
+ /** The counted plural for a severity: `blocker` and `nit` inflect, `major`
253
+ * and `minor` read as adjectives and stay uninflected. */
254
+ function severityCount(sev: FindingSeverity, n: number): string {
255
+ if (sev === "blocking") return `${n} blocker${n === 1 ? "" : "s"}`;
256
+ if (sev === "nit") return `${n} nit${n === 1 ? "" : "s"}`;
257
+ return `${n} ${sev}`;
258
+ }
259
+
260
+ /** The verdict as ONE line in the user's words (record 0066): `LGTM` for an
261
+ * approve, `Changes requested: 2 blockers, 1 major, 2 minor, 3 nits` — only
262
+ * the non-zero counts, most severe first — for a request for changes, the
263
+ * bare token word when the findings were not itemized or none were filed.
264
+ * The quiet thread reply prints this and nothing more; the full verdict line
265
+ * and the finding bullets are `verbose` material and stay on the pull
266
+ * request, where the post-step put them. */
267
+ export function verdictCountsLine(verdict: ReviewVerdict): string {
268
+ if (verdict.verdict === "approve") return "LGTM";
269
+ const counts = severityCounts(verdict.findings ?? []);
270
+ return counts ? `Changes requested: ${counts}` : "Changes requested";
271
+ }
272
+
273
+ /** The non-zero severity counts of a finding list, most severe first —
274
+ * `2 blockers, 1 major, 2 minor, 3 nits` — or the empty string for none.
275
+ * Shared by the quiet verdict line and the quiet round-cap report. */
276
+ export function severityCounts(findings: readonly Finding[]): string {
277
+ return FINDING_SEVERITIES.map((sev) => [sev, findings.filter((f) => f.severity === sev).length] as const)
278
+ .filter(([, n]) => n > 0)
279
+ .map(([sev, n]) => severityCount(sev, n))
280
+ .join(", ");
281
+ }
282
+
240
283
  /** One compact finding line — `[severity] id file[:line] — title` — shared by
241
284
  * the posted body's list (bulleted below) and ship's synthesized child turns. */
242
285
  export function formatFinding(f: Finding): string {
243
286
  const location = f.line !== undefined ? `${f.file}:${f.line}` : f.file;
244
- return `[${f.severity}] ${f.id} ${location} — ${f.title}`;
287
+ return `[${f.severity}] ${f.id} ${location} — ${f.title}${f.humanGated ? " (human-gated)" : ""}`;
245
288
  }
246
289
 
247
290
  /** One compact disposition line — `id: fixed|declined[ — note]` — the coding
@@ -318,6 +361,7 @@ function verdictMarker(verdict: ReviewVerdict | undefined, target: ReviewBodyTar
318
361
  severity: f.severity,
319
362
  file: f.file,
320
363
  ...(f.line !== undefined ? { line: f.line } : {}),
364
+ ...(f.humanGated ? { humanGated: true } : {}),
321
365
  })),
322
366
  }
323
367
  : {}),
@@ -369,6 +413,13 @@ export function buildReviewPostBody(
369
413
  * opt-out, a guard refusal) or findings not itemized (a list that says
370
414
  * nothing is no substitute) — so the review's text is always somewhere a
371
415
  * person reads it. No verdict → the bare answer with the link, as before.
416
+ *
417
+ * The request's verbosity decides how much of the verdict the thread hears
418
+ * (routing-and-config item 28, record 0066): below `verbose`, a verdict whose
419
+ * findings are itemized and posted to GitHub is ONE line — `verdictCountsLine`
420
+ * with the pull request link — because the full verdict, the finding lines and
421
+ * the prose already stand on the pull request; at `verbose`, and whenever the
422
+ * text would otherwise land nowhere a person reads it, the full render above.
372
423
  */
373
424
  export function buildReviewChannelReply(input: {
374
425
  answer: string;
@@ -376,8 +427,18 @@ export function buildReviewChannelReply(input: {
376
427
  /** The PR the post landed on, or undefined when nothing was posted. */
377
428
  posted: { repo: string; number: number } | undefined;
378
429
  liveUrl: string | undefined;
430
+ /** The request's level (default `verbose`: the full render, the row's shape). */
431
+ verbosity?: Verbosity;
379
432
  }): string {
380
433
  const { answer, verdict, posted, liveUrl } = input;
434
+ if (
435
+ !shows(input.verbosity ?? "verbose", "verbose") &&
436
+ verdict !== undefined &&
437
+ verdict.findings !== undefined &&
438
+ posted !== undefined
439
+ ) {
440
+ return `${verdictCountsLine(verdict)} — https://github.com/${posted.repo}/pull/${posted.number}`;
441
+ }
381
442
  const tail = [
382
443
  ...(posted && verdict ? [`Posted to ${posted.repo}#${posted.number}`] : []),
383
444
  ...(liveUrl ? [`[Live run](${liveUrl})`] : []),
@@ -463,7 +524,8 @@ function isFindingShape(v: unknown): v is Finding {
463
524
  (FINDING_SEVERITIES as readonly string[]).includes(v.severity as string) &&
464
525
  typeof v.file === "string" &&
465
526
  typeof v.title === "string" &&
466
- (v.line === undefined || typeof v.line === "number")
527
+ (v.line === undefined || typeof v.line === "number") &&
528
+ (v.humanGated === undefined || v.humanGated === true)
467
529
  );
468
530
  }
469
531
 
@@ -418,6 +418,16 @@ export type ShipRoundOutcome =
418
418
  * 0055): the failures become check findings and the findings step runs as
419
419
  * for any changes-requested round. */
420
420
  | "checks_failed"
421
+ /** The round's coding child died on a provider transient with nothing
422
+ * pushed (issue 1932): the first such boundary marks the round's one
423
+ * re-run, a second the `transient` ending. */
424
+ | "transient"
425
+ /** The merge door enqueued the pull request — the base takes changes only
426
+ * through a merge queue (issue 2011): the unit waits for the queue's outcome. */
427
+ | "enqueued"
428
+ /** The merge queue removed the pull request: the removal reason becomes a
429
+ * finding of the round, like a red check, and a fix round follows. */
430
+ | "dequeued"
421
431
  | "aborted"
422
432
  | "stopped"
423
433
  /** The coding round ended at its lease with the unit unfinished and the row
@@ -1019,6 +1029,10 @@ export type RunEvent =
1019
1029
  command?: string;
1020
1030
  input?: { readonly [key: string]: RouteInputValue };
1021
1031
  receipt?: string;
1032
+ /** The structured seam's attempts ([record 0067](../../docs/decisions/0067-one-seam-for-a-structured-answer-a-violation-is-re-asked-with-the-violation-named-and-the-callers-declared-floor-holds-never-a-refusal-shown-to-the-person.md)):
1033
+ * what each answer violated, or that it was accepted, so a flaky model
1034
+ * is legible on the record as re-asks, not as silent floors. */
1035
+ attempts?: ReadonlyArray<{ outcome: "accepted" | "violation"; violation?: string }>;
1022
1036
  outcome?: RouteOutcome;
1023
1037
  /** The refusal's code (src/core/refusal.ts) when `outcome` is `refused`
1024
1038
  * (record 0054): a refusal after a command was bound is a run
@@ -1031,28 +1045,33 @@ export type RunEvent =
1031
1045
  /** The operator's decision beside the routed request ([record 0057](../../docs/decisions/0057-the-operator-is-the-one-door-a-model-binds-every-chat-input-and-deterministic-code-authorizes-fences-and-executes.md);
1032
1046
  * the one-door plan's operator unit; run-history item 60): one per admitted chat
1033
1047
  * event under `routing.operator: shadow` or `on`, published beside the
1034
- * `route` event. The decision is binds, a question or a refusal; a bind's
1035
- * `line` is redacted and cut like the receipt (`ROUTE_RECEIPT_CAP`), never
1036
- * the message text; `intake` carries the intake gate's verdict when the
1037
- * gate is present; `latencyMs` and `outputTokens` feed the replay's median
1038
- * rows. Under `shadow` nothing runs from it. Additive: unknown → ignored. */
1048
+ * `route` event. The decision is binds, a question, a refusal or — the
1049
+ * structured seam's floor ([record 0067](../../docs/decisions/0067-one-seam-for-a-structured-answer-a-violation-is-re-asked-with-the-violation-named-and-the-callers-declared-floor-holds-never-a-refusal-shown-to-the-person.md)),
1050
+ * never the model's decision — `non_decision`: under `on` the dispatcher
1051
+ * falls back to the readers' route for that event, this event recorded on
1052
+ * the run that then runs; `attempts` lists what each answer violated or
1053
+ * that it was accepted. A bind's `line` is redacted and cut like the
1054
+ * receipt (`ROUTE_RECEIPT_CAP`), never the message text; `intake` carries
1055
+ * the intake gate's verdict when the gate is present; `latencyMs` and
1056
+ * `outputTokens` feed the replay's median rows. Under `shadow` nothing
1057
+ * runs from it. Additive: unknown → ignored. */
1039
1058
  | {
1040
1059
  type: "operator";
1041
1060
  mode: "shadow" | "on";
1042
- outcome: "binds" | "question" | "refusal";
1061
+ outcome: "binds" | "question" | "refusal" | "non_decision";
1043
1062
  reason: string;
1044
- binds?: ReadonlyArray<{ line: string; reason: string }>;
1063
+ /** A bind marked `confirmed` is a pending question's confirmed proposal
1064
+ * (`bindFromAnswer`): the line itself carries the task — the person's
1065
+ * message was the word "yes" — so a confirmed preset line routes its
1066
+ * own tail as the request. Additive: unknown → a fresh bind. */
1067
+ binds?: ReadonlyArray<{ line: string; reason: string; confirmed?: true }>;
1045
1068
  question?: string;
1046
1069
  /** A question's proposed line, redacted and cut like the receipt — what
1047
1070
  * the next turn's "yes" binds (`bindFromAnswer`). */
1048
1071
  proposal?: string;
1049
1072
  refusalCause?: string;
1050
1073
  refusalText?: string;
1051
- /** A refusal the seam itself produced (a non-decision answer, a wrong
1052
- * tool, a transport failure) — never the model's decision: under `on`
1053
- * the dispatcher falls back to the readers' route for that event, this
1054
- * event recorded on the run that then runs. */
1055
- fallback?: true;
1074
+ attempts?: ReadonlyArray<{ outcome: "accepted" | "violation"; violation?: string }>;
1056
1075
  intake?: { verdict: string; reason: string };
1057
1076
  latencyMs?: number;
1058
1077
  outputTokens?: number;
@@ -35,6 +35,134 @@ export function sessionKey(threadKey: string, agent: string | undefined): string
35
35
  return `${threadKey}:${agent ?? "-"}`;
36
36
  }
37
37
 
38
+ /** The thread session's key (record 0057; the one-door plan's memory unit,
39
+ * item 13): one log per thread, read and written by the operator — every
40
+ * connector event the intake gate admits, every child's report, every
41
+ * question and answer is a turn in it. The `@thread` half can never collide
42
+ * with `sessionKey`'s agent half: no agent id starts with `@`. */
43
+ export function threadSessionKey(threadKey: string): string {
44
+ return `${threadKey}:@thread`;
45
+ }
46
+
47
+ /** A working session's lane: a unit's coding rounds continue one log, its
48
+ * review rounds another (item 13). */
49
+ export type WorkingLane = "coding" | "review";
50
+
51
+ /** A working session's key `<instance>:<unit>:<lane>` (item 13). A re-issue of the
52
+ * same plan carries an attempt suffix on its instance id (`planInstanceId`:
53
+ * `plan-<id>-<attempt>`, attempt ≥ 2); the key strips it, so a re-issue
54
+ * continues the prior instance's lanes rather than starting cold. */
55
+ export function workingSessionKey(instance: { id: string; attempt?: number }, unit: string, lane: WorkingLane): string {
56
+ const suffix = instance.attempt !== undefined ? `-${instance.attempt}` : "";
57
+ const base =
58
+ suffix !== "" && instance.id.endsWith(suffix)
59
+ ? instance.id.slice(0, instance.id.length - suffix.length)
60
+ : instance.id;
61
+ return `${base}:${unit}:${lane}`;
62
+ }
63
+
64
+ /** A connector turn's row id (item 13): the message id, with its edit timestamp
65
+ * when the message was edited — an edited message appends a second row (the
66
+ * first said what the person first said), a re-delivered unedited one
67
+ * appends nothing. */
68
+ export function connectorRowId(messageId: string, editedAt?: string | number): string {
69
+ return editedAt === undefined ? messageId : `${messageId}:edit-${editedAt}`;
70
+ }
71
+
72
+ /** The fold's row id for a hosted parent's `ship_unit` event (item 13): the
73
+ * event's own identity — its unit, its state and the registry seq it was
74
+ * published under — so the same event read twice folds one row. The seq is
75
+ * required (the registry stamps every published event with one): without it,
76
+ * a unit reopened at a second segment would repeat a state — two `started`
77
+ * events — and the second fold would be dropped as a duplicate. */
78
+ export function shipUnitRowId(event: { unit: string; state: string; seq: number }): string {
79
+ return `ship-unit:${event.unit}:${event.state}:${event.seq}`;
80
+ }
81
+
82
+ /** One thread-session row as its JSON is stored (item 13): a text turn with
83
+ * its author when a person wrote it, `silent` when the intake gate withheld
84
+ * the reply (the person's words are context all the same), `folded` when the
85
+ * row is a child's report folded whole — what `operatorTail` keeps ahead of
86
+ * older turns. */
87
+ export function storedTurnRow(turn: {
88
+ role: "user" | "assistant";
89
+ text: string;
90
+ actor?: string;
91
+ silent?: boolean;
92
+ folded?: boolean;
93
+ }): string {
94
+ return JSON.stringify({
95
+ role: turn.role,
96
+ part: { type: "text", text: turn.text },
97
+ ...(turn.actor !== undefined ? { actor: turn.actor } : {}),
98
+ ...(turn.silent === true ? { silent: true } : {}),
99
+ ...(turn.folded === true ? { folded: true } : {}),
100
+ });
101
+ }
102
+
103
+ /** Whether a stored row is a reply the intake gate withheld as silent (item 13). */
104
+ export function silentOfStoredRow(json: string): boolean {
105
+ const stored = parseStored(json);
106
+ return stored !== undefined && !("compaction" in stored) && (stored as { silent?: unknown }).silent === true;
107
+ }
108
+
109
+ /** Whether a stored row is a folded report (item 13) — kept whole by the
110
+ * operator's cap ahead of older turns. */
111
+ export function foldedOfStoredRow(json: string): boolean {
112
+ const stored = parseStored(json);
113
+ return stored !== undefined && !("compaction" in stored) && (stored as { folded?: unknown }).folded === true;
114
+ }
115
+
116
+ /** A run of an old `<thread>:<agent>` log, as the cutover migration reads it
117
+ * (item 13): its log's key, when it started and the row range its record closed. */
118
+ export interface MigrationRun {
119
+ key: string;
120
+ startedAt: number;
121
+ range: { from: number; to?: number };
122
+ }
123
+
124
+ /** The order the cutover migration reads an old thread's per-agent logs into
125
+ * the thread session (item 13): the runs by their start times, each run's rows in
126
+ * its session range in index order, and rows outside any run's range after
127
+ * the runs that precede them in their own log (before every run of that log
128
+ * when none does). The read is once: each row lands under the row id
129
+ * `migrationRowId(key, idx)`, so a replay appends nothing twice, and the old
130
+ * keys stay read-only for recall. */
131
+ export function migrationOrder(
132
+ runs: readonly MigrationRun[],
133
+ logs: readonly { key: string; rows: readonly number[] }[],
134
+ ): Array<{ key: string; idx: number }> {
135
+ const byStart = [...runs].sort((a, b) => a.startedAt - b.startedAt);
136
+ // A row's place: inside a run's range it rides at that run's start (phase 0);
137
+ // past a run's closed range it rides after that run's rows (phase 1); before
138
+ // every run of its log it comes first of all (start -Infinity).
139
+ const placed = logs.flatMap((log, logOrder) =>
140
+ log.rows.map((idx) => {
141
+ let at = Number.NEGATIVE_INFINITY;
142
+ let phase = 1;
143
+ for (const r of byStart) {
144
+ if (r.key !== log.key) continue;
145
+ const to = r.range.to;
146
+ if (idx >= r.range.from && (to === undefined || idx <= to)) {
147
+ at = r.startedAt;
148
+ phase = 0;
149
+ break;
150
+ }
151
+ if (to !== undefined && to < idx && r.startedAt > at) at = r.startedAt;
152
+ }
153
+ return { key: log.key, idx, at, phase, logOrder };
154
+ }),
155
+ );
156
+ placed.sort((a, b) => a.at - b.at || a.phase - b.phase || a.logOrder - b.logOrder || a.idx - b.idx);
157
+ return placed.map(({ key, idx }) => ({ key, idx }));
158
+ }
159
+
160
+ /** The row id a migrated row lands under (item 13): the old log's key and the
161
+ * row's index there — stable, so the read-once migration is idempotent. */
162
+ export function migrationRowId(key: string, idx: number): string {
163
+ return `migrated:${key}#${idx}`;
164
+ }
165
+
38
166
  /** The request is the seed's last user turn (`splitSeed` reads the seed the
39
167
  * same way); a seed with no user turn — or no turns — puts its first row there. */
40
168
  export function requestIndex(seed: readonly ChatMessage[]): number {
@@ -287,6 +287,9 @@ export interface IntakeReceipt {
287
287
  verdict: "addressed" | "silent";
288
288
  reason: string;
289
289
  source: "model" | "mode" | "error" | "timeout";
290
+ /** The structured seam's attempts (docs/decisions/0067): what each answer
291
+ * violated, or that it was accepted; absent when no model was asked. */
292
+ attempts?: ReadonlyArray<{ outcome: "accepted" | "violation"; violation?: string }>;
290
293
  mode: "mention" | "classify";
291
294
  /** The `<provider>/<model>` ref the verdict ran on. */
292
295
  model: string;
@@ -392,9 +392,10 @@ export function callsInFlight(events: readonly RunEvent[], status: RunStatus): C
392
392
  return [...calls.values()].filter((c) => c.state === "cut" || (c.state === "open" && !orderly)).map((c) => c.call);
393
393
  }
394
394
 
395
- /** The router's decision as a record carries it — the same fields the
396
- * `route` event and the ledger row's `meta.route` carry (routing-and-config
397
- * item 21). */
395
+ /** The router's decision as a record carries it — the fields the `route`
396
+ * event and the ledger row's `meta.route` carry (routing-and-config item 21),
397
+ * minus the event's `attempts` (record 0067): the record keeps the decision,
398
+ * the re-asks stay on the event. */
398
399
  export interface RunRouteDecision {
399
400
  preset: string;
400
401
  reason: string;
@@ -425,7 +426,7 @@ export function routeOfEvents(events: readonly RunEvent[]): RunRouteDecision | u
425
426
  * fields less the stream bookkeeping (record 0057; run-history item 60). */
426
427
  export interface RunOperatorDecision {
427
428
  mode: "shadow" | "on";
428
- outcome: "binds" | "question" | "refusal";
429
+ outcome: "binds" | "question" | "refusal" | "non_decision";
429
430
  reason: string;
430
431
  binds?: { line: string; reason: string }[];
431
432
  question?: string;
@@ -433,6 +434,9 @@ export interface RunOperatorDecision {
433
434
  proposal?: string;
434
435
  refusalCause?: string;
435
436
  refusalText?: string;
437
+ /** The structured seam's attempts (record 0067): what each answer violated,
438
+ * or that it was accepted. */
439
+ attempts?: { outcome: "accepted" | "violation"; violation?: string }[];
436
440
  intake?: { verdict: string; reason: string };
437
441
  latencyMs?: number;
438
442
  outputTokens?: number;
@@ -454,6 +458,14 @@ export function operatorOfEvents(events: readonly RunEvent[]): RunOperatorDecisi
454
458
  ...(e.proposal !== undefined ? { proposal: e.proposal } : {}),
455
459
  ...(e.refusalCause !== undefined ? { refusalCause: e.refusalCause } : {}),
456
460
  ...(e.refusalText !== undefined ? { refusalText: e.refusalText } : {}),
461
+ ...(e.attempts
462
+ ? {
463
+ attempts: e.attempts.map((a) => ({
464
+ outcome: a.outcome,
465
+ ...(a.violation !== undefined ? { violation: a.violation } : {}),
466
+ })),
467
+ }
468
+ : {}),
457
469
  ...(e.intake ? { intake: { verdict: e.intake.verdict, reason: e.intake.reason } } : {}),
458
470
  ...(e.latencyMs !== undefined ? { latencyMs: e.latencyMs } : {}),
459
471
  ...(e.outputTokens !== undefined ? { outputTokens: e.outputTokens } : {}),
@@ -497,9 +509,14 @@ function isRunPullRequestShape(v: unknown): v is RunPullRequest {
497
509
  * (item 57). `policy_refusal`: the model provider refused the run's call
498
510
  * under its usage policy — the stop reason its wire names, never the
499
511
  * explanation's words — so the session's next seed leaves the refused
500
- * request out of its tail (docs/reference/specs/session-log.md item 9). A
501
- * failure without a name here leaves the record without the field. */
502
- export const RUN_FAILURE_KINDS = ["policy_refusal"] as const;
512
+ * request out of its tail (docs/reference/specs/session-log.md item 9).
513
+ * `provider_transient`: the run's model call failed on a provider transient
514
+ * (a gateway 5xx, a cut stream, a gateway timeout) with the harness's retry
515
+ * ladder spent — the ship runner reads it off the child's record to re-run a
516
+ * round-0 child that pushed nothing instead of aborting the unit
517
+ * (docs/reference/specs/agent-ship.md item 9, issue 1932). A failure without
518
+ * a name here leaves the record without the field. */
519
+ export const RUN_FAILURE_KINDS = ["policy_refusal", "provider_transient"] as const;
503
520
  export type RunFailureKind = (typeof RUN_FAILURE_KINDS)[number];
504
521
  export interface RunFailure {
505
522
  kind: RunFailureKind;
@@ -439,42 +439,32 @@ export const TIMEOUT_ON_LONG_COMMANDS =
439
439
  "State a timeout on any command you expect to run longer than a minute: a timeout that reaches past the loop's " +
440
440
  "end is refused before the command runs, never cut midway.";
441
441
 
442
- /** The fast gates a plan child runs before every push, named one by one and
443
- * each scoped to the changed set (agent-ship item 13; agent-coding item 9).
444
- * "Its cheapest proving checks" left the choice to the child, and children
445
- * chose wrong in both directions: prettier was reported clean while
446
- * `format:check` was red, hygiene imprints reached CI that `hygiene:check`
447
- * would have caught locally — and children ran the whole suite and the whole
448
- * typecheck on the shared resident, minutes each call, time-sliced against
449
- * every other run. So the gates are the changed-set forms, the full runs are
450
- * said to be CI's alone in the same breath, and each gate is a receipt — the
451
- * exit line goes into the PR description's validation table as the row's proof
452
- * (the handoff has no verified list; parseHandoff carries deviations, followUps,
453
- * unproven and landed), and a gate the child could not run goes under the
454
- * handoff's unproven list, never claimed clean. The test gate is the touched
455
- * files by name, never a changed-set or directory run. */
456
- /** The test command the contract hands a child: the touched files by name, once.
457
- * Never `--changed`: against a base that moves, it selects most of the suite, and on the
458
- * shared resident that is the memory incident the coding contract exists to prevent. */
459
- export const TOUCHED_TESTS_COMMAND = "`npx vitest run` on the test files you touched, by name,";
460
-
461
- export const FAST_GATES_BEFORE_PUSH =
462
- "The fast gates, before every push — each scoped to the changed set, never the whole project: " +
463
- `${TOUCHED_TESTS_COMMAND} once (never \`--changed\`, never a directory: on a moving base that is most of the suite), ` +
464
- "`tsc --noEmit -p` the touched tsconfig under `NODE_OPTIONS=--max-old-space-size=6144`, " +
465
- "`npx prettier --check` on the changed files, `npm run hygiene:check` and `npm run specs:check` — " +
466
- "then your judgement on what else this change needs, not a longer checklist. Every CI pipeline runs the " +
467
- "tests, the types, the formatting and the full verification on your push, so you never run them again: " +
468
- "you validate and fix your own change before pushing, at the changed-set scope. Passing the full test suite " +
469
- "and the full typecheck is NOT part of your criteria: CI is that gate and the only place they run — on a " +
470
- "shared resident they cost minutes that every other run pays for. " +
442
+ /** The fast gates a plan child runs before every push live in the coding
443
+ * preset's own instructions (`FAST_GATES_BEFORE_PUSH`,
444
+ * src/agents/registry.ts — agent-coding item 13; issue 1796): the changed-set
445
+ * forms with their commands named, the full verification named as CI's gate.
446
+ * The contract used to restate the whole paragraph, so every ask that was not
447
+ * a plan unit had to repeat it by hand; now the first instruction points at
448
+ * the preset's paragraph — the child reads the sentence once — and keeps only
449
+ * what is the contract's own: the receipts. Each gate is a receipt — the exit
450
+ * line goes into the PR description's validation table as the row's proof
451
+ * (the handoff has no verified list; parseHandoff carries deviations,
452
+ * followUps, unproven and landed), and a gate the child could not run goes
453
+ * under the handoff's unproven list, never claimed clean — because only a
454
+ * plan child has a handoff to route them to. */
455
+ export const FAST_GATES_POINTER =
456
+ "The fast gates are the ones your preset instructions name (THE FAST GATES): the changed-set forms, " +
457
+ "never the whole project — the full suite, the full typecheck and the full verification are CI's, " +
458
+ "never yours to run.";
459
+
460
+ export const GATE_RECEIPTS =
471
461
  "Paste each command's exit line into the PR description's validation table as the row's proof; a gate you " +
472
462
  "could not run goes under the handoff's unproven list and is never claimed clean.";
473
463
 
474
464
  function renderFirstInstruction(rebase: ChildContract["rebase"]): string {
475
465
  const branch = rebase.branch ? `\`${rebase.branch}\`` : "the unit's branch";
476
466
  const onto = rebase.onto ? `\`${rebase.onto}\`` : "the merged parent";
477
- const gates = FAST_GATES_BEFORE_PUSH;
467
+ const gates = `${FAST_GATES_POINTER} ${GATE_RECEIPTS}`;
478
468
  return (
479
469
  `Rebase ${branch} onto ${onto} before any other work — the parent unit has merged and the base has moved; ` +
480
470
  `the only writes are your own on that branch. A conflict ends the unit: report it as the handoff and stop. ` +