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…

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.
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
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.repos.yaml for Odoo, addons.yaml for OCA and custom addon repos, pinned by branch or commit, with a symlink farm builder..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.requirements.txt, locked with uv and built with uv2nix, so the flake and your shell agree.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.queue_job.The full inventory, including every skill and agent, is in docs/features.md.
Copier asks about twenty questions; most have a sensible default derived from your Odoo version. These are the ones worth a thought:
| Question | Default | Why you might change it |
|---|---|---|
odoo_version | 19.0 | everything else follows from it |
custom_repo_pattern / custom_repo_name | empty | your own addons repo; unlocks the development pipeline |
use_claude_code | true | the whole .claude/ layer |
use_kb | false | memory in kb instead of markdown files |
service_suffix | empty | set it to run two generated projects on one machine |
project_dir_var | ODOO<major>_PROJECT_DIR | same reason, for the project-root variable |
status_mcp / tickets_mcp | none | wire status posting and ticket tracking to Teams and Odoo |
backup_s3_bucket | empty | restore production dumps locally |
prod_ssh_host / test_ssh_hosts | empty | the deploy and prod-ops runbooks |
Every question and its default: docs/questions.md.
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.
nix flake check shellchecks every generated script; python3 tests/test_nudge_find_code.py runs the hook regression corpusMIT
hooks/register.tsx 585 lines1import { 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}
585hooks/gates.ts 265 lines1import 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}
265types/index.d.ts 41 lines1export 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