Progress of the /unlazy gates bound to this session, one quiet row above the prompt: cells per gate or a pie, met of total, and its state: waiting for a…

English | Українська
For sessions that work under /unlazy, the skill that writes acceptance gates before a task and keeps the session going until every gate is met. Its Stop hook speaks only at the end. This mod shows the progress along the way: one quiet row above the prompt, and the whole list on /gates.
A row appears only when an /unlazy scope is bound to this session (gate-check --bind). Without a binding the mod draws nothing.
Gates ■■■■■■■■■□□□ 9 of 12 │ ⏺ Waiting for a decision │ Details
Gates ◕ 22 of 23 95% │ Details
Gates alone; when several scopes are bound to the session, each row names its own, Gates alerts-mvp.× when abandoned, a red cell when a gate that was met broke on pause.mjs resume. From 15 gates the cells give way to one pie that fills by the share met, ◌ ◔ ◕ ⏺, and the dim percentage beside the count, rounded down so an unfinished ledger never reads 100%. The marks are the ones a Mac's desktop draws at one size; ● and ◑ come from larger fonts there. Once 75 % of the gates are met, the filled cells or the pie turn green.≈ before it means the unlazy checker could not be asked and the check boxes in the file decided.| Dot | Words | When |
|---|---|---|
yellow ⏺ | Waiting for a decision | BLOCKED.md is there: a large repair stopped the work |
red ⏺ | Regressed G3, G5 | a gate that was met broke on pause.mjs resume |
dim ⏺ | Paused (dim) | this session paused after the human typed /handoff |
| none | 2 abandoned (dim) | the ledger has ABANDON lines |
green ⏺ | All met (dim) | every gate is met |
When the row is too narrow, the dim counts give way first; the dotted states stay. Which gate is open next is not repeated in the row, the count already says how many are left: it is in the pane.
Details, a dim button at the end of the row (g in the terminal), opens a pane in the side panel and closes it again. The pane tells what each gate is about, not how it is checked. On top, the ledger's heading in bold, the count, and the states with the row's dots; a stop also gives the first sentence of the option BLOCKED.md recommends, so the pane says what it waits for, and a regression names the gates that broke. Then the gates in groups, open first, abandoned, then met, one line each: the mark, the id and a short name, the part of the title before its first spaced dash ( — ) or colon, outside quotes and brackets. /unlazy writes titles that way: G3: PR merged — the PR is MERGED and its commits are on main. A press opens the rest of the sentence, without code marks, and where the gate stands in words: met by an automatic check, met with a person's note, not checked yet, ticked but not run, the check changed after it was met, broke on resume, waiting for a person, abandoned and why. CHECK, EXPECT, CWD and the raw evidence sit one press further, under How it is checked, folded by default. About this scope, folded at the bottom, has the Scope line, where the counts come from and the ledger's path. Expand all and Collapse all do the whole list.
/gates lists every gate with ✓, ○ or × as text, why an open one is open, and where the ledger is. If another plugin already has /gates, the command is /gm-gates.
The mod reads .unlazy/<scope>/ under the session's folder and never writes there. The counts come from the unlazy checker in its read-only mode, gate-check.mjs --status, which never runs a CHECK: line: the same state model the Stop hook uses, so the row and the hook agree, stale evidence included. The checker runs only when a file of the scope changed; the scope is read again after each Bash call, at the end of each turn, and every 15 seconds.
The mod finds the checker next to the Stop hook in ~/.claude/settings.json, else in ~/.claude/skills/unlazy, else in the project's .claude/skills/unlazy. Set the checker option to point elsewhere. Without a checker, or when it does not answer, the ledger's check boxes decide, and /gates says so: a box ticked over stale evidence then counts as met.
The mod joins handoff-relay, cache-meter and next-steps in one band, below them: Handoff, cache, What next?, gates. It is not in the primaries pack, since it is useful only with /unlazy.
The mod publishes what it shows in $.state as gate-meter.view (see types/index.d.ts): the bound scopes with their counts, cells, states and the first open gate.
| Option | Default | What it does |
|---|---|---|
language | auto | auto follows Claude's response language from /config; en or uk set it |
checker | empty | Path to unlazy's scripts/gate-check.mjs, when the mod does not find it by itself |
claude plugin validate .
claude plugin test .hooks/register.mjs 678 lines1// SPDX-License-Identifier: MIT
2// Gate Meter: the progress of the /unlazy gates bound to this session, one quiet row in
3// the band above the prompt, and the whole list on /gates.
4//
5// Why: /unlazy keeps a session working until every acceptance gate of its scope is met,
6// and the Stop hook says so only at the end. The row shows along the way how many gates
7// are met, which one is next, and whether the scope is paused, stopped or regressed.
8//
9// What it reads, never writes: `.unlazy/<scope>/` under the session's folder. A scope is
10// this session's when its `session` file holds the session id, as `gate-check --bind`
11// writes it. The counts come from the unlazy checker in its read-only mode
12// (`gate-check.mjs --status`, which never runs a CHECK), so the row agrees with the Stop
13// hook; when the checker cannot be found or run, the ledger's check boxes stand in.
14
15import { atom, read, update } from 'claude-code'
16import { joinBand } from './band.mjs'
17import { automaticEvidence, bestOption, clip, parseChecker, parseHeader, parseLedgers, splitTitle, summarize } from './ledger.mjs'
18import { resolveLanguage, isLanguageKey } from './i18n.mjs'
19import en from './locales/en.mjs'
20import uk from './locales/uk.mjs'
21
22const LOCALES = { en, uk }
23const DIR = '.unlazy'
24// Scopes are re-read this often while nothing else asks for it; a Bash call or a finished turn asks at once
25const TICK_EVERY = 15000
26// From this many gates the row draws one pie that fills by the share met, and the percentage,
27// instead of one cell per gate: a long ledger's cells would crowd the row
28const BAR_FROM = 15
29// The marks are picked by the font that draws them. On a Mac's desktop ● and ◑ come from the system
30// and Hiragino fonts, a third larger than the ◔ ◕ that fall back to Menlo; ⏺ (STIX, kept as text by
31// U+FE0E) is the filled circle of their size, ◌ the empty one. A state carries its colour in that dot
32const DOT = '⏺\uFE0E'
33// The pie: none met, under half, half or more, all
34const PIE = ['◌', '◔', '◕', DOT]
35// From this share of met gates the filled cells turn green: the end is near
36const GREEN_AT = 0.75
37// Bound scopes drawn at most; the Stop hook enforces none when there are several
38const MAX_ROWS = 3
39// A gate name longer than this may not fit its line in the pane; opened, the gate repeats it whole
40const LONG_NAME = 40
41// The details pane in the side panel, and which gates are open in it
42const PANE = 'gate-meter'
43
44let L = en
45let language = 'auto'
46let checkerOption = ''
47let isLanguagePicked = false
48
49async function pickLanguage($) {
50 isLanguagePicked = true
51 let rows = []
52 if (language !== 'en' && language !== 'uk') {
53 try {
54 rows = await $.config.list()
55 } catch {
56 rows = [] // no /config here (a test, a -p run): English
57 }
58 }
59 L = LOCALES[resolveLanguage(language, rows)]
60}
61
62// What the band draws and other mods may read. Rewritten only when it changes.
63const VIEW = atom({ plugin: 'gate-meter', key: 'view' }, { scopes: [] })
64// Gates the person opened in the pane, as `<scope>/<qualified id>`; kept by the host across a reload
65const EXPANDED = atom({ plugin: 'gate-meter', key: 'expanded' }, [])
66let published = ''
67// Per scope: the fingerprint of its files at the last read, and what that read gave
68const seen = new Map()
69let checker = null // { node, script } once found; false when looked for and absent
70let commandName = 'gates'
71let refreshing = null
72
73function join(...parts) {
74 return parts.join('/').replace(/\/+/g, '/')
75}
76
77async function tryRead($, path) {
78 try {
79 return String(await $.fs.read(path))
80 } catch {
81 return null
82 }
83}
84
85async function tryList($, path) {
86 try {
87 return await $.fs.list(path)
88 } catch {
89 return []
90 }
91}
92
93async function mtime($, path) {
94 try {
95 return (await $.fs.stat(path)).mtimeMs
96 } catch {
97 return 0
98 }
99}
100
101// The session's folders: where the Stop hook looks (the working directory) and the project root
102async function roots($) {
103 const out = []
104 for (const get of [() => $.session.cwd(), () => $.session.root()]) {
105 try {
106 const r = String(await get())
107 if (r && !out.includes(r)) out.push(r)
108 } catch {
109 // not answered here
110 }
111 }
112 return out
113}
114
115// The ledger files of a scope as the checker discovers them: GATES.md, then gates/**/*.md
116async function ledgerFiles($, scopeDir) {
117 const files = []
118 const top = await tryRead($, join(scopeDir, 'GATES.md'))
119 if (top !== null) files.push({ name: 'GATES.md', path: join(scopeDir, 'GATES.md'), text: top })
120 const walk = async (dir, depth) => {
121 if (depth > 4) return
122 const entries = [...(await tryList($, dir))].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
123 for (const entry of entries) {
124 const path = join(dir, entry.name)
125 if (entry.kind === 'dir') await walk(path, depth + 1)
126 else if (entry.kind === 'file' && entry.name.endsWith('.md')) {
127 const text = await tryRead($, path)
128 if (text !== null) files.push({ name: entry.name, path, text })
129 }
130 }
131 }
132 await walk(join(scopeDir, 'gates'), 0)
133 return files
134}
135
136// The unlazy checker: the option, else the script next to the Stop hook in settings, else the usual places
137async function findChecker($, root) {
138 if (checker !== null) return checker || null
139 let home = ''
140 try {
141 home = String((await $.env.get('HOME')) || '')
142 } catch {
143 home = ''
144 }
145 const expand = (p) => (home && p.startsWith('~/') ? home + p.slice(1) : p)
146 const candidates = []
147 if (checkerOption) candidates.push({ node: 'node', script: expand(checkerOption) })
148 if (home) {
149 const settings = (await tryRead($, join(home, '.claude/settings.json'))) || ''
150 const m = settings.match(/(?:"|\s)([^"\s]*node)\s+([^"\s]+)\/scripts\/stop-hook\.mjs/)
151 if (m) candidates.push({ node: expand(m[1]), script: expand(m[2]) + '/scripts/gate-check.mjs' })
152 candidates.push({ node: 'node', script: join(home, '.claude/skills/unlazy/scripts/gate-check.mjs') })
153 }
154 candidates.push({ node: 'node', script: join(root, '.claude/skills/unlazy/scripts/gate-check.mjs') })
155 for (const c of candidates) {
156 try {
157 if (await $.fs.exists(c.script)) {
158 checker = c
159 return c
160 }
161 } catch {
162 // try the next one
163 }
164 }
165 checker = false
166 return null
167}
168
169async function runChecker($, root, scope) {
170 const c = await findChecker($, root)
171 if (!c) return null
172 for (const node of c.node === 'node' ? ['node'] : [c.node, 'node']) {
173 try {
174 const r = await $.process.run([node, c.script, '--status', '--scope', scope, '--root', root], { cwd: root, timeoutMs: 10000 })
175 const parsed = parseChecker(String(r.stdout || '') + '\n' + String(r.stderr || ''))
176 if (parsed) return parsed
177 } catch {
178 // this node could not start: try plain `node` on PATH
179 }
180 }
181 return null
182}
183
184async function scopeView($, root, scope) {
185 const dir = join(root, DIR, scope)
186 const markers = ['GATES.md', 'pause.json', 'BLOCKED.md', 'regression.json', 'session']
187 const stamps = await Promise.all(markers.map((m) => mtime($, join(dir, m))))
188 const gatesDir = await tryList($, join(dir, 'gates'))
189 // The session id is in it too: whose pause it is depends on it
190 const sessionId = await currentSession($)
191 const fingerprint = sessionId + '|' + stamps.join(',') + '|' + gatesDir.map((e) => e.name + ':' + e.mtimeMs).join(',')
192 const key = root + '\n' + scope
193 const before = seen.get(key)
194 if (before && before.fingerprint === fingerprint) return before.view
195 const files = await ledgerFiles($, dir)
196 if (!files.length) return null
197 const gates = parseLedgers(files)
198 const report = await runChecker($, root, scope)
199 let regressed = []
200 const regression = await tryRead($, join(dir, 'regression.json'))
201 if (regression) {
202 try {
203 const ids = JSON.parse(regression).ids
204 if (Array.isArray(ids)) regressed = ids.map(String)
205 } catch {
206 regressed = []
207 }
208 }
209 let paused = false
210 const pause = await tryRead($, join(dir, 'pause.json'))
211 if (pause) {
212 try {
213 paused = String(JSON.parse(pause).session) === sessionId
214 } catch {
215 paused = false
216 }
217 }
218 // What the stop waits for, in BLOCKED.md's own words
219 const blockedText = stamps[2] > 0 ? await tryRead($, join(dir, 'BLOCKED.md')) : null
220 const sum = summarize(gates, report, regressed)
221 const header = parseHeader(files[0].text)
222 const view = {
223 scope,
224 heading: header.heading,
225 scopeText: header.scope,
226 root,
227 path: join(dir, files[0].name),
228 multiFile: files.length > 1,
229 source: sum.source,
230 total: sum.total,
231 met: sum.met,
232 unmet: sum.unmet,
233 abandoned: sum.abandoned,
234 cells: sum.cells.map((c) => ({
235 qid: c.qid,
236 id: c.id,
237 title: c.title,
238 state: c.state,
239 check: c.check,
240 expect: c.expect,
241 cwd: c.cwd,
242 evidence: c.evidence,
243 ...(c.reason ? { reason: c.reason } : {}),
244 ...(c.regressed ? { regressed: true } : {}),
245 ...(c.abandonReason !== undefined ? { abandonReason: c.abandonReason } : {}),
246 })),
247 next: sum.next ? { qid: sum.next.qid, id: sum.next.id, title: sum.next.title, reason: sum.next.reason || 'unchecked' } : null,
248 regressed: sum.regressed,
249 blockers: sum.blockers,
250 paused,
251 blocked: stamps[2] > 0,
252 blockedNext: blockedText ? bestOption(blockedText) : '',
253 }
254 seen.set(key, { fingerprint, view })
255 return view
256}
257
258async function currentSession($) {
259 try {
260 return String(await $.session.id())
261 } catch {
262 return ''
263 }
264}
265
266// The scopes bound to this session, in every folder of the session
267async function boundScopes($) {
268 const id = (await currentSession($)).trim()
269 if (!id) return []
270 const out = []
271 for (const root of await roots($)) {
272 for (const entry of await tryList($, join(root, DIR))) {
273 if (entry.kind !== 'dir' || entry.name === 'locks') continue
274 const text = await tryRead($, join(root, DIR, entry.name, 'session'))
275 if (text !== null && text.trim() === id) out.push({ root, scope: entry.name })
276 }
277 }
278 return out
279}
280
281async function refresh($) {
282 // One refresh at a time; a call during one waits for it instead of starting another
283 if (refreshing) return refreshing
284 refreshing = (async () => {
285 const bound = await boundScopes($)
286 const scopes = []
287 for (const b of bound) {
288 const v = await scopeView($, b.root, b.scope)
289 if (v) scopes.push(v)
290 }
291 const value = { scopes }
292 const json = JSON.stringify(value)
293 if (json !== published) {
294 published = json
295 await update($, VIEW, () => value)
296 $.ui.invalidate('ui.render')
297 }
298 })().finally(() => {
299 refreshing = null
300 })
301 return refreshing
302}
303
304function soon($) {
305 refresh($).catch(() => {})
306}
307
308// The gate as the row and /gates name it: its own id, or the qualified one when the scope has several ledgers
309function label(view, gate) {
310 return view.multiFile ? gate.qid : gate.id
311}
312
313// One cell per gate in ledger order, or a pie for a long ledger. Each is a mark and its style:
314// a met gate is filled in the text's own colour, green once the end is near; an open one stays dim
315function cellsOf(view) {
316 const green = view.total > 0 && view.met / view.total >= GREEN_AT
317 const filled = green ? { color: 'green' } : {}
318 if (view.cells.length && view.cells.length < BAR_FROM) {
319 return view.cells.map((c) =>
320 c.state === 'met' ? { mark: '■', style: filled }
321 : c.state === 'abandoned' ? { mark: '×', style: { color: 'yellow' } }
322 : c.regressed ? { mark: '□', style: { color: 'red' } }
323 : { mark: '□', style: { dimColor: true } })
324 }
325 return [{ mark: pieOf(view), style: view.met ? filled : { dimColor: true } }]
326}
327
328// The pie for the share met: empty only with none met and full only with all
329function pieOf(view) {
330 if (!view.met) return PIE[0]
331 if (view.met >= view.total) return PIE[3]
332 return view.met / view.total < 0.5 ? PIE[1] : PIE[2]
333}
334
335// The share met in whole percent, rounded down so that an unfinished ledger never reads 100
336function percentOf(view) {
337 return view.total ? Math.floor((view.met / view.total) * 100) : 0
338}
339
340// The scope's states, most pressing first. Colour is carried by the dot alone; the words stay plain or dim.
341// `optional` ones give way first when the row is too narrow
342function statesOf(view) {
343 const out = []
344 if (view.blocked) out.push({ key: 'blocked', dot: DOT, dotStyle: { color: 'yellow' }, text: L.blocked, line: L.stateLine.blocked })
345 if (view.regressed.length) {
346 const ids = view.regressed.map((qid) => shortId(view, qid))
347 out.push({ key: 'regressed', dot: DOT, dotStyle: { color: 'red' }, ids, text: L.regressed(ids.join(', ')), line: L.regressedLine(ids.join(', ')) })
348 }
349 if (view.paused && !view.blocked) out.push({ key: 'paused', dot: DOT, dotStyle: { dimColor: true }, text: L.paused, line: L.stateLine.paused, dim: true })
350 if (view.abandoned) out.push({ key: 'abandoned', text: L.abandoned(view.abandoned), dim: true, optional: true })
351 if (view.blockers.length) out.push({ key: 'waves', text: L.waves(view.blockers.length), line: L.wavesLine(view.blockers.join('; ')), dim: true, optional: true })
352 if (view.unmet === 0 && view.abandoned === 0 && !view.blockers.length) out.push({ key: 'done', dot: DOT, dotStyle: { color: 'green' }, text: L.allMet, line: L.allMetLine(view.total), dim: true })
353 return out
354}
355
356// What a state takes from the row: the dot and its space, then the words
357const stateWidth = (s) => (s.dot ? 2 : 0) + s.text.length
358
359// A gate as unlazy names it in regression.json, shortened to its own id when the scope has one ledger
360function shortId(view, qid) {
361 return view.multiFile ? qid : String(qid).replace(/^[^:]*:/, '')
362}
363
364// The row's parts as plain strings with their style, so the width can be measured before drawing.
365// The scope's name stands by the label only when several scopes share the band
366function partsOf(view, columns, several) {
367 const head = several ? `${L.gates} ${view.scope}` : L.gates
368 const cells = cellsOf(view)
369 // Without the checker the count comes from the check boxes and may miss stale evidence: say it is approximate
370 const count = (view.source === 'checker' ? '' : '≈') + L.count(view.met, view.total)
371 // Beside the pie, the percentage says what the quarters cannot
372 const percent = view.cells.length >= BAR_FROM ? L.percent(percentOf(view)) : ''
373 const states = statesOf(view)
374 const progress = head.length + 1 + cells.length + 1 + count.length + (percent ? percent.length + 1 : 0)
375 // The parts and a bar with a space each side between them
376 const width = () => progress + states.reduce((a, s) => a + stateWidth(s) + 3, 0)
377 for (let i = states.length - 1; i >= 0 && width() > columns; i--) {
378 if (states[i].optional) states.splice(i, 1)
379 }
380 // Too many regressed gates to name: the first ones that fit, then how many more
381 const regressed = states.find((s) => s.key === 'regressed')
382 for (let shown = regressed ? regressed.ids.length - 1 : -1; shown >= 1 && width() > columns; shown--) {
383 regressed.text = L.regressed(regressed.ids.slice(0, shown).join(', ') + ` +${regressed.ids.length - shown}`)
384 }
385 return { head, cells, count, percent, states }
386}
387
388// The pane's title: the scope when there is one, else the word
389function paneTitle(scopes) {
390 return scopes.length === 1 ? L.paneTitle(scopes[0].scope) : L.gates
391}
392
393// Opens the details pane, or closes it when it is already up
394async function togglePane($) {
395 const isUp = (await $.ui.panes()).some((pane) => pane.id === PANE)
396 if (isUp) return $.ui.close({ id: PANE })
397 const { scopes } = await read($, VIEW)
398 await $.ui.open({ id: PANE, title: paneTitle(scopes) })
399}
400
401// The quiet button at the end of the row; the letter only on the terminal, as cache-meter's
402function detailsButton($, e) {
403 const { Button } = $.ui.resolve(e)
404 const key = e.surface === 'terminal' ? { hotkey: 'g' } : {}
405 return Button({ key: 'details', label: L.details, dimColor: true, ...key, onPress: () => togglePane($).catch(() => {}) })
406}
407
408// What the button takes from the row's width: its label, the bar and its spaces, the terminal's hotkey mark
409function detailsWidth(e) {
410 return L.details.length + 3 + (e.surface === 'terminal' ? 3 : 0) + 4
411}
412
413function rowOf($, e, view, withButton, several) {
414 const { Box, Text } = $.ui.resolve(e)
415 const columns = Math.max(40, Number(e.props && e.props.bodyColumns) || 100)
416 const p = partsOf(view, columns - (withButton ? detailsWidth(e) : 0), several)
417 const bar = (i) => Text({ dimColor: true, children: ['│'] })
418 const cells = Text({ children: p.cells.map((c) => Text({ ...c.style, children: [c.mark] })) })
419 const first = Box({ key: `progress:${view.scope}`, flexDirection: 'row', columnGap: 1, children: [Text({ dimColor: true, children: [p.head] }), cells, Text({ children: [p.count] }), ...(p.percent ? [Text({ dimColor: true, children: [p.percent] })] : [])] })
420 const states = p.states.map((s) => {
421 const words = Text({ ...(s.dim ? { dimColor: true } : {}), children: [s.text] })
422 return s.dot
423 ? Box({ key: `state:${s.key}`, flexDirection: 'row', columnGap: 1, children: [Text({ ...s.dotStyle, children: [s.dot] }), words] })
424 : Box({ key: `state:${s.key}`, flexDirection: 'row', children: [words] })
425 })
426 const parts = [first, ...states]
427 if (withButton) parts.push(detailsButton($, e))
428 // Each part is set off from the next by the same dim bar as the neighbours' rows
429 const row = parts.flatMap((part, i) => (i ? [bar(i), part] : [part]))
430 return Box({ key: `gate-meter:${view.scope}`, flexDirection: 'row', columnGap: 1, children: row })
431}
432
433function listText(view) {
434 const lines = [L.head(view.scope, view.met, view.total), view.source === 'checker' ? L.sourceChecker : L.sourceBoxes, '']
435 for (const c of view.cells) {
436 const mark = c.state === 'met' ? '✓' : c.state === 'abandoned' ? '×' : '○'
437 const note = c.state === 'abandoned' ? L.abandonedBecause(c.abandonReason || '') : c.state === 'unmet' ? [c.regressed ? L.regressedShort : '', L.reason[c.reason] || ''].filter(Boolean).join(', ') : ''
438 lines.push(`${mark} ${label(view, c)} ${clip(c.title, 140)}${note ? ` (${note})` : ''}`)
439 }
440 const after = statesOf(view).filter((s) => s.line).map((s) => s.line)
441 after.push(L.where(view.path))
442 return [...lines, '', ...after].join('\n')
443}
444
445// A field of how a gate is checked: its dim name on a line of its own, the value below it
446function fieldOf($, e, key, name, value) {
447 const { Box, Text } = $.ui.resolve(e)
448 const body = typeof value === 'string' ? Text({ wrap: 'wrap', children: [value] }) : value
449 return Box({ key, flexDirection: 'column', children: [Text({ dimColor: true, children: [name] }), body] })
450}
451
452// Where a gate stands, in words a person reads: how it was closed, or why it is still open
453function statusOf(c) {
454 if (c.state === 'abandoned') return L.status.abandoned(c.abandonReason || '—')
455 const ev = String(c.evidence || '').trim()
456 const auto = automaticEvidence(ev)
457 if (c.state === 'met') return auto ? L.status.metAuto : ev && ev.toLowerCase() !== 'pending' ? L.status.metBy(ev) : L.status.metAuto
458 if (c.regressed) return L.status.regressed
459 if (c.reason === 'stale') return L.status.stale
460 if (c.reason === 'pending') return L.status.pending
461 return c.check ? L.status.unchecked : L.status.waitsHuman
462}
463
464// One gate opened in the pane: what the outcome is and where it stands; how it is checked one press further
465function detailsOf($, e, view, c, open, toggle) {
466 const { Box, Text, Button } = $.ui.resolve(e)
467 const { name, rest } = splitTitle(c.title)
468 const id = `${view.scope}/${c.qid}`
469 const parts = []
470 // A title without a short lead is cut in its line: opened, it reads whole
471 const body = rest || (name.length > LONG_NAME ? name : '')
472 if (body) parts.push(Text({ wrap: 'wrap', children: [body] }))
473 parts.push(Text({ wrap: 'wrap', dimColor: true, children: [statusOf(c)] }))
474 if (c.check) {
475 const howOpen = open.has(`${id}#how`)
476 parts.push(Button({ key: `how:${id}`, label: `${howOpen ? '▾' : '▸'} ${L.howChecked}`, plain: true, dimColor: true, onPress: () => toggle(`${id}#how`) }))
477 if (howOpen) {
478 const { Code } = $.ui.resolve(e)
479 const auto = automaticEvidence(c.evidence)
480 const ev = !c.evidence || c.evidence.toLowerCase() === 'pending' ? L.evidenceNone : auto ? L.evidenceAuto(auto.exit, auto.matched) : c.evidence
481 const fields = [fieldOf($, e, 'check', 'CHECK', Code({ key: 'value', source: c.check, language: 'sh' })), fieldOf($, e, 'expect', 'EXPECT', c.expect || '—')]
482 if (c.cwd) fields.push(fieldOf($, e, 'cwd', 'CWD', c.cwd))
483 fields.push(fieldOf($, e, 'evidence', L.evidenceName, ev))
484 parts.push(Box({ key: `how-body:${c.qid}`, flexDirection: 'column', rowGap: 1, paddingLeft: 2, children: fields }))
485 }
486 }
487 // Under the gate's name, and a line after it before the next gate
488 return Box({ key: `details:${c.qid}`, flexDirection: 'column', rowGap: 1, paddingLeft: 4, marginBottom: 1, children: parts })
489}
490
491// The pane: each bound scope's heading, count and states; its gates in groups, open first; about the scope last
492function paneOf($, e, scopes, expanded) {
493 const { Box, Text, Button } = $.ui.resolve(e)
494 const columns = Math.max(30, Number(e.props && e.props.bodyColumns) || 60)
495 if (!scopes.length) return Box({ key: 'gate-meter-pane', flexDirection: 'column', children: [Text({ dimColor: true, children: [L.noBinding] })] })
496 const open = new Set(expanded)
497 const toggle = (id) => update($, EXPANDED, (list) => {
498 const next = new Set(list)
499 next.has(id) ? next.delete(id) : next.add(id)
500 return [...next]
501 })
502 const blocks = scopes.map((view) => {
503 // The heading without the scope's name, which the pane's title already says
504 const heading = (view.heading || '').replace(new RegExp('^' + view.scope.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*[—–-]\\s*'), '')
505 const header = []
506 if (heading) header.push(Text({ bold: true, wrap: 'wrap', children: [heading.charAt(0).toLocaleUpperCase() + heading.slice(1)] }))
507 header.push(Text({ children: [L.metOf(view.met, view.total)] }))
508 const states = []
509 // The abandoned have a group of their own below
510 for (const s of statesOf(view).filter((x) => x.key !== 'abandoned')) {
511 const line = s.key === 'regressed'
512 ? L.regressedLine(view.regressed.map((qid) => {
513 const gate = view.cells.find((c) => c.qid === qid)
514 return gate ? `${shortId(view, qid)} ${splitTitle(gate.title).name}` : shortId(view, qid)
515 }).join(', '))
516 : s.key === 'blocked' && view.blockedNext ? L.blocked : s.line || s.text
517 const words = [Text({ wrap: 'wrap', ...(s.dim ? { dimColor: true } : {}), children: [line] })]
518 if (s.key === 'blocked' && view.blockedNext) words.push(Text({ wrap: 'wrap', dimColor: true, children: [view.blockedNext] }))
519 const body = Box({ key: 'words', flexDirection: 'column', flexShrink: 1, children: words })
520 // A state without a dot keeps its words in line with those that have one
521 states.push(s.dot
522 ? Box({ key: `state:${s.key}`, flexDirection: 'row', columnGap: 1, alignItems: 'flex-start', children: [Text({ ...s.dotStyle, children: [s.dot] }), body] })
523 : Box({ key: `state:${s.key}`, flexDirection: 'row', paddingLeft: 2, children: [body] }))
524 }
525 if (states.length) header.push(Box({ key: 'states', flexDirection: 'column', children: states }))
526
527 const ids = view.cells.map((c) => `${view.scope}/${c.qid}`)
528 const allOpen = ids.length > 0 && ids.every((id) => open.has(id))
529 const gateRow = (c) => {
530 const id = `${view.scope}/${c.qid}`
531 const isOpen = open.has(id)
532 const mark = c.state === 'met' ? '✓' : c.state === 'abandoned' ? '×' : '○'
533 const head = `${isOpen ? '▾' : '▸'} ${mark} ${label(view, c)} `
534 // What is done steps back; what is left stays at full strength
535 const dim = c.state === 'met' ? { dimColor: true } : {}
536 const name = splitTitle(c.title).name
537 // The terminal's cells are the pane's real width, so the name is cut to them there. The desktop
538 // sets it in a proportional font a cell count says little about: it gets the whole name and
539 // fits it to the width the person gave the pane
540 const fit = e.surface === 'terminal' ? clip(name, Math.max(8, columns - head.length)) : name
541 const row = [Button({ key: `gate:${id}`, label: head + fit, plain: true, ...dim, onPress: () => toggle(id) })]
542 if (isOpen) row.push(detailsOf($, e, view, c, open, toggle))
543 return row
544 }
545 const groups = []
546 for (const [key, name, state] of [['open', L.groupOpen, 'unmet'], ['abandoned', L.groupAbandoned, 'abandoned'], ['met', L.groupMet, 'met']]) {
547 const cells = view.cells.filter((c) => c.state === state)
548 if (!cells.length) continue
549 const title = Box({ key: 'title', flexDirection: 'row', columnGap: 1, children: [Text({ bold: true, children: [name] }), Text({ dimColor: true, children: [String(cells.length)] })] })
550 groups.push(Box({ key: `group:${key}`, flexDirection: 'column', children: [title, ...cells.flatMap(gateRow)] }))
551 }
552 const all = Button({
553 key: `all:${view.scope}`,
554 label: allOpen ? L.collapseAll : L.expandAll,
555 plain: true,
556 dimColor: true,
557 onPress: () => update($, EXPANDED, (list) => (allOpen ? list.filter((id) => !ids.includes(id)) : [...new Set([...list, ...ids])])),
558 })
559
560 // About the scope, folded: its Scope line, where the counts come from, the ledger's path
561 const aboutId = `${view.scope}#about`
562 const aboutOpen = open.has(aboutId)
563 const about = [Button({ key: `about:${view.scope}`, label: `${aboutOpen ? '▾' : '▸'} ${L.about}`, plain: true, dimColor: true, onPress: () => toggle(aboutId) })]
564 if (aboutOpen) {
565 const lines = []
566 if (view.scopeText) lines.push(Text({ wrap: 'wrap', children: [view.scopeText] }))
567 const source = view.source === 'checker' ? L.sourceChecker : L.sourceBoxes
568 lines.push(Text({ wrap: 'wrap', dimColor: true, children: [source.charAt(0).toLocaleUpperCase() + source.slice(1) + '.'] }))
569 lines.push(Text({ wrap: 'wrap', dimColor: true, children: [L.where(view.path)] }))
570 about.push(Box({ key: 'about-body', flexDirection: 'column', rowGap: 1, paddingLeft: 2, children: lines }))
571 }
572 return Box({
573 key: `scope:${view.scope}`,
574 flexDirection: 'column',
575 rowGap: 1,
576 children: [
577 Box({ key: 'header', flexDirection: 'column', rowGap: 1, children: header }),
578 all,
579 ...groups,
580 Box({ key: 'about', flexDirection: 'column', rowGap: 1, children: about }),
581 ],
582 })
583 })
584 return Box({ key: 'gate-meter-pane', flexDirection: 'column', rowGap: 2, children: blocks })
585}
586
587async function registerCommand($) {
588 for (const name of ['gates', 'gm-gates']) {
589 try {
590 await $.command.register({ name, description: L.gatesDescription })
591 return name
592 } catch {
593 // taken: try the prefixed name
594 }
595 }
596 return null
597}
598
599export function register(on, options) {
600 language = options && options.language
601 checkerOption = String((options && options.checker) || '').trim()
602 if (language === 'en' || language === 'uk') L = LOCALES[language]
603
604 on('session.start', async ($, e, next) => {
605 await pickLanguage($)
606 commandName = (await registerCommand($)) || commandName
607 $.clock.every(TICK_EVERY, () => refresh($).catch(() => {}))
608 soon($)
609 return next(e)
610 })
611
612 // Claude's language changed in /config: under auto, the mods follow it
613 on('config.set', async ($, e, next) => {
614 const r = await next(e)
615 if (isLanguageKey(e.key)) {
616 await pickLanguage($)
617 $.ui.invalidate('ui.render')
618 }
619 return r
620 }).catch(($, e, next) => next(e))
621
622 // /clear, /resume and /branch go on under another session id: another binding
623 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
624 const r = await next(e)
625 soon($)
626 return r
627 }).catch(($, e, next) => next(e))
628
629 // gate-check and pause.mjs run through Bash: read the scope again once the call is over.
630 // A failure here must never cost the call its result: the catch hands back what next gives
631 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
632 const r = await next(e)
633 soon($)
634 return r
635 }).catch(($, e, next) => next(e))
636
637 // The prompt hook pauses and lifts pauses around a turn
638 on('turn.start', async ($, e, next) => {
639 soon($)
640 return next(e)
641 })
642
643 on('turn.complete', async ($, e, next) => {
644 const r = await next(e)
645 if (!e.agentId) await refresh($).catch(() => {})
646 return r
647 })
648
649 on('command.run', { command: ['gates', 'gm-gates'] }, async ($) => {
650 if (!isLanguagePicked) await pickLanguage($)
651 await refresh($).catch(() => {})
652 const { scopes } = await read($, VIEW)
653 return { text: scopes.length ? scopes.map(listText).join('\n\n') : L.noBinding }
654 })
655
656 // The details pane in the side panel, opened from the row's button
657 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
658 if (!isLanguagePicked) await pickLanguage($)
659 const { scopes } = await read($, VIEW)
660 return paneOf($, e, scopes, await read($, EXPANDED))
661 })
662
663 // The band above the prompt: one row per bound scope, none without a binding
664 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
665 const below = await next(e)
666 if (e.props && e.props.hasSurvey) return below
667 const view = (await read($, VIEW)).scopes.slice(0, MAX_ROWS)
668 if (!view.length) return below
669 if (!isLanguagePicked) await pickLanguage($)
670 const { Box } = $.ui.resolve(e)
671 // One button for all the rows: the pane shows every bound scope
672 const rows = view.map((v, i) => rowOf($, e, v, i === 0, view.length > 1))
673 const mine = rows.length === 1 ? rows[0] : Box({ key: 'gate-meter', flexDirection: 'column', children: rows })
674 // Its own place in the band the mods of this repo share, whatever order they loaded in
675 return joinBand(Box, 'gate-meter', mine, below)
676 })
677}
678hooks/band.mjs 49 lines1// SPDX-License-Identifier: MIT
2// The shared band above the prompt for this repository's mods. The file is the same in
3// every mod (scripts/sync-shared.sh puts the copy there), since a mod is also installed
4// alone, without its neighbours.
5//
6// The engine chains ui.render hooks, and the order of plugins in the chain is not
7// documented: it depends on how and in what order they were installed. So a mod does not
8// put its row "above" or "below" whatever came from further down; it places it in a shared
9// column by its own slot. Whoever is on top of the chain, the band comes out the same:
10// Handoff, cache, "What next?", then the /unlazy gates.
11
12export const BAND = 'prompt-band'
13const ROW = 'prompt-band-row:'
14
15// A row's slot in the band. Anything foreign (the engine's row or a mod from elsewhere) goes below ours.
16export const PLACE = { 'handoff-relay': 10, 'cache-meter': 20, 'next-steps': 30, 'gate-meter': 40 }
17const FOREIGN = 999
18
19/** @param {any} row */
20function placeOf(row) {
21 const key = row && typeof row === 'object' && row.props ? String(row.props.key ?? '') : ''
22 return key.startsWith(ROW) ? Number(key.slice(ROW.length).split(':')[0]) : FOREIGN
23}
24
25// The rows already in the band below us, or whatever someone else drew.
26/** @param {(props: any) => any} Box @param {any} below @returns {any[]} */
27function rowsOf(Box, below) {
28 if (below === null || below === undefined || below === false) return []
29 if (typeof below === 'object' && below.type === 'Box' && below.props && below.props.key === BAND) {
30 return [...(below.children ?? [])]
31 }
32 return [Box({ key: `${ROW}${FOREIGN}:other`, flexDirection: 'column', children: [below] })]
33}
34
35// The band with mod `name`'s row in its slot. Half a line between rows: a whole one looks like an empty paragraph.
36/**
37 * @param {(props: any) => any} Box the column from the surface's table, $.ui.resolve(e).Box
38 * @param {'handoff-relay' | 'cache-meter' | 'next-steps' | 'gate-meter'} name
39 * @param {any} mine this mod's row
40 * @param {any} below what next(e) returned
41 * @returns {any}
42 */
43export function joinBand(Box, name, mine, below) {
44 const rows = rowsOf(Box, below)
45 rows.push(Box({ key: `${ROW}${PLACE[name]}:${name}`, flexDirection: 'column', children: [mine] }))
46 rows.sort((a, b) => placeOf(a) - placeOf(b))
47 return Box({ key: BAND, flexDirection: 'column', rowGap: 0.5, children: rows })
48}
49hooks/ledger.mjs 275 lines1// SPDX-License-Identifier: MIT
2// Reading /unlazy gate ledgers without running anything. Pure functions: no $, so the
3// checks of this repository can import the file from Node and hold it against the real
4// checker on real ledgers.
5//
6// Two sources, one answer. The checker's `gate-check.mjs --status` is the truth: the
7// Stop hook uses the same state model, and only it can tell current automatic evidence
8// from stale. The ledger files themselves give the titles, the order and a fallback count
9// by their check boxes when the checker cannot be run.
10
11// The gate line and its attributes, as unlazy's parser reads them
12const GATE_RE = /^- \[( |x|X)\] (.*)$/
13const ATTR_RE = /^\s+(CHECK|EXPECT|EVIDENCE|CWD):\s?(.*)$/
14const ABANDON_RE = /^ABANDON:\s*(\S*)\s*(.*)$/
15const FENCE_OPEN_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/
16
17/** The file stem the checker qualifies ids with: GATES.md gives GATES. */
18export function stemOf(name) {
19 return String(name).split('/').pop().replace(/\.md$/i, '')
20}
21
22/**
23 * The gates of one ledger file, in file order.
24 * @param {string} name the file's name, for qualified ids
25 * @param {string} text
26 */
27export function parseLedger(name, text) {
28 const stem = stemOf(name)
29 const gates = []
30 const abandoned = new Map()
31 let current = null
32 let fence = null
33 for (const line of String(text).split(/\r?\n/)) {
34 if (fence) {
35 const close = line.match(/^( {0,3})(`+|~+)[ \t]*$/)
36 if (close && close[2][0] === fence.character && close[2].length >= fence.length) fence = null
37 continue
38 }
39 const open = line.match(FENCE_OPEN_RE)
40 if (open && !(open[2][0] === '`' && open[3].includes('`'))) {
41 fence = { character: open[2][0], length: open[2].length }
42 continue
43 }
44 const gate = line.match(GATE_RE)
45 if (gate) {
46 const raw = gate[2].trim()
47 const idMatch = raw.match(/^(\S+?):(?:\s+|$)/)
48 if (!idMatch) {
49 current = null
50 continue
51 }
52 current = {
53 id: idMatch[1],
54 qid: stem + ':' + idMatch[1],
55 title: raw.slice(idMatch[0].length).trim(),
56 checked: gate[1].toLowerCase() === 'x',
57 runnable: false,
58 evidence: '',
59 check: '',
60 expect: '',
61 cwd: '',
62 }
63 gates.push(current)
64 continue
65 }
66 const abandon = line.match(ABANDON_RE)
67 if (abandon && abandon[1]) {
68 abandoned.set(abandon[1], abandon[2].trim())
69 continue
70 }
71 const attr = current && line.match(ATTR_RE)
72 if (attr) {
73 if (attr[1] === 'CHECK') {
74 current.runnable = true
75 current.check = attr[2].trim()
76 }
77 if (attr[1] === 'EXPECT') current.expect = attr[2].trim()
78 if (attr[1] === 'CWD') current.cwd = attr[2].trim()
79 if (attr[1] === 'EVIDENCE') current.evidence = attr[2].trim()
80 }
81 }
82 return gates.map((g) => (abandoned.has(g.id) ? { ...g, abandonReason: abandoned.get(g.id) } : g))
83}
84
85/**
86 * What a ledger says about itself before its first gate: the `# Gates:` heading and the `Scope:` line.
87 * @param {string} text
88 */
89export function parseHeader(text) {
90 let heading = ''
91 let scope = ''
92 for (const line of String(text).split(/\r?\n/)) {
93 if (GATE_RE.test(line)) break
94 const h = line.match(/^#\s+(?:Gates:\s*)?(.+)$/)
95 if (h && !heading) heading = h[1].trim()
96 const sc = line.match(/^Scope:\s*(.+)$/)
97 if (sc && !scope) scope = sc[1].trim()
98 }
99 return { heading, scope }
100}
101
102/**
103 * Automatic evidence in a few words: its exit code and whether EXPECT matched; null for a person's note.
104 * @param {string} evidence
105 */
106export function automaticEvidence(evidence) {
107 const ev = String(evidence)
108 if (!ev.startsWith('automatic-evidence=v1')) return null
109 const exit = (ev.match(/(?:^|;\s*)exit=(\d+)/) || [])[1]
110 return { exit: exit === undefined ? null : Number(exit), matched: /EXPECT=matched/.test(ev) }
111}
112
113/**
114 * The gates of every ledger of a scope, GATES.md first, as the checker orders them.
115 * @param {{ name: string, text: string }[]} files
116 */
117export function parseLedgers(files) {
118 return files.flatMap((f) => parseLedger(f.name, f.text))
119}
120
121// A gate's state by its check box and evidence alone: what the fallback can see.
122// Without the checker a runnable gate's evidence cannot be bound to its definition, so
123// any automatic v1 evidence counts; a checked gate with pending or blank evidence does not.
124function boxState(g) {
125 if (g.abandonReason !== undefined) return 'abandoned'
126 if (!g.checked) return 'unmet'
127 const ev = g.evidence.trim().toLowerCase()
128 if (!ev || ev === 'pending') return 'unmet'
129 if (g.runnable && !ev.startsWith('automatic-evidence=v1')) return 'unmet'
130 return 'met'
131}
132
133// One unmet gate in --status: " UNMET GATES:G3 (unchecked): the title"
134const UNMET_LINE = /^ {2}UNMET (\S+) \((unchecked|checked but EVIDENCE pending|checked but automatic evidence is stale or unbound)\): ?(.*)$/
135const REASONS = {
136 unchecked: 'unchecked',
137 'checked but EVIDENCE pending': 'pending',
138 'checked but automatic evidence is stale or unbound': 'stale',
139}
140
141/**
142 * The checker's --status output, read back: the counts and each unmet gate with its reason.
143 * Null when the output is not a status report (no summary line).
144 * @param {string} out stdout and stderr of `gate-check.mjs --status`
145 */
146export function parseChecker(out) {
147 const text = String(out)
148 const unmetGates = []
149 const blockers = []
150 for (const line of text.split(/\r?\n/)) {
151 const m = line.match(UNMET_LINE)
152 if (m) unmetGates.push({ qid: m[1], reason: REASONS[m[2]], title: m[3] })
153 else if (/^ {2}UNMET \S/.test(line)) blockers.push(line.slice(8).trim())
154 }
155 const all = text.match(/^ALL MET \((\d+) met/m)
156 if (all) return { met: Number(all[1]), unmet: 0, abandoned: 0, unmetGates, blockers }
157 const handoff = text.match(/^HANDOFF REQUIRED: (\d+) abandoned \(met: (\d+)(?:, unmet: (\d+))?/m)
158 const unmet = text.match(/^UNMET: (\d+) \(met: (\d+)/m)
159 if (!handoff && !unmet) return null
160 return {
161 met: Number((handoff || unmet)[2]),
162 unmet: unmet ? Number(unmet[1]) : Number((handoff && handoff[3]) || 0),
163 abandoned: handoff ? Number(handoff[1]) : 0,
164 unmetGates,
165 blockers,
166 }
167}
168
169/**
170 * What the band and /gates show for one scope.
171 * With the checker's report, its counts and unmet set win; without it, the boxes decide.
172 * @param {ReturnType<typeof parseLedgers>} gates
173 * @param {ReturnType<typeof parseChecker>} checker
174 * @param {string[]} [regressed] qualified ids pause.mjs resume found broken
175 */
176export function summarize(gates, checker, regressed = []) {
177 const unmetBy = new Map((checker ? checker.unmetGates : []).map((u) => [u.qid, u]))
178 const cells = gates.map((g) => {
179 if (g.abandonReason !== undefined) return { ...g, state: 'abandoned' }
180 if (checker) return unmetBy.has(g.qid) ? { ...g, state: 'unmet', reason: unmetBy.get(g.qid).reason } : { ...g, state: 'met' }
181 const state = boxState(g)
182 return state === 'unmet' ? { ...g, state, reason: g.checked ? 'pending' : 'unchecked' } : { ...g, state }
183 })
184 const regressedSet = new Set(regressed)
185 for (const c of cells) if (c.state === 'unmet' && regressedSet.has(c.qid)) c.regressed = true
186 const count = (s) => cells.filter((c) => c.state === s).length
187 const met = checker ? checker.met : count('met')
188 const unmet = checker ? checker.unmet : count('unmet')
189 const abandoned = checker ? checker.abandoned : count('abandoned')
190 const next = cells.find((c) => c.state === 'unmet') || null
191 return {
192 source: checker ? 'checker' : 'boxes',
193 total: met + unmet + abandoned,
194 met,
195 unmet,
196 abandoned,
197 cells,
198 next,
199 regressed: cells.filter((c) => c.regressed).map((c) => c.qid),
200 blockers: checker ? checker.blockers : [],
201 }
202}
203
204/** Text cut to `max` characters, an ellipsis at the end when cut. */
205export function clip(text, max) {
206 const s = String(text).replace(/\s+/g, ' ').trim()
207 if (max <= 1) return s.slice(0, Math.max(0, max))
208 return s.length <= max ? s : s.slice(0, max - 1).trimEnd() + '…'
209}
210
211const capital = (s) => s.charAt(0).toLocaleUpperCase() + s.slice(1)
212// Inline code marks say nothing to a person reading the pane
213const plain = (s) => String(s).replace(/`([^`]*)`/g, '$1').replace(/\s+/g, ' ').trim()
214// A gate title's lead before its separator is its name when it is this short
215const MAX_NAME = 60
216// What parts a gate's short name from its details: a spaced dash, or a colon as in older titles
217const DASHES = [' — ', ' – ']
218const OPEN = '«(['
219const CLOSE = '»)]'
220
221/**
222 * Where a title's short name ends: at the first spaced dash or colon outside quotes and brackets,
223 * whichever comes first, so a name may quote «a word» and a colon written later in the details
224 * does not cut it. Returns the separator's start and length, or null without one.
225 * @param {string} text
226 */
227function nameEnd(text) {
228 let depth = 0
229 let straight = false
230 for (let i = 0; i < text.length && i <= MAX_NAME; i++) {
231 const ch = text[i]
232 if (ch === '"') straight = !straight
233 else if (OPEN.includes(ch)) depth++
234 else if (CLOSE.includes(ch)) depth = Math.max(0, depth - 1)
235 if (depth > 0 || straight || i === 0) continue
236 const dash = DASHES.find((d) => text.startsWith(d, i))
237 if (dash) return { at: i, length: dash.length }
238 if (ch === ':' && /\s/.test(text[i + 1] ?? '')) return { at: i, length: 1 }
239 }
240 return null
241}
242
243/**
244 * A gate title as a short name and the rest of its sentence: the part before its first spaced dash
245 * (or colon) when there is a short one, capitalised, and what follows it; without one, the whole
246 * title and no rest.
247 * @param {string} title
248 */
249export function splitTitle(title) {
250 const text = plain(title)
251 const end = nameEnd(text)
252 if (end) {
253 return { name: capital(text.slice(0, end.at).trim()), rest: capital(text.slice(end.at + end.length).trim()) }
254 }
255 return { name: capital(text), rest: '' }
256}
257
258/**
259 * The first sentence of the option BLOCKED.md recommends: what the stop is waiting for, in its own words.
260 * @param {string} text
261 */
262export function bestOption(text) {
263 const lines = String(text).split(/\r?\n/)
264 const at = lines.findIndex((l) => /^##\s+(Найкращий варіант|Best option)\s*$/i.test(l))
265 if (at < 0) return ''
266 const body = []
267 for (const line of lines.slice(at + 1)) {
268 if (/^##\s/.test(line)) break
269 if (line.trim()) body.push(line.trim())
270 }
271 const para = plain(body.join(' '))
272 const first = para.match(/^.+?[.!?](?=\s|$)/)
273 return first ? first[0] : para
274}
275hooks/i18n.mjs 42 lines1// SPDX-License-Identifier: MIT
2// The language of what a mod shows a person. The file is the same in every mod
3// (scripts/sync-shared.sh puts the copy there), since a mod is also installed alone,
4// without its neighbours.
5//
6// The mod option `language`: auto, en or uk. auto takes the language of Claude's replies
7// from /config (the language row), so one Claude Code setting sets the language for all
8// mods at once. A language the mods do not have, or none set, gives English.
9
10export const LANGUAGES = ['en', 'uk']
11
12/**
13 * The mods' language for a /config value: "ukrainian", "uk" or the word in Ukrainian give uk, anything else en.
14 * @param {unknown} value
15 * @returns {'en' | 'uk'}
16 */
17export function languageOf(value) {
18 const v = String(value ?? '').trim().toLowerCase()
19 return /^(uk|ua)\b|ukrain|україн|укр/.test(v) ? 'uk' : 'en'
20}
21
22/**
23 * The language for the mod option: en or uk as is, auto (or empty) by the language of Claude's replies.
24 * The mod reads the /config rows itself ($ is not passed to functions from another file).
25 * @param {unknown} option
26 * @param {readonly { key: string, value: unknown }[]} rows the rows of $.config.list(), or [] when they cannot be read
27 * @returns {'en' | 'uk'}
28 */
29export function resolveLanguage(option, rows) {
30 if (option === 'en' || option === 'uk') return option
31 const row = rows.find((r) => r.key === 'language') ?? rows.find((r) => isLanguageKey(r.key))
32 return languageOf(row?.value)
33}
34
35/**
36 * Whether a change to a /config row can change the mods' language.
37 * @param {string} key
38 */
39export function isLanguageKey(key) {
40 return !key.includes('.') && /language/i.test(key)
41}
42hooks/locales/en.mjs 56 lines1// SPDX-License-Identifier: MIT
2// Everything gate-meter shows a person, in English. uk.mjs has the same keys.
3
4export default {
5 // The band above the prompt
6 gates: 'Gates',
7 count: (met, total) => `${met} of ${total}`,
8 percent: (n) => `${n}%`,
9 abandoned: (n) => `${n} abandoned`,
10 allMet: 'All met',
11 paused: 'Paused',
12 blocked: 'Waiting for a decision',
13 regressed: (ids) => `Regressed ${ids}`,
14 waves: (n) => `${n} in dispatch`,
15 details: 'Details',
16
17 // The details pane
18 paneTitle: (scope) => `Gates ${scope}`,
19 metOf: (met, total) => `${met} of ${total} met`,
20 expandAll: 'Expand all',
21 collapseAll: 'Collapse all',
22 groupOpen: 'Open',
23 groupAbandoned: 'Abandoned',
24 groupMet: 'Met',
25 howChecked: 'How it is checked',
26 about: 'About this scope',
27 status: {
28 metAuto: 'Met by an automatic check.',
29 metBy: (evidence) => `Met: ${evidence}`,
30 unchecked: 'Not checked yet.',
31 pending: 'Ticked as met, but the check has not run yet.',
32 stale: 'The check changed after the gate was met; it has to run again.',
33 regressed: 'Was met and broke on resume.',
34 waitsHuman: 'Waiting for a person: a manual gate.',
35 abandoned: (reason) => `Abandoned: ${reason}`,
36 },
37 evidenceName: 'Evidence',
38 evidenceNone: 'none yet',
39 evidenceAuto: (exit, matched) => `automatic: exit ${exit ?? '?'}, EXPECT ${matched ? 'matched' : 'did not match'}`,
40 regressedShort: 'broke on resume',
41 allMetLine: (total) => `All ${total} met.`,
42
43 // /gates
44 gatesDescription: "This session's /unlazy gates: what is met and what is not",
45 noBinding: 'No /unlazy gates are bound to this session. They appear here once /unlazy binds a scope to it.',
46 head: (scope, met, total) => `Gates ${scope}: ${met} of ${total} met`,
47 sourceChecker: 'as the unlazy checker reads them (gate-check --status), the same state the Stop hook uses',
48 sourceBoxes: 'by the check boxes in the file: the unlazy checker was not found or did not answer, so stale evidence is not caught',
49 reason: { unchecked: '', pending: 'no evidence yet', stale: 'evidence is stale' },
50 abandonedBecause: (reason) => `abandoned: ${reason}`,
51 stateLine: { paused: 'Paused after /handoff: any later prompt lifts the pause.', blocked: 'Waiting for a decision: the reason and the option are in BLOCKED.md.' },
52 regressedLine: (ids) => `Regressed after resume: ${ids}.`,
53 wavesLine: (items) => `Open in dispatch: ${items}.`,
54 where: (path) => `Ledger: ${path}`,
55}
56hooks/locales/uk.mjs 56 lines1// SPDX-License-Identifier: MIT
2// Усе, що gate-meter показує людині, українською. Ключі ті самі, що в en.mjs.
3
4export default {
5 // Смуга над полем вводу
6 gates: 'Гейти',
7 count: (met, total) => `${met} з ${total}`,
8 percent: (n) => `${n} %`,
9 abandoned: (n) => `покинуто ${n}`,
10 allMet: 'Усі закрито',
11 paused: 'На паузі',
12 blocked: 'Очікує на рішення',
13 regressed: (ids) => `Регресія ${ids}`,
14 waves: (n) => `у диспетчері ${n}`,
15 details: 'Деталі',
16
17 // Панель деталей
18 paneTitle: (scope) => `Гейти ${scope}`,
19 metOf: (met, total) => `Закрито ${met} з ${total}`,
20 expandAll: 'Розгорнути все',
21 collapseAll: 'Згорнути все',
22 groupOpen: 'Відкриті',
23 groupAbandoned: 'Покинуті',
24 groupMet: 'Закриті',
25 howChecked: 'Як перевіряється',
26 about: 'Про набір',
27 status: {
28 metAuto: 'Закрито автоматичною перевіркою.',
29 metBy: (evidence) => `Закрито: ${evidence}`,
30 unchecked: 'Ще не перевірено.',
31 pending: 'Позначено як закритий, але перевірку ще не прогнано.',
32 stale: 'Перевірку змінили після закриття, треба прогнати знову.',
33 regressed: 'Був закритий і зламався після відновлення.',
34 waitsHuman: 'Чекає на людину: ручний гейт.',
35 abandoned: (reason) => `Покинуто: ${reason}`,
36 },
37 evidenceName: 'Свідчення',
38 evidenceNone: 'ще немає',
39 evidenceAuto: (exit, matched) => `автоматичне: код виходу ${exit ?? '?'}, EXPECT ${matched ? 'збігся' : 'не збігся'}`,
40 regressedShort: 'зламався після відновлення',
41 allMetLine: (total) => `Усі ${total} закрито.`,
42
43 // /gates
44 gatesDescription: 'Гейти /unlazy цієї сесії: що закрито, а що ні',
45 noBinding: 'До цієї сесії не привʼязано гейтів /unlazy. Вони зʼявляться тут, щойно /unlazy привʼяже до неї набір.',
46 head: (scope, met, total) => `Гейти ${scope}: закрито ${met} з ${total}`,
47 sourceChecker: 'так їх читає перевірник unlazy (gate-check --status), той самий стан, що бачить Stop-хук',
48 sourceBoxes: 'за позначками у файлі: перевірник unlazy не знайдено або він не відповів, тож застарілі свідчення не видно',
49 reason: { unchecked: '', pending: 'без свідчення', stale: 'свідчення застаріло' },
50 abandonedBecause: (reason) => `покинуто: ${reason}`,
51 stateLine: { paused: 'На паузі після /handoff: будь-який наступний промпт знімає паузу.', blocked: 'Очікує на рішення: причина й варіант у BLOCKED.md.' },
52 regressedLine: (ids) => `Регресія після відновлення: ${ids}.`,
53 wavesLine: (items) => `Відкрито в диспетчері: ${items}.`,
54 where: (path) => `Набір: ${path}`,
55}
56types/index.d.ts 62 lines1// SPDX-License-Identifier: MIT
2// What gate-meter publishes for other mods to read. A reader must work without it:
3// when gate-meter is not installed, the value is absent.
4// Fields are only ever added; a reader written for an older version keeps working.
5export type GateMeterState = 'met' | 'unmet' | 'abandoned'
6
7export type GateMeterGate = {
8 // The id qualified with its ledger's file stem, as unlazy reports it: GATES:G3.
9 qid: string
10 id: string
11 title: string
12 state: GateMeterState
13 // Why an open gate is open: not checked yet, checked without evidence, or evidence older than its definition.
14 reason?: 'unchecked' | 'pending' | 'stale'
15 // Was met before `pause.mjs resume` and is not now.
16 regressed?: boolean
17 abandonReason?: string
18 // The ledger's own lines for the gate, empty when absent.
19 check?: string
20 expect?: string
21 cwd?: string
22 evidence?: string
23}
24
25export type GateMeterScope = {
26 scope: string
27 // The first ledger's `# Gates:` heading and `Scope:` line, empty when absent.
28 heading?: string
29 scopeText?: string
30 // The folder that holds .unlazy/.
31 root: string
32 // The scope's first ledger file.
33 path: string
34 multiFile: boolean
35 // 'checker' when the counts come from `gate-check.mjs --status`, 'boxes' when from the ledger's check boxes.
36 source: 'checker' | 'boxes'
37 total: number
38 met: number
39 unmet: number
40 abandoned: number
41 cells: GateMeterGate[]
42 next: { qid: string; id: string; title: string; reason: 'unchecked' | 'pending' | 'stale' } | null
43 regressed: string[]
44 // Open dispatch waves the checker counts as unmet, as it words them.
45 blockers: string[]
46 paused: boolean
47 blocked: boolean
48 // The first sentence of BLOCKED.md's best option, what the stop waits for; empty without one.
49 blockedNext?: string
50}
51
52declare module 'claude-code' {
53 interface PluginState {
54 'gate-meter': {
55 // The scopes bound to this session, at most a few; empty without a binding.
56 view: { scopes: GateMeterScope[] }
57 // Gates opened in the details pane, as `<scope>/<qualified id>`.
58 expanded: string[]
59 }
60 }
61}
62