From 4489f27fa0acab4e8539eb29e6616023e7f7d235 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 05:56:27 -0300 Subject: [PATCH 01/15] fix(session-context): correct false sub-agent premise in --skip help and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --skip help text on claude-code/cursor/copilot claimed the flag is "useful when running as a sub-agent" — built on the false premise that skills/sub-agents create their own project session. Verified on real data for Claude Code: skills run inline (the current conversation IS the most recent session file, so --skip 1 reads an unrelated previous conversation), and Agent-tool sub-agents do not write session files into ~/.claude/projects/ at all. The plain command (skip 0) is correct in every agent context. - Reword the --skip option help on claude-code, cursor, and copilot - Rewrite the shared ReadSessionOptions.skip JSDoc - Fix the docs option rows and replace the "read the parent session" example with an accurate one (en/nb/pt-br), regenerate llms-full.txt - Update the session-context agent memory: claude-code/cursor/copilot guidance resolved; document the concurrent-conversations caveat Companion: the distributed lessons-learned skill's canonical source drops --skip 1 in favor of the plain command. Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- .../agent-memory/archgate-developer/MEMORY.md | 1 + .../project_session_context_skip_root_fix.md | 6 ++-- docs/public/llms-full.txt | 8 ++--- .../docs/nb/reference/cli/session-context.mdx | 30 +++++++++---------- .../pt-br/reference/cli/session-context.mdx | 30 +++++++++---------- .../docs/reference/cli/session-context.mdx | 30 +++++++++---------- src/commands/session-context/claude-code.ts | 2 +- src/commands/session-context/copilot.ts | 2 +- src/commands/session-context/cursor.ts | 2 +- src/helpers/session-context.ts | 10 +++++-- 10 files changed, 65 insertions(+), 56 deletions(-) diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index dc162c90..4db91bd9 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -44,6 +44,7 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - **YAML double-quoted strings require escaped backslashes for Windows paths in tests** — YAML interprets `\` as an escape character inside double-quoted strings. Writing `cwd: "E:\project"` silently corrupts the parsed value because `\p` is not a valid escape sequence. Fix: use `JSON.stringify(path)` to produce properly escaped YAML values (e.g., `cwd: ${JSON.stringify(cwd)}`). JSON and YAML double-quoted strings share the same escape syntax. Encountered in Copilot CLI session-context tests (`workspace.yaml` with Windows paths). - **`Bun.Glob.match()` triggers oxlint `prefer-regexp-test`** — `Bun.Glob.match()` returns a boolean (not a RegExp), but oxlint can't tell. Suppress with `// oxlint-disable-next-line prefer-regexp-test -- Bun.Glob.match() returns boolean, not RegExp`. - **Live `opencode.db` can't be opened `readonly: true` while opencode runs** — `new Database(path, { readonly: true })` fails with `SQLITE_CANTOPEN` (errno 14) on the live WAL-mode DB (`~/.local/share/opencode/opencode.db`). To inspect real data, copy `opencode.db` + `.db-wal` + `.db-shm` to a temp dir and open the copy. Data model facts load-bearing for `session-context opencode`: sub-agent runs are child sessions (`parent_id` set) sharing the parent's `directory`, and opencode skills run INLINE in the calling session (no own session row) — recency selection must filter `parent_id IS NULL` (fixed 2026-07-01; `--root` resolves a `--session-id` child to its top-level ancestor). +- [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 `archgate session-context opencode --root` (was `--skip 1`, which read sub-agent transcripts). When changing session-context CLI semantics, update the shipped skill in the same effort AND sequence releases: the CLI ships the flag first, the plugin follows — a skill referencing a flag the installed CLI lacks dies with "unknown option". 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. - **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`. diff --git a/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md index d2e4b95e..c63c36a7 100644 --- a/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md +++ b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md @@ -1,6 +1,6 @@ --- name: project-session-context-skip-root-fix -description: opencode session-context --skip 1 sibling/inline-skill bug fixed 2026-07-01 (top-level default + --root ancestry walk); same class of bug likely still open for claude-code/cursor/copilot +description: session-context --skip 1 inline-skill bug — opencode fixed 2026-07-01 (top-level default + --root); claude-code/cursor/copilot guidance fixed 2026-07-02 (plain command; --skip 1 premise was false everywhere) metadata: type: project --- @@ -11,6 +11,8 @@ Fixed a real, reproduced bug in `archgate session-context opencode --skip 1`: it Top-level filtering fixes the common case regardless of nesting depth or sibling count; `--session-id --root` gives deterministic ancestry resolution when the caller knows a session inside the right conversation tree (relevant when several top-level sessions share a directory — recency alone can pick a different conversation's root). The distributed archgate editor plugin's opencode lessons-learned skill was updated to use `--root` instead of `--skip 1`. -**Open/unresolved:** The _same class_ of bug likely still affects the other 3 editors' lessons-learned skill guidance (`archgate session-context claude-code --skip 1`, `cursor --skip 1`). Live-reproduced for Claude Code: running `archgate session-context claude-code --skip 1` from within an inline Skill invocation returned an unrelated, unconnected past session (`6ee6f0a5-...` about "ExitWorktree"), not the current/parent conversation — because Claude Code's Skill tool also runs inline, so `--skip 1` skips past the correct (current) session file. Unlike opencode, Claude Code/Cursor have no `parent_id`-equivalent — sessions are flat per-conversation JSONL/directory files with no structural parent link, so the `--root` fix pattern doesn't directly transfer; a different fix (e.g. detecting "am I inline vs a real sub-agent" some other way, or defaulting to `--skip 0` for inline skill runs) would be needed. Not investigated for Copilot. This has NOT been fixed — flag to the user before doing more lessons-learned/reviewer work that depends on `--skip 1` being reliable for non-opencode editors. +**Resolved for the other editors (2026-07-02):** The `--skip 1` guidance was wrong for ALL editors, not just opencode, but for a different reason — the premise "this skill runs as a sub-agent with its own session file" is simply false. Verified for Claude Code on real data: (1) skills run inline, so the current conversation is the most recent session file and `--skip 1` reads an unrelated previous conversation (live-reproduced: got an unrelated "ExitWorktree" session); (2) Agent-tool sub-agents do NOT write session files into `~/.claude/projects//` (4 sub-agents spawned, zero new .jsonl files) and modern sessions contain zero `isSidechain: true` entries — so even from a sub-agent, the most recent project session is the main conversation. Conclusion: the plain command (skip 0) is correct in every case; no `parent_id`-style CLI fix is needed for Claude Code. Cursor/Copilot skill variants had the same false guidance (inherited from the canonical claude-code SKILL.md source); their sub-agent storage behavior is unverified but the plain command + transcript sanity check is the right default there too. Fixed at two levels: the distributed lessons-learned skill's canonical source now instructs the plain command, and the CLI's `--skip` help text/JSDoc/docs no longer claim "useful when running as a sub-agent". + +Remaining caveat (all editors): when several conversations for the same project run concurrently, most-recent-by-mtime can pick the other live conversation — for opencode, `--session-id --root` is deterministic; claude-code/cursor have no equivalent linkage. **How to apply:** If asked to investigate "session context returns wrong data" for claude-code, cursor, or copilot, start here — this is the same architectural flaw already fixed once for opencode, not a new mystery. diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index ce4149ca..b6e285a4 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4356,7 +4356,7 @@ archgate session-context claude-code [options] | Option | Description | | ------------------- | ------------------------------------------------------------------ | | `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | +| `--skip ` | Skip the N most recent sessions to read an earlier conversation | ### archgate session-context copilot @@ -4369,7 +4369,7 @@ archgate session-context copilot [options] | Option | Description | | ------------------- | ------------------------------------------------------------------ | | `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | +| `--skip ` | Skip the N most recent sessions to read an earlier conversation | | `--session-id ` | Specific session UUID to read | ### archgate session-context cursor @@ -4383,7 +4383,7 @@ archgate session-context cursor [options] | Option | Description | | ------------------- | ------------------------------------------------------------------ | | `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | +| `--skip ` | Skip the N most recent sessions to read an earlier conversation | | `--session-id ` | Specific session UUID to read | ### archgate session-context opencode @@ -4429,7 +4429,7 @@ Read the latest opencode session: archgate session-context opencode ``` -Read the parent session (skip the sub-agent's own session): +Read the previous conversation for the project (skip the current session): ```bash archgate session-context claude-code --skip 1 diff --git a/docs/src/content/docs/nb/reference/cli/session-context.mdx b/docs/src/content/docs/nb/reference/cli/session-context.mdx index 8786fda5..3ccfcadd 100644 --- a/docs/src/content/docs/nb/reference/cli/session-context.mdx +++ b/docs/src/content/docs/nb/reference/cli/session-context.mdx @@ -19,10 +19,10 @@ Les Claude Code-sesjonsloggen for prosjektet. archgate session-context claude-code [options] ``` -| Valg | Beskrivelse | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste sesjonene (nyttig ved kjøring som underagent) | +| Valg | Beskrivelse | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | +| `--skip ` | Hopp over de N nyeste sesjonene for å lese en tidligere samtale | ### archgate session-context copilot @@ -32,11 +32,11 @@ Les Copilot CLI-sesjonsloggen for prosjektet. Sesjoner matches etter arbeidsomr archgate session-context copilot [options] ``` -| Valg | Beskrivelse | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste sesjonene (nyttig ved kjøring som underagent) | -| `--session-id ` | Spesifikk sesjons-UUID å lese | +| Valg | Beskrivelse | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | +| `--skip ` | Hopp over de N nyeste sesjonene for å lese en tidligere samtale | +| `--session-id ` | Spesifikk sesjons-UUID å lese | ### archgate session-context cursor @@ -46,11 +46,11 @@ Les Cursor-agentsesjonsloggen for prosjektet. archgate session-context cursor [options] ``` -| Valg | Beskrivelse | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste sesjonene (nyttig ved kjøring som underagent) | -| `--session-id ` | Spesifikk sesjons-UUID å lese | +| Valg | Beskrivelse | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | +| `--skip ` | Hopp over de N nyeste sesjonene for å lese en tidligere samtale | +| `--session-id ` | Spesifikk sesjons-UUID å lese | ### archgate session-context opencode @@ -95,7 +95,7 @@ Les den nyeste opencode-sesjonen: archgate session-context opencode ``` -Les foreldresesjonen (hopp over underagentens egen sesjon): +Les forrige samtale for prosjektet (hopp over gjeldende sesjon): ```bash archgate session-context claude-code --skip 1 diff --git a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx index 8247e4f3..5a638d05 100644 --- a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx @@ -19,10 +19,10 @@ Lê a transcrição de sessão do Claude Code para o projeto. archgate session-context claude-code [options] ``` -| Opção | Descrição | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões mais recentes (útil ao executar como sub-agente) | +| Opção | Descrição | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | +| `--skip ` | Pular as N sessões mais recentes para ler uma conversa anterior | ### archgate session-context copilot @@ -32,11 +32,11 @@ Lê a transcrição de sessão do Copilot CLI para o projeto. Sessões são corr archgate session-context copilot [options] ``` -| Opção | Descrição | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões mais recentes (útil ao executar como sub-agente) | -| `--session-id ` | UUID específico da sessão a ser lida | +| Opção | Descrição | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | +| `--skip ` | Pular as N sessões mais recentes para ler uma conversa anterior | +| `--session-id ` | UUID específico da sessão a ser lida | ### archgate session-context cursor @@ -46,11 +46,11 @@ Lê a transcrição de sessão do agente Cursor para o projeto. archgate session-context cursor [options] ``` -| Opção | Descrição | -| ------------------- | ------------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões mais recentes (útil ao executar como sub-agente) | -| `--session-id ` | UUID específico da sessão a ser lida | +| Opção | Descrição | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | +| `--skip ` | Pular as N sessões mais recentes para ler uma conversa anterior | +| `--session-id ` | UUID específico da sessão a ser lida | ### archgate session-context opencode @@ -95,7 +95,7 @@ Ler a última sessão do opencode: archgate session-context opencode ``` -Ler a sessão pai (pular a sessão do sub-agente): +Ler a conversa anterior do projeto (pular a sessão atual): ```bash archgate session-context claude-code --skip 1 diff --git a/docs/src/content/docs/reference/cli/session-context.mdx b/docs/src/content/docs/reference/cli/session-context.mdx index 131b4886..e2d2812c 100644 --- a/docs/src/content/docs/reference/cli/session-context.mdx +++ b/docs/src/content/docs/reference/cli/session-context.mdx @@ -19,10 +19,10 @@ Read the Claude Code session transcript for the project. archgate session-context claude-code [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | +| Option | Description | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--skip ` | Skip the N most recent sessions to read an earlier conversation | ### archgate session-context copilot @@ -32,11 +32,11 @@ Read the Copilot CLI session transcript for the project. Sessions are matched by archgate session-context copilot [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--skip ` | Skip the N most recent sessions to read an earlier conversation | +| `--session-id ` | Specific session UUID to read | ### archgate session-context cursor @@ -46,11 +46,11 @@ Read the Cursor agent session transcript for the project. archgate session-context cursor [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions (useful when running as sub-agent) | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--skip ` | Skip the N most recent sessions to read an earlier conversation | +| `--session-id ` | Specific session UUID to read | ### archgate session-context opencode @@ -95,7 +95,7 @@ Read the latest opencode session: archgate session-context opencode ``` -Read the parent session (skip the sub-agent's own session): +Read the previous conversation for the project (skip the current session): ```bash archgate session-context claude-code --skip 1 diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts index a4a6bc7a..9ecd5e23 100644 --- a/src/commands/session-context/claude-code.ts +++ b/src/commands/session-context/claude-code.ts @@ -16,7 +16,7 @@ const maxEntriesOption = new Option( const skipOption = new Option( "--skip ", - "skip the N most recent sessions (useful when running as a sub-agent)" + "skip the N most recent sessions to read an earlier conversation" ) .argParser((val) => Math.trunc(Number(val))) .default(0); diff --git a/src/commands/session-context/copilot.ts b/src/commands/session-context/copilot.ts index 1dedd2e2..4a3dd027 100644 --- a/src/commands/session-context/copilot.ts +++ b/src/commands/session-context/copilot.ts @@ -16,7 +16,7 @@ const maxEntriesOption = new Option( const skipOption = new Option( "--skip ", - "skip the N most recent sessions (useful when running as a sub-agent)" + "skip the N most recent sessions to read an earlier conversation" ) .argParser((val) => Math.trunc(Number(val))) .default(0); diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts index b9a9b2a4..5882d12f 100644 --- a/src/commands/session-context/cursor.ts +++ b/src/commands/session-context/cursor.ts @@ -16,7 +16,7 @@ const maxEntriesOption = new Option( const skipOption = new Option( "--skip ", - "skip the N most recent sessions (useful when running as a sub-agent)" + "skip the N most recent sessions to read an earlier conversation" ) .argParser((val) => Math.trunc(Number(val))) .default(0); diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index dcb97f15..322b44af 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -111,8 +111,14 @@ export interface ReadSessionOptions { maxEntries?: number; /** * Skip the N most recent sessions before selecting the one to read. - * Useful when running as a sub-agent: the sub-agent's own session is - * the most recent, so `skip: 1` reads the parent session instead. + * + * This is an escape hatch for reading an earlier conversation — NOT a + * way to reach "the parent session" from an agent context. Editor + * skills run inline in the current conversation, and sub-agent + * transcripts are not stored as project sessions (verified for Claude + * Code; opencode additionally excludes its child sessions from recency + * selection), so the most recent session is normally the conversation + * running right now and `skip: 0` is the correct default. */ skip?: number; } From 7b87dbc5ce9856195bb8a56381f3c3f9977de4c7 Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:57:09 +0000 Subject: [PATCH 02/15] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/public/llms-full.txt | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index b6e285a4..1e0c8a5e 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4353,10 +4353,10 @@ Read the Claude Code session transcript for the project. archgate session-context claude-code [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions to read an earlier conversation | +| Option | Description | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--skip ` | Skip the N most recent sessions to read an earlier conversation | ### archgate session-context copilot @@ -4366,11 +4366,11 @@ Read the Copilot CLI session transcript for the project. Sessions are matched by archgate session-context copilot [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions to read an earlier conversation | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--skip ` | Skip the N most recent sessions to read an earlier conversation | +| `--session-id ` | Specific session UUID to read | ### archgate session-context cursor @@ -4380,11 +4380,11 @@ Read the Cursor agent session transcript for the project. archgate session-context cursor [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions to read an earlier conversation | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | --------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--skip ` | Skip the N most recent sessions to read an earlier conversation | +| `--session-id ` | Specific session UUID to read | ### archgate session-context opencode From ce4ea47d1bcfeab8090e1fc58a41c0774b83ed65 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 06:05:11 -0300 Subject: [PATCH 03/15] chore(memory): capture public-repo privacy boundary for agent memory Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- .claude/agent-memory/archgate-developer/MEMORY.md | 2 ++ .../feedback_public_repo_privacy.md | 12 ++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 .claude/agent-memory/archgate-developer/feedback_public_repo_privacy.md diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 4db91bd9..256aef84 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -25,6 +25,8 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - [No prod changes for testability](feedback_no_prod_changes_for_tests.md) — mock implementations in tests (spyOn os.homedir works cross-module); never alter prod semantics for test isolation - [Pick the right enforcement layer](feedback_prefer_tests_over_adr_rules.md) — static syntactic invariants → custom oxlint rule in `.archgate/lint/oxlint.ts`; tests are for executable behavior; ADR .rules.ts for cross-file/governance checks +- [This repo is PUBLIC — no private sibling-repo internals in memory/PRs](feedback_public_repo_privacy.md) — split captures: public-safe summary here, full detail in the private repo's own memory + ## Known Bugs - _(none currently)_ diff --git a/.claude/agent-memory/archgate-developer/feedback_public_repo_privacy.md b/.claude/agent-memory/archgate-developer/feedback_public_repo_privacy.md new file mode 100644 index 00000000..40b09434 --- /dev/null +++ b/.claude/agent-memory/archgate-developer/feedback_public_repo_privacy.md @@ -0,0 +1,12 @@ +--- +name: feedback-public-repo-privacy +description: This repo is PUBLIC — never commit private sibling-repo internals into agent memory, PR bodies, or PR comments here +metadata: + type: feedback +--- + +Never put internals of private sibling repos (repo-relative paths, build-script names, service/backend structure, private PR numbers/links) into anything committed or posted to this repo: agent-memory files, MEMORY.md, PR bodies, PR comments, commit messages. This repo is public; the sibling plugins repo is private. + +**Why:** On 2026-07-02 the user flagged that agent-memory files describing the private sibling repo's build pipeline had been pushed to public PRs — they had to be scrubbed from the branch, the closed PR's branch deleted, and the PR body/comments edited. GitHub retains closed-PR diffs, so leaked content is hard to fully purge after the fact. + +**How to apply:** Before committing memory or posting PR text here, check for private-repo references. The private repo's _existence_ and the distributed plugin's _user-facing behavior_ (installed skill paths like `~/.config/opencode/skills/`, CLI flags the skills invoke) are public knowledge and fine to mention; its internal structure is not. Detailed sibling-repo knowledge belongs in that repo's own agent memory. When work spans both repos, split the capture: public-safe summary here, full detail there. From 9e9802dda17e3bbb753ae1063e23b6db76e747f9 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 06:46:36 -0300 Subject: [PATCH 04/15] feat(session-context)!: remove --skip, add --session-id and --list everywhere The session-context command's purpose is reading the current conversation's context; --skip existed solely for the false "skip the sub-agent's own session" premise removed in the previous commit, and recency arithmetic can never reliably address a specific earlier conversation anyway. Replace positional guessing with explicit selection: - Remove --skip from claude-code, cursor, copilot, and opencode - Add --session-id to claude-code (previously the only subcommand without one; ids are the JSONL file basenames) - Add --list to all four subcommands: prints the project's sessions as JSON (id + updatedAt; opencode also includes title and lists top-level sessions only). --list conflicts with --session-id. - opencode: reader is called without await (it is synchronous) - Docs (en/nb/pt-br) tables and examples updated; llms-full.txt regenerated; agent memory updated with the final API decision BREAKING CHANGE: `archgate session-context --skip ` is no longer accepted. Use plain invocation for the current conversation, `--list` to discover earlier sessions, and `--session-id ` to read one. Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- .../project_session_context_skip_root_fix.md | 2 +- docs/public/llms-full.txt | 19 +- .../docs/nb/reference/cli/session-context.mdx | 41 ++-- .../pt-br/reference/cli/session-context.mdx | 41 ++-- .../docs/reference/cli/session-context.mdx | 41 ++-- src/commands/session-context/claude-code.ts | 32 ++- src/commands/session-context/copilot.ts | 30 ++- src/commands/session-context/cursor.ts | 30 ++- src/commands/session-context/opencode.ts | 32 ++- src/helpers/session-context-copilot.ts | 79 ++++++-- src/helpers/session-context-opencode.ts | 120 ++++++++--- src/helpers/session-context.ts | 187 +++++++++++++----- tests/commands/session-context.test.ts | 22 ++- .../session-context/claude-code.test.ts | 31 ++- .../commands/session-context/copilot.test.ts | 8 +- tests/commands/session-context/cursor.test.ts | 8 +- .../commands/session-context/opencode.test.ts | 70 +++++-- tests/helpers/session-context-copilot.test.ts | 92 +++++---- tests/helpers/session-context-cursor.test.ts | 81 ++++---- .../helpers/session-context-opencode.test.ts | 50 +++-- tests/helpers/session-context.test.ts | 86 +++++--- 21 files changed, 771 insertions(+), 331 deletions(-) diff --git a/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md index c63c36a7..bb0dae72 100644 --- a/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md +++ b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md @@ -11,7 +11,7 @@ Fixed a real, reproduced bug in `archgate session-context opencode --skip 1`: it Top-level filtering fixes the common case regardless of nesting depth or sibling count; `--session-id --root` gives deterministic ancestry resolution when the caller knows a session inside the right conversation tree (relevant when several top-level sessions share a directory — recency alone can pick a different conversation's root). The distributed archgate editor plugin's opencode lessons-learned skill was updated to use `--root` instead of `--skip 1`. -**Resolved for the other editors (2026-07-02):** The `--skip 1` guidance was wrong for ALL editors, not just opencode, but for a different reason — the premise "this skill runs as a sub-agent with its own session file" is simply false. Verified for Claude Code on real data: (1) skills run inline, so the current conversation is the most recent session file and `--skip 1` reads an unrelated previous conversation (live-reproduced: got an unrelated "ExitWorktree" session); (2) Agent-tool sub-agents do NOT write session files into `~/.claude/projects//` (4 sub-agents spawned, zero new .jsonl files) and modern sessions contain zero `isSidechain: true` entries — so even from a sub-agent, the most recent project session is the main conversation. Conclusion: the plain command (skip 0) is correct in every case; no `parent_id`-style CLI fix is needed for Claude Code. Cursor/Copilot skill variants had the same false guidance (inherited from the canonical claude-code SKILL.md source); their sub-agent storage behavior is unverified but the plain command + transcript sanity check is the right default there too. Fixed at two levels: the distributed lessons-learned skill's canonical source now instructs the plain command, and the CLI's `--skip` help text/JSDoc/docs no longer claim "useful when running as a sub-agent". +**Resolved for the other editors (2026-07-02):** The `--skip 1` guidance was wrong for ALL editors, not just opencode, but for a different reason — the premise "this skill runs as a sub-agent with its own session file" is simply false. Verified for Claude Code on real data: (1) skills run inline, so the current conversation is the most recent session file and `--skip 1` reads an unrelated previous conversation (live-reproduced: got an unrelated "ExitWorktree" session); (2) Agent-tool sub-agents do NOT write session files into `~/.claude/projects//` (4 sub-agents spawned, zero new .jsonl files) and modern sessions contain zero `isSidechain: true` entries — so even from a sub-agent, the most recent project session is the main conversation. Conclusion: the plain command (skip 0) is correct in every case; no `parent_id`-style CLI fix is needed for Claude Code. Cursor/Copilot skill variants had the same false guidance (inherited from the canonical claude-code SKILL.md source); their sub-agent storage behavior is unverified but the plain command + transcript sanity check is the right default there too. Fixed at two levels: the distributed lessons-learned skill's canonical source now instructs the plain command, and — since the flag's only advertised purpose was this false premise — `--skip` was REMOVED from all four subcommands entirely (2026-07-02, user decision). Explicit selection replaced it: `--list` (lists the project's sessions with ids + timestamps; top-level only for opencode) and `--session-id` (now also on claude-code, which previously had no way to address an earlier session). Remaining caveat (all editors): when several conversations for the same project run concurrently, most-recent-by-mtime can pick the other live conversation — for opencode, `--session-id --root` is deterministic; claude-code/cursor have no equivalent linkage. diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 1e0c8a5e..505d35d5 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4356,7 +4356,8 @@ archgate session-context claude-code [options] | Option | Description | | ------------------- | --------------------------------------------------------------- | | `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions to read an earlier conversation | +| `--session-id ` | Specific session ID to read | +| `--list` | List available sessions for the project instead of reading one | ### archgate session-context copilot @@ -4369,7 +4370,7 @@ archgate session-context copilot [options] | Option | Description | | ------------------- | --------------------------------------------------------------- | | `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions to read an earlier conversation | +| `--list` | List available sessions for the project instead of reading one | | `--session-id ` | Specific session UUID to read | ### archgate session-context cursor @@ -4383,7 +4384,7 @@ archgate session-context cursor [options] | Option | Description | | ------------------- | --------------------------------------------------------------- | | `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions to read an earlier conversation | +| `--list` | List available sessions for the project instead of reading one | | `--session-id ` | Specific session UUID to read | ### archgate session-context opencode @@ -4399,7 +4400,7 @@ archgate session-context opencode [options] | Option | Description | | ------------------- | ------------------------------------------------------------------------------------------------------ | | `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent top-level sessions (sub-agent sessions always excluded) | +| `--list` | List available top-level sessions for the project instead of reading one | | `--session-id ` | Specific session ID to read | | `--root` | Resolve to the top-level (root) session — with `--session-id`, walks up from a sub-agent child session | @@ -4429,10 +4430,16 @@ Read the latest opencode session: archgate session-context opencode ``` -Read the previous conversation for the project (skip the current session): +List available sessions for the project: ```bash -archgate session-context claude-code --skip 1 +archgate session-context claude-code --list +``` + +Read a specific earlier session by ID: + +```bash +archgate session-context claude-code --session-id 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Resolve an opencode sub-agent child session to its top-level ancestor: diff --git a/docs/src/content/docs/nb/reference/cli/session-context.mdx b/docs/src/content/docs/nb/reference/cli/session-context.mdx index 3ccfcadd..d13dee64 100644 --- a/docs/src/content/docs/nb/reference/cli/session-context.mdx +++ b/docs/src/content/docs/nb/reference/cli/session-context.mdx @@ -19,10 +19,11 @@ Les Claude Code-sesjonsloggen for prosjektet. archgate session-context claude-code [options] ``` -| Valg | Beskrivelse | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste sesjonene for å lese en tidligere samtale | +| Valg | Beskrivelse | +| ------------------- | --------------------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | +| `--session-id ` | Spesifikk sesjons-ID å lese | +| `--list` | List opp tilgjengelige sesjoner for prosjektet i stedet for å lese én | ### archgate session-context copilot @@ -32,11 +33,11 @@ Les Copilot CLI-sesjonsloggen for prosjektet. Sesjoner matches etter arbeidsomr archgate session-context copilot [options] ``` -| Valg | Beskrivelse | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste sesjonene for å lese en tidligere samtale | -| `--session-id ` | Spesifikk sesjons-UUID å lese | +| Valg | Beskrivelse | +| ------------------- | --------------------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | +| `--list` | List opp tilgjengelige sesjoner for prosjektet i stedet for å lese én | +| `--session-id ` | Spesifikk sesjons-UUID å lese | ### archgate session-context cursor @@ -46,11 +47,11 @@ Les Cursor-agentsesjonsloggen for prosjektet. archgate session-context cursor [options] ``` -| Valg | Beskrivelse | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste sesjonene for å lese en tidligere samtale | -| `--session-id ` | Spesifikk sesjons-UUID å lese | +| Valg | Beskrivelse | +| ------------------- | --------------------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | +| `--list` | List opp tilgjengelige sesjoner for prosjektet i stedet for å lese én | +| `--session-id ` | Spesifikk sesjons-UUID å lese | ### archgate session-context opencode @@ -65,7 +66,7 @@ archgate session-context opencode [options] | Valg | Beskrivelse | | ------------------- | ----------------------------------------------------------------------------------------------------- | | `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--skip ` | Hopp over de N nyeste toppnivåsesjonene (underagentsesjoner ekskluderes alltid) | +| `--list` | List opp tilgjengelige toppnivåsesjoner for prosjektet i stedet for å lese én | | `--session-id ` | Spesifikk sesjons-ID å lese | | `--root` | Løs opp til toppnivåsesjonen (rot) — med `--session-id` gås det oppover fra en underagent-barnesesjon | @@ -95,10 +96,16 @@ Les den nyeste opencode-sesjonen: archgate session-context opencode ``` -Les forrige samtale for prosjektet (hopp over gjeldende sesjon): +List opp tilgjengelige sesjoner for prosjektet: ```bash -archgate session-context claude-code --skip 1 +archgate session-context claude-code --list +``` + +Les en bestemt tidligere sesjon med ID: + +```bash +archgate session-context claude-code --session-id 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Løs opp en underagent-barnesesjon i opencode til dens toppnivåforelder: diff --git a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx index 5a638d05..c83a63aa 100644 --- a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx @@ -19,10 +19,11 @@ Lê a transcrição de sessão do Claude Code para o projeto. archgate session-context claude-code [options] ``` -| Opção | Descrição | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões mais recentes para ler uma conversa anterior | +| Opção | Descrição | +| ------------------- | --------------------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | +| `--session-id ` | ID específico da sessão a ser lida | +| `--list` | Lista as sessões disponíveis do projeto em vez de ler uma | ### archgate session-context copilot @@ -32,11 +33,11 @@ Lê a transcrição de sessão do Copilot CLI para o projeto. Sessões são corr archgate session-context copilot [options] ``` -| Opção | Descrição | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões mais recentes para ler uma conversa anterior | -| `--session-id ` | UUID específico da sessão a ser lida | +| Opção | Descrição | +| ------------------- | --------------------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | +| `--list` | Lista as sessões disponíveis do projeto em vez de ler uma | +| `--session-id ` | UUID específico da sessão a ser lida | ### archgate session-context cursor @@ -46,11 +47,11 @@ Lê a transcrição de sessão do agente Cursor para o projeto. archgate session-context cursor [options] ``` -| Opção | Descrição | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões mais recentes para ler uma conversa anterior | -| `--session-id ` | UUID específico da sessão a ser lida | +| Opção | Descrição | +| ------------------- | --------------------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | +| `--list` | Lista as sessões disponíveis do projeto em vez de ler uma | +| `--session-id ` | UUID específico da sessão a ser lida | ### archgate session-context opencode @@ -65,7 +66,7 @@ archgate session-context opencode [options] | Opção | Descrição | | ------------------- | -------------------------------------------------------------------------------------------------------------------- | | `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--skip ` | Pular as N sessões de nível superior mais recentes (sessões de sub-agente são sempre excluídas) | +| `--list` | Lista as sessões de nível superior disponíveis do projeto em vez de ler uma | | `--session-id ` | ID específico da sessão a ser lida | | `--root` | Resolve para a sessão de nível superior (raiz) — com `--session-id`, sobe a partir de uma sessão filha de sub-agente | @@ -95,10 +96,16 @@ Ler a última sessão do opencode: archgate session-context opencode ``` -Ler a conversa anterior do projeto (pular a sessão atual): +Listar as sessões disponíveis do projeto: ```bash -archgate session-context claude-code --skip 1 +archgate session-context claude-code --list +``` + +Ler uma sessão anterior específica por ID: + +```bash +archgate session-context claude-code --session-id 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Resolver uma sessão filha de sub-agente do opencode para seu ancestral de nível superior: diff --git a/docs/src/content/docs/reference/cli/session-context.mdx b/docs/src/content/docs/reference/cli/session-context.mdx index e2d2812c..4d608f26 100644 --- a/docs/src/content/docs/reference/cli/session-context.mdx +++ b/docs/src/content/docs/reference/cli/session-context.mdx @@ -19,10 +19,11 @@ Read the Claude Code session transcript for the project. archgate session-context claude-code [options] ``` -| Option | Description | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions to read an earlier conversation | +| Option | Description | +| ------------------- | -------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--session-id ` | Specific session ID to read | +| `--list` | List available sessions for the project instead of reading one | ### archgate session-context copilot @@ -32,11 +33,11 @@ Read the Copilot CLI session transcript for the project. Sessions are matched by archgate session-context copilot [options] ``` -| Option | Description | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions to read an earlier conversation | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | -------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--list` | List available sessions for the project instead of reading one | +| `--session-id ` | Specific session UUID to read | ### archgate session-context cursor @@ -46,11 +47,11 @@ Read the Cursor agent session transcript for the project. archgate session-context cursor [options] ``` -| Option | Description | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent sessions to read an earlier conversation | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | -------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--list` | List available sessions for the project instead of reading one | +| `--session-id ` | Specific session UUID to read | ### archgate session-context opencode @@ -65,7 +66,7 @@ archgate session-context opencode [options] | Option | Description | | ------------------- | ------------------------------------------------------------------------------------------------------ | | `--max-entries ` | Maximum entries to return (default: 200) | -| `--skip ` | Skip the N most recent top-level sessions (sub-agent sessions always excluded) | +| `--list` | List available top-level sessions for the project instead of reading one | | `--session-id ` | Specific session ID to read | | `--root` | Resolve to the top-level (root) session — with `--session-id`, walks up from a sub-agent child session | @@ -95,10 +96,16 @@ Read the latest opencode session: archgate session-context opencode ``` -Read the previous conversation for the project (skip the current session): +List available sessions for the project: ```bash -archgate session-context claude-code --skip 1 +archgate session-context claude-code --list +``` + +Read a specific earlier session by ID: + +```bash +archgate session-context claude-code --session-id 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Resolve an opencode sub-agent child session to its top-level ancestor: diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts index 9ecd5e23..832e45ba 100644 --- a/src/commands/session-context/claude-code.ts +++ b/src/commands/session-context/claude-code.ts @@ -7,32 +7,46 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readClaudeCodeSession } from "../../helpers/session-context"; +import { + listClaudeCodeSessions, + readClaudeCodeSession, +} from "../../helpers/session-context"; const maxEntriesOption = new Option( "--max-entries ", "maximum entries to return (default: 200)" ).argParser((val) => Math.trunc(Number(val))); -const skipOption = new Option( - "--skip ", - "skip the N most recent sessions to read an earlier conversation" -) - .argParser((val) => Math.trunc(Number(val))) - .default(0); +const listOption = new Option( + "--list", + "list available sessions for the project instead of reading one" +).conflicts("sessionId"); export function registerClaudeCodeSessionContextCommand(parent: Command) { parent .command("claude-code") .description("Read Claude Code session transcript for the project") .addOption(maxEntriesOption) - .addOption(skipOption) + .option("--session-id ", "Specific session ID to read") + .addOption(listOption) .action(async (opts) => { try { const projectRoot = findProjectRoot(); + + if (opts.list) { + const listed = await listClaudeCodeSessions(projectRoot); + if (!listed.ok) { + logError(listed.error); + await exitWith(1); + return; + } + console.log(formatJSON(listed.data)); + return; + } + const result = await readClaudeCodeSession(projectRoot, { maxEntries: opts.maxEntries, - skip: opts.skip, + sessionId: opts.sessionId, }); if (!result.ok) { diff --git a/src/commands/session-context/copilot.ts b/src/commands/session-context/copilot.ts index 4a3dd027..bcd43017 100644 --- a/src/commands/session-context/copilot.ts +++ b/src/commands/session-context/copilot.ts @@ -7,33 +7,45 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readCopilotSession } from "../../helpers/session-context-copilot"; +import { + listCopilotSessions, + readCopilotSession, +} from "../../helpers/session-context-copilot"; const maxEntriesOption = new Option( "--max-entries ", "maximum entries to return (default: 200)" ).argParser((val) => Math.trunc(Number(val))); -const skipOption = new Option( - "--skip ", - "skip the N most recent sessions to read an earlier conversation" -) - .argParser((val) => Math.trunc(Number(val))) - .default(0); +const listOption = new Option( + "--list", + "list available sessions for the project instead of reading one" +).conflicts("sessionId"); export function registerCopilotSessionContextCommand(parent: Command) { parent .command("copilot") .description("Read Copilot CLI session transcript for the project") .addOption(maxEntriesOption) - .addOption(skipOption) .option("--session-id ", "Specific session UUID to read") + .addOption(listOption) .action(async (opts) => { try { const projectRoot = findProjectRoot(); + + if (opts.list) { + const listed = await listCopilotSessions(projectRoot); + if (!listed.ok) { + logError(listed.error); + await exitWith(1); + return; + } + console.log(formatJSON(listed.data)); + return; + } + const result = await readCopilotSession(projectRoot, { maxEntries: opts.maxEntries, - skip: opts.skip, sessionId: opts.sessionId, }); diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts index 5882d12f..6e5ca7ec 100644 --- a/src/commands/session-context/cursor.ts +++ b/src/commands/session-context/cursor.ts @@ -7,33 +7,45 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readCursorSession } from "../../helpers/session-context"; +import { + listCursorSessions, + readCursorSession, +} from "../../helpers/session-context"; const maxEntriesOption = new Option( "--max-entries ", "maximum entries to return (default: 200)" ).argParser((val) => Math.trunc(Number(val))); -const skipOption = new Option( - "--skip ", - "skip the N most recent sessions to read an earlier conversation" -) - .argParser((val) => Math.trunc(Number(val))) - .default(0); +const listOption = new Option( + "--list", + "list available sessions for the project instead of reading one" +).conflicts("sessionId"); export function registerCursorSessionContextCommand(parent: Command) { parent .command("cursor") .description("Read Cursor agent session transcript for the project") .addOption(maxEntriesOption) - .addOption(skipOption) .option("--session-id ", "Specific session UUID to read") + .addOption(listOption) .action(async (opts) => { try { const projectRoot = findProjectRoot(); + + if (opts.list) { + const listed = await listCursorSessions(projectRoot); + if (!listed.ok) { + logError(listed.error); + await exitWith(1); + return; + } + console.log(formatJSON(listed.data)); + return; + } + const result = await readCursorSession(projectRoot, { maxEntries: opts.maxEntries, - skip: opts.skip, sessionId: opts.sessionId, }); diff --git a/src/commands/session-context/opencode.ts b/src/commands/session-context/opencode.ts index d530c767..f9726345 100644 --- a/src/commands/session-context/opencode.ts +++ b/src/commands/session-context/opencode.ts @@ -7,37 +7,49 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readOpencodeSession } from "../../helpers/session-context-opencode"; +import { + listOpencodeSessions, + readOpencodeSession, +} from "../../helpers/session-context-opencode"; const maxEntriesOption = new Option( "--max-entries ", "maximum entries to return (default: 200)" ).argParser((val) => Math.trunc(Number(val))); -const skipOption = new Option( - "--skip ", - "skip the N most recent top-level sessions (sub-agent sessions are always excluded)" -) - .argParser((val) => Math.trunc(Number(val))) - .default(0); +const listOption = new Option( + "--list", + "list available top-level sessions for the project instead of reading one" +).conflicts("sessionId"); export function registerOpencodeSessionContextCommand(parent: Command) { parent .command("opencode") .description("Read opencode session transcript for the project") .addOption(maxEntriesOption) - .addOption(skipOption) .option("--session-id ", "Specific session ID to read") .option( "--root", "resolve to the top-level (root) session — with --session-id, walks up from a sub-agent child session" ) + .addOption(listOption) .action(async (opts) => { try { const projectRoot = findProjectRoot(); - const result = await readOpencodeSession(projectRoot, { + + if (opts.list) { + const listed = listOpencodeSessions(projectRoot); + if (!listed.ok) { + logError(listed.error); + await exitWith(1); + return; + } + console.log(formatJSON(listed.data)); + return; + } + + const result = readOpencodeSession(projectRoot, { maxEntries: opts.maxEntries, - skip: opts.skip, sessionId: opts.sessionId, root: opts.root, }); diff --git a/src/helpers/session-context-copilot.ts b/src/helpers/session-context-copilot.ts index dcf5c856..e56004cf 100644 --- a/src/helpers/session-context-copilot.ts +++ b/src/helpers/session-context-copilot.ts @@ -8,6 +8,7 @@ import { copilotSessionStateDir } from "./paths"; import { isWindows } from "./platform"; import { type ReadSessionOptions, + type SessionListResult, type TranscriptEntry, getContentPreview, } from "./session-context"; @@ -40,8 +41,17 @@ function normalizePath(p: string): string { const COPILOT_RELEVANT_TYPES = new Set(["user.message", "assistant.message"]); +interface CopilotSessionMatch { + name: string; + mtime: number; +} + +type CopilotMatchResult = + | { ok: true; matching: CopilotSessionMatch[]; stateDir: string } + | { ok: false; error: string; path?: string; available?: string[] }; + /** - * Read a Copilot CLI session transcript for a project. + * Find Copilot CLI sessions matching a project, most recent first. * * Copilot CLI stores sessions under `~/.copilot/session-state//`. * Each session directory contains: @@ -51,11 +61,9 @@ const COPILOT_RELEVANT_TYPES = new Set(["user.message", "assistant.message"]); * Sessions are matched by comparing the `cwd` field in workspace.yaml * to the provided project root. */ -export async function readCopilotSession( - projectRoot: string | null, - options?: ReadCopilotSessionOptions -): Promise { - const limit = options?.maxEntries ?? 200; +async function findMatchingCopilotSessions( + projectRoot: string | null +): Promise { const stateDir = copilotSessionStateDir(); const normalizedProjectRoot = normalizePath(projectRoot ?? process.cwd()); @@ -87,11 +95,7 @@ export async function readCopilotSession( } // 2. Parse workspace.yaml for each session to match by cwd - interface SessionMatch { - name: string; - mtime: number; - } - const matching: SessionMatch[] = []; + const matching: CopilotSessionMatch[] = []; const allSessionIds: string[] = []; for (const dir of allDirs) { @@ -119,17 +123,56 @@ export async function readCopilotSession( }; } - // 3. Select session by ID or most recent (with optional skip) - const skip = options?.skip ?? 0; + return { ok: true, matching, stateDir }; +} + +/** List Copilot CLI sessions for a project, most recent first. */ +export async function listCopilotSessions( + projectRoot: string | null +): Promise { + const found = await findMatchingCopilotSessions(projectRoot); + if (!found.ok) { + return { ok: false, error: found.error, path: found.path }; + } + return { + ok: true, + data: { + sessions: found.matching.map((s) => ({ + id: s.name, + updatedAt: new Date(s.mtime).toISOString(), + })), + }, + }; +} + +/** + * Read the most recent Copilot CLI session transcript for a project — + * normally the conversation that is running right now. Pass `sessionId` + * (from `listCopilotSessions`) to read an earlier session instead. + */ +export async function readCopilotSession( + projectRoot: string | null, + options?: ReadCopilotSessionOptions +): Promise { + const limit = options?.maxEntries ?? 200; + + const found = await findMatchingCopilotSessions(projectRoot); + if (!found.ok) { + return found; + } + const { matching, stateDir } = found; + + // 3. Select session by ID or most recent const target = options?.sessionId ? matching.find((s) => s.name === options.sessionId) - : matching[skip]; + : matching[0]; if (!target) { - const error = options?.sessionId - ? `Session not found: ${options.sessionId}` - : `Only ${String(matching.length)} session(s) available but --skip ${String(skip)} requested`; - return { ok: false, error, available: matching.map((s) => s.name) }; + return { + ok: false, + error: `Session not found: ${options?.sessionId ?? ""}`, + available: matching.map((s) => s.name), + }; } // 4. Read events.jsonl diff --git a/src/helpers/session-context-opencode.ts b/src/helpers/session-context-opencode.ts index c850b914..9f958c05 100644 --- a/src/helpers/session-context-opencode.ts +++ b/src/helpers/session-context-opencode.ts @@ -10,6 +10,7 @@ import { isWindows } from "./platform"; import { RELEVANT_ROLES, type ReadSessionOptions, + type SessionListResult, type TranscriptEntry, getContentPreview, } from "./session-context"; @@ -32,12 +33,11 @@ interface ReadOpencodeSessionOptions extends ReadSessionOptions { * * Opencode is the only session-context backend with a real parent/child * session graph, so this option lives here rather than in the shared - * `ReadSessionOptions`. A recency-based guess (the old bare `skip: 1`) - * cannot distinguish the true parent from a sibling sub-agent session - * once more than one sibling exists — and an inline Skill invocation - * creates no session row at all, so there is nothing to skip past. - * Ancestry via `parent_id` is correct regardless of nesting depth or - * sibling fan-out. + * `ReadSessionOptions`. A recency-based guess cannot distinguish the + * true parent from a sibling sub-agent session once more than one + * sibling exists — and an inline Skill invocation creates no session + * row at all. Ancestry via `parent_id` is correct regardless of nesting + * depth or sibling fan-out. */ root?: boolean; } @@ -56,6 +56,78 @@ function normalizePath(p: string): string { return isWindows() ? resolved.toLowerCase() : resolved; } +interface SessionRow { + id: string; + directory: string; + parent_id: string | null; + title: string; + time_updated: number; +} + +/** Query all sessions from an open opencode DB, most recent first. */ +function queryAllSessions(db: Database): SessionRow[] { + return db + .query( + "SELECT id, directory, parent_id, title, time_updated FROM session ORDER BY time_updated DESC" + ) + .all(); +} + +/** + * List opencode sessions for a project, most recent first. + * Only top-level sessions are listed — sub-agent child sessions are + * excluded (they can still be read explicitly via `sessionId`). + */ +export function listOpencodeSessions( + projectRoot: string | null +): SessionListResult { + const dbPath = opencodeDbPath(); + const normalizedProjectRoot = normalizePath(projectRoot ?? process.cwd()); + + if (!existsSync(dbPath)) { + return { ok: false, error: "No opencode database found", path: dbPath }; + } + + let db: Database; + try { + db = new Database(dbPath, { readonly: true }); + } catch { + return { + ok: false, + error: "Failed to open opencode database", + path: dbPath, + }; + } + + try { + const topLevel = queryAllSessions(db).filter( + (s) => + s.parent_id === null && + s.directory && + normalizePath(s.directory) === normalizedProjectRoot + ); + return { + ok: true, + data: { + sessions: topLevel.map((s) => ({ + id: s.id, + title: s.title, + updatedAt: new Date(s.time_updated).toISOString(), + })), + }, + }; + } catch (err) { + logDebug(`Failed to read opencode database: ${String(err)}`); + return { + ok: false, + error: "Failed to read opencode database", + path: dbPath, + }; + } finally { + db.close(); + } +} + /** * Read an opencode session transcript for a project. * @@ -70,12 +142,12 @@ function normalizePath(p: string): string { * to the provided project root. * * Sub-agent runs are stored as child sessions (`parent_id` set) that share - * the parent's `directory`, so recency-based selection (`skip`) only - * considers top-level sessions — otherwise sub-agent transcripts shadow the - * main session. Note that opencode skills run inline in the calling session - * (they do NOT create their own session), so no `skip` is needed to read - * the current development session. An explicit `sessionId` can still read - * any session, including sub-agent children. + * the parent's `directory`, so recency selection only considers top-level + * sessions — otherwise sub-agent transcripts shadow the main session. Note + * that opencode skills run inline in the calling session (they do NOT + * create their own session), so the most recent top-level session is the + * current development session. An explicit `sessionId` can still read any + * session, including sub-agent children. */ export function readOpencodeSession( projectRoot: string | null, @@ -102,17 +174,7 @@ export function readOpencodeSession( try { // 1. Find all sessions, sorted by most recently updated first - interface SessionRow { - id: string; - directory: string; - parent_id: string | null; - time_updated: number; - } - const allSessions = db - .query( - "SELECT id, directory, parent_id, time_updated FROM session ORDER BY time_updated DESC" - ) - .all(); + const allSessions = queryAllSessions(db); if (allSessions.length === 0) { return { ok: false, error: "No opencode sessions found", path: dbPath }; @@ -132,16 +194,14 @@ export function readOpencodeSession( }; } - // 3. Select session by ID or most recent (with optional skip). + // 3. Select session by ID or most recent. // Recency selection only considers top-level sessions: sub-agent runs // are child sessions (parent_id set) sharing the parent's directory, - // and would otherwise shadow the main session in the skip index. + // and would otherwise shadow the main session. // An explicit --session-id can read any session, including children. - const topLevel = matching.filter((s) => s.parent_id === null); - const skip = options?.skip ?? 0; const target = options?.sessionId ? matching.find((s) => s.id === options.sessionId) - : topLevel[skip]; + : matching.find((s) => s.parent_id === null); if (!target) { if (options?.sessionId) { @@ -153,8 +213,8 @@ export function readOpencodeSession( } return { ok: false, - error: `Only ${String(topLevel.length)} top-level session(s) available but --skip ${String(skip)} requested`, - available: topLevel.map((s) => s.id), + error: "No top-level opencode session found for this project", + available: matching.map((s) => s.id), }; } diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index 322b44af..5168432b 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -64,6 +64,16 @@ interface ClaudeSessionSummary { transcript: Array<{ type: string; role?: string; contentPreview: string }>; } +/** One entry in a `--list` result: session id + last-update timestamp. */ +export interface SessionListEntry { + id: string; + updatedAt: string; +} + +export type SessionListResult = + | { ok: true; data: { sessions: SessionListEntry[] } } + | { ok: false; error: string; path?: string }; + interface CursorSessionSummary { sessionId: string; sessionFile: string; @@ -74,7 +84,7 @@ interface CursorSessionSummary { type ClaudeSessionResult = | { ok: true; data: ClaudeSessionSummary } - | { ok: false; error: string; path?: string }; + | { ok: false; error: string; path?: string; available?: string[] }; type CursorSessionResult = | { ok: true; data: CursorSessionSummary } @@ -109,46 +119,80 @@ export function getContentPreview(entry: TranscriptEntry): string { export interface ReadSessionOptions { maxEntries?: number; - /** - * Skip the N most recent sessions before selecting the one to read. - * - * This is an escape hatch for reading an earlier conversation — NOT a - * way to reach "the parent session" from an agent context. Editor - * skills run inline in the current conversation, and sub-agent - * transcripts are not stored as project sessions (verified for Claude - * Code; opencode additionally excludes its child sessions from recency - * selection), so the most recent session is normally the conversation - * running right now and `skip: 0` is the correct default. - */ - skip?: number; +} + +interface ReadClaudeSessionOptions extends ReadSessionOptions { + sessionId?: string; } interface ReadCursorSessionOptions extends ReadSessionOptions { sessionId?: string; } +/** Resolve the Claude Code projects dir for a project root. */ +async function claudeProjectsDir(projectRoot: string | null): Promise { + const encodedPath = await encodeProjectPath(projectRoot ?? process.cwd()); + return join(homedir(), ".claude", "projects", encodedPath); +} + +/** + * Enumerate Claude Code session files for a project, most recent first. + * Returns null when the projects directory cannot be read. + */ +function enumerateClaudeSessionFiles( + projectsDir: string +): Array<{ name: string; mtime: number }> | null { + try { + return readdirSync(projectsDir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => ({ name: f, mtime: statSync(join(projectsDir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + } catch { + return null; + } +} + +/** + * List Claude Code sessions for a project, most recent first. + * Session ids are the JSONL file basenames (without extension). + */ +export async function listClaudeCodeSessions( + projectRoot: string | null +): Promise { + const projectsDir = await claudeProjectsDir(projectRoot); + const files = enumerateClaudeSessionFiles(projectsDir); + if (files === null) { + return { ok: false, error: "No session files found", path: projectsDir }; + } + return { + ok: true, + data: { + sessions: files.map((f) => ({ + id: basename(f.name, ".jsonl"), + updatedAt: new Date(f.mtime).toISOString(), + })), + }, + }; +} + /** - * Read the most recent Claude Code session transcript for a project. + * Read the most recent Claude Code session transcript for a project — + * normally the conversation that is running right now. Pass `sessionId` + * (from `listClaudeCodeSessions`) to read an earlier session instead. * Falls back to cwd when no project root is found. */ export async function readClaudeCodeSession( projectRoot: string | null, - options?: ReadSessionOptions + options?: ReadClaudeSessionOptions ): Promise { const limit = options?.maxEntries ?? 200; - const encodedPath = await encodeProjectPath(projectRoot ?? process.cwd()); - const projectsDir = join(homedir(), ".claude", "projects", encodedPath); + const projectsDir = await claudeProjectsDir(projectRoot); - let files: string[]; - try { - files = readdirSync(projectsDir) - .filter((f) => f.endsWith(".jsonl")) - .map((f) => ({ name: f, mtime: statSync(join(projectsDir, f)).mtimeMs })) - .sort((a, b) => b.mtime - a.mtime) - .map((f) => f.name); - } catch { + const allFiles = enumerateClaudeSessionFiles(projectsDir); + if (allFiles === null) { return { ok: false, error: "No session files found", path: projectsDir }; } + const files = allFiles.map((f) => f.name); if (files.length === 0) { return { @@ -158,16 +202,20 @@ export async function readClaudeCodeSession( }; } - const skip = options?.skip ?? 0; - if (skip >= files.length) { + const targetName = options?.sessionId + ? files.find((f) => f === `${options.sessionId}.jsonl`) + : files[0]; + + if (!targetName) { return { ok: false, - error: `Only ${String(files.length)} session(s) available but --skip ${String(skip)} requested`, + error: `Session not found: ${options?.sessionId ?? ""}`, path: projectsDir, + available: files.map((f) => basename(f, ".jsonl")), }; } - const sessionFile = join(projectsDir, files[skip]); + const sessionFile = join(projectsDir, targetName); let entries: TranscriptEntry[]; try { const raw = await Bun.file(sessionFile).text(); @@ -202,30 +250,32 @@ export async function readClaudeCodeSession( }; } -/** - * Read a Cursor agent session transcript for a project. - * Falls back to cwd when no project root is found. - */ -export async function readCursorSession( - projectRoot: string | null, - options?: ReadCursorSessionOptions -): Promise { - const limit = options?.maxEntries ?? 200; +/** Resolve the Cursor agent-transcripts dir for a project root. */ +async function cursorTranscriptsDir( + projectRoot: string | null +): Promise { const encodedPath = await encodeProjectPath( projectRoot ?? process.cwd(), "cursor" ); - const transcriptsDir = join( + return join( homedir(), ".cursor", "projects", encodedPath, "agent-transcripts" ); +} - let sessionDirs: Array<{ name: string; mtime: number }>; +/** + * Enumerate Cursor session directories for a project, most recent first. + * Returns null when the transcripts directory cannot be read. + */ +function enumerateCursorSessionDirs( + transcriptsDir: string +): Array<{ name: string; mtime: number }> | null { try { - sessionDirs = readdirSync(transcriptsDir) + return readdirSync(transcriptsDir) .map((name) => { const fullPath = join(transcriptsDir, name); try { @@ -238,6 +288,49 @@ export async function readCursorSession( .filter((d): d is { name: string; mtime: number } => d !== null) .sort((a, b) => b.mtime - a.mtime); } catch { + return null; + } +} + +/** List Cursor agent sessions for a project, most recent first. */ +export async function listCursorSessions( + projectRoot: string | null +): Promise { + const transcriptsDir = await cursorTranscriptsDir(projectRoot); + const sessionDirs = enumerateCursorSessionDirs(transcriptsDir); + if (sessionDirs === null) { + return { + ok: false, + error: "No Cursor agent-transcripts directory found", + path: transcriptsDir, + }; + } + return { + ok: true, + data: { + sessions: sessionDirs.map((d) => ({ + id: d.name, + updatedAt: new Date(d.mtime).toISOString(), + })), + }, + }; +} + +/** + * Read the most recent Cursor agent session transcript for a project — + * normally the conversation that is running right now. Pass `sessionId` + * (from `listCursorSessions`) to read an earlier session instead. + * Falls back to cwd when no project root is found. + */ +export async function readCursorSession( + projectRoot: string | null, + options?: ReadCursorSessionOptions +): Promise { + const limit = options?.maxEntries ?? 200; + const transcriptsDir = await cursorTranscriptsDir(projectRoot); + + const sessionDirs = enumerateCursorSessionDirs(transcriptsDir); + if (sessionDirs === null) { return { ok: false, error: "No Cursor agent-transcripts directory found", @@ -253,16 +346,16 @@ export async function readCursorSession( }; } - const skip = options?.skip ?? 0; const targetDir = options?.sessionId ? sessionDirs.find((d) => d.name === options.sessionId) - : sessionDirs[skip]; + : sessionDirs[0]; if (!targetDir) { - const error = options?.sessionId - ? `Session not found: ${options.sessionId}` - : `Only ${String(sessionDirs.length)} session(s) available but --skip ${String(skip)} requested`; - return { ok: false, error, available: sessionDirs.map((d) => d.name) }; + return { + ok: false, + error: `Session not found: ${options?.sessionId ?? ""}`, + available: sessionDirs.map((d) => d.name), + }; } const sessionFile = join( diff --git a/tests/commands/session-context.test.ts b/tests/commands/session-context.test.ts index f98b8afd..9a1a731c 100644 --- a/tests/commands/session-context.test.ts +++ b/tests/commands/session-context.test.ts @@ -61,7 +61,7 @@ describe("registerSessionContextCommand", () => { expect(sub).toBeDefined(); }); - test("claude-code subcommand has --max-entries and --skip options", () => { + test("claude-code subcommand has --max-entries, --session-id, and --list options", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -70,10 +70,12 @@ describe("registerSessionContextCommand", () => { const sub = parent.commands.find((c) => c.name() === "claude-code")!; const opts = sub.options.map((o) => o.long); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--skip"); + expect(opts).toContain("--session-id"); + expect(opts).toContain("--list"); + expect(opts).not.toContain("--skip"); }); - test("cursor subcommand has --max-entries, --skip, and --session-id options", () => { + test("cursor subcommand has --max-entries, --session-id, and --list options", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -82,11 +84,12 @@ describe("registerSessionContextCommand", () => { const sub = parent.commands.find((c) => c.name() === "cursor")!; const opts = sub.options.map((o) => o.long); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--skip"); expect(opts).toContain("--session-id"); + expect(opts).toContain("--list"); + expect(opts).not.toContain("--skip"); }); - test("copilot subcommand has --max-entries, --skip, and --session-id options", () => { + test("copilot subcommand has --max-entries, --session-id, and --list options", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -95,11 +98,12 @@ describe("registerSessionContextCommand", () => { const sub = parent.commands.find((c) => c.name() === "copilot")!; const opts = sub.options.map((o) => o.long); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--skip"); expect(opts).toContain("--session-id"); + expect(opts).toContain("--list"); + expect(opts).not.toContain("--skip"); }); - test("opencode subcommand has --max-entries, --skip, and --session-id options", () => { + test("opencode subcommand has --max-entries, --session-id, --root, and --list options", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -108,7 +112,9 @@ describe("registerSessionContextCommand", () => { const sub = parent.commands.find((c) => c.name() === "opencode")!; const opts = sub.options.map((o) => o.long); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--skip"); expect(opts).toContain("--session-id"); + expect(opts).toContain("--root"); + expect(opts).toContain("--list"); + expect(opts).not.toContain("--skip"); }); }); diff --git a/tests/commands/session-context/claude-code.test.ts b/tests/commands/session-context/claude-code.test.ts index 50c10eb4..e5363ca5 100644 --- a/tests/commands/session-context/claude-code.test.ts +++ b/tests/commands/session-context/claude-code.test.ts @@ -29,8 +29,15 @@ const mockReadClaudeCodeSession = mock( { ok: true; data: unknown } | { ok: false; error: string } > ); +const mockListClaudeCodeSessions = mock( + () => + Promise.resolve({ ok: true, data: { sessions: [] } }) as Promise< + { ok: true; data: { sessions: unknown[] } } | { ok: false; error: string } + > +); mock.module("../../../src/helpers/session-context", () => ({ readClaudeCodeSession: mockReadClaudeCodeSession, + listClaudeCodeSessions: mockListClaudeCodeSessions, })); // --------------------------------------------------------------------------- @@ -86,6 +93,7 @@ describe("claude-code action handler", () => { process.chdir(tempDir); mockReadClaudeCodeSession.mockReset(); + mockListClaudeCodeSessions.mockReset(); mockReadClaudeCodeSession.mockResolvedValue({ ok: true, data: {} }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); @@ -178,7 +186,28 @@ describe("claude-code action handler", () => { // findProjectRoot found our tempDir (which has .archgate/) expect(mockReadClaudeCodeSession).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - skip: 0, + sessionId: undefined, + }); + }); + + test("prints session list when --list is given", async () => { + mockListClaudeCodeSessions.mockResolvedValue({ + ok: true, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "claude-code", + "--list", + ]); + + expect(mockListClaudeCodeSessions).toHaveBeenCalledWith(tempDir); + expect(mockReadClaudeCodeSession).not.toHaveBeenCalled(); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); }); }); diff --git a/tests/commands/session-context/copilot.test.ts b/tests/commands/session-context/copilot.test.ts index dda70150..619a600f 100644 --- a/tests/commands/session-context/copilot.test.ts +++ b/tests/commands/session-context/copilot.test.ts @@ -25,8 +25,15 @@ const mockReadCopilotSession = mock( { ok: true; data: unknown } | { ok: false; error: string } > ); +const mockListCopilotSessions = mock( + () => + Promise.resolve({ ok: true, data: { sessions: [] } }) as Promise< + { ok: true; data: { sessions: unknown[] } } | { ok: false; error: string } + > +); mock.module("../../../src/helpers/session-context-copilot", () => ({ readCopilotSession: mockReadCopilotSession, + listCopilotSessions: mockListCopilotSessions, })); // --------------------------------------------------------------------------- @@ -180,7 +187,6 @@ describe("copilot action handler", () => { expect(mockReadCopilotSession).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - skip: 0, sessionId: undefined, }); }); diff --git a/tests/commands/session-context/cursor.test.ts b/tests/commands/session-context/cursor.test.ts index 05c7ae49..4d5eabfd 100644 --- a/tests/commands/session-context/cursor.test.ts +++ b/tests/commands/session-context/cursor.test.ts @@ -25,8 +25,15 @@ const mockReadCursorSession = mock( { ok: true; data: unknown } | { ok: false; error: string } > ); +const mockListCursorSessions = mock( + () => + Promise.resolve({ ok: true, data: { sessions: [] } }) as Promise< + { ok: true; data: { sessions: unknown[] } } | { ok: false; error: string } + > +); mock.module("../../../src/helpers/session-context", () => ({ readCursorSession: mockReadCursorSession, + listCursorSessions: mockListCursorSessions, })); // --------------------------------------------------------------------------- @@ -180,7 +187,6 @@ describe("cursor action handler", () => { expect(mockReadCursorSession).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - skip: 0, sessionId: undefined, }); }); diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts index 85e677b5..65e50937 100644 --- a/tests/commands/session-context/opencode.test.ts +++ b/tests/commands/session-context/opencode.test.ts @@ -21,12 +21,19 @@ import { Command } from "@commander-js/extra-typings"; const mockReadOpencodeSession = mock( () => - Promise.resolve({ ok: true, data: {} }) as Promise< - { ok: true; data: unknown } | { ok: false; error: string } - > + ({ ok: true, data: {} }) as + | { ok: true; data: unknown } + | { ok: false; error: string } +); +const mockListOpencodeSessions = mock( + () => + ({ ok: true, data: { sessions: [] } }) as + | { ok: true; data: { sessions: unknown[] } } + | { ok: false; error: string } ); mock.module("../../../src/helpers/session-context-opencode", () => ({ readOpencodeSession: mockReadOpencodeSession, + listOpencodeSessions: mockListOpencodeSessions, })); // --------------------------------------------------------------------------- @@ -70,6 +77,14 @@ describe("registerOpencodeSessionContextCommand", () => { const opt = sub.options.find((o) => o.long === "--session-id"); expect(opt).toBeDefined(); }); + + test("accepts --list option", () => { + const parent = new Command("session-context"); + registerOpencodeSessionContextCommand(parent); + const sub = parent.commands.find((c) => c.name() === "opencode")!; + const opt = sub.options.find((o) => o.long === "--list"); + expect(opt).toBeDefined(); + }); }); describe("opencode action handler", () => { @@ -91,7 +106,12 @@ describe("opencode action handler", () => { process.chdir(tempDir); mockReadOpencodeSession.mockReset(); - mockReadOpencodeSession.mockResolvedValue({ ok: true, data: {} }); + mockReadOpencodeSession.mockReturnValue({ ok: true, data: {} }); + mockListOpencodeSessions.mockReset(); + mockListOpencodeSessions.mockReturnValue({ + ok: true, + data: { sessions: [] }, + }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -115,7 +135,7 @@ describe("opencode action handler", () => { } test("prints JSON on successful result", async () => { - mockReadOpencodeSession.mockResolvedValue({ + mockReadOpencodeSession.mockReturnValue({ ok: true, data: { entries: [{ role: "assistant", content: "done" }], total: 1 }, }); @@ -131,7 +151,7 @@ describe("opencode action handler", () => { }); test("exits 1 when reader returns error result", async () => { - mockReadOpencodeSession.mockResolvedValue({ + mockReadOpencodeSession.mockReturnValue({ ok: false, error: "No opencode session found", }); @@ -148,9 +168,9 @@ describe("opencode action handler", () => { }); test("exits 2 when unexpected error is thrown", async () => { - mockReadOpencodeSession.mockRejectedValue( - new Error("ENOENT: no such file") - ); + mockReadOpencodeSession.mockImplementation(() => { + throw new Error("ENOENT: no such file"); + }); await expect( makeProgram().parseAsync(["node", "session-context", "opencode"]) @@ -166,7 +186,9 @@ describe("opencode action handler", () => { test("re-throws ExitPromptError", async () => { const exitPromptError = new Error("prompt cancelled"); exitPromptError.name = "ExitPromptError"; - mockReadOpencodeSession.mockRejectedValue(exitPromptError); + mockReadOpencodeSession.mockImplementation(() => { + throw exitPromptError; + }); await expect( makeProgram().parseAsync(["node", "session-context", "opencode"]) @@ -176,15 +198,39 @@ describe("opencode action handler", () => { }); test("passes findProjectRoot result to reader", async () => { - mockReadOpencodeSession.mockResolvedValue({ ok: true, data: {} }); + mockReadOpencodeSession.mockReturnValue({ ok: true, data: {} }); await makeProgram().parseAsync(["node", "session-context", "opencode"]); expect(mockReadOpencodeSession).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - skip: 0, sessionId: undefined, root: undefined, }); }); + + test("prints session list when --list is given", async () => { + mockListOpencodeSessions.mockReturnValue({ + ok: true, + data: { + sessions: [ + { id: "ses_abc", title: "t", updatedAt: "2026-01-01T00:00:00Z" }, + ], + }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "--list", + ]); + + expect(mockListOpencodeSessions).toHaveBeenCalledWith(tempDir); + expect(mockReadOpencodeSession).not.toHaveBeenCalled(); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("ses_abc"); + }); }); diff --git a/tests/helpers/session-context-copilot.test.ts b/tests/helpers/session-context-copilot.test.ts index 43f0f292..250690e6 100644 --- a/tests/helpers/session-context-copilot.test.ts +++ b/tests/helpers/session-context-copilot.test.ts @@ -5,7 +5,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { readCopilotSession } from "../../src/helpers/session-context-copilot"; +import { + listCopilotSessions, + readCopilotSession, +} from "../../src/helpers/session-context-copilot"; // This file covers readCopilotSession happy-path and error-case tests. @@ -239,64 +242,75 @@ describe("readCopilotSession", () => { } }); - test("skip option reads the second-most-recent matching session", async () => { - // Create parent session (make it older by creating first) - const parentId = `copilot-${uniqueId}-parent`; - makeSession(parentId, projectRoot, [ + test("sessionId reads an earlier session; default reads the most recent", async () => { + // Create an earlier session (make it older by backdating) + const earlierId = `copilot-${uniqueId}-earlier`; + makeSession(earlierId, projectRoot, [ JSON.stringify({ type: "user.message", - data: { content: "parent question" }, + data: { content: "earlier question" }, }), JSON.stringify({ type: "assistant.message", - data: { content: "parent answer" }, + data: { content: "earlier answer" }, }), ]); - // Make parent dir older so sub-agent dir is most recent + // Make the earlier dir older so the current dir is most recent const { utimesSync } = await import("node:fs"); const past = new Date(Date.now() - 60_000); - utimesSync(join(stateDir, parentId), past, past); + utimesSync(join(stateDir, earlierId), past, past); - // Create sub-agent session (newer) - const subagentId = `copilot-${uniqueId}-subagent`; - makeSession(subagentId, projectRoot, [ + // Create the current session (newer) + const currentId = `copilot-${uniqueId}-current`; + makeSession(currentId, projectRoot, [ JSON.stringify({ type: "user.message", - data: { content: "sub-agent init" }, + data: { content: "current msg" }, }), ]); - // Without skip → reads sub-agent session (most recent) - const resultNoSkip = await readCopilotSession(projectRoot); - expect(resultNoSkip.ok).toBe(true); - if (!resultNoSkip.ok) throw new Error("expected ok"); - expect(resultNoSkip.data.sessionId).toBe(subagentId); - - // With skip=1 → reads parent session - const resultSkip = await readCopilotSession(projectRoot, { skip: 1 }); - expect(resultSkip.ok).toBe(true); - if (!resultSkip.ok) throw new Error("expected ok"); - expect(resultSkip.data.sessionId).toBe(parentId); - expect(resultSkip.data.transcript[0]?.contentPreview).toBe( - "parent question" - ); + // Default → reads the most recent session (the current conversation) + const latest = await readCopilotSession(projectRoot); + expect(latest.ok).toBe(true); + if (!latest.ok) throw new Error("expected ok"); + expect(latest.data.sessionId).toBe(currentId); + + // sessionId → reads the earlier session explicitly + const earlier = await readCopilotSession(projectRoot, { + sessionId: earlierId, + }); + expect(earlier.ok).toBe(true); + if (!earlier.ok) throw new Error("expected ok"); + expect(earlier.data.sessionId).toBe(earlierId); + expect(earlier.data.transcript[0]?.contentPreview).toBe("earlier question"); }); - test("skip beyond available matching sessions returns error", async () => { - const sessionId = `copilot-${uniqueId}-onlysession`; - makeSession(sessionId, projectRoot, [ - JSON.stringify({ - type: "user.message", - data: { content: "only session" }, - }), + test("list returns matching sessions most recent first", async () => { + const earlierId = `copilot-${uniqueId}-list-earlier`; + makeSession(earlierId, projectRoot, [ + JSON.stringify({ type: "user.message", data: { content: "old" } }), + ]); + const { utimesSync } = await import("node:fs"); + const past = new Date(Date.now() - 60_000); + utimesSync(join(stateDir, earlierId), past, past); + const currentId = `copilot-${uniqueId}-list-current`; + makeSession(currentId, projectRoot, [ + JSON.stringify({ type: "user.message", data: { content: "new" } }), + ]); + // A session for a different project must not be listed + makeSession(`copilot-${uniqueId}-other`, "/some/other/project", [ + JSON.stringify({ type: "user.message", data: { content: "other" } }), ]); - const result = await readCopilotSession(projectRoot, { skip: 2 }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toContain("--skip 2 requested"); - } + const result = await listCopilotSessions(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.sessions.map((s) => s.id)).toEqual([ + currentId, + earlierId, + ]); + expect(Date.parse(result.data.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); }); test("truncates string content preview to 500 chars", async () => { diff --git a/tests/helpers/session-context-cursor.test.ts b/tests/helpers/session-context-cursor.test.ts index 77e07c13..65b03611 100644 --- a/tests/helpers/session-context-cursor.test.ts +++ b/tests/helpers/session-context-cursor.test.ts @@ -5,7 +5,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import { join } from "node:path"; -import { readCursorSession } from "../../src/helpers/session-context"; +import { + listCursorSessions, + readCursorSession, +} from "../../src/helpers/session-context"; // This file covers readCursorSession happy-path tests. // Error cases for readCursorSession live in session-context.test.ts. @@ -201,61 +204,73 @@ describe("readCursorSession", () => { expect(result.data.transcript[1]?.contentPreview).toBe("msg 7"); }); - test("skip option reads the second-most-recent session directory", async () => { - // Create parent session (make it older) - makeSession("session-parent", [ + test("sessionId reads an earlier session; default reads the most recent", async () => { + // Create an earlier session (make it older) + makeSession("session-earlier", [ JSON.stringify({ role: "user", - message: { role: "user", content: "parent question" }, + message: { role: "user", content: "earlier question" }, }), JSON.stringify({ role: "assistant", - message: { role: "assistant", content: "parent answer" }, + message: { role: "assistant", content: "earlier answer" }, }), ]); - // Make parent dir older + // Make the earlier dir older const { utimesSync } = await import("node:fs"); const past = new Date(Date.now() - 60_000); - utimesSync(join(transcriptsDir, "session-parent"), past, past); + utimesSync(join(transcriptsDir, "session-earlier"), past, past); - // Create sub-agent session (newer) - makeSession("session-subagent", [ + // Create the current session (newer) + makeSession("session-current", [ JSON.stringify({ role: "user", - message: { role: "user", content: "sub-agent init" }, + message: { role: "user", content: "current msg" }, }), ]); - // Without skip → reads sub-agent session (most recent) - const resultNoSkip = await readCursorSession(projectRoot); - expect(resultNoSkip.ok).toBe(true); - if (!resultNoSkip.ok) throw new Error("expected ok"); - expect(resultNoSkip.data.sessionId).toBe("session-subagent"); - - // With skip=1 → reads parent session - const resultSkip = await readCursorSession(projectRoot, { skip: 1 }); - expect(resultSkip.ok).toBe(true); - if (!resultSkip.ok) throw new Error("expected ok"); - expect(resultSkip.data.sessionId).toBe("session-parent"); - expect(resultSkip.data.transcript[0]?.contentPreview).toBe( - "parent question" - ); + // Default → reads the most recent session (the current conversation) + const latest = await readCursorSession(projectRoot); + expect(latest.ok).toBe(true); + if (!latest.ok) throw new Error("expected ok"); + expect(latest.data.sessionId).toBe("session-current"); + + // sessionId → reads the earlier session explicitly + const earlier = await readCursorSession(projectRoot, { + sessionId: "session-earlier", + }); + expect(earlier.ok).toBe(true); + if (!earlier.ok) throw new Error("expected ok"); + expect(earlier.data.sessionId).toBe("session-earlier"); + expect(earlier.data.transcript[0]?.contentPreview).toBe("earlier question"); }); - test("skip beyond available sessions returns error", async () => { - makeSession("session-only", [ + test("list returns sessions most recent first with timestamps", async () => { + makeSession("session-earlier", [ JSON.stringify({ role: "user", - message: { role: "user", content: "only session" }, + message: { role: "user", content: "old" }, + }), + ]); + const { utimesSync } = await import("node:fs"); + const past = new Date(Date.now() - 60_000); + utimesSync(join(transcriptsDir, "session-earlier"), past, past); + makeSession("session-current", [ + JSON.stringify({ + role: "user", + message: { role: "user", content: "new" }, }), ]); - const result = await readCursorSession(projectRoot, { skip: 2 }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toContain("--skip 2 requested"); - } + const result = await listCursorSessions(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.sessions.map((s) => s.id)).toEqual([ + "session-current", + "session-earlier", + ]); + expect(Date.parse(result.data.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); }); test("ignores non-directory entries in transcripts dir", async () => { diff --git a/tests/helpers/session-context-opencode.test.ts b/tests/helpers/session-context-opencode.test.ts index 11a47f9b..5738109f 100644 --- a/tests/helpers/session-context-opencode.test.ts +++ b/tests/helpers/session-context-opencode.test.ts @@ -6,7 +6,10 @@ import { mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { readOpencodeSession } from "../../src/helpers/session-context-opencode"; +import { + listOpencodeSessions, + readOpencodeSession, +} from "../../src/helpers/session-context-opencode"; /** * Tests for readOpencodeSession — reads session data from @@ -412,21 +415,22 @@ describe("readOpencodeSession", () => { } }); - test("skip indexes over top-level sessions only", async () => { + test("list returns top-level sessions only, most recent first", () => { const db = createDb(); makeSimpleSession(db, "ses_older", "older top-level", 1000); makeSimpleSession(db, "ses_newer", "newer top-level", 2000); - // Child session between the two — must not consume a skip index + // Child session between the two — must not appear in the list makeSimpleSession(db, "ses_child", "child", 1500, "ses_older"); db.close(); - const noSkip = await readOpencodeSession(projectRoot); - expect(noSkip.ok).toBe(true); - if (noSkip.ok) expect(noSkip.data.sessionId).toBe("ses_newer"); - - const skipped = await readOpencodeSession(projectRoot, { skip: 1 }); - expect(skipped.ok).toBe(true); - if (skipped.ok) expect(skipped.data.sessionId).toBe("ses_older"); + const result = listOpencodeSessions(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.sessions.map((s) => s.id)).toEqual([ + "ses_newer", + "ses_older", + ]); + expect(Date.parse(result.data.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); }); test("root resolves to the top-level ancestor session", async () => { @@ -464,10 +468,12 @@ describe("readOpencodeSession", () => { makeSimpleSession(db, "ses_sib_d", "domain review d", 5000, "ses_parent"); db.close(); - // skip: 1 now errors honestly — only one top-level session exists, so - // there is no second one to skip to (previously it returned a sibling). - const skipped = await readOpencodeSession(projectRoot, { skip: 1 }); - expect(skipped.ok).toBe(false); + // The list shows only the parent — no sibling can shadow it. + const listed = listOpencodeSessions(projectRoot); + expect(listed.ok).toBe(true); + if (listed.ok) { + expect(listed.data.sessions.map((s) => s.id)).toEqual(["ses_parent"]); + } // Default selection and explicit root both resolve to the parent. const byDefault = await readOpencodeSession(projectRoot); @@ -479,20 +485,22 @@ describe("readOpencodeSession", () => { if (rooted.ok) expect(rooted.data.sessionId).toBe("ses_parent"); }); - test("skip beyond available top-level sessions returns error", async () => { + test("returns error when only child sessions match the project", async () => { const db = createDb(); - makeSimpleSession(db, "ses_only", "only", 1000); - // Child sessions must not count toward the top-level total - makeSimpleSession(db, "ses_child", "child", 2000, "ses_only"); + // Parent lives in a different directory; only the child matches here + makeSession(db, "ses_parent", "/some/other/project", [ + { id: "msg_p", role: "user", content: "elsewhere" }, + ]); + makeSimpleSession(db, "ses_child", "child", 2000, "ses_parent"); db.close(); - const result = await readOpencodeSession(projectRoot, { skip: 1 }); + const result = await readOpencodeSession(projectRoot); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain( - "Only 1 top-level session(s) available but --skip 1 requested" + "No top-level opencode session found for this project" ); - expect(result.available).toEqual(["ses_only"]); + expect(result.available).toEqual(["ses_child"]); } }); diff --git a/tests/helpers/session-context.test.ts b/tests/helpers/session-context.test.ts index 70ec25e9..1ae28a38 100644 --- a/tests/helpers/session-context.test.ts +++ b/tests/helpers/session-context.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { encodeProjectPath, + listClaudeCodeSessions, readClaudeCodeSession, readCursorSession, } from "../../src/helpers/session-context"; @@ -248,67 +249,102 @@ describe("readClaudeCodeSession", () => { expect(result.data.transcript[2]?.contentPreview).toBe("message 9"); }); - test("skip option reads the second-most-recent session file", async () => { - // Write a newer session file (the sub-agent's session) + test("sessionId reads a specific earlier session file", async () => { + // Write a newer session file (the current conversation) writeFileSync( - join(projectsDir, "subagent.jsonl"), + join(projectsDir, "current.jsonl"), [ JSON.stringify({ type: "user", - message: { role: "user", content: "sub-agent msg" }, + message: { role: "user", content: "current msg" }, }), ].join("\n") ); - // Write an older session file (the parent's session) - const olderFile = join(projectsDir, "parent.jsonl"); + // Write an older session file (an earlier conversation) + const olderFile = join(projectsDir, "earlier.jsonl"); writeFileSync( olderFile, [ JSON.stringify({ type: "user", - message: { role: "user", content: "parent msg" }, + message: { role: "user", content: "earlier msg" }, }), JSON.stringify({ type: "assistant", - message: { role: "assistant", content: "parent reply" }, + message: { role: "assistant", content: "earlier reply" }, }), ].join("\n") ); - // Make parent older than subagent by backdating its mtime + // Backdate the earlier session's mtime const { utimesSync } = await import("node:fs"); const past = new Date(Date.now() - 60_000); utimesSync(olderFile, past, past); - // Without skip → reads subagent (most recent) - const resultNoSkip = await readClaudeCodeSession(projectRoot); - expect(resultNoSkip.ok).toBe(true); - if (!resultNoSkip.ok) throw new Error("expected ok"); - expect(resultNoSkip.data.transcript[0]?.contentPreview).toBe( - "sub-agent msg" - ); + // Default → reads the most recent session (the current conversation) + const latest = await readClaudeCodeSession(projectRoot); + expect(latest.ok).toBe(true); + if (!latest.ok) throw new Error("expected ok"); + expect(latest.data.transcript[0]?.contentPreview).toBe("current msg"); - // With skip=1 → reads parent session - const resultSkip = await readClaudeCodeSession(projectRoot, { skip: 1 }); - expect(resultSkip.ok).toBe(true); - if (!resultSkip.ok) throw new Error("expected ok"); - expect(resultSkip.data.transcript[0]?.contentPreview).toBe("parent msg"); - expect(resultSkip.data.relevantEntries).toBe(2); + // sessionId → reads the earlier conversation explicitly + const earlier = await readClaudeCodeSession(projectRoot, { + sessionId: "earlier", + }); + expect(earlier.ok).toBe(true); + if (!earlier.ok) throw new Error("expected ok"); + expect(earlier.data.transcript[0]?.contentPreview).toBe("earlier msg"); + expect(earlier.data.relevantEntries).toBe(2); }); - test("skip beyond available sessions returns error", async () => { + test("sessionId not found returns error with available ids", async () => { writeSession([ { type: "user", message: { role: "user", content: "only session" } }, ]); - const result = await readClaudeCodeSession(projectRoot, { skip: 5 }); + const result = await readClaudeCodeSession(projectRoot, { + sessionId: "nonexistent", + }); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error).toContain("--skip 5 requested"); + expect(result.error).toContain("Session not found: nonexistent"); + expect(result.available).toEqual(["session"]); } }); + test("list returns sessions most recent first with timestamps", async () => { + writeFileSync( + join(projectsDir, "current.jsonl"), + JSON.stringify({ + type: "user", + message: { role: "user", content: "hi" }, + }) + ); + const olderFile = join(projectsDir, "earlier.jsonl"); + writeFileSync( + olderFile, + JSON.stringify({ + type: "user", + message: { role: "user", content: "old" }, + }) + ); + const { utimesSync } = await import("node:fs"); + const past = new Date(Date.now() - 60_000); + utimesSync(olderFile, past, past); + + const result = await listClaudeCodeSessions(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.sessions.map((s) => s.id)).toEqual([ + "current", + "earlier", + ]); + expect( + Date.parse(result.data.sessions[0]?.updatedAt ?? "") + ).not.toBeNaN(); + }); + test("returns error when directory exists but has no .jsonl files", async () => { writeFileSync(join(projectsDir, "notes.txt"), "not a session"); From 3a8ab7311abfd13edf821bcf65526ba212d73414 Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:47:13 +0000 Subject: [PATCH 05/15] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/public/llms-full.txt | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 505d35d5..e4f83989 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4353,11 +4353,11 @@ Read the Claude Code session transcript for the project. archgate session-context claude-code [options] ``` -| Option | Description | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--session-id ` | Specific session ID to read | -| `--list` | List available sessions for the project instead of reading one | +| Option | Description | +| ------------------- | -------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--session-id ` | Specific session ID to read | +| `--list` | List available sessions for the project instead of reading one | ### archgate session-context copilot @@ -4367,11 +4367,11 @@ Read the Copilot CLI session transcript for the project. Sessions are matched by archgate session-context copilot [options] ``` -| Option | Description | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--list` | List available sessions for the project instead of reading one | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | -------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--list` | List available sessions for the project instead of reading one | +| `--session-id ` | Specific session UUID to read | ### archgate session-context cursor @@ -4381,11 +4381,11 @@ Read the Cursor agent session transcript for the project. archgate session-context cursor [options] ``` -| Option | Description | -| ------------------- | --------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--list` | List available sessions for the project instead of reading one | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | -------------------------------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--list` | List available sessions for the project instead of reading one | +| `--session-id ` | Specific session UUID to read | ### archgate session-context opencode @@ -4400,7 +4400,7 @@ archgate session-context opencode [options] | Option | Description | | ------------------- | ------------------------------------------------------------------------------------------------------ | | `--max-entries ` | Maximum entries to return (default: 200) | -| `--list` | List available top-level sessions for the project instead of reading one | +| `--list` | List available top-level sessions for the project instead of reading one | | `--session-id ` | Specific session ID to read | | `--root` | Resolve to the top-level (root) session — with `--session-id`, walks up from a sub-agent child session | From e8e3b3e2f1cafb168230c9fa4bf7f53e5efe7526 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 06:56:17 -0300 Subject: [PATCH 06/15] chore(memory): cover flag-removal case in release sequencing note Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- .claude/agent-memory/archgate-developer/MEMORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 256aef84..9d1a1937 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -47,7 +47,7 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - **`Bun.Glob.match()` triggers oxlint `prefer-regexp-test`** — `Bun.Glob.match()` returns a boolean (not a RegExp), but oxlint can't tell. Suppress with `// oxlint-disable-next-line prefer-regexp-test -- Bun.Glob.match() returns boolean, not RegExp`. - **Live `opencode.db` can't be opened `readonly: true` while opencode runs** — `new Database(path, { readonly: true })` fails with `SQLITE_CANTOPEN` (errno 14) on the live WAL-mode DB (`~/.local/share/opencode/opencode.db`). To inspect real data, copy `opencode.db` + `.db-wal` + `.db-shm` to a temp dir and open the copy. Data model facts load-bearing for `session-context opencode`: sub-agent runs are child sessions (`parent_id` set) sharing the parent's `directory`, and opencode skills run INLINE in the calling session (no own session row) — recency selection must filter `parent_id IS NULL` (fixed 2026-07-01; `--root` resolves a `--session-id` child to its top-level ancestor). - [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 `archgate session-context opencode --root` (was `--skip 1`, which read sub-agent transcripts). When changing session-context CLI semantics, update the shipped skill in the same effort AND sequence releases: the CLI ships the flag first, the plugin follows — a skill referencing a flag the installed CLI lacks dies with "unknown option". Don't hand-edit the installed copy under `~/.config/opencode/skills/` before the CLI release. +- **The distributed opencode lessons-learned skill references session-context CLI flags — sequence releases** — the skill instructs `archgate session-context opencode --root` (was `--skip 1`, which read sub-agent transcripts). 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. - **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. From 10d3ae60ed23abc4a95f618405f7f20ce71e08dd Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 07:39:05 -0300 Subject: [PATCH 07/15] feat(session-context)!: split listing and specific reads into list/show subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listing and reading-a-specific-session are distinct operations from reading the current conversation — give each its own verb instead of mode flags on every editor subcommand: - `session-context list [--editor ]` — sessions as JSON; without --editor aggregates all four editors (absent stores fold their error into the aggregate instead of failing the command) - `session-context show --editor [--max-entries] [--root]` — read a specific session; positional id mirrors `adr show `; --editor is required (UUIDs can collide across editor stores); --root is opencode-only, validated - Editor subcommands are now pure current-conversation readers with only --max-entries: --list and --session-id removed, and opencode's read loses --root (it was a no-op alias without a session id; its real semantics live in show) Tests: list/show behavior tests run the real CLI as a subprocess with HOME/USERPROFILE/XDG_DATA_HOME redirected — Bun's mock.module state is process-global across test files, and the sibling command tests' helper mocks leak into same-process imports (list.ts also no longer captures helper references in a module-level map, so live bindings are honored). Docs rewritten in en/nb/pt-br with the new subcommand sections (ARCH-016 headings) and examples; llms-full.txt regenerated; agent memory updated with the final API. BREAKING CHANGE: `--list` and `--session-id` are no longer accepted on the editor subcommands (both introduced earlier on this unreleased branch; `--session-id` previously shipped on copilot/cursor/opencode), and `--root` moved from `opencode` to `show`. Use `session-context list` and `session-context show --editor `. Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- .../project_session_context_skip_root_fix.md | 2 +- docs/public/llms-full.txt | 95 ++++++---- .../docs/nb/reference/cli/session-context.mdx | 95 ++++++---- .../pt-br/reference/cli/session-context.mdx | 97 +++++----- .../docs/reference/cli/session-context.mdx | 95 ++++++---- src/commands/session-context/claude-code.ts | 25 +-- src/commands/session-context/copilot.ts | 25 +-- src/commands/session-context/cursor.ts | 25 +-- src/commands/session-context/index.ts | 4 + src/commands/session-context/list.ts | 85 +++++++++ src/commands/session-context/opencode.ts | 30 +-- src/commands/session-context/show.ts | 78 ++++++++ tests/commands/session-context.test.ts | 52 ++++-- .../session-context/claude-code.test.ts | 23 --- .../commands/session-context/copilot.test.ts | 9 - tests/commands/session-context/cursor.test.ts | 9 - tests/commands/session-context/list.test.ts | 159 ++++++++++++++++ .../commands/session-context/opencode.test.ts | 55 ------ tests/commands/session-context/show.test.ts | 174 ++++++++++++++++++ 19 files changed, 765 insertions(+), 372 deletions(-) create mode 100644 src/commands/session-context/list.ts create mode 100644 src/commands/session-context/show.ts create mode 100644 tests/commands/session-context/list.test.ts create mode 100644 tests/commands/session-context/show.test.ts diff --git a/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md index bb0dae72..8123a3b6 100644 --- a/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md +++ b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md @@ -11,7 +11,7 @@ Fixed a real, reproduced bug in `archgate session-context opencode --skip 1`: it Top-level filtering fixes the common case regardless of nesting depth or sibling count; `--session-id --root` gives deterministic ancestry resolution when the caller knows a session inside the right conversation tree (relevant when several top-level sessions share a directory — recency alone can pick a different conversation's root). The distributed archgate editor plugin's opencode lessons-learned skill was updated to use `--root` instead of `--skip 1`. -**Resolved for the other editors (2026-07-02):** The `--skip 1` guidance was wrong for ALL editors, not just opencode, but for a different reason — the premise "this skill runs as a sub-agent with its own session file" is simply false. Verified for Claude Code on real data: (1) skills run inline, so the current conversation is the most recent session file and `--skip 1` reads an unrelated previous conversation (live-reproduced: got an unrelated "ExitWorktree" session); (2) Agent-tool sub-agents do NOT write session files into `~/.claude/projects//` (4 sub-agents spawned, zero new .jsonl files) and modern sessions contain zero `isSidechain: true` entries — so even from a sub-agent, the most recent project session is the main conversation. Conclusion: the plain command (skip 0) is correct in every case; no `parent_id`-style CLI fix is needed for Claude Code. Cursor/Copilot skill variants had the same false guidance (inherited from the canonical claude-code SKILL.md source); their sub-agent storage behavior is unverified but the plain command + transcript sanity check is the right default there too. Fixed at two levels: the distributed lessons-learned skill's canonical source now instructs the plain command, and — since the flag's only advertised purpose was this false premise — `--skip` was REMOVED from all four subcommands entirely (2026-07-02, user decision). Explicit selection replaced it: `--list` (lists the project's sessions with ids + timestamps; top-level only for opencode) and `--session-id` (now also on claude-code, which previously had no way to address an earlier session). +**Resolved for the other editors (2026-07-02):** The `--skip 1` guidance was wrong for ALL editors, not just opencode, but for a different reason — the premise "this skill runs as a sub-agent with its own session file" is simply false. Verified for Claude Code on real data: (1) skills run inline, so the current conversation is the most recent session file and `--skip 1` reads an unrelated previous conversation (live-reproduced: got an unrelated "ExitWorktree" session); (2) Agent-tool sub-agents do NOT write session files into `~/.claude/projects//` (4 sub-agents spawned, zero new .jsonl files) and modern sessions contain zero `isSidechain: true` entries — so even from a sub-agent, the most recent project session is the main conversation. Conclusion: the plain command (skip 0) is correct in every case; no `parent_id`-style CLI fix is needed for Claude Code. Cursor/Copilot skill variants had the same false guidance (inherited from the canonical claude-code SKILL.md source); their sub-agent storage behavior is unverified but the plain command + transcript sanity check is the right default there too. Fixed at two levels: the distributed lessons-learned skill's canonical source now instructs the plain command, and — since the flag's only advertised purpose was this false premise — `--skip` was REMOVED from all four subcommands entirely (2026-07-02, user decision). Explicit selection replaced it as dedicated verbs (user decision): `archgate session-context list [--editor ]` (no --editor = aggregate across all editors; top-level only for opencode) and `archgate session-context show --editor [--root]` (--root is opencode-only, resolving a child to its top-level ancestor). The editor subcommands now take only `--max-entries` and always read the current conversation. Remaining caveat (all editors): when several conversations for the same project run concurrently, most-recent-by-mtime can pick the other live conversation — for opencode, `--session-id --root` is deterministic; claude-code/cursor have no equivalent linkage. diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index e4f83989..56e60b42 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4343,109 +4343,124 @@ Read AI editor session transcripts for the project. Useful for auditing what an archgate session-context [options] ``` +The editor subcommands (`claude-code`, `copilot`, `cursor`, `opencode`) read the **current conversation** — the most recent session for the project. Use `list` to discover earlier sessions and `show` to read a specific one. + ## Subcommands ### archgate session-context claude-code -Read the Claude Code session transcript for the project. +Read the current Claude Code session transcript for the project. ```bash archgate session-context claude-code [options] ``` -| Option | Description | -| ------------------- | -------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--session-id ` | Specific session ID to read | -| `--list` | List available sessions for the project instead of reading one | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | ### archgate session-context copilot -Read the Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. +Read the current Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. ```bash archgate session-context copilot [options] ``` -| Option | Description | -| ------------------- | -------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--list` | List available sessions for the project instead of reading one | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | ### archgate session-context cursor -Read the Cursor agent session transcript for the project. +Read the current Cursor agent session transcript for the project. ```bash archgate session-context cursor [options] ``` -| Option | Description | -| ------------------- | -------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--list` | List available sessions for the project instead of reading one | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | + +### archgate session-context list + +List available sessions for the project as JSON. With `--editor`, lists only that editor's sessions (and fails if its session store is absent); without it, aggregates all editors — stores that are absent report their error inside the aggregate instead of failing the command. + +```bash +archgate session-context list [--editor ] +``` + +| Option | Description | +| ------------------- | -------------------------------------------------------------------------------------------------- | +| `--editor ` | Only list sessions for this editor (`claude-code`, `copilot`, `cursor`, `opencode`; default: all) | + +For opencode, only top-level sessions are listed — sub-agent child sessions are excluded (they can still be read via `show`). ### archgate session-context opencode -Read the opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. Use `--session-id` to read a specific session, including a sub-agent child session. +Read the current opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. -When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), pass `--session-id --root` to resolve its top-level ancestor deterministically instead of relying on recency. +When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), use `show --editor opencode --root` to resolve its top-level ancestor deterministically instead of relying on recency. ```bash archgate session-context opencode [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--list` | List available top-level sessions for the project instead of reading one | -| `--session-id ` | Specific session ID to read | -| `--root` | Resolve to the top-level (root) session — with `--session-id`, walks up from a sub-agent child session | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | -## Examples +### archgate session-context show -Read the latest Claude Code session: +Read a specific session by ID. Session IDs come from `session-context list`. ```bash -archgate session-context claude-code +archgate session-context show --editor [options] ``` -Read a specific Cursor session: +| Option | Description | +| ------------------- | ------------------------------------------------------------------------------ | +| `--editor ` | Editor whose session store holds the session (required) | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--root` | opencode only: resolve a sub-agent child session up to its top-level ancestor | + +## Examples + +Read the current Claude Code session: ```bash -archgate session-context cursor --session-id abc123 +archgate session-context claude-code ``` -Read the latest Copilot CLI session: +Read the current opencode session: ```bash -archgate session-context copilot +archgate session-context opencode ``` -Read the latest opencode session: +List sessions across all editors: ```bash -archgate session-context opencode +archgate session-context list ``` -List available sessions for the project: +List opencode sessions only: ```bash -archgate session-context claude-code --list +archgate session-context list --editor opencode ``` -Read a specific earlier session by ID: +Read a specific earlier session: ```bash -archgate session-context claude-code --session-id 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 +archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 --editor claude-code ``` Resolve an opencode sub-agent child session to its top-level ancestor: ```bash -archgate session-context opencode --session-id ses_child123 --root +archgate session-context show ses_child123 --editor opencode --root ``` --- diff --git a/docs/src/content/docs/nb/reference/cli/session-context.mdx b/docs/src/content/docs/nb/reference/cli/session-context.mdx index d13dee64..96468226 100644 --- a/docs/src/content/docs/nb/reference/cli/session-context.mdx +++ b/docs/src/content/docs/nb/reference/cli/session-context.mdx @@ -9,107 +9,122 @@ Les AI-editor-sesjonslogger for prosjektet. Nyttig for å revidere hva en AI-age archgate session-context [options] ``` +Editor-underkommandoene (`claude-code`, `copilot`, `cursor`, `opencode`) leser den **gjeldende samtalen** — den nyeste sesjonen for prosjektet. Bruk `list` for å oppdage tidligere sesjoner og `show` for å lese en bestemt en. + ## Underkommandoer ### archgate session-context claude-code -Les Claude Code-sesjonsloggen for prosjektet. +Les den gjeldende Claude Code-sesjonsloggen for prosjektet. ```bash archgate session-context claude-code [options] ``` -| Valg | Beskrivelse | -| ------------------- | --------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--session-id ` | Spesifikk sesjons-ID å lese | -| `--list` | List opp tilgjengelige sesjoner for prosjektet i stedet for å lese én | +| Valg | Beskrivelse | +| ------------------- | -------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | ### archgate session-context copilot -Les Copilot CLI-sesjonsloggen for prosjektet. Sesjoner matches etter arbeidsområdets `cwd`-felt. +Les den gjeldende Copilot CLI-sesjonsloggen for prosjektet. Sesjoner matches via arbeidsområdets `cwd`-felt. ```bash archgate session-context copilot [options] ``` -| Valg | Beskrivelse | -| ------------------- | --------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--list` | List opp tilgjengelige sesjoner for prosjektet i stedet for å lese én | -| `--session-id ` | Spesifikk sesjons-UUID å lese | +| Valg | Beskrivelse | +| ------------------- | -------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | ### archgate session-context cursor -Les Cursor-agentsesjonsloggen for prosjektet. +Les den gjeldende Cursor-agentsesjonsloggen for prosjektet. ```bash archgate session-context cursor [options] ``` -| Valg | Beskrivelse | -| ------------------- | --------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--list` | List opp tilgjengelige sesjoner for prosjektet i stedet for å lese én | -| `--session-id ` | Spesifikk sesjons-UUID å lese | +| Valg | Beskrivelse | +| ------------------- | -------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | + +### archgate session-context list + +List opp tilgjengelige sesjoner for prosjektet som JSON. Med `--editor` listes bare den editorens sesjoner (og kommandoen feiler hvis sesjonslageret mangler); uten flagget aggregeres alle editorer — lagre som mangler rapporterer feilen sin inne i aggregatet i stedet for å få kommandoen til å feile. + +```bash +archgate session-context list [--editor ] +``` + +| Valg | Beskrivelse | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | +| `--editor ` | List bare opp sesjoner for denne editoren (`claude-code`, `copilot`, `cursor`, `opencode`; standard: alle) | + +For opencode listes bare toppnivåsesjoner — underagent-barnesesjoner ekskluderes (de kan fortsatt leses via `show`). ### archgate session-context opencode -Les opencode-sesjonsloggen for prosjektet. Sesjoner matches ved å sammenligne sesjonens `directory`-felt med prosjektroten. opencode lagrer underagent-kjøringer som barnesesjoner som deler foreldrenes katalog — disse ekskluderes fra utvalget etter nylighet, slik at den nyeste toppnivåsesjonen alltid er hovedutviklingssesjonen. Bruk `--session-id` for å lese en bestemt sesjon, inkludert en underagent-barnesesjon. +Les den gjeldende opencode-sesjonsloggen for prosjektet. Sesjoner matches ved å sammenligne sesjonens `directory`-felt med prosjektroten. opencode lagrer underagent-kjøringer som barnesesjoner som deler foreldrenes katalog — disse ekskluderes fra utvalget etter nylighet, slik at den nyeste toppnivåsesjonen alltid er hovedutviklingssesjonen. -Når flere toppnivåsesjoner finnes for samme katalog, velger nylighetsutvalget den som sist ble oppdatert — som kanskje ikke er samtalen du er en del av. Hvis du kjenner en sesjons-ID i riktig samtaletre (for eksempel kjenner en underagent sin egen barnesesjons-ID), send `--session-id --root` for å løse opp toppnivåforelderen deterministisk i stedet for å stole på nylighet. +Når flere toppnivåsesjoner finnes for samme katalog, velger nylighetsutvalget den som sist ble oppdatert — som kanskje ikke er samtalen du er en del av. Hvis du kjenner en sesjons-ID i riktig samtaletre (for eksempel kjenner en underagent sin egen barnesesjons-ID), bruk `show --editor opencode --root` for å løse opp toppnivåforelderen deterministisk i stedet for å stole på nylighet. ```bash archgate session-context opencode [options] ``` -| Valg | Beskrivelse | -| ------------------- | ----------------------------------------------------------------------------------------------------- | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--list` | List opp tilgjengelige toppnivåsesjoner for prosjektet i stedet for å lese én | -| `--session-id ` | Spesifikk sesjons-ID å lese | -| `--root` | Løs opp til toppnivåsesjonen (rot) — med `--session-id` gås det oppover fra en underagent-barnesesjon | +| Valg | Beskrivelse | +| ------------------- | -------------------------------------------------------- | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -## Eksempler +### archgate session-context show -Les den nyeste Claude Code-sesjonen: +Les en bestemt sesjon etter ID. Sesjons-ID-er kommer fra `session-context list`. ```bash -archgate session-context claude-code +archgate session-context show --editor [options] ``` -Les en bestemt Cursor-sesjon: +| Valg | Beskrivelse | +| ------------------- | ------------------------------------------------------------------------- | +| `--editor ` | Editoren hvis sesjonslager inneholder sesjonen (påkrevd) | +| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | +| `--root` | Kun opencode: løs opp en underagent-barnesesjon til dens toppnivåforelder | + +## Eksempler + +Les den gjeldende Claude Code-sesjonen: ```bash -archgate session-context cursor --session-id abc123 +archgate session-context claude-code ``` -Les den nyeste Copilot CLI-sesjonen: +Les den gjeldende opencode-sesjonen: ```bash -archgate session-context copilot +archgate session-context opencode ``` -Les den nyeste opencode-sesjonen: +List opp sesjoner på tvers av alle editorer: ```bash -archgate session-context opencode +archgate session-context list ``` -List opp tilgjengelige sesjoner for prosjektet: +List bare opp opencode-sesjoner: ```bash -archgate session-context claude-code --list +archgate session-context list --editor opencode ``` -Les en bestemt tidligere sesjon med ID: +Les en bestemt tidligere sesjon: ```bash -archgate session-context claude-code --session-id 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 +archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 --editor claude-code ``` Løs opp en underagent-barnesesjon i opencode til dens toppnivåforelder: ```bash -archgate session-context opencode --session-id ses_child123 --root +archgate session-context show ses_child123 --editor opencode --root ``` diff --git a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx index c83a63aa..29798d99 100644 --- a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx @@ -6,110 +6,125 @@ description: "Lê transcrições de sessão de editores de IA para o projeto." Lê transcrições de sessão de editores de IA para o projeto. Útil para auditar o que um agente de IA fez durante uma sessão de codificação. ```bash -archgate session-context [options] +archgate session-context [options] ``` +Os subcomandos de editor (`claude-code`, `copilot`, `cursor`, `opencode`) leem a **conversa atual** — a sessão mais recente do projeto. Use `list` para descobrir sessões anteriores e `show` para ler uma sessão específica. + ## Subcomandos ### archgate session-context claude-code -Lê a transcrição de sessão do Claude Code para o projeto. +Lê a transcrição da sessão atual do Claude Code para o projeto. ```bash archgate session-context claude-code [options] ``` -| Opção | Descrição | -| ------------------- | --------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--session-id ` | ID específico da sessão a ser lida | -| `--list` | Lista as sessões disponíveis do projeto em vez de ler uma | +| Opção | Descrição | +| ------------------- | ------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | ### archgate session-context copilot -Lê a transcrição de sessão do Copilot CLI para o projeto. Sessões são correspondidas pelo campo `cwd` do workspace. +Lê a transcrição da sessão atual do Copilot CLI para o projeto. Sessões são correspondidas pelo campo `cwd` do workspace. ```bash archgate session-context copilot [options] ``` -| Opção | Descrição | -| ------------------- | --------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--list` | Lista as sessões disponíveis do projeto em vez de ler uma | -| `--session-id ` | UUID específico da sessão a ser lida | +| Opção | Descrição | +| ------------------- | ------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | ### archgate session-context cursor -Lê a transcrição de sessão do agente Cursor para o projeto. +Lê a transcrição da sessão atual do agente Cursor para o projeto. ```bash archgate session-context cursor [options] ``` -| Opção | Descrição | -| ------------------- | --------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--list` | Lista as sessões disponíveis do projeto em vez de ler uma | -| `--session-id ` | UUID específico da sessão a ser lida | +| Opção | Descrição | +| ------------------- | ------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | + +### archgate session-context list + +Lista as sessões disponíveis do projeto como JSON. Com `--editor`, lista apenas as sessões desse editor (e falha se o armazenamento de sessões não existir); sem a flag, agrega todos os editores — armazenamentos ausentes relatam seu erro dentro do agregado em vez de fazer o comando falhar. + +```bash +archgate session-context list [--editor ] +``` + +| Opção | Descrição | +| ------------------- | ---------------------------------------------------------------------------------------------------- | +| `--editor ` | Lista apenas as sessões deste editor (`claude-code`, `copilot`, `cursor`, `opencode`; padrão: todos) | + +Para o opencode, apenas sessões de nível superior são listadas — sessões filhas de sub-agente são excluídas (elas ainda podem ser lidas via `show`). ### archgate session-context opencode -Lê a transcrição de sessão do opencode para o projeto. Sessões são correspondidas comparando o campo `directory` da sessão com a raiz do projeto. O opencode registra execuções de sub-agentes como sessões filhas que compartilham o diretório da sessão pai — elas são excluídas da seleção por recência, de modo que a sessão de nível superior mais recente é sempre a sessão principal de desenvolvimento. Use `--session-id` para ler uma sessão específica, incluindo uma sessão filha de sub-agente. +Lê a transcrição da sessão atual do opencode para o projeto. Sessões são correspondidas comparando o campo `directory` da sessão com a raiz do projeto. O opencode registra execuções de sub-agentes como sessões filhas que compartilham o diretório da sessão pai — elas são excluídas da seleção por recência, de modo que a sessão de nível superior mais recente é sempre a sessão principal de desenvolvimento. -Quando existem várias sessões de nível superior para o mesmo diretório, a seleção por recência escolhe a atualizada mais recentemente — que pode não ser a conversa da qual você faz parte. Se você conhece um ID de sessão dentro da árvore de conversa correta (por exemplo, um sub-agente conhece o ID da sua própria sessão filha), passe `--session-id --root` para resolver o ancestral de nível superior de forma determinística em vez de depender da recência. +Quando existem várias sessões de nível superior para o mesmo diretório, a seleção por recência escolhe a atualizada mais recentemente — que pode não ser a conversa da qual você faz parte. Se você conhece um ID de sessão dentro da árvore de conversa correta (por exemplo, um sub-agente conhece o ID da sua própria sessão filha), use `show --editor opencode --root` para resolver o ancestral de nível superior de forma determinística em vez de depender da recência. ```bash archgate session-context opencode [options] ``` -| Opção | Descrição | -| ------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--list` | Lista as sessões de nível superior disponíveis do projeto em vez de ler uma | -| `--session-id ` | ID específico da sessão a ser lida | -| `--root` | Resolve para a sessão de nível superior (raiz) — com `--session-id`, sobe a partir de uma sessão filha de sub-agente | +| Opção | Descrição | +| ------------------- | ------------------------------------------- | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -## Exemplos +### archgate session-context show -Ler a última sessão do Claude Code: +Lê uma sessão específica por ID. Os IDs de sessão vêm de `session-context list`. ```bash -archgate session-context claude-code +archgate session-context show --editor [options] ``` -Ler uma sessão específica do Cursor: +| Opção | Descrição | +| ------------------- | ------------------------------------------------------------------------------------------- | +| `--editor ` | Editor cujo armazenamento de sessões contém a sessão (obrigatório) | +| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | +| `--root` | Apenas opencode: resolve uma sessão filha de sub-agente até seu ancestral de nível superior | + +## Exemplos + +Ler a sessão atual do Claude Code: ```bash -archgate session-context cursor --session-id abc123 +archgate session-context claude-code ``` -Ler a última sessão do Copilot CLI: +Ler a sessão atual do opencode: ```bash -archgate session-context copilot +archgate session-context opencode ``` -Ler a última sessão do opencode: +Listar sessões de todos os editores: ```bash -archgate session-context opencode +archgate session-context list ``` -Listar as sessões disponíveis do projeto: +Listar apenas sessões do opencode: ```bash -archgate session-context claude-code --list +archgate session-context list --editor opencode ``` -Ler uma sessão anterior específica por ID: +Ler uma sessão anterior específica: ```bash -archgate session-context claude-code --session-id 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 +archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 --editor claude-code ``` Resolver uma sessão filha de sub-agente do opencode para seu ancestral de nível superior: ```bash -archgate session-context opencode --session-id ses_child123 --root +archgate session-context show ses_child123 --editor opencode --root ``` diff --git a/docs/src/content/docs/reference/cli/session-context.mdx b/docs/src/content/docs/reference/cli/session-context.mdx index 4d608f26..04710df5 100644 --- a/docs/src/content/docs/reference/cli/session-context.mdx +++ b/docs/src/content/docs/reference/cli/session-context.mdx @@ -9,107 +9,122 @@ Read AI editor session transcripts for the project. Useful for auditing what an archgate session-context [options] ``` +The editor subcommands (`claude-code`, `copilot`, `cursor`, `opencode`) read the **current conversation** — the most recent session for the project. Use `list` to discover earlier sessions and `show` to read a specific one. + ## Subcommands ### archgate session-context claude-code -Read the Claude Code session transcript for the project. +Read the current Claude Code session transcript for the project. ```bash archgate session-context claude-code [options] ``` -| Option | Description | -| ------------------- | -------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--session-id ` | Specific session ID to read | -| `--list` | List available sessions for the project instead of reading one | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | ### archgate session-context copilot -Read the Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. +Read the current Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. ```bash archgate session-context copilot [options] ``` -| Option | Description | -| ------------------- | -------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--list` | List available sessions for the project instead of reading one | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | ### archgate session-context cursor -Read the Cursor agent session transcript for the project. +Read the current Cursor agent session transcript for the project. ```bash archgate session-context cursor [options] ``` -| Option | Description | -| ------------------- | -------------------------------------------------------------- | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--list` | List available sessions for the project instead of reading one | -| `--session-id ` | Specific session UUID to read | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | + +### archgate session-context list + +List available sessions for the project as JSON. With `--editor`, lists only that editor's sessions (and fails if its session store is absent); without it, aggregates all editors — stores that are absent report their error inside the aggregate instead of failing the command. + +```bash +archgate session-context list [--editor ] +``` + +| Option | Description | +| ------------------- | ------------------------------------------------------------------------------------------------- | +| `--editor ` | Only list sessions for this editor (`claude-code`, `copilot`, `cursor`, `opencode`; default: all) | + +For opencode, only top-level sessions are listed — sub-agent child sessions are excluded (they can still be read via `show`). ### archgate session-context opencode -Read the opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. Use `--session-id` to read a specific session, including a sub-agent child session. +Read the current opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. -When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), pass `--session-id --root` to resolve its top-level ancestor deterministically instead of relying on recency. +When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), use `show --editor opencode --root` to resolve its top-level ancestor deterministically instead of relying on recency. ```bash archgate session-context opencode [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------------------------------------------ | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--list` | List available top-level sessions for the project instead of reading one | -| `--session-id ` | Specific session ID to read | -| `--root` | Resolve to the top-level (root) session — with `--session-id`, walks up from a sub-agent child session | +| Option | Description | +| ------------------- | ---------------------------------------- | +| `--max-entries ` | Maximum entries to return (default: 200) | -## Examples +### archgate session-context show -Read the latest Claude Code session: +Read a specific session by ID. Session IDs come from `session-context list`. ```bash -archgate session-context claude-code +archgate session-context show --editor [options] ``` -Read a specific Cursor session: +| Option | Description | +| ------------------- | ----------------------------------------------------------------------------- | +| `--editor ` | Editor whose session store holds the session (required) | +| `--max-entries ` | Maximum entries to return (default: 200) | +| `--root` | opencode only: resolve a sub-agent child session up to its top-level ancestor | + +## Examples + +Read the current Claude Code session: ```bash -archgate session-context cursor --session-id abc123 +archgate session-context claude-code ``` -Read the latest Copilot CLI session: +Read the current opencode session: ```bash -archgate session-context copilot +archgate session-context opencode ``` -Read the latest opencode session: +List sessions across all editors: ```bash -archgate session-context opencode +archgate session-context list ``` -List available sessions for the project: +List opencode sessions only: ```bash -archgate session-context claude-code --list +archgate session-context list --editor opencode ``` -Read a specific earlier session by ID: +Read a specific earlier session: ```bash -archgate session-context claude-code --session-id 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 +archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 --editor claude-code ``` Resolve an opencode sub-agent child session to its top-level ancestor: ```bash -archgate session-context opencode --session-id ses_child123 --root +archgate session-context show ses_child123 --editor opencode --root ``` diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts index 832e45ba..bf2f8007 100644 --- a/src/commands/session-context/claude-code.ts +++ b/src/commands/session-context/claude-code.ts @@ -7,46 +7,23 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { - listClaudeCodeSessions, - readClaudeCodeSession, -} from "../../helpers/session-context"; +import { readClaudeCodeSession } from "../../helpers/session-context"; const maxEntriesOption = new Option( "--max-entries ", "maximum entries to return (default: 200)" ).argParser((val) => Math.trunc(Number(val))); -const listOption = new Option( - "--list", - "list available sessions for the project instead of reading one" -).conflicts("sessionId"); - export function registerClaudeCodeSessionContextCommand(parent: Command) { parent .command("claude-code") .description("Read Claude Code session transcript for the project") .addOption(maxEntriesOption) - .option("--session-id ", "Specific session ID to read") - .addOption(listOption) .action(async (opts) => { try { const projectRoot = findProjectRoot(); - - if (opts.list) { - const listed = await listClaudeCodeSessions(projectRoot); - if (!listed.ok) { - logError(listed.error); - await exitWith(1); - return; - } - console.log(formatJSON(listed.data)); - return; - } - const result = await readClaudeCodeSession(projectRoot, { maxEntries: opts.maxEntries, - sessionId: opts.sessionId, }); if (!result.ok) { diff --git a/src/commands/session-context/copilot.ts b/src/commands/session-context/copilot.ts index bcd43017..a942c123 100644 --- a/src/commands/session-context/copilot.ts +++ b/src/commands/session-context/copilot.ts @@ -7,46 +7,23 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { - listCopilotSessions, - readCopilotSession, -} from "../../helpers/session-context-copilot"; +import { readCopilotSession } from "../../helpers/session-context-copilot"; const maxEntriesOption = new Option( "--max-entries ", "maximum entries to return (default: 200)" ).argParser((val) => Math.trunc(Number(val))); -const listOption = new Option( - "--list", - "list available sessions for the project instead of reading one" -).conflicts("sessionId"); - export function registerCopilotSessionContextCommand(parent: Command) { parent .command("copilot") .description("Read Copilot CLI session transcript for the project") .addOption(maxEntriesOption) - .option("--session-id ", "Specific session UUID to read") - .addOption(listOption) .action(async (opts) => { try { const projectRoot = findProjectRoot(); - - if (opts.list) { - const listed = await listCopilotSessions(projectRoot); - if (!listed.ok) { - logError(listed.error); - await exitWith(1); - return; - } - console.log(formatJSON(listed.data)); - return; - } - const result = await readCopilotSession(projectRoot, { maxEntries: opts.maxEntries, - sessionId: opts.sessionId, }); if (!result.ok) { diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts index 6e5ca7ec..9aecfd3d 100644 --- a/src/commands/session-context/cursor.ts +++ b/src/commands/session-context/cursor.ts @@ -7,46 +7,23 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { - listCursorSessions, - readCursorSession, -} from "../../helpers/session-context"; +import { readCursorSession } from "../../helpers/session-context"; const maxEntriesOption = new Option( "--max-entries ", "maximum entries to return (default: 200)" ).argParser((val) => Math.trunc(Number(val))); -const listOption = new Option( - "--list", - "list available sessions for the project instead of reading one" -).conflicts("sessionId"); - export function registerCursorSessionContextCommand(parent: Command) { parent .command("cursor") .description("Read Cursor agent session transcript for the project") .addOption(maxEntriesOption) - .option("--session-id ", "Specific session UUID to read") - .addOption(listOption) .action(async (opts) => { try { const projectRoot = findProjectRoot(); - - if (opts.list) { - const listed = await listCursorSessions(projectRoot); - if (!listed.ok) { - logError(listed.error); - await exitWith(1); - return; - } - console.log(formatJSON(listed.data)); - return; - } - const result = await readCursorSession(projectRoot, { maxEntries: opts.maxEntries, - sessionId: opts.sessionId, }); if (!result.ok) { diff --git a/src/commands/session-context/index.ts b/src/commands/session-context/index.ts index 66d527ef..d327b36c 100644 --- a/src/commands/session-context/index.ts +++ b/src/commands/session-context/index.ts @@ -5,7 +5,9 @@ import type { Command } from "@commander-js/extra-typings"; import { registerClaudeCodeSessionContextCommand } from "./claude-code"; import { registerCopilotSessionContextCommand } from "./copilot"; import { registerCursorSessionContextCommand } from "./cursor"; +import { registerListSessionContextCommand } from "./list"; import { registerOpencodeSessionContextCommand } from "./opencode"; +import { registerShowSessionContextCommand } from "./show"; export function registerSessionContextCommand(program: Command) { const sessionContext = program @@ -15,5 +17,7 @@ export function registerSessionContextCommand(program: Command) { registerClaudeCodeSessionContextCommand(sessionContext); registerCopilotSessionContextCommand(sessionContext); registerCursorSessionContextCommand(sessionContext); + registerListSessionContextCommand(sessionContext); registerOpencodeSessionContextCommand(sessionContext); + registerShowSessionContextCommand(sessionContext); } diff --git a/src/commands/session-context/list.ts b/src/commands/session-context/list.ts new file mode 100644 index 00000000..c0be5de7 --- /dev/null +++ b/src/commands/session-context/list.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; + +import { exitWith, handleCommandError } from "../../helpers/exit"; +import { logError } from "../../helpers/log"; +import { formatJSON } from "../../helpers/output"; +import { findProjectRoot } from "../../helpers/paths"; +import { + type SessionListResult, + listClaudeCodeSessions, + listCursorSessions, +} from "../../helpers/session-context"; +import { listCopilotSessions } from "../../helpers/session-context-copilot"; +import { listOpencodeSessions } from "../../helpers/session-context-opencode"; + +const EDITORS = ["claude-code", "copilot", "cursor", "opencode"] as const; +type SessionEditor = (typeof EDITORS)[number]; + +const editorOption = new Option( + "--editor ", + "only list sessions for this editor (default: all editors)" +).choices(EDITORS); + +/** + * Dispatch to the editor's list helper. Resolved per call (not via a + * module-level map) so the live import bindings are honored. + */ +function listFor( + editor: SessionEditor, + projectRoot: string | null +): SessionListResult | Promise { + switch (editor) { + case "claude-code": + return listClaudeCodeSessions(projectRoot); + case "copilot": + return listCopilotSessions(projectRoot); + case "cursor": + return listCursorSessions(projectRoot); + case "opencode": + return listOpencodeSessions(projectRoot); + } +} + +export function registerListSessionContextCommand(parent: Command) { + parent + .command("list") + .description("List available sessions for the project") + .addOption(editorOption) + .action(async (opts) => { + try { + const projectRoot = findProjectRoot(); + + if (opts.editor) { + const result = await listFor(opts.editor, projectRoot); + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + console.log(formatJSON(result.data)); + return; + } + + // No --editor: aggregate every editor's sessions for the project. + // Editors whose store is absent report their error instead of + // failing the whole command — the aggregate view is informational. + const editors: Record< + string, + { sessions: unknown[] } | { error: string } + > = {}; + for (const editor of EDITORS) { + // oxlint-disable-next-line no-await-in-loop -- sequential on purpose: four cheap local reads, deterministic output order + const result = await listFor(editor, projectRoot); + editors[editor] = result.ok + ? { sessions: result.data.sessions } + : { error: result.error }; + } + console.log(formatJSON({ editors })); + } catch (err) { + await handleCommandError(err); + } + }); +} diff --git a/src/commands/session-context/opencode.ts b/src/commands/session-context/opencode.ts index f9726345..5d1760c7 100644 --- a/src/commands/session-context/opencode.ts +++ b/src/commands/session-context/opencode.ts @@ -7,51 +7,23 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { - listOpencodeSessions, - readOpencodeSession, -} from "../../helpers/session-context-opencode"; +import { readOpencodeSession } from "../../helpers/session-context-opencode"; const maxEntriesOption = new Option( "--max-entries ", "maximum entries to return (default: 200)" ).argParser((val) => Math.trunc(Number(val))); -const listOption = new Option( - "--list", - "list available top-level sessions for the project instead of reading one" -).conflicts("sessionId"); - export function registerOpencodeSessionContextCommand(parent: Command) { parent .command("opencode") .description("Read opencode session transcript for the project") .addOption(maxEntriesOption) - .option("--session-id ", "Specific session ID to read") - .option( - "--root", - "resolve to the top-level (root) session — with --session-id, walks up from a sub-agent child session" - ) - .addOption(listOption) .action(async (opts) => { try { const projectRoot = findProjectRoot(); - - if (opts.list) { - const listed = listOpencodeSessions(projectRoot); - if (!listed.ok) { - logError(listed.error); - await exitWith(1); - return; - } - console.log(formatJSON(listed.data)); - return; - } - const result = readOpencodeSession(projectRoot, { maxEntries: opts.maxEntries, - sessionId: opts.sessionId, - root: opts.root, }); if (!result.ok) { diff --git a/src/commands/session-context/show.ts b/src/commands/session-context/show.ts new file mode 100644 index 00000000..5443bfd4 --- /dev/null +++ b/src/commands/session-context/show.ts @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import type { Command } from "@commander-js/extra-typings"; +import { Option } from "@commander-js/extra-typings"; + +import { exitWith, handleCommandError } from "../../helpers/exit"; +import { logError } from "../../helpers/log"; +import { formatJSON } from "../../helpers/output"; +import { findProjectRoot } from "../../helpers/paths"; +import { + readClaudeCodeSession, + readCursorSession, +} from "../../helpers/session-context"; +import { readCopilotSession } from "../../helpers/session-context-copilot"; +import { readOpencodeSession } from "../../helpers/session-context-opencode"; + +const EDITORS = ["claude-code", "copilot", "cursor", "opencode"] as const; + +const editorOption = new Option( + "--editor ", + "editor whose session store holds the session" +) + .choices(EDITORS) + .makeOptionMandatory(); + +const maxEntriesOption = new Option( + "--max-entries ", + "maximum entries to return (default: 200)" +).argParser((val) => Math.trunc(Number(val))); + +export function registerShowSessionContextCommand(parent: Command) { + parent + .command("show") + .description("Read a specific session by ID (see `session-context list`)") + .argument("", "session ID from `session-context list`") + .addOption(editorOption) + .addOption(maxEntriesOption) + .option( + "--root", + "opencode only: resolve a sub-agent child session up to its top-level ancestor" + ) + .action(async (sessionId, opts) => { + try { + if (opts.root && opts.editor !== "opencode") { + logError( + "--root is only supported with --editor opencode (other editors have no parent/child session linkage)" + ); + await exitWith(1); + return; + } + + const projectRoot = findProjectRoot(); + const readOptions = { maxEntries: opts.maxEntries, sessionId }; + + const result = + opts.editor === "opencode" + ? readOpencodeSession(projectRoot, { + ...readOptions, + root: opts.root, + }) + : opts.editor === "claude-code" + ? await readClaudeCodeSession(projectRoot, readOptions) + : opts.editor === "cursor" + ? await readCursorSession(projectRoot, readOptions) + : await readCopilotSession(projectRoot, readOptions); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); +} diff --git a/tests/commands/session-context.test.ts b/tests/commands/session-context.test.ts index 9a1a731c..90a6a9c6 100644 --- a/tests/commands/session-context.test.ts +++ b/tests/commands/session-context.test.ts @@ -61,7 +61,7 @@ describe("registerSessionContextCommand", () => { expect(sub).toBeDefined(); }); - test("claude-code subcommand has --max-entries, --session-id, and --list options", () => { + test("claude-code subcommand has only --max-entries (read current conversation)", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -70,12 +70,12 @@ describe("registerSessionContextCommand", () => { const sub = parent.commands.find((c) => c.name() === "claude-code")!; const opts = sub.options.map((o) => o.long); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--session-id"); - expect(opts).toContain("--list"); + expect(opts).not.toContain("--session-id"); + expect(opts).not.toContain("--list"); expect(opts).not.toContain("--skip"); }); - test("cursor subcommand has --max-entries, --session-id, and --list options", () => { + test("cursor subcommand has only --max-entries (read current conversation)", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -84,12 +84,12 @@ describe("registerSessionContextCommand", () => { const sub = parent.commands.find((c) => c.name() === "cursor")!; const opts = sub.options.map((o) => o.long); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--session-id"); - expect(opts).toContain("--list"); + expect(opts).not.toContain("--session-id"); + expect(opts).not.toContain("--list"); expect(opts).not.toContain("--skip"); }); - test("copilot subcommand has --max-entries, --session-id, and --list options", () => { + test("copilot subcommand has only --max-entries (read current conversation)", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( @@ -98,23 +98,49 @@ describe("registerSessionContextCommand", () => { const sub = parent.commands.find((c) => c.name() === "copilot")!; const opts = sub.options.map((o) => o.long); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--session-id"); - expect(opts).toContain("--list"); + expect(opts).not.toContain("--session-id"); + expect(opts).not.toContain("--list"); expect(opts).not.toContain("--skip"); }); - test("opencode subcommand has --max-entries, --session-id, --root, and --list options", () => { + test("registers 'show' subcommand with --editor and --root options", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( (c) => c.name() === "session-context" )!; - const sub = parent.commands.find((c) => c.name() === "opencode")!; + const sub = parent.commands.find((c) => c.name() === "show")!; + expect(sub).toBeDefined(); const opts = sub.options.map((o) => o.long); + expect(opts).toContain("--editor"); expect(opts).toContain("--max-entries"); - expect(opts).toContain("--session-id"); expect(opts).toContain("--root"); - expect(opts).toContain("--list"); + }); + + test("registers 'list' subcommand with --editor choices", () => { + const program = new Command(); + registerSessionContextCommand(program); + const parent = program.commands.find( + (c) => c.name() === "session-context" + )!; + const sub = parent.commands.find((c) => c.name() === "list")!; + expect(sub).toBeDefined(); + const opts = sub.options.map((o) => o.long); + expect(opts).toContain("--editor"); + }); + + test("opencode subcommand has only --max-entries (read current conversation)", () => { + const program = new Command(); + registerSessionContextCommand(program); + const parent = program.commands.find( + (c) => c.name() === "session-context" + )!; + const sub = parent.commands.find((c) => c.name() === "opencode")!; + const opts = sub.options.map((o) => o.long); + expect(opts).toContain("--max-entries"); + expect(opts).not.toContain("--session-id"); + expect(opts).not.toContain("--root"); + expect(opts).not.toContain("--list"); expect(opts).not.toContain("--skip"); }); }); diff --git a/tests/commands/session-context/claude-code.test.ts b/tests/commands/session-context/claude-code.test.ts index e5363ca5..cc6baca7 100644 --- a/tests/commands/session-context/claude-code.test.ts +++ b/tests/commands/session-context/claude-code.test.ts @@ -93,7 +93,6 @@ describe("claude-code action handler", () => { process.chdir(tempDir); mockReadClaudeCodeSession.mockReset(); - mockListClaudeCodeSessions.mockReset(); mockReadClaudeCodeSession.mockResolvedValue({ ok: true, data: {} }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); @@ -186,28 +185,6 @@ describe("claude-code action handler", () => { // findProjectRoot found our tempDir (which has .archgate/) expect(mockReadClaudeCodeSession).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - sessionId: undefined, }); }); - - test("prints session list when --list is given", async () => { - mockListClaudeCodeSessions.mockResolvedValue({ - ok: true, - data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, - }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "claude-code", - "--list", - ]); - - expect(mockListClaudeCodeSessions).toHaveBeenCalledWith(tempDir); - expect(mockReadClaudeCodeSession).not.toHaveBeenCalled(); - const output = logSpy.mock.calls - .map((c: unknown[]) => String(c[0])) - .join(""); - expect(JSON.parse(output).sessions[0].id).toBe("abc"); - }); }); diff --git a/tests/commands/session-context/copilot.test.ts b/tests/commands/session-context/copilot.test.ts index 619a600f..35c5499e 100644 --- a/tests/commands/session-context/copilot.test.ts +++ b/tests/commands/session-context/copilot.test.ts @@ -69,14 +69,6 @@ describe("registerCopilotSessionContextCommand", () => { const opt = sub.options.find((o) => o.long === "--max-entries"); expect(opt).toBeDefined(); }); - - test("accepts --session-id option", () => { - const parent = new Command("session-context"); - registerCopilotSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "copilot")!; - const opt = sub.options.find((o) => o.long === "--session-id"); - expect(opt).toBeDefined(); - }); }); describe("copilot action handler", () => { @@ -187,7 +179,6 @@ describe("copilot action handler", () => { expect(mockReadCopilotSession).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - sessionId: undefined, }); }); }); diff --git a/tests/commands/session-context/cursor.test.ts b/tests/commands/session-context/cursor.test.ts index 4d5eabfd..8b35dbea 100644 --- a/tests/commands/session-context/cursor.test.ts +++ b/tests/commands/session-context/cursor.test.ts @@ -69,14 +69,6 @@ describe("registerCursorSessionContextCommand", () => { const opt = sub.options.find((o) => o.long === "--max-entries"); expect(opt).toBeDefined(); }); - - test("accepts --session-id option", () => { - const parent = new Command("session-context"); - registerCursorSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "cursor")!; - const opt = sub.options.find((o) => o.long === "--session-id"); - expect(opt).toBeDefined(); - }); }); describe("cursor action handler", () => { @@ -187,7 +179,6 @@ describe("cursor action handler", () => { expect(mockReadCursorSession).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - sessionId: undefined, }); }); }); diff --git a/tests/commands/session-context/list.test.ts b/tests/commands/session-context/list.test.ts new file mode 100644 index 00000000..c1e01d95 --- /dev/null +++ b/tests/commands/session-context/list.test.ts @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Command } from "@commander-js/extra-typings"; + +import { registerListSessionContextCommand } from "../../../src/commands/session-context/list"; +import { runCli } from "../../integration/cli-harness"; +import { safeRmSync } from "../../test-utils"; + +// Behavior tests spawn the real CLI in a subprocess with HOME/USERPROFILE +// and XDG_DATA_HOME redirected into a temp dir. This avoids Bun's +// process-global mock.module state (the sibling command tests mock the +// session helpers, and those mocks leak across test files). + +describe("registerListSessionContextCommand", () => { + test("registers 'list' as a subcommand with --editor option", () => { + const parent = new Command("session-context"); + registerListSessionContextCommand(parent); + const sub = parent.commands.find((c) => c.name() === "list")!; + expect(sub).toBeDefined(); + expect(sub.description()).toBeTruthy(); + const opt = sub.options.find((o) => o.long === "--editor"); + expect(opt).toBeDefined(); + }); +}); + +describe("session-context list (CLI subprocess)", () => { + let tempHome: string; + let projectDir: string; + let env: Record; + + /** Encode a project path the way Claude Code names its projects dir. */ + function encodeClaude(p: string): string { + return p + .replaceAll("\\", "-") + .replaceAll("/", "-") + .replaceAll(":", "-") + .replaceAll(".", "-"); + } + + beforeEach(() => { + tempHome = realpathSync(mkdtempSync(join(tmpdir(), "archgate-list-home-"))); + projectDir = join(tempHome, "project"); + mkdirSync(join(projectDir, ".archgate", "adrs"), { recursive: true }); + env = { + HOME: tempHome, + USERPROFILE: tempHome, + XDG_DATA_HOME: join(tempHome, "xdg"), + }; + }); + + afterEach(() => { + safeRmSync(tempHome); + }); + + /** Seed an opencode DB with one top-level and one child session. */ + function seedOpencode(): void { + mkdirSync(join(tempHome, "xdg", "opencode"), { recursive: true }); + const db = new Database(join(tempHome, "xdg", "opencode", "opencode.db")); + db.exec("PRAGMA journal_mode = DELETE"); + db.exec( + "CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0)" + ); + db.run( + "INSERT INTO session (id, parent_id, directory, title, time_updated) VALUES (?, ?, ?, ?, ?)", + ["ses_top", null, projectDir, "main work", 2000] + ); + db.run( + "INSERT INTO session (id, parent_id, directory, title, time_updated) VALUES (?, ?, ?, ?, ?)", + ["ses_child", "ses_top", projectDir, "sub-agent", 3000] + ); + db.close(); + } + + /** Seed a Claude Code session file for the project. */ + function seedClaudeCode(id: string): void { + const dir = join(tempHome, ".claude", "projects", encodeClaude(projectDir)); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, `${id}.jsonl`), + JSON.stringify({ type: "user", message: { role: "user", content: "hi" } }) + ); + } + + test("--editor opencode lists top-level sessions only", async () => { + seedOpencode(); + + const { exitCode, stdout } = await runCli( + ["session-context", "list", "--editor", "opencode"], + projectDir, + env + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as { + sessions: Array<{ id: string; title: string; updatedAt: string }>; + }; + expect(parsed.sessions.map((s) => s.id)).toEqual(["ses_top"]); + expect(parsed.sessions[0]?.title).toBe("main work"); + expect(Date.parse(parsed.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); + }); + + test("--editor with an absent store exits 1 with error", async () => { + const { exitCode, stderr } = await runCli( + ["session-context", "list", "--editor", "copilot"], + projectDir, + env + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Copilot"); + }); + + test("without --editor aggregates all editors, folding errors in", async () => { + seedClaudeCode("abc123"); + seedOpencode(); + + const { exitCode, stdout } = await runCli( + ["session-context", "list"], + projectDir, + env + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as { + editors: Record< + string, + { sessions?: Array<{ id: string }>; error?: string } + >; + }; + expect(Object.keys(parsed.editors).sort()).toEqual([ + "claude-code", + "copilot", + "cursor", + "opencode", + ]); + expect(parsed.editors["claude-code"]?.sessions?.[0]?.id).toBe("abc123"); + expect(parsed.editors.opencode?.sessions?.[0]?.id).toBe("ses_top"); + // Stores that don't exist report their error instead of failing the command + expect(parsed.editors.cursor?.error).toBeTruthy(); + expect(parsed.editors.copilot?.error).toBeTruthy(); + }); + + test("rejects an unknown --editor value", async () => { + const { exitCode, stderr } = await runCli( + ["session-context", "list", "--editor", "vim"], + projectDir, + env + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Allowed choices"); + }); +}); diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts index 65e50937..51e50fd3 100644 --- a/tests/commands/session-context/opencode.test.ts +++ b/tests/commands/session-context/opencode.test.ts @@ -25,15 +25,8 @@ const mockReadOpencodeSession = mock( | { ok: true; data: unknown } | { ok: false; error: string } ); -const mockListOpencodeSessions = mock( - () => - ({ ok: true, data: { sessions: [] } }) as - | { ok: true; data: { sessions: unknown[] } } - | { ok: false; error: string } -); mock.module("../../../src/helpers/session-context-opencode", () => ({ readOpencodeSession: mockReadOpencodeSession, - listOpencodeSessions: mockListOpencodeSessions, })); // --------------------------------------------------------------------------- @@ -69,22 +62,6 @@ describe("registerOpencodeSessionContextCommand", () => { const opt = sub.options.find((o) => o.long === "--max-entries"); expect(opt).toBeDefined(); }); - - test("accepts --session-id option", () => { - const parent = new Command("session-context"); - registerOpencodeSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "opencode")!; - const opt = sub.options.find((o) => o.long === "--session-id"); - expect(opt).toBeDefined(); - }); - - test("accepts --list option", () => { - const parent = new Command("session-context"); - registerOpencodeSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "opencode")!; - const opt = sub.options.find((o) => o.long === "--list"); - expect(opt).toBeDefined(); - }); }); describe("opencode action handler", () => { @@ -107,11 +84,6 @@ describe("opencode action handler", () => { mockReadOpencodeSession.mockReset(); mockReadOpencodeSession.mockReturnValue({ ok: true, data: {} }); - mockListOpencodeSessions.mockReset(); - mockListOpencodeSessions.mockReturnValue({ - ok: true, - data: { sessions: [] }, - }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -204,33 +176,6 @@ describe("opencode action handler", () => { expect(mockReadOpencodeSession).toHaveBeenCalledWith(tempDir, { maxEntries: undefined, - sessionId: undefined, - root: undefined, }); }); - - test("prints session list when --list is given", async () => { - mockListOpencodeSessions.mockReturnValue({ - ok: true, - data: { - sessions: [ - { id: "ses_abc", title: "t", updatedAt: "2026-01-01T00:00:00Z" }, - ], - }, - }); - - await makeProgram().parseAsync([ - "node", - "session-context", - "opencode", - "--list", - ]); - - expect(mockListOpencodeSessions).toHaveBeenCalledWith(tempDir); - expect(mockReadOpencodeSession).not.toHaveBeenCalled(); - const output = logSpy.mock.calls - .map((c: unknown[]) => String(c[0])) - .join(""); - expect(JSON.parse(output).sessions[0].id).toBe("ses_abc"); - }); }); diff --git a/tests/commands/session-context/show.test.ts b/tests/commands/session-context/show.test.ts new file mode 100644 index 00000000..e85af120 --- /dev/null +++ b/tests/commands/session-context/show.test.ts @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Command } from "@commander-js/extra-typings"; + +import { registerShowSessionContextCommand } from "../../../src/commands/session-context/show"; +import { runCli } from "../../integration/cli-harness"; +import { safeRmSync } from "../../test-utils"; + +// Behavior tests spawn the real CLI in a subprocess with HOME/USERPROFILE +// and XDG_DATA_HOME redirected into a temp dir. This avoids Bun's +// process-global mock.module state (the sibling command tests mock the +// session helpers, and those mocks leak across test files). + +describe("registerShowSessionContextCommand", () => { + test("registers 'show' with --editor, --max-entries, and --root", () => { + const parent = new Command("session-context"); + registerShowSessionContextCommand(parent); + const sub = parent.commands.find((c) => c.name() === "show")!; + expect(sub).toBeDefined(); + expect(sub.description()).toBeTruthy(); + const opts = sub.options.map((o) => o.long); + expect(opts).toContain("--editor"); + expect(opts).toContain("--max-entries"); + expect(opts).toContain("--root"); + }); +}); + +describe("session-context show (CLI subprocess)", () => { + let tempHome: string; + let projectDir: string; + let env: Record; + + beforeEach(() => { + tempHome = realpathSync(mkdtempSync(join(tmpdir(), "archgate-show-home-"))); + projectDir = join(tempHome, "project"); + mkdirSync(join(projectDir, ".archgate", "adrs"), { recursive: true }); + env = { + HOME: tempHome, + USERPROFILE: tempHome, + XDG_DATA_HOME: join(tempHome, "xdg"), + }; + }); + + afterEach(() => { + safeRmSync(tempHome); + }); + + /** Seed an opencode DB: parent session + sub-agent child, each with a message. */ + function seedOpencode(): void { + mkdirSync(join(tempHome, "xdg", "opencode"), { recursive: true }); + const db = new Database(join(tempHome, "xdg", "opencode", "opencode.db")); + db.exec("PRAGMA journal_mode = DELETE"); + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, parent_id TEXT, + directory TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', + time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL DEFAULT '{}' + ); + `); + const addSession = (id: string, parent: string | null, t: number) => { + db.run( + "INSERT INTO session (id, parent_id, directory, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + [id, parent, projectDir, t, t] + ); + db.run( + "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", + [`msg_${id}`, id, t + 1, JSON.stringify({ role: "user" })] + ); + db.run( + "INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)", + [ + `prt_${id}`, + `msg_${id}`, + id, + t + 1, + JSON.stringify({ type: "text", text: `content of ${id}` }), + ] + ); + }; + addSession("ses_parent", null, 1000); + addSession("ses_child", "ses_parent", 2000); + db.close(); + } + + test("reads a specific opencode session by positional id", async () => { + seedOpencode(); + + const { exitCode, stdout } = await runCli( + ["session-context", "show", "ses_child", "--editor", "opencode"], + projectDir, + env + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as { + sessionId: string; + transcript: Array<{ contentPreview: string }>; + }; + expect(parsed.sessionId).toBe("ses_child"); + expect(parsed.transcript[0]?.contentPreview).toBe("content of ses_child"); + }); + + test("--root resolves an opencode child session to its ancestor", async () => { + seedOpencode(); + + const { exitCode, stdout } = await runCli( + [ + "session-context", + "show", + "ses_child", + "--editor", + "opencode", + "--root", + ], + projectDir, + env + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as { sessionId: string }; + expect(parsed.sessionId).toBe("ses_parent"); + }); + + test("--root with a non-opencode editor exits 1", async () => { + const { exitCode, stderr } = await runCli( + ["session-context", "show", "abc", "--editor", "claude-code", "--root"], + projectDir, + env + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("only supported with --editor opencode"); + }); + + test("unknown session id exits 1 with error", async () => { + seedOpencode(); + + const { exitCode, stderr } = await runCli( + ["session-context", "show", "ses_nope", "--editor", "opencode"], + projectDir, + env + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Session not found: ses_nope"); + }); + + test("missing --editor is rejected", async () => { + const { exitCode, stderr } = await runCli( + ["session-context", "show", "abc"], + projectDir, + env + ); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("--editor"); + }); +}); From e0af65b9af43fc2dbcc544911cf7f7d2466d376f Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:39:34 +0000 Subject: [PATCH 08/15] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/public/llms-full.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 56e60b42..b082bc57 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4391,8 +4391,8 @@ List available sessions for the project as JSON. With `--editor`, lists only tha archgate session-context list [--editor ] ``` -| Option | Description | -| ------------------- | -------------------------------------------------------------------------------------------------- | +| Option | Description | +| ------------------- | ------------------------------------------------------------------------------------------------- | | `--editor ` | Only list sessions for this editor (`claude-code`, `copilot`, `cursor`, `opencode`; default: all) | For opencode, only top-level sessions are listed — sub-agent child sessions are excluded (they can still be read via `show`). @@ -4419,10 +4419,10 @@ Read a specific session by ID. Session IDs come from `session-context list`. archgate session-context show --editor [options] ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------------------ | +| Option | Description | +| ------------------- | ----------------------------------------------------------------------------- | | `--editor ` | Editor whose session store holds the session (required) | -| `--max-entries ` | Maximum entries to return (default: 200) | +| `--max-entries ` | Maximum entries to return (default: 200) | | `--root` | opencode only: resolve a sub-agent child session up to its top-level ancestor | ## Examples From de2279d425cbfdbbc8b4be5a48b37320d6dd156d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 07:40:17 -0300 Subject: [PATCH 09/15] chore(memory): align sequencing note with list/show API Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- .claude/agent-memory/archgate-developer/MEMORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 9d1a1937..bd20da64 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -47,7 +47,7 @@ Non-enforceable lessons — environment/CI/platform quirks no static rule can re - **`Bun.Glob.match()` triggers oxlint `prefer-regexp-test`** — `Bun.Glob.match()` returns a boolean (not a RegExp), but oxlint can't tell. Suppress with `// oxlint-disable-next-line prefer-regexp-test -- Bun.Glob.match() returns boolean, not RegExp`. - **Live `opencode.db` can't be opened `readonly: true` while opencode runs** — `new Database(path, { readonly: true })` fails with `SQLITE_CANTOPEN` (errno 14) on the live WAL-mode DB (`~/.local/share/opencode/opencode.db`). To inspect real data, copy `opencode.db` + `.db-wal` + `.db-shm` to a temp dir and open the copy. Data model facts load-bearing for `session-context opencode`: sub-agent runs are child sessions (`parent_id` set) sharing the parent's `directory`, and opencode skills run INLINE in the calling session (no own session row) — recency selection must filter `parent_id IS NULL` (fixed 2026-07-01; `--root` resolves a `--session-id` child to its top-level ancestor). - [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 `archgate session-context opencode --root` (was `--skip 1`, which read sub-agent transcripts). 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. +- **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. - **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. From 08517ab329e25228e14009fbd602f3a90264cca3 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 07:45:42 -0300 Subject: [PATCH 10/15] chore(memory): capture Bun mock.module cross-file leakage lesson Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- .claude/agent-memory/archgate-developer/MEMORY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index bd20da64..c0c79134 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -49,6 +49,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. +- **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. - **oxlint `no-unused-vars` on catch parameters** — Use bare `catch { }` (no parameter) when the caught error is not used. `catch (err) { }` with unused `err` triggers the rule. From 6850853e8dceade10848df804bfc4332064c8070 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 10:31:10 -0300 Subject: [PATCH 11/15] feat(session-context)!: nest list and show under each editor subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the editor is already in the command path, so a top-level --editor flag was redundant. Each editor subcommand is now a command group: - `session-context ` — read the current conversation (default action; --max-entries only) - `session-context list` — list that editor's sessions for the project (top-level only for opencode) - `session-context show [--max-entries]` — read a specific session; `--root` exists only on `opencode show` The standalone `session-context list`/`show` subcommands (added earlier on this unreleased branch) are removed, along with the cross-editor aggregate and the --editor flag. Sub-subcommands are documented as #### blocks inside each editor's docs section (same pattern as `adr domain`), matching ARCH-016's one-level rule scope. BREAKING CHANGE: `session-context list --editor ` and `session-context show --editor ` (both unreleased) are replaced by `session-context list` and `session-context show `. Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- .../project_session_context_skip_root_fix.md | 2 +- docs/public/llms-full.txt | 86 ++++++--- .../docs/nb/reference/cli/session-context.mdx | 86 ++++++--- .../pt-br/reference/cli/session-context.mdx | 86 ++++++--- .../docs/reference/cli/session-context.mdx | 86 ++++++--- src/commands/session-context/claude-code.ts | 67 ++++++- src/commands/session-context/copilot.ts | 67 ++++++- src/commands/session-context/cursor.ts | 67 ++++++- src/commands/session-context/index.ts | 4 - src/commands/session-context/list.ts | 85 --------- src/commands/session-context/opencode.ts | 70 ++++++- src/commands/session-context/show.ts | 78 -------- tests/commands/session-context.test.ts | 37 ++-- .../session-context/claude-code.test.ts | 97 +++++++++- .../commands/session-context/copilot.test.ts | 7 + tests/commands/session-context/cursor.test.ts | 7 + tests/commands/session-context/list.test.ts | 159 ---------------- .../commands/session-context/opencode.test.ts | 145 +++++++++++++++ tests/commands/session-context/show.test.ts | 174 ------------------ 19 files changed, 752 insertions(+), 658 deletions(-) delete mode 100644 src/commands/session-context/list.ts delete mode 100644 src/commands/session-context/show.ts delete mode 100644 tests/commands/session-context/list.test.ts delete mode 100644 tests/commands/session-context/show.test.ts diff --git a/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md index 8123a3b6..969bfe30 100644 --- a/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md +++ b/.claude/agent-memory/archgate-developer/project_session_context_skip_root_fix.md @@ -11,7 +11,7 @@ Fixed a real, reproduced bug in `archgate session-context opencode --skip 1`: it Top-level filtering fixes the common case regardless of nesting depth or sibling count; `--session-id --root` gives deterministic ancestry resolution when the caller knows a session inside the right conversation tree (relevant when several top-level sessions share a directory — recency alone can pick a different conversation's root). The distributed archgate editor plugin's opencode lessons-learned skill was updated to use `--root` instead of `--skip 1`. -**Resolved for the other editors (2026-07-02):** The `--skip 1` guidance was wrong for ALL editors, not just opencode, but for a different reason — the premise "this skill runs as a sub-agent with its own session file" is simply false. Verified for Claude Code on real data: (1) skills run inline, so the current conversation is the most recent session file and `--skip 1` reads an unrelated previous conversation (live-reproduced: got an unrelated "ExitWorktree" session); (2) Agent-tool sub-agents do NOT write session files into `~/.claude/projects//` (4 sub-agents spawned, zero new .jsonl files) and modern sessions contain zero `isSidechain: true` entries — so even from a sub-agent, the most recent project session is the main conversation. Conclusion: the plain command (skip 0) is correct in every case; no `parent_id`-style CLI fix is needed for Claude Code. Cursor/Copilot skill variants had the same false guidance (inherited from the canonical claude-code SKILL.md source); their sub-agent storage behavior is unverified but the plain command + transcript sanity check is the right default there too. Fixed at two levels: the distributed lessons-learned skill's canonical source now instructs the plain command, and — since the flag's only advertised purpose was this false premise — `--skip` was REMOVED from all four subcommands entirely (2026-07-02, user decision). Explicit selection replaced it as dedicated verbs (user decision): `archgate session-context list [--editor ]` (no --editor = aggregate across all editors; top-level only for opencode) and `archgate session-context show --editor [--root]` (--root is opencode-only, resolving a child to its top-level ancestor). The editor subcommands now take only `--max-entries` and always read the current conversation. +**Resolved for the other editors (2026-07-02):** The `--skip 1` guidance was wrong for ALL editors, not just opencode, but for a different reason — the premise "this skill runs as a sub-agent with its own session file" is simply false. Verified for Claude Code on real data: (1) skills run inline, so the current conversation is the most recent session file and `--skip 1` reads an unrelated previous conversation (live-reproduced: got an unrelated "ExitWorktree" session); (2) Agent-tool sub-agents do NOT write session files into `~/.claude/projects//` (4 sub-agents spawned, zero new .jsonl files) and modern sessions contain zero `isSidechain: true` entries — so even from a sub-agent, the most recent project session is the main conversation. Conclusion: the plain command (skip 0) is correct in every case; no `parent_id`-style CLI fix is needed for Claude Code. Cursor/Copilot skill variants had the same false guidance (inherited from the canonical claude-code SKILL.md source); their sub-agent storage behavior is unverified but the plain command + transcript sanity check is the right default there too. Fixed at two levels: the distributed lessons-learned skill's canonical source now instructs the plain command, and — since the flag's only advertised purpose was this false premise — `--skip` was REMOVED from all four subcommands entirely (2026-07-02, user decision). Explicit selection replaced it as verbs nested under each editor (user decision): `archgate session-context list` and `archgate session-context show ` (`--root` exists only on `opencode show`, resolving a child to its top-level ancestor; opencode list is top-level sessions only). The bare editor subcommands take only `--max-entries` and always read the current conversation. Remaining caveat (all editors): when several conversations for the same project run concurrently, most-recent-by-mtime can pick the other live conversation — for opencode, `--session-id --root` is deterministic; claude-code/cursor have no equivalent linkage. diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index b082bc57..e6705596 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4340,10 +4340,10 @@ Source: https://cli.archgate.dev/reference/cli/session-context/ Read AI editor session transcripts for the project. Useful for auditing what an AI agent did during a coding session. ```bash -archgate session-context [options] +archgate session-context [subcommand] [options] ``` -The editor subcommands (`claude-code`, `copilot`, `cursor`, `opencode`) read the **current conversation** — the most recent session for the project. Use `list` to discover earlier sessions and `show` to read a specific one. +Each editor subcommand reads the **current conversation** — the most recent session for the project. Every editor also has two nested subcommands: `list` to discover earlier sessions and `show ` to read a specific one. ## Subcommands @@ -4359,6 +4359,22 @@ archgate session-context claude-code [options] | ------------------- | ---------------------------------------- | | `--max-entries ` | Maximum entries to return (default: 200) | +#### archgate session-context claude-code list + +List available Claude Code sessions for the project as JSON (`id` + `updatedAt`), most recent first. + +```bash +archgate session-context claude-code list +``` + +#### archgate session-context claude-code show + +Read a specific session by ID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context claude-code show +``` + ### archgate session-context copilot Read the current Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. @@ -4371,6 +4387,22 @@ archgate session-context copilot [options] | ------------------- | ---------------------------------------- | | `--max-entries ` | Maximum entries to return (default: 200) | +#### archgate session-context copilot list + +List available Copilot CLI sessions for the project as JSON, most recent first. + +```bash +archgate session-context copilot list +``` + +#### archgate session-context copilot show + +Read a specific session by UUID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context copilot show +``` + ### archgate session-context cursor Read the current Cursor agent session transcript for the project. @@ -4383,25 +4415,27 @@ archgate session-context cursor [options] | ------------------- | ---------------------------------------- | | `--max-entries ` | Maximum entries to return (default: 200) | -### archgate session-context list +#### archgate session-context cursor list -List available sessions for the project as JSON. With `--editor`, lists only that editor's sessions (and fails if its session store is absent); without it, aggregates all editors — stores that are absent report their error inside the aggregate instead of failing the command. +List available Cursor agent sessions for the project as JSON, most recent first. ```bash -archgate session-context list [--editor ] +archgate session-context cursor list ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------------------------------------- | -| `--editor ` | Only list sessions for this editor (`claude-code`, `copilot`, `cursor`, `opencode`; default: all) | +#### archgate session-context cursor show -For opencode, only top-level sessions are listed — sub-agent child sessions are excluded (they can still be read via `show`). +Read a specific session by UUID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context cursor show +``` ### archgate session-context opencode Read the current opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. -When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), use `show --editor opencode --root` to resolve its top-level ancestor deterministically instead of relying on recency. +When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), use `show --root` to resolve its top-level ancestor deterministically instead of relying on recency. ```bash archgate session-context opencode [options] @@ -4411,19 +4445,21 @@ archgate session-context opencode [options] | ------------------- | ---------------------------------------- | | `--max-entries ` | Maximum entries to return (default: 200) | -### archgate session-context show +#### archgate session-context opencode list -Read a specific session by ID. Session IDs come from `session-context list`. +List available top-level opencode sessions for the project as JSON (`id`, `title`, `updatedAt`), most recent first. Sub-agent child sessions are excluded (they can still be read via `show`). ```bash -archgate session-context show --editor [options] +archgate session-context opencode list ``` -| Option | Description | -| ------------------- | ----------------------------------------------------------------------------- | -| `--editor ` | Editor whose session store holds the session (required) | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--root` | opencode only: resolve a sub-agent child session up to its top-level ancestor | +#### archgate session-context opencode show + +Read a specific session by ID (from `list`), including sub-agent child sessions. Accepts `--max-entries` and `--root` (resolve a sub-agent child session up to its top-level ancestor). + +```bash +archgate session-context opencode show [--root] +``` ## Examples @@ -4439,28 +4475,22 @@ Read the current opencode session: archgate session-context opencode ``` -List sessions across all editors: - -```bash -archgate session-context list -``` - -List opencode sessions only: +List the project's Claude Code sessions: ```bash -archgate session-context list --editor opencode +archgate session-context claude-code list ``` Read a specific earlier session: ```bash -archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 --editor claude-code +archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Resolve an opencode sub-agent child session to its top-level ancestor: ```bash -archgate session-context show ses_child123 --editor opencode --root +archgate session-context opencode show ses_child123 --root ``` --- diff --git a/docs/src/content/docs/nb/reference/cli/session-context.mdx b/docs/src/content/docs/nb/reference/cli/session-context.mdx index 96468226..b83b2142 100644 --- a/docs/src/content/docs/nb/reference/cli/session-context.mdx +++ b/docs/src/content/docs/nb/reference/cli/session-context.mdx @@ -6,10 +6,10 @@ description: "Les AI-editor-sesjonslogger for prosjektet." Les AI-editor-sesjonslogger for prosjektet. Nyttig for å revidere hva en AI-agent gjorde under en kodeøkt. ```bash -archgate session-context [options] +archgate session-context [subcommand] [options] ``` -Editor-underkommandoene (`claude-code`, `copilot`, `cursor`, `opencode`) leser den **gjeldende samtalen** — den nyeste sesjonen for prosjektet. Bruk `list` for å oppdage tidligere sesjoner og `show` for å lese en bestemt en. +Hver editor-underkommando leser den **gjeldende samtalen** — den nyeste sesjonen for prosjektet. Hver editor har også to nestede underkommandoer: `list` for å oppdage tidligere sesjoner og `show ` for å lese en bestemt en. ## Underkommandoer @@ -25,6 +25,22 @@ archgate session-context claude-code [options] | ------------------- | -------------------------------------------------------- | | `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | +#### archgate session-context claude-code list + +List opp tilgjengelige Claude Code-sesjoner for prosjektet som JSON (`id` + `updatedAt`), nyeste først. + +```bash +archgate session-context claude-code list +``` + +#### archgate session-context claude-code show + +Les en bestemt sesjon etter ID (fra `list`). Godtar `--max-entries`. + +```bash +archgate session-context claude-code show +``` + ### archgate session-context copilot Les den gjeldende Copilot CLI-sesjonsloggen for prosjektet. Sesjoner matches via arbeidsområdets `cwd`-felt. @@ -37,6 +53,22 @@ archgate session-context copilot [options] | ------------------- | -------------------------------------------------------- | | `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | +#### archgate session-context copilot list + +List opp tilgjengelige Copilot CLI-sesjoner for prosjektet som JSON, nyeste først. + +```bash +archgate session-context copilot list +``` + +#### archgate session-context copilot show + +Les en bestemt sesjon etter UUID (fra `list`). Godtar `--max-entries`. + +```bash +archgate session-context copilot show +``` + ### archgate session-context cursor Les den gjeldende Cursor-agentsesjonsloggen for prosjektet. @@ -49,25 +81,27 @@ archgate session-context cursor [options] | ------------------- | -------------------------------------------------------- | | `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -### archgate session-context list +#### archgate session-context cursor list -List opp tilgjengelige sesjoner for prosjektet som JSON. Med `--editor` listes bare den editorens sesjoner (og kommandoen feiler hvis sesjonslageret mangler); uten flagget aggregeres alle editorer — lagre som mangler rapporterer feilen sin inne i aggregatet i stedet for å få kommandoen til å feile. +List opp tilgjengelige Cursor-agentsesjoner for prosjektet som JSON, nyeste først. ```bash -archgate session-context list [--editor ] +archgate session-context cursor list ``` -| Valg | Beskrivelse | -| ------------------- | ---------------------------------------------------------------------------------------------------------- | -| `--editor ` | List bare opp sesjoner for denne editoren (`claude-code`, `copilot`, `cursor`, `opencode`; standard: alle) | +#### archgate session-context cursor show -For opencode listes bare toppnivåsesjoner — underagent-barnesesjoner ekskluderes (de kan fortsatt leses via `show`). +Les en bestemt sesjon etter UUID (fra `list`). Godtar `--max-entries`. + +```bash +archgate session-context cursor show +``` ### archgate session-context opencode Les den gjeldende opencode-sesjonsloggen for prosjektet. Sesjoner matches ved å sammenligne sesjonens `directory`-felt med prosjektroten. opencode lagrer underagent-kjøringer som barnesesjoner som deler foreldrenes katalog — disse ekskluderes fra utvalget etter nylighet, slik at den nyeste toppnivåsesjonen alltid er hovedutviklingssesjonen. -Når flere toppnivåsesjoner finnes for samme katalog, velger nylighetsutvalget den som sist ble oppdatert — som kanskje ikke er samtalen du er en del av. Hvis du kjenner en sesjons-ID i riktig samtaletre (for eksempel kjenner en underagent sin egen barnesesjons-ID), bruk `show --editor opencode --root` for å løse opp toppnivåforelderen deterministisk i stedet for å stole på nylighet. +Når flere toppnivåsesjoner finnes for samme katalog, velger nylighetsutvalget den som sist ble oppdatert — som kanskje ikke er samtalen du er en del av. Hvis du kjenner en sesjons-ID i riktig samtaletre (for eksempel kjenner en underagent sin egen barnesesjons-ID), bruk `show --root` for å løse opp toppnivåforelderen deterministisk i stedet for å stole på nylighet. ```bash archgate session-context opencode [options] @@ -77,19 +111,21 @@ archgate session-context opencode [options] | ------------------- | -------------------------------------------------------- | | `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -### archgate session-context show +#### archgate session-context opencode list -Les en bestemt sesjon etter ID. Sesjons-ID-er kommer fra `session-context list`. +List opp tilgjengelige toppnivåsesjoner i opencode for prosjektet som JSON (`id`, `title`, `updatedAt`), nyeste først. Underagent-barnesesjoner ekskluderes (de kan fortsatt leses via `show`). ```bash -archgate session-context show --editor [options] +archgate session-context opencode list ``` -| Valg | Beskrivelse | -| ------------------- | ------------------------------------------------------------------------- | -| `--editor ` | Editoren hvis sesjonslager inneholder sesjonen (påkrevd) | -| `--max-entries ` | Maksimalt antall oppføringer å returnere (standard: 200) | -| `--root` | Kun opencode: løs opp en underagent-barnesesjon til dens toppnivåforelder | +#### archgate session-context opencode show + +Les en bestemt sesjon etter ID (fra `list`), inkludert underagent-barnesesjoner. Godtar `--max-entries` og `--root` (løs opp en underagent-barnesesjon til dens toppnivåforelder). + +```bash +archgate session-context opencode show [--root] +``` ## Eksempler @@ -105,26 +141,20 @@ Les den gjeldende opencode-sesjonen: archgate session-context opencode ``` -List opp sesjoner på tvers av alle editorer: - -```bash -archgate session-context list -``` - -List bare opp opencode-sesjoner: +List opp prosjektets Claude Code-sesjoner: ```bash -archgate session-context list --editor opencode +archgate session-context claude-code list ``` Les en bestemt tidligere sesjon: ```bash -archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 --editor claude-code +archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Løs opp en underagent-barnesesjon i opencode til dens toppnivåforelder: ```bash -archgate session-context show ses_child123 --editor opencode --root +archgate session-context opencode show ses_child123 --root ``` diff --git a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx index 29798d99..9036e8db 100644 --- a/docs/src/content/docs/pt-br/reference/cli/session-context.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/session-context.mdx @@ -6,10 +6,10 @@ description: "Lê transcrições de sessão de editores de IA para o projeto." Lê transcrições de sessão de editores de IA para o projeto. Útil para auditar o que um agente de IA fez durante uma sessão de codificação. ```bash -archgate session-context [options] +archgate session-context [subcommand] [options] ``` -Os subcomandos de editor (`claude-code`, `copilot`, `cursor`, `opencode`) leem a **conversa atual** — a sessão mais recente do projeto. Use `list` para descobrir sessões anteriores e `show` para ler uma sessão específica. +Cada subcomando de editor lê a **conversa atual** — a sessão mais recente do projeto. Cada editor também tem dois subcomandos aninhados: `list` para descobrir sessões anteriores e `show ` para ler uma sessão específica. ## Subcomandos @@ -25,6 +25,22 @@ archgate session-context claude-code [options] | ------------------- | ------------------------------------------- | | `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | +#### archgate session-context claude-code list + +Lista as sessões disponíveis do Claude Code para o projeto como JSON (`id` + `updatedAt`), da mais recente para a mais antiga. + +```bash +archgate session-context claude-code list +``` + +#### archgate session-context claude-code show + +Lê uma sessão específica por ID (de `list`). Aceita `--max-entries`. + +```bash +archgate session-context claude-code show +``` + ### archgate session-context copilot Lê a transcrição da sessão atual do Copilot CLI para o projeto. Sessões são correspondidas pelo campo `cwd` do workspace. @@ -37,6 +53,22 @@ archgate session-context copilot [options] | ------------------- | ------------------------------------------- | | `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | +#### archgate session-context copilot list + +Lista as sessões disponíveis do Copilot CLI para o projeto como JSON, da mais recente para a mais antiga. + +```bash +archgate session-context copilot list +``` + +#### archgate session-context copilot show + +Lê uma sessão específica por UUID (de `list`). Aceita `--max-entries`. + +```bash +archgate session-context copilot show +``` + ### archgate session-context cursor Lê a transcrição da sessão atual do agente Cursor para o projeto. @@ -49,25 +81,27 @@ archgate session-context cursor [options] | ------------------- | ------------------------------------------- | | `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -### archgate session-context list +#### archgate session-context cursor list -Lista as sessões disponíveis do projeto como JSON. Com `--editor`, lista apenas as sessões desse editor (e falha se o armazenamento de sessões não existir); sem a flag, agrega todos os editores — armazenamentos ausentes relatam seu erro dentro do agregado em vez de fazer o comando falhar. +Lista as sessões disponíveis do agente Cursor para o projeto como JSON, da mais recente para a mais antiga. ```bash -archgate session-context list [--editor ] +archgate session-context cursor list ``` -| Opção | Descrição | -| ------------------- | ---------------------------------------------------------------------------------------------------- | -| `--editor ` | Lista apenas as sessões deste editor (`claude-code`, `copilot`, `cursor`, `opencode`; padrão: todos) | +#### archgate session-context cursor show -Para o opencode, apenas sessões de nível superior são listadas — sessões filhas de sub-agente são excluídas (elas ainda podem ser lidas via `show`). +Lê uma sessão específica por UUID (de `list`). Aceita `--max-entries`. + +```bash +archgate session-context cursor show +``` ### archgate session-context opencode Lê a transcrição da sessão atual do opencode para o projeto. Sessões são correspondidas comparando o campo `directory` da sessão com a raiz do projeto. O opencode registra execuções de sub-agentes como sessões filhas que compartilham o diretório da sessão pai — elas são excluídas da seleção por recência, de modo que a sessão de nível superior mais recente é sempre a sessão principal de desenvolvimento. -Quando existem várias sessões de nível superior para o mesmo diretório, a seleção por recência escolhe a atualizada mais recentemente — que pode não ser a conversa da qual você faz parte. Se você conhece um ID de sessão dentro da árvore de conversa correta (por exemplo, um sub-agente conhece o ID da sua própria sessão filha), use `show --editor opencode --root` para resolver o ancestral de nível superior de forma determinística em vez de depender da recência. +Quando existem várias sessões de nível superior para o mesmo diretório, a seleção por recência escolhe a atualizada mais recentemente — que pode não ser a conversa da qual você faz parte. Se você conhece um ID de sessão dentro da árvore de conversa correta (por exemplo, um sub-agente conhece o ID da sua própria sessão filha), use `show --root` para resolver o ancestral de nível superior de forma determinística em vez de depender da recência. ```bash archgate session-context opencode [options] @@ -77,19 +111,21 @@ archgate session-context opencode [options] | ------------------- | ------------------------------------------- | | `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -### archgate session-context show +#### archgate session-context opencode list -Lê uma sessão específica por ID. Os IDs de sessão vêm de `session-context list`. +Lista as sessões de nível superior disponíveis do opencode para o projeto como JSON (`id`, `title`, `updatedAt`), da mais recente para a mais antiga. Sessões filhas de sub-agente são excluídas (elas ainda podem ser lidas via `show`). ```bash -archgate session-context show --editor [options] +archgate session-context opencode list ``` -| Opção | Descrição | -| ------------------- | ------------------------------------------------------------------------------------------- | -| `--editor ` | Editor cujo armazenamento de sessões contém a sessão (obrigatório) | -| `--max-entries ` | Máximo de entradas a retornar (padrão: 200) | -| `--root` | Apenas opencode: resolve uma sessão filha de sub-agente até seu ancestral de nível superior | +#### archgate session-context opencode show + +Lê uma sessão específica por ID (de `list`), incluindo sessões filhas de sub-agente. Aceita `--max-entries` e `--root` (resolve uma sessão filha de sub-agente até seu ancestral de nível superior). + +```bash +archgate session-context opencode show [--root] +``` ## Exemplos @@ -105,26 +141,20 @@ Ler a sessão atual do opencode: archgate session-context opencode ``` -Listar sessões de todos os editores: - -```bash -archgate session-context list -``` - -Listar apenas sessões do opencode: +Listar as sessões do Claude Code do projeto: ```bash -archgate session-context list --editor opencode +archgate session-context claude-code list ``` Ler uma sessão anterior específica: ```bash -archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 --editor claude-code +archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Resolver uma sessão filha de sub-agente do opencode para seu ancestral de nível superior: ```bash -archgate session-context show ses_child123 --editor opencode --root +archgate session-context opencode show ses_child123 --root ``` diff --git a/docs/src/content/docs/reference/cli/session-context.mdx b/docs/src/content/docs/reference/cli/session-context.mdx index 04710df5..121ee4e4 100644 --- a/docs/src/content/docs/reference/cli/session-context.mdx +++ b/docs/src/content/docs/reference/cli/session-context.mdx @@ -6,10 +6,10 @@ description: "Read AI editor session transcripts for the project." Read AI editor session transcripts for the project. Useful for auditing what an AI agent did during a coding session. ```bash -archgate session-context [options] +archgate session-context [subcommand] [options] ``` -The editor subcommands (`claude-code`, `copilot`, `cursor`, `opencode`) read the **current conversation** — the most recent session for the project. Use `list` to discover earlier sessions and `show` to read a specific one. +Each editor subcommand reads the **current conversation** — the most recent session for the project. Every editor also has two nested subcommands: `list` to discover earlier sessions and `show ` to read a specific one. ## Subcommands @@ -25,6 +25,22 @@ archgate session-context claude-code [options] | ------------------- | ---------------------------------------- | | `--max-entries ` | Maximum entries to return (default: 200) | +#### archgate session-context claude-code list + +List available Claude Code sessions for the project as JSON (`id` + `updatedAt`), most recent first. + +```bash +archgate session-context claude-code list +``` + +#### archgate session-context claude-code show + +Read a specific session by ID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context claude-code show +``` + ### archgate session-context copilot Read the current Copilot CLI session transcript for the project. Sessions are matched by their workspace `cwd` field. @@ -37,6 +53,22 @@ archgate session-context copilot [options] | ------------------- | ---------------------------------------- | | `--max-entries ` | Maximum entries to return (default: 200) | +#### archgate session-context copilot list + +List available Copilot CLI sessions for the project as JSON, most recent first. + +```bash +archgate session-context copilot list +``` + +#### archgate session-context copilot show + +Read a specific session by UUID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context copilot show +``` + ### archgate session-context cursor Read the current Cursor agent session transcript for the project. @@ -49,25 +81,27 @@ archgate session-context cursor [options] | ------------------- | ---------------------------------------- | | `--max-entries ` | Maximum entries to return (default: 200) | -### archgate session-context list +#### archgate session-context cursor list -List available sessions for the project as JSON. With `--editor`, lists only that editor's sessions (and fails if its session store is absent); without it, aggregates all editors — stores that are absent report their error inside the aggregate instead of failing the command. +List available Cursor agent sessions for the project as JSON, most recent first. ```bash -archgate session-context list [--editor ] +archgate session-context cursor list ``` -| Option | Description | -| ------------------- | ------------------------------------------------------------------------------------------------- | -| `--editor ` | Only list sessions for this editor (`claude-code`, `copilot`, `cursor`, `opencode`; default: all) | +#### archgate session-context cursor show -For opencode, only top-level sessions are listed — sub-agent child sessions are excluded (they can still be read via `show`). +Read a specific session by UUID (from `list`). Accepts `--max-entries`. + +```bash +archgate session-context cursor show +``` ### archgate session-context opencode Read the current opencode session transcript for the project. Sessions are matched by comparing the session `directory` field to the project root. opencode records sub-agent runs as child sessions that share the parent's directory — these are excluded from recency selection, so the most recent top-level session is always the main development session. -When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), use `show --editor opencode --root` to resolve its top-level ancestor deterministically instead of relying on recency. +When several top-level sessions exist for the same directory, recency selection picks the most recently updated one — which may not be the conversation you are part of. If you know a session ID inside the right conversation tree (for example, a sub-agent knows its own child session ID), use `show --root` to resolve its top-level ancestor deterministically instead of relying on recency. ```bash archgate session-context opencode [options] @@ -77,19 +111,21 @@ archgate session-context opencode [options] | ------------------- | ---------------------------------------- | | `--max-entries ` | Maximum entries to return (default: 200) | -### archgate session-context show +#### archgate session-context opencode list -Read a specific session by ID. Session IDs come from `session-context list`. +List available top-level opencode sessions for the project as JSON (`id`, `title`, `updatedAt`), most recent first. Sub-agent child sessions are excluded (they can still be read via `show`). ```bash -archgate session-context show --editor [options] +archgate session-context opencode list ``` -| Option | Description | -| ------------------- | ----------------------------------------------------------------------------- | -| `--editor ` | Editor whose session store holds the session (required) | -| `--max-entries ` | Maximum entries to return (default: 200) | -| `--root` | opencode only: resolve a sub-agent child session up to its top-level ancestor | +#### archgate session-context opencode show + +Read a specific session by ID (from `list`), including sub-agent child sessions. Accepts `--max-entries` and `--root` (resolve a sub-agent child session up to its top-level ancestor). + +```bash +archgate session-context opencode show [--root] +``` ## Examples @@ -105,26 +141,20 @@ Read the current opencode session: archgate session-context opencode ``` -List sessions across all editors: - -```bash -archgate session-context list -``` - -List opencode sessions only: +List the project's Claude Code sessions: ```bash -archgate session-context list --editor opencode +archgate session-context claude-code list ``` Read a specific earlier session: ```bash -archgate session-context show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 --editor claude-code +archgate session-context claude-code show 6ee6f0a5-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Resolve an opencode sub-agent child session to its top-level ancestor: ```bash -archgate session-context show ses_child123 --editor opencode --root +archgate session-context opencode show ses_child123 --root ``` diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts index bf2f8007..b9f21778 100644 --- a/src/commands/session-context/claude-code.ts +++ b/src/commands/session-context/claude-code.ts @@ -7,18 +7,24 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readClaudeCodeSession } from "../../helpers/session-context"; +import { + listClaudeCodeSessions, + readClaudeCodeSession, +} from "../../helpers/session-context"; -const maxEntriesOption = new Option( - "--max-entries ", - "maximum entries to return (default: 200)" -).argParser((val) => Math.trunc(Number(val))); +const makeMaxEntriesOption = () => + new Option( + "--max-entries ", + "maximum entries to return (default: 200)" + ).argParser((val) => Math.trunc(Number(val))); export function registerClaudeCodeSessionContextCommand(parent: Command) { - parent + const cmd = parent .command("claude-code") - .description("Read Claude Code session transcript for the project") - .addOption(maxEntriesOption) + .description( + "Read the current Claude Code session transcript for the project" + ) + .addOption(makeMaxEntriesOption()) .action(async (opts) => { try { const projectRoot = findProjectRoot(); @@ -32,6 +38,51 @@ export function registerClaudeCodeSessionContextCommand(parent: Command) { return; } + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("list") + .description("List available Claude Code sessions for the project") + .action(async () => { + try { + const projectRoot = findProjectRoot(); + const result = await listClaudeCodeSessions(projectRoot); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("show") + .description("Read a specific Claude Code session by ID") + .argument("", "session ID from `list`") + .addOption(makeMaxEntriesOption()) + .action(async (sessionId, opts) => { + try { + const projectRoot = findProjectRoot(); + const result = await readClaudeCodeSession(projectRoot, { + maxEntries: opts.maxEntries, + sessionId, + }); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + console.log(formatJSON(result.data)); } catch (err) { await handleCommandError(err); diff --git a/src/commands/session-context/copilot.ts b/src/commands/session-context/copilot.ts index a942c123..f00a8981 100644 --- a/src/commands/session-context/copilot.ts +++ b/src/commands/session-context/copilot.ts @@ -7,18 +7,24 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readCopilotSession } from "../../helpers/session-context-copilot"; +import { + listCopilotSessions, + readCopilotSession, +} from "../../helpers/session-context-copilot"; -const maxEntriesOption = new Option( - "--max-entries ", - "maximum entries to return (default: 200)" -).argParser((val) => Math.trunc(Number(val))); +const makeMaxEntriesOption = () => + new Option( + "--max-entries ", + "maximum entries to return (default: 200)" + ).argParser((val) => Math.trunc(Number(val))); export function registerCopilotSessionContextCommand(parent: Command) { - parent + const cmd = parent .command("copilot") - .description("Read Copilot CLI session transcript for the project") - .addOption(maxEntriesOption) + .description( + "Read the current Copilot CLI session transcript for the project" + ) + .addOption(makeMaxEntriesOption()) .action(async (opts) => { try { const projectRoot = findProjectRoot(); @@ -32,6 +38,51 @@ export function registerCopilotSessionContextCommand(parent: Command) { return; } + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("list") + .description("List available Copilot CLI sessions for the project") + .action(async () => { + try { + const projectRoot = findProjectRoot(); + const result = await listCopilotSessions(projectRoot); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("show") + .description("Read a specific Copilot CLI session by UUID") + .argument("", "session UUID from `list`") + .addOption(makeMaxEntriesOption()) + .action(async (sessionId, opts) => { + try { + const projectRoot = findProjectRoot(); + const result = await readCopilotSession(projectRoot, { + maxEntries: opts.maxEntries, + sessionId, + }); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + console.log(formatJSON(result.data)); } catch (err) { await handleCommandError(err); diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts index 9aecfd3d..3175fa69 100644 --- a/src/commands/session-context/cursor.ts +++ b/src/commands/session-context/cursor.ts @@ -7,18 +7,24 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readCursorSession } from "../../helpers/session-context"; +import { + listCursorSessions, + readCursorSession, +} from "../../helpers/session-context"; -const maxEntriesOption = new Option( - "--max-entries ", - "maximum entries to return (default: 200)" -).argParser((val) => Math.trunc(Number(val))); +const makeMaxEntriesOption = () => + new Option( + "--max-entries ", + "maximum entries to return (default: 200)" + ).argParser((val) => Math.trunc(Number(val))); export function registerCursorSessionContextCommand(parent: Command) { - parent + const cmd = parent .command("cursor") - .description("Read Cursor agent session transcript for the project") - .addOption(maxEntriesOption) + .description( + "Read the current Cursor agent session transcript for the project" + ) + .addOption(makeMaxEntriesOption()) .action(async (opts) => { try { const projectRoot = findProjectRoot(); @@ -32,6 +38,51 @@ export function registerCursorSessionContextCommand(parent: Command) { return; } + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("list") + .description("List available Cursor agent sessions for the project") + .action(async () => { + try { + const projectRoot = findProjectRoot(); + const result = await listCursorSessions(projectRoot); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("show") + .description("Read a specific Cursor agent session by UUID") + .argument("", "session UUID from `list`") + .addOption(makeMaxEntriesOption()) + .action(async (sessionId, opts) => { + try { + const projectRoot = findProjectRoot(); + const result = await readCursorSession(projectRoot, { + maxEntries: opts.maxEntries, + sessionId, + }); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + console.log(formatJSON(result.data)); } catch (err) { await handleCommandError(err); diff --git a/src/commands/session-context/index.ts b/src/commands/session-context/index.ts index d327b36c..66d527ef 100644 --- a/src/commands/session-context/index.ts +++ b/src/commands/session-context/index.ts @@ -5,9 +5,7 @@ import type { Command } from "@commander-js/extra-typings"; import { registerClaudeCodeSessionContextCommand } from "./claude-code"; import { registerCopilotSessionContextCommand } from "./copilot"; import { registerCursorSessionContextCommand } from "./cursor"; -import { registerListSessionContextCommand } from "./list"; import { registerOpencodeSessionContextCommand } from "./opencode"; -import { registerShowSessionContextCommand } from "./show"; export function registerSessionContextCommand(program: Command) { const sessionContext = program @@ -17,7 +15,5 @@ export function registerSessionContextCommand(program: Command) { registerClaudeCodeSessionContextCommand(sessionContext); registerCopilotSessionContextCommand(sessionContext); registerCursorSessionContextCommand(sessionContext); - registerListSessionContextCommand(sessionContext); registerOpencodeSessionContextCommand(sessionContext); - registerShowSessionContextCommand(sessionContext); } diff --git a/src/commands/session-context/list.ts b/src/commands/session-context/list.ts deleted file mode 100644 index c0be5de7..00000000 --- a/src/commands/session-context/list.ts +++ /dev/null @@ -1,85 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; - -import { exitWith, handleCommandError } from "../../helpers/exit"; -import { logError } from "../../helpers/log"; -import { formatJSON } from "../../helpers/output"; -import { findProjectRoot } from "../../helpers/paths"; -import { - type SessionListResult, - listClaudeCodeSessions, - listCursorSessions, -} from "../../helpers/session-context"; -import { listCopilotSessions } from "../../helpers/session-context-copilot"; -import { listOpencodeSessions } from "../../helpers/session-context-opencode"; - -const EDITORS = ["claude-code", "copilot", "cursor", "opencode"] as const; -type SessionEditor = (typeof EDITORS)[number]; - -const editorOption = new Option( - "--editor ", - "only list sessions for this editor (default: all editors)" -).choices(EDITORS); - -/** - * Dispatch to the editor's list helper. Resolved per call (not via a - * module-level map) so the live import bindings are honored. - */ -function listFor( - editor: SessionEditor, - projectRoot: string | null -): SessionListResult | Promise { - switch (editor) { - case "claude-code": - return listClaudeCodeSessions(projectRoot); - case "copilot": - return listCopilotSessions(projectRoot); - case "cursor": - return listCursorSessions(projectRoot); - case "opencode": - return listOpencodeSessions(projectRoot); - } -} - -export function registerListSessionContextCommand(parent: Command) { - parent - .command("list") - .description("List available sessions for the project") - .addOption(editorOption) - .action(async (opts) => { - try { - const projectRoot = findProjectRoot(); - - if (opts.editor) { - const result = await listFor(opts.editor, projectRoot); - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - console.log(formatJSON(result.data)); - return; - } - - // No --editor: aggregate every editor's sessions for the project. - // Editors whose store is absent report their error instead of - // failing the whole command — the aggregate view is informational. - const editors: Record< - string, - { sessions: unknown[] } | { error: string } - > = {}; - for (const editor of EDITORS) { - // oxlint-disable-next-line no-await-in-loop -- sequential on purpose: four cheap local reads, deterministic output order - const result = await listFor(editor, projectRoot); - editors[editor] = result.ok - ? { sessions: result.data.sessions } - : { error: result.error }; - } - console.log(formatJSON({ editors })); - } catch (err) { - await handleCommandError(err); - } - }); -} diff --git a/src/commands/session-context/opencode.ts b/src/commands/session-context/opencode.ts index 5d1760c7..f3108fb7 100644 --- a/src/commands/session-context/opencode.ts +++ b/src/commands/session-context/opencode.ts @@ -7,18 +7,22 @@ import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; import { formatJSON } from "../../helpers/output"; import { findProjectRoot } from "../../helpers/paths"; -import { readOpencodeSession } from "../../helpers/session-context-opencode"; +import { + listOpencodeSessions, + readOpencodeSession, +} from "../../helpers/session-context-opencode"; -const maxEntriesOption = new Option( - "--max-entries ", - "maximum entries to return (default: 200)" -).argParser((val) => Math.trunc(Number(val))); +const makeMaxEntriesOption = () => + new Option( + "--max-entries ", + "maximum entries to return (default: 200)" + ).argParser((val) => Math.trunc(Number(val))); export function registerOpencodeSessionContextCommand(parent: Command) { - parent + const cmd = parent .command("opencode") - .description("Read opencode session transcript for the project") - .addOption(maxEntriesOption) + .description("Read the current opencode session transcript for the project") + .addOption(makeMaxEntriesOption()) .action(async (opts) => { try { const projectRoot = findProjectRoot(); @@ -32,6 +36,56 @@ export function registerOpencodeSessionContextCommand(parent: Command) { return; } + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("list") + .description("List available top-level opencode sessions for the project") + .action(async () => { + try { + const projectRoot = findProjectRoot(); + const result = listOpencodeSessions(projectRoot); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + + console.log(formatJSON(result.data)); + } catch (err) { + await handleCommandError(err); + } + }); + + cmd + .command("show") + .description("Read a specific opencode session by ID") + .argument("", "session ID from `list`") + .addOption(makeMaxEntriesOption()) + .option( + "--root", + "resolve a sub-agent child session up to its top-level ancestor" + ) + .action(async (sessionId, opts) => { + try { + const projectRoot = findProjectRoot(); + const result = readOpencodeSession(projectRoot, { + maxEntries: opts.maxEntries, + sessionId, + root: opts.root, + }); + + if (!result.ok) { + logError(result.error); + await exitWith(1); + return; + } + console.log(formatJSON(result.data)); } catch (err) { await handleCommandError(err); diff --git a/src/commands/session-context/show.ts b/src/commands/session-context/show.ts deleted file mode 100644 index 5443bfd4..00000000 --- a/src/commands/session-context/show.ts +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; - -import { exitWith, handleCommandError } from "../../helpers/exit"; -import { logError } from "../../helpers/log"; -import { formatJSON } from "../../helpers/output"; -import { findProjectRoot } from "../../helpers/paths"; -import { - readClaudeCodeSession, - readCursorSession, -} from "../../helpers/session-context"; -import { readCopilotSession } from "../../helpers/session-context-copilot"; -import { readOpencodeSession } from "../../helpers/session-context-opencode"; - -const EDITORS = ["claude-code", "copilot", "cursor", "opencode"] as const; - -const editorOption = new Option( - "--editor ", - "editor whose session store holds the session" -) - .choices(EDITORS) - .makeOptionMandatory(); - -const maxEntriesOption = new Option( - "--max-entries ", - "maximum entries to return (default: 200)" -).argParser((val) => Math.trunc(Number(val))); - -export function registerShowSessionContextCommand(parent: Command) { - parent - .command("show") - .description("Read a specific session by ID (see `session-context list`)") - .argument("", "session ID from `session-context list`") - .addOption(editorOption) - .addOption(maxEntriesOption) - .option( - "--root", - "opencode only: resolve a sub-agent child session up to its top-level ancestor" - ) - .action(async (sessionId, opts) => { - try { - if (opts.root && opts.editor !== "opencode") { - logError( - "--root is only supported with --editor opencode (other editors have no parent/child session linkage)" - ); - await exitWith(1); - return; - } - - const projectRoot = findProjectRoot(); - const readOptions = { maxEntries: opts.maxEntries, sessionId }; - - const result = - opts.editor === "opencode" - ? readOpencodeSession(projectRoot, { - ...readOptions, - root: opts.root, - }) - : opts.editor === "claude-code" - ? await readClaudeCodeSession(projectRoot, readOptions) - : opts.editor === "cursor" - ? await readCursorSession(projectRoot, readOptions) - : await readCopilotSession(projectRoot, readOptions); - - if (!result.ok) { - logError(result.error); - await exitWith(1); - return; - } - - console.log(formatJSON(result.data)); - } catch (err) { - await handleCommandError(err); - } - }); -} diff --git a/tests/commands/session-context.test.ts b/tests/commands/session-context.test.ts index 90a6a9c6..55857ee7 100644 --- a/tests/commands/session-context.test.ts +++ b/tests/commands/session-context.test.ts @@ -103,30 +103,43 @@ describe("registerSessionContextCommand", () => { expect(opts).not.toContain("--skip"); }); - test("registers 'show' subcommand with --editor and --root options", () => { + test("each editor subcommand has list and show children", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( (c) => c.name() === "session-context" )!; - const sub = parent.commands.find((c) => c.name() === "show")!; - expect(sub).toBeDefined(); - const opts = sub.options.map((o) => o.long); - expect(opts).toContain("--editor"); - expect(opts).toContain("--max-entries"); - expect(opts).toContain("--root"); + // list/show are NOT direct children of session-context + expect(parent.commands.map((c) => c.name())).toEqual([ + "claude-code", + "copilot", + "cursor", + "opencode", + ]); + for (const editor of ["claude-code", "copilot", "cursor", "opencode"]) { + const sub = parent.commands.find((c) => c.name() === editor)!; + const children = sub.commands.map((c) => c.name()).sort(); + expect(children).toEqual(["list", "show"]); + } }); - test("registers 'list' subcommand with --editor choices", () => { + test("only opencode show has --root", () => { const program = new Command(); registerSessionContextCommand(program); const parent = program.commands.find( (c) => c.name() === "session-context" )!; - const sub = parent.commands.find((c) => c.name() === "list")!; - expect(sub).toBeDefined(); - const opts = sub.options.map((o) => o.long); - expect(opts).toContain("--editor"); + for (const editor of ["claude-code", "copilot", "cursor", "opencode"]) { + const sub = parent.commands.find((c) => c.name() === editor)!; + const show = sub.commands.find((c) => c.name() === "show")!; + const opts = show.options.map((o) => o.long); + expect(opts).toContain("--max-entries"); + if (editor === "opencode") { + expect(opts).toContain("--root"); + } else { + expect(opts).not.toContain("--root"); + } + } }); test("opencode subcommand has only --max-entries (read current conversation)", () => { diff --git a/tests/commands/session-context/claude-code.test.ts b/tests/commands/session-context/claude-code.test.ts index cc6baca7..bb09483a 100644 --- a/tests/commands/session-context/claude-code.test.ts +++ b/tests/commands/session-context/claude-code.test.ts @@ -9,7 +9,7 @@ import { spyOn, test, } from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -45,6 +45,7 @@ mock.module("../../../src/helpers/session-context", () => ({ // --------------------------------------------------------------------------- import { registerClaudeCodeSessionContextCommand } from "../../../src/commands/session-context/claude-code"; +import { runCli } from "../../integration/cli-harness"; import { safeRmSync } from "../../test-utils"; // --------------------------------------------------------------------------- @@ -73,6 +74,13 @@ describe("registerClaudeCodeSessionContextCommand", () => { const opt = sub.options.find((o) => o.long === "--max-entries"); expect(opt).toBeDefined(); }); + + test("has list and show subcommands", () => { + const parent = new Command("session-context"); + registerClaudeCodeSessionContextCommand(parent); + const sub = parent.commands.find((c) => c.name() === "claude-code")!; + expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); + }); }); describe("claude-code action handler", () => { @@ -188,3 +196,90 @@ describe("claude-code action handler", () => { }); }); }); + +describe("claude-code list/show (CLI subprocess)", () => { + // Subprocess tests avoid Bun's process-global mock.module state — this + // file mocks the read helper for the in-process tests above, so the + // nested subcommands are exercised against real stores in a child + // process with HOME/USERPROFILE redirected. + let tempHome: string; + let projectDir: string; + let env: Record; + + function encodeClaude(p: string): string { + return p + .replaceAll("\\", "-") + .replaceAll("/", "-") + .replaceAll(":", "-") + .replaceAll(".", "-"); + } + + beforeEach(() => { + tempHome = realpathSync(mkdtempSync(join(tmpdir(), "archgate-cc-home-"))); + projectDir = join(tempHome, "project"); + mkdirSync(join(projectDir, ".archgate", "adrs"), { recursive: true }); + env = { HOME: tempHome, USERPROFILE: tempHome }; + }); + + afterEach(() => { + safeRmSync(tempHome); + }); + + function seedSession(id: string, content: string): void { + const dir = join(tempHome, ".claude", "projects", encodeClaude(projectDir)); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, `${id}.jsonl`), + JSON.stringify({ type: "user", message: { role: "user", content } }) + ); + } + + test("list returns the project's sessions", async () => { + seedSession("abc123", "hi"); + + const { exitCode, stdout } = await runCli( + ["session-context", "claude-code", "list"], + projectDir, + env + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as { + sessions: Array<{ id: string; updatedAt: string }>; + }; + expect(parsed.sessions.map((s) => s.id)).toEqual(["abc123"]); + expect(Date.parse(parsed.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); + }); + + test("show reads a specific session by id", async () => { + seedSession("older", "earlier content"); + seedSession("newer", "current content"); + + const { exitCode, stdout } = await runCli( + ["session-context", "claude-code", "show", "older"], + projectDir, + env + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as { + sessionFile: string; + transcript: Array<{ contentPreview: string }>; + }; + expect(parsed.sessionFile).toBe("older.jsonl"); + expect(parsed.transcript[0]?.contentPreview).toBe("earlier content"); + }); + + test("show with an unknown id exits 1", async () => { + seedSession("only", "hi"); + + const { exitCode, stderr } = await runCli( + ["session-context", "claude-code", "show", "nope"], + projectDir, + env + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Session not found: nope"); + }); +}); diff --git a/tests/commands/session-context/copilot.test.ts b/tests/commands/session-context/copilot.test.ts index 35c5499e..d6a4ae4b 100644 --- a/tests/commands/session-context/copilot.test.ts +++ b/tests/commands/session-context/copilot.test.ts @@ -69,6 +69,13 @@ describe("registerCopilotSessionContextCommand", () => { const opt = sub.options.find((o) => o.long === "--max-entries"); expect(opt).toBeDefined(); }); + + test("has list and show subcommands", () => { + const parent = new Command("session-context"); + registerCopilotSessionContextCommand(parent); + const sub = parent.commands.find((c) => c.name() === "copilot")!; + expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); + }); }); describe("copilot action handler", () => { diff --git a/tests/commands/session-context/cursor.test.ts b/tests/commands/session-context/cursor.test.ts index 8b35dbea..df8aef5d 100644 --- a/tests/commands/session-context/cursor.test.ts +++ b/tests/commands/session-context/cursor.test.ts @@ -69,6 +69,13 @@ describe("registerCursorSessionContextCommand", () => { const opt = sub.options.find((o) => o.long === "--max-entries"); expect(opt).toBeDefined(); }); + + test("has list and show subcommands", () => { + const parent = new Command("session-context"); + registerCursorSessionContextCommand(parent); + const sub = parent.commands.find((c) => c.name() === "cursor")!; + expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); + }); }); describe("cursor action handler", () => { diff --git a/tests/commands/session-context/list.test.ts b/tests/commands/session-context/list.test.ts deleted file mode 100644 index c1e01d95..00000000 --- a/tests/commands/session-context/list.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import { Database } from "bun:sqlite"; -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { Command } from "@commander-js/extra-typings"; - -import { registerListSessionContextCommand } from "../../../src/commands/session-context/list"; -import { runCli } from "../../integration/cli-harness"; -import { safeRmSync } from "../../test-utils"; - -// Behavior tests spawn the real CLI in a subprocess with HOME/USERPROFILE -// and XDG_DATA_HOME redirected into a temp dir. This avoids Bun's -// process-global mock.module state (the sibling command tests mock the -// session helpers, and those mocks leak across test files). - -describe("registerListSessionContextCommand", () => { - test("registers 'list' as a subcommand with --editor option", () => { - const parent = new Command("session-context"); - registerListSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "list")!; - expect(sub).toBeDefined(); - expect(sub.description()).toBeTruthy(); - const opt = sub.options.find((o) => o.long === "--editor"); - expect(opt).toBeDefined(); - }); -}); - -describe("session-context list (CLI subprocess)", () => { - let tempHome: string; - let projectDir: string; - let env: Record; - - /** Encode a project path the way Claude Code names its projects dir. */ - function encodeClaude(p: string): string { - return p - .replaceAll("\\", "-") - .replaceAll("/", "-") - .replaceAll(":", "-") - .replaceAll(".", "-"); - } - - beforeEach(() => { - tempHome = realpathSync(mkdtempSync(join(tmpdir(), "archgate-list-home-"))); - projectDir = join(tempHome, "project"); - mkdirSync(join(projectDir, ".archgate", "adrs"), { recursive: true }); - env = { - HOME: tempHome, - USERPROFILE: tempHome, - XDG_DATA_HOME: join(tempHome, "xdg"), - }; - }); - - afterEach(() => { - safeRmSync(tempHome); - }); - - /** Seed an opencode DB with one top-level and one child session. */ - function seedOpencode(): void { - mkdirSync(join(tempHome, "xdg", "opencode"), { recursive: true }); - const db = new Database(join(tempHome, "xdg", "opencode", "opencode.db")); - db.exec("PRAGMA journal_mode = DELETE"); - db.exec( - "CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0)" - ); - db.run( - "INSERT INTO session (id, parent_id, directory, title, time_updated) VALUES (?, ?, ?, ?, ?)", - ["ses_top", null, projectDir, "main work", 2000] - ); - db.run( - "INSERT INTO session (id, parent_id, directory, title, time_updated) VALUES (?, ?, ?, ?, ?)", - ["ses_child", "ses_top", projectDir, "sub-agent", 3000] - ); - db.close(); - } - - /** Seed a Claude Code session file for the project. */ - function seedClaudeCode(id: string): void { - const dir = join(tempHome, ".claude", "projects", encodeClaude(projectDir)); - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, `${id}.jsonl`), - JSON.stringify({ type: "user", message: { role: "user", content: "hi" } }) - ); - } - - test("--editor opencode lists top-level sessions only", async () => { - seedOpencode(); - - const { exitCode, stdout } = await runCli( - ["session-context", "list", "--editor", "opencode"], - projectDir, - env - ); - - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout) as { - sessions: Array<{ id: string; title: string; updatedAt: string }>; - }; - expect(parsed.sessions.map((s) => s.id)).toEqual(["ses_top"]); - expect(parsed.sessions[0]?.title).toBe("main work"); - expect(Date.parse(parsed.sessions[0]?.updatedAt ?? "")).not.toBeNaN(); - }); - - test("--editor with an absent store exits 1 with error", async () => { - const { exitCode, stderr } = await runCli( - ["session-context", "list", "--editor", "copilot"], - projectDir, - env - ); - - expect(exitCode).toBe(1); - expect(stderr).toContain("Copilot"); - }); - - test("without --editor aggregates all editors, folding errors in", async () => { - seedClaudeCode("abc123"); - seedOpencode(); - - const { exitCode, stdout } = await runCli( - ["session-context", "list"], - projectDir, - env - ); - - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout) as { - editors: Record< - string, - { sessions?: Array<{ id: string }>; error?: string } - >; - }; - expect(Object.keys(parsed.editors).sort()).toEqual([ - "claude-code", - "copilot", - "cursor", - "opencode", - ]); - expect(parsed.editors["claude-code"]?.sessions?.[0]?.id).toBe("abc123"); - expect(parsed.editors.opencode?.sessions?.[0]?.id).toBe("ses_top"); - // Stores that don't exist report their error instead of failing the command - expect(parsed.editors.cursor?.error).toBeTruthy(); - expect(parsed.editors.copilot?.error).toBeTruthy(); - }); - - test("rejects an unknown --editor value", async () => { - const { exitCode, stderr } = await runCli( - ["session-context", "list", "--editor", "vim"], - projectDir, - env - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("Allowed choices"); - }); -}); diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts index 51e50fd3..f5125284 100644 --- a/tests/commands/session-context/opencode.test.ts +++ b/tests/commands/session-context/opencode.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate +import { Database } from "bun:sqlite"; import { afterEach, beforeEach, @@ -34,6 +35,7 @@ mock.module("../../../src/helpers/session-context-opencode", () => ({ // --------------------------------------------------------------------------- import { registerOpencodeSessionContextCommand } from "../../../src/commands/session-context/opencode"; +import { runCli } from "../../integration/cli-harness"; import { safeRmSync } from "../../test-utils"; // --------------------------------------------------------------------------- @@ -62,6 +64,16 @@ describe("registerOpencodeSessionContextCommand", () => { const opt = sub.options.find((o) => o.long === "--max-entries"); expect(opt).toBeDefined(); }); + + test("has list and show subcommands; --root only on show", () => { + const parent = new Command("session-context"); + registerOpencodeSessionContextCommand(parent); + const sub = parent.commands.find((c) => c.name() === "opencode")!; + expect(sub.commands.map((c) => c.name()).sort()).toEqual(["list", "show"]); + const show = sub.commands.find((c) => c.name() === "show")!; + expect(show.options.map((o) => o.long)).toContain("--root"); + expect(sub.options.map((o) => o.long)).not.toContain("--root"); + }); }); describe("opencode action handler", () => { @@ -179,3 +191,136 @@ describe("opencode action handler", () => { }); }); }); + +describe("opencode list/show (CLI subprocess)", () => { + // Subprocess tests avoid Bun's process-global mock.module state — this + // file mocks the read helper for the in-process tests above, so the + // nested subcommands are exercised against a real temp opencode DB in a + // child process with XDG_DATA_HOME redirected. + let tempHome: string; + let projectDir: string; + let env: Record; + + beforeEach(() => { + tempHome = realpathSync(mkdtempSync(join(tmpdir(), "archgate-oc-home-"))); + projectDir = join(tempHome, "project"); + mkdirSync(join(projectDir, ".archgate", "adrs"), { recursive: true }); + env = { + HOME: tempHome, + USERPROFILE: tempHome, + XDG_DATA_HOME: join(tempHome, "xdg"), + }; + }); + + afterEach(() => { + safeRmSync(tempHome); + }); + + /** Seed an opencode DB: parent session + sub-agent child, each with a message. */ + function seedOpencode(): void { + mkdirSync(join(tempHome, "xdg", "opencode"), { recursive: true }); + const db = new Database(join(tempHome, "xdg", "opencode", "opencode.db")); + db.exec("PRAGMA journal_mode = DELETE"); + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, parent_id TEXT, + directory TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', + time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL DEFAULT '{}' + ); + `); + const addSession = (id: string, parent: string | null, t: number) => { + db.run( + "INSERT INTO session (id, parent_id, directory, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)", + [ + id, + parent, + projectDir, + id === "ses_parent" ? "main work" : "sub", + t, + t, + ] + ); + db.run( + "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", + [`msg_${id}`, id, t + 1, JSON.stringify({ role: "user" })] + ); + db.run( + "INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)", + [ + `prt_${id}`, + `msg_${id}`, + id, + t + 1, + JSON.stringify({ type: "text", text: `content of ${id}` }), + ] + ); + }; + addSession("ses_parent", null, 1000); + addSession("ses_child", "ses_parent", 2000); + db.close(); + } + + test("list returns top-level sessions only", async () => { + seedOpencode(); + + const { exitCode, stdout } = await runCli( + ["session-context", "opencode", "list"], + projectDir, + env + ); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout) as { + sessions: Array<{ id: string; title: string; updatedAt: string }>; + }; + expect(parsed.sessions.map((s) => s.id)).toEqual(["ses_parent"]); + expect(parsed.sessions[0]?.title).toBe("main work"); + }); + + test("show reads a specific session; --root resolves to the ancestor", async () => { + seedOpencode(); + + const shown = await runCli( + ["session-context", "opencode", "show", "ses_child"], + projectDir, + env + ); + expect(shown.exitCode).toBe(0); + expect((JSON.parse(shown.stdout) as { sessionId: string }).sessionId).toBe( + "ses_child" + ); + + const rooted = await runCli( + ["session-context", "opencode", "show", "ses_child", "--root"], + projectDir, + env + ); + expect(rooted.exitCode).toBe(0); + expect((JSON.parse(rooted.stdout) as { sessionId: string }).sessionId).toBe( + "ses_parent" + ); + }); + + test("show with an unknown id exits 1", async () => { + seedOpencode(); + + const { exitCode, stderr } = await runCli( + ["session-context", "opencode", "show", "ses_nope"], + projectDir, + env + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Session not found: ses_nope"); + }); +}); diff --git a/tests/commands/session-context/show.test.ts b/tests/commands/session-context/show.test.ts deleted file mode 100644 index e85af120..00000000 --- a/tests/commands/session-context/show.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import { Database } from "bun:sqlite"; -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { Command } from "@commander-js/extra-typings"; - -import { registerShowSessionContextCommand } from "../../../src/commands/session-context/show"; -import { runCli } from "../../integration/cli-harness"; -import { safeRmSync } from "../../test-utils"; - -// Behavior tests spawn the real CLI in a subprocess with HOME/USERPROFILE -// and XDG_DATA_HOME redirected into a temp dir. This avoids Bun's -// process-global mock.module state (the sibling command tests mock the -// session helpers, and those mocks leak across test files). - -describe("registerShowSessionContextCommand", () => { - test("registers 'show' with --editor, --max-entries, and --root", () => { - const parent = new Command("session-context"); - registerShowSessionContextCommand(parent); - const sub = parent.commands.find((c) => c.name() === "show")!; - expect(sub).toBeDefined(); - expect(sub.description()).toBeTruthy(); - const opts = sub.options.map((o) => o.long); - expect(opts).toContain("--editor"); - expect(opts).toContain("--max-entries"); - expect(opts).toContain("--root"); - }); -}); - -describe("session-context show (CLI subprocess)", () => { - let tempHome: string; - let projectDir: string; - let env: Record; - - beforeEach(() => { - tempHome = realpathSync(mkdtempSync(join(tmpdir(), "archgate-show-home-"))); - projectDir = join(tempHome, "project"); - mkdirSync(join(projectDir, ".archgate", "adrs"), { recursive: true }); - env = { - HOME: tempHome, - USERPROFILE: tempHome, - XDG_DATA_HOME: join(tempHome, "xdg"), - }; - }); - - afterEach(() => { - safeRmSync(tempHome); - }); - - /** Seed an opencode DB: parent session + sub-agent child, each with a message. */ - function seedOpencode(): void { - mkdirSync(join(tempHome, "xdg", "opencode"), { recursive: true }); - const db = new Database(join(tempHome, "xdg", "opencode", "opencode.db")); - db.exec("PRAGMA journal_mode = DELETE"); - db.exec(` - CREATE TABLE session ( - id TEXT PRIMARY KEY, parent_id TEXT, - directory TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', - time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE message ( - id TEXT PRIMARY KEY, session_id TEXT NOT NULL, - time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0, - data TEXT NOT NULL DEFAULT '{}' - ); - CREATE TABLE part ( - id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, - time_created INTEGER NOT NULL DEFAULT 0, time_updated INTEGER NOT NULL DEFAULT 0, - data TEXT NOT NULL DEFAULT '{}' - ); - `); - const addSession = (id: string, parent: string | null, t: number) => { - db.run( - "INSERT INTO session (id, parent_id, directory, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", - [id, parent, projectDir, t, t] - ); - db.run( - "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", - [`msg_${id}`, id, t + 1, JSON.stringify({ role: "user" })] - ); - db.run( - "INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)", - [ - `prt_${id}`, - `msg_${id}`, - id, - t + 1, - JSON.stringify({ type: "text", text: `content of ${id}` }), - ] - ); - }; - addSession("ses_parent", null, 1000); - addSession("ses_child", "ses_parent", 2000); - db.close(); - } - - test("reads a specific opencode session by positional id", async () => { - seedOpencode(); - - const { exitCode, stdout } = await runCli( - ["session-context", "show", "ses_child", "--editor", "opencode"], - projectDir, - env - ); - - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout) as { - sessionId: string; - transcript: Array<{ contentPreview: string }>; - }; - expect(parsed.sessionId).toBe("ses_child"); - expect(parsed.transcript[0]?.contentPreview).toBe("content of ses_child"); - }); - - test("--root resolves an opencode child session to its ancestor", async () => { - seedOpencode(); - - const { exitCode, stdout } = await runCli( - [ - "session-context", - "show", - "ses_child", - "--editor", - "opencode", - "--root", - ], - projectDir, - env - ); - - expect(exitCode).toBe(0); - const parsed = JSON.parse(stdout) as { sessionId: string }; - expect(parsed.sessionId).toBe("ses_parent"); - }); - - test("--root with a non-opencode editor exits 1", async () => { - const { exitCode, stderr } = await runCli( - ["session-context", "show", "abc", "--editor", "claude-code", "--root"], - projectDir, - env - ); - - expect(exitCode).toBe(1); - expect(stderr).toContain("only supported with --editor opencode"); - }); - - test("unknown session id exits 1 with error", async () => { - seedOpencode(); - - const { exitCode, stderr } = await runCli( - ["session-context", "show", "ses_nope", "--editor", "opencode"], - projectDir, - env - ); - - expect(exitCode).toBe(1); - expect(stderr).toContain("Session not found: ses_nope"); - }); - - test("missing --editor is rejected", async () => { - const { exitCode, stderr } = await runCli( - ["session-context", "show", "abc"], - projectDir, - env - ); - - expect(exitCode).not.toBe(0); - expect(stderr).toContain("--editor"); - }); -}); From 9abed1df22d36c33572b7ef1702e9a715d192a63 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 12:35:45 -0300 Subject: [PATCH 12/15] fix(session-context): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --max-entries now validates input: non-numeric or non-positive values are rejected with InvalidArgumentError instead of a NaN limit silently disabling transcript trimming (CodeRabbit) - Deduplicate the option factory: exported once from claude-code.ts (cross-command sharing convention) and imported by the other three editor commands (CodeRabbit) - SessionListEntry gains optional `title` so the shared list contract covers the field opencode actually emits (CodeRabbit) - Command tests converted from mock.module() to import * + spyOn per ARCH-005 — removes the process-global module-mock leakage the subprocess tests were working around (CodeRabbit) - Add sessionId-not-found error-path tests for the cursor and copilot helpers (CodeRabbit) Declined with reasoning on the PR: dropping the sync existsSync pre-check and replacing readdirSync/statSync enumeration — ARCH-006 prefers Bun APIs only where alternatives exist; Bun.file().exists() is async (helpers are sync) and Bun has no readdir/stat for directory enumeration. Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- pr446-comments.json | 86 +++++++++++++++++++ src/commands/session-context/claude-code.ts | 18 +++- src/commands/session-context/copilot.ts | 8 +- src/commands/session-context/cursor.ts | 8 +- src/commands/session-context/opencode.ts | 8 +- src/helpers/session-context.ts | 4 +- .../session-context/claude-code.test.ts | 75 +++++----------- .../commands/session-context/copilot.test.ts | 70 +++++---------- tests/commands/session-context/cursor.test.ts | 70 +++++---------- .../commands/session-context/opencode.test.ts | 62 +++++-------- tests/helpers/session-context-copilot.test.ts | 19 ++++ tests/helpers/session-context-cursor.test.ts | 18 ++++ 12 files changed, 234 insertions(+), 212 deletions(-) create mode 100644 pr446-comments.json diff --git a/pr446-comments.json b/pr446-comments.json new file mode 100644 index 00000000..673c01b1 --- /dev/null +++ b/pr446-comments.json @@ -0,0 +1,86 @@ +[ + { + "author": "coderabbitai[bot]", + "body": "_📐 Maintainability \u0026 Code Quality_ | _🟠 Major_ | _⚡ Quick win_\n\n**Regenerate the bundled docs artifact.**\n\nCI already reports `docs/public/llms-full.txt` as out of date. Please rerun `bun run docs/scripts/generate-llms-full.ts` and commit the regenerated output so this checked-in bundle stays in sync with the source docs. \n\n\nAlso applies to: 4369-4373, 4383-4387, 4432-4432\n\n\u003cdetails\u003e\n\u003csummary\u003e🤖 Prompt for AI Agents\u003c/summary\u003e\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@docs/public/llms-full.txt` around lines 4356 - 4359, The bundled docs\nartifact is out of date; regenerate the checked-in llms-full bundle from the\nsource docs. Re-run bun run docs/scripts/generate-llms-full.ts and commit the\nupdated output in docs/public/llms-full.txt so the generated content stays in\nsync. Use th", + "line": null, + "outdated": false, + "path": "docs/public/llms-full.txt" + }, + { + "author": "coderabbitai[bot]", + "body": "_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_\n\n**Invalid `--max-entries` input silently disables trimming instead of erroring.**\n\n`Math.trunc(Number(val))` returns `NaN` for non-numeric input (e.g. `--max-entries abc`). Downstream, `readClaudeCodeSession` does `options?.maxEntries ?? 200` — nullish coalescing does not catch `NaN`, so `limit` becomes `NaN`, and `relevant.length \u003e NaN` is always `false`, meaning the transcript is never trimmed. The CLI silently returns an unbounded transcript instead of failing fast on bad input.\n\n\u003cdetails\u003e\n\u003csummary\u003e🛠️ Proposed fix\u003c/summary\u003e\n\n```diff\n const makeMaxEntriesOption = () =\u003e\n new Option(\n \"--max-entries \u003cn\u003e\",\n \"maximum entries to return (default: 200)\"\n- ).argParser((val) =\u003e Math.trunc(Number(val)));\n+ ).argParser((val) =\u003e {\n+ const n = Math.trunc(Number(val));\n+ if (!Number.isFinite(n)) {\n+ throw new InvalidArgum", + "line": 19, + "outdated": false, + "path": "src/commands/session-context/claude-code.ts" + }, + { + "author": "coderabbitai[bot]", + "body": "_📐 Maintainability \u0026 Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n\u003cdetails\u003e\n\u003csummary\u003e🧩 Analysis chain\u003c/summary\u003e\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Compare makeMaxEntriesOption definitions across editor command files\nrg -n -A5 'makeMaxEntriesOption' src/commands/session-context/*.ts\n```\n\nRepository: archgate/cli\n\nLength of output: 5863\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nrg -n -A5 'makeMaxEntriesOption|max-entries \u003cn\u003e|cli-options' src/commands src/helpers\n```\n\nRepository: archgate/cli\n\nLength of output: 6590\n\n---\n\n\n\n\u003c/details\u003e\n\n**Extract `makeMaxEntriesOption()` into a shared helper**\n\n`makeMaxEntriesOption()` is duplicated across `src/commands/session-context/claude-code.ts`, `cursor.ts`, `copilot.ts`, and `opencode.ts`. Move it to a shared helper (for example `src/helpers/cli-options.ts`) and reuse it from each command.\n\n\u003cdetails\u003e\n\u003csummary\u003e🤖 Prompt for AI Agents\u003c/sum", + "line": 19, + "outdated": false, + "path": "src/commands/session-context/copilot.ts" + }, + { + "author": "coderabbitai[bot]", + "body": "_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_\n\n\u003cdetails\u003e\n\u003csummary\u003e🧩 Analysis chain\u003c/summary\u003e\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\ngit ls-files 'src/commands/session-context/cursor.ts' 'src/**/session-context*' 'tests/**/session-context*' | sed -n '1,200p'\n```\n\nRepository: archgate/cli\n\nLength of output: 869\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\n# Show the target file with line numbers, then find where maxEntries is consumed.\ncat -n src/commands/session-context/cursor.ts | sed -n '1,220p'\nprintf '\\n--- SEARCH ---\\n'\nrg -n \"maxEntries|readCursorSession|cursor session|session-context\" src tests\n```\n\nRepository: archgate/cli\n\nLength of output: 17326\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\n# Inspect the relevant helper/consumer in smaller slices if present.\nfor f in $(rg -l \"maxEntries|readCursorSession\" sr", + "line": 19, + "outdated": false, + "path": "src/commands/session-context/cursor.ts" + }, + { + "author": "coderabbitai[bot]", + "body": "_📐 Maintainability \u0026 Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**Duplicated option factory across editor commands.**\n\nPer the PR stack outline, this identical `makeMaxEntriesOption()` factory is independently added to `claude-code.ts`, `copilot.ts`, and `cursor.ts` as well. Consider extracting it to a shared module (e.g. `src/helpers/cli-options.ts`) to avoid four copies drifting apart over time.\n\n\u003cdetails\u003e\n\u003csummary\u003e🤖 Prompt for AI Agents\u003c/summary\u003e\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/commands/session-context/opencode.ts` around lines 15 - 19, The\nmakeMaxEntriesOption() factory is duplicated across multiple editor command\nfiles, so extract the shared option निर्माण into a common helper module and\nreuse it from opencode.ts, claude-code.ts, copilot.ts, and cursor.ts. M", + "line": 19, + "outdated": false, + "path": "src/commands/session-context/opencode.ts" + }, + { + "author": "coderabbitai[bot]", + "body": "_📐 Maintainability \u0026 Code Quality_ | _🟡 Minor_ | _⚡ Quick win_\n\n\u003cdetails\u003e\n\u003csummary\u003e🧩 Analysis chain\u003c/summary\u003e\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Verify opencode helper filesystem checks.\nrg -n -C2 '\\bexistsSync\\b' src/helpers/session-context-opencode.ts\n```\n\nRepository: archgate/cli\n\nLength of output: 719\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\n# Inspect the helper around both filesystem checks.\ncat -n src/helpers/session-context-opencode.ts | sed -n '1,240p'\n```\n\nRepository: archgate/cli\n\nLength of output: 9482\n\n---\n\n🌐 Web query:\n\n`Bun.file(path).exists() synchronous or async API docs`\n\n💡 Result:\n\nThe Bun.file(path).exists method in Bun is an asynchronous API [1][2]. It returns a Promise that resolves to a boolean value indicating whether the file exists [1][3]. According to the official Bun documentation, the method performs a system call to check for", + "line": 100, + "outdated": false, + "path": "src/helpers/session-context-opencode.ts" + }, + { + "author": "coderabbitai[bot]", + "body": "_🗄️ Data Integrity \u0026 Integration_ | _🟠 Major_ | _⚡ Quick win_\n\n**Include the fields emitted by all list APIs in the shared contract.**\n\n`SessionListEntry` omits `title`, but `listOpencodeSessions()` returns it in `src/helpers/session-context-opencode.ts` Lines 112-115. Typed callers of `SessionListResult` cannot rely on the documented/runtime opencode payload.\n\n\u003cdetails\u003e\n\u003csummary\u003eProposed fix\u003c/summary\u003e\n\n```diff\n export interface SessionListEntry {\n id: string;\n+ title?: string;\n updatedAt: string;\n }\n```\n\u003c/details\u003e\n\n\u003c!-- suggestion_start --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e📝 Committable suggestion\u003c/summary\u003e\n\n\u003e ‼️ **IMPORTANT**\n\u003e Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test \u0026 benchmark the code to ensure it meets the requirements.\n\n```suggestion\n/** One entry in", + "line": 75, + "outdated": false, + "path": "src/helpers/session-context.ts" + }, + { + "author": "coderabbitai[bot]", + "body": "_📐 Maintainability \u0026 Code Quality_ | _🟠 Major_ | _⚡ Quick win_\n\n\u003cdetails\u003e\n\u003csummary\u003e🧩 Analysis chain\u003c/summary\u003e\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Verify new Node fs enumeration sites in session-context helpers.\nrg -n -C2 '\\b(readdirSync|statSync|existsSync)\\b' src/helpers/session-context*.ts\n```\n\nRepository: archgate/cli\n\nLength of output: 3912\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\n# Inspect the relevant module and related helpers.\nsed -n '1,220p' src/helpers/session-context.ts\nprintf '\\n--- session-context-copilot.ts ---\\n'\nsed -n '1,220p' src/helpers/session-context-copilot.ts\nprintf '\\n--- session-context-opencode.ts ---\\n'\nsed -n '1,220p' src/helpers/session-context-opencode.ts\n\nprintf '\\n--- search for Bun.Glob usage in helpers ---\\n'\nrg -n 'new Bun\\.Glob|glob\\.scan\\(|Bun\\.file\\(|Bun\\.write\\(' src/helpers src -g '*.ts'\n```\n\nRepository: archgate/cl", + "line": 149, + "outdated": false, + "path": "src/helpers/session-context.ts" + }, + { + "author": "coderabbitai[bot]", + "body": "_📐 Maintainability \u0026 Code Quality_ | _🔵 Trivial_ | _⚖️ Poor tradeoff_\n\n**`mock.module()` used on a first-party module also imported directly elsewhere.**\n\n`../../../src/helpers/session-context` is mocked here, but `tests/helpers/session-context.test.ts` imports `listClaudeCodeSessions`/`readClaudeCodeSession` from the same module unmocked. Coding guidelines require `spyOn` instead of `mock.module()` for first-party modules that other test files import, since Bun's `mock.module` state is process-global.\n\nThe accompanying comment at lines 200-204 shows this is a known, partially-mitigated tradeoff (subprocess tests were added specifically to exercise real behavior for the new subcommands), and this diff only extends the pre-existing mock rather than introducing the pattern. Worth confirming this is an accepted, deliberate exception rather than latent test-isolation risk, and that it's trac", + "line": 41, + "outdated": false, + "path": "tests/commands/session-context/claude-code.test.ts" + }, + { + "author": "coderabbitai[bot]", + "body": "_📐 Maintainability \u0026 Code Quality_ | _🔵 Trivial_ | _⚖️ Poor tradeoff_\n\n**Extends existing `mock.module()` usage on a first-party module.**\n\nThe added `listCopilotSessions` mock is wired via `mock.module()` on `session-context-copilot`, which is also directly imported (unmocked) by `tests/helpers/session-context-copilot.test.ts`. Coding guidelines state: \"Do not use `mock.module()` on a first-party module that other test files import; use `spyOn` instead.\" This is an extension of a pre-existing pattern rather than a new introduction, but worth flagging since it grows the surface of the anti-pattern.\n\n\u003cdetails\u003e\n\u003csummary\u003e🤖 Prompt for AI Agents\u003c/summary\u003e\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@tests/commands/session-context/copilot.test.ts` around lines 28 - 36, Replace\nthe new `m", + "line": 36, + "outdated": false, + "path": "tests/commands/session-context/copilot.test.ts" + }, + { + "author": "coderabbitai[bot]", + "body": "_🩺 Stability \u0026 Availability_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**`mock.module()` on `session-context` conflicts with repo guideline against mocking shared first-party modules.**\n\n`../../../src/helpers/session-context` is imported directly (not mocked) by other test files in this PR stack (e.g. `tests/helpers/session-context-cursor.test.ts`). Bun's `mock.module()` replaces the module in the global registry for the whole test run, which is a documented source of cross-file leakage in Bun (oven-sh/bun#12823, `#6024`). As per coding guidelines: \"Do not use `mock.module()` on a first-party module that other test files import; use `spyOn` instead.\"\n\n\u003cdetails\u003e\n\u003csummary\u003e♻️ Suggested direction\u003c/summary\u003e\n\n```diff\n-const mockReadCursorSession = mock(...);\n-const mockListCursorSessions = mock(...);\n-mock.module(\"../../../src/helpers/session-context\", () =\u003e ({\n- readCursorSession: mockReadCursorSession", + "line": 36, + "outdated": false, + "path": "tests/commands/session-context/cursor.test.ts" + }, + { + "author": "coderabbitai[bot]", + "body": "_📐 Maintainability \u0026 Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**Coverage gap: no test for `sessionId` not found.**\n\nThe removed \"skip beyond available sessions\" test previously exercised the error path (`ok: false`, listing `available` sessions). The replacement tests only cover the happy path (default vs. valid `sessionId`) — there's no equivalent assertion that an unknown `sessionId` returns the `Session not found: ...` error with the `available` list, per `readCursorSession`'s error branch. Consider adding a case to lock down that behavior.\n\n\u003cdetails\u003e\n\u003csummary\u003e✅ Suggested addition\u003c/summary\u003e\n\n```ts\ntest(\"unknown sessionId returns not-found error with available sessions\", async () =\u003e {\n makeSession(\"session-current\", [\n JSON.stringify({ role: \"user\", message: { role: \"user\", content: \"hi\" } }),\n ]);\n const result = await readCursorSession(projectRoot, { sessionId: \"does-not-ex", + "line": 273, + "outdated": false, + "path": "tests/helpers/session-context-cursor.test.ts" + } +] diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts index b9f21778..760864ab 100644 --- a/src/commands/session-context/claude-code.ts +++ b/src/commands/session-context/claude-code.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; +import { InvalidArgumentError, Option } from "@commander-js/extra-typings"; import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; @@ -12,11 +12,23 @@ import { readClaudeCodeSession, } from "../../helpers/session-context"; -const makeMaxEntriesOption = () => +/** + * Shared `--max-entries` option factory for the session-context commands. + * Exported from this command file (not a helper) per the cross-command I/O + * sharing convention. Rejects non-numeric or non-positive input — a NaN + * limit would silently disable transcript trimming downstream. + */ +export const makeMaxEntriesOption = () => new Option( "--max-entries ", "maximum entries to return (default: 200)" - ).argParser((val) => Math.trunc(Number(val))); + ).argParser((val) => { + const n = Math.trunc(Number(val)); + if (!Number.isFinite(n) || n < 1) { + throw new InvalidArgumentError("must be a positive integer"); + } + return n; + }); export function registerClaudeCodeSessionContextCommand(parent: Command) { const cmd = parent diff --git a/src/commands/session-context/copilot.ts b/src/commands/session-context/copilot.ts index f00a8981..942ca56f 100644 --- a/src/commands/session-context/copilot.ts +++ b/src/commands/session-context/copilot.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; @@ -11,12 +10,7 @@ import { listCopilotSessions, readCopilotSession, } from "../../helpers/session-context-copilot"; - -const makeMaxEntriesOption = () => - new Option( - "--max-entries ", - "maximum entries to return (default: 200)" - ).argParser((val) => Math.trunc(Number(val))); +import { makeMaxEntriesOption } from "./claude-code"; export function registerCopilotSessionContextCommand(parent: Command) { const cmd = parent diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts index 3175fa69..9bc4d6ef 100644 --- a/src/commands/session-context/cursor.ts +++ b/src/commands/session-context/cursor.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; @@ -11,12 +10,7 @@ import { listCursorSessions, readCursorSession, } from "../../helpers/session-context"; - -const makeMaxEntriesOption = () => - new Option( - "--max-entries ", - "maximum entries to return (default: 200)" - ).argParser((val) => Math.trunc(Number(val))); +import { makeMaxEntriesOption } from "./claude-code"; export function registerCursorSessionContextCommand(parent: Command) { const cmd = parent diff --git a/src/commands/session-context/opencode.ts b/src/commands/session-context/opencode.ts index f3108fb7..b7550a17 100644 --- a/src/commands/session-context/opencode.ts +++ b/src/commands/session-context/opencode.ts @@ -1,7 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import type { Command } from "@commander-js/extra-typings"; -import { Option } from "@commander-js/extra-typings"; import { exitWith, handleCommandError } from "../../helpers/exit"; import { logError } from "../../helpers/log"; @@ -11,12 +10,7 @@ import { listOpencodeSessions, readOpencodeSession, } from "../../helpers/session-context-opencode"; - -const makeMaxEntriesOption = () => - new Option( - "--max-entries ", - "maximum entries to return (default: 200)" - ).argParser((val) => Math.trunc(Number(val))); +import { makeMaxEntriesOption } from "./claude-code"; export function registerOpencodeSessionContextCommand(parent: Command) { const cmd = parent diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index 5168432b..7fe25e0c 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -64,9 +64,11 @@ interface ClaudeSessionSummary { transcript: Array<{ type: string; role?: string; contentPreview: string }>; } -/** One entry in a `--list` result: session id + last-update timestamp. */ +/** One entry in a list result: session id + last-update timestamp. */ export interface SessionListEntry { id: string; + /** Session title — only populated by editors that store one (opencode). */ + title?: string; updatedAt: string; } diff --git a/tests/commands/session-context/claude-code.test.ts b/tests/commands/session-context/claude-code.test.ts index bb09483a..36888360 100644 --- a/tests/commands/session-context/claude-code.test.ts +++ b/tests/commands/session-context/claude-code.test.ts @@ -1,50 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; -// --------------------------------------------------------------------------- -// Module mocks — declared before the import under test. -// -// Only the session reader is mocked (unique to this command, no leak risk). -// findProjectRoot is controlled via process.chdir + ARCHGATE_PROJECT_CEILING -// to avoid Bun mock.module global-leak issues. -// --------------------------------------------------------------------------- - -const mockReadClaudeCodeSession = mock( - () => - Promise.resolve({ ok: true, data: {} }) as Promise< - { ok: true; data: unknown } | { ok: false; error: string } - > -); -const mockListClaudeCodeSessions = mock( - () => - Promise.resolve({ ok: true, data: { sessions: [] } }) as Promise< - { ok: true; data: { sessions: unknown[] } } | { ok: false; error: string } - > -); -mock.module("../../../src/helpers/session-context", () => ({ - readClaudeCodeSession: mockReadClaudeCodeSession, - listClaudeCodeSessions: mockListClaudeCodeSessions, -})); - -// --------------------------------------------------------------------------- -// Import under test — loaded AFTER mocks are registered. -// --------------------------------------------------------------------------- - import { registerClaudeCodeSessionContextCommand } from "../../../src/commands/session-context/claude-code"; +import * as sessionContextHelpers from "../../../src/helpers/session-context"; import { runCli } from "../../integration/cli-harness"; import { safeRmSync } from "../../test-utils"; @@ -89,6 +53,17 @@ describe("claude-code action handler", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; + let readSpy: ReturnType; + + /** Minimal complete summary for the default happy-path spy. */ + function emptySummary() { + return { + sessionFile: "s.jsonl", + totalEntries: 0, + relevantEntries: 0, + transcript: [], + }; + } beforeEach(() => { // realpathSync normalizes macOS /var → /private/var symlink so the @@ -100,8 +75,8 @@ describe("claude-code action handler", () => { Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; process.chdir(tempDir); - mockReadClaudeCodeSession.mockReset(); - mockReadClaudeCodeSession.mockResolvedValue({ ok: true, data: {} }); + readSpy = spyOn(sessionContextHelpers, "readClaudeCodeSession"); + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -113,6 +88,7 @@ describe("claude-code action handler", () => { process.chdir(originalCwd); delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); + readSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -125,7 +101,7 @@ describe("claude-code action handler", () => { } test("prints JSON on successful result", async () => { - mockReadClaudeCodeSession.mockResolvedValue({ + readSpy.mockResolvedValue({ ok: true, data: { entries: [{ role: "user", content: "hello" }], total: 1 }, }); @@ -141,10 +117,7 @@ describe("claude-code action handler", () => { }); test("exits 1 when reader returns error result", async () => { - mockReadClaudeCodeSession.mockResolvedValue({ - ok: false, - error: "No session found", - }); + readSpy.mockResolvedValue({ ok: false, error: "No session found" }); await expect( makeProgram().parseAsync(["node", "session-context", "claude-code"]) @@ -158,9 +131,7 @@ describe("claude-code action handler", () => { }); test("exits 2 when unexpected error is thrown", async () => { - mockReadClaudeCodeSession.mockRejectedValue( - new Error("Unexpected disk failure") - ); + readSpy.mockRejectedValue(new Error("Unexpected disk failure")); await expect( makeProgram().parseAsync(["node", "session-context", "claude-code"]) @@ -176,7 +147,7 @@ describe("claude-code action handler", () => { test("re-throws ExitPromptError", async () => { const exitPromptError = new Error("prompt cancelled"); exitPromptError.name = "ExitPromptError"; - mockReadClaudeCodeSession.mockRejectedValue(exitPromptError); + readSpy.mockRejectedValue(exitPromptError); await expect( makeProgram().parseAsync(["node", "session-context", "claude-code"]) @@ -186,14 +157,12 @@ describe("claude-code action handler", () => { }); test("passes findProjectRoot result to reader", async () => { - mockReadClaudeCodeSession.mockResolvedValue({ ok: true, data: {} }); + readSpy.mockResolvedValue({ ok: true, data: {} }); await makeProgram().parseAsync(["node", "session-context", "claude-code"]); // findProjectRoot found our tempDir (which has .archgate/) - expect(mockReadClaudeCodeSession).toHaveBeenCalledWith(tempDir, { - maxEntries: undefined, - }); + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); }); }); diff --git a/tests/commands/session-context/copilot.test.ts b/tests/commands/session-context/copilot.test.ts index d6a4ae4b..b40d87e4 100644 --- a/tests/commands/session-context/copilot.test.ts +++ b/tests/commands/session-context/copilot.test.ts @@ -1,46 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; -// --------------------------------------------------------------------------- -// Module mocks — declared before the import under test. -// --------------------------------------------------------------------------- - -const mockReadCopilotSession = mock( - () => - Promise.resolve({ ok: true, data: {} }) as Promise< - { ok: true; data: unknown } | { ok: false; error: string } - > -); -const mockListCopilotSessions = mock( - () => - Promise.resolve({ ok: true, data: { sessions: [] } }) as Promise< - { ok: true; data: { sessions: unknown[] } } | { ok: false; error: string } - > -); -mock.module("../../../src/helpers/session-context-copilot", () => ({ - readCopilotSession: mockReadCopilotSession, - listCopilotSessions: mockListCopilotSessions, -})); - -// --------------------------------------------------------------------------- -// Import under test — loaded AFTER mocks are registered. -// --------------------------------------------------------------------------- - import { registerCopilotSessionContextCommand } from "../../../src/commands/session-context/copilot"; +import * as copilotHelpers from "../../../src/helpers/session-context-copilot"; import { safeRmSync } from "../../test-utils"; // --------------------------------------------------------------------------- @@ -84,6 +52,18 @@ describe("copilot action handler", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; + let readSpy: ReturnType; + + /** Minimal complete summary for the default happy-path spy. */ + function emptySummary() { + return { + sessionId: "s", + sessionFile: "events.jsonl", + totalEntries: 0, + relevantEntries: 0, + transcript: [], + }; + } beforeEach(() => { // realpathSync normalizes macOS /var → /private/var symlink so the @@ -96,8 +76,8 @@ describe("copilot action handler", () => { Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; process.chdir(tempDir); - mockReadCopilotSession.mockReset(); - mockReadCopilotSession.mockResolvedValue({ ok: true, data: {} }); + readSpy = spyOn(copilotHelpers, "readCopilotSession"); + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -109,6 +89,7 @@ describe("copilot action handler", () => { process.chdir(originalCwd); delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); + readSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -121,7 +102,7 @@ describe("copilot action handler", () => { } test("prints JSON on successful result", async () => { - mockReadCopilotSession.mockResolvedValue({ + readSpy.mockResolvedValue({ ok: true, data: { entries: [{ role: "assistant", content: "hi" }], total: 1 }, }); @@ -137,10 +118,7 @@ describe("copilot action handler", () => { }); test("exits 1 when reader returns error result", async () => { - mockReadCopilotSession.mockResolvedValue({ - ok: false, - error: "No copilot session found", - }); + readSpy.mockResolvedValue({ ok: false, error: "No copilot session found" }); await expect( makeProgram().parseAsync(["node", "session-context", "copilot"]) @@ -154,7 +132,7 @@ describe("copilot action handler", () => { }); test("exits 2 when unexpected error is thrown", async () => { - mockReadCopilotSession.mockRejectedValue(new Error("Permission denied")); + readSpy.mockRejectedValue(new Error("Permission denied")); await expect( makeProgram().parseAsync(["node", "session-context", "copilot"]) @@ -170,7 +148,7 @@ describe("copilot action handler", () => { test("re-throws ExitPromptError", async () => { const exitPromptError = new Error("prompt cancelled"); exitPromptError.name = "ExitPromptError"; - mockReadCopilotSession.mockRejectedValue(exitPromptError); + readSpy.mockRejectedValue(exitPromptError); await expect( makeProgram().parseAsync(["node", "session-context", "copilot"]) @@ -180,12 +158,10 @@ describe("copilot action handler", () => { }); test("passes findProjectRoot result to reader", async () => { - mockReadCopilotSession.mockResolvedValue({ ok: true, data: {} }); + readSpy.mockResolvedValue({ ok: true, data: {} }); await makeProgram().parseAsync(["node", "session-context", "copilot"]); - expect(mockReadCopilotSession).toHaveBeenCalledWith(tempDir, { - maxEntries: undefined, - }); + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); }); }); diff --git a/tests/commands/session-context/cursor.test.ts b/tests/commands/session-context/cursor.test.ts index df8aef5d..262438c1 100644 --- a/tests/commands/session-context/cursor.test.ts +++ b/tests/commands/session-context/cursor.test.ts @@ -1,46 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; -// --------------------------------------------------------------------------- -// Module mocks — declared before the import under test. -// --------------------------------------------------------------------------- - -const mockReadCursorSession = mock( - () => - Promise.resolve({ ok: true, data: {} }) as Promise< - { ok: true; data: unknown } | { ok: false; error: string } - > -); -const mockListCursorSessions = mock( - () => - Promise.resolve({ ok: true, data: { sessions: [] } }) as Promise< - { ok: true; data: { sessions: unknown[] } } | { ok: false; error: string } - > -); -mock.module("../../../src/helpers/session-context", () => ({ - readCursorSession: mockReadCursorSession, - listCursorSessions: mockListCursorSessions, -})); - -// --------------------------------------------------------------------------- -// Import under test — loaded AFTER mocks are registered. -// --------------------------------------------------------------------------- - import { registerCursorSessionContextCommand } from "../../../src/commands/session-context/cursor"; +import * as sessionContextHelpers from "../../../src/helpers/session-context"; import { safeRmSync } from "../../test-utils"; // --------------------------------------------------------------------------- @@ -84,6 +52,18 @@ describe("cursor action handler", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; + let readSpy: ReturnType; + + /** Minimal complete summary for the default happy-path spy. */ + function emptySummary() { + return { + sessionId: "s", + sessionFile: "s.jsonl", + totalEntries: 0, + relevantEntries: 0, + transcript: [], + }; + } beforeEach(() => { // realpathSync normalizes macOS /var → /private/var symlink so the @@ -96,8 +76,8 @@ describe("cursor action handler", () => { Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; process.chdir(tempDir); - mockReadCursorSession.mockReset(); - mockReadCursorSession.mockResolvedValue({ ok: true, data: {} }); + readSpy = spyOn(sessionContextHelpers, "readCursorSession"); + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -109,6 +89,7 @@ describe("cursor action handler", () => { process.chdir(originalCwd); delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); + readSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -121,7 +102,7 @@ describe("cursor action handler", () => { } test("prints JSON on successful result", async () => { - mockReadCursorSession.mockResolvedValue({ + readSpy.mockResolvedValue({ ok: true, data: { entries: [{ role: "user", content: "test" }], total: 1 }, }); @@ -137,10 +118,7 @@ describe("cursor action handler", () => { }); test("exits 1 when reader returns error result", async () => { - mockReadCursorSession.mockResolvedValue({ - ok: false, - error: "No cursor session found", - }); + readSpy.mockResolvedValue({ ok: false, error: "No cursor session found" }); await expect( makeProgram().parseAsync(["node", "session-context", "cursor"]) @@ -154,7 +132,7 @@ describe("cursor action handler", () => { }); test("exits 2 when unexpected error is thrown", async () => { - mockReadCursorSession.mockRejectedValue(new Error("File system error")); + readSpy.mockRejectedValue(new Error("File system error")); await expect( makeProgram().parseAsync(["node", "session-context", "cursor"]) @@ -170,7 +148,7 @@ describe("cursor action handler", () => { test("re-throws ExitPromptError", async () => { const exitPromptError = new Error("prompt cancelled"); exitPromptError.name = "ExitPromptError"; - mockReadCursorSession.mockRejectedValue(exitPromptError); + readSpy.mockRejectedValue(exitPromptError); await expect( makeProgram().parseAsync(["node", "session-context", "cursor"]) @@ -180,12 +158,10 @@ describe("cursor action handler", () => { }); test("passes findProjectRoot result to reader", async () => { - mockReadCursorSession.mockResolvedValue({ ok: true, data: {} }); + readSpy.mockResolvedValue({ ok: true, data: {} }); await makeProgram().parseAsync(["node", "session-context", "cursor"]); - expect(mockReadCursorSession).toHaveBeenCalledWith(tempDir, { - maxEntries: undefined, - }); + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); }); }); diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts index f5125284..9e694a0c 100644 --- a/tests/commands/session-context/opencode.test.ts +++ b/tests/commands/session-context/opencode.test.ts @@ -1,40 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { Database } from "bun:sqlite"; -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; -// --------------------------------------------------------------------------- -// Module mocks — declared before the import under test. -// --------------------------------------------------------------------------- - -const mockReadOpencodeSession = mock( - () => - ({ ok: true, data: {} }) as - | { ok: true; data: unknown } - | { ok: false; error: string } -); -mock.module("../../../src/helpers/session-context-opencode", () => ({ - readOpencodeSession: mockReadOpencodeSession, -})); - -// --------------------------------------------------------------------------- -// Import under test — loaded AFTER mocks are registered. -// --------------------------------------------------------------------------- - import { registerOpencodeSessionContextCommand } from "../../../src/commands/session-context/opencode"; +import * as opencodeHelpers from "../../../src/helpers/session-context-opencode"; import { runCli } from "../../integration/cli-harness"; import { safeRmSync } from "../../test-utils"; @@ -82,6 +57,17 @@ describe("opencode action handler", () => { let logSpy: ReturnType; let errorSpy: ReturnType; let exitSpy: ReturnType; + let readSpy: ReturnType; + + /** Minimal complete summary for the default happy-path spy. */ + function emptySummary() { + return { + sessionId: "s", + totalEntries: 0, + relevantEntries: 0, + transcript: [], + }; + } beforeEach(() => { // realpathSync normalizes macOS /var → /private/var symlink so the @@ -94,8 +80,8 @@ describe("opencode action handler", () => { Bun.env.ARCHGATE_PROJECT_CEILING = tempDir; process.chdir(tempDir); - mockReadOpencodeSession.mockReset(); - mockReadOpencodeSession.mockReturnValue({ ok: true, data: {} }); + readSpy = spyOn(opencodeHelpers, "readOpencodeSession"); + readSpy.mockReturnValue({ ok: true, data: emptySummary() }); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); exitSpy = spyOn(process, "exit").mockImplementation(() => { @@ -107,6 +93,7 @@ describe("opencode action handler", () => { process.chdir(originalCwd); delete Bun.env.ARCHGATE_PROJECT_CEILING; safeRmSync(tempDir); + readSpy.mockRestore(); logSpy.mockRestore(); errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -119,7 +106,7 @@ describe("opencode action handler", () => { } test("prints JSON on successful result", async () => { - mockReadOpencodeSession.mockReturnValue({ + readSpy.mockReturnValue({ ok: true, data: { entries: [{ role: "assistant", content: "done" }], total: 1 }, }); @@ -135,10 +122,7 @@ describe("opencode action handler", () => { }); test("exits 1 when reader returns error result", async () => { - mockReadOpencodeSession.mockReturnValue({ - ok: false, - error: "No opencode session found", - }); + readSpy.mockReturnValue({ ok: false, error: "No opencode session found" }); await expect( makeProgram().parseAsync(["node", "session-context", "opencode"]) @@ -152,7 +136,7 @@ describe("opencode action handler", () => { }); test("exits 2 when unexpected error is thrown", async () => { - mockReadOpencodeSession.mockImplementation(() => { + readSpy.mockImplementation(() => { throw new Error("ENOENT: no such file"); }); @@ -170,7 +154,7 @@ describe("opencode action handler", () => { test("re-throws ExitPromptError", async () => { const exitPromptError = new Error("prompt cancelled"); exitPromptError.name = "ExitPromptError"; - mockReadOpencodeSession.mockImplementation(() => { + readSpy.mockImplementation(() => { throw exitPromptError; }); @@ -182,13 +166,11 @@ describe("opencode action handler", () => { }); test("passes findProjectRoot result to reader", async () => { - mockReadOpencodeSession.mockReturnValue({ ok: true, data: {} }); + readSpy.mockReturnValue({ ok: true, data: {} }); await makeProgram().parseAsync(["node", "session-context", "opencode"]); - expect(mockReadOpencodeSession).toHaveBeenCalledWith(tempDir, { - maxEntries: undefined, - }); + expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); }); }); diff --git a/tests/helpers/session-context-copilot.test.ts b/tests/helpers/session-context-copilot.test.ts index 250690e6..49fb6026 100644 --- a/tests/helpers/session-context-copilot.test.ts +++ b/tests/helpers/session-context-copilot.test.ts @@ -286,6 +286,25 @@ describe("readCopilotSession", () => { expect(earlier.data.transcript[0]?.contentPreview).toBe("earlier question"); }); + test("sessionId not found returns error with available ids", async () => { + const sessionId = `copilot-${uniqueId}-notfound-only`; + makeSession(sessionId, projectRoot, [ + JSON.stringify({ + type: "user.message", + data: { content: "only session" }, + }), + ]); + + const result = await readCopilotSession(projectRoot, { + sessionId: "copilot-nope", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("Session not found: copilot-nope"); + expect(result.available).toContain(sessionId); + } + }); + test("list returns matching sessions most recent first", async () => { const earlierId = `copilot-${uniqueId}-list-earlier`; makeSession(earlierId, projectRoot, [ diff --git a/tests/helpers/session-context-cursor.test.ts b/tests/helpers/session-context-cursor.test.ts index 65b03611..9cb661a3 100644 --- a/tests/helpers/session-context-cursor.test.ts +++ b/tests/helpers/session-context-cursor.test.ts @@ -246,6 +246,24 @@ describe("readCursorSession", () => { expect(earlier.data.transcript[0]?.contentPreview).toBe("earlier question"); }); + test("sessionId not found returns error with available ids", async () => { + makeSession("session-only", [ + JSON.stringify({ + role: "user", + message: { role: "user", content: "only session" }, + }), + ]); + + const result = await readCursorSession(projectRoot, { + sessionId: "session-nope", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("Session not found: session-nope"); + expect(result.available).toEqual(["session-only"]); + } + }); + test("list returns sessions most recent first with timestamps", async () => { makeSession("session-earlier", [ JSON.stringify({ From 91db9f897c5097a7974206e6f9770dcb9401b099 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 12:35:59 -0300 Subject: [PATCH 13/15] chore: remove accidentally committed review-triage scratch file Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- pr446-comments.json | 86 --------------------------------------------- 1 file changed, 86 deletions(-) delete mode 100644 pr446-comments.json diff --git a/pr446-comments.json b/pr446-comments.json deleted file mode 100644 index 673c01b1..00000000 --- a/pr446-comments.json +++ /dev/null @@ -1,86 +0,0 @@ -[ - { - "author": "coderabbitai[bot]", - "body": "_📐 Maintainability \u0026 Code Quality_ | _🟠 Major_ | _⚡ Quick win_\n\n**Regenerate the bundled docs artifact.**\n\nCI already reports `docs/public/llms-full.txt` as out of date. Please rerun `bun run docs/scripts/generate-llms-full.ts` and commit the regenerated output so this checked-in bundle stays in sync with the source docs. \n\n\nAlso applies to: 4369-4373, 4383-4387, 4432-4432\n\n\u003cdetails\u003e\n\u003csummary\u003e🤖 Prompt for AI Agents\u003c/summary\u003e\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@docs/public/llms-full.txt` around lines 4356 - 4359, The bundled docs\nartifact is out of date; regenerate the checked-in llms-full bundle from the\nsource docs. Re-run bun run docs/scripts/generate-llms-full.ts and commit the\nupdated output in docs/public/llms-full.txt so the generated content stays in\nsync. Use th", - "line": null, - "outdated": false, - "path": "docs/public/llms-full.txt" - }, - { - "author": "coderabbitai[bot]", - "body": "_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_\n\n**Invalid `--max-entries` input silently disables trimming instead of erroring.**\n\n`Math.trunc(Number(val))` returns `NaN` for non-numeric input (e.g. `--max-entries abc`). Downstream, `readClaudeCodeSession` does `options?.maxEntries ?? 200` — nullish coalescing does not catch `NaN`, so `limit` becomes `NaN`, and `relevant.length \u003e NaN` is always `false`, meaning the transcript is never trimmed. The CLI silently returns an unbounded transcript instead of failing fast on bad input.\n\n\u003cdetails\u003e\n\u003csummary\u003e🛠️ Proposed fix\u003c/summary\u003e\n\n```diff\n const makeMaxEntriesOption = () =\u003e\n new Option(\n \"--max-entries \u003cn\u003e\",\n \"maximum entries to return (default: 200)\"\n- ).argParser((val) =\u003e Math.trunc(Number(val)));\n+ ).argParser((val) =\u003e {\n+ const n = Math.trunc(Number(val));\n+ if (!Number.isFinite(n)) {\n+ throw new InvalidArgum", - "line": 19, - "outdated": false, - "path": "src/commands/session-context/claude-code.ts" - }, - { - "author": "coderabbitai[bot]", - "body": "_📐 Maintainability \u0026 Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n\u003cdetails\u003e\n\u003csummary\u003e🧩 Analysis chain\u003c/summary\u003e\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Compare makeMaxEntriesOption definitions across editor command files\nrg -n -A5 'makeMaxEntriesOption' src/commands/session-context/*.ts\n```\n\nRepository: archgate/cli\n\nLength of output: 5863\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nrg -n -A5 'makeMaxEntriesOption|max-entries \u003cn\u003e|cli-options' src/commands src/helpers\n```\n\nRepository: archgate/cli\n\nLength of output: 6590\n\n---\n\n\n\n\u003c/details\u003e\n\n**Extract `makeMaxEntriesOption()` into a shared helper**\n\n`makeMaxEntriesOption()` is duplicated across `src/commands/session-context/claude-code.ts`, `cursor.ts`, `copilot.ts`, and `opencode.ts`. Move it to a shared helper (for example `src/helpers/cli-options.ts`) and reuse it from each command.\n\n\u003cdetails\u003e\n\u003csummary\u003e🤖 Prompt for AI Agents\u003c/sum", - "line": 19, - "outdated": false, - "path": "src/commands/session-context/copilot.ts" - }, - { - "author": "coderabbitai[bot]", - "body": "_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_\n\n\u003cdetails\u003e\n\u003csummary\u003e🧩 Analysis chain\u003c/summary\u003e\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\ngit ls-files 'src/commands/session-context/cursor.ts' 'src/**/session-context*' 'tests/**/session-context*' | sed -n '1,200p'\n```\n\nRepository: archgate/cli\n\nLength of output: 869\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\n# Show the target file with line numbers, then find where maxEntries is consumed.\ncat -n src/commands/session-context/cursor.ts | sed -n '1,220p'\nprintf '\\n--- SEARCH ---\\n'\nrg -n \"maxEntries|readCursorSession|cursor session|session-context\" src tests\n```\n\nRepository: archgate/cli\n\nLength of output: 17326\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\n# Inspect the relevant helper/consumer in smaller slices if present.\nfor f in $(rg -l \"maxEntries|readCursorSession\" sr", - "line": 19, - "outdated": false, - "path": "src/commands/session-context/cursor.ts" - }, - { - "author": "coderabbitai[bot]", - "body": "_📐 Maintainability \u0026 Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**Duplicated option factory across editor commands.**\n\nPer the PR stack outline, this identical `makeMaxEntriesOption()` factory is independently added to `claude-code.ts`, `copilot.ts`, and `cursor.ts` as well. Consider extracting it to a shared module (e.g. `src/helpers/cli-options.ts`) to avoid four copies drifting apart over time.\n\n\u003cdetails\u003e\n\u003csummary\u003e🤖 Prompt for AI Agents\u003c/summary\u003e\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@src/commands/session-context/opencode.ts` around lines 15 - 19, The\nmakeMaxEntriesOption() factory is duplicated across multiple editor command\nfiles, so extract the shared option निर्माण into a common helper module and\nreuse it from opencode.ts, claude-code.ts, copilot.ts, and cursor.ts. M", - "line": 19, - "outdated": false, - "path": "src/commands/session-context/opencode.ts" - }, - { - "author": "coderabbitai[bot]", - "body": "_📐 Maintainability \u0026 Code Quality_ | _🟡 Minor_ | _⚡ Quick win_\n\n\u003cdetails\u003e\n\u003csummary\u003e🧩 Analysis chain\u003c/summary\u003e\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Verify opencode helper filesystem checks.\nrg -n -C2 '\\bexistsSync\\b' src/helpers/session-context-opencode.ts\n```\n\nRepository: archgate/cli\n\nLength of output: 719\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\n# Inspect the helper around both filesystem checks.\ncat -n src/helpers/session-context-opencode.ts | sed -n '1,240p'\n```\n\nRepository: archgate/cli\n\nLength of output: 9482\n\n---\n\n🌐 Web query:\n\n`Bun.file(path).exists() synchronous or async API docs`\n\n💡 Result:\n\nThe Bun.file(path).exists method in Bun is an asynchronous API [1][2]. It returns a Promise that resolves to a boolean value indicating whether the file exists [1][3]. According to the official Bun documentation, the method performs a system call to check for", - "line": 100, - "outdated": false, - "path": "src/helpers/session-context-opencode.ts" - }, - { - "author": "coderabbitai[bot]", - "body": "_🗄️ Data Integrity \u0026 Integration_ | _🟠 Major_ | _⚡ Quick win_\n\n**Include the fields emitted by all list APIs in the shared contract.**\n\n`SessionListEntry` omits `title`, but `listOpencodeSessions()` returns it in `src/helpers/session-context-opencode.ts` Lines 112-115. Typed callers of `SessionListResult` cannot rely on the documented/runtime opencode payload.\n\n\u003cdetails\u003e\n\u003csummary\u003eProposed fix\u003c/summary\u003e\n\n```diff\n export interface SessionListEntry {\n id: string;\n+ title?: string;\n updatedAt: string;\n }\n```\n\u003c/details\u003e\n\n\u003c!-- suggestion_start --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e📝 Committable suggestion\u003c/summary\u003e\n\n\u003e ‼️ **IMPORTANT**\n\u003e Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test \u0026 benchmark the code to ensure it meets the requirements.\n\n```suggestion\n/** One entry in", - "line": 75, - "outdated": false, - "path": "src/helpers/session-context.ts" - }, - { - "author": "coderabbitai[bot]", - "body": "_📐 Maintainability \u0026 Code Quality_ | _🟠 Major_ | _⚡ Quick win_\n\n\u003cdetails\u003e\n\u003csummary\u003e🧩 Analysis chain\u003c/summary\u003e\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\n# Verify new Node fs enumeration sites in session-context helpers.\nrg -n -C2 '\\b(readdirSync|statSync|existsSync)\\b' src/helpers/session-context*.ts\n```\n\nRepository: archgate/cli\n\nLength of output: 3912\n\n---\n\n🏁 Script executed:\n\n```shell\n#!/bin/bash\nset -euo pipefail\n\n# Inspect the relevant module and related helpers.\nsed -n '1,220p' src/helpers/session-context.ts\nprintf '\\n--- session-context-copilot.ts ---\\n'\nsed -n '1,220p' src/helpers/session-context-copilot.ts\nprintf '\\n--- session-context-opencode.ts ---\\n'\nsed -n '1,220p' src/helpers/session-context-opencode.ts\n\nprintf '\\n--- search for Bun.Glob usage in helpers ---\\n'\nrg -n 'new Bun\\.Glob|glob\\.scan\\(|Bun\\.file\\(|Bun\\.write\\(' src/helpers src -g '*.ts'\n```\n\nRepository: archgate/cl", - "line": 149, - "outdated": false, - "path": "src/helpers/session-context.ts" - }, - { - "author": "coderabbitai[bot]", - "body": "_📐 Maintainability \u0026 Code Quality_ | _🔵 Trivial_ | _⚖️ Poor tradeoff_\n\n**`mock.module()` used on a first-party module also imported directly elsewhere.**\n\n`../../../src/helpers/session-context` is mocked here, but `tests/helpers/session-context.test.ts` imports `listClaudeCodeSessions`/`readClaudeCodeSession` from the same module unmocked. Coding guidelines require `spyOn` instead of `mock.module()` for first-party modules that other test files import, since Bun's `mock.module` state is process-global.\n\nThe accompanying comment at lines 200-204 shows this is a known, partially-mitigated tradeoff (subprocess tests were added specifically to exercise real behavior for the new subcommands), and this diff only extends the pre-existing mock rather than introducing the pattern. Worth confirming this is an accepted, deliberate exception rather than latent test-isolation risk, and that it's trac", - "line": 41, - "outdated": false, - "path": "tests/commands/session-context/claude-code.test.ts" - }, - { - "author": "coderabbitai[bot]", - "body": "_📐 Maintainability \u0026 Code Quality_ | _🔵 Trivial_ | _⚖️ Poor tradeoff_\n\n**Extends existing `mock.module()` usage on a first-party module.**\n\nThe added `listCopilotSessions` mock is wired via `mock.module()` on `session-context-copilot`, which is also directly imported (unmocked) by `tests/helpers/session-context-copilot.test.ts`. Coding guidelines state: \"Do not use `mock.module()` on a first-party module that other test files import; use `spyOn` instead.\" This is an extension of a pre-existing pattern rather than a new introduction, but worth flagging since it grows the surface of the anti-pattern.\n\n\u003cdetails\u003e\n\u003csummary\u003e🤖 Prompt for AI Agents\u003c/summary\u003e\n\n```\nVerify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@tests/commands/session-context/copilot.test.ts` around lines 28 - 36, Replace\nthe new `m", - "line": 36, - "outdated": false, - "path": "tests/commands/session-context/copilot.test.ts" - }, - { - "author": "coderabbitai[bot]", - "body": "_🩺 Stability \u0026 Availability_ | _🟠 Major_ | _🏗️ Heavy lift_\n\n**`mock.module()` on `session-context` conflicts with repo guideline against mocking shared first-party modules.**\n\n`../../../src/helpers/session-context` is imported directly (not mocked) by other test files in this PR stack (e.g. `tests/helpers/session-context-cursor.test.ts`). Bun's `mock.module()` replaces the module in the global registry for the whole test run, which is a documented source of cross-file leakage in Bun (oven-sh/bun#12823, `#6024`). As per coding guidelines: \"Do not use `mock.module()` on a first-party module that other test files import; use `spyOn` instead.\"\n\n\u003cdetails\u003e\n\u003csummary\u003e♻️ Suggested direction\u003c/summary\u003e\n\n```diff\n-const mockReadCursorSession = mock(...);\n-const mockListCursorSessions = mock(...);\n-mock.module(\"../../../src/helpers/session-context\", () =\u003e ({\n- readCursorSession: mockReadCursorSession", - "line": 36, - "outdated": false, - "path": "tests/commands/session-context/cursor.test.ts" - }, - { - "author": "coderabbitai[bot]", - "body": "_📐 Maintainability \u0026 Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_\n\n**Coverage gap: no test for `sessionId` not found.**\n\nThe removed \"skip beyond available sessions\" test previously exercised the error path (`ok: false`, listing `available` sessions). The replacement tests only cover the happy path (default vs. valid `sessionId`) — there's no equivalent assertion that an unknown `sessionId` returns the `Session not found: ...` error with the `available` list, per `readCursorSession`'s error branch. Consider adding a case to lock down that behavior.\n\n\u003cdetails\u003e\n\u003csummary\u003e✅ Suggested addition\u003c/summary\u003e\n\n```ts\ntest(\"unknown sessionId returns not-found error with available sessions\", async () =\u003e {\n makeSession(\"session-current\", [\n JSON.stringify({ role: \"user\", message: { role: \"user\", content: \"hi\" } }),\n ]);\n const result = await readCursorSession(projectRoot, { sessionId: \"does-not-ex", - "line": 273, - "outdated": false, - "path": "tests/helpers/session-context-cursor.test.ts" - } -] From 19c80cabb5a1aaf011bfdfe5e11740af283f5c0e Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 12:51:28 -0300 Subject: [PATCH 14/15] test(session-context): cover list/show child actions in-process The subprocess-based list/show tests exercise the real wiring but the child process is not instrumented, so the eight new child actions were invisible to coverage (CI dropped to 88.5%). Add in-process tests for every editor's list/show actions using the spyOn pattern (happy path + error path; opencode also covers --root pass-through). Local coverage back above threshold. Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- .../session-context/claude-code.test.ts | 84 ++++++++++++++ .../commands/session-context/copilot.test.ts | 84 ++++++++++++++ tests/commands/session-context/cursor.test.ts | 84 ++++++++++++++ .../commands/session-context/opencode.test.ts | 104 ++++++++++++++++++ 4 files changed, 356 insertions(+) diff --git a/tests/commands/session-context/claude-code.test.ts b/tests/commands/session-context/claude-code.test.ts index 36888360..84ae7a59 100644 --- a/tests/commands/session-context/claude-code.test.ts +++ b/tests/commands/session-context/claude-code.test.ts @@ -164,6 +164,90 @@ describe("claude-code action handler", () => { // findProjectRoot found our tempDir (which has .archgate/) expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); }); + + test("list subcommand prints sessions", async () => { + const listSpy = spyOn(sessionContextHelpers, "listClaudeCodeSessions"); + try { + listSpy.mockResolvedValue({ + ok: true, + data: { + sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }], + }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "claude-code", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); + } finally { + listSpy.mockRestore(); + } + }); + + test("list subcommand exits 1 on error result", async () => { + const listSpy = spyOn(sessionContextHelpers, "listClaudeCodeSessions"); + try { + listSpy.mockResolvedValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "claude-code", + "list", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); + } finally { + listSpy.mockRestore(); + } + }); + + test("show subcommand reads the given session id", async () => { + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "claude-code", + "show", + "abc123", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { + maxEntries: undefined, + sessionId: "abc123", + }); + }); + + test("show subcommand exits 1 on error result", async () => { + readSpy.mockResolvedValue({ ok: false, error: "Session not found: abc123" }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "claude-code", + "show", + "abc123", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); }); describe("claude-code list/show (CLI subprocess)", () => { diff --git a/tests/commands/session-context/copilot.test.ts b/tests/commands/session-context/copilot.test.ts index b40d87e4..986c16ac 100644 --- a/tests/commands/session-context/copilot.test.ts +++ b/tests/commands/session-context/copilot.test.ts @@ -164,4 +164,88 @@ describe("copilot action handler", () => { expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); }); + + test("list subcommand prints sessions", async () => { + const listSpy = spyOn(copilotHelpers, "listCopilotSessions"); + try { + listSpy.mockResolvedValue({ + ok: true, + data: { + sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }], + }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "copilot", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); + } finally { + listSpy.mockRestore(); + } + }); + + test("list subcommand exits 1 on error result", async () => { + const listSpy = spyOn(copilotHelpers, "listCopilotSessions"); + try { + listSpy.mockResolvedValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "copilot", + "list", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); + } finally { + listSpy.mockRestore(); + } + }); + + test("show subcommand reads the given session id", async () => { + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "copilot", + "show", + "abc123", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { + maxEntries: undefined, + sessionId: "abc123", + }); + }); + + test("show subcommand exits 1 on error result", async () => { + readSpy.mockResolvedValue({ ok: false, error: "Session not found: abc123" }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "copilot", + "show", + "abc123", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); }); diff --git a/tests/commands/session-context/cursor.test.ts b/tests/commands/session-context/cursor.test.ts index 262438c1..94defcbe 100644 --- a/tests/commands/session-context/cursor.test.ts +++ b/tests/commands/session-context/cursor.test.ts @@ -164,4 +164,88 @@ describe("cursor action handler", () => { expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); }); + + test("list subcommand prints sessions", async () => { + const listSpy = spyOn(sessionContextHelpers, "listCursorSessions"); + try { + listSpy.mockResolvedValue({ + ok: true, + data: { + sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }], + }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "cursor", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); + } finally { + listSpy.mockRestore(); + } + }); + + test("list subcommand exits 1 on error result", async () => { + const listSpy = spyOn(sessionContextHelpers, "listCursorSessions"); + try { + listSpy.mockResolvedValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "cursor", + "list", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); + } finally { + listSpy.mockRestore(); + } + }); + + test("show subcommand reads the given session id", async () => { + readSpy.mockResolvedValue({ ok: true, data: emptySummary() }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "cursor", + "show", + "abc123", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { + maxEntries: undefined, + sessionId: "abc123", + }); + }); + + test("show subcommand exits 1 on error result", async () => { + readSpy.mockResolvedValue({ ok: false, error: "Session not found: abc123" }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "cursor", + "show", + "abc123", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); }); diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts index 9e694a0c..34f33058 100644 --- a/tests/commands/session-context/opencode.test.ts +++ b/tests/commands/session-context/opencode.test.ts @@ -172,6 +172,110 @@ describe("opencode action handler", () => { expect(readSpy).toHaveBeenCalledWith(tempDir, { maxEntries: undefined }); }); + + test("list subcommand prints sessions", async () => { + const listSpy = spyOn(opencodeHelpers, "listOpencodeSessions"); + try { + listSpy.mockReturnValue({ + ok: true, + data: { + sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }], + }, + }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "list", + ]); + + expect(listSpy).toHaveBeenCalledWith(tempDir); + const output = logSpy.mock.calls + .map((c: unknown[]) => String(c[0])) + .join(""); + expect(JSON.parse(output).sessions[0].id).toBe("abc"); + } finally { + listSpy.mockRestore(); + } + }); + + test("list subcommand exits 1 on error result", async () => { + const listSpy = spyOn(opencodeHelpers, "listOpencodeSessions"); + try { + listSpy.mockReturnValue({ ok: false, error: "store missing" }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "list", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + const errorOutput = errorSpy.mock.calls + .map((c: unknown[]) => c.join(" ")) + .join(" "); + expect(errorOutput).toContain("store missing"); + } finally { + listSpy.mockRestore(); + } + }); + + test("show subcommand reads the given session id", async () => { + readSpy.mockReturnValue({ ok: true, data: emptySummary() }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "show", + "abc123", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { + maxEntries: undefined, + sessionId: "abc123", + root: undefined, + }); + }); + + test("show subcommand exits 1 on error result", async () => { + readSpy.mockReturnValue({ ok: false, error: "Session not found: abc123" }); + + await expect( + makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "show", + "abc123", + ]) + ).rejects.toThrow("process.exit"); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + test("show subcommand passes --root through", async () => { + readSpy.mockReturnValue({ ok: true, data: emptySummary() }); + + await makeProgram().parseAsync([ + "node", + "session-context", + "opencode", + "show", + "abc123", + "--root", + ]); + + expect(readSpy).toHaveBeenCalledWith(tempDir, { + maxEntries: undefined, + sessionId: "abc123", + root: true, + }); + }); }); describe("opencode list/show (CLI subprocess)", () => { From 19d7e0e618186d46772f1550fce370a04b62d22a Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 2 Jul 2026 12:51:47 -0300 Subject: [PATCH 15/15] style: format test files Claude-Session: https://claude.ai/code/session_01SGjSjrywTnZDcUqgHWGrTj Signed-off-by: Rhuan Barreto --- .../commands/session-context/claude-code.test.ts | 9 +++++---- tests/commands/session-context/copilot.test.ts | 16 ++++++---------- tests/commands/session-context/cursor.test.ts | 16 ++++++---------- tests/commands/session-context/opencode.test.ts | 4 +--- 4 files changed, 18 insertions(+), 27 deletions(-) diff --git a/tests/commands/session-context/claude-code.test.ts b/tests/commands/session-context/claude-code.test.ts index 84ae7a59..da908561 100644 --- a/tests/commands/session-context/claude-code.test.ts +++ b/tests/commands/session-context/claude-code.test.ts @@ -170,9 +170,7 @@ describe("claude-code action handler", () => { try { listSpy.mockResolvedValue({ ok: true, - data: { - sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }], - }, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, }); await makeProgram().parseAsync([ @@ -234,7 +232,10 @@ describe("claude-code action handler", () => { }); test("show subcommand exits 1 on error result", async () => { - readSpy.mockResolvedValue({ ok: false, error: "Session not found: abc123" }); + readSpy.mockResolvedValue({ + ok: false, + error: "Session not found: abc123", + }); await expect( makeProgram().parseAsync([ diff --git a/tests/commands/session-context/copilot.test.ts b/tests/commands/session-context/copilot.test.ts index 986c16ac..1fc3d327 100644 --- a/tests/commands/session-context/copilot.test.ts +++ b/tests/commands/session-context/copilot.test.ts @@ -170,9 +170,7 @@ describe("copilot action handler", () => { try { listSpy.mockResolvedValue({ ok: true, - data: { - sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }], - }, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, }); await makeProgram().parseAsync([ @@ -198,12 +196,7 @@ describe("copilot action handler", () => { listSpy.mockResolvedValue({ ok: false, error: "store missing" }); await expect( - makeProgram().parseAsync([ - "node", - "session-context", - "copilot", - "list", - ]) + makeProgram().parseAsync(["node", "session-context", "copilot", "list"]) ).rejects.toThrow("process.exit"); expect(exitSpy).toHaveBeenCalledWith(1); @@ -234,7 +227,10 @@ describe("copilot action handler", () => { }); test("show subcommand exits 1 on error result", async () => { - readSpy.mockResolvedValue({ ok: false, error: "Session not found: abc123" }); + readSpy.mockResolvedValue({ + ok: false, + error: "Session not found: abc123", + }); await expect( makeProgram().parseAsync([ diff --git a/tests/commands/session-context/cursor.test.ts b/tests/commands/session-context/cursor.test.ts index 94defcbe..183dff64 100644 --- a/tests/commands/session-context/cursor.test.ts +++ b/tests/commands/session-context/cursor.test.ts @@ -170,9 +170,7 @@ describe("cursor action handler", () => { try { listSpy.mockResolvedValue({ ok: true, - data: { - sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }], - }, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, }); await makeProgram().parseAsync([ @@ -198,12 +196,7 @@ describe("cursor action handler", () => { listSpy.mockResolvedValue({ ok: false, error: "store missing" }); await expect( - makeProgram().parseAsync([ - "node", - "session-context", - "cursor", - "list", - ]) + makeProgram().parseAsync(["node", "session-context", "cursor", "list"]) ).rejects.toThrow("process.exit"); expect(exitSpy).toHaveBeenCalledWith(1); @@ -234,7 +227,10 @@ describe("cursor action handler", () => { }); test("show subcommand exits 1 on error result", async () => { - readSpy.mockResolvedValue({ ok: false, error: "Session not found: abc123" }); + readSpy.mockResolvedValue({ + ok: false, + error: "Session not found: abc123", + }); await expect( makeProgram().parseAsync([ diff --git a/tests/commands/session-context/opencode.test.ts b/tests/commands/session-context/opencode.test.ts index 34f33058..8bcc77b1 100644 --- a/tests/commands/session-context/opencode.test.ts +++ b/tests/commands/session-context/opencode.test.ts @@ -178,9 +178,7 @@ describe("opencode action handler", () => { try { listSpy.mockReturnValue({ ok: true, - data: { - sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }], - }, + data: { sessions: [{ id: "abc", updatedAt: "2026-01-01T00:00:00Z" }] }, }); await makeProgram().parseAsync([