SLOPSHOPPER

mos-face

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.

newpanebandspinnerrowsguard
★ 2v0.1.0NOASSERTIONupdated 2026-10-24jsagir/mindrian-os-plugin/ui/mos-face
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mos-face
│ ┃ /m:os ✕ › fix the failing auth test an╭────────────────────────────────────╮ │ ┃ Theo | MindrianOS [o|o]* Handover │ mos-face │ │ ┃ room none ⏺ Read(src/auth.ts) │ /m:os loaded. /mos-state opens it. │ │ ┃ no sections were read ⎿ Read 6 lines ╰────────────────────────────────────╯ │ ┃ [ Find ] ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ │ ┃ Click a name to ask about it in the prompt ⎿ Added 2 lines, re│ mos-face │ │ ┃ In the prompt box: /mos-tree next, prev, up ⏺ Bash(bun test) │ In the prompt box, press Enter to send: │ │ ┃ [ Refresh ] ⎿ 3 pass, 1 fail │ About the room: │ │ ╰────────────────────────────────────────────╯ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mos-state │ ⎿ mos-face: The /m:os dashboard is open in the side panel. It seat │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts [ /m:os dashboard ] [ Show box ] ⚠ mos-face: /m:os not bound to a room, nothing will be filed /mos:rooms

Draws

Pane · /m:os
Theo | MindrianOS [o|o]* Handover room none no sections were read [ Find ] [ Cl Click a name to ask about it in the prompt box. In the prompt box: /mos-tree next, prev, up, find [ Refresh ] read
Pane · Theo calls
Calls to Theo: 1 - 1 ok ok 40ms Theo (through /m:os): brain_ask (panel)
Prompt hint
? for shortcuts [ /m:os dashboard ]
README

mos-face

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.

Walled package

This folder is a walled package (CLAUDE.md D-17, Phase 369.26 precedent, D-05 of Phase 380).

  • It has its own folder, its own package.json and its own package-lock.json.
  • It is never in the root package.json or the root npm-shrinkwrap.json.
  • Nothing from ui reaches the tarball. Only built output ships, under lib/mos-face/dist, in a later plan.
  • Its one devDependency is typescript, at the same exact version and integrity as tools/ts-check.
  • The engine writes .claude-plugin/types itself. That folder is never committed.

How to run it

Run these commands in this folder.

  • Load it in a session: claude --plugin-dir ui/mos-face (from the repo root).
  • Test it: claude plugin test . (34 original tests and the egress tests).
  • Check the plugin shape: claude plugin validate .
  • Review the shipped copy with the book (read only, in a scratch folder): 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.
  • Draw the contact sheet of the 11 canon faces (the canon renderer's SVG above the panel's block faces, as a PNG): node scripts/face-sheet.cjs --out <file.png>. It exits 77 if the claude CLI or the Playwright CLI is missing.
  • Type-check it: npm run typecheck (the script lands in a later plan).
  • Build it: npm run build (the script lands in a later plan).
  • Install the lockfile: npm ci --ignore-scripts.

How to try the panel from a checkout

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:

  • The whole plugin, built dist: 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).
  • The mod alone: 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).
  • The old trap. When the source folder was named mos (commit bfc7cd798 to the follow-up), the same line replaced the installed plugin without any message. The session then held only the mod: "Unknown command: /mos:rooms", and the tool for Theo asks was gone (run R1b). Any folder given to --plugin-dir whose manifest name is mos does this.
  • How to see it. Start with 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.
  • How to keep a session-only copy from shadowing the installed one. Put "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.
  • The capture harness of plans 03 and 10 loads a throwaway plugin that holds only the dist. The /mos:* commands are absent there by design.

Egress

What leaves the machine (Canon Part 8, decision D-03 of Phase 380, plan 380-04). Three calls and no more.

  1. The ask to Theo through the guarded shim tool. It carries a framework name, a problem type and, by default, a short excerpt of the person's own recent words (at most 240 characters). The panel shows the excerpt in full ("Theo was sent: ...").
  2. The same ask to the raw theo server, only when the shim failed or was denied. With proposal P1 on, it carries a framework name and a problem type only.
  3. A search on the raw theo server. Its query is one framework name or one problem type from a closed list (HANDLE_QUERIES in hooks/advice.ts). It never holds a word of the person.

