Shows CI status lights above the prompt while a promote PR (base main) is open. One REST page per minute, PR-open only. Cancelled is not-pass.

Shows CI status lights above the prompt while a promote PR (base main) is open.
+N more line after them; when everything is green the band is the summary line alone: 🚦 #4435 3afff24 BLOCKED 19 🟢 0 🟡 2 🔴
🔴 PR Playground
🔴 CI Summary
HOLD in red when the latest PR comment starts with HOLD*DEGRADED in the band. Claude Code gives a mod no signal when the band is collapsed (ctrl+x ctrl+a), so /lights answers the one-line status on demandlights: <reason> band when it cannot show lights (no required contexts, gh failed, head moved), so a failure is never a blank space/lights watch owner/repo#N shows lights for any open PR in any repo, no promote PR neededThe band is built from the Box and Text elements that $.ui.resolve(e) hands the ui.render hook. On Claude Code 2.1.282 a plain { type: "Box" } object is not an element and never draws (measured 2026-09-25: the hook settles, nothing appears). The downstream tree from next(e) is an opaque engine node, so the band is placed above it in a column and never mutates it.
/lights watch owner/repo#123 (also owner/repo 123 or a PR URL) points the tick at any open PR:
-R owner/repo); the lights still show in this session's bandPROMOTE_LIGHTS_WATCH=owner/repo#123 starts watching at session start with no typing, for recorded demosA real promote PR keeps its stricter rule: an empty required-context union refuses green instead of falling back.
Lights are kept in $.store, which outlives a Claude Code session and is shared by every session on the machine, and the band can be drawn before session.start runs. Each session therefore stores its lights under its own key (the repo plus a session token), stamps the entry with that token, and draws only its own entry. Two live sessions on the same repo keep separate bands, and a new session (including a /clear, which rotates the token and removes the previous entry) never shows older lights, even for a moment.
| Status | Color |
|---|---|
| success | green circle |
| queued / in_progress / null | yellow circle |
| failure / timed_out / action_required | red circle |
| cancelled | warning sign (its own bucket, never pass) |
| skipped | dropped when the name has a real verdict; a skipped-only name stays yellow (never ran, never pass) |
| missing (no run found) | red circle |
Cancelled runs get their own bucket because a superseded attempt leaves tiers CANCELLED, and the aggregate must read failure even though zero tests failed.
classic.* binary)CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1; set it in your shell profile or personal settings. On 2.1.287+ Mods are public and the flag is no longer needed.CC 2.1.287 announces Claude Mods as public, so plugins may now modify deeper behaviour than hooks. Measured on CC 2.1.288 (2026-10-03, headless -p --plugin-dir per mod, flag unset then set to 1): all four ork mods load and fire with the flag unset. secrets-veil masked a secret in a Bash tool result, lesson-cards denied gh pr checks via lesson cancelled-check-is-not-pass, memory-lens indexed 384 memories at session start, and promote-lights ran gh through $.process.run. The flag only gates 2.1.266 to 2.1.286. The built-in "You should know" mod (/plugin enable cc-plugin-you-should-know@builtin) ships in the same release; coexistence is unverified and ork does not enable it. Migration questions stay open in GH-3917; the command hook fleet is not part of this check.
Hooks:
session.start{}turn.complete{}command.register{}ui.render{component=AbovePrompt}Calls:
$.process.run (gh CLI, read-only)$.fs.read (.github/branch-protection.json)$.session.repo$.clock.every$.store.get, $.store.set$.ui.status, $.ui.invalidate, $.ui.resolve$.env.get (PROMOTE_HEAD, PROMOTE_LIGHTS_WATCH)Declared capability: process.run (can run host processes; gh carries your auth, read-only calls only).
Negative: no $.secrets.*, no $.http.fetch, no $.model.*.
/lights off or disable the pluginCLAUDE_CODE_ENABLE_FUNCTION_HOOKS (inert on 2.1.287+)$.store keys lights:* are the only residue and are safe to dropOne REST call group per minute. Measured over 10 minutes with a real PR: at most 40 calls (gh API count), staying under the 5000/h budget shared across seats.
/lights - show current status/lights off - stop tracking and clear the band/lights refresh - force immediate refresh/lights watch owner/repo#N - show lights for any open PR (demo mode)/lights owner/repo#N - the same, without the watch word (also owner/repo N and PR URLs)main above the prompt within one tick, plus one line per context that is not green$.store on the next ui.rendermerge-on-required.sh is untouched and still the thing that mergeshooks/register.ts 573 lines1/**
2 * Promote Lights - Function Hooks registration.
3 *
4 * Hooks registered:
5 * - session.start
6 * - turn.complete
7 * - command.run{command=lights} (declared with $.command.register at session start)
8 * - ui.render { component: 'AbovePrompt' }
9 *
10 * Calls:
11 * - $.process.run (gh commands)
12 * - $.session.repo
13 * - $.clock.every (60s tick)
14 * - $.store.get/set
15 * - $.ui.status (startup warnings; cleared once the band draws)
16 * - $.ui.invalidate
17 * - $.ui.resolve (the band's Box and Text elements)
18 * - $.env.get (PROMOTE_HEAD override, PROMOTE_LIGHTS_WATCH demo target)
19 *
20 * Two tracking modes share one tick:
21 * - promote: the open promote PR (base main, dev head or promote label) in
22 * the session repo. An empty required-context union refuses green.
23 * - watch: any open PR in any repo, named with `/lights watch owner/repo#N`
24 * or PROMOTE_LIGHTS_WATCH. An unprotected repo falls back to every check
25 * run on the head, so the lights can be shown without a live promote PR.
26 */
27
28import { matchAndClassify, isPassing, type ClassifiedLight } from "../src/classify.js";
29import { computeRequiredUnion } from "../src/required.js";
30import { parsePRList, parsePRView, parseCheckRuns, isPromotePR, DEFAULT_PROMOTE_HEAD, buildHeadQueryArgs, buildLabelQueryArgs, mergePRLists } from "../src/gh.js";
31import { buildStatusLine, buildBand, buildErrorBand, type Elements } from "../src/pane.js";
32import {
33 parseWatchTarget,
34 formatWatchTarget,
35 allCheckNames,
36 WATCH_USAGE,
37 noPrHint,
38 unknownArgHint,
39 type WatchTarget,
40} from "../src/watch.js";
41
42/**
43 * Minimal $ facade for the calls this module uses, mirroring the
44 * lesson-cards mod: hooks are typed locally so the mod typechecks with
45 * only its own devDependencies installed.
46 */
47type Hook$ = {
48 session: {
49 repo: () => Promise<{ owner: string; name: string } | null>;
50 };
51 process: {
52 run: (
53 argv: readonly string[],
54 init?: { timeoutMs?: number }
55 ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
56 };
57 clock: {
58 // CC 2.1.282 hands back { cancel } only; there is no dispose.
59 every: (ms: number, fn: () => void) => { cancel: () => void };
60 };
61 store: {
62 get: (key: string) => Promise<StoredLights | null>;
63 set: (key: string, value: unknown) => Promise<void>;
64 delete: (key: string) => Promise<void>;
65 };
66 env: {
67 get: (key: string) => Promise<string | undefined>;
68 };
69 ui: {
70 /** undefined clears the line. */
71 status: (line: string | undefined) => Promise<void>;
72 invalidate: (component: string) => void;
73 resolve: (e: HookEvent) => Promise<Elements>;
74 };
75 command: {
76 register: (spec: { name: string; description: string; argumentHint?: string }) => Promise<unknown>;
77 };
78};
79
80type Matcher = Record<string, unknown>;
81
82type HookEvent = {
83 name?: string;
84 args?: string;
85 component?: string;
86 [key: string]: unknown;
87};
88
89type NextFn = (ev?: HookEvent) => Promise<Record<string, unknown> | undefined>;
90
91type On = (
92 event: string,
93 matcher: Matcher,
94 handler: ($: Hook$, e: HookEvent, next: NextFn) => unknown
95) => void;
96
97export type Register = (on: On) => void;
98
99/** Shape stored under lights:<owner>/<repo> (the SESSION repo, in both modes). */
100type StoredLights = {
101 prNumber?: number;
102 head?: string;
103 label?: string;
104 mode?: "promote" | "watch";
105 lights?: ClassifiedLight[];
106 mergeStateStatus?: string;
107 hold?: boolean;
108 /** True when the promote search ran on one query instead of two. */
109 degraded?: boolean;
110 passing?: boolean;
111 error?: string;
112 /** The token of the session that wrote this entry (see sessionToken). */
113 session?: string;
114 [key: string]: unknown;
115};
116
117/** The PR the tick follows: which repo to ask gh about, and which head. */
118type Tracked = {
119 owner: string;
120 repo: string;
121 number: number;
122 head: string;
123 mode: "promote" | "watch";
124 /** Branch whose protection names the required checks: main for a promote, the PR's own base for a watch. */
125 base: string;
126 /** True when the promote search ran on one query instead of two. */
127 degraded: boolean;
128};
129
130// Module state
131let ticking = false;
132let tickInterval: { cancel: () => void } | null = null;
133let tracked: Tracked | null = null;
134
135const TICK_MS = 60000;
136
137/**
138 * One token per loaded module, so per Claude Code process. $.store outlives
139 * the process, and ui.render can run BEFORE session.start clears the key
140 * (measured on 2.1.283: a new session drew the previous session's lights
141 * for about 3 s). Every write carries this token and every read ignores an
142 * entry with any other token, so a stale entry is never drawn.
143 */
144function newToken(): string {
145 return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
146}
147
148/** Rotated at every session.start, so a /clear inside one process starts clean too. */
149let sessionToken = newToken();
150
151/** The current token (tests seed entries with it). */
152export function currentSessionToken(): string {
153 return sessionToken;
154}
155
156/** Tag an entry as written by this process. */
157function stamp<T extends object>(value: T): T & { session: string } {
158 return { ...value, session: sessionToken };
159}
160
161/** The stored entry only when this process wrote it; anything else reads as nothing. */
162function own(stored: StoredLights | null): StoredLights | null {
163 return stored && stored.session === sessionToken ? stored : null;
164}
165
166/**
167 * Store key: the session repo plus this session's token. Per repo alone, two
168 * live sessions on the same repo shared one key, and each write blanked the
169 * other session's band until its next tick.
170 */
171function keyFor(repo: { owner: string; name: string } | null, token: string = sessionToken): string {
172 const base = repo ? `lights:${repo.owner}/${repo.name}` : "lights:_session";
173 return `${base}:${token}`;
174}
175
176function labelFor(t: Tracked): string {
177 return t.mode === "watch"
178 ? `watch ${formatWatchTarget({ owner: t.owner, repo: t.repo, number: t.number })}`
179 : `promote #${t.number}`;
180}
181
182function prViewArgv(t: { owner: string; repo: string; number: number }): string[] {
183 return [
184 "gh",
185 "pr",
186 "view",
187 String(t.number),
188 "--json",
189 "headRefOid,state,mergeStateStatus,baseRefName",
190 "-R",
191 `${t.owner}/${t.repo}`,
192 ];
193}
194
195export const register: Register = (on) => {
196 on("session.start", {}, async ($, e, next) => {
197 // Rotate first, before any await: from here on nothing this process
198 // stored for the previous session (a /clear) can be read or drawn.
199 const previousToken = sessionToken;
200 sessionToken = newToken();
201 await $.command.register({
202 name: "lights",
203 description: "CI lights for the open promote PR, or any PR with /lights watch",
204 argumentHint: "[off|refresh|watch] [owner/repo#N]",
205 });
206 const repo = await $.session.repo();
207 const key = keyFor(repo);
208
209 // $.store outlives the session: drop what this process stored under its
210 // previous token (a /clear), so no orphan is left behind. Entries of
211 // other processes live under their own keys and are never touched.
212 await $.store.delete(keyFor(repo, previousToken));
213 // A tick or a tracked PR from before /clear must not outlive it either.
214 stopTick();
215 tracked = null;
216
217 // Demo mode first: a configured watch target needs no promote PR and no
218 // session repo.
219 const watchEnv = await $.env.get("PROMOTE_LIGHTS_WATCH").catch(() => undefined);
220 const watchTarget = parseWatchTarget(watchEnv);
221 if (watchTarget) {
222 await startWatch($, key, watchTarget);
223 return next(e);
224 }
225
226 if (!repo) return next(e);
227
228 const { owner, name } = repo;
229
230 // Only a real promote PR matches: dev head (or the configured
231 // promote head) with base main, or the promote label. An ordinary
232 // PR with base main must never match.
233 let promoteHead = DEFAULT_PROMOTE_HEAD;
234 const configured = await $.env.get("PROMOTE_HEAD").catch(() => undefined);
235 if (configured) promoteHead = configured;
236
237 // Both queries are filtered server side so the result is complete
238 // no matter how many ordinary open PRs the repo has. A single
239 // unfiltered list returns at most 30 PRs by default, which misses
240 // a promote PR sitting past position 30. Settled, never all: one
241 // rejected query degrades the search instead of aborting the start.
242 const [headSettled, labelSettled] = await Promise.allSettled([
243 $.process.run(buildHeadQueryArgs(promoteHead), { timeoutMs: 15000 }),
244 $.process.run(buildLabelQueryArgs(), { timeoutMs: 15000 }),
245 ]);
246 const byHead = headSettled.status === "fulfilled"
247 ? headSettled.value
248 : { exitCode: 1, stdout: "", stderr: String(headSettled.reason) };
249 const byLabel = labelSettled.status === "fulfilled"
250 ? labelSettled.value
251 : { exitCode: 1, stdout: "", stderr: String(labelSettled.reason) };
252
253 if (byHead.exitCode !== 0 && byLabel.exitCode !== 0) {
254 await $.store.set(key, stamp({ error: "gh: not found" }));
255 return next(e);
256 }
257
258 // One failed query must never pass silently: the search is degraded,
259 // the snapshot records it, and the band keeps saying DEGRADED.
260 const degraded = byHead.exitCode !== 0 || byLabel.exitCode !== 0;
261 if (degraded) {
262 const failed = byHead.exitCode !== 0 ? "head" : "label";
263 await warnStatus($, `lights: promote ${failed} query failed, continuing with the other`);
264 }
265
266 const prs = mergePRLists(
267 byHead.exitCode === 0 ? parsePRList(byHead.stdout) : [],
268 byLabel.exitCode === 0 ? parsePRList(byLabel.stdout) : []
269 );
270 const promotePR = prs.find((pr) => isPromotePR(pr, promoteHead));
271
272 if (!promotePR) {
273 await $.store.delete(key);
274 return next(e);
275 }
276
277 tracked = { owner, repo: name, number: promotePR.number, head: promotePR.headRefOid, mode: "promote", base: "main", degraded };
278
279 if (!ticking) {
280 startTick($, key);
281 await doTick($, key);
282 }
283
284 return next(e);
285 });
286
287 // Re-check on turn complete in case PR changed
288 on("turn.complete", {}, async ($, e, next) => {
289 const repo = await $.session.repo();
290 if (!tracked) return next(e);
291 const key = keyFor(repo);
292 if (!repo && tracked.mode === "promote") return next(e);
293
294 const result = await $.process.run(prViewArgv(tracked), { timeoutMs: 10000 });
295
296 if (result.exitCode !== 0) return next(e);
297
298 const details = parsePRView(result.stdout);
299 if (!details || details.state !== "OPEN") {
300 stopTick();
301 await $.store.delete(key);
302 tracked = null;
303 $.ui.invalidate("ui.render");
304 return next(e);
305 }
306
307 if (details.headRefOid !== tracked.head) {
308 if (tracked.mode === "watch") {
309 // A watched PR is a demo of any PR: follow the new head.
310 tracked = { ...tracked, head: details.headRefOid };
311 await doTick($, key);
312 return next(e);
313 }
314 stopTick();
315 await $.store.set(key, stamp({
316 error: "head moved, stopped",
317 }));
318 tracked = null;
319 $.ui.invalidate("ui.render");
320 return next(e);
321 }
322
323 return next(e);
324 });
325
326 // Render the AbovePrompt band. Compose, never replace: next(e) always runs so
327 // Claude Code's own band and every other plugin's AbovePrompt drawing survive,
328 // and the lights sit above that tree in one column. The downstream tree is an
329 // opaque engine node: it is placed, never mutated. Every node drawn here comes
330 // from $.ui.resolve(e); a plain { type: "Box" } object never draws.
331 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
332 const downstream = await next(e);
333 const repo = await $.session.repo();
334 const stored = own(await $.store.get(keyFor(repo)));
335
336 if (!stored) return downstream;
337
338 let band: unknown = null;
339 if (stored.lights && stored.prNumber) {
340 const els = await $.ui.resolve(e);
341 band = buildBand(
342 els,
343 stored.label ?? `promote #${stored.prNumber}`,
344 stored.lights,
345 stored.head ?? "",
346 stored.mergeStateStatus ?? "",
347 stored.hold ?? false,
348 stored.degraded ?? false
349 );
350 } else if (stored.error) {
351 const els = await $.ui.resolve(e);
352 band = buildErrorBand(els, stored.error);
353 }
354
355 if (band === null) return downstream;
356 const { Box } = await $.ui.resolve(e);
357 return Box({
358 flexDirection: "column",
359 children: downstream ? [band, downstream] : [band],
360 });
361 });
362
363 // Manual control command
364 on("command.run", { command: "lights" }, async ($, e) => {
365 const trimmed = (e.args ?? "").trim();
366 const [arg] = trimmed.split(/\s+/);
367 const repo = await $.session.repo();
368 const key = keyFor(repo);
369
370 if (arg === "off") {
371 stopTick();
372 tracked = null;
373 await $.store.delete(key);
374 $.ui.invalidate("ui.render");
375 return { text: "lights: stopped" };
376 }
377
378 if (arg === "refresh") {
379 if (tracked) await doTick($, key);
380 return { text: "lights: refreshed" };
381 }
382
383 if (arg === "watch") {
384 const target = parseWatchTarget(trimmed.slice("watch".length));
385 if (!target) return { text: WATCH_USAGE };
386 const outcome = await startWatch($, key, target);
387 return { text: outcome };
388 }
389
390 // Any other non-empty argument is a target guess: a bare owner/repo#N,
391 // owner/repo N, or a PR URL starts the same watch as /lights watch.
392 // What does not parse gets its own line naming the word, never the
393 // no-PR hint (which says nothing about an argument it did not read).
394 if (trimmed) {
395 const target = parseWatchTarget(trimmed);
396 if (!target) return { text: unknownArgHint(arg) };
397 const outcome = await startWatch($, key, target);
398 return { text: outcome };
399 }
400
401 const stored = own(await $.store.get(key));
402
403 if (stored && stored.lights) {
404 return {
405 text: buildStatusLine(
406 stored.prNumber ?? 0,
407 stored.lights,
408 stored.mergeStateStatus ?? "",
409 stored.hold ?? false,
410 stored.label,
411 stored.degraded ?? false
412 ),
413 };
414 }
415 if (stored && stored.error) return { text: `lights: ${stored.error}` };
416 const promoteHead = (await $.env.get("PROMOTE_HEAD").catch(() => undefined)) || DEFAULT_PROMOTE_HEAD;
417 return { text: noPrHint(promoteHead) };
418 });
419};
420
421/**
422 * Point the tick at any open PR. Returns the text /lights watch answers with.
423 */
424async function startWatch($: Hook$, key: string, target: WatchTarget): Promise<string> {
425 const view = await $.process.run(prViewArgv(target), { timeoutMs: 10000 });
426 const details = view.exitCode === 0 ? parsePRView(view.stdout) : null;
427 const name = formatWatchTarget(target);
428 if (!details) {
429 await $.store.set(key, stamp({ error: `watch ${name}: gh pr view failed` }));
430 $.ui.invalidate("ui.render");
431 return `lights: could not read ${name} (gh pr view exit ${view.exitCode})`;
432 }
433 if (details.state !== "OPEN") {
434 return `lights: ${name} is ${details.state}, not open`;
435 }
436
437 stopTick();
438 tracked = {
439 owner: target.owner,
440 repo: target.repo,
441 number: target.number,
442 head: details.headRefOid,
443 mode: "watch",
444 // The watched PR's own base; main only when gh did not report one.
445 base: details.baseRefName || "main",
446 degraded: false,
447 };
448 startTick($, key);
449 const snapshot = await doTick($, key);
450 if (snapshot?.lights) {
451 return buildStatusLine(target.number, snapshot.lights, snapshot.mergeStateStatus ?? "", false, labelFor(tracked), tracked.degraded);
452 }
453 if (snapshot?.error) return `lights: ${snapshot.error}`;
454 return `lights: watching ${name}`;
455}
456
457function startTick($: Hook$, key: string): void {
458 ticking = true;
459 tickInterval = $.clock.every(TICK_MS, () => doTick($, key));
460}
461
462/** One tick: fetch, classify, store, draw. Returns what it stored. */
463async function doTick($: Hook$, key: string): Promise<StoredLights | null> {
464 if (!tracked) return null;
465
466 const t = tracked;
467 const { owner, repo, number: prNumber, head } = t;
468 const base = encodeURIComponent(t.base);
469
470 try {
471 const [protection, rulesets] = await Promise.all([
472 $.process.run(["gh", "api", `repos/${owner}/${repo}/branches/${base}/protection`], { timeoutMs: 10000 }),
473 $.process.run(["gh", "api", `repos/${owner}/${repo}/rules/branches/${base}`], { timeoutMs: 10000 }),
474 ]);
475
476 let requiredContexts = computeRequiredUnion(
477 protection.stdout ?? "",
478 rulesets.stdout ?? ""
479 );
480
481 if (requiredContexts.length === 0 && t.mode === "promote") {
482 // A promote verdict with nothing required would be a green lie.
483 const refused: StoredLights = { error: "empty required contexts", prNumber };
484 await $.store.set(key, stamp(refused));
485 $.ui.invalidate("ui.render");
486 return refused;
487 }
488
489 const checkRunsResult = await $.process.run([
490 "gh",
491 "api",
492 `repos/${owner}/${repo}/commits/${head}/check-runs?per_page=100`,
493 ], { timeoutMs: 10000 });
494 const checkRuns = parseCheckRuns(checkRunsResult.stdout ?? "");
495
496 if (requiredContexts.length === 0) {
497 // Watch mode on an unprotected repo: show every check that ran.
498 requiredContexts = allCheckNames(checkRuns.check_runs ?? []);
499 if (requiredContexts.length === 0) {
500 const empty: StoredLights = {
501 error: `watch ${formatWatchTarget({ owner, repo, number: prNumber })}: no check runs on ${head.slice(0, 7)}`,
502 prNumber,
503 };
504 await $.store.set(key, stamp(empty));
505 $.ui.invalidate("ui.render");
506 return empty;
507 }
508 }
509
510 const lights = matchAndClassify(requiredContexts, checkRuns.check_runs ?? []);
511
512 const prViewResult = await $.process.run(prViewArgv(t), { timeoutMs: 10000 });
513
514 const prDetails = parsePRView(prViewResult.stdout ?? "");
515 const mergeStateStatus = prDetails?.mergeStateStatus ?? "";
516 const passing = isPassing(lights);
517 const label = labelFor(t);
518
519 const snapshot: StoredLights = {
520 prNumber,
521 head,
522 label,
523 mode: t.mode,
524 lights,
525 mergeStateStatus,
526 hold: false,
527 degraded: t.degraded,
528 passing,
529 ts: Date.now(),
530 };
531 await $.store.set(key, stamp(snapshot));
532
533 // The band draws this state; a status line too showed it twice. Clear
534 // it (also drops a startup warning, which the band's DEGRADED now keeps).
535 // A collapsed band is not visible to a mod, so /lights answers the line.
536 await clearStatus($);
537 $.ui.invalidate("ui.render");
538 return snapshot;
539 } catch (err) {
540 const failed: StoredLights = { error: String(err), prNumber };
541 await $.store.set(key, stamp(failed));
542 $.ui.invalidate("ui.render");
543 return failed;
544 }
545}
546
547function stopTick(): void {
548 tickInterval?.cancel();
549 ticking = false;
550 tickInterval = null;
551}
552
553/**
554 * Best effort status sink for warnings: a rejection here must never
555 * abort the tracking that is already set up.
556 */
557async function warnStatus($: Hook$, message: string): Promise<void> {
558 try {
559 await $.ui.status(message);
560 } catch {
561 // Surfacing is best effort; tracking continues without it.
562 }
563}
564
565/** Best effort too: a rejected clear must never replace the stored lights with an error. */
566async function clearStatus($: Hook$): Promise<void> {
567 try {
568 await $.ui.status(undefined);
569 } catch {
570 // The band already carries the state; a stale line is cosmetic.
571 }
572}
573src/classify.ts 169 lines1/**
2 * Pure classifier for CI check status.
3 * Maps check-run conclusions to light colors.
4 * Cancelled gets its own bucket and is never pass.
5 * Missing is red (not run).
6 */
7
8export type LightColor = "green" | "yellow" | "red" | "cancelled";
9
10export interface CheckRun {
11 name: string;
12 status: string; // queued, in_progress, completed
13 conclusion: string | null; // success, failure, cancelled, timed_out, action_required, skipped
14}
15
16export interface ClassifiedLight {
17 name: string;
18 color: LightColor;
19 conclusion: string | null;
20}
21
22/**
23 * Classify a single check run into a light color.
24 * Rules from the brief:
25 * - success -> green
26 * - queued, in_progress, conclusion null -> yellow
27 * - cancelled -> cancelled (its own bucket, never pass)
28 * - failure, timed_out, action_required -> red
29 * - missing (no run found) -> red (not run)
30 */
31export function classifyCheckRun(run: CheckRun): ClassifiedLight {
32 const { conclusion, status } = run;
33
34 // Cancelled gets its own bucket
35 if (conclusion === "cancelled") {
36 return { name: run.name, color: "cancelled", conclusion };
37 }
38
39 // Success is green
40 if (conclusion === "success") {
41 return { name: run.name, color: "green", conclusion };
42 }
43
44 // Failure states are red
45 if (
46 conclusion === "failure" ||
47 conclusion === "timed_out" ||
48 conclusion === "action_required"
49 ) {
50 return { name: run.name, color: "red", conclusion };
51 }
52
53 // In-progress states are yellow
54 if (
55 status === "queued" ||
56 status === "in_progress" ||
57 conclusion === null
58 ) {
59 return { name: run.name, color: "yellow", conclusion };
60 }
61
62 // Skipped - we still classify but it depends on context
63 // In the brief, skipped runs don't count for the aggregate if there's a non-skipped run
64 // For classification purposes, we mark skipped as yellow (waiting)
65 if (conclusion === "skipped") {
66 return { name: run.name, color: "yellow", conclusion };
67 }
68
69 // Unknown - treat as red (not run)
70 return { name: run.name, color: "red", conclusion };
71}
72
73/**
74 * Aggregate light colors for the status line.
75 * Returns counts by color.
76 */
77export function aggregateLights(
78 lights: readonly ClassifiedLight[]
79): { green: number; yellow: number; red: number; cancelled: number } {
80 return {
81 green: lights.filter((l) => l.color === "green").length,
82 yellow: lights.filter((l) => l.color === "yellow").length,
83 red: lights.filter((l) => l.color === "red").length,
84 cancelled: lights.filter((l) => l.color === "cancelled").length,
85 };
86}
87
88/**
89 * Determine if the aggregate status is passing.
90 * Only an all-green set passes. Missing (red), pending or in progress
91 * (yellow), skipped (yellow with a skipped conclusion), cancelled, and any
92 * failing run all block the pass. A CI monitor that reports green when it
93 * cannot see is worse than no monitor (GH-4177 class of bug).
94 */
95export function isPassing(lights: readonly ClassifiedLight[]): boolean {
96 if (lights.length === 0) return false; // total_count: 0 is not-pass
97 return lights.every((l) => l.color === "green");
98}
99
100/**
101 * Given required contexts and check runs, match and classify.
102 *
103 * Order-independent worst case wins per context (GH-4177): reruns can put
104 * several runs under one name at the same sha and the API order is not a
105 * contract, so the verdict for a name is computed from the SET of its runs,
106 * never from which run arrived first or last. ANY non-skipped run under a
107 * name that is not success means that name is NOT passing: a pending,
108 * cancelled, failed, or unknown run each blocks the pass even when a green
109 * rerun sits next to it. The representative color shown is the worst color
110 * present: red beats cancelled beats yellow beats green. A name with no
111 * runs is red (not run).
112 *
113 * Skipped runs are the one exception (acct-nir-request-4, measured on Nir's
114 * Windows setup 2026-10-05): a rerun or a conditional leg leaves a skipped
115 * sibling under the same name, and worst-wins read [skipped, success] as
116 * yellow forever, so a green bundle printed "3 yellow". GitHub itself treats
117 * a skipped required check as satisfied, so skipped runs are dropped from
118 * the verdict pool when the name has any real verdict. A name whose runs
119 * are ALL skipped keeps its present colour: yellow (the check never ran,
120 * so it is not pass).
121 */
122export function matchAndClassify(
123 requiredContexts: string[],
124 checkRuns: CheckRun[]
125): ClassifiedLight[] {
126 const result: ClassifiedLight[] = [];
127
128 // Severity ranking for the representative color. Higher wins. green is
129 // only shown when every run under the name classified green.
130 const SEVERITY: Record<string, number> = {
131 green: 0,
132 yellow: 1,
133 cancelled: 2,
134 red: 3,
135 };
136
137 for (const ctx of requiredContexts) {
138 // Find all runs matching this context name
139 const matching = checkRuns.filter((r) => r.name === ctx);
140
141 if (matching.length === 0) {
142 // Missing - red (not run)
143 result.push({ name: ctx, color: "red", conclusion: null });
144 continue;
145 }
146
147 // Skipped runs carry no verdict: drop them when the name has any
148 // non-skipped run, so a skipped sibling cannot drag a success yellow.
149 // When every run under the name is skipped the pool stays the skipped
150 // set and the name keeps its present colour, yellow.
151 const verdicts = matching.filter((r) => r.conclusion !== "skipped");
152 const pool = verdicts.length > 0 ? verdicts : matching;
153
154 // Classify EVERY run in the pool and take the worst color. This is
155 // the order-independent core: sorting the same runs into any order
156 // yields the same worst color, so [success, in_progress] and
157 // [in_progress, success] both read pending, and a green rerun next to
158 // a failure never resuscitates the name.
159 const classified = pool.map((r) => classifyCheckRun(r));
160 const worst = classified.reduce((a, b) =>
161 SEVERITY[b.color] > SEVERITY[a.color] ? b : a
162 );
163
164 result.push(worst);
165 }
166
167 return result;
168}
169src/required.ts 96 lines1/**
2 * Compute the live union of required contexts from:
3 * 1. Branch protection (/branches/main/protection)
4 * 2. Rulesets (/rules/branches/main)
5 *
6 * Drops 404 bodies (lines that start with {).
7 * Refuses to return green when the union is empty.
8 */
9
10export interface ProtectionResponse {
11 required_status_checks?: {
12 contexts: string[];
13 };
14}
15
16export interface RulesetResponse {
17 rules?: Array<{
18 type: string;
19 parameters?: {
20 required_status_checks?: Array<{ context: string }>;
21 };
22 }>;
23}
24
25/**
26 * Parse protection response, extracting contexts.
27 */
28export function parseProtection(body: string): string[] {
29 // 404 body starts with { per brief - drop it
30 if (body.startsWith("{") && !body.includes("required_status_checks")) {
31 return [];
32 }
33
34 try {
35 const parsed: ProtectionResponse = JSON.parse(body);
36 return parsed.required_status_checks?.contexts ?? [];
37 } catch {
38 return [];
39 }
40}
41
42/**
43 * Parse rulesets response, extracting contexts from each rule.
44 *
45 * The GitHub rulesets API (GET /repos/{owner}/{repo}/rules/branches/{branch})
46 * returns a TOP-LEVEL ARRAY of rule objects, not an object with a rules key
47 * (GH-4165 review: the old parser expected {rules: []}, matched nothing, and
48 * silently dropped every ruleset-required context, fail-open). Parse the
49 * array; an object with a rules key is still tolerated for robustness.
50 */
51export function parseRulesets(body: string): string[] {
52 const trimmed = body.trim();
53 // 404 body starts with { per brief - drop it
54 if (trimmed.startsWith("{") && !trimmed.includes("rules")) {
55 return [];
56 }
57
58 try {
59 const parsed: unknown = JSON.parse(body);
60 // Real API shape: a top-level array of rules.
61 const rules: RulesetResponse["rules"] = Array.isArray(parsed)
62 ? (parsed as RulesetResponse["rules"])
63 : (parsed as RulesetResponse).rules ?? [];
64 const contexts: string[] = [];
65
66 for (const rule of rules ?? []) {
67 if (rule.parameters?.required_status_checks) {
68 for (const check of rule.parameters.required_status_checks) {
69 if (check.context) {
70 contexts.push(check.context);
71 }
72 }
73 }
74 }
75
76 return contexts;
77 } catch {
78 return [];
79 }
80}
81
82/**
83 * Compute the live union of required contexts.
84 */
85export function computeRequiredUnion(
86 protectionBody: string,
87 rulesetsBody: string
88): string[] {
89 const protection = parseProtection(protectionBody);
90 const rulesets = parseRulesets(rulesetsBody);
91
92 // Union with deduplication
93 const combined = new Set([...protection, ...rulesets]);
94 return Array.from(combined).sort();
95}
96src/gh.ts 203 lines1/**
2 * gh CLI wrapper for promote-lights.
3 * Read-only calls only, using the operator's own auth.
4 *
5 * Commands used:
6 * - gh pr list --base main --state open --json number,headRefName,headRefOid,title,labels
7 * - gh pr list with a head filter for the promote branch
8 * - gh pr list with a label filter for the promote label
9 * - gh api repos/{owner}/{repo}/branches/main/protection
10 * - gh api repos/{owner}/{repo}/rules/branches/main
11 * - gh api repos/{owner}/{repo}/commits/{head}/check-runs?per_page=100
12 * - gh api repos/{owner}/{repo}/commits/{head}/status
13 * - gh pr view N --json headRefOid,state,mergeStateStatus
14 *
15 * Also reads .github/branch-protection.json via fs.
16 */
17
18export interface PRInfo {
19 number: number;
20 headRefOid: string;
21 title: string;
22 headRefName?: string;
23 labels?: Array<{ name: string } | string>;
24}
25
26/**
27 * Promote PR matching.
28 *
29 * A real promote PR is an open PR with base main whose head is the
30 * promote branch (dev by default, PROMOTE_HEAD when configured) or
31 * which carries the promote label. Any other PR with base main is
32 * ordinary work and must never match.
33 */
34export const DEFAULT_PROMOTE_HEAD = "dev";
35export const PROMOTE_LABEL = "promote";
36
37/**
38 * Fields requested from the PR list endpoint.
39 */
40export const PR_LIST_FIELDS = "number,headRefName,headRefOid,title,labels";
41
42/**
43 * Page size for the filtered PR list queries. Each query is narrowed
44 * server side (one by head branch, one by label) so promote PRs are
45 * returned even when the repo has more open PRs than the gh default
46 * page of 30. Without the filters a promote PR past position 30 is
47 * silently missed.
48 */
49export const PR_LIST_LIMIT = "100";
50
51/**
52 * Query argv listing open PRs into main from the promote branch.
53 */
54export function buildHeadQueryArgs(promoteHead: string = DEFAULT_PROMOTE_HEAD): readonly string[] {
55 return [
56 "gh",
57 "pr",
58 "list",
59 "--base",
60 "main",
61 "--head",
62 promoteHead,
63 "--state",
64 "open",
65 "--json",
66 PR_LIST_FIELDS,
67 "--limit",
68 PR_LIST_LIMIT,
69 ];
70}
71
72/**
73 * Query argv listing open PRs into main carrying the promote label.
74 * This covers promote PRs raised from a head other than the promote
75 * branch, which the head query alone would miss.
76 */
77export function buildLabelQueryArgs(): readonly string[] {
78 return [
79 "gh",
80 "pr",
81 "list",
82 "--base",
83 "main",
84 "--label",
85 PROMOTE_LABEL,
86 "--state",
87 "open",
88 "--json",
89 PR_LIST_FIELDS,
90 "--limit",
91 PR_LIST_LIMIT,
92 ];
93}
94
95/**
96 * Merge PR list pages, deduped by PR number. Either query can return
97 * the same PR (a dev head PR that also carries the label), so the
98 * first occurrence wins.
99 */
100export function mergePRLists(...lists: PRInfo[][]): PRInfo[] {
101 const seen = new Map<number, PRInfo>();
102 for (const list of lists) {
103 for (const pr of list) {
104 if (!seen.has(pr.number)) seen.set(pr.number, pr);
105 }
106 }
107 return [...seen.values()];
108}
109
110export function isPromotePR(pr: PRInfo, promoteHead: string = DEFAULT_PROMOTE_HEAD): boolean {
111 if (pr.headRefName !== undefined && pr.headRefName === promoteHead) return true;
112 const labels = pr.labels ?? [];
113 return labels.some((label) => {
114 const name = typeof label === "string" ? label : label.name;
115 return name !== undefined && name.toLowerCase() === PROMOTE_LABEL;
116 });
117}
118
119export interface PRDetails {
120 headRefOid: string;
121 state: string;
122 mergeStateStatus: string;
123 /** The PR's base branch; watch mode reads THIS branch's protection. */
124 baseRefName?: string;
125}
126
127export interface CheckRunsResponse {
128 total_count: number;
129 check_runs: Array<{
130 name: string;
131 status: string;
132 conclusion: string | null;
133 }>;
134}
135
136export interface StatusResponse {
137 statuses: Array<{
138 context: string;
139 state: string;
140 }>;
141}
142
143/**
144 * Parse PR list JSON output.
145 */
146export function parsePRList(stdout: string): PRInfo[] {
147 if (!stdout.trim()) return [];
148 try {
149 return JSON.parse(stdout);
150 } catch {
151 return [];
152 }
153}
154
155/**
156 * Parse PR view JSON output.
157 */
158export function parsePRView(stdout: string): PRDetails | null {
159 if (!stdout.trim()) return null;
160 try {
161 return JSON.parse(stdout);
162 } catch {
163 return null;
164 }
165}
166
167/**
168 * Parse check-runs API response.
169 */
170export function parseCheckRuns(body: string): CheckRunsResponse {
171 try {
172 return JSON.parse(body);
173 } catch {
174 return { total_count: 0, check_runs: [] };
175 }
176}
177
178/**
179 * Parse status API response.
180 */
181export function parseStatus(body: string): StatusResponse {
182 try {
183 return JSON.parse(body);
184 } catch {
185 return { statuses: [] };
186 }
187}
188
189/**
190 * Parse .github/branch-protection.json.
191 */
192export function parseBranchProtectionConfig(content: string): {
193 dev?: string[];
194 main?: string[];
195} | null {
196 if (!content.trim()) return null;
197 try {
198 return JSON.parse(content);
199 } catch {
200 return null;
201 }
202}
203src/pane.ts 190 lines1/**
2 * Band rendering for promote-lights.
3 * Draws a summary line plus one line per non-green check above the prompt.
4 * No Client module needed - static between ticks.
5 */
6
7import type { ClassifiedLight } from "./classify.ts";
8
9export const LIGHT_SYMBOLS: Record<string, string> = {
10 green: "\u{1F7E2}", // green circle
11 yellow: "\u{1F7E1}", // yellow circle
12 red: "\u{1F534}", // red circle
13 cancelled: "\u26A0\uFE0F", // warning sign
14};
15
16/**
17 * Build the status line string for $.ui.status().
18 * Format: "promote #N: 9/11 green, 1 yellow, 1 cancelled, BEHIND"
19 */
20export function buildStatusLine(
21 prNumber: number,
22 lights: ClassifiedLight[],
23 mergeStateStatus: string,
24 hold: boolean,
25 label?: string,
26 degraded = false
27): string {
28 const counts = {
29 green: lights.filter((l) => l.color === "green").length,
30 yellow: lights.filter((l) => l.color === "yellow").length,
31 red: lights.filter((l) => l.color === "red").length,
32 cancelled: lights.filter((l) => l.color === "cancelled").length,
33 };
34
35 const parts: string[] = [label ?? `promote #${prNumber}`];
36
37 if (counts.green > 0) parts.push(`${counts.green} green`);
38 if (counts.yellow > 0) parts.push(`${counts.yellow} yellow`);
39 if (counts.red > 0) parts.push(`${counts.red} red`);
40 if (counts.cancelled > 0) parts.push(`${counts.cancelled} cancelled`);
41
42 if (mergeStateStatus && mergeStateStatus !== "CLEAN") {
43 parts.push(mergeStateStatus);
44 }
45
46 if (hold) {
47 parts.unshift("HOLD");
48 }
49
50 if (degraded) {
51 parts.push("DEGRADED");
52 }
53
54 return parts.join(", ");
55}
56
57/**
58 * Build the band content for AbovePrompt.
59 * Returns an array of [name, symbol] pairs plus trailing info.
60 */
61export function buildBandContent(
62 lights: ClassifiedLight[],
63 headSha: string,
64 mergeStateStatus: string,
65 hold: boolean
66): Array<{ name: string; symbol: string }> {
67 const result: Array<{ name: string; symbol: string }> = lights.map((l) => ({
68 name: l.name,
69 symbol: LIGHT_SYMBOLS[l.color] ?? "?",
70 }));
71
72 // Add trailing info cell
73 const head = headSha.slice(0, 7);
74 const status = mergeStateStatus || "";
75 const holdPrefix = hold ? "HOLD " : "";
76
77 result.push({
78 name: `${holdPrefix}${head} ${status}`.trim(),
79 symbol: "",
80 });
81
82 return result;
83}
84
85/** Text color per light, as the terminal Text element takes it. */
86export const LIGHT_COLORS: Record<string, string> = {
87 green: "green",
88 yellow: "yellow",
89 red: "red",
90 cancelled: "yellow",
91};
92
93/** Element props: children plus whatever the element takes. */
94export type ElementProps = { children?: unknown } & Record<string, unknown>;
95/** A constructor from $.ui.resolve(e): Box, Text and the rest. */
96export type ElementCtor = (props?: ElementProps) => unknown;
97export type Elements = { Box: ElementCtor; Text: ElementCtor };
98
99/** Most problem lines drawn under the summary; the rest collapse into "+N more". */
100export const MAX_PROBLEM_LINES = 5;
101
102/** Order for the problem lines: red first, then cancelled, then yellow. */
103const PROBLEM_ORDER: Record<string, number> = { red: 0, cancelled: 1, yellow: 2 };
104
105/** The non-green lights, red then cancelled then yellow, stable within a color. */
106export function problemLights(lights: ClassifiedLight[]): ClassifiedLight[] {
107 return lights
108 .map((l, i) => ({ l, i }))
109 .filter(({ l }) => l.color !== "green")
110 .sort((a, b) => (PROBLEM_ORDER[a.l.color] ?? 0) - (PROBLEM_ORDER[b.l.color] ?? 0) || a.i - b.i)
111 .map(({ l }) => l);
112}
113
114/** The summary label: "#N" for a promote PR, "owner/repo#N" when watching. */
115export function summaryLabel(label: string): string {
116 return label.replace(/^(watch|promote) /, "");
117}
118
119/**
120 * Build the AbovePrompt band from the elements $.ui.resolve(e) hands out.
121 * A plain { type: "Box" } object is not an element on CC 2.1.282 and never
122 * draws, so every node here comes from a constructor.
123 *
124 * Layout (approved 2026-09-26): one summary line always, then one line per
125 * check that is not green, full name, red first. All green is one line.
126 *
127 * 🚦 #4435 3afff24 BLOCKED 19 🟢 0 🟡 2 🔴
128 * 🔴 PR Playground
129 * 🔴 CI Summary
130 */
131export function buildBand(
132 els: Elements,
133 label: string,
134 lights: ClassifiedLight[],
135 headSha: string,
136 mergeStateStatus: string,
137 hold: boolean,
138 degraded = false
139): unknown {
140 const { Box, Text } = els;
141 const count = (color: string) => lights.filter((l) => l.color === color).length;
142 const green = count("green");
143 const yellow = count("yellow");
144 const red = count("red");
145 const cancelled = count("cancelled");
146
147 const runs: unknown[] = [];
148 if (hold) runs.push(Text({ color: "red", bold: true, children: "HOLD " }));
149 runs.push(Text({ bold: true, children: `\u{1F6A6} ${summaryLabel(label)} ` }));
150 const state = `${headSha.slice(0, 7)} ${mergeStateStatus}`.trim();
151 if (state) runs.push(Text({ dimColor: true, children: `${state} ` }));
152 runs.push(Text({ color: "green", children: `${green} ${LIGHT_SYMBOLS.green} ` }));
153 runs.push(Text({ color: "yellow", children: `${yellow} ${LIGHT_SYMBOLS.yellow} ` }));
154 runs.push(Text({ color: "red", children: `${red} ${LIGHT_SYMBOLS.red}` }));
155 if (cancelled > 0) {
156 runs.push(Text({ color: "yellow", children: ` ${cancelled} ${LIGHT_SYMBOLS.cancelled}` }));
157 }
158 if (degraded) runs.push(Text({ dimColor: true, children: " DEGRADED" }));
159
160 const lines: unknown[] = [Text({ children: runs })];
161 const problems = problemLights(lights);
162 for (const l of problems.slice(0, MAX_PROBLEM_LINES)) {
163 lines.push(
164 Text({
165 color: LIGHT_COLORS[l.color] ?? "red",
166 children: ` ${LIGHT_SYMBOLS[l.color] ?? "?"} ${l.name}`,
167 })
168 );
169 }
170 if (problems.length > MAX_PROBLEM_LINES) {
171 lines.push(Text({ dimColor: true, children: ` +${problems.length - MAX_PROBLEM_LINES} more` }));
172 }
173 return Box({ flexDirection: "column", children: lines });
174}
175
176/** One dim line saying why there are no lights, so a failure is never blank. */
177export function buildErrorBand(els: Elements, error: string): unknown {
178 const { Box, Text } = els;
179 return Box({ children: [Text({ dimColor: true, children: `lights: ${error}` })] });
180}
181
182/**
183 * Shorten context name for display.
184 * Truncates to ~20 chars.
185 */
186export function shortName(name: string, maxLen = 20): string {
187 if (name.length <= maxLen) return name;
188 return name.slice(0, maxLen - 1) + "\u2026"; // ellipsis
189}
190src/watch.ts 77 lines1// Generated by OrchestKit Claude Plugin
2// Created: 2026-09-25
3
4/**
5 * Watch targets for promote-lights demo mode.
6 *
7 * `/lights watch <target>` (or PROMOTE_LIGHTS_WATCH=<target>) tracks any open
8 * PR in any repo, so the band can be shown without a live promote PR.
9 * Accepted target forms:
10 * - owner/repo#123
11 * - owner/repo 123
12 * - https://github.com/owner/repo/pull/123
13 */
14
15export interface WatchTarget {
16 owner: string;
17 repo: string;
18 number: number;
19}
20
21const NAME = "[A-Za-z0-9_.-]+";
22const HASH_FORM = new RegExp(`^(${NAME})/(${NAME})#(\\d+)$`);
23const SPACE_FORM = new RegExp(`^(${NAME})/(${NAME})\\s+#?(\\d+)$`);
24const URL_FORM = new RegExp(`^https?://github\\.com/(${NAME})/(${NAME})/pull/(\\d+)(?:[/?#].*)?$`);
25
26export function parseWatchTarget(input: string | undefined | null): WatchTarget | null {
27 const text = (input ?? "").trim();
28 if (!text) return null;
29 for (const form of [HASH_FORM, SPACE_FORM, URL_FORM]) {
30 const m = form.exec(text);
31 if (m) {
32 const number = Number(m[3]);
33 if (!Number.isSafeInteger(number) || number <= 0) return null;
34 return { owner: m[1], repo: m[2], number };
35 }
36 }
37 return null;
38}
39
40export function formatWatchTarget(t: WatchTarget): string {
41 return `${t.owner}/${t.repo}#${t.number}`;
42}
43
44/**
45 * Every distinct check-run name on a head, in first-seen order. Used when the
46 * watched repo protects nothing: the lights then cover every check that ran,
47 * and matchAndClassify takes the worst non-skipped run per name.
48 */
49export function allCheckNames(runs: ReadonlyArray<{ name: string }>): string[] {
50 const seen = new Set<string>();
51 const names: string[] = [];
52 for (const r of runs) {
53 if (!seen.has(r.name)) {
54 seen.add(r.name);
55 names.push(r.name);
56 }
57 }
58 return names;
59}
60
61export const WATCH_USAGE = "usage: /lights watch owner/repo#123 (for example cli/cli#1; also owner/repo 123, or a PR URL)";
62
63/**
64 * What a bare /lights answers when nothing is tracked. Names the promote
65 * head the session search used (dev unless PROMOTE_HEAD overrides it): on a
66 * repo whose promote branch is named anything else, the bare "no promote PR
67 * open" read as a bug instead of a lookup that found nothing.
68 */
69export function noPrHint(promoteHead: string): string {
70 return `no promote PR open with head "${promoteHead}" into main (set PROMOTE_HEAD to look for another); try /lights watch owner/repo#123, for example cli/cli#1`;
71}
72
73/** An argument that is neither a subcommand nor a parseable target gets its own line. */
74export function unknownArgHint(arg: string): string {
75 return `lights: unknown argument "${arg}"; try /lights watch owner/repo#123, for example cli/cli#1`;
76}
77