toolroll 0.9.1 → 0.9.2

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.
package/dist/operate.js CHANGED
@@ -37,6 +37,7 @@ import { followSlack } from "./slack.js";
37
37
  import { validateScopeText } from "./task-text.js";
38
38
  import { runMemoryCommand } from "./memory-cli.js";
39
39
  import { runKnowledgeCommand } from "./knowledge-cli.js";
40
+ import { FLOWS_VALUE_FLAGS, runFlowsCommand } from "./flows-cli.js";
40
41
  import { runAssignmentCommand } from "./assignment-adapters.js";
41
42
  import { applyProjectProfile, runProjectCommand } from "./project-cli.js";
42
43
  import { runTaskOutcomeCommand } from "./task-outcome-cli.js";
@@ -414,6 +415,19 @@ Routines — tasks that fire on a schedule, each instance isolated
414
415
  toolroll routine pause|resume <name>
415
416
  toolroll routine run-now <name> --as <you> --token <t>
416
417
 
418
+ Flows — processes cards move through; the console's rules
419
+ toolroll flows list [--repo <path>] | show <id>
420
+ toolroll flows create --repo <path> --name <name> (--template <id> | --steps <file|->)
421
+ toolroll flows edit <id> --steps <file|-> [--name <name>]
422
+ toolroll flows trigger add <id> <json|file|->
423
+ toolroll flows trigger pause|resume|remove|check <id> <trigger>
424
+ toolroll flows script save --repo <path> --name <name> (--file <path in project> | --body <file>)
425
+ --about "<one line>" [--language shell|python|node] [--timeout-minutes <n>]
426
+ toolroll flows card add <id> --title <t> [--description <d>] [--zone <zone>]
427
+ toolroll flows archive <id>
428
+ writes take --as <you> --token <t> (or the remembered login); create,
429
+ edit, archive and trigger add preview until --yes
430
+
417
431
  Agents — which provider and model each phase runs on
418
432
  toolroll providers what is installed, logged in, and
419
433
  configured on this machine — without
@@ -579,6 +593,8 @@ export const OPERATE_VALUE_FLAGS = new Set([
579
593
  "run", "containment", "agent",
580
594
  // onboard: the starter flows to switch on.
581
595
  "starter",
596
+ // flows: templates, steps, scripts and cards.
597
+ "template", "steps", "about", "language", "timeout-minutes", "body", "description", "zone",
582
598
  "token-env", "after", "repair-max-attempts", "consumer", "batch", "feedback", "source", "view", "cursor", "why", "supersedes", "decision", "sessions", "timeout",
583
599
  ]);
584
600
  export const OPERATE_BOOLEAN_FLAGS = new Set([
@@ -592,16 +608,17 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
592
608
  // Settings → Integrations: the last checks, without checking again.
593
609
  "saved",
594
610
  ]);
