diff --git a/.gitignore b/.gitignore index ac35bd9..fb5a710 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ dist/ .claude/ .hermes/ .devkit/ + +# lg-handoff output: a distilled transcript and a print-once share url. +# Never commit either - the share url grants read access to the artifact. +handoff-bundle/ +SHARE-URL.txt diff --git a/AGENTS.md b/AGENTS.md index 584cedd..fe3d2d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ node dist/cli.js run examples/hello.yaml # smoke test, zero cost ## Hard rules -- **Tests never spawn a real agent CLI.** No test may execute `claude`, `codex`, or `opencode`, and no test may make a network request. Adapter tests parse fixture strings; engine tests inject stub adapters through the registry argument. +- **Tests never spawn a real agent CLI.** No test may execute `claude`, `codex`, `opencode`, `enclave`, or `git`, and no test may make a network request. Adapter tests parse fixture strings; engine tests inject stub adapters through the registry argument; `src/handoff/` routes every spawn - including `opencode export` and `enclave push` - through an injected `Exec` seam that tests replace with a fake. - **Never invent cost numbers.** If a CLI does not report a price, record `0`. Do not derive cost from a token count and a price table anywhere in this codebase. - **Checkpoint after every edge crossing.** Not at the end of a batch, not at the end of the run. `CheckpointStore.save` writes a temp file and renames it; never write `state.json` in place. - **The event log is append-only and unbuffered.** A killed process must leave a readable JSONL log. Do not add buffering or rewrite past lines. @@ -35,9 +35,36 @@ node dist/cli.js run examples/hello.yaml # smoke test, zero cost src/core/ types, store, events, graph, budget, engine (no CLI concerns) src/adapters/ one file per executor, plus the registry src/commands/ CLI command implementations and pure renderers +src/handoff/ the `lg-handoff` bin: session readers, secret scanner, brief renderer examples/ graph files that must stay valid (`lg validate`) ``` +Nothing under `src/handoff/` may import from `src/core/` or `src/adapters/`. The subtree +owns its own enclave helpers so it stays extractable into a sibling package with a `git mv`. +That is why `buildEnclavePushArgs` exists twice and neither copy should be deduplicated +into a shared module. + +Inside `src/handoff/`, one file per job, and the data flows one way: + +``` +readers/*.ts transcript text -> DistilledSession pure; the narrowing boundary +scan.ts text -> ScanFinding[] pure; also rewritePaths +render.ts DistilledSession -> md / html / txt pure; escapes everything +bundle.ts the 4 files -> disk + the enclave limit check +enclave.ts argv builders and stdout parsers pure +commands.ts pack / scan / push the only place that spawns +cli.ts commander wiring the only place that reads argv +``` + +Two rules that are the point of the subtree, not incidental: + +- **The readers drop, they do not carry.** Tool results, attachments, file-history + snapshots, codex `base_instructions` and permission modes must never reach a + `DistilledSession`. Adding a field to that type means deciding it is safe to publish. +- **The gates fail closed.** Anything the scanner cannot read becomes a finding, not a + skip - an empty result means "safe to publish" to every caller. If you add a path + where scanning can be skipped, `push` must refuse rather than proceed. + Pure functions (`readySet`, `interpolate`, `planLevels`, `renderStatus`, `checkBudget`, every parser) are exported so they can be unit-tested directly. Keep them pure. ## Forbidden paths diff --git a/README.md b/README.md index ede5e58..d46a65a 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,173 @@ Both agent adapters close stdin before spawning. Codex otherwise prints `Reading Exit codes: `0` success, `1` validation or usage error, `2` run failed, `3` budget exceeded, `4` paused awaiting a human. +## Handoff + +You spent two hours in a `claude` session narrowing a bug. Now someone else has to carry +it. `lg-handoff` turns that session into a short brief they can read in a minute - the +goal, the files, what was claimed done, what is still open, and the exact commit to start +from - then publishes it privately behind a link that expires. + +It hands over the *understanding*, not the transcript. A transcript is a credential dump; +a brief is a handover note. + +```bash +lg-handoff pack claude --title "LT-8451 null bank_code crash" # -> ./handoff-bundle +lg-handoff push ./handoff-bundle # -> prints a share link +``` + +Send the link. They open it in a browser and start a fresh session on the commit named in +the brief. There is nothing to install on their side. + +### What the recipient actually sees + +The published page is this, rendered. Nothing was summarised by a model - every quote is +lifted verbatim from a turn, and the banner says so: + +```markdown +# LT-8451 null bank_code crash + +> This brief was distilled mechanically (quoted turns only - no model summarised it). +> Verify every claim against the repo before acting on it. + +- adapter: claude - created by: alice +- session: 9f2c - turns: 3 +- model: claude-opus-5 + +## Goal +> The loan submission crashes when bank_code is null. Find it and fix it. + +## Repo +- remote: git@github.com:acme/api.git +- sha: 6d4584dddf395e6fe7f93f63b70475de45d85a3d +- branch: fix/LT-8451 + +## Files +- src/loan/disburse.ts +- src/loan/submit.ts +- tests/loan/submit.test.ts + +## Done +Quoted from the last assistant turn. It is a claim, not a verified fact. +> Found it. src/loan/submit.ts:88 dereferences bank_code before the null guard. +> I added the guard and a regression test in tests/loan/submit.test.ts. Both pass. + +## Open +> The same pattern probably exists in the disbursement path - check src/loan/disburse.ts next. +``` + +"It is a claim, not a verified fact" is deliberate. The tool cannot know whether the tests +really passed, so it refuses to imply that it does. + +### Commands + +| Command | What it does | +| --- | --- | +| `lg-handoff pack [sessionRef]` | Distil a session into a bundle at `--out` (default `./handoff-bundle`) | +| `lg-handoff scan ` | Report secrets and residual absolute paths, with masked excerpts | +| `lg-handoff push ` | Scan, check the enclave limits, publish privately, mint a share link | + +`pack` takes `--cwd ` (the repo the session ran in, default `.`), `--session-file +`, `--out `, `--title `. +`push` takes `--title `, `--expires ` (default `7d`), `--visibility private`, +`--dry-run`. + +`scan` runs automatically inside both `pack` and `push` - you only call it directly to +re-check a bundle you edited by hand. + +### Exit codes, and what to do about each + +Its own namespace, deliberately not `lg`'s. + +| Code | Means | Do this | +| --- | --- | --- | +| `0` | Done | For `push`, save the printed link - see below | +| `1` | Usage error, session/bundle not found, or `--expires` is not a duration/date | Read the message; usually `--session-file` or a valid `--expires` | +| `2` | Secrets found, an enclave limit broke, or a remote enclave step failed | Read stderr. A local-gate 2 means nothing was uploaded. A 2 after enclave ran can mean the artifact is already published - the view url is on stdout | + +Exit 2 at a local gate (scan findings or an enclave constraint) means enclave +was never invoked. Exit 2 after that means see stderr; a partial publish is +possible. Invalid `--expires` and a missing enclave binary are exit 1; the +latter also prints the view url when the artifact is already up. + +### The share link is printed once + +`enclave` prints a share url once and stores only its hash, so it cannot be recovered +later. `push` therefore also writes it to `/SHARE-URL.txt` the moment it gets +it. Both `handoff-bundle/` and `SHARE-URL.txt` are gitignored - the link grants read +access to the artifact, so committing one is worse than losing it. + +To hand over again later, `pack` and `push` again; you get a new link. To cut off access +early, `enclave share revoke `. + +### Picking the right session + +`--session-file ` always wins, and is the reliable option. Without it: + +- **claude** - most recent `*.jsonl` under `~/.claude/projects/`. +- **codex** - most recent `*.jsonl` under `~/.codex/sessions/`, searched a few levels deep. +- **opencode** - no file search; runs `opencode export [sessionRef] --sanitize` and reads + its stdout. + +That claude directory encoding is undocumented and changes between versions, so discovery +is best-effort. `pack` always prints which file it chose and labels it as a guess. If the +brief looks like the wrong conversation, that line is why - re-run with `--session-file`. + +### What gets stripped, and what does not + +Before rendering, every home path becomes `${HOME}`, the repo root becomes +`${REPO_ROOT}`, and a standalone username token becomes `user`. Tool-result blobs, attachments, +file-history snapshots, codex `base_instructions`, MCP config and permission modes are +dropped by the readers and never reach the page at all. + +Then the scanner runs, and `push` refuses on any hit. It knows URL-embedded credentials +(`scheme://user:pass@host`), `Authorization: Bearer` / `Basic` headers, Anthropic, OpenAI, +Stripe (`sk_` and `rk_`), GitHub, GitLab, Slack, AWS access key ids, GCP, HuggingFace +(`hf_`), Google OAuth (`GOCSPX-`), npm and SendGrid key shapes, JWTs, PEM private keys, +and token/secret/password assignments. The git remote is special-cased: it is published +verbatim and never passes through path rewriting, so a `user:password@` in it is stripped +at the source. + +**This is an allowlist of shapes, not a proof.** A credential in a shape it has never seen +goes straight through. Known gaps include AWS secret access keys, PEM bodies without a +header, hex client secrets, and non-home absolute paths. Read the brief before you send +the link - it is one screen, and you are the last check. If the scanner cannot read a file +it was asked to scan, it reports that as a finding rather than staying quiet, so "clean" +always means "looked at and found nothing". + +### Troubleshooting + +| You see | Cause | Fix | +| --- | --- | --- | +| `no claude session file found for ` | No transcript at the encoded path - common if the CLI stores sessions elsewhere, e.g. under a wrapper | `--session-file ` | +| `using discovered session file ... (discovery is best-effort)` and the brief looks wrong | Discovery picked the newest transcript, not the one you meant | `--session-file ` | +| `opencode not found on PATH` | `pack opencode` shells out to the real binary | Install it, or `opencode export --sanitize > s.json` elsewhere and pass `--session-file s.json` | +| `refusing to push: N scan finding(s)` | A secret shape in the brief | Fix the source, re-`pack`. Editing the bundle by hand works too - then `scan` it again | +| `: extension .jsonl is not in the enclave allowlist` | Something not in the four-file bundle landed in the directory | Remove it; `--out` should be a directory the tool owns | +| `enclave not found on PATH` | Only `push` needs it | The bundle is still on disk; install `enclave` or hand the folder over another way | +| `unreadable-file` finding | The scanner could not open a file, so it refuses to call the bundle clean | Fix permissions and re-scan | + +### What handoff is not + +- **No `pull`.** `enclave` has no fetch subcommand, and a share url is print-once. The + recipient opens the link; the brief *is* the page. +- **No raw transcript upload.** enclave allows 13 file extensions, `.jsonl` is not among + them, and files are capped at 2 MB - a real session transcript is larger. Distilled is + not a compromise here, it is the only thing that fits. +- **No cross-CLI replay and no session transplant.** Nothing writes into another person's + home directory, and no adapter resumes someone else's session id. +- **No summarisation.** The distillation is extractive - it quotes turns under fixed + headings. There is no model call, so `pack` works offline and cannot invent a claim. +- **`private` visibility only.** A transcript is production data. `--visibility org` and + `public` are refused. +- **No signal bus, inbox, or daemon.** That would contradict "Not a workflow server" + below, and an inbox that starts an agent on someone else's laptop is a different product + with a much harder threat model. + +Known rough edge: the `enclave share create --json` parser accepts several plausible field +names because that stdout shape has not yet been captured from a real invocation. + ## What this is not - **Not a model, and not an SDK for one.** loomgraph makes zero API calls of its own and has no LLM SDK dependency. diff --git a/package.json b/package.json index 532e882..a441b1f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Compose Claude Code, Codex, and OpenCode runs into a checkpointed graph - resumable, budgeted, auditable.", "type": "module", "bin": { - "lg": "./dist/cli.js" + "lg": "./dist/cli.js", + "lg-handoff": "./dist/handoff/cli.js" }, "main": "./dist/index.js", "files": [ diff --git a/src/handoff/bundle.test.ts b/src/handoff/bundle.test.ts new file mode 100644 index 0000000..c220094 --- /dev/null +++ b/src/handoff/bundle.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeBundle, checkEnclaveConstraints, SHARE_URL_FILE, type BundleFiles } from "./bundle.js"; +import { ENCLAVE_MAX_FILES, ENCLAVE_MAX_FILE_BYTES, ENCLAVE_MAX_TOTAL_BYTES } from "./types.js"; + +function makeFiles(): BundleFiles { + return { + "index.html": "handoff", + "handoff.md": "# Handoff\n\nGoal: ship it.\n", + "meta.json": JSON.stringify({ v: 1 }), + "files.txt": "src/a.ts\nsrc/b.ts\n", + }; +} + +describe("writeBundle", () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "lg-bundle-")); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it("round-trips every bundle file", () => { + const files = makeFiles(); + const out = join(dir, "bundle"); + writeBundle(out, files); + for (const [name, body] of Object.entries(files)) { + expect(readFileSync(join(out, name), "utf8")).toBe(body); + } + }); + + it("creates missing parent directories", () => { + const out = join(dir, "nested", "deeper", "bundle"); + writeBundle(out, makeFiles()); + expect(readFileSync(join(out, "index.html"), "utf8")).toContain("handoff"); + }); + + it("overwrites an existing bundle in place", () => { + const out = join(dir, "bundle"); + writeBundle(out, makeFiles()); + writeBundle(out, { ...makeFiles(), "handoff.md": "# Second\n" }); + expect(readFileSync(join(out, "handoff.md"), "utf8")).toBe("# Second\n"); + }); +}); + +describe("checkEnclaveConstraints", () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "lg-enclave-")); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it("accepts a valid bundle", () => { + writeBundle(dir, makeFiles()); + expect(checkEnclaveConstraints(dir)).toEqual([]); + }); + + it("ignores dotfiles, node_modules and .git the way enclave does", () => { + writeBundle(dir, makeFiles()); + writeFileSync(join(dir, ".enclave.json"), "{}"); + mkdirSync(join(dir, "node_modules"), { recursive: true }); + writeFileSync(join(dir, "node_modules", "junk.jsonl"), "{}"); + mkdirSync(join(dir, ".git"), { recursive: true }); + writeFileSync(join(dir, ".git", "HEAD"), "ref: refs/heads/main"); + expect(checkEnclaveConstraints(dir)).toEqual([]); + }); + + it("flags a missing index.html", () => { + writeFileSync(join(dir, "handoff.md"), "# nope\n"); + const v = checkEnclaveConstraints(dir); + expect(v).toHaveLength(1); + expect(v[0]).toContain("index.html"); + }); + + it("flags a disallowed extension", () => { + writeBundle(dir, makeFiles()); + writeFileSync(join(dir, "session.jsonl"), "{}\n"); + const v = checkEnclaveConstraints(dir); + expect(v).toHaveLength(1); + expect(v[0]).toContain("session.jsonl"); + expect(v[0]).toContain("allowlist"); + }); + + it("flags a file with no extension", () => { + writeBundle(dir, makeFiles()); + writeFileSync(join(dir, "LICENSE"), "MIT\n"); + expect(checkEnclaveConstraints(dir).join("\n")).toContain("LICENSE"); + }); + + it("flags a file over the per-file byte limit", () => { + writeBundle(dir, makeFiles()); + writeFileSync(join(dir, "big.txt"), Buffer.alloc(ENCLAVE_MAX_FILE_BYTES + 1, 0x61)); + const v = checkEnclaveConstraints(dir); + expect(v).toHaveLength(1); + expect(v[0]).toContain("big.txt"); + expect(v[0]).toContain(String(ENCLAVE_MAX_FILE_BYTES)); + }); + + it("flags more than the allowed file count", () => { + writeBundle(dir, makeFiles()); + for (let i = 0; i < ENCLAVE_MAX_FILES; i++) { + writeFileSync(join(dir, `pad-${i}.txt`), "x"); + } + const v = checkEnclaveConstraints(dir); + expect(v).toHaveLength(1); + expect(v[0]).toContain(`limit of ${ENCLAVE_MAX_FILES}`); + }); + + it("flags a bundle over the total byte limit", () => { + writeBundle(dir, makeFiles()); + // Six 2 MB files: each is within the per-file limit, the sum is not. + for (let i = 0; i < 6; i++) { + writeFileSync(join(dir, `chunk-${i}.txt`), Buffer.alloc(ENCLAVE_MAX_FILE_BYTES, 0x61)); + } + const v = checkEnclaveConstraints(dir); + expect(v).toHaveLength(1); + expect(v[0]).toContain(`limit of ${ENCLAVE_MAX_TOTAL_BYTES}`); + }); + + it("walks subdirectories and reports nested paths with forward slashes", () => { + writeBundle(dir, makeFiles()); + mkdirSync(join(dir, "assets"), { recursive: true }); + writeFileSync(join(dir, "assets", "notes.jsonl"), "{}\n"); + expect(checkEnclaveConstraints(dir).join("\n")).toContain("assets/notes.jsonl"); + }); + + it("reports several violations at once", () => { + writeFileSync(join(dir, "session.jsonl"), "{}\n"); + const v = checkEnclaveConstraints(dir); + expect(v).toHaveLength(2); + expect(v.join("\n")).toContain("index.html"); + expect(v.join("\n")).toContain("session.jsonl"); + }); + + it("reports an unreadable directory instead of throwing", () => { + const v = checkEnclaveConstraints(join(dir, "does-not-exist")); + expect(v).toHaveLength(1); + expect(v[0]).toContain("cannot read bundle directory"); + }); +}); + +describe("writeBundle purges a stale share url", () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "lg-share-")); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it("removes SHARE-URL.txt so a re-pack cannot republish the old link", () => { + writeBundle(dir, makeFiles()); + writeFileSync(join(dir, SHARE_URL_FILE), "https://host/s/OLDTOKEN\n", "utf8"); + // A second pack into the same --out directory. + writeBundle(dir, makeFiles()); + expect(existsSync(join(dir, SHARE_URL_FILE))).toBe(false); + // Nothing else would have caught it: .txt is an allowed extension, so the + // stale link would have been uploaded alongside the new artifact. + expect(checkEnclaveConstraints(dir)).toEqual([]); + }); +}); diff --git a/src/handoff/bundle.ts b/src/handoff/bundle.ts new file mode 100644 index 0000000..c327a59 --- /dev/null +++ b/src/handoff/bundle.ts @@ -0,0 +1,121 @@ +/** + * Bundle writer and the local half of the enclave push contract. + * + * A handoff bundle is a flat directory of four files. Writing it is trivial; + * the value here is `checkEnclaveConstraints`, which enforces the published + * `enclave push` limits locally so a bad bundle fails with a named limit + * instead of an opaque server refusal after an upload attempt. + * + * Import-clean: nothing here reaches into src/core/ or src/adapters/. + */ + +import { mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { extname, join, posix } from "node:path"; +import { + ENCLAVE_ALLOWED_EXTENSIONS, + ENCLAVE_MAX_FILES, + ENCLAVE_MAX_FILE_BYTES, + ENCLAVE_MAX_TOTAL_BYTES, +} from "./types.js"; + +/** The exact file set of a v1 bundle. `index.html` is what enclave serves. */ +export interface BundleFiles { + "index.html": string; + "handoff.md": string; + "meta.json": string; + "files.txt": string; +} + +/** Names enclave always skips when walking a push directory. */ +const SKIPPED_DIRS = new Set(["node_modules", ".git"]); + +/** Where `push` records the print-once share url. Never part of a bundle. */ +export const SHARE_URL_FILE = "SHARE-URL.txt"; + +/** Write every bundle file into `dir`, creating it (and parents) if missing. */ +export function writeBundle(dir: string, files: BundleFiles): void { + mkdirSync(dir, { recursive: true }); + // A share url from a previous push must not survive into the next bundle. + // enclave would upload it as an ordinary .txt file, so the new artifact would + // serve the old link - widening the exposure of a link the sender believes is + // separately scoped. Nothing else in the pipeline would catch it: `.txt` is an + // allowed extension and no scanner rule matches an enclave share url. + rmSync(join(dir, SHARE_URL_FILE), { force: true }); + for (const name of Object.keys(files) as Array) { + writeFileSync(join(dir, name), files[name], "utf8"); + } +} + + +interface WalkedFile { + /** Bundle-relative path, always with forward slashes. */ + rel: string; + bytes: number; +} + +/** + * Collect the files enclave would actually upload: recursive, skipping + * dotfiles/dotdirs, `node_modules` and `.git`, exactly as the CLI does. + */ +function walk(dir: string, prefix: string, out: WalkedFile[]): void { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const name = entry.name; + if (name.startsWith(".")) continue; + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(name)) continue; + walk(join(dir, name), prefix === "" ? name : posix.join(prefix, name), out); + continue; + } + if (!entry.isFile()) continue; + const rel = prefix === "" ? name : posix.join(prefix, name); + out.push({ rel, bytes: statSync(join(dir, name)).size }); + } +} + +/** + * Check `dir` against the enclave push contract. Returns one human-readable + * violation per problem, each naming the limit it broke. An empty array means + * the directory is publishable. + */ +export function checkEnclaveConstraints(dir: string): string[] { + const violations: string[] = []; + + let files: WalkedFile[]; + try { + files = []; + walk(dir, "", files); + } catch (err: unknown) { + const reason = err instanceof Error ? err.message : String(err); + return [`cannot read bundle directory ${dir}: ${reason}`]; + } + + if (!files.some((f) => f.rel === "index.html")) { + violations.push("missing index.html at the bundle root (enclave requires it)"); + } + + for (const f of files) { + const ext = extname(f.rel).replace(/^\./, "").toLowerCase(); + if (ext === "" || !ENCLAVE_ALLOWED_EXTENSIONS.includes(ext)) { + violations.push( + `${f.rel}: extension ${ext === "" ? "(none)" : `.${ext}`} is not in the enclave allowlist ` + + `(${ENCLAVE_ALLOWED_EXTENSIONS.join(", ")})`, + ); + } + if (f.bytes > ENCLAVE_MAX_FILE_BYTES) { + violations.push( + `${f.rel}: ${f.bytes} bytes exceeds the per-file limit of ${ENCLAVE_MAX_FILE_BYTES} bytes`, + ); + } + } + + if (files.length > ENCLAVE_MAX_FILES) { + violations.push(`${files.length} files exceeds the file-count limit of ${ENCLAVE_MAX_FILES}`); + } + + const total = files.reduce((sum, f) => sum + f.bytes, 0); + if (total > ENCLAVE_MAX_TOTAL_BYTES) { + violations.push(`${total} bytes total exceeds the bundle limit of ${ENCLAVE_MAX_TOTAL_BYTES} bytes`); + } + + return violations; +} diff --git a/src/handoff/cli.ts b/src/handoff/cli.ts new file mode 100644 index 0000000..9fc5f99 --- /dev/null +++ b/src/handoff/cli.ts @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/** + * `lg-handoff` - distil an agent CLI session into a publishable brief. + * + * A separate bin from `lg` on purpose: it shares no state with a loomgraph run, + * and it owns its own exit-code namespace (see `finish`). + */ +import { Command } from "commander"; +import { execa } from "execa"; +import { packCommand, pushCommand, scanCommand, type Exec } from "./commands.js"; +import type { HandoffAdapter } from "./types.js"; + +/** The one place this bin spawns anything. Never rejects; results carry the code. */ +const exec: Exec = async (bin, args, opts) => { + const result = await execa(bin, args, { reject: false, cwd: opts?.cwd }); + return { + exitCode: result.exitCode ?? 1, + stdout: typeof result.stdout === "string" ? result.stdout : "", + stderr: typeof result.stderr === "string" ? result.stderr : "", + failed: result.failed, + code: (result as { code?: string }).code, + }; +}; + +function log(line: string): void { + console.log(line); +} + +/** 0 ok, 1 usage or not found, 2 local refusal or remote failure. */ +async function finish(work: Promise): Promise { + try { + process.exitCode = await work; + } catch (err) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } +} + +const ADAPTERS: readonly string[] = ["claude", "codex", "opencode"]; + +/** Reject an unknown adapter as a usage error rather than a stack trace. */ +function parseAdapter(value: string): HandoffAdapter | null { + return ADAPTERS.includes(value) ? (value as HandoffAdapter) : null; +} + +const program = new Command(); + +program + .name("lg-handoff") + .description( + "Distil a claude/codex/opencode session into a self-contained brief, scan it " + + "for secrets, and publish it privately with the enclave cli.\n\n" + + "Hands over the understanding, not the transcript: a brief a colleague reads " + + "in a minute, with the goal, the files, what was claimed done, what is open, " + + "and the commit to start from.", + ) + .addHelpText( + "after", + ` +Typical run: + lg-handoff pack claude --title "LT-8451 null bank_code crash" # -> ./handoff-bundle + lg-handoff push ./handoff-bundle # -> prints a share link + + Send the link. The recipient opens it in a browser - nothing to install. + +Exit codes: + 0 done + 1 usage error, or the session / bundle was not found (usually: pass --session-file) + 2 local gate failed (nothing uploaded), or a remote enclave step failed (see stderr) + +Notes: + scan runs automatically inside both pack and push. + The share url is printed once and also written to /SHARE-URL.txt; + enclave keeps only its hash, so it cannot be recovered later. + Discovery of a session file is best-effort - pack prints which file it chose. + The scanner is an allowlist of shapes, not a proof. Read the brief before sharing. +`, + ); + +program + .command("pack") + .argument("", "claude, codex or opencode") + .argument("[sessionRef]", "session id, used by opencode export") + .option("--cwd ", "repo directory the session ran in", process.cwd()) + .option("--session-file ", "read this transcript instead of discovering one") + .option("--out ", "write the bundle here", "./handoff-bundle") + .option("--title ", "title for the brief") + .description("distil a session into a handoff bundle") + .addHelpText( + "after", + ` + --session-file always wins and is the reliable option. Without it: + claude newest *.jsonl under ~/.claude/projects/ -> + codex newest *.jsonl under ~/.codex/sessions/ + opencode runs: opencode export [sessionRef] --sanitize +`, + ) + .action((adapter: string, sessionRef: string | undefined, opts) => { + const parsed = parseAdapter(adapter); + if (parsed === null) { + console.error(`unknown adapter: ${adapter} (expected ${ADAPTERS.join(", ")})`); + process.exitCode = 1; + return; + } + void finish( + packCommand( + { + adapter: parsed, + sessionRef, + cwd: opts.cwd as string, + sessionFile: opts.sessionFile as string | undefined, + out: opts.out as string, + title: opts.title as string | undefined, + }, + exec, + log, + ), + ); + }); + +program + .command("scan") + .argument("") + .description("scan a bundle for secrets and residual absolute paths") + .action((bundleDir: string) => finish(scanCommand(bundleDir, log))); + +program + .command("push") + .argument("") + .option("--title ", "title for the published artifact") + .option("--expires ", "share link lifetime", "7d") + .option("--visibility ", "private only - a transcript is production data", "private") + .option("--dry-run", "let enclave validate the bundle without publishing", false) + .description("publish a bundle privately and mint a time-boxed share link") + .addHelpText( + "after", + ` + Refuses locally - before enclave is invoked at all - if the scanner finds + anything or the bundle breaks an enclave limit (exit 2, nothing uploaded), + or if --expires is not a duration / date / date-time / zoned ISO instant + (exit 1). Exit 2 after enclave ran can mean a partial publish; see stderr. + Use --dry-run to let enclave validate without publishing. +`, + ) + .action((bundleDir: string, opts) => + finish( + pushCommand( + bundleDir, + { + title: opts.title as string | undefined, + expires: opts.expires as string, + dryRun: opts.dryRun === true, + visibility: opts.visibility as string, + }, + exec, + log, + ), + ), + ); + +program.parse(); diff --git a/src/handoff/commands.test.ts b/src/handoff/commands.test.ts new file mode 100644 index 0000000..561f920 --- /dev/null +++ b/src/handoff/commands.test.ts @@ -0,0 +1,603 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { packCommand, pushCommand, scanCommand, type Exec } from "./commands.js"; +import { SHARE_URL_FILE, writeBundle } from "./bundle.js"; + +// Every spawn in these tests goes through this fake. No test may execute +// claude, codex, opencode, enclave or git (AGENTS.md), so a test that asserts +// "enclave was never invoked" is asserting on `calls` being empty. +interface Call { + bin: string; + args: string[]; + cwd?: string; +} + +interface Fake { + exec: Exec; + calls: Call[]; +} + +type Reply = { exitCode?: number; stdout?: string; stderr?: string; code?: string }; + +function fakeExec(replies: (call: Call) => Reply): Fake { + const calls: Call[] = []; + const exec: Exec = async (bin, args, opts) => { + const call: Call = { bin, args, cwd: opts?.cwd }; + calls.push(call); + const reply = replies(call); + const exitCode = reply.exitCode ?? 0; + return { + exitCode, + stdout: reply.stdout ?? "", + stderr: reply.stderr ?? "", + failed: exitCode !== 0 || reply.code !== undefined, + code: reply.code, + }; + }; + return { exec, calls }; +} + +/** An exec that fails the test if it is ever reached. */ +const forbiddenExec: Fake = (() => { + const calls: Call[] = []; + const exec: Exec = async (bin, args) => { + calls.push({ bin, args }); + throw new Error(`exec must not be called, got ${bin}`); + }; + return { exec, calls }; +})(); + +const dirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "lg-handoff-")); + dirs.push(dir); + return dir; +} + +function collector(): { log: (s: string) => void; lines: string[] } { + const lines: string[] = []; + return { log: (s) => lines.push(s), lines }; +} + +/** A minimal, publishable bundle. */ +function goodBundle(dir: string, extra?: string): void { + writeBundle(dir, { + "index.html": `

