@litfamily/lithermes 1.0.32 → 1.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +7 -7
  2. package/README_Ko-KR.md +7 -7
  3. package/assets/lithermes-plugin/README.md +0 -12
  4. package/assets/lithermes-plugin/adhd_stack.py +43 -1
  5. package/assets/lithermes-plugin/auto_handoff.py +13 -2
  6. package/assets/lithermes-plugin/bounded_work.py +33 -3
  7. package/assets/lithermes-plugin/core.py +59 -18
  8. package/assets/lithermes-plugin/core_review.py +2 -2
  9. package/assets/lithermes-plugin/lit_mark.py +2 -7
  10. package/assets/lithermes-plugin/payload-version.json +43 -47
  11. package/assets/lithermes-plugin/plugin.yaml +1 -1
  12. package/assets/lithermes-plugin/rules/bundled-rules/baseline-discipline.md +7 -13
  13. package/assets/lithermes-plugin/rules/globmatch.py +1 -1
  14. package/assets/lithermes-plugin/skills/debugging/SKILL.md +1 -1
  15. package/assets/lithermes-plugin/skills/debugging/references/methodology/00-setup.md +1 -1
  16. package/assets/lithermes-plugin/skills/debugging/references/methodology/02-investigate.md +2 -2
  17. package/assets/lithermes-plugin/skills/debugging/references/methodology/09-cleanup.md +1 -1
  18. package/assets/lithermes-plugin/skills/debugging/references/runtimes/native-binary.md +1 -1
  19. package/assets/lithermes-plugin/skills/debugging/references/runtimes/rust.md +4 -4
  20. package/assets/lithermes-plugin/skills/debugging/references/tools/ghidra.md +1 -1
  21. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/nuxtjs.csv +1 -1
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/manifest.json +2 -2
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -2
  24. package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +10 -10
  25. package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +12 -7
  26. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +81 -27
  27. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/next-step.mjs +34 -6
  28. package/assets/lithermes-plugin/skills/lit-burnoff/SKILL.md +1 -1
  29. package/assets/lithermes-plugin/skills/lit-code/SKILL.md +4 -4
  30. package/assets/lithermes-plugin/skills/lit-code/references/rust/README.md +2 -2
  31. package/assets/lithermes-plugin/skills/lit-code/references/rust/async-tokio.md +4 -4
  32. package/assets/lithermes-plugin/skills/lit-code/references/rust/axum-stack.md +1 -1
  33. package/assets/lithermes-plugin/skills/lit-code/references/rust/clap-stack.md +1 -1
  34. package/assets/lithermes-plugin/skills/lit-code/references/rust/libraries.md +3 -3
  35. package/assets/lithermes-plugin/skills/lit-code/references/rust/proptest-insta.md +10 -10
  36. package/assets/lithermes-plugin/skills/lit-code/references/rust/zero-cost-safety.md +1 -1
  37. package/assets/lithermes-plugin/skills/lit-code/references/typescript/README.md +2 -2
  38. package/assets/lithermes-plugin/skills/lit-code/references/typescript/backend-hono.md +1 -1
  39. package/assets/lithermes-plugin/skills/lit-code/references/typescript/tsconfig-strict.md +1 -1
  40. package/assets/lithermes-plugin/skills/lit-code/scripts/typescript/check-no-excuse-rules.ts +2 -2
  41. package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  42. package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package.json +2 -1
  43. package/assets/lithermes-plugin/skills/rules/SKILL.md +1 -1
  44. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +2 -2
  45. package/assets/{lithermes-plugin → npm-channel}/auto_update.py +43 -9
  46. package/package.json +1 -1
  47. package/readme-assets/badge-version.svg +1 -1
  48. package/src/lib/canonicalCorpus.js +1 -1
  49. package/src/lib/install.js +22 -0
  50. package/src/lib/skillPayload.js +10 -5
@@ -53,12 +53,16 @@ surfaces; a session id with no recorded value reads `unknown`.
53
53
  grant settles it. The brief never starts, approves, or denies anything itself.
54
54
  5. **One next step in a box.** Exactly the `next` record: what it does, then the slash command
55
55
  (`/start-work plans/<f>.md`) when `next.command` names one, otherwise "continue in chat".
56
- When `next` is `none`, the first item under `## Next` in this worktree's own HANDOFF.md
57
- (`handoffs[].next`, `local: true`) comes first (`HANDOFF.md Next: <item>`); without one the
58
- box shows the suggestion — a waiting session to answer, an open plan item, another active
59
- session to check, another ended one to pick up — and the headline names it; it is shown,
60
- never recorded. Another worktree's handoff never supplies the step. Never a second
61
- candidate, never a `go` to type: Hermes has no typed `go` command.
56
+ When `next` is `wait-lanes`, other lanes are at work (`lanes.count`): the box says to wait
57
+ for them or to answer the one waiting on the user, names no command, and the last line is
58
+ `Do not type \`go\` until they finish.` (`지금은 \`go\`는 입력하지 마세요.`).
59
+ When `next` is `none`, the box shows the suggestion, in this order — a waiting session to
60
+ answer, lanes at work to wait for (same last line), an open plan item, the first open item
61
+ under `## Next` in this worktree's newest HANDOFF file (`HANDOFF.md Next: <item>`; items
62
+ marked ✅, ✓, `[x]`, ~~struck~~, DONE or SHIPPED are skipped), another active session to
63
+ check, another ended one to pick up — and the headline names it; it is shown, never
64
+ recorded. Another worktree's handoff never supplies the step. Never a second candidate,
65
+ never a `go` to type: Hermes has no typed `go` command.
62
66
 
63
67
  ## Surface notes (C, D)
64
68
 
@@ -69,7 +73,8 @@ surfaces; a session id with no recorded value reads `unknown`.
69
73
 
70
74
  ## Closing lines
71
75
 
72
- - Name every `unknown` source once on the last line (`unknown: herdr, git`).
76
+ - Name every `unknown` source once on the last line (`unknown: herdr, git`); while lanes work,
77
+ the no-`go` line follows it as the very last line.
73
78
  - Say "older sessions were not read" when `budget.exhausted` is true.
74
79
  - Say "N plans were not read (read budget used up)" when `roadmap.plansSkipped` is above zero.
75
80
  Plans are read before session transcripts, so this happens only when the plans themselves
