SLOPSHOPPER

pipeline-gates

The pipeline skill's gates as code: no dev agent and no worktree edit before the Teams status and the grill confirmation, no PR before a QC pass that rests on…

newpanebandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pipeline-gates
│ ┃ pipeline ✕ › fix the failing auth test and add an audit log call │ ┃ No pipeline run in this session. │ ┃ /pipeline-gate start [TASK] starts one by ⏺ Read(src/auth.ts) │ ┃ hand. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pipeline-gate │ ⎿ pipeline-gates: no pipeline run in this session. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pipeline
No pipeline run in this session. /pipeline-gate start [TASK] starts one by hand.
README

nixodoo-copier-template

A Copier template that generates a complete Odoo development environment on Nix flakes: native processes, systemd user services, a project-local PostgreSQL cluster, and a Python environment locked with uv.

No container, no global install, no pip in sight. The same generated project also runs as a test or production server.

Quickstart

uvx copier copy --trust gh:okolovmark/nixodoo-copier-template my-odoo-project
cd my-odoo-project

nix run .#create-env          # .env: ports, database credentials
nix run .#update-repos        # clone Odoo and the addon repos, build the symlink farm
nix run .#bootstrap-deps      # import Odoo's requirements.txt into uv.lock
nix profile add .#dev-server  # odoo, psql, ruff and friends into your profile
nix run .#setup-dev           # postgres cluster, odoo.conf, nginx, systemd units
systemctl --user enable --now postgres.service odoo.service nginx.service odoo-logrotate.timer

Odoo is then on the port the template derived from your version, with its own PostgreSQL, its own addons farm and a log rotating daily. The generated README.md walks through the test and production flows the same way.

You need Nix with flakes enabled, uv (uvx runs copier without installing it), and a systemd user session. Starting from nothing:

curl -LsSf https://astral.sh/uv/install.sh | sh
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
sudo systemctl restart nix-daemon

What it generates

  • Odoo 16.0 to 19.0 on Python 3.10 to 3.14 and PostgreSQL 13 to 18, chosen at generation time and verified end to end for every version.
  • Three toolchains as flake packages: dev-server, test-server, prod-server, the first two wrapping odoo and the Postgres client tools against this project's cluster; prod-server carries Odoo alone - no proxy, no database client.
  • Pinned sources: repos.yaml for Odoo, addons.yaml for OCA and custom addon repos, pinned by branch or commit, with a symlink farm builder.
  • Generated configuration: .env, odoo.conf with a random master password, systemd user units, a log-rotation timer, and an nginx reverse proxy for dev and test - production is left to whatever already fronts it.
  • Python dependencies straight from Odoo's own requirements.txt, locked with uv and built with uv2nix, so the flake and your shell agree.
  • Claude Code integration, optional: CLAUDE.md, guard hooks, Odoo skills including semantic code navigation over the official language server, a requirements interview, a development pipeline, deploy and production-operations runbooks, and a persistent memory.
  • Optional extras, each behind a question: a custom addons repo, S3 backup restore with native neutralization, SSH helpers for the prod and test boxes, OCA queue_job.

The full inventory, including every skill and agent, is in docs/features.md.

The choices that matter

Copier asks about twenty questions; most have a sensible default derived from your Odoo version. These are the ones worth a thought:

QuestionDefaultWhy you might change it
odoo_version19.0everything else follows from it
custom_repo_pattern / custom_repo_nameemptyyour own addons repo; unlocks the development pipeline
use_claude_codetruethe whole .claude/ layer
use_kbfalsememory in kb instead of markdown files
service_suffixemptyset it to run two generated projects on one machine
project_dir_varODOO<major>_PROJECT_DIRsame reason, for the project-root variable
status_mcp / tickets_mcpnonewire status posting and ticket tracking to Teams and Odoo
backup_s3_bucketemptyrestore production dumps locally
prod_ssh_host / test_ssh_hostsemptythe deploy and prod-ops runbooks

Every question and its default: docs/questions.md.

Keeping a project current

uvx copier update --trust

Template improvements land in the generated project. A few files are yours and are never overwritten (odools.toml, the estimate calibration table); for the rest, resolve conflicts in favour of the project where you have edited it. Hook arrays in .claude/settings.json deserve a second look after an update: copier can add a second key with the same name, and JSON keeps only the last one.

More

  • docs/features.md — everything a generated project contains
  • docs/questions.md — every question copier asks
  • docs/notes.md — gotchas worth knowing before they bite
  • nix flake check shellchecks every generated script; python3 tests/test_nudge_find_code.py runs the hook regression corpus

License

MIT

