SLOPSHOPPER

management-portal

Operate the management-portal MCP with the full agent discipline in one install - now ENFORCED by hooks, not merely advised. You sign in with OAuth on first…

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · management-portal
│ ┃ Portal cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Plan 2: [Commands] 3: Gates 4: Graph │ ┃ Preset: Run (2) ▾ ● management-portal: canon status unreadable (exit 0): dev │ ┃ COMMANDS · Run · 2 shown ⏺ Read(src/auth.ts) │ ┃ [ /portal-cockpit ] … Open the run cockpit: ⎿ Read 6 lines │ ┃ [ /portal-gates ] Open the gate control pane ⏺ Update(src/auth.ts) │ ┃ ┌──────────────────────────────────────────┐ ⎿ Added 2 lines, removed 1 line │ ┃ │ point at a command for its full usage │ ⏺ Bash(bun test) │ ┃ │ │ ⎿ 3 pass, 1 fail │ ┃ │ │ │ ┃ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ └──────────────────────────────────────────┘ │ ┃ [ Choose commands… ] [ Reload ] ✻ Worked for 42s · done 4:20 PM │ │ › /portal-cockpit │ ⎿ management-portal: Portal cockpit opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Portal cockpit
1: Plan 2: [Commands] 3: Gates 4: Graph 5: Board 6: Set Preset: Run (2) ▾ COMMANDS · Run · 2 shown [ /portal-cockpit ] … Open the run cockpit: plan,… [ /portal-gates ] Open the gate control panel: … ┌──────────────────────────────────────────────────────────┐ │ point at a command for its full usage │ │ │ │ │ │ │ └──────────────────────────────────────────────────────────┘ [ Choose commands… ] [ Reload ]
README

management-portal — Claude Code plugin (one-click install)

The Claude Code plugin version of the management-portal operator. Same content as the file-copy bundle (agent-onboarding/bundles/claude-code/), packaged so a user installs it from the Claude Code Plugins marketplace (or two slash commands). You sign in with OAuth — there is no API key to create, paste or store. The plugin ships no userConfig prompt, and its .mcp.json carries no headers at all: just type and url.

Read Install before you click anything

How you connect decides whether the sign-in can complete at all. Claude Code Desktop cannot finish an MCP OAuth sign-in — the "Authenticate" prompt it shows you is a status badge, not a login, and retrying it will never work. The two routes that do finish are the claude.ai custom connector (recommended, no terminal) and an interactive terminal session. Nothing is wrong with the plugin or the server; the Desktop surface simply does not implement the browser step.

The gate arms itself on install

The turn-end gate ships in this plugin and fires from it. Installing is enough — there is nothing to add to .claude/settings.json by hand. SessionStart, PostToolUse and Stop all fire from the plugin's own hooks/hooks.json.

An earlier version of this file said the opposite in a large warning box and told you to hand-add the Stop entry. That was wrong, and it is worth knowing why, because the mistake is easy to repeat: the test that produced it seeded its fixture at a path passed through CLAUDE_PLUGIN_DATA, but Claude Code overrides that variable for plugin hooks and points it at ~/.claude/plugins/data/<plugin>/. The hook fired, read an empty state, correctly stayed silent, and never touched the seeded file — and the untouched file was read as proof it had not run. Produced no visible effect is not the same as did not execute.

Adding the entry to .claude/settings.json as well is harmless but unnecessary. If you do, note that a plugin hook and a settings hook resolve their state directory differently, so they will not share one.


The canon gates (1.9.0) — READ THE ESCAPE FIRST

1.5.0 turns parts of the agent discipline from reminders into hooks that refuse. Before anything else, here is how to turn them off, because someone reading this section is usually reading it because something is blocked.

STAND DOWN — if a gate is blocking work it cannot un-block

Any one of these. The first two take effect immediately, mid-session, with no restart:

# 1. Sentinel file. The gate script writes it; every hook re-reads it on every invocation.
node "<CLAUDE_PLUGIN_ROOT>/scripts/canon-gate.js" stand-down --gate <GATE-ID|all> --reason "why"
node "<CLAUDE_PLUGIN_ROOT>/scripts/canon-gate.js" re-arm     --gate <GATE-ID|all>
# 2. The same thing, typed.
/portal-stand-down [gate id or 'all'] [reason]
# 3. Environment. Fixed for the session's life — that is why 1 and 2 exist.
PORTAL_CANON=off        # every gate silent
PORTAL_CANON=advisory   # nothing refuses or blocks; the turn-end report still prints

Why the primary escape is a file and not a flag. This plugin's author runs defaultMode: bypassPermissions, where a PreToolUse deny is the only brake and there is no permission prompt to click through. So the brake must be something a hook re-reads from disk on every single invocation — otherwise a wrong gate would be unclearable until the session was restarted.

The stand-down invocation is exempt from every gate, including the coordinator gate that otherwise refuses Bash. A gate that can block its own escape is a trap; that exemption is hard-coded.

Sentinels live under <CANON_HOME>/: STAND-DOWN (everything), STAND-DOWN-<run_id> (one run), STAND-DOWN-<GATE-ID> (one gate). Delete the file to re-arm. To end a run outright, delete <CANON_HOME>/runs/by-project/<projhash>.json.

They also stand themselves down. 3 blocks per gate per run (2 for closeout), 12 PostToolUse blocks per session, a dead-man rule that disarms any gate blocking twice with no tool call in between, and a 24-hour no-progress TTL that closes an abandoned run. A stuck session un-sticks itself even if nobody finds this page.

From 1.6.1 the per-run budget is REFUNDED BY PROGRESS, and from 1.6.4 by a NEW SESSION too — a milestone delivered or approved, or a phase completed. It used to be monotonic, which meant a run lasting a working day lost CANON-ACCOUNT by mid-morning and never got it back, and the owner ended up doing the gate's job by hand. A genuinely stuck run still exhausts its three and cannot be trapped; a run that is visibly moving keeps its net.

What actually refuses, and what only advises

The distinction is the whole point of this release, so it is stated in three places and they must agree. skills/management-portal/canon-gates.md is the source of truth; this README and the public /docs/mcp page mirror it. If they disagree, that file wins and the drift is a bug.

VerdictMechanismConsequence
REFUSEPreToolUse → permissionDecision: "deny"The call never runs. Your data is untouched.
BLOCKPostToolUse / Stop → decision: "block"It already happened; you are compelled to act before continuing or ending the turn.
ADVISEText at turn endNothing is prevented.

Refusals cover: a fabricated id (CANON-ID), completing a parent before its subtasks (CANON-BOTTOM-UP), a coordinator implementing instead of delegating (CANON-COORD-ROLE), a participant acting before reading the channel policy (CANON-POLICY-FIRST), skipping the journal at a phase boundary (CANON-JOURNAL-PHASE), destroying a knowledge graph with regenerate_* (CANON-KG-DESTRUCTIVE), editing implementation files before the task tree exists (CANON-TREE-FIRST), writing the brief/proposal before the alignment board (CANON-BOARD-FIRST), a portal write after a phase boundary with the flow board unread (CANON-FLOW-READ), marking a milestone delivered with the task tree unread (CANON-STATUS-SYNC), and — new in 1.9.0 — starting implementation or the first portal write of a phase without consulting the project's knowledge graph (CANON-KG-CONSULT) or writing the phase journal without a "What the graph showed" section (CANON-JOURNAL-GRAPH).

Compulsions cover: read-after-write (CANON-READ-BACK, CANON-READ-BACK-STOP), ending a mid-run turn without an account of yourself (CANON-ACCOUNT), and closing a run without its summary board, graph closure and final journal (CANON-CLOSEOUT).

**Advisories — and they are advisory by design, not by weakness: bulk efficiency (CANON-BULK — a deny on the fourth single write cannot undo the first three), status discipline (CANON-STATUS), and completeness (CANON-COMPLETE — which names empty fields** and never judges what is written in them), and, new in 1.9.0, learning from the graph (CANON-KG-LEARN: a phase passed without extract + interpret; CANON-KG-GAPS: interpretation gaps not turned into a task or a "won't fix" line).

⚠️ Status, as of plugin 1.8.0 — three gates verified live, the rest armed

The engine ships. scripts/canon-gate.js is present, 2496 lines, emits a real PreToolUse permissionDecision: "deny", and hooks/hooks.json wires it into 8 of the 11 hook entries. The advisory portal-gate.js it replaces has been deleted. Every canon gate above therefore reads ARMED — shipped, wired, and fixture-verified by scripts/canon-selftest.js, which spawns the real binary with fixture payloads and drives each gate into its latched state and back out.

THREE ARE NOW VERIFIED LIVE, and the first refusals were WRONG — which is the honest headline. On 2026-08-16 CANON-ID, CANON-READ-BACK and CANON-BOTTOM-UP were all observed refusing real calls in a live session. CANON-ID's first refusals were false: 1.5.0 read a bulk response as a string when it is really content blocks, so every id returned inside a batch was dropped from the seen-id ledger and the gate refused ids the server had just issued. 1.6.0 fixed that; 1.6.1 fixed three more of the same family, including a CANON-BOTTOM-UP refusal naming a "subtask" that was a fragment of the parent's own uuid.

ARMED still is not ENFORCED for the rest, and must not be written up as one. Everything not named above is fixture-verified by scripts/canon-selftest.js (530 assertions as of 1.9.0) and has not been seen refusing a live call. The status board in skills/management-portal/canon-gates.md remains the one place that verdict lives; this box mirrors it and the two are required to agree.

The turn-end Team Chat ABSENT gate (watch-alarm.js) is still the one piece of enforcement observed live: decision: block, verified on Claude Code 2.1.222 and 2.1.85.

Why the distinction is drawn this hard: the failure it guards against already happened here. 1.4.3's portal-gate.js was 61 lines, contained no permissionDecision at all, and was advisory for its entire service life — while being described as a gate.

No card, no gates

Every session opens with a card injected at SessionStart:

[portal-canon v1 · alive · token <6 hex chars>]

If you do not see that line, the gates are not running. Hooks fail open and silently — on both a crash and a timeout the tool call proceeds and the model is told nothing — so a session that looks fine is not evidence that anything ran. A dead gate is indistinguishable from a live one from inside the conversation. That is why the card carries a token, and why the checks below exist.

Check properly, from a terminal:

claude -p "list two files" --output-format stream-json --include-hook-events --verbose

Look for {"type":"system","subtype":"hook_response","hook_name":"PreToolUse:…","exit_code":0,…}. An outcome of "error" means the gate crashed; "cancelled" means it timed out. Both fail open.

node "<CLAUDE_PLUGIN_ROOT>/scripts/canon-gate.js" doctor     # home, run, gates armed, last 5 blocks
node "<CLAUDE_PLUGIN_ROOT>/scripts/canon-gate.js" selftest   # fixture payloads through every mode

