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

moonlightcode mod)A Claude Code plugin of function hooks that brings MoonlightCode into the session itself, without the daemon:
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.Design and test evidence: .bmad-output/spike-1-mod-findings.md.
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.
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:
/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.~/.moonlight, Claude Code's settings)./phase moves on./control/gating-status at session start (its hooks must be installed), then with the cheaper /control/health while it governs, 3 s each.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.
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"
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.
| Phase | Project files | Notes (.ai/, .bmad-output/, .bmad/) | Next |
|---|---|---|---|
| ◐ Plan | read-only | writable | Auto |
| ● Auto | writable | writable | Test |
| ● Test | writable | writable | Review |
| ● Review | writable | writable | Commit |
| ■ Commit | read-only | read-only: everything frozen until you commit | Plan |
/tmp always is), and any other tool must be on the read-only allow-list.~/.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..claude/settings.local.json makes the gate refuse tools (with a toast) until it is fixed./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.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.
| Command | What it does | ||||
|---|---|---|---|---|---|
/phase | Show 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 next | Move to the next phase (Commit → Plan) | ||||
/phase approve · /phase deny | Answer the agent's request_phase | ||||
/ml-plan | Open 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-review | Open 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 accept | Close 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 cancel | Free 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.
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.
~/.moonlight/mod/sessions/<session>/plan/v<N>.md. A new version starts with no comments.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.+ 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.present_plan again./ml-plan note goes with it.In VS Code, where no mod UI draws, use /ml-plan: it prints the plan with its line numbers to comment.
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.
r refreshes the list.+/-, green/red, syntax colours.s) forces one or the other.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.+ 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.\ 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.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.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.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:
/ml-review attach <skill> and detach <skill> keep your own list; /ml-review skills reset drops it.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" } } }
bmad-code-review (BMAD) and code-review (Claude Code).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.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.~/.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.sed -i, a formatter…) have no copy: switch to Since last commit to see them./clear. A session that ends otherwise keeps them for --resume; after a week without a heartbeat, the next session start removes them.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.
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.
hooks/register.tsx 2512 lines1import { 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 lines1// 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}
367hooks/plan.ts 122 lines1// 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() !== ''
122hooks/bridge.ts 187 lines1// 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`
187hooks/daemon.ts 77 lines1// 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'
77hooks/policy.ts 215 lines1// 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 })
215hooks/findings.ts 165 lines1// 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}
165types/index.d.ts 103 lines1export 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