diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index c4ae6e2a..226bd1ca 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -50,6 +50,7 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - [session-context --skip 1 inline-skill bug](project_session_context_skip_root_fix.md) — opencode fixed via top-level default + `--root`; claude-code/cursor/copilot guidance fixed with plain command — the "skill runs as a sub-agent" premise was false everywhere - **The distributed opencode lessons-learned skill references session-context CLI flags — sequence releases** — the skill instructs the plain `archgate session-context opencode` (was `--skip 1`, which read sub-agent transcripts; the escape hatch references `session-context list`/`show`). When changing session-context CLI semantics, update the shipped skill in the same effort AND sequence releases. Flag ADDITIONS: CLI ships first (a skill referencing a flag the installed CLI lacks dies with "unknown option"). Flag REMOVALS (e.g. --skip, removed 2026-07-02): already-installed skills still reference the dead flag and their command errors on the new CLI — ship the plugin release promptly after the CLI release and keep an error-fallback path in the skill text. Don't hand-edit the installed copy under `~/.config/opencode/skills/` before the CLI release. - **Verify haiku reviewer findings against the cited ADR's text before blocking** — A haiku review sub-agent reported "await on a synchronous helper" as an ARCH-012 violation; ARCH-012 only mandates try-catch boundaries/exit codes and its automated rules passed. Re-read the cited ADR's Decision/Do's before accepting a FAIL; overrule misattributed style nits but still surface them as non-ADR notes. Recurred 2026-07-01: the same await-on-sync-helper nit came back self-labeled "ARCH-NONE" — a finding citing no ADR can never block. +- **Commander hoists parent-known options away from nested subcommands** — if a parent command and its child subcommand declare the same option (e.g. `session-context ` and ` show` both taking `--max-entries`), commander (without positional-options mode) parses the flag onto the PARENT no matter where it appears in argv — the child's `opts` silently gets `undefined`. Read merged values in the child action via `command.optsWithGlobals()` (third action parameter), and add an in-process regression test that passes the flag through the full `parseAsync` path. Shipped broken in v0.46.0, fixed in PR #448. - **Bun `mock.module` state is process-global across test FILES** — a helper mock registered in one command test file leaks into other files' imports of the same module (live bindings get re-bound to the mock), and module-level constants that capture function references freeze whatever binding existed at load time. Symptom: tests pass in isolation, fail (or silently hit mocks) in the full run. When a command test needs REAL helper behavior while sibling files mock those helpers, spawn the CLI via `tests/integration/cli-harness` `runCli` with `HOME`/`USERPROFILE`/`XDG_DATA_HOME` redirected to a temp dir (fresh subprocess = env-based homedir works). This is also why ARCH-005 prefers `spyOn` over `mock.module`. Hit in session-context list/show tests (PR #446). - **Git Bash `/tmp` is invisible to Windows-native tools** — A bash redirect to `/tmp/x` writes into Git Bash's virtual mount, but Windows-native python/node then fail with `FileNotFoundError` on that path (gh.exe survives only because Git Bash rewrites path-looking arguments). When piping a file between bash and Windows-native tools, use a repo-relative path (and clean it up) or `$TMPDIR`. - **oxlint `no-negated-condition`** — Always write ternaries with the positive condition first: `x === null ? A : B` not `x !== null ? B : A`. Applies to both `if/else` blocks and ternary expressions. diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts index 760864ab..a9bca492 100644 --- a/src/commands/session-context/claude-code.ts +++ b/src/commands/session-context/claude-code.ts @@ -30,6 +30,20 @@ export const makeMaxEntriesOption = () => return n; }); +/** + * Resolve `--max-entries` for the nested `show` subcommands. The parent + * editor command declares the option too, and commander hoists + * parent-known options from anywhere on the command line — so the flag + * is usually parsed by the parent, not the child. optsWithGlobals() + * merges the ancestor values (child value wins when present). + */ +export function resolveMaxEntries( + opts: { maxEntries?: number }, + command: { optsWithGlobals: () => { maxEntries?: number } } +): number | undefined { + return opts.maxEntries ?? command.optsWithGlobals().maxEntries; +} + export function registerClaudeCodeSessionContextCommand(parent: Command) { const cmd = parent .command("claude-code") @@ -81,11 +95,11 @@ export function registerClaudeCodeSessionContextCommand(parent: Command) { .description("Read a specific Claude Code session by ID") .argument("", "session ID from `list`") .addOption(makeMaxEntriesOption()) - .action(async (sessionId, opts) => { + .action(async (sessionId, opts, command) => { try { const projectRoot = findProjectRoot(); const result = await readClaudeCodeSession(projectRoot, { - maxEntries: opts.maxEntries, + maxEntries: resolveMaxEntries(opts, command), sessionId, }); diff --git a/src/commands/session-context/copilot.ts b/src/commands/session-context/copilot.ts index 942ca56f..7cafd595 100644 --- a/src/commands/session-context/copilot.ts +++ b/src/commands/session-context/copilot.ts @@ -10,7 +10,7 @@ import { listCopilotSessions, readCopilotSession, } from "../../helpers/session-context-copilot"; -import { makeMaxEntriesOption } from "./claude-code"; +import { makeMaxEntriesOption, resolveMaxEntries } from "./claude-code"; export function registerCopilotSessionContextCommand(parent: Command) { const cmd = parent @@ -63,11 +63,11 @@ export function registerCopilotSessionContextCommand(parent: Command) { .description("Read a specific Copilot CLI session by UUID") .argument("", "session UUID from `list`") .addOption(makeMaxEntriesOption()) - .action(async (sessionId, opts) => { + .action(async (sessionId, opts, command) => { try { const projectRoot = findProjectRoot(); const result = await readCopilotSession(projectRoot, { - maxEntries: opts.maxEntries, + maxEntries: resolveMaxEntries(opts, command), sessionId, }); diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts index 9bc4d6ef..31e8df3a 100644 --- a/src/commands/session-context/cursor.ts +++ b/src/commands/session-context/cursor.ts @@ -10,7 +10,7 @@ import { listCursorSessions, readCursorSession, } from "../../helpers/session-context"; -import { makeMaxEntriesOption } from "./claude-code"; +import { makeMaxEntriesOption, resolveMaxEntries } from "./claude-code"; export function registerCursorSessionContextCommand(parent: Command) { const cmd = parent @@ -63,11 +63,11 @@ export function registerCursorSessionContextCommand(parent: Command) { .description("Read a specific Cursor agent session by UUID") .argument("", "session UUID from `list`") .addOption(makeMaxEntriesOption()) - .action(async (sessionId, opts) => { + .action(async (sessionId, opts, command) => { try { const projectRoot = findProjectRoot(); const result = await readCursorSession(projectRoot, { - maxEntries: opts.maxEntries, + maxEntries: resolveMaxEntries(opts, command), sessionId, }); diff --git a/src/commands/session-context/opencode.ts b/src/commands/session-context/opencode.ts index b7550a17..c0ce6a46 100644 --- a/src/commands/session-context/opencode.ts +++ b/src/commands/session-context/opencode.ts @@ -10,7 +10,7 @@ import { listOpencodeSessions, readOpencodeSession, } from "../../helpers/session-context-opencode"; -import { makeMaxEntriesOption } from "./claude-code"; +import { makeMaxEntriesOption, resolveMaxEntries } from "./claude-code"; export function registerOpencodeSessionContextCommand(parent: Command) { const cmd = parent @@ -65,11 +65,11 @@ export function registerOpencodeSessionContextCommand(parent: Command) { "--root", "resolve a sub-agent child session up to its top-level ancestor" ) - .action(async (sessionId, opts) => { + .action(async (sessionId, opts, command) => { try { const projectRoot = findProjectRoot(); const result = readOpencodeSession(projectRoot, { - maxEntries: opts.maxEntries, + maxEntries: resolveMaxEntries(opts, command), sessionId, root: opts.root, }); diff --git a/tests/commands/session-context/claude-code.test.ts b/tests/commands/session-context/claude-code.test.ts index da908561..97bdb3ef 100644 --- a/tests/commands/session-context/claude-code.test.ts +++ b/tests/commands/session-context/claude-code.test.ts @@ -249,6 +249,28 @@ describe("claude-code action handler", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); + + test("show subcommand applies --max-entries (hoisted by the parent)", async () => { + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + + // Regression: the parent editor command also declares --max-entries and + // commander hoists parent-known options from anywhere in argv, so the + // child must read the merged value via optsWithGlobals(). + await makeProgram().parseAsync([ + "node", + "session-context", + "claude-code", + "show", + "abc123", + "--max-entries", + "2", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { + maxEntries: 2, + sessionId: "abc123", + }); + }); }); describe("claude-code list/show (CLI subprocess)", () => { @@ -305,12 +327,27 @@ describe("claude-code list/show (CLI subprocess)", () => { expect(Date.parse(parsed.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); }); - test("show reads a specific session by id", async () => { - seedSession("older", "earlier content"); + test("show reads a specific session by id and applies --max-entries", async () => { + // Two-entry session so --max-entries 1 provably trims + const dir = join(tempHome, ".claude", "projects", encodeClaude(projectDir)); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "older.jsonl"), + [ + JSON.stringify({ + type: "user", + message: { role: "user", content: "earlier content" }, + }), + JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: "earlier reply" }, + }), + ].join("\n") + ); seedSession("newer", "current content"); const { exitCode, stdout } = await runCli( - ["session-context", "claude-code", "show", "older"], + ["session-context", "claude-code", "show", "older", "--max-entries", "1"], projectDir, env ); @@ -321,7 +358,9 @@ describe("claude-code list/show (CLI subprocess)", () => { transcript: Array<{ contentPreview: string }>; }; expect(parsed.sessionFile).toBe("older.jsonl"); - expect(parsed.transcript[0]?.contentPreview).toBe("earlier content"); + // --max-entries keeps the LAST entry — trimming applied through show + expect(parsed.transcript).toHaveLength(1); + expect(parsed.transcript[0]?.contentPreview).toBe("earlier reply"); }); test("show with an unknown id exits 1", async () => { diff --git a/tests/commands/session-context/copilot.test.ts b/tests/commands/session-context/copilot.test.ts index 1fc3d327..c129c69d 100644 --- a/tests/commands/session-context/copilot.test.ts +++ b/tests/commands/session-context/copilot.test.ts @@ -244,4 +244,26 @@ describe("copilot action handler", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); + + test("show subcommand applies --max-entries (hoisted by the parent)", async () => { + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + + // Regression: the parent editor command also declares --max-entries and + // commander hoists parent-known options from anywhere in argv, so the + // child must read the merged value via optsWithGlobals(). + await makeProgram().parseAsync([ + "node", + "session-context", + "copilot", + "show", + "abc123", + "--max-entries", + "2", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { + maxEntries: 2, + sessionId: "abc123", + }); + }); }); diff --git a/tests/commands/session-context/cursor.test.ts b/tests/commands/session-context/cursor.test.ts index 183dff64..596074a5 100644 --- a/tests/commands/session-context/cursor.test.ts +++ b/tests/commands/session-context/cursor.test.ts @@ -244,4 +244,26 @@ describe("cursor action handler", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); + + test("show subcommand applies --max-entries (hoisted by the parent)", async () => { + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + + // Regression: the parent editor command also declares --max-entries and + // commander hoists parent-known options from anywhere in argv, so the + // child must read the merged value via optsWithGlobals(). + await makeProgram().parseAsync([ + "node", + "session-context", + "cursor", + "show", + "abc123", + "--max-entries", + "2", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { + maxEntries: 2, + sessionId: "abc123", + }); + }); }); diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts index 8bcc77b1..0935a96f 100644 --- a/tests/commands/session-context/opencode.test.ts +++ b/tests/commands/session-context/opencode.test.ts @@ -256,6 +256,29 @@ describe("opencode action handler", () => { expect(exitSpy).toHaveBeenCalledWith(1); }); + test("show subcommand applies --max-entries (hoisted by the parent)", async () => { + readSpy.mockReturnValue({ ok: true, data: emptySummary() }); + + // Regression: the parent editor command also declares --max-entries and + // commander hoists parent-known options from anywhere in argv, so the + // child must read the merged value via optsWithGlobals(). + await makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "show", + "abc123", + "--max-entries", + "2", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { + maxEntries: 2, + sessionId: "abc123", + root: undefined, + }); + }); + test("show subcommand passes --root through", async () => { readSpy.mockReturnValue({ ok: true, data: emptySummary() });