The deploy skill's T+60 invariant re-check as a timer that survives a restart: a countdown band with run-now and cancel, and a prompt when it is due

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 196 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Check } from '../types'
5import type { Schedule } from './checks'
6import { duePrompt, isSame, timeLeft, toSchedule, upsert, words } from './checks'
7
8const TICK_MS = 30_000
9const STORE_KEY = 'checks'
10const USAGE = 'usage: /t60 [list] | add <pr> "<deploy ts>" [high|medium|low] [minutes] | now <pr> | cancel <pr>'
11
12const checks = atom({ plugin: 'deploy-watch', key: 'checks' } as const, [])
13const now = atom({ plugin: 'deploy-watch', key: 'now' } as const, 0)
14
15// this module's pending timers by `<project>#<pr>`; a reload drops them and session.start re-arms
16const timers = new Map<string, Timer>()
17
18function timerKey(check: Pick<Check, 'pr' | 'project'>): string {
19 return `${check.project}#${check.pr}`
20}
21
22async function stored($: EngineInterface): Promise<Check[]> {
23 const value = await $.store.get(STORE_KEY)
24 return Array.isArray(value) ? (value as Check[]) : []
25}
26
27// the band shows this project's checks; other sessions' writes arrive on the next tick
28async function sync($: EngineInterface): Promise<void> {
29 const root = await $.session.root()
30 const list = (await stored($)).filter(check => check.project === root)
31 const at = await $.clock.now()
32 await update($, checks, () => list)
33 await update($, now, () => at)
34}
35
36async function save($: EngineInterface, list: Check[]): Promise<void> {
37 await $.store.set(STORE_KEY, list)
38 await sync($)
39}
40
41async function arm($: EngineInterface, check: Check): Promise<void> {
42 if (check.owner !== (await $.session.id())) return
43 timers.get(timerKey(check))?.cancel()
44 const wait = Math.max(0, check.dueAt - (await $.clock.now()))
45 timers.set(
46 timerKey(check),
47 $.clock.after(wait, () => void fire($, check.pr, false)),
48 )
49}
50
51async function schedule($: EngineInterface, wanted: Schedule): Promise<string> {
52 const check: Check = {
53 pr: wanted.pr,
54 title: wanted.title,
55 since: wanted.since,
56 tier: wanted.tier,
57 host: wanted.host,
58 dueAt: (await $.clock.now()) + wanted.minutes * 60_000,
59 project: await $.session.root(),
60 owner: await $.session.id(),
61 }
62 await save($, upsert(await stored($), check))
63 await arm($, check)
64 return `deploy-watch: T+${wanted.minutes} re-check for PR ${wanted.pr} scheduled, due in ${wanted.minutes}m (INV_SINCE "${wanted.since}", tier ${wanted.tier}, host ${wanted.host}). It survives a restart; the band above the prompt has run-now and cancel; /t60 cancel ${wanted.pr} cancels it.`
65}
66
67async function cancel($: EngineInterface, pr: number): Promise<string> {
68 const root = await $.session.root()
69 const list = await stored($)
70 const target = { pr, project: root }
71 if (!list.some(check => isSame(check, target))) return `deploy-watch: no pending re-check for PR ${pr}.`
72 timers.get(timerKey(target))?.cancel()
73 timers.delete(timerKey(target))
74 await save($, list.filter(check => !isSame(check, target)))
75 $.ui.toast(`T+60 for PR ${pr} cancelled`)
76 return `deploy-watch: the re-check for PR ${pr} is cancelled.`
77}
78
79// removed from the store first, so a second session or a second press finds nothing to fire
80async function fire($: EngineInterface, pr: number, isPress: boolean): Promise<string> {
81 const root = await $.session.root()
82 const list = await stored($)
83 const check = list.find(one => isSame(one, { pr, project: root }))
84 timers.get(timerKey({ pr, project: root }))?.cancel()
85 timers.delete(timerKey({ pr, project: root }))
86 if (check === undefined) return `deploy-watch: no pending re-check for PR ${pr}.`
87 await save($, list.filter(one => !isSame(one, check)))
88 // always framed as this plugin's, a press too: the text is built from stored data, not typed by the person
89 void $.prompt.submit({ text: duePrompt(check) })
90 $.ui.toast(`T+60 for PR ${pr}: the re-check prompt is queued${isPress ? '' : ' (timer)'}`)
91 return `deploy-watch: the re-check for PR ${pr} is queued as a prompt.`
92}
93
94async function listChecks($: EngineInterface): Promise<string> {
95 const pending = await read($, checks)
96 if (pending.length === 0) return 'deploy-watch: no pending re-checks in this project.'
97 const at = await $.clock.now()
98 return pending
99 .map(check => `PR ${check.pr} ${check.tier} ${timeLeft(check, at).text} (INV_SINCE "${check.since}", host ${check.host})`)
100 .join('\n')
101}
102
103async function command($: EngineInterface, args: string): Promise<string> {
104 const [verb = 'list', ...rest] = words(args)
105 if (verb === 'list') return listChecks($)
106 if (verb === 'cancel' || verb === 'now') {
107 const pr = Number(rest[0])
108 if (!Number.isInteger(pr) || pr <= 0) return USAGE
109 return verb === 'cancel' ? cancel($, pr) : fire($, pr, true)
110 }
111 if (verb === 'add') {
112 const wanted = toSchedule({ pr: rest[0], deploy_ts: rest[1], tier: rest[2], delay_minutes: rest[3] })
113 return typeof wanted === 'string' ? `deploy-watch: ${wanted}\n${USAGE}` : schedule($, wanted)
114 }
115 return USAGE
116}
117
118export const register: Register = on => {
119 on('session.start', async ($, e, next) => {
120 const started = await next(e)
121 await $.tool.register({
122 name: 'schedule_check',
123 description:
124 "Schedules the deploy skill's T+60 invariant re-check (Step 9) instead of a background `sleep 3600`. It is kept outside the session, so it survives a restart; at the due time it queues a prompt in this session to run Step 9, and the band above the prompt shows a countdown with run-now and cancel. Scheduling the same PR again replaces it.",
125 inputSchema: {
126 type: 'object',
127 properties: {
128 pr: { type: 'integer', description: 'The deployed PR number' },
129 deploy_ts: { type: 'string', description: 'The deploy timestamp passed to the check as INV_SINCE, YYYY-MM-DD HH:MM:SS' },
130 title: { type: 'string', description: "The PR title, for the band and the chat message" },
131 tier: { type: 'string', enum: ['high', 'medium', 'low'], description: 'Blast radius; default high' },
132 host: { type: 'string', description: 'Deploy host alias; default prod' },
133 delay_minutes: { type: 'integer', description: 'Minutes until the re-check; default 60' },
134 },
135 required: ['pr', 'deploy_ts'],
136 },
137 })
138 await $.tool.register({
139 name: 'cancel_check',
140 description: 'Cancels a pending deploy-watch re-check for a PR.',
141 inputSchema: { type: 'object', properties: { pr: { type: 'integer' } }, required: ['pr'] },
142 })
143 await $.command.register({
144 name: 't60',
145 description: 'Pending T+60 deploy re-checks: list, add, now, cancel',
146 argumentHint: '[list | add <pr> "<deploy ts>" [tier] [minutes] | now <pr> | cancel <pr>]',
147 })
148 await sync($)
149 for (const check of await read($, checks)) await arm($, check)
150 $.clock.every(TICK_MS, () => void sync($))
151 return started
152 })
153
154 on('tool.call', { tool: 'mcp__deploy-watch__schedule_check' }, async ($, e) => {
155 const wanted = toSchedule(e as unknown as Record<string, unknown>)
156 return { result: typeof wanted === 'string' ? `refused: ${wanted}` : await schedule($, wanted) }
157 })
158
159 on('tool.call', { tool: 'mcp__deploy-watch__cancel_check' }, async ($, e) => ({
160 result: await cancel($, Number((e as unknown as Record<string, unknown>).pr)),
161 }))
162
163 on('command.run', { command: 't60' }, async ($, e) => ({ text: await command($, e.args) }))
164
165 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
166 const below = await next(e)
167 const pending = await read($, checks)
168 if (pending.length === 0 || e.props.hasSurvey) return below
169 const at = await read($, now)
170 const sid = await $.session.id()
171 const { Box, Button, Text } = $.ui.resolve(e)
172
173 return (
174 <Box flexDirection="column">
175 {/* below the plugins beneath, as every band plugin: their tips stay on top */}
176 {below}
177 {pending.map(check => {
178 const left = timeLeft(check, at)
179 return (
180 <Box key={`t60-${check.pr}`} flexDirection="row" columnGap={1} alignItems="center">
181 <Text color={left.isDue ? 'red' : 'cyan'}>T+60</Text>
182 <Text bold>{`PR ${check.pr}`}</Text>
183 {check.title !== '' && <Text dimColor wrap="truncate-end">{check.title}</Text>}
184 <Text dimColor>{`${check.tier} · ${check.host}`}</Text>
185 <Text color={left.isDue ? 'red' : undefined}>{left.text}</Text>
186 {check.owner !== sid && <Text dimColor>(scheduled in another session)</Text>}
187 <Button key={`t60-now-${check.pr}`} label="run now" onPress={() => fire($, check.pr, true)} />
188 <Button key={`t60-cancel-${check.pr}`} label="cancel" onPress={() => cancel($, check.pr)} />
189 </Box>
190 )
191 })}
192 </Box>
193 )
194 })
195}
196hooks/checks.ts 56 lines1import type { Check } from '../types'
2
3export type Schedule = { pr: number; since: string; title: string; tier: string; host: string; minutes: number }
4
5// Every field that reaches the due prompt is checked to the end: the prompt is read by the model,
6// so nothing free-form (the PR title included) goes into it.
7const TIERS = ['high', 'medium', 'low']
8const SINCE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/
9const HOST = /^[A-Za-z0-9._-]{1,64}$/
10
11// the tool's and the command's input, checked; a string is the refusal
12export function toSchedule(input: Record<string, unknown>): Schedule | string {
13 const pr = Number(input.pr)
14 if (!Number.isInteger(pr) || pr <= 0) return 'pr must be a positive integer'
15 const since = typeof input.deploy_ts === 'string' ? input.deploy_ts.trim() : ''
16 if (!SINCE.test(since)) return 'deploy_ts must be the deploy timestamp, YYYY-MM-DD HH:MM[:SS] (INV_SINCE of Step 6.5)'
17 const tier = typeof input.tier === 'string' && input.tier !== '' ? input.tier : 'high'
18 if (!TIERS.includes(tier)) return 'tier must be high, medium or low'
19 const minutes = input.delay_minutes === undefined ? 60 : Number(input.delay_minutes)
20 if (!Number.isFinite(minutes) || minutes < 1 || minutes > 1440) return 'delay_minutes must be between 1 and 1440'
21 const host = typeof input.host === 'string' && input.host !== '' ? input.host : 'prod'
22 if (!HOST.test(host)) return 'host must be an ssh alias: letters, digits, dot, dash, underscore'
23 // drawn in the band as text, never put in the prompt
24 const title = typeof input.title === 'string' ? input.title.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) : ''
25 return { pr, since, tier, host, title, minutes }
26}
27
28export function isSame(a: Pick<Check, 'pr' | 'project'>, b: Pick<Check, 'pr' | 'project'>): boolean {
29 return a.pr === b.pr && a.project === b.project
30}
31
32// one pending check per PR and project: scheduling again replaces it
33export function upsert(checks: readonly Check[], check: Check): Check[] {
34 return [...checks.filter(one => !isSame(one, check)), check].sort((a, b) => a.dueAt - b.dueAt)
35}
36
37export function timeLeft(check: Check, now: number): { text: string; isDue: boolean } {
38 const minutes = Math.round((check.dueAt - now) / 60_000)
39 if (minutes > 0) return { text: `in ${minutes}m`, isDue: false }
40 return { text: minutes === 0 ? 'due now' : `overdue ${-minutes}m`, isDue: true }
41}
42
43export function duePrompt(check: Check): string {
44 return [
45 `deploy-watch: the T+60 re-check is due for PR ${check.pr}`,
46 `(deployed ${check.since}, blast radius ${check.tier}, host ${check.host}).`,
47 'Run Step 9 of the deploy skill now: the inventory-analysis refresh, then the invariant check with',
48 `INV_SINCE="${check.since}" and INV_TIER=${check.tier} against ${check.host}, and post the result to the chat as Step 9 says.`,
49 ].join(' ')
50}
51
52// `/t60 add 1278 "2026-10-06 14:53:46" high 60`: words, double quotes group
53export function words(args: string): string[] {
54 return [...args.matchAll(/"([^"]*)"|(\S+)/g)].map(match => match[1] ?? match[2] ?? '')
55}
56types/index.d.ts 21 lines1// One scheduled re-check, kept in $.store so it outlives the session that scheduled it.
2export type Check = {
3 pr: number
4 title: string
5 // the deploy's INV_SINCE, passed back verbatim so the window covers deploy -> now
6 since: string
7 tier: string
8 host: string
9 dueAt: number
10 // the session root it was scheduled from: other projects never see it
11 project: string
12 // only the scheduling session fires it on its own; any other session shows it with run-now
13 owner: string
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'deploy-watch': { checks: Check[]; now: number }
19 }
20}
21