Known failure modes — named, not hidden

  • A long listing could never vouch for the id near its end — fixed in 1.8.0. Measured 2026-10-06: list_flow_connections returned 156 connections, each row carrying its own id and the two cluster ids it joins — ~470 uuids. The gate kept at most 300 id fingerprints per read, and the 4 KB ledger line then cut them to 150, so the just-created connection (a60272c4-…, the last marked row) never reached the ledger and its create_flow_connection debt could not clear however often the printed read was run. A read now matches every pending id directly against its full output and writes the ones it found first, where no trim can reach them — memory is bounded by the number of pending ids, not by the length of the listing. The same change makes a deleted id still present at the end of a long listing count as present, so a delete is no longer "verified" because the id fell off the cap; and a bulk row that has to be shortened now loses fingerprints before it loses its inner calls.
  • A board built by a chained bulk latched three gates — fixed in 1.7.9. Measured 2026-10-06, three times in one session: bulk([create_board, create_board_block{board_id:"{{0.id}}"} ×28]) — the documented chaining — made the gate print list_board_blocks("<BLOCK id>"), a call that takes a board id and can never succeed. Two defects under one symptom. The owner id READ_ARG_SOURCE keys on was the unresolved template {{0.id}} — and on a bulk that long the 4 KB ledger line stripped the inner arguments anyway, so every block fell back to keying on its own id. Then read_board and list_board_blocks print block ids without an id: marker, so they reached the ledger only through the wide tier, capped at 20 by keeping both ends: a direct read_board(<board id>) cleared all but the 8 blocks in the middle. Those carried as debt, and CANON-READ-BACK, CANON-READ-BACK-STOP and CANON-DEBT-READ-BACK refused every write — and a Bash re-arm — until three gates were stood down. Now a {{N.id}} owner is resolved against the bulk's own results (the way the backend resolves it) and kept on the row through the strip, a template is never a key, and the id fingerprints cover unmarked uuids too — so one read_board(<board id>) settles every block, including a debt 1.7.8 already left on disk keyed on block ids.
  • The documented knowledge-graph close-out could never pass — fixed in 1.7.8. The canon says to attach sources in ONE call, add_source_to_knowledge_graph(graph_id, items:[…]), but CANON-CLOSEOUT counted source kinds only from a top-level source_type, and the ledger's allow-list drops items. So the documented call scored zero kinds against a rule wanting three. The ledger now keeps a compact item_types (the type strings only — deduped, capped at 20, never an id, title or config) and the gate counts them, from items[] and from create_knowledge_graph's sources[], single-call, recorded bulk and in-bulk replay alike. 1.7.8 also documents the two new source kinds — related earlier graphs (knowledge_graph) and GitHub repos (github_repo, found with list_my_github_repos) — which count like any other kind.
  • Reply and forward owed a read-back and were never asked for one — fixed in 1.7.7. Five tools — reply_to_inbox_message, forward_inbox_message, forward_chat_message, mark_dm_read, transcribe_voice_message — shipped without appearing in the gate's tool inventory or its write→read map, and an unmapped write carries no obligation at all. Reply and forward are the worst possible omission: they carry another person's words and attachment across a hop, and quoting had already been caught dropping attachments while keeping their labels — a lossy quote that looked complete. They now owe the read that can actually settle them: read_inbox for an inbox reply or forward, read_channel_messages or read_dm_messages for a forwarded chat message, and read_dm_conversations for mark_dm_read, because the unread count moving is the effect. transcribe_voice_message is inventoried but deliberately unmapped — it is read-shaped. Found by an independent acceptance pass, not by the gate.
  • The settling call a gate printed could not settle anything — fixed in 1.7.6. At turn end the gate names the one bulk that clears what is owed, and it built that call from the id the WRITE returned instead of the id the READ takes. So it printed list_subtasks("<the subtask just created>") — which answers "No subtasks found", because a fresh leaf has no children by construction — and get_proposal_detail("<proposal id>"), a read that takes a project id. Eight reads ran cleanly, settled nothing, and looked exactly like data loss; nothing was lost. It was the third instance of one defect, so the rule is now data, not a fourth hand-fix: READ_ARG_SOURCE names, per write, the argument its read is called with, and the obligation keys on that owner id. The cost, stated: two subtasks under one parent share one obligation, so one list_subtasks(parent) settles both — that listing is the proof both exist.
  • A restart, or a sub-agent, lost the decomposition — fixed in 1.6.3. CANON-TREE-FIRST counted the four decomposition calls in the SESSION's tool stream, but a run outlives a session. Restart Claude Code mid-run and a task tree built an hour earlier became invisible: every source write was refused, with the reason insisting the run had "no recorded create_task" while the tasks sat in the portal. It was worse for sub-agents, which is how it surfaced — three lanes blocked at once. A fresh sub-agent's stream contains no portal writes at all, and must not, because the canon explicitly says to keep portal writes in the parent session. The canon asked for something it then refused to accept. The decomposition is now recorded on the RUN, so it survives a restart and is visible to a sub-agent; a run that genuinely has no tree is still refused.
  • A > inside quotes was read as a redirection — fixed in 1.6.2. node -e '... a - b > 3600000 ...' was refused by CANON-TREE-FIRST as "writes 3600000 (via a redirection)". The > is a comparison inside a quoted argument and no shell would redirect there. The nuisance was not the cost: the same scan decides whether a command CHANGES STATE, so any quoted > also made a read-only command look like a write, which is the CANON-COORD-ROLE premise too. Quoted spans are now masked before the operator is located, while a genuinely quoted target — > "my file.txt" — is still read correctly.
  • And a RESTART did not restore it either — fixed in 1.6.4. The run outlives the session, so a run that spent its three blocks in the morning carried a silenced CANON-ACCOUNT through every restart for the rest of the day. A new session now refunds the budget. Safe, because the hazard the cap exists for cannot cross that boundary: a gate stuck in a refuse-loop is stuck within one session's tool stream, and a restart ends that stream.
  • A long run used to lose its safety net permanently — fixed in 1.6.1, and worth knowing why. Each gate has a per-run block budget (3), after which it degrades to a notice so it can never trap a session that genuinely cannot proceed. That counter was monotonic, so on a run lasting a working day CANON-ACCOUNT — the gate whose whole job is refusing a silent stop mid-run — was a notice by mid-morning and never came back. Compounding it, a phase boundary was only recorded for update_milestone_status while the tool everyone actually uses is update_proposal_milestone, so a run could deliver milestone after milestone and still report phase boundaries recorded 0. The gate went quiet and the evidence that it should not have was never written down. The budget is now refunded by progress — a milestone delivered or approved, or a phase completed — so a moving run keeps its net while a stuck one still exhausts its three.
  • A session that dies mid-run leaves the run open. The run manifest lives on disk keyed by project (which is what lets "keep going" survive compaction and a restart), so later sessions in that project spend one block per turn until the 24-hour no-progress TTL fires, or someone runs /portal-stand-down, or the run is closed. Mitigated five ways; not removed.
  • Two install paths keep two ledgers. CLAUDE_PLUGIN_DATA differs between a marketplace install and a --plugin-dir run, so a run opened under one is invisible to the other. Set PORTAL_CANON_HOME to the same path in both to force a single ledger. Fixed in 1.6.0 for the case that actually bit: CLAUDE_PLUGIN_DATA is set for a hook and unset for a Bash call, and /portal-continue starts by running run-promote from Bash — so the run went to one home and every gate read another, and the card said no run declared no matter how many times it was promoted. With no env set the CLI now discovers the live home rather than assuming a folder name. Run doctor: it prints the resolved path and which rule chose it.
  • On OAuth installs the one Stop-time portal read frequently returns 401 (watch-alarm returned http_401 seven turns running on the author's machine). The turn-end reports then fall back to local ledger evidence and label themselves "local evidence, not a verdict" rather than pretending. The read-back gates and CANON-ACCOUNT are unaffected — they read the tool stream, not the network.
  • A hook that crashes or times out fails open. Deliberate: a crashing gate must never be the reason your turn fails. It also means silence proves nothing.

The honest limits

  • A hook can refuse a call and refuse to end a turn. It cannot make an idle session resume. So "never stop between phases" ships as "never end a turn silently mid-run" — the enforceable form. Measured: a coordinator sat idle for 3h06m while no hook misfired, because an idle session ends no turn. These two sentences are not the same and this documentation does not blur them.
  • Gates judge STRUCTURE, never QUALITY. "A brief exists and was read back" is checkable. "The brief is good" is not. canon-gates.md ships a verbatim list of what no hook can ever check, so that nobody later builds a gate that pretends.
  • Refusal reasons are curt on purpose. Measured twice: instructions inside a PreToolUse deny reason are read as prompt injection and deliberately not followed. So refusals state facts only, and every clearing action is published through the channels the model trusts — the canon card, block reasons, and the command bodies.

Privacy — what the ledger keeps

The gates work from a local append-only ledger, not from network calls on the hot path. It is a plaintext file with ordinary user permissions. It records tool names, uuids, booleans, durations and a fixed allow-list of scalar id/status arguments — and it never records titles, bodies, descriptions, messages, objectives, deliverables, acceptance criteria, lessons, moods, reflections, or any prompt or response text. The journal is somebody's private account of how their days actually went; the ledger stores that an entry happened and never a word of what it said.


What it installs

ComponentWhat it does
management-portal MCP serverRegisters the remote MCP (https://…/mcp). Auth is OAuth 2.1 + PKCE — no key, no headers.
management-portal skillThe operable how-to; auto-triggers on any portal work.
reference.mdLoad-on-demand deep reference (playbook + write→read map + board-first).
portal-operator subagentOperates the portal under the discipline (portal tools only).
/portal commandDispatches the portal-operator subagent for a disciplined run.
canon-gates.mdThe source of truth for what is enforced vs advisory — the gate register, the status board, the escape hatch, the honest limits.
scripts/canon-gate.jsThe canon gates: PreToolUse refusals, PostToolUse/Stop compulsions, turn-end advisories, plus stand-down, doctor and selftest modes.
/portal-project, /portal-continueStart a disciplined run (client + project as arguments), and resume it without stopping between phases.
/channel-coordinate, /channel-joinJoin a Team Chat channel as coordinator or participant, under a name you choose.
/portal-stand-down commandThe escape. Stands one gate — or all of them — down, mid-session.
/portal-rearm commandThe way back. Re-arms them and names anything left off. The sentinels survive restarts, so a stand-down that was right on a broken build stays in force until this is typed.
/plain-english commandRe-explains the current work in plain language — short, jargon-free, decision-first — and holds that register for the rest of the session. For handing a status to someone who does not work on the code. It changes how things are said, never what is true.

| canon gate hooks | PreToolUse and PostToolUse on matcher .*, plus SessionStart, UserPromptSubmit, SubagentStart/SubagentStop, Stop and SessionEnd — all routed through canon-gate.js, which carries a real permissionDecision. **The 1.4.3 portal-gate.js, which carrie

Source 3 files
hooks/mods/register.tsx 1381 lines
1// register.tsx — the management-portal MOD (plugin 1.9.0). OPTIONAL EXTRAS, NEVER ENFORCEMENT.
2//
3// Claude Code >= 2.1.287 (the Desktop Code tab from 2.1.286) loads this module through
4// `hooks/hooks.json` → `modules`. An older Claude Code ignores that key and runs the plugin
5// exactly as 1.7.x did. Every gate still lives in the COMMAND hooks (scripts/canon-gate.js,
6// scripts/watch-alarm.js): a mod hook that throws fails OPEN, so nothing in this file is ever
7// relied on to refuse or block anything.
8//
9// What it adds (README "Mods" lists exactly what each reads and writes):
10//   RUN COCKPIT pane (/portal-cockpit; opens by itself on a wide Desktop): the run's PLAN —
11//     proposal phases → milestones → tasks → subtasks, with progress bars (SVG on the Desktop,
12//     text in the terminal), Completed vs Remaining, the current phase, deadlines and overdue,
13//     hours and cost delivered vs planned, what the canon owes and which gates are stood down.
14//     Tabs: Plan · Gates (re-arm / stand down, with a reason) · Graph (the latest
15//     interpret_knowledge_graph: hubs, gaps) · Board (the last board read) · Settings.
16//   STATUS LINE: run · phase n/N · overall % with a mini bar · gates armed/stood · owed reads.
17//   BAND above the prompt: what is owed now (button: ask Claude to run the settling read), the
18//     idle-run countdown (Continue now / Not now), and the timer on the current task.
19//   CARDS for portal tool results (tasks, milestones, proposal, boards, graphs, inbox, DMs, bulk),
20//     with a progress bar and "raw" one press away.
21//   NOTIFICATIONS: toasts for new unread DMs, unread inbox mail, approvals waiting, tasks due.
22//   TEAM CHAT WAKE-UP: polls the watched channel and starts a turn when this agent is @-addressed.
23//   IDLE-RUN NUDGE: a run in RUN state with milestones remaining, idle N minutes (default 10)
24//     with no background work → a toast, a 60-second countdown in the band, then a "continue
25//     the run" prompt. Default ON only for runs started with /portal-continue; capped at three
26//     nudges without progress. This closes the documented "no hook can restart an idle session".
27//   COMPACT RUN CONTEXT: a few lines about the run added to the first message (prompt.context).
28//
29// The portal server is DISCOVERED, never hard-coded: the server Claude's own portal calls ran
30// on (classic.PostToolUse names it — in the Desktop Code tab that is the claude.ai connector,
31// `mcp__<uuid>__…`), the one remembered from an earlier session, the plugin's own server, and
32// any server whose tool list carries the portal's signature. When the mod cannot reach any of
33// them itself, the plan is still drawn from the portal results Claude reads in the session.
34//
35// Writes: its own $.state and $.store values; the canon sentinels through canon-gate.js
36// stand-down / re-arm ONLY when the person presses those buttons; and start_timer / stop_timer
37// ONLY when the person presses the timer button. Every other portal call it makes is a READ.
38
39import { atom, read, update } from 'claude-code'
40import type { EngineInterface, Register, RenderNode } from 'claude-code'
41import type {
42  BoardView, CanonStatus, CommandInfo, GateRow, KgView, PortalCockpit, PortalIdle, PortalLauncher, PortalPrefs,
43  PortalTimer, PortalView, PortalWatch, TaskRow,
44} from '../../types'
45import {
46  type Card, type ChatMessage, type Plan,
47  CARD_TOOLS, addresses, buildPlan, cardFor, classOf, clip, currentTask, dueTasks, gatesFromStatus, glyph, helmosLink,
48  looksLikePortal, money, newNotes, nudgeDecision, nudgeEnabled, nudgePrompt, owedNow, parseBoard, parseCanonStatus,
49  parseDmConversations, parseDoctor, parseInbox, parseKgInterpretation, parseMessages, parseProposalDetail,
50  parseSchedulingRequests, parseSubtasks, parseTasks, parseTimerEntry, portalServersFrom, portalServersFromBreakdown,
51  progressMark, resultText, round1, rowLine, runContext, settlePrompt, splitMcpName, statusLine, svgBar, svgPlan,
52  textBar, todayOf, wakePrompt, NUDGE_GRACE_MS,
53  BUILTIN_PRESETS, PRESET_RUN, builtinPresets, phaseCounts, parseCommandFile, presetCommands, presetNameOk,
54} from './portal-view'
55
56const PANE = 'portal-cockpit'
57const CANON_EVERY_MS = 10_000
58const PLAN_EVERY_MS = 120_000
59const WATCH_EVERY_MS = 45_000
60const NOTIFY_EVERY_MS = 180_000
61const IDLE_EVERY_MS = 15_000
62const BG_MAX_MS = 30 * 60_000
63const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
64const PORTAL_WRITES = /^(create_|update_|add_|complete_task|delete_|reorder_|set_|start_timer|stop_timer|log_time|promote_|attach_|move_|pin_|tag_|bulk$)/
65const CONTINUE_RE = /^\s*\/(?:management-portal:)?portal-continue\b/i
66
67const DEFAULT_PREFS: PortalPrefs = { rich: true, band: true, watch: true, notify: true, autoOpen: true, nudge: 'auto', nudgeMinutes: 10, helmosUrl: null }
68const DEFAULT_IDLE: PortalIdle = { continueMode: false, lastActivityAt: 0, working: false, pendingSince: 0, nudges: 0, lastNudgeAt: 0, progressMark: '' }
69
70const canonA = atom({ plugin: 'management-portal', key: 'canon' } as const, null)
71const cockpitA = atom({ plugin: 'management-portal', key: 'cockpit' } as const,
72  { loading: false, at: 0, error: null, projectId: null, proposal: null, tasks: null, server: null, via: null })
73const prefsA = atom({ plugin: 'management-portal', key: 'prefs' } as const, DEFAULT_PREFS)
74const bandA = atom({ plugin: 'management-portal', key: 'band' } as const, { hidden: false, asked: '' })
75const rawA = atom({ plugin: 'management-portal', key: 'raw' } as const, [])
76const watchA = atom({ plugin: 'management-portal', key: 'watch' } as const, null)
77const serverA = atom({ plugin: 'management-portal', key: 'server' } as const, { name: null, how: 'not looked yet', tried: [], error: null, at: 0 })
78const viewA = atom({ plugin: 'management-portal', key: 'view' } as const, { tab: 'plan', showDone: false })
79const gatesA = atom({ plugin: 'management-portal', key: 'gates' } as const, { rows: null, reason: '', busy: null, last: null, at: 0 })
80const kgA = atom({ plugin: 'management-portal', key: 'kg' } as const, null)
81const boardA = atom({ plugin: 'management-portal', key: 'board' } as const, null)
82const timerA = atom({ plugin: 'management-portal', key: 'timer' } as const, null)
83const notifyA = atom({ plugin: 'management-portal', key: 'notify' } as const, { items: [], lastPollAt: 0, error: null })
84const idleA = atom({ plugin: 'management-portal', key: 'idle' } as const, DEFAULT_IDLE)
85const launcherA = atom({ plugin: 'management-portal', key: 'launcher' } as const,
86  { list: null, presets: {}, active: PRESET_RUN, editing: null, presetName: '', form: null, last: null })
87
88// Caches only — nothing a drawing reads. A fresh environment simply rediscovers them.
89let canonInFlight: Promise<void> | null = null
90let planInFlight: Promise<void> | null = null
91let planReloadQueued = false
92let lastStatus: string | undefined
93let observed: string[] = []
94let goodServer: string | null = null
95let watchBusy = false
96let notifyBusy = false
97let doctorTick = 0
98let autoOpened = false
99let lastContextKey = ''
100const bg = new Map<string, number>()
101
102export const register: Register = (on) => {
103  on('session.start', async ($, e, next) => {
104    const started = await next(e)
105    await loadStored($)
106    for (const c of [
107      { name: 'portal-cockpit', description: 'Portal run cockpit: plan, phases, milestones, tasks with progress bars, done vs remaining, gates (management-portal mod)', argumentHint: '[project-id | gates | graph | board | settings | watch <channel> <agent> | unwatch | nudge on|off|auto|<minutes> | url <https://…>]' },
108      { name: 'portal-gates', description: 'Portal canon gate control panel: every gate armed or stood down, with re-arm and stand-down buttons (management-portal mod)' },
109      { name: 'portal-commands', description: 'A button for every management-portal command, with saved presets of which ones to show (management-portal mod)' },
110    ]) {
111      try { await $.command.register({ ...c, immediate: true }) } catch (_) { /* a name clash must not take the rest of the mod down */ }
112    }
113    void loadCommands($)
114    $.clock.every(CANON_EVERY_MS, () => { void refreshCanon($) })
115    $.clock.every(PLAN_EVERY_MS, () => { void loadPlan($, null, false) })
116    $.clock.every(WATCH_EVERY_MS, () => { void pollWatch($) })
117    $.clock.every(NOTIFY_EVERY_MS, () => { void pollNotify($) })
118    $.clock.every(IDLE_EVERY_MS, () => { void idleTick($) })
119    $.clock.after(500, () => { void refreshCanon($).then(() => loadPlan($, null, false)).then(() => maybeAutoOpen($)) })
120    $.clock.after(20_000, () => { void pollNotify($) })
121    return started
122  })
123
124  // /clear, /resume and /branch reset every $.state value and fire no session.start.
125  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
126    const r = await next(e)
127    try { await loadStored($); void refreshCanon($).then(() => loadPlan($, null, false)) } catch (_) { /* convenience */ }
128    return r
129  }).catch(($, e, next) => next(e))
130
131  // Observe, never decide. classic.PostToolUse runs after the chain (the plugin's own command
132  // PostToolUse hooks, canon-gate post among them, answer exactly as before). It names the MCP
133  // server a portal call ran on, and carries the result the model read.
134  on('classic.PostToolUse', async ($, e, next) => {
135    const answered = await next(e)
136    try { await observeResult($, e as unknown as PostEvent) } catch (_) { /* observation only */ }
137    return answered
138  }).catch(($, e, next) => next(e))
139
140  // Pass-through: background work is remembered so the idle nudge never fires over it.
141  on('tool.call', async ($, e, next) => {
142    try {
143      const input = e as unknown as Record<string, unknown>
144      const tool = String(e.tool)
145      if ((tool === 'Bash' || tool === 'PowerShell') && input.run_in_background === true) bg.set(e.tool_use_id, await $.clock.now())
146      if (tool === 'Monitor') bg.set(e.tool_use_id, await $.clock.now())
147      if (tool === 'Skill' && /portal-continue/.test(String(input.skill || input.command || ''))) await setContinueMode($, true)
148    } catch (_) { /* observation only */ }
149    return next(e)
150  }).catch(($, e, next) => next(e))
151
152  on('classic.UserPromptSubmit', async ($, e, next) => {
153    const r = await next(e)
154    try {
155      const text = String((e as unknown as { prompt?: string }).prompt || '')
156      if (CONTINUE_RE.test(text)) await setContinueMode($, true)
157      const now = await $.clock.now()
158      await update($, idleA, (i) => ({ ...i, lastActivityAt: now, pendingSince: 0 }))
159    } catch (_) { /* observation only */ }
160    return r
161  }).catch(($, e, next) => next(e))
162
163  on('turn.start', async ($, e, next) => {
164    try { const now = await $.clock.now(); await update($, idleA, (i) => ({ ...i, working: true, pendingSince: 0, lastActivityAt: now })) } catch (_) { /* fine */ }
165    return next(e)
166  })
167
168  on('turn.complete', async ($, e, next) => {
169    const done = await next(e)
170    try {
171      if (!(e as unknown as { agentId?: string }).agentId) {
172        const now = await $.clock.now()
173        await update($, idleA, (i) => ({ ...i, working: false, lastActivityAt: now }))
174      }
175    } catch (_) { /* fine */ }
176    $.clock.after(300, () => { void refreshCanon($) })
177    return done
178  })
179
180  // The compact run context: a few lines on the first message, beside (never instead of) the
181  // canon card the command hooks print. Re-rendered only when the run or its phase changes.
182  on('prompt.context', async ($, e, next) => {
183    const r = await next(e)
184    const s = await read($, canonA)
185    const plan = planOf(await read($, cockpitA), await $.clock.now())
186    const text = runContext(s, plan)
187    if (!text) return r
188    return { ...r, blocks: [...r.blocks.filter((b) => b.name !== 'portalRun'), { name: 'portalRun', text }] }
189  }).catch(($, e, next) => next(e))
190
191  on('command.run', { command: 'portal-commands' }, async ($) => {
192    await update($, viewA, (v) => ({ ...v, tab: 'commands' as const }))
193    void loadCommands($)
194    await $.ui.open({ id: PANE, title: 'Portal cockpit' })
195    return { text: 'Portal command launcher opened.' }
196  })
197
198  on('command.run', { command: 'portal-gates' }, async ($) => {
199    await update($, viewA, (v) => ({ ...v, tab: 'gates' as const }))
200    await $.ui.open({ id: PANE, title: 'Portal cockpit' })
201    void refreshGates($)
202    return { text: 'Portal gate control panel opened.' }
203  })
204
205  on('command.run', { command: 'portal-cockpit' }, async ($, e) => {
206    const raw = String(e.args || '').trim()
207    const args = raw.split(/\s+/).filter(Boolean)
208    const a0 = (args[0] || '').toLowerCase()
209    if (a0 === 'watch' && args[1] && args[2]) {
210      await rememberWatch($, args[1], args.slice(2).join(' '))
211      return { text: `Team Chat wake-up will poll channel ${args[1]} as ${args.slice(2).join(' ')} every ${WATCH_EVERY_MS / 1000}s.` }
212    }
213    if (a0 === 'unwatch') {
214      await update($, watchA, () => null)
215      try { await $.store.delete('watch') } catch (_) { /* fine */ }
216      return { text: 'Team Chat wake-up stopped. The team-chat-watcher subagent is unaffected.' }
217    }
218    if (a0 === 'nudge' && args[1]) {
219      const v = args[1].toLowerCase()
220      if (v === 'on' || v === 'off' || v === 'auto') { await setPrefs($, { nudge: v }); return { text: `Idle-run nudge: ${v}.` } }
221      const n = parseInt(v, 10)
222      if (n >= 1 && n <= 240) { await setPrefs($, { nudgeMinutes: n }); return { text: `Idle-run nudge after ${n} idle minute(s).` } }
223      return { text: 'Usage: /portal-cockpit nudge on|off|auto|<minutes>' }
224    }
225    if (a0 === 'url') {
226      const u = args[1] || ''
227      if (u === 'off') { await setPrefs($, { helmosUrl: null }); return { text: 'HelmOS links off.' } }
228      if (!/^https:\/\/[a-z0-9.-]+(?::\d+)?(\/[\w./-]*)?$/i.test(u)) return { text: 'Usage: /portal-cockpit url https://<your HelmOS web address>' }
229      await setPrefs($, { helmosUrl: u.replace(/\/+$/, '') })
230      return { text: `HelmOS links point at ${u}.` }
231    }
232    if (['rich', 'band', 'watch', 'notify', 'autoopen'].includes(a0) && (args[1] === 'on' || args[1] === 'off')) {
233      const key = (a0 === 'autoopen' ? 'autoOpen' : a0) as 'rich' | 'band' | 'watch' | 'notify' | 'autoOpen'
234      await setPrefs($, { [key]: args[1] === 'on' } as Partial<PortalPrefs>)
235      return { text: `portal mod: ${a0} ${args[1]}` }
236    }
237    if (['plan', 'commands', 'gates', 'graph', 'board', 'settings'].includes(a0)) await update($, viewA, (v) => ({ ...v, tab: a0 as PortalView['tab'] }))
238    let projectId: string | null = null
239    if (UUID_RE.test(args[0] || '')) {
240      projectId = args[0]!
241      await update($, cockpitA, (c) => ({ ...c, projectId, proposal: c.projectId === projectId ? c.proposal : null, tasks: c.projectId === projectId ? c.tasks : null }))
242      try { await $.store.set('lastProject', projectId) } catch (_) { /* fine */ }
243    }
244    const opened = await $.ui.open({ id: PANE, title: 'Portal cockpit' })
245    void refreshCanon($).then(() => loadPlan($, projectId, true))
246    if (a0 === 'gates') void refreshGates($)
247    const placed = opened && typeof opened === 'object' && 'isPlaced' in opened ? (opened as { isPlaced: boolean }).isPlaced : true
248    return { text: placed ? 'Portal cockpit opened.' : 'Portal cockpit is open but not placed yet — widen the window.' }
249  })
250
251  on('ui.close', async ($, e, next) => {
252    if ((e as unknown as { id?: string }).id === PANE) autoOpened = true
253    return next(e)
254  }).catch(($, e, next) => next(e))
255
256  // ---------------------------------------------------------------------------------------
257  // The cockpit pane
258  // ---------------------------------------------------------------------------------------
259  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
260    const els = $.ui.resolve(e) as unknown as Els
261    const { Box, Text, Button } = els
262    const cols = Math.max(30, (e.props.bodyColumns || 80) - 2)
263    const desktop = e.surface === 'desktop' || e.surface === 'mobile'
264    const view = await read($, viewA)
265    const s = await read($, canonA)
266    const cockpit = await read($, cockpitA)
267    const prefs = await read($, prefsA)
268    const now = await $.clock.now()
269    const plan = planOf(cockpit, now)
270    const tabs: [PortalView['tab'], string, string][] = [['plan', 'Plan', '1'], ['commands', 'Commands', '2'], ['gates', 'Gates', '3'], ['graph', 'Graph', '4'], ['board', 'Board', '5'], ['settings', 'Settings', '6']]
271    const tabRow = (
272      <Box key="tabs" flexDirection="row" columnGap={2}>
273        {tabs.map(([id, label, hk]) => (
274          <Button key={'tab-' + id} label={view.tab === id ? `[${label}]` : label} hotkey={hk} plain dimColor={view.tab !== id}
275            onPress={() => { void update($, viewA, (v) => ({ ...v, tab: id })); if (id === 'gates') void refreshGates($) }} />
276        ))}
277      </Box>
278    )
279    let body: RenderNode
280    if (view.tab === 'gates') body = await gatesBody($, els, s, cols)
281    else if (view.tab === 'graph') body = await graphBody($, els, prefs, cockpit)
282    else if (view.tab === 'board') body = await boardBody($, els, prefs, cols)
283    else if (view.tab === 'settings') body = await settingsBody($, els, prefs)
284    else if (view.tab === 'commands') body = await commandsBody($, els, cols, desktop)
285    else body = await planBody($, els, { s, cockpit, plan, view, cols, desktop, now })
286    return (
287      <Box key="cockpit" flexDirection="column">
288        {tabRow}
289        {body}
290      </Box>
291    )
292  })
293
294  // ---------------------------------------------------------------------------------------
295  // The band above the prompt: owed read-backs, the idle-run countdown, the timer.
296  // ---------------------------------------------------------------------------------------
297  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
298    const prefs = await read($, prefsA)
299    if (!prefs.band || e.props.hasSurvey) return next(e)
300    const s = await read($, canonA)
301    const band = await read($, bandA)
302    const idle = await read($, idleA)
303    const timer = await read($, timerA)
304    const cockpit = await read($, cockpitA)
305    const now = await $.clock.now()
306    const o = owedNow(s)
307    const plan = planOf(cockpit, now)
308    const runOn = Boolean(s && s.run && s.run.state === 'RUN')
309    const task = runOn ? currentTask(plan) : null
310    const showOwed = !band.hidden && (o.count > 0 || o.journal)
311    const showIdle = idle.pendingSince > 0
312    const showTimer = Boolean(timer && timer.entryId) || (!band.hidden && runOn && Boolean(task))
313    if (!showOwed && !showIdle && !showTimer) return next(e)
314    const { Box, Text, Button } = $.ui.resolve(e)
315    const width = Math.max(30, (e.props.bodyColumns || 80) - 2)
316    const rows: RenderNode[] = []
317    if (showIdle) {
318      const left = Math.max(0, Math.ceil((NUDGE_GRACE_MS - (now - idle.pendingSince)) / 1000))
319      rows.push(
320        <Box key="idle" flexDirection="row" columnGap={1}>
321          <Text key="idle-t" color="cyan" wrap="truncate-end">{`Idle ${prefs.nudgeMinutes}m · continues in ${left}s`}</Text>
322          <Button key="idle-now" label="Continue now" variant="primary" onPress={() => { void fireNudge($) }} />
323          <Button key="idle-not" label="Not now" onPress={() => { void snoozeNudge($) }} />
324        </Box>,
325      )
326    }
327    if (showOwed) {
328      const what = o.count
329        ? `portal canon owes ${o.count} read-back${o.count === 1 ? '' : 's'}: ${o.writes.slice(0, 3).join(', ')}${o.writes.length > 3 ? '…' : ''}`
330        : 'portal canon: a journal entry is owed since the last phase boundary'
331      const settle = o.settle
332      const asked = settle !== null && band.asked === settle
333      rows.push(<Text key="what" color="yellow" wrap="truncate-end">{clip(what, width)}</Text>)
334      if (settle) rows.push(<Text key="call" dimColor wrap="truncate-end">{settle}</Text>)
335      rows.push(
336        <Box key="acts" flexDirection="row" columnGap={1}>
337          {settle && !asked ? (
338            <Button key="settle" label="Ask Claude to run the settling read" variant="primary" onPress={() => {
339              void update($, bandA, (b) => ({ ...b, asked: settle }))
340              void $.prompt.submit({ text: settlePrompt(settle) })
341              $.ui.toast('Asked Claude to run the settling read')
342            }} />
343          ) : null}
344          {asked ? <Text key="asked" dimColor>asked — waiting for the read</Text> : null}
345          <Button key="hide" label="Hide" onPress={() => { void update($, bandA, (b) => ({ ...b, hidden: true })) }} />
346        </Box>,
347      )
348    }
349    if (showTimer) {
350      if (timer && timer.entryId) {
351        rows.push(
352          <Box key="timer" flexDirection="row" columnGap={1}>
353            <Text key="timer-t" color="green" wrap="truncate-end">{`⏱ timer running since ${clock(timer.startedAt)} on ${clip(timer.taskTitle, Math.max(10, width - 40))}`}</Text>
354            <Button key="timer-stop" label={timer.busy ? 'stopping…' : 'Stop timer'} onPress={() => { void stopTimer($) }} />
355          </Box>,
356        )
357      } else if (task) {
358        rows.push(
359          <Box key="timer" flexDirection="row" columnGap={1}>
360            <Text key="timer-t" dimColor wrap="truncate-end">{`⏱ ${clip(task.title, Math.max(10, width - 30))}`}</Text>
361            <Button key="timer-start" label={timer && timer.busy ? 'starting…' : 'Start timer'} onPress={() => { void startTimer($, task.id, task.title) }} />
362            {timer && timer.error ? <Text key="timer-err" color="red">{clip(timer.error, 40)}</Text> : null}
363          </Box>,
364        )
365      }
366    }
367    const max = Math.max(1, e.props.maxRows || 6)
368    return <Box key="portal-band" flexDirection="column">{rows.slice(0, max)}</Box>
369  })
370
371  // ---------------------------------------------------------------------------------------
372  // Cards for portal tool results. Anything we do not recognise draws as the engine draws it.
373  // ---------------------------------------------------------------------------------------
374  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
375    const prefs = await read($, prefsA)
376    if (!prefs.rich || e.props.isErrored) return next(e)
377    const p = splitMcpName(String(e.props.tool))
378    if (!p || !CARD_TOOLS.has(p.tool) || !isPortalServer(p.server)) return next(e)
379    const id = String(e.props.tool_use_id || e.requestId || '')
380    const rawIds = await read($, rawA)
381    const els = $.ui.resolve(e) as unknown as Els
382    const { Box, Text, Button } = els
383    if (rawIds.includes(id)) {
384      const theirs = await next(e)
385      return (
386        <Box key="raw-wrap" flexDirection="column">
387          <Button key="card" label="card" plain onPress={() => { void update($, rawA, (l) => l.filter((x) => x !== id)) }} />
388          {theirs}
389        </Box>
390      )
391    }
392    const card: Card | null = cardFor(p.tool, resultText(e.props.output))
393    if (!card) return next(e)
394    const width = Math.max(30, (e.viewport?.columns || 100) - 8)
395    const desktop = e.surface === 'desktop' || e.surface === 'mobile'
396    const bar = typeof card.frac === 'number'
397      ? (desktop && els.Svg
398        ? <els.Svg key="bar" source={svgBar({ width: Math.min(560, width * 7), frac: card.frac, review: card.review || 0, label: 'progress' })} alt={`${Math.round(card.frac * 100)}% done`} />
399        : <Text key="bar" color="green">{`${textBar(card.frac, Math.min(30, width - 10), card.review || 0)} ${Math.round(card.frac * 100)}%`}</Text>)
400      : null
401    const input = (e.props as unknown as { input?: Record<string, unknown> }).input || {}
402    const linkKind = p.tool === 'read_board' ? 'board' : p.tool === 'interpret_knowledge_graph' ? 'graph' : p.tool === 'get_proposal_detail' ? 'project' : /tasks$/.test(p.tool) ? 'task' : null
403    const href = linkKind ? helmosLink(prefs.helmosUrl, linkKind, String(input.board_id || input.graph_id || input.project_id || '')) : null
404    return (
405      <Box key="card" flexDirection="column">
406        <Box key="head" flexDirection="row" columnGap={1}>
407          <Text key="title" bold color="cyan">{card.title}</Text>
408          <Text key="sum" dimColor wrap="truncate-end">{card.summary}</Text>
409          <Button key="raw" label="raw" plain onPress={() => { void update($, rawA, (l) => [...l.filter((x) => x !== id), id].slice(-100)) }} />
410        </Box>
411        {bar}
412        {card.rows.map((r, i) => (
413          <Text key={'r' + i} wrap="truncate-end" color={colorOf(r.cls)} dimColor={r.cls === 'done'} bold={r.depth === 0 && r.mark !== '•'}>{rowLine(r, width)}</Text>
414        ))}
415        {card.more ? <Text key="more" dimColor>{`…${card.more} more (raw shows all)`}</Text> : null}
416        {href ? <els.Link key="open" href={href} label="open in HelmOS" /> : null}
417      </Box>
418    )
419  })
420}
421
422// =========================================================================================
423// Pane bodies — top-level and in this file, as the engine requires of helpers that take `$`.
424// =========================================================================================
425
426// eslint-disable-next-line @typescript-eslint/no-explicit-any
427type El = (props: any) => RenderNode
428type Els = { Box: El; Text: El; Button: El; Input?: El; Select?: El; Svg?: El; Link: El; Markdown?: El; Code?: El }
429
430async function planBody($: EngineInterface, els: Els, x: { s: CanonStatus | null; cockpit: PortalCockpit; plan: Plan | null; view: PortalView; cols: number; desktop: boolean; now: number }): Promise<RenderNode> {
431  const { Box, Text, Button } = els
432  const { s, cockpit, plan, view, cols, desktop } = x
433  const server = await read($, serverA)
434  const timer = await read($, timerA)
435  const lines: RenderNode[] = []
436  const T = (key: string, text: string, o: { color?: string; dim?: boolean; bold?: boolean } = {}) =>
437    <Text key={key} color={o.color} dimColor={o.dim} bold={o.bold} wrap="truncate-end">{text}</Text>
438  const run = s && s.run
439  if (run) lines.push(T('run', `run ${run.id} · ${run.state} · ${run.mode || 'solo'}${run.project_id ? ' · project ' + run.project_id.slice(0, 8) : ''}`, { dim: run.state !== 'RUN' }))
440  else if (s) lines.push(T('run', 'no portal run declared for this project', { dim: true }))
441  if (!plan) {
442    if (cockpit.loading) lines.push(T('plan-loading', 'reading the plan from the portal…', { dim: true }))
443    if (cockpit.error) lines.push(T('plan-err', cockpit.error, { color: 'red' }))
444    if (!cockpit.loading && !cockpit.projectId) lines.push(T('plan-none', 'No project yet: start a run (/portal-project), or open /portal-cockpit <project-id>.', { dim: true }))
445    else if (!cockpit.loading && !cockpit.proposal) {
446      lines.push(T('plan-how', server.name ? 'No proposal read yet — press Refresh.' : 'The mod has not reached a portal server yet. Ask Claude to read the plan once; the cockpit fills from that read and remembers the server.', { dim: true }))
447      if (cockpit.projectId) lines.push(
448        <Button key="ask-plan" label="Ask Claude to read the plan" variant="primary" onPress={() => {
449          void $.prompt.submit({ text: `Read the management-portal plan for project ${cockpit.projectId}: call get_proposal_detail and list_tasks (project_id ${cockpit.projectId}). Only read; no changes.` })
450          $.ui.toast('Asked Claude to read the plan')
451        }} />,
452      )
453    }
454  } else {
455    const head = `${plan.title} · ${plan.status || 'draft'}`
456    lines.push(T('title', clip(head, cols), { bold: true }))
457    if (desktop && els.Svg) {
458      lines.push(<els.Svg key="chart" source={svgPlan(plan, Math.min(760, Math.max(280, cols * 7)))} alt={`Overall ${plan.pct}% delivered; phase ${plan.current + 1} of ${plan.phases.length}`} />)
459    } else {
460      lines.push(T('overall', `Overall ${textBar(plan.pct / 100, Math.min(30, cols - 20), plan.reviewPct / 100)} ${plan.pct}%`, { bold: true, color: 'green' }))
461    }
462    const facts = [
463      `milestones ${plan.milestones.done}/${plan.milestones.total} done${plan.milestones.review ? ` (${plan.milestones.review} in review)` : ''}`,
464      plan.tasks.total ? `tasks ${plan.tasks.done}/${plan.tasks.total}` : '',
465      plan.subtasks.total ? `subtasks ${plan.subtasks.done}/${plan.subtasks.total}` : '',
466      plan.hours ? `hours ${round1(plan.hours.delivered)}/${round1(plan.hours.planned)}` : '',
467      plan.cost ? `${money(plan.cost.delivered)}/${money(plan.cost.planned)} delivered` : '',
468    ].filter(Boolean).join(' · ')
469    lines.push(T('facts', facts, { dim: true }))
470    if (plan.overdue.length) lines.push(T('overdue', `OVERDUE: ${plan.overdue.map((o) => `${o.title} (${o.due})`).join(' · ')}`, { color: 'red' }))
471    // Phases → milestones → (current phase) subtasks.
472    lines.push(T('h-phases', 'PHASES', { bold: true }))
473    if (plan.awaitingReview.length) lines.push(T('awaiting', `awaiting review (only in-review milestones left): phase ${plan.awaitingReview.map((k) => k + 1).join(', ')}`, { color: 'yellow' }))
474    plan.phases.forEach((ph, i) => {
475      const bar = textBar(ph.isDone ? 1 : ph.progress.total ? ph.progress.done / ph.progress.total : 0, 10, ph.progress.total ? ph.progress.review / ph.progress.total : 0)
476      const mark = ph.isCurrent ? '▸' : ph.isDone ? '✓' : '·'
477      lines.push(T('ph' + i, `${mark} ${i + 1}. ${clip(ph.name, Math.max(12, cols - 34))}  ${desktop ? '' : bar + ' '}${ph.progress.done}/${ph.progress.total}${ph.deadline ? ' · due ' + ph.deadline : ''}${ph.overdue ? ' · OVERDUE' : ''}`,
478        { bold: ph.isCurrent, color: ph.isCurrent ? 'cyan' : ph.overdue ? 'red' : undefined, dim: ph.isDone && !ph.isCurrent }))
479      if (ph.isCurrent) lines.push(T('phc' + i, `   current phase · ${phaseCounts(ph)}`, { color: 'cyan', dim: true }))
480      const showMs = ph.isCurrent || view.showDone || !ph.isDone
481      if (!showMs) return
482      ph.milestones.forEach((m, j) => {
483        const sub = m.task && m.task.sub && m.task.sub.total ? ` · subtasks ${m.task.sub.done}/${m.task.sub.total} ${textBar(m.task.sub.done / m.task.sub.total, 8)}` : ''
484        const money_ = typeof m.cost === 'number' ? ` · ${money(m.cost)}` : ''
485        const hrs = typeof m.hours === 'number' ? ` · ${round1(m.hours)}h` : ''
486        const meta = `${m.status}${hrs}${money_}${sub}${m.overdue ? ' · OVERDUE' : ''}`
487        const msColor = m.overdue && m.cls !== 'done' ? 'red' : colorOf(m.cls)
488        if (cols < 90) {
489          // Narrow (a docked Desktop pane is ~50 columns): the name gets its own line, the facts the next.
490          lines.push(T(`ms${i}-${j}`, `   ${glyph(m.status)} ${m.name}`, { color: msColor, dim: m.cls === 'done' }))
491          lines.push(T(`msm${i}-${j}`, `      ${meta}`, { dim: true }))
492        } else {
493          lines.push(T(`ms${i}-${j}`, `   ${glyph(m.status)} ${clip(m.name, Math.max(16, cols - 60))} · ${meta}`, { color: msColor, dim: m.cls === 'done' }))
494        }
495        if ((ph.isCurrent || view.showDone) && m.task && m.task.subtasks && m.cls !== 'done') {
496          m.task.subtasks.forEach((st, k) => {
497            if (!view.showDone && classOf(st.status) === 'done') return
498            lines.push(T(`st${i}-${j}-${k}`, `       ${glyph(st.status)} ${clip(st.title, Math.max(10, cols - 12))}`, { dim: classOf(st.status) === 'done', color: colorOf(classOf(st.status)) }))
499          })
500        }
501      })
502    })
503    // Completed vs Remaining.
504    const remainingMs = plan.remaining.filter((r) => r.kind !== 'subtask')
505    const remainingSub = plan.remaining.filter((r) => r.kind === 'subtask')
506    lines.push(T('h-rem', `REMAINING · ${remainingMs.length} milestone/task(s), ${remainingSub.length} subtask(s)`, { bold: true, color: 'yellow' }))
507    remainingMs.slice(0, 12).forEach((r, i) => lines.push(T('rem' + i, `  ${glyph(r.status)} ${clip(r.title, Math.max(10, cols - 30))} · ${r.status}${r.due ? ' · due ' + r.due : ''}${r.overdue ? ' · OVERDUE' : ''}`, { color: r.overdue ? 'red' : colorOf(classOf(r.status)) })))
508    if (remainingMs.length > 12) lines.push(T('rem-more', `  …${remainingMs.length - 12} more`, { dim: true }))
509    const completedMs = plan.completed.filter((r) => r.kind !== 'subtask')
510    lines.push(T('h-done', `COMPLETED · ${completedMs.length} milestone/task(s)`, { bold: true, color: 'green' }))
511    const doneShown = view.showDone ? completedMs : completedMs.slice(-6)
512    doneShown.forEach((r, i) => lines.push(T('done' + i, `  ✓ ${clip(r.title, Math.max(10, cols - 6))}`, { dim: true })))
513    if (!view.showDone && completedMs.length > 6) lines.push(T('done-more', `  …${completedMs.length - 6} earlier (Show all)`, { dim: true }))
514  }
515  // What the canon owes, and what is stood down.
516  if (s) {
517    const o = owedNow(s)
518    lines.push(T('h-canon', `CANON · gates ${s.gates.armed}/${s.gates.total} armed${s.mode !== 'on' ? ' · PORTAL_CANON=' + s.mode : ''}`, { bold: true }))
519    for (const g of s.gates.stood) lines.push(T('sd-' + g.id, `  stood down: ${g.id}${g.why ? ' (' + clip(g.why, 60) + ')' : ''}`, { color: 'yellow' }))
520    if (!o.count && !o.journal && !o.closeout.length) lines.push(T('owed-none', '  nothing owed', { dim: true }))
521    if (o.count) lines.push(T('owed', `  owes ${o.count} read-back(s): ${o.writes.slice(0, 4).join(', ')}${o.writes.length > 4 ? '…' : ''}`, { color: 'yellow' }))
522    if (o.settle) lines.push(T('settle', `  settle: ${clip(o.settle, cols * 2)}`, { dim: true }))
523    if (o.journal && s.journal) lines.push(T('journal', `  journal owed since ${s.journal.boundary}${s.journal.id ? ' ' + s.journal.id.slice(0, 8) : ''} (${!s.journal.wrote ? 'no entry' : 'not read back'})`, { color: 'yellow' }))
524    if (o.closeout.length) lines.push(T('closeout', `  close-out missing: ${o.closeout.join(', ')}`, { color: 'yellow' }))
525  }
526  const src = cockpit.via === 'observed' ? 'from a portal result Claude read' : cockpit.server ? `via ${cockpit.server}` : 'not read yet'
527  lines.push(T('src', `plan ${src}${cockpit.at ? ' · ' + clock(cockpit.at) : ''}${server.error && !cockpit.proposal ? ' · ' + clip(server.error, 80) : ''}`, { dim: true }))
528  const task = currentTask(plan)
529  lines.push(
530    <Box key="buttons" flexDirection="row" columnGap={1}>
531      <Button key="refresh" label={cockpit.loading ? 'Refreshing…' : 'Refresh'} hotkey="r" onPress={() => { void refreshCanon($).then(() => loadPlan($, null, true)) }} />
532      <Button key="showdone" label={view.showDone ? 'Hide done' : 'Show all'} hotkey="d" onPress={() => { void update($, viewA, (v) => ({ ...v, showDone: !v.showDone })) }} />
533      {timer && timer.entryId
534        ? <Button key="timer" label="Stop timer" hotkey="t" onPress={() => { void stopTimer($) }} />
535        : task ? <Button key="timer" label="Start timer" hotkey="t" onPress={() => { void startTimer($, task.id, task.title) }} /> : null}
536      <Button key="close" label="Close" role="dismiss" onPress={() => { void $.ui.close({ id: PANE }) }} />
537    </Box>,
538  )
539  return <Box key="plan" flexDirection="column">{lines}</Box>
540}
541
542async function gatesBody($: EngineInterface, els: Els, s: CanonStatus | null, cols: number): Promise<RenderNode> {
543  const { Box, Text, Button } = els
544  const g = await read($, gatesA)
545  const rows: GateRow[] = g.rows && g.rows.length ? g.rows : gatesFromStatus(s)
546  const out: RenderNode[] = []
547  out.push(<Text key="g-head" bold>{`GATES · ${s ? `${s.gates.armed}/${s.gates.total} armed` : 'reading…'}${s && s.mode !== 'on' ? ' · PORTAL_CANON=' + s.mode : ''}`}</Text>)
548  out.push(<Text key="g-help" dimColor wrap="wrap">Standing a gate down writes its sentinel (it survives restarts); re-arm puts it back. Both run canon-gate.js exactly as /portal-stand-down and /portal-rearm do.</Text>)
549  if (els.Input) {
550    out.push(<els.Input key="reason" label="Reason" placeholder="why this gate must stand down (required)" value={g.reason}
551      onInput={(v: string) => { void update($, gatesA, (x) => ({ ...x, reason: String(v || '') })) }}
552      onSubmit={(v: string) => { void update($, gatesA, (x) => ({ ...x, reason: String(v || '') })) }} submitLabel="set" />)
553  }
554  if (!g.rows) out.push(<Text key="g-wait" dimColor>{s ? 'listing every gate (canon-gate.js doctor)…' : 'reading the canon…'}</Text>)
555  rows.forEach((r) => {
556    out.push(
557      <Box key={'g-' + r.id} flexDirection="row" columnGap={1}>
558        <Text key={'gs-' + r.id} color={r.armed ? 'green' : 'yellow'}>{r.armed ? 'ARMED     ' : 'STOOD DOWN'}</Text>
559        <Text key={'gi-' + r.id} bold={!r.armed} wrap="truncate-end">{clip(r.id + (r.why ? `  (${r.why})` : ''), Math.max(20, cols - 30))}</Text>
560        {r.armed
561          ? <Button key={'sd-' + r.id} label="Stand down" plain onPress={() => { void gateAction($, 'stand-down', r.id) }} />
562          : <Button key={'ra-' + r.id} label="Re-arm" plain onPress={() => { void gateAction($, 're-arm', r.id) }} />}
563      </Box>,
564    )
565  })
566  out.push(
567    <Box key="g-acts" flexDirection="row" columnGap={1}>
568      <Button key="rearm-all" label="Re-arm all" variant="primary" onPress={() => { void gateAction($, 're-arm', 'all') }} />
569      <Button key="g-refresh" label="Refresh" hotkey="r" onPress={() => { void refreshCanon($).then(() => refreshGates($)) }} />
570    </Box>,
571  )
572  if (g.busy) out.push(<Text key="g-busy" dimColor>{g.busy}</Text>)
573  if (g.last) out.push(<Text key="g-last" dimColor wrap="wrap">{g.last}</Text>)
574  return <Box key="gates" flexDirection="column">{out}</Box>
575}
576
577async function graphBody($: EngineInterface, els: Els, prefs: PortalPrefs, cockpit: PortalCockpit): Promise<RenderNode> {
578  const { Box, Text, Button } = els
579  const kg = await read($, kgA)
580  const out: RenderNode[] = []
581  if (!kg) {
582    out.push(<Text key="kg-none" dimColor wrap="wrap">No graph interpretation read in this session yet. The panel fills from the next interpret_knowledge_graph result: its hubs, bridges, communities and candidate missing links.</Text>)
583  } else {
584    out.push(<Text key="kg-title" bold>{`GRAPH · ${kg.title}`}</Text>)
585    if (kg.summary) out.push(els.Markdown ? <els.Markdown key="kg-sum" text={kg.summary} /> : <Text key="kg-sum" wrap="wrap">{kg.summary}</Text>)
586    const list = (k: string, head: string, items: string[], color?: string) => {
587      if (!items.length) return
588      out.push(<Text key={k + '-h'} bold color={color}>{head}</Text>)
589      items.forEach((it, i) => out.push(<Text key={k + i} wrap="truncate-end">{`  • ${it}`}</Text>))
590    }
591    list('hub', `HUBS (${kg.hubs.length}) — what holds the graph together`, kg.hubs, 'cyan')
592    list('gap', `GAPS (${kg.gaps.length}) — close in meaning, no edge between them`, kg.gaps, 'yellow')
593    list('bridge', `BRIDGES (${kg.bridges.length})`, kg.bridges)
594    list('comm', `COMMUNITIES (${kg.communities.length})`, kg.communities)
595    list('thin', `ISOLATED (${kg.thin.length})`, kg.thin, 'red')
596    const href = helmosLink(prefs.helmosUrl, 'graph', kg.graphId)
597    if (href) out.push(<els.Link key="kg-open" href={href} label="open the graph in HelmOS" />)
598  }
599  out.push(
600    <Box key="kg-acts" flexDirection="row" columnGap={1}>
601      {kg && kg.gaps.length ? (
602        <Button key="kg-gaps" label="Ask Claude to act on the gaps" onPress={() => {
603          void $.prompt.submit({ text: `The knowledge graph${kg.graphId ? ' ' + kg.graphId : ''} interpretation lists ${kg.gaps.length} candidate missing link(s). Review them against the project${cockpit.projectId ? ' ' + cockpit.projectId : ''} and, where a link is real, add the edge or a task for it; say which you rejected and why.` })
604          $.ui.toast('Asked Claude to review the graph gaps')
605        }} />
606      ) : null}
607      <Button key="kg-ask" label="Ask Claude to interpret the project graph" onPress={() => {
608        void $.prompt.submit({ text: `Interpret the knowledge graph for project ${cockpit.projectId || '(the current run\'s project)'}: find it with list_knowledge_graphs, run interpret_knowledge_graph on it, and tell me the hubs and gaps that matter for the remaining phases.` })
609        $.ui.toast('Asked Claude to interpret the graph')
610      }} />
611    </Box>,
612  )
613  return <Box key="graph" flexDirection="column">{out}</Box>
614}
615
616async function boardBody($: EngineInterface, els: Els, prefs: PortalPrefs, cols: number): Promise<RenderNode> {
617  const { Box, Text } = els
618  const b = await read($, boardA)
619  const out: RenderNode[] = []
620  if (!b) {
621    out.push(<Text key="b-none" dimColor wrap="wrap">No board read in this session yet. The preview fills from the next create_board / read_board result.</Text>)
622  } else {
623    out.push(<Text key="b-title" bold>{`BOARD · ${b.title} · ${b.blocks.length} block(s)`}</Text>)
624    b.blocks.forEach((x, i) => out.push(
625      <Text key={'b' + i} wrap="truncate-end" color={x.type === 'mermaid' ? 'cyan' : x.type === 'heading' ? undefined : undefined} bold={x.type === 'heading' || x.type === 'h1' || x.type === 'h2'}>
626        {`${'  '.repeat(x.depth)}${x.type === 'mermaid' ? '◇' : '▪'} ${clip(x.text || '(' + x.type + ')', Math.max(10, cols - 4 - x.depth * 2))}  ${x.type}`}
627      </Text>,
628    ))
629    const href = helmosLink(prefs.helmosUrl, 'board', b.id)
630    if (href) out.push(<els.Link key="b-open" href={href} label="open the board in HelmOS" />)
631  }
632  return <Box key="board" flexDirection="column">{out}</Box>
633}
634
635async function settingsBody($: EngineInterface, els: Els, prefs: PortalPrefs): Promise<RenderNode> {
636  const { Box, Text, Button } = els
637  const idle = await read($, idleA)
638  const watch = await read($, watchA)
639  const notify = await read($, notifyA)
640  const server = await read($, serverA)
641  const out: RenderNode[] = []
642  const toggle = (k: 'rich' | 'band' | 'watch' | 'notify' | 'autoOpen', label: string) => (
643    <Button key={'pref-' + k} label={`${label}: ${prefs[k] ? 'on' : 'off'}`} onPress={() => { void setPrefs($, { [k]: !prefs[k] } as Partial<PortalPrefs>) }} />
644  )
645  out.push(<Text key="s-h" bold>SETTINGS</Text>)
646  out.push(<Box key="s-t1" flexDirection="row" columnGap={1}>{toggle('rich', 'Cards')}{toggle('band', 'Band')}{toggle('notify', 'Notifications')}</Box>)
647  out.push(<Box key="s-t2" flexDirection="row" columnGap={1}>{toggle('watch', 'Chat wake')}{toggle('autoOpen', 'Auto-open')}</Box>)
648  out.push(<Text key="s-n" bold>{`IDLE-RUN NUDGE · ${prefs.nudge} · after ${prefs.nudgeMinutes} min · ${nudgeEnabled(prefs, idle) ? 'active in this session' : 'not active in this session'}${idle.continueMode ? ' · run started with /portal-continue' : ''}`}</Text>)
649  if (els.Select) {
650    out.push(<els.Select key="nudge" label="Nudge" value={prefs.nudge}
651      options={[{ value: 'auto', label: 'auto — only runs started with /portal-continue' }, { value: 'on', label: 'on — every run in RUN state' }, { value: 'off', label: 'off' }]}
652      onSelect={(v: string) => { void setPrefs($, { nudge: (v === 'on' || v === 'off' ? v : 'auto') }) }} />)
653    out.push(<els.Select key="nudge-min" label="Idle minutes" value={String(prefs.nudgeMinutes)}
654      options={[5, 10, 15, 20, 30, 60].map((n) => ({ value: String(n), label: `${n} min` }))}
655      onSelect={(v: string) => { void setPrefs($, { nudgeMinutes: Math.max(1, parseInt(v, 10) || 10) }) }} />)
656  } else {
657    out.push(<Box key="nudge-b" flexDirection="row" columnGap={1}>
658      {(['auto', 'on', 'off'] as const).map((v) => <Button key={'nudge-' + v} label={prefs.nudge === v ? `[${v}]` : v} onPress={() => { void setPrefs($, { nudge: v }) }} />)}
659    </Box>)
660  }
661  if (idle.nudges) out.push(<Text key="s-nc" dimColor>{`${idle.nudges} nudge(s) sent; pauses after 3 without progress`}</Text>)
662  if (els.Input) {
663    out.push(<els.Input key="url" label="HelmOS web address" placeholder="https://… (for links; empty = none)" value={prefs.helmosUrl || ''}
664      onSubmit={(v: string) => {
665        const u = String(v || '').trim().replace(/\/+$/, '')
666        if (!u) void setPrefs($, { helmosUrl: null })
667        else if (/^https:\/\/[a-z0-9.-]+(?::\d+)?(\/[\w./-]*)?$/i.test(u)) void setPrefs($, { helmosUrl: u })
668        else $.ui.toast('That is not an https:// address')
669      }} submitLabel="save" />)
670  }
671  out.push(<Text key="s-srv" dimColor wrap="wrap">{`Portal server: ${server.name || 'none reached yet'} (${server.how})${server.tried.length ? ' · tried ' + server.tried.join(', ') : ''}${server.error ? ' · ' + server.error : ''}`}</Text>)
672  out.push(<Text key="s-w" dimColor wrap="truncate-end">{watch ? `Team Chat wake-up: ${watch.agent} on ${watch.channelId.slice(0, 8)}${prefs.watch ? '' : ' (paused)'}${watch.lastError ? ' · ' + watch.lastError : ''}` : 'Team Chat wake-up: no channel watched (it starts when Claude starts watching one)'}</Text>)
673  if (watch) out.push(<Button key="unwatch" label="Stop the chat wake-up" onPress={() => { void update($, watchA, () => null); void $.store.delete('watch') }} />)
674  out.push(<Text key="s-nh" bold>{`NOTIFICATIONS${notify.lastPollAt ? ' · checked ' + clock(notify.lastPollAt) : ''}`}</Text>)
675  if (notify.error) out.push(<Text key="s-ne" color="red">{clip(notify.error, 100)}</Text>)
676  if (!notify.items.length) out.push(<Text key="s-n0" dimColor>nothing new</Text>)
677  notify.items.slice(-8).reverse().forEach((n, i) => out.push(<Text key={'nt' + i} wrap="truncate-end">{`${clock(n.at)}  ${n.text}`}</Text>))
678  return <Box key="settings" flexDirection="column">{out}</Box>
679}
680
681// =========================================================================================
682// Data: canon snapshot, gates, discovery, plan, observation
683// =========================================================================================
684
685type PostEvent = { tool_name?: string; tool_input?: Record<string, unknown>; tool_response?: unknown; mcp_server?: { name: string; source: string } }
686
687function colorOf(cls: string | undefined): string | undefined {
688  return cls === 'active' ? 'cyan' : cls === 'review' ? 'yellow' : cls === 'done' ? 'green' : cls === 'cancelled' ? 'gray' : undefined
689}
690
691function clock(ms: number): string {
692  try { return new Date(ms).toTimeString().slice(0, 5) } catch (_) { return '' }
693}
694
695function planOf(c: PortalCockpit, now: number): Plan | null {
696  return c.proposal ? buildPlan(c.proposal, c.tasks, todayOf(now)) : null
697}
698
699function isPortalServer(server: string): boolean {
700  return looksLikePortal(server, [...observed, ...(goodServer ? [goodServer] : [])])
701}
702
703async function loadStored($: EngineInterface): Promise<void> {
704  try {
705    const saved = (await $.store.get('prefs')) as Partial<PortalPrefs> | undefined
706    if (saved && typeof saved === 'object') await update($, prefsA, (p) => ({ ...DEFAULT_PREFS, ...p, ...saved }))
707    const w = (await $.store.get('watch')) as PortalWatch | undefined
708    if (w && typeof w === 'object' && w.channelId && w.agent) await update($, watchA, () => ({ ...w, seen: (w.seen || []).slice(-200) }))
709    const srv = (await $.store.get('server')) as string | undefined
710    if (typeof srv === 'string' && srv) { if (!observed.includes(srv)) observed = [...observed, srv] }
711    const sid = await $.session.id()
712    const cont = (await $.store.get('continue')) as Record<string, number> | undefined
713    const now = await $.clock.now()
714    await update($, idleA, (i) => ({ ...i, continueMode: Boolean(cont && cont[sid]), lastActivityAt: i.lastActivityAt || now }))
715    const t = (await $.store.get('timer')) as PortalTimer | undefined
716    if (t && typeof t === 'object' && t.entryId) await update($, timerA, () => ({ ...t, busy: false, error: null }))
717  } catch (_) { /* preferences are a convenience */ }
718}
719
720async function setPrefs($: EngineInterface, patch: Partial<PortalPrefs>): Promise<void> {
721  const prefs = await update($, prefsA, (p) => ({ ...p, ...patch }))
722  try { await $.store.set('prefs', prefs) } catch (_) { /* the session value still applies */ }
723}
724
725async function setContinueMode($: EngineInterface, on: boolean): Promise<void> {
726  await update($, idleA, (i) => ({ ...i, continueMode: on, nudges: 0 }))
727  try {
728    const sid = await $.session.id()
729    const cont = ((await $.store.get('continue')) as Record<string, number> | undefined) || {}
730    const now = await $.clock.now()
731    const keep = Object.fromEntries(Object.entries(cont).filter(([, at]) => now - at < 14 * 86_400_000))
732    if (on) keep[sid] = now; else delete keep[sid]
733    await $.store.set('continue', keep)
734  } catch (_) { /* the session value still applies */ }
735}
736
737async function dataEnv($: EngineInterface): Promise<Record<string, string>> {
738  // The hooks run with CLAUDE_PLUGIN_DATA = ~/.claude/plugins/data/<plugin>-<marketplace>; the
739  // status command must read the SAME canon home, so it is derived from where this plugin is
740  // installed. When it cannot be derived, canon-lib's own discovery (newest sessions/) decides.
741  try {
742    const root = String($.plugin.root || '')
743    const m = /^(.*[\\/]plugins)[\\/]cache[\\/]([^\\/]+)[\\/]([^\\/]+)[\\/][^\\/]+[\\/]?$/.exec(root)
744    let dir: string | null = null
745    if (m) dir = `${m[1]}/data/${m[3]}-${m[2]}`
746    else {
747      const cfg = (await $.env.get('CLAUDE_CONFIG_DIR')) || (((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '') + '/.claude')
748      if (cfg && cfg !== '/.claude') dir = `${cfg}/plugins/data/${$.plugin.name}-inline`
749    }
750    if (dir && (await $.fs.exists(dir + '/canon'))) return { CLAUDE_PLUGIN_DATA: dir }
751  } catch (_) { /* fall through to discovery */ }
752  return {}
753}
754
755/** One snapshot at a time; a caller that arrives mid-read waits for THAT read. */
756function refreshCanon($: EngineInterface): Promise<void> {
757  if (!canonInFlight) canonInFlight = readCanon($).finally(() => { canonInFlight = null })
758  return canonInFlight
759}
760
761async function readCanon($: EngineInterface): Promise<void> {
762  try {
763    const sid = await $.session.id()
764    const cwd = await $.session.cwd()
765    const env = await dataEnv($)
766    const r = await $.process.run(
767      ['node', `${$.plugin.root}/scripts/canon-gate.js`, 'status', '--json', '--session', sid, '--cwd', cwd],
768      { env, timeoutMs: 8000 },
769    )
770    const nextS: CanonStatus | null = parseCanonStatus(r.stdout)
771    if (!nextS) {
772      $.ui.log('canon status unreadable (exit ' + r.exitCode + '): ' + String(r.stderr || r.stdout || '').slice(0, 200), { to: 'debug' })
773      return
774    }
775    const before = await read($, canonA)
776    const beforeOwed = JSON.stringify(owedNow(before))
777    await update($, canonA, () => nextS)
778    // Something new is owed: the band comes back even if it was hidden for the last debt.
779    if (JSON.stringify(owedNow(nextS)) !== beforeOwed) await update($, bandA, () => ({ hidden: false, asked: '' }))
780    // A new run or a new project: the plan follows it.
781    const pid = nextS.run && nextS.run.project_id
782    const c = await read($, cockpitA)
783    if (pid && pid !== c.projectId && !c.loading) void loadPlan($, null, false)
784    await pushStatus($)
785    if (++doctorTick % 3 === 1) void refreshGates($)
786  } catch (err) {
787    // node missing or the status command failed: the status line simply stays as it was.
788    $.ui.log('canon status failed: ' + String((err as Error)?.message || err).slice(0, 200), { to: 'debug' })
789  }
790}
791
792async function refreshGates($: EngineInterface): Promise<void> {
793  try {
794    const env = await dataEnv($)
795    const cwd = await $.session.cwd()
796    const r = await $.process.run(['node', `${$.plugin.root}/scripts/canon-gate.js`, 'doctor'], { env, cwd, timeoutMs: 8000 })
797    const rows = parseDoctor(r.stdout)
798    if (rows.length) await update($, gatesA, (g) => ({ ...g, rows }))
799  } catch (_) { /* the status snapshot's stood-down list still draws */ }
800}
801
802async function gateAction($: EngineInterface, action: 'stand-down' | 're-arm', gate: string): Promise<void> {
803  const g = await read($, gatesA)
804  if (g.busy) return
805  const reason = g.reason.trim()
806  if (action === 'stand-down' && !reason) { $.ui.toast('Type a reason first — a stand-down needs one'); return }
807  await update($, gatesA, (x) => ({ ...x, busy: `${action} ${gate}…` }))
808  let last = ''
809  try {
810    const env = await dataEnv($)
811    const argv = ['node', `${$.plugin.root}/scripts/canon-gate.js`, action, '--gate', gate]
812    if (action === 'stand-down') argv.push('--reason', `${reason} (cockpit, by the person)`)
813    const r = await $.process.run(argv, { env, timeoutMs: 8000 })
814    last = String(r.stdout || r.stderr || '').trim().split(/\r?\n/).slice(0, 3).join(' ')
815    $.ui.toast(clip(last || `${action} ${gate}: exit ${r.exitCode}`, 160))
816  } catch (err) {
817    last = `${action} failed: ${String((err as Error)?.message || err).slice(0, 120)}`
818    $.ui.toast(last)
819  }
820  await update($, gatesA, (x) => ({ ...x, busy: null, last, reason: action === 'stand-down' ? '' : x.reason }))
821  await refreshCanon($)
822  await refreshGates($)
823}
824
825async function pushStatus($: EngineInterface): Promise<void> {
826  const s = await read($, canonA)
827  const plan = planOf(await read($, cockpitA), await $.clock.now())
828  const line = statusLine(s, plan)
829  if (line !== lastStatus) { lastStatus = line; $.ui.status(line) }
830  // The first message's run context changes only with the run or its phase.
831  const key = [s?.run?.id, s?.run?.state, plan?.current, plan?.phases.length].join('|')
832  if (key !== lastContextKey) { lastContextKey = key; try { $.ui.invalidate('prompt.context') } catch (_) { /* fine */ } }
833}
834
835/** Candidate servers, best first, each with how it was found. */
836async function candidates($: EngineInterface): Promise<{ name: string; how: string }[]> {
837  const out: { name: string; how: string }[] = []
838  const add = (name: string | null | undefined, how: string) => { if (name && !out.some((c) => c.name === name)) out.push({ name, how }) }
839  add(goodServer, 'worked before in this session')
840  for (const o of observed) add(o, 'Claude\'s portal calls ran on it')
841  try {
842    const own = await $.mcp.connect('management-portal')
843    if (own && own.isConnected && own.server) add(String(own.server), 'the plugin\'s own server')
844  } catch (_) { /* not connected or not ours */ }
845  try {
846    const all = await $.tool.list()
847    for (const s of portalServersFrom(all.map((t) => t.name), $.plugin.name)) add(s, 'its tools carry the portal signature')
848  } catch (_) { /* tool list unavailable */ }
849  try {
850    // With tool search on, $.tool.list() answers only the tools loaded into the prompt; the
851    // context breakdown lists every MCP tool, loaded or deferred, with the server's /mcp name.
852    const usage = await $.session.usage({ breakdown: 'summary' })
853    const rows = (usage.context.breakdown && usage.context.breakdown.mcpTools) || []
854    for (const s of portalServersFromBreakdown(rows, $.plugin.name)) add(s, 'its tools carry the portal signature (context)')
855  } catch (_) { /* breakdown unavailable */ }
856  return out
857}
858
859/** A portal READ (or a timer press) through whichever server answers. */
860async function portalCall($: EngineInterface, tool: string, args: Record<string, unknown>): Promise<{ text: string; server: string }> {
861  const cands = await candidates($)
862  const tried: string[] = []
863  let last = 'no portal MCP server reachable from the mod in this session'
864  for (const c of cands) {
865    tried.push(c.name)
866    try {
867      const res = await $.mcp.call(c.name, tool, args)
868      const text = resultText(res)
869      if (res && res.isError) {
870        last = `${c.name}: ${text.slice(0, 120) || 'error'}`
871        // The server answered: it IS the portal, the call itself failed. Do not try the others.
872        if (!/unknown tool|not found|no such tool|not connected|needs? auth|unauthori[sz]ed|401|403/i.test(text)) {
873          goodServer = c.name
874          throw new Error(last)
875        }
876        continue
877      }
878      if (goodServer !== c.name) {
879        goodServer = c.name
880        try { await $.store.set('server', c.name) } catch (_) { /* fine */ }
881      }
882      const now = await $.clock.now()
883      const cur = await read($, serverA)
884      if (cur.name !== c.name || cur.error) await update($, serverA, () => ({ name: c.name, how: c.how, tried, error: null, at: now }))
885      return { text, server: c.name }
886    } catch (err) {
887      last = String((err as Error)?.message || err).slice(0, 160)
888      if (goodServer === c.name && /: /.test(last)) break
889    }
890  }
891  const now = await $.clock.now()
892  await update($, serverA, (s) => ({ ...s, tried, error: last, at: now, how: cands.length ? s.how : 'no candidate found yet' }))
893  throw new Error(last)
894}
895
896async function resolveProject($: EngineInterface, explicit: string | null): Promise<string | null> {
897  if (explicit) return explicit
898  const c = await read($, cockpitA)
899  const s = await read($, canonA)
900  const fromRun = s && s.run && s.run.project_id
901  if (fromRun) return fromRun
902  if (c.projectId) return c.projectId
903  try { const last = (await $.store.get('lastProject')) as string | undefined; if (last && UUID_RE.test(last)) return last } catch (_) { /* fine */ }
904  return null
905}
906
907/** Proposal → tasks → each task's subtasks. One load at a time; a write queues one more. */
908function loadPlan($: EngineInterface, explicit: string | null, asked: boolean): Promise<void> {
909  if (planInFlight) { planReloadQueued = true; return planInFlight }
910  planInFlight = doLoadPlan($, explicit, asked).finally(() => {
911    planInFlight = null
912    if (planReloadQueued) { planReloadQueued = false; void loadPlan($, null, false) }
913  })
914  return planInFlight
915}
916
917async function doLoadPlan($: EngineInterface, explicit: string | null, asked: boolean): Promise<void> {
918  const projectId = await resolveProject($, explicit)
919  if (!projectId) { await update($, cockpitA, (c) => ({ ...c, loading: false, projectId: null })); return }
920  await update($, cockpitA, (c) => ({ ...c, loading: true, error: null, projectId }))
921  try {
922    const prop = await portalCall($, 'get_proposal_detail', { project_id: projectId })
923    const proposal = parseProposalDetail(prop.text)
924    const tl = await portalCall($, 'list_tasks', { project_id: projectId, limit: 50 })
925    const tasks: TaskRow[] = parseTasks(tl.text)
926    // Subtasks, four at a time; one that fails leaves that task without a subtask count.
927    const withId = tasks.filter((t) => t.id).slice(0, 30)
928    for (let i = 0; i < withId.length; i += 4) {
929      await Promise.all(withId.slice(i, i + 4).map(async (t) => {
930        try { t.subtasks = parseSubtasks((await portalCall($, 'list_subtasks', { parent_task_id: t.id })).text).rows } catch (_) { t.subtasks = null }
931      }))
932    }
933    const now = await $.clock.now()
934    await update($, cockpitA, () => ({ loading: false, error: proposal ? null : 'no proposal for this project yet', projectId, proposal, tasks, server: prop.server, via: 'mcp' as const, at: now }))
935    try { await $.store.set('lastProject', projectId) } catch (_) { /* fine */ }
936  } catch (err) {
937    const msg = String((err as Error)?.message || err).slice(0, 160)
938    await update($, cockpitA, (c) => ({ ...c, loading: false, error: c.proposal ? null : (asked ? 'portal read failed: ' : 'waiting for a portal server: ') + msg }))
939  }
940  await pushStatus($)
941}
942
943async function maybeAutoOpen($: EngineInterface): Promise<void> {
944  try {
945    const prefs = await read($, prefsA)
946    const c = await read($, cockpitA)
947    if (autoOpened || !prefs.autoOpen || !c.projectId) return
948    const surfaces = await $.session.surfaces()
949    if (!surfaces.some((s) => s === 'desktop')) return
950    autoOpened = true
951    // Unasked: the engine places it only where it fits as a sidebar (a wide Desktop window).
952    await $.ui.open({ id: PANE, title: 'Portal cockpit' })
953  } catch (_) { /* convenience */ }
954}
955
956/** Everything the mod learns from the portal results Claude itself read. */
957async function observeResult($: EngineInterface, e: PostEvent): Promise<void> {
958  const name = String(e.tool_name || '')
959  const p = splitMcpName(name)
960  if (!p) return
961  const input = (e.tool_input || {}) as Record<string, unknown>
962  const text = resultText(e.tool_response)
963  // Only a server that answers portal-only tools is remembered as the portal.
964  const portalish = looksLikePortal(p.server, observed) || ['get_proposal_detail', 'list_flow_connections', 'await_my_turn', 'list_subtasks', 'get_task', 'list_tasks', 'whoami'].includes(p.tool)
965  if (!portalish) return
966  const serverName = (e.mcp_server && e.mcp_server.name) || p.server
967  for (const n of [serverName, p.server]) if (n && !observed.includes(n)) observed = [...observed, n]
968  try { await $.store.set('server', serverName) } catch (_) { /* fine */ }
969  if (p.tool === 'start_watching_channel' || p.tool === 'await_my_turn') {
970    const ch = typeof input.channel_id === 'string' ? input.channel_id : null
971    const ag = typeof input.as_agent === 'string' ? input.as_agent : typeof input.agent_name === 'string' ? input.agent_name : null
972    if (ch && ag) await rememberWatch($, ch, ag)
973  }
974  const now = await $.clock.now()
975  const c = await read($, cockpitA)
976  const pid = typeof input.project_id === 'string' ? input.project_id : null
977  if ((p.tool === 'get_proposal_detail' || p.tool === 'list_milestones') && pid && (!c.projectId || c.projectId === pid)) {
978    const proposal = parseProposalDetail(text)
979    if (proposal) await update($, cockpitA, (x) => ({ ...x, projectId: pid, proposal, via: (x.via === 'mcp' && x.at > now - 60_000 ? 'mcp' : 'observed') as PortalCockpit['via'], at: now, error: null, loading: false }))
980  } else if (p.tool === 'list_tasks' && pid && (!c.projectId || c.projectId === pid) && !input.status) {
981    const tasks = parseTasks(text)
982    if (tasks.length) await update($, cockpitA, (x) => {
983      const old = new Map((x.tasks || []).map((t) => [t.id, t.subtasks]))
984      return { ...x, projectId: pid, tasks: tasks.map((t) => ({ ...t, subtasks: old.get(t.id) ?? null })), at: now }
985    })
986  } else if (p.tool === 'list_subtasks') {
987    const sub = parseSubtasks(text)
988    const parent = sub.parent || (typeof input.parent_task_id === 'string' ? input.parent_task_id : null)
989    if (parent && c.tasks && c.tasks.some((t) => t.id === parent)) {
990      await update($, cockpitA, (x) => ({ ...x, tasks: (x.tasks || []).map((t) => (t.id === parent ? { ...t, subtasks: sub.rows } : t)) }))
991    }
992  } else if (p.tool === 'interpret_knowledge_graph') {
993    const kg: KgView | null = parseKgInterpretation(text, typeof input.graph_id === 'string' ? input.graph_id : null, now)
994    if (kg) {
995      await update($, kgA, () => kg)
996      $.ui.toast(`Graph read: ${kg.hubs.length} hub(s), ${kg.gaps.length} gap(s) — /portal-cockpit graph`)
997    }
998  } else if (p.tool === 'read_board') {
999    const b: BoardView | null = parseBoard(text, typeof input.board_id === 'string' ? input.board_id : null, now)
1000    if (b) await update($, boardA, () => b)
1001  } else if (p.tool === 'create_board') {
1002    const id = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(text)
1003    const title = typeof input.title === 'string' ? input.title : 'new board'
1004    await update($, boardA, () => ({ id: id ? (id[1] ?? null) : null, title, blocks: [], at: now }))
1005  } else if (p.tool === 'start_timer') {
1006    const entryId = parseTimerEntry(text)
1007    if (entryId) await saveTimer($, { entryId, taskId: typeof input.task_id === 'string' ? input.task_id : null, taskTitle: String(input.description || input.task_id || 'timer'), startedAt: now, busy: false, error: null })
1008  } else if (p.tool === 'stop_timer') {
1009    const t = await read($, timerA)
1010    if (t && t.entryId && input.entry_id === t.entryId) await saveTimer($, null)
1011  }
1012  // A write moves the plan: reload it (debounced by the single-flight loader) and the canon.
1013  if (PORTAL_WRITES.test(p.tool)) {
1014    $.clock.after(1500, () => { void loadPlan($, null, false) })
1015    $.clock.after(400, () => { void refreshCanon($) })
1016  }
1017}
1018
1019async function saveTimer($: EngineInterface, t: PortalTimer | null): Promise<void> {
1020  await update($, timerA, () => t)
1021  try { if (t && t.entryId) await $.store.set('timer', t); else await $.store.delete('timer') } catch (_) { /* fine */ }
1022}
1023
1024async function startTimer($: EngineInterface, taskId: string, taskTitle: string): Promise<void> {
1025  const cur = await read($, timerA)
1026  if (cur && cur.busy) return
1027  await update($, timerA, () => ({ entryId: null, taskId, taskTitle, startedAt: 0, busy: true, error: null }))
1028  try {
1029    const c = await read($, cockpitA)
1030    const args: Record<string, unknown> = { task_id: taskId, description: taskTitle }
1031    if (c.projectId) args.project_id = c.projectId
1032    const r = await portalCall($, 'start_timer', args)
1033    const entryId = parseTimerEntry(r.text)
1034    if (!entryId) throw new Error(clip(r.text, 80) || 'no entry id in the answer')
1035    await saveTimer($, { entryId, taskId, taskTitle, startedAt: await $.clock.now(), busy: false, error: null })
1036    $.ui.toast(`Timer started on ${clip(taskTitle, 60)}`)
1037  } catch (err) {
1038    await update($, timerA, () => ({ entryId: null, taskId, taskTitle, startedAt: 0, busy: false, error: String((err as Error)?.message || err).slice(0, 80) }))
1039  }
1040}
1041
1042async function stopTimer($: EngineInterface): Promise<void> {
1043  const t = await read($, timerA)
1044  if (!t || !t.entryId || t.busy) return
1045  await update($, timerA, (x) => (x ? { ...x, busy: true } : x))
1046  try {
1047    const r = await portalCall($, 'stop_timer', { entry_id: t.entryId })
1048    $.ui.toast(clip(r.text.replace(/^✅\s*/, ''), 100) || 'Timer stopped')
1049    await saveTimer($, null)
1050  } catch (err) {
1051    await update($, timerA, (x) => (x ? { ...x, busy: false, error: String((err as Error)?.message || err).slice(0, 80) } : x))
1052  }
1053}
1054
1055async function rememberWatch($: EngineInterface, channelId: string, agent: string): Promise<void> {
1056  const cur = await read($, watchA)
1057  if (cur && cur.channelId === channelId && cur.agent === agent) return
1058  const w: PortalWatch = { channelId, agent, since: null, seen: [], lastPollAt: 0, lastError: null }
1059  await update($, watchA, () => w)
1060  try { await $.store.set('watch', w) } catch (_) { /* the session value still applies */ }
1061}
1062
1063/** Team Chat wake-up. The first poll of a watch only sets the baseline, so history never wakes. */
1064async function pollWatch($: EngineInterface): Promise<void> {
1065  const w = await read($, watchA)
1066  const prefs = await read($, prefsA)
1067  if (!w || !prefs.watch || watchBusy) return
1068  watchBusy = true
1069  try {
1070    // No as_agent: an unattributed read is delivered the channel charter in FULL and records
1071    // nothing, so this poll can never consume the agent's own first delivery of the policy.
1072    const args: Record<string, unknown> = { channel_id: w.channelId, limit: 20 }
1073    if (w.since) args.since = w.since.replace(' ', 'T')
1074    const { text } = await portalCall($, 'read_channel_messages', args)
1075    const msgs: ChatMessage[] = parseMessages(text)
1076    const baseline = w.since === null
1077    const seen = new Set(w.seen)
1078    let wake: ChatMessage | null = null
1079    for (const m of msgs) {
1080      if (seen.has(m.id)) continue
1081      seen.add(m.id)
1082      if (!baseline && !wake && addresses(m, w.agent)) wake = m
1083    }
1084    const newest = msgs.length ? msgs[msgs.length - 1]!.at : w.since
1085    const nw: PortalWatch = { ...w, since: newest || w.since || '1970-01-01 00:00:00', seen: [...seen].slice(-200), lastPollAt: await $.clock.now(), lastError: null }
1086    await update($, watchA, () => nw)
1087    try { await $.store.set('watch', nw) } catch (_) { /* fine */ }
1088    if (wake) {
1089      $.ui.toast(`Team Chat: ${wake.sender} addressed ${w.agent}`)
1090      void $.prompt.submit({ text: wakePrompt(w.channelId, w.agent, wake) })
1091    }
1092  } catch (err) {
1093    const msg = String((err as Error)?.message || err).slice(0, 80)
1094    await update($, watchA, (cur) => (cur ? { ...cur, lastError: msg } : cur))
1095  } finally {
1096    watchBusy = false
1097  }
1098}
1099
1100/** Notifications: new unread DMs, unread inbox mail, approvals waiting, tasks due. Toasts carry
1101 *  labels only (a sender's name, a subject) — never a message body. The first poll ever is a
1102 *  silent baseline, so a backlog never floods the screen. */
1103async function pollNotify($: EngineInterface): Promise<void> {
1104  const prefs = await read($, prefsA)
1105  if (!prefs.notify || notifyBusy) return
1106  notifyBusy = true
1107  try {
1108    const now = await $.clock.now()
1109    const safe = async (tool: string, args: Record<string, unknown>) => { try { return (await portalCall($, tool, args)).text } catch (_) { return '' } }
1110    const dmText = await safe('read_dm_conversations', {})
1111    if (!dmText && !goodServer) throw new Error('no portal server reached yet')
1112    const inboxText = await safe('read_inbox', { limit: 10 })
1113    const apText = await safe('list_scheduling_requests', {})
1114    const c = await read($, cockpitA)
1115    const due = dueTasks(c.tasks, todayOf(now)).map((t) => ({ id: t.id, title: t.title, due: t.due }))
1116    const seen = ((await $.store.get('notifySeen')) as string[] | undefined) || null
1117    const fresh = newNotes({ dms: parseDmConversations(dmText), inbox: parseInbox(inboxText), approvals: parseSchedulingRequests(apText), due }, seen || [], now)
1118    if (seen === null) {
1119      // Baseline: remember everything current, announce nothing.
1120      await $.store.set('notifySeen', fresh.map((n) => n.id).slice(-400))
1121    } else if (fresh.length) {
1122      await $.store.set('notifySeen', [...seen, ...fresh.map((n) => n.id)].slice(-400))
1123      fresh.slice(0, 3).forEach((n) => $.ui.toast(n.text, { timeoutMs: 8000 }))
1124      if (fresh.length > 3) $.ui.toast(`+${fresh.length - 3} more — /portal-cockpit settings`)
1125    }
1126    await update($, notifyA, (x) => ({ items: [...x.items, ...(seen === null ? [] : fresh)].slice(-40), lastPollAt: now, error: null }))
1127  } catch (err) {
1128    const msg = String((err as Error)?.message || err).slice(0, 100)
1129    await update($, notifyA, (x) => ({ ...x, error: msg }))
1130  } finally {
1131    notifyBusy = false
1132  }
1133}
1134
1135async function backgroundBusy($: EngineInterface): Promise<boolean> {
1136  const now = await $.clock.now()
1137  for (const [id, at] of bg) if (now - at > BG_MAX_MS) bg.delete(id)
1138  if (bg.size) return true
1139  try {
1140    const agents = await $.agent.list()
1141    return agents.some((a) => a.status === 'running' || a.status === 'pending' || a.status === 'waiting')
1142  } catch (_) { return false }
1143}
1144
1145/** The idle-run nudge: toast first, a countdown in the band, then one "continue" prompt. */
1146async function idleTick($: EngineInterface): Promise<void> {
1147  try {
1148    const prefs = await read($, prefsA)
1149    const idle = await read($, idleA)
1150    if (prefs.nudge === 'off' && !idle.pendingSince) return
1151    const s = await read($, canonA)
1152    const now = await $.clock.now()
1153    const plan = planOf(await read($, cockpitA), now)
1154    const verdict = nudgeDecision({ now, prefs, idle, canon: s, plan, busy: await backgroundBusy($), progressMark: progressMark(s, plan) })
1155    if (verdict.act === 'warn') {
1156      await update($, idleA, (i) => ({ ...i, pendingSince: now }))
1157      $.ui.toast(`Portal run idle ${prefs.nudgeMinutes} min — Claude continues it in 60s. "Not now" in the band cancels.`, { timeoutMs: 10_000 })
1158    } else if (verdict.act === 'nudge') {
1159      await fireNudge($)
1160    } else if (verdict.act === 'cancel') {
1161      await update($, idleA, (i) => ({ ...i, pendingSince: 0 }))
1162    }
1163  } catch (_) { /* a missed tick is harmless */ }
1164}
1165
1166async function fireNudge($: EngineInterface): Promise<void> {
1167  const s = await read($, canonA)
1168  const prefs = await read($, prefsA)
1169  const now = await $.clock.now()
1170  const plan = planOf(await read($, cockpitA), now)
1171  const mark = progressMark(s, plan)
1172  await update($, idleA, (i) => ({ ...i, pendingSince: 0, lastActivityAt: now, lastNudgeAt: now,
1173    nudges: i.progressMark === mark ? i.nudges + 1 : 1, progressMark: mark }))
1174  $.ui.toast('Continuing the portal run (idle-run nudge)')
1175  void $.prompt.submit({ text: nudgePrompt(s, plan, prefs.nudgeMinutes) })
1176}
1177
1178async function snoozeNudge($: EngineInterface): Promise<void> {
1179  const now = await $.clock.now()
1180  await update($, idleA, (i) => ({ ...i, pendingSince: 0, lastActivityAt: now }))
1181  $.ui.toast('Idle-run nudge snoozed')
1182}
1183
1184// =========================================================================================
1185// The command launcher — a button for every command the plugin ships, chosen by preset
1186// =========================================================================================
1187
1188/** Rows the hover detail area keeps reserved under the command list (border included). */
1189const DETAIL_ROWS = 6
1190
1191const MOD_COMMANDS: CommandInfo[] = [
1192  { name: 'portal-cockpit', description: 'Open the run cockpit: plan, phases, milestones, tasks with progress bars, done vs remaining.', argumentHint: '[project-id | gates | graph | board | settings]', needsArgs: false, source: 'mod' },
1193  { name: 'portal-gates', description: 'Open the gate control panel: every canon gate armed or stood down, with re-arm and stand-down buttons.', argumentHint: null, needsArgs: false, source: 'mod' },
1194]
1195
1196/** Reads commands/*.md from the plugin itself, so a new command shows up with no change here. */
1197async function loadCommands($: EngineInterface): Promise<void> {
1198  const list: CommandInfo[] = []
1199  try {
1200    const dir = `${$.plugin.root}/commands`
hooks/mods/portal-view.ts 996 lines
1// portal-view.ts — the PURE half of the management-portal mod (1.9.0).
2//
3// Everything here is plain data in, plain data out: parsing the text the portal's tools
4// return, turning a proposal + task tree into a PLAN (phases → milestones → tasks → subtasks,
5// done vs remaining, progress, deadlines, hours and cost), summarising the canon snapshot
6// `canon-gate.js status --json` prints, drawing progress bars as text or SVG, and deciding
7// whether the idle-run nudge or a Team Chat wake-up should fire. No `$`, no I/O, no timers —
8// so it is the part the tests can hold exactly, and the part that cannot do anything but describe.
9
10import type {
11  BoardBlock, BoardView, CanonStatus, CommandInfo, GateRow, KgView, Milestone, Phase, PortalIdle, PortalNote, PortalPrefs, Proposal,
12  SubtaskRow, TaskRow,
13} from '../../types'
14
15export type { CanonStatus, GateRow, Milestone, Phase, Proposal, SubtaskRow, TaskRow }
16
17/** Parse the LAST JSON line of the status command's stdout; null when it is not one. */
18export function parseCanonStatus(stdout: string): CanonStatus | null {
19  const lines = String(stdout || '').trim().split(/\r?\n/).filter(Boolean)
20  for (let i = lines.length - 1; i >= 0; i--) {
21    const l = (lines[i] ?? '').trim()
22    if (!l.startsWith('{')) continue
23    try {
24      const v = JSON.parse(l)
25      if (v && typeof v === 'object' && v.v === 1 && v.gates) return v as CanonStatus
26    } catch (_) { /* not this line */ }
27  }
28  return null
29}
30
31/** `canon-gate.js doctor` lists every gate: `    ARMED      CANON-ID` / `    stood down CANON-X  (why)`. */
32export function parseDoctor(text: string): GateRow[] {
33  const rows: GateRow[] = []
34  for (const line of String(text || '').split(/\r?\n/)) {
35    const m = /^\s+(ARMED|stood down)\s+(CANON-[A-Z0-9-]+)(?:\s+\((.*)\))?\s*$/.exec(line)
36    if (!m) continue
37    rows.push({ id: m[2] ?? '', armed: m[1] === 'ARMED', why: m[3] ? m[3] : null })
38  }
39  return rows
40}
41
42/** Gates from the status snapshot alone (no doctor output yet): the stood-down ones, named. */
43export function gatesFromStatus(s: CanonStatus | null): GateRow[] {
44  if (!s) return []
45  return s.gates.stood.map((g) => ({ id: g.id, armed: false, why: g.why }))
46}
47
48/** What the canon says is owed NOW: this session's open read-backs, else the carried debt. */
49export function owedNow(s: CanonStatus | null): { count: number; writes: string[]; settle: string | null; journal: boolean; closeout: string[] } {
50  if (!s) return { count: 0, writes: [], settle: null, journal: false, closeout: [] }
51  const session = s.owed || []
52  const debtItems = (s.debt && s.debt.items) || []
53  const useDebt = !session.length && debtItems.length > 0
54  const writes = (useDebt ? debtItems : session).map((o) => o.w)
55  const count = useDebt ? (s.debt ? s.debt.reads : 0) : (s.owedTotal ?? session.length)
56  const settle = useDebt ? (s.debt && s.debt.settle) || null : s.settle
57  const closeout = (s.closeout && s.closeout.length ? s.closeout : (s.debt && s.debt.closeout) || [])
58  return { count, writes, settle, journal: Boolean(s.journal && s.journal.owed), closeout }
59}
60
61/** The prompt the band's button submits. It ASKS for the read: a mod-made $.mcp.call is not a
62 *  model tool call, so it could never settle a gate — only the model running the read does. */
63export function settlePrompt(settle: string): string {
64  return 'The management-portal canon gates show an unsettled read-back. Run this settling read now, '
65    + 'exactly as printed, as your next tool call, and confirm the fields you wrote are present in what '
66    + 'comes back (a delete is verified by ABSENCE):\n\n' + settle
67}
68
69// ---------------------------------------------------------------------------
70// Portal server discovery — never hard-coded: the plugin's own server, the claude.ai connector
71// (in the Desktop Code tab it is named by a UUID: mcp__560b8d0b-…__get_proposal_detail) and a
72// raw MCP install all register the same tools under different names.
73// ---------------------------------------------------------------------------
74
75/** Tools only the portal registers. A server exposing all three is the portal. */
76export const PORTAL_SIGNATURE = ['get_proposal_detail', 'list_flow_connections', 'await_my_turn']
77/** Weaker evidence: a server whose tool list (as far as we can see it) carries one of these. */
78export const PORTAL_HINT_TOOLS = new Set(['get_proposal_detail', 'list_flow_clusters', 'list_flow_connections', 'await_my_turn',
79  'create_proposal', 'add_proposal_milestone', 'list_subtasks', 'interpret_knowledge_graph', 'read_board'])
80
81export function splitMcpName(name: string): { server: string; tool: string } | null {
82  if (!name || !name.startsWith('mcp__')) return null
83  const rest = name.slice(5)
84  const cut = rest.lastIndexOf('__')
85  if (cut <= 0) return null
86  return { server: rest.slice(0, cut), tool: rest.slice(cut + 2) }
87}
88
89/** Every server spelling (tool-name form) that carries the portal's signature tools, best first:
90 *  the plugin's own server, then a claude.ai connector, then anything else. */
91export function portalServersFrom(toolNames: readonly string[], pluginName: string): string[] {
92  const by = new Map<string, Set<string>>()
93  for (const n of toolNames) {
94    const p = splitMcpName(n)
95    if (!p) continue
96    if (!by.has(p.server)) by.set(p.server, new Set())
97    by.get(p.server)!.add(p.tool)
98  }
99  const hits = [...by.entries()].filter(([, tools]) => PORTAL_SIGNATURE.every((t) => tools.has(t))).map(([s]) => s)
100  return rankServers(hits, pluginName)
101}
102
103/** The breakdown rows carry `serverName` (what /mcp lists and $.mcp.call takes) beside the wire
104 *  name; a server qualifies on the signature, or on `get_proposal_detail` alone when the list is
105 *  partial. Returns names in the form $.mcp.call takes, best first, each once. */
106export function portalServersFromBreakdown(rows: readonly { name: string; serverName?: string }[], pluginName: string): string[] {
107  const by = new Map<string, { tools: Set<string>; spelled: string }>()
108  for (const r of rows) {
109    const p = splitMcpName(r.name)
110    if (!p) continue
111    const key = r.serverName || p.server
112    if (!by.has(key)) by.set(key, { tools: new Set(), spelled: p.server })
113    by.get(key)!.tools.add(p.tool)
114  }
115  const full: string[] = []
116  const partial: string[] = []
117  for (const [k, v] of by) {
118    if (PORTAL_SIGNATURE.every((t) => v.tools.has(t))) full.push(k)
119    else if (v.tools.has('get_proposal_detail')) partial.push(k)
120  }
121  return [...rankServers(full, pluginName), ...rankServers(partial, pluginName)]
122}
123
124export function rankServers(names: readonly string[], pluginName: string): string[] {
125  const rank = (s: string) => (s === `plugin_${pluginName}_${pluginName}` || s.startsWith(`plugin_${pluginName}_`)
126    || s.startsWith(`plugin:${pluginName}:`) ? 0
127    : /^claude[._ ]ai/i.test(s) ? 1 : 2)
128  return [...new Set(names)].sort((a, b) => rank(a) - rank(b))
129}
130
131/** Is this server name (any spelling) one we have evidence is the portal? */
132export function looksLikePortal(server: string, known: readonly string[]): boolean {
133  if (known.includes(server)) return true
134  return /management[-_ ]portal/i.test(server)
135}
136
137/** Text of an MCP result in any of the shapes it reaches a mod in. */
138export function resultText(out: unknown): string {
139  if (out == null) return ''
140  if (typeof out === 'string') return out
141  if (Array.isArray(out)) return out.map((b) => (b && typeof b === 'object' && 'text' in (b as object) ? String((b as { text: unknown }).text ?? '') : typeof b === 'string' ? b : '')).join('\n')
142  if (typeof out === 'object') {
143    const o = out as Record<string, unknown>
144    if (Array.isArray(o.content)) return resultText(o.content)
145    if (typeof o.text === 'string') return o.text
146    if (o.result !== undefined) return resultText(o.result)
147  }
148  try { return JSON.stringify(out) } catch (_) { return '' }
149}
150
151// ---------------------------------------------------------------------------
152// Parsers for the portal's text results
153// ---------------------------------------------------------------------------
154
155const RE_ID = /\[id: ([^\]\s]+)\]/
156const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
157
158function num(s: string | undefined | null): number | null {
159  if (s == null) return null
160  const v = parseFloat(String(s))
161  return Number.isFinite(v) ? v : null
162}
163
164/** `time: 6.0 hours` / `time: 1 d` / `time: 90 min` → hours. A day is 8 working hours. */
165export function hoursOf(s: string | null | undefined): number | null {
166  const m = /([\d.]+)\s*(hours?|hrs?|h|days?|d|min(?:ute)?s?|m|weeks?|w)\b/i.exec(String(s || ''))
167  if (!m) return num(s)
168  const v = num(m[1])
169  if (v === null) return null
170  const u = (m[2] || '').toLowerCase()
171  if (u.startsWith('d')) return v * 8
172  if (u.startsWith('w')) return v * 40
173  if (u.startsWith('m')) return v / 60
174  return v
175}
176
177function field(line: string, name: string): string | null {
178  const m = new RegExp('\\|\\s*' + name + ':\\s*([^|\\[]*?)\\s*(?:\\||\\[|$)').exec(line)
179  const v = m ? (m[1] ?? '').trim() : ''
180  return v && v !== 'None' ? v : null
181}
182
183export function parseProposalDetail(text: string): Proposal | null {
184  const t = String(text || '')
185  const title = /^Proposal: (.*)$/m.exec(t)
186  if (!title) return null
187  const status = /^Status: (.*)$/m.exec(t)
188  const phases: Phase[] = []
189  for (const line of t.split(/\r?\n/)) {
190    const ph = /^\s{2}Phase: (.*?) \|.*\[([0-9a-f-]{8,})\]\s*$/.exec(line)
191    if (ph) {
192      phases.push({ name: (ph[1] ?? '').trim(), id: (ph[2] ?? ''), milestones: [],
193        deadline: field(line, 'deadline'), order: num(field(line, 'order')) })
194      continue
195    }
196    const ms = /^\s{4}- (.*?) \|.*status: ([^|]*?) \|.*$/.exec(line)
197    if (ms && phases.length) {
198      const id = RE_ID.exec(line)
199      phases[phases.length - 1]!.milestones.push({
200        name: (ms[1] ?? '').trim(), status: (ms[2] ?? '').trim() || 'pending', id: (id?.[1] ?? null),
201        cost: num(field(line, 'cost')), hours: hoursOf(field(line, 'time')), deadline: field(line, 'deadline'),
202      })
203    }
204  }
205  return { title: (title[1] ?? '').trim(), status: status ? (status[1] ?? '').trim() : '', phases }
206}
207
208/** list_tasks and list_subtasks share the row shape: `- [status] Title (priority: p) due: d | … [id: …]`. */
209export function parseTasks(text: string): TaskRow[] {
210  const rows: TaskRow[] = []
211  for (const line of String(text || '').split(/\r?\n/)) {
212    const m = /^- \[([a-z_]+)\] (.*?)(?: \(priority: ([a-z]+)\))?(?: due: ([^|\[]*?))?\s*(?:\|.*)?(?:\[id: [^\]]+\])?$/.exec(line.trim())
213    if (!m) continue
214    const id = RE_ID.exec(line)
215    rows.push({ status: (m[1] ?? ''), title: (m[2] ?? '').trim(), priority: (m[3] ?? '') || null, id: (id?.[1] ?? null),
216      due: m[4] && m[4].trim() && m[4].trim() !== 'None' ? m[4].trim() : null })
217  }
218  return rows
219}
220
221export function parseSubtasks(text: string): { parent: string | null; rows: SubtaskRow[] } {
222  const head = /subtask\(s\) for task ([0-9a-f-]{36})/i.exec(String(text || ''))
223  return { parent: head ? (head[1] ?? null) : null, rows: parseTasks(text).map((r) => ({ status: r.status, title: r.title, id: r.id })) }
224}
225
226// ---------------------------------------------------------------------------
227// Status classes, progress and the PLAN
228// ---------------------------------------------------------------------------
229
230export const DONE = /^(completed|complete|done|delivered|approved|paid|accepted)$/i
231export const REVIEW = /^(in_review|review|submitted|awaiting_review|in review)$/i
232export const ACTIVE = /^(in_progress|active|started|in progress|doing)$/i
233export const CANCELLED = /^(cancel+ed|dropped|void)$/i
234
235export type StatusClass = 'done' | 'review' | 'active' | 'pending' | 'cancelled'
236export function classOf(status: string): StatusClass {
237  if (DONE.test(status)) return 'done'
238  if (REVIEW.test(status)) return 'review'
239  if (ACTIVE.test(status)) return 'active'
240  if (CANCELLED.test(status)) return 'cancelled'
241  return 'pending'
242}
243
244export function glyph(status: string): string {
245  switch (classOf(status)) {
246    case 'done': return '✓'
247    case 'review': return '◐'
248    case 'active': return '▶'
249    case 'cancelled': return '✗'
250    default: return '○'
251  }
252}
253
254export type Progress = { done: number; review: number; total: number; pct: number }
255
256/** Counts; cancelled items leave the denominator. */
257export function progress(statuses: readonly string[]): Progress {
258  const live = statuses.filter((s) => classOf(s) !== 'cancelled')
259  const done = live.filter((s) => classOf(s) === 'done').length
260  const review = live.filter((s) => classOf(s) === 'review').length
261  return { done, review, total: live.length, pct: live.length ? Math.round((done / live.length) * 100) : 0 }
262}
263
264export type PlanTask = TaskRow & { sub: Progress | null }
265export type PlanMilestone = Milestone & {
266  cls: StatusClass
267  overdue: boolean
268  task: PlanTask | null
269  /** 0..1 inside the milestone: delivered 1, else its task tree's done share. */
270  frac: number
271}
272export type PlanPhase = {
273  name: string
274  id: string | null
275  index: number
276  deadline: string | null
277  overdue: boolean
278  milestones: PlanMilestone[]
279  progress: Progress
280  frac: number
281  isCurrent: boolean
282  isDone: boolean
283}
284export type PlanItem = { kind: 'milestone' | 'task' | 'subtask'; title: string; status: string; phase: string; id: string | null; due?: string | null; overdue?: boolean }
285export type Plan = {
286  title: string
287  status: string
288  phases: PlanPhase[]
289  current: number
290  /** Overall share delivered: by planned hours when every milestone has them, else by count. */
291  pct: number
292  reviewPct: number
293  milestones: Progress
294  tasks: Progress
295  subtasks: Progress
296  hours: { planned: number; delivered: number } | null
297  cost: { planned: number; delivered: number } | null
298  completed: PlanItem[]
299  remaining: PlanItem[]
300  overdue: PlanItem[]
301  orphanTasks: PlanTask[]
302  /** Phases other than the current one whose only open milestones are in review (0-based). */
303  awaitingReview: number[]
304}
305
306const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
307
308/** `today` is YYYY-MM-DD; a deadline before it on something not done is overdue. */
309export function isOverdue(deadline: string | null | undefined, status: string, today: string): boolean {
310  if (!deadline) return false
311  const d = /^\d{4}-\d{2}-\d{2}/.exec(deadline)
312  if (!d) return false
313  const c = classOf(status)
314  return c !== 'done' && c !== 'cancelled' && d[0] < today
315}
316
317export function todayOf(ms: number): string {
318  return new Date(ms).toISOString().slice(0, 10)
319}
320
321export function buildPlan(p: Proposal, tasks: readonly TaskRow[] | null, today: string): Plan {
322  const used = new Set<string>()
323  const taskFor = (name: string): PlanTask | null => {
324    if (!tasks) return null
325    const n = norm(name)
326    const t = tasks.find((x) => !used.has(x.id || x.title) && norm(x.title) === n)
327      || tasks.find((x) => !used.has(x.id || x.title) && (norm(x.title).startsWith(n) || n.startsWith(norm(x.title))) && norm(x.title).length > 6)
328    if (!t) return null
329    used.add(t.id || t.title)
330    return { ...t, sub: t.subtasks ? progress(t.subtasks.map((s) => s.status)) : null }
331  }
332  const phases: PlanPhase[] = p.phases.map((ph, index) => {
333    const milestones: PlanMilestone[] = ph.milestones.map((m) => {
334      const cls = classOf(m.status)
335      const task = taskFor(m.name)
336      const frac = cls === 'done' ? 1 : task && task.sub && task.sub.total ? task.sub.done / task.sub.total : 0
337      return { ...m, cls, task, frac, overdue: isOverdue(m.deadline, m.status, today) }
338    })
339    const pr = progress(milestones.map((m) => m.status))
340    const live = milestones.filter((m) => m.cls !== 'cancelled')
341    const frac = live.length ? live.reduce((a, m) => a + m.frac, 0) / live.length : 0
342    const isDone = live.length > 0 && pr.done === pr.total
343    return { name: ph.name, id: ph.id, index, deadline: ph.deadline ?? null, milestones, progress: pr, frac,
344      isCurrent: false, isDone, overdue: !isDone && isOverdue(ph.deadline, 'pending', today) }
345  })
346  // The CURRENT phase is where work is happening, not merely the first one not finished: a phase
347  // whose only open milestones are in review is waiting on the client, not on the run.
348  //   1. the latest phase with a milestone in progress;
349  //   2. else the earliest phase with a milestone not started;
350  //   3. else the earliest phase still waiting on a review; 4. else the last phase.
351  const has = (ph: PlanPhase, c: StatusClass) => ph.milestones.some((m) => m.cls === c)
352  let current = -1
353  for (let i = phases.length - 1; i >= 0 && current < 0; i--) if (has(phases[i]!, 'active')) current = i
354  if (current < 0) current = phases.findIndex((ph) => has(ph, 'pending'))
355  if (current < 0) current = phases.findIndex((ph) => !ph.isDone)
356  if (current < 0) current = Math.max(0, phases.length - 1)
357  if (phases[current]) phases[current]!.isCurrent = true
358  const awaitingReview = phases.filter((ph, i) => i !== current && !ph.isDone && !has(ph, 'active') && !has(ph, 'pending') && has(ph, 'review')).map((ph) => ph.index)
359  const allMs = phases.flatMap((ph) => ph.milestones).filter((m) => m.cls !== 'cancelled')
360  const msProg = progress(allMs.map((m) => m.status))
361  const withHours = allMs.filter((m) => typeof m.hours === 'number' && (m.hours as number) > 0)
362  const hours = withHours.length ? {
363    planned: withHours.reduce((a, m) => a + (m.hours as number), 0),
364    delivered: withHours.filter((m) => m.cls === 'done').reduce((a, m) => a + (m.hours as number), 0),
365  } : null
366  const withCost = allMs.filter((m) => typeof m.cost === 'number')
367  const cost = withCost.length ? {
368    planned: withCost.reduce((a, m) => a + (m.cost as number), 0),
369    delivered: withCost.filter((m) => m.cls === 'done').reduce((a, m) => a + (m.cost as number), 0),
370  } : null
371  const byHours = hours && withHours.length === allMs.length && hours.planned > 0
372  const pct = byHours ? Math.round((hours!.delivered / hours!.planned) * 100) : msProg.pct
373  const reviewHours = byHours ? withHours.filter((m) => m.cls === 'review').reduce((a, m) => a + (m.hours as number), 0) : 0
374  const reviewPct = byHours ? Math.round((reviewHours / hours!.planned) * 100)
375    : msProg.total ? Math.round((msProg.review / msProg.total) * 100) : 0
376  const tk = tasks || []
377  const subs = tk.flatMap((t) => t.subtasks || [])
378  const completed: PlanItem[] = []
379  const remaining: PlanItem[] = []
380  const overdue: PlanItem[] = []
381  for (const ph of phases) {
382    for (const m of ph.milestones) {
383      if (m.cls === 'cancelled') continue
384      const item: PlanItem = { kind: 'milestone', title: m.name, status: m.status, phase: ph.name, id: m.id, due: m.deadline, overdue: m.overdue }
385      ;(m.cls === 'done' ? completed : remaining).push(item)
386      if (m.overdue) overdue.push(item)
387      if (m.task && m.task.subtasks && m.cls !== 'done') {
388        for (const s of m.task.subtasks) {
389          if (classOf(s.status) === 'cancelled') continue
390          const si: PlanItem = { kind: 'subtask', title: s.title, status: s.status, phase: ph.name, id: s.id }
391          ;(classOf(s.status) === 'done' ? completed : remaining).push(si)
392        }
393      }
394    }
395  }
396  const orphanTasks = tk.filter((t) => !used.has(t.id || t.title)).map((t) => ({ ...t, sub: t.subtasks ? progress(t.subtasks.map((s) => s.status)) : null }))
397  for (const t of orphanTasks) {
398    const item: PlanItem = { kind: 'task', title: t.title, status: t.status, phase: '', id: t.id, due: t.due, overdue: isOverdue(t.due, t.status, today) }
399    if (classOf(t.status) === 'cancelled') continue
400    ;(classOf(t.status) === 'done' ? completed : remaining).push(item)
401    if (item.overdue) overdue.push(item)
402  }
403  return {
404    title: p.title, status: p.status, phases, current, awaitingReview, pct, reviewPct, milestones: msProg,
405    tasks: progress(tk.map((t) => t.status)), subtasks: progress(subs.map((s) => s.status)),
406    hours, cost, completed, remaining, overdue, orphanTasks,
407  }
408}
409
410// ---------------------------------------------------------------------------
411// Bars — text for the terminal, SVG for the Desktop
412// ---------------------------------------------------------------------------
413
414/** `███▒▒░░░░░` — done, in review, remaining. */
415export function textBar(frac: number, width: number, reviewFrac = 0): string {
416  const w = Math.max(1, Math.floor(width))
417  const f = Math.max(0, Math.min(1, frac || 0))
418  const r = Math.max(0, Math.min(1 - f, reviewFrac || 0))
419  const d = Math.round(f * w)
420  const v = Math.min(w - d, Math.round(r * w))
421  return '█'.repeat(d) + '▒'.repeat(v) + '░'.repeat(Math.max(0, w - d - v))
422}
423
424const esc = (s: string) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] as string))
425
426export const SVG_COLORS = { done: '#2f9e5b', review: '#d49a1a', active: '#3b82f6', rest: '#8a8f98', overdue: '#d64545', text: '#8a8f98', current: '#3b82f6' }
427
428/** One bar row: label left, bar, percent right. Neutral text colour so it reads on light and dark. */
429export function svgBar(opts: { width: number; label?: string; frac: number; review?: number; overdue?: boolean; height?: number; current?: boolean }): string {
430  const W = Math.max(120, Math.round(opts.width))
431  const H = opts.height ?? 22
432  const labelW = opts.label ? Math.min(Math.round(W * 0.42), 260) : 0
433  const pctW = 44
434  const barX = labelW ? labelW + 8 : 0
435  const barW = Math.max(20, W - barX - pctW)
436  const f = Math.max(0, Math.min(1, opts.frac || 0))
437  const r = Math.max(0, Math.min(1 - f, opts.review || 0))
438  const pct = Math.round(f * 100)
439  const lab = opts.label ? `<text x="0" y="${H / 2 + 4}" font-family="system-ui,sans-serif" font-size="12" fill="${opts.current ? SVG_COLORS.current : SVG_COLORS.text}"${opts.current ? ' font-weight="700"' : ''}>${esc(clip(opts.label, Math.floor(labelW / 6.6)))}</text>` : ''
440  const y = Math.round(H / 2 - 5)
441  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${lab}`
442    + `<rect x="${barX}" y="${y}" width="${barW}" height="10" rx="5" fill="${SVG_COLORS.rest}" fill-opacity="0.25"/>`
443    + (r > 0 ? `<rect x="${barX}" y="${y}" width="${Math.round(barW * (f + r))}" height="10" rx="5" fill="${SVG_COLORS.review}"/>` : '')
444    + (f > 0 ? `<rect x="${barX}" y="${y}" width="${Math.max(10, Math.round(barW * f))}" height="10" rx="5" fill="${opts.overdue ? SVG_COLORS.overdue : SVG_COLORS.done}"/>` : '')
445    + `<text x="${W}" y="${H / 2 + 4}" text-anchor="end" font-family="system-ui,sans-serif" font-size="12" fill="${SVG_COLORS.text}">${pct}%</text></svg>`
446}
447
448/** The whole plan as one chart: an overall bar, then one row per phase. */
449export function svgPlan(plan: Plan, width: number): string {
450  const W = Math.max(240, Math.round(width))
451  const rowH = 24
452  const H = 34 + plan.phases.length * rowH + 6
453  const parts: string[] = []
454  const labelW = Math.min(Math.round(W * 0.45), 300)
455  const barX = labelW + 8
456  const barW = Math.max(40, W - barX - 48)
457  const f = plan.pct / 100
458  const r = plan.reviewPct / 100
459  parts.push(`<text x="0" y="16" font-family="system-ui,sans-serif" font-size="13" font-weight="700" fill="${SVG_COLORS.text}">Overall</text>`)
460  parts.push(`<rect x="${barX}" y="6" width="${barW}" height="12" rx="6" fill="${SVG_COLORS.rest}" fill-opacity="0.25"/>`)
461  if (r > 0) parts.push(`<rect x="${barX}" y="6" width="${Math.round(barW * Math.min(1, f + r))}" height="12" rx="6" fill="${SVG_COLORS.review}"/>`)
462  if (f > 0) parts.push(`<rect x="${barX}" y="6" width="${Math.max(12, Math.round(barW * f))}" height="12" rx="6" fill="${SVG_COLORS.done}"/>`)
463  parts.push(`<text x="${W}" y="16" text-anchor="end" font-family="system-ui,sans-serif" font-size="13" font-weight="700" fill="${SVG_COLORS.text}">${plan.pct}%</text>`)
464  plan.phases.forEach((ph, i) => {
465    const y = 34 + i * rowH
466    const pf = ph.isDone ? 1 : ph.progress.total ? ph.progress.done / ph.progress.total : 0
467    const pr = ph.progress.total ? ph.progress.review / ph.progress.total : 0
468    const col = ph.isCurrent ? SVG_COLORS.current : SVG_COLORS.text
469    const name = `${i + 1}. ${ph.name}`
470    if (ph.isCurrent) parts.push(`<rect x="0" y="${y - 2}" width="${W}" height="${rowH - 2}" rx="4" fill="${SVG_COLORS.current}" fill-opacity="0.10"/>`)
471    parts.push(`<text x="4" y="${y + 14}" font-family="system-ui,sans-serif" font-size="12" fill="${col}"${ph.isCurrent ? ' font-weight="700"' : ''}>${esc(clip(name, Math.floor(labelW / 6.6)))}</text>`)
472    parts.push(`<rect x="${barX}" y="${y + 5}" width="${barW}" height="10" rx="5" fill="${SVG_COLORS.rest}" fill-opacity="0.25"/>`)
473    if (pr > 0) parts.push(`<rect x="${barX}" y="${y + 5}" width="${Math.round(barW * Math.min(1, pf + pr))}" height="10" rx="5" fill="${SVG_COLORS.review}"/>`)
474    if (pf > 0) parts.push(`<rect x="${barX}" y="${y + 5}" width="${Math.max(10, Math.round(barW * pf))}" height="10" rx="5" fill="${ph.overdue ? SVG_COLORS.overdue : SVG_COLORS.done}"/>`)
475    parts.push(`<text x="${W}" y="${y + 14}" text-anchor="end" font-family="system-ui,sans-serif" font-size="12" fill="${col}">${ph.progress.done}/${ph.progress.total}</text>`)
476  })
477  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${parts.join('')}</svg>`
478}
479
480export function clip(s: string, n: number): string {
481  const t = String(s || '')
482  return t.length > n ? t.slice(0, Math.max(1, n - 1)) + '…' : t
483}
484
485export function money(v: number): string {
486  return v >= 1000 ? `$${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k` : `$${Math.round(v)}`
487}
488
489// ---------------------------------------------------------------------------
490// The status line
491// ---------------------------------------------------------------------------
492
493/** `HelmOS Live Voice · phase 7/7 · ███░░ 62% · gates 14/15 (1 down) · owes 2` — short on purpose. */
494export function statusLine(s: CanonStatus | null, plan?: Plan | null): string | undefined {
495  if (!s && !plan) return undefined
496  if (s && s.mode !== 'on') return `portal · canon ${s.mode}` + (plan ? ` · ${textBar(plan.pct / 100, 6)} ${plan.pct}%` : '')
497  const parts: string[] = []
498  if (plan) {
499    parts.push(clip(plan.title.split(/ [—–-] /)[0] || plan.title, 28))
500    if (plan.phases.length) parts.push(`phase ${plan.current + 1}/${plan.phases.length}`)
501    parts.push(`${textBar(plan.pct / 100, 8, plan.reviewPct / 100)} ${plan.pct}%`)
502  }
503  if (s) {
504    if (s.run) parts.unshift(`${s.run.id} ${s.run.state}`)
505    else if (!plan) parts.push('canon: no run')
506    const down = s.gates.stood.length
507    parts.push(`gates ${s.gates.armed}/${s.gates.total} armed` + (down ? ` (${down} stood down)` : ''))
508    const o = owedNow(s)
509    if (o.count) parts.push(`owes ${o.count} read-back${o.count === 1 ? '' : 's'}`)
510    if (o.journal) parts.push('journal owed')
511    if (o.closeout.length) parts.push(`close-out: ${o.closeout.length} missing`)
512  }
513  return parts.join(' · ')
514}
515
516/** The compact run context added to the first message (prompt.context): a few lines, not a card. */
517export function runContext(s: CanonStatus | null, plan: Plan | null): string | null {
518  if (!s?.run && !plan) return null
519  const lines: string[] = []
520  if (s?.run) lines.push(`Portal run ${s.run.id} is ${s.run.state}${s.run.project_id ? ` on project ${s.run.project_id}` : ''}.`)
521  if (plan) {
522    const ph = plan.phases[plan.current]
523    lines.push(`Plan "${plan.title}": ${plan.pct}% delivered; current phase ${plan.current + 1}/${plan.phases.length}${ph ? ` "${ph.name}" (${phaseCounts(ph)})` : ''}; ${plan.remaining.filter((r) => r.kind === 'milestone').length} milestone(s) remaining${plan.overdue.length ? `, ${plan.overdue.length} overdue` : ''}${plan.awaitingReview.length ? `; awaiting review: phase ${plan.awaitingReview.map((i) => i + 1).join(', ')}` : ''}.`)
524  }
525  if (s) {
526    const o = owedNow(s)
527    if (o.count) lines.push(`Owed now: ${o.count} read-back(s)${o.settle ? ` — settle with ${clip(o.settle, 240)}` : ''}.`)
528    if (s.gates.stood.length) lines.push(`Stood down: ${s.gates.stood.map((g) => g.id).join(', ')} (re-arm with /portal-rearm).`)
529  }
530  lines.push('This summary comes from the management-portal mod and is a snapshot; the canon gates remain the authority.')
531  return lines.join('\n')
532}
533
534// ---------------------------------------------------------------------------
535// The idle-run nudge — closes "no hook can restart an idle session", toast first, capped.
536// ---------------------------------------------------------------------------
537
538export const NUDGE_GRACE_MS = 60_000
539export const NUDGE_MAX_WITHOUT_PROGRESS = 3
540
541export type NudgeInput = {
542  now: number
543  prefs: Pick<PortalPrefs, 'nudge' | 'nudgeMinutes'>
544  idle: PortalIdle
545  canon: CanonStatus | null
546  plan: Plan | null
547  /** Subagents, background shells or monitors still running. */
548  busy: boolean
549  /** A fingerprint of the canon's progress (ledger position, owed, run state). */
550  progressMark: string
551}
552export type NudgeVerdict = { act: 'none' | 'warn' | 'nudge' | 'cancel'; why: string }
553
554export function nudgeEnabled(prefs: Pick<PortalPrefs, 'nudge'>, idle: Pick<PortalIdle, 'continueMode'>): boolean {
555  return prefs.nudge === 'on' || (prefs.nudge === 'auto' && idle.continueMode)
556}
557
558export function nudgeDecision(i: NudgeInput): NudgeVerdict {
559  if (!nudgeEnabled(i.prefs, i.idle)) return { act: i.idle.pendingSince ? 'cancel' : 'none', why: 'nudge off for this session' }
560  const run = i.canon && i.canon.run
561  if (!run || run.state !== 'RUN') return { act: i.idle.pendingSince ? 'cancel' : 'none', why: 'no run in RUN state' }
562  const remaining = i.plan ? i.plan.remaining.filter((r) => r.kind === 'milestone').length : null
563  if (remaining === 0) return { act: i.idle.pendingSince ? 'cancel' : 'none', why: 'no phases remaining' }
564  if (i.idle.working) return { act: i.idle.pendingSince ? 'cancel' : 'none', why: 'a turn is running' }
565  if (i.busy) return { act: i.idle.pendingSince ? 'cancel' : 'none', why: 'background work is running' }
566  if (i.idle.nudges >= NUDGE_MAX_WITHOUT_PROGRESS && i.idle.progressMark === i.progressMark) {
567    return { act: i.idle.pendingSince ? 'cancel' : 'none', why: `${NUDGE_MAX_WITHOUT_PROGRESS} nudges without progress — paused` }
568  }
569  const idleMs = Math.max(1, i.prefs.nudgeMinutes) * 60_000
570  if (i.now - i.idle.lastActivityAt < idleMs) return { act: i.idle.pendingSince ? 'cancel' : 'none', why: 'not idle long enough' }
571  if (!i.idle.pendingSince) return { act: 'warn', why: 'idle' }
572  if (i.now - i.idle.pendingSince >= NUDGE_GRACE_MS) return { act: 'nudge', why: 'idle past the grace period' }
573  return { act: 'none', why: 'counting down' }
574}
575
576export function nudgePrompt(s: CanonStatus | null, plan: Plan | null, minutes: number): string {
577  const run = s && s.run ? s.run.id : 'the portal run'
578  const ph = plan && plan.phases[plan.current]
579  return `The management-portal run ${run} is in RUN state and this session has been idle for ${minutes} minute(s) `
580    + `with no background work running${ph ? ` (current phase: "${clip(ph.name, 80)}", ${plan!.remaining.filter((r) => r.kind === 'milestone').length} milestone(s) remaining)` : ''}. `
581    + 'Continue the run from where it stopped, following /portal-continue: no confirmation between phases; settle anything owed first. '
582    + 'If you are genuinely blocked, say so in a journal entry tagged "blocked" and stop.'
583}
584
585// ---------------------------------------------------------------------------
586// Team Chat
587// ---------------------------------------------------------------------------
588
589export type ChatMessage = { id: string; sender: string; senderId: string | null; at: string; mentions: string[]; content: string }
590
591/** read_channel_messages lines: `Name (YYYY-MM-DD HH:MM:SS) [id=… | sender_id=… | …]: content` */
592export function parseMessages(text: string): ChatMessage[] {
593  const out: ChatMessage[] = []
594  for (const line of String(text || '').split(/\r?\n/)) {
595    const m = /^(.+?) \((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\) \[(.*?)\]: ?(.*)$/.exec(line)
596    if (!m) continue
597    const bits: Record<string, string> = {}
598    for (const b of (m[3] ?? '').split(' | ')) { const k = b.indexOf('='); if (k > 0) bits[b.slice(0, k)] = b.slice(k + 1) }
599    if (!bits.id) continue
600    out.push({ id: bits.id, sender: (m[1] ?? ''), senderId: bits.sender_id || null, at: (m[2] ?? ''),
601      mentions: bits.mentions ? bits.mentions.split(',') : [], content: (m[4] ?? '') })
602  }
603  return out
604}
605
606/** Does this message address `agent`? `@name` (case-insensitive) or `@all`/`@everyone`, never
607 *  its own messages. Deliberately narrow: a false wake costs a turn, a missed one costs latency,
608 *  and the server-side `await_my_turn` remains the authority. */
609export function addresses(m: ChatMessage, agent: string): boolean {
610  if (!agent) return false
611  const a = agent.trim().toLowerCase()
612  if (m.sender.trim().toLowerCase() === a) return false
613  const c = m.content.toLowerCase()
614  const escd = a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
615  return new RegExp('@' + escd + '(?![\\w-])').test(c) || /@(all|everyone)\b/.test(c)
616}
617
618/** Names that reach a prompt are reduced to a safe, short label — the text of a chat message
619 *  is never relayed into the model's prompt by the mod (it is untrusted input). */
620export function safeLabel(s: string | null | undefined, max = 40): string {
621  return String(s || '').replace(/[^\w .@-]/g, '').trim().slice(0, max) || '?'
622}
623
624export function wakePrompt(channelId: string, agent: string, msg: ChatMessage): string {
625  return `Team Chat: a new message in channel ${channelId} addresses you (${safeLabel(agent)}) — message id `
626    + `${msg.id}, from ${safeLabel(msg.sender)}. Read read_channel_policy and read_channel_messages for that `
627    + `channel (as_agent: "${safeLabel(agent)}"), answer per the channel policy, then re-arm your watch `
628    + '(/rearm-watch) as the team-chat-reachability skill describes.'
629}
630
631// ---------------------------------------------------------------------------
632// Notifications — DMs, inbox, tasks due, approvals. Labels only; message text never reaches a prompt.
633// ---------------------------------------------------------------------------
634
635/** read_dm_conversations: `conversation_id | unread | last_read_at | participants` rows. */
636export function parseDmConversations(text: string): { id: string; unread: number; who: string }[] {
637  const out: { id: string; unread: number; who: string }[] = []
638  for (const line of String(text || '').split(/\r?\n/)) {
639    const m = /^([0-9a-f-]{36})\s*\|\s*(\d+|\?)\s*\|\s*[^|]*\|\s*(.*)$/.exec(line.trim())
640    if (m) out.push({ id: m[1] ?? '', unread: m[2] === '?' ? 0 : Number(m[2]), who: (m[3] ?? '').trim() })
641  }
642  return out
643}
644
645/** read_inbox: `Sender (date) [id=… | sender_id=… | read=no] Subject: …`. */
646export function parseInbox(text: string): { id: string; sender: string; read: boolean; subject: string; at: string }[] {
647  const out: { id: string; sender: string; read: boolean; subject: string; at: string }[] = []
648  for (const line of String(text || '').split(/\r?\n/)) {
649    const m = /^(.+?) \((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\) \[(.*?)\] Subject: ?(.*)$/.exec(line)
650    if (!m) continue
651    const bits: Record<string, string> = {}
652    for (const b of (m[3] ?? '').split(' | ')) { const k = b.indexOf('='); if (k > 0) bits[b.slice(0, k)] = b.slice(k + 1) }
653    if (!bits.id) continue
654    out.push({ id: bits.id, sender: m[1] ?? '', read: bits.read === 'yes', subject: m[4] ?? '', at: m[2] ?? '' })
655  }
656  return out
657}
658
659/** list_scheduling_requests: one request per `- ` / id-bearing line; "No scheduling requests" = none. */
660export function parseSchedulingRequests(text: string): { id: string; label: string }[] {
661  const out: { id: string; label: string }[] = []
662  if (/^No scheduling requests/i.test(String(text || '').trim())) return out
663  for (const line of String(text || '').split(/\r?\n/)) {
664    const id = UUID.exec(line)
665    if (!id || !/^\s*(-|\*|\d+\.)\s/.test(line)) continue
666    out.push({ id: id[0], label: safeLabel(line.replace(/\[.*?\]/g, '').replace(UUID, '').replace(/^\s*(-|\*|\d+\.)\s*/, ''), 60) })
667  }
668  return out
669}
670
671/** New notifications against what was already announced. Unread DMs are keyed by conversation
672 *  and count, so a further message in the same conversation announces again. */
673export function newNotes(
674  input: { dms: { id: string; unread: number; who: string }[]; inbox: { id: string; sender: string; read: boolean; subject: string }[];
675    approvals: { id: string; label: string }[]; due: { id: string | null; title: string; due: string | null | undefined }[] },
676  seen: readonly string[], now: number,
677): PortalNote[] {
678  const s = new Set(seen)
679  const out: PortalNote[] = []
680  for (const d of input.dms) {
681    if (d.unread <= 0 || /just you/i.test(d.who)) continue
682    const id = `dm:${d.id}:${d.unread}`
683    if (!s.has(id)) out.push({ id, kind: 'dm', text: `${d.unread} unread DM${d.unread === 1 ? '' : 's'} from ${safeLabel(d.who, 30)}`, at: now })
684  }
685  for (const m of input.inbox) {
686    if (m.read) continue
687    const id = `inbox:${m.id}`
688    if (!s.has(id)) out.push({ id, kind: 'inbox', text: `Inbox: ${safeLabel(m.sender, 24)} — ${safeLabel(m.subject, 50)}`, at: now })
689  }
690  for (const a of input.approvals) {
691    const id = `approval:${a.id}`
692    if (!s.has(id)) out.push({ id, kind: 'approval', text: `Approval waiting: ${a.label}`, at: now })
693  }
694  for (const t of input.due) {
695    const id = `due:${t.id || t.title}:${t.due}`
696    if (!s.has(id)) out.push({ id, kind: 'due', text: `Due ${t.due}: ${clip(t.title, 60)}`, at: now })
697  }
698  return out
699}
700
701// ---------------------------------------------------------------------------
702// Knowledge graph interpretation and boards — what the graph says, what the board holds
703// ---------------------------------------------------------------------------
704
705/** The bullet lines under a STRUCTURAL FACTS heading such as `HUBS (by degree …):`. */
706function factList(text: string, heading: RegExp): string[] {
707  const lines = String(text || '').split(/\r?\n/)
708  const out: string[] = []
709  let on = false
710  for (const line of lines) {
711    if (/^[A-Z][A-Z /&-]{2,60}(\(.*\))?:\s*(.*)$/.test(line)) {
712      on = heading.test(line)
713      const inline = /:\s*(\S.*)$/.exec(line)
714      if (on && inline && inline[1] && !/^none$/i.test(inline[1].trim())) out.push(...inline[1].split(/,\s*/).map((x) => x.trim()).filter(Boolean))
715      continue
716    }
717    if (!on) continue
718    const m = /^\s+-\s+(.*)$/.exec(line)
719    if (m && m[1]) out.push(m[1].trim())
720    else if (/^\s+\S/.test(line) && !/^\s+-/.test(line)) out.push(...line.trim().split(/,\s*/).filter((x) => x && !/^none$/i.test(x)))
721    else if (!line.trim()) on = false
722  }
723  return out
724}
725
726/** interpret_knowledge_graph: `# Interpretation — {title}`, prose, then `--- STRUCTURAL FACTS … ---`
727 *  with HUBS / BRIDGES / COMMUNITIES / CANDIDATE SURPRISING CONNECTIONS / ISOLATED NODES lists. */
728export function parseKgInterpretation(text: string, graphId: string | null, now: number): KgView | null {
729  const t = String(text || '')
730  if (!t.trim() || /has no nodes yet/i.test(t) || /^(error|graph not found)/i.test(t.trim())) return null
731  const cut = t.search(/^---\s*STRUCTURAL FACTS/m)
732  const prose = (cut >= 0 ? t.slice(0, cut) : t).replace(/^#\s*Interpretation\s*[—-]\s*.*$/m, '').replace(/^⚠.*$/gm, '').trim()
733  const facts = cut >= 0 ? t.slice(cut) : t
734  const title = (/^#\s*Interpretation\s*[—-]\s*(.+)$/m.exec(t)?.[1] || /^GRAPH:\s*(.+?)(?:\s*\(id [^)]*\))?\s*$/m.exec(t)?.[1] || '').trim()
735  const gid = graphId || /^GRAPH:.*\(id ([0-9a-f-]{36})\)/m.exec(t)?.[1] || null
736  const label = (s: string) => s.replace(/\s*\[[^\]]*\]/g, '').replace(/\s+(degree|spans)\b.*$/, '').trim()
737  const hubs = factList(facts, /^HUBS/).map(label)
738  const bridges = factList(facts, /^BRIDGES/).map(label)
739  const communities = factList(facts, /^COMMUNITIES/).map((c) => c.replace(/:.*$/, '').trim())
740  const gaps = factList(facts, /^CANDIDATE SURPRISING/).map((g) => g.replace(/\s*\[[^\]]*\]/g, '').replace(/\s+cosine\s+([\d.]+).*$/, ' (cosine $1)').trim())
741  const thin = factList(facts, /^ISOLATED NODES/)
742  const firstPara = prose.split(/\n\s*\n/).map((p) => p.trim()).find((p) => p.length > 30 && !/^#/.test(p)) || ''
743  if (!hubs.length && !gaps.length && !communities.length && !firstPara) return null
744  return { graphId: gid, title: title || 'knowledge graph', hubs: hubs.slice(0, 8), bridges: bridges.slice(0, 6), gaps: gaps.slice(0, 8),
745    thin: thin.slice(0, 8), communities: communities.slice(0, 8), summary: clip(firstPara.replace(/\s+/g, ' '), 600), at: now }
746}
747
748/** read_board: `Board: {title}`, then `{indent}- [{block_id}] type={type} pos={n} comments={n} | {summary}`,
749 *  two spaces per depth from depth 1; comment lines (`💬 …`) are skipped. */
750export function parseBoard(text: string, boardId: string | null, now: number): BoardView | null {
751  const t = String(text || '')
752  const title = /^Board: (.+)$/m.exec(t)
753  if (!title) return null
754  const blocks: BoardBlock[] = []
755  for (const line of t.split(/\r?\n/)) {
756    if (/^Board-level comments:/.test(line)) break
757    const m = /^(\s*)- \[([^\]]+)\] type=(\S+)(?: pos=\S+)?(?: comments=(\d+))?\s*\|?\s*(.*)$/.exec(line)
758    if (!m) continue
759    blocks.push({ depth: Math.max(0, Math.floor((m[1] ?? '').length / 2) - 1), type: (m[3] ?? '').toLowerCase(),
760      text: (m[5] ?? '').trim(), id: m[2] ?? null })
761  }
762  return { id: boardId, title: (title[1] ?? '').trim(), blocks: blocks.slice(0, 80), at: now }
763}
764
765// ---------------------------------------------------------------------------
766// Links into HelmOS — only when the base URL is known (a portal result carried one, or the
767// person set it). Never guessed: a wrong link is worse than none.
768// ---------------------------------------------------------------------------
769
770export function helmosLink(base: string | null | undefined, kind: 'project' | 'task' | 'board' | 'journal' | 'graph', id: string | null | undefined): string | null {
771  if (!base || !/^https:\/\/[a-z0-9.-]+(?::\d+)?(\/[\w./-]*)?$/.test(base)) return null
772  const b = base.replace(/\/+$/, '')
773  // Routes from the HelmOS web app (frontend/src/App.tsx): boards, journal entries and graphs have
774  // their own page; a project and a task open from their list pages.
775  if (kind === 'project') return b + '/projects'
776  if (kind === 'task') return b + '/tasks'
777  if (!id || !UUID.test(id)) return null
778  return b + (kind === 'board' ? `/boards/${id}` : kind === 'journal' ? `/journal/${id}` : `/knowledge-graphs/${id}`)
779}
780
781// ---------------------------------------------------------------------------
782// Cards — the rich rendering of a portal tool result
783// ---------------------------------------------------------------------------
784
785export type CardRow = { mark: string; text: string; meta?: string; id?: string | null; cls?: StatusClass; frac?: number | null; depth?: number }
786export type Card = { title: string; summary: string; rows: CardRow[]; more: number; frac?: number | null; review?: number | null; kind?: string }
787
788const short = (id: string | null | undefined) => (id ? String(id).slice(0, 8) : '')
789
790/** The tools a card exists for. Anything else draws exactly as the engine draws it. */
791export const CARD_TOOLS = new Set([
792  'list_tasks', 'list_subtasks', 'get_proposal_detail', 'list_milestones', 'list_flow_clusters',
793  'list_flow_connections', 'read_channel_messages', 'list_projects', 'list_clients', 'list_boards',
794  'list_journals', 'list_knowledge_graphs', 'bulk', 'interpret_knowledge_graph', 'read_board',
795  'read_dm_conversations', 'read_inbox',
796])
797
798export function cardFor(tool: string, text: string, maxRows = 12): Card | null {
799  const t = String(text || '')
800  if (!t.trim()) return null
801  let rows: CardRow[] = []
802  let summary = ''
803  let frac: number | null = null
804  let review: number | null = null
805  if (tool === 'get_proposal_detail' || tool === 'list_milestones') {
806    const p = parseProposalDetail(t)
807    if (!p) return null
808    const plan = buildPlan(p, null, '0000-00-00')
809    summary = `${p.title} · ${p.status || 'draft'} · ${p.phases.length} phase(s) · milestones ${plan.milestones.done}/${plan.milestones.total} done`
810      + (plan.milestones.review ? ` · ${plan.milestones.review} in review` : '')
811      + (plan.hours ? ` · ${round1(plan.hours.delivered)}/${round1(plan.hours.planned)} h` : '')
812    frac = plan.pct / 100
813    review = plan.reviewPct / 100
814    for (const ph of plan.phases) {
815      rows.push({ mark: ph.isCurrent ? '▸' : '■', text: ph.name, meta: `${ph.progress.done}/${ph.progress.total}`, id: ph.id,
816        frac: ph.progress.total ? ph.progress.done / ph.progress.total : 0, depth: 0 })
817      for (const m of ph.milestones) rows.push({ mark: glyph(m.status), text: m.name, meta: m.status + (m.overdue ? ' · overdue' : ''), id: m.id, cls: m.cls, depth: 1 })
818    }
819  } else if (tool === 'list_tasks' || tool === 'list_subtasks') {
820    const tasks = parseTasks(t)
821    if (!tasks.length) return null
822    const pr = progress(tasks.map((x) => x.status))
823    summary = `${tasks.length} ${tool === 'list_subtasks' ? 'subtask' : 'task'}(s) · ${pr.done} done · ${tasks.filter((x) => classOf(x.status) === 'active').length} active · ${pr.total - pr.done} remaining`
824    frac = pr.total ? pr.done / pr.total : 0
825    rows = [...tasks].sort((a, b) => rankCls(classOf(a.status)) - rankCls(classOf(b.status)))
826      .map((x) => ({ mark: glyph(x.status), text: x.title, meta: [x.status, x.priority, x.due ? 'due ' + x.due : null].filter(Boolean).join(' · '), id: x.id, cls: classOf(x.status) }))
827  } else if (tool === 'read_channel_messages') {
828    const msgs = parseMessages(t)
829    if (!msgs.length) return null
830    summary = `${msgs.length} message(s)` + (/CHANNEL POLICY/.test(t) ? ' · policy attached (see raw)' : '')
831    rows = msgs.map((m) => ({ mark: '›', text: `${m.sender}: ${m.content.replace(/\s+/g, ' ').slice(0, 100)}`, meta: m.at.slice(5, 16), id: m.id }))
832  } else if (tool === 'read_dm_conversations') {
833    const dms = parseDmConversations(t)
834    if (!dms.length) return null
835    const unread = dms.reduce((a, d) => a + d.unread, 0)
836    summary = `${dms.length} conversation(s) · ${unread} unread`
837    rows = dms.map((d) => ({ mark: d.unread ? '●' : '○', text: d.who, meta: d.unread ? `${d.unread} unread` : '', id: d.id }))
838  } else if (tool === 'read_inbox') {
839    const msgs = parseInbox(t)
840    if (!msgs.length) return null
841    summary = `${msgs.length} message(s) · ${msgs.filter((m) => !m.read).length} unread`
842    rows = msgs.map((m) => ({ mark: m.read ? '○' : '●', text: `${m.sender}: ${m.subject}`, meta: m.at.slice(5, 16), id: m.id }))
843  } else if (tool === 'interpret_knowledge_graph') {
844    const kg = parseKgInterpretation(t, null, 0)
845    if (!kg) return null
846    summary = `${kg.hubs.length} hub(s) · ${kg.gaps.length} gap(s) · ${kg.communities.length} communit${kg.communities.length === 1 ? 'y' : 'ies'}`
847    for (const h of kg.hubs) rows.push({ mark: '◆', text: h, meta: 'hub' })
848    for (const g of kg.gaps) rows.push({ mark: '⚠', text: g, meta: 'gap' })
849    for (const b of kg.bridges) rows.push({ mark: '↔', text: b, meta: 'bridge' })
850  } else if (tool === 'read_board') {
851    const b = parseBoard(t, null, 0)
852    if (!b || !b.blocks.length) return null
853    summary = `${b.title} · ${b.blocks.length} block(s)`
854    rows = b.blocks.map((x) => ({ mark: x.type === 'mermaid' ? '◇' : '▪', text: x.text || '(empty)', meta: x.type, id: x.id, depth: x.depth }))
855  } else if (tool === 'bulk') {
856    const head = /Ran (\d+)\/(\d+) call\(s\); (\d+) failed/.exec(t)
857    if (!head) return null
858    summary = `bulk · ran ${(head[1] ?? '')}/${(head[2] ?? '')} · ${(head[3] ?? '')} failed`
859    for (const line of t.split(/\r?\n/)) {
860      const m = /^\[(\d+)\] ([a-z_]+): ?(.*)$/.exec(line)
861      if (m) rows.push({ mark: /^\s*(FAILED|SKIPPED)/.test((m[3] ?? '')) ? '✗' : '✓', text: (m[2] ?? ''), meta: (m[3] ?? '').replace(/\s+/g, ' ').slice(0, 70) })
862    }
863  } else {
864    // A generic portal listing: `Found N x(s):` then `- … [id: …]` rows.
865    const found = /^Found (\d+) ([^:]+):/m.exec(t)
866    if (!found) return null
867    summary = `${(found[1] ?? '')} ${(found[2] ?? '')}`
868    for (const line of t.split(/\r?\n/)) {
869      if (!/^\s*- /.test(line)) continue
870      const id = RE_ID.exec(line)
871      const body = line.replace(RE_ID, '').replace(/^\s*- /, '').trim()
872      const label = (body.split(' | ')[0] ?? '').replace(/\s+/g, ' ')
873      rows.push({ mark: '•', text: label.slice(0, 90), id: (id?.[1] ?? null) })
874    }
875  }
876  const more = Math.max(0, rows.length - maxRows)
877  return { title: tool, summary, rows: rows.slice(0, maxRows), more, frac, review, kind: tool }
878}
879
880function rankCls(c: StatusClass): number {
881  return c === 'active' ? 0 : c === 'review' ? 1 : c === 'pending' ? 2 : c === 'done' ? 3 : 4
882}
883
884export function round1(v: number): number {
885  return Math.round(v * 10) / 10
886}
887
888export function rowLine(r: CardRow, width: number): string {
889  const id = r.id ? ` ${short(r.id)}` : ''
890  const meta = r.meta ? `  ${r.meta}` : ''
891  const indent = r.depth ? '  '.repeat(r.depth) : ''
892  const room = Math.max(10, width - id.length - meta.length - r.mark.length - 2 - indent.length)
893  const text = r.text.length > room ? r.text.slice(0, room - 1) + '…' : r.text
894  return `${indent}${r.mark} ${text}${meta}${id}`
895}
896
897/** A cheap fingerprint of canon progress, for the nudge's "no progress since" check. */
898export function progressMark(s: CanonStatus | null, plan: Plan | null): string {
899  return [s?.run?.id, s?.run?.state, s?.run?.last_progress_at, owedNow(s).count, plan?.pct, plan?.subtasks.done].join('|')
900}
901
902/** The first task that is in progress, else the first pending one in the current phase. */
903export function currentTask(plan: Plan | null): { id: string; title: string } | null {
904  if (!plan) return null
905  const ph = plan.phases[plan.current]
906  const pick = (cls: StatusClass[]) => {
907    for (const p of [ph, ...plan.phases].filter(Boolean) as PlanPhase[]) {
908      for (const m of p.milestones) if (m.task && m.task.id && cls.includes(classOf(m.task.status))) return { id: m.task.id, title: m.task.title }
909    }
910    for (const t of plan.orphanTasks) if (t.id && cls.includes(classOf(t.status))) return { id: t.id, title: t.title }
911    return null
912  }
913  return pick(['active']) || pick(['pending', 'review'])
914}
915
916/** Tasks due today or earlier that are not done — for the notifications. */
917export function dueTasks(tasks: readonly TaskRow[] | null, today: string): TaskRow[] {
918  return (tasks || []).filter((t) => t.due && /^\d{4}-\d{2}-\d{2}/.test(t.due) && t.due.slice(0, 10) <= today
919    && classOf(t.status) !== 'done' && classOf(t.status) !== 'cancelled')
920}
921
922/** start_timer's answer names the new time entry; take the first uuid after "entry"/"id". */
923export function parseTimerEntry(text: string): string | null {
924  const t = String(text || '')
925  const m = /(?:entry|id)[^0-9a-f]{0,20}([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(t) || UUID.exec(t)
926  return m ? (m[1] ?? m[0] ?? null) : null
927}
928
929// ---------------------------------------------------------------------------
930// The command launcher — every command the plugin ships, read from commands/*.md at run time
931// ---------------------------------------------------------------------------
932
933/** A command file's frontmatter: `description:` and `argument-hint:`, quoted or bare. */
934export function parseCommandFile(fileName: string, text: string): CommandInfo | null {
935  const name = String(fileName || '').replace(/^.*[\/]/, '').replace(/\.md$/i, '')
936  if (!/^[\w-]{1,64}$/.test(name)) return null
937  const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''))
938  const get = (k: string) => {
939    if (!fm) return null
940    const m = new RegExp('^' + k + ':\s*(.*)$', 'm').exec(fm[1] ?? '')
941    if (!m) return null
942    const v = (m[1] ?? '').trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1').trim()
943    return v || null
944  }
945  const description = get('description') || (String(text || '').replace(/^---[\s\S]*?---/, '').trim().split(/\r?\n/)[0] || '').slice(0, 200)
946  const argumentHint = get('argument-hint')
947  return { name, description, argumentHint, needsArgs: needsArgs(argumentHint), source: 'plugin' }
948}
949
950/** `<x>` is required; `[x]` alone is optional. */
951export function needsArgs(hint: string | null | undefined): boolean {
952  return /<[^>]+>/.test(String(hint || ''))
953}
954
955export const PRESET_RUN = 'Run'
956export const PRESET_CHAT = 'Team Chat'
957export const PRESET_ALL = 'All'
958export const BUILTIN_PRESETS = [PRESET_RUN, PRESET_CHAT, PRESET_ALL]
959const RUN_FIRST = ['portal-continue', 'portal-cockpit', 'portal-gates', 'portal-project', 'portal', 'portal-rearm', 'portal-stand-down', 'plain-english']
960const CHAT_FIRST = ['channel-join', 'channel-coordinate', 'rearm-watch']
961
962/** The built-in presets, derived from whatever commands exist (so a renamed command never strands one). */
963export function builtinPresets(list: readonly CommandInfo[]): Record<string, string[]> {
964  const names = list.map((c) => c.name)
965  const pick = (order: string[], match: RegExp) => [...order.filter((n) => names.includes(n)), ...names.filter((n) => !order.includes(n) && match.test(n))]
966  return {
967    [PRESET_RUN]: pick(RUN_FIRST, /^portal/),
968    [PRESET_CHAT]: pick(CHAT_FIRST, /^(channel|rearm-watch|team)/),
969    [PRESET_ALL]: [...names],
970  }
971}
972
973/** The commands a preset shows, in the preset's order; names no longer shipped are dropped. */
974export function presetCommands(list: readonly CommandInfo[], presets: Record<string, string[]>, active: string): CommandInfo[] {
975  const all = { ...builtinPresets(list), ...presets }
976  const names = all[active] || all[PRESET_RUN] || []
977  const by = new Map(list.map((c) => [c.name, c]))
978  return names.map((n) => by.get(n)).filter((c): c is CommandInfo => Boolean(c))
979}
980
981/** A preset name a person may save: short, printable, not a built-in. */
982export function presetNameOk(name: string): boolean {
983  const n = String(name || '').trim()
984  return n.length > 0 && n.length <= 32 && !BUILTIN_PRESETS.includes(n) && /^[\w .-]+$/.test(n)
985}
986
987/** `2 delivered · 1 in review · 0 pending` for a phase. */
988export function phaseCounts(ph: { milestones: { cls: StatusClass }[] }): string {
989  const n = (c: StatusClass) => ph.milestones.filter((m) => m.cls === c).length
990  const parts = [`${n('done')} delivered`]
991  if (n('review')) parts.push(`${n('review')} in review`)
992  if (n('active')) parts.push(`${n('active')} in progress`)
993  parts.push(`${n('pending')} pending`)
994  return parts.join(' · ')
995}
996
types/index.d.ts 152 lines
1// The management-portal mod's $.state contract (plugin 1.9.0). Every value a drawing reads is
2// held by the host here, not in a module variable: a module variable is not what a render sees
3// (measured live on 2.1.291 — the pane kept drawing its first, empty value after the canon
4// snapshot had arrived) and a hot reload starts it over.
5
6export type CanonOwed = { w: string; id: string | null; neg?: boolean; carried?: boolean; read?: string | null }
7
8/** What `node scripts/canon-gate.js status --json` prints: a READ-ONLY snapshot of the canon. */
9export type CanonStatus = {
10  v: number
11  plugin?: string | null
12  home?: string
13  homeVia?: string
14  mode: string
15  run: null | {
16    id: string
17    state: string
18    mode?: string
19    project_id?: string | null
20    proposal_id?: string | null
21    client_id?: string | null
22    journal_folder?: string | null
23    last_progress_at?: number | null
24    blocks?: number
25    tree?: { task?: number; subtask?: number; cluster?: number; connection?: number } | null
26  }
27  gates: { total: number; armed: number; stood: { id: string; why: string | null }[] }
28  owed: CanonOwed[]
29  owedTotal?: number
30  settle: string | null
31  journal: null | { boundary: string; id: string | null; status: string | null; wrote: boolean; read: boolean; owed: boolean }
32  closeout: string[]
33  debt: null | { at: number; reads: number; items?: { w: string; id: string | null }[]; settle: string | null; closeout: string[] }
34  error?: string
35}
36
37/** One gate as `canon-gate.js doctor` lists it. */
38export type GateRow = { id: string; armed: boolean; why: string | null }
39
40export type Milestone = {
41  name: string
42  status: string
43  id: string | null
44  cost?: number | null
45  hours?: number | null
46  deadline?: string | null
47}
48export type Phase = { name: string; id: string | null; milestones: Milestone[]; deadline?: string | null; order?: number | null }
49export type Proposal = { title: string; status: string; phases: Phase[] }
50export type SubtaskRow = { status: string; title: string; id: string | null }
51export type TaskRow = {
52  status: string
53  title: string
54  priority: string | null
55  id: string | null
56  due?: string | null
57  subtasks?: SubtaskRow[] | null
58}
59
60/** Which MCP server the mod reaches the portal through, and how it was found. */
61export type PortalServer = { name: string | null; how: string; tried: string[]; error: string | null; at: number }
62
63export type PortalPrefs = {
64  rich: boolean
65  band: boolean
66  watch: boolean
67  notify: boolean
68  autoOpen: boolean
69  /** 'auto' = only for runs started with /portal-continue; 'on' = every RUN; 'off' = never. */
70  nudge: 'auto' | 'on' | 'off'
71  nudgeMinutes: number
72  /** The HelmOS web app's address, for links from cards and panes; null = no links. */
73  helmosUrl: string | null
74}
75export type PortalCockpit = {
76  loading: boolean
77  at: number
78  error: string | null
79  projectId: string | null
80  proposal: Proposal | null
81  tasks: TaskRow[] | null
82  server: string | null
83  /** `mcp` = read by the mod itself; `observed` = parsed from a portal result Claude read. */
84  via: 'mcp' | 'observed' | null
85}
86export type PortalBand = { hidden: boolean; asked: string }
87export type PortalWatch = { channelId: string; agent: string; since: string | null; seen: string[]; lastPollAt: number; lastError: string | null }
88export type PortalView = { tab: 'plan' | 'commands' | 'gates' | 'graph' | 'board' | 'settings'; showDone: boolean }
89
90/** One slash command the launcher can run: read from the plugin's commands/*.md at run time. */
91export type CommandInfo = {
92  name: string
93  description: string
94  argumentHint: string | null
95  /** The hint names a required argument (`<…>`): the launcher asks for it first. */
96  needsArgs: boolean
97  source: 'plugin' | 'mod'
98}
99export type PortalLauncher = {
100  list: CommandInfo[] | null
101  /** Named selections of command names; the built-in ones are re-derived and cannot be deleted. */
102  presets: Record<string, string[]>
103  active: string
104  /** The settings view: the selection being edited, or null when not editing. */
105  editing: string[] | null
106  presetName: string
107  /** A command waiting for its arguments. */
108  form: { name: string; args: string } | null
109  last: string | null
110}
111export type PortalGatePanel = { rows: GateRow[] | null; reason: string; busy: string | null; last: string | null; at: number }
112export type KgView = { graphId: string | null; title: string; hubs: string[]; bridges: string[]; gaps: string[]; thin: string[]; communities: string[]; summary: string; at: number }
113export type BoardBlock = { depth: number; type: string; text: string; id: string | null }
114export type BoardView = { id: string | null; title: string; blocks: BoardBlock[]; at: number }
115export type PortalTimer = { entryId: string | null; taskId: string | null; taskTitle: string; startedAt: number; busy: boolean; error: string | null }
116export type PortalNote = { id: string; kind: 'dm' | 'inbox' | 'due' | 'approval'; text: string; at: number }
117export type PortalNotify = { items: PortalNote[]; lastPollAt: number; error: string | null }
118export type PortalIdle = {
119  /** The run was started (or resumed) with /portal-continue in this session. */
120  continueMode: boolean
121  lastActivityAt: number
122  working: boolean
123  /** When the toast-first countdown started; 0 when none is pending. */
124  pendingSince: number
125  nudges: number
126  lastNudgeAt: number
127  /** The canon fingerprint when the last nudge fired: no progress since then means "stop nudging". */
128  progressMark: string
129}
130
131declare module 'claude-code' {
132  interface PluginState {
133    'management-portal': {
134      canon: CanonStatus | null
135      cockpit: PortalCockpit
136      prefs: PortalPrefs
137      band: PortalBand
138      raw: string[]
139      watch: PortalWatch | null
140      server: PortalServer
141      view: PortalView
142      gates: PortalGatePanel
143      kg: KgView | null
144      board: BoardView | null
145      timer: PortalTimer | null
146      notify: PortalNotify
147      idle: PortalIdle
148      launcher: PortalLauncher
149    }
150  }
151}
152