handoff

${extra ?? ""}`, + "handoff.md": "# handoff\n", + "meta.json": JSON.stringify({ v: 1, title: "sess handoff" }), + "files.txt": "src/a.ts\n", + }); +} + +afterEach(() => { + while (dirs.length > 0) { + const dir = dirs.pop()!; + rmSync(dir, { recursive: true, force: true }); + } + forbiddenExec.calls.length = 0; +}); + +const PUSH_JSON = JSON.stringify({ + artifactId: "art_1", + viewUrl: "https://enclave.example/a/art_1", +}); +const SHARE_JSON = JSON.stringify({ url: "https://enclave.example/s/tok" }); + +function enclaveOk(): Fake { + return fakeExec((call) => { + if (call.args[0] === "push") return { stdout: PUSH_JSON }; + return { stdout: SHARE_JSON }; + }); +} + +/** + * Assembled from split parts on purpose. The value is fabricated, but a + * provider's secret scanner matches on shape rather than validity, so a whole + * literal in the source files a false-positive alert against this repo. + */ +const FAKE_AWS_KEY = "AKIA" + "I0FAKE0FAKE0FAKE"; + +describe("pushCommand", () => { + it("publishes, then mints a share link, in that order", async () => { + const dir = tempDir(); + goodBundle(dir); + const fake = enclaveOk(); + const { log, lines } = collector(); + + const code = await pushCommand( + dir, + { expires: "7d", dryRun: false, visibility: "private" }, + fake.exec, + log, + ); + + expect(code).toBe(0); + expect(fake.calls.map((c) => `${c.bin} ${c.args[0]} ${c.args[1] ?? ""}`.trim())).toEqual([ + `enclave push ${dir}`, + "enclave share create", + ]); + expect(fake.calls[0]!.args).toContain("--visibility"); + expect(fake.calls[0]!.args).toContain("private"); + expect(fake.calls[0]!.args).not.toContain("--dry-run"); + // Title is read back out of the bundle's meta.json when not given. + expect(fake.calls[0]!.args[3]).toBe("sess handoff"); + expect(fake.calls[1]!.args).toEqual([ + "share", + "create", + "art_1", + "--expires", + "7d", + "--json", + ]); + expect(lines).toContain("https://enclave.example/s/tok"); + }); + + it("writes the print-once share url into the bundle", async () => { + const dir = tempDir(); + goodBundle(dir); + const { log } = collector(); + + await pushCommand( + dir, + { expires: "30d", dryRun: false, visibility: "private" }, + enclaveOk().exec, + log, + ); + + expect(readFileSync(join(dir, SHARE_URL_FILE), "utf8")).toBe( + "https://enclave.example/s/tok\n", + ); + }); + + it("scrubs a leftover SHARE-URL.txt before invoking enclave so a second push cannot republish it", async () => { + const dir = tempDir(); + goodBundle(dir); + writeFileSync(join(dir, SHARE_URL_FILE), "https://enclave.example/s/OLDTOKEN\n", "utf8"); + const fake = fakeExec((call) => { + if (call.args[0] === "push") { + expect(existsSync(join(dir, SHARE_URL_FILE))).toBe(false); + return { stdout: PUSH_JSON }; + } + return { stdout: SHARE_JSON }; + }); + const { log } = collector(); + + const code = await pushCommand( + dir, + { expires: "7d", dryRun: false, visibility: "private" }, + fake.exec, + log, + ); + + expect(code).toBe(0); + expect(fake.calls.map((c) => c.args[0])).toEqual(["push", "share"]); + expect(readFileSync(join(dir, SHARE_URL_FILE), "utf8")).toBe( + "https://enclave.example/s/tok\n", + ); + expect(readFileSync(join(dir, SHARE_URL_FILE), "utf8")).not.toContain("OLDTOKEN"); + }); + + it("prefers an explicit --title over the bundle's meta.json", async () => { + const dir = tempDir(); + goodBundle(dir); + const fake = enclaveOk(); + const { log } = collector(); + + await pushCommand( + dir, + { title: "override", expires: "7d", dryRun: false, visibility: "private" }, + fake.exec, + log, + ); + + expect(fake.calls[0]!.args[3]).toBe("override"); + }); + + it("refuses a bundle containing a secret without invoking enclave", async () => { + const dir = tempDir(); + goodBundle(dir); + // A fabricated key shape, planted so the scanner has something to catch. + writeFileSync( + join(dir, "handoff.md"), + `the run exported AWS_SECRET_KEY=${FAKE_AWS_KEY}\n`, + "utf8", + ); + const { log, lines } = collector(); + + const code = await pushCommand( + dir, + { expires: "7d", dryRun: false, visibility: "private" }, + forbiddenExec.exec, + log, + ); + + expect(code).toBe(2); + expect(forbiddenExec.calls).toEqual([]); + expect(lines.some((l) => l.includes("refusing to push"))).toBe(true); + expect(lines.some((l) => l.includes(FAKE_AWS_KEY))).toBe(false); + }); + + it("refuses a bundle that breaks the enclave contract without invoking enclave", async () => { + const dir = tempDir(); + // No index.html, and a disallowed extension. + writeFileSync(join(dir, "session.jsonl"), "{}\n", "utf8"); + const { log, lines } = collector(); + + const code = await pushCommand( + dir, + { expires: "7d", dryRun: false, visibility: "private" }, + forbiddenExec.exec, + log, + ); + + expect(code).toBe(2); + expect(forbiddenExec.calls).toEqual([]); + expect(lines.some((l) => l.includes("missing index.html"))).toBe(true); + expect(lines.some((l) => l.includes("allowlist"))).toBe(true); + }); + + it("refuses a non-private visibility before any other work", async () => { + const dir = tempDir(); + goodBundle(dir); + const { log, lines } = collector(); + + const code = await pushCommand( + dir, + { expires: "7d", dryRun: false, visibility: "org" }, + forbiddenExec.exec, + log, + ); + + expect(code).toBe(1); + expect(forbiddenExec.calls).toEqual([]); + expect(lines.some((l) => l.includes("production data"))).toBe(true); + }); + + it("refuses an invalid --expires before invoking enclave", async () => { + const dir = tempDir(); + goodBundle(dir); + const { log, lines } = collector(); + + const code = await pushCommand( + dir, + { expires: "forever", dryRun: false, visibility: "private" }, + forbiddenExec.exec, + log, + ); + + expect(code).toBe(1); + expect(forbiddenExec.calls).toEqual([]); + expect(lines.some((l) => l.includes("invalid --expires"))).toBe(true); + }); + + it("stops after push on --dry-run and never creates a share", async () => { + const dir = tempDir(); + goodBundle(dir); + const fake = fakeExec(() => ({ stdout: JSON.stringify({ dryRun: true }) })); + const { log, lines } = collector(); + + const code = await pushCommand( + dir, + { expires: "7d", dryRun: true, visibility: "private" }, + fake.exec, + log, + ); + + expect(code).toBe(0); + expect(fake.calls).toHaveLength(1); + expect(fake.calls[0]!.args).toContain("--dry-run"); + expect(lines.some((l) => l.includes("dry run"))).toBe(true); + }); + + it("reports a missing enclave binary and leaves the bundle on disk", async () => { + const dir = tempDir(); + goodBundle(dir); + const fake = fakeExec(() => ({ exitCode: 1, code: "ENOENT" })); + const { log, lines } = collector(); + + const code = await pushCommand( + dir, + { expires: "7d", dryRun: false, visibility: "private" }, + fake.exec, + log, + ); + + expect(code).toBe(1); + expect(lines.some((l) => l.includes("enclave not found on PATH"))).toBe(true); + expect(lines.some((l) => l.includes(dir))).toBe(true); + }); + + it("returns 2 when enclave push exits non-zero", async () => { + const dir = tempDir(); + goodBundle(dir); + const fake = fakeExec(() => ({ exitCode: 3, stderr: "quota exceeded" })); + const { log, lines } = collector(); + + const code = await pushCommand( + dir, + { expires: "7d", dryRun: false, visibility: "private" }, + fake.exec, + log, + ); + + expect(code).toBe(2); + expect(lines).toContain("quota exceeded"); + }); + + it("returns 2 when share create returns no url, after reporting the view url", async () => { + const dir = tempDir(); + goodBundle(dir); + const fake = fakeExec((call) => + call.args[0] === "push" ? { stdout: PUSH_JSON } : { stdout: "{}" }, + ); + const { log, lines } = collector(); + + const code = await pushCommand( + dir, + { expires: "7d", dryRun: false, visibility: "private" }, + fake.exec, + log, + ); + + expect(code).toBe(2); + expect(lines).toContain("https://enclave.example/a/art_1"); + expect(lines.some((l) => l.includes("no share url"))).toBe(true); + }); +}); + +describe("scanCommand", () => { + it("returns 0 for a clean bundle", async () => { + const dir = tempDir(); + goodBundle(dir); + const { log, lines } = collector(); + + expect(await scanCommand(dir, log)).toBe(0); + expect(lines).toContain("scan clean"); + }); + + it("returns 1 for a bundle directory that does not exist, not 0", async () => { + // "scan clean" on a typo'd path is the worst possible answer: the user reads + // it as "verified safe" when nothing was scanned at all. + const { log, lines } = collector(); + const missing = join(tempDir(), "nope"); + expect(await scanCommand(missing, log)).toBe(1); + expect(lines.some((l) => l.includes("no such bundle directory"))).toBe(true); + expect(lines).not.toContain("scan clean"); + }); + + it("returns 2 and reports masked findings for a dirty bundle", async () => { + const dir = tempDir(); + goodBundle(dir); + writeFileSync(join(dir, "handoff.md"), `key: ${FAKE_AWS_KEY}\n`, "utf8"); + const { log, lines } = collector(); + + expect(await scanCommand(dir, log)).toBe(2); + expect(lines.some((l) => l.includes("aws-access-key") && l.includes("handoff.md:1"))).toBe( + true, + ); + expect(lines.some((l) => l.includes(FAKE_AWS_KEY))).toBe(false); + }); +}); + +// Fabricated transcript. Deliberately free of absolute home paths so the pack +// happy path stays scan-clean; path redaction has its own tests in scan.test.ts. +const CLAUDE_JSONL = [ + `{"type":"user","sessionId":"sess-9","cwd":"/repo/demo","message":{"role":"user","content":"add a parser for src/handoff/readers/codex.ts"}}`, + `{"type":"assistant","sessionId":"sess-9","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"Added src/handoff/readers/codex.ts and a test."}]}}`, +].join("\n"); + +function gitExec(): Fake { + return fakeExec((call) => { + if (call.bin !== "git") return { exitCode: 1 }; + if (call.args[1] === "get-url") return { stdout: "git@example.com:acme/demo.git" }; + if (call.args.includes("--abbrev-ref")) return { stdout: "feat/handoff" }; + return { stdout: "a".repeat(40) }; + }); +} + +describe("packCommand", () => { + it("writes a bundle from an explicit session file and records repo facts", async () => { + const work = tempDir(); + const sessionFile = join(work, "session.jsonl"); + writeFileSync(sessionFile, CLAUDE_JSONL, "utf8"); + const out = join(work, "bundle"); + const fake = gitExec(); + const { log, lines } = collector(); + + const code = await packCommand( + { adapter: "claude", cwd: work, sessionFile, out, title: "demo handoff" }, + fake.exec, + log, + ); + + expect(code).toBe(0); + expect(lines.some((l) => l.includes(`using session file ${sessionFile}`))).toBe(true); + expect(lines).toContain("scan clean"); + + const meta = JSON.parse(readFileSync(join(out, "meta.json"), "utf8")); + expect(meta.v).toBe(1); + expect(meta.adapter).toBe("claude"); + expect(meta.sessionId).toBe("sess-9"); + expect(meta.title).toBe("demo handoff"); + expect(meta.repo).toEqual({ + remote: "git@example.com:acme/demo.git", + sha: "a".repeat(40), + branch: "feat/handoff", + }); + + const html = readFileSync(join(out, "index.html"), "utf8"); + expect(html).toContain("demo handoff"); + expect(readFileSync(join(out, "handoff.md"), "utf8")).toContain("## Next action"); + expect(readFileSync(join(out, "files.txt"), "utf8")).toContain( + "src/handoff/readers/codex.ts", + ); + + // Only git was spawned; packing never touches enclave. + expect(new Set(fake.calls.map((c) => c.bin))).toEqual(new Set(["git"])); + }); + + it("strips credentials out of a remote url before publishing it", async () => { + // A remote is published verbatim in the brief and never passes through + // redactSession, so it is the one field that could carry a live token into + // a shared artifact with nothing else in the pipeline to stop it. + const work = tempDir(); + const sessionFile = join(work, "session.jsonl"); + writeFileSync(sessionFile, CLAUDE_JSONL, "utf8"); + const out = join(work, "bundle"); + const token = "glpat" + "-FAKEfake0000FAKEfake"; + const fake = fakeExec((call) => { + if (call.bin !== "git") return { exitCode: 1 }; + if (call.args[1] === "get-url") { + return { stdout: `https://oauth2:${token}@gitlab.com/acme/demo.git` }; + } + if (call.args.includes("--abbrev-ref")) return { stdout: "feat/handoff" }; + return { stdout: "a".repeat(40) }; + }); + const { log, lines } = collector(); + + const code = await packCommand({ adapter: "claude", cwd: work, sessionFile, out }, fake.exec, log); + + expect(code).toBe(0); + expect(lines).toContain("warning: credentials stripped out of the git remote url"); + const meta = JSON.parse(readFileSync(join(out, "meta.json"), "utf8")); + expect(meta.repo.remote).toBe("https://${CREDENTIALS_REMOVED}@gitlab.com/acme/demo.git"); + // The token must not survive anywhere in the bundle. + for (const f of ["meta.json", "handoff.md", "index.html", "files.txt"]) { + expect(readFileSync(join(out, f), "utf8")).not.toContain(token); + } + }); + + it("records null repo fields and warns when git fails", async () => { + const work = tempDir(); + const sessionFile = join(work, "session.jsonl"); + writeFileSync(sessionFile, CLAUDE_JSONL, "utf8"); + const out = join(work, "bundle"); + const fake = fakeExec(() => ({ exitCode: 128, stderr: "not a git repository" })); + const { log, lines } = collector(); + + const code = await packCommand({ adapter: "claude", cwd: work, sessionFile, out }, fake.exec, log); + + expect(code).toBe(0); + const meta = JSON.parse(readFileSync(join(out, "meta.json"), "utf8")); + expect(meta.repo).toEqual({ remote: null, sha: null, branch: null }); + expect(lines.filter((l) => l.startsWith("warning: could not read repo"))).toHaveLength(3); + }); + + it("survives a git seam that rejects", async () => { + const work = tempDir(); + const sessionFile = join(work, "session.jsonl"); + writeFileSync(sessionFile, CLAUDE_JSONL, "utf8"); + const out = join(work, "bundle"); + const exec: Exec = async () => { + throw new Error("spawn refused"); + }; + const { log } = collector(); + + expect( + await packCommand({ adapter: "claude", cwd: work, sessionFile, out }, exec, log), + ).toBe(0); + }); + + it("returns 1 when the session file cannot be read", async () => { + const work = tempDir(); + const { log, lines } = collector(); + + const code = await packCommand( + { adapter: "claude", cwd: work, sessionFile: join(work, "nope.jsonl"), out: join(work, "b") }, + forbiddenExec.exec, + log, + ); + + expect(code).toBe(1); + expect(forbiddenExec.calls).toEqual([]); + expect(lines.some((l) => l.includes("cannot read session file"))).toBe(true); + }); + + it("keeps a dirty bundle on disk but returns 2", async () => { + const work = tempDir(); + const sessionFile = join(work, "session.jsonl"); + // A fabricated AWS key id inside a user turn: nothing rewrites this away, + // so the post-render scan must catch it. + writeFileSync( + sessionFile, + `{"type":"user","sessionId":"s","message":{"role":"user","content":"use ${FAKE_AWS_KEY} for the upload"}}`, + "utf8", + ); + const out = join(work, "bundle"); + const { log, lines } = collector(); + + const code = await packCommand( + { adapter: "claude", cwd: work, sessionFile, out }, + gitExec().exec, + log, + ); + + expect(code).toBe(2); + expect(readFileSync(join(out, "index.html"), "utf8")).toContain("

"); + expect(lines.some((l) => l.includes("aws-access-key"))).toBe(true); + expect(lines.some((l) => l.includes("will not be pushable"))).toBe(true); + }); + + it("exports an opencode session through the exec seam", async () => { + const work = tempDir(); + const out = join(work, "bundle"); + const exportJson = JSON.stringify({ + id: "oc-1", + messages: [ + { role: "user", parts: [{ type: "text", text: "rename the module" }] }, + { role: "assistant", parts: [{ type: "text", text: "renamed it" }] }, + ], + }); + const fake = fakeExec((call) => + call.bin === "opencode" ? { stdout: exportJson } : { stdout: "" }, + ); + const { log, lines } = collector(); + + const code = await packCommand( + { adapter: "opencode", sessionRef: "oc-1", cwd: work, out }, + fake.exec, + log, + ); + + expect(code).toBe(0); + expect(fake.calls[0]).toEqual({ + bin: "opencode", + args: ["export", "oc-1", "--sanitize"], + cwd: work, + }); + expect(lines.some((l) => l.includes("opencode export oc-1 --sanitize"))).toBe(true); + }); + + it("returns 1 when opencode is not installed", async () => { + const work = tempDir(); + const fake = fakeExec(() => ({ exitCode: 1, code: "ENOENT" })); + const { log, lines } = collector(); + + const code = await packCommand( + { adapter: "opencode", cwd: work, out: join(work, "bundle") }, + fake.exec, + log, + ); + + expect(code).toBe(1); + expect(lines.some((l) => l.includes("opencode not found on PATH"))).toBe(true); + }); + + it("returns 1 when the opencode export fails", async () => { + const work = tempDir(); + const fake = fakeExec(() => ({ exitCode: 1, stderr: "no such session" })); + const { log, lines } = collector(); + + const code = await packCommand( + { adapter: "opencode", cwd: work, out: join(work, "bundle") }, + fake.exec, + log, + ); + + expect(code).toBe(1); + expect(lines).toContain("no such session"); + }); +}); diff --git a/src/handoff/commands.ts b/src/handoff/commands.ts new file mode 100644 index 0000000..85933ad --- /dev/null +++ b/src/handoff/commands.ts @@ -0,0 +1,452 @@ +/** + * Command implementations for the `lg-handoff` bin. + * + * Every spawn - `git`, `opencode`, `enclave` - goes through the injected `Exec` + * seam, so tests inject a fake and no test in this subtree can execute a real + * binary or touch the network (AGENTS.md hard rule). + * + * The ordering in `pushCommand` is the security contract of this feature: + * scan, then constraints, then spawn. Both gates are fail-closed, and a bundle + * that trips either one must never reach `enclave`. + * + * Secret hygiene: nothing here reads or prints `ENCLAVE_TOKEN`, and no argv is + * logged. Import-clean: no imports from src/core/ or src/adapters/. + */ + +import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { homedir, userInfo } from "node:os"; +import { join, resolve } from "node:path"; +import { SHARE_URL_FILE, checkEnclaveConstraints, writeBundle } from "./bundle.js"; +import { + buildEnclavePushArgs, + buildEnclaveShareCreateArgs, + isValidExpires, + parseEnclavePushJson, + parseEnclaveShareCreateJson, +} from "./enclave.js"; +import { parseClaudeSessionJsonl, encodeClaudeProjectDir } from "./readers/claude.js"; +import { parseCodexSessionJsonl } from "./readers/codex.js"; +import { buildOpencodeExportArgs, parseOpencodeExportJson } from "./readers/opencode.js"; +import { renderFilesTxt, renderHandoffHtml, renderHandoffMd } from "./render.js"; +import { rewritePaths, scanBundleDir, stripUrlCredentials } from "./scan.js"; +import type { DistilledSession, HandoffAdapter, HandoffMeta, ScanFinding } from "./types.js"; + +/** The single spawn seam. Shaped after the subset of execa's result we use. */ +export type Exec = ( + bin: string, + args: string[], + opts?: { cwd?: string }, +) => Promise<{ + exitCode: number; + stdout: string; + stderr: string; + failed: boolean; + code?: string; +}>; + +export interface PackOptions { + adapter: HandoffAdapter; + sessionRef?: string; + cwd: string; + sessionFile?: string; + out: string; + title?: string; +} + +export interface PushOptions { + title?: string; + expires: string; + dryRun: boolean; + visibility: string; +} + +/** + * Pack a transcript into a publishable bundle. + * + * Returns 0 clean, 1 when the transcript could not be found or read, 2 when the + * bundle was written but the secret scanner found something. A 2 is + * informational, not a rollback: the bundle stays on disk so the author can + * inspect what tripped the scanner, it simply is not pushable. + */ +export async function packCommand( + opts: PackOptions, + exec: Exec, + log: (s: string) => void, +): Promise { + const cwd = resolve(opts.cwd); + const outDir = resolve(opts.out); + + const source = await resolveTranscript(opts, cwd, exec, log); + if (source === null) return 1; + + const session = parseTranscript(opts.adapter, source.text); + for (const warning of session.warnings) log(`warning: ${warning}`); + + const repo = await gatherRepoFacts(cwd, exec, log); + + const meta: HandoffMeta = { + v: 1, + adapter: opts.adapter, + sessionId: session.sessionId, + title: opts.title ?? defaultTitle(opts.adapter, session.sessionId), + createdBy: userInfo().username, + createdAt: new Date().toISOString(), + repo, + }; + + const redacted = redactSession(session, cwd); + const handoffMd = renderHandoffMd(redacted, meta); + + writeBundle(outDir, { + "index.html": renderHandoffHtml(handoffMd, meta), + "handoff.md": handoffMd, + "meta.json": `${JSON.stringify(meta, null, 2)}\n`, + "files.txt": renderFilesTxt(redacted), + }); + log(`bundle written to ${outDir}`); + + const findings = scanBundleDir(outDir); + if (findings.length > 0) { + printFindings(findings, log); + log( + `the bundle is on disk at ${outDir} but will not be pushable until these are removed`, + ); + return 2; + } + + log("scan clean"); + return 0; +} + +/** Scan an existing bundle. 2 if anything was found, else 0. */ +export async function scanCommand(dir: string, log: (s: string) => void): Promise { + const target = resolve(dir); + // A typo'd path must not read as "scan clean". Reported as a usage error + // rather than a finding, so "cannot find it" and "found something in it" stay + // distinguishable at the exit code. + if (!existsSync(target)) { + log(`no such bundle directory: ${target}`); + return 1; + } + const findings = scanBundleDir(target); + if (findings.length > 0) { + printFindings(findings, log); + return 2; + } + log("scan clean"); + return 0; +} + +/** + * Publish a bundle and mint a time-boxed share link. + * + * Order is load-bearing and each step is fail-closed: a refused visibility, a + * scan finding or a constraint violation all return before `enclave` is spawned + * even once. + */ +export async function pushCommand( + dir: string, + opts: PushOptions, + exec: Exec, + log: (s: string) => void, +): Promise { + const bundleDir = resolve(dir); + + if (opts.visibility !== "private") { + log( + `refusing --visibility ${opts.visibility}: a handoff bundle quotes a real ` + + "session, which is production data. Only private is allowed.", + ); + return 1; + } + + if (!isValidExpires(opts.expires)) { + log( + `invalid --expires ${opts.expires}: expected a duration like 7d/12h/2w, ` + + "a date, a date-time, or a zoned ISO instant", + ); + return 1; + } + + // Same reason writeBundle scrubs this file: .txt is allowlisted and no scan + // rule matches an enclave /s/… URL, so a leftover print-once link would be + // uploaded as a served page on the next push. + rmSync(join(bundleDir, SHARE_URL_FILE), { force: true }); + + const findings = scanBundleDir(bundleDir); + if (findings.length > 0) { + printFindings(findings, log); + log("refusing to push: fix the findings above, then push again"); + return 2; + } + + const violations = checkEnclaveConstraints(bundleDir); + if (violations.length > 0) { + for (const violation of violations) log(`enclave constraint: ${violation}`); + log("refusing to push: the bundle does not satisfy the enclave push contract"); + return 2; + } + + const title = opts.title ?? readBundleTitle(bundleDir); + const push = await exec("enclave", buildEnclavePushArgs(bundleDir, title, { dryRun: opts.dryRun })); + + if (push.failed && push.code === "ENOENT") { + log(`enclave not found on PATH - the bundle is still on disk at ${bundleDir}`); + return 1; + } + if (push.exitCode !== 0) { + const stderr = push.stderr.trim(); + if (stderr !== "") log(stderr); + log("enclave push failed"); + return 2; + } + + if (opts.dryRun) { + log("dry run: enclave accepted the bundle, nothing was published"); + return 0; + } + + const pushed = parseEnclavePushJson(push.stdout); + if (!pushed.ok) { + log(pushed.error); + return 2; + } + log(pushed.viewUrl); + + const share = await exec( + "enclave", + buildEnclaveShareCreateArgs(pushed.artifactId, opts.expires), + ); + if (share.failed && share.code === "ENOENT") { + log(`enclave not found on PATH - the artifact is published at ${pushed.viewUrl}`); + return 1; + } + if (share.exitCode !== 0) { + const stderr = share.stderr.trim(); + if (stderr !== "") log(stderr); + log(`enclave share create failed - the artifact is published at ${pushed.viewUrl}`); + return 2; + } + + const parsedShare = parseEnclaveShareCreateJson(share.stdout); + if (!parsedShare.ok) { + log(parsedShare.error); + return 2; + } + + // The share URL is printed once and never again - the server keeps only its + // hash. Persist it before anything else can fail or scroll it away. + const urlPath = join(bundleDir, SHARE_URL_FILE); + writeFileSync(urlPath, `${parsedShare.url}\n`, "utf8"); + log(parsedShare.url); + log(`share url saved to ${urlPath} (it cannot be recovered from the server later)`); + + return 0; +} + +function defaultTitle(adapter: HandoffAdapter, sessionId: string | null): string { + return sessionId === null ? `${adapter} handoff` : `${adapter} handoff ${sessionId}`; +} + +function printFindings(findings: ScanFinding[], log: (s: string) => void): void { + log(`${findings.length} scan finding${findings.length === 1 ? "" : "s"}:`); + for (const f of findings) { + log(` ${f.rule} ${f.file}:${f.line} ${f.excerpt}`); + } +} + +/** Read the title back out of a bundle's meta.json, falling back to a constant. */ +function readBundleTitle(dir: string): string { + try { + const parsed: unknown = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8")); + if (parsed !== null && typeof parsed === "object") { + const title = (parsed as Record).title; + if (typeof title === "string" && title !== "") return title; + } + } catch { + // A bundle without a readable meta.json still pushes; it just gets a + // generic title. + } + return "loomgraph handoff"; +} + +function parseTranscript(adapter: HandoffAdapter, text: string): DistilledSession { + if (adapter === "claude") return parseClaudeSessionJsonl(text); + if (adapter === "codex") return parseCodexSessionJsonl(text); + return parseOpencodeExportJson(text); +} + +/** + * Rewrite every machine-specific path out of the session before it is rendered. + * Returns a new session; the input is never mutated. + */ +function redactSession(session: DistilledSession, repoRoot: string): DistilledSession { + const opts = { home: homedir(), username: userInfo().username, repoRoot }; + return { + ...session, + cwd: session.cwd === null ? null : rewritePaths(session.cwd, opts), + turns: session.turns.map((turn) => ({ ...turn, text: rewritePaths(turn.text, opts) })), + filesTouched: session.filesTouched.map((path) => rewritePaths(path, opts)), + }; +} + +interface TranscriptSource { + text: string; +} + +/** + * Find and read the transcript. `--session-file` always wins; otherwise the + * adapter's default location is searched. The chosen path is always logged, + * because the discovery encodings are undocumented and a wrong pick must be + * visible rather than silent. + */ +async function resolveTranscript( + opts: PackOptions, + cwd: string, + exec: Exec, + log: (s: string) => void, +): Promise { + if (opts.sessionFile !== undefined) { + const path = resolve(opts.sessionFile); + const text = readTextFile(path); + if (text === null) { + log(`cannot read session file: ${path}`); + return null; + } + log(`using session file ${path}`); + return { text }; + } + + if (opts.adapter === "opencode") { + const args = buildOpencodeExportArgs(opts.sessionRef); + log(`exporting session with: opencode ${args.join(" ")}`); + const result = await exec("opencode", args, { cwd }); + if (result.failed && result.code === "ENOENT") { + log("opencode not found on PATH - pass --session-file with an exported json instead"); + return null; + } + if (result.exitCode !== 0) { + const stderr = result.stderr.trim(); + if (stderr !== "") log(stderr); + log("opencode export failed"); + return null; + } + return { text: result.stdout }; + } + + const found = discoverSessionFile(opts.adapter, cwd); + if (found === null) { + log( + `no ${opts.adapter} session file found for ${cwd} - pass --session-file explicitly`, + ); + return null; + } + const text = readTextFile(found); + if (text === null) { + log(`cannot read session file: ${found}`); + return null; + } + log(`using discovered session file ${found} (discovery is best-effort - check this is right)`); + return { text }; +} + +function readTextFile(path: string): string | null { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** + * Best-effort discovery of the most recently modified transcript. + * + * Both layouts are undocumented and version-dependent, which is why the caller + * logs the result and `--session-file` exists. + */ +function discoverSessionFile(adapter: "claude" | "codex", cwd: string): string | null { + const root = + adapter === "claude" + ? join(homedir(), ".claude", "projects", encodeClaudeProjectDir(cwd)) + : join(homedir(), ".codex", "sessions"); + if (!existsSync(root)) return null; + + const candidates: Array<{ path: string; mtimeMs: number }> = []; + collectJsonl(root, candidates, adapter === "codex" ? 6 : 1); + if (candidates.length === 0) return null; + + candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); + return candidates[0]!.path; +} + +/** Collect `*.jsonl` under `dir`, descending at most `depth` levels. */ +function collectJsonl( + dir: string, + out: Array<{ path: string; mtimeMs: number }>, + depth: number, +): void { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if (depth > 1) collectJsonl(path, out, depth - 1); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + try { + out.push({ path, mtimeMs: statSync(path).mtimeMs }); + } catch { + // A file that vanished between readdir and stat is simply not a candidate. + } + } +} + +/** + * Read remote, sha and branch from git. Never throws: any failing field becomes + * null with a warning, because a handoff from a directory that is not a repo is + * still worth producing. + */ +async function gatherRepoFacts( + cwd: string, + exec: Exec, + log: (s: string) => void, +): Promise { + const [remote, sha, branch] = await Promise.all([ + gitField(cwd, ["remote", "get-url", "origin"], exec, log, "remote"), + gitField(cwd, ["rev-parse", "HEAD"], exec, log, "sha"), + gitField(cwd, ["rev-parse", "--abbrev-ref", "HEAD"], exec, log, "branch"), + ]); + // A remote can carry credentials (https://oauth2:@host/repo.git). It is + // published verbatim in the brief, and unlike transcript text it never passes + // through redactSession, so it is stripped here at the source. + const safeRemote = remote === null ? null : stripUrlCredentials(remote); + if (safeRemote !== remote) { + log("warning: credentials stripped out of the git remote url"); + } + return { remote: safeRemote, sha, branch }; +} + +async function gitField( + cwd: string, + args: string[], + exec: Exec, + log: (s: string) => void, + field: string, +): Promise { + try { + const result = await exec("git", args, { cwd }); + if (result.exitCode !== 0 || result.stdout.trim() === "") { + log(`warning: could not read repo ${field} from git`); + return null; + } + return result.stdout.trim(); + } catch { + log(`warning: could not read repo ${field} from git`); + return null; + } +} diff --git a/src/handoff/enclave.test.ts b/src/handoff/enclave.test.ts new file mode 100644 index 0000000..2ad27b3 --- /dev/null +++ b/src/handoff/enclave.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { + buildEnclavePushArgs, + buildEnclaveShareCreateArgs, + isValidExpires, + parseEnclavePushJson, + parseEnclaveShareCreateJson, +} from "./enclave.js"; + +describe("buildEnclavePushArgs", () => { + it("always pins visibility to private and asks for json", () => { + expect(buildEnclavePushArgs("/tmp/bundle", "handoff")).toEqual([ + "push", + "/tmp/bundle", + "--title", + "handoff", + "--visibility", + "private", + "--json", + ]); + }); + + it("appends --dry-run last", () => { + expect(buildEnclavePushArgs("/tmp/bundle", "handoff", { dryRun: true })).toEqual([ + "push", + "/tmp/bundle", + "--title", + "handoff", + "--visibility", + "private", + "--json", + "--dry-run", + ]); + }); + + it("keeps a title with spaces and quotes as one argv element", () => { + const args = buildEnclavePushArgs("/tmp/b", 'a "risky" title; rm -rf /'); + expect(args[3]).toBe('a "risky" title; rm -rf /'); + expect(args).toHaveLength(7); + }); +}); + +describe("parseEnclavePushJson", () => { + it("reads artifactId and viewUrl", () => { + const stdout = JSON.stringify({ + artifactId: "art_123", + versionId: "ver_1", + versionNo: 1, + viewUrl: "https://enclave.example/a/art_123", + uploaded: ["index.html"], + skipped: [], + }); + expect(parseEnclavePushJson(stdout)).toEqual({ + ok: true, + artifactId: "art_123", + viewUrl: "https://enclave.example/a/art_123", + }); + }); + + it("fails on malformed json", () => { + const result = parseEnclavePushJson("not json at all"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("could not parse"); + }); + + it("fails on a json array", () => { + expect(parseEnclavePushJson("[]").ok).toBe(false); + }); + + it("fails when artifactId is absent", () => { + const result = parseEnclavePushJson(JSON.stringify({ dryRun: true })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("no artifactId"); + }); +}); + +describe("buildEnclaveShareCreateArgs", () => { + it("builds share create argv", () => { + expect(buildEnclaveShareCreateArgs("art_123", "7d")).toEqual([ + "share", + "create", + "art_123", + "--expires", + "7d", + "--json", + ]); + }); +}); + +describe("isValidExpires", () => { + it("accepts the shapes enclave documents", () => { + for (const value of [ + "7d", + "12h", + "2w", + "30d", + "2026-08-10", + "2026-08-10T14:30", + "2026-08-10T23:59:00Z", + "2026-08-10T23:59:00+07:00", + ]) { + expect(isValidExpires(value)).toBe(true); + } + }); + + it("rejects shapes enclave would refuse after publish", () => { + for (const value of ["forever", "", "tomorrow", "7", "7days", "abc"]) { + expect(isValidExpires(value)).toBe(false); + } + }); +}); + +describe("parseEnclaveShareCreateJson", () => { + // The real --json shape is not captured yet, so every accepted field name is + // exercised here. Tighten this to one case once a real fixture exists. + for (const field of ["url", "shareUrl", "link", "share_url"]) { + it(`accepts a top-level ${field} field`, () => { + const stdout = JSON.stringify({ [field]: "https://enclave.example/s/tok" }); + expect(parseEnclaveShareCreateJson(stdout)).toEqual({ + ok: true, + url: "https://enclave.example/s/tok", + }); + }); + } + + it("prefers url when several fields are present", () => { + const stdout = JSON.stringify({ link: "https://b", url: "https://a" }); + expect(parseEnclaveShareCreateJson(stdout)).toEqual({ ok: true, url: "https://a" }); + }); + + it("fails on malformed json", () => { + const result = parseEnclaveShareCreateJson("{oops"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("could not parse"); + }); + + it("fails when no known url field is present", () => { + const result = parseEnclaveShareCreateJson(JSON.stringify({ shareId: "s_1", expiresAt: "x" })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("no share url"); + }); + + it("rejects a non-string or empty url", () => { + expect(parseEnclaveShareCreateJson(JSON.stringify({ url: 42 })).ok).toBe(false); + expect(parseEnclaveShareCreateJson(JSON.stringify({ url: "" })).ok).toBe(false); + }); +}); diff --git a/src/handoff/enclave.ts b/src/handoff/enclave.ts new file mode 100644 index 0000000..a124203 --- /dev/null +++ b/src/handoff/enclave.ts @@ -0,0 +1,105 @@ +/** + * The `enclave` CLI seen from the handoff subtree: pure argv builders and pure + * stdout parsers, nothing else. Spawning is the caller's job, behind the `Exec` + * seam in `commands.ts`, so no test in this subtree can reach the real binary. + * + * This duplicates two helpers that also exist in `src/adapters/enclave.ts`. That + * is deliberate: `src/handoff/**` imports nothing from `src/core/**` or + * `src/adapters/**`, so the whole subtree can be extracted into a sibling + * package with a `git mv`. + * + * Never log the argv these builders return alongside environment state. The + * enclave token lives in `ENCLAVE_TOKEN` and in the CLI's own credentials file; + * nothing here reads either, and nothing here should start. + */ + +/** Handoff pushes are private-only, so visibility is not a parameter. */ +export function buildEnclavePushArgs( + dir: string, + title: string, + opts?: { dryRun?: boolean }, +): string[] { + const args = ["push", dir, "--title", title, "--visibility", "private", "--json"]; + if (opts?.dryRun === true) args.push("--dry-run"); + return args; +} + +export type EnclavePushResult = + | { ok: true; artifactId: string; viewUrl: string } + | { ok: false; error: string }; + +/** Parse `enclave push --json` stdout. Never throws; a bad reply is `ok: false`. */ +export function parseEnclavePushJson(stdout: string): EnclavePushResult { + const obj = parseObject(stdout); + if (obj === null) { + return { ok: false, error: `could not parse enclave json output: ${stdout.slice(0, 200)}` }; + } + + if (obj.artifactId === undefined || obj.viewUrl === undefined) { + return { ok: false, error: "enclave push returned no artifactId - was this a dry run?" }; + } + + return { ok: true, artifactId: String(obj.artifactId), viewUrl: String(obj.viewUrl) }; +} + +export function buildEnclaveShareCreateArgs(artifactId: string, expires: string): string[] { + return ["share", "create", artifactId, "--expires", expires, "--json"]; +} + +/** + * Shapes `enclave share create --expires` accepts. Anything else is an + * InvalidInputError after the artifact is already published, so push must + * reject these locally first. + */ +export function isValidExpires(value: string): boolean { + if (/^\d+[dhw]$/.test(value)) return true; + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return true; + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) return true; + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/.test(value)) return true; + return false; +} + +export type EnclaveShareResult = { ok: true; url: string } | { ok: false; error: string }; + +/** + * Field names `share create --json` may use for the share URL. + * + * UNVERIFIED SHAPE. The real stdout of `enclave share create --json` has not + * been captured on this machine - AGENTS.md requires fixtures from a real + * invocation, and no push was performed while writing this. The parser is + * therefore lenient: it accepts any of these top-level string fields. Capture a + * real invocation, commit that fixture, and tighten this to the one true field. + */ +const SHARE_URL_FIELDS: readonly string[] = ["url", "shareUrl", "link", "share_url"]; + +/** Parse `enclave share create --json` stdout. Never throws. */ +export function parseEnclaveShareCreateJson(stdout: string): EnclaveShareResult { + const obj = parseObject(stdout); + if (obj === null) { + return { ok: false, error: `could not parse enclave json output: ${stdout.slice(0, 200)}` }; + } + + for (const field of SHARE_URL_FIELDS) { + const value = obj[field]; + if (typeof value === "string" && value !== "") return { ok: true, url: value }; + } + + return { + ok: false, + error: + "enclave share create returned no share url " + + `(looked for ${SHARE_URL_FIELDS.join(", ")}): ${stdout.slice(0, 200)}`, + }; +} + +/** JSON.parse restricted to plain objects. Returns null instead of throwing. */ +function parseObject(stdout: string): Record | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed as Record; +} diff --git a/src/handoff/readers/claude.test.ts b/src/handoff/readers/claude.test.ts new file mode 100644 index 0000000..7f87be2 --- /dev/null +++ b/src/handoff/readers/claude.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { parseClaudeSessionJsonl, encodeClaudeProjectDir } from "./claude.js"; + +// Hand-written fixture in the shape of a Claude Code session jsonl. Every value +// is fabricated: the paths live under a made-up /Users/alice tree and the +// "secret" strings are not real credentials. +const HAPPY = [ + `{"type":"system","subtype":"init","sessionId":"sess-1","cwd":"/Users/alice/demo","gitBranch":"feat/handoff","version":"2.1.0"}`, + `{"type":"user","sessionId":"sess-1","cwd":"/Users/alice/demo","message":{"role":"user","content":"fix the parser in src/handoff/readers/claude.ts"}}`, + `{"type":"assistant","sessionId":"sess-1","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"Patched src/handoff/readers/claude.ts and added a test."},{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"/Users/alice/demo/src/x.ts","new_string":"NEVER_SHOW_ME"}}]}}`, + `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_1","content":"BLOB_THAT_MUST_NOT_LEAK"}]}}`, + `{"type":"file-history-snapshot","messageId":"m1","snapshot":{"trackedFileBackups":{"/Users/alice/demo/src/x.ts":"SNAPSHOT_BLOB"}}}`, + `{"type":"permission-mode","mode":"acceptEdits"}`, +].join("\n"); + +describe("parseClaudeSessionJsonl", () => { + it("extracts turns, session id, cwd and model from a well-formed transcript", () => { + const s = parseClaudeSessionJsonl(HAPPY); + + expect(s.adapter).toBe("claude"); + expect(s.sessionId).toBe("sess-1"); + expect(s.cwd).toBe("/Users/alice/demo"); + expect(s.model).toBe("claude-opus-5"); + expect(s.turns).toEqual([ + { role: "user", text: "fix the parser in src/handoff/readers/claude.ts" }, + { + role: "assistant", + text: "Patched src/handoff/readers/claude.ts and added a test.", + }, + ]); + expect(s.filesTouched).toEqual(["src/handoff/readers/claude.ts"]); + }); + + it("never copies tool results, snapshots or permission modes into the output", () => { + const json = JSON.stringify(parseClaudeSessionJsonl(HAPPY)); + + expect(json).not.toContain("BLOB_THAT_MUST_NOT_LEAK"); + expect(json).not.toContain("SNAPSHOT_BLOB"); + expect(json).not.toContain("NEVER_SHOW_ME"); + expect(json).not.toContain("acceptEdits"); + }); + + it("reports the transcript branch as a warning instead of dropping it silently", () => { + const s = parseClaudeSessionJsonl(HAPPY); + expect(s.warnings.some((w) => w.includes("feat/handoff"))).toBe(true); + }); + + it("skips a malformed line and warns rather than throwing", () => { + const jsonl = [ + `{"type":"user","message":{"role":"user","content":"hello"}}`, + `{"type":"assistant","message":`, + `not json at all`, + `{"type":"assistant","message":{"role":"assistant","content":"hi"}}`, + ].join("\n"); + + const s = parseClaudeSessionJsonl(jsonl); + + expect(s.turns.map((t) => t.text)).toEqual(["hello", "hi"]); + expect(s.warnings).toContain("skipped 2 malformed or unrecognised line(s)"); + }); + + it("collects unknown record types into a single warning", () => { + const jsonl = [ + `{"type":"quantum-entanglement","x":1}`, + `{"type":"telepathy","x":2}`, + `{"type":"quantum-entanglement","x":3}`, + `{"type":"user","message":{"role":"user","content":"go"}}`, + ].join("\n"); + + const s = parseClaudeSessionJsonl(jsonl); + const unknown = s.warnings.filter((w) => w.startsWith("unknown record types:")); + + expect(unknown).toEqual(["unknown record types: quantum-entanglement, telepathy"]); + }); + + it("returns an empty session for empty input", () => { + expect(parseClaudeSessionJsonl("")).toEqual({ + adapter: "claude", + sessionId: null, + cwd: null, + model: null, + turns: [], + filesTouched: [], + warnings: [], + }); + expect(parseClaudeSessionJsonl("\n\n \n").turns).toEqual([]); + }); +}); + +describe("encodeClaudeProjectDir", () => { + it("encodes a posix cwd the way the projects directory appears to", () => { + expect(encodeClaudeProjectDir("/Users/alice/Documents/demo")).toBe( + "-Users-alice-Documents-demo", + ); + }); + + it("flattens dots and spaces so the result is a single path segment", () => { + expect(encodeClaudeProjectDir("/Users/alice/my proj/.config")).toBe( + "-Users-alice-my-proj--config", + ); + }); +}); diff --git a/src/handoff/readers/claude.ts b/src/handoff/readers/claude.ts new file mode 100644 index 0000000..aca855e --- /dev/null +++ b/src/handoff/readers/claude.ts @@ -0,0 +1,184 @@ +// Reader for a Claude Code session transcript (`~/.claude/projects//.jsonl`). +// +// This is a narrowing boundary, not a converter. A transcript is untrusted, +// secret-bearing input: it contains tool-result blobs, pasted attachments, +// permission modes, MCP configuration and file snapshots, none of which may +// reach a handoff bundle. Everything not on the small allowlist below is +// dropped, and the drop is surfaced in `warnings` rather than hidden. +// +// The parser is pure: it never reads the filesystem, never spawns anything and +// never throws. A line it cannot understand is skipped with a warning. +// +// `gitBranch` is read but not carried: DistilledSession has no branch field +// (HandoffMeta.repo.branch is gathered from git at pack time), so a branch +// found in the transcript is reported as a warning instead of silently lost. + +import type { DistilledSession } from "../types.js"; + +/** + * Record types observed in real Claude Code transcripts that carry nothing a + * handoff needs. Listed so they are dropped quietly instead of inflating the + * "unknown record types" warning on every parse. + */ +const IGNORED_TYPES: readonly string[] = [ + "system", + "result", + "last-prompt", + "mode", + "permission-mode", + "attachment", + "file-history-snapshot", + "summary", + "content-replacement", + "worktree-state", + "queue-operation", + "rate_limit_event", +]; + +/** `context-collapse-start`, `context-collapse-end`, ... are all ignored. */ +const IGNORED_PREFIXES: readonly string[] = ["context-collapse"]; + +/** + * Path-shaped tokens: at least one separator and a file extension. Deliberately + * conservative - a false negative costs a missing line in files.txt, a false + * positive puts noise in front of a human reviewer. + */ +const PATH_RE = /(?:[A-Za-z]:\\|\/)?(?:[\w.@+-]+[/\\])+[\w.@+-]+\.[A-Za-z0-9]{1,10}/g; + +function isIgnoredType(type: string): boolean { + return ( + IGNORED_TYPES.includes(type) || IGNORED_PREFIXES.some((p) => type.startsWith(p)) + ); +} + +function collectPaths(text: string, into: Set): void { + for (const match of text.matchAll(PATH_RE)) into.add(match[0]); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** + * Pull only the plain-text blocks out of a Claude message `content`, which is + * either a bare string or an array of typed blocks. `tool_use`, `tool_result`, + * `image` and `thinking` blocks are dropped; the count is returned so the + * caller can say so in a warning. + */ +function extractText(content: unknown): { text: string; dropped: number } { + if (typeof content === "string") return { text: content, dropped: 0 }; + if (!Array.isArray(content)) return { text: "", dropped: 0 }; + + const parts: string[] = []; + let dropped = 0; + for (const block of content) { + if (!isRecord(block)) { + dropped += 1; + continue; + } + if (block.type === "text" && typeof block.text === "string") { + parts.push(block.text); + continue; + } + dropped += 1; + } + return { text: parts.join("\n").trim(), dropped }; +} + +export function parseClaudeSessionJsonl(jsonl: string): DistilledSession { + const session: DistilledSession = { + adapter: "claude", + sessionId: null, + cwd: null, + model: null, + turns: [], + filesTouched: [], + warnings: [], + }; + + const paths = new Set(); + const unknownTypes = new Set(); + let malformed = 0; + let droppedBlocks = 0; + let branch: string | null = null; + + for (const raw of jsonl.split("\n")) { + const line = raw.trim(); + if (line.length === 0) continue; + + let record: unknown; + try { + record = JSON.parse(line); + } catch { + malformed += 1; + continue; + } + if (!isRecord(record)) { + malformed += 1; + continue; + } + + session.sessionId ??= asString(record.sessionId); + session.cwd ??= asString(record.cwd); + branch ??= asString(record.gitBranch); + + const type = typeof record.type === "string" ? record.type : ""; + if (type !== "user" && type !== "assistant") { + if (type.length === 0) malformed += 1; + else if (!isIgnoredType(type)) unknownTypes.add(type); + continue; + } + + const message = isRecord(record.message) ? record.message : null; + if (message === null) { + malformed += 1; + continue; + } + session.model ??= asString(message.model); + + const role = message.role === "assistant" ? "assistant" : "user"; + const { text, dropped } = extractText(message.content); + droppedBlocks += dropped; + if (text.length === 0) continue; + + collectPaths(text, paths); + session.turns.push({ role, text }); + } + + if (malformed > 0) { + session.warnings.push(`skipped ${malformed} malformed or unrecognised line(s)`); + } + if (unknownTypes.size > 0) { + session.warnings.push( + `unknown record types: ${[...unknownTypes].sort().join(", ")}`, + ); + } + if (droppedBlocks > 0) { + session.warnings.push(`dropped ${droppedBlocks} tool-result or non-text block(s)`); + } + if (branch !== null) { + session.warnings.push( + `transcript git branch "${branch}" not carried; repo branch is recorded at pack time`, + ); + } + + session.filesTouched = [...paths]; + return session; +} + +/** + * Best-effort encoding of a cwd into the directory name Claude Code uses under + * `~/.claude/projects/`. + * + * WARNING: this encoding is undocumented and version-dependent. It is a + * discovery hint only - a caller must always accept an explicit session-file + * path override and print which file it actually chose, so a wrong guess is + * visible instead of silent. + */ +export function encodeClaudeProjectDir(cwd: string): string { + return cwd.replace(/[^A-Za-z0-9]/g, "-"); +} diff --git a/src/handoff/readers/codex.test.ts b/src/handoff/readers/codex.test.ts new file mode 100644 index 0000000..aacd1de --- /dev/null +++ b/src/handoff/readers/codex.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { parseCodexSessionJsonl } from "./codex.js"; + +// Hand-written fixture in the shape of a codex rollout jsonl. Fabricated values +// only - the "instructions" text below is a marker string, not a real prompt, +// and no credential appears anywhere. +const BASE_INSTRUCTIONS_MARKER = "SYSTEM_PROMPT_MUST_NOT_ESCAPE"; + +const HAPPY = [ + `{"timestamp":"2026-08-21T10:00:00Z","type":"session_meta","payload":{"id":"cdx-9","cwd":"/Users/alice/demo","originator":"codex_cli_rs","cli_version":"0.145.0","model":"gpt-5.6","model_provider":"openai","base_instructions":"${BASE_INSTRUCTIONS_MARKER}","git":{"branch":"feat/handoff","repository_url":"git@example.invalid:alice/demo.git"}}}`, + `{"timestamp":"2026-08-21T10:00:01Z","type":"turn_context","payload":{"cwd":"/Users/alice/demo","model":"gpt-5.6","approval_policy":"never"}}`, + `{"timestamp":"2026-08-21T10:00:02Z","type":"event_msg","payload":{"type":"user_message","message":"rename the reader in src/handoff/readers/codex.ts"}}`, + `{"timestamp":"2026-08-21T10:00:03Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"rename the reader in src/handoff/readers/codex.ts"}]}}`, + `{"timestamp":"2026-08-21T10:00:04Z","type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"{\\"command\\":[\\"cat\\",\\"TOOL_BLOB_MUST_NOT_ESCAPE\\"]}"}}`, + `{"timestamp":"2026-08-21T10:00:05Z","type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"HIDDEN_REASONING"}]}}`, + `{"timestamp":"2026-08-21T10:00:06Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Renamed it and updated src/handoff/readers/codex.test.ts."}]}}`, + `{"timestamp":"2026-08-21T10:00:07Z","type":"world_state","payload":{"agents":{"bob":{"token":"OTHER_AGENT_STATE"}}}}`, + `{"timestamp":"2026-08-21T10:00:08Z","type":"inter_agent_communication_v2","payload":{"to":"bob","body":"CROSS_AGENT_BLOB"}}`, +].join("\n"); + +describe("parseCodexSessionJsonl", () => { + it("extracts turns, session id, cwd and model", () => { + const s = parseCodexSessionJsonl(HAPPY); + + expect(s.adapter).toBe("codex"); + expect(s.sessionId).toBe("cdx-9"); + expect(s.cwd).toBe("/Users/alice/demo"); + expect(s.model).toBe("gpt-5.6"); + expect(s.turns).toEqual([ + { role: "user", text: "rename the reader in src/handoff/readers/codex.ts" }, + { + role: "assistant", + text: "Renamed it and updated src/handoff/readers/codex.test.ts.", + }, + ]); + expect(s.filesTouched).toEqual([ + "src/handoff/readers/codex.ts", + "src/handoff/readers/codex.test.ts", + ]); + }); + + it("never reproduces base_instructions anywhere in the output", () => { + const json = JSON.stringify(parseCodexSessionJsonl(HAPPY)); + expect(json).not.toContain(BASE_INSTRUCTIONS_MARKER); + }); + + it("drops world_state, inter-agent records, tool calls and reasoning", () => { + const s = parseCodexSessionJsonl(HAPPY); + const json = JSON.stringify(s); + + expect(json).not.toContain("OTHER_AGENT_STATE"); + expect(json).not.toContain("CROSS_AGENT_BLOB"); + expect(json).not.toContain("TOOL_BLOB_MUST_NOT_ESCAPE"); + expect(json).not.toContain("HIDDEN_REASONING"); + expect(s.warnings).toContain( + "dropped 2 world_state/inter_agent_communication record(s)", + ); + expect(s.warnings).toContain("dropped 2 tool-call or reasoning payload(s)"); + }); + + it("prefers response_item turns over the duplicate event_msg stream", () => { + const s = parseCodexSessionJsonl(HAPPY); + + expect(s.turns.filter((t) => t.role === "user")).toHaveLength(1); + expect(s.warnings).toContain( + "event_msg turns dropped as duplicates of response_item turns", + ); + }); + + it("falls back to event_msg when the transcript has no response items", () => { + const jsonl = [ + `{"timestamp":"t","type":"event_msg","payload":{"type":"user_message","message":"hello"}}`, + `{"timestamp":"t","type":"event_msg","payload":{"type":"agent_message","message":"hi"}}`, + `{"timestamp":"t","type":"event_msg","payload":{"type":"token_count","info":{"total":10}}}`, + ].join("\n"); + + expect(parseCodexSessionJsonl(jsonl).turns).toEqual([ + { role: "user", text: "hello" }, + { role: "assistant", text: "hi" }, + ]); + }); + + it("skips a malformed line and warns rather than throwing", () => { + const jsonl = [ + `{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]}}`, + `{"timestamp":"t","type":"response_item","payload":`, + `{"no_type_at_all":true}`, + ].join("\n"); + + const s = parseCodexSessionJsonl(jsonl); + + expect(s.turns).toEqual([{ role: "user", text: "go" }]); + expect(s.warnings).toContain("skipped 2 malformed or unrecognised line(s)"); + }); + + it("collects unknown record types into a single warning", () => { + const jsonl = [ + `{"timestamp":"t","type":"telemetry_blip","payload":{}}`, + `{"timestamp":"t","type":"telemetry_blip","payload":{}}`, + `{"timestamp":"t","type":"astral_projection","payload":{}}`, + ].join("\n"); + + const s = parseCodexSessionJsonl(jsonl); + const unknown = s.warnings.filter((w) => w.startsWith("unknown record types:")); + + expect(unknown).toEqual(["unknown record types: astral_projection, telemetry_blip"]); + }); + + it("returns an empty session for empty input", () => { + expect(parseCodexSessionJsonl("")).toEqual({ + adapter: "codex", + sessionId: null, + cwd: null, + model: null, + turns: [], + filesTouched: [], + warnings: [], + }); + }); +}); diff --git a/src/handoff/readers/codex.ts b/src/handoff/readers/codex.ts new file mode 100644 index 0000000..1663724 --- /dev/null +++ b/src/handoff/readers/codex.ts @@ -0,0 +1,197 @@ +// Reader for a Codex CLI rollout transcript (JSONL of `{timestamp, type, payload}`). +// +// Narrowing boundary, same posture as the Claude reader: a transcript is +// untrusted, secret-bearing input. Three things are dropped unconditionally and +// must never reach any output field: +// +// - `session_meta.payload.base_instructions` - the full system prompt, large, +// and not the sender's to redistribute. +// - any `world_state` / `inter_agent_communication*` record - other agents' +// state, frequently carrying paths and credentials from another session. +// - `function_call` / `function_call_output` / `reasoning` payloads - tool +// blobs and hidden reasoning. +// +// Pure: no filesystem, no spawning, never throws. Bad line -> skipped + warning. + +import type { DistilledSession } from "../types.js"; + +const RESTRICTED_TYPE_RE = /^(world_state|inter_agent_communication)/; + +const IGNORED_PAYLOAD_TYPES: readonly string[] = [ + "function_call", + "function_call_output", + "local_shell_call", + "custom_tool_call", + "custom_tool_call_output", + "reasoning", + "web_search_call", +]; + +const IGNORED_RECORD_TYPES: readonly string[] = ["compacted", "event_msg", "turn_context"]; + +const PATH_RE = /(?:[A-Za-z]:\\|\/)?(?:[\w.@+-]+[/\\])+[\w.@+-]+\.[A-Za-z0-9]{1,10}/g; + +type Turn = { role: "user" | "assistant"; text: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** + * Codex message content is an array of `{type, text}` blocks + * (`input_text` / `output_text`), or occasionally a bare string. + */ +function extractText(content: unknown): string { + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (isRecord(block) && typeof block.text === "string") parts.push(block.text); + } + return parts.join("\n").trim(); +} + +export function parseCodexSessionJsonl(jsonl: string): DistilledSession { + const session: DistilledSession = { + adapter: "codex", + sessionId: null, + cwd: null, + model: null, + turns: [], + filesTouched: [], + warnings: [], + }; + + const paths = new Set(); + const unknownTypes = new Set(); + let malformed = 0; + let restricted = 0; + let droppedPayloads = 0; + + // `event_msg` mirrors the same user/assistant text that `response_item` + // carries. Keeping both would duplicate every turn, so response items win and + // the event stream is only a fallback for transcripts that have none. + const responseTurns: Turn[] = []; + const eventTurns: Turn[] = []; + + for (const raw of jsonl.split("\n")) { + const line = raw.trim(); + if (line.length === 0) continue; + + let record: unknown; + try { + record = JSON.parse(line); + } catch { + malformed += 1; + continue; + } + if (!isRecord(record)) { + malformed += 1; + continue; + } + + const type = typeof record.type === "string" ? record.type : ""; + if (type.length === 0) { + malformed += 1; + continue; + } + if (RESTRICTED_TYPE_RE.test(type)) { + restricted += 1; + continue; + } + + const payload = isRecord(record.payload) ? record.payload : null; + const payloadType = payload === null ? "" : (asString(payload.type) ?? ""); + if (RESTRICTED_TYPE_RE.test(payloadType)) { + restricted += 1; + continue; + } + + if (type === "session_meta") { + if (payload === null) { + malformed += 1; + continue; + } + // Only these four keys are read. base_instructions, git, originator, + // cli_version and anything else are left behind on purpose. + session.sessionId ??= asString(payload.id); + session.cwd ??= asString(payload.cwd); + session.model ??= asString(payload.model); + continue; + } + + if (type === "turn_context") { + if (payload !== null) { + session.cwd ??= asString(payload.cwd); + session.model ??= asString(payload.model); + } + continue; + } + + if (type === "response_item") { + if (payload === null) { + malformed += 1; + continue; + } + if (payloadType !== "message") { + if (IGNORED_PAYLOAD_TYPES.includes(payloadType)) droppedPayloads += 1; + else unknownTypes.add(`response_item.${payloadType || "(none)"}`); + continue; + } + const text = extractText(payload.content); + if (text.length === 0) continue; + const role = payload.role === "assistant" ? "assistant" : "user"; + responseTurns.push({ role, text }); + continue; + } + + if (type === "event_msg") { + if (payload === null) { + malformed += 1; + continue; + } + const text = asString(payload.message); + if (text === null) continue; + if (payloadType === "user_message") eventTurns.push({ role: "user", text }); + else if (payloadType === "agent_message") + eventTurns.push({ role: "assistant", text }); + continue; + } + + if (!IGNORED_RECORD_TYPES.includes(type)) unknownTypes.add(type); + } + + session.turns = responseTurns.length > 0 ? responseTurns : eventTurns; + for (const turn of session.turns) { + for (const match of turn.text.matchAll(PATH_RE)) paths.add(match[0]); + } + + if (malformed > 0) { + session.warnings.push(`skipped ${malformed} malformed or unrecognised line(s)`); + } + if (restricted > 0) { + session.warnings.push( + `dropped ${restricted} world_state/inter_agent_communication record(s)`, + ); + } + if (droppedPayloads > 0) { + session.warnings.push(`dropped ${droppedPayloads} tool-call or reasoning payload(s)`); + } + if (unknownTypes.size > 0) { + session.warnings.push( + `unknown record types: ${[...unknownTypes].sort().join(", ")}`, + ); + } + if (responseTurns.length > 0 && eventTurns.length > 0) { + session.warnings.push( + "event_msg turns dropped as duplicates of response_item turns", + ); + } + + session.filesTouched = [...paths]; + return session; +} diff --git a/src/handoff/readers/opencode.test.ts b/src/handoff/readers/opencode.test.ts new file mode 100644 index 0000000..8ab7ddf --- /dev/null +++ b/src/handoff/readers/opencode.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { parseOpencodeExportJson, buildOpencodeExportArgs } from "./opencode.js"; + +// Hand-written fixture. The shape of a real `opencode export --sanitize` payload +// is unverified (see the header comment in opencode.ts), so this fixture asserts +// the parser's contract, not the CLI's - it must be replaced once a real export +// has been captured. +const HAPPY = JSON.stringify({ + session: { + id: "oc-42", + directory: "/Users/alice/demo", + modelID: "claude-sonnet-5", + }, + messages: [ + { + info: { role: "user" }, + parts: [{ type: "text", text: "split the reader in src/handoff/readers/opencode.ts" }], + }, + { + info: { role: "assistant" }, + parts: [ + { type: "text", text: "Split it; tests live in src/handoff/readers/opencode.test.ts." }, + { type: "tool", state: { output: "TOOL_OUTPUT_MUST_NOT_ESCAPE" } }, + { type: "file", url: "file:///Users/alice/secret.pem" }, + ], + }, + ], +}); + +describe("parseOpencodeExportJson", () => { + it("extracts turns, session id, cwd and model from the probed shape", () => { + const s = parseOpencodeExportJson(HAPPY); + + expect(s.adapter).toBe("opencode"); + expect(s.sessionId).toBe("oc-42"); + expect(s.cwd).toBe("/Users/alice/demo"); + expect(s.model).toBe("claude-sonnet-5"); + expect(s.turns).toEqual([ + { role: "user", text: "split the reader in src/handoff/readers/opencode.ts" }, + { + role: "assistant", + text: "Split it; tests live in src/handoff/readers/opencode.test.ts.", + }, + ]); + }); + + it("drops tool output and file attachments", () => { + const s = parseOpencodeExportJson(HAPPY); + const json = JSON.stringify(s); + + expect(json).not.toContain("TOOL_OUTPUT_MUST_NOT_ESCAPE"); + expect(json).not.toContain("secret.pem"); + expect(s.warnings).toContain("dropped 2 non-text part(s)"); + }); + + it("always warns that the export shape is unverified", () => { + expect(parseOpencodeExportJson(HAPPY).warnings).toContain( + "opencode export shape is unverified; check the brief against the transcript", + ); + }); + + it("accepts a bare array of messages with role and text", () => { + const json = JSON.stringify([ + { role: "user", text: "hello" }, + { role: "assistant", content: "hi" }, + ]); + + expect(parseOpencodeExportJson(json).turns).toEqual([ + { role: "user", text: "hello" }, + { role: "assistant", text: "hi" }, + ]); + }); + + it("skips messages with no recognisable role and warns", () => { + const json = JSON.stringify({ + messages: [{ role: "system", text: "ignored" }, { text: "orphan" }, { role: "user", text: "kept" }], + }); + + const s = parseOpencodeExportJson(json); + + expect(s.turns).toEqual([{ role: "user", text: "kept" }]); + expect(s.warnings).toContain("skipped 2 message(s) with no recognisable role"); + }); + + it("names what it looked for when the shape is unrecognised", () => { + const s = parseOpencodeExportJson(JSON.stringify({ conversation: { log: [] } })); + + expect(s.turns).toEqual([]); + expect(s.warnings).toHaveLength(1); + expect(s.warnings[0]).toContain("unrecognised opencode export shape"); + expect(s.warnings[0]).toContain("messages/turns/parts/entries"); + expect(s.warnings[0]).toContain("session/info/data/export"); + }); + + it("warns instead of throwing on invalid JSON", () => { + const s = parseOpencodeExportJson("{ not json"); + + expect(s.turns).toEqual([]); + expect(s.warnings).toEqual([ + "opencode export was not valid JSON; no turns extracted", + ]); + }); + + it("returns an empty session for empty input", () => { + expect(parseOpencodeExportJson("")).toEqual({ + adapter: "opencode", + sessionId: null, + cwd: null, + model: null, + turns: [], + filesTouched: [], + warnings: ["opencode export was not valid JSON; no turns extracted"], + }); + }); +}); + +describe("buildOpencodeExportArgs", () => { + it("includes the session id when one is given", () => { + expect(buildOpencodeExportArgs("oc-42")).toEqual(["export", "oc-42", "--sanitize"]); + }); + + it("omits the session id when none is given", () => { + expect(buildOpencodeExportArgs()).toEqual(["export", "--sanitize"]); + }); +}); diff --git a/src/handoff/readers/opencode.ts b/src/handoff/readers/opencode.ts new file mode 100644 index 0000000..0346e4f --- /dev/null +++ b/src/handoff/readers/opencode.ts @@ -0,0 +1,190 @@ +// Reader for the JSON emitted by `opencode export --sanitize`. +// +// EXPERIMENTAL - the exact shape of this output has NOT been verified against a +// real `opencode export` invocation (AGENTS.md "Adding an adapter", step 4). No +// fixture captured from the real binary exists in this repo, so every key name +// below is a guess drawn from OpenCode's published session model, and the parser +// is written to probe several plausible spellings and to fail loudly rather than +// to fabricate. When someone captures a real export, replace the probe lists +// with the observed keys and tighten the tests. +// +// Same narrowing posture as the other readers: only turn text, session id, cwd +// and model cross the boundary. Tool parts, file attachments, snapshots, +// permissions and provider configuration are dropped, and the drop is reported. +// +// Pure: no filesystem, no spawning, never throws. + +import type { DistilledSession } from "../types.js"; + +/** Keys probed for the session id, in order. */ +const SESSION_ID_KEYS: readonly string[] = ["sessionID", "sessionId", "session_id", "id"]; +/** Keys probed for the array of messages, in order. */ +const MESSAGES_KEYS: readonly string[] = ["messages", "turns", "parts", "entries"]; +/** Keys probed for the working directory, in order. */ +const CWD_KEYS: readonly string[] = ["cwd", "directory", "worktree", "path", "root"]; +/** Keys probed for the model name, in order. */ +const MODEL_KEYS: readonly string[] = ["model", "modelID", "model_id"]; +/** Containers probed for a nested session object before giving up. */ +const NESTED_KEYS: readonly string[] = ["session", "info", "data", "export"]; + +const PATH_RE = /(?:[A-Za-z]:\\|\/)?(?:[\w.@+-]+[/\\])+[\w.@+-]+\.[A-Za-z0-9]{1,10}/g; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** First non-empty string found at any of `keys` on any of `sources`. */ +function probeString( + sources: readonly Record[], + keys: readonly string[], +): string | null { + for (const source of sources) { + for (const key of keys) { + const found = asString(source[key]); + if (found !== null) return found; + } + } + return null; +} + +/** First array found at any of `keys` on any of `sources`. */ +function probeArray( + sources: readonly Record[], + keys: readonly string[], +): unknown[] | null { + for (const source of sources) { + for (const key of keys) { + const value = source[key]; + if (Array.isArray(value)) return value; + } + } + return null; +} + +/** + * Message text under any of the shapes OpenCode might use: a bare `text`, a + * string `content`, or an array of parts where only `type: "text"` parts count. + * Everything else - tool parts, files, snapshots, step markers - is dropped. + */ +function extractText(message: Record): { text: string; dropped: number } { + const direct = asString(message.text) ?? asString(message.content); + if (direct !== null) return { text: direct.trim(), dropped: 0 }; + + const parts = Array.isArray(message.parts) + ? message.parts + : Array.isArray(message.content) + ? message.content + : null; + if (parts === null) return { text: "", dropped: 0 }; + + const kept: string[] = []; + let dropped = 0; + for (const part of parts) { + if (isRecord(part) && part.type === "text" && typeof part.text === "string") { + kept.push(part.text); + continue; + } + dropped += 1; + } + return { text: kept.join("\n").trim(), dropped }; +} + +function extractRole(message: Record): "user" | "assistant" | null { + const nested = isRecord(message.info) ? message.info : null; + const role = + asString(message.role) ?? + (nested === null ? null : asString(nested.role)) ?? + (isRecord(message.message) ? asString(message.message.role) : null); + if (role === "assistant" || role === "user") return role; + return null; +} + +export function parseOpencodeExportJson(json: string): DistilledSession { + const session: DistilledSession = { + adapter: "opencode", + sessionId: null, + cwd: null, + model: null, + turns: [], + filesTouched: [], + warnings: [], + }; + + let root: unknown; + try { + root = JSON.parse(json); + } catch { + session.warnings.push("opencode export was not valid JSON; no turns extracted"); + return session; + } + + // Candidate objects to probe: the root plus one level of plausible nesting. + const sources: Record[] = []; + if (isRecord(root)) { + sources.push(root); + for (const key of NESTED_KEYS) { + const nested = root[key]; + if (isRecord(nested)) sources.push(nested); + } + } + + const messages = Array.isArray(root) ? root : probeArray(sources, MESSAGES_KEYS); + session.sessionId = probeString(sources, SESSION_ID_KEYS); + session.cwd = probeString(sources, CWD_KEYS); + session.model = probeString(sources, MODEL_KEYS); + + if (messages === null) { + session.warnings.push( + "unrecognised opencode export shape: no message array found (looked for a " + + `top-level array, or ${MESSAGES_KEYS.join("/")} on the root or on ` + + `${NESTED_KEYS.join("/")}); no turns extracted`, + ); + return session; + } + + const paths = new Set(); + let skipped = 0; + let droppedParts = 0; + + for (const message of messages) { + if (!isRecord(message)) { + skipped += 1; + continue; + } + const role = extractRole(message); + if (role === null) { + skipped += 1; + continue; + } + const { text, dropped } = extractText(message); + droppedParts += dropped; + if (text.length === 0) continue; + for (const match of text.matchAll(PATH_RE)) paths.add(match[0]); + session.turns.push({ role, text }); + } + + if (skipped > 0) { + session.warnings.push(`skipped ${skipped} message(s) with no recognisable role`); + } + if (droppedParts > 0) { + session.warnings.push(`dropped ${droppedParts} non-text part(s)`); + } + session.warnings.push( + "opencode export shape is unverified; check the brief against the transcript", + ); + + session.filesTouched = [...paths]; + return session; +} + +/** + * argv for `opencode export`. `--sanitize` is mandatory here: the unsanitised + * export carries provider credentials and full tool output. + */ +export function buildOpencodeExportArgs(sessionId?: string): string[] { + return ["export", ...(sessionId === undefined ? [] : [sessionId]), "--sanitize"]; +} diff --git a/src/handoff/render.test.ts b/src/handoff/render.test.ts new file mode 100644 index 0000000..02377ef --- /dev/null +++ b/src/handoff/render.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; + +import { HANDOFF_SECTIONS, renderFilesTxt, renderHandoffHtml, renderHandoffMd } from "./render.js"; +import type { DistilledSession, HandoffMeta } from "./types.js"; + +const meta: HandoffMeta = { + v: 1, + adapter: "claude", + sessionId: "sess-1", + title: "handoff: fix the budget guard", + createdBy: "dat", + createdAt: "2026-08-21T09:00:00.000Z", + // Deliberately an ssh remote: the html must contain no resource url at all, + // and an https remote would make that assertion meaningless. + repo: { remote: "git@github.com:dat/loomgraph.git", sha: "b0a4162", branch: "feat/handoff" }, +}; + +const session: DistilledSession = { + adapter: "claude", + sessionId: "sess-1", + cwd: "", + model: "claude-opus-5", + turns: [ + { role: "user", text: "make the budget guard fail closed" }, + { role: "assistant", text: "changed checkBudget so it throws" }, + { role: "user", text: "now add a test for the zero-budget case" }, + ], + filesTouched: ["src/core/budget.ts", "src/core/engine.ts"], + warnings: ["2 tool-result blocks were unparsed"], +}; + +const emptySession: DistilledSession = { + adapter: "codex", + sessionId: null, + cwd: null, + model: null, + turns: [], + filesTouched: [], + warnings: [], +}; + +describe("renderHandoffMd", () => { + it("emits every section", () => { + const md = renderHandoffMd(session, meta); + for (const section of HANDOFF_SECTIONS) { + expect(md).toContain(`## ${section}`); + } + expect(HANDOFF_SECTIONS).toEqual(["Goal", "Repo", "Files", "Done", "Open", "Next action", "Warnings"]); + }); + + it("emits the sections in the documented order", () => { + const md = renderHandoffMd(session, meta); + const offsets = HANDOFF_SECTIONS.map((section) => md.indexOf(`## ${section}`)); + expect(offsets.every((offset) => offset >= 0)).toBe(true); + expect(offsets).toEqual([...offsets].sort((a, b) => a - b)); + }); + + it("quotes the first user turn as the goal and the last assistant turn as done", () => { + const md = renderHandoffMd(session, meta); + const goal = md.slice(md.indexOf("## Goal"), md.indexOf("## Repo")); + const done = md.slice(md.indexOf("## Done"), md.indexOf("## Open")); + const next = md.slice(md.indexOf("## Next action"), md.indexOf("## Warnings")); + expect(goal).toContain("> make the budget guard fail closed"); + expect(done).toContain("> changed checkBudget so it throws"); + expect(next).toContain("> now add a test for the zero-budget case"); + }); + + it("prints remote, sha and branch under Repo", () => { + const repo = renderHandoffMd(session, meta); + expect(repo).toContain("- remote: git@github.com:dat/loomgraph.git"); + expect(repo).toContain("- sha: b0a4162"); + expect(repo).toContain("- branch: feat/handoff"); + }); + + it("carries a banner saying the brief was distilled mechanically", () => { + expect(renderHandoffMd(session, meta)).toMatch(/distilled mechanically/i); + }); + + it("lists files and warnings", () => { + const md = renderHandoffMd(session, meta); + expect(md).toContain("- src/core/budget.ts"); + expect(md).toContain("- 2 tool-result blocks were unparsed"); + }); + + it("says an empty section is empty instead of omitting the heading", () => { + const md = renderHandoffMd(emptySession, { ...meta, adapter: "codex", sessionId: null }); + for (const section of HANDOFF_SECTIONS) { + expect(md).toContain(`## ${section}`); + } + expect(md).toContain("no user turn"); + expect(md).toContain("no assistant turn"); + expect(md).toContain("No file paths were extracted"); + expect(md).toContain("no warnings"); + expect(md).toContain("- session: (none recorded)"); + expect(md).toContain("- model: (none recorded)"); + }); + + it("keeps a blank transcript line inside the quote so it cannot break out", () => { + const md = renderHandoffMd( + { ...session, turns: [{ role: "user", text: "first\n\n## Warnings\nsecond" }] }, + meta, + ); + expect(md).toContain("> first"); + expect(md).toContain("> ## Warnings"); + expect(md).toContain("> second"); + // The injected heading is quoted, so only the real heading is a heading. + expect(md.match(/^## Warnings$/gm)).toHaveLength(1); + }); +}); + +/** Every real tag in the document - escaped text nodes cannot appear here, + * because escaping leaves them without a literal `<`. */ +function tagsOf(html: string): string[] { + return html.match(/<[^>]*>/g) ?? []; +} + +describe("renderHandoffHtml", () => { + const hostile: DistilledSession = { + ...session, + turns: [ + { role: "user", text: ' & "quoted" \'single\'' }, + { role: "assistant", text: '' }, + { role: "user", text: "" }, + ], + filesTouched: ["src/ & more'], + }; + + it("escapes transcript text instead of emitting markup", () => { + const html = renderHandoffHtml(renderHandoffMd(hostile, meta), meta); + expect(tagsOf(html).some((tag) => /^<\s*(script|img)/i.test(tag))).toBe(false); + expect(html).not.toContain("' }); + expect(html).not.toContain("