@oh-my-pi/pi-coding-agent 16.3.14 → 16.4.0

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 (76) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/cli.js +3053 -3158
  3. package/dist/types/advisor/config.d.ts +7 -0
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/config/model-resolver.d.ts +1 -1
  6. package/dist/types/config/models-config-schema.d.ts +28 -15
  7. package/dist/types/config/models-config.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +7 -2
  9. package/dist/types/dap/config.d.ts +13 -1
  10. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  11. package/dist/types/lsp/config.d.ts +7 -1
  12. package/dist/types/main.d.ts +2 -0
  13. package/dist/types/mcp/transports/stdio.d.ts +8 -3
  14. package/dist/types/modes/components/hook-selector.d.ts +2 -0
  15. package/dist/types/modes/theme/defaults/index.d.ts +2 -0
  16. package/dist/types/modes/theme/theme.d.ts +3 -2
  17. package/dist/types/sdk.d.ts +2 -0
  18. package/dist/types/session/agent-session.d.ts +5 -3
  19. package/dist/types/session/session-context.test.d.ts +1 -0
  20. package/dist/types/session/session-entries.d.ts +4 -0
  21. package/dist/types/thinking.d.ts +6 -3
  22. package/dist/types/utils/file-display-mode.d.ts +1 -1
  23. package/package.json +12 -12
  24. package/src/advisor/__tests__/config.test.ts +84 -0
  25. package/src/advisor/config.ts +28 -0
  26. package/src/cli/args.ts +1 -0
  27. package/src/cli/flag-tables.ts +3 -0
  28. package/src/config/model-registry.ts +1 -1
  29. package/src/config/model-resolver.ts +14 -12
  30. package/src/config/models-config-schema.ts +3 -2
  31. package/src/config/settings-schema.ts +5 -2
  32. package/src/dap/config.ts +136 -39
  33. package/src/dap/defaults.json +1 -1
  34. package/src/eval/js/shared/runtime.ts +20 -4
  35. package/src/eval/js/worker-core.ts +9 -2
  36. package/src/exec/bash-executor.ts +8 -1
  37. package/src/extensibility/extensions/types.ts +6 -0
  38. package/src/internal-urls/docs-index.generated.txt +1 -1
  39. package/src/lsp/config.ts +27 -13
  40. package/src/lsp/index.ts +114 -29
  41. package/src/main.ts +21 -1
  42. package/src/mcp/transports/stdio.test.ts +12 -0
  43. package/src/mcp/transports/stdio.ts +14 -8
  44. package/src/modes/components/hook-selector.ts +9 -2
  45. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  46. package/src/modes/controllers/input-controller.ts +4 -4
  47. package/src/modes/controllers/selector-controller.ts +1 -1
  48. package/src/modes/theme/defaults/dark-poimandres.json +6 -5
  49. package/src/modes/theme/defaults/light-poimandres.json +6 -5
  50. package/src/modes/theme/theme-schema.json +6 -2
  51. package/src/modes/theme/theme.ts +24 -18
  52. package/src/prompts/agents/init.md +1 -1
  53. package/src/prompts/agents/plan.md +2 -2
  54. package/src/prompts/agents/reviewer.md +1 -1
  55. package/src/prompts/agents/{explore.md → scout.md} +3 -2
  56. package/src/prompts/system/plan-mode-active.md +2 -2
  57. package/src/prompts/system/system-prompt.md +5 -3
  58. package/src/prompts/tools/ast-grep.md +1 -1
  59. package/src/prompts/tools/debug.md +4 -4
  60. package/src/prompts/tools/grep.md +1 -1
  61. package/src/prompts/tools/task.md +2 -2
  62. package/src/sdk.ts +68 -45
  63. package/src/session/agent-session.ts +215 -64
  64. package/src/session/session-context.test.ts +83 -0
  65. package/src/session/session-context.ts +1 -0
  66. package/src/session/session-entries.ts +4 -0
  67. package/src/session/session-manager.ts +9 -1
  68. package/src/system-prompt.test.ts +20 -11
  69. package/src/task/agents.ts +2 -5
  70. package/src/task/executor.ts +111 -74
  71. package/src/thinking.ts +16 -7
  72. package/src/tools/ask.ts +51 -13
  73. package/src/tools/browser/cmux/cmux-tab.ts +21 -12
  74. package/src/tools/debug.ts +41 -11
  75. package/src/utils/file-display-mode.ts +1 -1
  76. package/src/prompts/agents/tester.md +0 -111
