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

A deterministic, LLM-free task tracker CLI — the record layer coding agents and humans write to, and that tools like
lorecouple 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).
quest manifest --json) is the live, authoritative description of the command surface.@opum-ai/quest (bin quest) with six exact-pinned platform packages, including Windows ARM64.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, andbun run check:packagesfails the release if it reappears: a version hand-maintained here goes stale the moment the next release lands, and becauseREADME.mdships 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. Seedocs/reference/quest-cli-release-truth.md.
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.
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.
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).
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.
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.
The full design lives in this repo's OKF bundle under docs/, authored and kept coherent with lore:
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.
MIT © 2026 Opum AI.
hooks/register.tsx 2130 lines1import { 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 }
1200hooks/quest.ts 931 lines1// 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}
931types/index.d.ts 113 lines1// 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