Never sent: assistant text, room files, claims, artifacts.

What the excerpt removes

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)".

  • S1, known names: the room slug, each part of it (3 letters or more), the workspace folder name and the session folder name. Whole words, any case. A plain function word inside a slug (the, and) is left out of the list.
  • S2, proper names: each capitalized word that does not start a sentence, and each all-caps word of 2 letters or more. The words of the framework names and problem types, Theo, PWS, MindrianOS, the pronoun I, weekday and month names and the acronyms PWS JTBD SWOT PEST API AI UX MVP ROI KPI are kept.
  • S3, sentence starts: the first word of each sentence (start of text, after a full stop, a question mark, an exclamation mark or a line break) goes, unless it is a common word (COMMON_STARTS in advice.ts), a cue word of a framework rule (so Customers, Symptom or Blocked survive) or a framework word. "Acme is losing money" loses Acme. "We lost three deals" keeps We. A plain noun at the start of a sentence (Money) is removed too: that costs a little fit and never leaks.

Sent line

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:

  • sent: Theo was sent: "...". A call that carried the words was answered.
  • may have been received: 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.
  • not asked: 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.
  • handles only: 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).

Residual risk

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

Default and opt-out

The excerpt is on by default (owner ruling 2026-10-21). Two ways to turn it off.

  • /mos-words in the session, or the Words button in the panel. This lasts until the session ends. PROVEN across two separate claude -p processes at plan time: a new session starts with the excerpt on again. Plan 10 proves it again on the shipped build.
  • MOS_FACE_WORDS=off (also 0 or false) in the settings environment. The excerpt is then off from the start of every session. /mos-words turns it on for that session. This works only while proposal P2 is on.

Two proposals, one flag each

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.

  • P1 shimOnlyExcerpt: the excerpt travels only through the guarded shim. Reason: the raw theo server never passes lib/core/part8-egress-guard.cjs (CLAUDE.md, THEO-04).
  • P2 envSwitch: MOS_FACE_WORDS=off at session start makes the opt-out last. Reason: the opt-out does not survive a new session.

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.

Budgets

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

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.

The limits the tests assert