Source 3 files
hooks/register.tsx 585 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { BlockTask, Gate, GateName, Run, TaskState } from '../types'
5import {
6  ASK_CONFIRM,
7  ASK_HEADER,
8  ASK_MECHANICAL,
9  ASK_NOT_YET,
10  GATE_LABELS,
11  PHASES,
12  addTasks,
13  askedNote,
14  blockLine,
15  bump,
16  closedForDev,
17  devRefusal,
18  enterPhase,
19  filePathOf,
20  grillQuestion,
21  hasTask,
22  isActive,
23  isAwayGrillAnswer,
24  isDocPath,
25  isOpen,
26  isPrCreate,
27  isWorktreeCode,
28  mayAskGrill,
29  moveTasks,
30  newRun,
31  openGate,
32  prRefusal,
33  qcEvidence,
34  qcHolds,
35  recordVerdict,
36  taskCounts,
37  taskKeyIn,
38  taskKeysIn,
39  tasksOf,
40  testSummary,
41  withBlock,
42} from './gates'
43import { HAS_STATUS_GATE, ODOO_WEB_URL, TASK_URL_BASE } from './config'
44
45const PANE = 'pipeline'
46const RUNS_KEPT = 20
47const GATES: readonly GateName[] = ['status', 'grill', 'qc']
48// the person's own input: the prompt box, the desktop bridge, an SDK host; never a plugin or another agent
49const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
50const USAGE = 'usage: /pipeline-gate [status] | open <status|grill|qc> <reason> | start [TASK-KEY ...] | end'
51const TASK_STATES: readonly TaskState[] = ['pending', 'active', 'done']
52
53const run = atom({ plugin: 'pipeline-gates', key: 'run' } as const, null)
54
55// subagent id -> its type (dev, review, documenter, ...), from $.agent.list()
56const agentTypes = new Map<string, string>()
57
58async function agentType($: EngineInterface, agentId: string): Promise<string> {
59  if (!agentTypes.has(agentId)) {
60    for (const agent of await $.agent.list()) agentTypes.set(agent.id, agent.type)
61  }
62  return agentTypes.get(agentId) ?? ''
63}
64
65async function save($: EngineInterface, next: Run | null): Promise<void> {
66  await update($, run, () => next)
67  await $.store.set(`run:${await $.session.id()}`, next)
68}
69
70async function change($: EngineInterface, apply: (current: Run) => Run): Promise<void> {
71  const current = await read($, run)
72  if (isActive(current)) await save($, apply(current))
73}
74
75// A run for another task is a new run with its gates closed again: an earlier run left open (stopped
76// halfway, never ended) must not lend its open gates to the next one. A block is one run: a task of the
77// block keeps it, and `block` (the keys a pipeline started with) joins it.
78async function start($: EngineInterface, task: string, block: readonly string[] = []): Promise<void> {
79  const current = await read($, run)
80  const now = await $.clock.now()
81  if (isActive(current) && (task === '' || current.task === '' || task === current.task || hasTask(current, task))) {
82    const named = task !== '' && current.task === '' ? { ...current, task } : current
83    const joined = addTasks(named, block, now, ODOO_WEB_URL)
84    if (joined !== current) await save($, joined)
85    return
86  }
87  await save($, addTasks(newRun(task, now, HAS_STATUS_GATE), block, now, ODOO_WEB_URL))
88  void $.ui.open({ id: PANE, title: 'pipeline' })
89}
90
91async function restore($: EngineInterface): Promise<void> {
92  const saved = await $.store.get(`run:${await $.session.id()}`)
93  if (saved !== undefined && saved !== null) await update($, run, () => saved as Run)
94  const keys = (await $.store.keys()).filter(key => key.startsWith('run:'))
95  for (const key of keys.slice(0, Math.max(0, keys.length - RUNS_KEPT))) await $.store.delete(key)
96}
97
98// the whole output of a Bash call: inline, or the file a large one was persisted to
99async function bashOutput($: EngineInterface, result: unknown, text: string | undefined): Promise<string> {
100  const record = (result ?? {}) as { stdout?: string; stderr?: string; persistedOutputPath?: string }
101  if (typeof record.persistedOutputPath === 'string') {
102    try {
103      return await $.fs.read(record.persistedOutputPath)
104    } catch {
105      // fall through to what the record holds inline
106    }
107  }
108  return `${record.stdout ?? ''}\n${record.stderr ?? ''}\n${text ?? ''}`
109}
110
111async function confirmGrill($: EngineInterface, how: string): Promise<void> {
112  const at = await $.clock.now()
113  await change($, current => openGate(current, 'grill', how, at))
114  $.ui.toast(`pipeline-gates: grill gate open (${how})`)
115}
116
117// a block task's link: the record URL it was given, else its key's /tasks/<key> where the project has one
118function taskLink(task: BlockTask): string {
119  if (task.url !== undefined && task.url !== '') return task.url
120  return TASK_URL_BASE === '' ? '' : `${TASK_URL_BASE}${task.key}`
121}
122
123// one grill dialog at a time: a call blocked while it is open waits for the same answer
124let asking: Promise<string> | null = null
125
126// The grill gate waits on the person. They get a push (on the phone through Remote Control; the engine
127// skips it while they are at the screen) and the engine's own question dialog, which every surface draws,
128// the phone included. Only a press on Confirm or Mechanical opens the gate; Not yet, a dismissal, a dialog
129// that resolved itself while they were away and text typed under Other leave it shut and mute the dialog
130// until the person's next message.
131async function askGrill($: EngineInterface, current: Run, what: string): Promise<string> {
132  const task = current.task || 'pipeline'
133  try {
134    await $.tool.call({ tool: 'PushNotification', message: `${task}: ${what} waits on your grill confirm`, status: 'proactive' })
135  } catch {
136    // notifications off or nowhere to send them: the dialog still asks
137  }
138  let answer = ''
139  try {
140    const options = [ASK_CONFIRM, ASK_MECHANICAL, ASK_NOT_YET]
141    answer = (await $.ui.ask(grillQuestion(task, what), { header: ASK_HEADER, options })).trim()
142  } catch {
143    // dismissed, or a run with no one to ask
144  }
145  if (answer === ASK_CONFIRM) await confirmGrill($, 'confirmed by you in the dialog')
146  else if (answer === ASK_MECHANICAL) await confirmGrill($, 'mechanical task, grill skipped by you in the dialog')
147  else await change($, r => ({ ...r, isAskMuted: true }))
148  return answer
149}
150
151function waitForGrill($: EngineInterface, current: Run, what: string): Promise<string> {
152  asking ??= askGrill($, current, what).finally(() => {
153    asking = null
154  })
155  return asking
156}
157
158function describe(current: Run | null): string {
159  if (!isActive(current)) return 'pipeline-gates: no pipeline run in this session.'
160  const evidence = qcEvidence(current)
161  return [
162    `pipeline ${current.task || '(task not set)'} · phase ${current.phase} ${PHASES[current.phase]}`,
163    ...(blockLine(current) === '' ? [] : [`tasks: ${blockLine(current)}`]),
164    ...GATES.map(name => {
165      const gate = current[name]
166      return `${GATE_LABELS[name]}: ${gate === null ? 'closed' : `${gate.how}${gate.isOpen ? '' : ' (closed)'}`}`
167    }),
168    `QC evidence since the last code change: review ${evidence.review ? 'yes' : 'no'}, green tests ${evidence.tests ? 'yes' : 'no'}${current.lastTests ? ` (last run: ${current.lastTests})` : ''}`,
169    `dev allowed: ${closedForDev(current).length === 0 ? 'yes' : 'no'} · PR allowed: ${qcHolds(current) ? 'yes' : 'no'}`,
170  ].join('\n')
171}
172
173async function runCommand($: EngineInterface, args: string, isPerson: boolean): Promise<string> {
174  const [verb = 'status', name = '', ...rest] = args.trim().split(/\s+/).filter(part => part !== '')
175  const current = await read($, run)
176  if (verb === 'status') return describe(current)
177  // `end` and `open` let work past the gates; `start` may come from anyone, a new run's gates are closed
178  if ((verb === 'end' || verb === 'open') && !isPerson) {
179    return 'pipeline-gates: only the person at the keyboard ends a run or opens a gate by hand.'
180  }
181  if (verb === 'start') {
182    // by hand the label is taken as typed: KIO-1834, a block of keys, or a name for a trial run
183    const keys = taskKeysIn([name, ...rest].join(' ').toUpperCase())
184    await start($, keys[0] ?? name.toUpperCase().slice(0, 40), keys)
185    return describe(await read($, run))
186  }
187  if (verb === 'end') {
188    if (!isActive(current)) return describe(current)
189    await save($, { ...current, isDone: true })
190    return 'pipeline-gates: the run is ended; nothing is gated any more.'
191  }
192  if (verb === 'open') {
193    if (!isActive(current)) return describe(current)
194    if (!GATES.includes(name as GateName) || rest.length === 0) return USAGE
195    await save($, openGate(current, name as GateName, `opened by you: ${rest.join(' ')}`, await $.clock.now()))
196    return `pipeline-gates: the ${GATE_LABELS[name as GateName]} gate is open (${rest.join(' ')}).`
197  }
198  return USAGE
199}
200
201export const register: Register = on => {
202  on('session.start', async ($, e, next) => {
203    const started = await next(e)
204    await $.tool.register({
205      name: 'phase',
206      description:
207        "Records that the pipeline enters a phase (0 memory bootstrap ... 7 memory closure), with the task key once known. Call it at the start of every phase. Refused for Phase 2 and later while the Teams status gate or the grill gate is closed, and for Phase 6 and later until the QC verdict passed; the refusal says what is missing. Starts the run when none is active.",
208      inputSchema: {
209        type: 'object',
210        properties: {
211          phase: { type: 'integer', minimum: 0, maximum: 7 },
212          task: { type: 'string', description: 'The task key, KIO-1234' },
213        },
214        required: ['phase'],
215      },
216    })
217    await $.tool.register({
218      name: 'qc_verdict',
219      description:
220        "Records the Phase-3 QC gate decision. A pass is accepted only when a review agent finished and a green Odoo test run (more than 0 tests) happened after the last code change in the worktree; otherwise it is refused with what is missing. Until a pass stands, gh pr create is refused; a later code change voids it.",
221      inputSchema: {
222        type: 'object',
223        properties: {
224          verdict: { type: 'string', enum: ['pass', 'blocking'] },
225          summary: { type: 'string', description: 'The gate report line' },
226        },
227        required: ['verdict', 'summary'],
228      },
229    })
230    await $.tool.register({
231      name: 'tasks',
232      description:
233        "The block of tasks this pipeline run works through, and where each stands: the user watches it as progress in the pipeline pane and band. A block is one run (one Teams status, one grill alignment), so a task of the block never resets the gates. `add` the block's tasks, each \"KIO-1234 short title <its _url from the odoo MCP>\" (the URL gives the task an open button in the pane; a pipeline started with several keys has the keys already, add again to give titles and URLs). Then move them as the work goes: `active` when you start on a task, `done` when it is finished (its part is in the PR, or it closed without code), `pending` to put one back. Tasks that share one PR are yours to mark.",
234      inputSchema: {
235        type: 'object',
236        properties: {
237          add: { type: 'array', items: { type: 'string' }, description: 'Tasks of the block: "KIO-1234 short title https://…/web#id=…" (title and URL optional)' },
238          active: { type: 'array', items: { type: 'string' }, description: 'Task keys now in work' },
239          done: { type: 'array', items: { type: 'string' }, description: 'Task keys finished' },
240          pending: { type: 'array', items: { type: 'string' }, description: 'Task keys put back to pending' },
241        },
242      },
243    })
244    await $.tool.register({
245      name: 'gates',
246      description: 'The current pipeline run: phase, the three gates, and what the QC gate still lacks.',
247      inputSchema: { type: 'object', properties: {} },
248    })
249    await $.command.register({
250      name: 'pipeline-gate',
251      description: 'The pipeline gates: status, or open a gate by hand with a reason',
252      argumentHint: '[status | open <status|grill|qc> <reason> | start [TASK ...] | end]',
253    })
254    await restore($)
255    if (isActive(await read($, run))) void $.ui.open({ id: PANE, title: 'pipeline' })
256    return started
257  })
258
259  on('skill.prompt', { skill: 'pipeline' }, async ($, e, next) => {
260    await start($, '')
261    return next(e)
262  })
263
264  on('tool.call', { tool: 'mcp__pipeline-gates__phase' }, async ($, e) => {
265    const input = e as unknown as Record<string, unknown>
266    const task = typeof input.task === 'string' ? taskKeyIn(input.task.toUpperCase()) : ''
267    await start($, task)
268    const current = await read($, run)
269    if (!isActive(current)) return { result: describe(current) }
270    const phase = Number(input.phase)
271    let entered = enterPhase(current, phase, task, await $.clock.now())
272    let asked = ''
273    if (typeof entered === 'string' && phase >= 2 && mayAskGrill(current)) {
274      const answer = await waitForGrill($, current, `Phase ${phase}`)
275      const fresh = await read($, run)
276      if (!isActive(fresh)) return { result: describe(fresh) }
277      entered = enterPhase(fresh, phase, task, await $.clock.now())
278      if (typeof entered === 'string' && !isOpen(fresh.grill)) asked = askedNote(answer)
279    }
280    if (typeof entered === 'string') {
281      const latest = await read($, run)
282      if (isActive(latest)) await save($, withBlock(latest, `Phase ${String(input.phase)}`, await $.clock.now()))
283      return { result: asked === '' ? entered : `${entered} ${asked}` }
284    }
285    await save($, entered)
286    return { result: describe(entered) }
287  })
288
289  on('tool.call', { tool: 'mcp__pipeline-gates__qc_verdict' }, async ($, e) => {
290    const input = e as unknown as Record<string, unknown>
291    const current = await read($, run)
292    if (!isActive(current)) return { result: describe(current) }
293    const summary = typeof input.summary === 'string' ? input.summary.slice(0, 300) : ''
294    const recorded = recordVerdict(current, String(input.verdict), summary, await $.clock.now())
295    if (typeof recorded === 'string') return { result: recorded }
296    await save($, recorded)
297    return { result: describe(recorded) }
298  })
299
300  on('tool.call', { tool: 'mcp__pipeline-gates__gates' }, async $ => ({ result: describe(await read($, run)) }))
301
302  on('tool.call', { tool: 'mcp__pipeline-gates__tasks' }, async ($, e) => {
303    const input = e as unknown as Record<string, unknown>
304    const list = (value: unknown) => (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [])
305    const added = list(input.add)
306    const now = await $.clock.now()
307    // added to the run in progress, never a reason to start over; with none, the block starts one
308    if (isActive(await read($, run))) await change($, r => addTasks(r, added, now, ODOO_WEB_URL))
309    else await start($, taskKeyIn(added.join(' ').toUpperCase()), added)
310    for (const state of TASK_STATES) {
311      const keys = list(input[state])
312      if (keys.length > 0) await change($, r => moveTasks(r, keys, state, now))
313    }
314    return { result: describe(await read($, run)) }
315  })
316
317  // a grill dialog that resolved itself while the person was away carries no press: its answer is dropped
318  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
319    const ran = await next(e)
320    if (!isAwayGrillAnswer(e.questions, ran.result)) return ran
321    const result = { ...(ran.result as object), answers: {} }
322    return ran.context === undefined ? { result } : { result, context: ran.context }
323  })
324
325  // the person's own message lets the grill dialog ask again
326  on('prompt.submit', async ($, e, next) => {
327    if (PERSON_ORIGINS.has(e.origin.kind) && (await read($, run))?.isAskMuted === true) {
328      await change($, r => ({ ...r, isAskMuted: false }))
329    }
330    return next(e)
331  })
332
333  // the surface already heads a command's output with the plugin's name
334  on('command.run', { command: 'pipeline-gate' }, async ($, e) => ({
335    text: (await runCommand($, e.args, PERSON_ORIGINS.has(e.origin.kind))).replace(/^pipeline-gates: /, ''),
336  }))
337
338  // the gates themselves, and the facts the QC gate rests on
339  on('tool.call', async ($, e, next) => {
340    const tool = String(e.tool)
341    const input = e as unknown as Record<string, unknown>
342    if (tool === 'Skill' && input.skill === 'pipeline') {
343      const keys = taskKeysIn(String(input.args ?? ''))
344      await start($, keys[0] ?? '', keys)
345    }
346
347    const current = await read($, run)
348    const path = filePathOf(tool, input)
349    const command = tool === 'Bash' && typeof input.command === 'string' ? input.command : ''
350    if (isActive(current)) {
351      const isDev = tool === 'Agent' && input.subagent_type === 'dev'
352      const isWorktreeEdit = path !== null && isWorktreeCode(path)
353      if ((isDev || isWorktreeEdit) && closedForDev(current).length > 0) {
354        const what = isDev ? 'spawning the dev agent' : `editing ${path}`
355        const answer = mayAskGrill(current) ? await waitForGrill($, current, what) : null
356        const latest = await read($, run)
357        if (isActive(latest) && closedForDev(latest).length > 0) {
358          await save($, withBlock(latest, what, await $.clock.now()))
359          $.ui.toast(`pipeline-gates: ${what} blocked`)
360          return { deny: devRefusal(latest, what, answer === null || isOpen(latest.grill) ? '' : askedNote(answer)) }
361        }
362      }
363      if (command !== '' && isPrCreate(command) && !qcHolds(current)) {
364        await save($, withBlock(current, 'gh pr create', await $.clock.now()))
365        $.ui.toast('pipeline-gates: gh pr create blocked')
366        return { deny: prRefusal(current) }
367      }
368    }
369
370    const ran = await next(e)
371    if (!isActive(await read($, run)) || ran.deny !== undefined || ran.isError === true) return ran
372
373    const agentId = (e as { agentId?: string }).agentId
374    if (path !== null && isWorktreeCode(path) && !isDocPath(path)) {
375      const isDocumenter = agentId !== undefined && (await agentType($, agentId)) === 'documenter'
376      if (!isDocumenter) {
377        await change($, r => {
378          const [next2, seq] = bump(r)
379          return { ...next2, lastChange: seq }
380        })
381      }
382    }
383    if (command.includes('--test-enable')) {
384      const summary = testSummary(await bashOutput($, ran.result, ran.text))
385      if (summary !== null) {
386        await change($, r => {
387          const [next2, seq] = bump(r)
388          return { ...next2, lastTests: summary.text, lastGreen: summary.isGreen ? seq : r.lastGreen }
389        })
390      }
391    }
392    if (tool.endsWith('__teams_send_chat_message')) {
393      const chat = typeof input.chatId === 'string' ? input.chatId : 'a chat'
394      const at = await $.clock.now()
395      await change($, r => (isOpen(r.status) ? r : openGate(r, 'status', `sent to ${chat.slice(0, 16)}…`, at)))
396    }
397    return ran
398  })
399
400  // a subagent's end: dev counts as a code change, review as the review the QC gate needs
401  on('turn.complete', async ($, e, next) => {
402    const result = await next(e)
403    if (e.agentId !== undefined && !e.isAborted && isActive(await read($, run))) {
404      const type = await agentType($, e.agentId)
405      if (type === 'dev' || type === 'review') {
406        await change($, r => {
407          const [next2, seq] = bump(r)
408          return type === 'dev' ? { ...next2, lastChange: seq } : { ...next2, lastReview: seq }
409        })
410      }
411    }
412    return result
413  })
414
415  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
416    const below = await next(e)
417    const current = await read($, run)
418    if (!isActive(current) || e.props.hasSurvey) return below
419    const { Box, Button, Text } = $.ui.resolve(e)
420    const hint = { scope: 'pipeline-gates', underline: true }
421    const counts = taskCounts(current)
422    const mark = (gate: Gate | null, holds: boolean) => (holds && isOpen(gate) ? '✓' : '○')
423
424    return (
425      <Box flexDirection="column">
426        {/* every band plugin draws its tips before the plugins beneath it and its rows after them: all tips sit on top */}
427        <Box key="tip-pipeline-gates" display="none" hover={{ scope: 'pipeline-gates', display: 'flex' }} flexDirection="row" columnGap={1}>
428          <Text color="cyan">ⓘ</Text>
429          <Text dimColor>
430            This session runs the pipeline: its phase and the gates the pipeline-gates mod enforces. Teams status and grill
431            gate the dev agent and worktree edits, the QC verdict gates gh pr create. For a block of tasks, done/total.
432            open = the pipeline pane.
433          </Text>
434        </Box>
435        {below}
436        <Box key="pipeline-row" flexDirection="row" columnGap={1} alignItems="center">
437          <Text dimColor hover={hint}>
438            pipeline
439          </Text>
440          <Text bold hover={hint}>
441            {current.task || 'task?'}
442          </Text>
443          {counts.total > 1 && <Text color={counts.done === counts.total ? 'green' : 'cyan'} hover={hint}>{`${counts.done}/${counts.total}`}</Text>}
444          <Text hover={hint}>{`${current.phase} ${PHASES[current.phase]}`}</Text>
445          <Text color={isOpen(current.status) ? 'green' : 'yellow'} hover={hint}>{`status ${mark(current.status, true)}`}</Text>
446          <Text color={isOpen(current.grill) ? 'green' : 'yellow'} hover={hint}>{`grill ${mark(current.grill, true)}`}</Text>
447          <Text color={qcHolds(current) ? 'green' : 'yellow'} hover={hint}>{`qc ${mark(current.qc, qcHolds(current))}`}</Text>
448          <Button key="pipeline-open" label="open" onPress={() => $.ui.open({ id: PANE, title: 'pipeline' })} />
449        </Box>
450      </Box>
451    )
452  })
453
454  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
455    const { Box, Button, Link, Text } = $.ui.resolve(e)
456    const current = await read($, run)
457    if (!isActive(current)) return <Text dimColor>No pipeline run in this session. /pipeline-gate start [TASK] starts one by hand.</Text>
458    const now = await $.clock.now()
459    const ago = (at: number) => {
460      const minutes = Math.max(0, Math.round((now - at) / 60_000))
461      return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes}m ago` : `${Math.floor(minutes / 60)}h${minutes % 60}m ago`
462    }
463    const hasCards = e.surface !== 'terminal'
464    const card = (key: string, children: RenderChildren) => (
465      <Box key={key} flexDirection="column" rowGap={hasCards ? 1 : 0} borderStyle={hasCards ? 'round' : undefined} borderDimColor={hasCards ? true : undefined}>
466        {children}
467      </Box>
468    )
469    const evidence = qcEvidence(current)
470    const tasks = tasksOf(current)
471    const counts = taskCounts(current)
472    const taskRow = (task: BlockTask) => (
473      <Box key={`task-${task.key}`} flexDirection="row" columnGap={1}>
474        <Text color={task.state === 'done' ? 'green' : task.state === 'active' ? 'cyan' : undefined} dimColor={task.state === 'pending'}>
475          {task.state === 'done' ? '✓' : task.state === 'active' ? '●' : '○'}
476        </Text>
477        <Text bold={task.state === 'active'} dimColor={task.state === 'pending'}>
478          {task.key}
479        </Text>
480        {task.title !== '' && <Text dimColor>{task.title}</Text>}
481        {task.state !== 'pending' && <Text dimColor>{`${task.state === 'done' ? 'done' : 'in work'} ${ago(task.at)}`}</Text>}
482        {taskLink(task) !== '' && <Link href={taskLink(task)} label="open in Odoo" />}
483      </Box>
484    )
485    const gateRow = (name: GateName, holds: boolean, extra: RenderChildren) => {
486      const gate = current[name]
487      const isUp = holds && isOpen(gate)
488      return (
489        <Box key={`gate-${name}`} flexDirection="column">
490          <Box flexDirection="row" columnGap={1}>
491            <Text color={isUp ? 'green' : 'yellow'}>{isUp ? '✓' : '○'}</Text>
492            <Text bold>{GATE_LABELS[name]}</Text>
493            <Text dimColor>{gate === null ? 'closed' : `${gate.how} · ${ago(gate.at)}`}</Text>
494          </Box>
495          {extra}
496        </Box>
497      )
498    }
499
500    return (
501      <Box flexDirection="column" rowGap={1}>
502        <Box key="pipeline-head" flexDirection="column">
503          <Text bold>{`pipeline ${current.task || '(task not set yet)'}${tasks.length > 1 ? ` · block of ${tasks.length}` : ''}`}</Text>
504          <Text dimColor>{`started ${ago(current.startedAt)} · dev ${closedForDev(current).length === 0 ? 'allowed' : 'gated'} · PR ${qcHolds(current) ? 'allowed' : 'gated'}`}</Text>
505        </Box>
506        {tasks.length > 1 &&
507          card(
508            'pipeline-tasks',
509            <Box flexDirection="column">
510              <Text bold>Tasks</Text>
511              <Box flexDirection="row">
512                <Text color="green">{'■'.repeat(counts.done)}</Text>
513                <Text color="cyan">{'■'.repeat(counts.active)}</Text>
514                <Text dimColor>{'□'.repeat(counts.pending)}</Text>
515              </Box>
516              <Text dimColor>{blockLine(current)}</Text>
517              {tasks.map(task => taskRow(task))}
518            </Box>,
519          )}
520        {card(
521          'pipeline-phases',
522          <Box flexDirection="column">
523            <Text bold>Phases</Text>
524            {PHASES.map((name, i) => {
525              const reached = (current.phaseAt[i] ?? 0) > 0
526              // a phase passed over without being entered (the optional GIF) reads as skipped, not done
527              const isSkipped = i < current.phase && !reached
528              return (
529                <Box flexDirection="row" columnGap={1}>
530                  <Text
531                    color={i < current.phase && !isSkipped ? 'green' : i === current.phase ? 'cyan' : undefined}
532                    dimColor={i > current.phase || isSkipped}
533                  >
534                    {isSkipped ? '–' : i < current.phase ? '✓' : i === current.phase ? '●' : '○'}
535                  </Text>
536                  <Text bold={i === current.phase} dimColor={i > current.phase || isSkipped}>
537                    {`${i} ${name}${isSkipped ? ' · skipped' : ''}`}
538                  </Text>
539                  {reached && <Text dimColor>{ago(current.phaseAt[i] ?? 0)}</Text>}
540                </Box>
541              )
542            })}
543          </Box>,
544        )}
545        {card(
546          'pipeline-gates',
547          <Box flexDirection="column" rowGap={1}>
548            <Text bold>Gates</Text>
549            {gateRow('status', true, null)}
550            {gateRow(
551              'grill',
552              true,
553              !isOpen(current.grill) && (
554                <Box flexDirection="row" columnGap={1} paddingLeft={2} alignItems="center">
555                  <Text dimColor>after you agree with the Alignment Summary:</Text>
556                  <Button key="grill-confirm" label="confirm" onPress={() => confirmGrill($, 'confirmed by you')} />
557                  <Button key="grill-mechanical" label="mechanical" onPress={() => confirmGrill($, 'mechanical task, grill skipped by you')} />
558                </Box>
559              ),
560            )}
561            {gateRow(
562              'qc',
563              qcHolds(current),
564              <Box flexDirection="column" paddingLeft={2}>
565                <Text dimColor>{`since the last code change: review ${evidence.review ? '✓' : '○'} · green tests ${evidence.tests ? '✓' : '○'}`}</Text>
566                {current.lastTests !== '' && <Text dimColor>{`last test run: ${current.lastTests}`}</Text>}
567              </Box>,
568            )}
569          </Box>,
570        )}
571        {current.blocked.length > 0 &&
572          card(
573            'pipeline-blocked',
574            <Box flexDirection="column">
575              <Text bold>Blocked</Text>
576              {current.blocked.map(block => (
577                <Text dimColor>{`${ago(block.at)}  ${block.what}`}</Text>
578              ))}
579            </Box>,
580          )}
581      </Box>
582    )
583  })
584}
585
hooks/gates.ts 265 lines
1import type { BlockTask, Gate, GateName, Run, TaskState } from '../types'
2
3export const PHASES = [
4  'Memory bootstrap',
5  'Task reading',
6  'Development',
7  'QC gate',
8  'Documentation',
9  'Demo GIF (optional)',
10  'Pull request',
11  'Memory closure',
12] as const
13
14export const GATE_LABELS: Record<GateName, string> = {
15  status: 'Teams status',
16  grill: 'grill alignment',
17  qc: 'QC verdict',
18}
19
20// the grill dialog: a press on its first two options opens the gate, as the pane's buttons do
21export const ASK_CONFIRM = 'Confirm'
22export const ASK_MECHANICAL = 'Mechanical'
23export const ASK_NOT_YET = 'Not yet'
24export const ASK_HEADER = 'Grill gate'
25const ASK_TAIL = 'waits on the grill gate. Do you agree with the Alignment Summary?'
26
27const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
28const WORKTREE = /\/\.worktrees\/(?!_env\/)[A-Za-z0-9][A-Za-z0-9._-]*\//
29// what the documenter writes: never a code change, so it never voids a QC pass
30const DOC_PATH = /\/readme\/|\/README\.(?:rst|md)$|\/static\/description\/|\.puml$/
31const TEST_SUMMARY = /(\d+) failed, (\d+) error\(s\) of (\d+) tests/g
32const PR_CREATE = /\bgh\s+pr\s+create\b/
33const TASK_KEY = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/
34
35export function newRun(task: string, now: number, hasStatusGate = true): Run {
36  return {
37    task,
38    startedAt: now,
39    phase: 0,
40    phaseAt: PHASES.map((_, i) => (i === 0 ? now : 0)),
41    status: hasStatusGate ? null : { isOpen: true, how: 'no Teams status flow in this project', at: now },
42    grill: null,
43    qc: null,
44    qcSeq: 0,
45    seq: 0,
46    lastChange: 0,
47    lastReview: 0,
48    lastGreen: 0,
49    lastTests: '',
50    blocked: [],
51    isAskMuted: false,
52    isDone: false,
53  }
54}
55
56export function isActive(run: Run | null): run is Run {
57  return run !== null && !run.isDone
58}
59
60export function filePathOf(tool: string, input: Record<string, unknown>): string | null {
61  if (!FILE_TOOLS.has(tool)) return null
62  const path = typeof input.file_path === 'string' ? input.file_path : input.notebook_path
63  return typeof path === 'string' ? path : null
64}
65
66export function isWorktreeCode(path: string): boolean {
67  return WORKTREE.test(path)
68}
69
70export function isDocPath(path: string): boolean {
71  return DOC_PATH.test(path)
72}
73
74export function isPrCreate(command: string): boolean {
75  return PR_CREATE.test(command)
76}
77
78export function taskKeyIn(text: string): string {
79  return TASK_KEY.exec(text)?.[0] ?? ''
80}
81
82// every task key in the text, once each, in order: the block a pipeline started with several takes on
83export function taskKeysIn(text: string): string[] {
84  return [...new Set(text.match(new RegExp(TASK_KEY.source, 'g')) ?? [])]
85}
86
87export function tasksOf(run: Run): BlockTask[] {
88  return run.tasks ?? []
89}
90
91export function hasTask(run: Run, key: string): boolean {
92  return tasksOf(run).some(task => task.key === key)
93}
94
95// The record URL in a task item, kept only when it opens a record in the production web client
96// (`webUrl`, then `#` or `?`): a link the person clicks never leads anywhere else.
97export function taskUrlIn(item: string, webUrl: string): string {
98  const url = /https:\/\/\S+/.exec(item)?.[0] ?? ''
99  const base = webUrl.replace(/\/+$/, '')
100  return base !== '' && (url.startsWith(`${base}#`) || url.startsWith(`${base}?`)) ? url.slice(0, 500) : ''
101}
102
103// Tasks given as "KIO-1234", "KIO-1234 short title", either with the task's record URL, join the block
104// as pending; one already in it keeps its state and takes a new title or URL when one is given.
105export function addTasks(run: Run, items: readonly string[], now: number, webUrl = ''): Run {
106  let tasks = tasksOf(run)
107  for (const item of items) {
108    const key = taskKeyIn(item.toUpperCase())
109    if (key === '') continue
110    const url = taskUrlIn(item, webUrl)
111    const text = item.replace(/https?:\/\/\S+/g, ' ')
112    const title = text.slice(text.toUpperCase().indexOf(key) + key.length).replace(/^[\s:·,-]+/, '').trim().slice(0, 80)
113    const known = tasks.find(task => task.key === key)
114    if (known === undefined) tasks = [...tasks, { key, title, state: 'pending', at: now, url }]
115    else if ((title !== '' && title !== known.title) || (url !== '' && url !== known.url)) {
116      tasks = tasks.map(task => (task.key === key ? { ...task, title: title || task.title, url: url || task.url } : task))
117    }
118  }
119  return tasks === tasksOf(run) ? run : { ...run, tasks }
120}
121
122// moves the named tasks to `state`; a key not in the block joins it in that state
123export function moveTasks(run: Run, keys: readonly string[], state: TaskState, now: number): Run {
124  const withAll = addTasks(run, keys, now)
125  const wanted = new Set(keys.map(key => taskKeyIn(key.toUpperCase())).filter(key => key !== ''))
126  const tasks = tasksOf(withAll).map(task => (wanted.has(task.key) && task.state !== state ? { ...task, state, at: now } : task))
127  return { ...withAll, tasks }
128}
129
130export function taskCounts(run: Run): Record<TaskState, number> & { total: number } {
131  const tasks = tasksOf(run)
132  const count = (state: TaskState) => tasks.filter(task => task.state === state).length
133  return { pending: count('pending'), active: count('active'), done: count('done'), total: tasks.length }
134}
135
136// "2/11 done · 4 in work · 5 pending", or '' for a run of one task
137export function blockLine(run: Run): string {
138  const counts = taskCounts(run)
139  return counts.total > 1 ? `${counts.done}/${counts.total} done · ${counts.active} in work · ${counts.pending} pending` : ''
140}
141
142// Every `N failed, M error(s) of K tests` line of an Odoo run: green when all are clean and K adds up
143// to more than zero (a bare --test-tags name runs 0 tests and only looks green).
144export function testSummary(output: string): { isGreen: boolean; text: string } | null {
145  const lines = [...output.matchAll(TEST_SUMMARY)]
146  if (lines.length === 0) return null
147  const failed = lines.reduce((sum, m) => sum + Number(m[1]), 0)
148  const errors = lines.reduce((sum, m) => sum + Number(m[2]), 0)
149  const tests = lines.reduce((sum, m) => sum + Number(m[3]), 0)
150  return { isGreen: failed === 0 && errors === 0 && tests > 0, text: `${failed} failed, ${errors} error(s) of ${tests} tests` }
151}
152
153export function isOpen(gate: Gate | null): boolean {
154  return gate?.isOpen === true
155}
156
157export function closedForDev(run: Run): GateName[] {
158  return (['status', 'grill'] as const).filter(name => !isOpen(run[name]))
159}
160
161export function qcHolds(run: Run): boolean {
162  return isOpen(run.qc) && run.qcSeq > run.lastChange
163}
164
165// what a QC pass rests on, since the last code change
166export function qcEvidence(run: Run): { review: boolean; tests: boolean } {
167  return { review: run.lastReview > run.lastChange, tests: run.lastGreen > run.lastChange }
168}
169
170export function bump(run: Run): [Run, number] {
171  const seq = run.seq + 1
172  return [{ ...run, seq }, seq]
173}
174
175export function withBlock(run: Run, what: string, at: number): Run {
176  return { ...run, blocked: [{ what, at }, ...run.blocked].slice(0, 5) }
177}
178
179export function devRefusal(run: Run, what: string, asked = ''): string {
180  const names = closedForDev(run).map(name => GATE_LABELS[name])
181  const closed = `${names.join(' and ')} ${names.length > 1 ? 'gates are' : 'gate is'}`
182  const how = [
183    !isOpen(run.status) ? 'the status opens on a successful Teams send (my-status send)' : '',
184    !isOpen(run.grill)
185      ? 'the grill gate opens only when the user presses confirm or mechanical, in the pipeline pane or in the dialog the mod opens when a step waits on it'
186      : '',
187  ].filter(part => part !== '')
188  const refusal = `pipeline-gates: ${what} is refused, the ${closed} closed: ${how.join('; ')}. A user who decides to go on anyway types /pipeline-gate open <gate> <reason>.`
189  return asked === '' ? refusal : `${refusal} ${asked}`
190}
191
192// The dialog asks only when the grill gate is the one still shut for dev, and once per message of the
193// person's: after a "not yet" or a dialog nobody answered, a retry is refused without asking again.
194export function mayAskGrill(run: Run): boolean {
195  const closed = closedForDev(run)
196  return closed.length === 1 && closed[0] === 'grill' && run.isAskMuted !== true
197}
198
199export function grillQuestion(task: string, what: string): string {
200  return `${task || 'pipeline'}: ${what} ${ASK_TAIL}`
201}
202
203// An AskUserQuestion that resolved itself while the person was away (afkTimeoutMs) carries no press of
204// theirs: when it is the grill dialog, its answers are dropped.
205export function isAwayGrillAnswer(questions: readonly { header?: string; question: string }[], result: unknown): boolean {
206  const isGrill = questions.some(asked => asked.header === ASK_HEADER && asked.question.endsWith(ASK_TAIL))
207  return isGrill && (result as { afkTimeoutMs?: unknown } | null)?.afkTimeoutMs !== undefined
208}
209
210// what the refusal tells the model after a dialog that did not open the gate
211export function askedNote(answer: string): string {
212  if (answer === ASK_NOT_YET) return 'The user answered Not yet in the grill dialog: stop and wait for them.'
213  if (answer === '') {
214    return 'Nobody answered the grill dialog (dismissed, or it closed while the user was away): tell the user the pipeline waits on their confirm, and stop.'
215  }
216  return `Instead of choosing, the user wrote: "${answer.slice(0, 500)}". That does not open the gate: answer them, then call again.`
217}
218
219// what a QC pass still lacks since the last code change
220export function qcMissing(run: Run): string[] {
221  const evidence = qcEvidence(run)
222  return [
223    !evidence.review ? 'a review agent that finished after the last code change' : '',
224    !evidence.tests ? 'a green Odoo test run (more than 0 tests) after the last code change' : '',
225  ].filter(part => part !== '')
226}
227
228export function prRefusal(run: Run): string {
229  const missing = [...qcMissing(run), 'a pass recorded with mcp__pipeline-gates__qc_verdict']
230  return `pipeline-gates: gh pr create is refused until the QC gate passes; missing: ${missing.join(', ')}.`
231}
232
233// The QC verdict the pipeline records: a pass is taken only on its evidence.
234export function recordVerdict(run: Run, verdict: string, summary: string, now: number): Run | string {
235  if (verdict === 'blocking') {
236    const [next, seq] = bump(run)
237    return { ...next, qc: { isOpen: false, how: `blocking: ${summary}`, at: now }, qcSeq: seq }
238  }
239  if (verdict !== 'pass') return 'pipeline-gates: verdict must be pass or blocking.'
240  const missing = qcMissing(run)
241  if (missing.length > 0) return `pipeline-gates: the pass is refused; missing: ${missing.join(', ')}.`
242  const [next, seq] = bump(run)
243  return { ...next, qc: { isOpen: true, how: `pass: ${summary}`, at: now }, qcSeq: seq }
244}
245
246export function enterPhase(run: Run, phase: number, task: string, now: number): Run | string {
247  if (!Number.isInteger(phase) || phase < 0 || phase >= PHASES.length) return `phase must be 0 to ${PHASES.length - 1}`
248  if (phase >= 2 && closedForDev(run).length > 0) return devRefusal(run, `Phase ${phase}`)
249  if (phase >= 6 && !qcHolds(run)) return prRefusal(run)
250  return {
251    ...run,
252    task: task !== '' ? task : run.task,
253    phase,
254    phaseAt: run.phaseAt.map((at, i) => (i === phase && at === 0 ? now : at)),
255    isDone: phase === PHASES.length - 1,
256  }
257}
258
259export function openGate(run: Run, name: GateName, how: string, now: number): Run {
260  const gate: Gate = { isOpen: true, how, at: now }
261  if (name !== 'qc') return { ...run, [name]: gate }
262  const [next, seq] = bump(run)
263  return { ...next, qc: gate, qcSeq: seq }
264}
265
types/index.d.ts 41 lines
1export type GateName = 'status' | 'grill' | 'qc'
2
3// how a gate opened (or why the QC gate stays shut), and when
4export type Gate = { isOpen: boolean; how: string; at: number }
5
6// one task of the block a run works through, and where it stands; the agent moves it
7export type TaskState = 'pending' | 'active' | 'done'
8// `url`: the task's record in the production web client, '' or absent when none was given
9export type BlockTask = { key: string; title: string; state: TaskState; at: number; url?: string }
10
11// One pipeline run. The seq fields order what happened: a QC pass counts only when the review and
12// the green test run it rests on came after the last code change, and it holds only until the next.
13export type Run = {
14  task: string
15  startedAt: number
16  phase: number
17  phaseAt: number[]
18  status: Gate | null
19  grill: Gate | null
20  qc: Gate | null
21  qcSeq: number
22  seq: number
23  lastChange: number
24  lastReview: number
25  lastGreen: number
26  lastTests: string
27  blocked: { what: string; at: number }[]
28  // the block of tasks this run works through, one run for all of them (absent in a run stored earlier)
29  tasks?: BlockTask[]
30  // the grill dialog was declined or went unanswered: it asks again only after the person's next message
31  // (absent in a run stored before the dialog existed)
32  isAskMuted?: boolean
33  isDone: boolean
34}
35
36declare module 'claude-code' {
37  interface PluginState {
38    'pipeline-gates': { run: Run | null }
39  }
40}
41