SLOPSHOPPER

Storybloq

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

newpanebandspinnerrowsguard
★ 765v1.16.0PolyForm-Shield-1.0.0updated 2026-10-05Storybloq/storybloq/plugins/storybloq
A shopper browsing a rack in a slop shop
README

<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>


The problem

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.

The idea

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.

  • CLI: storybloq - inspect and mutate .story/ from the terminal.
  • MCP server: structured tools Claude Code and Codex can call directly to read and update the project and guide supported workflows.
  • Skill: invoke /story in Claude Code or $story in Codex to load project state at the start of a session.
  • Mac app: native sidebar that watches .story/ and updates live while your AI client works (separate product, free on the App Store).

From choosing work to the next session

  • Choose the next useful story. Recommendations account for blockers, unfinished work, and what completing a story would unblock. Recorded decisions and a ranked digest of lessons give the agent context for the choice.
  • Build against a reviewed plan. Autonomous mode guides planning, configured independent reviews, tests, and finalization. Approved plan snapshots preserve the approach that advanced to implementation. Confirmed review findings can become durable issues.
  • See what was approved. Know what was checked. Gate acknowledgments in duet workflows can pin acceptance to a plan or staged code state. These records help you inspect the work; they do not guarantee correct code or replace tests, CI, and release checks.
  • Continue with the record intact. Handovers preserve decisions and next steps. Persisted session state supports guarded continuation; automatic recovery depends on the client and the interruption.

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.

Install

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.

Your first two sessions

  1. Open your project in Claude Code or Codex. Type /story in Claude Code chat or $story in Codex chat. For a new project, the skill guides you through setup.
  2. Ask: “Record a story to add an empty calendar state. Record the decision that the API owns availability.”
  3. Before stopping, ask: “Write a handover.”
  4. Start a new chat and invoke /story or $story again to load the recorded project context.

See the work

The optional Mac app shows stories, progress, and handovers from your project files. Explore the Mac app or follow the tutorials.

Add structure as your work grows

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.

Upgrading

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

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.

Bootstrap a project

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" />

Daily use

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.

Usage-limit auto-resume

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.

  • Autonomous sessions are parked on the same recovery lane as compaction and woken headlessly through the full state machine -- ownership rebind, git-HEAD validation, and recovery mapping all apply, so a wake after the workspace changed is validated, not blindly replayed. Sessions stopped mid-FINALIZE are never auto-resumed (commit replay is not proven safe); you get a notification with manual recovery steps instead.
  • Plain sessions get a desktop notification at reset with the exact claude --resume command. Per-project opt-in (limitResume.plainMode: "headless") wakes them headlessly instead.
  • Permission posture is never escalated. A session that ran with --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

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

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.

CLI reference

All commands accept --format json|md (default md). Pipe JSON through jq for scripting, read the markdown variant directly.

Project

CommandDescription
storybloq init [--name] [--type orchestrator] [--force]Scaffold .story/ (add --type orchestrator for multi-repo)
storybloq statusProject 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 NContext-aware work suggestions

Phases

CommandDescription
storybloq phase listAll phases with derived status (status is computed from tickets, never stored)
storybloq phase currentFirst non-complete phase
storybloq phase tickets --phase <id>Leaf tickets for a phase
storybloq phase create --id --name --label --description [--summary] --after/--at-startCreate a phase
storybloq phase rename <id> [--name] [--label] [--description] [--summary]Update phase metadata
storybloq phase move <id> --after/--at-startReorder
storybloq phase delete <id> [--reassign <target>]Delete (reassign contained tickets)

Tickets

CommandDescription
storybloq ticket list [--status] [--phase] [--type]List leaf tickets (umbrellas excluded)

| `storybloq ticket get

Source 10 files
hooks/mod.ts 39 lines
1/**
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}
39
hooks/sidebar.ts 1272 lines
1import { 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 lines
1import { 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}
131
hooks/dashboard-view.ts 738 lines
1import 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}
738
hooks/ledger-write-detection.ts 491 lines
1/**
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. */
491
hooks/terminal-text.ts 122 lines
1/** 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}
122
hooks/stage-label.ts 106 lines
1/**
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}
106
hooks/storyfield-logo.ts 104 lines
1/** 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}
104
hooks/dashboard-motion.ts 130 lines
1import 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}
130
hooks/sidebar-projection.ts 490 lines
1/**
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