toolroll 0.9.18 → 0.9.20

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.
@@ -912,6 +912,8 @@ export type BrowserFlowTrigger = {
912
912
  status: string | null;
913
913
  statusAt: string | null;
914
914
  failing: boolean;
915
+ /** Where the status points a person (a plane review's long waits: Needs you). */
916
+ statusLink: BrowserLink | null;
915
917
  button: {
916
918
  label: string;
917
919
  questions: string[];
@@ -677,13 +677,21 @@ function planResultShots(options, binding, notification, repos, id, now) {
677
677
  if (plan.kind === "none" || notification.taskId === null || notification.run === null)
678
678
  return;
679
679
  const where = { task: notification.taskId, run: notification.run };
680
- // The result's message here: its finished-work batch (quiet), or its progress card (every step).
680
+ // The result's message here: its finished-work batch (quiet), its progress card (every step), or the result's own
681
+ // notice. The transport threads the screenshots under it once it is posted, and holds them until then.
681
682
  const follows = (() => {
682
683
  const batch = notification.taskRef === null ? null : store.chatBatchMessageFor(`${state.channel}:${binding.id}`, notification.taskRef, notification.run);
683
684
  if (batch !== null)
684
685
  return Number(batch);
685
686
  const card = state.prepare("SELECT part FROM chat_progress WHERE binding=? AND run=?").get(binding.id, notification.run);
686
- return card === undefined ? null : Number(card.part);
687
+ if (card !== undefined)
688
+ return Number(card.part);
689
+ for (const one of store.handle.prepare("SELECT id FROM notification WHERE source_run=? AND recipient IS NULL AND id<? ORDER BY id DESC").all(notification.run, notification.id)) {
690
+ const part = state.prepare("SELECT id FROM chat_part WHERE event=? ORDER BY id LIMIT 1").get(chatHash(`${state.channel}:notice:${binding.id}:${Number(one["id"])}`));
691
+ if (part !== undefined)
692
+ return Number(part.id);
693
+ }
694
+ return null;
687
695
  })();
688
696
  const image = (artifact, sha256) => ({ taskId: where.task, run: where.run, artifact, sha256 });
689
697
  const parts = plan.kind === "line" ? [{ text: plan.text, ...where }]
@@ -2,7 +2,7 @@
2
2
  import { chatQuestionButtons } from "./teammate-question.js";
3
3
  import { chatAskButtons } from "./chat-ask.js";
4
4
  import { chatFlowButtons } from "./chat-flow.js";
5
- import { refuseResultShots, resultShotsPruned } from "./result-shots.js";
5
+ import { refuseResultShots, resultShotsPruned, shotWaitsUntil } from "./result-shots.js";
6
6
  import { channelInbox } from "./chat-inbox.js";
7
7
  import { roomCommand } from "./chat-rooms.js";
8
8
  import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.js";
@@ -210,8 +210,13 @@ export async function deliverDiscordPart(options) {
210
210
  state.prepare("UPDATE chat_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
211
211
  return true;
212
212
  }
213
- // A screenshot sent with a result replies to that result's message once it is placed.
214
- const follows = content.shot?.follows == null ? undefined : state.prepare("SELECT message FROM chat_part WHERE id=? AND state='sent'").get(content.shot.follows)?.message;
213
+ // A screenshot sent with a result replies to that result's message once it is placed, and waits until then.
214
+ const result = content.shot?.follows == null ? undefined : state.prepare("SELECT message,state,next_at FROM chat_part WHERE id=?").get(content.shot.follows);
215
+ if (result !== undefined && !discordId(result["message"]) && result["state"] === "pending") {
216
+ state.prepare("UPDATE chat_part SET next_at=? WHERE id=?").run(shotWaitsUntil(result["next_at"], now), row.id);
217
+ return true;
218
+ }
219
+ const follows = result?.["message"];
215
220
  const thread = discordId(follows) ? String(follows) : event.thread;
216
221
  if (content.image) {
217
222
  const image = verifyResultImage(store, options.evidenceRoot, repos, content.image);
@@ -199,6 +199,13 @@ export declare function firePlaneReview(store: Store, trigger: FlowTriggerRow, c
199
199
  joined: number;
200
200
  said: string;
201
201
  }>;
202
+ /** Where a person sees everything that waits on them. */
203
+ export declare const NEEDS_YOU_HREF = "/work?view=needs-you";
204
+ /** A plane review whose summary mentions long waits on a person links to Needs you. */
205
+ export declare function planeStatusLink(config: TriggerConfig | null, status: string | null): {
206
+ label: string;
207
+ href: string;
208
+ } | null;
202
209
  /** v96: a schedule (a teammate's routine) makes its card now, once per press, and keeps its own times. */
203
210
  export declare function runScheduleNow(store: Store, trigger: FlowTriggerRow, actor: string, now: Date): {
204
211
  ok: true;
@@ -16,7 +16,9 @@
16
16
  * - plane-review — every day at a set time (07:30 in the computer's time zone unless it says),
17
17
  * the plane's last 24 hours read straight from the store (plane-review.ts): one card
18
18
  * per distinct problem worth fixing, and the same problem on a later day joins its
19
- * card while that card is still open. A clean day makes nothing. Its row is kept as a
19
+ * card while that card is still open. A clean day makes nothing. Waits only a person
20
+ * can end make no card; ones over three days are mentioned once in its summary line,
21
+ * linked to Needs you. Its row is kept as a
20
22
  * 'schedule' (the table's kinds are fixed); its settings say what it is.
21
23
  *
22
24
  * GitHub and Linear are checked by the worker's pass — `gh` and one HTTPS
@@ -615,10 +617,14 @@ export async function firePlaneReview(store, trigger, config, now, scheduled) {
615
617
  if (flow === null || flow.state !== "active")
616
618
  return done("The flow isn't active.");
617
619
  // Loaded when a review first runs: the reader reaches the integrations and task status, which reach back to these triggers.
618
- const { problemCard, problemText, reviewPlane } = await import("./plane-review.js");
620
+ const { longPersonWaits, longWaitWords, problemCard, problemText, reviewPlane } = await import("./plane-review.js");
619
621
  // Only the projects the flow's owner may see: a flow's cards are read by everyone on its project.
620
622
  const owner = store.accountOf(flow.owner);
621
- const problems = reviewPlane(store, now, repo => owner === null || (repo !== null && store.accountCanAccess(flow.owner, repo)) || (repo === null && owner.projects === null));
623
+ const canSee = (repo) => owner === null || (repo !== null && store.accountCanAccess(flow.owner, repo)) || (repo === null && owner.projects === null);
624
+ const problems = reviewPlane(store, now, canSee);
625
+ // Long waits on a person are said here once, linked to Needs you (planeStatusLink), and file nothing.
626
+ const waited = longWaitWords(longPersonWaits(store, now, canSee));
627
+ const said = (words) => waited === null ? words : `${words} ${waited}`;
622
628
  const timeZone = schedule !== null && schedule.kind !== "every" ? schedule.timezone ?? "UTC" : "UTC";
623
629
  const day = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone }).format(now);
624
630
  const dayWords = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone }).format(now);
@@ -646,10 +652,16 @@ export async function firePlaneReview(store, trigger, config, now, scheduled) {
646
652
  skipped.push(made.note);
647
653
  }
648
654
  if (problems.length === 0)
649
- return done("A clean day: nothing to fix.");
655
+ return done(said("A clean day: nothing to fix."));
650
656
  const parts = [added > 0 ? `Added ${plural(added, "card")}.` : null, joined > 0 ? `${plural(joined, "problem")} came back and joined ${joined === 1 ? "its card" : "their cards"}.` : null,
651
657
  skipped.length > 0 ? `Left out ${plural(skipped.length, "problem")}: ${skipped[0]}.` : null].filter(one => one !== null);
652
- return done(parts.length === 0 ? "Nothing new since this morning's review." : parts.join(" "), added, joined);
658
+ return done(said(parts.length === 0 ? "Nothing new since this morning's review." : parts.join(" ")), added, joined);
659
+ }
660
+ /** Where a person sees everything that waits on them. */
661
+ export const NEEDS_YOU_HREF = "/work?view=needs-you";
662
+ /** A plane review whose summary mentions long waits on a person links to Needs you. */
663
+ export function planeStatusLink(config, status) {
664
+ return config?.kind === "plane-review" && status !== null && /waited over \d+ days for you\.$/.test(status) ? { label: "Needs you", href: NEEDS_YOU_HREF } : null;
653
665
  }
654
666
  /** v96: a schedule (a teammate's routine) makes its card now, once per press, and keeps its own times. */
655
667
  export function runScheduleNow(store, trigger, actor, now) {
package/dist/flows-ui.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { draftFor, flowDefinitionOf } from "./flow-engine.js";
2
- import { describeTrigger, triggerHeadline, FLOW_TRIGGER_KINDS, FLOW_TRIGGER_WORDS, githubRepoOf, HOOK_PATH, hookReady, readHooksBase, readLinearKey, takesDeliveries, triggerConfigOf } from "./flow-triggers.js";
2
+ import { describeTrigger, triggerHeadline, FLOW_TRIGGER_KINDS, FLOW_TRIGGER_WORDS, githubRepoOf, HOOK_PATH, hookReady, planeStatusLink, readHooksBase, readLinearKey, takesDeliveries, triggerConfigOf } from "./flow-triggers.js";
3
3
  import { deciderOf, FLOW_COLORS, FLOW_KIND_WORDS, FLOW_STAGE_KINDS } from "./flows.js";
4
4
  import { flowInsights, troubleWords } from "./flow-insights.js";
5
5
  import { parseSortDecision, sortChip } from "./flow-sort.js";
@@ -152,7 +152,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
152
152
  return {
153
153
  id: trigger.id, kind: trigger.kind, words: config === null ? "This trigger can't be read." : describeTrigger(config, store),
154
154
  ...(config === null ? { name: "Trigger", detail: "Can't be read" } : triggerHeadline(config, store)),
155
- zone: title(config?.zone ?? definition?.start ?? ""), zoneId: config?.zone ?? definition?.start ?? "", state: trigger.state, status: trigger.lastOutcome, statusAt: trigger.lastAt, failing: trigger.failures > 0,
155
+ zone: title(config?.zone ?? definition?.start ?? ""), zoneId: config?.zone ?? definition?.start ?? "", state: trigger.state, status: trigger.lastOutcome, statusLink: planeStatusLink(config, trigger.lastOutcome), statusAt: trigger.lastAt, failing: trigger.failures > 0,
156
156
  button: config?.kind === "button" ? { label: config.label, questions: config.questions } : null,
157
157
  hook: config !== null && takesDeliveries(config) ? { ready: hookReady(trigger, setup.dir), needsSecret: config.kind === "linear" } : null,
158
158
  checkable: ((config?.kind === "github" || config?.kind === "linear") && config.delivery === "poll") || config?.kind === "email" || (config?.kind === "schedule" && config.script !== undefined) || config?.kind === "plane-review",
package/dist/invoke.d.ts CHANGED
@@ -39,6 +39,9 @@ export type AgentOutcome = {
39
39
  sessionId: string | null;
40
40
  /** The agent's spoken conclusion — diagnostics, never the handoff. */
41
41
  finalMessage: string | null;
42
+ /** Claude's `structured_output` alone (see ParsedEnvelope) — the only
43
+ * field a structured handback may be read from. */
44
+ structuredOutput?: string | null;
42
45
  /** The harness's own account of the ending, when its dialect has one. */
43
46
  ending?: AgentEnding | null;
44
47
  usage: ProviderUsage;
package/dist/invoke.js CHANGED
@@ -550,6 +550,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
550
550
  notFound: result.notFound,
551
551
  sessionId: envelope.sessionId,
552
552
  finalMessage: envelope.finalMessage,
553
+ structuredOutput: envelope.structuredOutput ?? null,
553
554
  ending: envelope.ending ?? null,
554
555
  usage: {
555
556
  tokensIn: envelope.tokensIn,
@@ -11,6 +11,8 @@ export type PlaneProblem = {
11
11
  evidence: string[];
12
12
  };
13
13
  export declare const REVIEW_HOURS = 24;
14
+ /** A person's wait this long is mentioned in the review's summary line. */
15
+ export declare const LONG_WAIT_DAYS = 3;
14
16
  export declare const RUN_CAUSES: readonly ["provider", "sign-in", "check", "timeout", "no-handoff", "stuck-lease", "orphaned", "no-change", "other"];
15
17
  export type RunCause = (typeof RUN_CAUSES)[number];
16
18
  /** Anything shaped like a key is hidden; one short line. */
@@ -30,6 +32,19 @@ export declare function runCause(run: {
30
32
  * project counts only when every project may be seen).
31
33
  */
32
34
  export declare function reviewPlane(store: Store, now: Date, canSee?: (repo: string | null) => boolean): PlaneProblem[];
35
+ /**
36
+ * Results nobody has opened, and tasks waiting on a person's answer, approval or hold, for over `LONG_WAIT_DAYS`
37
+ * before `now`. Counted for the review's summary line only; they are never problems.
38
+ */
39
+ export declare function longPersonWaits(store: Store, now: Date, canSee?: (repo: string | null) => boolean): {
40
+ results: number;
41
+ tasks: number;
42
+ };
43
+ /** "3 results have waited over 3 days for you." — or null when nothing has. */
44
+ export declare function longWaitWords(waits: {
45
+ results: number;
46
+ tasks: number;
47
+ }): string | null;
33
48
  /** A problem as a card's details (or a day's note on a card it joins). */
34
49
  export declare function problemText(problem: PlaneProblem, day: string): string;
35
50
  /** The card's opening details: the day's evidence, and what it is. */
@@ -6,7 +6,9 @@
6
6
  * - failed or no-change runs, grouped by cause (provider error, sign-in
7
7
  * expiry, check failure, timeout, quit without handoff, stuck lease,
8
8
  * orphaned process, no change, other);
9
- * - tasks waiting on a person for over a day, grouped by what they wait for;
9
+ * - tasks stuck for over a day on something the system must fix (unfinished
10
+ * work no worker holds, repeated failures, an incident hold), grouped by
11
+ * what they wait for;
10
12
  * - sign-in and plan-limit pauses, per provider;
11
13
  * - chat replies that couldn't be delivered, per app;
12
14
  * - integrations that are Broken (integration_check);
@@ -17,6 +19,13 @@
17
19
  * problem (flow-triggers.ts joins it to its card), and carries counts, run
18
20
  * ids and short evidence excerpts with anything shaped like a secret hidden.
19
21
  * A clean day is no problems at all. Only projects the reader may see count.
22
+ *
23
+ * Waits only a person can end — a result to open, a question to answer, a
24
+ * plan or task to approve, a hold to lift, a scope to write — are never
25
+ * problems: each card starts a find-the-cause → fix → pull request flow, and
26
+ * no code change opens a result for someone. Needs you and chat already ask
27
+ * the person. `longPersonWaits` counts the ones over `LONG_WAIT_DAYS` so the
28
+ * review's own summary line can mention them once, filing nothing.
20
29
  */
21
30
  import { scanForSecrets } from "./evidence.js";
22
31
  import { scrubIntegrationText } from "./integrations.js";
@@ -24,8 +33,10 @@ import { taskWaitSnapshot } from "./lead-status.js";
24
33
  export const REVIEW_HOURS = 24;
25
34
  const EVIDENCE_LINES = 5;
26
35
  const WAITING_DAYS = 1;
27
- /** A Ready result older than this is history, not today's problem. */
28
- const READY_WINDOW_DAYS = 7;
36
+ /** A person's wait this long is mentioned in the review's summary line. */
37
+ export const LONG_WAIT_DAYS = 3;
38
+ /** What a person, not the code, must do next (lead-status.ts `taskWaitSnapshot`). The rest have a system cause. */
39
+ const PERSON_NEXT = new Set(["Open result", "Answer the task's question", "Answer question", "Remove hold", "Review plan", "Resume or close attempt", "Choose a result", "Approve plan", "Add scope", "Approve task"]);
29
40
  export const RUN_CAUSES = ["provider", "sign-in", "check", "timeout", "no-handoff", "stuck-lease", "orphaned", "no-change", "other"];
30
41
  const CAUSE_TITLES = {
31
42
  provider: "Runs failed on a provider error",
@@ -140,24 +151,12 @@ export function reviewPlane(store, now, canSee = () => true) {
140
151
  const run = Number(row["run"]);
141
152
  add("run/orphaned", CAUSE_TITLES.orphaned, ["run", "runs"], { run, evidence: `Run ${run} (task ${str(row["task"]) ?? "?"}): process ${str(row["pid"]) ?? "?"} on ${excerpt(str(row["host"]), 40)} was still running after it finished` });
142
153
  }
143
- // Tasks waiting on a person for over a day, by what they wait for.
144
- const waited = new Date(now.getTime() - WAITING_DAYS * 86_400_000).toISOString();
145
- const readyFrom = new Date(now.getTime() - READY_WINDOW_DAYS * 86_400_000).toISOString();
146
- const tasks = store.handle.prepare(`SELECT task.id, task.title, task.updated_at, ref.repo FROM task JOIN task_ref ref ON ref.backend = 'built-in' AND ref.external_id = task.id
147
- WHERE task.updated_at <= ? AND (task.state IN ('queued', 'running') OR (task.state = 'done' AND task.updated_at >= ?)) ORDER BY task.updated_at LIMIT 500`).all(waited, readyFrom);
148
- for (const row of tasks) {
149
- if (!canSee(str(row["repo"])))
150
- continue;
151
- // A version a later revision replaced never waits on its own.
152
- const family = store.taskFamilyOf(String(row["id"]), null, true);
153
- if (family !== null && family.current.id !== String(row["id"]))
154
- continue;
155
- const snapshot = taskWaitSnapshot(store, String(row["id"]), now);
156
- if (snapshot === null || (snapshot.reason !== "needs-person" && snapshot.reason !== "ready"))
154
+ // Tasks stuck for over a day on a system cause, by what they wait for. Waits on a person are never cards.
155
+ for (const wait of openWaits(store, new Date(now.getTime() - WAITING_DAYS * 86_400_000), now, canSee)) {
156
+ if (wait.person)
157
157
  continue;
158
- const days = Math.floor((now.getTime() - Date.parse(String(row["updated_at"]))) / 86_400_000);
159
- add(`waiting/${keyPart(snapshot.next)}`, `Tasks waited over a day: ${snapshot.next}`, ["task", "tasks"], { run: snapshot.run,
160
- evidence: `Task ${String(row["id"])} “${excerpt(str(row["title"]), 80)}”: ${plural(days, "day")} waiting` });
158
+ add(`waiting/${keyPart(wait.next)}`, `Tasks waited over a day: ${wait.next}`, ["task", "tasks"], { run: wait.run,
159
+ evidence: `Task ${wait.id} “${excerpt(wait.title, 80)}”: ${plural(wait.days, "day")} waiting` });
161
160
  }
162
161
  // Sign-in and plan-limit pauses.
163
162
  if (tableExists(store, "provider_auth_pause")) {
@@ -227,6 +226,49 @@ export function reviewPlane(store, now, canSee = () => true) {
227
226
  summary: `${plural(bucket.count, bucket.noun[0], bucket.noun[1])} in the last ${REVIEW_HOURS} hours${runsList.length === 0 ? "" : ` (${runsList.length === 1 ? "run" : "runs"} ${runsList.slice(0, 12).join(", ")}${runsList.length > 12 ? ", …" : ""})`}.` };
228
227
  }).sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
229
228
  }
229
+ /** Queued or running tasks last touched by `before` that wait, and on what; replaced versions never wait on their own. */
230
+ function openWaits(store, before, now, canSee) {
231
+ const rows = store.handle.prepare(`SELECT task.id, task.title, task.updated_at, ref.repo FROM task JOIN task_ref ref ON ref.backend = 'built-in' AND ref.external_id = task.id
232
+ WHERE task.updated_at <= ? AND task.state IN ('queued', 'running') ORDER BY task.updated_at LIMIT 500`).all(before.toISOString());
233
+ return waitsOf(store, rows, now, canSee);
234
+ }
235
+ function waitsOf(store, rows, now, canSee) {
236
+ const waits = [];
237
+ for (const row of rows) {
238
+ if (!canSee(str(row["repo"])))
239
+ continue;
240
+ const id = String(row["id"]);
241
+ const family = store.taskFamilyOf(id, null, true);
242
+ if (family !== null && family.current.id !== id)
243
+ continue;
244
+ const snapshot = taskWaitSnapshot(store, id, now);
245
+ if (snapshot === null || (snapshot.reason !== "needs-person" && snapshot.reason !== "ready"))
246
+ continue;
247
+ waits.push({ id, title: str(row["title"]), next: snapshot.next, run: snapshot.run, days: Math.floor((now.getTime() - Date.parse(String(row["updated_at"]))) / 86_400_000),
248
+ person: snapshot.reason === "ready" || PERSON_NEXT.has(snapshot.next) });
249
+ }
250
+ return waits;
251
+ }
252
+ /**
253
+ * Results nobody has opened, and tasks waiting on a person's answer, approval or hold, for over `LONG_WAIT_DAYS`
254
+ * before `now`. Counted for the review's summary line only; they are never problems.
255
+ */
256
+ export function longPersonWaits(store, now, canSee = () => true) {
257
+ const before = new Date(now.getTime() - LONG_WAIT_DAYS * 86_400_000);
258
+ // Newest first, so years of Complete work never crowds out this week's unopened results.
259
+ const finished = store.handle.prepare(`SELECT task.id, task.title, task.updated_at, ref.repo FROM task JOIN task_ref ref ON ref.backend = 'built-in' AND ref.external_id = task.id
260
+ WHERE task.updated_at <= ? AND task.state = 'done' ORDER BY task.updated_at DESC LIMIT 500`).all(before.toISOString());
261
+ const results = waitsOf(store, finished, now, canSee).filter(wait => wait.person).length;
262
+ const tasks = openWaits(store, before, now, canSee).filter(wait => wait.person).length;
263
+ return { results, tasks };
264
+ }
265
+ /** "3 results have waited over 3 days for you." — or null when nothing has. */
266
+ export function longWaitWords(waits) {
267
+ const parts = [waits.results > 0 ? plural(waits.results, "result") : null, waits.tasks > 0 ? plural(waits.tasks, "task") : null].filter(one => one !== null);
268
+ if (parts.length === 0)
269
+ return null;
270
+ return `${parts.join(" and ")} ${waits.results + waits.tasks === 1 ? "has" : "have"} waited over ${LONG_WAIT_DAYS} days for you.`;
271
+ }
230
272
  /** A problem as a card's details (or a day's note on a card it joins). */
231
273
  export function problemText(problem, day) {
232
274
  return [`${day}: ${problem.summary}`, ...problem.evidence.map(line => `- ${line}`)].join("\n");
@@ -68,6 +68,10 @@ export type Invocation = {
68
68
  * Codex renders it as its own sandbox profile; Claude as deny rules for
69
69
  * its file tools (its shell is fenced by the macOS sandbox around it). */
70
70
  fence?: readonly string[];
71
+ /** Claude's structured-output floor for a non-review phase: the scout's
72
+ * report rides the terminal result event instead of a file plan mode
73
+ * would refuse to write. Ignored by providers without `--json-schema`. */
74
+ jsonSchema?: Readonly<Record<string, unknown>>;
71
75
  };
72
76
  export type ProviderRunner = (file: string, args: readonly string[], options?: RunOptions) => Promise<ExecResult>;
73
77
  /** The harness's own account of how a turn ended. */
@@ -86,6 +90,10 @@ export type ParsedEnvelope = {
86
90
  protocolError: string | null;
87
91
  /** The agent's spoken conclusion — diagnostics only, never the handoff. */
88
92
  finalMessage: string | null;
93
+ /** Claude's schema-validated `structured_output` alone, re-serialized —
94
+ * never the prose result, even when that prose is JSON. Absent or null
95
+ * when the turn ran without `--json-schema` or returned none. */
96
+ structuredOutput?: string | null;
89
97
  /** How the harness said the turn ended (claude: the result's subtype and
90
98
  * turn count) — absent on dialects that carry no such record. */
91
99
  ending?: AgentEnding | null;
package/dist/provider.js CHANGED
@@ -229,7 +229,8 @@ const claudeArgv = (invocation) => [
229
229
  // Headless (run 2085): a wakeup, cron job or monitor needs a later
230
230
  // turn that a -p process never gets. Denied at the harness too.
231
231
  "--disallowedTools", HEADLESS_DISALLOWED_TOOLS.join(","),
232
- ...(invocation.toolArgv ?? []), ...(invocation.fence !== undefined && invocation.fence.length > 0 ? ["--settings", claudeFenceSettings(invocation.fence)] : [])
232
+ ...(invocation.toolArgv ?? []), ...(invocation.fence !== undefined && invocation.fence.length > 0 ? ["--settings", claudeFenceSettings(invocation.fence)] : []),
233
+ ...(invocation.jsonSchema === undefined ? [] : ["--json-schema", JSON.stringify(invocation.jsonSchema)])
233
234
  ]),
234
235
  ];
235
236
  /**
@@ -276,6 +277,9 @@ function claudeEnvelopeOf(result, sessionFromInit, initObserved, initSessionConf
276
277
  ? "the Claude init event and terminal result announced different session ids"
277
278
  : null,
278
279
  finalMessage: claudeFinalMessage(result),
280
+ structuredOutput: result === null || result.structured_output === undefined || result.structured_output === null
281
+ ? null
282
+ : JSON.stringify(result.structured_output),
279
283
  ending: result === null ? null : {
280
284
  subtype: typeof result.subtype === "string" ? result.subtype : null,
281
285
  turns: typeof result.num_turns === "number" && result.num_turns >= 0 ? result.num_turns : null,
@@ -39,6 +39,8 @@ export type ResultShotsPlan =
39
39
  * no wait in between, or asks again.
40
40
  */
41
41
  export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run">, sent?: ReadonlySet<number>): ResultShotsPlan;
42
+ /** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
43
+ export declare function shotWaitsUntil(resultNextAt: unknown, now: Date): string;
42
44
  /** Retention removed this result's files: its screenshots go quietly, never as a complaint. */
43
45
  export declare function resultShotsPruned(evidenceRoot: string, run: number): boolean;
44
46
  /**
@@ -102,6 +102,11 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
102
102
  const remaining = shots.filter(one => !sent.has(one.artifact));
103
103
  return remaining.length === 0 ? { kind: "none", why: "sent" } : { kind: "send", taskId: row.taskId, run: row.run, shots: remaining };
104
104
  }
105
+ /** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
106
+ export function shotWaitsUntil(resultNextAt, now) {
107
+ const soonest = new Date(now.getTime() + 1_000).toISOString();
108
+ return typeof resultNextAt === "string" && resultNextAt > soonest ? resultNextAt : soonest;
109
+ }
105
110
  /** Retention removed this result's files: its screenshots go quietly, never as a complaint. */
106
111
  export function resultShotsPruned(evidenceRoot, run) {
107
112
  return existsSync(join(evidenceRoot, String(run), RETENTION_NOTE));
@@ -39,4 +39,97 @@ export declare const REPORT_LIMITS: {
39
39
  readonly followUpTitle: 200;
40
40
  readonly followUpGoal: 2000;
41
41
  };
42
+ /**
43
+ * The scout's handback for Claude's `--json-schema` (run 2334's fix): plan
44
+ * mode only lets a session write its own plan file, so a Claude scout
45
+ * returns a report — or a question for the operator — as structured output
46
+ * on the terminal result event. Shape only, never the validator: byte caps,
47
+ * one-line titles and control characters stay `parseReport`'s and
48
+ * `parseDecision`'s, applied to the re-serialized body.
49
+ */
50
+ export declare const SCOUT_OUTPUT_JSON_SCHEMA: {
51
+ readonly type: "object";
52
+ readonly properties: {
53
+ readonly kind: {
54
+ readonly type: "string";
55
+ readonly enum: readonly ["report", "question"];
56
+ };
57
+ readonly report: {
58
+ readonly type: "object";
59
+ readonly properties: {
60
+ readonly title: {
61
+ readonly type: "string";
62
+ };
63
+ readonly summary: {
64
+ readonly type: "string";
65
+ };
66
+ readonly report: {
67
+ readonly type: "string";
68
+ };
69
+ readonly followUps: {
70
+ readonly type: "array";
71
+ readonly items: {
72
+ readonly type: "object";
73
+ readonly properties: {
74
+ readonly title: {
75
+ readonly type: "string";
76
+ };
77
+ readonly goal: {
78
+ readonly type: "string";
79
+ };
80
+ };
81
+ readonly required: readonly ["title", "goal"];
82
+ readonly additionalProperties: false;
83
+ };
84
+ };
85
+ };
86
+ readonly required: readonly ["title", "summary", "report"];
87
+ readonly additionalProperties: false;
88
+ };
89
+ readonly decision: {
90
+ readonly type: "object";
91
+ readonly properties: {
92
+ readonly urgency: {
93
+ readonly type: "string";
94
+ readonly enum: readonly ["blocking"];
95
+ };
96
+ readonly recap: {
97
+ readonly type: "string";
98
+ };
99
+ readonly question: {
100
+ readonly type: "string";
101
+ };
102
+ readonly options: {
103
+ readonly type: "array";
104
+ readonly items: {
105
+ readonly type: "object";
106
+ readonly properties: {
107
+ readonly id: {
108
+ readonly type: "string";
109
+ };
110
+ readonly label: {
111
+ readonly type: "string";
112
+ };
113
+ readonly consequence: {
114
+ readonly type: "string";
115
+ };
116
+ readonly reversible: {
117
+ readonly type: "boolean";
118
+ };
119
+ };
120
+ readonly required: readonly ["id", "label", "consequence", "reversible"];
121
+ readonly additionalProperties: false;
122
+ };
123
+ };
124
+ readonly recommendation: {
125
+ readonly type: "string";
126
+ };
127
+ };
128
+ readonly required: readonly ["urgency", "recap", "question", "options", "recommendation"];
129
+ readonly additionalProperties: false;
130
+ };
131
+ };
132
+ readonly required: readonly ["kind"];
133
+ readonly additionalProperties: false;
134
+ };
42
135
  export declare function parseReport(raw: string): ReportParseResult;
@@ -18,6 +18,69 @@ export const REPORT_LIMITS = {
18
18
  followUpTitle: 200,
19
19
  followUpGoal: 2_000,
20
20
  };
21
+ const REPORT_SHAPE = {
22
+ type: "object",
23
+ properties: {
24
+ title: { type: "string" },
25
+ summary: { type: "string" },
26
+ report: { type: "string" },
27
+ followUps: {
28
+ type: "array",
29
+ items: {
30
+ type: "object",
31
+ properties: { title: { type: "string" }, goal: { type: "string" } },
32
+ required: ["title", "goal"],
33
+ additionalProperties: false,
34
+ },
35
+ },
36
+ },
37
+ required: ["title", "summary", "report"],
38
+ additionalProperties: false,
39
+ };
40
+ /** The park mailbox's decision, the same fields `parseDecision` reads. */
41
+ const DECISION_SHAPE = {
42
+ type: "object",
43
+ properties: {
44
+ urgency: { type: "string", enum: ["blocking"] },
45
+ recap: { type: "string" },
46
+ question: { type: "string" },
47
+ options: {
48
+ type: "array",
49
+ items: {
50
+ type: "object",
51
+ properties: {
52
+ id: { type: "string" },
53
+ label: { type: "string" },
54
+ consequence: { type: "string" },
55
+ reversible: { type: "boolean" },
56
+ },
57
+ required: ["id", "label", "consequence", "reversible"],
58
+ additionalProperties: false,
59
+ },
60
+ },
61
+ recommendation: { type: "string" },
62
+ },
63
+ required: ["urgency", "recap", "question", "options", "recommendation"],
64
+ additionalProperties: false,
65
+ };
66
+ /**
67
+ * The scout's handback for Claude's `--json-schema` (run 2334's fix): plan
68
+ * mode only lets a session write its own plan file, so a Claude scout
69
+ * returns a report — or a question for the operator — as structured output
70
+ * on the terminal result event. Shape only, never the validator: byte caps,
71
+ * one-line titles and control characters stay `parseReport`'s and
72
+ * `parseDecision`'s, applied to the re-serialized body.
73
+ */
74
+ export const SCOUT_OUTPUT_JSON_SCHEMA = {
75
+ type: "object",
76
+ properties: {
77
+ kind: { type: "string", enum: ["report", "question"] },
78
+ report: REPORT_SHAPE,
79
+ decision: DECISION_SHAPE,
80
+ },
81
+ required: ["kind"],
82
+ additionalProperties: false,
83
+ };
21
84
  function refuse(reason, message) {
22
85
  return { ok: false, problems: [{ reason, message }] };
23
86
  }