SLOPSHOPPER

gate-meter

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…

panebandguardcommandprocesstimer
★ 3v0.2.1MITupdated 2026-10-18drdickgraysonjr/chalkery/plugins/gate-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gate-meter
│ ┃ gate-meter ✕ › fix the failing auth test and add an audit log call │ ┃ No /unlazy gates are bound to this session. │ ┃ They appear here once /unlazy binds a scope ⏺ Read(src/auth.ts) │ ┃ to it. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /gates │ ⎿ gate-meter: No /unlazy gates are bound to this session. They app │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · gate-meter
No /unlazy gates are bound to this session. They appear here once /unlazy binds a scope to it.
README

gate-meter

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.

What you see

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
  • Label. Gates alone; when several scopes are bound to the session, each row names its own, Gates alerts-mvp.
  • Cells. One per gate in ledger order up to 14 gates: filled when met, dim when open, a yellow × 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.
  • Count. Met of total. A ≈ before it means the unlazy checker could not be asked and the check boxes in the file decided.
  • State, after a bar, only when there is one. Colour is carried by the dot alone; the words stay plain or dim:
DotWordsWhen
yellow ⏺Waiting for a decisionBLOCKED.md is there: a large repair stopped the work
red ⏺Regressed G3, G5a gate that was met broke on pause.mjs resume
dim ⏺Paused (dim)this session paused after the human typed /handoff
none2 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.

Where the numbers come from

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.

In the band

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.

For other mods

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.

Options

OptionDefaultWhat it does
languageautoauto follows Claude's response language from /config; en or uk set it
checkeremptyPath to unlazy's scripts/gate-check.mjs, when the mod does not find it by itself

Checks

claude plugin validate .
claude plugin test .
Source 7 files
hooks/register.mjs 678 lines
1// 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}
678
hooks/band.mjs 49 lines
1// 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}
49
hooks/ledger.mjs 275 lines
1// 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}
275
hooks/i18n.mjs 42 lines
1// 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}
42
hooks/locales/en.mjs 56 lines
1// 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}
56
hooks/locales/uk.mjs 56 lines
1// 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}
56
types/index.d.ts 62 lines
1// 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