@coreplane/switchboard 1.255.0 → 1.257.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 (61) hide show
  1. package/dist/assets/config/config.example.yaml +14 -0
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +340 -7
  3. package/dist/assets/deploy/cloudflare-resident/drain.ts +100 -1
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +227 -34
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +5 -3
  7. package/dist/assets/project.json +17 -9
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/agents/registry.ts +55 -1
  10. package/dist/assets/src/core/authz/grants.ts +12 -0
  11. package/dist/assets/src/core/authz/policy.ts +15 -0
  12. package/dist/assets/src/core/budgets.ts +51 -17
  13. package/dist/assets/src/core/coordinator/contract.ts +45 -5
  14. package/dist/assets/src/core/coordinator/driver.ts +17 -4
  15. package/dist/assets/src/core/delivery.ts +8 -5
  16. package/dist/assets/src/core/pipelineStanding.ts +57 -0
  17. package/dist/assets/src/core/plane/decide.ts +468 -12
  18. package/dist/assets/src/core/plane/findings.ts +120 -0
  19. package/dist/assets/src/core/reviewVerdict.ts +49 -0
  20. package/dist/assets/src/core/runEvents.ts +41 -14
  21. package/dist/assets/src/core/runFriction.ts +16 -0
  22. package/dist/assets/src/core/runLedger/types.ts +16 -3
  23. package/dist/assets/src/core/runRecord.ts +12 -0
  24. package/dist/assets/src/core/ship/contract.ts +34 -32
  25. package/dist/assets/src/core/ship/coordinator.ts +291 -95
  26. package/dist/assets/src/core/ship/renewal.ts +19 -17
  27. package/dist/assets/src/core/trace/attrs.ts +1 -1
  28. package/dist/assets/src/core/untrusted.ts +35 -0
  29. package/dist/assets/src/execution/residentCredentials.ts +7 -4
  30. package/dist/assets/web/dist/.vite/manifest.json +82 -57
  31. package/dist/assets/web/dist/assets/HomePage-DJqJQzrL.js +1 -0
  32. package/dist/assets/web/dist/assets/{PendingTurnRow-DGINv9XT.js → PendingTurnRow-fhKqKUVv.js} +1 -1
  33. package/dist/assets/web/dist/assets/PlanePage-Bood0hNO.js +1 -0
  34. package/dist/assets/web/dist/assets/{ResidentDetailPage-D_RD6wLo.js → ResidentDetailPage-DOe1HtUC.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BLkSuCxo.js → ResidentsIndexPage-TyCGjXM4.js} +1 -1
  36. package/dist/assets/web/dist/assets/RunFoldRow-C_4t-sit.js +1 -0
  37. package/dist/assets/web/dist/assets/RunRoutePage-BNepPMRX.js +9 -0
  38. package/dist/assets/web/dist/assets/RunsIndexPage-jZnMw1mM.js +1 -0
  39. package/dist/assets/web/dist/assets/{ScheduledPage-3aYsDf-q.js → ScheduledPage-BkNoudDP.js} +1 -1
  40. package/dist/assets/web/dist/assets/{SettingsPage-DG-p5Xy1.js → SettingsPage-COvWGXl-.js} +1 -1
  41. package/dist/assets/web/dist/assets/SilentTurn-DNAANKgN.js +2 -0
  42. package/dist/assets/web/dist/assets/SlackMark-CtiHrNjr.js +1 -0
  43. package/dist/assets/web/dist/assets/{StatusDot-CEnGlyAL.js → StatusDot-BgmkSV0S.js} +1 -1
  44. package/dist/assets/web/dist/assets/{Tooltip-CiunVowT.js → Tooltip-DEUPuCPW.js} +1 -1
  45. package/dist/assets/web/dist/assets/UnitRoutePage-DNhdzxvY.js +1 -0
  46. package/dist/assets/web/dist/assets/{dist-luhv3YSo.js → dist-CF3jz9LM.js} +1 -1
  47. package/dist/assets/web/dist/assets/durationTone-CVpX_yIk.js +1 -0
  48. package/dist/assets/web/dist/assets/main-7SAujY_s.js +28 -0
  49. package/dist/assets/web/dist/assets/main-DGC6WFqS.css +1 -0
  50. package/dist/assets/web/dist/assets/{sseReplay-DE6wv1Ua.js → sseReplay-BRJEIh83.js} +1 -1
  51. package/dist/cli.js +2941 -1116
  52. package/package.json +1 -1
  53. package/dist/assets/web/dist/assets/HomePage-Be7jLLnU.js +0 -3
  54. package/dist/assets/web/dist/assets/PlanePage-JEj-lqgz.js +0 -1
  55. package/dist/assets/web/dist/assets/RunFoldRow-CfuZqf_O.js +0 -1
  56. package/dist/assets/web/dist/assets/RunRoutePage-CmWYGR36.js +0 -9
  57. package/dist/assets/web/dist/assets/RunsIndexPage-DW-HHuZa.js +0 -1
  58. package/dist/assets/web/dist/assets/UnitRoutePage-iOYnmTcQ.js +0 -1
  59. package/dist/assets/web/dist/assets/durationTone-U_rOqo6r.js +0 -1
  60. package/dist/assets/web/dist/assets/main-C4QfwybO.css +0 -1
  61. package/dist/assets/web/dist/assets/main-mAKx_zo9.js +0 -28
