Switches a session into an agentic development loop: a PM main thread that plans tickets and dispatches dev and review agents

Claude Code mods: plugins that change Claude Code's own interface or behaviour through function hooks. This repository holds several mods, one folder each.
| Mod | What it does |
|---|---|
dispatch-panel | A side panel listing dispatched agents and background shells, with their purpose, input and output tokens and duration, in Active and Finished tabs, coloured by what each agent is doing. |
godmode (an agentic dev loop for any project) is being built in godmode/. It is not in the marketplace yet.
The repository is a marketplace (.claude-plugin/marketplace.json), so the mods install from GitHub with no clone.
1. Add the marketplace. At a Claude Code prompt:
/plugin marketplace add tinkybeatz/tinky-claude-mods
2. Install a mod. Still at the prompt:
/plugin install dispatch-panel@tinky-claude-mods
Pick the user scope so it loads in every project. The mod runs at once, with no restart.
Use the <mod>@tinky-claude-mods form. /plugin install dispatch-panel --marketplace … is ignored by some versions, which then search the default marketplace and answer Plugin "dispatch-panel" not found.
3. Use the fullscreen layout, so the panel docks on the right. Claude Code draws a panel on the right only in its fullscreen layout, in a window at least 110 columns wide. Otherwise the panel sits above the input bar. The layout is chosen when Claude Code starts, so a mod cannot change it. In a normal terminal, run once:
echo 'export CLAUDE_CODE_NO_FLICKER=1' >> ~/.zshrc
(~/.bashrc for bash.) Then open a new terminal window and start claude. Every new window keeps the setting. Inside tmux, Claude Code uses the other layout by default, so start it outside tmux. A window narrower than 110 columns puts the panel back above the input bar until it is widened.
4. Updating. An installed mod runs the copy made when it was installed. To get a newer version:
claude plugin update dispatch-panel@tinky-claude-mods
Then restart Claude Code (or run /reload-plugins in an open session). /plugin → Installed also has Update now.
| What you see | Why | Fix |
|---|---|---|
/plugin → Errors: Failed to load hooks from …/hooks.json … expected record, received undefined | This machine's Claude Code has mods switched off, so it reads hooks.json in the older hooks format | Run claude -p "Reply with the single word ok." --model haiku once so Claude Code refreshes its settings, then quit and restart it. If the error stays, the logged-in account does not have mods yet. claude update if the version is old. |
Plugin "dispatch-panel" not found in … marketplace | The marketplace was not added, or the --marketplace flag was ignored | Steps 1 and 2 above, with the @tinky-claude-mods form. |
| Adding the marketplace is refused | An organization's managed settings restrict marketplaces | An admin has to allow it. |
| The panel is above the input bar | Main-screen layout, tmux, or a narrow window | Step 3. |
| Some features never react (on a work login) | Where an organization manages Claude Code, some hooks of plugins a person installs are skipped | An admin lists the plugins in the managed settings. |
To run a mod straight from a clone, so every edit is picked up: add its folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Separate several folders with :.
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/Documents/GAEL/AI/tinky-claude-mods/dispatch-panel"
}
New sessions load the mod, and a session reloads it when its files change. A session that was already running when the setting changed does not pick it up.
For one session only:
claude --plugin-dir ~/Documents/GAEL/AI/tinky-claude-mods/dispatch-panel
Do not install the same mod from the marketplace and from a clone at once.
The panel opens by itself in a terminal at least 144 columns wide, docked on the right in the fullscreen layout (see step 3 above). Elsewhere, open it with /dispatches.
| Command | Effect |
|---|---|
/dispatches | Opens the panel. |
/dispatches active / finished | Opens the panel on that tab. |
/dispatches clear | Drops finished rows. |
/dispatches clear all | Drops every row, active ones included. |
/dispatches width <columns> | Sets the panel's width, remembered across sessions. |
While the panel has focus, a and f switch tabs.
In each tab, rows are grouped into an Agents section and a Shells section; click a section's header to collapse or expand it.
Only work the main thread started gets a card of its own. An agent or shell that a dispatched agent started is listed inside that agent's card as an indented line (├ and └, one step deeper per level), with its own status mark, activity tag ([shell] for a shell) and duration. The card stays in Active while anything in it still runs, and its token line adds sub in and out, the tokens of every agent listed inside it. An agent counts as finished once it hands back its result (the engine marks it idle), and returns to Active if a message wakes it again. A teammate that is idle is waiting for a message, not done, so it stays in Active with a ◌ mark.
Each row's title is its purpose (the description the agent or shell was given), followed by its activity tag and how long it has run. Below it, an agent's type appears only when it is not a catch-all one (general-purpose, claude), such as Explore or Plan. Agents also show what they have consumed: in, the input tokens of all their requests added up (cache reads and writes included, since each request sends the whole context again), and out, all the output tokens they have generated.
The panel's footer shows the account's usage limits as bars, one per window the last API response reported (Session (5h), Week, or a gateway's spend limit), each with the percentage used and the time until it resets. A bar is green, then yellow from 70% and red from 90%. Off a subscription, or before the first response, no window has a reading and the footer is hidden.
A running agent's card ends with a strip naming what it is doing right now, read from its latest tool call: reading register.tsx, running tests, committing, updating Jira, and thinking between calls. Shell commands are matched against a list of known ones (git, gh, test runners, linters, …); any other command shows the description the agent gave it.
Each agent's colour shows what it is doing, not its type: a general-purpose agent writing code and one updating Jira get different colours. When an agent starts, a small fast model reads its type, description and the start of its prompt, and picks one activity (one model call per agent). A description that names its activity outright ("Update PROD-4882 in Jira", "Open a PR for the fix") is coloured at once, with no model call, and the model cannot overrule it. The card shows that activity's short tag in brackets after the agent's type, such as general-purpose [research], so two agents share a colour only when they do the same kind of work. The activities, tags and colours are in dispatch-panel/hooks/kinds.ts.
| Activity | Tag | Colour |
|---|---|---|
| writing or changing code | code | blue |
| reviewing or testing | review | red |
| updating tickets (Jira, issues) | jira | cyan |
| git, branches or pull requests | git | pink |
| searching or researching | research | yellow |
| planning or designing | plan | green |
| writing docs or text | docs | white |
| configuring or setting up | setup | purple |
| analysing data | data | lime |
| something else | other | orange |
Shells, and agents not classified yet (the first second or so), are slate grey and have no tag.
A failed row keeps its activity's border and shows a red ✗.
A background shell's row ends when its completion notification arrives, with the status that notification reports (done, failed or killed), including shells an agent started.
Known limit: workflow agents are not listed.
Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json, its hooks module and, when it keeps state, a types/index.d.ts contract. From a mod's folder:
claude plugin validate .
claude plugin test .
tsc -p .
tsc -p . needs the type declarations the engine lays in .claude-plugin/types/ when it loads the mod; they are generated, so they are not committed.
hooks/register.tsx 211 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GodmodeMode, SetupScope } from '../types'
5import { NO_GIT_LINE } from './host'
6import { afterToolCall, decideToolCall, planSpawn, recordAgent, REVIEW_VERDICT_TOOL, serveReviewVerdict, takePendingReviews } from './loop'
7import type { LoopCache } from './loop'
8import { changedConventions, godmodeVersionOf, isOlderVersion, projectOf, readNote } from './storage'
9import type { NoteLookup, Project } from './storage'
10import { runTicketTool, TICKET_TOOLS, TicketError, ticketBackendOf } from './tickets'
11import type { TicketToolName } from './tickets'
12
13const USAGE = 'Use `/godmode`, `/godmode setup`, `/godmode off`, `/godmode report` or `/godmode try <ticket>`.'
14
15/*
16 * State refs are written in each file that reads or updates them: `validate` follows a
17 * state source only to an atom made in the same file. `loop.ts` holds its own.
18 */
19const isOn = atom({ plugin: 'godmode', key: 'isOn' } as const, false)
20const mode = atom({ plugin: 'godmode', key: 'mode' } as const, 'coding' as GodmodeMode)
21const setupScope = atom({ plugin: 'godmode', key: 'setupScope' } as const, 'full' as SetupScope)
22const recheck = atom({ plugin: 'godmode', key: 'recheck' } as const, [] as string[])
23const mainThreadReads = atom({ plugin: 'godmode', key: 'mainThreadReads' } as const, 0)
24
25/** How `/godmode` switches on: the mode, and what the setup or the PM picks up first. */
26type SwitchOnPlan = {
27 mode: GodmodeMode
28 /** `personal` when the shared half of a committed note is there and this person's is not (D61). */
29 setupScope: SetupScope
30 /** Conventions files edited since the note was written (D20); Step 3 re-checks them. */
31 recheck: string[]
32 /** The one line the reply carries, if any (D46, D60). */
33 line?: string
34}
35
36/**
37 * Reads the project's setup note (D20): none means setup, a note means coding. A note
38 * from an older godmode adds the update line, and changes nothing until asked (D60).
39 */
40const switchOnPlan = async ($: EngineInterface, project: Project): Promise<SwitchOnPlan> => {
41 let found: NoteLookup
42 try {
43 found = await readNote($, project)
44 } catch (error) {
45 const reason = error instanceof Error ? error.message : String(error)
46 return { mode: 'setup', setupScope: 'full', recheck: [], line: `godmode could not look for its setup note (${reason}), so setup runs.` }
47 }
48 if (found.status === 'none') return { mode: 'setup', setupScope: 'full', recheck: [] }
49 if (found.status === 'unreadable') {
50 return { mode: 'setup', setupScope: 'full', recheck: [], line: `godmode could not read its setup note at ${found.path}: ${found.reason}. Setup runs again.` }
51 }
52 const { shared } = found
53 const recheck = await changedConventions($, found.project, shared.conventions).catch(() => [])
54 const current = await godmodeVersionOf($).catch(() => shared.godmodeVersion)
55 const line = isOlderVersion(shared.godmodeVersion, current)
56 ? `A godmode update is available for this project's conventions (set up with ${found.shared.godmodeVersion}, now ${current}): \`/godmode setup\` to apply it.`
57 : undefined
58 if (found.personal === undefined) return { mode: 'setup', setupScope: 'personal', recheck, line }
59 return { mode: 'coding', setupScope: 'full', recheck, line }
60}
61
62/**
63 * Declares the four ticket tools (D24) and `review_verdict` (D53) at each switch-on. No
64 * call takes a tool back, so after `/godmode off` they stay listed and answer "godmode is
65 * off". Resolves the line to show when they could not be declared.
66 */
67const registerTicketTools = async ($: EngineInterface) => {
68 try {
69 for (const tool of [...TICKET_TOOLS, REVIEW_VERDICT_TOOL]) await $.tool.register(tool)
70 return undefined
71 } catch (error) {
72 return `godmode could not add its ticket tools (${error instanceof Error ? error.message : String(error)}).`
73 }
74}
75
76const switchOn = async ($: EngineInterface, to: GodmodeMode) => {
77 await update($, mainThreadReads, () => 0)
78 await update($, mode, () => to)
79 await update($, isOn, () => true)
80 return registerTicketTools($)
81}
82
83/** The session's project, or undefined in a folder with no git, where godmode does not start (D54). */
84const gitProject = async ($: EngineInterface) => {
85 const project = await projectOf($).catch(() => undefined)
86 return project?.hasGit === true ? project : undefined
87}
88
89/** Serves a ticket tool from the project's backend; a failure is the model's to read, as an error. */
90const serveTicketTool = async ($: EngineInterface, e: { tool: string; agentId?: string } & Record<string, unknown>) => {
91 if ((await read($, isOn)) !== true) return { deny: 'godmode is off, so its ticket tools do nothing. The person switches it on with `/godmode`.' }
92 const { tool, tool_use_id: _id, agentId, consent: _consent, ...input } = e
93 try {
94 const backend = await ticketBackendOf($)
95 return { result: await runTicketTool(backend, tool.replace(/^mcp__godmode__/, '') as TicketToolName, input, { agentId }) }
96 } catch (error) {
97 if (error instanceof TicketError) return { deny: error.message }
98 return { deny: `godmode's ticket tool failed: ${error instanceof Error ? error.message : String(error)}` }
99 }
100}
101
102const failedTicketTool = () => ({ deny: 'godmode\'s ticket tool failed before it could answer.' })
103
104/**
105 * Prompts the person sent themselves; each starts the main thread's read count over (D63).
106 * A prompt with no origin is the person's own.
107 */
108const PERSON_ORIGINS: readonly (string | undefined)[] = ['composer', 'bridge', 'sdk', undefined]
109
110export const register: Register = on => {
111 // The note's settings and the open PRs, kept between calls for this load of the module.
112 const cache: LoopCache = {}
113
114 on('session.start', async ($, e, next) => {
115 await $.command.register({
116 name: 'godmode',
117 description:
118 'Switch godmode on: a PM main thread that files tickets and dispatches dev and review agents (`setup` to change the setup, `off`, `report`, `try <ticket>`)',
119 argumentHint: '[setup|off|report|try <ticket>]',
120 })
121 // A reload runs `register` again while the state says on: declare the tools again.
122 if ((await read($, isOn)) === true) await registerTicketTools($)
123 return next(e)
124 })
125
126 // One hook per tool, each named literally so `validate` reads what the module serves.
127 on('tool.call', { tool: 'mcp__godmode__ticket_create' }, ($, e) => serveTicketTool($, e)).catch(failedTicketTool)
128 on('tool.call', { tool: 'mcp__godmode__ticket_move' }, ($, e) => serveTicketTool($, e)).catch(failedTicketTool)
129 on('tool.call', { tool: 'mcp__godmode__ticket_list' }, ($, e) => serveTicketTool($, e)).catch(failedTicketTool)
130 on('tool.call', { tool: 'mcp__godmode__ticket_comment' }, ($, e) => serveTicketTool($, e)).catch(failedTicketTool)
131 on('tool.call', { tool: 'mcp__godmode__review_verdict' }, ($, e) => serveReviewVerdict($, cache, e)).catch(() => ({
132 deny: 'godmode\'s review_verdict tool failed before it could record anything.',
133 }))
134
135 /*
136 * The loop's gate (Step 7): every tool call while godmode is on. A failed check refuses
137 * the call rather than letting it through, and says how to go on without godmode. Its
138 * own `$.ui.ask` raises an AskUserQuestion call beneath this hook: that re-entry is
139 * passed on, or the person could never be asked.
140 */
141 on('tool.call', async ($, e, next) => {
142 const decision = await decideToolCall($, cache, e)
143 if (decision.deny !== undefined) return { deny: decision.deny }
144 return afterToolCall($, cache, decision, await next(e))
145 }).catch(($, e, next) => {
146 if (next.error.kind === 're-entry' || next.called) return next(e)
147 return {
148 deny: `godmode's tool-call check failed (${next.error.message ?? next.error.kind}), so it refused this call to be safe. Nothing ran. The person can go on without godmode with \`/godmode off\`.`,
149 }
150 })
151
152 // Dispatch (Step 7): the agent cap, the role's model, a dev agent's ticket, worktree and brief.
153 on('agent.spawn', async ($, e, next) => {
154 const plan = await planSpawn($, cache, e)
155 if (plan.deny !== undefined) return { deny: plan.deny }
156 const started = await next(plan.input)
157 if (started.agentId !== undefined && plan.record !== undefined) await recordAgent($, started.agentId, plan.record)
158 return started
159 }).catch(($, e, next) => {
160 if (next.error.kind === 're-entry' || next.called) return next(e)
161 return {
162 deny: `godmode's dispatch check failed (${next.error.message ?? next.error.kind}), so the agent did not start. The person can go on without godmode with \`/godmode off\`.`,
163 }
164 })
165
166 // The PM hears about each new draft PR on its next prompt, a person's or a task notification (D35, D64).
167 on('prompt.submit', async ($, e, next) => {
168 if ((await read($, isOn)) !== true) return next(e)
169 if (PERSON_ORIGINS.includes((e.origin as typeof e.origin | undefined)?.kind)) await update($, mainThreadReads, () => 0)
170 const told = await takePendingReviews($)
171 return next(told === undefined ? e : { ...e, context: [...(e.context ?? []), told] })
172 }).catch(($, e, next) => next(e))
173
174 on('command.run', { command: 'godmode' }, async ($, e) => {
175 const [action = '', ...rest] = e.args.trim().split(/\s+/)
176 const ticket = rest.join(' ')
177 // The setup note may have changed: the loop reads it again on its next call.
178 cache.context = undefined
179
180 // Switching on is quiet (D46), and typed while on it changes nothing (D45).
181 if (action === '') {
182 if ((await read($, isOn)) === true) return {}
183 const project = await gitProject($)
184 if (project === undefined) return { text: NO_GIT_LINE }
185 const plan = await switchOnPlan($, project)
186 await update($, setupScope, () => plan.setupScope)
187 await update($, recheck, () => plan.recheck)
188 const toolsLine = await switchOn($, plan.mode)
189 const lines = [plan.line, toolsLine].filter(line => line !== undefined)
190 return lines.length === 0 ? {} : { text: lines.join('\n') }
191 }
192 if (action === 'setup') {
193 if ((await gitProject($)) === undefined) return { text: NO_GIT_LINE }
194 const toolsLine = await switchOn($, 'change-setup')
195 const line = 'godmode is in change-setup mode: say what you want to change.'
196 return { text: toolsLine === undefined ? line : `${line}\n${toolsLine}` }
197 }
198 if (action === 'off') {
199 if ((await read($, isOn)) !== true) return { text: 'godmode is already off.' }
200 await update($, isOn, () => false)
201 return { text: 'godmode is off: plain Claude Code for the rest of the session.' }
202 }
203 if (action === 'report') return { text: '`/godmode report` is not built yet.' }
204 if (action === 'try') {
205 if (ticket === '') return { text: 'Name a ticket: `/godmode try <ticket>`.' }
206 return { text: `\`/godmode try\` is not built yet, so ${ticket} was not started.` }
207 }
208 return { text: `Unknown \`/godmode ${action}\`. ${USAGE}` }
209 })
210}
211hooks/host.ts 316 lines1import type { EngineInterface } from 'claude-code'
2
3import type { Answer, Project } from './storage'
4
5/**
6 * Where PRs live (D54, D55): `github` through `gh`; `local` with no PR host, where a
7 * branch stands for its PR and review runs on it; `other` for a host the setup maps
8 * (`glab`, a connector), not built yet.
9 */
10export type HostKind = 'github' | 'local' | 'other'
11
12export const HOST_KINDS: readonly HostKind[] = ['github', 'local', 'other']
13
14/** How a PR lands: GitHub takes all three; the local host only `merge` (a fast-forward when it can be one). */
15export type MergeMethod = 'merge' | 'squash' | 'rebase'
16
17/** An open PR as the contention sweep and the verdict checks read it (D47, D53). */
18export type PullRequest = {
19 /** The host's own reference: the PR number on GitHub, the branch on the local host. */
20 ref: string
21 url?: string
22 title: string
23 branch: string
24 base: string
25 /** Who opened it: a GitHub login, or the git email that recorded a local review. */
26 author: string
27 isDraft: boolean
28 /** Every path the PR changes, relative to the repo root. */
29 files: string[]
30 /** The commit the PR's branch points at; a verdict counts only at this commit (D53). */
31 headCommit: string
32}
33
34export type DraftRequest = { branch: string; base: string; title: string; body: string }
35
36/** The PR actions the loop takes (D55), the same on every host. */
37export interface GitHost {
38 readonly kind: HostKind
39 /** Opens a draft PR for a pushed branch (PRs are always drafts first, D53). */
40 openDraft(request: DraftRequest): Promise<{ ref: string; url?: string }>
41 comment(ref: string, body: string): Promise<void>
42 markReady(ref: string): Promise<void>
43 merge(ref: string, method: MergeMethod): Promise<void>
44 listOpen(): Promise<PullRequest[]>
45 headCommit(ref: string): Promise<string>
46}
47
48/** A host action that failed or cannot run: a failed command, a host not built yet. */
49export class HostError extends Error {}
50
51/** What `/godmode` says in a folder with no git (D54): it does not start, and it offers `git init`. */
52export const NO_GIT_LINE =
53 'godmode needs git, and this folder is not a git repository. Run `git init` here (or ask for it) and make a first commit, then `/godmode` again.'
54
55const isRecord = (value: unknown): value is Record<string, unknown> =>
56 typeof value === 'object' && value !== null && !Array.isArray(value)
57
58/** The PR number at the end of a GitHub PR link, as `gh pr create` prints it. */
59export const prNumberOf = (url: string) => /\/pull\/(\d+)\b/.exec(url)?.[1]
60
61/**
62 * The open PRs in `gh pr list --json` output. A malformed entry throws, so a sweep
63 * never reads a half-parsed list as "nothing held".
64 */
65export const parseGhPullRequests = (stdout: string): PullRequest[] => {
66 let value: unknown
67 try {
68 value = JSON.parse(stdout)
69 } catch {
70 throw new HostError(`gh pr list did not answer JSON: ${stdout.slice(0, 200)}`)
71 }
72 if (!Array.isArray(value)) throw new HostError('gh pr list did not answer a list.')
73 return value.map(entry => {
74 if (!isRecord(entry) || typeof entry.number !== 'number' || typeof entry.headRefName !== 'string') {
75 throw new HostError(`gh pr list answered an entry godmode cannot read: ${JSON.stringify(entry).slice(0, 200)}`)
76 }
77 const files = Array.isArray(entry.files) ? entry.files : []
78 return {
79 ref: String(entry.number),
80 url: typeof entry.url === 'string' ? entry.url : undefined,
81 title: typeof entry.title === 'string' ? entry.title : '',
82 branch: entry.headRefName,
83 base: typeof entry.baseRefName === 'string' ? entry.baseRefName : '',
84 author: isRecord(entry.author) && typeof entry.author.login === 'string' ? entry.author.login : '',
85 isDraft: entry.isDraft === true,
86 files: files.flatMap(file => (isRecord(file) && typeof file.path === 'string' ? [file.path] : [])),
87 headCommit: typeof entry.headRefOid === 'string' ? entry.headRefOid : '',
88 }
89 })
90}
91
92/** The host of a git remote URL, `https://`, `ssh://` or `user@host:path` alike. */
93export const remoteHostOf = (url: string) => {
94 const trimmed = url.trim()
95 const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/\/)/.exec(trimmed)
96 if (scp !== null && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return scp[1]?.toLowerCase()
97 return /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^:/]+)/i.exec(trimmed)?.[1]?.toLowerCase()
98}
99
100const isGitHub = (url: string) => ['github.com', 'ssh.github.com', 'www.github.com'].includes(remoteHostOf(url) ?? '')
101
102/** The failure line of a command, for an error the model reads. */
103const failure = (argv: readonly string[], result: { exitCode: number; stdout: string; stderr: string }) =>
104 `\`${argv.slice(0, 3).join(' ')}\` failed (exit ${result.exitCode}): ${(result.stderr.trim() || result.stdout.trim()).slice(0, 500)}`
105
106/*
107 * Helpers that take `$` are function declarations: the engine follows `$` past an import
108 * only into those.
109 */
110
111/** Runs a command with no shell; rejects with a HostError when it cannot start or exits non-zero. */
112async function run($: EngineInterface, argv: string[], cwd: string, stdin?: string) {
113 const result = await $.process.run(argv, { cwd, stdin, timeoutMs: 120_000 }).catch((error: unknown) => {
114 throw new HostError(`\`${argv[0]}\` could not run: ${error instanceof Error ? error.message : String(error)}`)
115 })
116 if (result.exitCode !== 0) throw new HostError(failure(argv, result))
117 return result.stdout
118}
119
120/** GitHub through `gh`, run in the repo's main working tree. Nothing pushes: a dev agent pushes its branch. */
121export function githubHost($: EngineInterface, cwd: string): GitHost {
122 const gh = (args: string[], stdin?: string) => run($, ['gh', ...args], cwd, stdin)
123 return {
124 kind: 'github',
125 openDraft: async ({ branch, base, title, body }) => {
126 const out = await gh(['pr', 'create', '--draft', '--base', base, '--head', branch, '--title', title, '--body-file', '-'], body)
127 const url = out.trim().split('\n').at(-1)?.trim() ?? ''
128 const ref = prNumberOf(url)
129 if (ref === undefined) throw new HostError(`gh opened a PR but printed no PR link: ${out.slice(0, 200)}`)
130 return { ref, url }
131 },
132 comment: async (ref, body) => void (await gh(['pr', 'comment', ref, '--body-file', '-'], body)),
133 markReady: async ref => void (await gh(['pr', 'ready', ref])),
134 merge: async (ref, method) => void (await gh(['pr', 'merge', ref, `--${method}`])),
135 listOpen: async () =>
136 parseGhPullRequests(
137 await gh([
138 'pr',
139 'list',
140 '--state',
141 'open',
142 '--limit',
143 '200',
144 '--json',
145 'number,url,title,headRefName,baseRefName,author,isDraft,files,headRefOid',
146 ]),
147 ),
148 headCommit: async ref => {
149 const value: unknown = JSON.parse(await gh(['pr', 'view', ref, '--json', 'headRefOid']))
150 if (!isRecord(value) || typeof value.headRefOid !== 'string') throw new HostError(`gh pr view ${ref} answered no head commit.`)
151 return value.headRefOid
152 },
153 }
154}
155
156/** One branch under review on the local host: what "open PR" records (D54). */
157export type LocalReview = {
158 branch: string
159 base: string
160 title: string
161 body: string
162 author: string
163 isDraft: boolean
164 openedAt: string
165 comments: { at: string; body: string }[]
166}
167
168export const LOCAL_REVIEWS = 'local-reviews.json'
169
170const isLocalReview = (value: unknown): value is LocalReview =>
171 isRecord(value) &&
172 typeof value.branch === 'string' &&
173 typeof value.base === 'string' &&
174 typeof value.isDraft === 'boolean' &&
175 Array.isArray(value.comments)
176
177/** The branch checked out in each working tree, from `git worktree list --porcelain`. */
178export const checkedOutBranches = (porcelain: string) =>
179 new Map(
180 porcelain.split(/\n\s*\n/).flatMap(block => {
181 const path = /^worktree (.+)$/m.exec(block)?.[1]
182 const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1]
183 return path === undefined || branch === undefined ? [] : [[branch, path] as const]
184 }),
185 )
186
187/**
188 * No PR host (D54): branches only. "Open PR" records the branch for review in the note's
189 * folder `dir`; review comments are kept with it; merge brings it into the base branch,
190 * a fast-forward when it can be one.
191 */
192export function localHost($: EngineInterface, project: Project, dir: string): GitHost {
193 const path = `${dir.replace(/[\\/]+$/, '')}/${LOCAL_REVIEWS}`
194 const git = (args: string[], cwd = project.root) => run($, ['git', ...args], cwd)
195
196 const load = async (): Promise<LocalReview[]> => {
197 if (!(await $.fs.exists(path))) return []
198 const value: unknown = JSON.parse(await $.fs.read(path))
199 if (!isRecord(value) || !Array.isArray(value.reviews) || !value.reviews.every(isLocalReview)) {
200 throw new HostError(`${path} is not a list of local reviews.`)
201 }
202 return value.reviews
203 }
204 const save = (reviews: LocalReview[]) => $.fs.write(path, `${JSON.stringify({ reviews }, null, 2)}\n`)
205 const branchHead = (branch: string) => git(['rev-parse', '--verify', `refs/heads/${branch}^{commit}`]).then(out => out.trim())
206 const find = async (ref: string) => {
207 const reviews = await load()
208 const review = reviews.find(candidate => candidate.branch === ref)
209 if (review === undefined) throw new HostError(`No branch \`${ref}\` is open for review.`)
210 return { reviews, review }
211 }
212
213 return {
214 kind: 'local',
215 openDraft: async ({ branch, base, title, body }) => {
216 await branchHead(branch)
217 await branchHead(base)
218 const reviews = await load()
219 if (reviews.some(review => review.branch === branch)) return { ref: branch }
220 const author = (await git(['config', 'user.email']).catch(() => '')).trim()
221 const openedAt = new Date(await $.clock.now()).toISOString()
222 await save([...reviews, { branch, base, title, body, author, isDraft: true, openedAt, comments: [] }])
223 return { ref: branch }
224 },
225 comment: async (ref, body) => {
226 const { reviews, review } = await find(ref)
227 review.comments.push({ at: new Date(await $.clock.now()).toISOString(), body })
228 await save(reviews)
229 },
230 markReady: async ref => {
231 const { reviews, review } = await find(ref)
232 review.isDraft = false
233 await save(reviews)
234 },
235 merge: async (ref, method) => {
236 if (method !== 'merge') throw new HostError(`The local host merges with \`merge\` only, not \`${method}\`.`)
237 const { reviews, review } = await find(ref)
238 if (review.isDraft) throw new HostError(`\`${ref}\` is still a draft: mark it ready before merging.`)
239 const where = checkedOutBranches(await git(['worktree', 'list', '--porcelain'])).get(review.base)
240 if (where === undefined) {
241 // The base is checked out nowhere: move its ref, which git allows only as a fast-forward.
242 await git(['fetch', '.', `refs/heads/${review.branch}:refs/heads/${review.base}`]).catch((error: unknown) => {
243 throw new HostError(
244 `\`${review.branch}\` is not a fast-forward of \`${review.base}\`, and \`${review.base}\` is checked out nowhere to merge in. Check it out, then merge again. (${error instanceof Error ? error.message : String(error)})`,
245 )
246 })
247 } else {
248 await git(['merge', '--no-edit', review.branch], where).catch(async (error: unknown) => {
249 await $.process.run(['git', 'merge', '--abort'], { cwd: where }).catch(() => undefined)
250 throw error
251 })
252 }
253 await save(reviews.filter(candidate => candidate !== review))
254 },
255 listOpen: async () => {
256 const open: PullRequest[] = []
257 for (const review of await load()) {
258 // A branch deleted by hand holds no files any more.
259 const headCommit = await branchHead(review.branch).catch(() => undefined)
260 if (headCommit === undefined) continue
261 const files = await git(['diff', '--name-only', `${review.base}...${review.branch}`])
262 open.push({
263 ref: review.branch,
264 title: review.title,
265 branch: review.branch,
266 base: review.base,
267 author: review.author,
268 isDraft: review.isDraft,
269 files: files.split('\n').map(line => line.trim()).filter(line => line !== ''),
270 headCommit,
271 })
272 }
273 return open
274 },
275 headCommit: async ref => {
276 await find(ref)
277 return branchHead(ref)
278 },
279 }
280}
281
282/** Another host (D55), mapped at setup: not built yet. It says so rather than falling back unasked. */
283export function otherHost(): GitHost {
284 throw new HostError(
285 'The "Other" git host is not built yet: godmode still needs the mapping the setup finds (`glab`, a connector) for opening, commenting on, readying and merging PRs. Nothing was done on any host.',
286 )
287}
288
289/** Which host serves a project, and why, so the reason can be said once. */
290export type HostChoice = { kind: HostKind; reason: string }
291
292/**
293 * Picks the host (D54, D55): the note's `gitHost` answer when it names one, else GitHub
294 * when the `origin` remote is on github.com and `gh` is signed in, else local branches.
295 */
296export async function chooseHost($: EngineInterface, project: Project, answers: Readonly<Record<string, Answer>> = {}): Promise<HostChoice> {
297 if (!project.hasGit) throw new HostError(NO_GIT_LINE)
298 const asked = answers.gitHost
299 if (asked === 'other') return { kind: 'other', reason: 'the setup note names another git host' }
300 if (asked === 'local') return { kind: 'local', reason: 'the setup note asks for local branches' }
301 const origin = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd: project.root }).catch(() => undefined)
302 if (origin?.exitCode !== 0 || origin.stdout.trim() === '') return { kind: 'local', reason: 'the repo has no `origin` remote' }
303 if (!isGitHub(origin.stdout)) return { kind: 'local', reason: `\`origin\` is not on github.com (${origin.stdout.trim()})` }
304 const auth = await $.process.run(['gh', 'auth', 'status', '--hostname', 'github.com'], { cwd: project.root }).catch(() => undefined)
305 if (auth === undefined) return { kind: 'local', reason: '`gh` is not installed' }
306 if (auth.exitCode !== 0) return { kind: 'local', reason: '`gh` is not signed in to github.com' }
307 return { kind: 'github', reason: '`origin` is on github.com and `gh` is signed in' }
308}
309
310/** The host for a choice; `dir` is the note's folder, where the local host records its reviews. */
311export function hostFor($: EngineInterface, kind: HostKind, project: Project, dir: string): GitHost {
312 if (kind === 'other') return otherHost()
313 if (kind === 'github') return githubHost($, project.root)
314 return localHost($, project, dir)
315}
316hooks/loop.ts 595 lines1import { atom, read, update } from 'claude-code'
2import type { AgentSpawnInput, EngineInterface, ToolCallInput, ToolCallResult, ToolSpec } from 'claude-code'
3
4import type { DispatchedAgent, GodmodeMode, PendingReview } from '../types'
5import { chooseHost, HostError, hostFor, prNumberOf } from './host'
6import type { GitHost, HostKind, PullRequest } from './host'
7import {
8 ACTION_WORDS,
9 attributionIn,
10 carriesText,
11 classifyShell,
12 howToChange,
13 isInside,
14 mainThreadVerdict,
15 normalisePath,
16 permissionKey,
17 relativeTo,
18 roleOf,
19 SCRATCH_FOLDERS,
20 SETTING,
21 settingsOf,
22 spawnModel,
23 strictest,
24 ticketIn,
25} from './rules'
26import type { Level, LoopSettings, ShellStep } from './rules'
27import { readNote } from './storage'
28import type { Answer, NoteLookup, Project } from './storage'
29import { backendFor, slugOf, TICKET_STATUSES, trackerOf } from './tickets'
30import type { TicketBackend } from './tickets'
31
32/*
33 * The loop hooks of Step 7 (D35-D38, D47, D48, D52, D53, D58, D62, D63, D66), applied to
34 * the session. The rules themselves are in `rules.ts`. Every helper here that takes `$`
35 * is a function declaration: the engine follows `$` past an import only into those.
36 */
37
38// The same state as `register.tsx`'s atoms: `validate` reads a state source only from this file.
39const isOn = atom({ plugin: 'godmode', key: 'isOn' } as const, false)
40const mode = atom({ plugin: 'godmode', key: 'mode' } as const, 'coding' as GodmodeMode)
41const agents = atom({ plugin: 'godmode', key: 'agents' } as const, {} as Record<string, DispatchedAgent>)
42const pendingReviews = atom({ plugin: 'godmode', key: 'pendingReviews' } as const, [] as PendingReview[])
43const mainThreadReads = atom({ plugin: 'godmode', key: 'mainThreadReads' } as const, 0)
44
45type FoundNote = Extract<NoteLookup, { status: 'found' }>
46
47/** What the loop hooks know of the project while godmode is on: its note and settings. */
48export type LoopContext = {
49 project: Project
50 note: FoundNote
51 /** Shared answers, then personal ones over them (D61). */
52 answers: Record<string, Answer>
53 settings: LoopSettings
54 /** The git host, chosen once per context. */
55 host?: { host: GitHost; kind: HostKind } | { error: string }
56}
57
58/**
59 * What the hooks keep between calls, for one load of the module. Every tool call passes
60 * the gate, so the note and the open PRs are read again only when their copy is stale.
61 */
62export type LoopCache = {
63 context?: { at: number; value?: LoopContext }
64 sweep?: { at: number; prs: PullRequest[] }
65 /** The person's own login on the host, once looked up; null when it could not be. */
66 login?: string | null
67}
68
69/** How long a read of the note stays fresh. `/godmode` reads it again at once. */
70export const CONTEXT_TTL_MS = 15_000
71
72const RUNNING_STATUSES: readonly string[] = ['pending', 'running', 'waiting']
73
74const EDIT_TOOLS: readonly string[] = ['Edit', 'Write', 'NotebookEdit']
75
76const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
77
78const short = (commit: string) => commit.slice(0, 7)
79
80const firstLine = (text: string, length = 120) => {
81 const line = text.trim().split('\n')[0] ?? ''
82 return line.length > length ? `${line.slice(0, length - 3)}...` : line
83}
84
85/** The note's settings, read again once stale. Undefined while there is no readable note (setup mode). */
86export async function loopContextOf($: EngineInterface, cache: LoopCache): Promise<LoopContext | undefined> {
87 const now = await $.clock.now()
88 if (cache.context !== undefined && now - cache.context.at < CONTEXT_TTL_MS) return cache.context.value
89 const found = await readNote($).catch(() => undefined)
90 let value: LoopContext | undefined
91 if (found?.status === 'found') {
92 const answers = { ...found.shared.answers, ...(found.personal?.answers ?? {}) }
93 value = { project: found.project, note: found, answers, settings: settingsOf(answers) }
94 }
95 cache.context = { at: now, value }
96 return value
97}
98
99/** The project's git host (D54, D55), or why there is none to use. */
100async function hostOf($: EngineInterface, context: LoopContext) {
101 if (context.host !== undefined) return context.host
102 try {
103 const choice = await chooseHost($, context.project, context.answers)
104 context.host = { host: hostFor($, choice.kind, context.project, context.note.dir), kind: choice.kind }
105 } catch (error) {
106 context.host = { error: messageOf(error) }
107 }
108 return context.host
109}
110
111/** The open PRs and their files (D47); a copy younger than the refresh setting serves unless `isFresh`. */
112export async function openPullRequests($: EngineInterface, cache: LoopCache, context: LoopContext, isFresh = false) {
113 const now = await $.clock.now()
114 if (!isFresh && cache.sweep !== undefined && now - cache.sweep.at < context.settings.contentionRefreshMs) return cache.sweep.prs
115 const choice = await hostOf($, context)
116 if ('error' in choice) throw new HostError(choice.error)
117 const prs = await choice.host.listOpen()
118 cache.sweep = { at: now, prs }
119 return prs
120}
121
122/** The person's own login on GitHub, so Free can tell their PRs from others' (D18). */
123async function ownLogin($: EngineInterface, cache: LoopCache, context: LoopContext) {
124 if (cache.login !== undefined) return cache.login ?? undefined
125 const result = await $.process.run(['gh', 'api', 'user', '--jq', '.login'], { cwd: context.project.root }).catch(() => undefined)
126 const login = result?.exitCode === 0 ? result.stdout.trim() : ''
127 cache.login = login === '' ? null : login
128 return cache.login ?? undefined
129}
130
131/** Whether a PR is someone else's: only when both its author and the person's login are known. */
132async function isOthersPr($: EngineInterface, cache: LoopCache, context: LoopContext, pr: string) {
133 const choice = await hostOf($, context)
134 if ('error' in choice || choice.kind !== 'github') return false
135 const find = (prs: readonly PullRequest[]) => prs.find(candidate => candidate.ref === pr || candidate.branch === pr)
136 const found = find(await openPullRequests($, cache, context).catch(() => [])) ?? find(await openPullRequests($, cache, context, true).catch(() => []))
137 const me = await ownLogin($, cache, context)
138 return found !== undefined && found.author !== '' && me !== undefined && found.author !== me
139}
140
141// ---------------------------------------------------------------------------
142// Review verdicts (D53)
143
144export type Verdict = 'clean' | 'change-request' | 'blocker'
145
146export const VERDICTS: readonly Verdict[] = ['clean', 'change-request', 'blocker']
147
148/** A verdict counts only at the commit it was given at (D53). */
149export type VerdictRecord = { verdict: Verdict; headCommit: string; at: string; agentId?: string; summary?: string }
150
151/** Verdicts outlive the session, so a resumed loop still knows what was reviewed; kept per project. */
152const verdictsKey = (project: Project) => `verdicts:${project.id}`
153
154const isVerdictRecord = (value: unknown): value is VerdictRecord =>
155 typeof value === 'object' &&
156 value !== null &&
157 VERDICTS.includes((value as VerdictRecord).verdict) &&
158 typeof (value as VerdictRecord).headCommit === 'string'
159
160export async function readVerdicts($: EngineInterface, project: Project): Promise<Record<string, VerdictRecord>> {
161 const value = await $.store.get(verdictsKey(project))
162 if (typeof value !== 'object' || value === null) return {}
163 return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, VerdictRecord] => isVerdictRecord(entry[1])))
164}
165
166/** A PR named by number, link or `#12`, as the host takes it. */
167const prRefOf = (value: unknown) => {
168 const text = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : ''
169 return text === '' ? undefined : (prNumberOf(text) ?? text.replace(/^#/, ''))
170}
171
172/** The `review_verdict` tool: a reviewer records its verdict at the PR's current commit. */
173export const REVIEW_VERDICT_TOOL: ToolSpec & { name: 'review_verdict' } = {
174 name: 'review_verdict',
175 description:
176 'Record your review verdict on a PR, at its current head commit: clean, change-request or blocker. Only a reviewer agent records one. A PR is marked ready or merged only with a clean verdict at its current commit, so a new commit needs a new review.',
177 inputSchema: {
178 type: 'object',
179 properties: {
180 pr: { type: 'string', description: 'The PR number (or the branch, on a project with no PR host).' },
181 verdict: { type: 'string', enum: [...VERDICTS] },
182 summary: { type: 'string', description: 'One or two lines: why this verdict.' },
183 },
184 required: ['pr', 'verdict'],
185 },
186 isDeferred: false,
187}
188
189/** Serves `review_verdict`: checks who calls, reads the PR's head commit, records the verdict there. */
190export async function serveReviewVerdict($: EngineInterface, cache: LoopCache, e: { agentId?: string } & Record<string, unknown>) {
191 if ((await read($, isOn)) !== true) return { deny: 'godmode is off, so it records no verdict. The person switches it on with `/godmode`.' }
192 const context = await loopContextOf($, cache)
193 if (context === undefined) return { deny: 'godmode is not set up in this project yet, so it has nowhere to record a verdict.' }
194 const caller = e.agentId === undefined ? undefined : (await read($, agents))[e.agentId]
195 if (context.settings.verdictFromReviewerOnly && caller?.role !== 'reviewer') {
196 return {
197 deny: `Only a reviewer agent records a verdict, so a PR is never cleared by the agent that wrote it or by the PM. Dispatch a godmode:reviewer on the PR. To change the rule, ${howToChange(SETTING.verdictFromReviewerOnly)}.`,
198 }
199 }
200 const pr = prRefOf(e.pr)
201 if (pr === undefined) return { deny: '`pr` is required: the PR number.' }
202 const verdict = VERDICTS.find(candidate => candidate === e.verdict)
203 if (verdict === undefined) return { deny: `\`${String(e.verdict)}\` is not a verdict; use one of ${VERDICTS.join(', ')}.` }
204 const choice = await hostOf($, context)
205 if ('error' in choice) return { deny: `godmode could not reach the git host to read PR #${pr}'s head commit: ${choice.error}` }
206 let headCommit: string
207 try {
208 headCommit = await choice.host.headCommit(pr)
209 } catch (error) {
210 return { deny: `godmode could not read PR #${pr}'s head commit (${messageOf(error)}), so no verdict was recorded.` }
211 }
212 const summary = typeof e.summary === 'string' && e.summary.trim() !== '' ? e.summary.trim() : undefined
213 const record: VerdictRecord = { verdict, headCommit, at: new Date(await $.clock.now()).toISOString(), agentId: e.agentId, summary }
214 await $.store.set(verdictsKey(context.project), { ...(await readVerdicts($, context.project)), [pr]: record })
215 const then = verdict === 'clean' ? ' It may now be marked ready; a new commit needs a new review.' : ' The PR stays a draft until a fix round and a clean review.'
216 return { result: `Recorded ${verdict} for PR #${pr} at ${short(headCommit)}.${then}` }
217}
218
219/** Why a PR may not be marked ready or merged yet, or undefined when its verdict clears it (D53). */
220async function verdictRefusal($: EngineInterface, context: LoopContext, step: ShellStep) {
221 const doing = step.action === 'pr-ready' ? 'marked ready' : 'merged'
222 if (step.pr === undefined) {
223 return `Name the PR by number (\`${step.verb} 12\`) so godmode can check its review verdict before it is ${doing}. Nothing ran.`
224 }
225 const label = `PR #${step.pr}`
226 const choice = await hostOf($, context)
227 if ('error' in choice) return `godmode could not reach the git host to check ${label}'s review (${choice.error}). Nothing ran.`
228 let head: string
229 try {
230 head = await choice.host.headCommit(step.pr)
231 } catch (error) {
232 return `godmode could not read ${label}'s head commit (${messageOf(error)}), so it cannot check its review verdict. Nothing ran; try again once the host answers.`
233 }
234 const record = (await readVerdicts($, context.project))[step.pr]
235 const rule = `To change the rule, ${howToChange(SETTING.mergeGate)}.`
236 if (record === undefined) {
237 return `${label} has no review verdict, so it cannot be ${doing}. Dispatch a godmode:reviewer on it; the reviewer records its verdict with review_verdict. ${rule}`
238 }
239 if (record.headCommit !== head) {
240 return `${label}'s last verdict (${record.verdict}) was given at commit ${short(record.headCommit)}, and the PR is now at ${short(head)}. Every new commit is reviewed again: dispatch a godmode:reviewer on the new commit. ${rule}`
241 }
242 if (record.verdict !== 'clean') {
243 return `${label}'s verdict at its current commit is ${record.verdict}, not clean, so it cannot be ${doing}. Send the findings to the dev agent for a fix round, then review again. ${rule}`
244 }
245 return undefined
246}
247
248// ---------------------------------------------------------------------------
249// The tool-call gate
250
251/** What the gate decided: a refusal, or what `afterToolCall` needs once the call ran. */
252export type ToolDecision =
253 | { deny: string }
254 | { deny?: undefined; isMain: boolean; agentId?: string; agent?: DispatchedAgent; opensPr: boolean }
255
256const who = (agentId: string | undefined, agent: DispatchedAgent | undefined) =>
257 agentId === undefined ? 'The PM (main thread)' : `A ${agent?.role ?? ''}${agent?.role === undefined ? '' : ' '}agent (${agentId})`
258
259/** The texts a commit or PR command carries: the command line and any file it reads its body from. */
260async function textsOf($: EngineInterface, command: string, step: ShellStep) {
261 const texts = [command]
262 for (const path of step.bodyFiles) {
263 if (!path.startsWith('/')) continue
264 const text = await $.fs.read(path).catch(() => undefined)
265 if (text !== undefined) texts.push(text)
266 }
267 return texts
268}
269
270/** The hard rules a shell step meets before any preset: drafts, attribution, auto-merge, the verdict gate. */
271async function shellRefusal($: EngineInterface, context: LoopContext, step: ShellStep, command: string) {
272 const { settings } = context
273 if (step.action === 'pr-open' && settings.isDraftsOnly && step.isDraft !== true) {
274 return `godmode opens every PR as a draft: add \`--draft\` to \`gh pr create\` and run it again. A PR leaves draft only once a clean review is recorded at its current commit. To change the rule, ${howToChange(SETTING.draftsOnly)}.`
275 }
276 if (!settings.isAttributionAllowed && carriesText(step)) {
277 for (const text of await textsOf($, command, step)) {
278 const line = attributionIn(text)
279 if (line !== undefined) {
280 const what = step.verb === 'git commit' ? 'commit message' : 'PR text'
281 return `This project's setup turns AI attribution off, and the ${what} carries \`${line}\`. Remove that line and run it again. To allow attribution, ${howToChange(SETTING.attribution)}.`
282 }
283 }
284 }
285 if (step.action === 'pr-merge' && step.isAuto === true && step.isUndo !== true && !settings.isAutoMergeOn) {
286 return `Auto-merge is off: a PR is merged only once the person says so. Tell them the PR is ready for their go instead. To arm auto-merge on its own, ${howToChange(SETTING.autoMerge)}.`
287 }
288 if ((step.action === 'pr-ready' || step.action === 'pr-merge') && step.isUndo !== true && settings.isMergeGateOn) {
289 return verdictRefusal($, context, step)
290 }
291 return undefined
292}
293
294/** Why a dev agent's edit is refused: outside its worktree (D38), or a file another open PR holds (D48). */
295async function devEditRefusal($: EngineInterface, cache: LoopCache, context: LoopContext, agent: DispatchedAgent, path: string) {
296 const { settings } = context
297 if (agent.worktree === undefined) return undefined
298 const isScratch = SCRATCH_FOLDERS.some(folder => isInside(path, folder))
299 if (settings.isWorktreeGuardOn && !isInside(path, agent.worktree) && !isScratch) {
300 const copy = isInside(path, context.project.root) ? ` (here, ${normalisePath(`${agent.worktree}/${relativeTo(path, context.project.root)}`)})` : ''
301 return `You are a dev agent working in your worktree ${agent.worktree}, and ${path} is outside it. Edit the file inside your worktree${copy} instead. To change the rule, ${howToChange(SETTING.worktreeGuard)}.`
302 }
303 if (!settings.isContentionOn || !isInside(path, agent.worktree)) return undefined
304 let prs: readonly PullRequest[]
305 try {
306 prs = await openPullRequests($, cache, context)
307 } catch (error) {
308 if (cache.sweep === undefined) {
309 return `godmode could not list the open PRs to check that no other PR holds ${path} (${messageOf(error)}). Nothing was edited; try again shortly. To change the rule, ${howToChange(SETTING.contention)}.`
310 }
311 prs = cache.sweep.prs
312 }
313 const file = relativeTo(path, agent.worktree)
314 const holder = prs.find(pr => pr.branch !== agent.branch && pr.files.includes(file))
315 if (holder === undefined) return undefined
316 return `\`${file}\` is held by open PR #${holder.ref} (${holder.title}${holder.author === '' ? '' : `, by ${holder.author}`}), so editing it now could collide with that work. Stop and report this to the PM; do not work around it. The file frees up when that PR merges or closes. To change the rule, ${howToChange(SETTING.contention)}.`
317}
318
319/** Asks the person directly (D66): never Claude Code's own permission check, which auto mode could settle alone. */
320async function askPerson($: EngineInterface, question: string): Promise<'yes' | 'no' | 'unasked'> {
321 try {
322 const answer = await $.ui.ask(question, { options: ['Allow', 'Deny'], header: 'godmode' })
323 return answer === 'Allow' ? 'yes' : 'no'
324 } catch {
325 return 'unasked'
326 }
327}
328
329/**
330 * The gate every tool call passes while godmode is on: the thin main thread (D63), the
331 * dev agent's worktree and held files (D38, D48), drafts, attribution and the verdict
332 * gate (D15, D53), then the permission preset (D18), whose "ask" asks the person (D66).
333 */
334export async function decideToolCall($: EngineInterface, cache: LoopCache, e: ToolCallInput): Promise<ToolDecision> {
335 const isMain = e.agentId === undefined
336 if ((await read($, isOn)) !== true) return { isMain, opensPr: false }
337 const input = e as unknown as Record<string, unknown> & { tool: string }
338 const context = await loopContextOf($, cache)
339 const settings = context?.settings ?? settingsOf({})
340 const agent = e.agentId === undefined ? undefined : (await read($, agents))[e.agentId]
341 const allowed: ToolDecision = { isMain, agentId: e.agentId, agent, opensPr: false }
342
343 if (isMain && settings.isThinMainThread && (await read($, mode)) === 'coding') {
344 const verdict = mainThreadVerdict(input, settings, await read($, mainThreadReads))
345 if (!verdict.isAllowed) return { deny: verdict.reason }
346 if (verdict.isRead === true) await update($, mainThreadReads, count => count + 1)
347 }
348 // No readable note (setup mode): the presets and loop rules are not known yet.
349 if (context === undefined) return allowed
350
351 const asks: { level: Level; what: string; key: string }[] = []
352 let excerpt = ''
353 if (EDIT_TOOLS.includes(input.tool)) {
354 const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
355 if (path !== undefined && agent?.role === 'dev') {
356 const refusal = await devEditRefusal($, cache, context, agent, path)
357 if (refusal !== undefined) return { deny: refusal }
358 }
359 excerpt = path ?? input.tool
360 asks.push({ level: settings.levels.edit, what: ACTION_WORDS.edit, key: permissionKey('edit') })
361 }
362 if (input.tool === 'Bash' && typeof input.command === 'string') {
363 const command = input.command
364 excerpt = firstLine(command)
365 for (const step of classifyShell(command)) {
366 const refusal = await shellRefusal($, context, step, command)
367 if (refusal !== undefined) return { deny: refusal }
368 let level = settings.levels[step.action]
369 let what = ACTION_WORDS[step.action]
370 if (step.pr !== undefined && step.action !== 'pr-open' && (await isOthersPr($, cache, context, step.pr))) {
371 level = strictest([level, settings.othersPrs])
372 what = `${what} (PR #${step.pr} is someone else's)`
373 }
374 asks.push({ level, what, key: permissionKey(step.action) })
375 if (step.action === 'pr-open') allowed.opensPr = true
376 }
377 }
378
379 const level = strictest(asks.map(ask => ask.level))
380 if (level === 'allow') return allowed
381 const gated = asks.filter(ask => ask.level === level)
382 const whats = [...new Set(gated.map(ask => ask.what))].join(' and ')
383 const presetLine = `the ${settings.preset} permission preset${settings.presetNote === undefined ? '' : ` (${settings.presetNote})`}`
384 if (level === 'never') {
385 return {
386 deny: `${presetLine} never allows this: ${whats}. Nothing ran. To change it, ${howToChange(gated[0]?.key ?? SETTING.preset)}.`,
387 }
388 }
389 const answer = await askPerson($, `godmode: ${who(e.agentId, agent)} wants to ${whats}: \`${excerpt}\`. Allow it?`)
390 if (answer === 'yes') return allowed
391 if (answer === 'no') return { deny: `The person said no to this (${whats}). Nothing ran. Do not retry it; say what you needed it for.` }
392 return {
393 deny: `Under ${presetLine}, this needs the person's yes (${whats}), and godmode could not ask them: nobody to ask in a headless run, or the question was dismissed. Nothing ran. Ask them in your reply, or ${howToChange(SETTING.preset)}.`,
394 }
395}
396
397/** A tool result's text as the model would read it. */
398const outputOf = (result: ToolCallResult) => {
399 if (result.deny !== undefined) return ''
400 if (typeof result.text === 'string') return result.text
401 const value = result.result as { stdout?: unknown } | undefined
402 return typeof value?.stdout === 'string' ? value.stdout : ''
403}
404
405/** The tickets backend, or undefined where it cannot be reached; for best-effort bookkeeping. */
406function backendOrNothing($: EngineInterface, context: LoopContext): TicketBackend | undefined {
407 try {
408 return backendFor($, trackerOf(context.note.shared.answers), context.note.dir)
409 } catch {
410 return undefined
411 }
412}
413
414/** Takes the draft PRs waiting for a review start, and says what the PM does next (D35). */
415export async function takePendingReviews($: EngineInterface) {
416 const pending = await read($, pendingReviews)
417 if (pending.length === 0) return undefined
418 await update($, pendingReviews, () => [])
419 const lines = pending.map(
420 review =>
421 `- PR #${review.pr}${review.url === undefined ? '' : ` (${review.url})`}${review.ticket === undefined ? '' : ` for ${review.ticket}`}, opened by dev agent ${review.agentId}`,
422 )
423 return [
424 `godmode: ${pending.length === 1 ? 'a dev agent opened a draft PR' : 'dev agents opened draft PRs'}. Start each review now, without waiting to be asked: dispatch one godmode:reviewer agent per PR.`,
425 ...lines,
426 'The reviewer records its verdict with review_verdict. A PR is marked ready or merged only with a clean verdict at its current commit.',
427 ].join('\n')
428}
429
430/**
431 * What the gate adds once a call ran: a dev agent's new draft PR is queued for review (D35);
432 * a main-thread result carries the queue to the PM.
433 */
434export async function afterToolCall(
435 $: EngineInterface,
436 cache: LoopCache,
437 decision: ToolDecision,
438 result: ToolCallResult,
439): Promise<ToolCallResult> {
440 if (decision.deny !== undefined || result.deny !== undefined || result.isError === true) return result
441 if ((await read($, isOn)) !== true) return result
442 const context = await loopContextOf($, cache)
443 const extra: string[] = []
444 const { agent, agentId } = decision
445 if (decision.opensPr && agent?.role === 'dev' && agentId !== undefined && context?.settings.isReviewStartOn === true) {
446 const url = /https?:\/\/\S+?\/pull\/\d+/.exec(outputOf(result))?.[0]
447 const pr = url === undefined ? undefined : prNumberOf(url)
448 if (pr !== undefined) {
449 const review: PendingReview = { pr, url, agentId, ticket: agent.ticket }
450 await update($, pendingReviews, queue => [...queue.filter(entry => entry.pr !== pr), review])
451 if (agent.ticket !== undefined) await backendOrNothing($, context)?.move(agent.ticket, { pr: url, status: 'review' }).catch(() => undefined)
452 extra.push(`godmode recorded draft PR #${pr} and tells the PM to start its review. Your part is done: stop here and report the PR link.`)
453 }
454 }
455 if (decision.isMain) {
456 const told = await takePendingReviews($)
457 if (told !== undefined) extra.push(told)
458 }
459 return extra.length === 0 ? result : { ...result, context: [...(result.context ?? []), ...extra] }
460}
461
462// ---------------------------------------------------------------------------
463// Dispatch (D52, D58, D62) and the dev brief (D38, D47)
464
465export type SpawnPlan = { deny: string } | { deny?: undefined; input: AgentSpawnInput; record?: DispatchedAgent }
466
467const parentOf = (path: string) => normalisePath(path).replace(/\/[^/]*$/, '') || '/'
468const baseNameOf = (path: string) => normalisePath(path).split('/').at(-1) ?? 'repo'
469
470/** A ticket's worktree: under the `worktreeFolder` setting, else beside the repo in `<repo>-worktrees/`. */
471export const worktreePathOf = (project: Project, settings: LoopSettings, ticket: string) => {
472 const folder =
473 settings.worktreeFolder === undefined
474 ? `${parentOf(project.root)}/${baseNameOf(project.root)}-worktrees`
475 : settings.worktreeFolder.startsWith('/')
476 ? settings.worktreeFolder
477 : `${project.root}/${settings.worktreeFolder}`
478 return normalisePath(`${folder}/${ticket}`)
479}
480
481/** The ticket's worktree, made on its branch when it is not there yet; a string says why it could not be. */
482async function ensureWorktree($: EngineInterface, project: Project, settings: LoopSettings, path: string, branch: string) {
483 if (await $.fs.exists(path)) return undefined
484 const git = (args: string[]) => $.process.run(['git', ...args], { cwd: project.root })
485 const hasBranch = (await git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).exitCode === 0
486 let base = settings.baseBranch
487 if (!hasBranch && base === undefined) {
488 const origin = await git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
489 base = origin.exitCode === 0 && origin.stdout.trim() !== '' ? origin.stdout.trim() : 'HEAD'
490 }
491 const args = hasBranch ? ['worktree', 'add', path, branch] : ['worktree', 'add', '-b', branch, path, base ?? 'HEAD']
492 const made = await git(args)
493 if (made.exitCode === 0) return undefined
494 return `godmode could not make the ticket's worktree (\`git ${args.join(' ')}\`: ${firstLine(made.stderr || made.stdout, 300)}). The dev agent did not start; fix that, then dispatch again.`
495}
496
497const HELD_LIST_LIMIT = 200
498
499/** What godmode adds to a dev agent's brief: its ticket, worktree, and the files other PRs hold (D38, D47). */
500export const devBrief = (ticket: { id: string; title: string }, worktree: string, branch: string, held: readonly PullRequest[] | undefined) => {
501 const lines = [
502 '---',
503 'From godmode (added to this brief by the mod):',
504 `- Ticket: ${ticket.id}, "${ticket.title}".`,
505 `- Your worktree: ${worktree}, on branch ${branch}. Work, run commands and commit only there: an edit outside it is refused.`,
506 ]
507 if (held !== undefined) {
508 const files = held.flatMap(pr => pr.files.map(file => ` - ${file} (PR #${pr.ref}${pr.author === '' ? '' : `, ${pr.author}`})`))
509 if (files.length === 0) {
510 lines.push('- No other open PR holds a file right now. If one opens while you work, an edit to a file it holds is refused: stop and report then.')
511 } else {
512 lines.push('- Files other open PRs hold. Do not edit them; an edit to one is refused. If the ticket needs one, stop and report to the PM:')
513 lines.push(...files.slice(0, HELD_LIST_LIMIT))
514 if (files.length > HELD_LIST_LIMIT) lines.push(` - ... and ${files.length - HELD_LIST_LIMIT} more`)
515 }
516 }
517 lines.push(
518 '- When the work is done, push the branch and open the PR as a draft (`gh pr create --draft`), then stop and report its link: the review starts from there.',
519 )
520 return lines.join('\n')
521}
522
523/** A dev agent's dispatch: a ready ticket (D52), its worktree, and the contention sweep in its brief (D47). */
524async function planDevSpawn($: EngineInterface, cache: LoopCache, context: LoopContext, e: AgentSpawnInput): Promise<SpawnPlan> {
525 const { settings, project } = context
526 let backend: TicketBackend
527 try {
528 backend = backendFor($, trackerOf(context.note.shared.answers), context.note.dir)
529 } catch (error) {
530 return { deny: `godmode cannot read the tickets to check this dispatch: ${messageOf(error)}` }
531 }
532 const id = ticketIn(e.prompt, backend.tracker === 'none')
533 if (id === undefined) {
534 return { deny: 'A dev agent builds one ticket, and this brief names none. Name the ticket (e.g. T-3) in the brief, then dispatch again.' }
535 }
536 const ticket = (await backend.list({ statuses: TICKET_STATUSES })).find(header => header.id.toUpperCase() === id)
537 if (ticket === undefined) return { deny: `The brief names ${id}, and there is no such ticket. File it first, or fix the id.` }
538 if (ticket.status === 'done') return { deny: `${ticket.id} is done; a dev agent does not start on a done ticket.` }
539 if (settings.requiresReadyTicket && !ticket.ready) {
540 return {
541 deny: `${ticket.id} is not marked ready: a dev agent starts only on a ticket the person said go for. Once they have, mark it with ticket_move (ready: true) and dispatch again. To change the rule, ${howToChange(SETTING.requireReadyTicket)}.`,
542 }
543 }
544 const branch = ticket.branch ?? `${settings.branchPrefix}${ticket.id.toLowerCase()}-${slugOf(ticket.title)}`
545 const worktree = worktreePathOf(project, settings, ticket.id)
546 const refusal = await ensureWorktree($, project, settings, worktree, branch)
547 if (refusal !== undefined) return { deny: refusal }
548 if (ticket.branch === undefined) await backend.move(ticket.id, { branch }).catch(() => undefined)
549
550 let held: PullRequest[] | undefined
551 if (settings.isContentionOn) {
552 try {
553 held = (await openPullRequests($, cache, context, true)).filter(pr => pr.branch !== branch && pr.files.length > 0)
554 } catch (error) {
555 return {
556 deny: `godmode could not list the open PRs and their files (${messageOf(error)}), so the dev agent did not start: without that list it could edit a file another PR holds. Try again once the host answers. To change the rule, ${howToChange(SETTING.contention)}.`,
557 }
558 }
559 }
560 return {
561 input: { ...e, prompt: `${e.prompt}\n\n${devBrief(ticket, worktree, branch, held)}`, cwd: worktree },
562 record: { role: 'dev', ticket: ticket.id, worktree, branch },
563 }
564}
565
566/**
567 * Every spawn while godmode is on: the agent cap (D58), the role's model floor (D62), and
568 * for a dev agent its ticket, worktree and brief (D38, D47, D52).
569 */
570export async function planSpawn($: EngineInterface, cache: LoopCache, e: AgentSpawnInput): Promise<SpawnPlan> {
571 if ((await read($, isOn)) !== true) return { input: e }
572 const context = await loopContextOf($, cache)
573 const settings = context?.settings ?? settingsOf({})
574 const running = (await $.agent.list()).filter(agent => RUNNING_STATUSES.includes(agent.status))
575 if (running.length >= settings.agentCap) {
576 const names = running.map(agent => agent.description || agent.type).slice(0, 8).join('; ')
577 return {
578 deny: `godmode runs at most ${settings.agentCap} agents at once, and ${running.length} are running (${names}). Wait for one to finish or stop one, then dispatch again. To change the cap, ${howToChange(SETTING.agentCap)}.`,
579 }
580 }
581 const role = roleOf(e.subagentType)
582 if (role === undefined) return { input: e }
583 // A workflow's agent can only be refused, not rewritten.
584 if (e.workflow !== undefined) return { input: e, record: { role } }
585 const model = spawnModel(e.model, role, context?.note)
586 const input = model === e.model ? e : { ...e, model }
587 if (role !== 'dev' || context === undefined || (await read($, mode)) !== 'coding') return { input, record: { role } }
588 return planDevSpawn($, cache, context, input)
589}
590
591/** Remembers a started agent's role, so its tool calls meet its role's rules. */
592export async function recordAgent($: EngineInterface, agentId: string, record: DispatchedAgent) {
593 await update($, agents, known => ({ ...known, [agentId]: record }))
594}
595hooks/storage.ts 345 lines1import type { EngineInterface } from 'claude-code'
2
3/**
4 * Where a project's godmode setup lives (D19): `committed` in the repo, shared with
5 * everyone who clones it; `gitignored` in the repo but on this disk only (ignored through
6 * `.git/info/exclude`, so the repo itself is untouched); `outside` under
7 * Claude Code's configuration folder, the project folder untouched.
8 */
9export type StorageMode = 'committed' | 'gitignored' | 'outside'
10
11export const STORAGE_MODES: readonly StorageMode[] = ['committed', 'gitignored', 'outside']
12
13/**
14 * The folder in the repo that holds godmode's own files in the committed and gitignored
15 * modes. A dot folder like `.github/`, named after the mod so whoever finds it knows what
16 * wrote it, and one folder is one ignore line in gitignored mode. Not under
17 * `.claude/`: Claude Code reads that folder itself (settings, rules, agents, skills), so
18 * a file there can change every session, and teams often keep it by hand.
19 */
20export const REPO_FOLDER = '.godmode'
21
22/**
23 * The note is two files (D61). The shared one is committed in committed mode; the
24 * personal one never is. In the other two modes both are personal and sit side by side.
25 * JSON, because only the mod writes them and the environment parses it with no code of
26 * ours; pretty-printed, so a committed note's diff reads line by line.
27 */
28export const SHARED_NOTE = 'shared.json'
29export const PERSONAL_NOTE = 'personal.json'
30
31/** The roles godmode dispatches (D33). The PM is the main thread, on the session's model (D39). */
32export type AgentRole = 'dev' | 'reviewer' | 'setup' | 'researcher' | 'qa'
33
34export type ModelTier = 'haiku' | 'sonnet' | 'opus'
35
36/** A questionnaire answer (D18, D19, D23, D15, D34); Step 3 names the questions. */
37export type Answer = string | number | boolean
38
39/** A conventions file the setup checked, and the fingerprint of the text it checked. */
40export type ConventionsFile = {
41 /** Relative to the project root, or absolute for a file outside the repo. */
42 path: string
43 /** `sha256:<hex>` of the file's text; see `fingerprint`. */
44 fingerprint: string
45}
46
47/** What the whole team shares in committed mode (D61): everything quality depends on. */
48export type SharedNote = {
49 /** The godmode version that wrote the conventions; an older one prompts the update notice (D60). */
50 godmodeVersion: string
51 storage: StorageMode
52 /** When the note was written, ISO 8601. */
53 writtenAt: string
54 /** Shared answers: tracker choice, start command, QA on or off. */
55 answers: Record<string, Answer>
56 /** The lowest model each role may run on (D62); personal settings raise it, never lower it. */
57 minimumModels: Partial<Record<AgentRole, ModelTier>>
58 conventions: ConventionsFile[]
59}
60
61/** What stays one person's (D61): permissions, attribution, models, caps, tracker identity. */
62export type PersonalNote = {
63 writtenAt: string
64 answers: Record<string, Answer>
65 /** The models this person picked per role (D39); below a shared minimum they are raised to it. */
66 models: Partial<Record<AgentRole, ModelTier>>
67}
68
69/** A project, named so its note is found in every storage mode (D20). */
70export type Project = {
71 /**
72 * The repo's first commit hash: the same in every clone and worktree, and it survives a
73 * move or rename of the folder. Without git, or before the first commit, `path-` and the
74 * folder path. D54 refuses a folder with no git; naming it must still not fail.
75 */
76 id: string
77 /** The main working tree's root, so a session in a worktree finds the same note; without git, the session's root. */
78 root: string
79 hasGit: boolean
80}
81
82/** What `readNote` found. `personal` is absent for a teammate who has not answered yet (D61). */
83export type NoteLookup =
84 | { status: 'none'; project: Project }
85 | { status: 'unreadable'; project: Project; path: string; reason: string }
86 | { status: 'found'; project: Project; dir: string; shared: SharedNote; personal?: PersonalNote }
87
88/** The default model per role (D39). */
89export const DEFAULT_MODELS: Readonly<Record<AgentRole, ModelTier>> = {
90 dev: 'sonnet',
91 reviewer: 'opus',
92 setup: 'opus',
93 researcher: 'sonnet',
94 qa: 'sonnet',
95}
96
97const MODEL_RANK: Readonly<Record<ModelTier, number>> = { haiku: 0, sonnet: 1, opus: 2 }
98
99/**
100 * The model a role runs on: the person's pick, or the default, raised to the shared
101 * minimum when below it (D62). Only committed mode has a minimum anyone else set.
102 */
103export const effectiveModel = (
104 role: AgentRole,
105 note?: { shared?: Pick<SharedNote, 'minimumModels'>; personal?: Pick<PersonalNote, 'models'> },
106): ModelTier => {
107 const picked = note?.personal?.models[role] ?? DEFAULT_MODELS[role]
108 const minimum = note?.shared?.minimumModels[role]
109 return minimum !== undefined && MODEL_RANK[minimum] > MODEL_RANK[picked] ? minimum : picked
110}
111
112/**
113 * A file's fingerprint: SHA-256 through `crypto.subtle`, which the hooks environment has
114 * (there is no Node `crypto`). Line endings are made `\n` first, so a checkout that turns
115 * them into `\r\n` does not count as an edit.
116 */
117export const fingerprint = async (text: string) => {
118 const bytes = new TextEncoder().encode(text.replace(/\r\n/g, '\n'))
119 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
120 return `sha256:${[...digest].map(byte => byte.toString(16).padStart(2, '0')).join('')}`
121}
122
123/** Whether version `a` is older than `b`, comparing the numbers of `major.minor.patch`. */
124export const isOlderVersion = (a: string, b: string) => {
125 const numbers = (version: string) => version.split(/[.+-]/).slice(0, 3).map(part => Number.parseInt(part, 10) || 0)
126 const [left, right] = [numbers(a), numbers(b)]
127 for (let index = 0; index < 3; index += 1) {
128 if ((left[index] ?? 0) !== (right[index] ?? 0)) return (left[index] ?? 0) < (right[index] ?? 0)
129 }
130 return false
131}
132
133const isAbsolute = (path: string) => path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
134
135const join = (folder: string, path: string) => (isAbsolute(path) ? path : `${folder.replace(/[\\/]+$/, '')}/${path}`)
136
137/*
138 * Every helper that takes `$` is a function declaration, not an arrow function: the
139 * engine follows `$` past an import only into a function declaration, and refuses to load
140 * the module otherwise.
141 */
142
143/** Runs git in `cwd`; its standard output, or undefined when git is missing or fails. */
144async function git($: EngineInterface, cwd: string, args: string[]) {
145 const result = await $.process.run(['git', ...args], { cwd }).catch(() => undefined)
146 return result?.exitCode === 0 ? result.stdout : undefined
147}
148
149const pathId = (root: string) => `path-${root.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`
150
151/** The project the session runs in (see `Project`). */
152export async function projectOf($: EngineInterface): Promise<Project> {
153 const base = await $.session.root()
154 const worktrees = await git($, base, ['worktree', 'list', '--porcelain'])
155 if (worktrees === undefined) return { id: pathId(base), root: base, hasGit: false }
156
157 // The first entry is the main working tree; a bare main has none, so keep the session's.
158 const main = worktrees.split(/\n\s*\n/)[0] ?? ''
159 const root = /^bare$/m.test(main) ? base : (/^worktree (.+)$/m.exec(main)?.[1] ?? base)
160 // A history merged from elsewhere has several roots; git lists the newest first.
161 const firstCommit = (await git($, root, ['rev-list', '--max-parents=0', 'HEAD']))?.trim().split('\n').at(-1)
162 return { id: firstCommit !== undefined && firstCommit !== '' ? firstCommit : pathId(root), root, hasGit: true }
163}
164
165/** Claude Code's configuration folder: `CLAUDE_CONFIG_DIR`, else `~/.claude`; undefined with no home. */
166export async function claudeDirOf($: EngineInterface) {
167 const configured = await $.env.get('CLAUDE_CONFIG_DIR')
168 if (configured !== undefined && configured !== '') return configured
169 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
170 return home === undefined || home === '' ? undefined : `${home}/.claude`
171}
172
173/** The folder holding a project's note in a storage mode; undefined for `outside` with no home. */
174export const noteDir = (project: Project, mode: StorageMode, claudeDir: string | undefined) => {
175 if (mode !== 'outside') return `${project.root}/${REPO_FOLDER}`
176 return claudeDir === undefined ? undefined : `${claudeDir}/godmode/projects/${project.id}`
177}
178
179/**
180 * The ignore lines a storage mode needs: committed mode ignores the personal note alone
181 * (D61) in `.gitignore`, for every clone; gitignored mode the whole folder, in
182 * `.git/info/exclude` (see `ensureExcluded`).
183 */
184export const gitignoreLines = (mode: StorageMode): string[] => {
185 if (mode === 'committed') return [`${REPO_FOLDER}/${PERSONAL_NOTE}`]
186 if (mode === 'gitignored') return [`${REPO_FOLDER}/`]
187 return []
188}
189
190/**
191 * `.gitignore`'s text with `lines` added under a godmode comment, or undefined when each is
192 * already there (as written, or anchored with a leading `/`).
193 */
194export const withGitignoreLines = (
195 current: string,
196 lines: readonly string[],
197 comment = 'godmode: personal setup, never committed',
198) => {
199 const present = new Set(current.split(/\r?\n/).map(line => line.trim().replace(/^\//, '')))
200 const missing = lines.filter(line => !present.has(line.replace(/^\//, '')))
201 if (missing.length === 0) return undefined
202 const head = current === '' || current.endsWith('\n') ? current : `${current}\n`
203 const gap = head === '' ? '' : '\n'
204 return `${head}${gap}# ${comment}\n${missing.join('\n')}\n`
205}
206
207/** Adds `lines` to the project's `.gitignore` once; true when it wrote. */
208export async function ensureGitignore($: EngineInterface, project: Project, lines: readonly string[]) {
209 if (lines.length === 0) return false
210 const path = `${project.root}/.gitignore`
211 const current = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
212 const updated = withGitignoreLines(current, lines)
213 if (updated === undefined) return false
214 await $.fs.write(path, updated)
215 return true
216}
217
218/**
219 * Adds `lines` to the repo's `.git/info/exclude` once; true when it wrote. Gitignored mode
220 * uses it rather than `.gitignore`, a tracked file whose edit would show as a change and
221 * could be committed by mistake. The exclude file lives in git's common folder, so every
222 * worktree of the repo honours it.
223 */
224export async function ensureExcluded($: EngineInterface, project: Project, lines: readonly string[]) {
225 if (lines.length === 0 || !project.hasGit) return false
226 const commonDir = (await git($, project.root, ['rev-parse', '--path-format=absolute', '--git-common-dir']))?.trim()
227 if (commonDir === undefined || commonDir === '') return false
228 const path = `${commonDir}/info/exclude`
229 const current = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
230 const updated = withGitignoreLines(current, lines, 'godmode: setup kept on this machine only')
231 if (updated === undefined) return false
232 await $.fs.write(path, updated)
233 return true
234}
235
236/** The version in this mod's `plugin.json`, the one a new note records. */
237export async function godmodeVersionOf($: EngineInterface) {
238 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }
239 return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
240}
241
242const isRecord = (value: unknown): value is Record<string, unknown> =>
243 typeof value === 'object' && value !== null && !Array.isArray(value)
244
245const isSharedNote = (value: unknown): value is SharedNote =>
246 isRecord(value) &&
247 typeof value.godmodeVersion === 'string' &&
248 STORAGE_MODES.includes(value.storage as StorageMode) &&
249 typeof value.writtenAt === 'string' &&
250 isRecord(value.answers) &&
251 isRecord(value.minimumModels) &&
252 Array.isArray(value.conventions) &&
253 value.conventions.every(file => isRecord(file) && typeof file.path === 'string' && typeof file.fingerprint === 'string')
254
255const isPersonalNote = (value: unknown): value is PersonalNote =>
256 isRecord(value) && typeof value.writtenAt === 'string' && isRecord(value.answers) && isRecord(value.models)
257
258const parse = <T>(text: string, check: (value: unknown) => value is T): T | string => {
259 try {
260 const value: unknown = JSON.parse(text)
261 return check(value) ? value : 'it is not a godmode note'
262 } catch (error) {
263 return `it is not valid JSON (${error instanceof Error ? error.message : String(error)})`
264 }
265}
266
267/**
268 * Finds the project's note: in the repo's `.godmode/` first (committed or gitignored, the
269 * note says which), then outside the repo. The personal half sits beside the shared one.
270 */
271export async function readNote($: EngineInterface, project?: Project): Promise<NoteLookup> {
272 const at = project ?? (await projectOf($))
273 const claudeDir = await claudeDirOf($)
274 const dirs = [noteDir(at, 'committed', claudeDir), noteDir(at, 'outside', claudeDir)]
275 for (const dir of dirs) {
276 if (dir === undefined || !(await $.fs.exists(`${dir}/${SHARED_NOTE}`))) continue
277 const sharedPath = `${dir}/${SHARED_NOTE}`
278 const shared = parse(await $.fs.read(sharedPath), isSharedNote)
279 if (typeof shared === 'string') return { status: 'unreadable', project: at, path: sharedPath, reason: shared }
280 const personalPath = `${dir}/${PERSONAL_NOTE}`
281 if (!(await $.fs.exists(personalPath))) return { status: 'found', project: at, dir, shared }
282 const personal = parse(await $.fs.read(personalPath), isPersonalNote)
283 if (typeof personal === 'string') return { status: 'unreadable', project: at, path: personalPath, reason: personal }
284 return { status: 'found', project: at, dir, shared, personal }
285 }
286 return { status: 'none', project: at }
287}
288
289/** What the setup hands `writeNote`; the version and the date are stamped on writing. */
290export type NoteDraft = {
291 storage: StorageMode
292 sharedAnswers: Record<string, Answer>
293 minimumModels: Partial<Record<AgentRole, ModelTier>>
294 /** Paths of the conventions files the setup checked; their fingerprints are taken now. */
295 conventions: string[]
296 personalAnswers: Record<string, Answer>
297 models: Partial<Record<AgentRole, ModelTier>>
298}
299
300/**
301 * Writes both halves of the note in the draft's storage and adds the mode's ignore
302 * lines. Step 3 calls it when setup ends; resolves the folder written to.
303 */
304export async function writeNote($: EngineInterface, draft: NoteDraft, project?: Project) {
305 const at = project ?? (await projectOf($))
306 const dir = noteDir(at, draft.storage, await claudeDirOf($))
307 if (dir === undefined) throw new Error('no home folder to keep the note outside the repo')
308 const writtenAt = new Date(await $.clock.now()).toISOString()
309 const conventions: ConventionsFile[] = []
310 for (const path of draft.conventions) {
311 const fileFingerprint = await fingerprintOf($, at, path)
312 if (fileFingerprint !== undefined) conventions.push({ path, fingerprint: fileFingerprint })
313 }
314 const shared: SharedNote = {
315 godmodeVersion: await godmodeVersionOf($),
316 storage: draft.storage,
317 writtenAt,
318 answers: draft.sharedAnswers,
319 minimumModels: draft.minimumModels,
320 conventions,
321 }
322 const personal: PersonalNote = { writtenAt, answers: draft.personalAnswers, models: draft.models }
323 if (draft.storage === 'gitignored') await ensureExcluded($, at, gitignoreLines(draft.storage))
324 else await ensureGitignore($, at, gitignoreLines(draft.storage))
325 await $.fs.write(`${dir}/${SHARED_NOTE}`, `${JSON.stringify(shared, null, 2)}\n`)
326 await $.fs.write(`${dir}/${PERSONAL_NOTE}`, `${JSON.stringify(personal, null, 2)}\n`)
327 return dir
328}
329
330/** A conventions file's fingerprint now, or undefined when it is gone. */
331export async function fingerprintOf($: EngineInterface, project: Project, path: string) {
332 const full = join(project.root, path)
333 if (!(await $.fs.exists(full))) return undefined
334 return fingerprint(await $.fs.read(full))
335}
336
337/** The conventions files edited or removed since the note was written; each is re-checked alone (D20). */
338export async function changedConventions($: EngineInterface, project: Project, conventions: readonly ConventionsFile[]) {
339 const changed: string[] = []
340 for (const file of conventions) {
341 if ((await fingerprintOf($, project, file.path)) !== file.fingerprint) changed.push(file.path)
342 }
343 return changed
344}
345hooks/tickets.ts 542 lines1import type { EngineInterface, ToolSpec } from 'claude-code'
2
3import { readNote } from './storage'
4import type { Answer } from './storage'
5
6/** Where a ticket stands (D24). `done` tickets move to `tickets/done/`. */
7export type TicketStatus = 'todo' | 'doing' | 'review' | 'done'
8
9export const TICKET_STATUSES: readonly TicketStatus[] = ['todo', 'doing', 'review', 'done']
10
11/** The tracker the setup chose (D23): `none` keeps markdown files, `jira` and `other` reach a real tracker. */
12export type TrackerKind = 'none' | 'jira' | 'other'
13
14export const TRACKERS: readonly TrackerKind[] = ['none', 'jira', 'other']
15
16/** A ticket's short header: everything `list` reads, none of the body. */
17export type TicketHeader = {
18 /** `T-1`, `T-2`, ... for markdown tickets (D50); a tracker's own key elsewhere. */
19 id: string
20 title: string
21 status: TicketStatus
22 /** Ids of the tickets that must be done first. */
23 blockedBy: string[]
24 branch?: string
25 /** The PR's number or link, or the branch under review on a host with no PRs (D54). */
26 pr?: string
27 /** Whether a user would see the change; QA runs only when it is true (D34, D36). */
28 visibleChange: boolean
29 /** Set only when the person said go for this ticket, so dispatch may start it (D52). */
30 ready: boolean
31 /** The day the ticket was filed, `YYYY-MM-DD`. */
32 created: string
33}
34
35/** What a new ticket holds: the header's own fields and the ticket shape from the reference loop. */
36export type TicketDraft = {
37 title: string
38 /** The person's ask, word for word. */
39 ask: string
40 /** Claims checked against the code or a run, each with its evidence. */
41 measured?: string
42 /** Hypotheses not checked yet. */
43 inferred?: string
44 /** A question to settle before building. */
45 answerFirst?: string
46 scopeIn?: string
47 scopeOut?: string
48 constraints?: string
49 /** The tests to add, each shown failing without the change. */
50 tests?: string
51 refs?: string
52 blockedBy?: string[]
53 visibleChange: boolean
54}
55
56/** A change to a ticket's header; fields left out stay as they are. */
57export type TicketChange = Partial<Pick<TicketHeader, 'status' | 'ready' | 'branch' | 'pr' | 'blockedBy' | 'visibleChange'>>
58
59/** Which tickets `list` returns: absent `statuses`, every one not done. */
60export type TicketFilter = { statuses?: readonly TicketStatus[] }
61
62export type TicketComment = { author: string; text: string }
63
64/**
65 * What every tracker does for the four ticket tools (D24): the PM always says "move T-3
66 * to review", and the backend the setup chose carries it out.
67 */
68export interface TicketBackend {
69 readonly tracker: TrackerKind
70 create(draft: TicketDraft): Promise<TicketHeader>
71 move(id: string, change: TicketChange): Promise<TicketHeader>
72 list(filter: TicketFilter): Promise<TicketHeader[]>
73 comment(id: string, comment: TicketComment): Promise<TicketHeader>
74}
75
76/** A ticket call the model can correct: an unknown id, a bad field, a tracker not built yet. */
77export class TicketError extends Error {}
78
79export const TICKETS_FOLDER = 'tickets'
80export const DONE_FOLDER = 'done'
81/** Holds the next id, so an id stays used after its ticket is gone (D50). */
82export const NEXT_ID_FILE = 'next-id'
83
84/** `T-3`, `t-3`, `T3` or `3` as `T-3`; undefined when it is no markdown ticket id. */
85export const normaliseId = (raw: string) => {
86 const number = /^\s*(?:t-?)?0*(\d+)\s*$/i.exec(raw)?.[1]
87 return number === undefined || number === '0' ? undefined : `T-${number}`
88}
89
90/** The file name's middle part: the title in lowercase ASCII words, at most 40 characters. */
91export const slugOf = (title: string) =>
92 title
93 .normalize('NFKD')
94 .replace(/[̀-ͯ]/g, '')
95 .toLowerCase()
96 .replace(/[^a-z0-9]+/g, '-')
97 .replace(/^-+/, '')
98 .slice(0, 40)
99 .replace(/-+$/, '') || 'ticket'
100
101const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
102
103/** The header as the file's front matter: one `key: value` per line between `---` lines. */
104export const renderHeader = (header: TicketHeader) =>
105 [
106 '---',
107 `id: ${header.id}`,
108 `title: ${oneLine(header.title)}`,
109 `status: ${header.status}`,
110 `ready: ${header.ready ? 'yes' : 'no'}`,
111 `visibleChange: ${header.visibleChange ? 'yes' : 'no'}`,
112 `blockedBy: ${header.blockedBy.join(', ')}`,
113 `branch: ${oneLine(header.branch ?? '')}`,
114 `pr: ${oneLine(header.pr ?? '')}`,
115 `created: ${header.created}`,
116 '---',
117 '',
118 ]
119 .map(line => line.trimEnd())
120 .join('\n')
121
122const isStatus = (value: string): value is TicketStatus => TICKET_STATUSES.includes(value as TicketStatus)
123
124/**
125 * Splits a ticket file into its header and the rest; a string says what is wrong with it.
126 * Only the front matter is parsed, so listing never depends on the body.
127 */
128export const parseTicket = (text: string): { header: TicketHeader; body: string } | string => {
129 const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text)
130 if (match === null) return 'it has no header between `---` lines'
131 const fields = new Map<string, string>()
132 for (const line of (match[1] ?? '').split(/\r?\n/)) {
133 const pair = /^([A-Za-z]+):\s?(.*)$/.exec(line)
134 if (pair !== null) fields.set(pair[1] ?? '', (pair[2] ?? '').trim())
135 }
136 const id = fields.get('id') ?? ''
137 const status = fields.get('status') ?? ''
138 if (id === '') return 'its header has no id'
139 if (!isStatus(status)) return `its status \`${status}\` is not one of ${TICKET_STATUSES.join(', ')}`
140 const optional = (key: string) => (fields.get(key) ?? '') || undefined
141 return {
142 header: {
143 id,
144 title: fields.get('title') ?? '',
145 status,
146 blockedBy: (fields.get('blockedBy') ?? '').split(',').map(part => part.trim()).filter(part => part !== ''),
147 branch: optional('branch'),
148 pr: optional('pr'),
149 visibleChange: fields.get('visibleChange') === 'yes',
150 ready: fields.get('ready') === 'yes',
151 created: fields.get('created') ?? '',
152 },
153 body: text.slice(match[0].length),
154 }
155}
156
157/** The body: the ticket shape (The ask, Measured, Inferred, Scope, ...), empty sections left out, then `## Comments`. */
158export const renderBody = (id: string, draft: TicketDraft) => {
159 const section = (heading: string, text?: string) =>
160 text === undefined || text.trim() === '' ? [] : [`## ${heading}`, '', text.trim(), '']
161 const scope = [
162 ...(draft.scopeIn?.trim() ? [`**In:** ${draft.scopeIn.trim()}`, ''] : []),
163 ...(draft.scopeOut?.trim() ? [`**Out:** ${draft.scopeOut.trim()}`, ''] : []),
164 ]
165 return [
166 `# ${id} ${oneLine(draft.title)}`,
167 '',
168 ...section('The ask', draft.ask),
169 ...section('Measured', draft.measured),
170 ...section('Inferred', draft.inferred),
171 ...section('Answer this first', draft.answerFirst),
172 ...(scope.length === 0 ? [] : ['## Scope', '', ...scope]),
173 ...section('Constraints', draft.constraints),
174 ...section('Tests', draft.tests),
175 ...section('Refs', draft.refs),
176 '## Comments',
177 '',
178 ].join('\n')
179}
180
181/** `text` with a comment added at its end, under `## Comments` (added first when missing). */
182export const withComment = (body: string, comment: TicketComment, at: number) => {
183 const stamp = new Date(at).toISOString().slice(0, 16).replace('T', ' ')
184 const head = body.endsWith('\n') ? body : `${body}\n`
185 const heading = /^## Comments\s*$/m.test(head) ? '' : '\n## Comments\n'
186 return `${head}${heading}\n### ${stamp} UTC, ${oneLine(comment.author)}\n\n${comment.text.trim()}\n`
187}
188
189/** One line per ticket, the way the model reads a list. */
190export const describeTicket = (header: TicketHeader) =>
191 [
192 header.id,
193 header.status,
194 ...(header.ready && header.status !== 'done' ? ['ready'] : []),
195 header.title,
196 ...(header.blockedBy.length === 0 ? [] : [`blocked by ${header.blockedBy.join(', ')}`]),
197 ...(header.branch === undefined ? [] : [`branch ${header.branch}`]),
198 ...(header.pr === undefined ? [] : [`PR ${header.pr}`]),
199 `visible change: ${header.visibleChange ? 'yes' : 'no'}`,
200 ].join(' | ')
201
202/*
203 * Writes run one at a time: the model can call several ticket tools at once, and two
204 * agents filing tickets together must not take the same id.
205 */
206let tail: Promise<unknown> = Promise.resolve()
207const serially = <T>(work: () => Promise<T>) => {
208 const run = tail.then(work, work)
209 tail = run.catch(() => undefined)
210 return run
211}
212
213type TicketFile = { number: number; path: string; isDone: boolean }
214
215const TICKET_FILE = /^T-(\d+)(?:-.*)?\.md$/
216
217/**
218 * The markdown backend, tracker "None" (D24): one file per ticket in `<dir>/tickets/`,
219 * named `T-<n>-<slug>.md`, done tickets in `tickets/done/`. `dir` is the note's folder,
220 * so tickets sit in the storage the setup chose (D19). `$.fs` has no rename, so a ticket
221 * moves between folders with `mv`.
222 */
223export function markdownBackend($: EngineInterface, dir: string): TicketBackend {
224 const folder = `${dir.replace(/[\\/]+$/, '')}/${TICKETS_FOLDER}`
225 const doneFolder = `${folder}/${DONE_FOLDER}`
226
227 const filesIn = async (path: string, isDone: boolean): Promise<TicketFile[]> => {
228 if (!(await $.fs.exists(path))) return []
229 const entries = await $.fs.list(path)
230 return entries.flatMap(entry => {
231 const number = entry.kind === 'file' ? TICKET_FILE.exec(entry.name)?.[1] : undefined
232 return number === undefined ? [] : [{ number: Number(number), path: `${path}/${entry.name}`, isDone }]
233 })
234 }
235 const allFiles = async () => [...(await filesIn(folder, false)), ...(await filesIn(doneFolder, true))]
236
237 const find = async (raw: string) => {
238 const id = normaliseId(raw)
239 if (id === undefined) throw new TicketError(`\`${raw}\` is not a ticket id; ids look like T-3.`)
240 const number = Number(id.slice(2))
241 const file = (await allFiles()).find(candidate => candidate.number === number)
242 if (file === undefined) throw new TicketError(`There is no ticket ${id}.`)
243 const parsed = parseTicket(await $.fs.read(file.path))
244 if (typeof parsed === 'string') throw new TicketError(`${id} cannot be read (${file.path}): ${parsed}.`)
245 return { ...file, id, ...parsed }
246 }
247
248 /** Blockers as ids, each an existing ticket, so a typo is caught when it is written. */
249 const checkBlockers = async (raw: readonly string[], self?: string) => {
250 const known = new Set((await allFiles()).map(file => `T-${file.number}`))
251 return raw.map(entry => {
252 const id = normaliseId(entry)
253 if (id === undefined) throw new TicketError(`\`${entry}\` in blockedBy is not a ticket id; ids look like T-3.`)
254 if (id === self) throw new TicketError(`${id} cannot block itself.`)
255 if (!known.has(id)) throw new TicketError(`blockedBy names ${id}, and there is no such ticket.`)
256 return id
257 })
258 }
259
260 const mv = async (from: string, to: string) => {
261 const target = to.slice(0, to.lastIndexOf('/'))
262 const made = await $.process.run(['mkdir', '-p', target])
263 const moved = made.exitCode === 0 ? await $.process.run(['mv', from, to]) : made
264 if (moved.exitCode !== 0) throw new TicketError(`Could not move ${from} to ${to}: ${moved.stderr.trim()}`)
265 }
266
267 return {
268 tracker: 'none',
269
270 create: draft =>
271 serially(async () => {
272 const files = await allFiles()
273 const counterPath = `${folder}/${NEXT_ID_FILE}`
274 const counter = (await $.fs.exists(counterPath)) ? Number.parseInt(await $.fs.read(counterPath), 10) || 1 : 1
275 const number = Math.max(counter, ...files.map(file => file.number + 1))
276 const header: TicketHeader = {
277 id: `T-${number}`,
278 title: oneLine(draft.title),
279 status: 'todo',
280 blockedBy: await checkBlockers(draft.blockedBy ?? []),
281 visibleChange: draft.visibleChange,
282 ready: false,
283 created: new Date(await $.clock.now()).toISOString().slice(0, 10),
284 }
285 await $.fs.write(`${folder}/T-${number}-${slugOf(draft.title)}.md`, `${renderHeader(header)}\n${renderBody(header.id, draft)}`)
286 await $.fs.write(counterPath, `${number + 1}\n`)
287 return header
288 }),
289
290 move: (id, change) =>
291 serially(async () => {
292 const ticket = await find(id)
293 const blockedBy = change.blockedBy === undefined ? ticket.header.blockedBy : await checkBlockers(change.blockedBy, ticket.id)
294 const header: TicketHeader = { ...ticket.header, ...change, blockedBy }
295 await $.fs.write(ticket.path, `${renderHeader(header)}\n${ticket.body.replace(/^\n+/, '')}`)
296 const name = ticket.path.slice(ticket.path.lastIndexOf('/') + 1)
297 if (header.status === 'done' && !ticket.isDone) await mv(ticket.path, `${doneFolder}/${name}`)
298 if (header.status !== 'done' && ticket.isDone) await mv(ticket.path, `${folder}/${name}`)
299 return header
300 }),
301
302 list: async filter => {
303 const statuses = filter.statuses ?? TICKET_STATUSES.filter(status => status !== 'done')
304 const files = statuses.includes('done') ? await allFiles() : await filesIn(folder, false)
305 const headers: TicketHeader[] = []
306 for (const file of files.sort((a, b) => a.number - b.number)) {
307 const parsed = parseTicket(await $.fs.read(file.path))
308 if (typeof parsed === 'string') throw new TicketError(`${file.path} cannot be read: ${parsed}.`)
309 if (statuses.includes(parsed.header.status)) headers.push(parsed.header)
310 }
311 return headers
312 },
313
314 comment: (id, comment) =>
315 serially(async () => {
316 const ticket = await find(id)
317 const text = await $.fs.read(ticket.path)
318 await $.fs.write(ticket.path, withComment(text, comment, await $.clock.now()))
319 return ticket.header
320 }),
321 }
322}
323
324/**
325 * Jira (D23): reached through the Atlassian connector once it is built. Until then it says
326 * what is missing and never falls back to markdown (D25).
327 */
328export function jiraBackend(): TicketBackend {
329 throw new TicketError(
330 'The Jira tracker is not built yet: godmode still needs its mapping of create, move, list and comment onto the Atlassian connector. No ticket was written, and nothing fell back to markdown tickets.',
331 )
332}
333
334/** "Other" (D25): the mapping found at setup (a connector or a CLI), not built yet; never a silent fallback. */
335export function otherBackend(): TicketBackend {
336 throw new TicketError(
337 'The "Other" tracker is not built yet: godmode still needs the mapping the setup finds (a connector or a command-line tool) and saves in the note. No ticket was written, and nothing fell back to markdown tickets.',
338 )
339}
340
341/** The tracker a note's answers name; no answer is "None", the default. */
342export const trackerOf = (answers: Readonly<Record<string, Answer>>): TrackerKind => {
343 const value = answers.tracker
344 if (value === undefined) return 'none'
345 const name = String(value).toLowerCase()
346 if (TRACKERS.includes(name as TrackerKind)) return name as TrackerKind
347 throw new TicketError(`The setup note names the tracker \`${String(value)}\`, which godmode does not know (${TRACKERS.join(', ')}). Fix it with \`/godmode setup\`.`)
348}
349
350/** The backend for a tracker, its markdown files kept in the note's folder `dir`. */
351export function backendFor($: EngineInterface, tracker: TrackerKind, dir: string): TicketBackend {
352 if (tracker === 'jira') return jiraBackend()
353 if (tracker === 'other') return otherBackend()
354 return markdownBackend($, dir)
355}
356
357/** The project's ticket backend, from its setup note. */
358export async function ticketBackendOf($: EngineInterface): Promise<TicketBackend> {
359 const found = await readNote($)
360 if (found.status === 'none') {
361 throw new TicketError('godmode is not set up in this project yet, so it has nowhere to keep tickets. Finish the setup first.')
362 }
363 if (found.status === 'unreadable') throw new TicketError(`godmode cannot read its setup note at ${found.path}: ${found.reason}.`)
364 return backendFor($, trackerOf(found.shared.answers), found.dir)
365}
366
367export type TicketToolName = 'ticket_create' | 'ticket_move' | 'ticket_list' | 'ticket_comment'
368
369const text = (description: string) => ({ type: 'string', description })
370const statusSchema = { type: 'string', enum: [...TICKET_STATUSES] }
371const idSchema = text('The ticket id, e.g. T-3.')
372const blockedBySchema = { type: 'array', items: { type: 'string' }, description: 'Ids of the tickets that must be done first, e.g. ["T-2"].' }
373const visibleChangeSchema = { type: 'boolean', description: 'Whether a user would see the change. QA runs only when it is true.' }
374
375/** The four ticket tools (D24), listed in the prompt rather than behind ToolSearch. */
376export const TICKET_TOOLS: readonly (ToolSpec & { name: TicketToolName })[] = [
377 {
378 name: 'ticket_create',
379 description:
380 'File a new ticket in the project\'s tracker. Filing never starts work. Tickets are read and changed only through the ticket tools, never by editing files.',
381 inputSchema: {
382 type: 'object',
383 properties: {
384 title: text('A short title.'),
385 ask: text('The person\'s ask, word for word.'),
386 measured: text('Claims checked against the code or a run, each with its evidence.'),
387 inferred: text('Hypotheses not checked yet.'),
388 answerFirst: text('A question to settle before building, if any.'),
389 scopeIn: text('What the ticket covers.'),
390 scopeOut: text('What it leaves out.'),
391 constraints: text('Rules the change must keep.'),
392 tests: text('The tests to add, each to be shown failing without the change.'),
393 refs: text('Links, files and decisions the work relies on.'),
394 blockedBy: blockedBySchema,
395 visibleChange: visibleChangeSchema,
396 },
397 required: ['title', 'ask', 'visibleChange'],
398 },
399 isDeferred: false,
400 },
401 {
402 name: 'ticket_move',
403 description:
404 'Change a ticket\'s status (todo, doing, review, done) or header: branch, PR, blockers, visible change. `ready` marks a ticket the person said go for; only the main thread may set it.',
405 inputSchema: {
406 type: 'object',
407 properties: {
408 id: idSchema,
409 status: statusSchema,
410 ready: { type: 'boolean', description: 'True only once the person said go for this ticket.' },
411 branch: text('The ticket\'s branch.'),
412 pr: text('The PR number or link.'),
413 blockedBy: blockedBySchema,
414 visibleChange: visibleChangeSchema,
415 },
416 required: ['id'],
417 },
418 isDeferred: false,
419 },
420 {
421 name: 'ticket_list',
422 description: 'List tickets, headers only (id, status, ready, title, blockers, branch, PR). By default every ticket not done.',
423 inputSchema: {
424 type: 'object',
425 properties: { status: { type: 'array', items: statusSchema, description: 'Only tickets in these statuses.' } },
426 },
427 isDeferred: false,
428 },
429 {
430 name: 'ticket_comment',
431 description: 'Add a comment to a ticket: a finding, a verdict, a decision. Comments are kept in order under the ticket.',
432 inputSchema: {
433 type: 'object',
434 properties: { id: idSchema, text: text('The comment, in markdown.'), author: text('Who is writing, e.g. PM, dev, reviewer.') },
435 required: ['id', 'text'],
436 },
437 isDeferred: false,
438 },
439]
440
441export const TICKET_TOOL_NAMES: readonly TicketToolName[] = TICKET_TOOLS.map(tool => tool.name)
442
443/** Who made a tool call: `agentId` is absent on the main thread, where the PM runs. */
444export type TicketCaller = { agentId?: string }
445
446const stringField = (input: Record<string, unknown>, key: string, isRequired = false) => {
447 const value = input[key]
448 if (value === undefined || value === null) {
449 if (isRequired) throw new TicketError(`\`${key}\` is required.`)
450 return undefined
451 }
452 if (typeof value !== 'string') throw new TicketError(`\`${key}\` must be text.`)
453 if (isRequired && value.trim() === '') throw new TicketError(`\`${key}\` must not be empty.`)
454 return value
455}
456
457const booleanField = (input: Record<string, unknown>, key: string, isRequired = false) => {
458 const value = input[key]
459 if (value === undefined || value === null) {
460 if (isRequired) throw new TicketError(`\`${key}\` is required: true or false.`)
461 return undefined
462 }
463 if (value === 'yes' || value === 'no') return value === 'yes'
464 if (typeof value !== 'boolean') throw new TicketError(`\`${key}\` must be true or false.`)
465 return value
466}
467
468const listField = (input: Record<string, unknown>, key: string) => {
469 const value = input[key]
470 if (value === undefined || value === null) return undefined
471 const items = typeof value === 'string' ? value.split(',') : value
472 if (!Array.isArray(items) || !items.every(item => typeof item === 'string')) throw new TicketError(`\`${key}\` must be a list of ticket ids.`)
473 return items.map(item => item.trim()).filter(item => item !== '')
474}
475
476const statusField = (value: unknown) => {
477 if (typeof value === 'string' && isStatus(value)) return value
478 throw new TicketError(`\`${String(value)}\` is not a status; use one of ${TICKET_STATUSES.join(', ')}.`)
479}
480
481/**
482 * Serves one ticket tool call on any backend: checks the model's input, routes it, and
483 * answers the text the model reads. A `TicketError` is the caller's to show as an error.
484 */
485export const runTicketTool = async (
486 backend: TicketBackend,
487 name: TicketToolName,
488 input: Record<string, unknown>,
489 caller: TicketCaller,
490): Promise<string> => {
491 if (name === 'ticket_create') {
492 const header = await backend.create({
493 title: stringField(input, 'title', true) ?? '',
494 ask: stringField(input, 'ask', true) ?? '',
495 measured: stringField(input, 'measured'),
496 inferred: stringField(input, 'inferred'),
497 answerFirst: stringField(input, 'answerFirst'),
498 scopeIn: stringField(input, 'scopeIn'),
499 scopeOut: stringField(input, 'scopeOut'),
500 constraints: stringField(input, 'constraints'),
501 tests: stringField(input, 'tests'),
502 refs: stringField(input, 'refs'),
503 blockedBy: listField(input, 'blockedBy'),
504 visibleChange: booleanField(input, 'visibleChange', true) ?? false,
505 })
506 return `Filed ${describeTicket(header)}`
507 }
508 if (name === 'ticket_move') {
509 const id = stringField(input, 'id', true) ?? ''
510 const change: TicketChange = {}
511 if (input.status !== undefined) change.status = statusField(input.status)
512 const ready = booleanField(input, 'ready')
513 if (ready !== undefined) {
514 // Readiness is the person's go (D52), heard only by the main thread.
515 if (caller.agentId !== undefined) throw new TicketError('Only the main thread marks a ticket ready, once the person said go for it.')
516 change.ready = ready
517 }
518 for (const key of ['branch', 'pr'] as const) {
519 const value = stringField(input, key)
520 if (value !== undefined) change[key] = value.trim() === '' ? undefined : value.trim()
521 }
522 const blockedBy = listField(input, 'blockedBy')
523 if (blockedBy !== undefined) change.blockedBy = blockedBy
524 const visibleChange = booleanField(input, 'visibleChange')
525 if (visibleChange !== undefined) change.visibleChange = visibleChange
526 if (Object.keys(change).length === 0) throw new TicketError('Nothing to change: give a status or a header field.')
527 return `Updated ${describeTicket(await backend.move(id, change))}`
528 }
529 if (name === 'ticket_list') {
530 const raw = input.status
531 const statuses = raw === undefined || raw === null ? undefined : (Array.isArray(raw) ? raw : [raw]).map(statusField)
532 const headers = await backend.list({ statuses })
533 if (headers.length === 0) return statuses === undefined ? 'No open tickets.' : `No tickets in ${statuses.join(', ')}.`
534 return headers.map(describeTicket).join('\n')
535 }
536 const id = stringField(input, 'id', true) ?? ''
537 const body = stringField(input, 'text', true) ?? ''
538 const author = stringField(input, 'author')?.trim() || (caller.agentId === undefined ? 'PM' : `agent ${caller.agentId}`)
539 const header = await backend.comment(id, { author, text: body })
540 return `Commented on ${header.id}.`
541}
542hooks/rules.ts 615 lines1import { prNumberOf } from './host'
2import { effectiveModel } from './storage'
3import type { AgentRole, Answer, ModelTier, PersonalNote, SharedNote } from './storage'
4
5/*
6 * The loop's rules as plain data and pure functions (Step 7, D37): what a preset allows,
7 * how a shell command reads as loop actions, what the main thread may do. Nothing here
8 * takes `$`; `loop.ts` applies these rules to the session.
9 */
10
11/** The four permission presets (D18). */
12export type PresetName = 'full-freedom' | 'free' | 'local-only' | 'locked'
13
14export const PRESETS: readonly PresetName[] = ['full-freedom', 'free', 'local-only', 'locked']
15
16/**
17 * What a rule says about an action (D13). `ask` always means asking the person directly
18 * (`$.ui.ask`), never Claude Code's own permission check, which auto mode could settle
19 * without them (D66).
20 */
21export type Level = 'allow' | 'ask' | 'never'
22
23const LEVEL_RANK: Readonly<Record<Level, number>> = { allow: 0, ask: 1, never: 2 }
24
25export const strictest = (levels: readonly Level[]): Level =>
26 levels.reduce<Level>((worst, level) => (LEVEL_RANK[level] > LEVEL_RANK[worst] ? level : worst), 'allow')
27
28/** The writes the presets gate (D13, D18). */
29export type Action =
30 | 'edit'
31 | 'git-write'
32 | 'push'
33 | 'force-push'
34 | 'pr-open'
35 | 'pr-comment'
36 | 'pr-approve'
37 | 'pr-ready'
38 | 'pr-merge'
39 | 'pr-edit'
40 | 'host-write'
41
42export const ACTIONS: readonly Action[] = [
43 'edit',
44 'git-write',
45 'push',
46 'force-push',
47 'pr-open',
48 'pr-comment',
49 'pr-approve',
50 'pr-ready',
51 'pr-merge',
52 'pr-edit',
53 'host-write',
54]
55
56/** How an action reads in a question or a refusal. */
57export const ACTION_WORDS: Readonly<Record<Action, string>> = {
58 edit: 'edit files',
59 'git-write': 'change the repo locally (commit, merge, rebase, reset, ...)',
60 push: 'push',
61 'force-push': 'force-push',
62 'pr-open': 'open a PR',
63 'pr-comment': 'comment on or review a PR',
64 'pr-approve': 'approve a PR',
65 'pr-ready': 'mark a PR ready',
66 'pr-merge': 'merge a PR',
67 'pr-edit': 'edit, close or reopen a PR',
68 'host-write': 'write to the git host through its API',
69}
70
71const everyAction = (level: Level) => Object.fromEntries(ACTIONS.map(action => [action, level])) as Record<Action, Level>
72
73/**
74 * Each preset's level per action (D18). Free acts alone on the person's own PRs, and asks
75 * before approving a PR, force-pushing, or a raw host API write. Auto-merge is a separate
76 * switch, off in every preset (D53).
77 */
78export const PRESET_LEVELS: Readonly<Record<PresetName, Readonly<Record<Action, Level>>>> = {
79 'full-freedom': everyAction('allow'),
80 free: { ...everyAction('allow'), 'force-push': 'ask', 'pr-approve': 'ask', 'host-write': 'ask' },
81 'local-only': { ...everyAction('ask'), edit: 'allow' },
82 locked: everyAction('ask'),
83}
84
85/** What a preset does to a write on someone else's PR (D16, D17, D18). */
86export const OTHERS_PRS_LEVEL: Readonly<Record<PresetName, Level>> = {
87 'full-freedom': 'allow',
88 free: 'ask',
89 'local-only': 'ask',
90 locked: 'ask',
91}
92
93/** Every loop rule as a setting (D26, D37), read from the setup note's answers. */
94export type LoopSettings = {
95 preset: PresetName
96 /** Set when the note's preset answer was missing or unknown, to say so in a refusal. */
97 presetNote?: string
98 /** The preset's levels with the note's `permission.<action>` overrides applied. */
99 levels: Readonly<Record<Action, Level>>
100 /** The level of a write on someone else's PR. */
101 othersPrs: Level
102 /** False blocks commit messages and PR texts that carry AI attribution (D15). */
103 isAttributionAllowed: boolean
104 /** Arming auto-merge, off unless the person turns it on (D53). */
105 isAutoMergeOn: boolean
106 /** Agents running at once (D58). */
107 agentCap: number
108 /** The main thread keeps to talking, tickets, dispatch and status checks (D63). */
109 isThinMainThread: boolean
110 /** Files the main thread may read per prompt before reading is a researcher's job (D63). */
111 mainThreadReadLimit: number
112 /** Extra tools the main thread may use, by name. */
113 mainThreadTools: readonly string[]
114 /** PRs are opened as drafts (D53). */
115 isDraftsOnly: boolean
116 /** No ready or merge without a clean verdict at the PR's current commit (D53). */
117 isMergeGateOn: boolean
118 /** The open PRs' files go into a dev brief, and a dev agent's edit to one is blocked (D47, D48). */
119 isContentionOn: boolean
120 /** How long a sweep of the open PRs stays fresh for the edit check, in milliseconds. */
121 contentionRefreshMs: number
122 /** A dev agent edits only inside its worktree (D38). */
123 isWorktreeGuardOn: boolean
124 /** A dev agent starts only on a ticket the person said go for (D52). */
125 requiresReadyTicket: boolean
126 /** A dev agent's draft PR tells the PM to start the review (D35). */
127 isReviewStartOn: boolean
128 /** Only a reviewer agent records a review verdict. */
129 verdictFromReviewerOnly: boolean
130 /** Where ticket worktrees go: absolute, or relative to the repo root. */
131 worktreeFolder?: string
132 /** The branch new ticket branches start from; found from `origin/HEAD` when unset. */
133 baseBranch?: string
134 /** The start of a new ticket branch's name. */
135 branchPrefix: string
136}
137
138/** The answers' names for each setting, for messages that say how to change one. */
139export const SETTING = {
140 preset: 'permissions',
141 attribution: 'attribution',
142 autoMerge: 'autoMerge',
143 agentCap: 'agentCap',
144 thinMainThread: 'thinMainThread',
145 mainThreadReadLimit: 'mainThreadReadLimit',
146 mainThreadTools: 'mainThreadTools',
147 draftsOnly: 'draftsOnly',
148 mergeGate: 'mergeGate',
149 contention: 'contention',
150 contentionRefreshSeconds: 'contentionRefreshSeconds',
151 worktreeGuard: 'worktreeGuard',
152 requireReadyTicket: 'requireReadyTicket',
153 reviewStart: 'reviewStart',
154 verdictFromReviewerOnly: 'verdictFromReviewerOnly',
155 worktreeFolder: 'worktreeFolder',
156 baseBranch: 'baseBranch',
157 branchPrefix: 'branchPrefix',
158 othersPrs: 'permission.others-prs',
159} as const
160
161/** `permission.<action>` overrides one action's level. */
162export const permissionKey = (action: Action) => `permission.${action}`
163
164/** The preset a note names when it names none it knows; every write but an edit asks. */
165export const DEFAULT_PRESET: PresetName = 'local-only'
166
167const booleanOf = (value: Answer | undefined, fallback: boolean) => {
168 if (typeof value === 'boolean') return value
169 if (typeof value === 'number') return value !== 0
170 if (typeof value === 'string') {
171 const word = value.trim().toLowerCase()
172 if (['yes', 'true', 'on', '1'].includes(word)) return true
173 if (['no', 'false', 'off', '0'].includes(word)) return false
174 }
175 return fallback
176}
177
178const numberOf = (value: Answer | undefined, fallback: number, minimum: number) => {
179 const number = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value) : Number.NaN
180 return Number.isFinite(number) && number >= minimum ? number : fallback
181}
182
183const textOf = (value: Answer | undefined) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined)
184
185const levelOf = (value: Answer | undefined): Level | undefined => {
186 const word = typeof value === 'string' ? value.trim().toLowerCase().replace(/\s+/g, '-') : undefined
187 if (word === 'allow' || word === 'allowed') return 'allow'
188 if (word === 'ask' || word === 'ask-me') return 'ask'
189 if (word === 'never') return 'never'
190 return undefined
191}
192
193/** `Full freedom`, `full_freedom`, `local only` and the like as a preset name. */
194export const presetOf = (value: Answer | undefined): PresetName | undefined => {
195 const word = typeof value === 'string' ? value.trim().toLowerCase().replace(/[\s_]+/g, '-') : undefined
196 return PRESETS.find(preset => preset === word)
197}
198
199/** The loop settings from a note's answers, shared then personal; anything unset takes its default. */
200export const settingsOf = (answers: Readonly<Record<string, Answer>>): LoopSettings => {
201 const named = presetOf(answers[SETTING.preset])
202 const preset = named ?? DEFAULT_PRESET
203 const levels = { ...PRESET_LEVELS[preset] }
204 for (const action of ACTIONS) {
205 const override = levelOf(answers[permissionKey(action)])
206 if (override !== undefined) levels[action] = override
207 }
208 const tools = textOf(answers[SETTING.mainThreadTools])
209 return {
210 preset,
211 presetNote:
212 named === undefined
213 ? `the setup note names no preset godmode knows (\`${String(answers[SETTING.preset] ?? 'none')}\`), so it uses ${DEFAULT_PRESET}`
214 : undefined,
215 levels,
216 othersPrs: levelOf(answers[SETTING.othersPrs]) ?? OTHERS_PRS_LEVEL[preset],
217 isAttributionAllowed: booleanOf(answers[SETTING.attribution], true),
218 isAutoMergeOn: booleanOf(answers[SETTING.autoMerge], false),
219 agentCap: Math.floor(numberOf(answers[SETTING.agentCap], 5, 1)),
220 isThinMainThread: booleanOf(answers[SETTING.thinMainThread], true),
221 mainThreadReadLimit: Math.floor(numberOf(answers[SETTING.mainThreadReadLimit], 2, 0)),
222 mainThreadTools: tools === undefined ? [] : tools.split(',').map(tool => tool.trim()).filter(tool => tool !== ''),
223 isDraftsOnly: booleanOf(answers[SETTING.draftsOnly], true),
224 isMergeGateOn: booleanOf(answers[SETTING.mergeGate], true),
225 isContentionOn: booleanOf(answers[SETTING.contention], true),
226 contentionRefreshMs: numberOf(answers[SETTING.contentionRefreshSeconds], 60, 0) * 1000,
227 isWorktreeGuardOn: booleanOf(answers[SETTING.worktreeGuard], true),
228 requiresReadyTicket: booleanOf(answers[SETTING.requireReadyTicket], true),
229 isReviewStartOn: booleanOf(answers[SETTING.reviewStart], true),
230 verdictFromReviewerOnly: booleanOf(answers[SETTING.verdictFromReviewerOnly], true),
231 worktreeFolder: textOf(answers[SETTING.worktreeFolder]),
232 baseBranch: textOf(answers[SETTING.baseBranch]),
233 branchPrefix: textOf(answers[SETTING.branchPrefix]) ?? 'godmode/',
234 }
235}
236
237/** How a refusal says to change a setting. */
238export const howToChange = (setting: string) => `the person can change this with \`/godmode setup\` (setting \`${setting}\`)`
239
240// ---------------------------------------------------------------------------
241// Shell commands as loop actions
242
243/** One command of a shell line that the rules care about. */
244export type ShellStep = {
245 action: Action
246 /** `git commit`, `gh pr create`, ...: what it is, for messages. */
247 verb: string
248 /** The PR the command names: a number, or a branch as `gh` takes it. */
249 pr?: string
250 /** `gh pr create --draft`. */
251 isDraft?: boolean
252 /** `gh pr merge --auto`. */
253 isAuto?: boolean
254 /** `gh pr ready --undo`, `gh pr merge --disable-auto`: a step back, never gated by a verdict. */
255 isUndo?: boolean
256 /** Files the command reads its message or body from (`git commit -F`, `gh pr create --body-file`). */
257 bodyFiles: string[]
258}
259
260/** The words of one simple command; quotes group words, and are dropped. */
261export const wordsOf = (segment: string) =>
262 [...segment.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(match => match[1] ?? match[2] ?? match[3] ?? '')
263
264/**
265 * A shell line split into its chains (`&&`, `||`, `;`, new lines), each a pipeline of
266 * simple commands. Quoting is not parsed: a quoted `;` splits too, which only ever makes
267 * a check stricter.
268 */
269export const pipelinesOf = (command: string) =>
270 command
271 .split(/&&|\|\||;|\n/)
272 .map(chain => chain.split('|').map(part => part.trim()).filter(part => part !== ''))
273 .filter(chain => chain.length > 0)
274
275const WRAPPERS = new Set(['sudo', 'command', 'env', 'time', 'exec', 'nohup', '(', '{'])
276
277/** A simple command's words with leading `FOO=bar`, `sudo`, `(` and the like left out. */
278const commandWords = (segment: string) => {
279 const words = wordsOf(segment.replace(/^[({]+/, ''))
280 let start = 0
281 while (start < words.length && (WRAPPERS.has(words[start] ?? '') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[start] ?? ''))) start += 1
282 return words.slice(start)
283}
284
285/** git's own options before the subcommand, the ones that take a value. */
286const GIT_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace'])
287
288/** The git subcommand and its arguments, past git's own options. */
289const gitParts = (words: readonly string[]) => {
290 let index = 1
291 while (index < words.length && (words[index] ?? '').startsWith('-')) index += GIT_VALUE_OPTIONS.has(words[index] ?? '') ? 2 : 1
292 return { sub: words[index] ?? '', args: words.slice(index + 1) }
293}
294
295const hasAny = (args: readonly string[], flags: readonly string[]) =>
296 args.some(arg => flags.includes(arg) || flags.some(flag => flag.startsWith('--') && arg.startsWith(`${flag}=`)))
297
298/** The value after a flag (`-F file`, `--file=file`), for each time it appears. */
299const valuesOf = (args: readonly string[], flags: readonly string[]) =>
300 args.flatMap((arg, index) => {
301 const inline = flags.find(flag => flag.startsWith('--') && arg.startsWith(`${flag}=`))
302 if (inline !== undefined) return [arg.slice(inline.length + 1)]
303 return flags.includes(arg) && args[index + 1] !== undefined ? [args[index + 1] as string] : []
304 })
305
306const GIT_WRITES = new Set(['commit', 'merge', 'rebase', 'reset', 'revert', 'cherry-pick', 'am', 'clean', 'restore', 'rm', 'mv', 'update-ref', 'filter-branch', 'filter-repo', 'replace', 'apply'])
307
308/** A git command as a loop action, or undefined when it writes nothing the presets gate. */
309const gitStep = (words: readonly string[]): ShellStep | undefined => {
310 const { sub, args } = gitParts(words)
311 const step = (action: Action, extra: Partial<ShellStep> = {}): ShellStep => ({ action, verb: `git ${sub}`, bodyFiles: [], ...extra })
312 if (sub === 'push') {
313 const isForce = hasAny(args, ['-f', '--force', '--force-with-lease', '--force-if-includes']) || args.some(arg => /^\+[^+]/.test(arg))
314 return step(isForce ? 'force-push' : 'push')
315 }
316 if (sub === 'commit') return step('git-write', { bodyFiles: valuesOf(args, ['-F', '--file']) })
317 if (GIT_WRITES.has(sub)) return step('git-write')
318 if (sub === 'checkout' || sub === 'switch') {
319 return hasAny(args, ['--', '.', '-f', '--force', '-B', '-C', '--discard-changes']) ? step('git-write') : undefined
320 }
321 if (sub === 'stash') return ['list', 'show'].includes(args[0] ?? '') ? undefined : step('git-write')
322 if (sub === 'branch') return hasAny(args, ['-d', '-D', '--delete', '-m', '-M', '--move', '-f', '--force', '-c', '-C', '--copy']) ? step('git-write') : undefined
323 if (sub === 'tag') {
324 if (hasAny(args, ['-l', '--list'])) return undefined
325 return args.some(arg => !arg.startsWith('-')) || hasAny(args, ['-d', '--delete']) ? step('git-write') : undefined
326 }
327 if (sub === 'worktree') return ['add', 'remove', 'move', 'prune'].includes(args[0] ?? '') ? step('git-write') : undefined
328 return undefined
329}
330
331/** `gh pr` options that take a value, so the PR named after them is found. */
332const GH_VALUE_OPTIONS = new Set([
333 '-b',
334 '--body',
335 '-F',
336 '--body-file',
337 '-t',
338 '--title',
339 '--subject',
340 '-R',
341 '--repo',
342 '-B',
343 '--base',
344 '-H',
345 '--head',
346 '--match-head-commit',
347 '-A',
348 '--author-email',
349 '--add-label',
350 '--remove-label',
351 '--add-reviewer',
352 '--remove-reviewer',
353 '--add-assignee',
354 '--remove-assignee',
355 '-c',
356 '--comment',
357 '-r',
358 '--reason',
359 '-m',
360 '--milestone',
361])
362
363/** The PR a `gh pr <sub>` command names: its number (from a link too), or a branch. */
364const prArgument = (args: readonly string[]) => {
365 for (let index = 0; index < args.length; index += 1) {
366 const arg = args[index] ?? ''
367 if (arg.startsWith('-')) {
368 if (GH_VALUE_OPTIONS.has(arg)) index += 1
369 continue
370 }
371 return prNumberOf(arg) ?? arg.replace(/^#/, '')
372 }
373 return undefined
374}
375
376/** A gh command as a loop action, or undefined when it writes nothing the presets gate. */
377const ghStep = (words: readonly string[]): ShellStep | undefined => {
378 const [, group = '', sub = '', ...args] = words
379 if (group === 'api') {
380 const rest = words.slice(2)
381 const method = valuesOf(rest, ['-X', '--method'])[0]?.toUpperCase()
382 const hasFields = hasAny(rest, ['-f', '-F', '--field', '--raw-field', '--input'])
383 const isMutation = rest.some(arg => /^\s*mutation\b/.test(arg) || /query=\s*mutation\b/.test(arg))
384 const isWrite = method !== undefined ? method !== 'GET' && method !== 'HEAD' : hasFields || isMutation
385 return isWrite ? { action: 'host-write', verb: 'gh api', bodyFiles: [] } : undefined
386 }
387 if (group !== 'pr') return undefined
388 const verb = `gh pr ${sub}`
389 const bodyFiles = valuesOf(args, ['-F', '--body-file'])
390 if (sub === 'create' || sub === 'new') {
391 return { action: 'pr-open', verb, isDraft: hasAny(args, ['-d', '--draft']), bodyFiles }
392 }
393 const pr = prArgument(args)
394 if (sub === 'comment') return { action: 'pr-comment', verb, pr, bodyFiles }
395 if (sub === 'review') return { action: hasAny(args, ['-a', '--approve']) ? 'pr-approve' : 'pr-comment', verb, pr, bodyFiles }
396 if (sub === 'ready') return { action: 'pr-ready', verb, pr, isUndo: hasAny(args, ['--undo']), bodyFiles }
397 if (sub === 'merge') {
398 return { action: 'pr-merge', verb, pr, isAuto: hasAny(args, ['--auto']), isUndo: hasAny(args, ['--disable-auto']), bodyFiles }
399 }
400 if (['edit', 'close', 'reopen', 'lock', 'unlock'].includes(sub)) return { action: 'pr-edit', verb, pr, bodyFiles }
401 return undefined
402}
403
404/** Every write the presets gate in a shell line, in order. */
405export const classifyShell = (command: string): ShellStep[] =>
406 pipelinesOf(command).flatMap(chain =>
407 chain.flatMap(segment => {
408 const words = commandWords(segment)
409 const step = words[0] === 'git' ? gitStep(words) : words[0] === 'gh' ? ghStep(words) : undefined
410 return step === undefined ? [] : [step]
411 }),
412 )
413
414/** The verbs whose text is a commit message or a PR text, where attribution is looked for (D15). */
415export const carriesText = (step: ShellStep) =>
416 step.verb === 'git commit' || ['pr-open', 'pr-comment', 'pr-approve', 'pr-edit', 'pr-merge'].includes(step.action)
417
418const ATTRIBUTION: readonly RegExp[] = [
419 /co-authored-by:[^\n]*\b(?:claude|anthropic)\b[^\n]*/i,
420 /generated (?:with|by) \[?claude(?: code)?\]?/i,
421 /noreply@anthropic\.com/i,
422]
423
424/** The AI attribution line in a text, when it carries one (D15). */
425export const attributionIn = (text: string) => {
426 for (const pattern of ATTRIBUTION) {
427 const found = pattern.exec(text)?.[0]
428 if (found !== undefined) return found.trim()
429 }
430 return undefined
431}
432
433// ---------------------------------------------------------------------------
434// The thin main thread (D63)
435
436/** The main thread's own tools: talking, dispatching, its task list, and loading tools. */
437export const MAIN_THREAD_TOOLS: ReadonlySet<string> = new Set([
438 'AskUserQuestion',
439 'Agent',
440 'Task',
441 'SendMessage',
442 'TaskStop',
443 'TaskOutput',
444 'TaskCreate',
445 'TaskGet',
446 'TaskList',
447 'TaskUpdate',
448 'TodoWrite',
449 'Monitor',
450 'ToolSearch',
451 'Skill',
452 'EnterPlanMode',
453 'ExitPlanMode',
454 'ScheduleWakeup',
455 'ListAgents',
456])
457
458/** Status checks: a git or gh read of the loop's state, never of the code. */
459const isStatusCheck = (words: readonly string[]) => {
460 const [program = '', ...rest] = words
461 if (program === 'pwd' || program === 'date') return true
462 if (program === 'ls') return !rest.some(arg => /^-[A-Za-z]*R/.test(arg) || arg === '--recursive')
463 if (program === 'git') {
464 const { sub, args } = gitParts(words)
465 if (['status', 'rev-parse', 'fetch', 'describe', 'ls-remote', 'remote', 'shortlog'].includes(sub)) return sub !== 'remote' || !['add', 'remove', 'rm', 'rename', 'set-url'].includes(args[0] ?? '')
466 if (sub === 'log') return !hasAny(args, ['-p', '--patch', '-L', '-u'])
467 if (sub === 'diff' || sub === 'show') return hasAny(args, ['--stat', '--shortstat', '--numstat', '--name-only', '--name-status', '--no-patch', '-s'])
468 if (sub === 'branch') return gitStep(words) === undefined
469 if (sub === 'worktree') return args[0] === 'list'
470 if (sub === 'stash') return args[0] === 'list'
471 if (sub === 'rev-list') return hasAny(args, ['--count'])
472 return false
473 }
474 if (program === 'gh') {
475 const [group = '', sub = ''] = rest
476 if (group === 'pr') return ['list', 'view', 'status', 'checks'].includes(sub)
477 if (group === 'run') return ['list', 'view', 'watch'].includes(sub)
478 if (group === 'auth' || group === 'repo') return sub === 'status' || sub === 'view'
479 return false
480 }
481 return false
482}
483
484/** What may read a status check's output: filters, never a file. */
485const isFilter = (words: readonly string[]) => {
486 const [program = '', ...rest] = words
487 if (['head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'column', 'jq', 'tr'].includes(program)) {
488 return program !== 'jq' || rest.filter(arg => !arg.startsWith('-')).length <= 1
489 }
490 if (program === 'grep' || program === 'rg') return rest.filter(arg => !arg.startsWith('-')).length <= 1
491 return false
492}
493
494/** The PR actions the PM itself takes: relaying a verdict, marking ready, merging on the person's go. */
495const isPmLoopAction = (words: readonly string[]) => {
496 const step = words[0] === 'gh' ? ghStep(words) : undefined
497 return step !== undefined && ['pr-comment', 'pr-ready', 'pr-merge'].includes(step.action)
498}
499
500/** Whether a shell line is a quick status check (or the PM's own PR action) the main thread may run. */
501export const isMainThreadShell = (command: string) => {
502 // A redirect writes a file, a substitution runs a second command: neither is a status check.
503 const plain = command.replace(/\d?>&\d/g, '').replace(/\d?>\s*\/dev\/null/g, '')
504 if (/[<>`]|\$\(/.test(plain)) return false
505 const chains = pipelinesOf(plain)
506 return (
507 chains.length > 0 &&
508 chains.every(chain => {
509 const [first = [], ...filters] = chain.map(commandWords)
510 return (isStatusCheck(first) || isPmLoopAction(first)) && filters.every(isFilter)
511 })
512 )
513}
514
515export type MainThreadCall = { tool: string; command?: unknown; run_in_background?: unknown }
516
517/** What the main thread may do, or why not. */
518export type MainThreadVerdict = { isAllowed: true; isRead?: boolean } | { isAllowed: false; reason: string }
519
520/**
521 * Whether the main thread may make a call (D63): talking, the ticket tools, dispatching
522 * agents and shells, quick status checks, and a couple of reads per prompt.
523 */
524export const mainThreadVerdict = (call: MainThreadCall, settings: LoopSettings, readsThisPrompt: number): MainThreadVerdict => {
525 const { tool } = call
526 if (MAIN_THREAD_TOOLS.has(tool) || tool.startsWith('mcp__godmode__') || settings.mainThreadTools.includes(tool)) return { isAllowed: true }
527 const refuse = (what: string, setting: string = SETTING.thinMainThread): MainThreadVerdict => ({
528 isAllowed: false,
529 reason: `godmode keeps the main thread thin: ${what} is work for an agent, not the PM. Dispatch a researcher or a shell to do it, and keep this thread for talking, tickets and dispatch. To loosen the rule, ${howToChange(setting)}.`,
530 })
531 if (tool === 'Read') {
532 if (readsThisPrompt < settings.mainThreadReadLimit) return { isAllowed: true, isRead: true }
533 return refuse(
534 `reading more than ${settings.mainThreadReadLimit} file${settings.mainThreadReadLimit === 1 ? '' : 's'} in one prompt`,
535 SETTING.mainThreadReadLimit,
536 )
537 }
538 if (tool === 'Bash') {
539 if (call.run_in_background === true) return { isAllowed: true }
540 const command = typeof call.command === 'string' ? call.command : ''
541 if (isMainThreadShell(command)) return { isAllowed: true }
542 return refuse(`running \`${command.split('\n')[0]?.slice(0, 80) ?? ''}\` (only quick git and gh status checks run here; a background shell is fine)`)
543 }
544 if (['Edit', 'Write', 'NotebookEdit'].includes(tool)) return refuse('editing files')
545 if (['Grep', 'Glob', 'LSP'].includes(tool)) return refuse('searching code')
546 if (['WebFetch', 'WebSearch'].includes(tool)) return refuse('research on the web')
547 return refuse(`\`${tool}\``, SETTING.mainThreadTools)
548}
549
550// ---------------------------------------------------------------------------
551// Roles, models and paths
552
553export const ROLES: readonly AgentRole[] = ['dev', 'reviewer', 'setup', 'researcher', 'qa']
554
555/** The godmode role of an agent type: `godmode:dev` or a bare `dev`; undefined for any other. */
556export const roleOf = (subagentType: string): AgentRole | undefined => {
557 const name = subagentType.replace(/^godmode:/, '')
558 return ROLES.find(role => role === name)
559}
560
561const TIER_RANK: Readonly<Record<ModelTier, number>> = { haiku: 0, sonnet: 1, opus: 2 }
562
563/** The tier of an alias or a full model id; undefined for a model godmode cannot rank. */
564export const tierOf = (model: string): ModelTier | undefined => {
565 const name = model.toLowerCase()
566 return (['opus', 'sonnet', 'haiku'] as const).find(tier => name.includes(tier))
567}
568
569/**
570 * The model a role's spawn runs on (D39, D62): the role's model is a floor. A request at
571 * or above it stands (the PM may pick Opus for a hard ticket); one below, or none, takes
572 * the floor. A model godmode cannot rank stands as asked.
573 */
574export const spawnModel = (
575 requested: string | undefined,
576 role: AgentRole,
577 note?: { shared?: Pick<SharedNote, 'minimumModels'>; personal?: Pick<PersonalNote, 'models'> },
578): string => {
579 const floor = effectiveModel(role, note)
580 if (requested === undefined || requested === '' || requested === 'inherit') return floor
581 const tier = tierOf(requested)
582 if (tier === undefined) return requested
583 return TIER_RANK[tier] < TIER_RANK[floor] ? floor : requested
584}
585
586/** A path with `.` and `..` resolved and no trailing slash. */
587export const normalisePath = (path: string) => {
588 const parts: string[] = []
589 for (const part of path.replace(/\\/g, '/').split('/')) {
590 if (part === '' || part === '.') continue
591 if (part === '..') parts.pop()
592 else parts.push(part)
593 }
594 return `/${parts.join('/')}`
595}
596
597/** Whether `path` is `folder` or inside it. */
598export const isInside = (path: string, folder: string) => {
599 const [inner, outer] = [normalisePath(path), normalisePath(folder)]
600 return inner === outer || inner.startsWith(`${outer}/`)
601}
602
603/** `path` relative to `folder`, which holds it. */
604export const relativeTo = (path: string, folder: string) => normalisePath(path).slice(normalisePath(folder).length + 1)
605
606/** The system's scratch folders: writing there edits no project. */
607export const SCRATCH_FOLDERS: readonly string[] = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders']
608
609/**
610 * The first ticket id a brief names: `T-3` for markdown tickets (D50), else a tracker key
611 * like `ABC-12`.
612 */
613export const ticketIn = (brief: string, isMarkdown = true) =>
614 (isMarkdown ? /\b(T-\d+)\b/i : /\b([A-Z][A-Z0-9]+-\d+)\b/).exec(brief)?.[1]?.toUpperCase()
615types/index.d.ts 58 lines1/**
2 * What godmode is doing while it is on (D9, D22): `setup` scans the project and asks the
3 * questionnaire, `change-setup` discusses a change to an earlier setup, `coding` runs the
4 * PM loop.
5 */
6export type GodmodeMode = 'setup' | 'change-setup' | 'coding'
7
8/**
9 * How much of the setup runs: `full` on a project with no note, `personal` when a
10 * teammate's committed note is there and only this person's answers are missing (D61).
11 */
12export type SetupScope = 'full' | 'personal'
13
14/** The roles godmode dispatches (D33); the PM is the main thread. */
15export type GodmodeRole = 'dev' | 'reviewer' | 'setup' | 'researcher' | 'qa'
16
17/** An agent godmode saw start, by the role its type names (Step 7). */
18export interface DispatchedAgent {
19 role: GodmodeRole
20 /** The ticket a dev agent builds. */
21 ticket?: string
22 /** The worktree a dev agent works in, absolute; its edits stay inside it (D38). */
23 worktree?: string
24 /** The dev agent's branch: an open PR on it is its own, not a held file's owner (D48). */
25 branch?: string
26}
27
28/** A draft PR a dev agent opened, waiting for the PM to be told to start its review (D35). */
29export interface PendingReview {
30 /** The PR's number, or the branch on a host with no PRs. */
31 pr: string
32 url?: string
33 /** The dev agent that opened it. */
34 agentId: string
35 ticket?: string
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 godmode: {
41 /** True between `/godmode` and `/godmode off`; off, the session is plain Claude Code (D12). */
42 isOn: boolean
43 /** The mode godmode runs in; read only while `isOn`. */
44 mode: GodmodeMode
45 /** Which questions setup asks; read only in `setup` mode. */
46 setupScope: SetupScope
47 /** Conventions files edited since the setup note was written, each re-checked alone (D20). */
48 recheck: string[]
49 /** The godmode agents started this session, by agent id. */
50 agents: Record<string, DispatchedAgent>
51 /** Draft PRs whose review the PM has not been told to start yet. */
52 pendingReviews: PendingReview[]
53 /** Files the main thread read since the person's last prompt (D63). */
54 mainThreadReads: number
55 }
56 }
57}
58