@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.
- package/README.md +7 -7
- package/README_Ko-KR.md +7 -7
- package/assets/lithermes-plugin/README.md +0 -12
- package/assets/lithermes-plugin/adhd_stack.py +43 -1
- package/assets/lithermes-plugin/auto_handoff.py +13 -2
- package/assets/lithermes-plugin/bounded_work.py +33 -3
- package/assets/lithermes-plugin/core.py +59 -18
- package/assets/lithermes-plugin/core_review.py +2 -2
- package/assets/lithermes-plugin/lit_mark.py +2 -7
- package/assets/lithermes-plugin/payload-version.json +43 -47
- package/assets/lithermes-plugin/plugin.yaml +1 -1
- package/assets/lithermes-plugin/rules/bundled-rules/baseline-discipline.md +7 -13
- package/assets/lithermes-plugin/rules/globmatch.py +1 -1
- package/assets/lithermes-plugin/skills/debugging/SKILL.md +1 -1
- package/assets/lithermes-plugin/skills/debugging/references/methodology/00-setup.md +1 -1
- package/assets/lithermes-plugin/skills/debugging/references/methodology/02-investigate.md +2 -2
- package/assets/lithermes-plugin/skills/debugging/references/methodology/09-cleanup.md +1 -1
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/native-binary.md +1 -1
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/rust.md +4 -4
- package/assets/lithermes-plugin/skills/debugging/references/tools/ghidra.md +1 -1
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/nuxtjs.csv +1 -1
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/manifest.json +2 -2
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -2
- package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +10 -10
- package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +12 -7
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +81 -27
- package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/next-step.mjs +34 -6
- package/assets/lithermes-plugin/skills/lit-burnoff/SKILL.md +1 -1
- package/assets/lithermes-plugin/skills/lit-code/SKILL.md +4 -4
- package/assets/lithermes-plugin/skills/lit-code/references/rust/README.md +2 -2
- package/assets/lithermes-plugin/skills/lit-code/references/rust/async-tokio.md +4 -4
- package/assets/lithermes-plugin/skills/lit-code/references/rust/axum-stack.md +1 -1
- package/assets/lithermes-plugin/skills/lit-code/references/rust/clap-stack.md +1 -1
- package/assets/lithermes-plugin/skills/lit-code/references/rust/libraries.md +3 -3
- package/assets/lithermes-plugin/skills/lit-code/references/rust/proptest-insta.md +10 -10
- package/assets/lithermes-plugin/skills/lit-code/references/rust/zero-cost-safety.md +1 -1
- package/assets/lithermes-plugin/skills/lit-code/references/typescript/README.md +2 -2
- package/assets/lithermes-plugin/skills/lit-code/references/typescript/backend-hono.md +1 -1
- package/assets/lithermes-plugin/skills/lit-code/references/typescript/tsconfig-strict.md +1 -1
- package/assets/lithermes-plugin/skills/lit-code/scripts/typescript/check-no-excuse-rules.ts +2 -2
- package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package-lock.json +3 -3
- package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package.json +2 -1
- package/assets/lithermes-plugin/skills/rules/SKILL.md +1 -1
- package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +2 -2
- package/assets/{lithermes-plugin → npm-channel}/auto_update.py +43 -9
- package/package.json +1 -1
- package/readme-assets/badge-version.svg +1 -1
- package/src/lib/canonicalCorpus.js +1 -1
- package/src/lib/install.js +22 -0
- 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 `
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
//
|
|
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
|
|
172
|
-
if (/^#{1,6}\s/u.test(
|
|
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(
|
|
175
|
-
} else if (inside &&
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
:
|
|
579
|
-
? { kind: 'suggest', label: '
|
|
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
|
-
//
|
|
9
|
-
//
|
|
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
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
300
|
-
- `assert_debug_snapshot
|
|
301
|
-
- `assert_json_snapshot
|
|
302
|
-
- `assert_yaml_snapshot
|
|
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
|
|
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
|
|
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
|
|
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` |
|
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
3775
|
-
"resolved": "https://registry.npmjs.org/zod/-/zod-4.
|
|
3776
|
-
"integrity": "sha512-
|
|
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"
|