@@ -0,0 +1,120 @@
1
+ // The plane's findings (docs/decisions/0064, "Endings and the watches"): a
2
+ // finding is filed when no move applies
3
+ // — it carries the watch's name and the timeline of events that fired it, and
4
+ // two findings on one watch and subject are ONE, their timelines merged. The
5
+ // plane itself calls no model: the bot dispatches a research-tier run over the
6
+ // timeline inside the untrusted fence (`findingResearchRequest`) and files the
7
+ // answer through the path `friction propose` uses (`filePlaneFinding`, the
8
+ // same `IssueTracker` seam). The dedupe half is node-free, like `decide.ts`.
9
+
10
+ import { wrapUntrusted } from "../untrusted.js";
11
+
12
+ /** The tracker seam, structurally the `IssueTracker` of
13
+ * `src/execution/githubIssues.ts` (the friction proposer's) — declared here
14
+ * so this module stays node-free like `decide.ts` and imports nothing the
15
+ * Worker's build cannot carry. */
16
+ export interface PlaneIssueRef {
17
+ number: number;
18
+ url: string;
19
+ title: string;
20
+ body: string;
21
+ }
22
+
23
+ export interface PlaneIssueTracker {
24
+ listOpen(repo: string, label: string): Promise<PlaneIssueRef[]>;
25
+ create(repo: string, issue: { title: string; body: string; labels: string[] }): Promise<PlaneIssueRef>;
26
+ }
27
+
28
+ /** One event on a finding's timeline: when, and what the watch saw — words
29
+ * the bot recorded, never a model's. */
30
+ export interface PlaneFindingEvent {
31
+ at: number;
32
+ what: string;
33
+ }
34
+
35
+ /** A finding: the watch, its subject (the run, pull request or instance the
36
+ * watch fired about) and the timeline. Deduplicated by watch and subject —
37
+ * `planeFindingKey` — so the same incident observed twice is one finding. */
38
+ export interface PlaneFinding {
39
+ watch: string;
40
+ subject: string;
41
+ timeline: PlaneFindingEvent[];
42
+ firstAt: number;
43
+ lastAt: number;
44
+ }
45
+
46
+ export function planeFindingKey(f: Pick<PlaneFinding, "watch" | "subject">): string {
47
+ return `${f.watch}#${f.subject}`;
48
+ }
49
+
50
+ /** A timeline is bounded: a watch that fires forever grows one finding, not
51
+ * unbounded storage — the newest events are kept. */
52
+ export const PLANE_FINDING_TIMELINE_CAP = 50;
53
+
54
+ /** Fold one observation into the set: a finding already standing for the same
55
+ * watch and subject absorbs the timeline (two findings on one subject are
56
+ * one); a new pair appends. Pure — the object commits the returned set. */
57
+ export function mergePlaneFindings(existing: readonly PlaneFinding[], incoming: PlaneFinding): PlaneFinding[] {
58
+ const key = planeFindingKey(incoming);
59
+ const standing = existing.find((f) => planeFindingKey(f) === key);
60
+ if (!standing) return [...existing, { ...incoming, timeline: incoming.timeline.slice(-PLANE_FINDING_TIMELINE_CAP) }];
61
+ const timeline = [...standing.timeline, ...incoming.timeline]
62
+ .sort((a, b) => a.at - b.at)
63
+ .slice(-PLANE_FINDING_TIMELINE_CAP);
64
+ const merged: PlaneFinding = {
65
+ ...standing,
66
+ timeline,
67
+ firstAt: Math.min(standing.firstAt, incoming.firstAt),
68
+ lastAt: Math.max(standing.lastAt, incoming.lastAt),
69
+ };
70
+ return existing.map((f) => (planeFindingKey(f) === key ? merged : f));
71
+ }
72
+
73
+ /** The request text for the research-tier run the bot dispatches over a
74
+ * finding (record 0064): the watch and subject are the bot's own words; the
75
+ * timeline — recorded run output, GitHub words, whatever the watch saw — is
76
+ * data inside the untrusted fence, never instructions. */
77
+ export function findingResearchRequest(finding: PlaneFinding): string {
78
+ const timeline = finding.timeline.map((e) => `${new Date(e.at).toISOString()} — ${e.what}`).join("\n");
79
+ return [
80
+ `The orchestration plane's \`${finding.watch}\` watch fired for \`${finding.subject}\` and no mechanical move applied.`,
81
+ `Investigate the timeline below and write up what happened, why the watch fired, and what change would prevent it — a report to file as an issue, nothing executed.`,
82
+ wrapUntrusted(timeline),
83
+ ].join("\n\n");
84
+ }
85
+
86
+ /** The issue title a finding files under — also the dedupe key on the tracker:
87
+ * an open issue with this exact title is the same finding, not filed twice. */
88
+ export function planeFindingIssueTitle(finding: Pick<PlaneFinding, "watch" | "subject">): string {
89
+ return `plane finding: ${finding.watch} — ${finding.subject}`;
90
+ }
91
+
92
+ export const PLANE_FINDING_LABEL = "switchboard-plane";
93
+
94
+ export type FiledPlaneFinding = { kind: "filed"; issue: PlaneIssueRef } | { kind: "duplicate"; issue: PlaneIssueRef };
95
+
96
+ /** File one finding's write-up through the path `friction propose` uses — the
97
+ * `IssueTracker` seam over the repository's open issues: an open issue with
98
+ * the finding's title is a duplicate (the tracker-side half of the dedupe),
99
+ * else the report is created under the plane's label. The body is the
100
+ * research run's answer with the timeline appended as data. */
101
+ export async function filePlaneFinding(
102
+ finding: PlaneFinding,
103
+ report: string,
104
+ opts: { tracker: PlaneIssueTracker; repo: string; label?: string },
105
+ ): Promise<FiledPlaneFinding> {
106
+ const label = opts.label ?? PLANE_FINDING_LABEL;
107
+ const title = planeFindingIssueTitle(finding);
108
+ const open = await opts.tracker.listOpen(opts.repo, label);
109
+ const existing = open.find((i) => i.title === title);
110
+ if (existing) return { kind: "duplicate", issue: existing };
111
+ const timeline = finding.timeline.map((e) => `- ${new Date(e.at).toISOString()} — ${e.what}`).join("\n");
112
+ return {
113
+ kind: "filed",
114
+ issue: await opts.tracker.create(opts.repo, {
115
+ title,
116
+ body: `${report}\n\n## Timeline\n\n${timeline}`,
117
+ labels: [label],
118
+ }),
119
+ };
120
+ }
@@ -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
 
