Project memory for Claude Code and Codex. Storybloq stores tickets, issues, handovers, and lessons in a .story/ folder inside your repo.

<img src="https://raw.githubusercontent.com/Storybloq/storybloq/main/assets/logo.png" width="120" alt="Storybloq logo" />
<h1 align="center">Storybloq</h1>
<strong>Your project’s memory. Your agents’ workflow.</strong><br /> Project memory and workflows for Claude Code and Codex. Keep stories, plans, handovers, and review evidence beside your code. Pick up work across sessions and follow progress in the optional Mac app.
<a href="https://www.npmjs.com/package/@storybloq/storybloq"><img src="https://img.shields.io/npm/v/@storybloq/storybloq?color=333&label=npm" alt="npm version" /></a> <a href="https://github.com/Storybloq/storybloq/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-PolyForm--Shield%201.0-blue" alt="License" /></a> <img src="https://img.shields.io/badge/node-%E2%89%A520-brightgreen" alt="Node" /> <img src="https://img.shields.io/badge/claude%20code-compatible-orange" alt="Claude Code compatible" /> <img src="https://img.shields.io/badge/codex-compatible-111" alt="Codex compatible" />
<a href="https://storybloq.com">storybloq.com</a> · <a href="https://storybloq.com/mac">Mac app</a> · <a href="https://github.com/Storybloq/lenses">Review lenses</a> · <a href="https://storybloq.com/privacy">Privacy</a>
A new coding session may be missing the decisions and unfinished work from the last. Project instructions describe how to work; handovers, stories, and review records capture what happened and where to continue.
The real cost isn't wasted setup time. It's repeated mistakes, relitigated design decisions, hallucinated context, and linear instead of compounding work.
Every project gets a .story/ directory of JSON and markdown files. Stories, issues, roadmap phases, session handovers, and lessons learned live there as readable files you can track with Git. Stories use ticket records in the CLI and file format.
storybloq - inspect and mutate .story/ from the terminal./story in Claude Code or $story in Codex to load project state at the start of a session..story/ and updates live while your AI client works (separate product, free on the App Store).A lesson is a recorded pattern or mistake, with reinforcement to help useful guidance surface again. An approval records acceptance of particular work, within the workflow that requested it. Coordination records participants and assignments; the client or configured transport supplies dispatch and messages. Recovery uses saved state and checks before continuing, rather than assuming an interrupted action finished.
Start with one story and a handover. Add autonomous mode, coordination workflows, or federation when the project needs them.
npm install -g @storybloq/storybloq@latest
storybloq setup --client all
Requires Node.js 20+ and at least one AI client: Claude Code or Codex CLI 0.130.0+. Package lives on npm at @storybloq/storybloq; releases are tagged on this repo at github.com/Storybloq/storybloq/releases.
setup --client all installs the Storybloq skill for Claude and Codex, registers this package as an MCP server, and configures available client hooks. It also registers the bundled codex-claude-bridge review backend as the codex-bridge MCP server when Codex is installed; the bridge ships as an optional dependency of this package, so a copy you registered yourself is left alone and storybloq health launches whichever one is registered and reports whether it answers. The bridge needs a Codex CLI login to review (or agy for its Gemini failover); without one it registers but reviews fail. Re-running it is safe. Codex reports installed hooks with trust unknown; open /hooks in Codex to review and trust them. setup-skill remains as a compatibility alias for Claude-only setup.
Setup also installs the ledger dashboard for Claude Code. On version 2.1.272 or newer, a project with a .story/ directory draws its tickets, issues, and progress in a pane beside the transcript, with no extra flags on the install command and nothing to configure. Projects without .story/ show nothing. Turn the dashboard off with /plugin configure storybloq. The context figure in the footer is how full the session is toward its automatic compaction point, not a share of the model's window, so it reaches 100% when compaction is due; it refreshes during a turn as tools run, at most every five seconds.
While a /story auto session runs, the dashboard shows where it is. The In progress card the session is working, a ticket or, while it fixes an issue, that issue, carries its stage after the id, as in T-001 [Code] Native canvas, document objects, and local saving. When no card on screen is the session's own (nothing picked yet, the item not in progress, or cut off by the column's limit), the In progress heading carries the stage instead, as in In progress 1 [Code]. There is no footer line for the session. The stage comes from .story/status.json alone. A status older than 12 hours, the presence TTL, is drawn dimmed with a question mark ([Code?]) because nothing has confirmed it since. When the pane is narrow the title is cut first, and the tag is dropped before the id is shortened or the heading's count is cut.
/story in Claude Code chat or $story in Codex chat. For a new project, the skill guides you through setup./story or $story again to load the recorded project context.The optional Mac app shows stories, progress, and handovers from your project files. Explore the Mac app or follow the tutorials.
Start with one project and one agent. Add autonomous workflows, independent review, or connected repositories when the work calls for them. Review records preserve what was checked; they do not guarantee correctness. Tests, CI, and release checks still matter.
The CLI and MCP server are source-available under PolyForm Shield. Your coding client and optional review backends have their own data handling and costs. See the privacy policy and the license below.
npm install -g @storybloq/storybloq@latest
storybloq setup --client all
Same two commands as a fresh install: @latest pulls the newest version, and re-running setup refreshes the Storybloq skill files, re-registers the MCP server, and sweeps any stale hook entries from prior installs.
You'll usually see a one-line banner on the next storybloq invocation whenever a newer version is on npm:
storybloq v1.2.0 is available (you have v1.1.6).
Update: npm install -g @storybloq/storybloq@latest
The CLI also silently refreshes the skill dir and migrates any legacy hook entries (for example, from the pre-rename @anthropologies/claudestory package) on the first run after an upgrade — no manual cleanup needed.
Alternative install via the Claude Code plugin system: see Storybloq/plugin-archive (legacy path; storybloq setup --client all is the recommended install).
codex plugin marketplace add https://github.com/Storybloq/storybloq
codex plugin add storybloq@storybloq
This installs the Storybloq skill only. It does not run this package's npm install, register the MCP server, or configure hooks -- those still need the CLI, which the skill's own bootstrap step installs for you the first time you invoke it: on your first $story, if the CLI or MCP server isn't set up yet, the skill runs npm install -g @storybloq/storybloq@latest followed by storybloq setup --client codex --skip-skill for you. --skip-skill skips re-copying the skill files, since the plugin already manages that copy -- passing it yourself only matters if you're driving storybloq setup directly instead of letting the skill's bootstrap step do it.
If you already have a standalone Codex skill copy from a prior direct storybloq setup --client codex (at ~/.agents/skills/story/ and/or ~/.codex/skills/story/), installing the marketplace plugin on top of it is an untested configuration, not a verified-harmless one -- Codex's handling of two same-named skill providers isn't something this project has tested. Recommended migration: move the existing copy OUT of the skills root rather than deleting it. A backup left under ~/.agents/skills/ (for example story.bak) still contains a SKILL.md and can be discovered as a second skill, which is the duplicate this procedure exists to remove, so park it one level up instead: mkdir -p ~/.agents/story-skill-backups && mv ~/.agents/skills/story ~/.agents/story-skill-backups/story.$(date +%s), and the same for ~/.codex/skills/story into ~/.codex/story-skill-backups/ if present. Restart Codex and confirm $story still works via the plugin-managed copy. To roll back, move the backup to its original path. Only then, separately and at your own discretion, remove the backup.
If the CLI or MCP server is still missing after installing the plugin and invoking $story once, run the bootstrap command yourself: npm install -g @storybloq/storybloq@latest && storybloq setup --client codex --skip-skill.
cd your-project
storybloq init --name "your-project"
For multi-repo projects, see Federation below.
That scaffolds:
.story/
├── config.json project config + recipe overrides
├── roadmap.json phase ordering + metadata
├── tickets/ T-001.json, T-002.json, ...
├── issues/ ISS-001.json, ISS-002.json, ...
├── notes/ N-001.json, N-002.json, ...
├── lessons/ L-001.json, ...
├── handovers/ YYYY-MM-DD-<slug>.md
└── snapshots/ state snapshots (gitignored)
Commit everything except .story/snapshots/.
<img src="https://raw.githubusercontent.com/Storybloq/storybloq/main/assets/board.png" alt="Ticket board showing phases, tickets, and in-progress work" />
Inside Claude Code or Codex:
/story in Claude Code or $story in Codex - loads project status, reads the latest handover, surfaces open tickets and issues, lists blocked work, summarizes recent changes. When the client can run background agents and the actionable backlog is large, it also surfaces the orchestrate working style proactively (a recommendation, still gated by explicit opt-in)./story auto T-001 T-002 ISS-013 / $story auto T-001 T-002 ISS-013 - autonomous mode scoped to those items. Drives a ticket through plan -> plan review -> implement -> tests -> code review -> commit with handovers at each checkpoint./story review T-001 / $story review T-001 - runs the multi-lens review (see Storybloq/lenses) against a ticket's diff./story orchestrate / $story orchestrate - drives a multi-repo (or large single-repo) backlog when the client exposes exact callable workflow/subagent tools. Codex uses multi_agent_v1.spawn_agent, its normalized multi_agent_v1__spawn_agent identifier, or an exact spawn_agent tool. The Claude Agent View-backed storybloq dispatch command is shipped; a product-managed Codex dispatch backend is not./story triage / $story triage - read-only triage of the open issue backlog: verifies each finding against the pinned current HEAD, flags already-fixed and duplicate issues, groups issues that share one verified root cause, and recommends a prioritized ticket plan. Mutates no issue and no ticket./story bus / $story bus - polls a task-bound local Bus endpoint so an implementer and an independent reviewer can exchange advisory findings without copy and paste./story handover / $story handover - writes a session handover capturing decisions, blockers, and next steps.Both clients support context loading, autonomous mode, MCP, and compaction/status hooks. Codex Desktop can open an autonomous session's owning task and relay an exact owner response to it; Codex CLI safely falls back to a manual task switch. Autonomous code review defaults to a 12-round landing cap (clamped upward by ticket risk): unresolved critical findings and rejects still block, while non-blocking findings become follow-up issues at the cap. Set recipeOverrides.stages.CODE_REVIEW.maxReviewRounds to 0 for unlimited, which disables the cap and the ceiling below it alike. Otherwise, three rounds past the cap a hard ceiling ends the session: the outstanding findings are filed as issues, the work is left uncommitted in the tree, and a handover is written. The item returns to open when the session still owns its claim; if the claim has moved, the item is left exactly as it is.
recipeOverrides.compactThreshold accepts medium, high (default), or critical. The value selects both the pressure limits and the rotation trigger: medium uses lower limits and rotates at medium pressure, while critical uses higher limits and waits for critical pressure. At a clean COMPLETE boundary, threshold pressure ends the bounded session through HANDOVER because Storybloq cannot invoke a client compaction command. When the client itself compacts, the PreCompact and SessionStart hooks preserve the same session; pressure resets only after SessionStart confirms source: compact.
Outside the AI client, the same state is one storybloq invocation away.
Claude Code sessions stop at usage limits ("You've hit your usage limit"), and overnight autonomous work silently dies with them. Storybloq detects the stop through Claude Code's StopFailure hook, parses the reset time from the session transcript, records the stop in a global ledger (~/.claude/storybloq/limit-ledger.json), and resumes the session when the limit resets. On by default once hooks are installed.
claude --resume command. Per-project opt-in (limitResume.plainMode: "headless") wakes them headlessly instead.--dangerously-skip-permissions is only woken with that flag if the project explicitly opts in (limitResume.inheritBypass: true); otherwise it notifies.The wake is driven by a transient detached waker process, not a daemon: it polls the ledger every 30 seconds, resumes what is due (attempt-capped, staggered, concurrency-bounded), and exits when nothing is pending. It survives laptop sleep but not reboot or logout -- after a reboot, the next storybloq invocation or hook fire in any project respawns it, so weekly-scale waits recover on your next activity. That trade is the cost of "no daemon."
Inspect and manage the queue with storybloq limit-status (--cancel <key> destroys a pending auto-resume, --requeue <key> retries a stood-down record). Disable globally with {"limitResume": {"enabled": false}} in ~/.claude/storybloq/config.json, or per project via limitResume in .story/config.json (also maxAttempts, staggerMs, maxConcurrent, notify, and more).
Prior art: the detection-and-reparse approach is modeled on unsnooze (MIT), which pioneered transcript-based limit detection and reset-time parsing for tmux-hosted sessions. Storybloq's version drops the tmux layer in favor of the documented hook surface and resumes autonomous sessions through its own state machine instead of a keystroke.
Storybloq Bus connects two task-bound endpoints in one local checkout. One may implement while the other reviews, but routing follows the paired endpoints rather than role names. Runtime messages live under gitignored .story/bus/; confirmed findings remain canonical Storybloq issues.
Run setup from each participating client task, using its actual client identity. For example, inside a Codex CLI task:
storybloq bus setup --client codex --surface codex_cli --delivery poll
storybloq bus status
storybloq bus endpoint list
Setup initializes the local runtime and binds the current task id (normally discovered from the client environment). If discovery is unavailable, supply the validated client task id with --task-id; do not invent one. A second task runs setup in the same checkout to become the peer. Re-running setup is resumable. bus init and bus join remain lower-level commands; the legacy role argument to join does not select a message destination.
--delivery poll uses explicit polling. The default, --delivery live, requires supported client hooks and configures guarded delivery. Live injection also depends on the session’s available transport. bus auto-attach on is a separate project opt-in for later sessions. Neither configuration guarantees an inactive peer reads a message immediately.
Messages are hash-chained, idempotent, bounded, and delivered through recoverable recipient mailboxes. Critical notices participate in ship checks and require canonical issue handling. A message is peer advice; it never grants owner approval to merge, push, deploy, spend, or perform destructive actions. bus redeliver can move a hop-cap-parked message to a successor thread using its recorded content.
Optional idle wake: bus setup --wake idle opts a supported Codex CLI endpoint into a best-effort wake attempt after mail is committed. It requires a reachable compatible Codex app-server, an idle thread, and proven ownership. This implementation accepts app-server version 0.153.4; other versions are refused. It cannot wake Codex Desktop or Claude endpoints. Omit --wake to preserve an existing policy or pass --wake never to disable it. There is no Bus retry daemon: a failed attempt waits for a later send, and a wake request is not proof of receipt. bus endpoint list reports the policy and last outcome.
Claude usage-limit recovery, described above, is a separate mechanism. It is not Bus message delivery and does not establish equivalent usage-limit recovery in Codex.
<img src="https://raw.githubusercontent.com/Storybloq/storybloq/main/assets/autonomous.png" alt="Autonomous mode running a ticket through plan, implement, test, review" />
Federation coordinates AI agent work across multiple repos. One project becomes the orchestrator. It declares which repos (nodes) are part of the system, how they depend on each other, and how they communicate at runtime. Each node keeps its own .story/ with its own tickets, issues, and handovers. The orchestrator reads across all of them.
# Create an orchestrator
storybloq init --type orchestrator --name "my-platform"
# Register nodes
storybloq node add api --path ../api --stack typescript --role "REST backend"
storybloq node add web --path ../web --stack nextjs --depends-on api
storybloq node add sdk --path ../sdk --stack typescript
Three relationship types connect nodes:
dependsOn on node config: build-order edges. The web app depends on the API.links on node config: runtime integration. The web app calls the API over HTTP.crossNodeBlockedBy on tickets: a ticket in one repo is blocked until a ticket in another repo is complete. Example: "crossNodeBlockedBy": ["api:T-012"].From the orchestrator directory:
storybloq status # aggregated view across all nodes
storybloq recommend # federation-aware suggestions (bottlenecks, stale nodes, blockers)
storybloq ticket list --node api # list tickets in the api node without cd-ing
The recommendation engine generates federation-specific suggestions: nodes blocking downstream work, bottleneck nodes depended on by many others, nodes with no handover in two weeks. Tickets with crossNodeBlockedBy refs never surface in recommendations until the blocking ticket is complete.
All commands accept --format json|md (default md). Pipe JSON through jq for scripting, read the markdown variant directly.
| Command | Description | ||
|---|---|---|---|
storybloq init [--name] [--type orchestrator] [--force] | Scaffold .story/ (add --type orchestrator for multi-repo) | ||
storybloq status | Project summary with phase statuses, counts, and risks | ||
storybloq validate [--integrity-only] | Reference, schema, source-provenance, and loader-independent JSON checks | ||
| `storybloq setup --client claude\ | codex\ | all [--skip-hooks]` | Install Storybloq skills, register MCP, and configure client hooks |
storybloq setup-skill [--skip-hooks] | Compatibility alias for storybloq setup --client claude | ||
storybloq recommend --count N | Context-aware work suggestions |
| Command | Description |
|---|---|
storybloq phase list | All phases with derived status (status is computed from tickets, never stored) |
storybloq phase current | First non-complete phase |
storybloq phase tickets --phase <id> | Leaf tickets for a phase |
storybloq phase create --id --name --label --description [--summary] --after/--at-start | Create a phase |
storybloq phase rename <id> [--name] [--label] [--description] [--summary] | Update phase metadata |
storybloq phase move <id> --after/--at-start | Reorder |
storybloq phase delete <id> [--reassign <target>] | Delete (reassign contained tickets) |
| Command | Description |
|---|---|
storybloq ticket list [--status] [--phase] [--type] | List leaf tickets (umbrellas excluded) |
| `storybloq ticket get
hooks/mod.ts 39 lines1/**
2 * The storybloq plugin's one hooks module (Claude Code function hooks).
3 *
4 * hooks/hooks.json names exactly this file: the client admits one module per
5 * plugin, so every storybloq Mod registers through here. Each Mod lives in
6 * its own file and is gated by its own `userConfig` option:
7 *
8 * - sidebar.ts T-508, the ledger dashboard (option `sidebar`, on by default)
9 *
10 * The client loads this module only under CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
11 * and after workspace trust; a Mod whose option is off registers nothing, so
12 * it costs no hook on any event. Event names are string literals at every
13 * on() call, as the client's source scan requires; the pinned list they are
14 * checked against is client-api.ts.
15 *
16 * The sidebar landed at 72f98679 (T-508). The roster Mod (T-507) was removed
17 * by owner ruling on 2026-09-15: one dashboard Mod; the roster core stays.
18 */
19
20import { registerSidebar } from "./sidebar.js";
21
22export type Options = Readonly<Record<string, string | number | boolean | readonly string[]>>;
23type Hook = ($: any, e: any, next: (e: any) => unknown) => unknown;
24export type On = (event: string, hook: Hook) => unknown;
25
26/**
27 * `register(on, options)`: the entry the client calls once per activation.
28 *
29 * T-516: the dashboard is on unless the user turned it off, so the test is
30 * "not false" rather than "is true". 2.1.273 does hand userConfig defaults
31 * through (measured: a probe module saw its declared defaults with nothing
32 * configured), which alone would be enough; reading an absent option as on
33 * keeps a client that does not pass defaults from silently hiding the Mod.
34 */
35export function register(on: On, options: Options): void {
36 const sidebar = options["sidebar"] !== false;
37 if (sidebar) registerSidebar(on, options);
38}
39hooks/sidebar.ts 1272 lines1import { createDashboardState, type DashboardState, type ScanItem, type CachedRecord } from "./dashboard-state.js";
2import { inProgressBoard, compactLineNode, paneText, panePlacement, boardLayout, rowBudget, compactBoard, narrowBoard, boardNode, headerNode, contextNode, footerNode, contextLabel } from "./dashboard-view.js";
3export { contextLabel, MOD_VERSION } from "./dashboard-view.js";
4import { wroteLedger } from "./ledger-write-detection.js";
5import { cellWidth, truncate } from "./terminal-text.js";
6import { displaySafe, statusField } from "./stage-label.js";
7/**
8* T-508: the ledger sidebar Mod. Draws `.story/` beside the transcript.
9*
10* Read-only by construction: it reads the ledger through `$.fs` and never
11* writes it. The write path stays the CLI and the MCP server, as the ticket
12* requires, and `claude plugin validate` prints the calls this module makes so
13* a `$.fs.write` added here would show up in a list the tests compare.
14*
15* WHERE THE NUMBERS COME FROM. `sidebar-projection.ts`, which the repo's own
16* vitest holds equal to `storybloq status --compact`. Nothing in this file
17* counts anything; it reads files, caches what it read, and draws.
18*
19* WHY THE SCAN IS CHUNKED. The compact numbers come from the whole
20* file-per-item ledger, which on a mature project is a couple of thousand
21* files, and `.story/status.json` is not a projection of them (it is a session
22* flag, four fields). A hook that read them all in one go would sit on the
23* client's budget, so the first pass runs in chunks on `$.clock.every` and the
24* pane says how many files are left. Afterwards `$.fs.stat` is the only cost
25* for a file that has not changed: the cache in `$.store` is keyed by path to
26* its mtime and the handful of fields the pane shows, so a later session
27* starts warm and a refresh re-reads only what moved.
28*
29* WHY IT POLLS. Nothing tells a session that another process wrote the
30* ledger: a peer session, the Mac app, a git pull and the CLI in a terminal
31* all leave this Mod's events silent, so the pane used to sit on the last
32* projection until this session next finished a turn (T-517). The client
33* exposes no `$.fs.watch`, so the refresh is a poll on the timer that is
34* already running: four `$.fs.stat` calls every two seconds, and nothing
35* further unless one of those four mtimes moved.
36*
37* WIDTH AND PLACEMENT (ISS-1247, ISS-1251; the 2.1.277 declarations). Two
38* client rules, neither ours to set. WHERE a pane sits is the renderer's: the
39* fullscreen (alternate-screen) layout docks it beside the transcript from 110
40* columns, the main-screen layout seats it inline above the prompt at any
41* width; `e.props.placement` says which on every Pane render. WHETHER it draws
42* is judged at each `$.ui.open`: an open answering the person's input (a
43* prompt they entered, a command, a press) is placed at any width; one the
44* plugin makes on its own waits undrawn below 144 columns (110 once asked).
45* The `session.start` open is the plugin's own, so a session started narrow
46* shows the one `AbovePrompt` line instead, and the person's first prompt
47* re-opens the pane (`prompt.submit`), which the client then places.
48*
49* LAYOUT FOLLOWS PLACEMENT (ISS-1254). A docked pane is a sidebar: tall and
50* never wider than 90 columns, so its three columns always stack one under
51* another, whatever its width (the 1.15.4 sidebar the owner asked back after
52* 1.15.5 squeezed four frames side by side at 150 columns and drew only the
53* narrow board at 132). An inline pane is a strip above the prompt: the three
54* columns side by side from BOARD_MIN_COLUMNS up, the narrow board below.
55* A client that reports no placement gets the width rule alone, as 1.15.4
56* did. Colour follows placement too (ISS-1255): the client paints a docked
57* pane's background from its theme, so text there gets a colour for that
58* theme; an inline pane sits on the terminal's own background, so its text
59* takes the terminal's default foreground, which is the one that matches.
60*
61* EVENT NAMES AND `$`. Every event name is a string literal at its `on()` call
62* and every call is spelled `$.noun.member(...)` inline, because the client
63* reads both from this source rather than from a manifest. `client-api.ts` is
64* the documentary pin the tests compare that reading against.
65*/
66import { logoFrame, logoLayout, LOGO_DURATION_MS } from "./storyfield-logo.js";
67import { DashboardMotion } from "./dashboard-motion.js";
68import type { On } from "./mod.js";
69import { extractRecord, projectSidebar, type SidebarIssue, type SidebarRecord, type SidebarTicket, } from "./sidebar-projection.js";
70type Options = Readonly<Record<string, string | number | boolean | readonly string[]>>;
71/** The pane's id. Also the `requestId` its `ui.render` and `ui.close` carry. */
72const PANE_ID = "storybloq";
73const PANE_TITLE = "Storybloq";
74/**
75* The narrowest terminal the client will place a plugin's OWN open into, from
76* the API's rule: an unasked open "waits undrawn below 144 columns (110 once
77* asked)". The `session.start` open is that kind, so below this a narrow
78* start has no pane and the one-line fallback draws instead; the person's
79* first prompt re-opens it as an open answering their input, which is placed
80* at any width (ISS-1251). 110 was the wrong floor and left 110-143 columns
81* with nothing drawn at all (ISS-1235). This is not the dock width: whether
82* a placed pane docks or sits inline is the renderer's call (ISS-1247).
83*/
84const DOCK_COLUMNS = 144;
85const BAND_HINT = ", board opens at your next prompt";
86const STORE_KEY = "sidebar-ledger-cache-v1";
87/** Under the store's 4 MiB, with room for whatever else the plugin keeps. */
88const STORE_BUDGET_BYTES = 3000000;
89/** Files per tick, and the tick, so no single dispatch sits on the budget. */
90const SCAN_CHUNK = 25;
91const SCAN_TICK_MS = 25;
92/**
93* T-517: ticks between idle polls, so eighty of a 25 ms tick is two seconds.
94*
95* Exported because the Mod's own tests count ticks against it, and a poll
96* interval they had to restate as a number would drift from this one.
97*/
98export const IDLE_POLL_TICKS = 80;
99/**
100* How many cards a column ever draws, and the line that stands for the rest.
101*
102* A fixed six, by the owner's ruling, and not a figure derived from
103* `props.scroll.bodyRows`. The derived cap is what produced the bug the owner
104* hit live: a Done column of 27 was headed 27 correctly, drew 18 rows and
105* showed no tail, because the pane clips at `bodyRows` and the tail WAS drawn,
106* below the cut, along with the issues and handover lines under it. Six
107* bounds every column's body at seven rows whatever the pane reports, so the
108* board is the same height on every terminal and nothing is silently cut. It
109* was eight until the owner asked for the height back.
110*
111* The tail is three dots and not "+19 more": the heading already carries the
112* true total, so the tail only has to say that the column goes on.
113*/
114const LEDGER_DIR = ".story";
115const TICKETS_DIR = ".story/tickets";
116const ISSUES_DIR = ".story/issues";
117const HANDOVERS_DIR = ".story/handovers";
118const CONFIG_PATH = ".story/config.json";
119const ROADMAP_PATH = ".story/roadmap.json";
120const STATUS_PATH = ".story/status.json";
121/** T-537: the setup hint's action, in the Claude Code profile's command. */
122export const SETUP_HINT = "Run /story to set this project up; the pane opens when it finishes.";
123/** T-532: tool calls read the context fill at most this often. */
124const CONTEXT_REFRESH_MS = 5000;
125function forgetEverything(dashboard: DashboardState): void {
126 dashboard.cache = {};
127 dashboard.cacheLoaded = false;
128 dashboard.projection = null;
129 dashboard.project = "";
130 dashboard.phases = [];
131 dashboard.handoverFilenames = [];
132 dashboard.queue = [];
133 dashboard.idleTicks = 0;
134 dashboard.polledMtimes = {};
135 dashboard.scanInitializing = false;
136 dashboard.scanActive = false;
137 dashboard.pendingRefresh = false;
138 dashboard.ticking = false;
139 dashboard.timerStarted = false;
140 dashboard.logoElapsed = 0;
141 dashboard.logoStarted = false;
142 dashboard.logoFinished = false;
143 dashboard.logoTicks = 0;
144 dashboard.motion = new DashboardMotion();
145 dashboard.bandDrawn = false;
146 dashboard.sidebarEnabled = false;
147 dashboard.paneOpen = false;
148 dashboard.paneDrawn = false;
149 dashboard.reopenAsked = false;
150 dashboard.themeLight = false;
151 dashboard.paneInline = false;
152 dashboard.sessionActive = false;
153 forgetSession(dashboard);
154 dashboard.contextPercent = null;
155 dashboard.contextReadAt = null;
156 dashboard.warm = false;
157 dashboard.uiAvailable = true;
158 dashboard.noLedger = false;
159 dashboard.saidNoUi = false;
160 dashboard.saidNoLedger = false;
161 dashboard.saidScanFailed = false;
162 dashboard.saidRootUnresolved = false;
163 dashboard.initialCwd = null;
164 dashboard.ledgerRoot = null;
165}
166function isTicketRecord(record: SidebarRecord): record is SidebarTicket {
167 return record.kind === "ticket";
168}
169function isIssueRecord(record: SidebarRecord): record is SidebarIssue {
170 return record.kind === "issue";
171}
172/** Rebuilds the projection from whatever the cache holds right now. */
173function reproject(dashboard: DashboardState, completedScan = false): void {
174 const tickets: SidebarTicket[] = [];
175 const issues: SidebarIssue[] = [];
176 for (const entry of Object.values(dashboard.cache)) {
177 if (isTicketRecord(entry.record))
178 tickets.push(entry.record);
179 else if (isIssueRecord(entry.record))
180 issues.push(entry.record);
181 }
182 dashboard.projection = projectSidebar({ project: dashboard.project, phases: dashboard.phases, tickets, issues, handoverFilenames: dashboard.handoverFilenames });
183 if (completedScan)
184 dashboard.motion.observe(dashboard.projection, dashboard.ledgerRoot);
185}
186/** The one line the narrow fallback draws, and the pane's own summary row. */
187function summaryLine(dashboard: DashboardState): string {
188 // Until one scan has finished (or a warm cache came out of the store) the
189 // numbers are a partial read, and drawing them would be a figure that
190 // changes a second later for no reason the reader can see.
191 const busy = dashboard.scanActive || dashboard.scanInitializing;
192 if (!dashboard.warm || dashboard.projection === null) {
193 return busy
194 ? `Storybloq: reading the ledger, ${dashboard.queue.length} files left`
195 : "Storybloq: no ledger read yet";
196 }
197 // The phase in hand; failing that, why there is none: a project whose
198 // every phase is complete is finished, not phaseless (ISS-1257, the
199 // `complete` sample), and only a roadmap with no phases says "no phase".
200 const phase = dashboard.projection.currentPhase
201 ? dashboard.projection.currentPhase.name
202 : dashboard.projection.phases.length > 0
203 ? "all phases complete"
204 : "no phase";
205 const parts = [
206 phase,
207 `${dashboard.projection.openTickets} open`,
208 `${dashboard.projection.inProgressTickets.length} in progress`,
209 `${dashboard.projection.blockedTickets} blocked`,
210 `${dashboard.projection.openIssues} issues`,
211 ];
212 if (busy)
213 parts.push(`reading ${dashboard.queue.length}`);
214 return `Storybloq: ${parts.join(", ")}`;
215}
216/**
217* The band, cut to the terminal. Below the dock width it ends with what width
218* the board needs, but only when the whole summary fits beside it. Reserve
219* context pressure first so a long phase name cannot push it out of view.
220*/
221const SHORT_TERMINAL_ROWS = 24;
222// The scroll host lays out the plugin tree without a bounded parent height,
223// so percentage heights cannot make a flex spacer consume the dock's room.
224function dockHeight(event: any): number {
225 const rows = event.viewport?.rows;
226 const scroll = event.props?.scroll;
227 if (typeof scroll?.bodyRows === "number" && scroll.bodyRows > 0
228 && scroll.contentRows > scroll.bodyRows) return scroll.bodyRows;
229 return Math.max(1, (typeof rows === "number" ? rows : 40) - 6);
230}
231
232function shortTerminal(event: any): boolean {
233 return typeof event.viewport?.rows === "number"
234 && event.viewport.rows > 0 && event.viewport.rows <= SHORT_TERMINAL_ROWS;
235}
236
237function bandText(dashboard: DashboardState, columns: number, narrow: boolean): string {
238 const context = contextLabel(dashboard.contextPercent, Math.min(columns, 20));
239 const room = columns - cellWidth(context) - 2;
240 if (room <= 0)
241 return context;
242 const summary = summaryLine(dashboard);
243 const line = `${truncate(summary, room)} ${context}`;
244 const hint = narrow && cellWidth(line) + cellWidth(BAND_HINT) <= columns ? BAND_HINT : "";
245 return truncate(`${line}${hint}`, columns);
246}
247/** config.json, roadmap.json, the handover names and the session flag. */
248async function readHeader(dashboard: DashboardState, $: any): Promise<void> {
249 try {
250 const configText = await $.fs.read(p(dashboard, CONFIG_PATH));
251 const parsed = JSON.parse(configText) as {
252 project?: unknown;
253 };
254 dashboard.project = typeof parsed.project === "string" ? parsed.project : "";
255 }
256 catch {
257 dashboard.project = "";
258 }
259 try {
260 const roadmapText = await $.fs.read(p(dashboard, ROADMAP_PATH));
261 const parsed = JSON.parse(roadmapText) as {
262 phases?: readonly {
263 id?: unknown;
264 name?: unknown;
265 label?: unknown;
266 }[];
267 };
268 const found: {
269 id: string;
270 name: string;
271 label?: string;
272 }[] = [];
273 for (const phase of parsed.phases ?? []) {
274 if (typeof phase.id === "string") {
275 found.push({ id: phase.id, name: typeof phase.name === "string" ? phase.name : phase.id, label: typeof phase.label === "string" ? phase.label : undefined });
276 }
277 }
278 dashboard.phases = found;
279 }
280 catch {
281 dashboard.phases = [];
282 }
283 try {
284 const entries = await $.fs.list(p(dashboard, HANDOVERS_DIR));
285 dashboard.handoverFilenames = entries
286 .filter((entry: {
287 kind: string;
288 }) => entry.kind === "file")
289 .map((entry: {
290 name: string;
291 }) => entry.name);
292 }
293 catch {
294 dashboard.handoverFilenames = [];
295 }
296 // status.json is the session flag and, since T-531, the stage the session
297 // is in; the ledger numbers do not come from it. Each stage field is kept
298 // only when it is a usable string and is independent of the others, and
299 // none of them is read before the flag is decided, so a malformed field
300 // can neither throw past it nor change it.
301 dashboard.sessionActive = false;
302 forgetSession(dashboard);
303 if (await $.fs.exists(p(dashboard, STATUS_PATH))) {
304 try {
305 const parsed = JSON.parse(await $.fs.read(p(dashboard, STATUS_PATH))) as {
306 sessionActive?: unknown;
307 state?: unknown;
308 ticket?: unknown;
309 claudeStatus?: unknown;
310 observedAt?: unknown;
311 currentIssue?: unknown;
312 };
313 dashboard.sessionActive = parsed.sessionActive === true;
314 dashboard.sessionState = statusField(parsed.state);
315 dashboard.sessionTicket = statusField(parsed.ticket);
316 dashboard.sessionClaudeStatus = statusField(parsed.claudeStatus);
317 dashboard.sessionObservedAt = statusField(parsed.observedAt);
318 // T-532: an ISSUE_FIX session names its issue here, with `ticket` null.
319 const issue = parsed.currentIssue;
320 if (typeof issue === "object" && issue !== null && !Array.isArray(issue)) {
321 const { id, displayId } = issue as { id?: unknown; displayId?: unknown };
322 dashboard.sessionIssueId = statusField(id);
323 dashboard.sessionIssue = statusField(displayId) ?? dashboard.sessionIssueId;
324 }
325 }
326 catch {
327 dashboard.sessionActive = false;
328 }
329 }
330}
331/** T-531: the stage fields back to unset. */
332function forgetSession(dashboard: DashboardState): void {
333 dashboard.sessionState = null;
334 dashboard.sessionTicket = null;
335 dashboard.sessionClaudeStatus = null;
336 dashboard.sessionObservedAt = null;
337 dashboard.sessionIssue = null;
338 dashboard.sessionIssueId = null;
339}
340async function loadCache(dashboard: DashboardState, $: any): Promise<void> {
341 if (dashboard.cacheLoaded)
342 return;
343 dashboard.cacheLoaded = true;
344 try {
345 const stored = await $.store.get(STORE_KEY);
346 if (stored && typeof stored === "object" && !Array.isArray(stored)) {
347 dashboard.cache = stored as Record<string, CachedRecord>;
348 // ISS-1306: a record cached before titles were made safe is served until
349 // its file changes, so its title is cleaned here too.
350 for (const entry of Object.values(dashboard.cache)) {
351 const record = entry?.record as { title?: unknown } | undefined;
352 if (record && typeof record.title === "string") record.title = displaySafe(record.title);
353 }
354 // A cache from an earlier session is enough to draw real numbers while
355 // this session's scan confirms them.
356 dashboard.warm = Object.keys(dashboard.cache).length > 0;
357 }
358 }
359 catch {
360 dashboard.cache = {};
361 }
362}
363async function saveCache(dashboard: DashboardState, $: any): Promise<void> {
364 const text = JSON.stringify(dashboard.cache);
365 if (text.length > STORE_BUDGET_BYTES) {
366 $.ui.log(`storybloq sidebar: the ledger cache is over ${STORE_BUDGET_BYTES} bytes, so it is not kept between sessions`);
367 return;
368 }
369 try {
370 await $.store.set(STORE_KEY, dashboard.cache);
371 }
372 catch {
373 // A store that refuses costs a cold start next session, nothing more.
374 }
375}
376/** One guarded line, once: a failing sidebar must not become a chatty one. */
377function noteFailure(dashboard: DashboardState, $: any, what: string): void {
378 if (dashboard.saidScanFailed)
379 return;
380 dashboard.saidScanFailed = true;
381 try {
382 $.ui.log(`storybloq sidebar: ${what}, so the pane may be behind the ledger until a later turn`);
383 }
384 catch {
385 // A refused log is not worth a second failure.
386 }
387}
388/**
389* ONE timer for the module's life, not one per scan.
390*
391* `$.clock.every` runs until its `cancel()`, and a scan that registered its
392* own would leave it running: two scans, two timers, every later tick paying
393* for both. The callback returns at once unless a scan is actually draining.
394*/
395function startTimer(dashboard: DashboardState, $: any): void {
396 if (dashboard.timerStarted)
397 return;
398 try {
399 $.clock.every(SCAN_TICK_MS, () => {
400 tick(dashboard, $).catch(() => {
401 finalizeScan(dashboard, $, "failed");
402 });
403 });
404 }
405 catch {
406 // A hook beneath may refuse the registration. Marking it started before
407 // it returned would mean no later attempt is ever made, and a scan begun
408 // with no timer builds a queue that nothing drains.
409 noteFailure(dashboard, $, "the scan timer could not be started");
410 return;
411 }
412 dashboard.timerStarted = true;
413}
414/**
415* The one exit from a scan, whichever way it ended.
416*
417* Releasing the in-flight flags and consuming the pending refresh belong
418* together: a failure path that released the flags but left the pending flag
419* set would strand the request, because every later tick returns at once with
420* no scan active and nothing else reads that flag. Consumed exactly once, so
421* a failed scan nobody asked to repeat is not retried on its own.
422*/
423function finalizeScan(dashboard: DashboardState, $: any, outcome: "done" | "failed"): void {
424 dashboard.scanActive = false;
425 dashboard.scanInitializing = false;
426 dashboard.ticking = false;
427 if (outcome === "failed")
428 noteFailure(dashboard, $, "a ledger scan did not finish");
429 if (!dashboard.pendingRefresh)
430 return;
431 dashboard.pendingRefresh = false;
432 requestScan(dashboard, $);
433}
434/**
435* Asks for a scan, coalescing.
436*
437* A refresh asked for while one is in flight is REMEMBERED, not dropped: the
438* queue the running scan is draining was listed before the write that
439* prompted this call, so that write would otherwise never be listed at all.
440* Many requests during one scan collapse into the single scan that follows it.
441*/
442/**
443* Joins a pinned root to one of the ledger suffixes with exactly one
444* separator.
445*
446* The trailing-separator trim deliberately refuses to shorten a bare drive
447* root: on Windows `C:\` trimmed to `C:` stops being absolute and becomes
448* drive-RELATIVE, which would reintroduce the very bug this pins down. `/`
449* has the same shape and is left alone for the same reason.
450*/
451function joinRoot(root: string, suffix: string): string {
452 const bareDriveRoot = /^[A-Za-z]:[/\\]$/.test(root);
453 const trimmed = root.length > 1 && !bareDriveRoot ? root.replace(/[/\\]+$/, "") : root;
454 const separated = trimmed.endsWith("/") || trimmed.endsWith("\\");
455 return separated ? `${trimmed}${suffix}` : `${trimmed}/${suffix}`;
456}
457/**
458* A ledger suffix as an absolute path under the pinned root.
459*
460* Every `$.fs` call that touches the ledger goes through here. The seven
461* suffix constants are left exactly as they are, so the ledger-write detector
462* further down, which matches command TEXT rather than filesystem paths, is
463* untouched by this change.
464*/
465function p(dashboard: DashboardState, suffix: string): string {
466 return dashboard.ledgerRoot === null ? suffix : joinRoot(dashboard.ledgerRoot, suffix);
467}
468/** The parent of a directory, or the directory itself once it is a root. */
469function parentDir(dir: string): string {
470 const cut = Math.max(dir.lastIndexOf("/"), dir.lastIndexOf("\\"));
471 if (cut < 0)
472 return dir;
473 if (cut === 0)
474 return dir.slice(0, 1);
475 const parent = dir.slice(0, cut);
476 return /^[A-Za-z]:$/.test(parent) ? `${parent}\\` : parent;
477}
478/** An errno off a rejected `$.fs` call, when the host supplied one. */
479function errnoOf(error: unknown): string {
480 const code = (error as {
481 code?: unknown;
482 } | null)?.code;
483 return typeof code === "string" ? code : "";
484}
485/** A missing path, as opposed to one the host refused to answer for. */
486function isMissing(error: unknown): boolean {
487 return errnoOf(error) === "ENOENT";
488}
489/** How far up the walk will look before calling the question unanswerable. */
490const MAX_ROOT_WALK = 64;
491type RootResolution = {
492 readonly kind: "pinned";
493 readonly root: string;
494} | {
495 readonly kind: "absent";
496} | {
497 readonly kind: "unresolved";
498 readonly reason: string;
499};
500/**
501* Walks up from `start` for the nearest directory holding a `.story/`.
502*
503* Three ANSWERS, and keeping them apart is the point. "pinned" is a root.
504* "absent" is a clean walk that reached the filesystem root without finding
505* one, which is a project that never ran `storybloq init` and is not a
506* failure. "unresolved" is the host refusing the question, which is a failure
507* and must never be mistaken for the second.
508*
509* A ledger is `.story/config.json`, the CLI's own rule (`checkRoot` in
510* src/core/project-root-shared.ts), not the bare directory (ISS-1256). A
511* fresh `storybloq init` writes the config, so it still counts; a `.story/`
512* with no config is a miss and the walk goes on past it. The owner's home
513* directory holds one such stray (`~/.story/sessions/` from May, nothing
514* else), and the bare-directory test pinned every ledgerless project under
515* the home directory to it and drew an all-zero board there.
516*
517* The walk stops when a directory is its own parent, so a filesystem root
518* cannot loop, and is bounded anyway: a bound that is never reached costs
519* nothing and a walk that never ends costs the session.
520*/
521async function resolveLedgerRoot(dashboard: DashboardState, $: any, start: string): Promise<RootResolution> {
522 // A trailing separator would make the first step ask about `/repo//.story`
523 // and the second, after `parentDir` trims it, ask about the same directory
524 // again. Same shape as `joinRoot`: a bare drive root keeps its separator,
525 // because without it it stops being absolute.
526 const bareDriveRoot = /^[A-Za-z]:[/\\]$/.test(start);
527 let dir = start.length > 1 && !bareDriveRoot ? start.replace(/[/\\]+$/, "") : start;
528 for (let step = 0; step < MAX_ROOT_WALK; step += 1) {
529 try {
530 if ((await $.fs.exists(joinRoot(dir, CONFIG_PATH))) !== false) {
531 return { kind: "pinned", root: dir };
532 }
533 }
534 catch (error) {
535 const code = errnoOf(error);
536 return { kind: "unresolved", reason: code === "" ? "the client refused the read" : code };
537 }
538 const parent = parentDir(dir);
539 if (parent === dir)
540 return { kind: "absent" };
541 dir = parent;
542 }
543 return { kind: "absent" };
544}
545/**
546* Opens the pane and reads the ledger: everything `session.start` does once
547* it knows there is something to draw.
548*
549* Also the recovery path. A project that had no `.story/` when the session
550* started gets one the moment someone runs `storybloq init`, and the pane has
551* to appear then rather than at the next reload, so `turn.complete` and a
552* ledger-writing `tool.call` both come back through here.
553*/
554async function attach(dashboard: DashboardState, $: any): Promise<void> {
555 if (!dashboard.paneOpen) {
556 // T-519: the pane's background is the client's. As of the 2.1.273 d.ts,
557 // `PaneOpenArgs` is { id, title, focus, closeOnEscape, holdToasts, rows }
558 // and the `Pane` props are read-only placement data: nothing names a
559 // theme or background. `Box`/`Text` take `backgroundColor`, but that
560 // fixes a colour rather than following the terminal, so none is set and
561 // the tones below are chosen for the client's own pane background.
562 await $.ui.open({ id: PANE_ID, title: PANE_TITLE });
563 dashboard.paneOpen = true;
564 }
565 // The context figures belong to the window, not to the turn: they are
566 // readable the moment the Mod loads into a session that has already had a
567 // response. Reading them only on `turn.complete` is why the owner's header
568 // was blank after a reload, with the fill only appearing a turn later.
569 const read = await refreshContext(dashboard, $);
570 if (read !== null) {
571 dashboard.contextPercent = read.value;
572 dashboard.motion.setContext(read.value, true);
573 }
574 dashboard.themeLight = await readThemeLight(dashboard, $);
575 await readHeader(dashboard, $);
576 await loadCache(dashboard, $);
577 // The idle poll's baseline (T-517). Taken before the timer starts and before
578 // the scan below, so the first poll compares against the ledger as it was
579 // when this session read it: a write between the two is a change, not a
580 // missed one. Taken after the timer, a tick could poll against an empty
581 // baseline, find every path "moved" and rescan for nothing.
582 dashboard.polledMtimes = await ledgerMtimes(dashboard, $);
583 startTimer(dashboard, $);
584 requestScan(dashboard, $);
585}
586/**
587* The ledger was missing; is it there now? Attaches if it is.
588*
589* Nothing is drawn while it is absent, so this is the only way back: every
590* refresh the Mod already made asks the question again, and the answer costs
591* one `$.fs.exists` on a project that has no ledger to read anyway.
592*/
593async function attachIfLedgerArrived(dashboard: DashboardState, $: any): Promise<void> {
594 // From the ORIGIN, never from the live working directory: the session may
595 // have cd-ed anywhere by now, and resolving from there would either miss
596 // the project's ledger or attach to an unrelated nested one.
597 if (dashboard.initialCwd === null)
598 return;
599 const resolution = await resolveLedgerRoot(dashboard, $, dashboard.initialCwd);
600 if (resolution.kind !== "pinned")
601 return;
602 dashboard.ledgerRoot = resolution.root;
603 dashboard.noLedger = false;
604 await attach(dashboard, $);
605}
606function requestScan(dashboard: DashboardState, $: any): void {
607 if (dashboard.scanActive || dashboard.scanInitializing) {
608 dashboard.pendingRefresh = true;
609 return;
610 }
611 // The timer may still be missing because an earlier registration was
612 // refused. Without it a queue would be built that nothing drains, so try
613 // again here and start no scan while it is absent.
614 startTimer(dashboard, $);
615 if (!dashboard.timerStarted)
616 return;
617 beginScan(dashboard, $).catch(() => {
618 // The scan is detached, so nothing else would hear this.
619 finalizeScan(dashboard, $, "failed");
620 });
621}
622/** Lists the ledger and leaves a queue for the ticker to drain. */
623async function beginScan(dashboard: DashboardState, $: any): Promise<void> {
624 dashboard.scanInitializing = true;
625 try {
626 const items: ScanItem[] = [];
627 // ISS-1239: a directory that is MISSING and one the host refused to read
628 // are different facts, and the purge below may only act on the first.
629 // `$.fs` rejects with the OS errno, so they are separable.
630 let refused = false;
631 try {
632 for (const entry of await $.fs.list(p(dashboard, TICKETS_DIR))) {
633 if (entry.kind === "file" && entry.name.endsWith(".json")) {
634 items.push({ path: `${TICKETS_DIR}/${entry.name}`, kind: "ticket" });
635 }
636 }
637 }
638 catch (error) {
639 // No tickets directory: nothing to read from it.
640 if (!isMissing(error))
641 refused = true;
642 }
643 try {
644 for (const entry of await $.fs.list(p(dashboard, ISSUES_DIR))) {
645 if (entry.kind === "file" && entry.name.endsWith(".json")) {
646 items.push({ path: `${ISSUES_DIR}/${entry.name}`, kind: "issue" });
647 }
648 }
649 }
650 catch (error) {
651 // Same.
652 if (!isMissing(error))
653 refused = true;
654 }
655 // A file the ledger no longer has must leave the cache, or a deleted
656 // ticket would keep being counted.
657 //
658 // Skipped on a refusal. Purging then would turn "I could not read this"
659 // into "this was deleted" and empty the board on a transient failure,
660 // which is this issue's bug wearing a different hat. Keeping a stale
661 // record costs a board that is briefly behind; purging costs the board.
662 // An empty scan that really is an empty ledger still clears, because that
663 // path throws ENOENT and leaves `refused` false.
664 if (!refused) {
665 const present = new Set(items.map((item) => item.path));
666 for (const path of Object.keys(dashboard.cache)) {
667 if (!present.has(path))
668 delete dashboard.cache[path];
669 }
670 }
671 dashboard.queue = items;
672 dashboard.scanActive = true;
673 reproject(dashboard);
674 $.ui.invalidate("ui.render");
675 }
676 finally {
677 dashboard.scanInitializing = false;
678 }
679}
680/**
681* What one turn of the module's single timer does: drain an active scan, and
682* once every IDLE_POLL_TICKS look for a write nothing told this session about
683* (T-517).
684*
685* The counter lives here rather than in a second `$.clock.every`, because a
686* second timer would be a second dispatch on every 25 ms tick for the life of
687* the session, and the poll is a two-second thing.
688*/
689async function tick(dashboard: DashboardState, $: any): Promise<void> {
690 // Share the existing clock. Redraw only during the short, visible intro,
691 // at 20 fps. Later effects use this same timer and stop redrawing when idle.
692 if (dashboard.logoStarted && !dashboard.logoFinished) {
693 if (!dashboard.paneOpen || !dashboard.sidebarEnabled)
694 dashboard.logoFinished = true;
695 else {
696 dashboard.logoElapsed += SCAN_TICK_MS;
697 dashboard.logoTicks += 1;
698 if (dashboard.logoElapsed >= LOGO_DURATION_MS)
699 dashboard.logoFinished = true;
700 if (dashboard.logoTicks % 2 === 0 || dashboard.logoFinished)
701 $.ui.invalidate("ui.render");
702 }
703 }
704 if (dashboard.motion.advance(SCAN_TICK_MS) && dashboard.sidebarEnabled && ((dashboard.paneOpen && dashboard.paneDrawn) || dashboard.bandDrawn))
705 $.ui.invalidate("ui.render");
706 await drainChunk(dashboard, $);
707 dashboard.idleTicks += 1;
708 if (dashboard.idleTicks < IDLE_POLL_TICKS)
709 return;
710 dashboard.idleTicks = 0;
711 try {
712 await pollLedger(dashboard, $);
713 }
714 catch {
715 // The timer's catch finalizes the ACTIVE scan as failed, which a poll that
716 // could not stat or read the header has no business doing: the scan is
717 // unrelated to it. A failed poll costs nothing and runs again in two
718 // seconds.
719 }
720}
721/**
722* The four directory mtimes the idle poll watches.
723*
724* Directories and not files: every CLI and MCP write lands by rename or link
725* INTO a directory (project-loader's atomicWrite and atomicCreate), so a
726* create, a delete and a replace all move the directory's own mtime, and four
727* stats stand in for a walk of a couple of thousand files. `.story` itself is
728* where roadmap.json, config.json and status.json land. An in-place edit by
729* an editor moves no directory, and that case is what the turn-end rescan is
730* still for.
731*
732* A path that cannot be stat-ed reads 0, so one that appears later moves.
733*/
734const POLLED_PATHS = [LEDGER_DIR, TICKETS_DIR, ISSUES_DIR, HANDOVERS_DIR] as const;
735async function ledgerMtimes(dashboard: DashboardState, $: any): Promise<Record<string, number>> {
736 const seen: Record<string, number> = {};
737 for (const path of POLLED_PATHS) {
738 try {
739 const stat = await $.fs.stat(p(dashboard, path));
740 seen[path] = typeof stat?.mtimeMs === "number" ? stat.mtimeMs : 0;
741 }
742 catch {
743 seen[path] = 0;
744 }
745 }
746 return seen;
747}
748/**
749* Has anything moved since the last look? If so, refresh.
750*
751* Deliberately NOT gated on `paneOpen`, for the same reason the AbovePrompt
752* line is not: below the client's dock width there is no pane and that line
753* IS the sidebar, so a poll tied to the pane would leave the only thing drawn
754* standing still.
755*
756* Deliberately NOT skipped while a scan is draining either: the running scan
757* took its worklist before this write existed, so it will not see it. Asking
758* mid-scan is what `requestScan`'s pending flag is for, and the rescan
759* follows the one in flight instead of being dropped.
760*
761* Never while `noLedger`: a project with no `.story/` draws nothing at all,
762* and `turn.complete` and a ledger-writing tool call already carry the one
763* question worth asking there (has a ledger arrived?).
764*/
765async function pollLedger(dashboard: DashboardState, $: any): Promise<void> {
766 if (!dashboard.uiAvailable || !dashboard.sidebarEnabled || dashboard.noLedger)
767 return;
768 const seen = await ledgerMtimes(dashboard, $);
769 let moved = false;
770 for (const path of POLLED_PATHS) {
771 if (dashboard.polledMtimes[path] !== seen[path])
772 moved = true;
773 }
774 // The whole of the idle cost: four stats and this comparison. Dropping it
775 // is M-POLL-ALWAYS-RESCANS, which re-reads the ledger every two seconds
776 // whether or not anyone wrote it.
777 if (!moved)
778 return;
779 dashboard.polledMtimes = seen;
780 // The header files (roadmap, config, status, the handover names) land in
781 // `.story` itself, and a scan does not re-read them, so this mirrors what
782 // `turn.complete` does. It runs only when something actually moved.
783 await readHeader(dashboard, $);
784 requestScan(dashboard, $);
785}
786/**
787* One tick: up to SCAN_CHUNK files, each stat-ed and re-read only when its
788* mtime moved. The mtime check is the whole of "updates within one prompt";
789* serving the cached fields without it is the M-STALE-CACHE mutant.
790*/
791async function drainChunk(dashboard: DashboardState, $: any): Promise<void> {
792 // The idle guard. Without it every tick after the first scan reprojects the
793 // whole ledger, serializes it, writes it to the store and invalidates, for
794 // as long as the session lasts.
795 if (dashboard.ticking || !dashboard.scanActive)
796 return;
797 dashboard.ticking = true;
798 let outcome: "done" | "failed" | null = null;
799 try {
800 let read = 0;
801 while (dashboard.queue.length > 0 && read < SCAN_CHUNK) {
802 const item = dashboard.queue.shift()!;
803 read += 1;
804 try {
805 const stat = await $.fs.stat(p(dashboard, item.path));
806 const cached = dashboard.cache[item.path];
807 if (cached && cached.mtimeMs === stat.mtimeMs)
808 continue;
809 const record = extractRecord(item.kind, await $.fs.read(p(dashboard, item.path)));
810 if (record === null)
811 delete dashboard.cache[item.path];
812 else
813 dashboard.cache[item.path] = { mtimeMs: stat.mtimeMs, record };
814 }
815 catch (error) {
816 // Keep the last valid record on transient failures; retry next scan.
817 if (isMissing(error))
818 delete dashboard.cache[item.path];
819 else
820 noteFailure(dashboard, $, "a ledger record could not be read");
821 }
822 }
823 if (dashboard.queue.length === 0) {
824 dashboard.warm = true;
825 reproject(dashboard, true);
826 await saveCache(dashboard, $);
827 $.ui.invalidate("ui.render");
828 outcome = "done";
829 }
830 }
831 catch {
832 // Whatever failed, this scan is over. Which of the two it was changes
833 // only the log line: both leave through the same door.
834 outcome = "failed";
835 }
836 if (outcome === null) {
837 dashboard.ticking = false;
838 return;
839 }
840 finalizeScan(dashboard, $, outcome);
841}
842/**
843* The context fill from what `$.session.usage()` actually answers.
844*
845* `SessionContextUsage` carries `window` always, `tokens` and `percent` only
846* "from the first API response of the live window": a fresh session or one
847* just compacted has neither until its next response. Live, the owner's
848* header stayed empty because this read `percent` alone, so the percent is
849* computed from `tokens` whenever they exist, the engine's `percent` stands
850* in only when they do not, and null (show an unknown reading) when there is no
851* reading at all. `compactWindow` is the settings' auto-compact window, or
852* null for the model's own.
853*/
854/** Whether a theme name is a light one: `light`, `light-daltonized`, `light-ansi`. */
855function isLightTheme(value: unknown): boolean {
856 return typeof value === "string" && value.startsWith("light");
857}
858/**
859* The client's `theme` row, as `$.config.list()` answers it (ISS-1238). A
860* refused or unexpected answer keeps the dark default, never the board.
861*/
862async function readThemeLight(dashboard: DashboardState, $: any): Promise<boolean> {
863 try {
864 const rows = await $.config.list();
865 const row = Array.isArray(rows) ? rows.find((r: any) => r?.key === "theme") : undefined;
866 return isLightTheme(row?.value);
867 }
868 catch {
869 return false;
870 }
871}
872/**
873* The context fill, or null, and never a rejection.
874*
875* The fill is telemetry on the header's right, and `$.session.usage` is a call
876* a host may not have or may refuse. Unguarded in `session.start` it rejects
877* AFTER the pane is opened, which takes the ledger read, the board and
878* `next(e)` with it: a missing figure would cost the whole sidebar. One helper
879* for all three call sites so the shape cannot drift between them.
880*/
881async function readContextFill($: any): Promise<number | null> {
882 try {
883 // Summary is local-only. The session's own threshold survives settings
884 // edits and plugin reloads; reading settings here would change history.
885 const usage = await $.session.usage({ breakdown: "summary" });
886 const threshold = usage?.context?.breakdown?.autoCompactThreshold;
887 const tokens = usage?.context?.tokens;
888 if (typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0
889 && typeof tokens === "number" && Number.isFinite(tokens) && tokens >= 0) {
890 return Math.min(100, Math.round(tokens / threshold * 100));
891 }
892 // Native window percent is a different measure. An unavailable or
893 // disabled compaction threshold must not masquerade as pressure.
894 return null;
895 }
896 catch {
897 return null;
898 }
899}
900/**
901* T-532: a context read, and whether it is still the newest one. Every read
902* takes the next sequence number and a session start takes one too, so a slow
903* read that a later read or a restart has overtaken comes back null and is
904* dropped: a tool call's figure from before a compaction must not replace the
905* compaction's own.
906*/
907async function refreshContext(dashboard: DashboardState, $: any): Promise<{ value: number | null } | null> {
908 const seq = ++dashboard.contextSeq;
909 const value = await readContextFill($);
910 return seq === dashboard.contextSeq ? { value } : null;
911}
912export function registerSidebar(on: On, _options: Options): void {
913 const dashboard = createDashboardState();
914 forgetEverything(dashboard);
915 dashboard.motion = new DashboardMotion(_options["motion"] !== false, _options["changeHighlights"] !== false);
916 // The pane. `ui.render` fires once per input value and again on
917 // `$.ui.invalidate("ui.render")`, so this hook only draws what the refresh
918 // hooks have already computed: it awaits nothing.
919 (on("ui.render", ($: any, e: any, next: (e: any) => unknown) => {
920 // Nothing is drawn without a ledger, pane or band: there is no pane open
921 // to render into, and the band's line would be the empty board in one row.
922 if (dashboard.noLedger)
923 return next(e);
924 if (e.component === "Pane" && e.requestId === PANE_ID) {
925 if (!dashboard.paneDrawn) {
926 // The band may have drawn on this same pass believing the pane
927 // parked (a reload runs `register` fresh, so this flag starts false
928 // while the client keeps the pane up). One redraw and it stands down.
929 dashboard.paneDrawn = true;
930 dashboard.reopenAsked = false;
931 $.ui.invalidate("ui.render");
932 }
933 const elements = $.ui.resolve(e);
934 const { Box, Text } = elements;
935 const width: number = typeof e.props?.bodyColumns === "number" ? e.props.bodyColumns : 40;
936 // The header, then one blank row: the break the owner asked for, so the
937 // wordmark does not read as part of the first column heading. A single
938 // space and not an empty string, because an empty Text collapses to no
939 // row at all in this client and the break simply did not draw.
940 if (shortTerminal(e) && panePlacement(e) !== "dock") {
941 dashboard.logoFinished = true;
942 return compactLineNode(dashboard, elements, width);
943 }
944 const placement = panePlacement(e);
945 dashboard.paneInline = placement === "inline";
946 const layout = boardLayout(placement, width);
947 const stacked = layout === "stacked";
948 // The narrow board is at most five rows and skips the blank rows, so it
949 // needs no budget: it is the inline strip on a small window, where
950 // every row is paid for.
951 const budget = layout === "narrow" ? { body: 0, gaps: false, compact: false } : rowBudget(e, stacked);
952 // Use the actual pane window: viewport.rows includes the transcript
953 // and prompt and is never the available height of an inline pane.
954 const bodyRows = e.props?.scroll?.bodyRows;
955 const intro = typeof bodyRows === "number" && bodyRows <= 1 ? null
956 : logoLayout(width, typeof bodyRows === "number" ? bodyRows - 1 : undefined, placement);
957 if (!shortTerminal(e) && dashboard.motion.enabled && _options["startupLogo"] !== false && !dashboard.logoFinished && intro) {
958 dashboard.logoStarted = true;
959 const art = logoFrame(intro.columns, dashboard.logoElapsed);
960 return Box({ flexDirection: "column", children: [
961 headerNode(dashboard, elements),
962 ...Array.from({ length: intro.top }, (_, index) => Text({ key: `logo-space-${index}`, children: " " })),
963 ...art.map((cells, index) => Text({ key: `storyfield-${index}`, wrap: "truncate", children: [
964 Text({ key: "inset", children: " ".repeat(intro.left) }),
965 ...cells.map((cell, x) => Text({ key: `dot-${x}`, color: cell.color, children: cell.glyph })),
966 ] })),
967 contextNode(dashboard, elements, dashboard.contextPercent, width),
968 ] });
969 }
970 if (placement === "dock" && shortTerminal(e)) {
971 const body = Math.max(1, Math.min(7, dockHeight(e) - 7));
972 return Box({ key: "short-sidebar", height: dockHeight(e), flexDirection: "column", children: [
973 headerNode(dashboard, elements),
974 Text({ children: " " }),
975 inProgressBoard(dashboard, elements, width, body),
976 Box({ key: "footer-space", flexGrow: 1 }),
977 footerNode(dashboard, elements, dashboard.projection?.issuesBySeverity ?? { critical: 0, high: 0, medium: 0, low: 0 }, dashboard.contextPercent, width),
978 ] });
979 }
980 const rows: unknown[] = [headerNode(dashboard, elements)];
981 if (budget.gaps)
982 rows.push(paneText(dashboard, Text, { key: "header-gap", children: " " }));
983 if (dashboard.projection === null) {
984 // Nothing to draw a board from yet: the one line that says why.
985 rows.push(paneText(dashboard, Text, { children: truncate(summaryLine(dashboard), width) }));
986 rows.push(contextNode(dashboard, elements, dashboard.contextPercent, width));
987 }
988 else if (layout === "narrow") {
989 rows.push(narrowBoard(dashboard, elements, dashboard.projection.board, width));
990 rows.push(Box({ key: "footer", flexDirection: "row", justifyContent: "flex-end", children: [contextNode(dashboard, elements, dashboard.contextPercent, width)] }));
991 }
992 else {
993 rows.push(budget.compact
994 ? compactBoard(dashboard, elements, dashboard.projection.board, width)
995 : boardNode(dashboard, elements, dashboard.projection.board, width, stacked, budget.body));
996 if (budget.gaps)
997 rows.push(paneText(dashboard, Text, { key: "issues-gap", children: " " }));
998 if (placement === "dock") rows.push(Box({ key: "footer-space", flexGrow: 1 }));
999 rows.push(footerNode(dashboard, elements, dashboard.projection.issuesBySeverity, dashboard.contextPercent, width));
1000 }
1001 return Box({ flexDirection: "column", ...(placement === "dock" ? { height: dockHeight(e) } : {}), children: rows });
1002 }
1003 // The narrow fallback: the client leaves a plugin's pane undrawn on a
1004 // small terminal, so the same numbers go out as one line above the prompt.
1005 // Gated on the Mod being on, and deliberately NOT on the pane being OPEN:
1006 // below DOCK_COLUMNS the client draws no pane at all, so this line IS the
1007 // sidebar, and tying it to `paneOpen` would let a close of something never
1008 // drawn turn off the only thing that was. It IS tied to the pane having
1009 // been DRAWN: a pane opened narrow stays parked after a resize (ISS-1235),
1010 // and a wide terminal with a parked pane still has to show the numbers.
1011 if (e.component === "AbovePrompt" && dashboard.sidebarEnabled) {
1012 // Also catches a Mod reloaded during an already-running main turn.
1013 if (!e.props?.view?.agentId && typeof e.props?.isWorking === "boolean")
1014 dashboard.motion.setWorking(e.props.isWorking);
1015 dashboard.bandDrawn = false;
1016 const columns: number = typeof e.viewport?.columns === "number" ? e.viewport.columns : 0;
1017 const narrow = columns < DOCK_COLUMNS;
1018 if (shortTerminal(e)) {
1019 if (dashboard.paneDrawn) return next(e);
1020 dashboard.bandDrawn = true;
1021 const elements = $.ui.resolve(e);
1022 return compactLineNode(dashboard, elements, columns);
1023 }
1024 if (narrow) {
1025 dashboard.reopenAsked = false;
1026 }
1027 else if (dashboard.paneOpen && !dashboard.paneDrawn && !dashboard.reopenAsked) {
1028 // Wide, open, parked: ask the client to place it again. The width is
1029 // judged at each open (ISS-1235), so this is what a narrow-then-wide
1030 // session needs; once per crossing, and never for a pane the person
1031 // closed (`paneOpen` is false then). A refusal costs nothing: the
1032 // band below is still drawn on this pass.
1033 // Not awaited: a render never waits on an open, and the hook is sync.
1034 dashboard.reopenAsked = true;
1035 Promise.resolve()
1036 .then(() => {
1037 // Re-checked on the microtask: a close or a draw that landed in
1038 // between makes the ask stale, and a closed pane must stay closed.
1039 if (!dashboard.paneOpen || dashboard.paneDrawn)
1040 return;
1041 return $.ui.open({ id: PANE_ID, title: PANE_TITLE });
1042 })
1043 .catch(() => {
1044 // The band stands in; the next crossing asks again.
1045 });
1046 }
1047 if (columns > 0 && (narrow || !dashboard.paneDrawn)) {
1048 dashboard.bandDrawn = true;
1049 const { Text } = $.ui.resolve(e);
1050 // The hint only while there is no board on screen: a placed pane keeps
1051 // its seat when the window shrinks (inline, ISS-1247), and the band
1052 // under it carries the counts the narrow board leaves out (ISS-1252).
1053 return Text({ dimColor: true, children: bandText(dashboard, columns, narrow && !dashboard.paneDrawn) });
1054 }
1055 }
1056 return next(e);
1057 // A client without the UI events refuses this registration rather than
1058 // throwing at the call site; that is the "renders nothing" case, and the
1059 // scan admits `.catch` here and nothing else.
1060 }) as {
1061 catch: (fn: (error: unknown) => void) => void;
1062 }).catch(() => {
1063 dashboard.uiAvailable = false;
1064 });
1065 on("session.start", async ($: any, e: any, next: (e: any) => unknown) => {
1066 // T-532: first, before anything can return or wait. A reload fires this
1067 // again without re-registering: the tool-call throttle starts over, and
1068 // any context read still in flight from before is overtaken.
1069 dashboard.contextReadAt = null;
1070 dashboard.contextSeq += 1;
1071 if (!dashboard.uiAvailable) {
1072 if (!dashboard.saidNoUi) {
1073 dashboard.saidNoUi = true;
1074 $.ui.log("storybloq sidebar: this client has no UI render events, so the pane is not drawn");
1075 }
1076 return next(e);
1077 }
1078 // A `-p` run and the SDK draw nowhere: `surface` is null and nobody is at
1079 // the prompt, so there is no pane to open and no ledger worth reading for
1080 // a sidebar nobody will see.
1081 if (e.surface === null || e.isInteractive !== true)
1082 return next(e);
1083 // On, whatever happens next: the refresh hooks stay armed so the pane can
1084 // appear the moment a ledger does.
1085 dashboard.sidebarEnabled = true;
1086 dashboard.motion = new DashboardMotion(_options["motion"] !== false, _options["changeHighlights"] !== false);
1087 // ISS-1239: pin the root for the session, here and nowhere else. A reload
1088 // fires this event again and re-pins against the new directory, which is
1089 // wanted; `turn.complete` and `tool.call` must never re-resolve, which is
1090 // why neither of them touches these three.
1091 dashboard.ledgerRoot = null;
1092 // Said-once flags are per SESSION START, not per module load: a reload
1093 // fires this event again without re-registering, and leaving them set
1094 // would silently swallow the diagnostic the second time around.
1095 dashboard.saidNoLedger = false;
1096 dashboard.saidRootUnresolved = false;
1097 dashboard.initialCwd = typeof e.cwd === "string" && e.cwd.length > 0 ? e.cwd : null;
1098 if (dashboard.initialCwd === null) {
1099 // No absolute origin to address from. Falling back to relative paths
1100 // here is precisely the defect, so the Mod stays closed and says why
1101 // rather than drawing a board that empties on the first `cd`.
1102 dashboard.noLedger = true;
1103 if (!dashboard.saidNoLedger) {
1104 dashboard.saidNoLedger = true;
1105 $.ui.log("storybloq sidebar: this session start carried no working directory, so the pane stays closed");
1106 }
1107 return next(e);
1108 }
1109 const resolution = await resolveLedgerRoot(dashboard, $, dashboard.initialCwd);
1110 if (resolution.kind === "absent") {
1111 // No `.story/` means no pane, by the owner's ruling. A project that
1112 // never ran `storybloq init` was getting four bordered "none" columns
1113 // and an all-zero issues line, which is a dashboard reporting on
1114 // nothing; the Mod hides instead. T-537: it says how to set the
1115 // project up, once per session and root: a reload from the same root
1116 // stays quiet, a different root is told once. The command is the
1117 // Claude Code profile's, since this Mod runs only in Claude Code's
1118 // hooks runtime. The pane attaches when init finishes, through
1119 // `attachIfLedgerArrived`.
1120 dashboard.noLedger = true;
1121 if (!dashboard.setupHintRoots.has(dashboard.initialCwd)) {
1122 dashboard.setupHintRoots.add(dashboard.initialCwd);
1123 $.ui.log(`storybloq sidebar: no .story directory here, so the pane stays closed. ${SETUP_HINT}`);
1124 }
1125 return next(e);
1126 }
1127 if (resolution.kind === "unresolved") {
1128 // The host refused the walk, so there is no root to address from. An
1129 // earlier draft pinned the origin as a guess and carried on; that is
1130 // wrong, because `attachIfLedgerArrived` only runs while `noLedger` is
1131 // set, so guessing would lock the session to a possibly-wrong root for
1132 // good and disable its own recovery. Hiding costs one board until the
1133 // refusal lifts; the retry loop then re-walks and pins properly. What
1134 // is never done either way is falling back to relative addressing.
1135 dashboard.noLedger = true;
1136 if (!dashboard.saidRootUnresolved) {
1137 dashboard.saidRootUnresolved = true;
1138 $.ui.log(`storybloq sidebar: could not resolve the ledger root (${resolution.reason}), so the pane stays closed until it can be read`);
1139 }
1140 return next(e);
1141 }
1142 dashboard.ledgerRoot = resolution.root;
1143 dashboard.noLedger = false;
1144 // `session.start` fires again on a reload, and an open of an open id only
1145 // retitles it, but asking twice is still asking twice: `attach` asks once.
1146 await attach(dashboard, $);
1147 return next(e);
1148 });
1149 on("turn.start", ($: any, e: any, next: (e: any) => unknown) => {
1150 if (dashboard.uiAvailable && dashboard.sidebarEnabled && !dashboard.noLedger) {
1151 dashboard.motion.setWorking(true, e.turnId);
1152 $.ui.invalidate("ui.render");
1153 }
1154 return next(e);
1155 });
1156 // A turn is the unit the acceptance names: a `.story/` write during it shows
1157 // up by the next prompt.
1158 on("turn.complete", async ($: any, e: any, next: (e: any) => unknown) => {
1159 if (!dashboard.uiAvailable || !dashboard.sidebarEnabled)
1160 return next(e);
1161 dashboard.motion.finishTurn(e.turnId, e.agentId);
1162 $.ui.invalidate("ui.render");
1163 if (dashboard.noLedger) {
1164 await attachIfLedgerArrived(dashboard, $);
1165 return next(e);
1166 }
1167 const read = await refreshContext(dashboard, $);
1168 if (read !== null) {
1169 dashboard.contextPercent = read.value;
1170 dashboard.motion.setContext(read.value);
1171 }
1172 await readHeader(dashboard, $);
1173 requestScan(dashboard, $);
1174 return next(e);
1175 });
1176 // A ledger write inside a turn has to show up inside that turn: the owner
1177 // moved a ticket to in progress, waited five seconds and moved it back, and
1178 // the board sat on the old column the whole time because nothing asked for
1179 // a rescan until the turn ended. So this runs the call first and then, only
1180 // for a call that can have written `.story/`, requests a scan; the scan is
1181 // mtime-keyed, so the cost of one that changed nothing is a stat sweep.
1182 //
1183 // Filtered here rather than by an `on()` matcher on the tool name: a
1184 // matcher prints as `tool.call{tool=/.../}` in the client's scan, and the
1185 // contract test compares that list against the bare event names pinned in
1186 // client-api.ts, which is not this Mod's file to change.
1187 on("tool.call", async ($: any, e: any, next: (e: any) => unknown) => {
1188 const result = await next(e);
1189 if (dashboard.uiAvailable && dashboard.sidebarEnabled && wroteLedger(e)) {
1190 // `storybloq init` is a ledger write like any other, and it is the one
1191 // that turns a hidden Mod into a drawn one, so the no-ledger case goes
1192 // through the same filter rather than waiting for the turn to end.
1193 if (dashboard.noLedger)
1194 await attachIfLedgerArrived(dashboard, $);
1195 else
1196 requestScan(dashboard, $);
1197 }
1198 // T-532: the gauge inside a turn. A long turn otherwise sat on the figure
1199 // from its start: the owner's board read 99% while the client had long
1200 // since moved. At most one read per CONTEXT_REFRESH_MS of tool calls,hooks/dashboard-state.ts 131 lines1import { DashboardMotion } from "./dashboard-motion.js";
2import type { SidebarProjection, SidebarRecord } from "./sidebar-projection.js";
3export interface CachedRecord {
4 readonly mtimeMs: number;
5 readonly record: SidebarRecord;
6}
7export interface ScanItem {
8 readonly path: string;
9 readonly kind: "ticket" | "issue";
10}
11/** Owned by one registration; host callbacks retain their own instance. */
12export interface DashboardState {
13 cache: Record<string, CachedRecord>;
14 cacheLoaded: boolean;
15 projection: SidebarProjection | null;
16 project: string;
17 phases: {
18 readonly id: string;
19 readonly name: string;
20 readonly label?: string;
21 }[];
22 handoverFilenames: string[];
23 queue: ScanItem[];
24 idleTicks: number;
25 polledMtimes: Record<string, number>;
26 scanInitializing: boolean;
27 scanActive: boolean;
28 pendingRefresh: boolean;
29 ticking: boolean;
30 timerStarted: boolean;
31 logoElapsed: number;
32 logoStarted: boolean;
33 logoFinished: boolean;
34 logoTicks: number;
35 motion: DashboardMotion;
36 bandDrawn: boolean;
37 sidebarEnabled: boolean;
38 paneOpen: boolean;
39 paneDrawn: boolean;
40 reopenAsked: boolean;
41 themeLight: boolean;
42 paneInline: boolean;
43 sessionActive: boolean;
44 /** T-531: status.json's `state`, `ticket`, `claudeStatus` and `observedAt`; null when absent or malformed. */
45 sessionState: string | null;
46 sessionTicket: string | null;
47 sessionClaudeStatus: string | null;
48 sessionObservedAt: string | null;
49 /**
50 * T-532: status.json's `currentIssue`, which ISSUE_FIX carries in place of
51 * `ticket`. `sessionIssue` is its display id, falling back to its id;
52 * `sessionIssueId` is the id alone. Null when absent or malformed.
53 */
54 sessionIssue: string | null;
55 sessionIssueId: string | null;
56 contextPercent: number | null;
57 /** T-532: when a tool call last read the context fill; null before the first. */
58 contextReadAt: number | null;
59 /**
60 * T-532: bumped by every context read and every session start. A read's
61 * result is applied only while its number is still the latest. Monotonic:
62 * never reset.
63 */
64 contextSeq: number;
65 warm: boolean;
66 uiAvailable: boolean;
67 noLedger: boolean;
68 saidNoUi: boolean;
69 saidNoLedger: boolean;
70 saidScanFailed: boolean;
71 saidRootUnresolved: boolean;
72 /**
73 * T-537: the origins the setup hint has already been given for. Once per
74 * session and root, so unlike the said-once flags it is NOT cleared by a
75 * reload's session start: the same root stays quiet, a new one speaks once.
76 */
77 setupHintRoots: Set<string>;
78 initialCwd: string | null;
79 ledgerRoot: string | null;
80}
81export function createDashboardState(): DashboardState {
82 return {
83 cache: {},
84 cacheLoaded: false,
85 projection: null,
86 project: "",
87 phases: [],
88 handoverFilenames: [],
89 queue: [],
90 idleTicks: 0,
91 polledMtimes: {},
92 scanInitializing: false,
93 scanActive: false,
94 pendingRefresh: false,
95 ticking: false,
96 timerStarted: false,
97 logoElapsed: 0,
98 logoStarted: false,
99 logoFinished: false,
100 logoTicks: 0,
101 motion: new DashboardMotion(),
102 bandDrawn: false,
103 sidebarEnabled: false,
104 paneOpen: false,
105 paneDrawn: false,
106 reopenAsked: false,
107 themeLight: false,
108 paneInline: false,
109 sessionActive: false,
110 sessionState: null,
111 sessionTicket: null,
112 sessionClaudeStatus: null,
113 sessionObservedAt: null,
114 sessionIssue: null,
115 sessionIssueId: null,
116 contextPercent: null,
117 contextReadAt: null,
118 contextSeq: 0,
119 warm: false,
120 uiAvailable: true,
121 noLedger: false,
122 saidNoUi: false,
123 saidNoLedger: false,
124 saidScanFailed: false,
125 saidRootUnresolved: false,
126 setupHintRoots: new Set(),
127 initialCwd: null,
128 ledgerRoot: null,
129 };
130}
131hooks/dashboard-view.ts 738 lines1import type { DashboardState } from "./dashboard-state.js";
2import type { SidebarBoardCard, SidebarProjection } from "./sidebar-projection.js";
3import { graphemes, cellWidth, truncate } from "./terminal-text.js";
4import { stageLabel, stageStale } from "./stage-label.js";
5const COLUMN_CARD_CAP = 6;
6const COLUMN_TAIL = "...";
7const BOARD_COLUMNS = 3;
8/** What a column with nothing in it says, rather than drawing a blank frame. */
9const EMPTY_COLUMNS = {
10 "board-open": { symbol: "◇", title: "Queue clear", short: "Queue clear", hint: "Add your next story." },
11 "board-inprogress": { symbol: "○", title: "Ready to begin", short: "Ready to start", hint: "Start a story from Open." },
12 "board-done": { symbol: "·", title: "Progress starts here", short: "Room to grow", hint: "Completed work gathers here." },
13} as const;
14type BoardColumnKey = keyof typeof EMPTY_COLUMNS;
15/**
16* The rows the pane spends on everything that is not a card: the header, the
17* two blank rows around the board and the footer, and per column the heading
18* box (text plus its two border rows) and the body box's two border rows.
19*/
20const CHROME_ROWS = 4;
21const GAP_ROWS = 2;
22/** Card rows per column below which the blank rows are not worth their cost. */
23const GAPS_MIN_BODY = 3;
24/** The card's two border rows, its heading row and the rule under it. */
25const COLUMN_FRAME_ROWS = 4;
26/** Each column is a bordered card, and the border costs a column each side. */
27const COLUMN_BORDER = "round";
28const BORDER_COLUMNS = 2;
29/** The rule under a heading, and the gaps between the three columns. */
30const HEADING_RULE = "\u2500";
31const COLUMN_GAP = 1;
32const GAP_TOTAL = COLUMN_GAP * (BOARD_COLUMNS - 1);
33/**
34* From this width, In progress is widened and Done narrowed by about a
35* twentieth of the pane: at that size there is room to weight the board
36* toward the column being worked rather than the one already finished.
37*/
38const WIDE_COLUMNS = 158;
39const WIDE_SHIFT = 0.05;
40const MIN_COLUMN_WIDTH = 12;
41/**
42* The severity buckets in the order the footer names them, with the colour a
43* nonzero one carries and the short label it falls back to.
44*/
45/** What the issues line says when every bucket is empty. */
46const NO_ISSUES = "issues: none";
47const SEVERITY_ORDER = [
48 { key: "critical", long: "critical", short: "crit", tone: "red" },
49 { key: "high", long: "high", short: "high", tone: "yellow" },
50 { key: "medium", long: "medium", short: "med", tone: null },
51 { key: "low", long: "low", short: "low", tone: null },
52] as const;
53/**
54* The colour an issue's id carries on a board row, by severity.
55*
56* The same two tones the footer already uses for the same two buckets, so one
57* red on the board means what a red in the issues line means. Medium and low
58* carry none: a column where every row is coloured marks nothing.
59*/
60const SEVERITY_TONES: Readonly<Record<string, string>> = { critical: "red", high: "yellow" };
61/**
62* How each column's heading is drawn.
63*
64* One column is emphasised and it is the one that says what is happening
65* now: In progress, bold and cyan. Open is plain, being the resting state and the
66* column a reader lands on most; Done recedes, since finished work is
67* reference rather than news. Counts use a quieter weight inside each heading.
68*/
69const COLUMN_STYLES = {
70 open: {},
71 inProgress: { color: "cyan", bold: true },
72 done: { dimColor: true },
73} as const;
74/**
75* Cells kept clear to the right of the pane's own rows.
76*
77* The engine draws its close mark in the last cell of the pane, and the
78* context fill was right-aligned straight into it: live it read "context 7%×",
79* with the mark looking like part of our string. `BoxProps` carries
80* `marginRight`, so the row simply stops short of the edge. The fill now sits
81* at the foot rather than the head, and keeps the clearance there.
82*/
83const PANE_EDGE_CLEARANCE = 3;
84/**
85* Narrower than this and three columns are shredded rather than laid out, so
86* the pane draws the narrow board instead (ISS-1252): the work in hand and
87* the footer, nothing else. A placed pane keeps its seat when the window
88* shrinks (inline on the main screen, docked in fullscreen), so this is the
89* in-between case: a pane that exists but is too narrow to be a board.
90*/
91const BOARD_MIN_COLUMNS = 60;
92/** Cards the narrow board shows before it says how many more there are. */
93const NARROW_BOARD_CARDS = 3;
94/** The ledger directory itself, which is what says a project HAS a ledger. */
95export function paneText(dashboard: DashboardState, Text: (props: Record<string, unknown>) => unknown, props: Record<string, unknown>): unknown {
96 // Inline, the pane is on the terminal's own background and the default
97 // foreground is the one that matches it (ISS-1255); only the dock is painted.
98 if (dashboard.paneInline)
99 return Text(props);
100 return Text({ color: dashboard.themeLight ? "black" : "white", ...props });
101}
102type PanePlacement = "dock" | "inline";
103export function panePlacement(e: any): PanePlacement | null {
104 const value: unknown = e?.props?.placement;
105 return value === "dock" || value === "inline" ? value : null;
106}
107/**
108* The board's shape (ISS-1254): `stacked` is the three framed columns one
109* under another, the sidebar; `columns` is the three side by side; `narrow`
110* is the In progress strip. Docked, always stacked. Inline, side by side
111* from BOARD_MIN_COLUMNS up and the strip below it. No placement reported,
112* the width alone decides between stacked and side by side, as 1.15.4 did.
113*/
114type BoardLayout = "stacked" | "columns" | "narrow";
115export function boardLayout(placement: PanePlacement | null, width: number): BoardLayout {
116 if (placement === "dock")
117 return "stacked";
118 if (width >= BOARD_MIN_COLUMNS)
119 return "columns";
120 return placement === "inline" ? "narrow" : "stacked";
121}
122/**
123* How many card rows each column may draw, and whether the pane can afford
124* the blank rows around the board at all.
125*
126* The pane clips what will not fit, silently, so the budget is counted out
127* before anything is drawn:
128*
129* header 1, header gap 1, footer gap 1, footer 1 = 4 chrome rows
130* the card's border, above and below = 2
131* its heading row and the rule under it = 2
132*
133* Side by side the three columns are parallel, so one column's four frame rows
134* are the board's; stacked they run one after another, so the frames cost
135* three times that and the rows left over are shared between them. The blank
136* rows go before the cards do, because a board with one card in it still says
137* something and a gap says nothing. When even three framed headings will not
138* fit, the board falls back to one plain counted row per column, which is the
139* smallest thing that is still the board.
140*
141* What comes back is the rows one BODY may draw, tail included; the board
142* decides how many of those are cards once it knows whether anything was left
143* out.
144*
145* WHY `bodyRows` IS NOT THE ROOM. The owner reloaded a 213 column, 61 row
146* terminal and got the compact fallback where the build before drew eight
147* cards a column. `scroll.bodyRows` is not the height the surface has for us:
148* `SiteScroll` is "where a site's window sits over THE TREE A HOOK DREW in
149* it", and `ui.scroll` spells the same field "how many rows of the tree the
150* window shows at once, AS DRAWN NOW", with `contentRows` beside it and
151* "the window's last offset is `contentRows - bodyRows`, none when the tree
152* fits". A tree that fits is its own window, so `bodyRows` is the height of
153* what we last drew. Reading it as a cap is a ratchet: one short board makes
154* the next budget shorter, the compact fallback draws six rows, and the pane
155* reports six rows for ever after.
156*
157* So the field is allowed to PROVE room and never to deny it. The cap comes
158* from `viewport.rows`, which is the whole surface and so an honest upper
159* bound on the pane ("cells down the whole surface, not the room left for
160* this component"), and `bodyRows` only raises that. Above the rows the whole
161* board needs neither matters and the layout is the one the owner had before
162* any budget existed: the capped cards, a tail, and the blank rows.
163*/
164export function rowBudget(e: any, stacked: boolean): {
165 body: number;
166 gaps: boolean;
167 compact: boolean;
168} {
169 const frames = stacked ? COLUMN_FRAME_ROWS * BOARD_COLUMNS : COLUMN_FRAME_ROWS;
170 const share = stacked ? BOARD_COLUMNS : 1;
171 const whole = CHROME_ROWS + frames + share * (COLUMN_CARD_CAP + 1);
172 const drawn: number = typeof e.props?.scroll?.bodyRows === "number" ? e.props.scroll.bodyRows : 0;
173 const screen: number = typeof e.viewport?.rows === "number" ? e.viewport.rows : 0;
174 const room = Math.max(drawn, screen);
175 if (room <= 0 || room >= whole)
176 return { body: COLUMN_CARD_CAP + 1, gaps: true, compact: false };
177 // With the blank rows first, but only while they are affordable: below
178 // GAPS_MIN_BODY rows of cards per column the gaps are costing more than
179 // they are worth, and a board with cards in it beats a tidy empty one.
180 for (const [gaps, floor] of [[true, GAPS_MIN_BODY], [false, 1]] as const) {
181 const chrome = CHROME_ROWS - (gaps ? 0 : GAP_ROWS);
182 const left = room - chrome - frames;
183 if (left >= share * floor) {
184 return { body: Math.min(COLUMN_CARD_CAP + 1, Math.floor(left / share)), gaps, compact: false };
185 }
186 }
187 return { body: 0, gaps: false, compact: true };
188}
189/**
190* A heading that keeps its count when the column is too narrow for both.
191*
192* The count is the point of the heading, so the label is what gets cut:
193* "In progress 100" at fourteen cells is "In progr… 100", never
194* "In progress 1…", which would quietly report a different number.
195*/
196function headingContent(dashboard: DashboardState, elements: any, label: string, count: number, width: number, stage: StageMark | null = null): unknown[] {
197 const tail = ` ${count}`;
198 const emphasis = dashboard.motion.count(label === "In progress" ? "inProgress" : label === "Done" ? "done" : "open");
199 const tag = headingTag(label, count, stage, width);
200 return [
201 paneText(dashboard, elements.Text, { children: truncate(label, Math.max(1, width - tail.length)) }),
202 paneText(dashboard, elements.Text, { dimColor: !emphasis, bold: emphasis, children: tail }),
203 ...(tag === "" ? [] : [paneText(dashboard, elements.Text, { ...(stage!.stale ? { dimColor: true } : { color: "cyan" }), children: tag })]),
204 ];
205}
206/**
207* T-532: the stage after a heading, " [Code]", for a session no drawn card
208* carries. Only whole, after the whole label and count: the tag is what goes
209* first, so the count is never cut to make room for it. Empty otherwise.
210*/
211function headingTag(label: string, count: number, stage: StageMark | null, width: number): string {
212 if (stage === null || stage.key !== null)
213 return "";
214 const tag = ` ${stage.text.trimEnd()}`;
215 return cellWidth(label) + cellWidth(` ${count}`) + cellWidth(tag) <= width ? tag : "";
216}
217/**
218* T-531: the stage an autonomous session is in and where it is drawn. `key` is
219* the stable key of the drawn card that carries it, or null when no drawn card
220* does and the In progress heading carries it instead (T-532); `text` is the
221* card's tag with its trailing space; `stale` dims it.
222*/
223interface StageMark {
224 readonly key: string | null;
225 readonly text: string;
226 readonly stale: boolean;
227}
228/**
229* The session's stage for an In progress column that draws its first `shown`
230* cards, or null when no session is active or its state did not parse.
231*
232* The card is resolved against the whole column first and only then asked
233* whether it is drawn: a twin past the cap must still make a drawn card
234* ambiguous, and a capped card must not hand its tag to a drawn look-alike.
235* Unresolved, ambiguous or cut by the cap, the stage goes to the heading.
236*/
237function sessionStage(dashboard: DashboardState, cards: readonly SidebarBoardCard[], shown: number, now = Date.now()): StageMark | null {
238 const state = dashboard.sessionState;
239 if (!dashboard.sessionActive || state === null)
240 return null;
241 const card = sessionCard(dashboard, cards);
242 const stale = stageStale(dashboard.sessionObservedAt, now);
243 return {
244 key: card !== null && cards.indexOf(card) < shown ? card.key : null,
245 text: `[${stageLabel(state)}${stale ? "?" : ""}] `,
246 stale,
247 };
248}
249/**
250* The one In progress card that is the session's item, or null.
251*
252* A ticket session matches ticket cards only, by display id (falling back to
253* the id), and two that share it match neither: guessing would put the stage
254* on work the session is not doing. An ISSUE_FIX session (T-532) carries no
255* ticket and matches issue cards only: by its canonical id first, which no
256* reconcile can duplicate, and by display id only when no card has that id.
257*/
258function sessionCard(dashboard: DashboardState, cards: readonly SidebarBoardCard[]): SidebarBoardCard | null {
259 const only = (matches: readonly SidebarBoardCard[]): SidebarBoardCard | null => matches.length === 1 ? matches[0]! : null;
260 const ticket = dashboard.sessionTicket;
261 if (ticket !== null)
262 return only(cards.filter((card) => card.kind === "ticket" && card.id === ticket));
263 const issueId = dashboard.sessionIssueId;
264 const byKey = issueId === null ? null : only(cards.filter((card) => card.kind === "issue" && card.key === `issue:${issueId}`));
265 if (byKey !== null)
266 return byKey;
267 const issue = dashboard.sessionIssue;
268 return issue === null ? null : only(cards.filter((card) => card.kind === "issue" && card.id === issue));
269}
270/**
271* IDs lead in muted text; titles carry emphasis and blockers stay visible.
272*
273* The session's stage tag (T-531) sits after the id, ahead of any marker, and
274* only when the whole id, the tag, the marker and two title cells fit: the
275* title is what gets cut, and the tag goes whole before the id would be. A card
276* without it draws exactly the three runs it always did.
277*/
278function cardRow(dashboard: DashboardState, elements: any, card: SidebarBoardCard, width: number, done = false, stage: StageMark | null = null): unknown {
279 const effect = dashboard.motion.card(card.key);
280 const ready = effect.ready && !card.blocked && !done;
281 const blocked = card.blocked ? (width >= 32 ? "[Blocked] " : "[!] ") : ready ? (width >= 32 ? "✓ Ready " : "✓ ") : "";
282 const tag = stage !== null && stage.key === card.key
283 && cellWidth(card.id) + 1 + cellWidth(stage.text) + cellWidth(blocked) + 2 <= width ? stage.text : "";
284 const id = truncate(card.id, Math.max(1, width - cellWidth(tag) - cellWidth(blocked) - 3));
285 const room = Math.max(1, width - cellWidth(id) - cellWidth(tag) - cellWidth(blocked) - 1);
286 const tone = card.kind === "issue" && card.severity !== null ? SEVERITY_TONES[card.severity] : undefined;
287 return paneText(dashboard, elements.Text, {
288 key: card.key,
289 wrap: "truncate",
290 children: [
291 paneText(dashboard, elements.Text, { dimColor: true, ...(tone ? { color: tone } : {}), children: `${id} ` }),
292 ...(tag === "" ? [] : [paneText(dashboard, elements.Text, { ...(stage!.stale ? { dimColor: true } : { color: "cyan" }), children: tag })]),
293 paneText(dashboard, elements.Text, { ...(card.blocked ? { color: "yellow" } : { dimColor: effect.fading }), children: blocked }),
294 paneText(dashboard, elements.Text, { bold: !done, dimColor: done, children: shimmerTitle(dashboard, elements, truncate(card.title, room), effect.progress) }),
295 ],
296 });
297}
298/** Preserve graphemes and cell widths while a narrow highlight crosses the text. */
299function shimmerTitle(dashboard: DashboardState, elements: any, title: string, progress: number | null): unknown {
300 if (progress === null)
301 return title;
302 const start = Math.round(progress * (cellWidth(title) + 6)) - 6;
303 const runs: {
304 text: string;
305 highlight: boolean;
306 }[] = [];
307 let position = 0;
308 for (const { cluster, cells } of graphemes(title)) {
309 const highlight = position >= start && position < start + 6;
310 const last = runs[runs.length - 1];
311 if (last?.highlight === highlight)
312 last.text += cluster;
313 else
314 runs.push({ text: cluster, highlight });
315 position += cells;
316 }
317 return runs.map((run, index) => paneText(dashboard, elements.Text, {
318 key: `shimmer-${index}`, ...(run.highlight ? { dimColor: false, bold: true, underline: true } : {}), children: run.text,
319 }));
320}
321/**
322* The rows of one column's body, every body the same height.
323*
324* The three bodies draw the same number of rows, so the cards end level
325* instead of leaving a ragged edge: a column with fewer cards is padded with
326* blanks. Empty columns use that same height for a quiet, centered invitation.
327* The tail row is part of that common height, held
328* back by the budget, so a capped column can say it was capped without
329* standing a row taller than the rest.
330*/
331function bodyRowsOf(dashboard: DashboardState, elements: any, cards: readonly SidebarBoardCard[], width: number, shown: number, height: number, column: BoardColumnKey, stage: StageMark | null = null): unknown[] {
332 const rows: unknown[] = [];
333 if (cards.length === 0) {
334 return emptyColumnRows(dashboard, elements, column, width, height);
335 }
336 else {
337 for (const card of cards.slice(0, shown))
338 rows.push(cardRow(dashboard, elements, card, width, column === "board-done", stage));
339 if (cards.length > shown)
340 rows.push(paneText(dashboard, elements.Text, { dimColor: true, wrap: "truncate", children: COLUMN_TAIL }));
341 }
342 while (rows.length < height)
343 rows.push(paneText(dashboard, elements.Text, { children: " " }));
344 return rows;
345}
346function emptyColumnRows(dashboard: DashboardState, elements: any, column: BoardColumnKey, width: number, height: number, centered = true): unknown[] {
347 const state = EMPTY_COLUMNS[column];
348 const title = cellWidth(`${state.symbol} ${state.title}`) <= width ? state.title : state.short;
349 const lines = [`${state.symbol} ${title}`];
350 if (height >= 3 && cellWidth(state.hint) <= width)
351 lines.push(state.hint);
352 const top = centered ? Math.floor((height - lines.length) / 2) : 0;
353 return Array.from({ length: height }, (_, index) => {
354 const line = truncate(lines[index - top] ?? " ", width);
355 const inset = centered ? Math.max(0, Math.floor((width - cellWidth(line)) / 2)) : 0;
356 return paneText(dashboard, elements.Text, {
357 key: `${column}-empty-${index}`, dimColor: true, wrap: "truncate", children: " ".repeat(inset) + line,
358 });
359 });
360}
361/**
362* One column: a bordered card with its heading at the top, a rule under the
363* heading, and the card rows beneath.
364*
365* One box and not two. The heading is enclosed by the card's own top and side
366* borders and the rule below it, which is the divider; two stacked bordered
367* boxes drew a double line between heading and body. The rule is a Text of
368* box-drawing dashes spanning the inner width, so it meets both side borders;
369* it cannot render the ├ and ┤ junctions, since a child of the box cannot
370* reach into the border cells the renderer owns.
371*
372* The count in the heading is the WHOLE column, not the rows drawn, so a
373* capped column still tells the truth about the project; the tail says the
374* column goes on. Titles are cut to the column's width, not the pane's.
375*
376* Takes the resolved element table rather than `$`: these are plain
377* constructors, and the client's scan is strict about where `$` may travel.
378*/
379export function inProgressBoard(dashboard: DashboardState, elements: any, width: number, body: number): unknown {
380 const cards = dashboard.projection?.board.inProgress ?? [];
381 const shown = Math.min(COLUMN_CARD_CAP, Math.max(0, body - (cards.length > body ? 1 : 0)));
382 return boardColumn(dashboard, elements, "board-inprogress", "In progress", COLUMN_STYLES.inProgress,
383 cards, width, shown, Math.max(1, Math.min(body, cards.length || 2)), sessionStage(dashboard, cards, shown));
384}
385
386function boardColumn(dashboard: DashboardState, elements: any, key: BoardColumnKey, heading: string, style: Readonly<Record<string, unknown>>, cards: readonly SidebarBoardCard[], width: number, shown: number, height: number, stage: StageMark | null = null): unknown {
387 // The border takes a column on each side, so the text inside has that much
388 // less. Getting this wrong wraps every row and the board falls apart.
389 const textWidth = Math.max(1, width - BORDER_COLUMNS);
390 return elements.Box({
391 key,
392 flexDirection: "column",
393 borderStyle: COLUMN_BORDER,
394 width,
395 overflow: "hidden",
396 children: [
397 paneText(dashboard, elements.Text, {
398 key: `${key}-heading`,
399 ...style,
400 wrap: "truncate",
401 children: headingContent(dashboard, elements, heading, cards.length, textWidth, stage),
402 }),
403 paneText(dashboard, elements.Text, { key: `${key}-rule`, dimColor: true, wrap: "truncate", children: HEADING_RULE.repeat(textWidth) }),
404 ...bodyRowsOf(dashboard, elements, cards, textWidth, shown, height, key, stage),
405 ],
406 });
407}
408/**
409* The board reduced to three counted rows, when no frame will fit. No card is
410* drawn, so a session's stage rides on the In progress row.
411*/
412export function compactBoard(dashboard: DashboardState, elements: any, board: SidebarProjection["board"], width: number): unknown {
413 const line = (key: string, label: string, style: Readonly<Record<string, unknown>>, cards: readonly SidebarBoardCard[], stage: StageMark | null = null): unknown => paneText(dashboard, elements.Text, { key, ...style, wrap: "truncate", children: headingContent(dashboard, elements, label, cards.length, width, stage) });
414 return elements.Box({
415 key: "board",
416 flexDirection: "column",
417 children: [
418 line("board-open", "Open", COLUMN_STYLES.open, board.open),
419 line("board-inprogress", "In progress", COLUMN_STYLES.inProgress, board.inProgress, sessionStage(dashboard, board.inProgress, 0)),
420 line("board-done", "Done", COLUMN_STYLES.done, board.done),
421 ],
422 });
423}
424/**
425* The narrow board (ISS-1252): below BOARD_MIN_COLUMNS the three framed
426* columns stacked into a strip the person had to scroll, past a cut-off Open
427* card and an empty In progress frame. Owner: "in that view we can just show
428* top 3 in progress and context pressure." So this draws the work in hand
429* only: the In progress heading with its count, up to NARROW_BOARD_CARDS
430* cards, a tail when more exist. Open and Done keep their counts in
431* the band's summary line under the pane. At most five rows, so the client's
432* inline block shows it whole, without a budget.
433*
434* Nothing in progress and the strip shows the Open column the same way
435* (ISS-1254): the owner's project had no work in hand and the strip said
436* "In progress 0, none", which is a count and not the work; the next thing to
437* pick up is what the strip is for then. An active session's stage (T-532)
438* still gets a row there, "In progress 0 [Pick]", above Open, which gives up
439* a card for it so the strip stays five rows; the Open cards never carry it.
440*/
441export function narrowBoard(dashboard: DashboardState, elements: any, board: SidebarProjection["board"], width: number): unknown {
442 const inProgress = board.inProgress as readonly SidebarBoardCard[];
443 const fallback = inProgress.length === 0;
444 const cards = fallback ? (board.open as readonly SidebarBoardCard[]) : inProgress;
445 const stage = sessionStage(dashboard, inProgress, NARROW_BOARD_CARDS);
446 const stageRow = fallback && headingTag("In progress", 0, stage, width) !== "";
447 const limit = stageRow ? NARROW_BOARD_CARDS - 1 : NARROW_BOARD_CARDS;
448 const rows: unknown[] = [];
449 if (stageRow) {
450 rows.push(paneText(dashboard, elements.Text, {
451 key: "narrow-stage-heading",
452 ...COLUMN_STYLES.inProgress,
453 wrap: "truncate",
454 children: headingContent(dashboard, elements, "In progress", 0, width, stage),
455 }));
456 }
457 rows.push(paneText(dashboard, elements.Text, {
458 key: "narrow-heading",
459 ...(fallback ? COLUMN_STYLES.open : COLUMN_STYLES.inProgress),
460 wrap: "truncate",
461 children: headingContent(dashboard, elements, fallback ? "Open" : "In progress", cards.length, width, fallback ? null : stage),
462 }));
463 if (cards.length === 0) {
464 rows.push(...emptyColumnRows(dashboard, elements, fallback ? "board-open" : "board-inprogress", width, 1, false));
465 }
466 else {
467 // The Open fallback is not work in hand, so its cards never carry the stage.
468 cards.slice(0, limit).forEach((card, index) => {
469 rows.push(elements.Box({ key: `narrow-card-${index}`, children: [cardRow(dashboard, elements, card, width, false, fallback ? null : stage)] }));
470 });
471 if (cards.length > limit) {
472 rows.push(paneText(dashboard, elements.Text, {
473 key: "narrow-tail",
474 dimColor: true,
475 wrap: "truncate",
476 children: truncate(`... ${cards.length - limit} more`, width),
477 }));
478 }
479 }
480 return elements.Box({ key: "board", flexDirection: "column", children: rows });
481}
482/**
483* The three column widths.
484*
485* Stacked, every card takes the pane. Side by side, the width less the two
486* gaps splits three ways, the leftover cells going to Open; from WIDE_COLUMNS
487* up, a twentieth of the pane moves from Done to In progress, so the board
488* leans toward the work in hand. The three widths and the gaps always sum to
489* the pane's width, whatever the arithmetic above did.
490*/
491function columnWidths(width: number, stacked: boolean): number[] {
492 if (stacked)
493 return [width, width, width];
494 const base = Math.max(MIN_COLUMN_WIDTH, Math.floor((width - GAP_TOTAL) / BOARD_COLUMNS));
495 const widths = [base, base, base];
496 widths[0] = (widths[0] ?? base) + Math.max(0, width - GAP_TOTAL - base * BOARD_COLUMNS);
497 if (width >= WIDE_COLUMNS) {
498 const shift = Math.min(Math.round(width * WIDE_SHIFT), (widths[2] ?? base) - MIN_COLUMN_WIDTH);
499 if (shift > 0) {
500 widths[1] = (widths[1] ?? base) + shift;
501 widths[2] = (widths[2] ?? base) - shift;
502 }
503 }
504 return widths;
505}
506export function boardNode(dashboard: DashboardState, elements: any, board: SidebarProjection["board"], width: number, stacked: boolean, body: number): unknown {
507 const widths = columnWidths(width, stacked);
508 // Every body the same height, and that height inside the budget: as many
509 // rows as the fullest column can show, one of them given up to the tail
510 // when anything was left out, so a capped column says so without standing a
511 // row taller than the rest.
512 const columns = [board.open, board.inProgress, board.done] as const;
513 const longest = Math.max(...columns.map((column) => column.length));
514 // Never more than the cap, whatever the budget allows: a body of seven rows
515 // is six cards and a tail, not seven cards.
516 let shown = Math.min(body, longest, COLUMN_CARD_CAP);
517 if (columns.some((column) => column.length > shown))
518 shown = Math.max(0, Math.min(shown, body - 1));
519 const omitted = columns.some((column) => column.length > shown);
520 const height = Math.max(1, Math.min(body, shown + (omitted ? 1 : 0)));
521 // Left to right in the order the work moves: what can be
522 // picked up, what is being done, what is finished.
523 return elements.Box({
524 key: "board",
525 flexDirection: stacked ? "column" : "row",
526 gap: stacked ? 0 : COLUMN_GAP,
527 children: [
528 boardColumn(dashboard, elements, "board-open", "Open", COLUMN_STYLES.open, board.open, widths[0]!, shown, height),
529 boardColumn(dashboard, elements, "board-inprogress", "In progress", COLUMN_STYLES.inProgress, board.inProgress, widths[1]!, shown, height, sessionStage(dashboard, board.inProgress, shown)),
530 boardColumn(dashboard, elements, "board-done", "Done", COLUMN_STYLES.done, board.done, widths[2]!, shown, height),
531 ],
532 });
533}
534/**
535* The header keeps identity and an animated wordmark on one row.
536* Context pressure stays in the footer.
537*/
538/**
539* The Mod's own version, drawn in the header (ISS-1266). The Mod cannot
540* read package.json through the client, so this is a literal, bumped with
541* the two plugin manifests on every release; test/plugin/sidebar-validate
542* holds it equal to package.json so a forgotten bump fails before publish.
543*/
544export const MOD_VERSION = "1.16.0";
545function wordmarkNode(dashboard: DashboardState, elements: any): unknown {
546 const sweep = dashboard.motion.activity().sweep;
547 return paneText(dashboard, elements.Text, { key: "wordmark", bold: true,
548 children: sweep === null ? "Storybloq" : [..."Storybloq"].map((letter, index) => {
549 // A soft Gaussian crest blends ivory, champagne, and copper without
550 // toggling font weight or terminal dimness at band edges.
551 const distance = index - (sweep - 3);
552 const blend = Math.exp(-(distance * distance) / 8);
553 // Darker warm equivalents keep the light theme readable.
554 const base = dashboard.themeLight ? [91, 77, 65] : [224, 216, 202];
555 const middle = dashboard.themeLight ? [119, 91, 63] : [219, 198, 165];
556 const crest = dashboard.themeLight ? [139, 91, 63] : [203, 165, 137];
557 const from = blend < 0.75 ? base : middle;
558 const to = blend < 0.75 ? middle : crest;
559 const mix = blend < 0.75 ? blend / 0.75 : (blend - 0.75) / 0.25;
560 const color = "#" + from.map((value, channel) =>
561 Math.round(value + (to[channel]! - value) * mix).toString(16).padStart(2, "0")
562 ).join("");
563 return paneText(dashboard, elements.Text, {
564 key: `wordmark-${index}`, bold: true, color, children: letter,
565 });
566 }),
567 });
568}
569
570export function compactLineNode(dashboard: DashboardState, elements: any, columns: number): unknown {
571 const width = Math.max(1, Math.floor(columns) - PANE_EDGE_CLEARANCE);
572 const contextWidth = Math.min(width, 24);
573 const context = contextLabel(dashboard.contextPercent, contextWidth);
574 const room = width - cellWidth(context) - 3;
575 const text = (props: Record<string, unknown>) => paneText(dashboard, elements.Text, props);
576 if (room < 12) return contextNode(dashboard, elements, dashboard.contextPercent, width);
577 const item = dashboard.projection?.board.inProgress[0];
578 const runs: unknown[] = [wordmarkNode(dashboard, elements)];
579 let used = 9;
580 if (room >= 30) {
581 const status = item ? "In progress" : "Ready";
582 runs.push(text({ dimColor: true, children: " │ " }));
583 runs.push(text({ color: item ? "cyan" : undefined, children: status }));
584 used += 5 + status.length;
585 if (item && room - used >= cellWidth(item.id) + 8) {
586 runs.push(text({ dimColor: true, children: ` ${item.id} ` }));
587 used += cellWidth(item.id) + 4;
588 if (item.blocked && room - used >= 15) {
589 runs.push(text({ color: "yellow", children: "[Blocked] " }));
590 used += 10;
591 }
592 const title = truncate(item.title, Math.min(58, room - used));
593 runs.push(text({ bold: true, children: title }));
594 used += cellWidth(title);
595 }
596 }
597 runs.push(text({ children: " ".repeat(width - used - cellWidth(context)) }));
598 runs.push(contextNode(dashboard, elements, dashboard.contextPercent, contextWidth));
599 return text({ key: "compact-line", wrap: "truncate", children: runs });
600}
601
602export function headerNode(dashboard: DashboardState, elements: any): unknown {
603 // "Storybloq (1.15.8) - CPM" (ISS-1266): the wordmark, the version faint,
604 // then the project in the wordmark's own weight. The project is the folder
605 // the ledger sits in, so two checkouts of one project read apart; the
606 // config's `project` name would say "storybloq" for CPM.
607 const name = projectFolderName(dashboard);
608 return elements.Box({
609 key: "header",
610 flexDirection: "row",
611 alignItems: "center",
612 marginRight: PANE_EDGE_CLEARANCE,
613 children: [
614 paneText(dashboard, elements.Text, {
615 wrap: "truncate",
616 children: [
617 wordmarkNode(dashboard, elements),
618 paneText(dashboard, elements.Text, { key: "version", dimColor: true, children: ` (${MOD_VERSION})` }),
619 ...(name === "" ? [] : [paneText(dashboard, elements.Text, { key: "project", bold: true, children: ` - ${name}` })]),
620 ],
621 }),
622 ],
623 });
624}
625/** The last segment of the pinned ledger root, or nothing before one is pinned. */
626function projectFolderName(dashboard: DashboardState): string {
627 if (dashboard.ledgerRoot === null)
628 return "";
629 const segments = dashboard.ledgerRoot.replace(/[/\\]+$/, "").split(/[/\\]/);
630 return segments[segments.length - 1] ?? "";
631}
632/**
633* The foot of the pane: the issues breakdown flush left, the context fill
634* right-aligned on the same row.
635*
636* The four buckets are always all there, so the shape of the line does not
637* move about; what changes is the weight. A zero bucket is dim and a nonzero
638* one is not, critical reads red and high yellow when they have anything in
639* them, and the separators are dim throughout, so the eye lands on the
640* severities that exist. The context fill is neutral and gets its width
641* first, the issues line taking what is left and going to the short labels
642* when the long ones will not fit.
643*
644* No right margin here: the engine's close mark is a top-right thing, and the
645* header is what keeps clear of it.
646*/
647export function contextLabel(context: number | null, width: number, meterValue = context): string {
648 if (width <= 0)
649 return "";
650 if (context === null || !Number.isFinite(context))
651 return truncate(width < 6 ? "--" : width < 14 ? "-- ctx" : "context --", width);
652 const value = Math.max(0, Math.min(100, Math.round(context)));
653 if (width < 14) {
654 const compact = `${value}% ctx`;
655 return truncate(cellWidth(compact) <= width ? compact : `${value}%`, width);
656 }
657 const cells = width >= 48 ? 8 : width >= 32 ? 4 : 0;
658 const meterPercent = meterValue !== null && Number.isFinite(meterValue) ? Math.max(0, Math.min(100, meterValue)) : value;
659 const filled = Math.round(meterPercent / 100 * cells);
660 const meter = cells ? ` [${"━".repeat(filled)}${"·".repeat(cells - filled)}]` : "";
661 return `context${meter} ${value}%`;
662}
663export function contextNode(dashboard: DashboardState, elements: any, context: number | null, width: number): unknown {
664 const high = context !== null && Number.isFinite(context) && context >= 80;
665 return paneText(dashboard, elements.Text, {
666 key: "context", dimColor: !high, bold: high, wrap: "truncate", children: contextLabel(context, width, dashboard.motion.meterValue()),
667 });
668}
669export function footerNode(dashboard: DashboardState, elements: any, bySeverity: Readonly<Record<string, number>>, context: number | null, width: number): unknown {
670 const contextText = contextLabel(context, width);
671 const room = Math.max(0, width - cellWidth(contextText) - 1);
672 const counts = SEVERITY_ORDER.map((severity) => bySeverity[severity.key] ?? 0);
673 // A ledger with nothing open says so in a word. Four zeros is four numbers
674 // to read before finding out there is nothing to read, and it looks like a
675 // pane that failed rather than a project with no open issues. The
676 // abbreviated row says the same word, since there is nothing to abbreviate.
677 if (counts.every((count) => count === 0)) {
678 return elements.Box({
679 key: "footer",
680 flexDirection: "row",
681 justifyContent: "space-between",
682 alignItems: "center",
683 children: [
684 elements.Box({
685 key: "issues",
686 flexDirection: "row",
687 width: Math.min(room, cellWidth(NO_ISSUES)),
688 overflow: "hidden",
689 children: [paneText(dashboard, elements.Text, { dimColor: true, wrap: "truncate", children: NO_ISSUES })],
690 }),
691 contextNode(dashboard, elements, context, width),
692 ],
693 });
694 }
695 const long = `issues: ${SEVERITY_ORDER.map((s, i) => `${counts[i]} ${s.long}`).join(", ")}`;
696 const short = SEVERITY_ORDER.map((s, i) => `${counts[i]} ${s.short}`).join(" ");
697 const abbreviated = cellWidth(long) > room;
698 const parts: unknown[] = [];
699 if (!abbreviated)
700 parts.push(paneText(dashboard, elements.Text, { children: "issues: " }));
701 SEVERITY_ORDER.forEach((severity, index) => {
702 const count = counts[index] ?? 0;
703 const props: Record<string, unknown> = {
704 children: `${count} ${abbreviated ? severity.short : severity.long}`,
705 };
706 if (count === 0)
707 props["dimColor"] = true;
708 else if (severity.tone !== null)
709 props["color"] = severity.tone;
710 if (index > 0) {
711 parts.push(paneText(dashboard, elements.Text, { dimColor: true, children: abbreviated ? " " : ", " }));
712 }
713 parts.push(paneText(dashboard, elements.Text, props));
714 });
715 return elements.Box({
716 key: "footer",
717 flexDirection: "row",
718 justifyContent: "space-between",
719 alignItems: "center",
720 children: [
721 // ONE Text, not a row of them. The coloured fragments are its children,
722 // which keeps each its colour, and the truncation is the parent's: a Box
723 // of Texts has no wrap prop to set, so when even the abbreviated buckets
724 // outgrew the room the row wrapped and the footer took two rows out of a
725 // budget counted for one. The room is the width, so the cut is the
726 // context fill's clearance and not the pane's edge.
727 elements.Box({
728 key: "issues",
729 flexDirection: "row",
730 width: Math.min(room, cellWidth(abbreviated ? short : long)),
731 overflow: "hidden",
732 children: [paneText(dashboard, elements.Text, { wrap: "truncate", children: parts })],
733 }),
734 contextNode(dashboard, elements, context, width),
735 ],
736 });
737}
738hooks/ledger-write-detection.ts 491 lines1/**
2 * How a ledger write is recognised at `tool.call`. The MCP names arrive
3 * prefixed by their server, the CLI's own do not; the verb at the end is what
4 * separates a write from a read.
5 */
6const MCP_PREFIX = "mcp__storybloq__";
7const LEDGER_TOOL_PREFIX = "storybloq_";
8const LEDGER_WRITE_VERB = /_(create|update|set|unset|add|init|snapshot|reinforce|supersede)$/;
9/**
10 * The built-in tools that can write a file, from this build's own tool table
11 * (`BuiltinToolInputs` in claude-code.d.ts carries Edit, Write and
12 * NotebookEdit; MultiEdit is named here for builds that have it, and costs
13 * nothing where it does not exist).
14 */
15const MUTATING_FILE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
16const BASH_TOOL = "Bash";
17/**
18 * The storybloq CLI's writing subcommands, the same verbs the tool names end
19 * in, and how far after `storybloq` one still counts as the subcommand
20 * (`storybloq note create`, so two words).
21 */
22const CLI_NAME = "storybloq";
23const WRITE_VERBS = ["create", "update", "set", "unset", "add", "init", "snapshot", "reinforce", "supersede"];
24const CLI_VERB_DEPTH = 2;
25/**
26 * The characters that are operators outside a quoted run, the runs of them
27 * that cut one segment from the next, and the one that redirects.
28 */
29const OPERATOR_CHARACTERS = [";", "|", "&", "\n", ">", "<"];
30const SEPARATOR_CHARACTERS = [";", "|", "&", "\n"];
31const REDIRECT = ">";
32/** `&>`, the other way of writing a redirect that takes both streams. */
33const BOTH_STREAMS = "&>";
34/** `<<` and `<<<`: past one, the line is a document rather than a command. */
35const HEREDOC = "<<";
36/** A leading `NAME=value`, which is an assignment and not the command. */
37const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
38/** The few commands that only lead up to the one that runs. */
39const WRAPPER_COMMANDS = new Set(["env", "npx", "time", "nice", "sudo", "command"]);
40/**
41 * The options of those that take a VALUE in the next word, per wrapper.
42 *
43 * `sudo -u someone storybloq ticket update` runs the CLI, and a reader that
44 * steps over `-u` and stops at `someone` decides the command is a username.
45 * `--option=value` is one word already and needs none of this.
46 */
47const WRAPPER_VALUE_OPTIONS: Readonly<Record<string, readonly string[]>> = {
48 sudo: ["-u", "-g", "-h", "-p"],
49 nice: ["-n"],
50 env: ["-u", "-C", "-S"],
51 time: ["-f", "-o"],
52 npx: ["-p", "--package", "-c", "--call"],
53};
54/** The CLI's own global options that take the next word as their value. */
55const CLI_VALUE_OPTIONS = ["--node", "--format", "--client"];
56/** The shell commands that write, by what each of them writes. */
57const COPY_COMMANDS = new Set(["cp", "install"]);
58const MOVE_COMMAND = "mv";
59const TEE_COMMAND = "tee";
60const REMOVE_COMMAND = "rm";
61const SED_COMMAND = "sed";
62/** Where a path can arrive on a built-in file tool's event. */
63const PATH_ARGUMENTS = ["file_path", "path", "notebook_path"] as const;
64const STORY_DIR = ".story/";
65
66/** One piece of a command line: an operator, or a word to be read as one. */
67interface Token {
68 readonly text: string;
69 readonly operator: boolean;
70}
71
72/**
73 * A command line read ONCE, quotes and escapes honoured, operators kept apart
74 * from arguments.
75 *
76 * Splitting on separators before reading the quotes was the bug: `echo "x;
77 * storybloq ticket update"` came apart into two segments and the second
78 * looked like a CLI call, and `echo '>' .story/config.json` looked like a
79 * redirect into the ledger. A separator, a redirect and a quote mark only
80 * mean what they say OUTSIDE a quoted run, so there is one pass and it knows
81 * which run it is in.
82 *
83 * An unterminated quote makes the REST of the line unreadable, not the whole
84 * of it. Dropping everything was a regression: a heredoc writing a ticket
85 * (`cat > .story/tickets/T-001.json <<'EOF'`) has a body full of apostrophes,
86 * and so does a trailing `# that's it` comment, and the write on the line
87 * before them stopped sweeping. So the tokens read before the bad quote
88 * opened come back with `ok: false`, and the caller judges every segment that
89 * closed before it and discards the one the quote is in.
90 *
91 * A heredoc operator ends the read for the same reason from the other side:
92 * its head is a command and its body is data, so reading the body as shell
93 * would take words out of a document and call them a write.
94 */
95/**
96 * Steps over a heredoc's delimiter word, quoted or bare, and answers with the
97 * index of its last character, the word its terminator line must equal, and
98 * whether `<<-` allowed that line leading tabs.
99 *
100 * The delimiter's own quotes are opened and closed here rather than by the
101 * lexer's, so `<<'EOF'` does not read as a quote left open and throw the rest
102 * of the head away, and a backslash quotes the character after it as it does
103 * in the shell.
104 */
105function afterHeredocDelimiter(command: string, from: number): { index: number; delimiter: string; dashed: boolean } {
106 let index = from;
107 let delimiter = "";
108 let dashed = false;
109 for (let word = 0; word < 2; word += 1) {
110 while (index + 1 < command.length && (command[index + 1] === " " || command[index + 1] === "\t")) index += 1;
111 let quote = "";
112 while (index + 1 < command.length) {
113 const character = command[index + 1]!;
114 if (quote !== "") {
115 index += 1;
116 if (character === quote) quote = "";
117 else delimiter += character;
118 continue;
119 }
120 // A backslash quotes the next character of the delimiter, so `<<\\EOF`
121 // ends at a line reading EOF. Keeping the backslash means the
122 // terminator is never found and the rest of the script is read as body.
123 if (character === "\\") {
124 index += 1;
125 const escaped = command[index + 1];
126 if (escaped !== undefined) {
127 delimiter += escaped;
128 index += 1;
129 }
130 continue;
131 }
132 if (character === '"' || character === "'") {
133 quote = character;
134 index += 1;
135 continue;
136 }
137 if (character === " " || character === "\t" || character === "\n" || OPERATOR_CHARACTERS.includes(character)) break;
138 delimiter += character;
139 index += 1;
140 }
141 // `<<-EOF` allows leading tabs on the terminator; `<<- EOF` writes the
142 // dash as a word of its own, so one more word is read for it.
143 if (!delimiter.startsWith("-")) break;
144 dashed = true;
145 delimiter = delimiter.slice(1);
146 if (delimiter !== "") break;
147 }
148 return { index, delimiter, dashed };
149}
150
151/**
152 * Where a heredoc's body ends: the index of the newline that closes its
153 * terminator line, or -1 where the terminator never comes and the rest of the
154 * command is body.
155 *
156 * The body is never read as shell, but what follows it is: a script that
157 * writes a note and then updates a ticket is one Bash call, and stopping at
158 * the body would lose the write.
159 */
160function afterHeredocBody(command: string, from: number, delimiter: string, dashed: boolean): number {
161 let start = from + 1;
162 while (start <= command.length) {
163 const cut = command.indexOf("\n", start);
164 const last = cut === -1;
165 const end = last ? command.length : cut;
166 let line = command.slice(start, end);
167 if (line.endsWith("\r")) line = line.slice(0, -1);
168 if (dashed) line = line.replace(/^\t+/, "");
169 if (line === delimiter) return last ? command.length - 1 : end;
170 if (last) return -1;
171 start = end + 1;
172 }
173 return -1;
174}
175
176function lex(command: string): { tokens: Token[]; ok: boolean } {
177 const tokens: Token[] = [];
178 let text = "";
179 let started = false;
180 let quote = "";
181 /** How many tokens were whole when the quote now open was opened. */
182 let opened = 0;
183 /**
184 * A heredoc head is being read, so the next newline starts its body, and
185 * the word that ends it. Two heredocs on one head line (`cat <<A <<B`) are
186 * read as one body ending at the LAST delimiter, which is close enough: the
187 * body is never read either way and what follows it still is.
188 */
189 let heredoc = false;
190 let delimiter = "";
191 let dashed = false;
192 const flush = (): void => {
193 if (started) tokens.push({ text, operator: false });
194 text = "";
195 started = false;
196 };
197 for (let index = 0; index < command.length; index += 1) {
198 const character = command[index]!;
199 if (quote !== "") {
200 // Inside single quotes a backslash is a backslash; inside double quotes
201 // it escapes the next character, as the shell reads them. (Bash keeps
202 // the backslash before anything but $ \ " ` and a newline; this drops
203 // it either way, which costs a character in a path nobody writes.)
204 if (character === "\\" && quote === '"' && index + 1 < command.length) {
205 index += 1;
206 text += command[index];
207 started = true;
208 continue;
209 }
210 if (character === quote) quote = "";
211 else {
212 text += character;
213 started = true;
214 }
215 continue;
216 }
217 if (character === "\\") {
218 index += 1;
219 if (index < command.length) {
220 text += command[index];
221 started = true;
222 }
223 continue;
224 }
225 if (character === '"' || character === "'") {
226 // Where the quote opened, so an unclosed one can give back what was
227 // whole before it rather than nothing at all.
228 quote = character;
229 opened = tokens.length;
230 started = true;
231 continue;
232 }
233 if (character === " " || character === "\t" || character === "\r") {
234 flush();
235 continue;
236 }
237 if (OPERATOR_CHARACTERS.includes(character)) {
238 flush();
239 let run = character;
240 while (index + 1 < command.length && command[index + 1] === character) {
241 run += character;
242 index += 1;
243 }
244 // The body of a heredoc starts at the newline after its head and is
245 // never read: it is a document, and a line of it that looks like a write
246 // is prose someone is filing. What comes AFTER the terminator is shell
247 // again, and dropping it was losing the write in the commonest script
248 // Claude Code produces: a note written with `cat <<EOF`, then a ticket
249 // updated on the line below its EOF.
250 if (heredoc && run.startsWith("\n")) {
251 const end = afterHeredocBody(command, index, delimiter, dashed);
252 if (end === -1) return { tokens, ok: true };
253 index = end;
254 heredoc = false;
255 tokens.push({ text: "\n", operator: true });
256 continue;
257 }
258 // `<<` and `<<<`: the head is still a command and the REST OF ITS LINE
259 // still counts, because `cat <<'EOF' > .story/tickets/T-001.json` writes
260 // a ticket with the redirect sitting after the delimiter. So the
261 // operator and its delimiter are stepped over and the line goes on being
262 // read as shell.
263 if (run.startsWith(HEREDOC)) {
264 const head = afterHeredocDelimiter(command, index);
265 index = head.index;
266 // `<<<` is a here-string: its word IS the input, and there is no body
267 // to step over.
268 if (run === HEREDOC) {
269 heredoc = true;
270 delimiter = head.delimiter;
271 dashed = head.dashed;
272 }
273 continue;
274 }
275 // `>&` and `&>` are one redirect written two ways, and a run of one
276 // character would split them: the `&` would then cut the segment and
277 // the redirect would lose its target. `2>&1` is the same shape and
278 // still names no file of ours, so it neither sweeps nor cuts.
279 if (run[0] === REDIRECT && command[index + 1] === "&") {
280 run += "&";
281 index += 1;
282 } else if (run[0] === "&" && command[index + 1] === REDIRECT) {
283 while (command[index + 1] === REDIRECT) {
284 run += REDIRECT;
285 index += 1;
286 }
287 }
288 tokens.push({ text: run, operator: true });
289 continue;
290 }
291 text += character;
292 started = true;
293 }
294 flush();
295 // An unclosed quote: give back what was already whole when it opened. The
296 // word it started, and everything after, is not shell this can read.
297 if (quote !== "") return { tokens: tokens.slice(0, opened), ok: false };
298 return { tokens, ok: true };
299}
300
301/** A word that names something inside the ledger directory. */
302function inLedger(word: string | undefined): boolean {
303 return typeof word === "string" && word.includes(STORY_DIR);
304}
305
306/** The last path segment of a word, which is the name a command runs under. */
307function basename(word: string): string {
308 const cut = word.lastIndexOf("/");
309 return cut === -1 ? word : word.slice(cut + 1);
310}
311
312/**
313 * The command a segment actually runs, past the wrappers that only lead up to
314 * one, and the arguments it was given.
315 *
316 * `storybloq` counts as the CLI only HERE, in executable position: `echo
317 * storybloq ticket update` prints a sentence and writes nothing, and the two
318 * read identically to anything that only looks for the word. The wrappers are
319 * a named few (`env`, `npx`, `time`, `nice`, `sudo`, `command`) with their own
320 * options and any leading `NAME=value` assignments stepped over. Anything
321 * else in front of the command (`xargs`, a subshell, a substitution) is a
322 * line this cannot read, and it returns nothing rather than guess.
323 */
324function executableOf(words: readonly string[]): { name: string; args: string[] } | null {
325 let index = 0;
326 for (;;) {
327 while (index < words.length && ASSIGNMENT.test(words[index]!)) index += 1;
328 if (index < words.length && WRAPPER_COMMANDS.has(basename(words[index]!))) {
329 const takesValue = WRAPPER_VALUE_OPTIONS[basename(words[index]!)] ?? [];
330 index += 1;
331 while (index < words.length && words[index]!.startsWith("-")) {
332 const option = words[index]!;
333 index += 1;
334 if (!option.includes("=") && takesValue.includes(option)) index += 1;
335 }
336 continue;
337 }
338 break;
339 }
340 if (index >= words.length) return null;
341 return { name: words[index]!, args: words.slice(index + 1) };
342}
343
344/**
345 * Did one segment of a command line write the ledger?
346 *
347 * Direction is the whole question. `cat .story/tickets/T-001.json > /tmp/x`
348 * and `cp .story/tickets/T-001.json /tmp/x` both name the ledger and both
349 * write a file, and neither changes a thing we draw; only where the ledger is
350 * the DESTINATION has anything moved. So a redirect counts at its target and
351 * a copy at its last operand. `mv` counts at either end, because moving a
352 * ticket OUT of the ledger takes it off the board as surely as moving one in;
353 * `tee` counts at any of its files, and `rm` and `sed -i` at the path they
354 * are given.
355 */
356function segmentWrote(tokens: readonly Token[]): boolean {
357 const words: string[] = [];
358 for (let index = 0; index < tokens.length; index += 1) {
359 const token = tokens[index]!;
360 if (!token.operator) {
361 words.push(token.text);
362 continue;
363 }
364 // A redirect writes what follows it; `<` reads it, and the rest of the
365 // operators never reach here (they are what the segments were cut on).
366 if (token.text.startsWith(REDIRECT) || token.text.startsWith(BOTH_STREAMS)) {
367 const target = tokens[index + 1];
368 if (target !== undefined && !target.operator && inLedger(target.text)) return true;
369 index += 1;
370 }
371 }
372
373 const run = executableOf(words);
374 if (run === null) return false;
375 const name = basename(run.name);
376 const args = run.args;
377
378 // The CLI resolves `.story/` itself, so the command line need not name it;
379 // what says it writes is the SUBCOMMAND. A verb further along is an
380 // argument (`storybloq note list --tags update`) or prose in a flag's
381 // value, and neither writes anything.
382 if (name === CLI_NAME) {
383 // Only the words that are SUBCOMMANDS count toward the depth: a global
384 // option before the verb (`storybloq --node x ticket update`) would
385 // otherwise push it out of reach and the write would go unseen.
386 let depth = 0;
387 for (let index = 0; index < args.length && depth < CLI_VERB_DEPTH; index += 1) {
388 const word = args[index]!;
389 if (word.startsWith("-")) {
390 if (!word.includes("=") && CLI_VALUE_OPTIONS.includes(word)) index += 1;
391 continue;
392 }
393 depth += 1;
394 if (WRITE_VERBS.includes(word)) return true;
395 }
396 return false;
397 }
398
399 if (name === MOVE_COMMAND || name === TEE_COMMAND || name === REMOVE_COMMAND) return args.some(inLedger);
400 if (COPY_COMMANDS.has(name)) return args.length >= 2 && inLedger(args[args.length - 1]);
401 if (name === SED_COMMAND) return args.some((word) => word.startsWith("-i")) && args.some(inLedger);
402 return false;
403}
404
405/**
406 * Every segment of a command line, cut on the separators, each on its own.
407 *
408 * The last segment is judged only when the line was read to its end: where an
409 * unterminated quote stopped the read, that segment is the one the quote is
410 * in and there is no telling what it says.
411 */
412function commandWroteLedger(command: string): boolean {
413 const { tokens, ok } = lex(command);
414 let segment: Token[] = [];
415 for (const token of tokens) {
416 // `&>` opens with a separator character and is not one: it is a redirect,
417 // and cutting the segment there would leave its target orphaned.
418 if (token.operator && SEPARATOR_CHARACTERS.includes(token.text[0]!) && !token.text.startsWith(BOTH_STREAMS)) {
419 if (segmentWrote(segment)) return true;
420 segment = [];
421 continue;
422 }
423 segment.push(token);
424 }
425 return ok && segmentWrote(segment);
426}
427
428/**
429 * Did this tool call change the ledger?
430 *
431 * The question has to be answered from the tool NAME first, because a ledger
432 * read is the common case and a sweep per read is the cost the chunked scan
433 * exists to avoid. Reading a ticket, globbing `.story`, or catting a config
434 * file all mention the directory and none of them change a thing.
435 *
436 * tool scan
437 * storybloq_* / mcp__storybloq__* whose last word yes
438 * is a writing verb (ticket_update, meta_set)
439 * any other storybloq tool (status, list, get) no
440 * Write, Edit, MultiEdit, NotebookEdit at a yes
441 * path under .story/
442 * the same four anywhere else no
443 * Bash running the storybloq CLI in EXECUTABLE yes
444 * position with a writing verb in SUBCOMMAND
445 * position (it resolves .story/ itself, so the
446 * command line need not name the directory)
447 * Bash writing INTO .story/ (a redirect whose yes
448 * destination is there, cp whose last argument
449 * is, tee at one, rm or sed -i of one)
450 * Bash moving a file at either end of .story/ yes
451 * (mv out of it takes a ticket off the board
452 * as surely as mv into it puts one on)
453 * Bash reading .story/ and writing elsewhere no
454 * (cat a ticket into /tmp, cp one out of it)
455 * Bash naming the CLI anywhere but executable no
456 * position (echo storybloq ticket update), or
457 * inside quotes, or in a heredoc's body, or in
458 * the segment an unterminated quote is in
459 * (the segments that closed before it still
460 * count, one at a time)
461 * Bash otherwise (cat, ls, grep, git status, no
462 * storybloq status, storybloq ticket list)
463 * Read, Glob, Grep, LS, anything else no
464 *
465 * Bash is conservative by construction: what it cannot read confidently does
466 * not sweep. A missed write costs one turn of staleness, since turn.complete
467 * still scans; a false positive costs a stat sweep of the whole ledger for
468 * every ledger read in the session, which is worse.
469 *
470 * Pure, and it reads only the few fields a path or a command arrives in, so a
471 * Write of a megabyte is not serialized to answer a yes or no question.
472 */
473export function wroteLedger(e: any): boolean {
474 const tool: unknown = e?.tool;
475 if (typeof tool !== "string") return false;
476 const bare = tool.startsWith(MCP_PREFIX) ? tool.slice(MCP_PREFIX.length) : tool;
477 if (bare.startsWith(LEDGER_TOOL_PREFIX)) return LEDGER_WRITE_VERB.test(bare);
478 if (tool === BASH_TOOL) {
479 const command: unknown = e?.["command"];
480 return typeof command === "string" && commandWroteLedger(command);
481 }
482 if (!MUTATING_FILE_TOOLS.has(tool)) return false;
483 for (const key of PATH_ARGUMENTS) {
484 const value: unknown = e?.[key];
485 if (typeof value === "string" && value.includes(STORY_DIR)) return true;
486 }
487 return false;
488}
489
490/** Where the client seated the pane, as its Pane props say; null when they do not. */
491hooks/terminal-text.ts 122 lines1/** The cell-width approximation's special code points, and the cut mark. */
2const ZERO_WIDTH_JOINER = 0x200d;
3const VARIATION_SELECTOR = 0xfe0f;
4const ELLIPSIS = "\u2026";
5
6
7/**
8 * The graphemes of a string with the cells each one takes.
9 *
10 * One routine, used by both the measuring and the cutting, because two that
11 * disagree is how "👩💻abc" cut to four cells came apart in the middle of the
12 * emoji: the measure suppressed the code point after the zero-width joiner
13 * and the cut counted it again. `Intl.Segmenter` gives the clusters (this
14 * runtime has it; where it does not, the fallback is code points, which is
15 * the old behaviour and no worse). A cluster is two cells wide if any code
16 * point in it is wide, or if it carries the emoji variation selector, which
17 * is what makes a text glyph like "♥️" render double.
18 */
19export function graphemes(text: string): { cluster: string; cells: number }[] {
20 const out: { cluster: string; cells: number }[] = [];
21 for (const cluster of clustersOf(text)) {
22 let cells = 0;
23 let emoji = false;
24 for (const character of cluster) {
25 const point = character.codePointAt(0) ?? 0;
26 if (point === VARIATION_SELECTOR) emoji = true;
27 if (isCombining(point) || point === VARIATION_SELECTOR || point === ZERO_WIDTH_JOINER) continue;
28 cells = Math.max(cells, isWide(point) ? 2 : 1);
29 }
30 out.push({ cluster, cells: emoji ? 2 : Math.max(cells, cluster === "" ? 0 : 1) });
31 }
32 return out;
33}
34
35/** Grapheme clusters where the runtime has them, code points where it does not. */
36function clustersOf(text: string): string[] {
37 const segmenter = (Intl as unknown as { Segmenter?: any }).Segmenter;
38 if (typeof segmenter === "function") {
39 const out: string[] = [];
40 for (const part of new segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
41 out.push(part.segment as string);
42 }
43 return out;
44 }
45 return [...text];
46}
47
48/**
49 * How many terminal cells a string takes, which is not its length.
50 *
51 * A CJK ideograph or an emoji occupies two cells and a combining mark none,
52 * so measuring `.length` overruns a column by a cell per wide character, the
53 * row wraps, and the board comes apart. This is the usual approximation (the
54 * East Asian Wide and Fullwidth blocks plus the emoji planes), not a full
55 * Unicode width table, which is more than a sidebar can carry.
56 */
57export function cellWidth(text: string): number {
58 let width = 0;
59 for (const { cells } of graphemes(text)) width += cells;
60 return width;
61}
62
63function isCombining(point: number): boolean {
64 return (
65 (point >= 0x0300 && point <= 0x036f)
66 || (point >= 0x0483 && point <= 0x0489)
67 || (point >= 0x0591 && point <= 0x05bd)
68 || (point >= 0x0610 && point <= 0x061a)
69 || (point >= 0x064b && point <= 0x065f)
70 || (point >= 0x1ab0 && point <= 0x1aff)
71 || (point >= 0x1dc0 && point <= 0x1dff)
72 || (point >= 0x20d0 && point <= 0x20ff)
73 || (point >= 0xfe20 && point <= 0xfe2f)
74 );
75}
76
77function isWide(point: number): boolean {
78 return (
79 (point >= 0x1100 && point <= 0x115f)
80 || (point >= 0x2e80 && point <= 0x303e)
81 || (point >= 0x3041 && point <= 0x33ff)
82 || (point >= 0x3400 && point <= 0x4dbf)
83 || (point >= 0x4e00 && point <= 0x9fff)
84 || (point >= 0xa000 && point <= 0xa4cf)
85 || (point >= 0xac00 && point <= 0xd7a3)
86 || (point >= 0xf900 && point <= 0xfaff)
87 || (point >= 0xfe10 && point <= 0xfe19)
88 || (point >= 0xfe30 && point <= 0xfe6f)
89 || (point >= 0xff00 && point <= 0xff60)
90 || (point >= 0xffe0 && point <= 0xffe6)
91 || (point >= 0x1f300 && point <= 0x1f64f)
92 || (point >= 0x1f680 && point <= 0x1f6ff)
93 || (point >= 0x1f900 && point <= 0x1f9ff)
94 || (point >= 0x20000 && point <= 0x3fffd)
95 );
96}
97
98/**
99 * Cuts a string to fit `cells` terminal cells, ending in one ellipsis where
100 * anything was cut.
101 *
102 * Cluster by cluster, on the same measure the width uses, so a family emoji
103 * or an accented letter is either wholly in or wholly out and never halved.
104 * The ellipsis is U+2026, one cell wide.
105 */
106export function truncate(text: string, cells: number): string {
107 if (cells <= 0) return "";
108 const parts = graphemes(text);
109 let total = 0;
110 for (const part of parts) total += part.cells;
111 if (total <= cells) return text;
112 const room = cells - 1;
113 let width = 0;
114 let out = "";
115 for (const part of parts) {
116 if (width + part.cells > room) break;
117 width += part.cells;
118 out += part.cluster;
119 }
120 return `${out}${ELLIPSIS}`;
121}
122hooks/stage-label.ts 106 lines1/**
2 * T-531: the words the dashboard uses for an autonomous session's stage.
3 *
4 * `.story/status.json` names the guide's state ("IMPLEMENT"); the In progress
5 * card, or its heading, says what that means in one short word ("Code",
6 * T-532). The Mod cannot import `src/`, which does not ship with the plugin,
7 * so the table lives here and test/plugin/stage-label.test.ts holds it to the
8 * guide's own state list: a state added there fails that test until it has a
9 * label here.
10 */
11
12export const STAGE_LABELS: Readonly<Record<string, string>> = Object.freeze({
13 INIT: "Start",
14 LOAD_CONTEXT: "Load",
15 PICK_TICKET: "Pick",
16 PLAN: "Plan",
17 PLAN_REVIEW: "Review",
18 WRITE_TESTS: "Tests",
19 IMPLEMENT: "Code",
20 TEST: "Test",
21 CODE_REVIEW: "Review",
22 BUILD: "Build",
23 VERIFY: "Verify",
24 FINALIZE: "Commit",
25 KNOWLEDGE_REVIEW: "Ledger",
26 COMPACT: "Compacted",
27 LESSON_CAPTURE: "Lessons",
28 ISSUE_FIX: "Fix",
29 ISSUE_SWEEP: "Sweep",
30 HANDOVER: "Handover",
31 COMPLETE: "Done",
32 SESSION_END: "Ended",
33});
34
35/**
36 * How old a status may be before the stage is drawn as uncertain: the
37 * presence TTL (`PRESENCE_TTL_MS`, src/presence/types.ts), the window the
38 * presence handler sweeps with. A copy, because `src/` does not ship with the
39 * plugin; the stage-label test fails if the two drift apart.
40 *
41 * It is this long and not minutes on purpose. The status writer skips a write
42 * that would change only `observedAt` (ISS-1012), so the stamp is the time of
43 * the last change, not a heartbeat, and a quiet stage keeps an old stamp while
44 * the session is healthy.
45 */
46export const STAGE_STALE_MS = 12 * 60 * 60 * 1000;
47
48/**
49 * Code points a terminal would act on rather than draw: C0 (ESC, BEL, tab,
50 * CR, LF and the rest), DEL, C1, the Unicode line and paragraph separators,
51 * and the bidirectional embeddings, overrides and isolates.
52 */
53const UNSAFE_RUN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g;
54const UNSAFE_ONE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/;
55
56/**
57 * ISS-1306: invisible code points that hide text or flip its direction: the
58 * zero-width space, word joiner and byte-order mark, and the LRM, RLM and
59 * ALM marks. They take no cell, so they are removed, not spaced. U+200C
60 * (ZWNJ, part of Persian and Indic words) and U+200D (ZWJ, which joins an
61 * emoji sequence into one cluster) are real text and stay.
62 */
63const INVISIBLE = /[\u200b\u200e\u200f\u2060\ufeff\u061c]/g;
64const INVISIBLE_ONE = /[\u200b\u200e\u200f\u2060\ufeff\u061c]/;
65
66/**
67 * A string made fit to draw on one line: the invisible code points go, each
68 * run of unsafe code points becomes one space, then the ends are trimmed.
69 * status.json and ledger titles are read, never trusted, so nothing in them
70 * reaches the pane as a control sequence, a line break or hidden text, and
71 * the width arithmetic measures what is actually drawn.
72 */
73export function displaySafe(text: string): string {
74 return text.replace(INVISIBLE, "").replace(UNSAFE_RUN, " ").trim();
75}
76
77/**
78 * A status.json field worth keeping: a string with something in it besides
79 * whitespace, underscores, unsafe and invisible code points. Anything else
80 * is unset.
81 */
82export function statusField(value: unknown): string | null {
83 if (typeof value !== "string") return null;
84 for (const char of value) {
85 if (char !== "_" && char.trim() !== "" && !UNSAFE_ONE.test(char) && !INVISIBLE_ONE.test(char)) return value;
86 }
87 return null;
88}
89
90/**
91 * The label for a state: the table's own entry, otherwise the state itself,
92 * lowercased with its underscores as spaces. Never blank.
93 */
94export function stageLabel(state: string): string {
95 if (Object.hasOwn(STAGE_LABELS, state)) return STAGE_LABELS[state]!;
96 const fallback = displaySafe(state.toLowerCase().replace(/_/g, " ")).replace(/\s+/g, " ");
97 return fallback === "" ? "unknown" : fallback;
98}
99
100/** True only when the stamp reads as a time more than STAGE_STALE_MS before `now`. */
101export function stageStale(observedAt: string | null, now: number): boolean {
102 if (observedAt === null) return false;
103 const at = Date.parse(observedAt);
104 return Number.isFinite(at) && now - at > STAGE_STALE_MS;
105}
106hooks/storyfield-logo.ts 104 lines1/** Terminal Storyfield adapter. Samples the homepage mark into a fixed Braille field.
2 * Geometry comes from web/src/components/sections/Hero.tsx (850 by 950 view).
3 * The startup wave changes light, never the mark's structure or cell positions.
4 */
5export const LOGO_DURATION_MS = 2400;
6export type LogoCell = { glyph: string; color: string };
7type Point = readonly [number, number];
8const points: Point[] = [];
9function line(a: Point, b: Point): void { points.push(a, b); }
10function curve(a: Point, b: Point, c: Point, d: Point): void {
11 let last = a;
12 for (let i = 1; i <= 32; i++) {
13 const t = i / 32, s = 1 - t;
14 const next: Point = [s*s*s*a[0]+3*s*s*t*b[0]+3*s*t*t*c[0]+t*t*t*d[0], s*s*s*a[1]+3*s*s*t*b[1]+3*s*t*t*c[1]+t*t*t*d[1]];
15 line(last, next); last = next;
16 }
17}
18line([858,315],[520,315]);
19curve([520,315],[426,315],[354,386],[354,482]);
20curve([354,482],[354,578],[426,648],[518,648]);
21line([532,648],[776,648]);
22curve([776,648],[862,648],[912,716],[912,798]);
23curve([912,798],[912,880],[862,940],[776,940]);
24line([776,940],[342,940]);
25// Cache the raster once per terminal width. Animation only shades cells.
26const lengths: number[] = [];
27let totalLength = 0;
28for (let i = 0; i < points.length; i += 2) {
29 lengths.push(totalLength);
30 totalLength += Math.hypot(points[i + 1]![0] - points[i]![0], points[i + 1]![1] - points[i]![1]);
31}
32function sample(x: number, y: number): number | null {
33 if (Math.hypot(x - 342, y - 940) <= 61) return 0;
34 if (Math.abs(Math.hypot(x - 921, y - 315) - 62) <= 23) return 1;
35 let nearest = Infinity, along = 0;
36 for (let i = 0; i < points.length; i += 2) {
37 const a = points[i]!, b = points[i + 1]!, dx = b[0] - a[0], dy = b[1] - a[1];
38 const t = Math.max(0, Math.min(1, ((x - a[0]) * dx + (y - a[1]) * dy) / (dx * dx + dy * dy)));
39 const distance = Math.hypot(x - a[0] - t * dx, y - a[1] - t * dy);
40 if (distance < nearest) {
41 nearest = distance;
42 along = 1 - (lengths[i / 2]! + t * Math.hypot(dx, dy)) / totalLength;
43 }
44 }
45 return nearest <= 23 ? along : null;
46}
47const bits = [[1, 8], [2, 16], [4, 32], [64, 128]];
48type RasterCell = { glyph: string; along: number; metal: number };
49const rasters = new Map<number, RasterCell[][]>();
50function raster(width: number): RasterCell[][] {
51 const cached = rasters.get(width);
52 if (cached) return cached;
53 const height = Math.round(width * 950 / 850 / 2);
54 const result = Array.from({ length: height }, (_, row) => Array.from({ length: width }, (_, col) => {
55 let mask = 0, along = 0, count = 0;
56 for (let y = 0; y < 4; y++) for (let x = 0; x < 2; x++) {
57 const at = sample(200 + (col * 2 + x + .5) / (width * 2) * 850, 170 + (row * 4 + y + .5) / (height * 4) * 950);
58 if (at !== null) { mask |= bits[y]![x]!; along += at; count++; }
59 }
60 return {
61 glyph: mask ? String.fromCharCode(0x2800 + mask) : ' ',
62 along: count ? along / count : 0,
63 metal: .5 + .5 * Math.sin(col / width * 3.4 + row / height * 2.2),
64 };
65 }));
66 rasters.set(width, result);
67 return result;
68}
69
70// Fixed geometry and deterministic time make resizing and seeking predictable.
71// A single light impulse follows the thread from its solid end to its open ring.
72export function logoFrame(columns: number, elapsedMs: number, reducedMotion = false): LogoCell[][] {
73 const width = Math.max(8, Math.min(32, Math.floor(Number.isFinite(columns) ? columns : 28)));
74 const time = Number.isFinite(elapsedMs) ? Math.max(0, elapsedMs) : LOGO_DURATION_MS;
75 const progress = Math.min(1, time / 2050);
76 const head = -.15 + 1.45 * (progress * progress * (3 - 2 * progress));
77 return raster(width).map(row => row.map(cell => {
78 const distance = cell.along - head;
79 const pulse = reducedMotion || time >= 2050 ? 0
80 : Math.exp(-distance * distance / (distance < 0 ? .022 : .003));
81 const base = [155 + cell.metal * 45, 91 + cell.metal * 49, 58 + cell.metal * 40];
82 const crest = [255, 235, 199];
83 const color = '#' + base.map((v, i) => Math.round(v + (crest[i]! - v) * pulse).toString(16).padStart(2, '0')).join('');
84 return { glyph: cell.glyph, color };
85 }));
86}
87
88/** Fit to the pane body, never to the height of the entire terminal.
89 * Inline panes are intentionally short. Docked panes can show a larger mark.
90 * Missing initial measurements use a conservative bound until the next render.
91 */
92export function logoLayout(columns: number, bodyRows: number | undefined, placement: "dock" | "inline" | null): { columns: number; top: number; left: number; rows: number } | null {
93 const width = Number.isFinite(columns) ? Math.max(0, Math.floor(columns)) : 0;
94 const cap = placement === "dock" ? 18 : 9;
95 const measured = typeof bodyRows === "number" && Number.isFinite(bodyRows) && bodyRows > 0;
96 const room = measured ? Math.min(cap, Math.floor(bodyRows)) : (placement === "dock" ? 14 : 7);
97 // One header row, one breathing row, and at least five rows of artwork.
98 const available = room - 2;
99 const artWidth = Math.min(28, width - 2, Math.floor(available * 850 * 2 / 950));
100 if (artWidth < 8) return null;
101 const height = Math.round(artWidth * 950 / 850 / 2);
102 return { columns: artWidth, left: Math.floor((width - artWidth) / 2), top: 1 + Math.floor((available - height) / 2), rows: height };
103}
104hooks/dashboard-motion.ts 130 lines1import type { SidebarProjection } from "./sidebar-projection.js";
2
3const CARD_MS = 1400;
4const READY_MS = 1800;
5const COUNT_MS = 1000;
6const CONTEXT_MS = 650;
7const MAX_CARD_EFFECTS = 128;
8
9type CardState = { column: string; blocked: boolean };
10type CardEffect = { age: number; ready: boolean };
11const clamp = (n: number): number => Math.max(0, Math.min(1, n));
12const ease = (n: number): number => 1 - (1 - clamp(n)) ** 3;
13
14/** Presentation only. Actual statuses, counts, and percentages always come from the ledger/client. */
15export class DashboardMotion {
16 private cards: Map<string, CardState> | null = null;
17 private root: string | null = null;
18 private cardEffects = new Map<string, CardEffect>();
19 private counts = new Map<string, number>();
20 private working = false;
21 private activityMs = 0;
22 private turnId: string | null = null;
23 private context: number | null = null;
24 private lastContext: number | null = null;
25 private contextTween: { from: number; to: number; age: number } | null = null;
26 private frameMs = 0;
27
28 constructor(readonly enabled = true, private readonly highlights = true) {}
29
30 observe(projection: SidebarProjection, root: string | null): void {
31 const current = new Map<string, CardState>();
32 for (const [column, cards] of Object.entries(projection.board)) {
33 for (const card of cards) current.set(card.key, { column, blocked: card.blocked });
34 }
35 if (root !== this.root) {
36 this.cards = null;
37 this.cardEffects.clear();
38 this.counts.clear();
39 }
40 if (this.enabled && this.cards !== null) {
41 if (this.highlights) {
42 for (const [key, card] of current) {
43 const before = this.cards.get(key);
44 if (!before) continue;
45 if (before.column !== card.column || before.blocked !== card.blocked) {
46 this.cardEffects.delete(key);
47 this.cardEffects.set(key, {
48 age: 0,
49 ready: before.blocked && !card.blocked && card.column !== "done",
50 });
51 if (before.column !== card.column) this.counts.set(card.column, 0);
52 }
53 }
54 }
55
56 }
57 for (const key of this.cardEffects.keys()) if (!current.has(key)) this.cardEffects.delete(key);
58 while (this.cardEffects.size > MAX_CARD_EFFECTS) this.cardEffects.delete(this.cardEffects.keys().next().value!);
59 this.cards = current;
60 this.root = root;
61 }
62
63 setWorking(working: boolean, turnId?: string): void {
64 if (working !== this.working) this.activityMs = 0;
65 this.working = working;
66 if (!working) this.turnId = null;
67 else if (turnId) this.turnId = turnId;
68 }
69
70 finishTurn(turnId?: string, agentId?: string): void {
71 if (agentId || (turnId && this.turnId && turnId !== this.turnId)) return;
72 this.setWorking(false);
73 }
74
75 activity(): { working: boolean; sweep: number | null } {
76 return {
77 working: this.working,
78 sweep: this.working && this.enabled ? Math.floor(this.activityMs / 50) * (50 / 120) : null,
79 };
80 }
81
82 setContext(value: number | null, immediate = false): void {
83 const next = value === null || !Number.isFinite(value) ? null : Math.max(0, Math.min(100, value));
84 if (next === this.context && !immediate) return;
85 const from = this.meterValue() ?? this.lastContext;
86 this.context = next;
87 this.contextTween = this.enabled && !immediate && from !== null && next !== null && from !== next
88 ? { from, to: next, age: 0 } : null;
89 if (next !== null) this.lastContext = next;
90 }
91
92 meterValue(): number | null {
93 if (this.context === null) return null;
94 const tween = this.contextTween;
95 return tween ? tween.from + (tween.to - tween.from) * ease(tween.age / CONTEXT_MS) : this.context;
96 }
97
98 card(key: string): { progress: number | null; ready: boolean; fading: boolean } {
99 const effect = this.cardEffects.get(key);
100 return {
101 progress: effect && effect.age < CARD_MS ? ease(effect.age / CARD_MS) : null,
102 ready: !!effect?.ready,
103 fading: !!effect && effect.age > READY_MS - 300,
104 };
105 }
106
107 count(column: string): boolean { return this.counts.has(column); }
108
109 /** One shared clock; at most 20 redraws/sec while effects run, 20/sec for the smooth lettering gradient. */
110 advance(ms: number): boolean {
111 const hadEffects = this.cardEffects.size > 0 || this.counts.size > 0 || this.contextTween !== null;
112 const beforeActivity = this.activity();
113 if (this.working && this.enabled) this.activityMs = (this.activityMs + ms) % 2400;
114 for (const [key, effect] of this.cardEffects) {
115 effect.age += ms;
116 if (effect.age >= (effect.ready ? READY_MS : CARD_MS)) this.cardEffects.delete(key);
117 }
118 for (const [key, age] of this.counts) {
119 if (age + ms >= COUNT_MS) this.counts.delete(key);
120 else this.counts.set(key, age + ms);
121 }
122 if (this.contextTween) { this.contextTween.age += ms; if (this.contextTween.age >= CONTEXT_MS) this.contextTween = null; }
123 const activity = this.activity();
124 this.frameMs = Math.min(50, this.frameMs + ms);
125 const hasEffects = this.cardEffects.size > 0 || this.counts.size > 0 || this.contextTween !== null;
126 if (hadEffects && (this.frameMs >= 50 || !hasEffects)) { this.frameMs = 0; return true; }
127 return beforeActivity.sweep !== activity.sweep;
128 }
129}
130hooks/sidebar-projection.ts 490 lines1/**
2 * T-508: the ledger sidebar Mod's projection of `.story/`.
3 *
4 * WHY THIS FILE IS HERE AND NOT IN src/core. A Claude Code hooks module
5 * "imports its own files by relative path and 'claude-code', nothing else":
6 * `claude plugin validate` refuses an import from outside the plugin's folder
7 * and refuses any `node:` import, and `src/core`'s status projection reaches
8 * both. So the numbers are computed here, in a file the client will load, and
9 * `test/plugin/sidebar-projection.test.ts` asserts they equal the ones
10 * `storybloq status --compact` prints for the same fixture `.story/`. That
11 * test is the whole of "do not fork the projection": there is one set of
12 * rules, and two implementations that are held equal.
13 *
14 * Every rule below is the CLI's, reproduced deliberately:
15 * - active means `lifecycle` absent or "active"; deleted is out of every count
16 * - a ticket named as another's `parentTicket` is an umbrella and never a leaf
17 * - counts are over leaves only, so an umbrella's own status is ignored
18 * - a phase's status aggregates its leaves: all complete is complete, any
19 * complete or in progress is in progress, otherwise not started
20 * - a blocker reference resolves by id, then displayId, then a previous
21 * displayId; one that is missing or ambiguous counts as blocking, which is
22 * the conservative reading the CLI takes
23 *
24 * Pure: no clock, no I/O, and one import, the plugin's own display-safety
25 * function. The caller reads the files (`$.fs`) and caches what
26 * `extractRecord` returns; this module only counts.
27 */
28
29import { displaySafe } from "./stage-label.js";
30
31/** How long a title may be once cached. The store holds 4 MiB for the whole plugin. */
32const TITLE_CAP = 80;
33
34/** How many handover names the sidebar names below the board. */
35const HANDOVERS_SHOWN = 2;
36
37/** The severities the ledger uses, in the order the sidebar shows them. */
38const SEVERITIES = ["critical", "high", "medium", "low"] as const;
39
40export type LedgerKind = "ticket" | "issue";
41
42export type PhaseStatus = "complete" | "inprogress" | "notstarted";
43
44/** A ticket, reduced to the fields the sidebar counts or shows. */
45export interface SidebarTicket {
46 readonly kind: "ticket";
47 readonly id: string;
48 /** Raw, as the record carries it: absent is null, not the id. The two
49 * resolvers below differ on exactly that. */
50 readonly displayId: string | null;
51 readonly previousDisplayIds: readonly string[];
52 readonly title: string;
53 readonly status: string;
54 readonly phase: string | null;
55 readonly parentTicket: string | null;
56 readonly blockedBy: readonly string[];
57 readonly lifecycle: string | null;
58 readonly order: number;
59}
60
61/** An issue, reduced the same way. */
62export interface SidebarIssue {
63 readonly kind: "issue";
64 readonly id: string;
65 readonly displayId: string | null;
66 readonly previousDisplayIds: readonly string[];
67 readonly title: string;
68 readonly status: string;
69 readonly severity: string;
70 readonly lifecycle: string | null;
71}
72
73export type SidebarRecord = SidebarTicket | SidebarIssue;
74
75export interface SidebarPhase {
76 readonly label?: string;
77 readonly id: string;
78 readonly name: string;
79 readonly status: PhaseStatus;
80 readonly leafCount: number;
81}
82
83/** One ledger item as a board row: what the column shows and how it is marked. */
84export interface SidebarBoardCard {
85 /** Stable identity, independent of reconciled display IDs. */
86 readonly key: string;
87 readonly id: string;
88 readonly title: string;
89 /** An unfinished ticket whose blockedBy still points at something unfinished. */
90 readonly blocked: boolean;
91 /** Which side of the ledger the row came from. */
92 readonly kind: LedgerKind;
93 /** An issue's severity; null on a ticket, which has none. */
94 readonly severity: string | null;
95}
96
97/** Every active leaf and issue appears once, in its recorded status column.
98 * Blocking is a card attribute, so a blocked in-progress story stays there.
99 * Unknown unfinished statuses belong to Open; completed records belong to Done.
100 */
101export interface SidebarBoard {
102 readonly open: readonly SidebarBoardCard[];
103 readonly inProgress: readonly SidebarBoardCard[];
104 readonly done: readonly SidebarBoardCard[];
105}
106
107export interface SidebarTicketRef {
108 readonly id: string;
109 readonly title: string;
110 readonly phase: string | null;
111}
112
113export interface SidebarInput {
114 readonly project: string;
115 readonly phases: readonly { readonly id: string; readonly name: string; readonly label?: string }[];
116 readonly tickets: readonly SidebarTicket[];
117 readonly issues: readonly SidebarIssue[];
118 readonly handoverFilenames: readonly string[];
119}
120
121export interface SidebarProjection {
122 readonly project: string;
123 readonly totalTickets: number;
124 readonly completeTickets: number;
125 readonly openTickets: number;
126 readonly blockedTickets: number;
127 readonly openIssues: number;
128 readonly issuesBySeverity: Readonly<Record<string, number>>;
129 readonly phases: readonly SidebarPhase[];
130 readonly currentPhase: SidebarPhase | null;
131 readonly inProgressTickets: readonly SidebarTicketRef[];
132 readonly board: SidebarBoard;
133 /** The newest handovers, newest first, at most HANDOVERS_SHOWN of them. */
134 readonly latestHandovers: readonly string[];
135}
136
137function asString(value: unknown): string | null {
138 return typeof value === "string" && value.length > 0 ? value : null;
139}
140
141/**
142 * Where a severity sits in the order the board reads them, worst first.
143 *
144 * One the ledger does not use sorts after all of them rather than throwing
145 * the column's order away: this reads a hand-editable file.
146 */
147function severityRank(severity: string): number {
148 const at = (SEVERITIES as readonly string[]).indexOf(severity);
149 return at === -1 ? SEVERITIES.length : at;
150}
151
152function asStringArray(value: unknown): string[] {
153 if (!Array.isArray(value)) return [];
154 const out: string[] = [];
155 for (const entry of value) {
156 if (typeof entry === "string") out.push(entry);
157 }
158 return out;
159}
160
161/**
162 * Reads one ledger file's text into the fields the sidebar keeps, or null when
163 * the text is not a record of that kind.
164 *
165 * Null rather than a throw: the Mod reads whatever is in the directory, and a
166 * half-written file during someone else's transaction must cost one row, not
167 * the pane. The CLI's loader takes the same line (it skips a corrupt entry with
168 * a warning), so skipping here keeps the two projections equal.
169 *
170 * The title is truncated at extraction, not at render: this is what goes into
171 * `$.store`, and a ledger of two thousand untruncated records would spend the
172 * store's whole 4 MiB on prose the pane never shows.
173 */
174export function extractRecord(kind: LedgerKind, text: string): SidebarRecord | null {
175 let parsed: unknown;
176 try {
177 parsed = JSON.parse(text);
178 } catch {
179 return null;
180 }
181 if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
182 const raw = parsed as Record<string, unknown>;
183
184 const id = asString(raw["id"]);
185 const status = asString(raw["status"]);
186 if (id === null || status === null) return null;
187
188 const displayId = asString(raw["displayId"]);
189 // ISS-1306: a title is ledger text any teammate or tool wrote. Made safe to
190 // draw before the cap, so the cap counts what the pane shows.
191 const title = displaySafe(asString(raw["title"]) ?? "").slice(0, TITLE_CAP);
192 const lifecycle = asString(raw["lifecycle"]);
193 const previousDisplayIds = asStringArray(raw["previousDisplayIds"]);
194
195 if (kind === "issue") {
196 const severity = asString(raw["severity"]);
197 if (severity === null) return null;
198 return { kind: "issue", id, displayId, previousDisplayIds, title, status, severity, lifecycle };
199 }
200
201 const orderRaw = raw["order"];
202 return {
203 kind: "ticket",
204 id,
205 displayId,
206 previousDisplayIds,
207 title,
208 status,
209 phase: asString(raw["phase"]),
210 parentTicket: asString(raw["parentTicket"]),
211 blockedBy: asStringArray(raw["blockedBy"]),
212 lifecycle,
213 order: typeof orderRaw === "number" && Number.isFinite(orderRaw) ? orderRaw : 0,
214 };
215}
216
217function isActive(record: { readonly lifecycle: string | null }): boolean {
218 return record.lifecycle === null || record.lifecycle === "active";
219}
220
221/** The CLI's three-step reference resolution, over every ticket including deleted ones. */
222type Resolution =
223 | { readonly kind: "found"; readonly item: SidebarTicket }
224 | { readonly kind: "ambiguous" }
225 | { readonly kind: "missing" };
226
227function buildResolver(tickets: readonly SidebarTicket[]): (ref: string) => Resolution {
228 const byId = new Map<string, SidebarTicket>();
229 const byDisplay = new Map<string, SidebarTicket[]>();
230 const byPrev = new Map<string, SidebarTicket[]>();
231 for (const t of tickets) {
232 // First wins, as the CLI's index does.
233 if (!byId.has(t.id)) byId.set(t.id, t);
234 // buildDisplayIndex: the displayId trimmed, and the id where it is blank
235 // or absent.
236 const trimmedDisplay = (t.displayId ?? "").trim();
237 const displayKey = trimmedDisplay === "" ? t.id : trimmedDisplay;
238 const atDisplay = byDisplay.get(displayKey);
239 if (atDisplay) atDisplay.push(t);
240 else byDisplay.set(displayKey, [t]);
241 for (const prev of t.previousDisplayIds) {
242 const trimmed = prev.trim();
243 if (trimmed === "") continue;
244 const atPrev = byPrev.get(trimmed);
245 if (atPrev) atPrev.push(t);
246 else byPrev.set(trimmed, [t]);
247 }
248 }
249 return (ref: string): Resolution => {
250 const hit = byId.get(ref);
251 if (hit) return { kind: "found", item: hit };
252 const display = byDisplay.get(ref);
253 if (display && display.length === 1) return { kind: "found", item: display[0]! };
254 if (display && display.length > 1) return { kind: "ambiguous" };
255 const prev = byPrev.get(ref);
256 if (prev && prev.length === 1) return { kind: "found", item: prev[0]! };
257 if (prev && prev.length > 1) return { kind: "ambiguous" };
258 return { kind: "missing" };
259 };
260}
261
262/**
263 * Parent references, resolved as `ProjectState`'s own `localResolve` does.
264 *
265 * This is NOT the blocker resolver above, and the difference is load bearing.
266 * `localResolve`'s order, from src/core/project-state.ts:
267 *
268 * if (localById.has(ref)) return ref;
269 * const byDisplay = localByDisplay.get(ref);
270 * if (byDisplay?.length === 1) return byDisplay[0];
271 * const byPrev = localByPrev.get(ref);
272 * if (byPrev?.length === 1) return byPrev[0];
273 * return ref;
274 *
275 * Two things follow that the blocker resolver does the other way. An
276 * AMBIGUOUS displayId does not end the search here: it falls through to the
277 * previous displayIds, so a ref that two tickets currently answer to and one
278 * ticket used to answer to resolves to that one. And the index is built on
279 * the RAW displayId only where a record carries one, with no trimming and no
280 * falling back to the id, so a ticket with no displayId is not indexed under
281 * its own id here even though `buildDisplayIndex` does index it that way.
282 *
283 * A ref that resolves to nothing comes back unchanged, which is what makes an
284 * unresolvable parent name an umbrella id nothing matches: no ticket is
285 * excluded from the leaves by it, and none should be.
286 */
287function buildParentResolver(tickets: readonly SidebarTicket[]): (ref: string) => string {
288 const byId = new Set<string>();
289 const byDisplay = new Map<string, string[]>();
290 const byPrev = new Map<string, string[]>();
291 for (const t of tickets) {
292 byId.add(t.id);
293 if (t.displayId !== null && t.displayId !== "") {
294 const at = byDisplay.get(t.displayId);
295 if (at) at.push(t.id);
296 else byDisplay.set(t.displayId, [t.id]);
297 }
298 for (const prev of t.previousDisplayIds) {
299 const at = byPrev.get(prev);
300 if (at) at.push(t.id);
301 else byPrev.set(prev, [t.id]);
302 }
303 }
304 return (ref: string): string => {
305 if (byId.has(ref)) return ref;
306 const display = byDisplay.get(ref);
307 if (display && display.length === 1) return display[0]!;
308 const prev = byPrev.get(ref);
309 if (prev && prev.length === 1) return prev[0]!;
310 return ref;
311 };
312}
313
314function aggregateStatus(leaves: readonly SidebarTicket[]): PhaseStatus {
315 if (leaves.length === 0) return "notstarted";
316 if (leaves.every((t) => t.status === "complete")) return "complete";
317 const anyProgress = leaves.some((t) => t.status === "inprogress");
318 const anyComplete = leaves.some((t) => t.status === "complete");
319 return anyProgress || anyComplete ? "inprogress" : "notstarted";
320}
321
322/**
323 * The numbers the pane draws, from records the caller already read.
324 *
325 * Held equal to `buildCompactStatusData` by the repo's own test; change a rule
326 * here without changing it there and that test goes red.
327 */
328export function projectSidebar(input: SidebarInput): SidebarProjection {
329 const resolve = buildResolver(input.tickets);
330 const resolveParent = buildParentResolver(input.tickets);
331
332 const activeTickets = input.tickets.filter(isActive);
333 const activeIssues = input.issues.filter(isActive);
334
335 // An umbrella is any ticket another active ticket names as its parent. The
336 // reference is normalized first, so a parent named by displayId still makes
337 // that ticket an umbrella.
338 const umbrellaIds = new Set<string>();
339 for (const t of activeTickets) {
340 if (t.parentTicket === null) continue;
341 umbrellaIds.add(resolveParent(t.parentTicket));
342 }
343
344 const leaves = activeTickets.filter((t) => !umbrellaIds.has(t.id));
345 const completeTickets = leaves.filter((t) => t.status === "complete").length;
346
347 const isBlocked = (t: SidebarTicket): boolean => {
348 for (const ref of t.blockedBy) {
349 const resolved = resolve(ref);
350 if (resolved.kind === "missing" || resolved.kind === "ambiguous") return true;
351 if (resolved.item.lifecycle !== "deleted" && resolved.item.status !== "complete") return true;
352 }
353 return false;
354 };
355
356 const phases: SidebarPhase[] = input.phases.map((p) => {
357 const ofPhase = leaves.filter((t) => t.phase === p.id);
358 return { id: p.id, name: p.name, label: p.label, status: aggregateStatus(ofPhase), leafCount: ofPhase.length };
359 });
360
361 const issuesBySeverity: Record<string, number> = {};
362 for (const severity of SEVERITIES) issuesBySeverity[severity] = 0;
363 let openIssues = 0;
364 for (const i of activeIssues) {
365 if (i.status === "resolved") continue;
366 openIssues += 1;
367 issuesBySeverity[i.severity] = (issuesBySeverity[i.severity] ?? 0) + 1;
368 }
369
370 const inProgressTickets = leaves
371 .filter((t) => t.status === "inprogress")
372 .sort((a, b) => a.order - b.order)
373 .map((t) => ({ id: t.displayId ?? t.id, title: t.title, phase: t.phase }));
374
375 const currentPhase =
376 phases.find((p) => p.status === "inprogress")
377 ?? phases.find((p) => p.status === "notstarted")
378 ?? null;
379
380 // The whole project, every phase: the owner wants the board to be the
381 // ledger's shape, so its counts are the ones `storybloq status` prints. The
382 // column cap is what keeps a thousand complete tickets off the screen, not
383 // a filter that changes what the numbers mean.
384 const boardLeaves = leaves;
385 const card = (t: SidebarTicket): SidebarBoardCard => ({
386 key: `ticket:${t.id}`,
387 id: t.displayId ?? t.id,
388 title: t.title,
389 blocked: t.status !== "complete" && isBlocked(t),
390 kind: "ticket",
391 severity: null,
392 });
393 const issueCard = (i: SidebarIssue): SidebarBoardCard => ({
394 key: `issue:${i.id}`,
395 id: i.displayId ?? i.id,
396 title: i.title,
397 // An issue is never blocked: it carries no blockedBy for anything to
398 // point at.
399 blocked: false,
400 kind: "issue",
401 severity: i.severity,
402 });
403 // Ticket order, then the id as the tie-break, so the columns are stable
404 // between renders and between sessions. Order alone is not enough: the
405 // ledger hands out the same order number freely (every ticket filed without
406 // one shares a default), and Array.prototype.sort is only stable with
407 // respect to INPUT order, which here is directory read order. Done sorts
408 // the other way, newest first, and breaks its ties the other way too.
409 const byOrderAscending = (a: SidebarTicket, b: SidebarTicket): number =>
410 (a.order - b.order)
411 || (a.displayId ?? a.id).localeCompare(b.displayId ?? b.id)
412 // The canonical id has the last word: two tickets can share an order AND
413 // a display id (`storybloq reconcile` exists for exactly that), and
414 // without this they still compare equal and swap on read order.
415 || a.id.localeCompare(b.id);
416 // Issues sort by how much they matter and then by id, in every column: an
417 // issue has no order field to sort on, and severity is the only ranking the
418 // ledger gives. The id is the tie-break for the same reason it is on a
419 // ticket, and the canonical id has the last word for the same reason again.
420 const bySeverityThenId = (a: SidebarIssue, b: SidebarIssue): number =>
421 (severityRank(a.severity) - severityRank(b.severity))
422 || (a.displayId ?? a.id).localeCompare(b.displayId ?? b.id)
423 || a.id.localeCompare(b.id);
424 // Tickets first, then issues: the two are different things and a column
425 // that interleaves them reads as one list of neither.
426 const withIssues = (
427 tickets: readonly SidebarBoardCard[],
428 belongs: (i: SidebarIssue) => boolean,
429 ): readonly SidebarBoardCard[] => [
430 ...tickets,
431 ...activeIssues.filter(belongs).slice().sort(bySeverityThenId).map(issueCard),
432 ];
433 const board: SidebarBoard = {
434 // Open is the REMAINDER, not a status match, on both sides of the ledger.
435 // It is hand-editable JSON and this reads it raw, so a leaf can carry a
436 // status the CLI's enum does not have ("blocked" and "deferred" both
437 // occur) and so can an issue ("closed", "wontfix"); matching on "open"
438 // drops those records out of every column while they still count in
439 // leafCount and openIssues, and the board then does not add up.
440 open: withIssues(
441 boardLeaves
442 .filter((t) => t.status !== "complete" && t.status !== "inprogress")
443 .sort(byOrderAscending)
444 .map(card),
445 (i) => i.status !== "inprogress" && i.status !== "resolved",
446 ),
447 inProgress: withIssues(
448 boardLeaves.filter((t) => t.status === "inprogress").sort(byOrderAscending).map(card),
449 (i) => i.status === "inprogress",
450 ),
451 // Newest first: the last thing finished is the useful one to see, and the
452 // rest is history the ledger already keeps.
453 done: withIssues(
454 boardLeaves
455 .filter((t) => t.status === "complete")
456 .sort(
457 (a, b) =>
458 (b.order - a.order)
459 || (b.displayId ?? b.id).localeCompare(a.displayId ?? a.id)
460 || b.id.localeCompare(a.id),
461 )
462 .map(card),
463 (i) => i.status === "resolved",
464 ),
465 };
466
467 // Names are date-led, so a reverse sort is newest first. It is exact
468 // between two names of the same shape and deterministic always, but it
469 // cannot order a date-only name against a timestamped one from the same day
470 // (2026-01-02-x sorts before 2026-01-02-193000-y whatever their real order).
471 // Reading each file's mtime to do better would cost a $.fs.stat per
472 // handover on every refresh, which is not worth the tie.
473 const latestHandovers = [...input.handoverFilenames].sort().reverse().slice(0, HANDOVERS_SHOWN);
474
475 return {
476 project: input.project,
477 totalTickets: leaves.length,
478 completeTickets,
479 openTickets: leaves.length - completeTickets,
480 blockedTickets: leaves.filter((t) => t.status !== "complete" && isBlocked(t)).length,
481 openIssues,
482 issuesBySeverity,
483 phases,
484 currentPhase,
485 inProgressTickets,
486 board,
487 latestHandovers,
488 };
489}
490