@@ -11,7 +11,7 @@ import { basename, dirname, join, resolve } from 'node:path';
11
11
 
12
12
  import { parsePlanTaskRows } from './plan-task-rows.mjs';
13
13
  import { aggregateProgress, clipToWidth, displayWidth, progressOf } from './gauge.mjs';
14
- import { chooseNextStep, suggestNext, writeNextStep } from './next-step.mjs';
14
+ import { chooseNextStep, suggestNext, waitText, writeNextStep } from './next-step.mjs';
15
15
  import { makeRedactor } from './redact.mjs';
16
16
  import { findHermesSessions, hermesSessionRecord, hermesStorePath, makeBudget, normalizeRoot, readHermesMessages } from './hermes-sessions.mjs';
17
17
 
@@ -164,33 +164,42 @@ const goalsOf = (root, budget, redact) => {
164
164
  });
165
165
  };
166
166
 
167
- // The first item under a `## Next` (or `## Next steps`) heading, its list marker removed.
167
+ // A Next item already marked finished: a tick, a checked box, a struck line, or DONE/SHIPPED
168
+ // written in capitals (lowercase "done" in ordinary prose stays open).
169
+ const finishedItem = (raw, line) =>
170
+ /[✅✓]/u.test(line) || /^(?:[-*+]|\d+[.)])\s+\[[xX]\]/u.test(raw) || /^~~.*~~$/u.test(line) || /\b(?:DONE|SHIPPED)\b/u.test(line);
171
+
172
+ // The first open item under a `## Next` (or `## Next steps`) heading, its list marker removed.
168
173
  const nextItemOf = (text) => {
169
174
  let inside = false;
170
175
  for (const raw of text.split('\n')) {
171
- const line = raw.trim();
172
- if (/^#{1,6}\s/u.test(line)) {
176
+ const trimmed = raw.trim();
177
+ if (/^#{1,6}\s/u.test(trimmed)) {
173
178
  if (inside) return null;
174
- inside = /^#{1,6}\s+next(?:\s+steps?)?$/iu.test(line);
175
- } else if (inside && line) {
176
- return line.replace(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/u, '');
179
+ inside = /^#{1,6}\s+next(?:\s+steps?)?$/iu.test(trimmed);
180
+ } else if (inside && trimmed) {
181
+ const item = trimmed.replace(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/u, '');
182
+ if (!finishedItem(trimmed, item)) return item;
177
183
  }
178
184
  }
179
185
  return null;
180
186
  };
181
187
 
182
- // Only this worktree's own handoff offers a `next`; another worktree's never does.
188
+ // Only this worktree's own handoff offers a `next`, and only from its newest file: an old root
189
+ // HANDOFF.md never outranks a newer one (the dedicated `.handoff/HANDOFF.md` wins a tie).
190
+ // Another worktree's handoff never offers one.
183
191
  const handoffsOf = (root, budget, redact, local) => {
184
192
  const files = (entries(root) ?? [])
185
193
  .filter((entry) => entry.isFile() && /^HANDOFF.*\.md$/u.test(entry.name))
186
194
  .map((entry) => join(root, entry.name));
187
195
  const dotted = join(root, '.handoff', 'HANDOFF.md');
188
196
  if (existsSync(dotted)) files.push(dotted);
189
- return files.map((path) => {
190
- const text = readSmallFile(path, budget) ?? '';
191
- const headings = text.split('\n').slice(0, 40).filter((line) => /^#{1,3} /u.test(line)).map((line) => clipToWidth(redact(line), 100));
192
- const next = local ? nextItemOf(text) : null;
193
- return { worktree: redact(root), file: basename(path), headings, local, next: next ? clipToWidth(redact(next), 120) : null };
197
+ const read = files.map((path) => ({ path, text: readSmallFile(path, budget) ?? '', mtimeMs: mtimeOf(path) ?? 0 }));
198
+ const newest = read.reduce((best, item) => (best === null || item.mtimeMs > best.mtimeMs || (item.mtimeMs === best.mtimeMs && item.path === dotted) ? item : best), null);
199
+ return read.map((item) => {
200
+ const headings = item.text.split('\n').slice(0, 40).filter((line) => /^#{1,3} /u.test(line)).map((line) => clipToWidth(redact(line), 100));
201
+ const next = local && item === newest ? nextItemOf(item.text) : null;
202
+ return { worktree: redact(root), file: basename(item.path), headings, local, next: next ? clipToWidth(redact(next), 120) : null };
194
203
  });
195
204
  };
196
205
 
@@ -233,7 +242,7 @@ const stacksOf = (root, budget, redact) => {
233
242
  // Hermes start-work state: newest `runs/*/state.json` with `command: start-work` (a run has no
234
243
  // status field, so an unfinished plan reads active and a finished plan reads done), plus
235
244
  // bounded-work `work/*/state.json` records, which carry a real status and a pending boundary.
236
- const startWorkOf = (root, budget, redact, sessionId) => {
245
+ const startWorkOf = (root, budget, redact) => {
237
246
  const items = [];
238
247
  const runs = entries(join(root, '.hermes', 'lithermes', 'runs')) ?? [];
239
248
  let newestRun = null;
@@ -254,8 +263,9 @@ const startWorkOf = (root, budget, redact, sessionId) => {
254
263
  plan: plan ? redact(plan) : 'unknown',
255
264
  status: rows.length && !open.length ? 'done' : 'active',
256
265
  atMs: Date.parse(String(newestRun.created_at ?? '')),
257
- sessionId: sessionId ?? null,
258
- id8: sessionId ? String(sessionId).slice(0, 8) : 'unknown',
266
+ // A start-work run records no session, so it is never assumed to be this session's.
267
+ sessionId: null,
268
+ id8: 'unknown',
259
269
  progress: rows.length
260
270
  ? progressOf(rows.length - open.length, rows.length, open.map((row) => clipToWidth(redact(row.text), NAME_MAX)))
261
271
  : progressOf(null, null),
@@ -295,16 +305,53 @@ const startWorkOf = (root, budget, redact, sessionId) => {
295
305
  return items.length ? items : null;
296
306
  };
297
307
 
298
- const herdrOf = (env, exec, redact) => {
308
+ const insideAny = (dir, roots) =>
309
+ typeof dir === 'string' && dir !== '' && roots.some((root) => dir === root || dir.startsWith(`${root}/`));
310
+
311
+ // `herdr agent list` prints one JSON document. An agent is a lane when it is working or blocked
312
+ // in this herdr workspace or inside one of this repo's worktrees; this pane is never a lane.
313
+ // Anything unreadable leaves herdr `unknown` with no lanes.
314
+ const herdrOf = (env, exec, redact, roots) => {
299
315
  if (env.HERDR_ENV !== '1') return { status: 'unknown', agents: [] };
300
316
  try {
301
317
  const out = exec('herdr', ['agent', 'list'], { timeout: HERDR_TIMEOUT_MS });
302
- return { status: 'ok', agents: out.split('\n').filter((line) => line.trim()).slice(0, 20).map((line) => clipToWidth(redact(line.trim()), 120)) };
318
+ const listed = JSON.parse(out)?.result?.agents;
319
+ if (!Array.isArray(listed)) return { status: 'unknown', agents: [] };
320
+ const agents = listed.slice(0, 40).filter((agent) => agent && typeof agent === 'object').map((agent) => {
321
+ const status = typeof agent.agent_status === 'string' ? agent.agent_status : 'unknown';
322
+ const pane = typeof agent.pane_id === 'string' ? agent.pane_id : 'unknown';
323
+ const here = (env.HERDR_WORKSPACE_ID && agent.workspace_id === env.HERDR_WORKSPACE_ID)
324
+ || insideAny(agent.cwd, roots) || insideAny(agent.foreground_cwd, roots);
325
+ const session = typeof agent.agent_session?.value === 'string' ? agent.agent_session.value : null;
326
+ return {
327
+ agent: clipToWidth(redact(String(agent.agent ?? 'unknown')), 40),
328
+ status,
329
+ pane: clipToWidth(redact(pane), 40),
330
+ lane: ['working', 'blocked'].includes(status) && Boolean(here) && !(env.HERDR_PANE_ID && pane === env.HERDR_PANE_ID),
331
+ sessionId: session,
332
+ };
333
+ });
334
+ return { status: 'ok', agents };
303
335
  } catch {
304
336
  return { status: 'unknown', agents: [] };
305
337
  }
306
338
  };
307
339
 
340
+ // Lanes: other live sessions of this repo (active or waiting on the user) plus herdr lane agents;
341
+ // an agent whose session is already counted, or is this session, counts once or not at all.
342
+ const lanesOf = (sessions, herdr, sessionId) => {
343
+ const others = sessions.filter((session) => session.sessionId !== sessionId && ['active', 'waiting-on-user'].includes(session.status));
344
+ const counted = new Set(others.map((session) => session.sessionId));
345
+ const agents = herdr.agents.filter((agent) => agent.lane && !(agent.sessionId && (counted.has(agent.sessionId) || agent.sessionId === sessionId)));
346
+ const waiting = others.find((session) => session.status === 'waiting-on-user');
347
+ return {
348
+ count: others.length + agents.length,
349
+ sessions: others.map((session) => session.id8),
350
+ agents: agents.map((agent) => agent.pane),
351
+ waiting: waiting ? waiting.id8 : null,
352
+ };
353
+ };
354
+
308
355
  const known = (list) => (list === null ? 'unknown' : 'ok');
309
356
  // A run records its plan as an absolute path; inside its own worktree it reads `plans/<f>.md`.
310
357
  const planInWorktree = (worktree, plan) => (plan.startsWith(`${worktree}/`) ? plan.slice(worktree.length + 1) : plan);
@@ -350,7 +397,7 @@ export const collectAdhd = ({
350
397
  });
351
398
  const handoffs = roots.flatMap((root) => handoffsOf(root, budget, redact, resolve(root) === start));
352
399
  const workflows = collectEach((root) => stacksOf(root, budget, redact));
353
- const startWork = collectEach((root) => startWorkOf(root, budget, redact, sessionId));
400
+ const startWork = collectEach((root) => startWorkOf(root, budget, redact));
354
401
 
355
402
  const found = findHermesSessions(hermesStorePath(env), roots, { exec });
356
403
  const sessions = [];
@@ -364,7 +411,7 @@ export const collectAdhd = ({
364
411
  }
365
412
 
366
413
  const commits = collectEach((root) => commitsOf(root, sinceMs, now, exec, redact));
367
- const herdr = herdrOf(env, exec, redact);
414
+ const herdr = herdrOf(env, exec, redact, roots.map((root) => resolve(root)));
368
415
 
369
416
  // A session waits on the user when it asked something, or holds a paused boundary.
370
417
  for (const session of sessions) {
@@ -382,6 +429,7 @@ export const collectAdhd = ({
382
429
  .map(({ hash, subject }) => ({ hash, subject }));
383
430
  }
384
431
  sessions.sort((a, b) => statusRank[a.status] - statusRank[b.status] || (b.lastMs ?? 0) - (a.lastMs ?? 0));
432
+ const lanes = lanesOf(sessions, herdr, sessionId);
385
433
 
386
434
  // Open plans changed inside the window are listed, this worktree's first, then the most recently
387
435
  // changed, up to a short cap; a stale plan is left out here and elsewhere. The roadmap gauge
@@ -433,7 +481,8 @@ export const collectAdhd = ({
433
481
  startWork: startWork ?? [],
434
482
  handoffs,
435
483
  commits: (commits ?? []).slice(0, 20).map(({ atMs: _atMs, ...commit }) => commit),
436
- herdr,
484
+ herdr: { status: herdr.status, agents: herdr.agents.map(({ sessionId: _sessionId, ...agent }) => agent) },
485
+ lanes,
437
486
  };
438
487
  const next = chooseNextStep(data, sessionId);
439
488
  data.next = next;
@@ -561,7 +610,7 @@ const openerLines = (data, nextText) => {
561
610
  : active
562
611
  ? clipToWidth(goalOf(active), 80)
563
612
  : running[0]
564
- ? `start-work ${planInWorktree(running[0].worktree, running[0].plan)} ${running[0].progress.gauge} (another session)`
613
+ ? `start-work ${planInWorktree(running[0].worktree, running[0].plan)} ${running[0].progress.gauge}${running[0].id8 === 'unknown' ? '' : ' (another session)'}`
565
614
  : 'nothing running';
566
615
  return [`Done so far: ${done}`, `Doing now: ${doing}`, `Next: ${nextText}`];
567
616
  };
@@ -570,19 +619,23 @@ const openerLines = (data, nextText) => {
570
619
  export const renderAdhd = (data, width = 100, sessionId = null) => {
571
620
  const out = [];
572
621
  const push = (text) => out.push(line(text, width));
573
- const recorded = data.next.kind !== 'none';
574
- // With no step on record, this worktree's HANDOFF.md `## Next` item comes before any suggestion.
575
- const handoffNext = (data.handoffs ?? []).find((handoff) => handoff.local && handoff.next)?.next;
622
+ const waiting = data.next.kind === 'wait-lanes';
623
+ const recorded = data.next.kind !== 'none' && !waiting;
624
+ // With no runnable step on record, the suggestion ranks live signals (a session waiting on the
625
+ // user, lanes at work) and open plan items ahead of this worktree's HANDOFF.md `## Next` item.
576
626
  const step = recorded
577
627
  ? data.next
578
- : handoffNext
579
- ? { kind: 'suggest', label: 'handoff next', say: `HANDOFF.md Next: ${handoffNext}`, command: null }
628
+ : waiting
629
+ ? { kind: 'suggest', label: 'wait for lanes', say: waitText(data.next.target.lanes, data.next.target.waiting), command: null }
580
630
  : suggestNext(data, sessionId);
581
631
  const stepName = recorded ? data.next.kind.replace(/-/gu, ' ') : step.label;
582
632
  const stepSay = recorded && data.next.say ? ` (say "${data.next.say}")` : '';
583
633
  const stepRun = step.command ? ` (run ${step.command})` : recorded ? '' : ' (continue in chat)';
584
634
  const target = recorded ? data.next.target?.plan ?? data.next.target?.workId ?? '' : '';
585
635
  const nextText = recorded ? `${stepName}${target ? `: ${target}` : ''}${stepSay}${stepRun}` : `${step.say}${stepRun}`;
636
+ // While other lanes work, `go` has nothing to start. An own run is held back
637
+ // before it is recorded, so this line covers that case too.
638
+ const holdGo = (data.lanes?.count ?? 0) > 0 && !recorded;
586
639
  for (const opener of openerLines(data, nextText)) push(opener);
587
640
  push(`lit-adhd: ${data.summary.sessions} session(s) · ${data.summary.waitingOnUser} waiting on you · next: ${stepName}${stepSay}${stepRun}`);
588
641
  const live = data.sessions.filter((session) => session.status !== 'ended');
@@ -618,6 +671,7 @@ export const renderAdhd = (data, width = 100, sessionId = null) => {
618
671
  if (unknownSources.length) push(`unknown: ${unknownSources.join(', ')}`);
619
672
  const skipped = data.roadmap.plansSkipped ?? 0;
620
673
  if (skipped) push(`${skipped} ${skipped === 1 ? 'plan was' : 'plans were'} not read (read budget used up)`);
674
+ if (holdGo) push('Do not type `go` until they finish.');
621
675
  return `${out.join('\n')}\n`;
622
676
  };
623
677
 
@@ -4,10 +4,14 @@
4
4
  // user answers in words, and an active run simply continues in chat. The record `go` reads back
5
5
  // lives under the workspace's `.hermes/lithermes/adhd/` directory alongside the stack file.
6
6
  //
7
- // 1. a bounded-work run paused at a boundary -> approve-boundary (shown, never started)
8
- // 2. this session's own active run or bounded work -> continue-work (continue in chat)
9
- // 3. a single open plan with no work running -> start-plan (`/start-work plans/<f>.md`)
7
+ // 1. a bounded-work run paused at a boundary -> approve-boundary (shown, never started),
8
+ // even while other lanes are at work: that choice is the user's
9
+ // 2. this session's own active run or bounded work -> continue-work (continue in chat),
10
+ // or wait-lanes while other lanes are at work
11
+ // 3. a single open plan with no work running -> start-plan (`/start-work plans/<f>.md`),
12
+ // or wait-lanes while other lanes are at work (no command, and no `go` until they finish)
10
13
  // 4. otherwise -> none
14
+ // A paused discipline frame is not a resume. Hermes has no pausable stack to continue.
11
15
  import { closeSync, constants, lstatSync, mkdirSync, openSync, renameSync, writeSync } from 'node:fs';
12
16
  import { dirname, join } from 'node:path';
13
17
 
@@ -22,22 +26,40 @@ export const chooseNextStep = (data, sessionId) => {
22
26
  if (paused) {
23
27
  return { kind: 'approve-boundary', target: { workId: paused.workId, plan: paused.plan, ...paused.pendingBoundary }, say: null, automatic: false };
24
28
  }
29
+ const lanes = data.lanes?.count ?? 0;
25
30
  const active = sessionId
26
31
  ? (data.startWork ?? []).find((work) => work.status === 'active' && work.sessionId === sessionId)
27
32
  : null;
33
+ // The lane hold used to sit only on the start-plan branch, so this session's own
34
+ // run was still offered while the lanes it started were at work.
35
+ if (active && lanes > 0) {
36
+ return { kind: 'wait-lanes', target: { lanes, waiting: data.lanes?.waiting ?? null }, say: null };
37
+ }
28
38
  if (active) return { kind: 'continue-work', target: { workId: active.workId, plan: active.plan }, say: null };
29
39
  const running = (data.startWork ?? []).some((work) => ['active', 'paused'].includes(work.status));
30
40
  const open = data.roadmap.plans.filter((plan) => plan.local && plan.progress.total !== null && plan.progress.done < plan.progress.total);
31
41
  if (!running && open.length === 1) {
42
+ // Other lanes at work hold the plan back: the step is to wait (or answer the one waiting).
43
+ const lanes = data.lanes?.count ?? 0;
44
+ if (lanes > 0) {
45
+ return { kind: 'wait-lanes', target: { lanes, waiting: data.lanes.waiting ?? null, plan: open[0].plan }, say: null };
46
+ }
32
47
  return { kind: 'start-plan', target: { plan: open[0].plan }, say: null, command: `/start-work ${open[0].plan}` };
33
48
  }
34
49
  return { kind: 'none', target: null };
35
50
  };
36
51
 
52
+ const lanesText = (count) => `${count} lane${count === 1 ? '' : 's'}`;
53
+
54
+ /** The box text for a lanes wait: answer the lane waiting on the user, else wait for the rest. */
55
+ export const waitText = (lanes, waiting) =>
56
+ waiting ? `answer ${waiting} in chat (${lanesText(lanes)} at work)` : `wait for ${lanesText(lanes)} to finish`;
57
+
37
58
  // Shown, never recorded: when no step can run, the brief still recommends one from what
38
- // it read — a session that waits on the user, the next item of an open plan, the most
39
- // recent other active session, the most recent other ended one. Hermes has no typed `go`,
40
- // so every suggestion continues in chat except a plan item, which names its command.
59
+ // it read — a session that waits on the user, other lanes at work, the next item of an open
60
+ // plan, this worktree's newest HANDOFF Next item, the most recent other active session, the
61
+ // most recent other ended one. Live signals come first, so an old HANDOFF line never outranks
62
+ // them. Every suggestion continues in chat except a plan item, which names its command.
41
63
  export const suggestNext = (data, sessionId) => {
42
64
  const sessions = data.sessions ?? [];
43
65
  const waiting = sessions.filter((session) => session.status === 'waiting-on-user');
@@ -47,11 +69,17 @@ export const suggestNext = (data, sessionId) => {
47
69
  const clipped = question ? clipToWidth(bare1(question), 90) : null;
48
70
  return { kind: 'suggest', label: `answer ${first.id8}`, say: `Answer ${first.id8} in chat${clipped ? `: "${clipped}"` : ''}`, command: null };
49
71
  }
72
+ const lanes = data.lanes?.count ?? 0;
73
+ if (lanes > 0) {
74
+ return { kind: 'suggest', label: 'wait for lanes', say: waitText(lanes, null), command: null };
75
+ }
50
76
  const open = (data.roadmap?.plans ?? []).filter((plan) => plan.current);
51
77
  const plan = open.find((item) => item.local) ?? open[0];
52
78
  if (plan) {
53
79
  return { kind: 'suggest', label: `start ${plan.plan}`, say: `Next in ${plan.plan}: ${clipToWidth(bare1(plan.current), 60)}`, command: `/start-work ${plan.plan}` };
54
80
  }
81
+ const handoffNext = (data.handoffs ?? []).find((handoff) => handoff.local && handoff.next)?.next;
82
+ if (handoffNext) return { kind: 'suggest', label: 'handoff next', say: `HANDOFF.md Next: ${handoffNext}`, command: null };
55
83
  const others = sessions.filter((session) => session.sessionId !== sessionId);
56
84
  const active = others.find((session) => session.status === 'active');
57
85
  if (active) {
@@ -176,7 +176,7 @@ The agent looks for these nine categories. The first three are stylistic, the ne
176
176
  5. **Boundary violations** — wrong-layer imports (UI importing DB driver), leaky responsibilities (handler doing business logic that belongs in a service), hidden coupling (module A reads module B's private state), side effects in pure-named functions.
177
177
  - KEEP: pragmatic short-circuits already established as a pattern in this codebase. Flag for human judgment if unsure.
178
178
 
179
- 6. **Dead code** — unused imports, unused private functions/methods, unreachable branches, stale feature flags, debug leftovers (`console.log`, `print(...)`, `dbg!`), removed-but-still-referenced code.
179
+ 6. **Dead code** — unused imports, unused private functions/methods, unreachable branches, stale feature flags, debug leftovers (`console.log`, `print(...)`, `dbg!()), removed-but-still-referenced code.
180
180
  - KEEP: code referenced via reflection, dynamic dispatch, or string lookup. Code intentionally kept as a feature flag rollback path (verify with the user).
181
181
 
182
182
  ### Hidden cost
@@ -188,7 +188,7 @@ These are not style preferences. They are the six axioms every recipe in `refere
188
188
 
189
189
  4. **Exhaustive variant matching, always.** Discriminated unions and enums are matched exhaustively. Python: `match` + `case unreachable: assert_never(unreachable)`. Rust: `match` (the compiler enforces). TypeScript: `switch` + `assertNever`. **`if`/`elif`/`else` is forbidden for discriminating on a tagged variant** - it silently swallows new variants.
190
190
 
191
- 5. **Trust framework guarantees. Validate only at boundaries.** No null checks for values the type system already proves non-null. No `try/except` around code that cannot raise. No `unwrap`/`!`/`as` to paper over a contract you should have encoded in types. No defensive layer for a scenario you cannot name.
191
+ 5. **Trust framework guarantees. Validate only at boundaries.** No null checks for values the type system already proves non-null. No `try/except` around code that cannot raise. No `unwrap`/(!)/`as` to paper over a contract you should have encoded in types. No defensive layer for a scenario you cannot name.
192
192
 
193
193
  6. **Test-driven, with the right shape of test.** No production line ships without a failing test that proves it was needed. Behavior is locked by tests, not by hope. See the TDD discipline below.
194
194
 
@@ -275,9 +275,9 @@ Apply unless the per-language reference overrides with something stricter.
275
275
  | Immutable by default | `@dataclass(frozen=True, slots=True)` / Pydantic `frozen=True` | every binding is `let` (not `let mut`) unless mutation is the documented purpose | every field is `readonly`; arrays are `readonly T[]` | value types, unexported fields, no mutation methods unless mutation is the purpose |
276
276
  | Branded primitives | `UserId = NewType("UserId", int)` | `struct UserId(u64);` (newtype tuple) | `type UserId = Brand<string, "UserId">` | `type UserID string` + smart constructor with unexported field |
277
277
  | Exhaustive variant matching | `match` + `assert_never` | `match` (compiler-enforced) | `switch` + `assertNever` | sealed interface + type switch + **`exhaustive` linter** (the compiler will not help) |
278
- | No untyped escape hatches | no `Any` in public sigs, no `cast`, no `# type: ignore` | no `unwrap`/`expect` outside `main`/tests, no `as` for narrowing, no `#[allow]` to silence real warnings | no `any`, no `as` (except `as const`, `satisfies`), no `!`, no `@ts-ignore`, no `@ts-expect-error` | no `interface{}` / bare `any` in domain sigs; no `_ = err`; no `//nolint` without reason |
278
+ | No untyped escape hatches | no `Any` in public sigs, no `cast`, no `# type: ignore` | no `unwrap`/`expect` outside `main`/tests, no `as` for narrowing, no `#[allow]` to silence real warnings | no `any`, no `as` (except `as const`, `satisfies`), no (!), no `@ts-ignore`, no `@ts-expect-error` | no `interface{}` / bare `any` in domain sigs; no `_ = err`; no `//nolint` without reason |
279
279
  | No bare error strings | typed exception dataclass with `__str__` | `thiserror` enum (lib) or `anyhow` with `.context(...)` (app) | `Error` subclass with typed fields | sentinel `errors.New` + typed `*XError` struct; wrap with `%w`; check via `errors.Is/As` |
280
- | Boundary catch only | catch the exact exception you expect; broad `except Exception` only in `main()`, with logging + re-raise | `?` everywhere; never `panic!` in library code | `catch` must narrow with `instanceof` and re-throw or convert; no empty catch | every `(T, error)` checked; `panic` only in `main`/tests; one `httperr.Write` funnel in handlers |
280
+ | Boundary catch only | catch the exact exception you expect; broad `except Exception` only in `main()`, with logging + re-raise | `?` everywhere; never `panic!() in library code | `catch` must narrow with `instanceof` and re-throw or convert; no empty catch | every `(T, error)` checked; `panic` only in `main`/tests; one `httperr.Write` funnel in handlers |
281
281
  | Resources via RAII | `with` (sync) / `async with` (async) | `Drop` impl or RAII guard | `using`/`await using` (TC39 explicit resource management) | `defer x.Close()` immediately after acquisition; `bodyclose`/`sqlclosecheck` linters enforce |
282
282
  | Async runtime is mandatory | `anyio` (NEVER bare `asyncio`) | `tokio` (`async-std` is unmaintained) | platform-native async (Bun/Node) with structured cancellation via `AbortSignal` | `context.Context` as first param + `errgroup` for structured concurrency; `-race` on every test |
283
283
  | Modern HTTP client | [`httpx2`](https://github.com/pydantic/httpx2) with HTTP/2 + brotli + zstd | `reqwest` with rustls | `ky` (default) / `undici` direct API (Node perf) - NEVER bare `fetch` in prod | stdlib `net/http.Client` with tuned `Transport` + `go-retryablehttp` for retry/backoff |
@@ -496,7 +496,7 @@ After every code-writing session, answer these out loud (in your reply) before d
496
496
  1. **Single responsibility?** Can I name what this file owns in one short noun phrase? If the answer needs the word "and", split.
497
497
  2. **Boundary purity?** Did I parse untrusted input into a typed value at the boundary, or did I pass `dict[str, Any]` / `serde_json::Value` / `unknown` past the boundary? If the latter, fix it.
498
498
  3. **Variant discrimination?** Did I use `if`/`elif`/`else` (or `switch` without `assertNever`, or `match` without `assert_never`) anywhere to discriminate on a tagged type or enum? If yes, rewrite as exhaustive match.
499
- 4. **Escape hatches?** Any `Any`, `# type: ignore`, `unwrap`, `expect` outside `main`/tests, `as` numeric cast, `!`, `@ts-ignore`, `@ts-expect-error`, `#[allow]` on a real warning? If yes, fix the type or document why with a comment.
499
+ 4. **Escape hatches?** Any `Any`, `# type: ignore`, `unwrap`, `expect` outside `main`/tests, `as` numeric cast, (!), `@ts-ignore`, `@ts-expect-error`, `#[allow]` on a real warning? If yes, fix the type or document why with a comment.
500
500
  5. **Defensive layer?** Any null check, try/except, or `isinstance` guarding a value the type system already proves? If yes, delete.
501
501
  6. **Helpers for one-off?** Any function, class, or trait introduced for a single caller that will never get a second caller? If yes, inline.
502
502
  7. **Tests?** Is the behavior I just introduced locked by a test that would fail if I revert this commit?
@@ -164,7 +164,7 @@ match status {
164
164
  }
165
165
  ```
166
166
 
167
- For `#[non_exhaustive]` enums from external crates, the wildcard `_` is required — but add a `tracing::warn!` in the catch-all so you notice when new variants appear.
167
+ For `#[non_exhaustive]` enums from external crates, the wildcard `_` is required — but add a `tracing::warn!() in the catch-all so you notice when new variants appear.
168
168
 
169
169
  ### 8. Type-State Over Runtime Checks
170
170
 
@@ -278,7 +278,7 @@ zerocopy = { version = "0.8", features = ["derive"] }
278
278
  | [unsafe-discipline.md](unsafe-discipline.md) | Any `unsafe` block — SAFETY comments, safe wrappers, miri proof |
279
279
  | [libraries.md](libraries.md) | Library selection, crate decision tree, dependency audit |
280
280
  | [cargo-strict.md](cargo-strict.md) | Project bootstrap, lint config, CI gate commands |
281
- | [async-tokio.md](async-tokio.md) | Async runtime, spawning, cancellation, `JoinSet`, `select!` |
281
+ | [async-tokio.md](async-tokio.md) | Async runtime, spawning, cancellation, `JoinSet`, `select!() |
282
282
  | [axum-stack.md](axum-stack.md) | HTTP services — axum + sqlx + tower + tracing |
283
283
  | [clap-stack.md](clap-stack.md) | CLI tools — clap derive + color-eyre + indicatif |
284
284
  | [concurrency.md](concurrency.md) | Locks, atomics, channels, loom model checker |
@@ -48,14 +48,14 @@ while let Some(joined) = set.join_next().await {
48
48
  - Dropping the set aborts every still-running task.
49
49
  - Lets you handle failures one by one rather than all-or-nothing.
50
50
 
51
- For wait-for-all semantics with one type, `join!`:
51
+ For wait-for-all semantics with one type, `join!():
52
52
 
53
53
  ```rust
54
54
  let (a, b, c) = tokio::join!(load_a(), load_b(), load_c());
55
55
  let a = a?; let b = b?; let c = c?;
56
56
  ```
57
57
 
58
- For first-of-many, `select!`:
58
+ For first-of-many, `select!():
59
59
 
60
60
  ```rust
61
61
  tokio::select! {
@@ -74,7 +74,7 @@ Without `biased`, branches are polled in random order each iteration (good for f
74
74
 
75
75
  ## Cancellation
76
76
 
77
- A future is cancelled when it is dropped (e.g., the `select!` arm wins another branch). **Always think: if this future is dropped mid-await, what state is left behind?**
77
+ A future is cancelled when it is dropped (e.g., the `select!() arm wins another branch). **Always think: if this future is dropped mid-await, what state is left behind?**
78
78
 
79
79
  Cancel-safe futures (you can drop without lasting effect):
80
80
  - `recv()` on channels
@@ -229,7 +229,7 @@ async fn main() -> anyhow::Result<()> {
229
229
  }
230
230
  ```
231
231
 
232
- Pattern: catch signal → cancel a token shared with the server → server's `select!` arms see the cancel and exit cleanly → wait with a timeout so a hung worker can't deadlock shutdown.
232
+ Pattern: catch signal → cancel a token shared with the server → server's `select!() arms see the cancel and exit cleanly → wait with a timeout so a hung worker can't deadlock shutdown.
233
233
 
234
234
  ## Concurrency primitives
235
235
 
@@ -447,7 +447,7 @@ let app = router.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json
447
447
  - Bind to `0.0.0.0` in containers, `127.0.0.1` for local-only services.
448
448
  - Set `RUST_LOG=info,sqlx=warn` (or use `EnvFilter` defaults as shown).
449
449
  - Send logs to stdout in JSON. Ingest via Vector / Fluent Bit / Loki.
450
- - Run migrations on startup (`sqlx::migrate!` block). Fail fast on schema mismatch.
450
+ - Run migrations on startup (`sqlx::migrate!() block). Fail fast on schema mismatch.
451
451
  - Health endpoint **must hit the DB** (so load balancers know if the pool is dead).
452
452
  - Add `tower::limit::RateLimitLayer` or token-bucket middleware for public endpoints.
453
453
  - Set `tower_http::limit::RequestBodyLimitLayer` to bound request size.
@@ -375,7 +375,7 @@ Errors with `.wrap_err("...")` from `eyre::WrapErr` (compatible with anyhow's `.
375
375
  1. **Mixing stdout and stderr.** Tool output goes to stdout; logs and progress go to stderr.
376
376
  2. **No `--non-interactive` flag.** Interactive prompts block automation.
377
377
  3. **Printing colored output unconditionally.** Honor `NO_COLOR` env var, detect TTY with `console::user_attended()`.
378
- 4. **`println!` for errors.** Use `tracing::error!` so logs go to stderr automatically and respect verbosity.
378
+ 4. **`println!() for errors.** Use `tracing::error!() so logs go to stderr automatically and respect verbosity.
379
379
  5. **`unwrap()` on `Cli::parse()`.** clap returns clean errors with `--help` text; `parse()` exits on its own.
380
380
  6. **Long subcommand handlers in `main.rs`.** Split into `src/commands/<name>.rs` per command.
381
381
  7. **Missing exit code semantics.** Use `std::process::exit(1)` (general error), `2` (usage), `130` (SIGINT) appropriately. Or return `Result` and let main map.
@@ -120,7 +120,7 @@ async fn process_user(db: &Pool, user: &User) -> anyhow::Result<()> {
120
120
  }
121
121
  ```
122
122
 
123
- Replace `println!` with `info!`/`warn!`/`error!`. Replace `eprintln!` with `tracing::error!`.
123
+ Replace `println!() with `info!()/`warn!()/`error!(). Replace `eprintln!() with `tracing::error!().
124
124
 
125
125
  ## Error reporting (binaries) — `color-eyre`
126
126
 
@@ -235,7 +235,7 @@ pub async fn find_user(pool: &PgPool, email: &str) -> Result<Option<User>, sqlx:
235
235
  }
236
236
  ```
237
237
 
238
- `query_as!` checks the SQL against the live database at compile time. To work without a live DB during builds, generate offline metadata: `cargo sqlx prepare`. Commit the resulting `.sqlx/` directory.
238
+ `query_as!() checks the SQL against the live database at compile time. To work without a live DB during builds, generate offline metadata: `cargo sqlx prepare`. Commit the resulting `.sqlx/` directory.
239
239
 
240
240
  Avoid `diesel` (sync-first, heavy DSL), raw `tokio-postgres` (loses type checks), `sea-orm` (more magic, less control).
241
241
 
@@ -391,7 +391,7 @@ use std::sync::LazyLock;
391
391
  static CONFIG: LazyLock<Config> = LazyLock::new(|| Config::load_from_env().unwrap());
392
392
  ```
393
393
 
394
- Avoid `lazy_static!` (macro-heavy, predates std), `once_cell` (now in std as `LazyLock`/`OnceLock`).
394
+ Avoid `lazy_static!() (macro-heavy, predates std), `once_cell` (now in std as `LazyLock`/`OnceLock`).
395
395
 
396
396
  ## Hash maps — `std::collections::HashMap` + `ahash` for hot paths
397
397
 
@@ -6,11 +6,11 @@ Two test types every Rust project should have alongside unit tests. Proptest hun
6
6
 
7
7
  | Want to test… | Use |
8
8
  |---|---|
9
- | One specific behavior with a known input | `#[test]` + `assert_eq!` |
10
- | All inputs of a certain shape work | `proptest!` |
11
- | Output structure stays stable across refactors | `insta::assert_*_snapshot!` |
12
- | Parser/serializer round-trips | `proptest!` (the round-trip property) |
13
- | CLI help text, JSON response shape, debug output | `insta::assert_snapshot!` |
9
+ | One specific behavior with a known input | `#[test]` + `assert_eq!() |
10
+ | All inputs of a certain shape work | `proptest!() |
11
+ | Output structure stays stable across refactors | `insta::assert_*_snapshot!() |
12
+ | Parser/serializer round-trips | `proptest!() (the round-trip property) |
13
+ | CLI help text, JSON response shape, debug output | `insta::assert_snapshot!() |
14
14
  | Concurrency under all interleavings | `loom` (see `concurrency.md`) |
15
15
 
16
16
  Use all three. They cover different bug classes.
@@ -58,7 +58,7 @@ proptest! {
58
58
  }
59
59
  ```
60
60
 
61
- `proptest!` macro takes `(arg in strategy, ...)` pairs. Each strategy produces values; proptest runs the body with random samples, then shrinks failing cases to minimal forms.
61
+ `proptest!() macro takes `(arg in strategy, ...)` pairs. Each strategy produces values; proptest runs the body with random samples, then shrinks failing cases to minimal forms.
62
62
 
63
63
  ## Strategies — the value-generation language
64
64
 
@@ -296,10 +296,10 @@ fn debug_repr() {
296
296
  ```
297
297
 
298
298
  Choose:
299
- - `assert_snapshot!` for `String`/`Display` output (CLI help, error messages, generated code).
300
- - `assert_debug_snapshot!` for `{:?}` (Rust-internal data).
301
- - `assert_json_snapshot!` for structured data crossing process boundaries.
302
- - `assert_yaml_snapshot!` when YAML is easier to read in diffs.
299
+ - `assert_snapshot!() for `String`/`Display` output (CLI help, error messages, generated code).
300
+ - `assert_debug_snapshot!() for `{:?}` (Rust-internal data).
301
+ - `assert_json_snapshot!() for structured data crossing process boundaries.
302
+ - `assert_yaml_snapshot!() when YAML is easier to read in diffs.
303
303
 
304
304
  ## Insta — redactions and filters
305
305
 
@@ -512,7 +512,7 @@ tempfile = "3" # idiomatic RAII temp files/dirs
512
512
  | No hidden allocations | `#![no_std]` / slice-based APIs / `Cow` | Style choice | §3 |
513
513
  | `packed struct` / bitfields | `#[repr(C, packed)]` / `bitfield` / `zerocopy` | Easy | §4 |
514
514
  | `errdefer` | `scopeguard::guard` + defuse on success | Easy | §5 |
515
- | `defer` | `scopeguard::defer!` / `Drop` | Easy | §5 |
515
+ | `defer` | `scopeguard::defer!() / `Drop` | Easy | §5 |
516
516
 
517
517
  All achievable within Rust's single toolchain. You get Zig's explicitness **plus** the borrow checker, lifetime analysis, trait bounds, and `miri`. The combination is strictly more powerful than either alone.
518
518
 
@@ -31,7 +31,7 @@ These are deliberate project choices. Violations are always wrong, not "style pr
31
31
  3. **Exhaustive switch** — every `switch` on a discriminated union ends with `default: assertNever(x)`. No fall-through.
32
32
  4. **No any** — `any` is banned in annotations, returns, and parameters. Use `unknown` and narrow.
33
33
  5. **No type assertions** — `as any`, `as unknown` banned. `as const` and `satisfies` are fine.
34
- 6. **No non-null assertion** — `x!` is banned. Use narrowing or optional chaining (`x?.y`).
34
+ 6. **No non-null assertion** — `x!.y` is banned. Use narrowing or optional chaining (`x?.y`).
35
35
  7. **No @ts-ignore / @ts-expect-error** — fix the type.
36
36
  8. **No enum** — use `as const` objects + literal union types.
37
37
  9. **Zod at boundaries** — external input (API, user, file) → Zod schema. Internal → plain types.
@@ -163,7 +163,7 @@ Violations caught by `../../scripts/typescript/check-no-excuse-rules.ts`. Run af
163
163
  | `no-ts-ignore` | `@ts-ignore` | None — fix the type |
164
164
  | `no-ts-expect-error` | `@ts-expect-error` | None — fix the type |
165
165
  | `no-enum` | `enum` declarations | None — use `as const` |
166
- | `no-non-null-assertion` | `x!` postfix | None — narrow or `?.` |
166
+ | `no-non-null-assertion` | `x!.y` postfix | None — narrow or `?.` |
167
167
  | `no-throw-literal` | `throw "string"` / `throw 123` | None — throw Error subclass |
168
168
  | `no-mutable-export` | `export let` / `export var` | None — use `export const` |
169
169
  | `no-any-annotation` | `: any` in parameter/return/variable types | `// no-excuse-ok: any` |
@@ -100,7 +100,7 @@ app.get(
100
100
  validator('query', QuerySchema),
101
101
  (c) => {
102
102
  const query = c.req.valid('query')
103
- return c.json({ message: `Hello ${query.name ?? 'Hono'}!` })
103
+ return c.json({ message: `Hello ${query.name ?? 'Hono'}.` })
104
104
  },
105
105
  )
106
106
 
@@ -128,7 +128,7 @@ For Bun projects, add to `compilerOptions`:
128
128
  | Rule | What |
129
129
  |---|---|
130
130
  | `noExplicitAny` | `any` in annotations is an error |
131
- | `noNonNullAssertion` | `x!` is an error |
131
+ | `noNonNullAssertion` | `x!.y` is an error |
132
132
  | `noDefaultExport` | Forces named exports |
133
133
  | `useImportType` | Forces `import type` for type-only imports |
134
134
  | `noParameterAssign` | No mutation of function parameters |
@@ -8,7 +8,7 @@
8
8
  * no-ts-ignore - `@ts-ignore` comments
9
9
  * no-ts-expect-error - `@ts-expect-error` comments
10
10
  * no-enum - `enum` declarations
11
- * no-non-null-assertion - `x!` postfix operator
11
+ * no-non-null-assertion - `x!.y` postfix operator
12
12
  * no-throw-literal - `throw "string"` / `throw 123`
13
13
  * no-mutable-export - `export let` / `export var`
14
14
  * no-any-annotation - `: any` in annotations (opt out: `// no-excuse-ok: any`)
@@ -140,7 +140,7 @@ function analyzeFile(filePath: string): Violation[] {
140
140
  // ── x! non-null assertion ──
141
141
  if (ts.isNonNullExpression(node)) {
142
142
  const p = pos(node)
143
- violations.push({ ruleId: "no-non-null-assertion", filePath, ...p, message: "`x!` — use narrowing or optional chaining" })
143
+ violations.push({ ruleId: "no-non-null-assertion", filePath, ...p, message: "`x!.y` — use narrowing or optional chaining" })
144
144
  }
145
145
 
146
146
  // ── throw "literal" ──
@@ -3771,9 +3771,9 @@
3771
3771
  "license": "ISC"
3772
3772
  },
3773
3773
  "node_modules/zod": {
3774
- "version": "4.5.4",
3775
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
3776
- "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
3774
+ "version": "4.6.6",
3775
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.6.tgz",
3776
+ "integrity": "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==",
3777
3777
  "license": "MIT",
3778
3778
  "funding": {
3779
3779
  "url": "https://github.com/sponsors/colinhacks"