SLOPSHOPPER

moonlightcode

MoonlightCode for Claude Code: workflow phases, with Plan as a read-only boundary while Claude Code runs in auto mode

newpanebandspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · moonlightcode
│ ┃ Moonlight ✕ › fix the failing auth test and add an audit log call │ ┃ ◐ PLAN project read-only │ ┃ plan → a: auto → t: test → r: review → c: co ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Edit(/work/app/src/auth.ts) │ ⎿ Denied by moonlightcode: Moonlight (Plan): /work/app/src/ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /phase │ ⎿ moonlightcode: Phase is plan (project read-only); next: auto. Us │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ◐ PLAN project read-only · next: auto ? for shortcuts

Draws

Pane · Moonlight
◐ PLAN project read-only plan → a: auto → t: test → r: review → c: commit
Pane · Plan
Plan No plan yet. In the Plan phase, the agent's plan shows up here for you to approve. [ Close ]
Pane · Review
Review changes since the last commit 0 files, 0 comments No git repository in /work/app or its subfolders (4 levels): nothing to diff against. AI review run a review skill, then keep or dismiss what it No attached review skill is installed (bmad-code-review, code-review): install one, or /ml-review attach <skill> [ This session only ] [ Refresh ] [ Close ]
Prompt hint
◐ PLAN project read-only · next: auto ? for shortcuts
README

MoonlightCode for Claude Code (moonlightcode mod)

A Claude Code plugin of function hooks that brings MoonlightCode into the session itself, without the daemon:

  • Phases: the daemon's five (Plan → Auto → Test → Review → Commit). Plan and Commit keep project files read-only while Claude Code runs in auto mode; Auto, Test and Review let it write. Per project, Plan by default.
  • Plan panel: the agent presents its plan with present_plan; you comment its lines and answer Approve, Ask for changes, or Reject. Claude Code's own plan mode is not used: it would leave auto mode.
  • Review panel: the changed files, each file's diff, comments anchored to diff lines, and the triage of what an AI review skill found.

Design and test evidence: .bmad-output/spike-1-mod-findings.md.

Install it

From this repository's marketplace, for every session (function hooks are early access: the variable is needed):

claude plugin marketplace add titouanfreville/moonlight-ide-plugins --sparse .claude-plugin clients/claude-code
claude plugin install moonlightcode@moonlight

and in the env block of ~/.claude/settings.json: "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1".

The MoonlightCode VS Code and JetBrains plugins do both for you: when no daemon can be started, they offer to install it (or run MoonlightCode: Install the Claude Code Plugin). They take the repository's marketplace, or the copy bundled in them when GitHub is out of reach. Installed this way, the mod also gates the sessions Anthropic's own Claude Code plugins start, which no launch flag of ours reaches.

With the daemon

The mod is the fallback, not a second gate. At session start it reads ~/.moonlight/control.json ($MOONLIGHT_HOME when set) and asks the daemon's /control/gating-status:

  • The daemon answers with its hooks installed: it governs, and the mod stands by. No sandbox config, no /phase, /ml-plan, present_plan or request_phase: the daemon's own mcp__moonlight__* verbs and the IDE panels are the way. The hint under the prompt says ☾ gated by the MoonlightCode daemon. The Review panel (/ml-review) still works.
  • No answer within 1.5 s, or hooks missing: the mod governs, as below.
  • While it stands by, the mod still refuses Edit, Write and NotebookEdit on its own files (the mod, ~/.moonlight, Claude Code's settings).
  • The daemon stops governing mid-session: after three missed probes in a row (about 6 s, so a daemon restart flips nothing), the mod takes the session over, in Plan unless another live mod session governs the project (below): the stored phase is not trusted otherwise, since the mod's sandbox did not guard it meanwhile. A toast and a note to the agent say so; /phase moves on.
  • A session the mod governs stays the mod's when the daemon comes back: two gates would hold two phases. New sessions go to the daemon.
  • The daemon is checked with /control/gating-status at session start (its hooks must be installed), then with the cheaper /control/health while it governs, 3 s each.
  • While it stands by, the mod takes its own deny entries out of the project's sandbox (the gate's own files stay denied), so leftovers from an earlier phase never block a session the daemon governs. The mod records what it added per project, so one project's phase change never touches another's lists.
  • A takeover keeps the project's phase when another live session already governs the project with the mod: the phase is the project's, and that session kept it guarded.

In the IDE

The MoonlightCode VS Code and JetBrains panels show the sessions the mod governs beside the daemon's: their phase, a phase request waiting (Allow / Refuse), the plan to review. Decisions made there reach the mod through files under ~/.moonlight/mod/sessions/<id>/; see ../fallback-bridge.md.

Load it from a checkout

To develop the mod, load the working copy instead:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ~/path/to/moonlight-ide-plugins/clients/claude-code/moonlight

To load it in every session, set it in the env block of ~/.claude/settings.json:

"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
"CLAUDE_CODE_PLUGIN_DIRS": "~/path/to/moonlight-ide-plugins/clients/claude-code/moonlight",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"

Phases

The same workflow as the MoonlightCode daemon (crates/domain/src/phase.rs). Claude Code runs in auto mode in every phase; the mod's policy is what differs.

