From 2341881804425eb640902a5fd372915f0d2edf16 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 9 Nov 2026 08:51:16 +0000 Subject: [PATCH] feat(handoff): brief, git evidence, --verify, redaction, c0mpute target `moshcode handoff` used to hand the next engine a pointer to up to 200 raw messages. It now writes a brief first (~/.moshcode/handoffs/.md), built without a model inside --budget tokens (4000), with every fact tagged by where it came from: [user], [agent-reported], [observed], [inferred]. - git evidence: branch, uncommitted changes, diffstat, commits since the session started - --verify [auto|none] / --verify="": runs the project's own typecheck/lint/test with no shell and records the real exit codes; failure lines are kept even when a suite's output overflows the capture - secrets are redacted from every carried message and check output, always - `moshcode handoff c0mpute` hands off to the c0mpute agent in process, with the brief as its first turn (warns that peers read prompts) - c0agent REPL submits a command-line prompt as its first turn; `--` ends flags - portable transcript 0.2: optional redactions, evidence, checks - MCP moshcode_handoff takes budget and verify Co-Authored-By: Claude Opus 5.5 --- docs/portable-transcript.md | 40 ++++- src/c0agent-repl.mjs | 13 ++ src/cli-schema.mjs | 22 ++- src/handoff-brief.mjs | 311 ++++++++++++++++++++++++++++++++++++ src/handoff.mjs | 146 +++++++++++++++-- src/mcp.mjs | 6 +- src/redact.mjs | 69 ++++++++ src/work.mjs | 7 +- test/c0agent.test.mjs | 3 + test/handoff-brief.test.mjs | 297 ++++++++++++++++++++++++++++++++++ test/handoff.test.mjs | 6 +- 11 files changed, 892 insertions(+), 28 deletions(-) create mode 100644 src/handoff-brief.mjs create mode 100644 src/redact.mjs create mode 100644 test/handoff-brief.test.mjs diff --git a/docs/portable-transcript.md b/docs/portable-transcript.md index 95806df0..d1db8568 100644 --- a/docs/portable-transcript.md +++ b/docs/portable-transcript.md @@ -65,6 +65,9 @@ One JSON document, UTF-8, written to `~/.moshcode/handoffs/.json` with mode | `changes` | array | yes | files the source session wrote. May be empty. | | `changes[].path` | string | yes | absolute path as the engine recorded it. | | `changes[].action` | string | yes | `written`. More verbs may be added; a reader must tolerate one it does not know. | +| `redactions` | object | no (0.2) | `{ total, kinds }`: how many secrets were replaced with `[redacted:]`, by kind. Never the secrets. | +| `evidence` | object | no (0.2) | what git showed when the handoff ran: `branch`, `head`, `status` (porcelain lines), `untracked`, `diffstat`, `commits` since the session started. Absent outside a repository. | +| `checks` | array | no (0.2) | checks moshcode ran (`--verify`): `label`, `command`, `code` (null if it never started), `timed_out`, `ms`. | ## Rules @@ -94,10 +97,34 @@ conversation without checking it. with a line reading `… (clipped)`. Clipping one message does not change `source.messages` or `source.dropped`. +**Secrets do not travel.** Every message, and the output of every check, is +redacted before it is written: private key blocks, JWTs, credentials in URLs, +bearer tokens, the key shapes of the common providers (Anthropic, OpenAI, +GitHub, Slack, AWS, Google, Stripe, npm, c0mpute), and `NAME=value` where the +name says it is a secret. There is no flag to turn this off. + **An empty `messages` array is invalid.** A conversation with nothing in it is not a short conversation, it is a failed read, and a writer must refuse rather than emit one. +## The brief + +Beside every transcript is `.md`, the brief, and that is what the target +engine is told to read first. It is built without a model, from the redacted +transcript, the `evidence` and the `checks`, inside `--budget` tokens (4000 by +default, estimated as characters / 4). Every fact carries where it came from: + +| tag | source | +|---|---| +| `[user]` | the operator's own words: the session's first request (the goal) and the most recent ones | +| `[agent-reported]` | the previous engine's last message, which is a claim and may be wrong | +| `[observed]` | git state and check exit codes, which moshcode saw itself | +| `[inferred]` | moshcode's reading of the above, such as "failing checks come first" | + +The full transcript stays the record. The brief is the cheap first read, which +is where a handoff saves tokens: the next engine reads about 4000 tokens, not +200 messages, and opens the transcript only if it needs to. + ## What is fed to the target engine The file is not pasted into the engine. Every engine takes its first prompt as @@ -116,7 +143,14 @@ where the transcript stops. ``` That keeps one code path for every target engine, and it means the transcript -can be as long as the disk allows. +can be as long as the disk allows. Since 0.2 the seed names the brief first and +the transcript second. + +The exception is `c0mpute`, moshcode's own agent on the c0mpute network. It runs +inside moshcode, so it has no argv limit and is given the brief itself as its +first turn. A small model on a CPU node takes a minute per tool call, and +reading a file it could have been handed is one call too many. Its prompts are +read by the network peers that run them, which is why redaction is not optional. ## Which engines can read and write it @@ -137,6 +171,7 @@ is short and honest. | mimocode | no | no, `run` prints one answer and exits | | aider | no, its history is rendered prose with no turn markers | no, `--message` runs one exchange and exits | | openagents | no, it launches engines and holds no conversation | no, same reason | +| c0mpute | no, it keeps no session log yet | yes, in process, the brief as its first turn | A refusal here is always by name and always with the reason. An engine that cannot be read is not an engine with nothing to say, and an engine that answers @@ -144,7 +179,8 @@ one prompt headlessly is not an engine that can receive a conversation. ## Versioning -`0.1` is the first version. A minor bump adds optional fields and a reader that +`0.1` is the first version. `0.2` adds the optional `redactions`, `evidence` +and `checks` fields. A minor bump adds optional fields and a reader that does not know them must ignore them. A major bump may change or remove a required field, and a reader that does not know the major must refuse the file rather than read what it recognises. diff --git a/src/c0agent-repl.mjs b/src/c0agent-repl.mjs index a2d94d62..f6f951fd 100644 --- a/src/c0agent-repl.mjs +++ b/src/c0agent-repl.mjs @@ -52,6 +52,8 @@ export function parseArgs(argv) { const opts = { print: null, model: null, agent: null, mode: null, cwd: null, words: [], manage: null, help: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; + // Everything after `--` is the prompt, even words that look like flags. + if (a === "--") { opts.words.push(...argv.slice(i + 1)); break; } if (a === "-h" || a === "--help") opts.help = true; else if (a === "-p" || a === "--print") { opts.print = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : ""; } else if (a === "-m" || a === "--model") opts.model = argv[++i]; @@ -596,6 +598,17 @@ async function repl({ cfg, opts, write }) { if (saved) log(ash(` the lead agent can now delegate to it; or run it yourself: @${saved.name} `)); } + // A prompt given on the command line is the first turn, the way + // `claude ""` starts: interactive, with that already submitted. + // `moshcode handoff c0mpute` opens the session this way. + const initial = opts.words.join(" ").trim(); + if (initial) { + const firstLine = initial.split("\n")[0]; + log(`${acid(">")} ${firstLine.length > 100 ? `${firstLine.slice(0, 100)}…` : firstLine}${initial.includes("\n") ? ash(` (+${initial.split("\n").length - 1} lines)`) : ""}`); + try { await runTurn(lead, initial); } catch (e) { spinner.stop(); log(err(String(e?.stack || e))); } + log(""); + } + // The loop. prompt(); for await (const raw of rl) { diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 9274a278..82c064ab 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -312,21 +312,29 @@ export const CORE_CLI_COMMANDS = [ flags: [ ["--session ", "which source session, by id or a unique prefix", "the newest for this directory"], ["--cwd ", "the directory whose session to read", "."], - ["--max ", "how many messages to carry, newest kept", "200"], - ["--dry-run", "do everything but launch: the transcript, the fleet record, the argv", ""], - ["--json", "machine-readable: the transcript path, the argv, the fleet edge", ""], + ["--max ", "how many messages to carry in the full transcript, newest kept", "200"], + ["--budget ", "size of the brief the next engine reads first", "4000"], + ["--verify [auto|none]", "run the project's own typecheck/lint/test (no shell) and put the exit codes in the brief", "none"], + ["--verify=", "run this command instead, split on spaces, no shell; repeatable", ""], + ["--verify-timeout ", "limit per check", "600"], + ["--dry-run", "do everything but launch: the brief, the transcript, the fleet record, the argv", ""], + ["--json", "machine-readable: the brief and transcript paths, checks, the argv, the fleet edge", ""], ], examples: [ ["moshcode handoff claude codex", "continue in Codex what Claude Code was doing here"], + ["moshcode handoff claude codex --verify", "the same, with the test results in the brief"], + ["moshcode handoff claude c0mpute", "out of Claude credits: keep going on the c0mpute agent"], ["moshcode handoff codex omp --session 01a01818", "a particular session, by the start of its id"], ["moshcode handoff opencode claude --dry-run", "see what would be carried, and where"], ], seeAlso: ["cost", "fleet", "agents", "start"], note: "reads from claude, codex, opencode and privacycode, the four engines whose session logs hold the conversation and not just " - + "token counts. launches claude, codex, opencode, privacycode, qwen, gemini or omp, the engines that take a first prompt and stay " - + "interactive. anything else is refused by name with the reason. the conversation and a list of the files that changed travel; " - + "the edits do not, because the working tree is already the real state. a transcript that parses to nothing is a refusal, never " - + "an empty conversation. the rendered file is ~/.moshcode/handoffs/.json in the format documented at " + + "token counts. launches claude, codex, opencode, privacycode, qwen, gemini, omp, or c0mpute (moshcode's own agent on the c0mpute " + + "network, whose prompts are read by network peers). anything else is refused by name with the reason. the next engine reads a " + + "brief first, ~/.moshcode/handoffs/.md: the goal and recent requests [user], the last report [agent-reported], git state and " + + "check exit codes [observed], and next steps [inferred], built without a model inside --budget. secrets are redacted from " + + "everything carried, always. the edits do not travel, because the working tree is already the real state. a transcript that " + + "parses to nothing is a refusal, never an empty conversation. the full transcript is ~/.moshcode/handoffs/.json in the format documented at " + "docs/portable-transcript.md, and each handoff writes an OpenFleet record linking the new session to the old one so " + "`moshcode fleet tree` shows the edge.", }, diff --git a/src/handoff-brief.mjs b/src/handoff-brief.mjs new file mode 100644 index 00000000..49849096 --- /dev/null +++ b/src/handoff-brief.mjs @@ -0,0 +1,311 @@ +// The handoff brief: what the next engine reads first. +// +// A portable transcript carries the conversation, up to 200 messages of it. That +// is the honest record and it stays, but it is the wrong first read: the next +// engine pays for every one of those tokens before it does anything, and most of +// them are a story whose ending is already on disk. The brief is the short +// version, built without a model so it cannot invent anything: +// +// [user] the operator's own words, quoted from the session +// [agent-reported] what the previous engine SAID it did, which may be wrong +// [observed] what moshcode saw itself: git, and checks it ran +// [inferred] moshcode's reading of the above, labelled as a guess +// +// The labels are the point. An engine that is told "tests pass" by the engine +// before it repeats the claim; one told "[agent-reported] tests pass" next to +// "[observed] pnpm test exited 1" does not. +// +// Nothing here sends anything anywhere. The checks run the project's own +// commands, without a shell, in the session's directory. +import fs from "node:fs"; +import path from "node:path"; + +import { runProcess } from "./work.mjs"; +import { redact, addHits } from "./redact.mjs"; + +/** The default brief size, in tokens (estimated as characters / 4). */ +export const DEFAULT_BUDGET = 4000; +/** Per-check limit. A test suite that runs longer is reported as timed out. */ +export const DEFAULT_VERIFY_TIMEOUT_MS = 10 * 60 * 1000; + +const CHARS_PER_TOKEN = 4; +const GIT_TIMEOUT_MS = 15_000; + +/* ------------------------------------------------------------------ evidence */ + +const lines = (s) => String(s || "").split("\n").map((l) => l.trimEnd()).filter(Boolean); + +/** + * What git says about the directory: branch, uncommitted changes, diffstat, and + * the commits made since the session started. Null when it is not a repository + * or git is missing; a handoff out of a scratch directory is still a handoff. + */ +export async function collectEvidence(cwd, { since = null, run = runProcess } = {}) { + const git = (args) => run("git", args, { cwd, timeoutMs: GIT_TIMEOUT_MS }); + const inside = await git(["rev-parse", "--is-inside-work-tree"]); + if (inside.code !== 0 || inside.stdout.trim() !== "true") return null; + + const [branch, head, status, stat, log] = await Promise.all([ + git(["rev-parse", "--abbrev-ref", "HEAD"]), + git(["rev-parse", "--short", "HEAD"]), + git(["status", "--porcelain=v1"]), + git(["diff", "--stat", "HEAD"]), + since + ? git(["log", `--since=${new Date(since).toISOString()}`, "--format=%h %s", "-n", "30"]) + : Promise.resolve({ code: 0, stdout: "" }), + ]); + const statusLines = status.code === 0 ? lines(status.stdout) : []; + const statLines = stat.code === 0 ? lines(stat.stdout) : []; + return { + branch: branch.code === 0 ? branch.stdout.trim() : null, + head: head.code === 0 ? head.stdout.trim() : null, + status: statusLines, + untracked: statusLines.filter((l) => l.startsWith("??")).length, + diffstat: statLines, + commits: log.code === 0 ? lines(log.stdout) : [], + }; +} + +/* -------------------------------------------------------------------- checks */ + +const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } }; +const exists = (cwd, name) => fs.existsSync(path.join(cwd, name)); + +/** npm's placeholder test script, which fails by design and proves nothing. */ +const PLACEHOLDER_TEST = /no test specified/i; + +/** Which package manager the lockfile says this project uses. */ +export function packageManager(cwd) { + if (exists(cwd, "pnpm-lock.yaml")) return "pnpm"; + if (exists(cwd, "bun.lock") || exists(cwd, "bun.lockb")) return "bun"; + if (exists(cwd, "yarn.lock")) return "yarn"; + return "npm"; +} + +/** + * The project's own checks, found rather than guessed: package.json scripts + * named typecheck/lint/test, Cargo, Go, or a Makefile test target. Each is an + * argv, never a shell string, so a script name cannot smuggle a command. + */ +export function detectChecks(cwd) { + const checks = []; + const pkg = readJson(path.join(cwd, "package.json")); + if (pkg?.scripts) { + const pm = packageManager(cwd); + for (const name of ["typecheck", "type-check", "lint", "test"]) { + const script = pkg.scripts[name]; + if (!script || (name === "test" && PLACEHOLDER_TEST.test(script))) continue; + if (name === "type-check" && checks.some((c) => c.label === "typecheck")) continue; + checks.push({ label: name === "type-check" ? "typecheck" : name, bin: pm, args: ["run", name] }); + } + } + if (exists(cwd, "Cargo.toml")) { + checks.push({ label: "cargo check", bin: "cargo", args: ["check", "--quiet"] }); + checks.push({ label: "cargo test", bin: "cargo", args: ["test", "--quiet"] }); + } + if (exists(cwd, "go.mod")) { + checks.push({ label: "go vet", bin: "go", args: ["vet", "./..."] }); + checks.push({ label: "go test", bin: "go", args: ["test", "./..."] }); + } + if (!checks.length && exists(cwd, "Makefile")) { + const make = fs.readFileSync(path.join(cwd, "Makefile"), "utf8"); + if (/^test\s*:/m.test(make)) checks.push({ label: "make test", bin: "make", args: ["test"] }); + } + return checks; +} + +/** + * A `--verify` value to checks. `auto` finds them, `none` is none, anything + * else is one command split on whitespace and run without a shell. Quoting is + * not interpreted, which is the cost of not having a shell; a command that + * needs one belongs in a package script. + */ +export function checksFor(values, cwd) { + const out = []; + for (const v of values) { + if (v === "none") continue; + if (v === "auto") { out.push(...detectChecks(cwd)); continue; } + const [bin, ...args] = v.trim().split(/\s+/); + if (bin) out.push({ label: v.trim(), bin, args }); + } + return out; +} + +/** Lines that name a failure, in the vocabularies of the common runners. */ +const FAILURE_LINE = /^\s*(not ok\b|FAIL\b|✗|×|✖|[0-9]+\) |error(\[E[0-9]+\])?[: ]|Error:|AssertionError|panicked at|--- FAIL)/i; + +/** + * What of a check's output is worth carrying. A runner's last lines are its + * summary ("# fail 2"), which says that something failed and not what, so a + * failure keeps the lines naming the failures first and then the summary. + */ +export function failureLines(output, passed = false) { + const all = lines(output); + if (passed) return all.slice(-5).join("\n"); + const named = [...new Set(all.filter((l) => FAILURE_LINE.test(l)))].slice(0, 12); + const summary = all.slice(-6).filter((l) => !named.includes(l)); + return (named.length ? [...named, "…", ...summary] : all.slice(-15)).join("\n"); +} + +/** + * runProcess, plus every failure line seen on the way. A test suite's output + * runs past runProcess's capture, which keeps the tail, and the tail is the + * summary; the lines naming what failed scrolled out long before it. + */ +export function runCheckProcess(bin, args, opts = {}) { + const named = []; + let partial = ""; + const scan = (chunk) => { + const text = partial + chunk; + const parts = text.split("\n"); + partial = parts.pop() ?? ""; + for (const l of parts) if (named.length < 40 && FAILURE_LINE.test(l)) named.push(l.trimEnd()); + }; + return runProcess(bin, args, { ...opts, onData: scan }).then((r) => { + if (partial && FAILURE_LINE.test(partial)) named.push(partial.trimEnd()); + return { ...r, named }; + }); +} + +/** Run each check in turn and keep the exit code and the tail of its output. */ +export async function runChecks(checks, { cwd, timeoutMs = DEFAULT_VERIFY_TIMEOUT_MS, run = runCheckProcess, now = () => Date.now(), hits = {} } = {}) { + const results = []; + for (const check of checks) { + const started = now(); + const r = await run(check.bin, check.args, { cwd, timeoutMs }); + const output = `${(r.named || []).join("\n")}\n${r.stdout || ""}\n${r.stderr || ""}`; + const tail = failureLines(output, r.code === 0); + const clean = redact(tail); + addHits(hits, clean.hits); + results.push({ + label: check.label, + command: [check.bin, ...check.args].join(" "), + code: r.code, + timedOut: Boolean(r.timedOut), + error: r.error || null, + ms: now() - started, + tail: clean.text, + }); + } + return results; +} + +/** One word for a check result. */ +export function verdict(check) { + if (check.error && check.code == null) return `did not run (${check.error})`; + if (check.timedOut) return "timed out"; + return check.code === 0 ? "passed" : `failed, exit ${check.code ?? check.signal ?? "?"}`; +} + +/* --------------------------------------------------------------------- brief */ + +const clip = (s, n) => { + const t = String(s || "").trim(); + return t.length > n ? `${t.slice(0, Math.max(0, n - 12)).trimEnd()} … (clipped)` : t; +}; +const quote = (s) => s.split("\n").map((l) => `> ${l}`).join("\n"); + +/** + * Render the brief from an already-redacted transcript doc, git evidence and + * check results, inside `budget` tokens. + * + * Each section gets a share of the budget in priority order, and what a + * section leaves unused rolls to the next. The final clamp is a backstop that + * should not fire. Deterministic: the same inputs give the same brief. + */ +export function buildBrief(doc, { evidence = null, checks = [], budget = DEFAULT_BUDGET, transcriptFile = "" } = {}) { + const total = Math.max(400, budget) * CHARS_PER_TOKEN; + const users = doc.messages.filter((m) => m.role === "user"); + const assistants = doc.messages.filter((m) => m.role === "assistant"); + const s = doc.source; + + const head = [ + `# Handoff from ${s.engine}`, + "", + `Session ${s.session} in ${s.cwd || "(no directory recorded)"}, ${s.started || "?"} to ${s.ended || "?"}.`, + "Every fact is tagged: [user] is the operator's words, [agent-reported] is what the previous engine claimed,", + "[observed] is what moshcode saw in git or ran itself, [inferred] is moshcode's guess. Trust them in that order of evidence.", + "The working tree is the real state of the work. Read it before you edit anything.", + "", + "", + ].join("\n"); + + let left = total - head.length; + const sections = []; + const take = (share, render) => { + const cap = Math.floor(left * share); + const body = render(cap); + if (!body) return; + const text = body.length > cap ? clip(body, cap) : body; + left -= text.length + 2; + sections.push(text); + }; + + // 1. The goal: how the operator opened the session. + if (users.length) { + take(0.15, (cap) => `## Goal [user]\n\n${quote(clip(users[0].text, cap - 20))}`); + } + + // 2. Checks: the only thing in this file nobody could have made up. + if (checks.length) { + take(0.3, (cap) => { + const per = Math.floor((cap - 40) / checks.length); + const parts = checks.map((c) => { + const line = `- \`${c.command}\`: ${verdict(c)} [observed]`; + if (c.code === 0 || !c.tail) return line; + return `${line}\n\n\`\`\`\n${c.tail.slice(-Math.max(0, per - line.length - 10))}\n\`\`\``; + }); + return `## Checks [observed]\n\n${parts.join("\n")}`; + }); + } + + // 3. The tree. + if (evidence) { + take(0.35, (cap) => { + const out = [`## Working tree [observed]`, "", `Branch \`${evidence.branch || "?"}\` at ${evidence.head || "?"}.`]; + if (evidence.commits.length) { + out.push("", `Commits since the session started (${evidence.commits.length}):`, ...evidence.commits.map((c) => `- ${c}`)); + } else out.push("", "No commits since the session started."); + if (evidence.status.length) { + out.push("", `Uncommitted (${evidence.status.length}, ${evidence.untracked} untracked):`, "```", ...evidence.status.slice(0, 40), "```"); + if (evidence.diffstat.length) out.push("", "```", ...evidence.diffstat.slice(-25), "```"); + } else out.push("", "Nothing uncommitted."); + return out.join("\n").slice(0, cap); + }); + } else if (doc.changes.length) { + take(0.25, () => `## Files the previous engine wrote [agent-reported]\n\n${doc.changes.slice(0, 60).map((c) => `- ${c.path}`).join("\n")}`); + } + + // 4. Where the previous engine says it got to. + const last = assistants[assistants.length - 1]; + if (last) take(0.5, (cap) => `## Last report from ${s.engine} [agent-reported]\n\n${quote(clip(last.text, cap - 60))}`); + + // 5. What the operator asked for most recently, newest first. + const recent = users.slice(1).slice(-5).reverse(); + if (recent.length) { + take(0.7, (cap) => { + const per = Math.floor((cap - 40) / recent.length); + return `## Recent requests, newest first [user]\n\n${recent.map((m) => quote(clip(m.text, per - 10))).join("\n\n")}`; + }); + } + + // 6. The reading. Labelled as a guess because it is one. + const next = []; + const failing = checks.filter((c) => c.code !== 0); + if (failing.length) next.push(`- Failing checks (${failing.map((c) => c.label).join(", ")}) come first: fix them before new work. [inferred]`); + else if (checks.length) next.push("- All checks passed when this was written. [inferred]"); + const lastMsg = doc.messages[doc.messages.length - 1]; + if (lastMsg?.role === "user") next.push("- The operator's last message has no answer in the transcript: start there. [inferred]"); + else if (recent.length || users.length) next.push("- Continue from the most recent request above. [inferred]"); + if (evidence?.status.length) next.push("- There is uncommitted work; it is the previous engine's, not a stray. [inferred]"); + if (next.length) take(1, () => `## Next [inferred]\n\n${next.join("\n")}`); + + const tailLine = transcriptFile + ? `\n---\nFull conversation (${doc.messages.length} messages${s.dropped ? `, newest of ${s.messages}` : ""}): ${transcriptFile}\n` + : ""; + const out = `${head}${sections.join("\n\n")}\n${tailLine}`; + return out.length > total ? `${clip(out, total - tailLine.length)}\n${tailLine}` : out; +} + +/** Estimated tokens in a string. */ +export const estimateTokens = (s) => Math.ceil(String(s || "").length / CHARS_PER_TOKEN); diff --git a/src/handoff.mjs b/src/handoff.mjs index fb95e7ec..ce8aef89 100644 --- a/src/handoff.mjs +++ b/src/handoff.mjs @@ -44,6 +44,11 @@ import path from "node:path"; import { ENGINES, openSession, resolveEngine } from "./engines.mjs"; import { UNREADABLE, isReadable, listSessions, readSession } from "./transcript.mjs"; import * as openfleet from "./openfleet.mjs"; +import { isC0mputeAgent } from "./c0agent-defs.mjs"; +import { redact, addHits, countHits } from "./redact.mjs"; +import { + DEFAULT_BUDGET, DEFAULT_VERIFY_TIMEOUT_MS, buildBrief, checksFor, collectEvidence, estimateTokens, runChecks, verdict, +} from "./handoff-brief.mjs"; /** * The format version, as it appears in every file this writes. @@ -52,7 +57,7 @@ import * as openfleet from "./openfleet.mjs"; * because a reader is allowed to refuse a major it does not know, which is the * only way a twelfth engine can join without reading this source file. */ -export const PORTABLE_TRANSCRIPT_VERSION = "0.1"; +export const PORTABLE_TRANSCRIPT_VERSION = "0.2"; /** How many messages a handoff carries by default, newest kept. */ export const DEFAULT_MAX_MESSAGES = 200; @@ -87,6 +92,12 @@ export const SEEDS = { // `omp [MESSAGES]`, verified on 18.x. A message prefixed with `@` would be // read as a file, and the seed never starts with one. omp: { argv: (text) => [text] }, + // moshcode's own coding agent on the c0mpute network (`moshcode agents + // c0mpute`). Not an ENGINES entry because there is no binary: it runs in + // this process, so there is no argv limit and the brief goes in whole rather + // than as a pointer, which saves a 9B model a read-tool round trip that costs + // a minute on a CPU node. Its prompts are read by network peers. + c0mpute: { argv: (text) => [text], inProcess: true }, // The refusals. Every one of these engines does take a prompt, and every one // of them takes it HEADLESSLY: `kimi -p`, `mimo run`, `aider --message`, the @@ -107,7 +118,10 @@ export const SEEDS = { export const sources = () => Object.keys(ENGINES).filter((key) => isReadable(key)); /** Engines a handoff can launch. */ -export const targets = () => Object.keys(ENGINES).filter((key) => SEEDS[key]?.argv); +export const targets = () => [...Object.keys(ENGINES).filter((key) => SEEDS[key]?.argv), "c0mpute"]; + +/** What a c0mpute handoff launches: moshcode itself. */ +const C0MPUTE_TARGET = { name: "c0mpute", bin: "moshcode agents c0mpute" }; const iso = (ms) => (ms == null ? null : new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z")); @@ -140,6 +154,14 @@ export function handoffId(from, to, now = Date.now()) { export function buildTranscript(session, { max = DEFAULT_MAX_MESSAGES, to, now = Date.now(), version = "" } = {}) { const all = session.messages || []; const kept = max > 0 && all.length > max ? all.slice(-max) : all; + // Every carried word goes through redaction, with no flag to skip it: this + // file is read by another engine, and with c0mpute by other people's nodes. + const hits = {}; + const messages = kept.map((m) => { + const clean = redact(m.text); + addHits(hits, clean.hits); + return { role: m.role, at: iso(m.at), text: clean.text }; + }); return { portable_transcript: PORTABLE_TRANSCRIPT_VERSION, generated: { @@ -156,8 +178,9 @@ export function buildTranscript(session, { max = DEFAULT_MAX_MESSAGES, to, now = messages: all.length, dropped: all.length - kept.length, }, - messages: kept.map((m) => ({ role: m.role, at: iso(m.at), text: m.text })), + messages, changes: (session.changes || []).map((file) => ({ path: file, action: "written" })), + redactions: { total: countHits(hits), kinds: hits }, }; } @@ -169,12 +192,20 @@ export function buildTranscript(session, { max = DEFAULT_MAX_MESSAGES, to, now = * has already happened on disk, so the tree is the truth and the transcript is * the account of how it got that way. */ -export function seedPrompt(doc, file) { +export function seedPrompt(doc, file, brief = null) { const s = doc.source; const lines = [ `You are continuing a conversation that was running in ${s.engine}.`, - `Read ${file} first. It is a moshcode portable transcript, version ${doc.portable_transcript}.`, - `It holds ${doc.messages.length} messages${s.dropped ? ` (the newest of ${s.messages})` : ""} and a list of the files that were changed.`, + ...(brief + ? [ + `Read ${brief} first: a short handoff brief whose facts are tagged [user], [agent-reported], [observed] or [inferred].`, + `Open ${file} only if the brief is not enough. It is a moshcode portable transcript, version ${doc.portable_transcript},`, + `holding ${doc.messages.length} messages${s.dropped ? ` (the newest of ${s.messages})` : ""}.`, + ] + : [ + `Read ${file} first. It is a moshcode portable transcript, version ${doc.portable_transcript}.`, + `It holds ${doc.messages.length} messages${s.dropped ? ` (the newest of ${s.messages})` : ""} and a list of the files that were changed.`, + ]), "The files were already written. The working tree is the real state, so read it before you edit anything.", "Pick up where the transcript stops.", ]; @@ -194,6 +225,16 @@ export function writeTranscript(doc, id, { env = process.env } = {}) { return file; } +/** Write the brief beside the transcript, 0600 for the same reason. */ +export function writeBrief(text, id, { env = process.env } = {}) { + const dir = handoffDir(env); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const file = path.join(dir, `${id}.md`); + fs.writeFileSync(file, text, { mode: 0o600 }); + fs.chmodSync(file, 0o600); + return file; +} + /** * Write the OpenFleet edge a handoff creates (R2). * @@ -260,16 +301,36 @@ export function recordHandoff({ from, to, id, session, transcript, cwd, messages /** `--flag value` and bare flags, with an error rather than a silent default. */ export function parseHandoff(tokens = []) { - const out = { from: null, to: null, session: null, cwd: null, max: DEFAULT_MAX_MESSAGES, dryRun: false, json: false }; + const out = { + from: null, to: null, session: null, cwd: null, max: DEFAULT_MAX_MESSAGES, dryRun: false, json: false, + budget: null, verify: [], verifyTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS, + }; const positional = []; for (let i = 0; i < tokens.length; i++) { const t = tokens[i]; if (t === "--json") out.json = true; else if (t === "--dry-run") out.dryRun = true; - else if (t === "--session" || t === "--cwd" || t === "--max") { + // `--verify` alone is auto; `--verify auto|none`; a command is + // `--verify="pnpm test"` so it can never swallow an engine name. + else if (t === "--verify") { + if (tokens[i + 1] === "auto" || tokens[i + 1] === "none") out.verify.push(tokens[++i]); + else out.verify.push("auto"); + } else if (t.startsWith("--verify=")) { + const value = t.slice("--verify=".length).trim(); + if (!value) return { error: "--verify= needs a command, or use --verify for auto" }; + out.verify.push(value); + } else if (t === "--session" || t === "--cwd" || t === "--max" || t === "--budget" || t === "--verify-timeout") { const value = tokens[i + 1]; if (value == null || value.startsWith("-")) return { error: `${t} needs a value` }; - if (t === "--max") { + if (t === "--budget") { + const n = Number(value); + if (!Number.isSafeInteger(n) || n < 400) return { error: "--budget must be a whole number of tokens, 400 or more" }; + out.budget = n; + } else if (t === "--verify-timeout") { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return { error: "--verify-timeout must be a number of seconds" }; + out.verifyTimeoutMs = Math.round(n * 1000); + } else if (t === "--max") { const n = Number(value); if (!Number.isSafeInteger(n) || n < 1) return { error: "--max must be a whole number of messages" }; out.max = n; @@ -295,6 +356,13 @@ const list = (names) => names.join(", "); export function planHandoff({ from, to }) { const source = resolveEngine(from); if (!source) return { error: `unknown engine "${from}". try: ${list(Object.keys(ENGINES))}` }; + if (isC0mputeAgent(to)) { + const [fromKey] = source; + if (!isReadable(fromKey)) { + return { error: `${fromKey} cannot be handed off from: ${UNREADABLE[fromKey] || "moshcode cannot read its transcripts"}. readable: ${list(sources())}` }; + } + return { from: fromKey, to: "c0mpute", engine: C0MPUTE_TARGET, seed: SEEDS.c0mpute }; + } const target = resolveEngine(to); if (!target) return { error: `unknown engine "${to}". try: ${list(Object.keys(ENGINES))}` }; const [fromKey] = source; @@ -327,6 +395,9 @@ export async function handoffCommand(tokens = [], { env = process.env, now = () => Date.now(), launch = openSession, + launchC0mpute = defaultLaunchC0mpute, + evidenceOf = collectEvidence, + check = runChecks, version = "", } = {}) { const parsed = parseHandoff(tokens); @@ -347,8 +418,36 @@ export async function handoffCommand(tokens = [], { const at = now(); const id = handoffId(plan.from, plan.to, at); const doc = buildTranscript(session, { max: parsed.max, to: plan.to, now: at, version }); + const workDir = session.cwd && fs.existsSync(session.cwd) ? session.cwd : cwd; + + // Evidence and checks: what moshcode can see for itself, as opposed to what + // the transcript says happened. Both are optional in the sense that a + // directory with no git and no checks still hands off; neither is optional + // in the sense of being skippable when it is there. + const evidence = await evidenceOf(workDir, { since: session.start ?? null }).catch(() => null); + const checkList = checksFor(parsed.verify, workDir); + if (parsed.verify.length && !parsed.verify.every((v) => v === "none") && !checkList.length) { + write(`· --verify found no checks in ${workDir} (no package.json scripts, Cargo.toml, go.mod or Makefile test)`); + } + if (checkList.length && !parsed.json) write(`· running ${checkList.length} check${checkList.length === 1 ? "" : "s"}: ${checkList.map((c) => c.label).join(", ")}`); + const checkHits = {}; + const checks = checkList.length + ? await check(checkList, { cwd: workDir, timeoutMs: parsed.verifyTimeoutMs, hits: checkHits }) + : []; + addHits(doc.redactions.kinds, checkHits); + doc.redactions.total = countHits(doc.redactions.kinds); + if (evidence) doc.evidence = evidence; + if (checks.length) doc.checks = checks.map(({ label, command, code, timedOut, ms }) => ({ label, command, code, timed_out: timedOut, ms })); + const file = writeTranscript(doc, id, { env }); - const prompt = seedPrompt(doc, file); + const budget = parsed.budget || DEFAULT_BUDGET; + const briefText = buildBrief(doc, { evidence, checks, budget, transcriptFile: file }); + const brief = writeBrief(briefText, id, { env }); + // c0mpute runs in this process, so it can take the brief itself; every + // other engine gets one line pointing at it (argv and pane limits, above). + const prompt = plan.seed.inProcess + ? `You are continuing work that was running in ${doc.source.engine}. The handoff brief follows; the full transcript is ${file} if you need it.\n\n${briefText}` + : seedPrompt(doc, file, brief); const argv = plan.seed.argv(prompt); const fleet = recordHandoff({ @@ -359,8 +458,14 @@ export async function handoffCommand(tokens = [], { if (parsed.json) { write(JSON.stringify({ handoff: id, from: plan.from, to: plan.to, transcript: file, + brief, brief_tokens: estimateTokens(briefText), budget, session: doc.source, messages: doc.messages.length, changes: doc.changes.length, - fleet, argv: [plan.engine.bin, ...argv], launched: !parsed.dryRun, + redactions: doc.redactions.total, + evidence: evidence ? { branch: evidence.branch, head: evidence.head, uncommitted: evidence.status.length, commits: evidence.commits.length } : null, + checks: checks.map((c) => ({ label: c.label, command: c.command, code: c.code, timed_out: c.timedOut, verdict: verdict(c) })), + fleet, + argv: plan.seed.inProcess ? ["moshcode", "agents", "c0mpute", "-C", workDir, ""] : [plan.engine.bin, ...argv], + launched: !parsed.dryRun, }, null, 2)); } else { // Say what was picked before anything starts, because the session was @@ -369,14 +474,25 @@ export async function handoffCommand(tokens = [], { write(`· ${plan.from} session ${session.id}`); write(` ${doc.messages.length} message${doc.messages.length === 1 ? "" : "s"}${doc.source.dropped ? ` of ${doc.source.messages}` : ""}, ${span}`); write(` ${doc.changes.length} file${doc.changes.length === 1 ? "" : "s"} changed, in ${doc.source.cwd || cwd}`); + if (evidence) { + write(` git ${evidence.branch || "?"} at ${evidence.head || "?"}: ${evidence.status.length} uncommitted, ${evidence.commits.length} commit${evidence.commits.length === 1 ? "" : "s"} since the session started`); + } + for (const c of checks) write(` check ${c.command}: ${verdict(c)}`); + if (doc.redactions.total) { + write(` ${doc.redactions.total} secret${doc.redactions.total === 1 ? "" : "s"} redacted (${Object.entries(doc.redactions.kinds).map(([k, n]) => `${k} ${n}`).join(", ")})`); + } + write(`· brief ${brief} (~${estimateTokens(briefText)} of ${budget} tokens)`); write(`· transcript ${file}`); write(`· fleet ${fleet.fleet}: ${fleet.member} under ${fleet.parent}`); + if (plan.seed.inProcess) write("· c0mpute sends prompts to network peers: the brief is redacted, but it is still your code conversation"); write(parsed.dryRun - ? `· dry run, not launching: ${plan.engine.bin} ${argv.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}` + ? `· dry run, not launching: ${plan.seed.inProcess ? `moshcode agents c0mpute -C ${workDir} ` : `${plan.engine.bin} ${argv.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}`}` : `· handing it to ${plan.to}`); } if (parsed.dryRun) return 0; + if (plan.seed.inProcess) return (await launchC0mpute(prompt, { cwd: workDir })) || 0; + // Plain defaults, never the agent-mode flags. A handoff moves a // conversation; it does not also decide that the next engine may skip its // approvals, and an operator who wants that says so by starting the engine @@ -391,6 +507,12 @@ export async function handoffCommand(tokens = [], { return result.code || 0; } +/** Open the c0mpute agent REPL in `cwd` with the brief as its first turn. */ +async function defaultLaunchC0mpute(prompt, { cwd }) { + const { c0agentMain } = await import("./c0agent-repl.mjs"); + return c0agentMain(["-C", cwd, "--", prompt]); +} + /** The sessions a handoff could pick, for anything that wants to offer a choice. */ export async function handoffSessions(engine, { cwd = null } = {}) { const resolved = resolveEngine(engine); diff --git a/src/mcp.mjs b/src/mcp.mjs index 61ffa713..ec0e7256 100644 --- a/src/mcp.mjs +++ b/src/mcp.mjs @@ -496,10 +496,12 @@ export const BRIDGE_TOOLS = [ type: "object", properties: { from: { type: "string", description: "the engine to read: claude, codex, opencode or privacycode" }, - to: { type: "string", description: "the engine to hand it to: claude, codex, opencode, privacycode, qwen, gemini or omp" }, + to: { type: "string", description: "the engine to hand it to: claude, codex, opencode, privacycode, qwen, gemini, omp or c0mpute" }, session: { type: "string", description: "which source session, by id or a unique prefix; the newest by default" }, cwd: { type: "string", description: "the directory whose session to read; the current one by default" }, max: { type: "integer", description: "how many messages to carry, newest kept" }, + budget: { type: "integer", description: "size in tokens of the brief the target reads first (default 4000)" }, + verify: { type: "boolean", description: "run the project's own typecheck/lint/test and put the exit codes in the brief" }, }, required: ["from", "to"], }, @@ -511,6 +513,8 @@ export const BRIDGE_TOOLS = [ if (args.session) tokens.push("--session", String(args.session)); if (args.cwd) tokens.push("--cwd", String(args.cwd)); if (args.max) tokens.push("--max", String(args.max)); + if (args.budget) tokens.push("--budget", String(args.budget)); + if (args.verify) tokens.push("--verify"); const code = await handoffCommand(tokens, { write: (line) => out.push(line), fail: (line) => errors.push(line), diff --git a/src/redact.mjs b/src/redact.mjs new file mode 100644 index 00000000..a99ab7d5 --- /dev/null +++ b/src/redact.mjs @@ -0,0 +1,69 @@ +// Secret redaction for text that leaves the session it was written in. +// +// A coding conversation is full of things pasted to get unstuck: a curl with a +// bearer token, an .env dumped to show a misconfiguration, a connection string. +// Inside the engine's own log that is the operator's business. Once a handoff +// renders it into a file another engine reads, and especially once that engine +// is a network of peers (c0mpute), it is a leak. So everything a handoff +// carries goes through here first, and there is no flag to turn it off. +// +// The patterns are shapes, not a list of every provider: a known prefix with +// enough entropy behind it, a private key block, a JWT, credentials in a URL, +// and NAME=value where the name says it is a secret. A false positive costs a +// placeholder in a transcript. A false negative costs a rotated key. + +const MARK = (kind) => `[redacted:${kind}]`; + +/** + * Ordered: the multi-line and structured shapes first, so a private key block + * is replaced whole before the token patterns could nibble at its body. + */ +export const PATTERNS = [ + ["private-key", /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g], + ["url-credentials", /\b([a-z][a-z0-9+.-]*:\/\/)([^\s:/@]+):([^\s@/]+)@/gi, (_m, scheme, user) => `${scheme}${user}:${MARK("password")}@`], + ["jwt", /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g], + ["anthropic-key", /\bsk-ant-[A-Za-z0-9_-]{20,}/g], + ["openai-key", /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}/g], + ["github-token", /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})/g], + ["slack-token", /\bxox[abposr]-[A-Za-z0-9-]{10,}/g], + ["aws-key", /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g], + ["google-key", /\bAIza[A-Za-z0-9_-]{35}\b/g], + ["stripe-key", /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g], + ["c0mpute-key", /\b(?:ik_live_|c0k_|ifc_)[A-Za-z0-9_-]{12,}/g], + ["npm-token", /\bnpm_[A-Za-z0-9]{30,}/g], + ["bearer", /\b(Bearer)\s+[A-Za-z0-9._~+/=-]{20,}/g, (_m, word) => `${word} ${MARK("token")}`], + // NAME=value, NAME: value, "NAME": "value" where NAME says secret. The value + // must look like a value (8+ chars, no spaces) so prose about "the API key + // field" is left alone. + ["assignment", + /\b([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY|CLIENT_?SECRET)[A-Za-z0-9_]*)(["']?\s*[:=]\s*["']?)([^\s"'`,;]{8,})/gi, + (_m, name, sep) => `${name}${sep}${MARK("secret")}`], +]; + +/** + * Redact one string. Returns the new text and how many secrets of each kind + * were removed, so a caller can say "3 secrets redacted" without ever printing + * one. + */ +export function redact(text, { patterns = PATTERNS } = {}) { + let out = String(text ?? ""); + const hits = {}; + for (const [kind, re, replace] of patterns) { + out = out.replace(re, (...args) => { + const match = args[0]; + if (match.includes("[redacted:")) return match; + hits[kind] = (hits[kind] || 0) + 1; + return replace ? replace(...args) : MARK(kind); + }); + } + return { text: out, hits }; +} + +/** Add one hits map into another, in place. */ +export function addHits(into, hits) { + for (const [k, n] of Object.entries(hits || {})) into[k] = (into[k] || 0) + n; + return into; +} + +/** Total number of redactions in a hits map. */ +export const countHits = (hits) => Object.values(hits || {}).reduce((a, b) => a + b, 0); diff --git a/src/work.mjs b/src/work.mjs index 668d9914..3dd64c24 100644 --- a/src/work.mjs +++ b/src/work.mjs @@ -315,7 +315,7 @@ export function summaryOf(output) { * Run a process to completion without blocking the loop, which is what lets * one moshcode drive many workers. Output past CAPTURE_BYTES keeps its tail. */ -export function runProcess(bin, args, { cwd, env = process.env, timeoutMs = 0, shell = false } = {}) { +export function runProcess(bin, args, { cwd, env = process.env, timeoutMs = 0, shell = false, onData = null } = {}) { return new Promise((resolve) => { let out = "", errText = "", timedOut = false, settled = false; const keep = (buf, chunk) => { const s = buf + chunk; return s.length > CAPTURE_BYTES ? s.slice(-CAPTURE_BYTES) : s; }; @@ -329,8 +329,9 @@ export function runProcess(bin, args, { cwd, env = process.env, timeoutMs = 0, s // The whole process group: a test runner's children die with it. const kill = () => { try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* gone */ } } }; const timer = timeoutMs > 0 ? setTimeout(() => { timedOut = true; kill(); }, timeoutMs) : null; - child.stdout.on("data", (c) => { out = keep(out, c.toString()); }); - child.stderr.on("data", (c) => { errText = keep(errText, c.toString()); }); + // `onData` sees every byte, including what the capture cap later drops. + child.stdout.on("data", (c) => { const s = c.toString(); if (onData) onData(s); out = keep(out, s); }); + child.stderr.on("data", (c) => { const s = c.toString(); if (onData) onData(s); errText = keep(errText, s); }); const done = (result) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); resolve(result); }; child.on("error", (error) => done({ code: null, signal: null, stdout: out, stderr: errText, error: String(error.message || error), timedOut })); child.on("close", (code, signal) => done({ code, signal, stdout: out, stderr: errText, error: null, timedOut })); diff --git a/test/c0agent.test.mjs b/test/c0agent.test.mjs index 7af5060b..e41487ad 100644 --- a/test/c0agent.test.mjs +++ b/test/c0agent.test.mjs @@ -292,6 +292,9 @@ test("routing: the names that mean the c0mpute agent, and CLI flags", () => { for (const n of ["c0mpute", "C0mpute", "c0", "compute"]) assert.ok(isC0mputeAgent(n)); assert.ok(!isC0mputeAgent("claude")); assert.deepEqual(parseArgs(["-p", "fix", "it"]).print, "fix"); + // After `--` everything is prompt, flags included: a handoff brief can say anything. + assert.deepEqual(parseArgs(["-C", "/w", "--", "-y is not a flag here", "-p"]).words, ["-y is not a flag here", "-p"]); + assert.equal(parseArgs(["-C", "/w", "--", "-y"]).mode, null); assert.equal(parseArgs(["-p", "-m", "qwen3:4b"]).model, "qwen3:4b"); assert.equal(parseArgs(["--dangerously-skip-permissions"]).mode, "yolo"); assert.deepEqual(parseArgs(["agents", "show", "plan"]).manage, ["show", "plan"]); diff --git a/test/handoff-brief.test.mjs b/test/handoff-brief.test.mjs new file mode 100644 index 00000000..9941cdbe --- /dev/null +++ b/test/handoff-brief.test.mjs @@ -0,0 +1,297 @@ +// The handoff brief, redaction, evidence and checks, and the c0mpute target. +// +// The brief is what the next engine believes first, so the tests here are +// about provenance: a claim must never be dressed as an observation, a failing +// check must never read as a pass, and a secret must never reach the file. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { redact } from "../src/redact.mjs"; +import { + buildBrief, checksFor, collectEvidence, detectChecks, estimateTokens, packageManager, runChecks, verdict, +} from "../src/handoff-brief.mjs"; +import { buildTranscript, handoffCommand, parseHandoff, planHandoff, seedPrompt } from "../src/handoff.mjs"; +import { claudeProjectSlugs } from "../src/cost.mjs"; + +const FIXTURES = fileURLToPath(new URL("./fixtures/handoff", import.meta.url)); +const CWD = "/work/api"; + +function tmp(prefix = "moshcode-brief-") { + return mkdtempSync(path.join(tmpdir(), prefix)); +} + +function withHome(fn) { + const dir = tmp("moshcode-handoff-"); + const saved = { HOME: process.env.HOME, OPENFLEET_HOME: process.env.OPENFLEET_HOME, MOSHCODE_HOME: process.env.MOSHCODE_HOME }; + process.env.HOME = dir; + process.env.OPENFLEET_HOME = path.join(dir, ".openfleet"); + process.env.MOSHCODE_HOME = path.join(dir, ".moshcode"); + return Promise.resolve() + .then(() => fn(dir)) + .finally(() => { + for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } + rmSync(dir, { recursive: true, force: true }); + }); +} + +function plantClaude(home) { + const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(CWD)[0]); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "c41b501f-de0a-411d-90a9-1e858636a4c2.jsonl"), fs.readFileSync(path.join(FIXTURES, "claude-session.jsonl"), "utf8")); +} + +/* ------------------------------------------------------------------ redaction */ + +test("redaction removes the common secret shapes and says which", () => { + const secrets = [ + "sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123", + "sk-proj-abcdefghijklmnopqrstuvwxyz012345", + "ghp_abcdefghijklmnopqrstuvwxyz0123456789", + "AKIAIOSFODNN7EXAMPLE", + "ik_live_abcdef0123456789xyz", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + ]; + const text = [ + ...secrets.map((s) => `token ${s} here`), + "curl -H 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345' https://api", + "DATABASE_URL=postgres://app:hunter2hunter2@db:5432/app", + "OPENAI_API_KEY=notarealkeybutlongenough", + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAA\n-----END OPENSSH PRIVATE KEY-----", + ].join("\n"); + const { text: out, hits } = redact(text); + for (const s of secrets) assert.ok(!out.includes(s), `${s} survived`); + assert.ok(!out.includes("hunter2hunter2")); + assert.ok(!out.includes("notarealkeybutlongenough")); + assert.ok(!out.includes("b3BlbnNzaC1rZXktdjEAAAA")); + assert.match(out, /Bearer \[redacted:token\]/); + assert.match(out, /postgres:\/\/app:\[redacted:password\]@db/); + assert.equal(hits["private-key"], 1); + assert.ok(Object.values(hits).reduce((a, b) => a + b, 0) >= 10); +}); + +test("redaction leaves prose about secrets alone", () => { + const prose = "Set the API key in the settings page. The token field is optional; password resets go by email."; + assert.deepEqual(redact(prose), { text: prose, hits: {} }); +}); + +test("the transcript is redacted and counts what it removed", () => { + const doc = buildTranscript({ + engine: "claude", id: "x", cwd: CWD, start: 0, end: 1, changes: [], + messages: [{ role: "user", text: "use GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789", at: 0 }], + }); + assert.ok(!doc.messages[0].text.includes("ghp_")); + assert.equal(doc.redactions.total, 1); +}); + +/* --------------------------------------------------------------------- checks */ + +test("checks come from the project, by its own package manager, and skip npm's placeholder", () => { + const dir = tmp(); + try { + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ scripts: { lint: "eslint .", test: "node --test", build: "x" } })); + fs.writeFileSync(path.join(dir, "pnpm-lock.yaml"), ""); + assert.equal(packageManager(dir), "pnpm"); + assert.deepEqual(detectChecks(dir).map((c) => [c.label, c.bin, ...c.args]), [["lint", "pnpm", "run", "lint"], ["test", "pnpm", "run", "test"]]); + + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ scripts: { test: "echo \"Error: no test specified\" && exit 1" } })); + assert.deepEqual(detectChecks(dir), []); + + fs.writeFileSync(path.join(dir, "Cargo.toml"), "[package]\n"); + assert.deepEqual(detectChecks(dir).map((c) => c.label), ["cargo check", "cargo test"]); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("--verify values: auto finds, none is nothing, a command is an argv with no shell", () => { + assert.deepEqual(checksFor(["none"], "/nonexistent"), []); + assert.deepEqual(checksFor(["make check"], "/x"), [{ label: "make check", bin: "make", args: ["check"] }]); + assert.equal(parseHandoff(["claude", "codex", "--verify"]).verify[0], "auto"); + assert.deepEqual(parseHandoff(["--verify", "claude", "codex"]).verify, ["auto"]); + assert.equal(parseHandoff(["--verify", "claude", "codex"]).from, "claude", "--verify must not swallow an engine"); + assert.deepEqual(parseHandoff(["claude", "codex", "--verify", "none"]).verify, ["none"]); + assert.deepEqual(parseHandoff(["claude", "codex", "--verify=pnpm test", "--verify=pnpm lint"]).verify, ["pnpm test", "pnpm lint"]); + assert.match(parseHandoff(["claude", "codex", "--budget", "10"]).error, /400 or more/); + assert.equal(parseHandoff(["claude", "codex", "--budget", "2000"]).budget, 2000); +}); + +test("a check's real exit code is kept, and its output is redacted", async () => { + const dir = tmp(); + try { + const results = await runChecks([ + { label: "ok", bin: process.execPath, args: ["-e", "process.exit(0)"] }, + { label: "bad", bin: process.execPath, args: ["-e", "console.log('leaked sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123'); process.exit(3)"] }, + { label: "missing", bin: "definitely-not-a-binary-moshcode", args: [] }, + ], { cwd: dir }); + assert.equal(results[0].code, 0); + assert.equal(verdict(results[0]), "passed"); + assert.equal(results[1].code, 3); + assert.equal(verdict(results[1]), "failed, exit 3"); + assert.ok(!results[1].tail.includes("sk-ant-")); + assert.match(results[1].tail, /\[redacted:anthropic-key\]/); + assert.match(verdict(results[2]), /did not run/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("a failed check carries the lines that name the failures, not just the runner's summary", async () => { + const { failureLines } = await import("../src/handoff-brief.mjs"); + const output = [ + "ok 1 - parses", "not ok 2 - retries after a 503", " AssertionError: expected 3", ...Array.from({ length: 40 }, (_, i) => `ok ${i + 3} - fine`), + "# pass 41", "# fail 1", + ].join("\n"); + const tail = failureLines(output); + assert.match(tail, /not ok 2 - retries after a 503/); + assert.match(tail, /AssertionError/); + assert.match(tail, /# fail 1/); + assert.ok(!tail.includes("ok 20 - fine")); +}); + +test("a failure printed early survives a run whose output overflows the capture", async () => { + const dir = tmp(); + try { + // ~600 KB after the failure line, well past runProcess's 256 KB tail. + const script = "console.log('not ok 7 - the early one'); for (let i = 0; i < 20000; i++) console.log('ok ' + i + ' - filler filler filler'); process.exit(1)"; + const [r] = await runChecks([{ label: "t", bin: process.execPath, args: ["-e", script] }], { cwd: dir }); + assert.equal(r.code, 1); + assert.match(r.tail, /not ok 7 - the early one/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +/* ------------------------------------------------------------------- evidence */ + +test("evidence is what git says: branch, uncommitted, commits since the start", async () => { + const dir = tmp(); + try { + const git = (...args) => execFileSync("git", args, { cwd: dir, stdio: "pipe", env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } }); + git("init", "-q", "-b", "main"); + fs.writeFileSync(path.join(dir, "a.txt"), "1\n"); + git("add", "."); + git("commit", "-q", "-m", "first"); + fs.writeFileSync(path.join(dir, "a.txt"), "2\n"); + fs.writeFileSync(path.join(dir, "new.txt"), "n\n"); + const ev = await collectEvidence(dir, { since: Date.now() - 60_000 }); + assert.equal(ev.branch, "main"); + assert.equal(ev.status.length, 2); + assert.equal(ev.untracked, 1); + assert.deepEqual(ev.commits.map((c) => c.replace(/^\w+ /, "")), ["first"]); + assert.ok(ev.diffstat.some((l) => l.includes("a.txt"))); + assert.equal(await collectEvidence(tmpdir()).then((e) => e === null || typeof e === "object"), true); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("outside a repository there is no evidence, not an error", async () => { + const dir = tmp(); + try { assert.equal(await collectEvidence(dir), null); } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +/* ---------------------------------------------------------------------- brief */ + +const DOC = buildTranscript({ + engine: "claude", id: "s1", cwd: CWD, start: 0, end: 1000, changes: ["/work/api/src/q.mjs"], + messages: [ + { role: "user", text: "Build a retry queue for webhooks.", at: 0 }, + { role: "assistant", text: "Started on src/q.mjs.", at: 1 }, + { role: "user", text: "Add dead letters after 5 tries.", at: 2 }, + { role: "assistant", text: "Done. All tests pass.", at: 3 }, + ], +}); + +test("the brief tags every section by where it came from", () => { + const brief = buildBrief(DOC, { + evidence: { branch: "main", head: "abc123", status: [" M src/q.mjs"], untracked: 0, diffstat: [" src/q.mjs | 4 ++--"], commits: [] }, + checks: [{ label: "test", command: "pnpm run test", code: 1, timedOut: false, error: null, tail: "1 failing" }], + transcriptFile: "/h/t.json", + }); + assert.match(brief, /## Goal \[user\][\s\S]*retry queue/); + assert.match(brief, /## Last report from claude \[agent-reported\][\s\S]*All tests pass/); + // The agent's claim and the observation that contradicts it, both labelled. + assert.match(brief, /`pnpm run test`: failed, exit 1 \[observed\]/); + assert.match(brief, /Failing checks \(test\) come first.*\[inferred\]/); + assert.match(brief, /## Working tree \[observed\][\s\S]*M src\/q\.mjs/); + assert.match(brief, /## Recent requests, newest first \[user\][\s\S]*dead letters/); + assert.match(brief, /\/h\/t\.json/); +}); + +test("the brief stays inside its budget however long the session was", () => { + const long = "word ".repeat(20_000); + const doc = buildTranscript({ + engine: "codex", id: "s", cwd: CWD, start: 0, end: 1, changes: [], + messages: Array.from({ length: 40 }, (_, i) => ({ role: i % 2 ? "assistant" : "user", text: long, at: i })), + }); + for (const budget of [400, 1000, 4000]) { + const brief = buildBrief(doc, { budget, transcriptFile: "/h/t.json" }); + assert.ok(estimateTokens(brief) <= budget + 40, `budget ${budget}: got ${estimateTokens(brief)}`); + assert.match(brief, /\/h\/t\.json/, "the pointer to the full record survives any clamp"); + } +}); + +test("without git, the files the engine wrote are listed as its own report", () => { + const brief = buildBrief(DOC, {}); + assert.match(brief, /## Files the previous engine wrote \[agent-reported\][\s\S]*src\/q\.mjs/); +}); + +test("the seed names the brief first and stays one line", () => { + const prompt = seedPrompt(DOC, "/h/t.json", "/h/t.md"); + assert.ok(!prompt.includes("\n")); + assert.ok(prompt.indexOf("/h/t.md") < prompt.indexOf("/h/t.json")); + assert.match(prompt, /\[observed\]/); +}); + +/* ------------------------------------------------------------------- command */ + +test("the command writes a 0600 brief with evidence and check results", async () => { + await withHome(async () => { + plantClaude(process.env.HOME); + const out = []; + const errors = []; + const code = await handoffCommand(["claude", "codex", "--cwd", CWD, "--dry-run", "--json", "--verify=fake test", "--budget", "1200"], { + write: (l) => out.push(l), fail: (l) => errors.push(l), + launch: () => { throw new Error("a dry run must not launch"); }, + evidenceOf: async () => ({ branch: "feat", head: "abc", status: [" M x"], untracked: 0, diffstat: [], commits: ["abc wip"] }), + check: async (checks) => checks.map((c) => ({ label: c.label, command: [c.bin, ...c.args].join(" "), code: 2, timedOut: false, error: null, ms: 5, tail: "boom" })), + }); + assert.equal(code, 0, errors.join("\n")); + const r = JSON.parse(out.join("\n")); + assert.equal(r.budget, 1200); + assert.ok(r.brief_tokens <= 1240); + assert.deepEqual(r.checks.map((c) => c.verdict), ["failed, exit 2"]); + assert.deepEqual(r.evidence, { branch: "feat", head: "abc", uncommitted: 1, commits: 1 }); + assert.equal(fs.statSync(r.brief).mode & 0o077, 0); + const brief = fs.readFileSync(r.brief, "utf8"); + assert.match(brief, /`fake test`: failed, exit 2 \[observed\]/); + const doc = JSON.parse(fs.readFileSync(r.transcript, "utf8")); + assert.equal(doc.checks[0].code, 2); + assert.equal(doc.evidence.branch, "feat"); + assert.ok(r.argv[1].includes(r.brief), "the seed points at the brief"); + }); +}); + +test("c0mpute is a target: planned without a binary, launched in process with the brief inline", async () => { + const plan = planHandoff({ from: "claude", to: "c0mpute" }); + assert.equal(plan.to, "c0mpute"); + assert.equal(planHandoff({ from: "cc", to: "c0" }).to, "c0mpute"); + assert.match(planHandoff({ from: "omp", to: "c0mpute" }).error, /cannot be handed off from/); + + await withHome(async () => { + plantClaude(process.env.HOME); + const lines = []; + const got = []; + const code = await handoffCommand(["claude", "c0mpute", "--cwd", CWD], { + write: (l) => lines.push(l), fail: (l) => lines.push(l), + launch: () => { throw new Error("c0mpute must not be spawned as a binary"); }, + launchC0mpute: async (prompt, opts) => { got.push([prompt, opts]); return 0; }, + evidenceOf: async () => null, + }); + assert.equal(code, 0, lines.join("\n")); + assert.equal(got.length, 1); + const [prompt, opts] = got[0]; + assert.match(prompt, /# Handoff from claude/); + assert.match(prompt, /\[user\]/); + assert.ok(opts.cwd); + assert.ok(lines.some((l) => /network peers/.test(l)), "the operator is told where the prompt goes"); + }); +}); diff --git a/test/handoff.test.mjs b/test/handoff.test.mjs index f4fb6b66..2afcb8d1 100644 --- a/test/handoff.test.mjs +++ b/test/handoff.test.mjs @@ -233,7 +233,7 @@ test("a --session that matches nothing refuses rather than falling back to the n test("every engine is either seedable or refused by name, and none is left out", () => { const seedable = targets(); - assert.deepEqual(seedable, ["opencode", "privacycode", "claude", "codex", "gemini", "qwen", "omp"]); + assert.deepEqual(seedable, ["opencode", "privacycode", "claude", "codex", "gemini", "qwen", "omp", "c0mpute"]); for (const [key, seed] of Object.entries(SEEDS)) { assert.ok(seed.argv || seed.unsupported, `${key} is neither seedable nor refused`); assert.ok(!(seed.argv && seed.unsupported), `${key} is both`); @@ -297,7 +297,7 @@ test("the seed is one line, because a pane submits a prompt on the first newline const prompt = seedPrompt(doc, "/tmp/h.json"); assert.ok(!prompt.includes("\n"), "a newline in the seed submits it early"); assert.match(prompt, /\/tmp\/h\.json/); - assert.match(prompt, /portable transcript, version 0\.1/); + assert.match(prompt, /portable transcript, version 0\.2/); assert.match(prompt, /working tree is the real state/); }); @@ -327,7 +327,7 @@ test("the command writes the transcript and builds the launch, without launching assert.equal(result.transcript, path.join(home, ".moshcode", "handoffs", `${result.handoff}.json`)); const doc = JSON.parse(fs.readFileSync(result.transcript, "utf8")); - assert.equal(doc.portable_transcript, "0.1"); + assert.equal(doc.portable_transcript, "0.2"); assert.equal(doc.messages.length, 4); // A whole conversation about someone's code is not world-readable. assert.equal(fs.statSync(result.transcript).mode & 0o077, 0);