SLOPSHOPPER

opum-quest

Drive the quest CLI: create, list, view, edit, complete, and archive tasks, drafts, milestones, and decisions in a Quest-initialized workspace instead of…

newpanebandguardtoaststatus
v0.13.0MITupdated 2026-10-07opum-ai/quest-cli
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · opum-quest
│ ┃ Quest ✕ › fix the failing auth test and add an audit log call │ ┃ [ List ] [ Kanban ] │ ┃ [ Fleet ] [ app ] [ Refs ] [ Refresh ] [ Nor ⏺ Read(src/auth.ts) │ ┃ Filter by id, title or label ⏎ Filter ⎿ Read 6 lines │ ┃ Status: In progress ▾Repo: All repos ▾ ⏺ Update(src/auth.ts) │ ┃ 0 in progress tasks in 0 repos, checked 08:… ⎿ Added 2 lines, removed 1 line │ ┃ No Quest workspaces found above /work. Add … ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 2 tracker changes in app not committed yet.… │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Could not read: │ ┃ app: JSON Parse error: Unexpected EOF ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Quest
[ List ] [ Kanban ] [ Fleet ] [ app ] [ Refs ] [ Refresh ] [ Normal · z for full Filter by id, title or label ⏎ Filter Status: In progress ▾Repo: All repos ▾ 0 in progress tasks in 0 repos, checked 08:53 No Quest workspaces found above /work. Add the plugin's roo… 2 tracker changes in app not committed yet. Until they land… Could not read: app: JSON Parse error: Unexpected EOF
README

quest

A deterministic, LLM-free task tracker CLI — the record layer coding agents and humans write to, and that tools like lore couple to over JSON.

quest is the tracker of record for repository-resident work: tasks, drafts, decisions, and milestones live as plain JSON under .quest/, committed to Git alongside the code they describe. There is no server, no database, and no LLM in quest's core — every command is deterministic, reproducible, and CI/agent-safe (non-interactive by default, stable semantic exit codes, machine-readable --json).

  • Built on Bun + TypeScript with an exact-pinned Commander parser; a versioned capability manifest (quest manifest --json) is the live, authoritative description of the command surface.
  • Published on npm as @opum-ai/quest (bin quest) with six exact-pinned platform packages, including Windows ARM64.
  • The agent bridge is a generated managed block in CLAUDE.md, AGENTS.md, or GEMINI.md (Claude Code, Codex/OpenCode/pi/etc., and Google Antigravity/Gemini CLI respectively) plus quest instructions for just-in-time, task-shaped guidance.

Status: released. The release tag, the qualified workflow artifacts, all seven public @opum-ai/quest* npm packages, and a clean registry install agree on one published version. This file deliberately does not restate that number, and bun run check:packages fails the release if it reappears: a version hand-maintained here goes stale the moment the next release lands, and because README.md ships inside the tarball, the npm page then advertises the wrong one permanently -- version pages are immutable. npm and the repository's tags both show the current version already. Releases are qualified in lockstep with @opum-ai/lore. See docs/reference/quest-cli-release-truth.md.


The headline: every write declares who made it

quest never accepts an anonymous write. Every command that mutates a record — task create, task edit, task complete, and the rest — requires an explicit actor declaration:

quest task edit QCLI-1 --status "In Progress" \
  --actor jdnewhouse --actor-kind human

or, when the actor performing the write is an agent rather than the human it answers to:

quest task edit QCLI-1 --status "In Progress" \
  --actor my-agent-session --actor-kind delegated-agent \
  --accountable-human jdnewhouse

A missing --actor-kind is rejected outright ("Tracker writes require an explicit actor declaration"); an invalid value names itself and lists the valid kinds. This is deliberate, load-bearing provenance, not an afterthought — it is what lets a record say not just what changed but who is accountable for it, which matters most exactly when the actor is an autonomous agent.

Tools couple to quest the same way quest couples to Git: by reading its deterministic JSON, never by parsing prose or editing .quest/ files directly. lore is the reference consumer — see Lore dependency and adapter contract evidence.


Install

The package and bin are @opum-ai/quest and quest:

# Node / npm
npx @opum-ai/quest --help

# Bun
bunx @opum-ai/quest --help

# Global npm install
npm install -g @opum-ai/quest

The launcher installs only the matching script-free platform package for your OS/architecture, so a current install requires no install-script approval exception. There is no native addon and nothing to compile locally.

Or add it to a project:

bun add -d @opum-ai/quest   # or: npm i -D @opum-ai/quest

The npm package is a dual artifact: a tiny Node .cjs launcher plus a per-platform compiled binary (built with bun build --compile), delivered as optionalDependencies — macOS, Linux, and Windows, each on x64 and arm64.


Quickstart

Every command is non-interactive by default and emits stable, semantic exit codes. Output has three modes: pretty (default, color on a TTY), --plain (ANSI-free stable text), and --json (a {schemaVersion, kind, data} envelope on stdout, with errors on stderr as {error_type, message, hint}). The one interactive path is quest init on a bare TTY invocation, which runs a guided setup (project name, task ID prefix, a multi-select for which instruction file(s) to write) — any flag or a non-TTY stdin runs it fully non-interactively instead.

# 1. Initialize a Quest workspace in the current Git worktree.
quest init --name "My Project" --task-id-prefix ABC

# 2. Create a task. Every write needs an actor.
quest task create "Fix the flaky retry loop" \
  --description "Retries don't back off; a burst of failures thundering-herds the API." \
  --acceptance-criteria '["Retry loop applies exponential backoff with jitter"]' \
  --actor jdnewhouse --actor-kind human

# 3. Move it forward.
quest task edit ABC-1 --status "In Progress" --actor jdnewhouse --actor-kind human

# 4. See what's actually ready to work — dependencies resolved, blockers respected.
quest task list --ready --json

# 5. Finish it with evidence, not on faith.
quest task edit ABC-1 --check-ac 1 --actor jdnewhouse --actor-kind human
quest task edit ABC-1 --final-summary "Added exponential backoff with jitter; verified via the retry-storm test." \
  --actor jdnewhouse --actor-kind human
quest task complete ABC-1 --actor jdnewhouse --actor-kind human

--json is the additive-only machine contract:

$ quest task view ABC-1 --json
{
  "schemaVersion": 1,
  "kind": "task.view",
  "data": { "id": "ABC-1", "status": "Done", "title": "Fix the flaky retry loop", "..." : "..." },
  "principal": null
}

Semantic exit codes (uniform across commands, matching lore's own convention): 0 success, 1 uncaught, 2 usage, 3 not found, 4 denied, 5 conflict, 6 validation or drift. The full, live command surface is self-describing: quest manifest --json lists every command with its kind and whether it mutates; quest help <command> shows flags, fields, and examples for any command, including two-word ones (quest help task edit).


How coding agents use quest

quest is CLI-first for humans and agents. Its agent bridge is generated, not bespoke:

  • quest agents --update-instructions --target claude|codex|antigravity writes (or refreshes) a managed block in CLAUDE.md, AGENTS.md, or GEMINI.md pointing an agent at quest instructions and the write contract above.
  • quest instructions overview — and the more specific quest instructions task-creation / task-execution / task-finalization / workspace — print task-shaped guidance on demand for any agent or human, so onboarding doesn't depend on a human reading a wiki page first.
  • quest agents --check --require-installed --target <target> verifies an installed instructions block is current, for CI: exit 0 on current instructions (including a version-only diff on a routine bump), exit 6 on missing, drifted, or malformed ones.

An agent's typical loop: run quest instructions overview before answering or acting, read/search/create/update tasks with --json and an explicit actor, and check acceptance criteria only with evidence in hand.


Migrating from Backlog.md

quest migration backlog preview --source <project> --json previews a Backlog.md-to-Quest cutover — digest, mappings, and (with --preserve-source-ids) exactly what a dotted-subtask renumbering would change — before quest migration backlog apply commits to it. See the Backlog adoption and migration playbook.


Documentation

The full design lives in this repo's OKF bundle under docs/, authored and kept coherent with lore:


Contributing

This is a public repository (main + dev; dev is the default branch, main is the release branch, fast-forward only). See CONTRIBUTING, the Code of Conduct, and SECURITY.

License

MIT © 2026 Opum AI.

Source 3 files
hooks/register.tsx 2130 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register, UiOpenResult } from "claude-code";
3
4import type {
5  FsLike,
6  RepoRow,
7  Scope,
8  StatusFilter,
9  Tab,
10  TaskDetail,
11  View,
12} from "../types";
13import {
14  COLUMNS,
15  FRAME_COLUMNS,
16  STATUSES,
17  actorArgs,
18  alertChanges,
19  alertToasts,
20  awayLine,
21  commentArg,
22  decisionListArgs,
23  discoverRoot,
24  filterRows,
25  isAlertsState,
26  isSizeHeld,
27  isStoredView,
28  isWideLayout,
29  keptWidthNotice,
30  listArgs,
31  paneSize,
32  parseAcrossRefs,
33  parseAlerts,
34  parseDecisionList,
35  parseTaskList,
36  parseTaskOutcome,
37  parseTaskView,
38  parentOf,
39  repoNameFromGitCommonDir,
40  reposUnder,
41  waitingStatus,
42} from "./quest";
43import type {
44  AlertDecision,
45  AlertTask,
46  AlertsState,
47  Placement,
48  Viewport,
49} from "./quest";
50
51const PANE = "quest-board";
52// The pane's way in since QCLI-441: the engine lists this tool to Claude as
53// `mcp__opum-quest__dashboard`, and the generated `quest` skill routes
54// "quest dashboard" -- with `full`, `fleet`, `local` or a task id after it --
55// to it, sending every other argument to the CLI. There is no slash command:
56// the plugin's own skill owns `/opum-quest:quest`, so a command named `quest`
57// is refused by the engine, and a refused registration makes the whole
58// `session.start` hook throw (measured, QCLI-440).
59const DASHBOARD_TOOL = "dashboard";
60const DASHBOARD_TOOL_NAME = `mcp__opum-quest__${DASHBOARD_TOOL}`;
61const REFRESH_MS = 60_000;
62const PREFS_KEY = "opum-quest.board.view";
63
64const board = atom({ plugin: "opum-quest", key: "board" } as const, {
65  rows: [],
66  refreshedAt: null,
67  isLoading: false,
68});
69const view = atom({ plugin: "opum-quest", key: "view" } as const, {
70  tab: "list",
71  scope: "fleet",
72  status: "In Progress",
73  query: "",
74  repo: "all",
75  isCollapsed: false,
76  isFull: false,
77  readRefs: false,
78  selected: null,
79  pending: null,
80});
81const detail = atom({ plugin: "opum-quest", key: "detail" } as const, {
82  key: null,
83  task: null,
84  error: null,
85  isLoading: false,
86});
87const edits = atom({ plugin: "opum-quest", key: "edits" } as const, {
88  isWriting: false,
89  uncommitted: 0,
90});
91
92// Who pane edits are recorded as; the plugin's `actor` option.
93let actor = "jdnewhouse";
94
95// The fleet, discovered at session start: the root it was found under, the
96// repository names under it, and where each one's checkout is. Local mode adds
97// the session's own root.
98let fleetRoot: string | null = null;
99let repos: string[] = [];
100let localRepo: string | null = null;
101let discoveryNote: string | null = null;
102let isFleetResolved = false;
103let isUncommittedResolved = false;
104let pluginOptions: Record<string, unknown> = {};
105const dirs = new Map<string, string>();
106
107// The size the surface last reported, so a pane opened outside a draw --
108// `session.start`, or a `dashboard` tool call -- can still ask for the full
109// size. Only `ui.render` measures the surface, and it fills both axes; an
110// axis nothing has measured yet is left out of what is asked for.
111let viewport: Partial<Viewport> = {};
112
113// The body the docked pane last drew, when the last pane draw was a dock.
114// DEC-154 rule 2 as amended: a docked render's `viewport.columns` is the
115// transcript column, so the terminal width is that column plus this drawn
116// width plus the divider -- all from the same render -- and the full ask is
117// that less the engine's margin. Null before any dock has drawn.
118let lastDockBodyColumns: number | null = null;
119
120// The numbers the full mode this session last asked for, and whether that ask
121// has so far gone ungranted -- the module's reading of "a width is holding"
122// (DEC-154 rule 3). The ask sets it and a later full draw at the ask's own
123// size clears it, so it is an OUTCOME rather than a shortfall: amended rule 2
124// forbids re-asking on a resize, so after a granted ask a widening lifts what
125// full mode would ask for while the pane keeps the grant, and the shortfall
126// that opens up has no width of anyone's behind it (QCLI-456, the defect
127// opum-ai/lore-cli#486 F2 found in its own pane). The render that fires the
128// mode's own ask is suppressed -- it drew before the surface answered -- and
129// each placement reads the axis it is sized on: the dock across, the inline
130// block down.
131let fullAsk: { columns?: number; rows?: number } | null = null;
132let awaitingGrant = false;
133
134// How the pane last drew, for the `dashboard` tool's own line: the record of
135// what happened -- the placement, the size the surface actually granted, what
136// was asked, whether that counts as granted, and whether a width is holding
137// (the ask's outcome) -- not what the open asked for. A line only trusts it
138// when its mode still matches the view the call applied.
139let lastRendered: {
140  isFull: boolean;
141  isCollapsed: boolean;
142  placement: Placement;
143  drawn: number;
144  asked: number | null;
145  granted: boolean | null;
146  holding: boolean;
147} | null = null;
148
149// Whether this session has made its full-size ask with numbers. DEC-154 rule 2
150// as amended: a full ask is made once per toggle -- a person's `z`, or a tool
151// call with `full`, using the latest render's numbers -- and a stored full mode
152// restored at `session.start` asks once more on its first render, because the
153// session start runs before any draw and cannot size it. Never on a resize or a
154// redraw: re-asking whenever the measured width moved is what chased
155// 96 -> 79 -> 96 on the operator's surface.
156let fullAskMade = false;
157
158/**
159 * Discovers the fleet once, on whichever comes first: `session.start`, or the
160 * first read.
161 *
162 * The second path is not a fallback for an error -- a pane mounted without a
163 * session start (a test, and any host that draws a component without running
164 * the session lifecycle) would otherwise draw an empty fleet, and an empty
165 * fleet looks exactly like a fleet with nothing in it.
166 */
167async function ensureFleet($: EngineInterface): Promise<void> {
168  if (isFleetResolved) {
169    return;
170  }
171  await resolveFleet($, pluginOptions);
172  isFleetResolved = true;
173}
174
175function dirFor(repo: string): string {
176  return dirs.get(repo) ?? (fleetRoot ? `${fleetRoot}/${repo}` : repo);
177}
178
179// Reads this session's own repo, the one place the pane may write, from the session.
180async function resolveLocal($: EngineInterface): Promise<string> {
181  const root = await $.session.root();
182  let name = root.replace(/\/+$/, "").split("/").pop() ?? root;
183  try {
184    const ran = await $.process.run(
185      ["git", "-C", root, "rev-parse", "--git-common-dir"],
186      {
187        timeoutMs: 10_000,
188      },
189    );
190    if (ran.exitCode === 0) {
191      name = repoNameFromGitCommonDir(root, ran.stdout.trim() || null);
192    }
193  } catch {
194    // Not a checkout, or git is not on PATH: the directory name will do.
195  }
196  localRepo = name;
197  dirs.set(name, root);
198
199  return name;
200}
201
202function parseRepoList(value: unknown): string[] {
203  if (typeof value !== "string") {
204    return [];
205  }
206
207  return [...new Set(value.split(/[\s,]+/).filter((one) => one.trim() !== ""))];
208}
209
210/**
211 * Where the fleet comes from, in the order the plugin's options give it: an
212 * explicit list, then an explicit root, then the nearest ancestor of the
213 * session's own directory that holds Quest workspaces.
214 *
215 * Discovery rather than a constant is the point: the list drawn here is the
216 * operator's own set of Quest workspaces, not a roster this file has to be
217 * edited to follow.
218 */
219async function resolveFleet(
220  $: EngineInterface,
221  options: Record<string, unknown>,
222): Promise<void> {
223  dirs.clear();
224  fleetRoot = null;
225  discoveryNote = null;
226  repos = [];
227  // A noun of `$` is never passed as a value, so the two calls discovery needs
228  // are spelled out at the call site.
229  const fs: FsLike = {
230    list: (path) => $.fs.list(path),
231    exists: (path) => $.fs.exists(path),
232  };
233  const configured = parseRepoList(options.repos);
234  if (configured.length > 0) {
235    repos = configured;
236    if (typeof options.root === "string" && options.root.trim()) {
237      fleetRoot = options.root.trim();
238    }
239  } else {
240    const root = typeof options.root === "string" ? options.root.trim() : "";
241    const root_ = root !== "" ? root : null;
242    if (root_) {
243      fleetRoot = root_;
244      repos = await reposUnder(fs, root_);
245    } else {
246      const sessionRoot = await $.session.root();
247      const found = await discoverRoot(fs, sessionRoot);
248      if (found) {
249        fleetRoot = found.root;
250        repos = found.repos;
251      } else {
252        discoveryNote = `No Quest workspaces found above ${parentOf(sessionRoot)}. Add the plugin's root or repos option, or use this-repo scope.`;
253      }
254    }
255  }
256  if (fleetRoot) {
257    for (const repo of repos) {
258      dirs.set(repo, `${fleetRoot}/${repo}`);
259    }
260  }
261  // The session's own repository is always on the board, even when it sits
262  // outside the discovered root -- and it keeps the session's path, not the
263  // discovered one, because that is the tree this pane may write in.
264  if (localRepo && !repos.includes(localRepo)) {
265    repos = [...repos, localRepo].sort();
266  }
267}
268
269async function quest($: EngineInterface, repo: string, args: string[]) {
270  const ran = await $.process.run(["quest", ...args, "--json"], {
271    cwd: dirFor(repo),
272    timeoutMs: 20_000,
273  });
274  if (ran.exitCode !== 0) {
275    let reason = ran.stderr.trim().split("\n")[0] || `exit ${ran.exitCode}`;
276    try {
277      const body = JSON.parse(ran.stdout || ran.stderr) as {
278        message?: unknown;
279      };
280      if (typeof body.message === "string") {
281        reason = body.message;
282      }
283    } catch {
284      // Not JSON: keep the first stderr line.
285    }
286    throw Object.assign(new Error(reason), { exitCode: ran.exitCode });
287  }
288
289  return ran.stdout;
290}
291
292async function readRepo(
293  $: EngineInterface,
294  repo: string,
295  status: StatusFilter,
296  readRefs: boolean,
297): Promise<RepoRow> {
298  try {
299    const stdout = await quest($, repo, [
300      "task",
301      "list",
302      ...listArgs(status, readRefs),
303    ]);
304    if (readRefs) {
305      const { tasks, coverage } = parseAcrossRefs(stdout);
306
307      return { repo, tasks, error: null, coverage };
308    }
309
310    return { repo, tasks: parseTaskList(stdout), error: null, coverage: null };
311  } catch (error) {
312    return {
313      repo,
314      tasks: [],
315      error: error instanceof Error ? error.message : String(error),
316      coverage: null,
317    };
318  }
319}
320
321let isRefreshing = false;
322let isRefreshQueued = false;
323
324/**
325 * Reads every repository in scope.
326 *
327 * A refresh asked for while one is in flight is not dropped -- it is queued,
328 * and the loop reads again with the view as it stands then. Dropping it left
329 * the board drawing the previous scope: a toggle or a filter pressed during a
330 * read settled back into the read it was meant to replace.
331 */
332async function refresh($: EngineInterface): Promise<void> {
333  if (isRefreshing) {
334    isRefreshQueued = true;
335    return;
336  }
337  isRefreshing = true;
338  try {
339    do {
340      isRefreshQueued = false;
341      await readScopes($);
342    } while (isRefreshQueued);
343  } finally {
344    isRefreshing = false;
345  }
346}
347
348async function readScopes($: EngineInterface): Promise<void> {
349  await resolveLocal($);
350  await ensureFleet($);
351  await ensureUncommitted($);
352  const { scope, status: picked, tab, readRefs } = await read($, view);
353  const status: StatusFilter = tab === "kanban" ? "open" : picked;
354  const shown = scope === "local" ? (localRepo ? [localRepo] : []) : repos;
355  await update($, board, (current) => ({ ...current, isLoading: true }));
356  const rows = await Promise.all(
357    shown.map((repo) => readRepo($, repo, status, readRefs)),
358  );
359  const refreshedAt = await $.clock.now();
360  await update($, board, () => ({ rows, refreshedAt, isLoading: false }));
361}
362
363async function loadDetail(
364  $: EngineInterface,
365  repo: string,
366  id: string,
367): Promise<void> {
368  const key = `${repo}:${id}`;
369  await update($, detail, () => ({
370    key,
371    task: null,
372    error: null,
373    isLoading: true,
374  }));
375  try {
376    const stdout = await quest($, repo, [
377      "task",
378      "view",
379      id,
380      "--max-notes",
381      "3",
382    ]);
383    const task = parseTaskView(stdout);
384    await update($, detail, (current) =>
385      current.key === key
386        ? { key, task, error: null, isLoading: false }
387        : current,
388    );
389  } catch (error) {
390    const message = error instanceof Error ? error.message : String(error);
391    await update($, detail, (current) =>
392      current.key === key
393        ? { key, task: null, error: message, isLoading: false }
394        : current,
395    );
396  }
397}
398
399async function countUncommitted($: EngineInterface): Promise<void> {
400  await resolveLocal($);
401  if (!localRepo) {
402    return;
403  }
404  const ran = await $.process.run(
405    ["git", "status", "--porcelain", "--", ".quest"],
406    {
407      cwd: dirFor(localRepo),
408      timeoutMs: 10_000,
409    },
410  );
411  const uncommitted =
412    ran.exitCode === 0
413      ? ran.stdout.split("\n").filter((line) => line.trim() !== "").length
414      : 0;
415  await update($, edits, (current) => ({ ...current, uncommitted }));
416}
417
418/**
419 * Reads the unlanded count once, on whichever comes first: the session start,
420 * or the first read -- the same two paths discovery uses, and for the same
421 * reason. `claude plugin test` never fires `session.start`, so a kit-mounted
422 * pane drew no window at all, and no window looks exactly like nothing worth
423 * warning about.
424 *
425 * The count is what makes the id-collision window visible. `quest task create`
426 * takes the next id from the working tree plus every LOCAL ref, so a record
427 * that is written but not yet committed is invisible to this repository's
428 * other checkouts, and a create in one of those can mint the same id.
429 * Measured on this branch: two checkouts of one repository, one record each
430 * left uncommitted, both minted T-1; committing the first closed the window
431 * and the next create minted T-2.
432 */
433async function ensureUncommitted($: EngineInterface): Promise<void> {
434  if (isUncommittedResolved) {
435    return;
436  }
437  isUncommittedResolved = true;
438  await countUncommitted($);
439}
440
441// The alerts check. It keeps a clock of its own rather than hanging off a draw,
442// because an alert matters most when the pane is closed: it is the one read
443// here that is not about what the pane is showing.
444const ALERTS_MS = 120_000;
445const ALERTS_KEY = "opum-quest.board.alerts";
446
447// Whether the check has been started, and whether it has run once. The first
448// check of a session records a baseline -- or, when a state was already stored,
449// reports what moved while no session was running; every later one is a change
450// since the check before it.
451let isAlertsStarted = false;
452let isAlertsChecked = false;
453
454// The status line this session pinned, so a count that has not moved is not
455// pinned again on every check.
456let waitingShown: string | undefined;
457
458type AlertsRead = {
459  repo: string;
460  decisions: AlertDecision[];
461  open: AlertTask[];
462};
463
464/**
465 * Reads one repository for the check: its decisions and its open tasks.
466 *
467 * Null when either read failed, and that is the whole of the failure handling.
468 * A repository that cannot be read is skipped for this check -- neither its
469 * decisions nor its tasks are compared, and what it last reported is left
470 * standing -- and the board's own "Could not read" line covers it where the
471 * person can see it. The check never toasts about its own read errors.
472 */
473async function readAlerts(
474  $: EngineInterface,
475  repo: string,
476): Promise<AlertsRead | null> {
477  try {
478    const decisions = parseDecisionList(
479      await quest($, repo, decisionListArgs()),
480    );
481    const open = parseTaskList(await quest($, repo, listArgs("open", false)));
482    const tasks: AlertTask[] = open.map((task) => ({
483      repo,
484      id: task.id,
485      title: task.title,
486      status: task.status,
487      priority: task.priority,
488      resolution: null,
489    }));
490
491    return {
492      repo,
493      decisions: decisions.map((decision) => ({ repo, ...decision })),
494      open: tasks,
495    };
496  } catch {
497    return null;
498  }
499}
500
501/** One `quest task view`, for a task that was open last check and is not now. */
502async function lookUpTask(
503  $: EngineInterface,
504  repo: string,
505  id: string,
506): Promise<AlertTask | null> {
507  try {
508    const outcome = parseTaskOutcome(
509      await quest($, repo, ["task", "view", id, "--max-notes", "1"]),
510    );
511
512    return { repo, ...outcome };
513  } catch {
514    return null;
515  }
516}
517
518/** The status line the count of waiting decisions asks for: pinned or cleared. */
519function setWaiting($: EngineInterface, waiting: number): void {
520  const text = waitingStatus(waiting);
521  if (text === waitingShown) {
522    return;
523  }
524  waitingShown = text;
525  $.ui.status(text);
526}
527
528async function checkAlerts($: EngineInterface): Promise<void> {
529  const setting = parseAlerts(pluginOptions.alerts);
530  if (setting === "off") {
531    // Off is off: nothing is read and nothing is stored, and a count this
532    // session pinned comes down rather than standing with nothing keeping it
533    // current.
534    if (waitingShown !== undefined) {
535      waitingShown = undefined;
536      $.ui.status(undefined);
537    }
538
539    return;
540  }
541  try {
542    await resolveLocal($);
543    await ensureFleet($);
544    const stored = await $.store.get(ALERTS_KEY);
545    const isBaseline = !isAlertsState(stored);
546    const previous: AlertsState = isBaseline
547      ? { decisions: {}, tasks: {} }
548      : stored;
549    const reads = await Promise.all(repos.map((repo) => readAlerts($, repo)));
550    const read = reads.filter((one): one is AlertsRead => one !== null);
551    if (read.length === 0) {
552      return;
553    }
554    const decisions = read.flatMap((one) => one.decisions);
555    const open = read.flatMap((one) => one.open);
556    // A task that left the open set is told Done from Closed by looking at it
557    // once -- and only in a repository this check could read, since a
558    // repository it could not read has left nothing to compare against.
559    const openIds = new Map<string, Set<string>>();
560    for (const task of open) {
561      const ids = openIds.get(task.repo) ?? new Set<string>();
562      ids.add(task.id);
563      openIds.set(task.repo, ids);
564    }
565    const departed: AlertTask[] = [];
566    for (const [repo, ids] of Object.entries(previous.tasks)) {
567      if (!read.some((one) => one.repo === repo)) {
568        continue;
569      }
570      for (const id of Object.keys(ids)) {
571        if (openIds.get(repo)?.has(id)) {
572          continue;
573        }
574        const left = await lookUpTask($, repo, id);
575        if (left) {
576          departed.push(left);
577        }
578      }
579    }
580
581    const changes = alertChanges(
582      previous,
583      { decisions, open, departed },
584      setting,
585    );
586    setWaiting(
587      $,
588      decisions.filter((decision) => decision.status === "proposed").length,
589    );
590    if (isBaseline) {
591      // The first check after install shows nothing: there is no earlier state
592      // for a change to be a change from.
593    } else if (isAlertsChecked) {
594      for (const toast of alertToasts(changes)) {
595        $.ui.toast(toast.text, { timeoutMs: toast.timeoutMs });
596      }
597    } else {
598      // The first check after a restart: everything that moved while no session
599      // was running, as one line rather than a dozen.
600      const line = awayLine(changes);
601      if (line) {
602        $.ui.toast(line, { timeoutMs: 8_000 });
603      }
604    }
605    isAlertsChecked = true;
606
607    const tasks: AlertsState["tasks"] = { ...previous.tasks };
608    for (const one of read) {
609      tasks[one.repo] = {};
610    }
611    for (const task of open) {
612      const ids = tasks[task.repo] ?? {};
613      ids[task.id] = task.status;
614      tasks[task.repo] = ids;
615    }
616    const seen: AlertsState["decisions"] = { ...previous.decisions };
617    for (const decision of decisions) {
618      seen[`${decision.repo}:${decision.id}`] = decision.status;
619    }
620    await $.store.set(ALERTS_KEY, { decisions: seen, tasks });
621  } catch {
622    // Quiet on purpose: a check that toasts about its own read errors is the
623    // noise the alerts exist to keep down.
624  }
625}
626
627/**
628 * Starts the alerts check: once now, and every {@link ALERTS_MS} after that.
629 *
630 * Started from `session.start` and, on whichever comes first, the first draw --
631 * the same two paths the fleet and the uncommitted count use, and for the same
632 * reason: `claude plugin test` never fires `session.start`, so a kit-mounted
633 * pane would otherwise never alert at all.
634 */
635function startAlerts($: EngineInterface): void {
636  if (isAlertsStarted) {
637    return;
638  }
639  isAlertsStarted = true;
640  void checkAlerts($);
641  $.clock.every(ALERTS_MS, () => {
642    void checkAlerts($);
643  });
644}
645
646// Runs one Quest write in this session's own repo, then reloads what it touched.
647async function write(
648  $: EngineInterface,
649  id: string,
650  args: string[],
651  done: string,
652): Promise<boolean> {
653  if (!localRepo) {
654    return false;
655  }
656  const repo = localRepo;
657  let isSaved = false;
658  await update($, edits, (current) => ({ ...current, isWriting: true }));
659  try {
660    await quest($, repo, [...args, ...actorArgs(actor)]);
661    $.ui.toast(done);
662    isSaved = true;
663  } catch (error) {
664    const failure = error as Error & { exitCode?: number };
665    $.ui.toast(
666      failure.exitCode === 5
667        ? `${id} changed since you opened it. It has been reloaded; try again.`
668        : `Could not save ${id}: ${failure.message}`,
669    );
670  } finally {
671    await update($, edits, (current) => ({ ...current, isWriting: false }));
672  }
673  const { selected } = await read($, view);
674  if (selected && selected.repo === repo && selected.id === id) {
675    await loadDetail($, repo, id);
676  }
677  await refresh($);
678  await countUncommitted($);
679
680  return isSaved;
681}
682
683async function editTask(
684  $: EngineInterface,
685  task: TaskDetail,
686  args: string[],
687  done: string,
688) {
689  const guard = task.revision ? ["--if-revision", task.revision] : [];
690  await write($, task.id, ["task", "edit", task.id, ...args, ...guard], done);
691}
692
693async function createTask($: EngineInterface, title: string): Promise<void> {
694  const trimmed = title.trim();
695  if (!trimmed || !localRepo) {
696    return;
697  }
698  const repo = localRepo;
699  try {
700    const stdout = await quest($, repo, [
701      "task",
702      "create",
703      trimmed,
704      ...actorArgs(actor),
705    ]);
706    const id = String(
707      (JSON.parse(stdout) as { data?: { id?: unknown } }).data?.id ?? "",
708    );
709    $.ui.toast(id ? `Created ${id}` : "Created the task");
710    await refresh($);
711    await countUncommitted($);
712    if (id) {
713      await select($, repo, id);
714    }
715  } catch (error) {
716    $.ui.toast(
717      `Could not create the task: ${error instanceof Error ? error.message : String(error)}`,
718    );
719  }
720}
721
722async function closeTask($: EngineInterface, task: TaskDetail): Promise<void> {
723  const answer = await $.ui.ask(`Close ${task.id} as won't do?`, [
724    "Close it",
725    "Keep it",
726  ]);
727  if (answer === "Close it") {
728    await write(
729      $,
730      task.id,
731      ["task", "close", task.id, "--resolution", "wont-do"],
732      `Closed ${task.id}`,
733    );
734  }
735}
736
737async function savePrefs($: EngineInterface): Promise<void> {
738  const { tab, scope, status, isCollapsed, isFull, readRefs } = await read(
739    $,
740    view,
741  );
742  await $.store.set(PREFS_KEY, {
743    tab,
744    scope,
745    status,
746    isCollapsed,
747    isFull,
748    readRefs,
749  });
750}
751
752/**
753 * Opens the pane at the size the two states ask for, with the viewport and the
754 * drawn dock body the caller knows.
755 *
756 * `focus` is asked for only on a full toggle a PERSON starts -- the `z` key or
757 * the button beside it. Everywhere else it is left off, and the cases that are
758 * easy to mistake for a toggle are the ones that matter: a full mode restored
759 * at `session.start`, the one ask that mode owes on its first render, and the
760 * `dashboard` tool call, which Claude may make unasked. None of those is a
761 * person asking for the pane, so none may take the keyboard off the prompt.
762 *
763 * A full open that carries numbers marks the session's ask as made (DEC-154
764 * rule 2 as amended): every numbered full ask is made through here -- at a
765 * toggle, or on the first render of a restored full mode -- and a resize or a
766 * redraw never makes a second one. An open before any draw (a `session.start`)
767 * asks the surface's own default and leaves the ask owed.
768 *
769 * The numbers are kept, and the ask stands ungranted until a full draw comes
770 * within the slack of them (QCLI-456): the kept-width line and the tool's
771 * short-size phrase read that outcome, so they claim a width the person set
772 * only while THIS ask is the one that went ungranted.
773 */
774async function openPane(
775  $: EngineInterface,
776  state: Pick<View, "isCollapsed" | "isFull">,
777  size: Partial<Viewport> = viewport,
778  isFocused = false,
779  dockBody: number | null = lastDockBodyColumns,
780): Promise<UiOpenResult> {
781  const asked = paneSize(state, size, dockBody);
782  if (
783    state.isFull &&
784    !state.isCollapsed &&
785    (asked.columns !== undefined || asked.rows !== undefined)
786  ) {
787    fullAskMade = true;
788    fullAsk = { columns: asked.columns, rows: asked.rows };
789    awaitingGrant = true;
790  }
791
792  return await $.ui.open({
793    id: PANE,
794    title: "Quest",
795    ...(isFocused && !state.isCollapsed ? { focus: true as const } : {}),
796    ...asked,
797  });
798}
799
800async function setCollapsed(
801  $: EngineInterface,
802  isCollapsed: boolean,
803): Promise<void> {
804  await update($, view, (current) => ({ ...current, isCollapsed }));
805  await savePrefs($);
806  await $.ui.close({ id: PANE });
807  await openPane($, await read($, view));
808}
809
810/**
811 * The band's Open press: the person asking for the board, so the pane it seats
812 * is placed at any width.
813 *
814 * The invalidate is what takes the band back down. The band is drawn only while
815 * the pane waits undrawn, and without a redraw it would stand beneath a board
816 * the press just seated.
817 */
818async function openBoardFromBand($: EngineInterface): Promise<void> {
819  await openPane($, await read($, view), viewport, true);
820  $.ui.invalidate("ui.render");
821}
822
823/**
824 * The full-screen toggle: the largest pane the surface allows, or the normal
825 * size, with the choice stored beside the pane's other settings. The caller is
826 * a person's own press, so the pane is re-placed and handed the keyboard.
827 */
828async function setFull($: EngineInterface, isFull: boolean): Promise<void> {
829  await setFullState($, isFull);
830  await $.ui.close({ id: PANE });
831  await openPane($, await read($, view), viewport, true);
832}
833
834/**
835 * Records the full-size choice and nothing else: the shape the `dashboard`
836 * tool uses, because a tool call is not a person asking for the pane and the
837 * open it goes on to make must not take the keyboard. That open is what asks,
838 * and it marks the session's ask as made (DEC-154 rule 2 as amended: one ask
839 * per toggle).
840 *
841 * The mark is set here, before the view flips, when the toggle's ask will
842 * carry the render's numbers: a draw landing between the flip and that open
843 * then asks no second time. Measured on the seq 230 probe (QCLI-454): without
844 * this, the first full toggle of a session opened twice, identical both
845 * times; with it, once. Before any draw there are no numbers, nothing is
846 * marked, and the first render still owes the ask.
847 */
848async function setFullState(
849  $: EngineInterface,
850  isFull: boolean,
851): Promise<void> {
852  if (isFull) {
853    const asked = paneSize(
854      { isCollapsed: false, isFull: true },
855      viewport,
856      lastDockBodyColumns,
857    );
858    if (asked.columns !== undefined || asked.rows !== undefined) {
859      fullAskMade = true;
860    }
861  }
862  await update($, view, (current) => ({ ...current, isFull }));
863  await savePrefs($);
864}
865
866async function setScope($: EngineInterface, scope: Scope): Promise<void> {
867  await update($, view, (current) => ({
868    ...current,
869    scope,
870    repo: "all",
871    selected: null,
872  }));
873  await savePrefs($);
874  await refresh($);
875}
876
877async function setTab($: EngineInterface, tab: Tab): Promise<void> {
878  await update($, view, (current) => ({ ...current, tab, selected: null }));
879  await savePrefs($);
880  await refresh($);
881}
882
883async function setStatus(
884  $: EngineInterface,
885  status: StatusFilter,
886): Promise<void> {
887  await update($, view, (current) => ({ ...current, status, selected: null }));
888  await savePrefs($);
889  await refresh($);
890}
891
892async function setReadRefs(
893  $: EngineInterface,
894  readRefs: boolean,
895): Promise<void> {
896  await update($, view, (current) => ({
897    ...current,
898    readRefs,
899    selected: null,
900  }));
901  await savePrefs($);
902  await refresh($);
903}
904
905async function select(
906  $: EngineInterface,
907  repo: string,
908  id: string,
909): Promise<void> {
910  await update($, view, (current) => ({
911    ...current,
912    selected: { repo, id },
913    pending: null,
914  }));
915  await loadDetail($, repo, id);
916}
917
918/**
919 * Resolves a bare task id to the repository that holds it, so the `dashboard`
920 * tool's `task` can name one without the caller knowing where it lives.
921 *
922 * The board's own rows cannot answer this: they are read at one status filter
923 * and one scope, so a task that is Done, or in a repository the current view
924 * leaves out, would read as unknown while existing. One `task view` per
925 * workspace is the existence check that depends on neither. A workspace that
926 * cannot be read is not a match either way -- the board's own "Could not read"
927 * line reports that, and this probe decides nothing about it.
928 */
929async function findTask(
930  $: EngineInterface,
931  id: string,
932): Promise<{ repo: string; id: string } | null> {
933  const candidates = [
934    ...new Set([...repos, ...(localRepo ? [localRepo] : [])]),
935  ];
936  for (const repo of candidates) {
937    try {
938      await quest($, repo, ["task", "view", id, "--max-notes", "1"]);
939      return { repo, id };
940    } catch {
941      // Not in this workspace, or this one could not be read: ask the next.
942    }
943  }
944
945  return null;
946}
947
948/** A tool argument as it was given, for an error result that names it. */
949function quoted(value: unknown): string {
950  return JSON.stringify(value) ?? String(value);
951}
952
953/**
954 * The `dashboard` tool's one line, naming what it opened and the state it
955 * applied -- the design's own example reads "Opened the Quest, fleet scope,
956 * OCLI-8 selected." (`pane-dashboard-tool-design.md`).
957 *
958 * The size it names is the one the surface granted, read off the pane's draw
959 * that follows the open (DEC-154 rule 4): "full size" only when the drawn
960 * size is within the frame slack of the ask; otherwise the drawn size and
961 * why; and "full requested" when no draw has answered yet.
962 *
963 * The dock's short case mirrors rule 3's pane line -- the width the person
964 * set, named as the pane's own width -- but only while that draw read a width
965 * as holding: a shortfall with nothing holding (a grant the surface honoured,
966 * then a resize or a drag under it, with nothing re-asked) names no owner
967 * (seq 234, ODOC-OP-2026-10-03-65; QCLI-457), so it says the pane kept its
968 * width instead of claiming the width is kept.
969 */
970function dashboardOpened(
971  scope: Scope | undefined,
972  isFull: boolean | undefined,
973  taskId: string | null,
974  draw: {
975    placement: Placement;
976    drawn: number;
977    granted: boolean | null;
978    holding: boolean;
979  } | null,
980): string {
981  const parts = ["Opened the Quest"];
982  if (scope) {
983    parts.push(scope === "fleet" ? "fleet scope" : "local scope");
984  }
985  if (taskId) {
986    parts.push(`${taskId} selected`);
987  }
988  const head = parts.join(", ");
989
990  if (isFull === true) {
991    if (draw === null) {
992      return `${head}, full requested.`;
993    }
994    if (draw.granted === true) {
995      return `${head}, full size.`;
996    }
997
998    return draw.placement === "dock"
999      ? draw.holding
1000        ? `${head} at ${draw.drawn + FRAME_COLUMNS} columns; the width is kept.`
1001        : `${head} at ${draw.drawn + FRAME_COLUMNS} columns; the pane kept its width.`
1002      : `${head} at ${draw.drawn} rows; the screen keeps room for the prompt.`;
1003  }
1004  if (isFull === false) {
1005    return `${head}, normal size.`;
1006  }
1007
1008  return `${head}.`;
1009}
1010
1011/**
1012 * The tool's line when the surface kept the pane the call asked for undrawn.
1013 *
1014 * The floor is the engine's, not this mod's and not a constant: an unasked open
1015 * is placed from 144 terminal columns, or 110 for a pane id the person opened
1016 * before, and the engine remembers that across sessions. So this line quotes
1017 * the engine's own `reason` for the open it just made rather than composing a
1018 * number of its own (seq 213); below the floor the pane waits with no
1019 * `ui.render` raised at all. The band above the prompt carries the person's own
1020 * way in, and a press is placed at any width.
1021 */
1022function dashboardWaiting(reason: string | undefined): string {
1023  const waiting = reason
1024    ? `The Quest is waiting — ${reason}`
1025    : "The Quest is not shown.";
1026
1027  return `${waiting} Press Open on the band above the prompt.`;
1028}
1029
1030/**
1031 * Whether the board's pane is on screen right now, read from the engine's own
1032 * record.
1033 *
1034 * The tool reports on this, not on what its `$.ui.open` asked for: an open that
1035 * returned without error still leaves the pane waiting undrawn when the
1036 * terminal is under the floor an unasked pane is placed from, and a result
1037 * claiming otherwise names a pane the person cannot see (QCLI-444, seq 182).
1038 * Placed and shown are two facts -- a pane can be open, drawn and behind
1039 * another of the plugin's own -- and the board's one pane is only the second
1040 * when both hold.
1041 */
1042async function boardIsShown($: EngineInterface): Promise<boolean> {
1043  const panes = await $.ui.panes();
1044  const pane = panes.find((one) => one.id === PANE);
1045
1046  return pane !== undefined && pane.isPlaced && pane.isShown;
1047}
1048
1049function clockTime(ms: number): string {
1050  return new Date(ms).toISOString().slice(11, 16);
1051}
1052
1053function statusWords(status: StatusFilter): string {
1054  return status === "open" ? "open" : status.toLowerCase();
1055}
1056
1057function shortName(repo: string): string {
1058  return repo.replace(/^opum-/, "");
1059}
1060
1061/** What the across-refs read managed to read, folded over the rows. */
1062export function coverageLine(
1063  rows: RepoRow[],
1064  readRefs: boolean,
1065): string | null {
1066  if (!readRefs) {
1067    return null;
1068  }
1069  const read = rows.filter((row) => row.coverage !== null);
1070  if (read.length === 0) {
1071    return null;
1072  }
1073  const refs = read.reduce(
1074    (sum, row) => sum + (row.coverage?.refsRead ?? 0),
1075    0,
1076  );
1077  const partial = read.filter((row) => row.coverage?.complete !== true);
1078  if (partial.length === 0) {
1079    return `Across refs: ${refs} ref${refs === 1 ? "" : "s"} read, complete.`;
1080  }
1081
1082  return `Across refs: ${refs} read, INCOMPLETE in ${partial
1083    .map((row) => row.repo)
1084    .join(
1085      ", ",
1086    )} -- a ref could not be read there, so an empty row is unread rather than empty.`;
1087}
1088
1089export const register: Register = (on, options) => {
1090  pluginOptions = options;
1091  if (typeof options.actor === "string" && options.actor.trim()) {
1092    actor = options.actor.trim();
1093  }
1094
1095  on("session.start", async ($, e, next) => {
1096    await resolveLocal($);
1097    await ensureFleet($);
1098
1099    const stored = await $.store.get(PREFS_KEY);
1100    if (isStoredView(stored)) {
1101      await update($, view, (current) => ({
1102        ...current,
1103        ...stored,
1104        isFull: stored.isFull === true,
1105        readRefs: stored.readRefs === true,
1106        selected: null,
1107      }));
1108    }
1109
1110    // The board's way in. No slash command is registered: the plugin's own
1111    // skill owns `/opum-quest:quest`, so the engine refuses a command named
1112    // `quest`, and `quest-board` was dropped with it (QCLI-440, seq 176). The
1113    // skill routes the board's words to this tool instead.
1114    //
1115    // The description is listed to Claude in every session that loads the mod,
1116    // so it stays to two sentences.
1117    await $.tool.register({
1118      name: DASHBOARD_TOOL,
1119      description:
1120        "Open the Quest pane: the tasks being worked on across the operator's Quest workspaces, and the detail of one. Use it when the person asks to see the board or a task in the pane.",
1121      inputSchema: {
1122        type: "object",
1123        properties: {
1124          scope: {
1125            type: "string",
1126            enum: ["fleet", "local"],
1127            description:
1128              "fleet draws every Quest workspace; local only this session's own repository.",
1129          },
1130          full: {
1131            type: "boolean",
1132            description: "Open at the largest size the surface allows.",
1133          },
1134          task: {
1135            type: "string",
1136            description: "A task id to open in the detail view.",
1137          },
1138        },
1139      },
1140    });
1141    // A session starts before any draw: nothing has been measured, nothing has
1142    // been asked, and no full draw has taught the header anything. Whatever a
1143    // previous run of this module left behind -- reachable only where one copy
1144    // is shared, as in a test -- is not this session's own.
1145    viewport = {};
1146    lastDockBodyColumns = null;
1147    fullAskMade = false;
1148    fullAsk = null;
1149    awaitingGrant = false;
1150
1151    // No viewport has been measured yet -- `session.start` runs ahead of the
1152    // first draw -- so a stored full mode opens at the surface's default and
1153    // the first render asks once more, now that it can size it (DEC-154 rule 2
1154    // as amended).
1155    await openPane($, await read($, view));
1156    void refresh($);
1157    void ensureUncommitted($);
1158    startAlerts($);
1159    $.clock.every(REFRESH_MS, () => {
1160      void (async () => {
1161        const panes = await $.ui.panes();
1162        if (panes.some((pane) => pane.id === PANE && pane.isShown)) {
1163          await refresh($);
1164          await countUncommitted($);
1165        }
1166      })();
1167    });
1168
1169    return next(e);
1170  });
1171
1172  // The engine lists the registered `dashboard` tool to Claude as
1173  // `mcp__opum-quest__dashboard` and routes a call here.
1174  on("tool.call", { tool: DASHBOARD_TOOL_NAME }, async ($, e) => {
1175    // Bad input is reported, never guessed at -- the design's rule for a task
1176    // id applies to the other two arguments as well, and a rejected call opens
1177    // nothing at all.
1178    const scope: Scope | undefined =
1179      e.scope === undefined || e.scope === "fleet" || e.scope === "local"
1180        ? e.scope
1181        : undefined;
1182    if (e.scope !== undefined && scope === undefined) {
1183      return {
1184        deny: `Unknown scope ${quoted(e.scope)}: the Quest takes "fleet" or "local". Nothing was opened.`,
1185      };
1186    }
1187    const full: boolean | undefined =
1188      typeof e.full === "boolean" ? e.full : undefined;
1189    if (e.full !== undefined && full === undefined) {
1190      return {
1191        deny: `Unknown full ${quoted(e.full)}: the Quest takes true or false. Nothing was opened.`,
1192      };
1193    }
1194    const wanted = typeof e.task === "string" ? e.task.trim() : undefined;
1195    if (e.task !== undefined && !wanted) {
1196      return {
1197        deny: `Unknown task ${quoted(e.task)}: the Quest takes a task id. Nothing was opened.`,
1198      };
1199    }
1200
hooks/quest.ts 931 lines
1// The Quest board's non-drawing half: the argv every read and write builds, the
2// parsers for both `task list` shapes, and repository discovery.
3//
4// It is a separate module from `register.tsx` so each of those can be tested
5// against the engine without a surface -- the tests import this file directly.
6
7import type {
8  Coverage,
9  FsLike,
10  QuestTask,
11  RepoRow,
12  StatusFilter,
13  TaskDetail,
14  View,
15} from "../types";
16
17export const STATUSES: { value: StatusFilter; label: string }[] = [
18  { value: "In Progress", label: "In progress" },
19  { value: "open", label: "All open" },
20  { value: "To Do", label: "To do" },
21  { value: "Paused", label: "Paused" },
22  { value: "Done", label: "Done (latest 50)" },
23  { value: "Closed", label: "Closed (latest 50)" },
24];
25
26// The kanban's columns: the open statuses, in the order work moves.
27export const COLUMNS = ["To Do", "In Progress", "Paused"] as const;
28
29// The pane's geometry, all of it here so the sizes are computed and tested
30// without a surface (QCLI-435).
31
32/** The dock width the pane asks for normally. */
33export const DOCK_COLUMNS = 64;
34
35/** The rail it collapses to: the third state beside normal and full. */
36export const RAIL_COLUMNS = 22;
37
38/**
39 * Cells a full dock request leaves the transcript, in the request itself.
40 *
41 * The engine clamps a dock request to `columns - 24` on 2.1.288 (read from
42 * cc-patch's decompiled build and measured by lore-cli, LCLI-674; ruled in
43 * opum-doc DEC-154, 2026-10-03). The first design kept 20, which the engine
44 * never granted.
45 *
46 * DEC-154 rule 2 as amended (seq 230, 2026-10-03): the dock's ask is this
47 * margin less the TERMINAL width, reconstructed from the render by
48 * `dockedTerminalColumns` -- a docked render's `viewport.columns` is the
49 * transcript column, not the terminal.
50 */
51export const FULL_MARGIN_COLUMNS = 24;
52
53/** Rows the engine keeps for the prompt under an inline pane. */
54export const PROMPT_FLOOR_ROWS = 8;
55
56/** Rows of transcript it keeps visible above one. */
57export const TRANSCRIPT_PEEK_ROWS = 3;
58
59/** The prompt area an inline full pane leaves below it (DEC-154's rows - 11). */
60export const FULL_MARGIN_ROWS = PROMPT_FLOOR_ROWS + TRANSCRIPT_PEEK_ROWS;
61
62/** The narrowest a full pane is ever asked for. */
63export const MIN_PANE_ROWS = 5;
64
65/**
66 * The frame column a docked pane's body sits inside, between it and the
67 * transcript: the body the render reports is one cell narrower than the width
68 * the person's kept size names.
69 *
70 * Measured on Claude Code 2.1.288 (QCLI-454): a dock whose person-set width is
71 * 80 reports `bodyColumns` 79. The kept-width notice adds this back so it names
72 * the same number `pluginPanes.dockColumns` holds (DEC-154 counts the store's
73 * value, "Width kept at 80").
74 *
75 * The same cell is the divider `dockedTerminalColumns` adds back when it
76 * reconstructs the terminal from a docked render: transcript column + drawn
77 * body + this = the terminal (120 + 79 + 1 = 200, measured).
78 */
79export const FRAME_COLUMNS = 1;
80
81/**
82 * At or above this body width the board draws the list and the detail side by
83 * side instead of the detail below the list.
84 */
85export const WIDE_COLUMNS = 120;
86
87/**
88 * Cells of slack before a granted size counts as one the person set rather
89 * than the full size asked for.
90 *
91 * `bodyColumns` and `scroll.bodyRows` are the body INSIDE the frame, so a
92 * granted request still reads a couple of cells under it; a person who dragged
93 * the pane moved it further than that. Without the slack every draw would call
94 * a granted request a held one.
95 */
96export const SIZE_SLACK = 4;
97
98/** The size the surface measured, as `ui.render` reports it under `e.viewport`. */
99export type Viewport = { columns: number; rows: number };
100
101/** Where the surface seated a pane: beside the transcript, or above the prompt. */
102export type Placement = "dock" | "inline";
103
104/**
105 * The size a full pane asks for: the largest the surface allows, less what the
106 * design keeps for the transcript (docked) or the prompt (inline).
107 *
108 * `columns` and `rows` are asked for together rather than one being picked from
109 * the placement, because each is ignored where it does not apply -- the dock
110 * ignores `rows`, the inline block ignores `columns` -- so asking both is
111 * right whichever shape the surface seats the pane in.
112 *
113 * Either axis is left out when it is not known: `session.start` runs before any
114 * draw, and a request is not a grant, so the surface's own default stands and a
115 * stored full mode asks once, when a render first reports a viewport (DEC-154
116 * rule 2 as amended: once per toggle, never on a resize or a redraw).
117 *
118 * The docked `columns` this computes is the fallback for a pane whose drawn
119 * body is not yet known; `dockFullColumns` is the dock's measured form.
120 */
121export function fullPaneSize(viewport: Partial<Viewport> | null): {
122  columns?: number;
123  rows?: number;
124} {
125  const size: { columns?: number; rows?: number } = {};
126  if (viewport?.columns !== undefined) {
127    size.columns = Math.max(
128      RAIL_COLUMNS,
129      viewport.columns - FULL_MARGIN_COLUMNS,
130    );
131  }
132  if (viewport?.rows !== undefined) {
133    size.rows = Math.max(MIN_PANE_ROWS, viewport.rows - FULL_MARGIN_ROWS);
134  }
135
136  return size;
137}
138
139/**
140 * The terminal's width, from a docked pane's own render facts: the transcript
141 * column it draws beside and the body it drew, both from the same render, plus
142 * the divider between them.
143 *
144 * Null until both are known -- `session.start` runs before any draw, and the
145 * dock's body only arrives with a render. A docked `viewport.columns` is the
146 * transcript column, not the terminal (measured, QCLI-454: 120 + 79 + 1 = 200
147 * at a 200-column terminal, 90 + 109 + 1 = 200 at another session's), which is
148 * why the terminal is reconstructed rather than read off the render.
149 */
150export function dockedTerminalColumns(
151  viewport: Partial<Viewport> | null,
152  bodyColumns: number | null,
153): number | null {
154  if (viewport?.columns === undefined || bodyColumns === null) {
155    return null;
156  }
157
158  return viewport.columns + bodyColumns + FRAME_COLUMNS;
159}
160
161/**
162 * The columns a full pane asks for while DOCKED: the whole terminal less the
163 * engine's transcript margin (DEC-154 rule 2 as amended, 2026-10-03).
164 *
165 * A docked pane cannot use `viewport.columns` directly for this -- that is the
166 * transcript column, so the ask would come out narrower than the pane already
167 * is, and the surface would grant it as-is: the full toggle would change
168 * nothing on screen (QCLI-454, measured against the operator's report). Null
169 * when the drawn body is not known yet.
170 */
171export function dockFullColumns(
172  viewport: Partial<Viewport> | null,
173  bodyColumns: number | null,
174): number | null {
175  const terminal = dockedTerminalColumns(viewport, bodyColumns);
176
177  return terminal === null
178    ? null
179    : Math.max(RAIL_COLUMNS, terminal - FULL_MARGIN_COLUMNS);
180}
181
182/**
183 * The size the pane asks `$.ui.open` for, from the two states the view holds.
184 *
185 * Collapsed is the rail and wins over full, so collapsing from full mode lands
186 * on the rail and expanding returns to whichever of the other two was stored.
187 *
188 * `dockBodyColumns` is the body the docked pane last drew, when one is known:
189 * with it, a docked full asks for the whole terminal (via `dockFullColumns`)
190 * rather than for the transcript column the render's viewport reports. Without
191 * it -- no draw yet, or an inline pane -- `fullPaneSize`'s own arithmetic
192 * stands, which is also the only shape an inline pane can use.
193 */
194export function paneSize(
195  state: { isCollapsed: boolean; isFull: boolean },
196  viewport: Partial<Viewport> | null,
197  dockBodyColumns: number | null = null,
198): { columns?: number; rows?: number } {
199  if (state.isCollapsed) {
200    return { columns: RAIL_COLUMNS };
201  }
202  if (state.isFull) {
203    const size = fullPaneSize(viewport);
204    const docked = dockFullColumns(viewport, dockBodyColumns);
205    if (docked !== null) {
206      size.columns = docked;
207    }
208
209    return size;
210  }
211
212  return { columns: DOCK_COLUMNS };
213}
214
215/** Whether the board draws its list and detail side by side at this width. */
216export function isWideLayout(bodyColumns: number): boolean {
217  return bodyColumns >= WIDE_COLUMNS;
218}
219
220/**
221 * Whether the surface kept a size the person set instead of granting the size
222 * asked for, so the pane can say so rather than claiming a size it did not get.
223 */
224export function isSizeHeld(granted: number, asked: number): boolean {
225  return granted < asked - SIZE_SLACK;
226}
227
228/**
229 * The line a docked full pane shows while the surface is keeping the person's
230 * own width, or null when there is none to show.
231 *
232 * DEC-154, rule 3: a kept width is the person's choice, and while one holds
233 * full mode says so plainly -- naming the width the way
234 * `pluginPanes.dockColumns` holds it -- rather than showing a generic hint.
235 * Only a dock can keep one, and only full mode speaks of it: the inline block
236 * is content-sized, so a short one is honest and needs no notice, and the
237 * normal dock and the rail ask for fixed sizes.
238 *
239 * `holding` is the ask's OUTCOME -- a full ask that so far went ungranted --
240 * not a reading of the drawn width against what full mode would ask for now.
241 * A re-derived ask moves under the pane: after a GRANTED ask a widening
242 * resize lifts it while the pane keeps the granted width and nothing is
243 * re-asked (amended rule 2), and the drawn width alone then reported a width
244 * the person had set when nothing was held (QCLI-456, the defect
245 * opum-ai/lore-cli#486 F2 found in its own pane). The outcome is tracked at
246 * the ask instead: only an ask that was made can have gone ungranted.
247 */
248export function keptWidthNotice(
249  state: { isCollapsed: boolean; isFull: boolean },
250  placement: Placement,
251  drawn: number,
252  holding: boolean,
253): string | null {
254  if (state.isCollapsed || !state.isFull || placement !== "dock" || !holding) {
255    return null;
256  }
257
258  return `Width kept at ${drawn + FRAME_COLUMNS} (you set it): drag the pane edge to change`;
259}
260
261/**
262 * Whether a value read back from the pane's prefs is one this board stored.
263 *
264 * Its job is to survive the field changing between releases: a view stored by
265 * an older build has no `isFull`, and one stored by a newer build may carry a
266 * field this build does not know, so every field but the ones a pane cannot
267 * draw without is optional here and defaulted where it is read.
268 */
269export function isStoredView(
270  value: unknown,
271): value is Pick<
272  View,
273  "tab" | "scope" | "status" | "isCollapsed" | "isFull" | "readRefs"
274> {
275  const v = value as Partial<View> | null;
276
277  return (
278    !!v &&
279    (v.tab === "list" || v.tab === "kanban") &&
280    (v.scope === "local" || v.scope === "fleet") &&
281    STATUSES.some((s) => s.value === v.status) &&
282    typeof v.isCollapsed === "boolean" &&
283    (typeof v.isFull === "boolean" || v.isFull === undefined) &&
284    (typeof v.readRefs === "boolean" || v.readRefs === undefined)
285  );
286}
287
288/**
289 * The arguments one `quest task list` read takes.
290 *
291 * The across-refs read answers about the repository -- origin/dev plus every
292 * open pull request head into it -- where the plain read answers about the
293 * checkout, uncommitted records included. It costs a forge call per
294 * repository, which is why it is the toggle and not the default.
295 *
296 * `--allow-partial` keeps a ref the run could not read from turning the whole
297 * read into a refusal (exit 6): the row draws the incompleteness instead,
298 * which is the point the flag exists for.
299 */
300export function listArgs(status: StatusFilter, readRefs: boolean): string[] {
301  const isTerminal = status === "Done" || status === "Closed";
302  const filters =
303    status === "open"
304      ? [
305          "--exclude-status",
306          "Done",
307          "--exclude-status",
308          "Closed",
309          "--limit",
310          "200",
311        ]
312      : ["--status", status, "--limit", isTerminal ? "50" : "200"];
313
314  return readRefs ? ["--across-refs", "--allow-partial", ...filters] : filters;
315}
316
317function asStrings(value: unknown): string[] {
318  return Array.isArray(value) ? value.map(String) : [];
319}
320
321/** Reads `quest task list --json` output down to the fields the list draws. */
322export function parseTaskList(stdout: string): QuestTask[] {
323  const parsed = JSON.parse(stdout) as { data?: unknown };
324  if (!Array.isArray(parsed.data)) {
325    throw new Error("quest output has no data array");
326  }
327
328  return parsed.data.map((raw) => {
329    const task = raw as Record<string, unknown>;
330
331    return {
332      id: String(task.id ?? "?"),
333      title: String(task.title ?? ""),
334      status: String(task.status ?? ""),
335      priority: typeof task.priority === "string" ? task.priority : null,
336      labels: asStrings(task.labels),
337      proposedBy: null,
338      conflict: false,
339    };
340  });
341}
342
343type AcrossRefState = { status?: unknown; refProvenance?: { ref?: unknown } };
344
345/**
346 * The status to draw for an id whose states may disagree.
347 *
348 * `origin/dev` is the landed truth; an entry that exists only on a pull
349 * request head carries that state instead. The first state is the fallback,
350 * and `conflict` on the entry is what tells the reader the others exist.
351 */
352export function pickState(states: readonly AcrossRefState[]): string {
353  const landed = states.find(
354    (state) => state.refProvenance?.ref === "origin/dev",
355  );
356  const chosen = landed ?? states[0];
357
358  return typeof chosen?.status === "string" ? chosen.status : "";
359}
360
361export function parseCoverage(value: unknown): Coverage | null {
362  if (!value || typeof value !== "object") {
363    return null;
364  }
365  const coverage = value as {
366    complete?: unknown;
367    refsRead?: unknown;
368    refsUnreadable?: unknown;
369  };
370
371  return {
372    complete: coverage.complete === true,
373    refsRead: Array.isArray(coverage.refsRead) ? coverage.refsRead.length : 0,
374    refsUnreadable: asStrings(coverage.refsUnreadable),
375  };
376}
377
378/**
379 * Reads `quest task list --across-refs --json` output.
380 *
381 * The envelope differs from the plain read in both directions: the entries
382 * carry `states` per ref rather than a single status, and coverage sits beside
383 * `data` at the top level, not inside it.
384 */
385export function parseAcrossRefs(stdout: string): {
386  tasks: QuestTask[];
387  coverage: Coverage | null;
388} {
389  const parsed = JSON.parse(stdout) as { data?: unknown; coverage?: unknown };
390  if (!Array.isArray(parsed.data)) {
391    throw new Error("quest output has no data array");
392  }
393
394  const tasks = parsed.data.map((raw) => {
395    const entry = raw as {
396      id?: unknown;
397      title?: unknown;
398      proposedBy?: unknown;
399      conflict?: unknown;
400      states?: unknown;
401    };
402    const states = Array.isArray(entry.states)
403      ? (entry.states as AcrossRefState[])
404      : [];
405
406    return {
407      id: String(entry.id ?? "?"),
408      title: String(entry.title ?? ""),
409      status: pickState(states),
410      priority: null,
411      labels: [],
412      proposedBy:
413        typeof entry.proposedBy === "string" ? entry.proposedBy : null,
414      conflict: entry.conflict === true,
415    };
416  });
417
418  return { tasks, coverage: parseCoverage(parsed.coverage) };
419}
420
421/** Reads `quest task view --json` output down to the fields the detail draws. */
422export function parseTaskView(stdout: string): TaskDetail {
423  const parsed = JSON.parse(stdout) as { data?: unknown };
424  const task = parsed.data as Record<string, unknown> | undefined;
425  if (!task || typeof task !== "object") {
426    throw new Error("quest output has no task");
427  }
428  const criteria = Array.isArray(task.acceptanceCriteria)
429    ? task.acceptanceCriteria
430    : [];
431  const notes = asStrings(task.implementationNotes);
432  const dependencies = Array.isArray(task.dependencies)
433    ? task.dependencies.map((dep) =>
434        typeof dep === "string"
435          ? dep
436          : String((dep as { id?: unknown }).id ?? "?"),
437      )
438    : [];
439
440  return {
441    id: String(task.id ?? "?"),
442    title: String(task.title ?? ""),
443    status: String(task.status ?? ""),
444    priority: typeof task.priority === "string" ? task.priority : null,
445    type: typeof task.type === "string" ? task.type : null,
446    labels: asStrings(task.labels),
447    description: String(task.description ?? ""),
448    revision: typeof task.revision === "string" ? task.revision : null,
449    criteria: criteria.map((item, at) => {
450      const one = item as {
451        text?: unknown;
452        checked?: unknown;
453        position?: unknown;
454      };
455
456      return {
457        text: String(one.text ?? ""),
458        isChecked: one.checked === true,
459        position: typeof one.position === "number" ? one.position : at + 1,
460      };
461    }),
462    comments: (Array.isArray(task.comments) ? task.comments : []).map(
463      (item) => {
464        const one = item as {
465          authorId?: unknown;
466          body?: unknown;
467          createdAt?: unknown;
468        };
469
470        return {
471          author: String(one.authorId ?? "?"),
472          body: String(one.body ?? ""),
473          createdAt: String(one.createdAt ?? ""),
474        };
475      },
476    ),
477    dependencies,
478    latestNote: notes.length > 0 ? (notes[notes.length - 1] ?? null) : null,
479    updatedAt: typeof task.updatedAt === "string" ? task.updatedAt : null,
480  };
481}
482
483/** The search and repo filters, applied to what was fetched. */
484export function filterRows(
485  rows: RepoRow[],
486  query: string,
487  repo: string,
488): RepoRow[] {
489  const needle = query.trim().toLowerCase();
490
491  return rows
492    .filter((row) => repo === "all" || row.repo === repo)
493    .map((row) => ({
494      ...row,
495      tasks: needle
496        ? row.tasks.filter((task) =>
497            [task.id, task.title, ...task.labels].some((field) =>
498              field.toLowerCase().includes(needle),
499            ),
500          )
501        : row.tasks,
502    }));
503}
504
505// The arguments every pane edit adds: the operator, acting in their own pane.
506export function actorArgs(id: string): string[] {
507  return ["--actor", id, "--actor-kind", "human"];
508}
509
510/** A comment as Quest stores one. */
511export function commentArg(
512  author: string,
513  body: string,
514  nowMs: number,
515): string {
516  const createdAt = new Date(nowMs).toISOString();
517
518  return JSON.stringify([
519    { id: `c-${nowMs}`, authorId: author, body, createdAt },
520  ]);
521}
522
523/** A Quest workspace is a directory holding `.quest/workspace.toml`. */
524export const QUEST_WORKSPACE = ".quest/workspace.toml";
525
526export function parentOf(path: string): string {
527  const trimmed = path.replace(/\/+$/, "");
528  const at = trimmed.lastIndexOf("/");
529
530  return at <= 0 ? "/" : trimmed.slice(0, at);
531}
532
533/** The Quest workspaces directly under one directory, by name. */
534export async function reposUnder(fs: FsLike, root: string): Promise<string[]> {
535  let entries: readonly { name: string; kind: string }[];
536  try {
537    entries = await fs.list(root);
538  } catch {
539    return [];
540  }
541  const found: string[] = [];
542  for (const entry of entries) {
543    if (entry.kind !== "dir" || entry.name.startsWith(".")) {
544      continue;
545    }
546    if (await fs.exists(`${root}/${entry.name}/${QUEST_WORKSPACE}`)) {
547      found.push(entry.name);
548    }
549  }
550
551  return found.sort();
552}
553
554/**
555 * The fleet root: the nearest ancestor of the session's own directory holding
556 * Quest workspaces.
557 *
558 * Walking up from the session is what keeps a machine path out of the mod --
559 * the prototype carried the operator's checkout root as a constant. Five
560 * levels is enough for a worktree, which sits three below the root.
561 */
562export async function discoverRoot(
563  fs: FsLike,
564  startDir: string,
565  levels = 5,
566): Promise<{ root: string; repos: string[] } | null> {
567  let dir = parentOf(startDir);
568  for (let step = 0; step < levels; step += 1) {
569    const repos = await reposUnder(fs, dir);
570    if (repos.length > 0) {
571      return { root: dir, repos };
572    }
573    const next = parentOf(dir);
574    if (next === dir) {
575      break;
576    }
577    dir = next;
578  }
579
580  return null;
581}
582
583/**
584 * The session's own repository, by name.
585 *
586 * `git rev-parse --git-common-dir` names the repository rather than the
587 * worktree the session may be running in, so a worktree session still keys its
588 * row -- and its writes -- to the repository the operator knows.
589 */
590export function repoNameFromGitCommonDir(
591  root: string,
592  commonDir: string | null,
593): string {
594  const fallback = root.replace(/\/+$/, "").split("/").pop() ?? root;
595  if (!commonDir) {
596    return fallback;
597  }
598  const dotGit = (
599    commonDir.startsWith("/") ? commonDir : `${root}/${commonDir}`
600  ).replace(/\/+$/, "");
601  if (!dotGit.endsWith(".git")) {
602    return fallback;
603  }
604
605  return dotGit.slice(0, -"/.git".length).split("/").pop() ?? fallback;
606}
607
608// The alerts check: what the board tells the operator about while they are
609// working somewhere else. Everything here is pure -- the argv, the parsers, and
610// the comparison against the last state seen -- so it is tested without a
611// surface; the reads, the clock and the toasts live in `register.tsx`.
612
613/** What the plugin's `alerts` option offers. */
614export type AlertsSetting = "all" | "decisions" | "off";
615
616/**
617 * The `alerts` option as the check reads it.
618 *
619 * A value outside the three reads as the default rather than as a refusal: the
620 * engine reads a stored value outside a field's `options` as unset, so an
621 * option nobody set has to read the same way here.
622 */
623export function parseAlerts(value: unknown): AlertsSetting {
624  return value === "decisions" || value === "off" ? value : "all";
625}
626
627/** One decision as `quest decision list` reports it. */
628export type DecisionEntry = {
629  id: string;
630  title: string;
631  status: string;
632};
633
634/**
635 * One decision as the check reads it: the record, and the workspace it was read
636 * from.
637 *
638 * The repository is carried because a decision id is minted per workspace --
639 * `highestSequence` reads that workspace's own `planning.json` -- so the same
640 * `DEC-3` in two repositories is two decisions, and comparing them by id alone
641 * would call one of them the other's resolution. The toasts print it for the
642 * same reason: an id alone cannot say which workspace it names.
643 */
644export type AlertDecision = DecisionEntry & { repo: string };
645
646/** The arguments the check reads decisions with. `decision list` takes none. */
647export function decisionListArgs(): string[] {
648  return ["decision", "list"];
649}
650
651/** Reads `quest decision list --json` down to the fields the check reads. */
652export function parseDecisionList(stdout: string): DecisionEntry[] {
653  const parsed = JSON.parse(stdout) as { data?: unknown };
654  if (!Array.isArray(parsed.data)) {
655    throw new Error("quest output has no data array");
656  }
657
658  return parsed.data.map((raw) => {
659    const decision = raw as Record<string, unknown>;
660
661    return {
662      id: String(decision.id ?? "?"),
663      title: String(decision.title ?? ""),
664      status: String(decision.status ?? ""),
665    };
666  });
667}
668
669/**
670 * One task as the check reads it: open now, or the state it left the open set
671 * for.
672 */
673export type AlertTask = {
674  repo: string;
675  id: string;
676  title: string;
677  status: string;
678  priority: string | null;
679  /** The kind a Closed task was retired under; null on every other task. */
680  resolution: string | null;
681};
682
683/**
684 * Reads `quest task view --json` down to the fields the check classifies on.
685 *
686 * `parseTaskView` reads the detail the pane draws; a task that left the open
687 * set is told Done from Closed by its status and, when it was closed, by why --
688 * neither of which the pane's own reader keeps.
689 */
690export function parseTaskOutcome(stdout: string): Omit<AlertTask, "repo"> {
691  const parsed = JSON.parse(stdout) as { data?: unknown };
692  const task = parsed.data as Record<string, unknown> | undefined;
693  if (!task || typeof task !== "object") {
694    throw new Error("quest output has no task");
695  }
696  const resolution = task.resolution as { kind?: unknown } | undefined;
697
698  return {
699    id: String(task.id ?? "?"),
700    title: String(task.title ?? ""),
701    status: String(task.status ?? ""),
702    priority: typeof task.priority === "string" ? task.priority : null,
703    resolution: typeof resolution?.kind === "string" ? resolution.kind : null,
704  };
705}
706
707/** The last state the check saw, as `$.store` keeps it. */
708export type AlertsState = {
709  /** `${repo}:${id}` -> status, the same reason `AlertDecision` carries its repo. */
710  decisions: Record<string, string>;
711  /** Repository -> task id -> status, over the tasks open at that check. */
712  tasks: Record<string, Record<string, string>>;
713};
714
715function isStringMap(value: unknown): value is Record<string, string> {
716  return (
717    !!value &&
718    typeof value === "object" &&
719    Object.values(value).every((one) => typeof one === "string")
720  );
721}
722
723/**
724 * Whether a value read back from the store is a state this build wrote.
725 *
726 * A stored state that does not pass reads as no state at all, and the check
727 * then records a baseline rather than toasting a fleet's worth of changes it
728 * cannot be sure it has not already shown.
729 */
730export function isAlertsState(value: unknown): value is AlertsState {
731  const state = value as Partial<AlertsState> | null;
732
733  return (
734    !!state &&
735    typeof state === "object" &&
736    isStringMap(state.decisions) &&
737    !!state.tasks &&
738    typeof state.tasks === "object" &&
739    Object.values(state.tasks).every((one) => isStringMap(one))
740  );
741}
742
743/** What one check found worth telling the operator about. */
744export type AlertChanges = {
745  /** Decisions that have just become `proposed`. */
746  waiting: AlertDecision[];
747  /** Decisions that were `proposed` and are not any more, with their repo. */
748  decided: { repo: string; id: string; status: string }[];
749  /** Open tasks that have moved to Paused. */
750  paused: AlertTask[];
751  /** Tasks that left the open set at the closed status. */
752  closed: AlertTask[];
753  /** Tasks that left the open set at Done with a high priority. */
754  done: AlertTask[];
755};
756
757/** Everything one check read, from the repositories it could read. */
758export type AlertsSeen = {
759  decisions: readonly AlertDecision[];
760  open: readonly AlertTask[];
761  /** The tasks that were open last check and are not now, each looked up once. */
762  departed: readonly AlertTask[];
763};
764
765/**
766 * The alert-worthy changes between the last state seen and this check's read.
767 *
768 * Only changes a person would act on: a task starting, or a normal-priority
769 * task finishing, is drawn in the pane and says nothing. Decisions are alert-
770 * worthy whichever way they move, so `decisions` mode stops after them.
771 */
772export function alertChanges(
773  previous: AlertsState,
774  seen: AlertsSeen,
775  setting: AlertsSetting,
776): AlertChanges {
777  const changes: AlertChanges = {
778    waiting: [],
779    decided: [],
780    paused: [],
781    closed: [],
782    done: [],
783  };
784  for (const decision of seen.decisions) {
785    const was = previous.decisions[`${decision.repo}:${decision.id}`];
786    if (decision.status === "proposed" && was !== "proposed") {
787      changes.waiting.push(decision);
788    }
789    if (was === "proposed" && decision.status !== "proposed") {
790      changes.decided.push({
791        repo: decision.repo,
792        id: decision.id,
793        status: decision.status,
794      });
795    }
796  }
797  if (setting !== "all") {
798    return changes;
799  }
800  for (const task of seen.open) {
801    const was = previous.tasks[task.repo]?.[task.id];
802    if (was !== undefined && was !== task.status && task.status === "Paused") {
803      changes.paused.push(task);
804    }
805  }
806  for (const task of seen.departed) {
807    if (task.status === "Closed") {
808      changes.closed.push(task);
809    }
810    if (task.status === "Done" && task.priority === "high") {
811      changes.done.push(task);
812    }
813  }
814
815  return changes;
816}
817
818/** One toast the check shows, and how long it stays. */
819export type AlertToast = { text: string; timeoutMs: number };
820
821/** More alert-worthy task changes than this in one check become one toast. */
822export const BATCH_AT = 3;
823
824/** A resolution kind as a toast reads it: `wont-do` is "won't do". */
825function resolutionWords(kind: string): string {
826  return kind === "wont-do" ? "won't do" : kind;
827}
828
829/** The toast for a task retired at the closed status. */
830function closedText(task: AlertTask): string {
831  const why = task.resolution ? ` (${resolutionWords(task.resolution)})` : "";
832
833  return `${task.id} closed${why} in ${task.repo}`;
834}
835
836/** The one toast a check with more than {@link BATCH_AT} task changes shows.
837 *
838 * The line points at the `dashboard` tool by the words that reach it -- the
839 * board's slash command was retired, so `/quest` is not a command any more and
840 * naming it here would send the person nowhere (QCLI-444, seq 182).
841 */
842export function batchLine(changes: AlertChanges): string {
843  const counts: readonly (readonly [number, string])[] = [
844    [changes.done.length, "done"],
845    [changes.paused.length, "paused"],
846    [changes.closed.length, "closed"],
847  ];
848  const total = counts.reduce((sum, [count]) => sum + count, 0);
849  const named = counts
850    .filter(([count]) => count > 0)
851    .map(([count, word]) => `${count} ${word}`)
852    .join(", ");
853
854  return `${total} updates: ${named}. Open the board: /quest dashboard`;
855}
856
857/**
858 * The toasts one check shows: the decisions first, each its own, then the task
859 * changes -- individually, or as one batch once there are more than
860 * {@link BATCH_AT} of them.
861 *
862 * Decisions are never folded into the batch. A waiting decision is something
863 * the person has to answer, and "3 updates" is not a line they can act on.
864 *
865 * Both decision toasts name the repository, because the id alone does not
866 * identify the decision: ids are minted per workspace, so two trackers can
867 * both hold a `DEC-3` and the person can be looking at either (QCLI-444).
868 */
869export function alertToasts(changes: AlertChanges): AlertToast[] {
870  const toasts: AlertToast[] = [];
871  for (const decision of changes.waiting) {
872    toasts.push({
873      text: `${decision.repo} ${decision.id} needs a decision: ${decision.title}`,
874      timeoutMs: 10_000,
875    });
876  }
877  for (const decision of changes.decided) {
878    toasts.push({
879      text: `${decision.repo} ${decision.id} decided: ${decision.status}`,
880      timeoutMs: 6_000,
881    });
882  }
883  const tasks: AlertToast[] = [
884    ...changes.paused.map((task) => ({
885      text: `${task.id} paused in ${task.repo}: ${task.title}`,
886      timeoutMs: 8_000,
887    })),
888    ...changes.closed.map((task) => ({
889      text: closedText(task),
890      timeoutMs: 6_000,
891    })),
892    ...changes.done.map((task) => ({
893      text: `${task.id} done: ${task.title}`,
894      timeoutMs: 6_000,
895    })),
896  ];
897  if (tasks.length > BATCH_AT) {
898    toasts.push({ text: batchLine(changes), timeoutMs: 8_000 });
899  } else {
900    toasts.push(...tasks);
901  }
902
903  return toasts;
904}
905
906/**
907 * The one line a first check after a restart shows, or null when nothing moved
908 * while no session was running.
909 */
910export function awayLine(changes: AlertChanges): string | null {
911  const waiting = changes.waiting.length;
912  const updates =
913    changes.paused.length + changes.closed.length + changes.done.length;
914  const parts: string[] = [];
915  if (waiting > 0) {
916    parts.push(`${waiting} decision${waiting === 1 ? "" : "s"} waiting`);
917  }
918  if (updates > 0) {
919    parts.push(`${updates} update${updates === 1 ? "" : "s"}`);
920  }
921
922  return parts.length === 0 ? null : `While you were away: ${parts.join(", ")}`;
923}
924
925/** The status line that stands while decisions wait, or undefined for none. */
926export function waitingStatus(waiting: number): string | undefined {
927  return waiting > 0
928    ? `Quest: ${waiting} decision${waiting === 1 ? "" : "s"} waiting`
929    : undefined;
930}
931
types/index.d.ts 113 lines
1// The Quest board's state contract and the shapes the pane draws from.
2//
3// `.claude-plugin/plugin.json` names this file as the plugin's `types`, and
4// `claude plugin validate` holds every `$.state` key the hooks module names to
5// what is declared under `PluginState` here. The `hooks/` module imports these
6// types with `import type`, so this file carries no runtime code.
7
8/** One task as the board draws it in a row. */
9export type QuestTask = {
10  id: string;
11  title: string;
12  status: string;
13  /** Null in the across-refs read, which reports no priority. */
14  priority: string | null;
15  /** Empty in the across-refs read, which reports no labels. */
16  labels: string[];
17  /** `owner/repo#N` when the record exists only on a pull request head. */
18  proposedBy: string | null;
19  /** True when the refs disagree about this id's status. */
20  conflict: boolean;
21};
22
23/** What the across-refs read says it actually managed to read. */
24export type Coverage = {
25  complete: boolean;
26  refsRead: number;
27  refsUnreadable: string[];
28};
29
30/** One repository's row: its tasks, or why it could not be read. */
31export type RepoRow = {
32  repo: string;
33  tasks: QuestTask[];
34  error: string | null;
35  /** Null unless the row was read across refs. */
36  coverage: Coverage | null;
37};
38
39/** `local` is the session's own repository, the one the pane may write in. */
40export type Scope = "local" | "fleet";
41
42// "open" is every status but Done and Closed; the rest are Quest statuses.
43export type StatusFilter =
44  | "open"
45  | "In Progress"
46  | "To Do"
47  | "Paused"
48  | "Done"
49  | "Closed";
50
51export type Tab = "list" | "kanban";
52
53export type Board = {
54  rows: RepoRow[];
55  refreshedAt: number | null;
56  isLoading: boolean;
57};
58
59export type View = {
60  tab: Tab;
61  scope: Scope;
62  status: StatusFilter;
63  query: string;
64  repo: string;
65  isCollapsed: boolean;
66  /** The full-screen toggle: the largest pane the surface allows, or the normal size. */
67  isFull: boolean;
68  /** Read across refs (`quest task list --across-refs`) instead of the working tree. */
69  readRefs: boolean;
70  selected: { repo: string; id: string } | null;
71  pending: "complete" | null;
72};
73
74export type Edits = {
75  isWriting: boolean;
76  uncommitted: number;
77};
78
79export type TaskDetail = {
80  id: string;
81  title: string;
82  status: string;
83  priority: string | null;
84  type: string | null;
85  labels: string[];
86  description: string;
87  revision: string | null;
88  criteria: { text: string; isChecked: boolean; position: number }[];
89  comments: { author: string; body: string; createdAt: string }[];
90  dependencies: string[];
91  latestNote: string | null;
92  updatedAt: string | null;
93};
94
95export type Detail = {
96  key: string | null;
97  task: TaskDetail | null;
98  error: string | null;
99  isLoading: boolean;
100};
101
102/** The part of `$.fs` repository discovery uses. */
103export type FsLike = {
104  list: (path?: string) => Promise<readonly { name: string; kind: string }[]>;
105  exists: (path: string) => Promise<boolean>;
106};
107
108declare module "claude-code" {
109  interface PluginState {
110    "opum-quest": { board: Board; view: View; detail: Detail; edits: Edits };
111  }
112}
113