src / handoff.ts
src / handoff.ts
// Handing one session's work to a fresh one. A long session's view of its own work drifts as
// the context is compacted: it misremembers what was applied, what it ruled out, what broke. A
// handoff fixes that by pairing the model's judgment (the goal, what was ruled out, the next
// step) with evidence simpleFiles gathers itself and recomputes every time the handoff is read,
// so the facts can never go stale in the saved file.
//
// write_handoff (a tool) saves the model's judgment. /files handoff and /files resume (user
// commands) print the handoff, evidence first-hand and fresh. The file lives in simpleFiles'
// own data folder, never in the project, so it cannot end up in a commit or an upload.
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { codeHash, lastTestRun, pluginAt, pluginLog } from "./dev";
import { appliedList, pendingList } from "./edits";
import { hasMarker } from "./projects";
import { folderInText, isInside, Scope } from "./scope";
import { dataDir, readJson, stamp, writeJson } from "./store";
interface Judgment {
root: string;
goal: string;
ruledOut: string;
nextStep: string;
at: string;
}
const dir = () => path.join(dataDir(), "handoffs");
const slug = (root: string) => crypto.createHash("sha256").update(root.toLowerCase()).digest("hex").slice(0, 16);
const file = (root: string) => path.join(dir(), `${slug(root)}.json`);
// The last few lines of simpleFiles' own change log, which records every applied change, undo
// and install across sessions.
function recentChanges(limit = 12): string[] {
try {
const lines = fs.readFileSync(path.join(dataDir(), "changes.md"), "utf8").split("\n").filter((l) => l.startsWith("- "));
return lines.slice(-limit);
} catch {
return [];
}
}
// Where a handoff belongs: the folder named, or, when none is, the folder the latest changes
// were in. A file stands for its folder. Any folder inside a granted folder will do, so a
// repository that holds several projects can carry one handoff at its root.
export function workRoot(scope: Scope, input: string): { root: string } | { error: string } {
const text = (input ?? "").trim();
if (!text) return inferRoot(scope);
let r = scope.resolve(text, "read");
if ("error" in r) {
const named = folderInText(text);
if (!named) return { error: r.error };
r = scope.resolve(named.folder, "read");
if ("error" in r) return { error: r.error };
}
try {
return { root: fs.statSync(r.path).isFile() ? path.dirname(r.path) : r.path };
} catch {
return { error: `${r.path} does not exist.` };
}
}
// The folder of the most recent proposals or changes, widened to the nearest folder above it that
// already has a handoff, else holds a repository (.git), else is a project.
function inferRoot(scope: Scope): { root: string } | { error: string } {
const recent = [...pendingList().map((p) => p.path), ...appliedList().map((a) => a.path).reverse()];
for (const p of recent) {
const grant = scope.grantFor(p);
if (!grant) continue;
const chain: string[] = [];
for (let d = path.dirname(p); isInside(d, grant.folder); d = path.dirname(d)) {
chain.push(d);
if (d === path.dirname(d)) break;
}
const pick =
chain.find((d) => fs.existsSync(file(d))) ??
chain.find((d) => fs.existsSync(path.join(d, ".git"))) ??
chain.find((d) => hasMarker(d)) ??
chain[0];
if (pick) return { root: pick };
}
return { error: "No folder was named and there are no recent changes to tell which work this is. Send the command again with the folder's full path." };
}
// Projects directly inside a folder that is not one itself.
function projectsIn(root: string): string[] {
if (hasMarker(root)) return [root];
try {
return fs
.readdirSync(root, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && hasMarker(path.join(root, e.name)))
.map((e) => path.join(root, e.name));
} catch {
return [];
}
}
// The verified state of the work right now, gathered first-hand.
function evidence(scope: Scope, root: string): string[] {
const out: string[] = [];
const pending = pendingList().filter((p) => within(p.path, root));
out.push(`waiting for approval: ${pending.length ? pending.map((p) => `#${p.id} ${p.kind} ${p.path.split(/[\\/]/).pop()}`).join(", ") : "none"}`);
const applied = appliedList().filter((a) => within(a.path, root)).slice(-8);
out.push(`applied here (oldest first): ${applied.length ? applied.map((a) => `#${a.id} ${a.kind} ${a.path.split(/[\\/]/).pop()}`).join(", ") : "none recorded"}`);
const projects = projectsIn(root);
for (const proj of projects) {
const label = proj === root ? "" : ` (${path.basename(proj)})`;
const run = lastTestRun(proj);
if (run) {
const current = run.code === codeHash(proj);
out.push(`last test run${label}: ${run.passed ? "passed" : "failed"} at ${run.at}${current ? "" : ", but the code has changed since, so run the tests again"}`);
if (!run.passed) out.push(...run.summary.split("\n").slice(0, 8).map((l) => ` ${l}`));
} else {
out.push(`last test run${label}: none recorded`);
}
const info = pluginAt(scope, proj);
if (!("error" in info)) {
const log = pluginLog(info.id, 12);
if (/status: ok/.test(log)) {
out.push(`${info.id} recent log:`);
out.push(...log.split("\n").filter((l) => /^\d{2}:\d{2}:\d{2} /.test(l)).slice(-10).map((l) => ` ${l}`));
}
}
}
if (!projects.length) out.push("no project (package.json, pyproject.toml, Makefile or plugin manifest) in this folder or just inside it");
if (fs.existsSync(path.join(root, ".git"))) out.push("this folder is a git repository; its commits are not checked here");
const changes = recentChanges().filter((l) => l.toLowerCase().replace(/\\/g, "/").includes(root.toLowerCase().replace(/\\/g, "/")));
if (changes.length) {
out.push("recent change log:");
out.push(...changes.map((l) => ` ${l}`));
}
return out;
}
const within = (p: string, root: string) => {
const a = p.toLowerCase().replace(/\\/g, "/");
const b = root.toLowerCase().replace(/\\/g, "/");
return a === b || a.startsWith(b.endsWith("/") ? b : b + "/");
};
function render(j: Judgment | null, scope: Scope, root: string): string {
const lines = [`# Handoff: ${root}`, ""];
if (j) {
lines.push(`Written ${j.at}.`, "", "## Goal", j.goal || "(not given)", "", "## Tried and ruled out", j.ruledOut || "(not given)", "", "## Next step", j.nextStep || "(not given)", "");
} else {
lines.push("No handoff has been written for this folder yet. The verified state is below; the goal, what was ruled out, and the next step are missing.", "");
}
lines.push("## Verified state (gathered now, not from memory)", ...evidence(scope, root));
return lines.join("\n");
}
// The tool: the model records its judgment; the evidence is added when the handoff is read.
export function writeHandoff(scope: Scope, folder: string, goal: string, ruledOut: string, nextStep: string): string {
const where = workRoot(scope, folder);
if ("error" in where) return `status: error\nreason: ${where.error}`;
if (!goal.trim()) return "status: error\nreason: A handoff needs at least a goal: what the work is trying to do.\nnext: Call write_handoff again with goal set.";
const j: Judgment = { root: where.root, goal: goal.trim(), ruledOut: (ruledOut ?? "").trim(), nextStep: (nextStep ?? "").trim(), at: stamp() };
try {
fs.mkdirSync(dir(), { recursive: true });
writeJson(file(where.root), j);
} catch (e) {
return `status: error\nreason: the handoff could not be saved (${(e as Error).message}).`;
}
return [
"status: ok",
`The handoff is saved. In a new chat, the user opens it with /files resume (the folder is optional).`,
"",
render(j, scope, where.root),
].join("\n");
}
const load = (root: string) => readJson<Judgment | null>(file(root), null);
// Every saved handoff, newest first.
function allHandoffs(): Judgment[] {
let names: string[] = [];
try {
names = fs.readdirSync(dir()).filter((n) => n.endsWith(".json"));
} catch {
return [];
}
return names
.map((n) => readJson<Judgment | null>(path.join(dir(), n), null))
.filter((j): j is Judgment => !!j && typeof j.root === "string")
.sort((a, b) => (a.at < b.at ? 1 : -1));
}
// The handoff a resume means: the folder's own, else the nearest one above it, else the only one
// below it; with no folder named, the newest one inside the granted folders.
function findHandoff(scope: Scope, input: string): { root: string; j: Judgment | null } | { error: string } {
const saved = allHandoffs().filter((j) => scope.grantFor(j.root));
if (!input.trim()) {
if (saved.length) return { root: saved[0].root, j: saved[0] };
return { error: "No handoff has been saved in the granted folders yet. In the old chat, send /files handoff, or ask the model to call write_handoff." };
}
const where = workRoot(scope, input);
if ("error" in where) return where;
const own = load(where.root);
if (own) return { root: where.root, j: own };
const above = saved.filter((j) => isInside(where.root, j.root)).sort((a, b) => b.root.length - a.root.length)[0];
if (above) return { root: above.root, j: above };
const below = saved.filter((j) => isInside(j.root, where.root));
if (below.length === 1) return { root: below[0].root, j: below[0] };
return { root: where.root, j: null };
}
// /files handoff [folder]: show the current handoff and evidence, and tell the model to record
// or update the judgment parts.
export function handoffCommand(scope: Scope, input: string): string {
const where = workRoot(scope, input);
if ("error" in where) return where.error;
const j = load(where.root);
return [
render(j, scope, where.root),
"",
j
? `[To update the goal, what was ruled out, or the next step, call write_handoff with folder ${where.root}. The verified state above is always recomputed fresh.]`
: `[Call write_handoff with folder ${where.root} and the goal, what was tried and ruled out, and the next step, to record the judgment that the verified state above cannot capture.]`,
].join("\n");
}
// /files resume [folder]: print the handoff into a new chat, with the state recomputed now.
export function resumeCommand(scope: Scope, input: string): string {
const found = findHandoff(scope, input);
if ("error" in found) return found.error;
return [
render(found.j, scope, found.root),
"",
"[Resuming earlier work. Confirm the verified state above with your own tools (read_file, list_proposals, run_script, lessons) before changing anything. The goal and ruled-out notes are the previous session's judgment; the evidence is current.]",
].join("\n");
}
export function hasHandoff(root: string): boolean {
return fs.existsSync(file(root));
}
// Handing one session's work to a fresh one. A long session's view of its own work drifts as
// the context is compacted: it misremembers what was applied, what it ruled out, what broke. A
// handoff fixes that by pairing the model's judgment (the goal, what was ruled out, the next
// step) with evidence simpleFiles gathers itself and recomputes every time the handoff is read,
// so the facts can never go stale in the saved file.
//
// write_handoff (a tool) saves the model's judgment. /files handoff and /files resume (user
// commands) print the handoff, evidence first-hand and fresh. The file lives in simpleFiles'
// own data folder, never in the project, so it cannot end up in a commit or an upload.
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { codeHash, lastTestRun, pluginAt, pluginLog } from "./dev";
import { appliedList, pendingList } from "./edits";
import { hasMarker } from "./projects";
import { folderInText, isInside, Scope } from "./scope";
import { dataDir, readJson, stamp, writeJson } from "./store";
interface Judgment {
root: string;
goal: string;
ruledOut: string;
nextStep: string;
at: string;
}
const dir = () => path.join(dataDir(), "handoffs");
const slug = (root: string) => crypto.createHash("sha256").update(root.toLowerCase()).digest("hex").slice(0, 16);
const file = (root: string) => path.join(dir(), `${slug(root)}.json`);
// The last few lines of simpleFiles' own change log, which records every applied change, undo
// and install across sessions.
function recentChanges(limit = 12): string[] {
try {
const lines = fs.readFileSync(path.join(dataDir(), "changes.md"), "utf8").split("\n").filter((l) => l.startsWith("- "));
return lines.slice(-limit);
} catch {
return [];
}
}
// Where a handoff belongs: the folder named, or, when none is, the folder the latest changes
// were in. A file stands for its folder. Any folder inside a granted folder will do, so a
// repository that holds several projects can carry one handoff at its root.
export function workRoot(scope: Scope, input: string): { root: string } | { error: string } {
const text = (input ?? "").trim();
if (!text) return inferRoot(scope);
let r = scope.resolve(text, "read");
if ("error" in r) {
const named = folderInText(text);
if (!named) return { error: r.error };
r = scope.resolve(named.folder, "read");
if ("error" in r) return { error: r.error };
}
try {
return { root: fs.statSync(r.path).isFile() ? path.dirname(r.path) : r.path };
} catch {
return { error: `${r.path} does not exist.` };
}
}
// The folder of the most recent proposals or changes, widened to the nearest folder above it that
// already has a handoff, else holds a repository (.git), else is a project.
function inferRoot(scope: Scope): { root: string } | { error: string } {
const recent = [...pendingList().map((p) => p.path), ...appliedList().map((a) => a.path).reverse()];
for (const p of recent) {
const grant = scope.grantFor(p);
if (!grant) continue;
const chain: string[] = [];
for (let d = path.dirname(p); isInside(d, grant.folder); d = path.dirname(d)) {
chain.push(d);
if (d === path.dirname(d)) break;
}
const pick =
chain.find((d) => fs.existsSync(file(d))) ??
chain.find((d) => fs.existsSync(path.join(d, ".git"))) ??
chain.find((d) => hasMarker(d)) ??
chain[0];
if (pick) return { root: pick };
}
return { error: "No folder was named and there are no recent changes to tell which work this is. Send the command again with the folder's full path." };
}
// Projects directly inside a folder that is not one itself.
function projectsIn(root: string): string[] {
if (hasMarker(root)) return [root];
try {
return fs
.readdirSync(root, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules" && hasMarker(path.join(root, e.name)))
.map((e) => path.join(root, e.name));
} catch {
return [];
}
}
// The verified state of the work right now, gathered first-hand.
function evidence(scope: Scope, root: string): string[] {
const out: string[] = [];
const pending = pendingList().filter((p) => within(p.path, root));
out.push(`waiting for approval: ${pending.length ? pending.map((p) => `#${p.id} ${p.kind} ${p.path.split(/[\\/]/).pop()}`).join(", ") : "none"}`);
const applied = appliedList().filter((a) => within(a.path, root)).slice(-8);
out.push(`applied here (oldest first): ${applied.length ? applied.map((a) => `#${a.id} ${a.kind} ${a.path.split(/[\\/]/).pop()}`).join(", ") : "none recorded"}`);
const projects = projectsIn(root);
for (const proj of projects) {
const label = proj === root ? "" : ` (${path.basename(proj)})`;
const run = lastTestRun(proj);
if (run) {
const current = run.code === codeHash(proj);
out.push(`last test run${label}: ${run.passed ? "passed" : "failed"} at ${run.at}${current ? "" : ", but the code has changed since, so run the tests again"}`);
if (!run.passed) out.push(...run.summary.split("\n").slice(0, 8).map((l) => ` ${l}`));
} else {
out.push(`last test run${label}: none recorded`);
}
const info = pluginAt(scope, proj);
if (!("error" in info)) {
const log = pluginLog(info.id, 12);
if (/status: ok/.test(log)) {
out.push(`${info.id} recent log:`);
out.push(...log.split("\n").filter((l) => /^\d{2}:\d{2}:\d{2} /.test(l)).slice(-10).map((l) => ` ${l}`));
}
}
}
if (!projects.length) out.push("no project (package.json, pyproject.toml, Makefile or plugin manifest) in this folder or just inside it");
if (fs.existsSync(path.join(root, ".git"))) out.push("this folder is a git repository; its commits are not checked here");
const changes = recentChanges().filter((l) => l.toLowerCase().replace(/\\/g, "/").includes(root.toLowerCase().replace(/\\/g, "/")));
if (changes.length) {
out.push("recent change log:");
out.push(...changes.map((l) => ` ${l}`));
}
return out;
}
const within = (p: string, root: string) => {
const a = p.toLowerCase().replace(/\\/g, "/");
const b = root.toLowerCase().replace(/\\/g, "/");
return a === b || a.startsWith(b.endsWith("/") ? b : b + "/");
};
function render(j: Judgment | null, scope: Scope, root: string): string {
const lines = [`# Handoff: ${root}`, ""];
if (j) {
lines.push(`Written ${j.at}.`, "", "## Goal", j.goal || "(not given)", "", "## Tried and ruled out", j.ruledOut || "(not given)", "", "## Next step", j.nextStep || "(not given)", "");
} else {
lines.push("No handoff has been written for this folder yet. The verified state is below; the goal, what was ruled out, and the next step are missing.", "");
}
lines.push("## Verified state (gathered now, not from memory)", ...evidence(scope, root));
return lines.join("\n");
}
// The tool: the model records its judgment; the evidence is added when the handoff is read.
export function writeHandoff(scope: Scope, folder: string, goal: string, ruledOut: string, nextStep: string): string {
const where = workRoot(scope, folder);
if ("error" in where) return `status: error\nreason: ${where.error}`;
if (!goal.trim()) return "status: error\nreason: A handoff needs at least a goal: what the work is trying to do.\nnext: Call write_handoff again with goal set.";
const j: Judgment = { root: where.root, goal: goal.trim(), ruledOut: (ruledOut ?? "").trim(), nextStep: (nextStep ?? "").trim(), at: stamp() };
try {
fs.mkdirSync(dir(), { recursive: true });
writeJson(file(where.root), j);
} catch (e) {
return `status: error\nreason: the handoff could not be saved (${(e as Error).message}).`;
}
return [
"status: ok",
`The handoff is saved. In a new chat, the user opens it with /files resume (the folder is optional).`,
"",
render(j, scope, where.root),
].join("\n");
}
const load = (root: string) => readJson<Judgment | null>(file(root), null);
// Every saved handoff, newest first.
function allHandoffs(): Judgment[] {
let names: string[] = [];
try {
names = fs.readdirSync(dir()).filter((n) => n.endsWith(".json"));
} catch {
return [];
}
return names
.map((n) => readJson<Judgment | null>(path.join(dir(), n), null))
.filter((j): j is Judgment => !!j && typeof j.root === "string")
.sort((a, b) => (a.at < b.at ? 1 : -1));
}
// The handoff a resume means: the folder's own, else the nearest one above it, else the only one
// below it; with no folder named, the newest one inside the granted folders.
function findHandoff(scope: Scope, input: string): { root: string; j: Judgment | null } | { error: string } {
const saved = allHandoffs().filter((j) => scope.grantFor(j.root));
if (!input.trim()) {
if (saved.length) return { root: saved[0].root, j: saved[0] };
return { error: "No handoff has been saved in the granted folders yet. In the old chat, send /files handoff, or ask the model to call write_handoff." };
}
const where = workRoot(scope, input);
if ("error" in where) return where;
const own = load(where.root);
if (own) return { root: where.root, j: own };
const above = saved.filter((j) => isInside(where.root, j.root)).sort((a, b) => b.root.length - a.root.length)[0];
if (above) return { root: above.root, j: above };
const below = saved.filter((j) => isInside(j.root, where.root));
if (below.length === 1) return { root: below[0].root, j: below[0] };
return { root: where.root, j: null };
}
// /files handoff [folder]: show the current handoff and evidence, and tell the model to record
// or update the judgment parts.
export function handoffCommand(scope: Scope, input: string): string {
const where = workRoot(scope, input);
if ("error" in where) return where.error;
const j = load(where.root);
return [
render(j, scope, where.root),
"",
j
? `[To update the goal, what was ruled out, or the next step, call write_handoff with folder ${where.root}. The verified state above is always recomputed fresh.]`
: `[Call write_handoff with folder ${where.root} and the goal, what was tried and ruled out, and the next step, to record the judgment that the verified state above cannot capture.]`,
].join("\n");
}
// /files resume [folder]: print the handoff into a new chat, with the state recomputed now.
export function resumeCommand(scope: Scope, input: string): string {
const found = findHandoff(scope, input);
if ("error" in found) return found.error;
return [
render(found.j, scope, found.root),
"",
"[Resuming earlier work. Confirm the verified state above with your own tools (read_file, list_proposals, run_script, lessons) before changing anything. The goal and ruled-out notes are the previous session's judgment; the evidence is current.]",
].join("\n");
}
export function hasHandoff(root: string): boolean {
return fs.existsSync(file(root));
}