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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 28 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
167 changes: 167 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <claude\|codex\|opencode> [sessionRef]` | Distil a session into a bundle at `--out` (default `./handoff-bundle`) |
| `lg-handoff scan <bundleDir>` | Report secrets and residual absolute paths, with masked excerpts |
| `lg-handoff push <bundleDir>` | Scan, check the enclave limits, publish privately, mint a share link |

`pack` takes `--cwd <dir>` (the repo the session ran in, default `.`), `--session-file
<path>`, `--out <dir>`, `--title <t>`.
`push` takes `--title <t>`, `--expires <duration>` (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 `<bundleDir>/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 <shareId>`.

### Picking the right session

`--session-file <path>` always wins, and is the reliable option. Without it:

- **claude** - most recent `*.jsonl` under `~/.claude/projects/<cwd with every
non-alphanumeric character replaced by ->`.
- **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 <dir>` | No transcript at the encoded path - common if the CLI stores sessions elsewhere, e.g. under a wrapper | `--session-file <path>` |
| `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 <path>` |
| `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 |
| `<file>: 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.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
154 changes: 154 additions & 0 deletions src/handoff/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -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": "<!doctype html><title>handoff</title>",
"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([]);
});
});
Loading
Loading