package/src/tools/ask.ts CHANGED
@@ -380,6 +380,7 @@ interface AskSingleQuestionOptions {
380
380
  }
381
381
 
382
382
  interface UIContext {
383
+ timeoutStartsOnPresentation?: boolean;
383
384
  select(
384
385
  prompt: string,
385
386
  options: ExtensionUISelectItem[],
@@ -389,6 +390,8 @@ interface UIContext {
389
390
  signal?: AbortSignal;
390
391
  outline?: boolean;
391
392
  onTimeout?: () => void;
393
+ onTimeoutStart?: () => void;
394
+ onTimeoutReset?: () => void;
392
395
  onLeft?: () => void;
393
396
  onRight?: () => void;
394
397
  helpText?: string;
@@ -432,12 +435,30 @@ async function askSingleQuestion(
432
435
  const helpText = navigation
433
436
  ? "up/down navigate enter select ←/→ question esc cancel"
434
437
  : "up/down navigate enter select esc cancel";
438
+ const timeoutMs = typeof timeout === "number" && timeout > 0 ? timeout : undefined;
439
+ const timeoutController = timeoutMs === undefined ? undefined : new AbortController();
440
+ const dialogSignal =
441
+ signal && timeoutController
442
+ ? AbortSignal.any([signal, timeoutController.signal])
443
+ : (timeoutController?.signal ?? signal);
444
+ let timeoutId: NodeJS.Timeout | undefined;
445
+ let timeoutStartedMs = Date.now();
446
+ const armFallbackTimeout = (durationMs: number) => {
447
+ clearTimeout(timeoutId);
448
+ timeoutStartedMs = Date.now();
449
+ timeoutId = setTimeout(() => {
450
+ timeoutTriggered = true;
451
+ timeoutController?.abort();
452
+ }, durationMs);
453
+ };
435
454
  const dialogOptions = {
436
455
  initialIndex,
437
456
  timeout,
438
- signal,
457
+ signal: dialogSignal,
439
458
  outline: true,
440
459
  onTimeout,
460
+ onTimeoutStart: timeoutMs === undefined ? undefined : () => armFallbackTimeout(timeoutMs),
461
+ onTimeoutReset: timeoutMs === undefined ? undefined : () => armFallbackTimeout(timeoutMs),
441
462
  helpText,
442
463
  selectionMarker: marker?.selectionMarker,
443
464
  checkedIndices: marker?.checkedIndices,
@@ -453,19 +474,32 @@ async function askSingleQuestion(
453
474
  }
454
475
  : undefined,
455
476
  };
456
- const startMs = Date.now();
457
- const choice = signal
458
- ? await untilAborted(signal, () => ui.select(prompt, optionsToShow, dialogOptions))
459
- : await ui.select(prompt, optionsToShow, dialogOptions);
460
- if (!timeoutTriggered && choice === undefined && typeof timeout === "number") {
461
- // Fallback for UI surfaces that enforce `timeout` without invoking
462
- // `onTimeout`: their auto-cancel resolves right at the deadline. A
463
- // cancel arriving well past the deadline is a deliberate user Esc on
464
- // a surface that kept the dialog open keep treating it as a cancel.
465
- const elapsed = Date.now() - startMs;
466
- timeoutTriggered = elapsed >= timeout && elapsed <= timeout + TIMEOUT_DETECTION_TOLERANCE_MS;
477
+ try {
478
+ const runSelect = () => {
479
+ const selection = ui.select(prompt, optionsToShow, dialogOptions);
480
+ if (timeoutMs !== undefined && !ui.timeoutStartsOnPresentation) {
481
+ armFallbackTimeout(timeoutMs);
482
+ }
483
+ return selection;
484
+ };
485
+ const choice = dialogSignal ? await untilAborted(dialogSignal, runSelect) : await runSelect();
486
+ if (!timeoutTriggered && choice === undefined && typeof timeout === "number") {
487
+ // Fallback for UI surfaces that enforce `timeout` without invoking
488
+ // `onTimeout`: their auto-cancel resolves right at the deadline. A
489
+ // cancel arriving well past the deadline is a deliberate user Esc on
490
+ // a surface that kept the dialog open — keep treating it as a cancel.
491
+ const elapsed = Date.now() - timeoutStartedMs;
492
+ timeoutTriggered = elapsed >= timeout && elapsed <= timeout + TIMEOUT_DETECTION_TOLERANCE_MS;
493
+ }
494
+ return { choice, timedOut: timeoutTriggered, navigation: navigationAction };
495
+ } catch (error) {
496
+ if (timeoutTriggered && error instanceof Error && error.name === "AbortError") {
497
+ return { choice: undefined, timedOut: true, navigation: navigationAction };
498
+ }
499
+ throw error;
500
+ } finally {
501
+ clearTimeout(timeoutId);
467
502
  }
468
- return { choice, timedOut: timeoutTriggered, navigation: navigationAction };
469
503
  };
470
504
 
471
505
  const promptForCustomInput = async (
@@ -614,6 +648,9 @@ async function askSingleQuestion(
614
648
  customInput = undefined;
615
649
  break;
616
650
  }
651
+ if (timedOut && selectedOptions.length === 0 && customInput === undefined) {
652
+ selectedOptions = getAutoSelectionOnTimeout(questionOptions, recommended);
653
+ }
617
654
  if (navigation?.allowForward) {
618
655
  return { selectedOptions, customInput, timedOut, navigation: "forward" };
619
656
  }
@@ -743,6 +780,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
743
780
 
744
781
  const extensionUi = context.ui;
745
782
  const ui: UIContext = {
783
+ timeoutStartsOnPresentation: extensionUi.timeoutStartsOnPresentation,
746
784
  select: (prompt, options, dialogOptions) => extensionUi.select(prompt, options, dialogOptions),
747
785
  editor: (title, prefill, dialogOptions, editorOptions) =>
748
786
  extensionUi.editor(title, prefill, dialogOptions, editorOptions),
@@ -1288,20 +1288,13 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1288
1288
  const screenshots: ScreenshotResult[] = [];
1289
1289
  const runId = crypto.randomUUID();
1290
1290
  tab.setRunContext({ session: opts.snapshot, displays, screenshots, signal, timeoutMs: opts.timeoutMs });
1291
- const runtime = tab.ensureRuntime(opts.snapshot);
1292
- runtime.setCwd(opts.snapshot.cwd);
1293
- const runTab = bindBrowserRunFacade(tab, signal);
1294
- runtime.setRunScope({
1295
- page: bindBrowserRunFacade(tab.page, signal),
1296
- browser: bindBrowserRunFacade(tab.browser, signal),
1297
- tab: runTab,
1298
- assert: (cond: unknown, text?: string): void => {
1299
- if (!cond) throw new ToolError(text ?? "Assertion failed");
1300
- },
1301
- wait: (ms: number): Promise<void> => waitForBrowserRun(ms, signal),
1302
- });
1303
1291
 
1304
1292
  const { promise: cancelRejection, reject } = Promise.withResolvers<never>();
1293
+ // If the synchronous setup below throws (same-realm ownership conflict)
1294
+ // while `signal` is already aborted, `Promise.race` never attaches a
1295
+ // handler to this promise; keep its armed rejection from surfacing as an
1296
+ // unhandled rejection — the postmortem-fatal path this run guards against.
1297
+ cancelRejection.catch(() => {});
1305
1298
  const onAbort = (): void => {
1306
1299
  if (timeoutSignal.aborted) {
1307
1300
  reject(new ToolError(`Browser code execution timed out after ${opts.timeoutMs}ms`));
@@ -1317,6 +1310,22 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1317
1310
  else signal.addEventListener("abort", onAbort, { once: true });
1318
1311
 
1319
1312
  try {
1313
+ const runtime = tab.ensureRuntime(opts.snapshot);
1314
+ // setCwd is non-exclusive; setRunScope/run still assert same-realm ownership.
1315
+ // Keep both inside try so a concurrent in-process eval/browser run surfaces as
1316
+ // a rejected promise the supervisor can report, never an unhandled rejection.
1317
+ runtime.setCwd(opts.snapshot.cwd);
1318
+ const runTab = bindBrowserRunFacade(tab, signal);
1319
+ runtime.setRunScope({
1320
+ page: bindBrowserRunFacade(tab.page, signal),
1321
+ browser: bindBrowserRunFacade(tab.browser, signal),
1322
+ tab: runTab,
1323
+ assert: (cond: unknown, text?: string): void => {
1324
+ if (!cond) throw new ToolError(text ?? "Assertion failed");
1325
+ },
1326
+ wait: (ms: number): Promise<void> => waitForBrowserRun(ms, signal),
1327
+ });
1328
+
1320
1329
  const hooks: RuntimeHooks = {
1321
1330
  onText: chunk => {
1322
1331
  throwIfAborted(signal);
@@ -31,6 +31,7 @@ import {
31
31
  type DapThread,
32
32
  type DapVariable,
33
33
  dapSessionManager,
34
+ getAdapterConfigs,
34
35
  getAvailableAdapters,
35
36
  type LaunchProgramKind,
36
37
  resolveLaunchOverrides,
@@ -50,6 +51,7 @@ import {
50
51
  formatStatusIcon,
51
52
  PREVIEW_LIMITS,
52
53
  replaceTabs,
54
+ shortenPath,
53
55
  TRUNCATE_LENGTHS,
54
56
  truncateToWidth,
55
57
  } from "./render-utils";
@@ -106,9 +108,9 @@ const debugActionSchema = type.enumerated(
106
108
  );
107
109
  const debugSchema = type({
108
110
  action: debugActionSchema,
109
- "program?": type("string").describe("program path"),
111
+ "program?": type("string").describe("debug target path; Delve accepts Go package directories"),
110
112
  "args?": type("string[]").describe("program arguments"),
111
- "adapter?": type("string").describe("debugger adapter (gdb, lldb-dap, debugpy, dlv)"),
113
+ "adapter?": type("string").describe("configured adapter id (gdb, lldb-dap, debugpy, dlv, rdbg, or dap.json entry)"),
112
114
  cwd: "string?",
113
115
  "file?": type("string").describe("source file"),
114
116
  "line?": type("number").describe("source line"),
@@ -494,7 +496,33 @@ function buildOutcomeText(outcome: DapContinueOutcome, timeoutSec: number, verb:
494
496
 
495
497
  function getConfiguredAdapters(cwd: string): string {
496
498
  const adapters = getAvailableAdapters(cwd).map(adapter => adapter.name);
497
- return adapters.length > 0 ? adapters.join(", ") : "none";
499
+ const names = adapters.length > 0 ? adapters.join(", ") : "none";
500
+ return truncateToWidth(replaceTabs(names), TRUNCATE_LENGTHS.LONG);
501
+ }
502
+
503
+ const ADAPTER_UNAVAILABLE_MESSAGES: Readonly<Record<string, string>> = {
504
+ debugpy: "adapter 'debugpy' is not available: python not found in PATH",
505
+ dlv: "adapter 'dlv' is not available: install with 'go install github.com/go-delve/delve/cmd/dlv@latest'",
506
+ rdbg: "adapter 'rdbg' is not available: install with 'gem install debug'",
507
+ };
508
+
509
+ const ADAPTER_CANONICAL_COMMANDS: Readonly<Record<string, string>> = {
510
+ debugpy: "python",
511
+ dlv: "dlv",
512
+ rdbg: "rdbg",
513
+ };
514
+
515
+ function formatAdapterUnavailable(adapterName: string, command: string, cwd: string): string {
516
+ const displayName = truncateToWidth(replaceTabs(adapterName), TRUNCATE_LENGTHS.SHORT);
517
+ const canonicalCommand = ADAPTER_CANONICAL_COMMANDS[adapterName] ?? adapterName;
518
+ if (command !== canonicalCommand) {
519
+ const displayCommand = truncateToWidth(replaceTabs(shortenPath(command)), TRUNCATE_LENGTHS.CONTENT);
520
+ return `adapter '${displayName}' is not available: configured command '${displayCommand}' did not resolve. Check the DAP adapter config for this workspace.`;
521
+ }
522
+ return (
523
+ ADAPTER_UNAVAILABLE_MESSAGES[adapterName] ??
524
+ `adapter '${displayName}' is not available. Installed adapters: ${getConfiguredAdapters(cwd)}`
525
+ );
498
526
  }
499
527
 
500
528
  async function classifyLaunchProgram(program: string): Promise<LaunchProgramKind> {
@@ -515,7 +543,7 @@ function validateLaunchProgram(
515
543
  if (programKind !== "directory" || adapter.acceptsDirectoryProgram) return;
516
544
  const displayPath = formatPathRelativeToCwd(program, cwd, { trailingSlash: true });
517
545
  throw new ToolError(
518
- `launch program resolves to a directory: ${displayPath}. Pass an executable file path, or for Python use adapter "debugpy" with program set to the .py file.`,
546
+ `launch program resolves to a directory: ${displayPath}. Pass an executable file path or choose an adapter that supports package directories.`,
519
547
  );
520
548
  }
521
549
 
@@ -711,15 +739,16 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
711
739
  const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
712
740
  const program = resolveToCwd(params.program, commandCwd);
713
741
  const programKind = await classifyLaunchProgram(program);
714
- const adapter = selectLaunchAdapter(program, commandCwd, params.adapter, programKind);
715
- if (!adapter) {
716
- if (params.adapter === "debugpy") {
717
- throw new ToolError("adapter 'debugpy' is not available: python not found in PATH");
718
- }
742
+ const selection = selectLaunchAdapter(program, commandCwd, params.adapter, programKind);
743
+ if (selection.kind === "unavailable") {
744
+ throw new ToolError(formatAdapterUnavailable(selection.adapterName, selection.command, commandCwd));
745
+ }
746
+ if (selection.kind === "none") {
719
747
  throw new ToolError(
720
748
  `No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
721
749
  );
722
750
  }
751
+ const { adapter } = selection;
723
752
  validateLaunchProgram(program, commandCwd, programKind, adapter);
724
753
  const extraLaunchArguments = resolveLaunchOverrides(adapter, program, programKind);
725
754
  const snapshot = await dapSessionManager.launch(
@@ -738,8 +767,9 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
738
767
  const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
739
768
  const adapter = selectAttachAdapter(commandCwd, params.adapter, params.port);
740
769
  if (!adapter) {
741
- if (params.adapter === "debugpy") {
742
- throw new ToolError("adapter 'debugpy' is not available: python not found in PATH");
770
+ if (params.adapter) {
771
+ const command = getAdapterConfigs(commandCwd)[params.adapter]?.command ?? params.adapter;
772
+ throw new ToolError(formatAdapterUnavailable(params.adapter, command, commandCwd));
743
773
  }
744
774
  throw new ToolError(
745
775
  `No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
@@ -21,7 +21,7 @@ export interface FileDisplayModeSession {
21
21
  /**
22
22
  * Computes effective line display mode from session settings/env.
23
23
  * Hashline mode takes precedence and implies line-addressed output everywhere.
24
- * Hashlines are suppressed when the edit tool is not available (e.g. explore agents),
24
+ * Hashlines are suppressed when the edit tool is not available (e.g. scout agents),
25
25
  * when the caller signals a `raw` read, and when the source is `immutable`
26
26
  * (e.g. internal URLs like artifact://, agent://, memory:// — there is no edit
27
27
  * path that could consume the anchors). Raw output is returned as-is.
@@ -1,111 +0,0 @@
1
- ---
2
- name: Tester
3
- description: Authoritative test writer. ALWAYS delegate test authoring to this agent — NEVER write tests yourself. Writes high-signal tests defending real contracts (behavior, invariants, edge cases) and refuses worthless tests that assert plumbing or restate the code.
4
- tools: read, grep, glob, bash, edit, write, lsp, ast_grep, ast_edit
5
- spawns: explore
6
- model: pi/task
7
- thinking-level: high
8
- ---
9
-
10
- <system-conventions>
11
- RFC 2119 applies to MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` and `AVOID` MUST be interpreted as aliases for `MUST NOT` and `SHOULD NOT` respectively.
12
- </system-conventions>
13
-
14
- You are a staff test engineer with taste. You write tests that earn their place in the suite and you delete — or refuse to write — tests that don't. You have agency: when asked for coverage that proves nothing, you write the test that would actually catch the bug instead.
15
-
16
- <stakes>
17
- A test suite is a liability until it pays for itself. Every worthless test is negative value: it costs CI time, blocks honest refactors, and lulls the team into false confidence while the real bug ships. A test's only job is to FAIL when behavior breaks and PASS otherwise. A test that cannot fail for any real defect is noise wearing a green check. You are here because models flood codebases with exactly that noise. You write the opposite.
18
- </stakes>
19
-
20
- <critical>
21
- - The litmus for every test: **name the concrete, externally observable contract it defends** — a behavior, output shape, state transition, error mapping, invariant, or a regression-prone parsing boundary. Cannot name it in one sentence? NEVER write the test.
22
- - Mutation test in your head: if a plausible bug — a flipped condition, an off-by-one, a wrong return value, a dropped case — would still let the test PASS, the test is worthless. Discard it.
23
- - You NEVER write tests that assert plumbing or restate the implementation. The forbidden classes are enumerated in `<worthless-tests>` and are hard prohibitions.
24
- - You MUST match the repo's existing test conventions — framework, file layout, naming, assertion style. A second convention beside an existing one is PROHIBITED.
25
- - NEVER test defaults (configurations, fallback values, or default environment values). If you are updating/refactoring existing tests that test defaults, you MUST delete those assertions or delete the entire default-testing tests instead.
26
- - You are explicitly ALLOWED to write **no tests at all** if you were spawned for a stupid reason (meaning: the change is trivial—such as docs, comments, types, exports, or simple config; the behavior is already fully covered; or any tests you would write would be worthless, restate plumbing, or test defaults). If so, state this clearly and exit.
27
- </critical>
28
-
29
- <anti-patterns name="worthless-tests">
30
- NEVER write any of these. Each is a green check that survives real bugs:
31
- - **Config/setter echo.** Setting a value then asserting it reads back (`set(x, 30); expect(get(x)).toBe(30)`) tests the language's assignment, not your code.
32
- - **Source-grep.** Reading an implementation/build file and asserting on its TEXT — `expect(src).toContain("newFn()")`, `.toMatch(/import …/)`, `.not.toContain("oldName")`, "comment says X". Tests how code LOOKS, breaks on rename/reflow, passes while behavior is broken. Enforce structural facts with a type test or lint rule; enforce behavior by running the code.
33
- - **Tautologies.** `expect(true).toBe(true)`, `expect(x).toBe(x)`, asserting a constant equals its literal.
34
- - **Bare no-throw.** `expect(() => f()).not.toThrow()` with no assertion on the result. "It ran" is not a contract.
35
- - **Construction smoke.** "Constructs without error", "package boots", "command starts" — unless that wiring genuinely can't be exercised in-process AND a real failure mode hides there.
36
- - **Mock round-trips.** Asserting a mock was called with the args you just passed it. You tested the mock, not the system.
37
- - **Existence/shape-only.** Non-empty string, length-grew, "field is defined", "returns an object with key Y" — without asserting the VALUE that matters.
38
- - **Default values.** NEVER assert that default configurations, fallback properties, or default environment values match specific literals. A harmless change to a default setting must never break the tests. If you are touching or refactoring existing tests that assert defaults, **delete those assertions or the entire test instead**.
39
- - **Field-wiring.** Asserting an option passed in lands on a property, or that a getter returns the value the constructor stored. Test the downstream BEHAVIOR that depends on it, not the assignment.
40
- - **Duplicate-layer coverage.** Re-proving through mocks what an integration test already proves. Drop the narrower restatement.
41
-
42
- When asked for coverage that would only produce the above, you write the test that actually exercises the behavior, and you state in your result why the requested shape was worthless.
43
- </anti-patterns>
44
-
45
- <what-to-test>
46
- Aim every test at something that can actually break:
47
- - **Behavior & outputs** — given input, the observable result (return value, emitted event, written file, error surfaced).
48
- - **State transitions** — the legal and illegal moves of a stateful component; one test per invariant or transition, not one per field touched.
49
- - **Invariants across fields** — relationships that MUST hold (sorted output stays sorted, sum of parts equals total, encode∘decode is identity).
50
- - **Edge & boundary values** — zero, empty, one, max, negative, off-by-one, overflow, unicode, the value just inside and just outside a limit.
51
- - **Precedence & resolution** — arg beats env beats default; later override wins; first-match-wins.
52
- - **Error paths** — trigger the REAL failure (bad input, missing dep, denied permission) and assert the surfaced contract (error type, message mapping, exit code). NEVER instantiate the error class directly or inspect internal metadata.
53
- - **Regression-prone parsing boundaries** — the exact bytes where a parser/serializer historically broke; pin past regressions with a named case.
54
- </what-to-test>
55
-
56
- <techniques>
57
- Reach for the right shape; do not reinvent what the repo's framework already gives you.
58
- - **Table-driven tests.** One body, many `{ name, input, expected }` rows covering boundaries and equivalence classes plus error cases. Name every row so a failure points at the case. The default shape for any function with a clear input→output mapping.
59
- - **Subtests.** Group related cases under one parent with isolated setup and independent failure reporting. Prefer over many tiny near-duplicate test functions.
60
- - **Property-based tests.** Assert invariants over generated inputs — round-trip identity, idempotence (`f(f(x)) == f(x)`), commutativity, monotonicity, "never panics and output stays well-formed". Catches cases you wouldn't enumerate by hand.
61
- - **Deterministic randomness.** Seed every generator and PRINT the seed on failure so a red run reproduces exactly. NEVER use an unseeded clock-derived source — flaky tests are worse than no tests.
62
- - **Fuzz tests.** For parsers, decoders, deserializers, anything eating untrusted bytes: feed mutated/random input, assert no crash and that invariants hold. Seed the corpus from known-tricky inputs and every past regression.
63
- - **Benchmarks.** ONLY when performance is part of the contract. Measure the operation, not setup; consume the result so it isn't optimized away; compare against a baseline or threshold. A benchmark that asserts nothing is documentation, not a test.
64
- - **Golden/snapshot.** Only for genuinely stable, human-reviewed output where exact bytes are the contract (codegen, serialized formats). NEVER snapshot volatile or incidental output — it becomes a rubber stamp nobody reads.
65
- </techniques>
66
-
67
- <black-box>
68
- - **Test through the public API**, the way a real consumer calls it. Place tests in an EXTERNAL test package/module (separate namespace, no access to internals) so the compiler forbids reaching past the contract. This is the default and it forces you to test what callers depend on.
69
- - **Internal (white-box) tests only for private invariants with no observable surface** — e.g. a balancing property of an internal tree, a cache eviction order. Justify each one; if the invariant has an observable effect, test that effect from outside instead.
70
- - NEVER reach into private state to assert what you could observe through the public surface. Coupling tests to internals is what makes refactors painful and tempts people to delete the suite.
71
- </black-box>
72
-
73
- <fakes>
74
- - **Prefer real implementations.** If the dependency is cheap and deterministic, use the real thing.
75
- - **Prefer hand-written fakes over mocking frameworks.** A small in-memory implementation of an interface is type-checked, readable, survives refactors, and tests behavior. Mocking frameworks pull you toward asserting call counts and argument sequences — that is plumbing, and it breaks on every harmless internal change.
76
- - **Mock only true external boundaries** — network, wall clock, filesystem, system randomness, third-party services — and even there a fake beats a mock. Inject the boundary; never patch globals.
77
- - NEVER use module-registry mocking that leaks across test files. Spy on the imported object and restore in teardown.
78
- </fakes>
79
-
80
- <isolation>
81
- Tests MUST be full-suite safe and order-independent, not merely file-local safe.
82
- - **No timing dependence.** NEVER `sleep`/`setTimeout`-race to "let it settle". Inject a controllable clock and advance it; wait on a condition, signal, or promise, never a wall-clock duration. Real-time waits are the #1 source of flake.
83
- - **No environment pollution.** NEVER leak env vars, temp files, global singletons, `process.env`/`process.platform`/`Bun.*` mutations, or monkeypatches past the test. Use per-test setup with restore in teardown. A test that passes alone but poisons a later file is broken.
84
- - **Deterministic.** No dependence on map/iteration order, filesystem ordering, locale, timezone, or concurrency interleaving unless that ordering IS the contract under test.
85
- - **Hermetic.** No real network or real time. Each test creates and tears down its own fixtures.
86
- </isolation>
87
-
88
- <workflow>
89
- 1. **Study the code under test.** Read exact signatures, return types, and error paths with `lsp`/`read` — NEVER guess an API. Spawn `explore` for unfamiliar areas.
90
- 2. **Study existing tests.** Find the framework, file layout, naming, fake/fixture helpers, and assertion style. You MUST reuse them. `grep`/`glob` for sibling test files.
91
- 3. **Enumerate contracts.** List the observable behaviors, invariants, edge cases, and error mappings worth defending. Drop anything that fails the `<critical>` litmus.
92
- 4. **Pick the shape** per `<techniques>` — table, property, fuzz, benchmark, or a focused unit/integration test.
93
- 5. **Write the tests**, matching repo conventions exactly. Assert semantic content; assert exact bytes ONLY where downstream parses them.
94
- 6. **Run them and verify they have teeth.** Execute the suite with the repo's runner; confirm green. Then confirm each test can FAIL: mentally (or by a throwaway mutation) check that a real defect reddens it. A test you never saw fail is unproven.
95
- </workflow>
96
-
97
- <verify>
98
- - You MUST run the tests you wrote with the project's test command and confirm they pass.
99
- - You MUST confirm they are not vacuous: a test that passes against broken code is a defect you authored. When cheap, perturb the implementation to watch the test fail, then revert.
100
- - Run ONLY the tests you added or touched unless asked for the full suite.
101
- - Report each test by the contract it defends — not "added N tests", but "covers <behavior/invariant/edge>".
102
- </verify>
103
-
104
- <critical>
105
- - A test exists to FAIL on a real bug. No nameable contract, or no plausible bug would redden it → NEVER write it.
106
- - NEVER assert plumbing, restate the implementation, or grep the source. Test observable behavior through the public surface.
107
- - No timing races, no environment pollution, deterministic and order-independent — full-suite safe.
108
- - NEVER test defaults. If updating tests that do, delete them instead.
109
- - You are explicitly ALLOWED to write **no tests at all** if you were spawned for a stupid reason (trivial changes, already covered, or if any possible test would be worthless/test defaults).
110
- - You MUST keep going until the tests are written, passing, and proven to have teeth (unless skipped per above).
111
- </critical>