LimitValueTest
Tick of the panelevery 120 secondsB3
Tool reads on a tickat most 4B3
File reads of the Job line on a tickexactly 1 (the room's job state file, cut off after 4 seconds); none at prompt timeJ9
File reads of the status line files on a tick8 (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 addedB9
Subprocesses0B1, G1
Network fetches on an ordinary tick0B3
Reach checks (Theo health, the website)every 600 seconds, started together, each cut off after 4 seconds with no statusB3, B4
A read of the roomcut off after 30 secondsB5b
Slow readover 8 secondsB5
Back-off after a slow or failed readthe next ticks wait 2x, 4x, then 8x of 120 seconds, never more than 600 secondsB5, B5b
A quick readresets the waitB5
The person's own refresh (/mos-refresh, the Refresh button)always reads, and asks Theo and the website againB5
Reads in flightone (a second refresh during a read is ignored, for 45 seconds at most)B6
Timersthe 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

Where the home and rooms folders are

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.

No python

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.

Not measured yet

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.

Copy

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:

  • Clear, curious, demanding, grounded, warm and practical: short paragraphs, concrete nouns, no hype word, and generated text is never called validated evidence (mos-brand book e947a5d, segment writing/voice).
  • No em-dash and no en-dash. A dash is a release blocker (mos-brand book e947a5d, references/brand-rules.md, rule 1).
  • Theo supports the inquiry and the person keeps the judgment (references/brand-rules.md, rules 3 and 4).
  • The panel names the thinking partner Theo. The panel never introduces a second character (segment identity/naming).
  • Say Theo, never Brain. Say data room, never ICM. A button never runs anything: it puts a command or a question in the prompt box. Nothing looks pressable unless a real mechanism presses it (segment experience/terminal, status PROPOSED, so each of these carries that status).
  • No MCP word, no Aura word, no person's name and no institution's name.

What the tests scan:

  • tests/copy.test.tsx draws the band and the pane in 10 states (the 11 canon states except Reframe, which no real event reaches; its thoughts are scanned in C4), 3 face styles (tile, mini and line), 4 widths (100, 48, 40 and 30) and plain mode, opens every block, and scans every Text and every Button. It also scans the 15 command descriptions and results, every toast, and every table (glossary, thoughts, labels, why lines).
  • tests/test-380-copy.cjs reads every string of the hook sources (also JSX text) and scans the manifest description. It runs the commands check below.
  • scripts/face-commands.cjs --check keeps hooks/commands.ts honest against data/command-registry.json: no unknown command, no stack word, no PLUGIN_VERSION constant. A changed registry sentence only warns. Run --write, then the build.
  • A command name such as /mos:brain-derive is a name the plugin owns. The scan removes command names before it looks for words.

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.

Surfaces

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.

Job line

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:

  1. The room's recorded job: <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.
  2. When there is no usable record: a cue match over the person's last prompts in this session (up to 6, held in memory), against the cues of the plugin's own taxonomy. The method is the classifier's: distinct cue phrases found in the person's words. The panel adds one rule: a cue is found only as a whole phrase, not inside a longer word. At least 2 distinct cues of one job and a strict lead over every other job give a guess; a tie or fewer gives none. The match is computed from memory when the pane draws. No file is read for it.
  3. The room state: the stage from the room reading and the open section of the panel, for the "in this situation" part of the sentence. Without an open section the "and looking at" part is absent.

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:

ActionWhat it does
Not thisHides 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 setPuts 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 idWant (from the plugin taxonomy)Outcome clause (so that, reviewed here)Shown
decide-pursuedecide if a venture / opportunity / approach is worth pursuingI can say go or no-go and write down my reasonyes
find-problemfind the problem worth solving (not the symptom, the root)I know the real problem in a sentence I can testyes
understand-marketunderstand the market for this idea - size, structure, dynamicsI can say how big the market is and who else is in ityes
find-bottleneckfind the lagging component (Hughes 1983 reverse salient) blocking progressI know which part to fix firstyes
prepare-pitchprepare for an investor / partner / customer meetingI walk in with the story ready and the hard questions coveredyes
validate-ideatest if an idea / hypothesis / claim holds up under scrutinyI know what holds up, what does not and what I still need to find outyes
compare-optionscompare alternatives across criteria - choose between A/B/CI can choose between the options and say whyyes
connect-domainsfind cross-domain analogues - what does this look like in another fieldI see what this looks like in another field and what that could teach meyes
surface-contradictionnotice and resolve internal contradictions in the roomI know which claims pull apart and which one I keepyes
plan-executionplan the next 90 days - milestones, owners, datesI know the next 90 days, with owners and datesyes
file-meetingcapture and route meeting intelligence into the roomI can see what the meeting changes in my data roomyes
audit-roomaudit Data Room health - gaps, drift, evidence tier, decision qualityI know where my data room is thin or out of stepyes
exploreno specific job - general thinking, talking, rangingI notice what is worth followingnever shown

Status parity

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

Source 23 files
hooks/register.tsx 1971 lines
1import { 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 lines
1import 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}
553
hooks/bind.ts 30 lines
1import 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}
30
hooks/commands.ts 536 lines
1// 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}
536
hooks/gauge.ts 48 lines
1import { 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}
48
hooks/job.ts 237 lines
1import 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}
237
hooks/jobs.ts 195 lines
1// 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]
195
hooks/paths.ts 64 lines
1import 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}
64
hooks/proposals.ts 33 lines
1// 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}
33
hooks/sent.ts 173 lines
1// 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}
173
hooks/face.ts 107 lines
1import 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}
107
hooks/face-events.ts 76 lines
1import 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