SLOPSHOPPER

godmode

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

newguardcommandprompttoolprocess
v0.4.0no licenseupdated 2026-10-16tinkybeatz/tinky-claude-mods/godmode
A shopper browsing a rack in a slop shop
README

tinky-claude-mods

Claude Code mods: plugins that change Claude Code's own interface or behaviour through function hooks. This repository holds several mods, one folder each.

ModWhat it does
dispatch-panelA 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.

Installing on a new machine

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.

When it does not work

What you seeWhyFix
/plugin → Errors: Failed to load hooks from …/hooks.json … expected record, received undefinedThis machine's Claude Code has mods switched off, so it reads hooks.json in the older hooks formatRun 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 … marketplaceThe marketplace was not added, or the --marketplace flag was ignoredSteps 1 and 2 above, with the @tinky-claude-mods form.
Adding the marketplace is refusedAn organization's managed settings restrict marketplacesAn admin has to allow it.
The panel is above the input barMain-screen layout, tmux, or a narrow windowStep 3.
Some features never react (on a work login)Where an organization manages Claude Code, some hooks of plugins a person installs are skippedAn admin lists the plugins in the managed settings.

From a local clone (developing)

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.

dispatch-panel

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.

CommandEffect
/dispatchesOpens the panel.
/dispatches active / finishedOpens the panel on that tab.
/dispatches clearDrops finished rows.
/dispatches clear allDrops 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.

ActivityTagColour
writing or changing codecodeblue
reviewing or testingreviewred
updating tickets (Jira, issues)jiracyan
git, branches or pull requestsgitpink
searching or researchingresearchyellow
planning or designingplangreen
writing docs or textdocswhite
configuring or setting upsetuppurple
analysing datadatalime
something elseotherorange

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.

Developing

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.

Source 7 files
hooks/register.tsx 211 lines
1import { 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}
211
hooks/host.ts 316 lines
1import 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}
316
hooks/loop.ts 595 lines
1import { 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}
595
hooks/storage.ts 345 lines
1import 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}
345
hooks/tickets.ts 542 lines
1import 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}
542
hooks/rules.ts 615 lines
1import { 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()
615
types/index.d.ts 58 lines
1/**
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