@@ -248,6 +249,37 @@ export function verdictLine(verdict: ReviewVerdict | undefined): string {
248
249
  return summary ? `${token} ${summary}` : token;
249
250
  }
250
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
+
251
283
  /** One compact finding line — `[severity] id file[:line] — title` — shared by
252
284
  * the posted body's list (bulleted below) and ship's synthesized child turns. */
253
285
  export function formatFinding(f: Finding): string {
@@ -381,6 +413,13 @@ export function buildReviewPostBody(
381
413
  * opt-out, a guard refusal) or findings not itemized (a list that says
382
414
  * nothing is no substitute) — so the review's text is always somewhere a
383
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.
384
423
  */
385
424
  export function buildReviewChannelReply(input: {
386
425
  answer: string;
@@ -388,8 +427,18 @@ export function buildReviewChannelReply(input: {
388
427
  /** The PR the post landed on, or undefined when nothing was posted. */
389
428
  posted: { repo: string; number: number } | undefined;
390
429
  liveUrl: string | undefined;
430
+ /** The request's level (default `verbose`: the full render, the row's shape). */
431
+ verbosity?: Verbosity;
391
432
  }): string {
392
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
+ }
393
442
  const tail = [
394
443
  ...(posted && verdict ? [`Posted to ${posted.repo}#${posted.number}`] : []),
395
444
  ...(liveUrl ? [`[Live run](${liveUrl})`] : []),
@@ -93,6 +93,11 @@ export type RunNoteKind =
93
93
  * note says that loop's settlement instead: the executor waited for the
94
94
  * wake and the run went on. */
95
95
  | "sandbox_restarted"
96
+ /** The run was admitted onto a drained fleet (docs/reference/specs/resident-repos.md
97
+ * item 69) and waited at its attach for the deploy to finish: the summary
98
+ * names the wait. Published by the dispatcher after the attach, so `runs
99
+ * friction` reads the drain as the wait's category instead of "none". */
100
+ | "drain_wait"
96
101
  | "stop_requested"
97
102
  | "stopped"
98
103
  /** A stop the loop or turn had to ask pi for again reached it: the series a
@@ -317,6 +322,7 @@ export const RUN_NOTE_KINDS = [
317
322
  "sandbox_dead",
318
323
  "fleet_busy",
319
324
  "sandbox_restarted",
325
+ "drain_wait",
320
326
  "stop_requested",
321
327
  "stopped",
322
328
  "stop_landed",
@@ -537,15 +543,16 @@ export type RouteInputObject3 = { readonly [key: string]: RouteInputLeafOrList |
537
543
  export type RouteInputValue = RouteInputLeafOrList | RouteInputObject3;
538
544
 
539
545
  /** How a door decision about a state change ended (docs/decisions/0044-a-routed-write-is-confirmed-in-proportion-to-its-blast-radius.md):
540
- * `hand_back` — the router bound a state-changing command and the door answered
541
- * the line to paste, nothing invoked; `offered` — the same decision on a
546
+ * `hand_back` — the door held a state-changing command and ran nothing: a
547
+ * typed surface's refusal naming the typed form, or a chat surface's refusal
548
+ * naming why the click could not mint; `offered` — the same decision on a
542
549
  * channel that can show a confirmation: the line, its risk and a button, a
543
550
  * row stored in the config object, nothing invoked; `confirmed` — the stored
544
- * input ran at the click, as the requester, through the typed line's path;
545
- * `pasted` — the typed line that followed a hand-back in its thread, with the
546
- * same receipt, ran. A routed read carries no outcome: it is not a decision
547
- * about a state change. */
548
- export type RouteOutcome = "hand_back" | "offered" | "confirmed" | "pasted" | "refused";
551
+ * input ran at the click, as the requester, through the typed line's path.
552
+ * A routed read carries no outcome: it is not a decision about a state
553
+ * change. (A record from before the hand-back retired from chat may carry
554
+ * `pasted`; nothing writes or reads it now.) */
555
+ export type RouteOutcome = "hand_back" | "offered" | "confirmed" | "refused";
549
556
 
550
557
  export type RunEvent =
551
558
  /** `callId` is the provider's tool_use id — the explicit pair key between a
@@ -895,7 +902,17 @@ export type RunEvent =
895
902
  * (`by: "push"`), or the budget-end salvage pushed (`by: "salvage"`),
896
903
  * whether or not a pull request follows — the fact renewal reads. Published
897
904
  * straight to the registry like `pr_opened`. Additive: unknown → ignored. */
898
- | { type: "pushed_head"; ref: string; sha: string; by: "push" | "salvage"; seq?: number; at?: number }
905
+ | {
906
+ type: "pushed_head";
907
+ ref: string;
908
+ sha: string;
909
+ by: "push" | "salvage";
910
+ /** No uncommitted or unpushed work at the push (record 0064): the fact
911
+ * the plane's soft stop reads. Absent where the measure was missing. */
912
+ clean?: boolean;
913
+ seq?: number;
914
+ at?: number;
915
+ }
899
916
  /** The coordinator tag as a fact of the run (docs/reference/specs/run-history.md
900
917
  * item 48a): the instance the run is a child of, the unit its idempotency
901
918
  * key named, and the base branch its pull request targets — published by
@@ -1014,11 +1031,10 @@ export type RunEvent =
1014
1031
  * how the invoke ended is the run's own status and `answer`. A door
1015
1032
  * decision about a state change (record 0044) is a command run too, with
1016
1033
  * `outcome` saying which: a hand-back invoked nothing and its `answer` is
1017
- * the line to paste; an offer invoked nothing and its `answer` is the offer
1018
- * as the channel shows it; a confirmation is the stored input run at the
1019
- * click, its `answer` the command's own text as a routed read's is; a paste is
1020
- * the typed line that followed a hand-back, whatever its command,
1021
- * `handBackRunId` naming the hand-back's record. */
1034
+ * the refusal the surface was shown (the typed form on a typed surface);
1035
+ * an offer invoked nothing and its `answer` is the offer as the channel
1036
+ * shows it; a confirmation is the stored input run at the click, its
1037
+ * `answer` the command's own text as a routed read's is. */
1022
1038
  | {
1023
1039
  type: "route";
1024
1040
  preset: string;
@@ -1038,7 +1054,6 @@ export type RunEvent =
1038
1054
  * (record 0054): a refusal after a command was bound is a run
1039
1055
  * record, and the door report counts it by cause and code. */
1040
1056
  refusalCode?: string;
1041
- handBackRunId?: string;
1042
1057
  seq?: number;
1043
1058
  at?: number;
1044
1059
  }
@@ -1060,6 +1075,11 @@ export type RunEvent =
1060
1075
  mode: "shadow" | "on";
1061
1076
  outcome: "binds" | "question" | "refusal" | "non_decision";
1062
1077
  reason: string;
1078
+ /** The decision was the loop's floor (record 0069, as amended): a turn
1079
+ * that ended with no tool call, or the bounded re-asks ran out — the
1080
+ * readers' route ran the person's own request, and the event never
1081
+ * re-enters the loop. Additive: unknown → ignored. */
1082
+ floored?: true;
1063
1083
  /** A bind marked `confirmed` is a pending question's confirmed proposal
1064
1084
  * (`bindFromAnswer`): the line itself carries the task — the person's
1065
1085
  * message was the word "yes" — so a confirmed preset line routes its
@@ -1069,6 +1089,13 @@ export type RunEvent =
1069
1089
  /** A question's proposed line, redacted and cut like the receipt — what
1070
1090
  * the next turn's "yes" binds (`bindFromAnswer`). */
1071
1091
  proposal?: string;
1092
+ /** A question's original ask, redacted and capped: the request the
1093
+ * question interrupted, kept so the person's next words in the thread
1094
+ * join back onto it (`joinedAnswerRequest` —
1095
+ * `<request> — <question>: <answer>`) and bind as the request would
1096
+ * have been, never routed as a bare fragment. Set on `question`
1097
+ * outcomes alone. Additive: unknown → ignored. */
1098
+ request?: string;
1072
1099
  refusalCause?: string;
1073
1100
  refusalText?: string;
1074
1101
  attempts?: ReadonlyArray<{ outcome: "accepted" | "violation"; violation?: string }>;
@@ -39,6 +39,7 @@ export type FrictionCategory =
39
39
  | "wrap_up"
40
40
  | "budget_hit"
41
41
  | "infra_failure"
42
+ | "drain_wait"
42
43
  | "unkept_promise";
43
44
 
44
45
  export const FRICTION_CATEGORIES: readonly FrictionCategory[] = [
@@ -50,6 +51,7 @@ export const FRICTION_CATEGORIES: readonly FrictionCategory[] = [
50
51
  "wrap_up",
51
52
  "budget_hit",
52
53
  "infra_failure",
54
+ "drain_wait",
53
55
  "unkept_promise",
54
56
  ];
55
57
 
@@ -63,6 +65,7 @@ export const CATEGORY_LABEL: Record<FrictionCategory, string> = {
63
65
  wrap_up: "agent wind-down",
64
66
  budget_hit: "budget hits",
65
67
  infra_failure: "infra failures",
68
+ drain_wait: "the fleet drain",
66
69
  unkept_promise: "unkept promises",
67
70
  };
68
71
 
@@ -78,6 +81,7 @@ export const DENOMINATOR_OF: Record<FrictionCategory, "tool" | "run"> = {
78
81
  wrap_up: "run",
79
82
  budget_hit: "run",
80
83
  infra_failure: "run",
84
+ drain_wait: "run",
81
85
  unkept_promise: "run",
82
86
  };
83
87
 
@@ -574,6 +578,18 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
574
578
  eventIndex: index,
575
579
  });
576
580
  return;
581
+ case "drain_wait":
582
+ // The run was admitted onto a drained fleet (resident-repos item 69;
583
+ // issue 2044): the wait is the deploy's, its own category — a verdict
584
+ // of "none" over a run that sat half an hour at the drain was the
585
+ // incident's shape. No extent: the wait precedes the loop's spans.
586
+ findings.push({
587
+ category: "drain_wait",
588
+ severity: "medium",
589
+ summary: `fleet drained: ${ev.summary}`,
590
+ eventIndex: index,
591
+ });
592
+ return;
577
593
  case "sandbox_restarted":
578
594
  // The container rolled under the run: on pi the run ends here and its
579
595
  // request starts over (harness-pi item 16); on the deleted native loop
@@ -79,6 +79,12 @@ export interface LiveRunMeta {
79
79
  /** The run that spawned this one (item 46), so a reclaimed child's record
80
80
  * still names its parent. Absent on every run a person or a schedule started. */
81
81
  parentRunId?: string;
82
+ /** The run this claim restarts (record 0064; run-history item 54): set by
83
+ * the restart-from-request dispatch, so the plane knows the claim continues
84
+ * a run it saw end `restarting` — the object sends the waiting parent
85
+ * `child-resumed-<runId>` from it, and a `restartOf` ask passes the windows
86
+ * and the memory line. Absent on every fresh request. */
87
+ restartOf?: string;
82
88
  /** The coordinator instance this run is a child of, and the key its spawn
83
89
  * carried (item 48) — stored at the claim, so a reclaimed child's record
84
90
  * still sends the parent its event and a retried spawn finds its run. */
@@ -150,10 +156,13 @@ export interface LiveRunRow {
150
156
  state: RunState;
151
157
  }
152
158
 
153
- /** One tool call the step dispatched; `tool` decides how a resume settles it. */
159
+ /** One tool call the step dispatched; `tool` decides how a resume settles it.
160
+ * `boundMs` is the bound the call declared (a bash `timeout`), when it stated
161
+ * one — what the plane judges a `long_call` steer against (record 0064). */
154
162
  export interface InFlightCall {
155
163
  callId: string;
156
164
  tool: string;
165
+ boundMs?: number;
157
166
  }
158
167
 
159
168
  /** Written BEFORE a step's tools run (after its transcript turns landed). */
@@ -286,7 +295,7 @@ export type AppendableEvent = RunEvent & { seq: number };
286
295
  export interface IntakeReceipt {
287
296
  verdict: "addressed" | "silent";
288
297
  reason: string;
289
- source: "model" | "mode" | "error" | "timeout";
298
+ source: "model" | "mode" | "question" | "error" | "timeout";
290
299
  /** The structured seam's attempts (docs/decisions/0067): what each answer
291
300
  * violated, or that it was accepted; absent when no model was asked. */
292
301
  attempts?: ReadonlyArray<{ outcome: "accepted" | "violation"; violation?: string }>;
@@ -322,7 +331,11 @@ export function isIntakeReceipt(v: unknown): v is IntakeReceipt {
322
331
  return (
323
332
  (r.verdict === "addressed" || r.verdict === "silent") &&
324
333
  typeof r.reason === "string" &&
325
- (r.source === "model" || r.source === "mode" || r.source === "error" || r.source === "timeout") &&
334
+ (r.source === "model" ||
335
+ r.source === "mode" ||
336
+ r.source === "question" ||
337
+ r.source === "error" ||
338
+ r.source === "timeout") &&
326
339
  (r.mode === "mention" || r.mode === "classify") &&
327
340
  typeof r.model === "string" &&
328
341
  typeof r.gen === "number" &&
@@ -225,6 +225,12 @@ export interface RunRecord {
225
225
  * carried onto the record at the seal, so a history reader draws the run as
226
226
  * a pipeline. Absent on every other run and on older records. */
227
227
  hosted?: true;
228
+ /** The reattach path restarted this run from its request when its workspace
229
+ * could not be re-attached (record 0064; item 47a): the `interrupted` close
230
+ * is not the run's end — the same id carries on — so a waiting parent keeps
231
+ * waiting for `child_resumed` instead of ending its unit on this record.
232
+ * Absent on every ending that is final. */
233
+ restarting?: true;
228
234
  /** Where the run's conversation started (item 52): `channel` — its own
229
235
  * thread's history, as for every run a person, a schedule or a coordinator
230
236
  * started — or `parent` — a spawned child seeded from its parent's text
@@ -432,6 +438,9 @@ export interface RunOperatorDecision {
432
438
  question?: string;
433
439
  /** A question's proposed line — what the next turn's "yes" binds. */
434
440
  proposal?: string;
441
+ /** A question's original ask — what the person's next words join back onto
442
+ * (`joinedAnswerRequest`). */
443
+ request?: string;
435
444
  refusalCause?: string;
436
445
  refusalText?: string;
437
446
  /** The structured seam's attempts (record 0067): what each answer violated,
@@ -456,6 +465,7 @@ export function operatorOfEvents(events: readonly RunEvent[]): RunOperatorDecisi
456
465
  ...(e.binds ? { binds: e.binds.map((b) => ({ line: b.line, reason: b.reason })) } : {}),
457
466
  ...(e.question !== undefined ? { question: e.question } : {}),
458
467
  ...(e.proposal !== undefined ? { proposal: e.proposal } : {}),
468
+ ...(e.request !== undefined ? { request: e.request } : {}),
459
469
  ...(e.refusalCause !== undefined ? { refusalCause: e.refusalCause } : {}),
460
470
  ...(e.refusalText !== undefined ? { refusalText: e.refusalText } : {}),
461
471
  ...(e.attempts
@@ -1017,6 +1027,8 @@ export function isRunRecord(v: unknown): v is RunRecord {
1017
1027
  // marker, like `provisional`, is the literal `true` or absent.
1018
1028
  if (r.pipeline !== undefined && !isPipelineSummaryShape(r.pipeline)) return false;
1019
1029
  if (r.hosted !== undefined && r.hosted !== true) return false;
1030
+ // A restarting close carries the literal `true` or nothing (record 0064).
1031
+ if (r.restarting !== undefined && r.restarting !== true) return false;
1020
1032
  if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
1021
1033
  if (r.relayedBy !== undefined && typeof r.relayedBy !== "string") return false;
1022
1034
  if (r.authenticatedAs !== undefined && typeof r.authenticatedAs !== "string") return false;
@@ -439,49 +439,51 @@ 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
 
464
+ /** The rebase before every push lives in the coding preset's own instructions
465
+ * (`REBASE_BEFORE_PUSH`, src/agents/registry.ts — agent-coding item 13;
466
+ * record 0071 mechanism one): the base fetched, the branch rebased onto it,
467
+ * the fast gates re-run on the rebased tree, then the push — always, never
468
+ * configurable. The contract used to carry the conditional form ("rebase once
469
+ * more if it moved while you worked"); now the first instruction points at
470
+ * the preset's paragraph, the same shape as the fast-gates pointer above, so
471
+ * the child reads the rule once and cannot read a softer one here. */
472
+ export const REBASE_POINTER =
473
+ "The rebase before every push is your preset instructions' rule (REBASE BEFORE EVERY PUSH): it holds before " +
474
+ "each push here — always, not configurable — so every head you push is current with its base and the pull " +
475
+ "request is never born conflicting.";
476
+
474
477
  function renderFirstInstruction(rebase: ChildContract["rebase"]): string {
475
478
  const branch = rebase.branch ? `\`${rebase.branch}\`` : "the unit's branch";
476
479
  const onto = rebase.onto ? `\`${rebase.onto}\`` : "the merged parent";
477
- const gates = FAST_GATES_BEFORE_PUSH;
480
+ const gates = `${FAST_GATES_POINTER} ${GATE_RECEIPTS}`;
478
481
  return (
479
482
  `Rebase ${branch} onto ${onto} before any other work — the parent unit has merged and the base has moved; ` +
480
483
  `the only writes are your own on that branch. A conflict ends the unit: report it as the handoff and stop. ` +
481
484
  `Push the branch as soon as the change exists and the fast gates pass — the project's full verification ` +
482
485
  `is CI's gate, run there after the push with any fix as a further commit; an unpushed tree does not ` +
483
- `survive the run's end. ${gates} Right before each push, fetch ${onto} again and rebase ` +
484
- `once more if it moved while you worked, so the pull request is not born conflicting. At the wind-down note, ` +
486
+ `survive the run's end. ${gates} ${REBASE_POINTER} At the wind-down note, ` +
485
487
  `commit and push what compiles, say what does not, then answer. ${TIMEOUT_ON_LONG_COMMANDS}`
486
488
  );
487
489
  }