Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/agent-memory/archgate-developer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <editor>` and `<editor> 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.
Expand Down
18 changes: 16 additions & 2 deletions src/commands/session-context/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -81,11 +95,11 @@ export function registerClaudeCodeSessionContextCommand(parent: Command) {
.description("Read a specific Claude Code session by ID")
.argument("<session-id>", "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,
});

Expand Down
6 changes: 3 additions & 3 deletions src/commands/session-context/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,11 +63,11 @@ export function registerCopilotSessionContextCommand(parent: Command) {
.description("Read a specific Copilot CLI session by UUID")
.argument("<session-id>", "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,
});

Expand Down
6 changes: 3 additions & 3 deletions src/commands/session-context/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,11 +63,11 @@ export function registerCursorSessionContextCommand(parent: Command) {
.description("Read a specific Cursor agent session by UUID")
.argument("<session-id>", "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,
});

Expand Down
6 changes: 3 additions & 3 deletions src/commands/session-context/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
Expand Down
47 changes: 43 additions & 4 deletions tests/commands/session-context/claude-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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
);
Expand All @@ -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 () => {
Expand Down
22 changes: 22 additions & 0 deletions tests/commands/session-context/copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
});
22 changes: 22 additions & 0 deletions tests/commands/session-context/cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
});
23 changes: 23 additions & 0 deletions tests/commands/session-context/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() });

Expand Down