PhaseProject filesNotes (.ai/, .bmad-output/, .bmad/)Next
◐ Planread-onlywritableAuto
● AutowritablewritableTest
● TestwritablewritableReview
● ReviewwritablewritableCommit
■ Commitread-onlyread-only: everything frozen until you commitPlan
  • In the read-only phases (Plan, Commit), Bash and Monitor run under the sandbox with the project denied (in every phase, both are refused unless the sandbox is safe and carries the gate's deny list), the Edit and Write tools only reach the folders still open (/tmp always is), and any other tool must be on the read-only allow-list.
  • In every phase, the agent can't write the gate's own files: the mod, ~/.moonlight, the project's .moonlight/, and Claude Code's settings (.claude/settings.json, .claude/settings.local.json, ~/.claude/settings.json), so an Auto session can't widen the next Plan. Bash is refused while the effective sandbox has an allowWrite entry that opens one of them.
  • The gate fails closed: a gate hook that fails or runs out of time refuses its call, and an unreadable .claude/settings.local.json makes the gate refuse tools (with a toast) until it is fixed.
  • When you change the phase yourself (/phase, the hint, the pane), the agent finds a note about it at its next turn, and a pending request_phase is dropped.
  • present_plan is a Plan tool: it is refused in the other phases. Approving a plan moves Plan to Auto.
  • next is cyclic: after Commit, Plan starts the next unit of work. You may also jump to any phase.
  • Aliases, as the daemon takes them: discovery, discover for Plan; implement, autoimplement, auto-implement for Auto.

The phase is per project, shared by every session open on it. A session that sets a project to a read-only phase makes Bash read-only there for the other sessions too, since they share the project's sandbox settings.

Loading it turns the gate on for the session's project, in Plan: Bash runs under Claude Code's sandbox with the project read-only, and only the notes folders (.ai/, .bmad-output/, .bmad/) are writable. Type /phase auto to work normally.

The phase shows under the prompt, on the hint line: the current one as a badge (◐ PLAN yellow, ● AUTO green, ● TEST cyan, ● REVIEW blue, ■ COMMIT red), its rule in the same colour, then a dim · next: … that steps to the next phase, then Claude Code's own hint. /phase opens the Moonlight pane: the badge, then the five phases as dim words to jump to any. When the agent asks for a phase change, a band shows above the prompt with Approve (y) and Deny (n); otherwise nothing is drawn there.

The panes keep colour for state: the phase badge carries its phase's colour, the diff keeps Claude Code's own. The rest is plain or dim text. 🤖 marks what the AI reviewer found, with its severity as a word (high, medium, dim low); 💬 marks your comments, whose text hangs under them behind a ┃ rail.

The panes, the hint and the band draw on the terminal and desktop surfaces. The VS Code extension draws no mod UI today, so use the commands there.

Commands (operator only: refused when the model, the SDK or a plugin runs them)

CommandWhat it does
/phaseShow the phase, its rule and the next one, and open the Moonlight pane
`/phase plan\auto\test\review\commit`Change the phase (aliases accepted)
/phase nextMove to the next phase (Commit → Plan)
/phase approve · /phase denyAnswer the agent's request_phase
/ml-planOpen or close the Plan panel, and print the plan with line numbers, its comments and its state
/ml-plan comment <line[-end]> <text>Comment a line or a range of the plan; \n starts a new line
/ml-plan uncomment <id>Remove a comment
/ml-plan approve [note]Approve the plan: the phase moves to Auto, and the agent is told to implement it
/ml-plan refine <note>Send the comments and the note back: the agent revises and presents the whole plan again
/ml-plan reject <reason>Reject the plan: the agent stops
/ml-reviewOpen or close the Review panel, and list changed files and comments
/ml-review comment <path:line[-end]> <text>Comment a line or a range of the current file; \n starts a new line
/ml-review uncomment <id>Remove a comment
/ml-review submit [summary]Send every comment to the agent as one message (the panel's Send review), then clear them
/ml-review acceptClose the review: the next one starts from here
/ml-review run [skill]Run one review skill (the only attached one, else name any installed one; your list is left as it is)
/ml-review cancelFree the panel from a review that will not answer
/ml-review attach <skill> · detach <skill>Your own list of review skills to choose from at run time
/ml-review skills [reset]The review skills offered, and the attached ones not installed; reset drops your own list
`/ml-review scope session\head`Review this session's changes, or everything since HEAD

/ml-review rather than /review: /review is the alias of Claude Code's built-in /code-review.

Plan panel

In the Plan phase, the agent proposes a plan by calling mcp__moonlightcode__present_plan with the whole plan as Markdown (and an optional title). The tool answers at once and tells the agent to stop and wait; the verdict reaches it later as a [moonlight] prompt, which starts a turn. EnterPlanMode and ExitPlanMode are refused in Plan, with a pointer to present_plan: Claude Code's plan mode would leave auto mode.

  • Each call is the session's next version (v1, v2…), saved to ~/.moonlight/mod/sessions/<session>/plan/v<N>.md. A new version starts with no comments.
  • The Plan pane opens with focus: Plan vN and its title, then the plan line by line, numbered, each drawn as Claude's replies are: headings, bullets (nested by their indent), bold, code and links through Markdown; code blocks highlighted in their fence's language, the fence shown only as that language. Table rows stay plain text, since a row drawn alone is not a table.
  • Click the + before a line to comment it; click a second + to extend to a range; click the same single line again to clear. • marks a line with a comment, ✕ removes one. Type the comment, then Enter; end a line with \ to write several.
  • Note (optional): an overall note for the agent; Enter saves it.
  • ✓ Approve: moves the phase to Auto (from Plan, or Commit), then tells the agent to implement the plan, with your note and comments to keep in mind.
  • ✎ Ask for changes, once there is a comment or a note: sends them, each comment with the plan lines it is about; the agent revises and calls present_plan again.
  • ✕ Reject asks for a reason (required), then tells the agent to stop. The phase does not change.
  • The note is sent as typed, Enter or not; a reject reason or a /ml-plan note goes with it.
  • After a verdict, the version shows as decided. If the send fails, a toast says why, an approval's phase change is undone, and the comments and the note come back, unless the agent has presented a newer version meanwhile.

In VS Code, where no mod UI draws, use /ml-plan: it prints the plan with its line numbers to comment.

Review panel

In the terminal's fullscreen layout, at least 110 columns wide, the panel docks on the right; elsewhere it shows above the prompt. Type /ml-review to open or close it. When it has focus, the arrow keys scroll, Tab walks the buttons, and Esc returns to the prompt.

  • Files view: click a file to open its diff. r refreshes the list.
  • File view: the diff, GitHub style. Claude Code's own highlighter draws each line: line numbers, +/-, green/red, syntax colours.
  • Unified by default; side by side once the panel is at least 140 columns wide. Split / Unified (s) forces one or the other.
  • ⤢ Expand (e), docked panel only: reopens the panel at the terminal's full width, like a modal. ⤡ Shrink goes back to the dock's normal width. A width you set by hand wins.
  • Click the + in front of a line to comment it. Click a second + to extend to a range: every row between, removed, added and context lines alike, so a whole changed block takes one comment. Click the same single line again to clear. The line under the header shows the selection (src/f.ts:2-4 (+ removed 2-3)).
  • ▌ marks the selection, • a line with a comment.
  • Type the comment, then press Enter. For several lines, end a line with \ before Enter: it joins the draft shown above the field, and the next line without \ saves the comment. ✕ removes a comment.
  • b goes back to the files view.
  • Send: once there is a comment, Send review (s in the files view, Send in a file's header) sends every comment to the agent as one message: grouped by file, in line order, each with the lines it is about. The files view's Summary field adds an optional overall note; Enter there submits too. The review goes in as a prompt, so the agent starts on it at once, or as soon as its current turn ends. If the send fails, the comments come back.
  • ✓ All good (a in the files view, also in a file's header, or /ml-review accept), when no comment is pending: accepts the review. The agent is told all is good, the saved copies are dropped, and the next review shows only what the agent changes from then on.

AI review: an existing review skill, attached

The mod has no review skill of its own: it attaches the ones you already use. A skill matches bare or under its plugin (bmad-method:bmad-code-review), and only the installed ones are offered.

Which skills are attached, first match wins:

  1. Yours: /ml-review attach <skill> and detach <skill> keep your own list; /ml-review skills reset drops it.
  2. Your team's: the plugin's reviewSkills option, comma-separated. A team sets it for everyone in managed or shared settings:
   "pluginConfigs": { "moonlightcode": { "options": { "reviewSkills": "deep-review, bmad-code-review, code-review" } } }
  1. Built in: bmad-code-review (BMAD) and code-review (Claude Code).
  • Run one: ▸ Run AI review (i) in the files view. With one skill attached it runs it; with several it asks which. Or /ml-review run <skill>, or invoke an attached skill yourself as usual.
  • One review at a time: while it runs, the panel shows ⏳ \<skill\> running… and refuses a second run; Cancel frees it from a review that will not answer.
  • When the running review's skill starts, the mod appends to its prompt the review's scope (the panel's files, with the saved copies to diff against) and asks for one moonlight-findings JSON block at the end, the format MoonlightCode's desktop panel reads. Only that skill's prompt gets it, not an attached skill it calls in turn. How the skill reviews is left to it.
  • When an answer ends with that block, its findings load into the panel (outside a review run, only if you have not started triaging: a note or a dismissal keeps the current findings), worst first: in the files view's AI review list, and under their line in the file's diff. A block that does not parse shows as an error, never as a clean review; a review that ends without one says so.
  • Triage each finding: ✕ Dismiss drops it from this pass (↺ Keep brings it back); ✎ Note adds your decision about it, which the agent is told wins over the reviewer.
  • Send review sends the kept findings, with your notes, together with your comments, as one message. ✓ All good waits until no kept finding is left.

What is diffed

  • This session (the default once Claude has changed a file): each file against a copy the mod saved just before Claude first changed it with Edit, Write or NotebookEdit. No git needed.
  • Copies live in ~/.moonlight/mod/sessions/<session>/baseline/, out of the agent's reach. A copy is named by its content, so the same content is stored once.
  • Files over 1 MB and binary files are not copied; the panel lists them with the reason.
  • Changes made through Bash (sed -i, a formatter…) have no copy: switch to Since last commit to see them.
  • The copies are dropped on All good and on /clear. A session that ends otherwise keeps them for --resume; after a week without a heartbeat, the next session start removes them.
  • Since last commit (v toggles): everything changed against HEAD. Untracked files count their lines as added (binary ones say so; past 200 untracked files the rest are listed uncounted). If the session's root is inside a git repository, the panel reviews that repository. Otherwise it looks for repositories up to 4 levels below the root, skipping node_modules, target, .venv, dist and build, and lists each one's changes under its folder name.

Paths in comments start from the session root.

Develop

claude plugin validate .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .

The mod protects its own folder from the agent in every phase. To let an agent edit it, the operator creates ~/.moonlight/mod/dev-unlock; the agent can't create that file. Plan still applies.

The backstop (hooks/backstop.sh) is a classic PreToolUse hook, registered in ~/.claude/settings.json. It is active only while ~/.moonlight/mod/governed exists. In that state, a session without a fresh heartbeat from the mod can only use read-only tools.

Source 8 files
hooks/register.tsx 2512 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { MoonlightcodePhaseRequest, MoonlightcodePlan, MoonlightcodeReviewView } from '../types'
4import {
5  anchorLabel,
6  anchorOf,
7  anchorOfRows,
8  continueDraft,
9  excerptOf,
10  excerptOfRows,
11  nextRowSelection,
12  type RowRange,
13  formatReview,
14  inRepo,
15  isInAnchor,
16  isSplit,
17  lineHunk,
18  parseChangedFiles,
19  parseFileDiff,
20  parseRepoDirs,
21  REPO_SEARCH_DEPTH,
22  REPO_SEARCH_SKIP,
23  toSplitRows,
24  baselineOf,
25  baselineStatus,
26  contentHash,
27  effectiveScope,
28  parseBaselineIndex,
29  parseNumstat,
30  type BaselineEntry,
31  type BaselineIndex,
32  type ChangedFile,
33  type DiffLine,
34  type Repo,
35  type ReviewScope,
36  type ReviewLayout,
37  counted,
38  untrackedCount,
39  UNTRACKED_COUNT_MAX,
40} from './review'
41import {
42  canSend,
43  drawable,
44  formatVerdict,
45  isTableRow,
46  parsePlan,
47  planExcerpt,
48  planSpan,
49  presentedAnswer,
50  type PlanComment,
51  type PlanLine,
52  type PlanVerdict,
53} from './plan'
54import { answer, BRIDGE_VERSION, type InboxEntry, parseInboxEntry, pendingEntries, type ReviewEntry } from './bridge'
55import { controlUrl, countMisses, discoveryFile, type Governor, hooksInstalled, isHealthy, nextGovernor } from './daemon'
56import {
57  allowWriteBreach,
58  checkSandbox,
59  CLAUDE_PLAN_MODE_TOOLS,
60  gateFailure,
61  parseSettings,
62  allowsNotes,
63  allowsWrites,
64  DEFAULT_WRITABLE,
65  isAllowedInPlan,
66  nextPhase,
67  notesFor,
68  phaseFromToken,
69  phaseLabel,
70  PHASES,
71  phaseRule,
72  isUnderAny,
73  mergeOwned,
74  ownedByAnyPhase,
75  newRootEntries,
76  parsePhase,
77  PROJECT_CONFIG_DIR,
78  PROJECT_SETTINGS,
79  projectKey,
80  sandboxListsFor,
81  type Phase,
82  type SandboxWriteLists,
83} from './policy'
84import {
85  DEFAULT_ATTACHED,
86  findingPlace,
87  findingsContract,
88  formatFinding,
89  installedSkills,
90  isAttached,
91  parseFindings,
92  parseSkillList,
93  sortFindings,
94  type TriagedFinding,
95} from './findings'
96
97// MoonlightCode for Claude Code. Everything that touches `$` lives in this one
98// file: the validator follows `$` only into functions declared in the same
99// module. Pure logic is in policy.ts, review.ts, findings.ts and plan.ts.
100//
101// The daemon's five phases: Plan → Auto → Test → Review → Commit → Plan.
102// In the read-only ones (Plan, Commit), project files stay read-only:
103// - Bash runs under Claude Code's own sandbox, configured here per phase;
104// - Edit / Write / NotebookEdit are checked against the notes folders (open
105//   in Plan, frozen in Commit) and /tmp;
106// - any other tool runs only when it is on the read-only allow-list.
107// The phase is per project (Plan by default), since the sandbox config it
108// drives is per project too: ~/.moonlight/mod/projects/<root>/phase, which only this mod writes:
109// /phase (typed by the operator) or request_phase (the agent asks, the
110// operator approves). In every phase, the gate's own files (the mod,
111// ~/.moonlight, the project's .moonlight/) are never writable by the agent.
112// To develop the mod, its own folder is unlocked while
113// ~/.moonlight/mod/dev-unlock exists, a file only the operator can create.
114
115type $ = EngineInterface
116
117const OWNED_KEY = 'owned-sandbox-lists'
118const POLL_MS = 2000
119// The phase view: a band above the prompt where the surface draws one
120// (terminal, desktop), and this pane everywhere (VS Code and mobile have no band).
121const PANE = 'moonlight-phase'
122const SURFACES_WITHOUT_BAND: readonly string[] = ['vscode', 'mobile']
123const TOOL = 'request_phase'
124
125let root: string | undefined
126let appliedPhase: Phase | undefined
127let deniedEntries: readonly string[] = []
128// The notes folders, and the scratch folders (/tmp, TMPDIR) open in every phase.
129let noteRoots: string[] = []
130let scratchRoots: string[] = []
131let home: string | undefined
132let projectDir: string | undefined
133let modRoot: string | undefined
134// Who governs this session: the daemon (the mod stands by) or the mod. Unset
135// until session.start decides; the gate hooks govern unless it is 'daemon'.
136let governor: Governor | undefined
137let probing = false
138let missedProbes = 0
139let protectedRoots: string[] = []
140
141// What the band and the dialog draw from: written on change, read while drawing.
142const phaseState = atom({ plugin: 'moonlightcode', key: 'phase' } as const, 'plan' as Phase)
143
144// Each phase's badge and colour, on the hint line, the pane and the status.
145const PHASE_STYLE: Record<Phase, { badge: string; color: string }> = {
146  plan: { badge: ' ◐ PLAN ', color: 'yellow' },
147  auto: { badge: ' ● AUTO ', color: 'green' },
148  test: { badge: ' ● TEST ', color: 'cyan' },
149  review: { badge: ' ● REVIEW ', color: 'blue' },
150  commit: { badge: ' ■ COMMIT ', color: 'red' },
151}
152const governorState = atom({ plugin: 'moonlightcode', key: 'governor' } as const, null as Governor | null)
153const pendingState = atom({ plugin: 'moonlightcode', key: 'pending' } as const, null)
154
155let pending: MoonlightcodePhaseRequest | undefined
156
157const setPending = async ($: $, request: MoonlightcodePhaseRequest | undefined): Promise<void> => {
158  pending = request
159  await update($, pendingState, () => request ?? null)
160  void publishState($).catch(() => undefined)
161}
162
163// ---- Paths ----
164
165// A deny-list on spellings is best effort: compare without case, since a case
166// alias keeps its own spelling on a case-insensitive disk.
167const isUnderAnyFolded = (path: string, roots: readonly string[]): boolean =>
168  isUnderAny(path.toLowerCase(), roots.map(r => r.toLowerCase()))
169
170const isDevUnlocked = async ($: $): Promise<boolean> =>
171  home !== undefined && (await $.fs.exists(`${home}/.moonlight/mod/dev-unlock`))
172
173const listRoot = async ($: $, projectRoot: string): Promise<string[]> =>
174  (await $.fs.list(projectRoot)).map(entry => entry.name)
175
176const realPathOf = async ($: $, path: string): Promise<string | undefined> => {
177  const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
178  return stat?.realPath
179}
180
181// A notes folder that is a symlink could point into src/: it is skipped.
182const resolveNoteRoots = async ($: $, projectRoot: string): Promise<string[]> => {
183  const roots: string[] = []
184  for (const name of DEFAULT_WRITABLE) {
185    const path = `${projectRoot}/${name}`
186    const stat = await $.fs.stat(path).catch(() => undefined)
187    if (stat === undefined) roots.push(path)
188    else if (stat.isLink) $.ui.toast(`Moonlight: ${name} is a symlink, not writable in Plan`)
189    else roots.push(path)
190  }
191  return roots
192}
193
194const resolveScratchRoots = async ($: $): Promise<string[]> => {
195  const roots: string[] = []
196  for (const tmp of ['/tmp', await $.env.get('TMPDIR')]) {
197    if (tmp === undefined || tmp === '') continue
198    const real = await realPathOf($, tmp)
199    if (real !== undefined) roots.push(real)
200  }
201  return roots
202}
203
204// Where a write would land: the nearest existing ancestor's realPath plus the
205// segments that do not exist yet. Undefined when it cannot be told safely.
206const resolveTarget = async ($: $, path: string): Promise<string | undefined> => {
207  if (root === undefined) return undefined
208  let current = path.startsWith('/') ? path : `${root}/${path}`
209  const missing: string[] = []
210  while (current !== '' && current !== '/') {
211    const stat = await $.fs.stat(current, { resolve: true }).catch(() => undefined)
212    if (stat !== undefined) {
213      if (stat.realPath === undefined) return undefined
214      if (missing.some(s => s === '..' || s === '.')) return undefined
215      return [stat.realPath, ...missing].join('/')
216    }
217    const cut = current.lastIndexOf('/')
218    missing.unshift(current.slice(cut + 1))
219    current = current.slice(0, cut)
220  }
221  return undefined
222}
223
224// ---- Phase and sandbox ----
225
226// No project known yet reads as Plan, the safe default.
227const readPhase = async ($: $): Promise<Phase> =>
228  projectDir === undefined ? 'plan' : parsePhase(await $.fs.read(`${projectDir}/phase`).catch(() => undefined))
229
230const settingsPath = (projectRoot: string): string => `${projectRoot}/.claude/settings.local.json`
231
232// Per project: the store is the plugin's, shared by every session on the
233// machine, and one key for all let another project's lists stand for this
234// one's (its own stale entries then stayed, read as the user's).
235const ownedKey = (projectRoot: string): string => `${OWNED_KEY}:${projectKey(projectRoot)}`
236
237// Puts the mod's lists for this project in its settings, replacing what the mod
238// added last time and keeping the user's own entries. With no record for the
239// project, whatever a phase would have added here counts as the mod's.
240const writeSandboxLists = async ($: $, projectRoot: string, owned: (entries: string[]) => SandboxWriteLists): Promise<SandboxWriteLists> => {
241  const path = settingsPath(projectRoot)
242  const text = await $.fs.read(path).catch(() => '{}')
243  const parsed = parseSettings(text)
244  if ('error' in parsed) {
245    // Thrown: the gate hooks' handlers then refuse every call they guard.
246    $.ui.toast(`Moonlight: ${path} cannot be read (${parsed.error}). Fix it: until then the gate refuses tools.`)
247    throw new Error(`${path}: ${parsed.error}`)
248  }
249  const settings = parsed.settings
250  const sandbox = (settings.sandbox ??= {})
251  sandbox.enabled = true
252  sandbox.allowUnsandboxedCommands = false
253  const filesystem = (sandbox.filesystem ??= {})
254  const entries = await listRoot($, projectRoot)
255  const recorded = (await $.store.get(ownedKey(projectRoot))) as SandboxWriteLists | undefined
256  const previous = recorded ?? ownedByAnyPhase(DEFAULT_WRITABLE, entries)
257  const lists = owned(entries)
258  filesystem.denyWrite = mergeOwned(filesystem.denyWrite ?? [], previous.denyWrite, lists.denyWrite)
259  filesystem.allowWrite = mergeOwned(filesystem.allowWrite ?? [], previous.allowWrite, lists.allowWrite)
260  const next = `${JSON.stringify(settings, null, 2)}\n`
261  if (next !== text) await $.fs.write(path, next)
262  await $.store.set(ownedKey(projectRoot), lists)
263  return lists
264}
265
266const applySandbox = async ($: $, phase: Phase): Promise<void> => {
267  if (root === undefined) return
268  const owned = await writeSandboxLists($, root, entries => sandboxListsFor(phase, DEFAULT_WRITABLE, entries))
269  deniedEntries = owned.denyWrite
270  appliedPhase = phase
271  await update($, phaseState, () => phase)
272  // The hint line under the prompt shows the phase where it draws; the status
273  // only where it does not (the VS Code extension today), never both.
274  const surfaces = await $.session.surfaces().catch(() => [] as string[])
275  if (surfaces.some(s => SURFACES_WITHOUT_BAND.includes(s)))
276    $.ui.status(`Moonlight: ${PHASE_STYLE[phase].badge.trim()} (${phaseRule(phase)})`)
277}
278
279const syncPhase = async ($: $): Promise<Phase> => {
280  const phase = await readPhase($)
281  if (phase !== appliedPhase) await applySandbox($, phase)
282  return phase
283}
284
285// Only the mod changes the phase: the agent's tools cannot write the file.
286const setPhase = async ($: $, to: Phase): Promise<Phase> => {
287  if (projectDir === undefined) return 'plan'
288  await $.fs.write(`${projectDir}/phase`, `${to}\n`)
289  return syncPhase($)
290}
291
292// Where things are, for the gate and the Review panel alike.
293const initPaths = async ($: $): Promise<void> => {
294  // The project root, not the cwd: a shell `cd` moves the cwd, not the root.
295  root = await realPathOf($, await $.session.root())
296  home = await $.env.get('HOME')
297  if (home !== undefined && root !== undefined) projectDir = `${home}/.moonlight/mod/projects/${projectKey(root)}`
298  modRoot = (await realPathOf($, $.plugin.root)) ?? $.plugin.root
299  protectedRoots = [modRoot]
300  if (home !== undefined) {
301    protectedRoots.push((await realPathOf($, `${home}/.moonlight`)) ?? `${home}/.moonlight`)
302    // Where the backstop is registered.
303    protectedRoots.push(`${home}/.claude/settings.json`)
304  }
305  if (root !== undefined) {
306    protectedRoots.push(`${root}/${PROJECT_CONFIG_DIR}`, ...PROJECT_SETTINGS.map(file => `${root}/${file}`))
307    noteRoots = await resolveNoteRoots($, root)
308    scratchRoots = await resolveScratchRoots($)
309  }
310}
311
312const initGate = async ($: $): Promise<void> => {
313  await initPaths($)
314  if (root !== undefined) await syncPhase($)
315}
316
317// ---- Daemon hand-over ----
318
319const PROBE_TIMEOUT_MS = 3000
320
321// Whether the MoonlightCode daemon governs Claude Code right now: it answers
322// on loopback and reports its hooks installed. Any doubt reads as no, so the
323// mod keeps the gate.
324// `watch`: the daemon is known to govern, so its health is enough (cheap); at
325// session start, its hooks must be installed too.
326const daemonGoverns = async ($: $, watch = false): Promise<boolean> => {
327  const file = discoveryFile(await $.env.get('HOME'), await $.env.get('MOONLIGHT_HOME'))
328  if (file === undefined || !(await $.fs.exists(file))) return false
329  const url = controlUrl(await $.fs.read(file))
330  if (url === undefined) return false
331  const answer = $.http
332    .fetch(`${url}/control/${watch ? 'health' : 'gating-status'}`)
333    .then(response => response.ok && (watch ? isHealthy(response.text) : hooksInstalled(response.text)))
334    .catch(() => false)
335  const late = $.clock.sleep(PROBE_TIMEOUT_MS).then(() => false)
336  return Promise.race([answer, late])
337}
338
339// Whether another session on this project is governed by the mod right now:
340// its state is published (only a governing mod publishes) and its heartbeat fresh.
341const projectGovernedElsewhere = async ($: $): Promise<boolean> => {
342  const sessions = await sessionsDir($)
343  if (sessions === undefined || root === undefined || !(await $.fs.exists(sessions))) return false
344  const me = await $.session.id()
345  const now = await $.clock.now()
346  for (const entry of await $.fs.list(sessions)) {
347    if (entry.kind !== 'dir' || entry.name === me) continue
348    try {
349      const lastBeat = await $.fs.stat(`${sessions}/${entry.name}/heartbeat`)
350      if (now - lastBeat.mtimeMs > HEARTBEAT_FRESH_MS) continue
351      const state = JSON.parse(await $.fs.read(`${sessions}/${entry.name}/state.json`)) as { root?: unknown }
352      if (state.root === root) return true
353    } catch {
354      // No heartbeat or no state: not a session the mod governs.
355    }
356  }
357  return false
358}
359
360// The backstop's own limit (hooks/backstop.sh): a fresher beat is a live mod.
361const HEARTBEAT_FRESH_MS = 10_000
362
363// While the daemon governs, the mod's own deny entries come out of the project's
364// sandbox (the gate's own files stay denied): leftovers from an earlier phase
365// would block the session the daemon governs.
366const releaseSandbox = async ($: $): Promise<void> => {
367  if (root === undefined) return
368  await writeSandboxLists($, root, entries => sandboxListsFor('auto', DEFAULT_WRITABLE, entries))
369}
370
371const setGovernor = async ($: $, to: Governor): Promise<void> => {
372  governor = to
373  await update($, governorState, () => to)
374}
375
376// While the daemon governs, the mod checks on it each tick, and takes the
377// session over once it has stopped (TAKEOVER_AFTER_MISSES probes in a row), in Plan: the stored phase is not trusted, since
378// the mod's sandbox did not guard it while the daemon governed. The operator
379// and the agent are told.
380const watchDaemon = async ($: $): Promise<void> => {
381  if (governor !== 'daemon' || probing) return
382  probing = true
383  try {
384    const governs = await daemonGoverns($, true).catch(() => false)
385    missedProbes = countMisses(missedProbes, governs)
386    if (nextGovernor(governor, governs, missedProbes) === 'daemon') return
387    await setGovernor($, 'mod')
388    await tryStart($, 'the gate', async () => {
389      await initPaths($)
390      // The phase is the project's: another live session the mod governs here
391      // has kept it guarded, so it stands; otherwise it is not trusted.
392      if (await projectGovernedElsewhere($)) await syncPhase($)
393      else await setPhase($, 'plan')
394    })
395    await startGoverned($)
396    const phase = await read($, phaseState)
397    $.ui.toast(`Moonlight: the daemon stopped governing; this session is now gated by the mod (${phaseLabel(phase)})`)
398    await tellModel(
399      $,
400      `[moonlight] The MoonlightCode daemon stopped answering: this session is now governed by the moonlightcode mod, ` +
401        `in the ${phaseLabel(phase)} phase (${phaseRule(phase)}). Use mcp__moonlightcode__request_phase and ` +
402        'mcp__moonlightcode__present_plan from now on, not the mcp__moonlight__ verbs.',
403    )
404  } finally {
405    probing = false
406  }
407}
408
409// ---- Write guard ----
410
411const denyWrite = (path: string, phase: Phase): { deny: string } => ({
412  deny:
413    phase === 'commit'
414      ? `Moonlight (Commit): ${path} cannot be written: the commit gate freezes everything until you commit, ` +
415        `${DEFAULT_WRITABLE.map(w => `${w}/`).join(', ')} included. Only /tmp stays writable. ` +
416        'Wait for the operator to commit, or ask with request_phase.'
417      : `Moonlight (${phaseLabel(phase)}): ${path} is a project file and ${phaseLabel(phase)} is read-only. ` +
418        `Write notes under ${DEFAULT_WRITABLE.map(w => `${w}/`).join(', ')} or /tmp, ` +
419        'then propose the plan.',
420})
421
422const guardSelf = async ($: $, path: string, target: string | undefined): Promise<{ deny: string } | undefined> => {
423  const asGiven = path.startsWith('/') ? path : root === undefined ? undefined : `${root}/${path}`
424  const candidates = [target, asGiven].filter((c): c is string => c !== undefined)
425  const unlocked = await isDevUnlocked($)
426  const roots = protectedRoots.filter(r => !(unlocked && r === modRoot))
427  if (!candidates.some(c => isUnderAnyFolded(c, roots))) return undefined
428  return {
429    deny:
430      `Moonlight: ${path} belongs to the gate itself (the mod, ~/.moonlight, ${PROJECT_CONFIG_DIR}/ or Claude Code's settings) ` +
431      'and is never writable by the agent, in any phase. Ask the operator.',
432  }
433}
434
435const guardWrite = async ($: $, path: string): Promise<{ deny: string } | undefined> => {
436  // The daemon's own hooks gate this session; the mod still keeps its own files
437  // out of the file tools' reach, since a takeover reads them.
438  if (governor === 'daemon') return guardSelf($, path, await resolveTarget($, path))
439  const phase = await syncPhase($)
440  const target = await resolveTarget($, path)
441  const self = await guardSelf($, path, target)
442  if (self !== undefined) return self
443  if (allowsWrites(phase)) return undefined
444  const open = allowsNotes(phase) ? [...noteRoots, ...scratchRoots] : scratchRoots
445  if (target === undefined || !isUnderAny(target, open)) return denyWrite(path, phase)
446  return undefined
447}
448
449// ---- Shell gate (Bash, Monitor) ----
450
451// Why a shell command must not run now, if it must not: the sandbox is not
452// safe, an allowWrite opens the gate's own files, or the deny list this mod
453// applied is not the one the engine runs under (fail closed).
454const shellRefusal = async ($: $, tool: string, phase: Phase): Promise<{ deny: string } | undefined> => {
455  const settings = await $.settings.read()
456  const health = checkSandbox(settings)
457  if (!health.isSafe) return { deny: `Moonlight (${phase}): ${tool} refused, the sandbox is not safe: ${health.reason}.` }
458  const allowed = ((settings.sandbox as any)?.filesystem?.allowWrite ?? []) as unknown[]
459  const breach =
460    home !== undefined && root !== undefined
461      ? allowWriteBreach(Array.isArray(allowed) ? allowed : [], home, root, protectedRoots)
462      : undefined
463  if (breach !== undefined)
464    return {
465      deny:
466        `Moonlight (${phase}): ${tool} refused, the sandbox's allowWrite entry ${breach} opens the gate's own files. ` +
467        'Ask the operator to remove it from the settings.',
468    }
469  const effective = ((settings.sandbox as any)?.filesystem?.denyWrite ?? []) as string[]
470  const missing = deniedEntries.filter(entry => !effective.includes(entry))
471  if (appliedPhase !== phase || missing.length > 0)
472    return { deny: `Moonlight (${phase}): ${tool} refused, the deny list is not in force yet.` }
473  return undefined
474}
475
476// After a shell command in a read-only phase. The root stays writable to the
477// sandbox: any top-level entry the command created is denied from now on, and
478// the agent is told.
479const afterShell = async <R extends { deny?: string }>($: $, phase: Phase, ran: R): Promise<R> => {
480  if (allowsWrites(phase) || root === undefined || ran.deny !== undefined) return ran
481  const created = newRootEntries(await listRoot($, root), deniedEntries, notesFor(phase, DEFAULT_WRITABLE))
482  if (created.length === 0) return ran
483  await applySandbox($, phase)
484  const names = created.join(', ')
485  const label = phaseLabel(phase)
486  $.ui.toast(`Moonlight: ${label} — new top-level entry created: ${names}`)
487  return {
488    ...ran,
489    context: [
490      `Moonlight (${label}): this command created ${names} at the project root. ` +
491        `${label} is read-only (${phaseRule(phase)}): it is now write-denied and left for the operator to review. ` +
492        (allowsNotes(phase) ? `Write notes under ${DEFAULT_WRITABLE.map(w => `${w}/`).join(', ')} instead.` : 'Only /tmp stays writable.'),
493    ],
494  }
495}
496
497// ---- Heartbeat ----
498
499// The backstop (hooks/backstop.sh, a classic PreToolUse hook outside this mod)
500// denies writes for a session whose heartbeat is missing or stale: no mod
501// loaded means no gate, so fail closed.
502const beat = async ($: $): Promise<void> => {
503  if (home === undefined) return
504  const id = await $.session.id()
505  if (!SESSION_ID.test(id)) return
506  await $.fs.write(`${home}/.moonlight/mod/sessions/${id}/heartbeat`, `${await $.clock.now()}\n`)
507}
508
509const tick = async ($: $): Promise<void> => {
510  if (governor === 'daemon') await watchDaemon($)
511  else await syncPhase($)
512  await beat($)
513  if (governor === 'mod') {
514    await applyInbox($)
515    await refreshReview($)
516    await publishState($)
517  }
518}
519
520// The review the IDE shows: recomputed when a baseline was taken or a decision
521// applied, and every REVIEW_REFRESH_TICKS ticks for what changed otherwise
522// (a shell command, an edit outside the session). git runs only then.
523const REVIEW_REFRESH_TICKS = 5
524let reviewDirty = true
525let reviewTicks = 0
526let bridgeSkills: string[] = []
527
528const refreshReview = async ($: $): Promise<void> => {
529  reviewTicks += 1
530  if (!reviewDirty && reviewTicks % REVIEW_REFRESH_TICKS !== 0) return
531  reviewDirty = false
532  // A failed refresh keeps the last list: the phase and plan still publish.
533  await loadChangedFiles($).catch(() => undefined)
534  bridgeSkills = await runnableSkills($).then(r => r.runnable, () => bridgeSkills)
535}
536
537// ---- IDE bridge (bridge.ts) ----
538
539// The session's own folder, beside its heartbeat.
540const sessionFolder = async ($: $): Promise<{ dir: string; id: string } | undefined> => {
541  const sessions = await sessionsDir($)
542  const id = await $.session.id()
543  return sessions === undefined || !SESSION_ID.test(id) ? undefined : { dir: `${sessions}/${id}`, id }
544}
545
546let publishedState: string | undefined
547
548// What the IDE panels show, written while the mod governs and only when it
549// changed: the phase, the request waiting, the latest plan.
550const publishState = async ($: $): Promise<void> => {
551  if (governor !== 'mod') return
552  const at = await sessionFolder($)
553  if (at === undefined) return
554  const doc = await read($, planDocState)
555  const state = {
556    v: BRIDGE_VERSION,
557    sessionId: at.id,
558    root: root ?? null,
559    phase: await read($, phaseState),
560    pending: pending === undefined ? null : { to: pending.to, reason: pending.reason },
561    plan: doc === null ? null : doc,
562    review: await reviewForIde($),
563  }
564  const key = JSON.stringify(state)
565  if (key === publishedState) return
566  await $.fs.write(`${at.dir}/state.json`, `${JSON.stringify({ ...state, updatedMs: await $.clock.now() })}\n`)
567  publishedState = key
568}
569
570const reviewForIde = async ($: $) => {
571  const run = await read($, reviewRunState)
572  return {
573    scope: effectiveScope(await read($, reviewScopeState), await read($, reviewHasBaselinesState)),
574    files: (await read($, reviewFilesState)).map(f => ({
575      path: f.path,
576      absolute: `${f.repo}/${f.repoPath}`,
577      status: f.status,
578      added: f.added,
579      removed: f.removed,
580      ...(f.baseline !== undefined ? { baseline: f.baseline } : {}),
581      ...(f.skipped !== undefined ? { skipped: f.skipped } : {}),
582    })),
583    comments: await read($, reviewCommentsState),
584    findings: await read($, reviewFindingsState),
585    findingsStatus: await read($, reviewFindingsStatusState),
586    run: run === null ? null : { skill: run.skill },
587    skills: bridgeSkills,
588  }
589}
590
591// The operator's decisions from the IDE, oldest first, each answered in the
592// outbox; the IDE removes both files once it has read the answer.
593const applyInbox = async ($: $): Promise<void> => {
594  const at = await sessionFolder($)
595  if (at === undefined || !(await $.fs.exists(`${at.dir}/inbox`))) return
596  const names = async (dir: string) =>
597    (await $.fs.exists(dir)) ? (await $.fs.list(dir)).filter(e => e.kind === 'file').map(e => e.name) : []
598  const answered: { name: string; ok: boolean; message: string }[] = []
599  for (const name of pendingEntries(await names(`${at.dir}/inbox`), await names(`${at.dir}/outbox`))) {
600    try {
601      const entry = parseInboxEntry(await $.fs.read(`${at.dir}/inbox/${name}`))
602      answered.push({ name, ...('error' in entry ? { ok: false, message: entry.error } : await applyEntry($, entry)) })
603    } catch (error) {
604      answered.push({ name, ok: false, message: String(error) })
605    }
606  }
607  if (answered.length === 0) return
608  // The state first, the answers after: an IDE that reads its answer then the
609  // state must find the change it asked for already there.
610  reviewDirty = true
611  await refreshReview($)
612  await publishState($)
613  for (const a of answered) await $.fs.write(`${at.dir}/outbox/${a.name}`, answer(a.ok, a.message))
614}
615
616// One review decision, through the functions the Review panel calls.
617const applyReviewEntry = async ($: $, entry: ReviewEntry): Promise<{ ok: boolean; message: string }> => {
618  reviewDirty = true
619  switch (entry.kind) {
620    case 'review-comment': {
621      if (!(await read($, reviewFilesState)).some(f => f.path === entry.path))
622        return { ok: false, message: `${entry.path} is not among the files under review.` }
623      const id = `c${await $.clock.now()}`
624      const { kind: _kind, ...comment } = entry
625      await update($, reviewCommentsState, list => [...(list ?? []), { ...comment, id }])
626      return { ok: true, message: id }
627    }
628    case 'review-comment-edit': {
629      let found = false
630      await update($, reviewCommentsState, list =>
631        (list ?? []).map(c => (c.id === entry.id ? ((found = true), { ...c, body: entry.body }) : c)),
632      )
633      return found ? { ok: true, message: 'Comment updated.' } : { ok: false, message: 'No such comment: it was sent or removed.' }
634    }
635    case 'review-uncomment':
636      await removeReviewComment($, entry.id)
637      return { ok: true, message: 'Comment removed.' }
638    case 'review-submit': {
639      const message = await submitReview($, entry.summary)
640      return { ok: message.startsWith('Review submitted'), message }
641    }
642    case 'review-accept': {
643      const message = await acceptReview($)
644      return { ok: message.startsWith('Review accepted'), message }
645    }
646    case 'review-accept-file':
647      return acceptReviewFile($, entry.path)
648    case 'review-run': {
649      const { runnable } = await runnableSkills($)
650      if (!runnable.includes(entry.skill))
651        return { ok: false, message: `${entry.skill} is not a review skill this session can run (${runnable.join(', ') || 'none'}).` }
652      const message = await runReviewSkill($, entry.skill)
653      return { ok: (await read($, reviewRunState))?.skill === entry.skill, message }
654    }
655    case 'review-cancel':
656      await cancelAiReview($)
657      return { ok: true, message: 'AI review cancelled.' }
658    case 'review-scope':
659      await update($, reviewScopeState, () => entry.scope)
660      await loadChangedFiles($)
661      return { ok: true, message: `Review scope: ${entry.scope === 'session' ? 'changes made in this session' : 'changes since the last commit'}.` }
662    case 'finding': {
663      if (!(await read($, reviewFindingsState)).some(f => f.id === entry.id))
664        return { ok: false, message: 'No such finding: the review was sent or replaced.' }
665      await decideFinding($, entry.id, entry.decision)
666      if (entry.note !== undefined) await saveFindingNote($, entry.id, entry.note)
667      return { ok: true, message: 'Finding updated.' }
668    }
669  }
670}
671
672// One decision, through the functions the panes call.
673const applyEntry = async ($: $, entry: InboxEntry): Promise<{ ok: boolean; message: string }> => {
674  if (entry.kind !== 'phase' && entry.kind !== 'request' && entry.kind !== 'plan') return applyReviewEntry($, entry)
675  if (entry.kind === 'phase') {
676    const now = await operatorSetPhase($, entry.to)
677    $.ui.toast(`Moonlight: phase is now ${phaseLabel(now)} (set from the IDE)`)
678    return { ok: now === entry.to, message: `The phase is ${now}.` }
679  }
680  if (entry.kind === 'request') {
681    if (pending === undefined) return { ok: false, message: 'No phase request is waiting: it was answered already.' }
682    await decide($, entry.approve, entry.reason)
683    return { ok: true, message: entry.approve ? `The phase is ${await readPhase($)}.` : 'Denied: the agent was told.' }
684  }
685  const doc = await read($, planDocState)
686  if (doc === null || doc.version !== entry.version)
687    return { ok: false, message: `Plan v${entry.version} is not the current plan${doc !== null ? ` (v${doc.version} is)` : ''}.` }
688  if (doc.decided !== undefined) return { ok: false, message: `Plan v${doc.version} is already ${VERDICT_WORD[doc.decided]}.` }
689  const count = parsePlan(doc.markdown).length
690  const outside = entry.comments.find(c => c.start < 1 || c.end < c.start || c.end > count)
691  if (outside !== undefined) return { ok: false, message: `No such lines ${outside.start}-${outside.end}: the plan has ${count}.` }
692  for (const c of entry.comments) await addPlanComment($, c.start, c.end, c.body)
693  // A rejection's reason is its own text; any other note joins the panel's.
694  if (entry.verdict !== 'reject' && entry.note !== '')
695    await update($, planNoteState, current => [current.trim(), entry.note].filter(t => t !== '').join('\n\n'))
696  const message = await sendPlanVerdict($, entry.verdict, entry.verdict === 'reject' ? entry.note : undefined)
697  return { ok: (await read($, planDocState))?.decided === entry.verdict, message }
698}
699
700// ---- Phase control ----
701
702// A note the model reads at its next turn; starts none.
703const tellModel = async ($: $, text: string): Promise<void> => {
704  await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
705}
706
707// A prompt the model acts on: a turn of its own, once the session is idle.
708const askModel = async ($: $, text: string): Promise<void> => {
709  await $.prompt.submit({ text })
710}
711
712// `reason`: why the operator denied it, when they said (the IDE asks).
713const decide = async ($: $, isApproved: boolean, reason?: string): Promise<void> => {
714  const request = pending
715  await setPending($, undefined)
716  if (request === undefined) return
717  if (!isApproved) {
718    $.ui.toast(`Moonlight: phase change to ${request.to} denied`)
719    const denied =
720      `[moonlight] The operator DENIED the phase change to ${request.to}` +
721      (reason !== undefined ? `: ${reason}` : '') +
722      '. Stay in the current phase.'
723    // The agent may be idle, waiting on this answer: a prompt wakes it.
724    void askModel($, denied).catch(() => tellModel($, denied))
725    return
726  }
727  const now = await setPhase($, request.to)
728  $.ui.toast(`Moonlight: phase is now ${phaseLabel(now)} (${phaseRule(now)})`)
729  const told = `[moonlight] The operator APPROVED the phase change. The phase is now ${now}: ${phaseRule(now)}.`
730  void askModel($, told).catch(() => tellModel($, told))
731}
732
733// The operator's own change (/phase, the hint, the pane): any pending request
734// is moot, and the agent learns the new rule at its next turn, from a note
735// that starts no turn of its own.
736const operatorSetPhase = async ($: $, to: Phase): Promise<Phase> => {
737  const before = await readPhase($)
738  await setPending($, undefined)
739  const now = await setPhase($, to)
740  if (now !== before)
741    await tellModel($, `[moonlight] The operator changed the phase to ${now}: ${phaseRule(now)}.`).catch(() => undefined)
742  return now
743}
744
745// Says where the pane stands: a surface may place no panes at all.
746// A pane that cannot open never fails what opened it (a request, a command).
747const openPhasePane = async ($: $, isFocused: boolean): Promise<string> => {
748  try {
749    const opened = await $.ui.open(isFocused ? { id: PANE, title: 'Moonlight', focus: true } : { id: PANE, title: 'Moonlight' })
750    return opened.isPlaced ? 'pane placed' : `pane not placed: ${opened.reason}`
751  } catch (error) {
752    return `pane not opened: ${String(error)}`
753  }
754}
755
756// ---- Review panel ----
757
758// A pane docked on the right in the terminal's fullscreen layout (inline
759// elsewhere): the changed files, then one file's diff where a click on a line
760// number anchors a comment (a second click extends it to a range).
761const REVIEW_PANE = 'moonlight-review'
762const reviewViewState = atom({ plugin: 'moonlightcode', key: 'reviewView' } as const, { kind: 'files' } as MoonlightcodeReviewView)
763const reviewFilesState = atom({ plugin: 'moonlightcode', key: 'reviewFiles' } as const, [])
764const reviewRowsState = atom({ plugin: 'moonlightcode', key: 'reviewRows' } as const, [])
765const reviewSelectionState = atom({ plugin: 'moonlightcode', key: 'reviewSelection' } as const, null)
766const reviewCommentsState = atom({ plugin: 'moonlightcode', key: 'reviewComments' } as const, [])
767// Why there is nothing to review, when it is not simply "no changes".
768const reviewProblemState = atom({ plugin: 'moonlightcode', key: 'reviewProblem' } as const, null)
769// Unified or side by side; `auto` picks side by side when the panel is wide enough.
770const reviewLayoutState = atom({ plugin: 'moonlightcode', key: 'reviewLayout' } as const, 'auto' as ReviewLayout)
771// Docked panel opened at the terminal's full width, like a modal.
772const reviewExpandedState = atom({ plugin: 'moonlightcode', key: 'reviewExpanded' } as const, false)
773// This session's changes (against baselines) or everything against HEAD.
774const reviewScopeState = atom({ plugin: 'moonlightcode', key: 'reviewScope' } as const, 'auto' as ReviewScope)
775const reviewHasBaselinesState = atom({ plugin: 'moonlightcode', key: 'reviewHasBaselines' } as const, false)
776// The lines of a comment being written, before its last one.
777const reviewDraftState = atom({ plugin: 'moonlightcode', key: 'reviewDraft' } as const, [] as string[])
778// The rows picked in the open file's diff; `reviewSelection` is their anchor.
779const reviewRowSelectionState = atom({ plugin: 'moonlightcode', key: 'reviewRowSelection' } as const, null as RowRange | null)
780// What an attached review skill found, with the operator's triage.
781const reviewFindingsState = atom({ plugin: 'moonlightcode', key: 'reviewFindings' } as const, [] as TriagedFinding[])
782const reviewFindingsStatusState = atom({ plugin: 'moonlightcode', key: 'reviewFindingsStatus' } as const, null as string | null)
783const reviewNoteForState = atom({ plugin: 'moonlightcode', key: 'reviewNoteFor' } as const, null as string | null)
784// One AI review at a time; only its skill's prompt gets the contract.
785const reviewRunState = atom(
786  { plugin: 'moonlightcode', key: 'reviewRun' } as const,
787  null as { skill: string; isPrompted: boolean; isFromPanel?: boolean; turnId?: string } | null,
788)
789
790// The prompt a panel run submits; `turn.start` knows the run's turn by it.
791const runPrompt = (skill: string): string => `Run the ${skill} skill to review my changes.`
792
793// The latest turn to start: where a skill started by hand runs.
794let lastTurnId: string | undefined
795const reviewPickingState = atom({ plugin: 'moonlightcode', key: 'reviewPicking' } as const, false)
796
797// Resolved on first use, so the panel works before or without the gate's init.
798let reviewRoot: string | undefined
799
800// The session's project root; the plugin test kit's engine has no `root()`,
801// so fall back to the cwd there.
802const sessionRoot = async ($: $): Promise<string> => {
803  try {
804    return await $.session.root()
805  } catch {
806    return await $.session.cwd()
807  }
808}
809
810const resolveReviewRoot = async ($: $): Promise<string | undefined> =>
811  (reviewRoot ??= root ?? (await realPathOf($, await sessionRoot($))))
812
813// Runs git in `cwd`, the session root by default.
814const git = async ($: $, args: readonly string[], cwd?: string): Promise<string> => {
815  const where = cwd ?? (await resolveReviewRoot($))
816  if (where === undefined) return ''
817  const ran = await $.process.run(['git', ...args], { cwd: where })
818  return ran.stdout
819}
820
821// ---- Baselines ----
822
823// Each file as it was before the agent first changed it this session, so a
824// review shows the agent's changes, git or not. Under ~/.moonlight, which the
825// agent cannot write. Dropped on Accept and on /clear; a session left without
826// either is swept once stale.
827const BASELINE_STALE_MS = 7 * 24 * 60 * 60 * 1000
828const SESSION_ID = /^[A-Za-z0-9_-]+$/
829
830// HOME is read here too, so the panel works before or without the gate's init.
831const sessionsDir = async ($: $): Promise<string | undefined> => {
832  const at = home ?? (await $.env.get('HOME').catch(() => undefined))
833  return at === undefined || at === '' ? undefined : `${at}/.moonlight/mod/sessions`
834}
835
836const baselineDir = async ($: $, sessionId?: string): Promise<string | undefined> => {
837  const sessions = await sessionsDir($)
838  if (sessions === undefined) return undefined
839  const id = sessionId ?? (await $.session.id())
840  return SESSION_ID.test(id) ? `${sessions}/${id}/baseline` : undefined
841}
842
843const readBaselineIndex = async ($: $): Promise<BaselineIndex> => {
844  const dir = await baselineDir($)
845  if (dir === undefined || !(await $.fs.exists(`${dir}/index.json`))) return {}
846  return parseBaselineIndex(await $.fs.read(`${dir}/index.json`))
847}
848
849// Called before an Edit, Write or NotebookEdit runs; only the first change of
850// a file is kept. A failure here never blocks the tool: that file then shows
851// against HEAD.
852const captureBaseline = async ($: $, path: string | undefined): Promise<void> => {
853  try {
854    const dir = await baselineDir($)
855    const at = await resolveReviewRoot($)
856    if (dir === undefined || at === undefined || path === undefined) return
857    const given = path.startsWith('/') ? path : `${at}/${path}`
858    // The root is a real path: so is the key, when the file exists.
859    const absolute = (await $.fs.exists(given)) ? ((await realPathOf($, given)) ?? given) : given
860    const index = await readBaselineIndex($)
861    if (index[absolute] !== undefined) return
862    let entry: BaselineEntry
863    if (!(await $.fs.exists(absolute))) entry = { kind: 'absent' }
864    else {
865      const kept = baselineOf(await $.fs.read(absolute))
866      if ('reason' in kept) entry = { kind: 'skipped', reason: kept.reason }
867      else {
868        const blob = `${dir}/blobs/${contentHash(kept.text)}`
869        if (!(await $.fs.exists(blob))) await $.fs.write(blob, kept.text)
870        entry = { kind: 'copy', blob }
871      }
872    }
873    await $.fs.write(`${dir}/index.json`, JSON.stringify({ ...index, [absolute]: entry }))
874    reviewDirty = true
875  } catch (error) {
876    $.ui.toast(`Moonlight: no baseline for ${path}: ${String(error)}`)
877  }
878}
879
880// `$.fs` has no delete: `rm -r`, on a session's baseline folder only.
881const dropBaseline = async ($: $, sessionId?: string): Promise<void> => {
882  const dir = await baselineDir($, sessionId)
883  if (dir === undefined || !(await $.fs.exists(dir))) return
884  await $.process.run(['rm', '-r', '--', dir])
885}
886
887// Baselines of sessions whose heartbeat is older than a week: never accepted
888// nor cleared, and not coming back.
889const sweepStaleBaselines = async ($: $): Promise<void> => {
890  const sessions = await sessionsDir($)
891  if (sessions === undefined || !(await $.fs.exists(sessions))) return
892  const now = await $.clock.now()
893  const current = await $.session.id()
894  for (const entry of await $.fs.list(sessions)) {
895    if (entry.name === current || !SESSION_ID.test(entry.name)) continue
896    if (!(await $.fs.exists(`${sessions}/${entry.name}/baseline`))) continue
897    const heartbeat = `${sessions}/${entry.name}/heartbeat`
898    const seenAt = (await $.fs.exists(heartbeat)) ? (await $.fs.stat(heartbeat)).mtimeMs : 0
899    if (now - seenAt > BASELINE_STALE_MS) await dropBaseline($, entry.name)
900  }
901}
902
903// The files changed since their baselines, under the session root.
904const baselineFiles = async ($: $, at: string): Promise<ChangedFile[]> => {
905  const files: ChangedFile[] = []
906  for (const [absolute, entry] of Object.entries(await readBaselineIndex($))) {
907    if (!absolute.startsWith(`${at}/`)) continue
908    const path = absolute.slice(at.length + 1)
909    const isPresent = await $.fs.exists(absolute)
910    const place = { path, repo: at, repoPath: path, status: baselineStatus(entry, isPresent) }
911    if (entry.kind === 'skipped') {
912      files.push({ ...place, added: 0, removed: 0, skipped: entry.reason })
913      continue
914    }
915    const before = entry.kind === 'copy' ? entry.blob : '/dev/null'
916    if (!isPresent && before === '/dev/null') continue
917    const stat = parseNumstat(await git($, ['diff', '--no-index', '--numstat', '--', before, isPresent ? absolute : '/dev/null'], at))
918    if (stat.added === 0 && stat.removed === 0) continue
919    files.push({ ...place, ...stat, baseline: before })
920  }
921  return files.sort((a, b) => a.path.localeCompare(b.path))
922}
923
924// The repositories to review: the one the root is in, else every one found in
925// its subfolders (a session opened on a folder of projects).
926const findRepos = async ($: $): Promise<Repo[]> => {
927  const at = await resolveReviewRoot($)
928  if (at === undefined) return []
929  if ((await git($, ['rev-parse', '--is-inside-work-tree'])).trim() === 'true') {
930    const top = (await git($, ['rev-parse', '--show-toplevel'])).trim()
931    return [{ dir: top.startsWith('/') ? top : at, prefix: '' }]
932  }
933  const skip = REPO_SEARCH_SKIP.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name]))
934  const found = await $.process.run(
935    ['find', at, '-maxdepth', String(REPO_SEARCH_DEPTH), '(', ...skip, ')', '-prune', '-o', '-name', '.git', '-print', '-prune'],
936    { cwd: at },
937  )
938  return parseRepoDirs(found.stdout, at)
939}
940
941const loadChangedFiles = async ($: $): Promise<void> => {
942  const at = await resolveReviewRoot($)
943  const sinceBaselines = at === undefined ? [] : await baselineFiles($, at)
944  const hasBaselines = Object.keys(await readBaselineIndex($)).length > 0
945  await update($, reviewHasBaselinesState, () => hasBaselines)
946  if (effectiveScope(await read($, reviewScopeState), hasBaselines) === 'session') {
947    await update($, reviewProblemState, () => null)
948    await update($, reviewFilesState, () => sinceBaselines)
949    return
950  }
951  const repos = await findRepos($)
952  await update($, reviewProblemState, () =>
953    repos.length > 0
954      ? null
955      : `No git repository in ${reviewRoot ?? 'this project'} or its subfolders (${REPO_SEARCH_DEPTH} levels): nothing to diff against.`,
956  )
957  const files: ChangedFile[] = []
958  for (const repo of repos) {
959    const numstat = await git($, ['diff', '--numstat', 'HEAD'], repo.dir)
960    const porcelain = await git($, ['status', '--porcelain', '--untracked-files=all'], repo.dir)
961    const listed = parseChangedFiles(numstat, porcelain)
962    // `git diff --numstat HEAD` leaves untracked files out: count their lines
963    // as a diff from nothing, up to a cap.
964    let counted = 0
965    for (const file of listed) {
966      if (file.status !== '??' || file.added !== 0 || file.removed !== 0) continue
967      if (counted >= UNTRACKED_COUNT_MAX) {
968        file.skipped = `not counted (over ${UNTRACKED_COUNT_MAX} untracked files)`
969        continue
970      }
971      counted += 1
972      Object.assign(file, untrackedCount(await git($, ['diff', '--no-index', '--numstat', '--', '/dev/null', file.path], repo.dir)))
973    }
974    files.push(...inRepo(listed, repo))
975  }
976  await update($, reviewFilesState, () => files)
977}
978
979const diffAgainstHead = async ($: $, inside: string, cwd: string | undefined): Promise<string> => {
980  const tracked = await git($, ['diff', 'HEAD', '--', inside], cwd)
981  // An untracked file has no diff against HEAD: show it whole, as added.
982  return tracked !== '' ? tracked : await git($, ['diff', '--no-index', '--', '/dev/null', inside], cwd)
983}
984
985// Only a file under review: a finding names its file, and the model wrote it.
986const openReviewFile = async ($: $, path: string): Promise<void> => {
987  const file = (await read($, reviewFilesState)).find(f => f.path === path)
988  if (file === undefined) {
989    $.ui.toast(`Moonlight: ${path} is not among the files under review`)
990    return
991  }
992  const cwd = file?.repo
993  const inside = file?.repoPath ?? path
994  const diff =
995    file?.skipped !== undefined
996      ? ''
997      : file?.baseline !== undefined
998        ? await git($, ['diff', '--no-index', '--', file.baseline, file.status === 'D' ? '/dev/null' : inside], cwd)
999        : await diffAgainstHead($, inside, cwd)
1000  await update($, reviewRowsState, () => parseFileDiff(diff))
1001  await clearSelection($)
1002  await update($, reviewViewState, () => ({ kind: 'file', path }))
1003}
1004
1005const showReviewFiles = async ($: $): Promise<void> => {
1006  await clearSelection($)
1007  await update($, reviewViewState, () => ({ kind: 'files' }))
1008  await loadChangedFiles($)
1009}
1010
1011const clearSelection = async ($: $): Promise<void> => {
1012  await update($, reviewRowSelectionState, () => null)
1013  await update($, reviewSelectionState, () => null)
1014}
1015
1016// A click picks the row; a second one extends to every row between, removed,
1017// added and context alike. The anchor the comment gets follows from them.
1018// `index`: the row's place in the file's rows, as the render drew them (a
1019// state read hands back copies, so rows are not found by identity here).
1020const clickReviewLine = async ($: $, path: string, index: number): Promise<void> => {
1021  const rows = await read($, reviewRowsState)
1022  const row = rows[index]
1023  if (row === undefined || anchorOf(row) === undefined) return
1024  const range = nextRowSelection(await read($, reviewRowSelectionState), index)
1025  await update($, reviewRowSelectionState, () => range)
1026  await update($, reviewSelectionState, () => (range === null ? null : anchorOfRows(rows, path, range)))
1027  await update($, reviewDraftState, () => [])
1028}
1029
1030// The comment field's Enter: a line ending with `\` joins the draft, any
1031// other line ends the comment.
1032const submitCommentLine = async ($: $, value: string): Promise<void> => {
1033  const next = continueDraft(await read($, reviewDraftState), value)
1034  if ('draft' in next) {
1035    await update($, reviewDraftState, () => next.draft)
1036    return
1037  }
1038  await update($, reviewDraftState, () => [])
1039  await addReviewComment($, next.body)
1040}
1041
1042const addReviewComment = async ($: $, body: string): Promise<void> => {
1043  const text = body.trim()
1044  const anchor = await read($, reviewSelectionState)
1045  if (text === '' || anchor === null) return
1046  const id = `c${await $.clock.now()}`
1047  // The loaded rows are the open file's: from /ml-review comment on another
1048  // file, there is no excerpt.
1049  const view = await read($, reviewViewState)
1050  const picked = await read($, reviewRowSelectionState)
1051  const rows = await read($, reviewRowsState)
1052  const excerpt =
1053    view.kind !== 'file' || view.path !== anchor.path
1054      ? ''
1055      : picked !== null
1056        ? excerptOfRows(rows, picked)
1057        : excerptOf(rows, anchor)
1058  await update($, reviewCommentsState, list => [
1059    ...(list ?? []),
1060    { ...anchor, id, body: text, ...(excerpt !== '' ? { excerpt } : {}) },
1061  ])
1062  await clearSelection($)
1063}
1064
1065const removeReviewComment = async ($: $, id: string): Promise<void> => {
1066  await update($, reviewCommentsState, list => (list ?? []).filter(c => c.id !== id))
1067}
1068
1069// Closes the review: drops the baselines, so the next one shows only what the
1070// agent changes from here; stays on this session's changes, not HEAD's.
1071const acceptReview = async ($: $): Promise<string> => {
1072  const comments = await read($, reviewCommentsState)
1073  const kept = (await read($, reviewFindingsState)).filter(f => f.decision === 'keep')
1074  if (comments.length > 0 || kept.length > 0) {
1075    const pending = [
1076      ...(comments.length > 0 ? [counted(comments.length, 'comment')] : []),
1077      ...(kept.length > 0 ? [counted(kept.length, 'kept finding')] : []),
1078    ].join(' and ')
1079    const refused = `Not accepted: ${pending} pending. Send them, or remove or dismiss them first.`
1080    $.ui.toast(`Moonlight: ${refused}`)
1081    return refused
1082  }
1083  await update($, reviewFindingsState, () => [])
1084  await update($, reviewFindingsStatusState, () => null)
1085  await dropBaseline($)
1086  await update($, reviewScopeState, () => 'session')
1087  await clearSelection($)
1088  await update($, reviewDraftState, () => [])
1089  await update($, reviewViewState, () => ({ kind: 'files' }))
1090  await loadChangedFiles($)
1091  try {
1092    // A note, not a prompt: there is nothing left for the agent to do.
1093    await tellModel(
1094      $,
1095      '[moonlight] Review ACCEPTED: the operator reviewed your changes and all is good, nothing to change. ' +
1096        'The next review starts from here.',
1097    )
1098  } catch {
1099    // The review is closed whether or not the note reaches the agent.
1100  }
1101  $.ui.toast('Moonlight: all good, review accepted')
1102  return 'Review accepted, all good: the next review starts from here.'
1103}
1104
1105// "Mark reviewed" for one file (the IDE's per-file accept): its baseline goes,
1106// so it leaves the list until the agent changes it again. Session scope only:
1107// against HEAD there is no baseline to drop.
1108const acceptReviewFile = async ($: $, path: string): Promise<{ ok: boolean; message: string }> => {
1109  const file = (await read($, reviewFilesState)).find(f => f.path === path)
1110  if (file === undefined) return { ok: false, message: `${path} is not among the files under review.` }
1111  const dir = await baselineDir($)
1112  const absolute = `${file.repo}/${file.repoPath}`
1113  const index = await readBaselineIndex($)
1114  if (dir === undefined || index[absolute] === undefined)
1115    return { ok: false, message: `${path} is reviewed against the last commit: commit it, or accept the whole review.` }
1116  const { [absolute]: _accepted, ...rest } = index
1117  await $.fs.write(`${dir}/index.json`, JSON.stringify(rest))
1118  await update($, reviewCommentsState, list => (list ?? []).filter(c => c.path !== path))
1119  await loadChangedFiles($)
1120  return { ok: true, message: `${path} marked reviewed.` }
1121}
1122
1123// Sends every comment and every kept finding to the agent as one message,
1124// then starts a new review.
1125const submitReview = async ($: $, summary: string): Promise<string> => {
1126  const comments = await read($, reviewCommentsState)
1127  const findings = await read($, reviewFindingsState)
1128  const kept = findings.filter(f => f.decision === 'keep')
1129  if (comments.length === 0 && kept.length === 0) return 'Nothing to submit: add a comment, or keep a finding.'
1130  // Taken off the panel at once, so a second press sends nothing twice; the
1131  // prompt enters once the session is idle, and a failure puts them back.
1132  await update($, reviewCommentsState, () => [])
1133  await update($, reviewFindingsState, () => [])
1134  await update($, reviewNoteForState, () => null)
1135  await clearSelection($)
1136  const sent = comments.map(c => c.id)
1137  const what = [
1138    ...(kept.length > 0 ? [counted(kept.length, 'finding')] : []),
1139    ...(comments.length > 0 ? [counted(comments.length, 'comment')] : []),
1140  ].join(', ')
1141  void askModel($, formatReview(comments, summary, kept.map(formatFinding))).then(
1142    () => $.ui.toast(`Moonlight: review sent (${what})`),
1143    async error => {
1144      await update($, reviewCommentsState, list => [...comments, ...(list ?? []).filter(c => !sent.includes(c.id))])
1145      await update($, reviewFindingsState, list => ((list ?? []).length > 0 ? (list ?? []) : findings))
1146      $.ui.toast(`Moonlight: review not sent, kept on the panel: ${String(error)}`)
1147    },
1148  )
1149  return `Review submitted: ${what}. The agent starts on it once it is idle.`
1150}
1151
1152// ---- Findings: an existing review skill, attached ----
1153
1154// Review skills whose prompt gets the scope and the findings contract.
1155const ATTACHED_KEY = 'attachedReviewSkills'
1156
1157// An unreadable store must not take the panel down: the defaults stand in.
1158// The `reviewSkills` plugin option (a team's choice, e.g. in managed
1159// settings), read at load; the built-in defaults when unset.
1160let configuredSkills: string[] = [...DEFAULT_ATTACHED]
1161
1162// The operator's own list (attach/detach) wins over the option.
1163const attachedSkills = async ($: $): Promise<string[]> => {
1164  try {
1165    return ((await $.store.get(ATTACHED_KEY)) as string[] | undefined) ?? [...configuredSkills]
1166  } catch {
1167    return [...configuredSkills]
1168  }
1169}
1170
1171// The attached skills this session can run, and the ones it cannot: an
1172// attached skill that is not installed is not offered.
1173const runnableSkills = async ($: $): Promise<{ runnable: string[]; missing: string[] }> => {
1174  const attached = await attachedSkills($)
1175  const commands = await $.command
1176    .list()
1177    .then(list => list.map(c => c.name))
1178    .catch(() => undefined)
1179  const runnable = installedSkills(attached, commands !== undefined && commands.length > 0 ? commands : undefined)
1180  return { runnable, missing: attached.filter(s => !runnable.includes(s)) }
1181}
1182
1183const setAttached = async ($: $, change: (list: string[]) => string[]): Promise<string[]> => {
1184  const next = change(await attachedSkills($))
1185  await $.store.set(ATTACHED_KEY, next)
1186  return next
1187}
1188
1189// What the attached skill is to review: the panel's files in its scope.
1190const contractFor = async ($: $): Promise<string> => {
1191  await loadChangedFiles($)
1192  const at = (await resolveReviewRoot($)) ?? '.'
1193  const scope = effectiveScope(await read($, reviewScopeState), await read($, reviewHasBaselinesState))
1194  const files = (await read($, reviewFilesState)).map(f => ({
1195    path: f.path,
1196    status: f.status,
1197    ...(f.baseline !== undefined ? { baseline: f.baseline } : {}),
1198  }))
1199  return findingsContract(at, scope, files)
1200}
hooks/review.ts 367 lines
1// Pure logic of the Review panel: diff lines, line selection, comments.
2// No `$` here, so it is unit-testable.
3
4export type DiffLineKind = 'context' | 'added' | 'removed' | 'hunk'
5
6// One row of a file's diff, as the panel draws it. `newLine` is the line in
7// the current file (comments anchor there, side After); a removed line has
8// only `oldLine` (side Before).
9export type DiffLine = {
10  kind: DiffLineKind
11  text: string
12  oldLine?: number
13  newLine?: number
14}
15
16// `path`: as shown and commented, relative to the session root (the repo's
17// folder first when the root holds several). `repo`: the repo's absolute
18// folder; `repoPath`: the path inside it, as git takes it. `baseline`: set when
19// the diff is against the file's baseline, not HEAD: the blob, or `/dev/null`;
20// `skipped`: why that file has no diff.
21export type ChangedFile = {
22  path: string
23  status: string
24  added: number
25  removed: number
26  repo: string
27  repoPath: string
28  baseline?: string
29  skipped?: string
30}
31
32// "1 comment", "3 comments": what a person reads, never "comment(s)".
33export const counted = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
34
35// ---- Baselines: each file as it was before the agent first changed it ----
36
37// Larger files, and binary ones, are not copied: the panel says why instead.
38export const BASELINE_MAX_BYTES = 1_000_000
39
40// `copy`: the blob holding the file as it was; `absent`: the file did not
41// exist (the agent created it); `skipped`: not copied, `reason` says why.
42export type BaselineEntry =
43  | { kind: 'copy'; blob: string }
44  | { kind: 'absent' }
45  | { kind: 'skipped'; reason: string }
46
47// By absolute path.
48export type BaselineIndex = Record<string, BaselineEntry>
49
50export const parseBaselineIndex = (text: string): BaselineIndex => {
51  try {
52    const parsed: unknown = JSON.parse(text)
53    return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as BaselineIndex) : {}
54  } catch {
55    return {}
56  }
57}
58
59// What to keep of a file about to change: its text, or why not.
60export const baselineOf = (text: string): { text: string } | { reason: string } =>
61  text.length > BASELINE_MAX_BYTES
62    ? { reason: `over ${BASELINE_MAX_BYTES / 1_000_000} MB` }
63    : text.includes('\u0000')
64      ? { reason: 'binary' }
65      : { text }
66
67// Names a blob by its content (cyrb53, 53 bits): the same content is stored once.
68export const contentHash = (text: string): string => {
69  let h1 = 0xdeadbeef
70  let h2 = 0x41c6ce57
71  for (let i = 0; i < text.length; i += 1) {
72    const code = text.charCodeAt(i)
73    h1 = Math.imul(h1 ^ code, 2654435761)
74    h2 = Math.imul(h2 ^ code, 1597334677)
75  }
76  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
77  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
78  return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0')
79}
80
81// A file's status against its baseline: created, deleted, or modified.
82export const baselineStatus = (entry: BaselineEntry, isPresent: boolean): string =>
83  entry.kind === 'absent' ? 'A' : isPresent ? 'M' : 'D'
84
85// The first `git diff --numstat` line's counts.
86export const parseNumstat = (numstat: string): { added: number; removed: number } => {
87  const [added = '0', removed = '0'] = (numstat.split('\n')[0] ?? '').split('\t')
88  return { added: Number(added) || 0, removed: Number(removed) || 0 }
89}
90
91// An untracked file's count, from `git diff --no-index --numstat /dev/null f`:
92// git prints `-` for a binary file, which is not "no lines".
93export const untrackedCount = (numstat: string): { added: number; removed: number } | { skipped: string } =>
94  /^-\t-\t/.test(numstat) ? { skipped: 'binary' } : parseNumstat(numstat)
95
96// Past this many untracked files, the rest are listed uncounted: one git call
97// each, on every refresh, would stall the panel (an un-ignored build folder).
98export const UNTRACKED_COUNT_MAX = 200
99
100// Which changes the panel shows: the agent's since the baselines (`session`),
101// or everything against HEAD; `auto` picks `session` once a baseline exists.
102export type ReviewScope = 'auto' | 'session' | 'head'
103
104export const effectiveScope = (scope: ReviewScope, hasBaselines: boolean): 'session' | 'head' =>
105  scope === 'auto' ? (hasBaselines ? 'session' : 'head') : scope
106
107// A git repository under the session root: its absolute folder, and its folder
108// relative to the root (`''` for the root itself).
109export type Repo = { dir: string; prefix: string }
110
111// How deep under the root to look for repositories, and what never to enter.
112export const REPO_SEARCH_DEPTH = 4
113export const REPO_SEARCH_SKIP = ['node_modules', 'target', '.venv', 'dist', 'build']
114
115// Parses `find` output (one `<dir>/.git` per line) into repos under `root`.
116export const parseRepoDirs = (found: string, root: string): Repo[] =>
117  found
118    .split('\n')
119    .map(line => line.trim())
120    .filter(line => line.endsWith('/.git'))
121    .map(line => line.slice(0, -'/.git'.length))
122    .filter(dir => dir === root || dir.startsWith(`${root}/`))
123    .map(dir => ({ dir, prefix: dir === root ? '' : dir.slice(root.length + 1) }))
124    .sort((a, b) => a.prefix.localeCompare(b.prefix))
125
126// Places one repo's changed files under the session root.
127export const inRepo = (files: readonly Omit<ChangedFile, 'repo' | 'repoPath'>[], repo: Repo): ChangedFile[] =>
128  files.map(file => ({
129    ...file,
130    path: repo.prefix === '' ? file.path : `${repo.prefix}/${file.path}`,
131    repo: repo.dir,
132    repoPath: file.path,
133  }))
134
135export type LineSide = 'before' | 'after'
136
137// A comment's anchor: a line range on one side of a file's diff.
138// `removed`: the old lines a range on the new side also covers.
139export type LineAnchor = {
140  path: string
141  side: LineSide
142  start: number
143  end: number
144  removed?: { start: number; end: number }
145}
146
147// `excerpt`: the commented lines as they were when the comment was written.
148export type ReviewComment = LineAnchor & { id: string; body: string; excerpt?: string }
149
150const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
151
152// Parses one file's unified diff (`git diff` output) into drawable rows.
153// File headers (`diff --git`, `index`, `---`, `+++`) are skipped.
154export const parseFileDiff = (diff: string): DiffLine[] => {
155  const rows: DiffLine[] = []
156  let oldLine = 0
157  let newLine = 0
158  let isInHunk = false
159  for (const raw of diff.split('\n')) {
160    const header = HUNK_HEADER.exec(raw)
161    if (header !== null) {
162      oldLine = Number(header[1])
163      newLine = Number(header[2])
164      isInHunk = true
165      rows.push({ kind: 'hunk', text: raw })
166      continue
167    }
168    if (!isInHunk || raw.startsWith('\\')) continue
169    if (raw.startsWith('+')) rows.push({ kind: 'added', text: raw.slice(1), newLine: newLine++ })
170    else if (raw.startsWith('-')) rows.push({ kind: 'removed', text: raw.slice(1), oldLine: oldLine++ })
171    else if (raw.startsWith(' ') || raw === '')
172      rows.push({ kind: 'context', text: raw.slice(1), oldLine: oldLine++, newLine: newLine++ })
173  }
174  // A trailing newline in the diff text leaves one empty context row: drop it.
175  while (rows.length > 0 && rows[rows.length - 1]!.kind === 'context' && rows[rows.length - 1]!.text === '' && diff.endsWith('\n'))
176    rows.pop()
177  return rows
178}
179
180// Parses `git diff --numstat` plus `git status --porcelain` into the files list.
181export const parseChangedFiles = (numstat: string, porcelain: string): Omit<ChangedFile, 'repo' | 'repoPath'>[] => {
182  const stats = new Map<string, { added: number; removed: number }>()
183  for (const line of numstat.split('\n')) {
184    const [added, removed, path] = line.split('\t')
185    if (path === undefined) continue
186    stats.set(path, { added: Number(added) || 0, removed: Number(removed) || 0 })
187  }
188  const files: Omit<ChangedFile, 'repo' | 'repoPath'>[] = []
189  for (const line of porcelain.split('\n')) {
190    if (line.length < 4) continue
191    const status = line.slice(0, 2).trim() || 'M'
192    const path = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop()!
193    const stat = stats.get(path) ?? { added: 0, removed: 0 }
194    files.push({ path, status, ...stat })
195  }
196  return files.sort((a, b) => a.path.localeCompare(b.path))
197}
198
199// Where a row can be commented: the current file's line, else the old one.
200export const anchorOf = (row: DiffLine): { side: LineSide; line: number } | undefined =>
201  row.newLine !== undefined
202    ? { side: 'after', line: row.newLine }
203    : row.oldLine !== undefined
204      ? { side: 'before', line: row.oldLine }
205      : undefined
206
207// Line selection by clicks: a first click picks a line, a second click on the
208// same side extends it to a range, a click on a line already the whole range
209// clears it. A click on the other side starts over there.
210// The rows picked in the open file's diff, by index: a range may mix removed,
211// added and context lines, as a changed block does.
212export type RowRange = { from: number; to: number }
213
214// Click a row; a second click extends to a range; clicking the single picked
215// row again clears it; a click after a range starts over.
216export const nextRowSelection = (current: RowRange | null, clicked: number): RowRange | null => {
217  if (current === null || current.from !== current.to) return { from: clicked, to: clicked }
218  if (current.from === clicked) return null
219  return { from: Math.min(current.from, clicked), to: Math.max(current.from, clicked) }
220}
221
222// Where a comment on those rows anchors: on the current file's lines they
223// cover, the removed lines among them noted aside; on the old lines when
224// they are all removed ones.
225export const anchorOfRows = (rows: readonly DiffLine[], path: string, range: RowRange): LineAnchor | null => {
226  const picked = rows.slice(range.from, range.to + 1)
227  const after = picked.flatMap(r => (r.newLine === undefined ? [] : [r.newLine]))
228  const before = picked.flatMap(r => (r.kind === 'removed' && r.oldLine !== undefined ? [r.oldLine] : []))
229  if (after.length > 0) {
230    const anchor: LineAnchor = { path, side: 'after', start: Math.min(...after), end: Math.max(...after) }
231    return before.length > 0 ? { ...anchor, removed: { start: Math.min(...before), end: Math.max(...before) } } : anchor
232  }
233  return before.length > 0 ? { path, side: 'before', start: Math.min(...before), end: Math.max(...before) } : null
234}
235
236// The picked rows as diff text, so the agent sees what was commented.
237export const excerptOfRows = (rows: readonly DiffLine[], range: RowRange): string =>
238  rows
239    .slice(range.from, range.to + 1)
240    .filter(row => row.kind !== 'hunk')
241    .map(row => `${row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ' '}${row.text}`)
242    .join('\n')
243
244// ---- Drawing rows with the engine's diff highlighter ----
245
246// Control characters a `Code` source may not hold (tab is allowed).
247const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g
248
249// One row as a one-line unified-diff hunk, for a `Code` element with
250// `format: 'diff'`: the engine then draws that row's line numbers, marker,
251// add/remove colour and syntax highlighting, GitHub style.
252export const lineHunk = (row: DiffLine): string => {
253  const text = row.text.replace(CONTROL, '')
254  if (row.kind === 'added') return `@@ -${row.newLine! - 1},0 +${row.newLine},1 @@\n+${text}`
255  if (row.kind === 'removed') return `@@ -${row.oldLine},1 +${row.oldLine! - 1},0 @@\n-${text}`
256  return `@@ -${row.oldLine},1 +${row.newLine},1 @@\n ${text}`
257}
258
259// One row of the side-by-side view: the old file on the left, the new on
260// the right. A changed block pairs its removed lines with its added ones,
261// line by line, as GitHub's split view does; context lines sit on both sides.
262export type SplitRow =
263  | { kind: 'hunk'; text: string }
264  | { kind: 'pair'; left?: DiffLine; right?: DiffLine }
265
266export const toSplitRows = (rows: readonly DiffLine[]): SplitRow[] => {
267  const split: SplitRow[] = []
268  let removed: DiffLine[] = []
269  let added: DiffLine[] = []
270  const flush = (): void => {
271    for (let i = 0; i < Math.max(removed.length, added.length); i += 1)
272      split.push({ kind: 'pair', left: removed[i], right: added[i] })
273    removed = []
274    added = []
275  }
276  for (const row of rows) {
277    if (row.kind === 'removed') {
278      // A removed line after added ones starts a new changed block.
279      if (added.length > 0) flush()
280      removed.push(row)
281    } else if (row.kind === 'added') added.push(row)
282    else {
283      flush()
284      split.push(row.kind === 'hunk' ? { kind: 'hunk', text: row.text } : { kind: 'pair', left: row, right: row })
285    }
286  }
287  flush()
288  return split
289}
290
291// The side-by-side view only when each side keeps room for code.
292export const SPLIT_MIN_COLUMNS = 140
293
294export type ReviewLayout = 'auto' | 'unified' | 'split'
295
296export const isSplit = (layout: ReviewLayout, bodyColumns: number): boolean =>
297  layout === 'split' || (layout === 'auto' && bodyColumns >= SPLIT_MIN_COLUMNS)
298
299export const isInAnchor = (anchor: LineAnchor | null, path: string, side: LineSide, line: number): boolean =>
300  anchor !== null && anchor.path === path && anchor.side === side && line >= anchor.start && line <= anchor.end
301
302const span = (start: number, end: number): string => (start === end ? `${start}` : `${start}-${end}`)
303
304export const anchorLabel = (anchor: LineAnchor): string =>
305  `${anchor.path}:${span(anchor.start, anchor.end)}` +
306  (anchor.side === 'before' ? ' (removed)' : '') +
307  (anchor.removed !== undefined ? ` (+ removed ${span(anchor.removed.start, anchor.removed.end)})` : '')
308
309// A comment of several lines from a one-line field: a line ending with `\`
310// goes on to the next, as in a shell; the first line without it ends the
311// comment. Answers the draft so far, or the whole body.
312export const continueDraft = (draft: readonly string[], value: string): { draft: string[] } | { body: string } => {
313  const line = value.replace(/\s+$/, '')
314  if (line.endsWith('\\')) return { draft: [...draft, line.slice(0, -1).replace(/\s+$/, '')] }
315  return { body: [...draft, line].join('\n').trim() }
316}
317
318// The rows a selection covers, as text, so the agent sees what was commented.
319export const excerptOf = (rows: readonly DiffLine[], anchor: LineAnchor): string =>
320  rows
321    .filter(row => {
322      const at = anchorOf(row)
323      return at !== undefined && isInAnchor(anchor, anchor.path, at.side, at.line)
324    })
325    .map(row => `${row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ' '}${row.text}`)
326    .join('\n')
327
328// The review as one message to the agent: an optional summary, then every
329// comment grouped by file, in line order, each with the lines it is about.
330// `findings`: the AI reviewer's findings the operator kept, already formatted;
331// they lead, worst first, with the operator's decision on each.
332export const formatReview = (
333  comments: readonly ReviewComment[],
334  summary: string,
335  findings: readonly string[] = [],
336): string => {
337  const byFile = new Map<string, ReviewComment[]>()
338  for (const comment of comments) byFile.set(comment.path, [...(byFile.get(comment.path) ?? []), comment])
339  const sections = [...byFile.entries()].map(([path, list]) => {
340    const items = [...list]
341      .sort((a, b) => a.start - b.start)
342      .map(c => {
343        const quote =
344          c.excerpt !== undefined && c.excerpt !== ''
345            ? `\n  \`\`\`diff\n${c.excerpt.replace(/^/gm, '  ')}\n  \`\`\``
346            : ''
347        // A body of several lines stays inside its list item.
348        return `- ${anchorLabel(c)}: ${c.body.replace(/\n/g, '\n  ')}${quote}`
349      })
350    return [`### ${path}`, ...items].join('\n')
351  })
352  const parts = [
353    ...(findings.length > 0 ? [`${findings.length} finding(s) the operator kept from the AI review`] : []),
354    ...(comments.length > 0 ? [`${comments.length} comment(s) on ${byFile.size} file(s)`] : []),
355  ]
356  return [
357    `[moonlight] Code review from the operator: ${parts.join(', ')}.`,
358    'Address each item: change the code, or explain why not. A line marked "→ Operator" is their decision ' +
359      'about that finding and wins over the reviewer. Then summarize what you did, per item.',
360    ...(summary.trim() !== '' ? ['', `Summary: ${summary.trim()}`] : []),
361    ...(findings.length > 0 ? ['', '## Findings', ...findings] : []),
362    ...(comments.length > 0 && findings.length > 0 ? ['', '## Comments'] : []),
363    '',
364    ...sections,
365  ].join('\n')
366}
367
hooks/plan.ts 122 lines
1// Pure logic of the Plan panel: the plan's lines, comments on them, and the
2// messages to the agent. No `$` here, so it is unit-testable.
3
4export type PlanLineKind = 'heading' | 'bullet' | 'code' | 'fence' | 'blank' | 'text'
5
6// One line of the plan as the panel draws it. `line`: 1-based, as comments and
7// /ml-plan name it. `text`: the line as written. `level`: a heading's depth
8// (`#` count), a bullet's or a text line's nesting (2 spaces each, from 0).
9// `lang`: a code line's language, from its opening fence (```ts), and on the
10// opening fence itself; `opens`: the fence opens a block (else it closes one).
11export type PlanLine = { line: number; kind: PlanLineKind; text: string; level?: number; lang?: string; opens?: boolean }
12
13export type PlanVerdict = 'approve' | 'refine' | 'reject'
14
15// A comment on lines `start`-`end` of one plan version. `excerpt`: those lines
16// as they were when the comment was written.
17export type PlanComment = { id: string; start: number; end: number; body: string; excerpt?: string }
18
19const FENCE = /^\s*(```|~~~)/
20const HEADING = /^(#{1,6})\s+\S/
21const BULLET = /^(\s*)(?:[-*+]|\d+[.)])\s+\S/
22
23const linesOf = (markdown: string): string[] => {
24  const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
25  // A final newline is not a line of its own.
26  if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
27  return lines
28}
29
30// A fence opens a code block and the next fence closes it: inside, every line
31// is code, `# x` included.
32export const parsePlan = (markdown: string): PlanLine[] => {
33  let isInFence = false
34  let lang = ''
35  const withLang = (l: PlanLine): PlanLine => (lang !== '' ? { ...l, lang } : l)
36  return linesOf(markdown).map((text, i): PlanLine => {
37    const line = i + 1
38    if (FENCE.test(text)) {
39      isInFence = !isInFence
40      if (!isInFence) return { line, kind: 'fence', text, opens: false }
41      lang = text.trim().replace(/^(```|~~~)/, '').trim().split(/\s+/)[0] ?? ''
42      return withLang({ line, kind: 'fence', text, opens: true })
43    }
44    if (isInFence) return withLang({ line, kind: 'code', text })
45    if (text.trim() === '') return { line, kind: 'blank', text }
46    const heading = HEADING.exec(text)
47    if (heading !== null) return { line, kind: 'heading', text, level: heading[1]!.length }
48    const bullet = BULLET.exec(text)
49    if (bullet !== null) return { line, kind: 'bullet', text, level: Math.floor(bullet[1]!.replace(/\t/g, '  ').length / 2) }
50    const indent = /^\s*/.exec(text)![0].replace(/\t/g, '  ').length
51    return indent >= 2 ? { line, kind: 'text', text, level: Math.floor(indent / 2) } : { line, kind: 'text', text }
52  })
53}
54
55// A line as the engine's Markdown and Code elements take it: no control
56// characters but tab, and within their 10000-character limit.
57export const drawable = (text: string): string =>
58  text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').slice(0, 10_000)
59
60// A table row drawn alone is not a table: those lines stay plain text.
61export const isTableRow = (text: string): boolean => /^\s*\|/.test(text)
62
63// Lines `start` to `end` (1-based, inclusive) of the plan.
64export const planExcerpt = (markdown: string, start: number, end: number): string =>
65  linesOf(markdown)
66    .slice(Math.max(0, start - 1), Math.max(0, end))
67    .join('\n')
68
69export const planSpan = ({ start, end }: { start: number; end: number }): string =>
70  start === end ? `line ${start}` : `lines ${start}-${end}`
71
72// What present_plan answers the agent at once: the verdict comes later.
73export const presentedAnswer = (version: number): string =>
74  `[moonlight] Plan v${version} sent to the operator for review in the Plan panel. ` +
75  'Stop here and wait: do not start implementing, and do not call present_plan again until the operator answers. ' +
76  'The verdict arrives as a [moonlight] prompt: APPROVED (implement it), REFINEMENT (revise and present the whole ' +
77  'plan again) or REJECTED (stop). Tell the operator in one line that the plan is in the Plan panel, or that ' +
78  '`/ml-plan` shows it where no panel draws (the VS Code extension).'
79
80const commentItems = (comments: readonly PlanComment[]): string[] =>
81  [...comments]
82    .sort((a, b) => a.start - b.start || a.end - b.end)
83    .map(c => {
84      const quote =
85        c.excerpt !== undefined && c.excerpt !== '' ? `\n${c.excerpt.split('\n').map(l => `  > ${l}`).join('\n')}` : ''
86      return `- ${planSpan(c)}: ${c.body.replace(/\n/g, '\n  ')}${quote}`
87    })
88
89// The operator's verdict as one prompt to the agent. `phase`: the phase once
90// the verdict applied (an approval moves Plan to Auto first).
91export const formatVerdict = (
92  verdict: PlanVerdict,
93  version: number,
94  comments: readonly PlanComment[],
95  note: string,
96  phase: string,
97): string => {
98  const said = note.trim()
99  if (verdict === 'approve')
100    return [
101      `[moonlight] Plan v${version} APPROVED by the operator. The phase is now ${phase}: implement the plan.`,
102      ...(said !== '' ? ['', `Operator note: ${said}`] : []),
103      ...(comments.length > 0 ? ['', 'Comments to keep in mind while implementing:', ...commentItems(comments)] : []),
104    ].join('\n')
105  if (verdict === 'refine')
106    return [
107      `[moonlight] The operator asks for a REFINEMENT of plan v${version}. Address every comment, then call ` +
108        'present_plan again with the whole revised plan (not only the changes). Do not start implementing.',
109      ...(said !== '' ? ['', `Operator note: ${said}`] : []),
110      ...(comments.length > 0 ? ['', 'Comments:', ...commentItems(comments)] : []),
111    ].join('\n')
112  return [
113    `[moonlight] Plan v${version} REJECTED by the operator. Stop: do not implement it, and do not present ` +
114      'another plan unless the operator asks for one.',
115    ...(said !== '' ? ['', `Reason: ${said}`] : []),
116  ].join('\n')
117}
118
119// A refinement must say what to change: a comment or a note.
120export const canSend = (verdict: PlanVerdict, comments: readonly PlanComment[], note: string): boolean =>
121  verdict !== 'refine' || comments.length > 0 || note.trim() !== ''
122
hooks/bridge.ts 187 lines
1// Pure logic of the file bridge between the mod and the MoonlightCode IDE
2// plugins (VS Code, JetBrains). No `$` here, so it is unit-testable.
3//
4// The mod cannot listen, so the two meet in files, per session, under
5// ~/.moonlight/mod/sessions/<id>/ (the gate refuses the agent there):
6// - state.json: what the IDE panels show, written by the mod while it governs;
7// - inbox/<entry>.json: one operator decision each, written by the IDE;
8// - outbox/<entry>.json: the mod's answer to it. An entry with an answer is
9//   done; the IDE removes both files once it has read the answer.
10// The format is versioned: `v` changes when a reader would misread it.
11
12import { PHASES, type Phase } from './policy'
13import type { PlanVerdict } from './plan'
14
15export const BRIDGE_VERSION = 1
16
17export type BridgePlan = { version: number; title: string; markdown: string; decided?: PlanVerdict }
18
19export type BridgeState = {
20  v: number
21  sessionId: string
22  root: string | null
23  phase: Phase
24  pending: { to: Phase; reason: string } | null
25  plan: BridgePlan | null
26  updatedMs: number
27}
28
29export type PlanComment = { start: number; end: number; body: string }
30
31// A diff-line anchor, as the Review panel has it: 1-based lines of one side.
32export type ReviewAnchor = { path: string; side: 'before' | 'after'; start: number; end: number }
33
34export type ReviewEntry =
35  | ({ kind: 'review-comment'; body: string; excerpt?: string } & ReviewAnchor)
36  | { kind: 'review-comment-edit'; id: string; body: string }
37  | { kind: 'review-uncomment'; id: string }
38  | { kind: 'review-submit'; summary: string }
39  | { kind: 'review-accept' }
40  | { kind: 'review-accept-file'; path: string }
41  | { kind: 'review-run'; skill: string }
42  | { kind: 'review-cancel' }
43  | { kind: 'review-scope'; scope: 'session' | 'head' }
44  | { kind: 'finding'; id: string; decision: 'keep' | 'dismiss'; note?: string }
45
46export type InboxEntry =
47  | { kind: 'phase'; to: Phase }
48  | { kind: 'request'; approve: boolean; reason?: string }
49  | { kind: 'plan'; version: number; verdict: PlanVerdict; comments: PlanComment[]; note: string }
50  | ReviewEntry
51
52export type InboxAnswer = { v: number; ok: boolean; message: string }
53
54// An inbox entry's name: what the IDE writes, nothing that climbs or hides.
55export const ENTRY_FILE = /^[A-Za-z0-9_-]{1,64}\.json$/
56
57const VERDICTS: readonly string[] = ['approve', 'refine', 'reject']
58const MAX_TEXT = 20_000
59const MAX_COMMENTS = 200
60
61const text = (value: unknown): string | undefined =>
62  typeof value === 'string' && value.length <= MAX_TEXT ? value : undefined
63
64// An id or a name the IDE echoes back: one short word, nothing that climbs.
65const word = (value: unknown): string | undefined =>
66  typeof value === 'string' && /^[A-Za-z0-9_.:@-]{1,200}$/.test(value) ? value : undefined
67
68// A path as the review lists it: relative to the session root, never climbing.
69const reviewPath = (value: unknown): string | undefined =>
70  typeof value === 'string' && value !== '' && value.length <= 1000 && !value.startsWith('/') &&
71  !value.split('/').includes('..')
72    ? value
73    : undefined
74
75const parseReviewEntry = (e: Record<string, unknown>): ReviewEntry | { error: string } | undefined => {
76  switch (e.kind) {
77    case 'review-comment': {
78      const path = reviewPath(e.path)
79      const body = text(e.body)?.trim()
80      const excerpt = e.excerpt === undefined ? undefined : text(e.excerpt)
81      if (path === undefined) return { error: 'a comment needs the path of a file under review' }
82      if (e.side !== 'before' && e.side !== 'after') return { error: 'a comment side is before or after' }
83      if (!Number.isInteger(e.start) || !Number.isInteger(e.end) || (e.start as number) < 1 || (e.end as number) < (e.start as number))
84        return { error: 'a comment needs 1-based start and end lines' }
85      if (body === undefined || body === '') return { error: 'a comment needs a body' }
86      return {
87        kind: 'review-comment',
88        path,
89        side: e.side,
90        start: e.start as number,
91        end: e.end as number,
92        body,
93        ...(excerpt !== undefined && excerpt !== '' ? { excerpt } : {}),
94      }
95    }
96    case 'review-comment-edit': {
97      const id = word(e.id)
98      const body = text(e.body)?.trim()
99      return id === undefined || body === undefined || body === ''
100        ? { error: 'an edit needs the comment id and its new body' }
101        : { kind: 'review-comment-edit', id, body }
102    }
103    case 'review-uncomment': {
104      const id = word(e.id)
105      return id === undefined ? { error: 'needs the comment id' } : { kind: 'review-uncomment', id }
106    }
107    case 'review-submit': {
108      const summary = e.summary === undefined ? '' : text(e.summary)
109      return summary === undefined ? { error: 'the summary is not text, or too long' } : { kind: 'review-submit', summary: summary.trim() }
110    }
111    case 'review-accept':
112      return { kind: 'review-accept' }
113    case 'review-accept-file': {
114      const path = reviewPath(e.path)
115      return path === undefined ? { error: 'needs the path of a file under review' } : { kind: 'review-accept-file', path }
116    }
117    case 'review-run': {
118      const skill = word(e.skill)
119      return skill === undefined ? { error: 'needs the review skill to run' } : { kind: 'review-run', skill }
120    }
121    case 'review-cancel':
122      return { kind: 'review-cancel' }
123    case 'review-scope':
124      if (e.scope !== 'session' && e.scope !== 'head') return { error: 'a review scope is session or head' }
125      return { kind: 'review-scope', scope: e.scope }
126    case 'finding': {
127      const id = word(e.id)
128      const note = e.note === undefined ? undefined : text(e.note)
129      if (id === undefined) return { error: 'needs the finding id' }
130      if (e.decision !== 'keep' && e.decision !== 'dismiss') return { error: 'a finding is kept or dismissed' }
131      if (e.note !== undefined && note === undefined) return { error: 'the note is not text, or too long' }
132      return { kind: 'finding', id, decision: e.decision, ...(note !== undefined ? { note } : {}) }
133    }
134    default:
135      return undefined
136  }
137}
138
139// Reads one inbox entry. Anything malformed is an error the IDE is told about,
140// never a guess: these are operator decisions.
141export const parseInboxEntry = (raw: string): InboxEntry | { error: string } => {
142  let parsed: unknown
143  try {
144    parsed = JSON.parse(raw)
145  } catch {
146    return { error: 'not JSON' }
147  }
148  const e = (parsed ?? {}) as Record<string, unknown>
149  if (e.v !== BRIDGE_VERSION) return { error: `unsupported version ${String(e.v)} (the mod reads ${BRIDGE_VERSION})` }
150  switch (e.kind) {
151    case 'phase':
152      return PHASES.includes(e.to as Phase) ? { kind: 'phase', to: e.to as Phase } : { error: `unknown phase ${String(e.to)}` }
153    case 'request': {
154      if (typeof e.approve !== 'boolean') return { error: 'a request answer needs approve: true or false' }
155      const reason = text(e.reason)
156      return { kind: 'request', approve: e.approve, ...(reason !== undefined && reason.trim() !== '' ? { reason } : {}) }
157    }
158    case 'plan': {
159      if (!Number.isInteger(e.version) || (e.version as number) < 1) return { error: 'a plan answer needs its plan version' }
160      if (!VERDICTS.includes(String(e.verdict))) return { error: `unknown verdict ${String(e.verdict)}` }
161      const note = e.note === undefined ? '' : text(e.note)
162      if (note === undefined) return { error: 'the note is not text, or too long' }
163      const list = e.comments === undefined ? [] : e.comments
164      if (!Array.isArray(list) || list.length > MAX_COMMENTS) return { error: 'comments must be a list' }
165      const comments: PlanComment[] = []
166      for (const c of list) {
167        const k = (c ?? {}) as Record<string, unknown>
168        const body = text(k.body)
169        if (!Number.isInteger(k.start) || !Number.isInteger(k.end) || body === undefined || body.trim() === '')
170          return { error: 'each comment needs start, end and body' }
171        comments.push({ start: k.start as number, end: k.end as number, body: body.trim() })
172      }
173      return { kind: 'plan', version: e.version as number, verdict: e.verdict as PlanVerdict, comments, note: note.trim() }
174    }
175    default:
176      return parseReviewEntry(e) ?? { error: `unknown kind ${String(e.kind)}` }
177  }
178}
179
180// The entries still to apply: well-named, in the order they were written
181// (names sort by time: the IDE prefixes them with it), none already answered.
182export const pendingEntries = (inbox: readonly string[], answered: readonly string[]): string[] =>
183  inbox.filter(name => ENTRY_FILE.test(name) && !answered.includes(name)).sort()
184
185export const answer = (ok: boolean, message: string): string =>
186  `${JSON.stringify({ v: BRIDGE_VERSION, ok, message } satisfies InboxAnswer)}\n`
187
hooks/daemon.ts 77 lines
1// Pure logic of the hand-over with the MoonlightCode daemon. No `$` here, so
2// it is unit-testable.
3//
4// The mod is installed into Claude Code itself, so it loads in every session,
5// whoever launched it (a terminal, Anthropic's VS Code or JetBrains plugin).
6// When the daemon answers and its hooks are installed, the daemon governs and
7// the mod stands by; otherwise the mod governs. A session the mod governs
8// stays the mod's (never two gates with two phases); one on standby is taken
9// over as soon as the daemon stops governing.
10
11export type Governor = 'mod' | 'daemon'
12
13// Where the daemon publishes its port: `$MOONLIGHT_HOME/.moonlight/control.json`
14// when MOONLIGHT_HOME is absolute, else under the home directory (the rule the
15// IDE clients follow, moonlight-control-client/src/daemon.ts).
16export const discoveryFile = (home: string | undefined, override: string | undefined): string | undefined => {
17  const anchor = override !== undefined && override.startsWith('/') ? override : home
18  return anchor === undefined ? undefined : `${anchor}/.moonlight/control.json`
19}
20
21// The daemon's control API, from the discovery file: loopback only.
22export const controlUrl = (text: string): string | undefined => {
23  try {
24    const port = (JSON.parse(text) as { port?: unknown }).port
25    return Number.isInteger(port) && (port as number) > 0 && (port as number) < 65536
26      ? `http://127.0.0.1:${port as number}`
27      : undefined
28  } catch {
29    return undefined
30  }
31}
32
33// Whether the daemon's hooks gate Claude Code, from `/control/gating-status`:
34// at least one entry, every one installed. A daemon whose hooks are missing
35// governs nothing, so the mod keeps the gate.
36export const hooksInstalled = (text: string): boolean => {
37  try {
38    const entries = JSON.parse(text) as unknown
39    return (
40      Array.isArray(entries) &&
41      entries.length > 0 &&
42      entries.every(entry => (entry as { installed?: unknown } | null)?.installed === true)
43    )
44  } catch {
45    return false
46  }
47}
48
49// Probes in a row the daemon must miss before the mod takes a session over:
50// a daemon restart (a second or two) must not flip every open session.
51export const TAKEOVER_AFTER_MISSES = 3
52
53// Missed probes so far, after this one: a governing daemon resets the count.
54export const countMisses = (misses: number, daemonGoverns: boolean): number => (daemonGoverns ? 0 : misses + 1)
55
56// Whether `/control/health` answered as the daemon does: a cheap check, for
57// watching a daemon already known to govern (gating-status reads the hook
58// registrations, and can outlast a short timeout on a busy machine).
59export const isHealthy = (text: string): boolean => {
60  try {
61    return Number.isInteger((JSON.parse(text) as { api?: unknown }).api)
62  } catch {
63    return false
64  }
65}
66
67// Who governs from now on. The mod never hands a session back: its phase and
68// sandbox are in force, and a second gate would hold a second phase.
69// `misses`: probes missed in a row while the daemon governed (a session that
70// starts with no daemon is the mod's at once).
71export const nextGovernor = (current: Governor | undefined, daemonGoverns: boolean, misses = 0): Governor =>
72  current === 'mod'
73    ? 'mod'
74    : daemonGoverns || (current === 'daemon' && misses < TAKEOVER_AFTER_MISSES)
75      ? 'daemon'
76      : 'mod'
77
hooks/policy.ts 215 lines
1// Pure policy of the MoonlightCode gate. No `$` here, so it is unit-testable.
2
3// The daemon's workflow (crates/domain/src/phase.rs), in order. Plan and
4// Commit are read-only; Commit also freezes the notes folders, until the
5// operator commits. `next` is cyclic: Commit → Plan starts the next unit of work.
6export type Phase = 'plan' | 'auto' | 'test' | 'review' | 'commit'
7
8export const PHASES: readonly Phase[] = ['plan', 'auto', 'test', 'review', 'commit']
9
10export type SandboxWriteLists = {
11  denyWrite: readonly string[]
12  allowWrite: readonly string[]
13}
14
15export const DEFAULT_WRITABLE: readonly string[] = ['.ai', '.bmad-output', '.bmad']
16
17// A project's folder name under ~/.moonlight/mod/projects/, encoded the way
18// Claude Code names its own project folders (`/a/b.c` → `-a-b-c`).
19export const projectKey = (root: string): string => root.replace(/[^A-Za-z0-9]/g, '-')
20
21// Project files are writable.
22export const allowsWrites = (phase: Phase): boolean => phase === 'auto' || phase === 'test' || phase === 'review'
23
24// The notes folders are writable: every phase but the commit gate.
25export const allowsNotes = (phase: Phase): boolean => phase !== 'commit'
26
27export const nextPhase = (phase: Phase): Phase => PHASES[(PHASES.indexOf(phase) + 1) % PHASES.length]!
28
29// A phase as typed or requested, with the daemon's aliases; undefined for
30// anything else (`next` included: the caller resolves it against the current phase).
31export const phaseFromToken = (raw: string | undefined): Phase | undefined => {
32  const token = raw?.trim().toLowerCase()
33  if (token === 'plan' || token === 'discovery' || token === 'discover') return 'plan'
34  if (token === 'auto' || token === 'implement' || token === 'autoimplement' || token === 'auto-implement') return 'auto'
35  return token === 'test' || token === 'review' || token === 'commit' ? token : undefined
36}
37
38// The phase file: anything unreadable stays Plan, the safe default.
39export const parsePhase = (raw: string | undefined): Phase => phaseFromToken(raw) ?? 'plan'
40
41export const phaseLabel = (phase: Phase): string => phase.charAt(0).toUpperCase() + phase.slice(1)
42
43// What the phase allows, as the hint line, the pane and the messages say it.
44export const phaseRule = (phase: Phase): string =>
45  phase === 'plan' ? 'project read-only' : phase === 'commit' ? 'everything frozen until you commit' : 'project writable'
46
47// The notes folders open in this phase.
48export const notesFor = (phase: Phase, writable: readonly string[]): readonly string[] =>
49  allowsNotes(phase) ? writable : []
50
51// Paths are written to the project's .claude/settings.local.json, where `./x`
52// resolves under the project root.
53//
54// In the sandbox a denyWrite always wins over an allowWrite, however narrow the
55// allow is, so the root cannot be denied with the notes folders re-opened.
56// A read-only phase instead denies every top-level entry except the notes
57// folders it leaves open (none in Commit). The root itself stays writable: a
58// new top-level entry is caught after the call.
59//
60// The project's .moonlight/ holds the gate's own config: it is write-denied in
61// every phase, so an Auto session cannot widen the next Plan.
62export const PROJECT_CONFIG_DIR = '.moonlight'
63
64// Claude Code's project settings: they hold the sandbox lists the gate writes,
65// and hooks that would run outside it. Write-denied in every phase, so an Auto
66// session cannot widen the next Plan either.
67export const PROJECT_SETTINGS: readonly string[] = ['.claude/settings.json', '.claude/settings.local.json']
68
69export const sandboxListsFor = (
70  phase: Phase,
71  writable: readonly string[],
72  rootEntries: readonly string[],
73): SandboxWriteLists => {
74  const always = [`./${PROJECT_CONFIG_DIR}`, ...PROJECT_SETTINGS.map(file => `./${file}`)]
75  if (allowsWrites(phase)) return { denyWrite: always, allowWrite: [] }
76  const open = notesFor(phase, writable)
77  const entries = rootEntries.filter(name => !open.includes(name)).map(name => `./${name}`)
78  return { denyWrite: [...always, ...entries.filter(e => !always.includes(e))], allowWrite: [] }
79}
80
81// Everything a phase could have added for this root: what counts as the mod's
82// own when there is no record of what it added last.
83export const ownedByAnyPhase = (writable: readonly string[], rootEntries: readonly string[]): SandboxWriteLists => {
84  const lists = PHASES.map(phase => sandboxListsFor(phase, writable, rootEntries))
85  return {
86    denyWrite: [...new Set(lists.flatMap(l => l.denyWrite))],
87    allowWrite: [...new Set(lists.flatMap(l => l.allowWrite))],
88  }
89}
90
91// Top-level entries that appeared since the deny list was built.
92export const newRootEntries = (
93  rootEntries: readonly string[],
94  denied: readonly string[],
95  writable: readonly string[],
96): string[] =>
97  rootEntries.filter(name => !writable.includes(name) && !denied.includes(`./${name}`))
98
99// Replaces the entries this mod added last time with the new ones, keeping
100// whatever the user put there themselves.
101export const mergeOwned = (
102  existing: readonly string[],
103  previouslyOwned: readonly string[],
104  owned: readonly string[],
105): string[] => {
106  const kept = existing.filter(p => !previouslyOwned.includes(p))
107  return [...kept, ...owned.filter(p => !kept.includes(p))]
108}
109
110// In a read-only phase (Plan, Commit), a tool runs only when it is listed here. Everything else is
111// denied: unknown MCP tools (they run outside the sandbox), cross-session and
112// remote tools (another session could write for this one), schedulers.
113// Bash / Monitor / Edit / Write / NotebookEdit are listed because their own hooks gate them.
114const PLAN_ALLOWED_TOOLS: readonly string[] = [
115  'Read', 'Grep', 'Glob', 'LSP', 'WebFetch', 'WebSearch', 'ToolSearch',
116  'TodoWrite', 'AskUserQuestion', 'Skill', 'Agent', 'Task', 'TaskStop', 'Monitor',
117  'Bash', 'Edit', 'Write', 'NotebookEdit',
118  'mcp__moonlightcode__request_phase', 'mcp__moonlightcode__present_plan',
119]
120
121// Claude Code's own plan mode leaves auto mode: in Plan, plans go through
122// present_plan instead, so these are refused with a pointer to it.
123export const CLAUDE_PLAN_MODE_TOOLS: readonly string[] = ['EnterPlanMode', 'ExitPlanMode']
124
125// Read-only MCP tools, by exact name or by prefix (ending in `__` or `_`).
126const PLAN_ALLOWED_MCP: readonly string[] = [
127  'mcp__moonlight__phase_status', 'mcp__moonlight__request_phase',
128  'mcp__moonlight__present_plan', 'mcp__moonlight__report_blocked',
129  'mcp__codegraph__', 'mcp__context7__', 'mcp__exa__',
130  'mcp__github__get_', 'mcp__github__list_', 'mcp__github__search_',
131]
132
133export const isAllowedInPlan = (tool: string, extra: readonly string[] = []): boolean => {
134  if (PLAN_ALLOWED_TOOLS.includes(tool)) return true
135  return [...PLAN_ALLOWED_MCP, ...extra].some(entry =>
136    entry.endsWith('_') ? tool.startsWith(entry) : tool === entry,
137  )
138}
139
140export const isUnder = (path: string, root: string): boolean =>
141  path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
142
143export const isUnderAny = (path: string, roots: readonly string[]): boolean =>
144  roots.some(r => isUnder(path, r))
145
146export type SandboxHealth = { isSafe: true } | { isSafe: false; reason: string }
147
148// Fail closed: Plan only trusts Bash when the sandbox cannot be bypassed.
149export const checkSandbox = (settings: Readonly<Record<string, unknown>>): SandboxHealth => {
150  const sandbox = (settings.sandbox ?? {}) as Record<string, unknown>
151  if (sandbox.enabled !== true) return { isSafe: false, reason: 'sandbox.enabled is not true' }
152  if (sandbox.allowUnsandboxedCommands !== false)
153    return { isSafe: false, reason: 'sandbox.allowUnsandboxedCommands is not false' }
154  const excluded = sandbox.excludedCommands
155  if (Array.isArray(excluded) && excluded.length > 0)
156    return { isSafe: false, reason: `sandbox.excludedCommands is set (${excluded.join(', ')})` }
157  const filesystem = (sandbox.filesystem ?? {}) as Record<string, unknown>
158  if (filesystem.disabled === true)
159    return { isSafe: false, reason: 'sandbox.filesystem.disabled is true' }
160  return { isSafe: true }
161}
162
163// The project's .claude/settings.local.json, as the gate edits it: an object
164// whose `sandbox.filesystem` lists are string arrays. Anything else is
165// refused, so the gate fails closed instead of writing over what it misreads.
166export const parseSettings = (text: string): { settings: Record<string, any> } | { error: string } => {
167  let parsed: unknown
168  try {
169    parsed = JSON.parse(text.trim() === '' ? '{}' : text)
170  } catch (error) {
171    return { error: `it is not valid JSON (${String(error)})` }
172  }
173  const isObject = (value: unknown): value is Record<string, unknown> =>
174    value !== null && typeof value === 'object' && !Array.isArray(value)
175  if (!isObject(parsed)) return { error: 'it is not a JSON object' }
176  const sandbox = parsed.sandbox
177  if (sandbox !== undefined && !isObject(sandbox)) return { error: '`sandbox` is not an object' }
178  const filesystem = sandbox?.filesystem
179  if (filesystem !== undefined && !isObject(filesystem)) return { error: '`sandbox.filesystem` is not an object' }
180  for (const key of ['denyWrite', 'allowWrite']) {
181    const list = filesystem?.[key]
182    if (list !== undefined && !(Array.isArray(list) && list.every(item => typeof item === 'string')))
183      return { error: `\`sandbox.filesystem.${key}\` is not a list of paths` }
184  }
185  return { settings: parsed }
186}
187
188// Why a gate hook that failed refuses its call: the engine skips a failed hook
189// that has no handler, which for a gate would let the call through.
190export const gateFailure = (failure: { kind: 'throw' | 'timeout'; message?: string }): string =>
191  'Moonlight: the gate could not check this call ' +
192  (failure.kind === 'timeout' ? '(it ran out of time)' : `(${failure.message ?? 'it failed'})`) +
193  ', so it is refused. Ask the operator.'
194
195// An `allowWrite` entry of the effective sandbox that opens a protected folder
196// (the entry lies under it, or holds it): with it, a sandboxed command could
197// write the gate's own files. `~` and `./` are expanded as the sandbox does.
198export const allowWriteBreach = (
199  allowWrite: readonly unknown[],
200  home: string,
201  projectRoot: string,
202  protectedRoots: readonly string[],
203): string | undefined =>
204  allowWrite
205    .filter((entry): entry is string => typeof entry === 'string')
206    .find(entry => {
207      const path = entry.startsWith('~')
208        ? `${home}${entry.slice(1)}`
209        : entry.startsWith('/')
210          ? entry
211          : `${projectRoot}/${entry.replace(/^\.\//, '')}`
212      const clean = path.length > 1 ? path.replace(/\/+$/, '') : path
213      return protectedRoots.some(r => isUnder(clean, r) || isUnder(r, clean))
214    })
215
hooks/findings.ts 165 lines
1// Pure logic of review findings: attaching an existing review skill, reading
2// what it found, and the operator's triage. No `$` here, so it is unit-testable.
3//
4// The output contract is the one `moonlight-review` and the desktop panel use
5// (packaging/skills/moonlight-review/SKILL.md): one fenced `moonlight-findings`
6// JSON block, last in the answer.
7
8export type Severity = 'high' | 'medium' | 'low'
9export type Route = 'patch' | 'decision' | 'defer'
10
11export type Finding = {
12  key: string
13  severity: Severity
14  route: Route
15  owner: string[]
16  file: string
17  line: number
18  summary: string
19  detail: string
20  fix?: string
21}
22
23// A finding plus the operator's call on it: kept (sent with the review, with
24// the note as the decision) or dismissed (dropped from this pass).
25export type TriagedFinding = Finding & { id: string; decision: 'keep' | 'dismiss'; note?: string }
26
27export type FindingsReport = { findings: Finding[]; notes: string; lanesFailed: string[] }
28
29// Review skills attached when neither the plugin option nor the operator says
30// otherwise: BMAD's and Claude Code's. A team adds its own through the
31// `reviewSkills` option (plugin.json), not here.
32export const DEFAULT_ATTACHED = ['bmad-code-review', 'code-review']
33
34// The `reviewSkills` option: names separated by commas or spaces.
35export const parseSkillList = (text: unknown): string[] | undefined => {
36  if (typeof text !== 'string') return undefined
37  const names = text.split(/[\s,]+/).filter(name => name !== '')
38  return names.length > 0 ? [...new Set(names)] : undefined
39}
40
41// The attached skills the session can run, by the names its commands list
42// (`plugin:name` or bare); with no list to go by, all of them.
43export const installedSkills = (attached: readonly string[], commands: readonly string[] | undefined): string[] =>
44  commands === undefined ? [...attached] : attached.filter(skill => commands.some(name => isAttached(name, [skill])))
45
46// A skill matches by its name, bare or under its plugin (`plugin:name`).
47export const isAttached = (skill: string, attached: readonly string[]): boolean =>
48  attached.some(name => skill === name || skill.endsWith(`:${name}`))
49
50const SEVERITIES: readonly string[] = ['high', 'medium', 'low']
51const ROUTES: readonly string[] = ['patch', 'decision', 'defer']
52
53const FENCE = '```moonlight-findings'
54
55// Reads the last findings block of an answer. A block that is missing or does
56// not parse is an error, never an empty review: a failed review must not read
57// as a clean one.
58export const parseFindings = (answer: string): FindingsReport | { error: string } => {
59  const start = answer.lastIndexOf(FENCE)
60  if (start < 0) return { error: 'no moonlight-findings block in the answer' }
61  const body = answer.slice(start + FENCE.length)
62  const end = body.indexOf('```')
63  if (end < 0) return { error: 'the moonlight-findings block is not closed' }
64  let parsed: unknown
65  try {
66    parsed = JSON.parse(body.slice(0, end))
67  } catch (error) {
68    return { error: `the moonlight-findings block is not JSON: ${String(error)}` }
69  }
70  const root = (parsed ?? {}) as { findings?: unknown; coverage?: { notes?: unknown; lanes_failed?: unknown } }
71  if (!Array.isArray(root.findings)) return { error: 'the moonlight-findings block has no findings array' }
72  const findings: Finding[] = []
73  for (const [i, raw] of root.findings.entries()) {
74    const f = (raw ?? {}) as Record<string, unknown>
75    if (!SEVERITIES.includes(String(f.severity))) return { error: `finding ${i + 1}: unknown severity ${String(f.severity)}` }
76    if (typeof f.file !== 'string' || typeof f.summary !== 'string' || !Number.isInteger(f.line))
77      return { error: `finding ${i + 1}: needs file, line and summary` }
78    findings.push({
79      key: typeof f.key === 'string' && f.key !== '' ? f.key : `F${i + 1}`,
80      severity: f.severity as Severity,
81      route: ROUTES.includes(String(f.route)) ? (f.route as Route) : 'decision',
82      owner: Array.isArray(f.owner) ? f.owner.map(String) : [],
83      file: f.file,
84      line: f.line as number,
85      summary: f.summary,
86      detail: typeof f.detail === 'string' ? f.detail : '',
87      ...(typeof f.fix === 'string' && f.fix !== '' ? { fix: f.fix } : {}),
88    })
89  }
90  const coverage = root.coverage ?? {}
91  return {
92    findings: sortFindings(findings),
93    notes: typeof coverage.notes === 'string' ? coverage.notes : '',
94    lanesFailed: Array.isArray(coverage.lanes_failed) ? coverage.lanes_failed.map(String) : [],
95  }
96}
97
98const RANK: Record<Severity, number> = { high: 0, medium: 1, low: 2 }
99
100// Worst first, then by place: the order a reviewer wants to read.
101export const sortFindings = <F extends Finding>(findings: readonly F[]): F[] =>
102  [...findings].sort(
103    (a, b) => RANK[a.severity] - RANK[b.severity] || a.file.localeCompare(b.file) || a.line - b.line,
104  )
105
106// What the review covers, for the skill: each file with the copy it is to be
107// diffed against, or the repo to diff against HEAD.
108export type ScopeFile = { path: string; baseline?: string; status: string }
109
110// A path as one shell word, for the commands the contract hands the skill.
111export const shellQuote = (text: string): string => `'${text.replace(/'/g, `'\\''`)}'`
112
113// Appended to an attached skill's prompt: what to review and how to answer.
114// It leaves how the skill reviews alone.
115export const findingsContract = (root: string, scope: 'session' | 'head', files: readonly ScopeFile[]): string => {
116  const list = files.map(f => {
117    if (f.baseline === undefined) return `- ${f.path} (${f.status}; diff against HEAD in its repository)`
118    return f.baseline === '/dev/null'
119      ? `- ${f.path} (created this session: all of it is new)`
120      : `- ${f.path} (before this session: ${f.baseline}; diff with \`git diff --no-index -- ${shellQuote(f.baseline)} ${shellQuote(`${root}/${f.path}`)}\`)`
121  })
122  return [
123    '',
124    '---',
125    '[moonlight] The operator reviews your findings in the MoonlightCode Review panel. Two additions to this review:',
126    '',
127    `1. Scope. Review these files under ${root} (${scope === 'session' ? "this session's changes, against the copies saved before the session changed them" : 'everything changed against HEAD'}):`,
128    ...(list.length > 0 ? list : ['- (no changed files: say so in coverage.notes)']),
129    '',
130    '2. Output. Run the review exactly as this skill says, then end your answer with ONE fenced block, nothing after it:',
131    '',
132    '```moonlight-findings',
133    '{ "coverage": { "notes": "what was or was not checked" },',
134    '  "findings": [ { "key": "H1", "severity": "high|medium|low", "route": "patch|decision|defer",',
135    '    "owner": ["who raised it"], "file": "path as listed above", "line": 12,',
136    '    "summary": "one line, read as a claim", "detail": "why it is true, where it is reachable from", "fix": "optional" } ] }',
137    '```',
138    '',
139    '`line` is 1-based in the file as it is now. Emit the block even with no findings: an empty array is a result.',
140    '',
141    'If the skill runs in the background or in a subagent, it only gets what you pass it: pass it this scope, and',
142    'write the block yourself in the answer where you report its results, with every finding it returned.',
143  ].join('\n')
144}
145
146// Where a finding is, short enough for a button in a docked panel: the last
147// two segments of its path. A button does not shrink, so a long path would
148// push the rest of its row out of sight.
149export const findingPlace = (file: string, line: number): string => `${file.split('/').slice(-2).join('/')}:${line}`
150
151// The marker of the operator's decision belongs to the operator alone: the
152// reviewer's text (the model's) cannot carry it.
153const OPERATOR_MARK = '→'
154const asReviewer = (text: string): string => text.replaceAll(OPERATOR_MARK, '->')
155
156// A kept finding as the agent reads it, the operator's note labelled as theirs.
157export const formatFinding = (f: TriagedFinding): string => {
158  const lines = [`- ${f.file}:${f.line} [${f.severity}, ${f.route}] ${asReviewer(f.summary)}`]
159  if (f.detail !== '') lines.push(...f.detail.split('\n').map(l => `  ${asReviewer(l)}`))
160  if (f.fix !== undefined)
161    lines.push(...asReviewer(f.fix).split('\n').map((l, i) => (i === 0 ? `  Suggested fix: ${l}` : `  ${l}`)))
162  if (f.note !== undefined) lines.push(...f.note.split('\n').map(l => `  ${OPERATOR_MARK} Operator: ${l}`))
163  return lines.join('\n')
164}
165
types/index.d.ts 103 lines
1export type MoonlightcodePhase = 'plan' | 'auto' | 'test' | 'review' | 'commit'
2
3export type MoonlightcodePhaseRequest = { to: MoonlightcodePhase; reason: string }
4
5export type MoonlightcodeReviewView = { kind: 'files' } | { kind: 'file'; path: string }
6
7export type MoonlightcodeChangedFile = {
8  path: string
9  status: string
10  added: number
11  removed: number
12  repo: string
13  repoPath: string
14  baseline?: string
15  skipped?: string
16}
17
18export type MoonlightcodeDiffLine = {
19  kind: 'context' | 'added' | 'removed' | 'hunk'
20  text: string
21  oldLine?: number
22  newLine?: number
23}
24
25export type MoonlightcodeLineAnchor = {
26  path: string
27  side: 'before' | 'after'
28  start: number
29  end: number
30  removed?: { start: number; end: number }
31}
32
33export type MoonlightcodeReviewComment = MoonlightcodeLineAnchor & { id: string; body: string; excerpt?: string }
34
35export type MoonlightcodeTriagedFinding = {
36  id: string
37  key: string
38  severity: 'high' | 'medium' | 'low'
39  route: 'patch' | 'decision' | 'defer'
40  owner: string[]
41  file: string
42  line: number
43  summary: string
44  detail: string
45  fix?: string
46  decision: 'keep' | 'dismiss'
47  note?: string
48}
49
50// One version of the agent's plan, as present_plan gave it. `decided`: the
51// operator's verdict on it, once sent.
52export type MoonlightcodePlan = {
53  version: number
54  title: string
55  markdown: string
56  decided?: 'approve' | 'refine' | 'reject'
57}
58
59export type MoonlightcodePlanComment = { id: string; start: number; end: number; body: string; excerpt?: string }
60
61declare module 'claude-code' {
62  interface PluginState {
63    moonlightcode: {
64      phase: MoonlightcodePhase
65      // Who governs the session: the daemon (the mod stands by) or the mod;
66      // null until session.start decides.
67      governor: 'mod' | 'daemon' | null
68      pending: MoonlightcodePhaseRequest | null
69      reviewView: MoonlightcodeReviewView
70      reviewFiles: MoonlightcodeChangedFile[]
71      reviewRows: MoonlightcodeDiffLine[]
72      reviewSelection: MoonlightcodeLineAnchor | null
73      reviewComments: MoonlightcodeReviewComment[]
74      reviewProblem: string | null
75      reviewLayout: 'auto' | 'unified' | 'split'
76      reviewExpanded: boolean
77      reviewScope: 'auto' | 'session' | 'head'
78      reviewHasBaselines: boolean
79      reviewDraft: string[]
80      reviewRowSelection: { from: number; to: number } | null
81      reviewFindings: MoonlightcodeTriagedFinding[]
82      // What the last AI review said of its own coverage, or why it failed.
83      reviewFindingsStatus: string | null
84      // The finding whose note is being written.
85      reviewNoteFor: string | null
86      // The one AI review running: its skill, whether its prompt got the
87      // contract, and the turn it runs in (its end ends the run).
88      reviewRun: { skill: string; isPrompted: boolean; isFromPanel?: boolean; turnId?: string } | null
89      // The skill picker of "Run AI review" is open.
90      reviewPicking: boolean
91      // The plan under review (the latest version), its comments, the lines
92      // picked (0-based rows), a comment being written, the overall note, and
93      // whether Reject is asking for its reason.
94      planDoc: MoonlightcodePlan | null
95      planComments: MoonlightcodePlanComment[]
96      planRowSelection: { from: number; to: number } | null
97      planDraft: string[]
98      planNote: string
99      planRejecting: boolean
100    }
101  }
102}
103