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…

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.jsonby hand.SessionStart,PostToolUseandStopall fire from the plugin's ownhooks/hooks.json.An earlier version of this file said the opposite in a large warning box and told you to hand-add the
Stopentry. 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 throughCLAUDE_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.jsonas 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.
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.
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.
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.
| Verdict | Mechanism | Consequence |
|---|---|---|
| REFUSE | PreToolUse → permissionDecision: "deny" | The call never runs. Your data is untouched. |
| BLOCK | PostToolUse / Stop → decision: "block" | It already happened; you are compelled to act before continuing or ending the turn. |
| ADVISE | Text at turn end | Nothing 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.jsis present, 2496 lines, emits a realPreToolUsepermissionDecision: "deny", andhooks/hooks.jsonwires it into 8 of the 11 hook entries. The advisoryportal-gate.jsit replaces has been deleted. Every canon gate above therefore reads ARMED — shipped, wired, and fixture-verified byscripts/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-BACKandCANON-BOTTOM-UPwere all observed refusing real calls in a live session.CANON-ID's first refusals were false: 1.5.0 read abulkresponse 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 aCANON-BOTTOM-UPrefusal 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 inskills/management-portal/canon-gates.mdremains 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.jswas 61 lines, contained nopermissionDecisionat all, and was advisory for its entire service life — while being described as a gate.
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
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.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.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_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.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.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.> 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.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.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./portal-stand-down, or the run is closed. Mitigated five ways; not removed.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.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.canon-gates.md ships a verbatim list of what no hook can ever check, so that nobody later builds a gate that pretends.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.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.
| Component | What it does |
|---|---|
management-portal MCP server | Registers the remote MCP (https://…/mcp). Auth is OAuth 2.1 + PKCE — no key, no headers. |
management-portal skill | The operable how-to; auto-triggers on any portal work. |
reference.md | Load-on-demand deep reference (playbook + write→read map + board-first). |
portal-operator subagent | Operates the portal under the discipline (portal tools only). |
/portal command | Dispatches the portal-operator subagent for a disciplined run. |
canon-gates.md | The source of truth for what is enforced vs advisory — the gate register, the status board, the escape hatch, the honest limits. |
scripts/canon-gate.js | The canon gates: PreToolUse refusals, PostToolUse/Stop compulsions, turn-end advisories, plus stand-down, doctor and selftest modes. |
/portal-project, /portal-continue | Start a disciplined run (client + project as arguments), and resume it without stopping between phases. |
/channel-coordinate, /channel-join | Join a Team Chat channel as coordinator or participant, under a name you choose. |
/portal-stand-down command | The escape. Stands one gate — or all of them — down, mid-session. |
/portal-rearm command | The 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 command | Re-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
hooks/mods/register.tsx 1381 lines1// 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 lines1// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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}
996types/index.d.ts 152 lines1// 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