595
- export function parseOperateArgs(argv) {
611
+ export function parseOperateArgs(argv, ownValues = new Set()) {
596
612
  const positional = [];
597
613
  const flags = new Map();
598
614
  const repoList = [];
599
- const wantsValue = OPERATE_VALUE_FLAGS;
615
+ const wantsValue = new Set([...OPERATE_VALUE_FLAGS, ...ownValues]);
600
616
  // Every boolean flag any verb reads. A --flag in neither set is a typo,
601
617
  // and a typo silently becoming `true` (with its intended value demoted to
602
618
  // a positional) surfaces later as a different, wronger error — refuse it
603
619
  // here by name instead (Codex round-4 findings 3/8).
604
- const booleans = OPERATE_BOOLEAN_FLAGS;
620
+ // A verb may read a global switch's name as a value of its own (`flows script save --file <path>`).
621
+ const booleans = new Set([...OPERATE_BOOLEAN_FLAGS].filter(name => !ownValues.has(name)));
605
622
  for (let index = 0; index < argv.length; index++) {
606
623
  const argument = argv[index];
607
624
  if (!argument.startsWith("--")) {
@@ -644,7 +661,7 @@ export function parseOperateArgs(argv) {
644
661
  }
645
662
  /** Route an `operate` command. Returns the process exit code. */
646
663
  export async function runOperate(command, argv, write, options = {}) {
647
- const parsed = parseOperateArgs(argv);
664
+ const parsed = parseOperateArgs(argv, command === "flows" ? FLOWS_VALUE_FLAGS : undefined);
648
665
  // A parse error precedes the flags map, so JSON mode is read from the raw
649
666
  // argv — the envelope contract holds even for the earliest refusal.
650
667
  if ("error" in parsed)
@@ -920,6 +937,8 @@ async function dispatch(command, positional, flags, context) {
920
937
  return incidentCommand(positional, flags, context);
921
938
  case "routine":
922
939
  return routineCommand(positional, flags, context);
940
+ case "flows":
941
+ return flowsCommand(positional, flags, context);
923
942
  case "config":
924
943
  return configCommand(positional, flags, context);
925
944
  case "chat":
@@ -10946,6 +10965,22 @@ async function askCredentials(flags, context) {
10946
10965
  return null;
10947
10966
  return { name, token };
10948
10967
  }
10968
+ /** `flows …`: the console's flow rules from a terminal. Writes are an approver's (--as/--token or the remembered login). */
10969
+ async function flowsCommand(positional, flags, context) {
10970
+ const { store } = context;
10971
+ const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
10972
+ const projects = [...new Set([...store.knownRepos(), ...store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])];
10973
+ const writes = positional[0] !== undefined && positional[0] !== "list" && positional[0] !== "show";
10974
+ const acting = writes && !flags.has("help") ? await askCredentials(flags, context) : null;
10975
+ const dir = dirname(context.databaseFile);
10976
+ return runFlowsCommand(positional, flags, {
10977
+ store, write: context.write, json: context.json, clock: context.clock, credentials: acting, projects, configDir: dir, evidenceRoot: context.evidenceRoot,
10978
+ // "Check now": the same io the worker's pass checks triggers with.
10979
+ triggerIo: { gh: context.flowTriggerIo?.gh ?? run, fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dir,
10980
+ shell: context.flowTriggerIo?.shell ?? context.flowStepIo?.shell ?? run, scratch: context.flowTriggerIo?.scratch ?? join(dir, "flow-scratch"),
10981
+ ...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) },
10982
+ });
10983
+ }
10949
10984
  async function approverCommand(positional, flags, context) {
10950
10985
  const { store, write, json, now } = context;
10951
10986
  const [action, name] = positional;
@@ -0,0 +1,39 @@
1
+ import type { Store } from "./store.js";
2
+ export type PlaneProblem = {
3
+ /** Stable across days: the same problem tomorrow has the same key. Only [a-z0-9/._-]. */
4
+ key: string;
5
+ title: string;
6
+ /** One line: how many, in the last 24 hours. */
7
+ summary: string;
8
+ count: number;
9
+ runs: number[];
10
+ /** Short excerpts, secrets hidden, at most EVIDENCE_LINES. */
11
+ evidence: string[];
12
+ };
13
+ export declare const REVIEW_HOURS = 24;
14
+ export declare const RUN_CAUSES: readonly ["provider", "sign-in", "check", "timeout", "no-handoff", "stuck-lease", "orphaned", "no-change", "other"];
15
+ export type RunCause = (typeof RUN_CAUSES)[number];
16
+ /** Anything shaped like a key is hidden; one short line. */
17
+ export declare function excerpt(text: string | null | undefined, cap?: number): string;
18
+ /** Why one finished run is a problem, or null when it isn't. */
19
+ export declare function runCause(run: {
20
+ outcome: string | null;
21
+ reason: string | null;
22
+ terminalClass: string | null;
23
+ handoff: string | null;
24
+ check: string | null;
25
+ stopped: boolean;
26
+ }): RunCause | "plan-limit" | null;
27
+ /**
28
+ * Every problem worth fixing in the `REVIEW_HOURS` before `now`, worst first.
29
+ * `canSee(repo)` says which projects' work counts (a run or task with no
30
+ * project counts only when every project may be seen).
31
+ */
32
+ export declare function reviewPlane(store: Store, now: Date, canSee?: (repo: string | null) => boolean): PlaneProblem[];
33
+ /** A problem as a card's details (or a day's note on a card it joins). */
34
+ export declare function problemText(problem: PlaneProblem, day: string): string;
35
+ /** The card's opening details: the day's evidence, and what it is. */
36
+ export declare function problemCard(problem: PlaneProblem, day: string): {
37
+ title: string;
38
+ description: string;
39
+ };
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The morning plane review: what went wrong on this plane in the last 24
3
+ * hours, read straight from the store — never a model, never a fenced
4
+ * script — as one problem per distinct thing worth fixing:
5
+ *
6
+ * - failed or no-change runs, grouped by cause (provider error, sign-in
7
+ * expiry, check failure, timeout, quit without handoff, stuck lease,
8
+ * orphaned process, no change, other);
9
+ * - tasks waiting on a person for over a day, grouped by what they wait for;
10
+ * - sign-in and plan-limit pauses, per provider;
11
+ * - chat replies that couldn't be delivered, per app;
12
+ * - integrations that are Broken (integration_check);
13
+ * - release checks that failed;
14
+ * - worker passes that logged work as broke.
15
+ *
16
+ * Each problem has a stable key, so the same problem tomorrow is the same
17
+ * problem (flow-triggers.ts joins it to its card), and carries counts, run
18
+ * ids and short evidence excerpts with anything shaped like a secret hidden.
19
+ * A clean day is no problems at all. Only projects the reader may see count.
20
+ */
21
+ import { scanForSecrets } from "./evidence.js";
22
+ import { scrubIntegrationText } from "./integrations.js";
23
+ import { taskWaitSnapshot } from "./lead-status.js";
24
+ export const REVIEW_HOURS = 24;
25
+ const EVIDENCE_LINES = 5;
26
+ const WAITING_DAYS = 1;
27
+ /** A Ready result older than this is history, not today's problem. */
28
+ const READY_WINDOW_DAYS = 7;
29
+ export const RUN_CAUSES = ["provider", "sign-in", "check", "timeout", "no-handoff", "stuck-lease", "orphaned", "no-change", "other"];
30
+ const CAUSE_TITLES = {
31
+ provider: "Runs failed on a provider error",
32
+ "sign-in": "Runs stopped: sign-in expired",
33
+ check: "Runs failed their checks",
34
+ timeout: "Runs timed out",
35
+ "no-handoff": "Agents quit without a handoff",
36
+ "stuck-lease": "Leases got stuck",
37
+ orphaned: "Processes were left behind",
38
+ "no-change": "Runs ended with no change",
39
+ other: "Runs failed for another reason",
40
+ };
41
+ /** Anything shaped like a key is hidden; one short line. */
42
+ export function excerpt(text, cap = 160) {
43
+ if (text === null || text === undefined)
44
+ return "";
45
+ const clean = scrubIntegrationText(text);
46
+ if (scanForSecrets(clean).length > 0)
47
+ return "[hidden: it looked like it held a key or password]";
48
+ return clean.length > cap ? `${clean.slice(0, cap - 1).trimEnd()}…` : clean;
49
+ }
50
+ const PROVIDER_REASONS = new Set(["retryable-infra", "provider-init", "provider-protocol", "provider-unattested", "setup"]);
51
+ const TIMEOUT_WORDS = /made no observable progress|ran past \d+ minutes|timed out/i;
52
+ /** Why one finished run is a problem, or null when it isn't. */
53
+ export function runCause(run) {
54
+ const reason = run.reason ?? "";
55
+ if (reason === "auth-expired" || run.terminalClass === "auth-expired")
56
+ return "sign-in";
57
+ if (run.terminalClass === "usage-exhausted" || run.terminalClass === "credits-depleted")
58
+ return "plan-limit";
59
+ if (run.check === "failed")
60
+ return "check";
61
+ if (reason === "timeout" || (run.outcome === "failed" && TIMEOUT_WORDS.test(run.handoff ?? "")))
62
+ return "timeout";
63
+ if (reason === "no-handoff")
64
+ return "no-handoff";
65
+ if (reason === "interrupted" || run.outcome === "interrupted")
66
+ return run.stopped ? null : "orphaned";
67
+ if (PROVIDER_REASONS.has(reason) || run.terminalClass === "transient-throttle")
68
+ return "provider";
69
+ if (run.outcome === "no-change")
70
+ return "no-change";
71
+ if (run.outcome === "failed")
72
+ return "other";
73
+ return null;
74
+ }
75
+ const plural = (n, word, many = `${word}s`) => `${n} ${n === 1 ? word : many}`;
76
+ const keyPart = (value) => value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "unknown";
77
+ const str = (value) => value === null || value === undefined ? null : String(value);
78
+ const tableExists = (store, table) => store.handle.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !== undefined;
79
+ const columnExists = (store, table, column) => store.handle.prepare(`SELECT 1 FROM pragma_table_info('${table}') WHERE name = ?`).get(column) !== undefined;
80
+ /**
81
+ * Every problem worth fixing in the `REVIEW_HOURS` before `now`, worst first.
82
+ * `canSee(repo)` says which projects' work counts (a run or task with no
83
+ * project counts only when every project may be seen).
84
+ */
85
+ export function reviewPlane(store, now, canSee = () => true) {
86
+ const since = new Date(now.getTime() - REVIEW_HOURS * 3_600_000).toISOString();
87
+ const buckets = new Map();
88
+ const add = (key, title, noun, item) => {
89
+ const bucket = buckets.get(key) ?? { title, count: 0, runs: new Set(), evidence: [], noun };
90
+ bucket.count += item.count ?? 1;
91
+ if (item.run !== undefined && item.run !== null)
92
+ bucket.runs.add(item.run);
93
+ if (item.evidence && bucket.evidence.length < EVIDENCE_LINES && !bucket.evidence.includes(item.evidence))
94
+ bucket.evidence.push(item.evidence);
95
+ buckets.set(key, bucket);
96
+ };
97
+ // Runs that finished badly, by cause.
98
+ const runs = store.handle.prepare(`SELECT run.id, run.outcome, run.reason, run.terminal_class, run.handoff, run.provider, ref.repo, ref.external_id AS task,
99
+ (SELECT status FROM run_check WHERE run_check.run = run.id AND run_check.release = 0) AS check_status,
100
+ (SELECT line FROM run_check WHERE run_check.run = run.id AND run_check.release = 0) AS check_line,
101
+ EXISTS (SELECT 1 FROM run_stop WHERE run_stop.run = run.id) AS stopped
102
+ FROM run JOIN task_ref ref ON ref.id = run.task_ref
103
+ WHERE run.finished_at >= ? AND run.finished_at <= ? ORDER BY run.id`).all(since, now.toISOString());
104
+ for (const row of runs) {
105
+ if (!canSee(str(row["repo"])))
106
+ continue;
107
+ const cause = runCause({ outcome: str(row["outcome"]), reason: str(row["reason"]), terminalClass: str(row["terminal_class"]), handoff: str(row["handoff"]), check: str(row["check_status"]), stopped: Number(row["stopped"]) === 1 });
108
+ if (cause === null)
109
+ continue;
110
+ const id = Number(row["id"]);
111
+ const said = excerpt(cause === "check" ? str(row["check_line"]) ?? str(row["handoff"]) : str(row["handoff"]) ?? str(row["reason"]));
112
+ const evidence = `Run ${id} (task ${str(row["task"]) ?? "?"})${said === "" ? "" : `: ${said}`}`;
113
+ if (cause === "plan-limit") {
114
+ const provider = str(row["provider"]) ?? "provider";
115
+ add(`pause/limit/${keyPart(provider)}`, `${provider} hit its plan limit`, ["time", "times"], { run: id, evidence });
116
+ }
117
+ else
118
+ add(`run/${cause}`, CAUSE_TITLES[cause], ["run", "runs"], { run: id, evidence });
119
+ }
120
+ // Leases that expired without being given back: the worker holding them went quiet.
121
+ const leases = store.handle.prepare(`SELECT claim.lease_id, claim.runner, claim.expires_at, claim.released_by, ref.repo, ref.external_id AS task,
122
+ (SELECT MAX(run.id) FROM run WHERE run.lease_id = claim.lease_id) AS run
123
+ FROM claim JOIN task_ref ref ON ref.id = claim.task_ref
124
+ WHERE (claim.released_by = 'reaped' AND claim.released_at >= ?) OR (claim.released_at IS NULL AND claim.expires_at <= ? AND claim.expires_at >= ?)
125
+ ORDER BY claim.expires_at`).all(since, now.toISOString(), since);
126
+ for (const row of leases) {
127
+ if (!canSee(str(row["repo"])))
128
+ continue;
129
+ const run = row["run"] === null ? null : Number(row["run"]);
130
+ add("run/stuck-lease", CAUSE_TITLES["stuck-lease"], ["lease", "leases"], { run,
131
+ evidence: `Task ${str(row["task"]) ?? "?"}${run === null ? "" : `, run ${run}`}: the lease held by ${excerpt(str(row["runner"]), 40)} ${row["released_by"] === "reaped" ? "expired and was taken back" : "expired and is still held"}` });
132
+ }
133
+ // Processes still alive for a run that has finished.
134
+ const orphans = store.handle.prepare(`SELECT rp.run, rp.pid, rp.host, ref.repo, ref.external_id AS task FROM run_process rp
135
+ JOIN run ON run.id = rp.run JOIN task_ref ref ON ref.id = run.task_ref
136
+ WHERE rp.exited_at IS NULL AND run.outcome IS NOT NULL AND (run.finished_at >= ? OR rp.observed_at >= ?) ORDER BY rp.run`).all(since, since);
137
+ for (const row of orphans) {
138
+ if (!canSee(str(row["repo"])))
139
+ continue;
140
+ const run = Number(row["run"]);
141
+ 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
+ }
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
+ const snapshot = taskWaitSnapshot(store, String(row["id"]), now);
152
+ if (snapshot === null || (snapshot.reason !== "needs-person" && snapshot.reason !== "ready"))
153
+ continue;
154
+ const days = Math.floor((now.getTime() - Date.parse(String(row["updated_at"]))) / 86_400_000);
155
+ add(`waiting/${keyPart(snapshot.next)}`, `Tasks waited over a day: ${snapshot.next}`, ["task", "tasks"], { run: snapshot.run,
156
+ evidence: `Task ${String(row["id"])} “${excerpt(str(row["title"]), 80)}”: ${plural(days, "day")} waiting` });
157
+ }
158
+ // Sign-in and plan-limit pauses.
159
+ if (tableExists(store, "provider_auth_pause")) {
160
+ for (const row of store.handle.prepare("SELECT provider, opened_at, lifted_at, runs, first_run FROM provider_auth_pause WHERE opened_at >= ? OR lifted_at IS NULL OR lifted_at >= ? ORDER BY id").all(since, since)) {
161
+ const provider = String(row["provider"]);
162
+ add(`pause/sign-in/${keyPart(provider)}`, `${provider} needed signing in again`, ["pause", "pauses"], { run: row["first_run"] === null ? null : Number(row["first_run"]),
163
+ evidence: `Paused ${String(row["opened_at"]).slice(0, 16).replace("T", " ")} UTC, ${plural(Number(row["runs"] ?? 1), "run")} held; ${row["lifted_at"] === null ? "still paused" : "lifted"}` });
164
+ }
165
+ }
166
+ if (tableExists(store, "provider_limit")) {
167
+ for (const row of store.handle.prepare("SELECT provider, window, used_percent, resets_at FROM provider_limit WHERE reached = 1 AND observed_at >= ? ORDER BY provider, window").all(since)) {
168
+ const provider = String(row["provider"]);
169
+ add(`pause/limit/${keyPart(provider)}`, `${provider} hit its plan limit`, ["time", "times"], {
170
+ evidence: `The ${excerpt(str(row["window"]), 20)} limit was reached (${Math.round(Number(row["used_percent"]))}% used)${row["resets_at"] === null ? "" : `; resets ${String(row["resets_at"]).slice(0, 16).replace("T", " ")} UTC`}`
171
+ });
172
+ }
173
+ }
174
+ // Chat replies that couldn't be delivered.
175
+ for (const app of ["slack", "discord", "teams"]) {
176
+ if (!tableExists(store, `${app}_part`))
177
+ continue;
178
+ const name = { slack: "Slack", discord: "Discord", teams: "Teams" }[app];
179
+ for (const row of store.handle.prepare(`SELECT id, problem, attempts FROM ${app}_part WHERE state = 'dropped' AND created >= ? ORDER BY id`).all(since)) {
180
+ add(`chat/${app}`, `${name} replies weren't delivered`, ["reply", "replies"], { evidence: `After ${plural(Number(row["attempts"] ?? 0), "try", "tries")}: ${excerpt(str(row["problem"])) || "dropped"}` });
181
+ }
182
+ }
183
+ if (tableExists(store, "telegram_conversation_part")) {
184
+ for (const row of store.handle.prepare("SELECT last_error, attempts FROM telegram_conversation_part WHERE state = 'dropped' AND created_at >= ?").all(since)) {
185
+ add("chat/telegram", "Telegram replies weren't delivered", ["reply", "replies"], { evidence: `After ${plural(Number(row["attempts"] ?? 0), "try", "tries")}: ${excerpt(str(row["last_error"])) || "dropped"}` });
186
+ }
187
+ }
188
+ if (tableExists(store, "notification_delivery")) {
189
+ for (const row of store.handle.prepare("SELECT last_error, attempts FROM notification_delivery WHERE destination LIKE 'telegram:%' AND last_error IS NOT NULL AND delivered_at IS NULL AND last_attempt_at >= ?").all(since)) {
190
+ add("chat/telegram", "Telegram replies weren't delivered", ["reply", "replies"], { evidence: `After ${plural(Number(row["attempts"] ?? 0), "try", "tries")}: ${excerpt(str(row["last_error"]))}` });
191
+ }
192
+ }
193
+ // Integrations that are Broken now and were checked in the window.
194
+ if (tableExists(store, "integration_check")) {
195
+ for (const row of store.handle.prepare("SELECT key, problem, checked_at FROM integration_check WHERE outcome = 'failed' AND checked_at >= ? ORDER BY key").all(since)) {
196
+ const key = String(row["key"]);
197
+ add(`integration/${keyPart(key)}`, `Integration broken: ${excerpt(key, 40)}`, ["check", "checks"], { evidence: excerpt(str(row["problem"])) || "The last check failed." });
198
+ }
199
+ }
200
+ // Release checks that failed.
201
+ const releases = store.handle.prepare(`SELECT rc.run, rc.line, ref.repo, ref.external_id AS task FROM run_check rc JOIN run ON run.id = rc.run JOIN task_ref ref ON ref.id = run.task_ref
202
+ WHERE rc.release = 1 AND rc.status = 'failed' AND rc.recorded_at >= ? ORDER BY rc.run`).all(since);
203
+ for (const row of releases) {
204
+ if (!canSee(str(row["repo"])))
205
+ continue;
206
+ const run = Number(row["run"]);
207
+ const said = excerpt(str(row["line"]));
208
+ add("release-check", "Release checks failed", ["check", "checks"], { run, evidence: `Run ${run} (task ${str(row["task"]) ?? "?"})${said === "" ? "" : `: ${said}`}` });
209
+ }
210
+ // What the worker counted as broke in its passes.
211
+ if (tableExists(store, "watch_episode") && columnExists(store, "watch_episode", "broke")) {
212
+ for (const row of store.handle.prepare("SELECT id, repo, runner, broke, started_at, ended_at FROM watch_episode WHERE broke > 0 AND COALESCE(ended_at, started_at) >= ? ORDER BY id").all(since)) {
213
+ if (!canSee(str(row["repo"])))
214
+ continue;
215
+ const broke = Number(row["broke"]);
216
+ add("worker/broke", "The worker logged work as broke", ["time", "times"], { count: broke,
217
+ evidence: `Worker ${excerpt(str(row["runner"]), 40)} (pass ${Number(row["id"])}): ${plural(broke, "piece")} of work broke${row["ended_at"] === null ? ", still running" : ""}` });
218
+ }
219
+ }
220
+ return [...buckets].map(([key, bucket]) => {
221
+ const runsList = [...bucket.runs].sort((a, b) => a - b);
222
+ return { key, title: bucket.title, count: bucket.count, runs: runsList, evidence: bucket.evidence,
223
+ 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 ? ", …" : ""})`}.` };
224
+ }).sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
225
+ }
226
+ /** A problem as a card's details (or a day's note on a card it joins). */
227
+ export function problemText(problem, day) {
228
+ return [`${day}: ${problem.summary}`, ...problem.evidence.map(line => `- ${line}`)].join("\n");
229
+ }
230
+ /** The card's opening details: the day's evidence, and what it is. */
231
+ export function problemCard(problem, day) {
232
+ return { title: problem.title, description: `${problemText(problem, day)}\n\nFound by the morning plane review (problem ${problem.key}). If it comes back, the new day joins this card.` };
233
+ }
package/dist/surface.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { KNOWLEDGE_DESCRIPTORS } from "./knowledge-cli.js";
2
2
  import { MEMORY_DESCRIPTORS } from "./memory-cli.js";
3
3
  import { MODELS_DESCRIPTORS } from "./models-cli.js";
4
+ import { FLOWS_DESCRIPTORS } from "./flows-cli.js";
4
5
  /**
5
6
  * The declared command guide (arc 5): the agent-facing surface as data,
6
7
  * dumped by `contract --commands`. This is DOCUMENTATION with a stable
@@ -115,6 +116,9 @@ export const COMMAND_GUIDE = [
115
116
  })),
116
117
  ...KNOWLEDGE_DESCRIPTORS.map(spec => ({ invocation: `knowledge ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text or source file for impact" }] } : {}) })),
117
118
  ...MEMORY_DESCRIPTORS.map(spec => ({ invocation: `memory ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text, a decision id, or the decision sentence" }] } : {}) })),
119
+ // Flow writes carry an approver's credential: an agent runs them only as the person asked, after showing the preview.
120
+ ...FLOWS_DESCRIPTORS.map(spec => ({ invocation: `flows ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags,
121
+ ...("positionals" in spec ? { positionals: spec.positionals.map(name => ({ name, required: true, meaning: name === "flow" ? "a flow id from flows list" : "a trigger id from flows show, or for trigger add its settings as JSON, a JSON file, or -" })) } : {}) })),
118
122
  ...MODELS_DESCRIPTORS.map(spec => ({ invocation: `models ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "target", required: true, meaning: "the CLI to update, or on/off" }] } : {}) })),
119
123
  // ---- the queue (agent surface) ----
120
124
  { invocation: "status", synopsis: "running work, queued reasons, results to review, the latest release check and plan windows", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"