Shows the Theo face, the state of your data room and the next move beside the chat, driven by what really happens in the session.

The /m:os face panel mod. It draws a side panel, one status line and buttons in the prompt box, natively in the Claude Code terminal. It shows the state of the room in plain English.
This folder is a walled package (CLAUDE.md D-17, Phase 369.26 precedent, D-05 of Phase 380).
Run these commands in this folder.
claude --plugin-dir ui/mos-face (from the repo root).claude plugin test . (34 original tests and the egress tests).claude plugin validate .node scripts/book-review.cjs --out <file>. It exits 1 if the book changes during the run and 77 if the book or the claude CLI is missing.node scripts/face-sheet.cjs --out <file.png>. It exits 77 if the claude CLI or the Playwright CLI is missing.npm run typecheck (the script lands in a later plan).npm run build (the script lands in a later plan).npm ci --ignore-scripts.Written in plan 380-17 from a diagnosis with fixture plugins in a scratch home (380-17-G6-FACTS.md in the phase folder; claude 2.1.309). Plain words:
claude --plugin-dir /path/to/MindrianOS-Plugin-release. The plugin folder is named mos, so for this session it replaces the installed plugin named mos, and it brings its own commands and the panel together. This is the way to try the panel with the /mos:* commands. Tested with a stand-in folder named mos, not with the real plugin root (the real root runs real session code, so it was not run in the diagnosis).claude --plugin-dir ui/mos-face. Since the source is named mos-face (plan 380-09 follow-up), the session keeps the installed plugin and adds the mod next to it: the /mos:* commands stay and the panel loads (run R1 of the facts file).--plugin-dir whose manifest name is mos does this.claude --debug (or --debug-file FILE). The log then holds the line Plugin "mos" from --plugin-dir overrides installed version. claude plugin list does not show it: the installed row still reads enabled."enabledPlugins": {"mos@inline": false} in a settings file. A turned-off session-only plugin does not shadow, so the installed copy loads (run R4). The entry is for a manifest named mos; for a folder named mos-face the id is mos-face@inline.What leaves the machine (Canon Part 8, decision D-03 of Phase 380, plan 380-04). Three calls and no more.
Never sent: assistant text, room files, claims, artifacts.
Before anything is sent, sanitize removes code, links, email addresses, file paths and key-like strings. Then stripNames removes names with three rules. A run of removed words becomes one "(a name)".
Added in plan 380-16 (D-03, D-04; gaps G1 and G3 of the first real terminal run). One ask has one honest sentence about what happened to the person's words. The row under "Ask Theo now" is in one of these states:
Theo was sent: "...". A call that carried the words was answered.A call to Theo failed on the way, so Theo may have received: "...". A call that carried the words failed. The note under the ask says Theo did not answer.Theo was not asked: the call was not allowed, or Theo was not asked: this session has no Theo tool to call. Every call was refused or the tool is missing. No excerpt is shown, because none left.Theo was sent only a framework name and a problem type this time. No words were due, or Words is off (Theo is sent only a framework name and a problem type).The sent line and the not-asked line never show together. A new ask clears the old row while it is in flight.
When the words had a part removed, the row says what kind of thing, never the text: (left out: a link, an email address, a file path, code, a key-like string, a long name, a name, the rest of a long message). The kinds come in that fixed order and each shows once. A removed part is named by its kind only.
The text that goes on the wire did not change. sanitize is now the text of sanitizeNoted, which runs the same steps in the same order and also notes which step removed something. tests/sent.test.tsx S9 pins six texts to the output of the code before this plan.
A tool that does not exist in the session makes the engine throw no tool named "..." in this session (measured, plan 380-16). The panel treats that call as never sent.
Canon: mos-brand book e947a5d, segments/experience/terminal (status: proposed), "Rules that hold everywhere", "Side panel template" (One signal: a state is said once) and "Priority order" (honesty sits above usability and brand).
A name typed in lowercase in the middle of a sentence is not caught, unless it equals a known name (S1). A name in a script without capital letters is caught only by S1. The panel shows the excerpt in full and /mos-words turns it off. The plugin egress guard is a second line on the shim path; its effect on a call that a plugin module makes is UNVERIFIED (tests/test-380-egress.cjs prints what the guard says about the six question shapes).
The excerpt is on by default (owner ruling 2026-10-21). Two ways to turn it off.
claude -p processes at plan time: a new session starts with the excerpt on again. Plan 10 proves it again on the shipped build.Both go beyond D-03 as written. Both are planner proposals, on by default, in hooks/proposals.ts. The navigator can veto either in plan 11, section B.
A veto is one flag set to false, the dist rebuilt (node scripts/build-mos-face.cjs, added by plan 380-08) and the tests re-run. tests/egress.test.ts reads the flags and holds for both values of each, so no test edit and no re-plan is needed.
The mod adds no hook and no read that can starve a prompt (MOD380-05). The tests in tests/budget.test.ts and the gate tests/test-380-budget.cjs pin everything on this page.
The module registers events only: session.start, 15 command.run commands (mos-state, mos-face, mos-style, mos-chip, mos-suggest, mos-ask, mos-refresh, mos-words, mos-text, mos-plain, mos-advice, mos-auto, theo-calls, mos-job, mos-tree), prompt.submit, turn.start, tool.call, turn.complete and 5 ui.render entries. There is no shell hook. The module's hooks/hooks.json holds only the module list, and the hooks.json of the plugin names no shell command for this mod. claude plugin validate --json prints the list, and the gate fails on any addition.
prompt.submit and turn.start do no file, tool, network or process work and never wait on the clock.
| Limit | Value | Test |
|---|---|---|
| Tick of the panel | every 120 seconds | B3 |
| Tool reads on a tick | at most 4 | B3 |
| File reads of the Job line on a tick | exactly 1 (the room's job state file, cut off after 4 seconds); none at prompt time | J9 |
| File reads of the status line files on a tick | 8 (the 7 status line files and the room health file, started together, one cut-off of 4 seconds; the mode file only when a room is read); none at prompt time; no tool, fetch or process added | B9 |
| Subprocesses | 0 | B1, G1 |
| Network fetches on an ordinary tick | 0 | B3 |
| Reach checks (Theo health, the website) | every 600 seconds, started together, each cut off after 4 seconds with no status | B3, B4 |
| A read of the room | cut off after 30 seconds | B5b |
| Slow read | over 8 seconds | B5 |
| Back-off after a slow or failed read | the next ticks wait 2x, 4x, then 8x of 120 seconds, never more than 600 seconds | B5, B5b |
| A quick read | resets the wait | B5 |
| The person's own refresh (/mos-refresh, the Refresh button) | always reads, and asks Theo and the website again | B5 |
| Reads in flight | one (a second refresh during a read is ignored, for 45 seconds at most) | B6 |
| Timers | the 220 ms spinner (the running mark in the Theo calls pane) stops when the turn ends; the 400 ms flash of a lit section stops when the flash ends. The face has no timer (plan 380-13) | B8, W4 |
The panel finds them the way the plugin does: the home folder is HOME, then USERPROFILE (a native Windows process may not set HOME). The rooms folder is MINDRIAN_ROOMS_HOME when it is set, else the home folder plus /MindrianRooms (lib/core/cross-room-aggregator.cjs holds the same rule). Paths use forward slashes. A registry that holds the default root with a tilde follows the same rule. This also lets a test or a hermetic screenshot point the panel at a scratch rooms folder.
The panel no longer needs python3. The old fallback ran a python3 graph query. It is removed (SEED-142 named the Windows gap: python3 is not on a default Windows PATH). The graph numbers come from the room tools. When those are out of reach, the panel reads the room from files and says the graph numbers were not read, instead of guessing.
The real idle CPU cost of the panel on a machine is measured by plan 380-10 and is UNVERIFIED until then. This page states limits, not a CPU number.
The panel speaks as Theo, in plain English (D-04). Every word it writes follows these rules. The rules come from the mos-brand book e947a5d (read only, at /home/jsagi/mos-brand).
What the words must be:
What the tests scan:
The reply chip (D-07, Quote the reply as is): the colored chip in a reply quotes the reply's own lead as it stands. It adds, removes and rewrites nothing, and it is built from the reply, never from panel copy. So the word Larry appears in the chip only when the reply put it there. The move glyph of the reply is shown as the color of the chip, as before.
The glossary lines are held back (navigator ruling 2026-10-24). The first sentences came from a private PWS glossary and do not ship. hooks/glossary.ts keeps the Word type and an empty WORDS, and the pane draws no glossary row. They return only when the owner clears that glossary.
The panel draws only in the Claude Code terminal. Desktop and Cowork get no panel in this phase. This is a deliberate skip (D-05, N7). Their plain MCP reads stay as they are.
Added in plan 380-14 (D-09, MOD380-08). The panel shows one Job line: what the person is probably trying to do, as one sentence in the jobs-to-be-done form the plugin already uses, "When I am in this situation, I want to do this, so that I get this." It sits in the side panel between NEXT and ASK. It is a guess, it is labelled a guess, and it sends nothing anywhere.
What it shows. A heading (JOB, then "a guess, is this it?", or "set by you"), the sentence, and two buttons. For example: "When I am in the Design stage, I want to decide if a venture / opportunity / approach is worth pursuing, so that I can say go or no-go and write down my reason." Under 40 columns the line is one short row with the want clause only and the word guess kept. With no usable record and no cue match, one dim row says there is no guess yet and what makes one: two different phrases of one job in your own words ("Job: no guess yet. Say what you want to decide, test or find, in two different phrases, and one may come."); the line invents nothing.
Where each part comes from, in order:
<room>/.mindrian/jtbd-state.json, written by the plugin's own classifier (lib/hmi/jtbd-classifier.cjs, lib/hmi/jtbd-state.cjs, the same file the status line reads). The panel reads it once for each refresh tick, with a cut-off of 4 seconds, and keeps only the fields of the current job (compactJob). An automatic job is used for 24 hours and is a guess. A job the person set with /mos:jtbd set, not yet expired, is shown as set by you and is not a guess. An expired set job, a job older than 24 hours, an unknown id and the steady state (explore) are ignored.The form comes from the plugin's JTBD method, not from a new one (Canon Part 7): the 13 job ids, their one-line wants and their cues are copied from lib/hmi/jtbd-taxonomy.json into hooks/jobs.ts by scripts/face-jobs.cjs --write, and --check fails on drift, a missing outcome, a banned word or a stale header. The "so that" clauses are the one thing the plugin did not have; they are written in scripts/face-jobs.cjs (OUTCOMES), drawn from each job's completion shape, and listed below for review. The form when, want, so is the one the book's own intake uses (mos-brand book e947a5d, the consumer intake schema fields jtbd.when, jtbd.want and jtbd.so; references/brand-rules.md rule 7, start from the job).
Why it is a guess. The book's voice segment (segments/writing/voice) says: do not describe generated content as validated evidence. The terminal guide (segments/experience/terminal, proposed) says thoughts are questions or hedged reflections. So every predicted line holds the word guess, its heading asks "is this it?", and it never says this is your job, or validated, confirmed or proven. The label is in words; no color carries it.
What it never does. It makes no Theo call, no Brain call, no tool call, no http call and no process call (Canon Part 8). It never prints any text of the person's prompts: the sentence is built from the taxonomy text, the stage and the section name only. It never reads the goal field of the state file, which holds a navigator-ratified local sentence; the panel does not even keep that field in memory (compactJob copies the current job only). Tests J5 (canary tests on prompt words and goal text, and a check that the same session sends the same calls with the line shown and hidden) and N2 (no tool name, fetch, process call or import of advice.ts in the job sources) prove it.
Dismiss and correct, one action each:
| Action | What it does |
|---|---|
| Not this | Hides a guess for this session until the recorded job changes (or, for a guess from the talk, until the guess changes). It changes only a panel atom and makes no call. A job the person set has no Not this button. |
| Fix: /mos:jtbd set | Puts the text /mos:jtbd set in the prompt box. It never runs it and never submits it. |
| /mos-job (the 14th command) | Shows or hides the line from the keyboard. A line hidden this way stays hidden until the command runs again. |
Placement. The terminal side-panel template of the book puts decisions first (where this session writes, what is wrong now, what to do next) and then asks about the work. The Job line sits after NEXT and before ASK. The canon has no segment for an intent line in a side panel: the book is silent on it. A segment text is proposed in plan 380-11 (item A5).
Budget. One added file read for each refresh tick (test J9: 1, 2, 3 reads over 3 ticks), nothing at prompt time, no timer, no new hook except the command. The budget tests B1 to B8 pass with the 14th command in the pinned lists.
Surfaces. Terminal only. Desktop and Cowork have no panel (D-05, N7), a deliberate skip; the same job is already reachable there through /mos:jtbd.
The 13 outcome clauses, for the navigator to read. The want text is the plugin taxonomy's own one-line (first letter lowered, final stop mark removed: a full stop, or the question mark of connect-domains, which the book review showed as "field?." in the sentence), so it carries the taxonomy's words, for example the citation in find-bottleneck.
| Job id | Want (from the plugin taxonomy) | Outcome clause (so that, reviewed here) | Shown |
|---|---|---|---|
| decide-pursue | decide if a venture / opportunity / approach is worth pursuing | I can say go or no-go and write down my reason | yes |
| find-problem | find the problem worth solving (not the symptom, the root) | I know the real problem in a sentence I can test | yes |
| understand-market | understand the market for this idea - size, structure, dynamics | I can say how big the market is and who else is in it | yes |
| find-bottleneck | find the lagging component (Hughes 1983 reverse salient) blocking progress | I know which part to fix first | yes |
| prepare-pitch | prepare for an investor / partner / customer meeting | I walk in with the story ready and the hard questions covered | yes |
| validate-idea | test if an idea / hypothesis / claim holds up under scrutiny | I know what holds up, what does not and what I still need to find out | yes |
| compare-options | compare alternatives across criteria - choose between A/B/C | I can choose between the options and say why | yes |
| connect-domains | find cross-domain analogues - what does this look like in another field | I see what this looks like in another field and what that could teach me | yes |
| surface-contradiction | notice and resolve internal contradictions in the room | I know which claims pull apart and which one I keep | yes |
| plan-execution | plan the next 90 days - milestones, owners, dates | I know the next 90 days, with owners and dates | yes |
| file-meeting | capture and route meeting intelligence into the room | I can see what the meeting changes in my data room | yes |
| audit-room | audit Data Room health - gaps, drift, evidence tier, decision quality | I know where my data room is thin or out of step | yes |
| explore | no specific job - general thinking, talking, ranging | I notice what is worth following | never shown |
Added in plan 380-15 (D-08, MOD380-07). The panel keeps what the plugin status line prints, and adds the room and filing facts, from the same local files. Nothing new is sent anywhere and nothing new is read at prompt time.
Where the list comes from. tests/test-380-status-parity.cjs builds the list of status line fields from the status line's own code: the keys of collectSignals (lib/statusline/cockpit-signals.cjs), the keys and assignments of rowState and the segments written by scripts/context-monitor, and the focus s
hooks/register.tsx 1971 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 Advice,
6 FaceId,
7 FaceStyle,
8 TheoCall,
9 TheoStatus,
10 TheoTotals,
11} from '../types'
12import { askTheo, classify, frameFor, situationOf, subjectOf, thinkPrompt, wordsForNoted, wordsStartOn, wordsTexts } from './advice'
13import { boundRoom } from './bind'
14import { DOCTOR, STATE_COMMANDS, TERM_COMMANDS, WHY } from './commands'
15import { gaugeText } from './gauge'
16import { FIX_COMMAND, JOB_NARROW_BELOW, compactJob, guessJob, jobLine, jobRows, jobShown, readJob } from './job'
17import { JOBS } from './jobs'
18import { workspaceOf } from './paths'
19import { PROPOSALS } from './proposals'
20import { noteOf, sentRow, sentText } from './sent'
21import {
22 BLUE,
23 INK,
24 LABEL,
25 LINE_FACE,
26 MOVE_BG,
27 MOVE_FG,
28 MOVE_NAME,
29 MUTED,
30 PAPER,
31 gridRuns,
32 moveOf,
33} from './face'
34import type { Run } from './face'
35import { stateFor, stateForLead } from './face-events'
36import { clock, fit, nowLine, panelLights, plain, recommend, stateBlocks, stateText, statusText, suggestionText } from './lines'
37import { contextCanvas } from './meter'
38import { bandLayout, hintLayout, overviewLayout, screenColumnsOf } from './overview'
39import { isPanelReading, readRoom, resetReach } from './source'
40import { contextBand, factLabel, nextMove, pluginFacts, readStatusLocal, roomFacts } from './status'
41import type { Fact } from './status'
42import { loadFaceAsset, rasterFace, readFaceAsset } from './theo-face'
43import type { FaceAsset } from './theo-face'
44import { assertNever, capEntries, controlsOf, counterText, crumbRowPlan, crumbText, hintLines, isFolder, noteRowsOf, pageOf, parseTreeArgs, rowsIn, sortSections, tooSmallLines, treeLayout, viewOf } from './tree'
45import type { ControlId, Page } from './tree'
46import { THEO_CALL_THOUGHTS, THEO_EMPTY, THEO_FAILED, THOUGHTS, glossaryWords } from './voice'
47
48// /m:os face - the Theo face, a side-panel dashboard, a context meter and a
49// Theo call monitor, ruled by the mos-brand book (Canon v5).
50//
51// Rules taken from the book and kept here:
52// - The panel sits on the paper ground with ink text and secondary ink for
53// asides, so contrast does not depend on the terminal theme.
54// - Every Button sits on an ink ground; its label color is the host's.
55// - Color never carries meaning alone: each colored block has words beside it.
56// - In Theo's face the colors are the voice vocabulary (blue building, red
57// challenging, yellow contradiction or reframe, black human decision, white
58// handover). The face is an illustration of real events, never proof.
59// - Theo receives a framework name and a problem type only, never the talk.
60
61const PANE = 'mos-state'
62// Below this many columns the panel says so, instead of drawing a broken layout.
63const MIN_PANE = 30
64const emptyTotals: TheoTotals = { total: 0, ok: 0, empty: 0, error: 0, blocked: 0 }
65const noAdvice: Advice = {
66 status: 'idle',
67 at: 0,
68 turn: 0,
69 handle: '',
70 framework: '',
71 commands: [],
72 whys: [],
73 framings: [],
74 matched: [],
75 sent: '',
76 delivery: { kind: 'none' },
77 question: '',
78 guided: '',
79 note: '',
80}
81
82// The state owner is the plugin that ships this module. In source it is mos-face, so a dev run
83// (claude --plugin-dir ui/mos-face) does not replace the installed mos plugin. The shipped plugin is
84// named mos, and the engine lets only the owner write a value, so scripts/build-mos-face.cjs rewrites
85// the owner from mos-face to mos in lib/mos-face/dist (register module and types) and fails the build
86// on a leftover. The engine needs the plugin and key as literals in source, so the name cannot sit in
87// a constant.
88const face = atom({ plugin: 'mos-face', key: 'face' } as const, 'rest')
89const detail = atom({ plugin: 'mos-face', key: 'detail' } as const, '')
90const mos = atom({ plugin: 'mos-face', key: 'mos' } as const, 0)
91const move = atom({ plugin: 'mos-face', key: 'move' } as const, 'none')
92const frame = atom({ plugin: 'mos-face', key: 'frame' } as const, 0)
93const isHidden = atom({ plugin: 'mos-face', key: 'isHidden' } as const, false)
94const isChipOff = atom({ plugin: 'mos-face', key: 'isChipOff' } as const, false)
95const style = atom({ plugin: 'mos-face', key: 'style' } as const, 'tile')
96const spoken = atom({ plugin: 'mos-face', key: 'spoken' } as const, 0)
97const theo = atom({ plugin: 'mos-face', key: 'theo' } as const, [])
98const theoTurn = atom({ plugin: 'mos-face', key: 'theoTurn' } as const, emptyTotals)
99const theoTotals = atom({ plugin: 'mos-face', key: 'theoTotals' } as const, emptyTotals)
100const flag = atom({ plugin: 'mos-face', key: 'flag' } as const, '')
101const isSuggestOff = atom({ plugin: 'mos-face', key: 'isSuggestOff' } as const, false)
102const snap = atom({ plugin: 'mos-face', key: 'snap' } as const, null)
103const isRefreshing = atom({ plugin: 'mos-face', key: 'isRefreshing' } as const, false)
104const advice = atom({ plugin: 'mos-face', key: 'advice' } as const, noAdvice)
105const isAutoOff = atom({ plugin: 'mos-face', key: 'isAutoOff' } as const, false)
106const paneAsked = atom({ plugin: 'mos-face', key: 'paneAsked' } as const, false)
107const bound = atom({ plugin: 'mos-face', key: 'bound' } as const, null)
108const boundHow = atom({ plugin: 'mos-face', key: 'boundHow' } as const, '')
109const workspace = atom({ plugin: 'mos-face', key: 'workspace' } as const, '')
110const isPlain = atom({ plugin: 'mos-face', key: 'isPlain' } as const, false)
111// The owner allows the panel to send a cleaned excerpt of the person's own recent words to
112// Theo (opt out any time). Held in memory for this session only: a new session starts with
113// the excerpt on, unless MOS_FACE_WORDS=off is set (proposal envSwitch, hooks/proposals.ts).
114const wordsOn = atom({ plugin: 'mos-face', key: 'wordsOn' } as const, true)
115const refreshNote = atom({ plugin: 'mos-face', key: 'refreshNote' } as const, '')
116const openSection = atom({ plugin: 'mos-face', key: 'openSection' } as const, '')
117const openBlock = atom({ plugin: 'mos-face', key: 'openBlock' } as const, '')
118const sectionFiles = atom({ plugin: 'mos-face', key: 'sectionFiles' } as const, [])
119const freshNames = atom({ plugin: 'mos-face', key: 'freshNames' } as const, [])
120const lit = atom({ plugin: 'mos-face', key: 'lit' } as const, {})
121const litWhy = atom({ plugin: 'mos-face', key: 'litWhy' } as const, {})
122// The canon face asset as checked text (an empty string when it could not be read), and the plain
123// sentence that says so. Read once at session start through the engine (Phase 380 plan 13, D-10).
124const faceAsset = atom({ plugin: 'mos-face', key: 'faceAsset' } as const, '')
125const faceAssetNote = atom({ plugin: 'mos-face', key: 'faceAssetNote' } as const, '')
126// The Job line (plan 14, D-09): the compact text of the room's recorded job, read once for each refresh
127// tick (an empty string when there is none); whether the person hid the line; and the job it was hidden
128// for. An empty jobHiddenFor means hidden whatever the job is (the /mos-job toggle).
129const jobRecord = atom({ plugin: 'mos-face', key: 'jobRecord' } as const, '')
130const jobHidden = atom({ plugin: 'mos-face', key: 'jobHidden' } as const, false)
131const jobHiddenFor = atom({ plugin: 'mos-face', key: 'jobHiddenFor' } as const, '')
132// The status line's own local files, read once for each refresh tick (plan 15, D-08). Null before the first
133// read. Nothing reads them in a prompt or turn hook.
134const statusLocal = atom({ plugin: 'mos-face', key: 'statusLocal' } as const, null)
135// The room tree (plan 380-18, G7 and G2): the page and the filter of the browse view, and how many names of
136// the opened folder were left out by the cap of MAX_ENTRIES. The filter is read only by the tree.
137const treePage = atom({ plugin: 'mos-face', key: 'treePage' } as const, 0)
138const treeFilter = atom({ plugin: 'mos-face', key: 'treeFilter' } as const, '')
139const sectionMore = atom({ plugin: 'mos-face', key: 'sectionMore' } as const, 0)
140
141// ---- Helpers ----------------------------------------------------------------
142
143const THEO_TOOL = /^mcp__(theo|theo-context|claude_ai_Theo-Claude|plugin_mos_mindrian-brain)__/
144const MOS_TOOL = /^mcp__plugin_mos_mindrian-os__/
145const EMPTY_RESULT = /^\s*(\[\s*\]|\{\s*\}|no (matches|results|files)( found)?\.?)\s*$/i
146const MENTIONS_THEO = /\bTheo\b/
147// The lead of a reply: one move glyph (group 1) and the marker text the reply wrote (group 2). The chip
148// quotes group 2 as it stands (D-07), so this pattern is the only place the persona's own words are named.
149const LEAD = /^\s*(🟦|🟥|🟨|⬛|⬜)️?\s*\**(Larry \| MindrianOS)\**/
150const SPIN = ['-', '\\', '|', '/']
151const STYLES: FaceStyle[] = ['tile', 'mini', 'line']
152
153function theoVia(name: string): string {
154 if (name.startsWith('mcp__theo-context__')) return 'Theo (context)'
155 if (name.startsWith('mcp__claude_ai_Theo-Claude__')) return 'Theo (Claude app)'
156 if (name.startsWith('mcp__plugin_mos_mindrian-brain__')) return 'Theo (through /m:os)'
157 return 'Theo'
158}
159
160function toolName(name: string): string {
161 const parts = name.replace(/^mcp__/, '').split('__')
162 return parts.length > 1 ? parts[parts.length - 1] ?? name : name
163}
164
165function shortTool(name: string): string {
166 const parts = name.replace(/^mcp__/, '').split('__')
167 const server = (parts[0] ?? '').replace(/^plugin_/, '')
168 const tool = parts.length > 1 ? parts.slice(1).join('.') : ''
169 const text = tool ? `${server}: ${tool}` : server
170 return text.length > 44 ? `${text.slice(0, 43)}~` : text
171}
172
173function seconds(ms: number | null): string {
174 if (ms === null) return '...'
175 return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`
176}
177
178function countText(t: TheoTotals): string {
179 const parts: string[] = []
180 if (t.ok) parts.push(`${t.ok} ok`)
181 if (t.empty) parts.push(`${t.empty} empty`)
182 if (t.error) parts.push(`${t.error} error`)
183 if (t.blocked) parts.push(`${t.blocked} blocked`)
184 return parts.join(', ')
185}
186
187// A pick that walks the list with a stride coprime to its length, so a line
188// does not return for a whole lap, and a salt so two lists do not move in step.
189function pickVaried<T>(list: T[], n: number, salt: number): T {
190 const len = list.length
191 const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b))
192 const stride = [7, 5, 3, 1].find(k => gcd(k, len) === 1) ?? 1
193 return list[(((salt + n * stride) % len) + len) % len] as T
194}
195
196// The canon asset, parsed once for each text it comes from (the atom holds the text).
197let parsedText = ''
198let parsedAsset: FaceAsset | null = null
199
200function assetOf(text: string): FaceAsset | null {
201 if (text === '') return null
202 if (text !== parsedText) {
203 parsedAsset = loadFaceAsset(text)
204 parsedText = text
205 }
206
207 return parsedAsset
208}
209
210// The runs of one face, n by n cells, drawn from the canon asset. Empty when n is 0 or the asset is
211// missing: the panel then shows the words-only line face. It never draws numbers typed by hand.
212function faceRuns(text: string, id: FaceId, n: number): Run[][] {
213 const asset = assetOf(text)
214 if (asset === null || n === 0) return []
215 const grid = rasterFace(asset, id, n)
216
217 return grid === null ? [] : gridRuns(grid)
218}
219
220// ---- The module -------------------------------------------------------------
221
222// ---- State and helpers live at the top of the file: the engine only lets a
223// function that receives $ be declared here, never inside register.
224
225let spin: { cancel: () => void } | undefined
226let back: { cancel: () => void } | undefined
227let poll: { cancel: () => void } | undefined
228let lastToastAt = 0
229let autoInFlight = false
230let lastUser = ''
231let recentUser: string[] = []
232let lastAnswer = ''
233let turnsDone = 0
234let lastAskAt = 0
235let refreshStartedAt = 0
236let seenFrameworks: string[] = []
237let seenCommands: string[] = []
238let flash: { cancel: () => void } | undefined
239let flashStop: { cancel: () => void } | undefined
240
241const stopSpin = () => {
242 spin?.cancel()
243 spin = undefined
244}
245const stopBack = () => {
246 back?.cancel()
247 back = undefined
248}
249
250// A section of the room that just changed is lit: it flashes for a few seconds,
251// then stays marked, with words, until it is opened or ten minutes pass.
252const LIT_FLASH_MS = 9000
253const LIT_KEEP_MS = 600000
254
255const agoText = (ms: number, now: number): string => {
256 const s = Math.max(0, Math.round((now - ms) / 1000))
257 if (s < 60) return `${s}s ago`
258 if (s < 3600) return `${Math.round(s / 60)}m ago`
259 return `${Math.round(s / 3600)}h ago`
260}
261
262const markLit = async ($: EngineInterface, section: string, why: string) => {
263 try {
264 const now = await $.clock.now()
265 await update($, lit, m => ({ ...m, [section]: now }))
266 await update($, litWhy, m => ({ ...m, [section]: why }))
267 if (!flash) {
268 flash = $.clock.every(400, () => {
269 void update($, frame, n => n + 1)
270 })
271 }
272 flashStop?.cancel()
273 flashStop = $.clock.after(LIT_FLASH_MS, () => {
274 flash?.cancel()
275 flash = undefined
276 $.ui.invalidate('ui.render')
277 })
278 $.ui.invalidate('ui.render')
279 } catch {
280 // marking is a courtesy
281 }
282}
283
284// Which part of the room did a finished tool call touch? Only real signals:
285// the filing tools name their section, and a write names its file.
286const noteChange = async ($: EngineInterface, input: { tool: string; section?: unknown; file_path?: unknown }) => {
287 try {
288 const tool = input.tool
289 if (tool === 'mcp__plugin_mos_mindrian-os__artifact_file' && typeof input.section === 'string' && input.section !== '') {
290 await markLit($, input.section, 'artifact filed')
291 return
292 }
293 if (/^mcp__plugin_mos_mindrian-os__(claim_write|graph_write|meeting|room_content)$/.test(tool)) {
294 await markLit($, '(graph)', 'a claim or an edge was written')
295 return
296 }
297 if ((tool === 'Write' || tool === 'Edit' || tool === 'NotebookEdit') && typeof input.file_path === 'string') {
298 const snapshot = await read($, snap)
299 const root = snapshot?.room?.path ?? ''
300 if (root !== '' && input.file_path.startsWith(`${root}/`)) {
301 const rel = input.file_path.slice(root.length + 1)
302 const top = rel.split('/')[0] ?? ''
303 if (rel.includes('/') && top !== '' && !top.startsWith('.')) await markLit($, top, 'a file was written')
304 }
305 }
306 } catch {
307 // marking is a courtesy
308 }
309}
310
311// What the Job line shows now, from memory only: the recorded job, else a cue match over the person's
312// recent prompts, and whether the person hid it. No read, no call.
313const jobNow = async ($: EngineInterface) => {
314 const now = await $.clock.now()
315 const record = readJob(await read($, jobRecord), now)
316 const guess = record ? null : guessJob(recentUser, JOBS)
317 const id = jobShown(record, guess)
318 const hiddenFor = await read($, jobHiddenFor)
319 const isHidden2 = (await read($, jobHidden)) && (hiddenFor === '' || hiddenFor === id)
320
321 return { record, guess, id, isHidden: isHidden2 }
322}
323
324// One pinned line under the prompt, in words. A fallback that does not depend
325// on the panel or the band being drawn.
326const pin = async ($: EngineInterface) => {
327 try {
328 const snapshot = await read($, snap)
329 const input = {
330 snap: snapshot,
331 bound: await read($, bound),
332 boundHow: await read($, boundHow),
333 workspace: await read($, workspace),
334 advice: await read($, advice),
335 totals: await read($, theoTotals),
336 calls: await read($, theo),
337 now: await $.clock.now(),
338 width: 60,
339 local: await read($, statusLocal),
340 }
341 $.ui.status(statusText(input))
342 } catch {
343 // the pinned line is a courtesy
344 }
345}
346
347// A read that does not come back must not block every later one.
348const withTimeout = <T,>($: EngineInterface, work: Promise<T>, ms: number): Promise<T> =>
349 new Promise<T>((resolve, reject) => {
350 const timer = $.clock.after(ms, () => reject(new Error('the read took too long')))
351 work.then(
352 value => {
353 timer.cancel()
354 resolve(value)
355 },
356 error => {
357 timer.cancel()
358 reject(error)
359 },
360 )
361 })
362
363// A read that is slow, or that fails, backs the next gentle reads off: 2x, then 4x, then 8x of the
364// tick, never more than ten minutes. A quick read resets the wait. The person's own refresh always
365// reads, and its result counts the same way. A starved machine is not hammered.
366const TICK_MS = 120000
367const SLOW_READ_MS = 8000
368const BACKOFF_CAP_MS = 600000
369let slowStreak = 0
370let slowUntil = 0
371
372const backOff = (ended: number, wasSlow: boolean) => {
373 if (!wasSlow) {
374 slowStreak = 0
375 slowUntil = 0
376
377 return
378 }
379 slowStreak = Math.min(slowStreak + 1, 3)
380 slowUntil = ended + Math.min(TICK_MS * 2 ** slowStreak, BACKOFF_CAP_MS)
381}
382
383// force: the person asked, so read now. askReach: ask Theo and the website again whatever their age.
384const refresh = async ($: EngineInterface, force = true, askReach = force) => {
385 try {
386 const started = await $.clock.now()
387 // Polls and turn ends ask gently; the person's Refresh always reads.
388 if (!force) {
389 if (started < slowUntil) return
390 const last = await read($, snap)
391 if (last && started - last.at < TICK_MS) return
392 }
393 if (await read($, isRefreshing)) {
394 if (started - refreshStartedAt < 45000) return
395 }
396 refreshStartedAt = started
397 await update($, isRefreshing, () => true)
398 let failed = false
399 try {
400 const before = await read($, snap)
401 const snapshot = await withTimeout($, readRoom($, askReach), 30000).catch(error => {
402 failed = true
403 throw error
404 })
405 await update($, snap, () => snapshot)
406 // The room's recorded job: one small local file, read inside the same tick, with a cut-off of its
407 // own. Nothing is read at prompt time, and only the fields of the current job are kept.
408 const jobPath = snapshot.room?.path ?? ''
409 const jobText = jobPath === '' ? '' : await withTimeout($, $.fs.read(`${jobPath}/.mindrian/jtbd-state.json`).then(String), 4000).catch(() => '')
410 await update($, jobRecord, () => compactJob(jobText))
411 // The status line's own local files: eight small reads, started together, with a cut-off of their own.
412 // When the cut-off hits, the last good reading stays.
413 const local = await withTimeout($, readStatusLocal($, jobPath), 4000).catch(() => null)
414 if (local !== null) await update($, statusLocal, () => local)
415 if (before?.room && snapshot.room && before.room.slug === snapshot.room.slug) {
416 for (const sec of snapshot.room.sections) {
417 const prev = before.room.sections.find(one => one.name === sec.name)
418 if (prev && sec.files > prev.files) await markLit($, sec.name, 'a new file arrived')
419 }
420 }
421 if (snapshot.binding) {
422 // The tools say it: a fact, not an inference from the registry.
423 const fact = snapshot.binding
424 await update($, bound, () => (fact.bound ? fact.slug : null))
425 await update($, boundHow, () => fact.note || 'reported by the MindrianOS tools')
426 } else {
427 const binding = await boundRoom($)
428 await update($, bound, () => binding.room)
429 await update($, boundHow, () => binding.how)
430 }
431 const cwd = await $.session.cwd().catch(() => '')
432 const folder = await workspaceOf($, cwd)
433 await update($, workspace, () => folder)
434 await update($, refreshNote, () => '')
435 } catch (error) {
436 const text = error instanceof Error ? error.message : String(error)
437 await update($, refreshNote, () => text.slice(0, 80))
438 } finally {
439 const ended = await $.clock.now()
440 backOff(ended, failed || ended - started > SLOW_READ_MS)
441 await update($, isRefreshing, () => false)
442 }
443 await pin($)
444 } catch {
445 // keep the last reading
446 }
447}
448
449const openPane = async ($: EngineInterface) => {
450 try {
451 await $.ui.open({ id: PANE, title: '/m:os', columns: 46 })
452 await update($, paneAsked, () => true)
453 $.ui.invalidate('ui.render')
454 } catch {
455 // no surface places panes here; the band stays
456 }
457}
458
459// The names this machine already knows, removed from the excerpt before it is sent: the
460// bound room, the workspace folder and the folder of this session.
461const knownNames = async ($: EngineInterface): Promise<string[]> => {
462 const names: string[] = []
463 const slug = await read($, bound)
464 if (slug) names.push(slug)
465 const room = (await read($, snap))?.room?.slug
466 if (room) names.push(room)
467 const folder = await read($, workspace)
468 if (folder) names.push(folder)
469 const cwd = await $.session.cwd().catch(() => '')
470 const last = cwd.split(/[\\/]/).filter(part => part !== '').at(-1)
471 if (last) names.push(last)
472
473 return names
474}
475
476// Ask Theo what to do next. The question carries a framework name and a problem
477// type, and the person's cleaned excerpt unless /mos-words turned it off; the talk is
478// read here to choose the handles and the excerpt.
479const runAdvice = async ($: EngineInterface, force: boolean) => {
480 try {
481 if (autoInFlight) return
482 const now = await $.clock.now()
483 if (!force && now - lastAskAt < 45000) return
484
485 // After a reload this module has not heard the talk yet: read it back, here,
486 // from the session's own transcript. It is used only to choose the handles.
487 if (lastUser === '' && lastAnswer === '') {
488 try {
489 const messages = await $.session.messages()
490 const people: string[] = []
491 for (let i = messages.length - 1; i >= 0; i--) {
492 const one = messages[i]
493 if (!one || typeof one.text !== 'string') continue
494 if (one.role === 'user' && !looksInjected(one.text) && people.length < 6) people.unshift(one.text.slice(0, 2000))
495 if (lastAnswer === '' && one.role === 'assistant') lastAnswer = one.text.slice(0, 4000)
496 if (people.length >= 6 && lastAnswer !== '') break
497 }
498 recentUser = people
499 lastUser = people[people.length - 1] ?? ''
500 } catch {
501 // no transcript to read
502 }
503 }
504
505 const classified = classify(recentUser.slice(-3).join('. '), lastAnswer)
506 // The same handles would always get the same answer. When the talk has not
507 // changed what it points at, walk on along Theo's chain from the framework it
508 // last returned, instead of asking the same question again.
509 const before = await read($, advice)
510 const repeats = before.status === 'ok' && before.framework !== '' && (classified.matched.length === 0 || before.handle.startsWith(classified.framework))
511 const handle = repeats ? { ...classified, framework: before.framework } : classified
512 // Nothing to go on is not a reason to guess: wait for a cue unless asked.
513 if (!force && handle.matched.length === 0) return
514
515 lastAskAt = now
516 autoInFlight = true
517 // The old sent line goes the moment a new ask starts (plan 380-16): it described an earlier ask.
518 await update($, advice, a => ({ ...a, status: 'asking' as const, sent: '', delivery: { kind: 'none' as const } }))
519 try {
520 const noted = (await read($, wordsOn)) ? wordsForNoted(handle.matched, recentUser, await knownNames($)) : { text: '', left: [] }
521 const result = await askTheo($, handle, turnsDone, seenFrameworks.slice(-6), noted.text, seenCommands.slice(-6), PROPOSALS, noted.left)
522 if (result.status === 'ok' && result.framework !== '') seenFrameworks = [...seenFrameworks, result.framework].slice(-8)
523 if (result.status === 'ok') seenCommands = [...seenCommands, ...result.commands.slice(0, 1)].slice(-8)
524 const because = repeats
525 ? `moving on from ${before.framework}`
526 : handle.matched.length > 0
527 ? `because of: ${handle.matched.join(', ')}`
528 : 'no strong cue in the talk, so a general start'
529 const framings = result.commands.map(command => frameFor(command, handle.matched, recentUser))
530 await update($, advice, () => ({
531 ...result,
532 framings,
533 matched: handle.matched,
534 note: result.status === 'ok' && result.note === '' ? because : result.note,
535 }))
536 const first = framings[0]
537 if (result.status === 'ok' && first && !(await read($, isSuggestOff))) {
538 await $.prompt.suggest({ text: first })
539 }
540 } finally {
541 autoInFlight = false
542 $.ui.invalidate('ui.render')
543 await pin($)
544 }
545 } catch {
546 autoInFlight = false
547 }
548}
549
550// Fold or open one part of the panel; only one is open at a time.
551// Text that is not a person's own words: hooks, reminders and notices.
552const looksInjected = (text: string): boolean =>
553 /^\s*</.test(text) || /system-reminder|SessionStart|NAVIGATION DECISION|hook success/i.test(text.slice(0, 200))
554
555const toggleBlock = async ($: EngineInterface, key: string) => {
556 try {
557 const current = await read($, openBlock)
558 await update($, openBlock, () => (current === key ? '' : key))
559 $.ui.invalidate('ui.render')
560 } catch {
561 // leave the panel as it was
562 }
563}
564
565// Put a question stem in the prompt box; the person finishes it and sends it.
566const askAbout = async ($: EngineInterface, text: string) => {
567 const preview = text.length > 70 ? `${text.slice(0, 69)}~` : text
568 try {
569 const filled = await $.prompt.fill({ text, mode: 'replace' })
570 if (filled.isFilled) {
571 $.ui.toast(`In the prompt box, press Enter to send: ${preview}`)
572 return
573 }
574 // The box would not take it: leave it waiting dim, and say so.
575 const shown = await $.prompt.suggest({ text })
576 $.ui.toast(
577 shown.isShown
578 ? 'The prompt box is busy, so it is waiting there dim. Press Tab to take it.'
579 : `The prompt box could not take it. Type this: ${preview}`,
580 )
581 } catch {
582 $.ui.toast(`The prompt box could not take it. Type this: ${preview}`)
583 }
584}
585
586// ---- The room tree (plan 380-18, G7 and G2) ----------------------------------------------------
587//
588// The browse view pages the list inside the rows the host gives the pane, so the host never scrolls the
589// header away. The page and the filter live in atoms; nothing here reads a file except the one directory
590// read when a section or folder is opened.
591
592// The tree rows and the inner width of the last drawn pane. The command pages with the same window as the
593// pane (the tree rows less the cap note row, which depends on the folder), so a page means the same rows on
594// the pane and in the text. The rows are 10 and the width is unknown until a pane has drawn.
595let treeRowsBase = 10
596let treeInnerCols = Number.POSITIVE_INFINITY
597
598const resetTree = async ($: EngineInterface) => {
599 await update($, treePage, () => 0)
600 await update($, treeFilter, () => '')
601}
602
603const clearFolder = async ($: EngineInterface) => {
604 await update($, openSection, () => '')
605 await update($, sectionFiles, () => [])
606 await update($, sectionMore, () => 0)
607}
608
609// Open the browse view at the top: the list of sections, page one, no filter.
610const openTree = async ($: EngineInterface) => {
611 if ((await read($, openBlock)) === 'ask') return
612 await update($, openBlock, () => 'ask')
613 await clearFolder($)
614 await resetTree($)
615}
616
617const closeTree = async ($: EngineInterface) => {
618 await update($, openBlock, () => '')
619 await clearFolder($)
620 await resetTree($)
621}
622
623// The Sections button, and the Close button of the browse view (the same key).
624const toggleTree = async ($: EngineInterface) => {
625 try {
626 if ((await read($, openBlock)) === 'ask') await closeTree($)
627 else await openTree($)
628 $.ui.invalidate('ui.render')
629 } catch {
630 // leave the panel as it was
631 }
632}
633
634// Go back one level: a folder to its parent, a section to the list of sections, the list to closed.
635// Returns true when the view closed.
636const treeUp = async ($: EngineInterface): Promise<boolean> => {
637 try {
638 const path = await read($, openSection)
639 if (path === '') {
640 await closeTree($)
641 $.ui.invalidate('ui.render')
642
643 return true
644 }
645 const up = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''
646 const root = (await read($, snap))?.room?.path
647 const folder = up !== '' && root ? await readFolder($, root, up) : { names: [], more: 0, fresh: [] }
648 await update($, openSection, () => up)
649 await update($, sectionFiles, () => folder.names)
650 await update($, sectionMore, () => folder.more)
651 await update($, freshNames, () => folder.fresh)
652 await resetTree($)
653 $.ui.invalidate('ui.render')
654 } catch {
655 // leave the panel as it was
656 }
657
658 return false
659}
660
661const treeGoto = async ($: EngineInterface, page: number) => {
662 await update($, treePage, () => Math.max(0, page))
663 $.ui.invalidate('ui.render')
664}
665
666// One directory read when a section or folder opens. At most MAX_ENTRIES names are kept (folders first,
667// then names in order); the number left out is kept so the panel can say so. A folder that cannot be read
668// gives no names. Nothing is stored here: the caller stores the folder and its path together, so the pane
669// never draws one folder's names under another folder's crumb.
670const readFolder = async ($: EngineInterface, root: string, path: string): Promise<{ names: string[]; more: number; fresh: string[] }> => {
671 try {
672 const entries = await $.fs.list(`${root}/${path}`)
673 const names = entries
674 .filter(one => !one.name.startsWith('.'))
675 .filter(one => one.kind === 'dir' || /\.(md|html|json|txt)$/i.test(one.name))
676 .map(one => (one.kind === 'dir' ? `${one.name}/` : one.name))
677 .sort((a, b) => Number(b.endsWith('/')) - Number(a.endsWith('/')) || a.localeCompare(b))
678 const capped = capEntries(names)
679 const now = await $.clock.now()
680 const fresh = entries
681 .filter(one => one.kind === 'file' && one.mtimeMs > 0 && now - one.mtimeMs < LIT_KEEP_MS)
682 .map(one => one.name)
683
684 return { names: capped.kept, more: capped.more, fresh }
685 } catch {
686 return { names: [], more: 0, fresh: [] }
687 }
688}
689
690// Open one section or folder of the room: read it once, then show it at its first page. No question is
691// put in the prompt box; a click adds that (pickSection), the slash command does not.
692const enterSection = async ($: EngineInterface, root: string, path: string) => {
693 const folder = await readFolder($, root, path)
694 await update($, openSection, () => path)
695 await update($, sectionFiles, () => folder.names)
696 await update($, sectionMore, () => folder.more)
697 await update($, freshNames, () => folder.fresh)
698 const top = path.split('/')[0] ?? path
699 await update($, lit, m => {
700 const { [top]: _gone, ...rest } = m
701 return rest
702 })
703 await resetTree($)
704}
705
706// A section of the room: open it to see what is inside, and put a question
707// stem about it in the prompt box. A folder inside it drills one level deeper.
708const pickSection = async ($: EngineInterface, path: string) => {
709 try {
710 const snapshot = await read($, snap)
711 const root = snapshot?.room?.path
712 if (!root) return
713 const current = await read($, openSection)
714 if (current === path) {
715 await treeUp($)
716 return
717 }
718 await enterSection($, root, path)
719 await askAbout($, `About the "${path.replaceAll('/', ' / ')}" section of the room: `)
720 } catch {
721 // leave the list as it was
722 }
723}
724
725const pickEntry = async ($: EngineInterface, name: string) => {
726 const path = await read($, openSection)
727 if (isFolder(name)) {
728 await pickSection($, `${path}/${name.slice(0, -1)}`)
729 return
730 }
731 await askAbout($, `About "${path.replaceAll('/', ' / ')} / ${name}" in the room: `)
732}
733
734// One name of the list at the level now open: a section with its file count, or an entry of a folder.
735type TreeItem = { name: string; files: number | null }
736
737// The level now open as pages: the room, the open path, every name, the names left out by the cap, and
738// the page the size of the last drawn window gives. Read from memory only.
739const levelOf = async ($: EngineInterface) => {
740 const snapshot = await read($, snap)
741 const path = await read($, openSection)
742 const filter = await read($, treeFilter)
743 const wanted = await read($, treePage)
744 const items: TreeItem[] =
745 path === ''
746 ? sortSections(snapshot?.room?.sections ?? []).map(sec => ({ name: sec.name, files: sec.files }))
747 : (await read($, sectionFiles)).map(name => ({ name, files: null }))
748 const room = snapshot?.room?.slug ?? ''
749 const more = path === '' ? 0 : await read($, sectionMore)
750 const page = pageOf(items, treeRowsBase - noteRowsOf(room, path, items.length, more, filter, treeInnerCols, treeRowsBase), wanted, filter, item => item.name)
751
752 return {
753 room,
754 path,
755 items,
756 more,
757 page,
758 filter,
759 shownPage: page.kind === 'paged' ? page.page : 0,
760 }
761}
762
763// The plain linear page: the crumb line, the counter line, one line for each name with its count (a slash
764// ends a folder), and the hint lines.
765const treeText = async ($: EngineInterface): Promise<string> => {
766 const level = await levelOf($)
767 const counter = `${counterText(level.page)}${level.more > 0 ? ` first ${level.items.length} of ${level.items.length + level.more}` : ''}`
768 const rows = rowsIn(level.page).map(item => (item.files === null ? ` ${item.name}` : ` ${item.name} ${item.files}`))
769
770 return [crumbText(level.room, level.path, 200), counter, ...rows, ...hintLines(80, 40)].join('\n')
771}
772
773const TREE_USAGE = 'Try /mos-tree next, prev, up, find, open, clear. For example /mos-tree find legal.'
774
775// The names of the open level that a typed word picks: one exact name, else every name that holds the word
776// (smart case, as the filter).
777const pickNames = (items: TreeItem[], word: string): string[] => {
778 const bare = (name: string) => (isFolder(name) ? name.slice(0, -1) : name).toLowerCase()
779 const exact = items.filter(item => bare(item.name) === word.toLowerCase())
780 if (exact.length === 1) return exact.map(item => item.name)
781
782 return rowsIn(pageOf(items, Math.max(1, items.length), 0, word, item => item.name)).map(item => item.name)
783}
784
785const runTree = async ($: EngineInterface, args: string): Promise<string> => {
786 const command = parseTreeArgs(args)
787 switch (command.kind) {
788 case 'unknown':
789 return TREE_USAGE
790 case 'up': {
791 if ((await read($, openBlock)) !== 'ask') return 'The room tree is closed.'
792 if (await treeUp($)) return 'The room tree is closed.'
793
794 return treeText($)
795 }
796 case 'list':
797 await openTree($)
798 break
799 case 'next':
800 case 'prev': {
801 await openTree($)
802 const level = await levelOf($)
803 await update($, treePage, () => Math.max(0, level.shownPage + (command.kind === 'next' ? 1 : -1)))
804 break
805 }
806 case 'clear':
807 await openTree($)
808 await resetTree($)
809 break
810 case 'find':
811 await openTree($)
812 await update($, treeFilter, () => command.text)
813 await update($, treePage, () => 0)
814 break
815 case 'open': {
816 await openTree($)
817 const level = await levelOf($)
818 const names = pickNames(level.items, command.text)
819 const name = names[0]
820 if (names.length === 0 || name === undefined) return `No name holds "${command.text}". Try /mos-tree find ${command.text} to see what is close.`
821 if (names.length > 1) return `${names.length} names hold "${command.text}". Type more of the name.`
822 const root = (await read($, snap))?.room?.path
823 if (level.path === '') {
824 if (root) await enterSection($, root, name)
825 } else if (isFolder(name)) {
826 if (root) await enterSection($, root, `${level.path}/${name.slice(0, -1)}`)
827 } else {
828 await askAbout($, `About "${level.path.replaceAll('/', ' / ')} / ${name}" in the room: `)
829 $.ui.invalidate('ui.render')
830
831 return `A question about "${level.path.replaceAll('/', ' / ')} / ${name}" is in the prompt box. Finish it and send it.`
832 }
833 break
834 }
835 default:
836 return assertNever(command)
837 }
838 $.ui.invalidate('ui.render')
839
840 return treeText($)
841}
842
843// The terminal ground is ink with paper text; the face tile and the context
844// canvas are the only bright planes. Buttons sit on soft ink so they read as
845// raised. Asides are dimmed paper, never a dark tone on a dark ground.
846const ink = { color: PAPER } as const
847const soft = { color: PAPER, dimColor: true } as const
848const RAISED = MUTED
849
850export const register: Register = on => {
851 on('session.start', async ($, e, next) => {
852 await update($, isHidden, () => false)
853 treeRowsBase = 10
854 treeInnerCols = Number.POSITIVE_INFINITY
855 const noColor = await $.env.get('NO_COLOR')
856 await update($, isPlain, () => noColor !== undefined && noColor !== '')
857 // Proposal envSwitch: MOS_FACE_WORDS=off at session start makes the opt-out last.
858 if (!wordsStartOn(await $.env.get('MOS_FACE_WORDS'), PROPOSALS)) await update($, wordsOn, () => false)
859 for (const [name, description] of [
860 ['mos-state', 'Open the /m:os dashboard in the side panel'],
861 ['mos-face', 'Show or hide the Theo face box (shown when the panel is not)'],
862 ['mos-style', 'Change the Theo face style: tile, mini or line'],
863 ['mos-chip', 'Turn the colored badge on replies on or off'],
864 ['mos-suggest', 'Turn the grey command suggestion in the prompt box on or off'],
865 ['mos-advice', 'Ask Theo now what to do next'],
866 ['mos-ask', 'Start a question about the whole room in the prompt box'],
867 ['mos-refresh', 'Read the room, the data room, Theo and the website again'],
868 ['mos-plain', 'Plain mode: ASCII only, no color pictures (also on when NO_COLOR is set)'],
869 ['mos-text', 'Print the whole /m:os state as plain linear text'],
870 ['mos-words', 'Turn on or off sending your own recent words to Theo with each ask'],
871 ['mos-auto', 'Turn the panel asking Theo every second turn on or off'],
872 ['theo-calls', 'Open a pane with every call made to Theo this session'],
873 ['mos-job', 'Show or hide the job line, the guess of what you are doing'],
874 ['mos-tree', 'Browse every section and file of the room: next, prev, up, find, open, clear'],
875 ] as const) {
876 await $.command.register({ name, description })
877 }
878
879 // The canon face asset is read once, through the engine. When it cannot be read the panel shows the
880 // words-only line face and one plain sentence (D-10); it never draws numbers typed by hand.
881 const got = await readFaceAsset($)
882 await update($, faceAsset, () => (got.asset === null ? '' : JSON.stringify(got.asset)))
883 await update($, faceAssetNote, () => (got.asset === null ? got.reason : ''))
884
885 $.ui.toast('/m:os loaded. /mos-state opens it.')
886 resetReach()
887 slowStreak = 0
888 slowUntil = 0
889 void refresh($)
890 poll?.cancel()
891 poll = $.clock.every(TICK_MS, () => {
892 void refresh($, false)
893 })
894 void openPane($)
895 await pin($)
896
897 return next(e)
898 })
899
900 on('command.run', { command: 'mos-state' }, async $ => {
901 await openPane($)
902 void refresh($, true, false)
903
904 return { text: 'The /m:os dashboard is open in the side panel. It seats at any width when you ask for it.' }
905 })
906
907 on('command.run', { command: 'mos-face' }, async $ => {
908 const hidden = await read($, isHidden)
909 await update($, isHidden, () => !hidden)
910 $.ui.invalidate('ui.render')
911
912 return { text: hidden ? 'Theo face box shown.' : 'Theo face box hidden. Run /mos-face to show it again.' }
913 })
914
915 on('command.run', { command: 'mos-style' }, async $ => {
916 const current = await read($, style)
917 const next = STYLES[(STYLES.indexOf(current) + 1) % STYLES.length] ?? 'tile'
918 await update($, style, () => next)
919 const names: Record<FaceStyle, string> = {
920 tile: 'tile - the Theo face for each state',
921 mini: 'mini - a small tile',
922 line: 'line - one plain-text line',
923 }
924
925 return { text: `Face style: ${names[next]}.` }
926 })
927
928 on('command.run', { command: 'mos-chip' }, async $ => {
929 const off = await read($, isChipOff)
930 await update($, isChipOff, () => !off)
931
932 return { text: off ? 'Colored badge in replies is on.' : 'Colored badge in replies is off.' }
933 })
934
935 on('command.run', { command: 'mos-suggest' }, async $ => {
936 const off = await read($, isSuggestOff)
937 await update($, isSuggestOff, () => !off)
938
939 return {
940 text: off
941 ? 'Grey command suggestions are on: Tab takes one into the prompt, Enter runs it.'
942 : 'Grey command suggestions are off.',
943 }
944 })
945
946 on('command.run', { command: 'mos-ask' }, async $ => {
947 const snapshot = await read($, snap)
948 const roomName = snapshot?.room?.slug
949 await askAbout($, roomName ? `About the room ${roomName}: ` : 'About the room: ')
950
951 return { text: 'A question stem is in the prompt box. Finish it and press Enter.' }
952 })
953
954 on('command.run', { command: 'mos-refresh' }, async $ => {
955 void refresh($)
956
957 return { text: 'Reading the room, the data room, Theo and the website again.' }
958 })
959
960 on('command.run', { command: 'mos-words' }, async $ => {
961 const on2 = await read($, wordsOn)
962 await update($, wordsOn, () => !on2)
963 $.ui.invalidate('ui.render')
964
965 // on2 is the setting before the toggle: the setting now is its opposite.
966 return { text: wordsTexts(!on2, PROPOSALS).words }
967 })
968
969 on('command.run', { command: 'mos-text' }, async $ => {
970 const usage = await $.session.usage().catch(() => null)
971 const percent = usage?.context.percent
972 const tokens = usage?.context.tokens !== undefined && usage.context.window ? `, ${usage.context.tokens} of ${usage.context.window} tokens` : ''
973 const snapshot = await read($, snap)
974 const job = await jobNow($)
975 const jobWords = jobLine(
976 jobRows({ record: job.record, guess: job.guess, stage: snapshot?.room?.stage ?? '', section: await read($, openSection), hidden: job.isHidden, width: 200, plain: true }),
977 )
978 const input = {
979 snap: snapshot,
980 bound: await read($, bound),
981 boundHow: await read($, boundHow),
982 workspace: await read($, workspace),
983 advice: await read($, advice),
984 totals: await read($, theoTotals),
985 calls: await read($, theo),
986 now: await $.clock.now(),
987 width: 200,
988 context: percent === undefined ? '' : `${Math.round(percent)}% used${tokens}`,
989 sections: true,
990 job: jobWords,
991 local: await read($, statusLocal),
992 jobId: job.record?.id ?? '',
993 }
994
995 return { text: stateText(input) }
996 })
997
998 on('command.run', { command: 'mos-plain' }, async $ => {
999 const on2 = await read($, isPlain)
1000 await update($, isPlain, () => !on2)
1001 $.ui.invalidate('ui.render')
1002
1003 return { text: on2 ? 'Plain mode off.' : 'Plain mode on: ASCII only, no color pictures.' }
1004 })
1005
1006 on('command.run', { command: 'mos-advice' }, async $ => {
1007 void runAdvice($, true)
1008
1009 return { text: wordsTexts(await read($, wordsOn), PROPOSALS).advice }
1010 })
1011
1012 on('command.run', { command: 'mos-auto' }, async $ => {
1013 const off = await read($, isAutoOff)
1014 await update($, isAutoOff, () => !off)
1015
1016 return { text: off ? 'The panel asks Theo every second turn.' : 'The panel no longer asks Theo by itself.' }
1017 })
1018
1019 on('command.run', { command: 'mos-job' }, async $ => {
1020 const job = await jobNow($)
1021 if (job.isHidden) {
1022 await update($, jobHidden, () => false)
1023 $.ui.invalidate('ui.render')
1024
1025 return { text: 'Job line shown. It is a guess from the room and your recent words, and nothing is sent anywhere.' }
1026 }
1027 await update($, jobHidden, () => true)
1028 await update($, jobHiddenFor, () => '')
1029 $.ui.invalidate('ui.render')
1030
1031 return { text: 'Job line hidden. Run /mos-job to show it again.' }
1032 })
1033
1034 // The room tree by keyboard (plan 380-18): every name of the room is reachable from the prompt box.
1035 on('command.run', { command: 'mos-tree' }, async ($, e) => ({ text: await runTree($, e.args) }))
1036
1037 on('command.run', { command: 'theo-calls' }, async $ => {
1038 await $.ui.open({ id: 'theo-calls', title: 'Theo calls' })
1039
1040 return { text: 'Theo calls pane opened (it seats from 110 columns wide, or at any width on request).' }
1041 })
1042
1043 on('prompt.submit', async ($, e, next) => {
1044 if (!e.command && !looksInjected(e.text)) {
1045 lastUser = e.text.slice(0, 2000)
1046 recentUser = [...recentUser, lastUser].slice(-6)
1047 }
1048
1049 return next(e)
1050 })
1051
1052 on('turn.start', async ($, e, next) => {
1053 if (e.agentId === undefined) {
1054 stopBack()
1055 await update($, mos, () => 0)
1056 await update($, theoTurn, () => emptyTotals)
1057 await update($, flag, () => '')
1058 await update($, face, () => stateFor('turn.start'))
1059 await update($, detail, () => '')
1060 await update($, spoken, n => n + 1)
1061 if (!spin) {
1062 spin = $.clock.every(220, () => {
1063 void update($, frame, n => n + 1)
1064 })
1065 }
1066 await pin($)
1067 }
1068
1069 return next(e)
1070 })
1071
1072 on('tool.call', async ($, e, next) => {
1073 const name = e.tool
1074 const isTheo = THEO_TOOL.test(name)
1075 const isMain = e.agentId === undefined
1076 const isAutoCall = autoInFlight || isPanelReading()
1077 const id = e.tool_use_id
1078 const startedAt = isTheo ? await $.clock.now() : 0
1079
1080 if (isTheo) {
1081 const entry: TheoCall = {
1082 id,
1083 tool: toolName(name),
1084 via: theoVia(name),
1085 ms: null,
1086 status: 'running',
1087 isAgent: !isMain,
1088 isAuto: isAutoCall,
1089 }
1090 await update($, theo, list => [...list, entry].slice(-40))
1091 await update($, theoTotals, t => ({ ...t, total: t.total + 1 }))
1092 await update($, theoTurn, t => ({ ...t, total: t.total + 1 }))
1093 }
1094
1095 const shows = isMain && !isAutoCall
1096 if (shows) {
1097 stopBack()
1098 if (MOS_TOOL.test(name)) await update($, mos, n => n + 1)
1099 await update($, face, () => stateFor('tool.call'))
1100 await update($, detail, () => (isTheo ? `${theoVia(name)}: ${toolName(name)}` : shortTool(name)))
1101 await update($, spoken, n => n + 1)
1102 }
1103
1104 const ran = await next(e)
1105
1106 const text = typeof ran.text === 'string' ? ran.text : ''
1107 let state: FaceId = stateFor('tool.ok')
1108 let note = isTheo ? `${theoVia(name)}: ${toolName(name)}` : shortTool(name)
1109 let status: TheoStatus = 'ok'
1110 if (ran.deny !== undefined) {
1111 state = stateFor('tool.denied')
1112 status = 'blocked'
1113 note = `blocked - ${note}`
1114 } else if (ran.isError) {
1115 state = stateFor('tool.failed')
1116 status = 'error'
1117 note = `error - ${note}`
1118 } else if (text.trim() === '' || EMPTY_RESULT.test(text)) {
1119 state = stateFor('tool.empty')
1120 status = 'empty'
1121 note = `empty - ${note}`
1122 }
1123
1124 if (isTheo) {
1125 const ms = (await $.clock.now()) - startedAt
1126 await update($, theo, list => list.map(one => (one.id === id ? { ...one, ms, status } : one)))
1127 const bump = (t: TheoTotals): TheoTotals => ({
1128 ...t,
1129 ok: t.ok + (status === 'ok' ? 1 : 0),
1130 empty: t.empty + (status === 'empty' ? 1 : 0),
1131 error: t.error + (status === 'error' ? 1 : 0),
1132 blocked: t.blocked + (status === 'blocked' ? 1 : 0),
1133 })
1134 await update($, theoTotals, bump)
1135 await update($, theoTurn, bump)
1136
1137 if (status === 'error' || status === 'blocked') {
1138 const now = await $.clock.now()
1139 if (now - lastToastAt > 20000) {
1140 lastToastAt = now
1141 $.ui.toast(`Theo call ${status === 'error' ? 'failed' : 'was blocked'}: ${toolName(name)}`)
1142 }
1143 }
1144 }
1145
1146 if (shows) {
1147 await update($, face, () => state)
1148 await update($, detail, () => note)
1149 await update($, spoken, n => n + 1)
1150 back = $.clock.after(1400, () => {
1151 void update($, face, () => stateFor('turn.start'))
1152 void update($, detail, () => '')
1153 void update($, spoken, n => n + 1)
1154 })
1155 }
1156 if (ran.deny === undefined && !ran.isError) {
1157 void noteChange($, e as unknown as { tool: string; section?: unknown; file_path?: unknown })
1158 }
1159 void pin($)
1160
1161 return ran
1162 })
1163
1164 on('turn.complete', async ($, e, next) => {
1165 if (e.agentId === undefined) {
1166 stopSpin()
1167 stopBack()
1168 lastAnswer = e.answer.slice(0, 4000)
1169 turnsDone += 1
1170
1171 const lead = LEAD.exec(e.answer)
1172 if (lead) {
1173 const glyph = lead[1] ?? ''
1174 await update($, move, () => moveOf(glyph))
1175 } else if (e.answer.trim() !== '') {
1176 await update($, move, () => 'none')
1177 }
1178
1179 const turnCalls = await read($, theoTurn)
1180 if (MENTIONS_THEO.test(e.answer) && turnCalls.total === 0) {
1181 await update($, flag, () => 'the reply mentions Theo, but no call to Theo was made this turn')
1182 }
1183
1184 const secs = Math.round(e.durationMs / 1000)
1185 // A finished answer is a handover, unless its lead square names another state (red, yellow, black).
1186 let state: FaceId = lead ? stateForLead(lead[1] ?? '') : stateFor('turn.complete')
1187 let note = `${secs}s`
1188 if (e.reason === 'refusal') {
1189 state = stateFor('turn.refusal')
1190 note = 'the model refused'
1191 } else if (e.reason === 'aborted') {
1192 state = stateFor('turn.aborted')
1193 note = 'interrupted'
1194 } else if (e.reason === 'error') {
1195 state = stateFor('turn.error')
1196 note = 'the turn ended on an error'
1197 }
1198
1199 await update($, face, () => state)
1200 await update($, detail, () => note)hooks/advice.ts 553 lines1import type { EngineInterface } from 'claude-code'
2
3import type { Advice } from '../types'
4import { WHY } from './commands'
5import { PROPOSALS } from './proposals'
6import { REMOVED_KINDS, deliveryOf, excerptOf, failureNote, outcomeOf } from './sent'
7import type { Attempt, RawCall, RemovedKind } from './sent'
8import type { Proposals } from './proposals'
9
10// The panel asks Theo what to do next, every couple of turns.
11//
12// What leaves the machine (Canon Part 8, plan 380-04, decision D-03). Three calls, and
13// only three:
14// 1. The question to the guarded shim tool. It carries a framework name, a problem type
15// and, on by default, a cleaned excerpt of the person's OWN recent words (owner ruling
16// 2026-10-21, kept as written). The excerpt is at most 240 characters. Links, emails,
17// file paths, code, key-like strings and names are removed first (sanitize, stripNames).
18// It is shown in full in the panel, and /mos-words turns it off.
19// 2. The same question to the raw theo server, only when the shim failed or was denied.
20// With the proposal shimOnlyExcerpt on (hooks/proposals.ts) it carries handles only,
21// because the raw server never passes lib/core/part8-egress-guard.cjs.
22// 3. A search on the raw theo server. Its query is always one string of HANDLE_QUERIES:
23// a framework name or a problem type, never a word taken from the person's text.
24// Never assistant text, room files, claims or artifacts. Larry's own brain_* calls are not
25// touched by this: they stay generic.
26
27type Engine = EngineInterface
28
29export type ProblemType = 'Un-Defined' | 'Ill-Defined' | 'Well-Defined' | 'Wicked'
30
31export type Handle = {
32 framework: string
33 problemType: ProblemType
34 matched: string[]
35}
36
37const RULES: { framework: string; words: RegExp }[] = [
38 { framework: 'Root Cause Analysis', words: /\b(root cause|symptom\w*|keeps? (happening|breaking|failing|coming back)|regress\w*|bug|recurring)\b/gi },
39 { framework: 'Red Teaming', words: /\b(assum\w*|prove|validate|claim\w*|evidence|doubt\w*|challenge\w*|devil\w*|verify)\b/gi },
40 { framework: 'Reverse Salient Analysis', words: /\b(stuck|bottleneck\w*|blocked|lagging|slow\w*|holds? (us|it|things) back)\b/gi },
41 { framework: 'Scenario Planning', words: /\b(future|scenario\w*|trend\w*|uncertain\w*|forecast\w*|next (year|decade))\b/gi },
42 { framework: 'Jobs to Be Done (JTBD)', words: /\b(customer\w*|user needs?|jtbd|persona\w*|audience|job to be done)\b/gi },
43 { framework: 'Four Lenses of Innovation', words: /\b(analog\w*|another field|other (domain|industry)|borrow\w*|inspiration)\b/gi },
44 { framework: 'Lean Canvas', words: /\b(business model|pricing|revenue|investor\w*|pitch\w*|funding|market size)\b/gi },
45 { framework: 'Knowns and Unknowns Matrix Framework', words: /\b(unknowns?|not sure|unclear|don'?t know)\b/gi },
46 { framework: 'Systems Thinking', words: /\b(feedback loops?|dependenc\w*|ecosystem\w*|knock-?on|second-order)\b/gi },
47 { framework: 'Dominant Design', words: /\b(winner|competitor\w*|market leader|dominant design)\b/gi },
48]
49
50const FALLBACK = 'Problem Definition Transformation Framework'
51
52// The closed set of strings the search call may send as its query: the framework names of
53// the rules, the fallback framework and the four problem types. Nothing else, ever.
54export const HANDLE_QUERIES: string[] = [...RULES.map(rule => rule.framework), FALLBACK, 'Un-Defined', 'Ill-Defined', 'Well-Defined', 'Wicked']
55
56// Singular and plural are one cue: "ecosystem" and "ecosystems" count once.
57const stem = (word: string): string => word.toLowerCase().replace(/(ing|ed|es|s)$/, '')
58
59function count(text: string, words: RegExp): string[] {
60 return [...text.matchAll(words)].map(m => m[0].toLowerCase())
61}
62
63function distinct(words: string[]): string[] {
64 const seen = new Set<string>()
65 const out: string[] = []
66 for (const word of words) {
67 const key = stem(word)
68 if (!seen.has(key)) {
69 seen.add(key)
70 out.push(word)
71 }
72 }
73
74 return out
75}
76
77export function classify(user: string, answer: string): Handle {
78 let best = { framework: FALLBACK, score: 0, matched: [] as string[] }
79 for (const rule of RULES) {
80 const inUser = count(user, rule.words)
81 const inAnswer = count(answer, rule.words)
82 const score = inUser.length * 2 + inAnswer.length
83 if (score > best.score) {
84 best = { framework: rule.framework, score, matched: distinct([...inUser, ...inAnswer]).slice(0, 4) }
85 }
86 }
87 // One common word is not a cue. It takes two different cue words to count.
88 if (best.matched.length < 2) best = { framework: FALLBACK, score: 0, matched: [] }
89 const both = `${user} ${answer}`
90 let problemType: ProblemType = 'Ill-Defined'
91 if (/\b(stakeholders?|ethic\w*|polic(y|ies)|society|conflict|trade-?offs?)\b/i.test(both)) problemType = 'Wicked'
92 else if (/\b(future|explore|opportunit\w*|possibilit\w*|what could)\b/i.test(both)) problemType = 'Un-Defined'
93 else if (/\b(fix|implement\w*|build|deploy\w*|error|how do i)\b/i.test(both)) problemType = 'Well-Defined'
94 return { framework: best.framework, problemType, matched: best.matched }
95}
96
97export function questionFor(h: Handle, words = ''): string {
98 const ask = `For a ${h.problemType} problem, what comes next after the ${h.framework} framework, and which commands carry it?`
99
100 return words === '' ? ask : `The person says: "${words}". ${ask}`
101}
102
103// ---- The person's own words, cleaned --------------------------------------------
104
105const SECRET = /\b(sk|pk|ghp|gho|ghs|xox[abp]|AKIA|eyJ)[A-Za-z0-9_\-.]{8,}\b/g
106
107const NAME = '(a name)'
108
109// S3: the words that may start a sentence without being a name. A closed list of function
110// words: articles, pronouns, conjunctions, auxiliary verbs, question words, prepositions and
111// common adverbs. A word that is also a given name (will, may) is left out on purpose.
112export const COMMON_STARTS: ReadonlySet<string> = new Set(
113 (
114 'the a an we our ours us i my me mine it its this that these those there here they their them you your yours he she his her him ' +
115 'what why how when where who whom which whose if but and or nor so yet because since while although though as unless until whether ' +
116 'can could should would do does did don doesn didn is are was were am be been being have has had haven hasn hadn isn aren wasn weren ' +
117 'won wouldn couldn shouldn cannot not no yes now then also maybe just some each all any every one only still even again once often ' +
118 'sometimes always never please let first next finally instead however today yesterday tomorrow soon later already almost really ' +
119 'about after before for from in on of with without to at by up out over under between during into through against among near ' +
120 'both either neither many much more most less few other another such same own very too'
121 ).split(' '),
122)
123
124// The words of the framework names and problem types, the product names, the pronoun I, the
125// weekday and month names and a few plain acronyms are not names of a venture or a person.
126const ALLOW: ReadonlySet<string> = new Set([
127 ...HANDLE_QUERIES.flatMap(query => query.toLowerCase().split(/[^a-z]+/)).filter(word => word !== ''),
128 ...'theo pws mindrianos jtbd swot pest api ai ux mvp roi kpi'.split(' '),
129 ...'monday tuesday wednesday thursday friday saturday sunday january february march april june july august september october november december'.split(' '),
130])
131
132// The cue words of every rule, each as a whole-token test, so a cue word at the start of a
133// sentence (Customers, Symptom, Blocked) survives: the classifier needs it.
134const CUES = RULES.map(rule => new RegExp(`^(?:${rule.words.source})$`, 'i'))
135
136const isCue = (word: string): boolean => CUES.some(cue => cue.test(word))
137
138// A word starts a sentence when only spaces, quotes, brackets or list marks lie between it
139// and the start of the text, a full stop, a question mark, an exclamation mark or a line break.
140function startsSentence(whole: string, offset: number): boolean {
141 let i = offset - 1
142 while (i >= 0 && /[ \t"'\u201C\u201D\u2018\u2019(\[{*_#>-]/.test(whole.charAt(i))) i--
143 if (i < 0) return true
144
145 return /[.?!\u2026\n\r]/.test(whole.charAt(i))
146}
147
148// Remove the names from prose, with the rule S1 plus S2 plus S3 of plan 380-04:
149// S1 known names: the room slug, each part of it (3 letters or more), the workspace folder
150// name and the session folder name, whole words, any case. A part that is a plain
151// function word (the, and) is not a name and is left out of the list.
152// S2 proper names: a capitalized word that does not start a sentence, and any all-caps word
153// of 2 letters or more, unless it is on the allow-list.
154// S3 sentence starts: the first word of each sentence, unless it is a common word, a cue
155// word of a rule or an allow-listed word.
156// A run of replaced words collapses to one placeholder. Residual risk: a name typed in
157// lowercase in the middle of a sentence, or in a script without capital letters, is caught
158// only when it equals a known name.
159export function stripNames(text: string, known: string[] = []): string {
160 const names = new Set<string>()
161 for (const raw of known) {
162 const whole = raw.trim().toLowerCase()
163 if (whole.length >= 3) names.add(whole)
164 for (const part of whole.split(/[-_.\s]+/)) {
165 if (part.replace(/[^\p{L}]/gu, '').length >= 3 && !COMMON_STARTS.has(part)) names.add(part)
166 }
167 }
168
169 const replaced = text.replace(/\p{L}[\p{L}\p{N}'\u2019]*/gu, (token: string, offset: number, whole: string) => {
170 const base = token.replace(/['\u2019]+$/, '').replace(/['\u2019]s$/i, '')
171 const lower = base.toLowerCase()
172 const head = lower.split(/['\u2019]/)[0] ?? lower
173 if (names.has(lower)) return NAME
174 if (head === 'i' || ALLOW.has(lower)) return token
175 const letters = base.replace(/[^\p{L}]/gu, '')
176 if (letters.length >= 2 && base === base.toUpperCase() && base !== base.toLowerCase()) return NAME
177 if (startsSentence(whole, offset)) {
178 return COMMON_STARTS.has(lower) || COMMON_STARTS.has(head) || isCue(lower) || ALLOW.has(head) ? token : NAME
179 }
180
181 return /^\p{Lu}/u.test(base) ? NAME : token
182 })
183
184 return replaced.replace(/\(a name\)(?:[\s-]*\(a name\))+/g, NAME)
185}
186
187// Strip what should never travel, then keep a short excerpt, and note which kinds of thing were removed
188// (plan 380-16, D-03). The steps and their order are the ones sanitize always had, so the text is byte
189// for byte the same. The note holds only kinds, never the removed text. known: the names this machine
190// already knows (room slug, workspace and session folder), removed from the prose.
191export function sanitizeNoted(text: string, max = 240, known: string[] = []): { text: string; left: RemovedKind[] } {
192 const left = new Set<RemovedKind>()
193 const strip = (input: string, kind: RemovedKind, pattern: RegExp): string => {
194 const out = input.replace(pattern, ' ')
195 if (out !== input) left.add(kind)
196
197 return out
198 }
199 let cleaned = strip(text, 'code', /```[\s\S]*?```/g)
200 cleaned = strip(cleaned, 'code', /`[^`]*`/g)
201 cleaned = strip(cleaned, 'link', /https?:\/\/\S+/g)
202 cleaned = strip(cleaned, 'email', /[\w.+-]+@[\w-]+\.[\w.-]+/g)
203 cleaned = strip(cleaned, 'path', /(?:~|\/home|\/Users|\/tmp|[A-Za-z]:\\)[\w./\\~-]*/g)
204 cleaned = strip(cleaned, 'key', SECRET)
205 cleaned = strip(cleaned, 'long-name', /\b[A-Za-z0-9_-]{28,}\b/g)
206 const named = stripNames(cleaned, known)
207 if (named !== cleaned) left.add('name')
208 const spaced = named.replace(/["]/g, "'").replace(/\s+/g, ' ').trim()
209 if (spaced.length > max) left.add('cut')
210
211 return { text: clip(spaced, max), left: REMOVED_KINDS.filter(kind => left.has(kind)) }
212}
213
214export function sanitize(text: string, max = 240, known: string[] = []): string {
215 return sanitizeNoted(text, max, known).text
216}
217
218// The words worth sending: the newest sentence of the person's own that carries a
219// cue, else the newest substantial message. Never the assistant, never the room. The
220// left list names the kinds of thing removed on the way (plan 380-16).
221export function wordsForNoted(matched: string[], messages: string[], known: string[] = []): { text: string; left: RemovedKind[] } {
222 const cue = subjectOfNoted(matched, messages)
223 if (cue.text !== '') {
224 const noted = sanitizeNoted(cue.text, 240, known)
225
226 return { text: noted.text, left: REMOVED_KINDS.filter(kind => cue.left.includes(kind) || noted.left.includes(kind)) }
227 }
228 for (let i = messages.length - 1; i >= 0; i--) {
229 const noted = sanitizeNoted(messages[i] ?? '', 240, known)
230 if (noted.text.length >= 20) return noted
231 }
232
233 return { text: '', left: [] }
234}
235
236export function wordsFor(matched: string[], messages: string[], known: string[] = []): string {
237 return wordsForNoted(matched, messages, known).text
238}
239
240type Option = { framework?: string; commands?: string[]; theo_rank?: number }
241
242function parse(text: string): { options: Option[]; guided: string } {
243 const start = text.indexOf('{')
244 if (start < 0) return { options: [], guided: '' }
245 try {
246 const env = JSON.parse(text.slice(start)) as {
247 next_gate?: { options?: Option[] }
248 directive?: { guided?: { questions?: string[] } }
249 }
250 const options = [...(env.next_gate?.options ?? [])].sort((a, b) => (a.theo_rank ?? 99) - (b.theo_rank ?? 99))
251 const q = env.directive?.guided?.questions
252 return { options, guided: Array.isArray(q) && typeof q[0] === 'string' ? q[0] : '' }
253 } catch {
254 return { options: [], guided: '' }
255 }
256}
257
258
259// ---- The framework picks chapters ------------------------------------------------
260// Theo's keyword search answers with chapters. The query is the framework name of the
261// handle and nothing else: never a word of the person (plan 380-04, decision D-03). Each
262// chapter that comes back points at the command that teaches it.
263
264const CHAPTER_COMMAND: Record<string, { framework: string; command: string }> = {
265 redteam: { framework: 'Red Teaming', command: '/mos:challenge-assumptions' },
266 nested: { framework: 'Nested Hierarchies', command: '/mos:structure-argument' },
267 decomplex: { framework: 'Decomplexification', command: '/mos:analyze-systems' },
268 ch02b: { framework: 'Scenario Analysis', command: '/mos:scenario-plan' },
269 ch03: { framework: 'Trend Analysis', command: '/mos:explore-trends' },
270 ch04: { framework: 'Dominant Design', command: '/mos:dominant-designs' },
271 ch07: { framework: 'Reverse Salient Analysis', command: '/mos:find-bottlenecks' },
272 bottleneck: { framework: 'Bottleneck Analysis', command: '/mos:find-bottlenecks' },
273 fastdiag: { framework: 'Fast Diagnosis', command: '/mos:diagnose' },
274 momtest: { framework: 'The Mom Test', command: '/mos:user-needs' },
275 bmd: { framework: 'Business Model Design', command: '/mos:lean-canvas' },
276 beautiful: { framework: 'Beautiful Question', command: '/mos:beautiful-question' },
277 bono: { framework: 'Six Thinking Hats', command: '/mos:think-hats' },
278 sciroad: { framework: 'Scientific Roadmap', command: '/mos:scientific-roadmap' },
279}
280
281type Row = { chapterId?: string }
282
283export function chaptersToPicks(rows: Row[], skip: string[]): { framework: string; command: string }[] {
284 const out: { framework: string; command: string }[] = []
285 for (const row of rows) {
286 const pick = CHAPTER_COMMAND[row.chapterId ?? '']
287 if (pick && WHY[pick.command] && !skip.includes(pick.command) && !out.some(o => o.command === pick.command)) out.push(pick)
288 }
289
290 return out
291}
292
293async function searchChapters($: Engine, h: Handle, skip: string[]): Promise<{ framework: string; command: string }[]> {
294 // Handles only: the query is one string of the closed set, or there is no call at all.
295 const query = h.framework
296 if (!HANDLE_QUERIES.includes(query)) return []
297 try {
298 const ran = await $.tool.call({ tool: 'mcp__theo__search', query, topK: 12 } as never)
299 const out = ran as { deny?: string; isError?: boolean; text?: string }
300 if (out.deny !== undefined || out.isError) return []
301 const text = String(out.text ?? '')
302 const start = text.indexOf('{')
303 const data = JSON.parse(text.slice(start)) as { rows?: Row[] }
304
305 return chaptersToPicks(data.rows ?? [], skip)
306 } catch {
307 return []
308 }
309}
310
311const SHIM = 'mcp__plugin_mos_mindrian-brain__brain_ask'
312const RAW = 'mcp__theo__brain_ask'
313// The guarded shim first, the raw theo server only after it failed or was denied.
314const TOOLS = [SHIM, RAW]
315
316// Does the first value of the environment switch turn the excerpt off at session start?
317// With the envSwitch proposal off, the variable is ignored. Returns whether words start on.
318export function wordsStartOn(envValue: string | undefined, proposals: Proposals = PROPOSALS): boolean {
319 if (!proposals.envSwitch) return true
320 const value = (envValue ?? '').trim().toLowerCase()
321
322 return !(value === 'off' || value === '0' || value === 'false')
323}
324
325// The plain texts of /mos-advice and /mos-words. advice: what an ask sends while the excerpt
326// is on or off. words: what /mos-words says after it set the excerpt on or off. Both tell
327// the truth about now, and the second says how long the setting lasts.
328export function wordsTexts(isOn: boolean, proposals: Proposals = PROPOSALS): { advice: string; words: string } {
329 const lasting = proposals.envSwitch
330 ? ' A new session starts with the excerpt on, unless MOS_FACE_WORDS=off is set in the settings environment.'
331 : ' A new session starts with the excerpt on again.'
332
333 return isOn
334 ? {
335 advice:
336 'Asking Theo. If Theo is reached, it gets a framework name, a problem type and a short excerpt of your own words. The panel says what went, or that nothing did. Run /mos-words to stop the excerpt.',
337 words:
338 'Theo now also gets a short excerpt of your own recent words, with links, emails, paths, code, key-like strings and names removed. Never the assistant, never the room. Run /mos-words to stop. This lasts until this session ends.' +
339 lasting,
340 }
341 : {
342 advice: 'Asking Theo. If Theo is reached, it gets only a framework name and a problem type. The panel says what went, or that nothing did.',
343 words: 'Theo now gets only a framework name and a problem type. This lasts until this session ends.' + lasting,
344 }
345}
346
347const textOf = (value: unknown): string => {
348 if (typeof value !== 'object' || value === null || !('text' in value)) return ''
349
350 return String(value.text ?? '')
351}
352
353export async function askTheo(
354 $: Engine,
355 h: Handle,
356 turn: number,
357 avoid: string[] = [],
358 words = '',
359 skipCommands: string[] = [],
360 proposals: Proposals = PROPOSALS,
361 left: readonly RemovedKind[] = [],
362): Promise<Advice> {
363 const base: Advice = {
364 status: 'failed',
365 at: await $.clock.now(),
366 turn,
367 handle: `${h.framework} / ${h.problemType}`,
368 framework: '',
369 commands: [],
370 whys: [],
371 framings: [],
372 matched: [],
373 sent: '',
374 delivery: { kind: 'none' },
375 question: questionFor(h),
376 guided: '',
377 note: '',
378 }
379
380 // Every call is classified (plan 380-16). What the panel says about the words comes from all of the
381 // calls together, never from one: a call that failed on the way may still have delivered them, a
382 // refused or missing call sent nothing, and an answered call that carried them sent them.
383 const attempts: Attempt[] = []
384 for (const tool of TOOLS) {
385 // The excerpt rides the guarded shim. The raw server gets it only when the proposal
386 // shimOnlyExcerpt is off (the owner ruling as written).
387 const carries = words !== '' && (tool === SHIM || !proposals.shimOnlyExcerpt)
388 base.question = questionFor(h, carries ? words : '')
389 let raw: RawCall
390 try {
391 raw = { kind: 'returned', value: await $.tool.call({ tool, question: base.question } as never) }
392 } catch (error) {
393 raw = { kind: 'threw', message: error instanceof Error ? error.message : String(error) }
394 }
395 const outcome = outcomeOf(raw)
396 attempts.push({ outcome, carried: carries })
397 if (outcome.kind !== 'answered' || raw.kind !== 'returned') continue
398
399 const delivery = deliveryOf(attempts, words, left)
400 base.delivery = delivery
401 base.sent = excerptOf(delivery)
402 const parsed = parse(textOf(raw.value))
403 // Skip frameworks already shown; if Theo offers nothing else, say so plainly.
404 const fresh = parsed.options.filter(option => !avoid.includes(option.framework ?? ''))
405 const sameAgain = parsed.options.length > 0 && fresh.length === 0
406 const options = sameAgain ? parsed.options : fresh
407 const guided = parsed.guided
408 const commands: string[] = []
409 for (const option of options) {
410 for (const slug of option.commands ?? []) {
411 const command = `/mos:${slug}`
412 if (WHY[command] && !commands.includes(command)) commands.push(command)
413 }
414 }
415 if (commands.length === 0) {
416 base.note = 'Theo answered, but named no command to use'
417 base.framework = options[0]?.framework ?? ''
418 base.guided = guided
419 base.status = 'ok'
420
421 return base
422 }
423 // The framework leads: commands from chapters its name found come first, then the
424 // problem-type chain, never repeating a command just shown. The search sends the
425 // framework name only.
426 const fromHandle = await searchChapters($, h, skipCommands)
427 const merged = [...fromHandle.map(p => p.command), ...commands.filter(c => !skipCommands.includes(c))]
428 const ordered = [...new Set(merged)]
429 const top = (ordered.length > 0 ? ordered : commands).slice(0, 3)
430 const led = fromHandle[0]
431
432 return {
433 ...base,
434 status: 'ok',
435 framework: led?.framework ?? options[0]?.framework ?? h.framework,
436 commands: top,
437 whys: top.map(command => WHY[command] ?? ''),
438 framings: [],
439 matched: [],
440 guided,
441 note: led ? 'chosen from the framework' : sameAgain ? 'Theo suggested the same frameworks again, so the talk gives it nothing new to go on' : '',
442 }
443 }
444
445 const delivery = deliveryOf(attempts, words, left)
446 base.delivery = delivery
447 base.sent = excerptOf(delivery)
448 base.note = failureNote(attempts, delivery)
449
450 return base
451}
452
453// ---- Framing a command in the person's own situation ---------------------------
454// The command goes into the prompt box with a short, plain subject taken from
455// what the person actually said, so Enter runs it already pointed at their case.
456// This stays on this machine: it fills the person's own box and is never sent
457// anywhere until they press Enter.
458
459// The same cleaning, with the kinds it removed.
460function cleanNoted(text: string): { text: string; left: RemovedKind[] } {
461 const left: RemovedKind[] = []
462 const strip = (input: string, kind: RemovedKind, pattern: RegExp): string => {
463 const out = input.replace(pattern, ' ')
464 if (out !== input) left.push(kind)
465
466 return out
467 }
468 let out = strip(text, 'code', /```[\s\S]*?```/g)
469 out = strip(out, 'code', /`[^`]*`/g)
470 out = strip(out, 'link', /https?:\/\/\S+/g)
471
472 return { text: out.replace(/\s+/g, ' ').trim(), left }
473}
474
475function clip(text: string, max: number): string {
476 if (text.length <= max) return text
477 const cut = text.slice(0, max)
478 const space = cut.lastIndexOf(' ')
479
480 return (space > 40 ? cut.slice(0, space) : cut).replace(/[,;:\s]+$/, '')
481}
482
483// The newest sentence the person said that carries one of the cue words that
484// chose this advice. Only a sentence with a cue counts: an old, unrelated line
485// is never a subject, and with no cue there is no subject at all. The left list names
486// what the cleaning and the clip took out of the message on the way.
487export function subjectOfNoted(matched: string[], messages: string[]): { text: string; left: RemovedKind[] } {
488 if (matched.length === 0) return { text: '', left: [] }
489 for (let i = messages.length - 1; i >= 0; i--) {
490 const cleaned = cleanNoted(messages[i] ?? '')
491 const sentences = cleaned.text.split(/(?<=[.!?])\s+/).filter(one => one.length >= 12)
492 const hit = sentences.find(one => matched.some(word => one.toLowerCase().includes(word)))
493 if (hit) return { text: clip(hit, 120), left: hit.length > 120 ? [...cleaned.left, 'cut'] : cleaned.left }
494 }
495
496 return { text: '', left: [] }
497}
498
499export function subjectOf(matched: string[], messages: string[]): string {
500 return subjectOfNoted(matched, messages).text
501}
502
503export function frameFor(command: string, matched: string[], messages: string[]): string {
504 const subject = subjectOf(matched, messages)
505 if (subject !== '') return `${command} ${subject}`
506 const clause = (WHY[command] ?? '').replace(/^When\s+/i, '').split(/,\s*\/mos:/)[0] ?? ''
507
508 return clause !== '' ? `${command} ${clip(clause, 110)}` : command
509}
510
511// ---- Theo suggests, Larry contextualizes ---------------------------------------
512// A click on a suggestion does not just paste a command. It writes a question
513// for Larry into the person's prompt box: which framework Theo suggested, why
514// it might fit this room and this moment, and what to do first. The person
515// presses Enter; Larry answers with the real context. Everything here stays on
516// this machine until then, and is the person's own box to edit.
517
518export type ThinkInput = {
519 command: string
520 framework: string
521 whyLine: string
522 room: string | null
523 stage: string
524 stale: string[]
525 subject: string
526}
527
528// The plain situation a command is for: the part of its teaching line before the
529// command name, without the tool names that come after it.
530export function situationOf(whyLine: string): string {
531 return (whyLine.replace(/^When\s+/i, '').split(/,\s*\/mos:/)[0] ?? '').trim()
532}
533
534export function thinkPrompt(input: ThinkInput): string {
535 const situation = situationOf(input.whyLine)
536 const parts: string[] = []
537 parts.push(
538 input.framework !== ''
539 ? `Theo suggests the ${input.framework} framework, through ${input.command}${situation ? ` (when ${situation})` : ''}.`
540 : `Theo suggests ${input.command}${situation ? ` (when ${situation})` : ''}.`,
541 )
542 if (input.room) {
543 const stale = input.stale.length > 0 ? `; reasoning is stale in ${input.stale.slice(0, 3).join(', ')}` : ''
544 parts.push(`My room is ${input.room}, stage ${input.stage}${stale}.`)
545 }
546 if (input.subject !== '') parts.push(`What I said that points here: "${input.subject}".`)
547 parts.push(
548 `Think it through with me in plain words: why does this fit my situation right now, what would I use it for, and what is the first step? If it fits, say so and I will run ${input.command}.`,
549 )
550
551 return parts.join(' ')
552}
553hooks/bind.ts 30 lines1import type { EngineInterface } from 'claude-code'
2
3import { roomsHome } from './paths'
4
5// Which room is this session bound to? The binding itself lives in the running
6// tool server's memory, so the most that can be read from disk is the registry's
7// record. Say exactly what was found, and no more.
8
9export type Binding = { room: string | null; how: string }
10
11export async function boundRoom($: EngineInterface): Promise<Binding> {
12 try {
13 const id = await $.session.id()
14 const text = await $.fs.read(`${await roomsHome($)}/.rooms/registry.json`)
15 const registry = JSON.parse(String(text)) as {
16 active?: string
17 active_session?: string
18 sessions?: Record<string, { room?: string }>
19 }
20 const own = registry.sessions?.[id]?.room
21 if (typeof own === 'string' && own !== '') return { room: own, how: 'recorded for this session' }
22 if (registry.active_session === id && typeof registry.active === 'string') {
23 return { room: registry.active, how: 'recorded as the active session' }
24 }
25 return { room: null, how: 'no record for this session' }
26 } catch {
27 return { room: null, how: 'the registry could not be read' }
28 }
29}
30hooks/commands.ts 536 lines1// Generated file. Do not edit by hand.
2// Regenerate with: node ui/mos-face/scripts/face-commands.cjs --write
3// TERM_COMMANDS and STATE_COMMANDS: framework -> /mos: commands, resolved with the plugin's own
4// resolver (lib/workflow/command-resolver.cjs commandsForFramework), never typed from memory. They
5// are kept as they are; the --check mode fails when one names a command the registry no longer holds.
6// WHY: the first sentence of each command teaching line in data/command-registry.json, in plain
7// words (the override map of scripts/face-commands.cjs replaces a sentence that holds a stack word).
8import type { FaceId } from '../types'
9
10export const TERM_COMMANDS: Record<string, { framework: string; commands: string[] }> = {
11 "Problem First": {
12 "framework": "Problem Definition Transformation Framework",
13 "commands": [
14 "/mos:diagnose"
15 ]
16 },
17 "Systematic Innovation": {
18 "framework": "Domain Selection",
19 "commands": [
20 "/mos:explore-domains"
21 ]
22 },
23 "Innovation": {
24 "framework": "PWS Triple Validation Compass",
25 "commands": [
26 "/mos:compare-ventures",
27 "/mos:deep-grade"
28 ]
29 },
30 "Problem Type Progression": {
31 "framework": "Problem Definition Transformation Framework",
32 "commands": [
33 "/mos:diagnose"
34 ]
35 },
36 "Taxonomy of Problems": {
37 "framework": "Problem Definition Transformation Framework",
38 "commands": [
39 "/mos:diagnose"
40 ]
41 },
42 "Un-defined Problem": {
43 "framework": "Domain Selection",
44 "commands": [
45 "/mos:explore-domains"
46 ]
47 },
48 "Systems Thinking": {
49 "framework": "Systems Thinking",
50 "commands": [
51 "/mos:analyze-systems",
52 "/mos:systems-thinking"
53 ]
54 },
55 "Problem Worth Solving": {
56 "framework": "Problem Definition Transformation Framework",
57 "commands": [
58 "/mos:diagnose"
59 ]
60 },
61 "Why-What If-How": {
62 "framework": "Beautiful Question Framework",
63 "commands": [
64 "/mos:beautiful-question"
65 ]
66 },
67 "Problem Statement": {
68 "framework": "Problem Definition Transformation Framework",
69 "commands": [
70 "/mos:diagnose"
71 ]
72 },
73 "Ill-defined Problem": {
74 "framework": "Problem Definition Transformation Framework",
75 "commands": [
76 "/mos:diagnose"
77 ]
78 },
79 "Well-defined Problem": {
80 "framework": "Problem Definition Transformation Framework",
81 "commands": [
82 "/mos:diagnose"
83 ]
84 },
85 "Job Statement": {
86 "framework": "Jobs to Be Done (JTBD)",
87 "commands": [
88 "/mos:analyze-needs",
89 "/mos:discover"
90 ]
91 },
92 "Jobs-to-be-Done Theory": {
93 "framework": "Jobs to Be Done (JTBD)",
94 "commands": [
95 "/mos:analyze-needs",
96 "/mos:discover"
97 ]
98 },
99 "Emotional Job": {
100 "framework": "Jobs to Be Done (JTBD)",
101 "commands": [
102 "/mos:analyze-needs",
103 "/mos:discover"
104 ]
105 },
106 "Functional Job": {
107 "framework": "Jobs to Be Done (JTBD)",
108 "commands": [
109 "/mos:analyze-needs",
110 "/mos:discover"
111 ]
112 },
113 "Extensive Searching": {
114 "framework": "Domain Selection",
115 "commands": [
116 "/mos:explore-domains"
117 ]
118 },
119 "Intensive Searching": {
120 "framework": "Domain Selection",
121 "commands": [
122 "/mos:explore-domains"
123 ]
124 },
125 "Weak Signals": {
126 "framework": "PEST Analysis",
127 "commands": [
128 "/mos:macro-trends"
129 ]
130 },
131 "Signals of Change": {
132 "framework": "PEST Analysis",
133 "commands": [
134 "/mos:macro-trends"
135 ]
136 },
137 "Root Cause Analysis": {
138 "framework": "Root Cause Analysis",
139 "commands": [
140 "/mos:causal",
141 "/mos:root-cause"
142 ]
143 },
144 "Systems Analysis": {
145 "framework": "Systems Thinking",
146 "commands": [
147 "/mos:analyze-systems",
148 "/mos:systems-thinking"
149 ]
150 },
151 "Orthodoxy": {
152 "framework": "Red Teaming",
153 "commands": [
154 "/mos:challenge-assumptions"
155 ]
156 },
157 "Reverse Salient": {
158 "framework": "Reverse Salient Analysis",
159 "commands": [
160 "/mos:find-bottlenecks",
161 "/mos:rs-experts"
162 ]
163 },
164 "Macro Trends": {
165 "framework": "PEST Analysis",
166 "commands": [
167 "/mos:macro-trends"
168 ]
169 },
170 "Future-Back": {
171 "framework": "Scenario Planning",
172 "commands": [
173 "/mos:explore-futures",
174 "/mos:scenario-plan"
175 ]
176 },
177 "Futures Wheel": {
178 "framework": "Futures Wheel",
179 "commands": [
180 "/mos:futures"
181 ]
182 },
183 "Logic Tree": {
184 "framework": "MECE (Mutually Exclusive, Collectively Exhaustive)",
185 "commands": [
186 "/mos:structure-argument"
187 ]
188 },
189 "Innovation Formula": {
190 "framework": "Usher's Model of Cumulative Synthesis",
191 "commands": [
192 "/mos:find-connections"
193 ]
194 },
195 "Medici Effect": {
196 "framework": "Four Lenses of Innovation",
197 "commands": [
198 "/mos:find-analogies"
199 ]
200 },
201 "Leverage Point": {
202 "framework": "Systems Thinking",
203 "commands": [
204 "/mos:analyze-systems",
205 "/mos:systems-thinking"
206 ]
207 },
208 "Opportunity Sweet Spot": {
209 "framework": "Jobs to Be Done (JTBD)",
210 "commands": [
211 "/mos:analyze-needs",
212 "/mos:discover"
213 ]
214 },
215 "Cross-Scenario Pattern": {
216 "framework": "Scenario Planning",
217 "commands": [
218 "/mos:explore-futures",
219 "/mos:scenario-plan"
220 ]
221 },
222 "Dominant Design": {
223 "framework": "Dominant Design",
224 "commands": [
225 "/mos:dominant-designs"
226 ]
227 },
228 "Importance-Satisfaction": {
229 "framework": "Jobs to Be Done (JTBD)",
230 "commands": [
231 "/mos:analyze-needs",
232 "/mos:discover"
233 ]
234 },
235 "Non-consumers": {
236 "framework": "Jobs to Be Done (JTBD)",
237 "commands": [
238 "/mos:analyze-needs",
239 "/mos:discover"
240 ]
241 },
242 "Knightian uncertainty": {
243 "framework": "Knowns and Unknowns Matrix Framework",
244 "commands": [
245 "/mos:map-unknowns"
246 ]
247 },
248 "Era of Ferment": {
249 "framework": "Dominant Design",
250 "commands": [
251 "/mos:dominant-designs"
252 ]
253 },
254 "Solution-Jumping Trap": {
255 "framework": "Problem Definition Transformation Framework",
256 "commands": [
257 "/mos:diagnose"
258 ]
259 },
260 "TAM Trap": {
261 "framework": "Lean Canvas",
262 "commands": [
263 "/mos:lean-canvas"
264 ]
265 },
266 "Scenario Planning": {
267 "framework": "Scenario Planning",
268 "commands": [
269 "/mos:explore-futures",
270 "/mos:scenario-plan"
271 ]
272 },
273 "Presentism": {
274 "framework": "Scenario Planning",
275 "commands": [
276 "/mos:explore-futures",
277 "/mos:scenario-plan"
278 ]
279 },
280 "Era of Incremental Change": {
281 "framework": "S-Curve Analysis",
282 "commands": [
283 "/mos:analyze-timing",
284 "/mos:explore-trends"
285 ]
286 },
287 "Red Teaming": {
288 "framework": "Red Teaming",
289 "commands": [
290 "/mos:challenge-assumptions"
291 ]
292 },
293 "Unintended Consequences": {
294 "framework": "Systems Thinking",
295 "commands": [
296 "/mos:analyze-systems",
297 "/mos:systems-thinking"
298 ]
299 },
300 "Overshoot": {
301 "framework": "S-Curve Analysis",
302 "commands": [
303 "/mos:analyze-timing",
304 "/mos:explore-trends"
305 ]
306 },
307 "Overshooting": {
308 "framework": "S-Curve Analysis",
309 "commands": [
310 "/mos:analyze-timing",
311 "/mos:explore-trends"
312 ]
313 },
314 "Terms of Competition": {
315 "framework": "Dominant Design",
316 "commands": [
317 "/mos:dominant-designs"
318 ]
319 },
320 "Destruction Dynamics": {
321 "framework": "S-Curve Analysis",
322 "commands": [
323 "/mos:analyze-timing",
324 "/mos:explore-trends"
325 ]
326 },
327 "Commoditization": {
328 "framework": "Dominant Design",
329 "commands": [
330 "/mos:dominant-designs"
331 ]
332 },
333 "Robust Strategy": {
334 "framework": "Scenario Planning",
335 "commands": [
336 "/mos:explore-futures",
337 "/mos:scenario-plan"
338 ]
339 },
340 "Strategic Options": {
341 "framework": "Scenario Planning",
342 "commands": [
343 "/mos:explore-futures",
344 "/mos:scenario-plan"
345 ]
346 },
347 "Strong Value Proposition": {
348 "framework": "PWS Value Proposition",
349 "commands": [
350 "/mos:build-thesis",
351 "/mos:validate-proposition"
352 ]
353 },
354 "Business Case": {
355 "framework": "Lean Canvas",
356 "commands": [
357 "/mos:lean-canvas"
358 ]
359 },
360 "Variation-Selection-Retention": {
361 "framework": "Dominant Design",
362 "commands": [
363 "/mos:dominant-designs"
364 ]
365 },
366 "Value Proposition": {
367 "framework": "PWS Value Proposition",
368 "commands": [
369 "/mos:build-thesis",
370 "/mos:validate-proposition"
371 ]
372 },
373 "Pains & Gains": {
374 "framework": "PWS Value Proposition",
375 "commands": [
376 "/mos:build-thesis",
377 "/mos:validate-proposition"
378 ]
379 }
380}
381
382export const STATE_COMMANDS: Record<FaceId, string[]> = {
383 "rest": [
384 "/mos:status",
385 "/mos:suggest-next"
386 ],
387 "listen": [
388 "/mos:diagnose"
389 ],
390 "search": [
391 "/mos:research"
392 ],
393 "found": [
394 "/mos:find-connections"
395 ],
396 "challenge": [
397 "/mos:challenge-assumptions"
398 ],
399 "contradiction": [
400 "/mos:challenge-assumptions"
401 ],
402 "reframe": [
403 "/mos:beautiful-question"
404 ],
405 "limit": [
406 "/mos:map-unknowns"
407 ],
408 "decision": [
409 "/mos:status"
410 ],
411 "boundary": [
412 "/mos:doctor"
413 ],
414 "handover": [
415 "/mos:suggest-next",
416 "/mos:status"
417 ]
418}
419
420export const DOCTOR = "/mos:doctor"
421
422export const WHY: Record<string, string> = {
423 "/mos:diagnose": "When you cannot tell if this is an Ill-Defined or Well-Defined Problem, /mos:diagnose classifies it against the PWS matrix",
424 "/mos:explore-domains": "When the problem could live in any of several domains, /mos:explore-domains maps the territory through IKA and Feynman decomposition",
425 "/mos:compare-ventures": "When you are weighing your approach against ventures that tried this before, /mos:compare-ventures lines them up on the dimensions that matter",
426 "/mos:deep-grade": "When you need a calibrated grade on the room's quality, /mos:deep-grade scores it against 100+ real student projects",
427 "/mos:analyze-systems": "When you need to find where leverage lives in a complex system, /mos:analyze-systems decomposes it into layers and surfaces the leverage points",
428 "/mos:systems-thinking": "When the dynamics matter more than the parts, /mos:systems-thinking maps feedback loops, stocks, and flows",
429 "/mos:beautiful-question": "When the framing feels stuck, /mos:beautiful-question reshapes the challenge as Why / What-if / How",
430 "/mos:analyze-needs": "When you need to know which customer jobs matter most, /mos:analyze-needs scores them by importance versus satisfaction",
431 "/mos:discover": "When you have a client or a product and need to understand it before you design or build, /mos:discover runs a guided one-question-at-a-time conversation across six movements and lands a Discovery Brief plus a scaffolded Data Room",
432 "/mos:macro-trends": "When you need to map the forces shaping a domain, /mos:macro-trends runs PEST across Political, Economic, Social, and Technological dimensions",
433 "/mos:causal": "When you suspect the visible problem is downstream of something else, /mos:causal traces causal edges in the room's graph",
434 "/mos:root-cause": "When the symptom keeps coming back, /mos:root-cause traces it via 5-Whys, Fishbone, and Fault Tree",
435 "/mos:challenge-assumptions": "When an idea feels too clean, /mos:challenge-assumptions runs Devil's Advocate against the load-bearing claims",
436 "/mos:find-bottlenecks": "When progress feels stuck and you cannot say where, /mos:find-bottlenecks runs Reverse Salient analysis to name the lagging component",
437 "/mos:rs-experts": "When you need to know who in the world is working on a reverse salient you found, /mos:rs-experts resolves the expert network from your local room mirror, populated by /mos:rs-fetch",
438 "/mos:explore-futures": "When the path forward branches into multiple plausible futures, /mos:explore-futures synthesizes TTA, Scenario, and S-Curve views",
439 "/mos:scenario-plan": "When the future could go two different ways on two key uncertainties, /mos:scenario-plan builds the 2x2 matrix and names each quadrant",
440 "/mos:futures": "When you need to see the invisible cross-domain ripples a linear mind misses, /mos:futures turns a seed concept into a bounded multi-ring consequence wheel, scans for hidden bridges with HSI, and banks the opportunities",
441 "/mos:structure-argument": "When an argument is muddled and you cannot say why, /mos:structure-argument restructures it with Minto pyramid, SCQA, and MECE",
442 "/mos:find-connections": "When you suspect your work touches a pattern in another field, /mos:find-connections traces cross-domain links through the graph",
443 "/mos:find-analogies": "When the answer might live in another field, /mos:find-analogies runs SAPPhIRE and TRIZ against your problem to surface cross-domain matches",
444 "/mos:dominant-designs": "When you are wondering if the market has settled on a winning design, /mos:dominant-designs runs Utterback-Abernathy to spot it",
445 "/mos:map-unknowns": "When you cannot tell what you do not know, /mos:map-unknowns plots known, unknown, and unknowable in a Rumsfeld matrix",
446 "/mos:lean-canvas": "When you need the whole business model on one page, /mos:lean-canvas fills the nine boxes from your room data",
447 "/mos:analyze-timing": "When you need to know if your technology is too early, too late, or right on time, /mos:analyze-timing places it on the S-Curve",
448 "/mos:explore-trends": "When today's trends could become tomorrow's problems, /mos:explore-trends pushes them to extremes to surface the second-order effects",
449 "/mos:build-thesis": "When you need to know if this venture is worth pursuing, /mos:build-thesis runs the Ten-Questions investment thesis gate",
450 "/mos:validate-proposition": "When you have a value proposition but no proof it holds, /mos:validate-proposition scores it against the three PWS VP gates with sequential math",
451 "/mos:status": "When you need a fast read on the room's current state, /mos:status shows the governing thought per section plus health glyphs",
452 "/mos:suggest-next": "When you finish a step and want Theo to recommend the next move, /mos:suggest-next reads the room graph and proposes 3-5 options with reasons",
453 "/mos:doctor": "When something feels off with the install, /mos:doctor diagnoses install-cache drift, ROOM.md gaps, statusline visibility, and UI compliance",
454 "/mos:act": "When you have analyses on the table but no clear next step, /mos:act picks the best-fit methodology for your current room state and runs it",
455 "/mos:admin": "When you need to inspect or rotate your Theo access keys, /mos:admin opens the admin panel",
456 "/mos:agentshield": "When you want to know whether the plugin's own tools, hooks, skills, CLAUDE.md, and dependencies carry a known attack pattern, /mos:agentshield scans all five and reports what is clean and what is flagged",
457 "/mos:auto-explore": "The room normally starts this domain decomposition on its own the moment a new artifact lands. /mos:auto-explore is the fallback: use it by hand only when the ambient run could not start on this surface",
458 "/mos:bono": "When you have a wicked question and need a structured argument rather than one opinion, /mos:bono spawns a governed research swarm across the (subdomain x hat) grid, each persona bound to its hat's scrutiny discipline and its own wired sources, then debates the readings as a sequential inter-hat argument over a graph-proposed what-if hypothesis",
459 "/mos:brain-derive": "When a room section drifts from its Theo context file, /mos:brain-derive rebuilds that file now",
460 "/mos:build-knowledge": "When you have data but not yet wisdom, /mos:build-knowledge climbs Ackoff's DIKW pyramid across the room",
461 "/mos:correct-reference-now": "When you see today's real date diverge from what the room thinks now is, /mos:correct-reference-now writes the true calendar date into the reference seam so every delta Theo speaks (you raised this 3 days ago) stays honest",
462 "/mos:dashboard": "When you need to see the whole room at a glance, /mos:dashboard opens the live or snapshot view in De Stijl layout",
463 "/mos:deck": "When your room is full but the story is not yet a deck, /mos:deck asks one thing: which shape fits (Feynman to make it clear, HEART to make it land, or mesh to do both), then walks the structure section by section, filling each from your own room and binding the MindrianOS look by default",
464 "/mos:diagnostics": "When you need a quantitative read on the room, /mos:diagnostics runs the Wave-1 fingerprint: disruption, blindspot, novelty, surprise",
465 "/mos:dial-memory-refresh": "When a section's Dial Memory feels stale, /mos:dial-memory-refresh rebuilds the auto-section from the SELECTED_REACH / PIVOTED / DRSCH relationship layer",
466 "/mos:diffusion": "When you are working on a dual-use or deep-tech innovation and need to know if it will actually spread - and to whom first - /mos:diffusion runs the Adoption-Capacity Engine over it",
467 "/mos:dogfood-flush": "When you want the plugin's own venture room to reflect your latest edits immediately, /mos:dogfood-flush drains the PostToolUse queue and regenerates the Live (auto) section in STATE.md",
468 "/mos:eureka": "When you want to see where your room's ideas cross-pollinate into fundable opportunities, /mos:eureka recalls cross-domain pairs from your room's graph, researches them as a perspective of the research planner, and files the supported ones only on your yes",
469 "/mos:explain-decision": "When Theo made a recommendation and you want to know why, /mos:explain-decision shows the Navigation Engine trace for the last turn",
470 "/mos:explore-opportunity": "A qualified opportunity is still conceptual: a connection without a defined problem. /mos:explore-opportunity runs the analysis chain (deep research, diffusion timing, analogies, web validation) and files a Minto-shaped explored artifact - a governing thought backed by cited sources and typed graph evidence",
471 "/mos:export": "When you need to share a Data Room view with someone outside the room, /mos:export packages it as a De Stijl HTML artifact",
472 "/mos:feynman-timeline-refresh": "When a section's FEYNMAN.md timeline feels stale, /mos:feynman-timeline-refresh rebuilds the auto-section from the memory event log",
473 "/mos:file-meeting": "When a meeting just happened, /mos:file-meeting captures the transcript and routes the intelligence into the right room sections",
474 "/mos:funding": "When you need to track grants and investors as a real pipeline, /mos:funding manages the lifecycle from spotted to applied to decided",
475 "/mos:grade-grant": "Grant reviewers score against a fixed rubric whether you see it or not. /mos:grade-grant runs that rubric on your draft BEFORE you submit, so the gaps a human reviewer would flag show up here first",
476 "/mos:grade": "When you want a fast read on problem-discovery quality, /mos:grade scores the room on six components in under a minute",
477 "/mos:graph": "When you want to ask the knowledge graph a question without writing Cypher, /mos:graph translates plain English into the right traversal",
478 "/mos:hat-briefing": "When you have run several Six Hats sessions and need them consolidated, /mos:hat-briefing pulls the hat memory into one briefing",
479 "/mos:heal": "Deprecated alias",
480 "/mos:help": "When the surface feels overwhelming, /mos:help is a selector: 11 command families across 3 cards, each family's commands are the options, and the one you pick runs",
481 "/mos:hmi-status": "Deprecated alias",
482 "/mos:ignite": "Theo walks you through three birth gates (starting point, blueprint approve, first win) so every new room begins with a clear JTBD, the right section set, and one bankable opportunity",
483 "/mos:ingest-methodology": "Maintainer-only",
484 "/mos:intel-pipeline": "When you have a room and a question but no time to run the research by hand, /mos:intel-pipeline reads your active JTBD, breaks it into research dimensions, fans out a small set of evidence passes, scores them, and closes the loop into your graph",
485 "/mos:jtbd": "When you need to see or set the active job-to-be-done for this room, /mos:jtbd shows the per-room signal that tells Theo what kind of work you are doing right now",
486 "/mos:leadership": "When the team feels wrong-shaped for the stage, /mos:leadership diagnoses which leadership pattern you actually need",
487 "/mos:memory-cortex-reach": "When a governing thought you set has gone stale, or a fresh contradiction has just landed against a claim, your memory cortex has something to tell you. /mos:memory-cortex-reach is the navigator-facing surface the orchestrator dispatches to bring that signal to a Decision Gate",
488 "/mos:memory": "When you need to inspect what the room remembers, /mos:memory shows the three layers: within-session, across-session, and cross-room",
489 "/mos:models": "When you want to see or switch which model routes which kind of work, /mos:models opens the routing profile",
490 "/mos:mos-reason": "When a section needs its MINTO face generated against its artifacts, /mos:mos-reason produces the pyramid: governing thought down to grounded support",
491 "/mos:mos": "When you do not know which /mos:* surface to invoke, /mos:mos picks the right one",
492 "/mos:mullins": "When you need to screen an opportunity against seven domains, /mos:mullins runs the 7-Domains Model from Mullins' New Business Road Test",
493 "/mos:mva-brief": "When you have just typed a venture sentence and want a brief in under a minute, /mos:mva-brief runs the 6-agent fan-out and deploys a shareable deck",
494 "/mos:mva-option": "When the 3-option footer shows after an MVA brief, /mos:mva-option routes your pick (refine / build a room / iterate) into the right next move",
495 "/mos:mva-report": "When you have finished a conversation with Theo and want to see whether it fired the right things at the right time (did it reach a conclusion or circle, did it elevate across frames, did the gates fire), run /mos:mva-report",
496 "/mos:new-project": "When you are starting a new venture, /mos:new-project creates the room scaffolding and registers it in the room registry",
497 "/mos:new-surface": "When you are adding a new command, agent, or skill to the harness, /mos:new-surface emits the surface .md with the correct 11-key connector frontmatter so its wiring is declared, never hand-written, then regenerates the connector-registry (its real home) and the harness-manifest wiring digest",
498 "/mos:onboard": "When you just installed MindrianOS, /mos:onboard walks you through the system and builds your first room",
499 "/mos:operator": "When you want to see or set how Theo should render right now, /mos:operator shows the conversation state machine: JUST_TALK, EXPLORE_CAPTURE, BUILD_ROOM, METHODOLOGY, or DECISION_GATE",
500 "/mos:opportunities": "When you need to manage grants as a live pipeline rather than a list, /mos:opportunities surfaces the Opportunity Bank with HSI scores and decision states",
501 "/mos:organize": "Deprecated alias",
502 "/mos:persona": "When you need fresh perspectives on the room, /mos:persona generates Six-Hats lenses from your room data",
503 "/mos:pipeline": "When you want several methodologies chained instead of run one-by-one, /mos:pipeline executes a multi-step pipeline with the room as the connecting tissue",
504 "/mos:present": "When you need to walk someone through the room visually, /mos:present generates the 6-view presentation and opens it",
505 "/mos:publish": "When the Data Room is ready to share publicly, /mos:publish deploys the presentation to Vercel with a one-click link",
506 "/mos:qualify-opportunity": "When the harvest sensor surfaces a candidate, /mos:qualify-opportunity puts the decision where it belongs: with you",
507 "/mos:radar": "When new Claude capabilities ship that might change what MindrianOS can do, /mos:radar tracks them so you do not have to",
508 "/mos:reanalyze": "When a meeting was filed earlier and the room has since grown, /mos:reanalyze re-runs the cascade pattern detection",
509 "/mos:research": "When you need fresh evidence from the web cross-referenced with Theo's methodology graph, /mos:research runs the dual-source pull",
510 "/mos:room": "When you need to view the active Data Room, /mos:room opens it. /mos:room question shows the room's governing question with its origin and history, and asks what the old question got wrong before a changed question is recorded",
511 "/mos:rooms": "When you have multiple venture rooms and need to switch, list, or archive them, /mos:rooms manages the registry",
512 "/mos:rs-explain": "When you have a question about a Reverse Salient discovery, /mos:rs-explain takes natural language in and returns a Theo-voiced explanation grounded in the graph",
513 "/mos:rs-fetch": "When you need the full Reverse Salient pipeline run on a topic, /mos:rs-fetch executes the discovery end-to-end: corpus, math, cross-domain match, thesis",
514 "/mos:rs-thesis": "When you ran a Reverse Salient discovery earlier and want the thesis read back, /mos:rs-thesis surfaces the analytic conclusion in plain language",
515 "/mos:scheduled-tasks": "When you want Cowork to run something on a schedule against this room, /mos:scheduled-tasks defines the recurring job",
516 "/mos:scientific-roadmap": "When you know what must be delivered but not how, /mos:scientific-roadmap walks Theo's Scientific Roadmapping steps to find the limits that are only assumed, ranks them by what they unlock, and hands the top ones to /mos:research as falsifiable hypotheses",
517 "/mos:score-innovation": "When you are choosing between cross-domain innovation candidates, /mos:score-innovation runs HSI scoring to rank them by semantic surprise",
518 "/mos:scout": "When you want background scans running across the room without driving them yourself, /mos:scout fires the sentinel checks",
519 "/mos:setup": "When you want to wire optional integrations like Velma or a meeting source, /mos:setup walks you through configuration",
520 "/mos:show": "When the room is full but you don't know which view answers your question, /mos:show asks your job in plain language (know where I stand, find what's broken, make it land, get it into the world) and runs the right view underneath",
521 "/mos:skill": "When you have a CONFIRMED SyntheticExpert in the graph and you want to talk to it as a persona anywhere in the room, /mos:skill projects that node's props (hat, name, beautiful question, research approach, evidence tier, provenance) into a room-scoped skills/<expert>/SKILL.md through the SHIPPED build-new-surface.cjs generator (kind=skill, --from-expert)",
522 "/mos:snapshot": "When you need a frozen Data Room artifact to share with someone outside the team, /mos:snapshot packages everything into a portable bundle",
523 "/mos:speakers": "When you have a meeting filed and want to know who said what, /mos:speakers shows the participants with their roles, attendance, and contribution patterns",
524 "/mos:splash": "When you want the MindrianOS Mondrian banner, /mos:splash displays it",
525 "/mos:stance": "When you want Theo to shift how he talks to you this session (pull evidence, deliver decisively, stay Socratic, or challenge you), /mos:stance flips the dial in one confirm",
526 "/mos:think-hats": "When the team keeps wearing the same hat and missing perspectives, /mos:think-hats rotates them through de Bono's six",
527 "/mos:trending-to-absurd": "When a trend feels safe, you have not pushed it far enough. /mos:trending-to-absurd seeds itself from your room's connective taxonomy, then extrapolates each trend to its absurd extreme across the 3-10 / 11-30 / 50yr horizons. The disruptive opportunity shows up at the edge first",
528 "/mos:update": "When you suspect MindrianOS has a newer version waiting, /mos:update checks and installs via Claude Code's native plugin loader",
529 "/mos:user-needs": "When you need to map what users actually want versus what they say they want, /mos:user-needs plots importance against satisfaction",
530 "/mos:validate": "When an idea needs a real test before more investment, /mos:validate runs importance-satisfaction scoring against the customer segment",
531 "/mos:vault": "When you want the Data Room available in Obsidian for offline reading, /mos:vault exports it as a nested vault with wikilinks intact",
532 "/mos:visualize": "Deprecated alias",
533 "/mos:whitespace": "When you suspect a gap exists in the room's coverage of a domain, /mos:whitespace runs HSI scoring across the artifact corpus to find under-explored zones",
534 "/mos:wiki": "When you want to read the Data Room as linked wiki pages, /mos:wiki opens the wiki view"
535}
536hooks/gauge.ts 48 lines1import { contextBand } from './status'
2
3// The context gauge line (Phase 380 plan 17, G5, D-04 D-08 D-11).
4//
5// While no reply has used any of the window, the eight boxes of the canvas look like empty boxes.
6// The words next to them say what they are. The boxes are not redrawn or dimmed: their pixels are
7// pinned (tests/meter-pin.test.ts), and a dimmed fill would need a color the book does not have.
8//
9// Pure and erasable: no enum, no explicit any, no I/O.
10
11export type GaugeLine =
12 | { kind: 'idle'; drawsBoxes: boolean; text: string }
13 | { kind: 'reading'; band: 'ok' | 'watch' | 'cliff'; text: string }
14
15// Fails to compile when a case of a switch is not handled.
16export function assertNever(value: never): never {
17 throw new Error(`unhandled gauge case: ${String(value)}`)
18}
19
20const IDLE_WITH_BOXES = 'Context gauge: waiting for the first reply'
21const IDLE_WITHOUT_BOXES = 'Context: waiting for the first reply'
22
23// A figure the host really gave: a finite number. Zero is a reading, not a wait.
24function isReading(percent: number | undefined): percent is number {
25 return typeof percent === 'number' && !Number.isNaN(percent)
26}
27
28// The line of the context gauge. percent is the figure the host gives, or undefined when no reply
29// has used any of the window yet. drawsBoxes is true when the canvas is drawn above the line. label is
30// the field label of the status map (Context, or its short form); a reading starts with it.
31export function gaugeText(percent: number | undefined, drawsBoxes: boolean, label: string): GaugeLine {
32 if (!isReading(percent)) {
33 return { kind: 'idle', drawsBoxes, text: drawsBoxes ? IDLE_WITH_BOXES : IDLE_WITHOUT_BOXES }
34 }
35 const band = contextBand(percent)
36 switch (band.band) {
37 case 'ok':
38 case 'watch':
39 case 'cliff':
40 return { kind: 'reading', band: band.band, text: `${label} ${Math.round(percent)}% - ${band.words}` }
41 case 'none':
42 // contextBand says none only for no figure, which isReading has already ruled out.
43 return { kind: 'idle', drawsBoxes, text: drawsBoxes ? IDLE_WITH_BOXES : IDLE_WITHOUT_BOXES }
44 default:
45 return assertNever(band.band)
46 }
47}
48hooks/job.ts 237 lines1import type { JobRecord } from '../types'
2import { JOBS } from './jobs'
3
4// The Job line (Phase 380 plan 14, D-09, MOD380-08): one jobs-to-be-done statement, "when I am in this
5// situation, I want to do this, so that I get this", guessed from LOCAL data only.
6//
7// Everything here is a pure function over text and numbers. It makes no call of any kind: no tool, no
8// network, no process, no prompt box. The panel passes in what it already holds in memory.
9//
10// Sources, in order:
11// 1. The room's recorded job, read from the room's job state file by the panel (one small read for
12// each refresh tick). readJob keeps only the current job. A job the person set (manual) and not
13// yet expired is shown as set by you. An automatic job is a guess, and it is ignored after 24
14// hours. The panel keeps the compact text of compactJob, so nothing else from the file is held.
15// 2. When there is no usable record, a cue match over the person's recent prompts in this session,
16// against the cues of the plugin's own taxonomy (guessJob). The same method as the plugin's
17// classifier: distinct cues found in the person's words. The text of the prompts is matched and
18// never shown or sent.
19// 3. The room state, stage and open section, for the "in this situation" part of the sentence.
20//
21// The form (when, want, so) is the one the book's own intake uses (mos-brand book e947a5d, the consumer
22// intake schema fields jtbd.when, jtbd.want and jtbd.so). The book has no segment for an intent line in
23// a side panel (silent): the placement follows the terminal template (decisions first, then ask about
24// the work), and a segment text is proposed in plan 380-11, item A5.
25//
26// Honesty: a prediction is a guess and says so every time. A job the person set is not a guess. The
27// words never claim the guess is the person's job, and never call it validated, confirmed or proven.
28
29type JobLike = (typeof JOBS)[number]
30
31export type JobRow =
32 | { kind: 'heading'; title: string; text: string }
33 | { kind: 'text'; text: string }
34 | { kind: 'dim'; text: string }
35 | { kind: 'button'; text: string; action: 'dismiss' | 'fix' }
36
37export type JobInput = {
38 record: JobRecord | null
39 guess: string | null
40 stage: string
41 section: string
42 hidden: boolean
43 width: number
44 plain: boolean
45}
46
47const DAY_MS = 24 * 3600000
48// Below this panel width the line is one short row, the want clause only (the width ladder: words only).
49export const JOB_NARROW_BELOW = 40
50// The ids the line can show. The steady state (explore) is not a job to show.
51const SHOWN = new Set(JOBS.filter(job => job.shown).map(job => job.id))
52
53export const GUESS_WORDS = 'a guess, is this it?'
54export const SET_WORDS = 'set by you'
55export const FIX_LABEL = 'Fix: /mos:jtbd set'
56export const DISMISS_LABEL = 'Not this'
57export const FIX_COMMAND = '/mos:jtbd set'
58
59// ---- The recorded job ---------------------------------------------------------------------------
60
61function currentOf(text: string): Record<string, unknown> | null {
62 if (typeof text !== 'string' || text.trim() === '') return null
63 let data: unknown
64 try {
65 data = JSON.parse(text)
66 } catch {
67 return null
68 }
69 if (data === null || typeof data !== 'object') return null
70 const cur = (data as { current?: unknown }).current
71 if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) return null
72
73 return cur as Record<string, unknown>
74}
75
76// The text of the state file reduced to the fields of the current job. This is all the panel keeps.
77export function compactJob(text: string): string {
78 const cur = currentOf(text)
79 if (cur === null) return ''
80
81 return JSON.stringify({
82 current: {
83 jtbd: cur.jtbd,
84 confidence: cur.confidence,
85 entered_at: cur.entered_at,
86 expires_at: cur.expires_at,
87 manual_set: cur.manual_set,
88 },
89 })
90}
91
92// The usable recorded job, or null: malformed text, no current job, an id the plugin does not know,
93// the steady state, an expired job the person set, an automatic job older than 24 hours, or an
94// automatic job with no time stamp (freshness cannot be shown). It accepts the whole file or the
95// compact text.
96export function readJob(text: string, now: number): JobRecord | null {
97 const cur = currentOf(text)
98 if (cur === null) return null
99 const id = cur.jtbd
100 if (typeof id !== 'string' || !SHOWN.has(id)) return null
101 const manual = cur.manual_set === true
102 const entered = typeof cur.entered_at === 'string' ? Date.parse(cur.entered_at) : Number.NaN
103 const confidence = typeof cur.confidence === 'number' ? cur.confidence : 0
104 if (manual) {
105 const expires = typeof cur.expires_at === 'string' ? Date.parse(cur.expires_at) : Number.NaN
106 if (Number.isNaN(expires) || expires <= now) return null
107
108 return { id, manual: true, confidence, enteredAt: Number.isNaN(entered) ? 0 : entered }
109 }
110 if (Number.isNaN(entered) || now - entered > DAY_MS) return null
111
112 return { id, manual: false, confidence, enteredAt: entered }
113}
114
115// ---- The cue match ------------------------------------------------------------------------------
116
117const isWordChar = (ch: string): boolean => /[a-z0-9]/.test(ch)
118
119// True when the cue is in the text as a whole phrase: a cue that starts or ends in a letter or digit
120// is not found inside a longer word ("plan" is not found in "explanation").
121function hasPhrase(text: string, cue: string): boolean {
122 if (cue === '') return false
123 let from = 0
124 for (;;) {
125 const at = text.indexOf(cue, from)
126 if (at < 0) return false
127 const before = at === 0 ? '' : (text[at - 1] ?? '')
128 const after = text[at + cue.length] ?? ''
129 const startsInWord = isWordChar(cue.charAt(0))
130 const endsInWord = isWordChar(cue.charAt(cue.length - 1))
131 const insideBefore = startsInWord && before !== '' && isWordChar(before)
132 const insideAfter = endsInWord && after !== '' && isWordChar(after)
133 if (!insideBefore && !insideAfter) return true
134 from = at + 1
135 }
136}
137
138// The single job that has at least 2 distinct cues in the prompts and a strict lead over every other
139// job, else null. A job flagged never shown is skipped. The prompts are matched, never kept.
140export function guessJob(prompts: readonly string[], jobs: readonly JobLike[]): string | null {
141 const text = prompts.join('\n').toLowerCase()
142 if (text.trim() === '') return null
143 let best = ''
144 let bestCount = 0
145 let second = 0
146 for (const job of jobs) {
147 if (!job.shown) continue
148 let count = 0
149 for (const cue of new Set(job.cues.map(one => one.toLowerCase()))) if (hasPhrase(text, cue)) count += 1
150 if (count > bestCount) {
151 second = bestCount
152 best = job.id
153 bestCount = count
154 } else if (count > second) {
155 second = count
156 }
157 }
158
159 return bestCount >= 2 && bestCount > second ? best : null
160}
161
162// The id the line shows now: the recorded job, else the guess, else nothing.
163export function jobShown(record: JobRecord | null, guess: string | null): string {
164 if (record !== null && SHOWN.has(record.id)) return record.id
165 if (guess !== null && SHOWN.has(guess)) return guess
166
167 return ''
168}
169
170// ---- The sentence -------------------------------------------------------------------------------
171
172// A room name or a stage, made safe for one sentence: no stop mark, no line break, at most 40 characters.
173function clean(text: string): string {
174 const one = text.replace(/[\r\n\t]+/g, ' ').replace(/[.!?]+/g, '').replace(/\//g, ' / ').replace(/\s+/g, ' ').trim()
175
176 return one.length > 40 ? `${one.slice(0, 39)}~` : one
177}
178
179// "When I am in the Design stage and looking at market-analysis, I want to find the problem worth
180// solving (not the symptom, the root), so that I know the real problem in a sentence I can test."
181// One sentence. Without an open section the "and looking at" part is absent. An unknown id gives ''.
182export function jobStatement(input: { id: string; stage: string; section: string }): string {
183 const job = JOBS.find(one => one.id === input.id)
184 if (!job) return ''
185 const stage = clean(input.stage)
186 const section = clean(input.section)
187 const where = stage !== '' && stage.toLowerCase() !== 'unknown' ? `in the ${stage} stage` : 'working in this room'
188 const looking = section !== '' ? ` and looking at ${section}` : ''
189
190 return `When I am ${where}${looking}, I want to ${job.want}, so that ${job.outcome}.`
191}
192
193const ascii = (text: string): string => text.replace(/[^\x20-\x7e]/g, '?')
194
195// ---- The rows of the block ----------------------------------------------------------------------
196
197// The rows of the Job block, in order. Wide: the heading (JOB and the words "a guess, is this it?", or
198// "set by you"), the sentence, and the buttons. Narrow (under 40 columns): one short row, the want clause
199// only with the guess word kept, and the buttons. A guess has Not this and Fix; a job the person set has
200// Fix only. With no usable record and no guess: one dim row that says there is no guess yet. Hidden: one
201// dim row that says how to bring the line back.
202export function jobRows(input: JobInput): JobRow[] {
203 const fit = (text: string): string => (input.plain ? ascii(text) : text)
204 if (input.hidden) return [{ kind: 'dim', text: fit('Job: hidden. /mos-job shows it again.') }]
205
206 const id = jobShown(input.record, input.guess)
207 const job = JOBS.find(one => one.id === id)
208 if (!job) return [{ kind: 'dim', text: fit('Job: no guess yet. Say what you want to decide, test or find, in two different phrases, and one may come.') }]
209
210 const manual = input.record !== null && input.record.id === id && input.record.manual
211 const rows: JobRow[] = []
212 if (input.width < JOB_NARROW_BELOW) {
213 rows.push({ kind: 'text', text: fit(`JOB, ${manual ? SET_WORDS : 'a guess'}: I want to ${job.want}.`) })
214 } else {
215 rows.push({ kind: 'heading', title: 'JOB', text: manual ? SET_WORDS : GUESS_WORDS })
216 rows.push({ kind: 'text', text: fit(jobStatement({ id, stage: input.stage, section: input.section })) })
217 }
218 if (!manual) rows.push({ kind: 'button', text: DISMISS_LABEL, action: 'dismiss' })
219 rows.push({ kind: 'button', text: fit(FIX_LABEL), action: 'fix' })
220
221 return rows
222}
223
224// The same words as one line of plain text, for /mos-text.
225export function jobLine(rows: readonly JobRow[]): string {
226 const first = rows[0]
227 if (first === undefined) return 'JOB: none'
228 if (first.kind === 'dim') return `JOB: ${first.text.replace(/^Job:\s*/, '')}`
229 if (first.kind === 'heading') {
230 const statement = rows.find(row => row.kind === 'text')
231
232 return `JOB (${first.text}): ${statement?.text ?? ''}`
233 }
234
235 return first.kind === 'text' ? first.text : 'JOB: none'
236}
237hooks/jobs.ts 195 lines1// Generated file. Do not edit by hand.
2// Regenerate with: node ui/mos-face/scripts/face-jobs.cjs --write
3// Source: lib/hmi/jtbd-taxonomy.json, the plugin's own list of jobs (id, one-line want, cues). The
4// outcome of each job is the reviewed map of scripts/face-jobs.cjs. The check mode fails on drift.
5// A job with shown false is never drawn on the panel (explore is the steady state, not a job).
6
7export type Job = {
8 id: string
9 want: string
10 cues: readonly string[]
11 outcome: string
12 shown: boolean
13}
14export const JOBS: readonly Job[] = [
15 {
16 "id": "decide-pursue",
17 "want": "decide if a venture / opportunity / approach is worth pursuing",
18 "cues": [
19 "should we pursue",
20 "go / no-go",
21 "is this worth",
22 "kill it or keep it",
23 "pivot",
24 "double down"
25 ],
26 "outcome": "I can say go or no-go and write down my reason",
27 "shown": true
28 },
29 {
30 "id": "find-problem",
31 "want": "find the problem worth solving (not the symptom, the root)",
32 "cues": [
33 "what's the real problem",
34 "why are users",
35 "wrong problem",
36 "five whys",
37 "root cause",
38 "first principles"
39 ],
40 "outcome": "I know the real problem in a sentence I can test",
41 "shown": true
42 },
43 {
44 "id": "understand-market",
45 "want": "understand the market for this idea - size, structure, dynamics",
46 "cues": [
47 "market size",
48 "TAM SAM SOM",
49 "competitors",
50 "industry",
51 "market dynamics",
52 "Porter"
53 ],
54 "outcome": "I can say how big the market is and who else is in it",
55 "shown": true
56 },
57 {
58 "id": "find-bottleneck",
59 "want": "find the lagging component (Hughes 1983 reverse salient) blocking progress",
60 "cues": [
61 "lagging",
62 "stuck",
63 "reverse salient",
64 "blocking",
65 "bottleneck",
66 "what's slow",
67 "what's holding us back"
68 ],
69 "outcome": "I know which part to fix first",
70 "shown": true
71 },
72 {
73 "id": "prepare-pitch",
74 "want": "prepare for an investor / partner / customer meeting",
75 "cues": [
76 "investor",
77 "pitch deck",
78 "demo day",
79 "fundraising",
80 "meeting on Tuesday",
81 "stakeholder review"
82 ],
83 "outcome": "I walk in with the story ready and the hard questions covered",
84 "shown": true
85 },
86 {
87 "id": "validate-idea",
88 "want": "test if an idea / hypothesis / claim holds up under scrutiny",
89 "cues": [
90 "test if",
91 "validate",
92 "does this hold up",
93 "stress-test",
94 "challenge",
95 "Devil's Advocate",
96 "red team"
97 ],
98 "outcome": "I know what holds up, what does not and what I still need to find out",
99 "shown": true
100 },
101 {
102 "id": "compare-options",
103 "want": "compare alternatives across criteria - choose between A/B/C",
104 "cues": [
105 "compare",
106 "A vs B",
107 "tradeoffs",
108 "options",
109 "alternatives",
110 "scenarios",
111 "decision matrix"
112 ],
113 "outcome": "I can choose between the options and say why",
114 "shown": true
115 },
116 {
117 "id": "connect-domains",
118 "want": "find cross-domain analogues - what does this look like in another field",
119 "cues": [
120 "analogous",
121 "similar to",
122 "from another domain",
123 "cross-domain",
124 "biology of",
125 "what's the equivalent in"
126 ],
127 "outcome": "I see what this looks like in another field and what that could teach me",
128 "shown": true
129 },
130 {
131 "id": "surface-contradiction",
132 "want": "notice and resolve internal contradictions in the room",
133 "cues": [
134 "contradicts",
135 "doesn't match",
136 "earlier we said",
137 "inconsistent",
138 "this conflicts with",
139 "challenge our own"
140 ],
141 "outcome": "I know which claims pull apart and which one I keep",
142 "shown": true
143 },
144 {
145 "id": "plan-execution",
146 "want": "plan the next 90 days - milestones, owners, dates",
147 "cues": [
148 "plan",
149 "next 90 days",
150 "milestones",
151 "roadmap",
152 "what's next",
153 "execution plan",
154 "OKRs"
155 ],
156 "outcome": "I know the next 90 days, with owners and dates",
157 "shown": true
158 },
159 {
160 "id": "file-meeting",
161 "want": "capture and route meeting intelligence into the room",
162 "cues": [
163 "transcript",
164 "meeting",
165 "Velma",
166 "spoke with",
167 "spoke to",
168 "call yesterday"
169 ],
170 "outcome": "I can see what the meeting changes in my data room",
171 "shown": true
172 },
173 {
174 "id": "audit-room",
175 "want": "audit Data Room health - gaps, drift, evidence tier, decision quality",
176 "cues": [
177 "is the room healthy",
178 "audit",
179 "drift",
180 "what's missing",
181 "gaps",
182 "deep grade"
183 ],
184 "outcome": "I know where my data room is thin or out of step",
185 "shown": true
186 },
187 {
188 "id": "explore",
189 "want": "no specific job - general thinking, talking, ranging",
190 "cues": [],
191 "outcome": "I notice what is worth following",
192 "shown": false
193 }
194]
195hooks/paths.ts 64 lines1import type { EngineInterface } from 'claude-code'
2
3// Where the person's home and rooms folders are, found the way the plugin finds them.
4//
5// The plugin reads HOME, and on a native Windows process, USERPROFILE. A test, a hermetic
6// screenshot or a person with rooms elsewhere sets MINDRIAN_ROOMS_HOME, and then the rooms folder is
7// that folder (lib/core/cross-room-aggregator.cjs and lib/core/rooms-home-env.cjs hold the same
8// rule). One rule here keeps the panel reading the rooms the plugin reads.
9//
10// Every path is returned with forward slashes and no trailing slash: the engine's file calls take
11// them on every system, and a backslash path is dropped by a shell (SEED-142).
12
13type Engine = EngineInterface
14
15const DEFAULT_ROOMS = '~/MindrianRooms'
16
17const clean = (path: string): string => path.trim().replace(/\\/g, '/').replace(/\/+$/, '')
18
19// The engine wants a literal name in every $.env.get call, so the variables a module reads can be
20// listed (claude plugin validate prints them, and tests/test-380-budget.cjs pins the list).
21
22// HOME first, then USERPROFILE; an empty variable counts as not set. Empty when neither is set.
23export async function homeDir($: Engine): Promise<string> {
24 const home = clean((await $.env.get('HOME').catch(() => undefined)) ?? '')
25 if (home !== '') return home
26
27 return clean((await $.env.get('USERPROFILE').catch(() => undefined)) ?? '')
28}
29
30// MINDRIAN_ROOMS_HOME when it is set and not empty, else the home folder plus /MindrianRooms.
31// Empty when no variable names a folder at all.
32export async function roomsHome($: Engine): Promise<string> {
33 const set = clean((await $.env.get('MINDRIAN_ROOMS_HOME').catch(() => undefined)) ?? '')
34 if (set !== '') return set
35 const home = await homeDir($)
36
37 return home === '' ? '' : `${home}/MindrianRooms`
38}
39
40// The plugin's own state folder: the Theo warm-up record, the install record, the room health file.
41export async function mindrianDir($: Engine): Promise<string> {
42 return `${await homeDir($)}/.mindrian`
43}
44
45// The folder that holds the rooms, from the registry's root field. A missing field and the default
46// written with a tilde both mean the rooms folder of this machine, so they follow MINDRIAN_ROOMS_HOME.
47// Any other leading tilde is the home folder.
48export async function registryRoot($: Engine, raw: unknown): Promise<string> {
49 const text = typeof raw === 'string' ? clean(raw) : ''
50 if (text === '' || text === DEFAULT_ROOMS) return roomsHome($)
51
52 return text.replace(/^~/, await homeDir($))
53}
54
55// The folder name of a room when the terminal sits inside the rooms folder, else an empty string.
56export async function workspaceOf($: Engine, cwd: string): Promise<string> {
57 const base = await roomsHome($)
58 if (base === '') return ''
59 const rooms = `${base}/`
60 const here = `${clean(cwd)}/`
61
62 return here.startsWith(rooms) ? (here.slice(rooms.length).split('/')[0] ?? '') : ''
63}
64hooks/proposals.ts 33 lines1// Two PROPOSALS of plan 380-04, kept behind one boolean flag each.
2//
3// Both go beyond decision D-03 as written (the owner ruling of 2026-10-21: only brain_ask
4// carries the cleaned 240-character excerpt of the person's own prompts, on by default, off
5// with /mos-words, shown in full). A planner proposed them. They default to on. The
6// navigator can veto either one in plan 11, section B.
7//
8// shimOnlyExcerpt (P1) The excerpt travels only through the guarded shim tool
9// (mcp__plugin_mos_mindrian-brain__brain_ask). The fallback to the
10// raw mcp__theo__brain_ask sends a framework name and a problem
11// type only, because the raw server never passes
12// lib/core/part8-egress-guard.cjs (CLAUDE.md, THEO-04).
13// Off: the fallback may carry the excerpt, as the ruling reads.
14//
15// envSwitch (P2) The environment variable MOS_FACE_WORDS=off (also 0 and false, any
16// case) at session start turns the excerpt off for good, because the
17// /mos-words setting is proven not to survive a new session.
18// Off: the variable is ignored and no text names it.
19//
20// How to veto: set the flag to false, run node scripts/build-mos-face.cjs (the build script
21// that plan 380-05 adds), run claude plugin test in ui/mos-face, commit. The tests in tests/egress.test.ts read these
22// flags and hold for both values of each, so a veto needs no re-plan and no test edit.
23
24export type Proposals = {
25 shimOnlyExcerpt: boolean
26 envSwitch: boolean
27}
28
29export const PROPOSALS: Proposals = {
30 shimOnlyExcerpt: true,
31 envSwitch: true,
32}
33hooks/sent.ts 173 lines1// The sent line (Phase 380 plan 16, D-03 and D-04). Pure and erasable: no engine import.
2//
3// One ask has one honest sentence about what happened to the person's words. The sentence is
4// one of four delivery states (sent, may have been received, not asked, handles only) plus
5// the states before an ask (asking, idle, words off). The left-out words name the KIND of
6// thing that was removed from the words before they were sent, and never the removed text.
7
8import type { Delivery, RemovedKind } from '../types'
9
10export type { Delivery, RemovedKind }
11
12export const REMOVED_KINDS = ['link', 'email', 'path', 'code', 'key', 'long-name', 'name', 'cut'] as const satisfies readonly RemovedKind[]
13
14// The plain words for each kind of removal.
15export const LEFT_WORDS = {
16 link: 'a link',
17 email: 'an email address',
18 path: 'a file path',
19 code: 'code',
20 key: 'a key-like string',
21 'long-name': 'a long name',
22 name: 'a name',
23 cut: 'the rest of a long message',
24} as const satisfies Record<RemovedKind, string>
25
26// What one call to a Theo tool did, as the panel can tell it.
27export type CallOutcome = { kind: 'denied' } | { kind: 'unavailable' } | { kind: 'answered' } | { kind: 'failed' }
28
29// What the engine gave back for one call: a value, or a thrown failure with its message.
30export type RawCall = { kind: 'returned'; value: unknown } | { kind: 'threw'; message: string }
31
32// One call in call order: its outcome, and whether it carried the excerpt.
33export type Attempt = { outcome: CallOutcome; carried: boolean }
34
35export type SentRow =
36 | { kind: 'asking' }
37 | { kind: 'handles-off' }
38 | { kind: 'handles-only' }
39 | { kind: 'idle' }
40 | { kind: 'sent'; excerpt: string; left: readonly RemovedKind[] }
41 | { kind: 'unsure'; excerpt: string; left: readonly RemovedKind[] }
42 | { kind: 'not-asked'; why: 'denied' | 'unavailable' }
43
44// The part of an Advice that the sent line reads.
45export type SentSource = { status: 'idle' | 'asking' | 'ok' | 'failed'; sent: string; note: string; delivery: Delivery }
46
47const assertNever = (value: never): never => {
48 throw new Error(`unhandled case: ${JSON.stringify(value)}`)
49}
50
51const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
52
53// The words of a refusal and of a missing tool. A caller never builds these by hand.
54export const NOT_ASKED_WORDS = {
55 denied: 'Theo was not asked: the call was not allowed',
56 unavailable: 'Theo was not asked: this session has no Theo tool to call',
57} as const satisfies Record<'denied' | 'unavailable', string>
58
59export const FAILED_NOTE = 'Theo did not answer'
60
61// "left out: a link, a name": the kinds in the fixed order of REMOVED_KINDS, each word once. An empty
62// list gives an empty string. Only the keys of LEFT_WORDS are used, so the removed text cannot appear.
63export function leftPhrase(kinds: readonly RemovedKind[]): string {
64 const words = REMOVED_KINDS.filter(kind => kinds.includes(kind)).map(kind => LEFT_WORDS[kind])
65
66 return words.length === 0 ? '' : `left out: ${words.join(', ')}`
67}
68
69// A tool that is not in the session: the engine throws this text (measured, 380-16-G4-FACTS.md), so the
70// call never left. The returned form is the same case in another shape (not measured).
71const MISSING_THROWN = /\bno tool named "[^"]*" in this session\b/
72const MISSING_RETURNED = /^no such tool available\b/i
73
74export function outcomeOf(raw: RawCall): CallOutcome {
75 if (raw.kind === 'threw') return MISSING_THROWN.test(raw.message) ? { kind: 'unavailable' } : { kind: 'failed' }
76 const value = raw.value
77 if (isRecord(value) && value.deny !== undefined) return { kind: 'denied' }
78 if (isRecord(value) && value.isError === true) {
79 return MISSING_RETURNED.test(String(value.text ?? '').trim()) ? { kind: 'unavailable' } : { kind: 'failed' }
80 }
81
82 return { kind: 'answered' }
83}
84
85// What happened to the words, from every call in call order. An answered call that carried the words
86// gives sent. Else a failed call that carried them gives unsure (the words may have been received). Else
87// when every call was refused or missing it gives not-asked (a refusal wins over a missing tool). Else
88// none. With no words nothing was carried.
89export function deliveryOf(attempts: readonly Attempt[], excerpt: string, left: readonly RemovedKind[]): Delivery {
90 const carried = (kind: CallOutcome['kind']): boolean => excerpt !== '' && attempts.some(one => one.carried && one.outcome.kind === kind)
91 if (carried('answered')) return { kind: 'sent', excerpt, left }
92 if (carried('failed')) return { kind: 'unsure', excerpt, left }
93 const isBlocked = attempts.length > 0 && attempts.every(one => one.outcome.kind === 'denied' || one.outcome.kind === 'unavailable')
94 if (isBlocked) return { kind: 'not-asked', why: attempts.some(one => one.outcome.kind === 'denied') ? 'denied' : 'unavailable' }
95
96 return { kind: 'none' }
97}
98
99// The excerpt that the Advice keeps in its sent field: set for sent and unsure only.
100export function excerptOf(delivery: Delivery): string {
101 switch (delivery.kind) {
102 case 'sent':
103 case 'unsure':
104 return delivery.excerpt
105 case 'none':
106 case 'not-asked':
107 return ''
108 default:
109 return assertNever(delivery)
110 }
111}
112
113// The one note that describes a whole ask that did not end in an answer.
114export function failureNote(attempts: readonly Attempt[], delivery: Delivery): string {
115 if (attempts.some(one => one.outcome.kind === 'failed')) return FAILED_NOTE
116
117 return delivery.kind === 'not-asked' ? NOT_ASKED_WORDS[delivery.why] : FAILED_NOTE
118}
119
120export function sentRow(advice: SentSource, isWordsOn: boolean): SentRow {
121 if (!isWordsOn) return { kind: 'handles-off' }
122 if (advice.status === 'asking') return { kind: 'asking' }
123 const delivery = advice.delivery
124 switch (delivery.kind) {
125 case 'sent':
126 return { kind: 'sent', excerpt: delivery.excerpt, left: delivery.left }
127 case 'unsure':
128 return { kind: 'unsure', excerpt: delivery.excerpt, left: delivery.left }
129 case 'not-asked':
130 return { kind: 'not-asked', why: delivery.why }
131 case 'none':
132 return advice.status === 'ok' || advice.status === 'failed' ? { kind: 'handles-only' } : { kind: 'idle' }
133 default:
134 return assertNever(delivery)
135 }
136}
137
138const withLeft = (text: string, left: readonly RemovedKind[]): string => {
139 const phrase = leftPhrase(left)
140
141 return phrase === '' ? text : `${text} (${phrase})`
142}
143
144// The one line of text for a row. The asking row has no sent line (the row "asking Theo..." says it).
145export function sentText(row: SentRow): string {
146 switch (row.kind) {
147 case 'asking':
148 return ''
149 case 'handles-off':
150 return 'Theo is sent only a framework name and a problem type'
151 case 'handles-only':
152 return 'Theo was sent only a framework name and a problem type this time'
153 case 'idle':
154 return 'Theo is sent a short excerpt of your own words, links, paths and names removed'
155 case 'sent':
156 return withLeft(`Theo was sent: "${row.excerpt}"`, row.left)
157 case 'unsure':
158 return withLeft(`A call to Theo failed on the way, so Theo may have received: "${row.excerpt}"`, row.left)
159 case 'not-asked':
160 return NOT_ASKED_WORDS[row.why]
161 default:
162 return assertNever(row)
163 }
164}
165
166// The note under a failed ask. When the sent row already says Theo was not asked, the note is empty, so
167// the panel says it once. With the words off the row is the handles text, so the note says it.
168export function noteOf(advice: SentSource, isWordsOn: boolean, fallback: string): string {
169 if (advice.delivery.kind === 'not-asked') return sentRow(advice, isWordsOn).kind === 'not-asked' ? '' : NOT_ASKED_WORDS[advice.delivery.why]
170
171 return advice.note !== '' ? advice.note : fallback
172}
173hooks/face.ts 107 lines1import type { FaceId, Move } from '../types'
2
3// The Theo face: the seven brand colors, the move colors, the labels and the plain-text faces, as pure
4// data. Nothing here draws, and no pose is held here: the drawing of each state comes from the canon
5// asset through hooks/theo-face.ts (Phase 380 plan 13, D-10). The old poses, the crew faces, their
6// rasterizers and the tone HOLLOW were removed in that plan (README, section Visual change).
7
8// ---- Brand colors (De Stijl primaries from the brand book) -----------------
9
10export const PAPER = '#F7F5EE'
11export const INK = '#111111'
12export const WHITE = '#FFFFFF'
13export const BLUE = '#1F3FBF'
14export const RED = '#D6281F'
15export const YELLOW = '#F2C200'
16export const MUTED = '#4B4943'
17
18export const MOVE_BG: Record<Move, string> = {
19 none: MUTED,
20 blue: BLUE,
21 red: RED,
22 yellow: YELLOW,
23 black: INK,
24 white: WHITE,
25}
26export const MOVE_FG: Record<Move, string> = {
27 none: PAPER,
28 blue: PAPER,
29 red: PAPER,
30 yellow: INK,
31 black: PAPER,
32 white: INK,
33}
34export const MOVE_NAME: Record<Move, string> = {
35 none: 'host',
36 blue: 'building',
37 red: 'challenging',
38 yellow: 'contradiction',
39 black: 'gate',
40 white: 'hand-over',
41}
42
43// ---- Labels and plain-text faces --------------------------------------------------------------
44// The label of each state is the visible label of the canon asset (mos-brand book e947a5d, segment
45// identity/theo, asset-fa8504998f.json), as the asset spells it: Searching and Nothing found.
46// tests/face-events.test.ts (W1) and tests/colors.test.tsx (K5) check this table against the asset
47// text. The book's states table spells two of them in older words (Search, Limit); that conflict is
48// recorded in the README for the book follow-ups. What happened stays in the detail line, in words.
49export const LABEL: Record<FaceId, string> = {
50 rest: 'Rest',
51 listen: 'Listening',
52 search: 'Searching',
53 found: 'Found',
54 challenge: 'Challenge',
55 contradiction: 'Contradiction',
56 reframe: 'Reframe',
57 limit: 'Nothing found',
58 decision: 'Your decision',
59 boundary: 'Boundary',
60 handover: 'Handover',
61}
62
63// Plain-text faces for the words-only style, and for the panel when the asset file cannot be read
64// (they also work with color stripped). Seven characters at most, one for each state.
65export const LINE_FACE: Record<FaceId, string> = {
66 rest: '[o|o]',
67 listen: '[O/o]',
68 search: '[o/O]>',
69 found: '[O/O]!',
70 challenge: '[o\\O]',
71 contradiction: '[o<>o]',
72 reframe: '[o~o]',
73 limit: '[. - .]',
74 decision: '[O|O]?',
75 boundary: '[x|x]',
76 handover: '[o|o]*',
77}
78
79export function moveOf(glyph: string): Move {
80 if (glyph === '🟦') return 'blue'
81 if (glyph === '🟥') return 'red'
82 if (glyph === '🟨') return 'yellow'
83 if (glyph === '⬛') return 'black'
84 return 'white'
85}
86
87export type Run = { top: string; bottom: string; w: number }
88
89// Two pixels per terminal row: merge neighbours of the same colors into runs.
90// Works for any rectangle of pixels, not only the square faces.
91export function gridRuns(grid: string[][]): Run[][] {
92 const width = grid[0]?.length ?? 0
93 const rows: Run[][] = []
94 for (let k = 0; k < Math.floor(grid.length / 2); k++) {
95 const runs: Run[] = []
96 for (let c = 0; c < width; c++) {
97 const top = grid[k * 2]?.[c] ?? '#FFFFFF'
98 const bottom = grid[k * 2 + 1]?.[c] ?? '#FFFFFF'
99 const last = runs[runs.length - 1]
100 if (last && last.top === top && last.bottom === bottom) last.w += 1
101 else runs.push({ top, bottom, w: 1 })
102 }
103 rows.push(runs)
104 }
105 return rows
106}
107hooks/face-events.ts 76 lines1import type { FaceId } from '../types'
2
3// Which real event shows which canon face (Phase 380 plan 13, D-10, MOD380-09).
4//
5// The rule: a state is mapped only where a real event exists. The 11 states and their ids are the
6// ones of the canon asset (mos-brand book e947a5d, segment identity/theo, asset-fa8504998f.json).
7// A state that no event reaches is listed in UNMAPPED_STATES and stays unmapped.
8// tests/face-events.test.ts (W2) fails when an event maps to an id that is not in the asset, when a
9// canon id is neither mapped nor listed as unmapped, and when a listed id is also mapped.
10//
11// The reply marker: the persona leads a reply with one colored square (blue building, red
12// challenging, yellow contradiction, black gate, white deliverable). The marker is a real event of
13// the reply text. In the canon colors (foundations/color) yellow stands for contradiction or
14// reframe, and the marker alone does not say which of the two; the persona marker names it
15// contradiction, so the yellow square maps to Contradiction. Reframe has no marker and no other
16// event, so it stays unmapped. Blue and white lead a finished answer, which is a handover.
17
18// The 11 ids, in the order of the asset.
19export const FACE_STATES: readonly FaceId[] = [
20 'rest',
21 'listen',
22 'search',
23 'found',
24 'challenge',
25 'contradiction',
26 'reframe',
27 'limit',
28 'decision',
29 'boundary',
30 'handover',
31]
32
33// Event name to state. The names are this panel's own labels for the events it already hears.
34export const EVENT_STATES: Record<string, FaceId> = {
35 // A turn began and Theo is attending.
36 'turn.start': 'listen',
37 // A call is running.
38 'tool.call': 'search',
39 // A call returned material (this does not say the material is right).
40 'tool.ok': 'found',
41 // A call returned nothing, or failed.
42 'tool.empty': 'limit',
43 'tool.failed': 'limit',
44 // The permission mode refused a call, or the model refused.
45 'tool.denied': 'boundary',
46 'turn.refusal': 'boundary',
47 // A turn finished with an answer (this does not say the project is done).
48 'turn.complete': 'handover',
49 // The turn ended on an error, with nothing to show.
50 'turn.error': 'limit',
51 // The person interrupted the turn, or the session is quiet.
52 'turn.aborted': 'rest',
53 // The lead square of a reply.
54 'lead.red': 'challenge',
55 'lead.yellow': 'contradiction',
56 'lead.black': 'decision',
57}
58
59// The canon states that no real event reaches.
60export const UNMAPPED_STATES: readonly FaceId[] = ['reframe']
61
62// The state of one event, by the name used in EVENT_STATES. The panel sets every state through this
63// function, so the table above and the running panel cannot drift apart. An unknown name is Rest.
64export function stateFor(event: string): FaceId {
65 return EVENT_STATES[event] ?? 'rest'
66}
67
68// The state a finished answer shows, from the glyph that leads it.
69export function stateForLead(glyph: string): FaceId {
70 if (glyph === '\u{1F7E5}') return EVENT_STATES['lead.red'] ?? 'handover'
71 if (glyph === '\u{1F7E8}') return EVENT_STATES['lead.yellow'] ?? 'handover'
72 if (glyph === '⬛') return EVENT_STATES['lead.black'] ?? 'handover'
73
74 return EVENT_STATES['turn.complete'] ?? 'handover'
75}
76