@leantli/agent-handoff 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +177 -0
- package/dist/core.d.ts +103 -0
- package/dist/core.js +678 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +43 -0
- package/resources/agent-handoff.SKILL.md +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 agent-handoff contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# agent-handoff
|
|
2
|
+
|
|
3
|
+
`agent-handoff` gives Codex and Claude Code a shared memory handoff layer across
|
|
4
|
+
new sessions, fresh clones, git worktrees, and devices.
|
|
5
|
+
|
|
6
|
+
A long agent session often accumulates useful context: project background,
|
|
7
|
+
current task state, decisions, preferences, and repeated corrections. Without a
|
|
8
|
+
handoff layer, a new session or another device starts cold.
|
|
9
|
+
|
|
10
|
+
`agent-handoff` stores that context in a user-level vault, then lets any clone or
|
|
11
|
+
worktree of the same repository recover it by project identity.
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
~/.agent-handoff/vault/ # private user memory
|
|
15
|
+
repo/.agent-handoff.yml # lightweight project identity
|
|
16
|
+
repo/AGENTS.md # Codex bootstrap instruction
|
|
17
|
+
repo/CLAUDE.md # Claude Code bootstrap instruction
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
From npm:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g @leantli/agent-handoff
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
From a checkout:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install
|
|
32
|
+
npm run build
|
|
33
|
+
npm link
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Three-Minute Setup
|
|
37
|
+
|
|
38
|
+
Create your local vault once:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
agent-handoff setup
|
|
42
|
+
agent-handoff install-skill
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Optional: sync the vault through a private git repo so another device can share
|
|
46
|
+
the same handoff memory:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
agent-handoff setup --sync git@github.com:you/agent-handoff-vault.git
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Bootstrap each coding project once:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
agent-handoff init
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This writes:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
.agent-handoff.yml
|
|
62
|
+
AGENTS.md
|
|
63
|
+
CLAUDE.md
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
It does not write private memory into the project repository.
|
|
67
|
+
|
|
68
|
+
## Daily Workflow
|
|
69
|
+
|
|
70
|
+
At the start of a new Codex or Claude Code session:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
agent-handoff sync # only if vault sync is configured
|
|
74
|
+
agent-handoff start
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Paste or let the agent read the start packet before it works.
|
|
78
|
+
|
|
79
|
+
When a useful session is about to end, or before switching devices:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
agent-handoff checkpoint --note "Implemented vault storage; next step is README polish."
|
|
83
|
+
agent-handoff sync # only if vault sync is configured
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
When the user corrects a stable preference or recurring rule:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
agent-handoff learn --kind preference --note "Prefer TDD for behavior changes."
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For project-specific decisions or branch-specific context:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
agent-handoff learn --scope project --kind decision --note "Use vault-first storage."
|
|
96
|
+
agent-handoff learn --scope branch --kind context --note "Main is preparing v0.3."
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
When configured, `sync` commits pending vault changes and pushes them to the
|
|
100
|
+
private vault repository.
|
|
101
|
+
|
|
102
|
+
## Why This Solves Cross-Clone Context
|
|
103
|
+
|
|
104
|
+
`agent-handoff` identifies a project from `.agent-handoff.yml` or the git
|
|
105
|
+
`origin` remote. These all map to the same vault project:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
~/code/repo
|
|
109
|
+
~/tmp/repo
|
|
110
|
+
~/worktrees/repo-feature
|
|
111
|
+
another device's ~/projects/repo
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
For example, both remotes below normalize to the same project id:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
https://github.com/leantli/agent-handoff.git
|
|
118
|
+
git@github.com:leantli/agent-handoff.git
|
|
119
|
+
|
|
120
|
+
github.com__leantli__agent-handoff
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
That means A session can checkpoint context into the vault, and B session can
|
|
124
|
+
recover it from any clone or worktree that resolves to the same project id.
|
|
125
|
+
|
|
126
|
+
## What Gets Stored
|
|
127
|
+
|
|
128
|
+
The vault is private user state:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
~/.agent-handoff/
|
|
132
|
+
config.json
|
|
133
|
+
vault/
|
|
134
|
+
global/
|
|
135
|
+
preferences.md
|
|
136
|
+
lessons.md
|
|
137
|
+
projects/
|
|
138
|
+
github.com__owner__repo/
|
|
139
|
+
project.md
|
|
140
|
+
decisions.md
|
|
141
|
+
preferences.md
|
|
142
|
+
branches/
|
|
143
|
+
main.md
|
|
144
|
+
feature-demo.md
|
|
145
|
+
checkpoints/
|
|
146
|
+
20260508T103000Z-laptop-codex-main.md
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The project repository gets only bootstrap files:
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
.agent-handoff.yml
|
|
153
|
+
AGENTS.md
|
|
154
|
+
CLAUDE.md
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Commands
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
agent-handoff setup # create/configure the user vault
|
|
161
|
+
agent-handoff install-skill # install the agent workflow skill
|
|
162
|
+
agent-handoff init # bootstrap the current repo
|
|
163
|
+
agent-handoff start # print the context packet for a new session
|
|
164
|
+
agent-handoff checkpoint # write a session checkpoint
|
|
165
|
+
agent-handoff learn # write durable global/project/branch memory
|
|
166
|
+
agent-handoff sync # git pull/rebase + push the vault
|
|
167
|
+
agent-handoff status # quick readiness check
|
|
168
|
+
agent-handoff doctor # detailed health check
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Agent Skill
|
|
172
|
+
|
|
173
|
+
This repo includes a Codex-compatible skill:
|
|
174
|
+
|
|
175
|
+
```text
|
|
176
|
+
.agents/skills/agent-handoff/SKILL.md
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The skill tells an agent when to run `start`, `checkpoint`, `learn`, and `sync`.
|
|
180
|
+
Install it into your user skills directory to make the workflow available across
|
|
181
|
+
all repositories:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
agent-handoff install-skill
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The repo also keeps a copy at `.agents/skills/agent-handoff/SKILL.md` for
|
|
188
|
+
project-local use.
|
|
189
|
+
|
|
190
|
+
## Status
|
|
191
|
+
|
|
192
|
+
This is an early prototype. It does not read proprietary chat transcripts or
|
|
193
|
+
client-internal state. Agents must still call `start`, `checkpoint`, and `learn`
|
|
194
|
+
at the right moments, guided by `AGENTS.md` and `CLAUDE.md`.
|
|
195
|
+
|
|
196
|
+
## Development
|
|
197
|
+
|
|
198
|
+
Run tests:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
npm test
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Run type checking and build:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
npm run typecheck
|
|
208
|
+
npm run build
|
|
209
|
+
```
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
|
|
3
|
+
import { HandoffError, buildStartPacket, doctor, getStatus, initRepo, installSkill, learn, setupHome, syncVault, writeCheckpoint, } from "./core.js";
|
|
4
|
+
export function main(argv = process.argv.slice(2), opts = {}) {
|
|
5
|
+
const stdout = opts.stdout ?? process.stdout;
|
|
6
|
+
const stderr = opts.stderr ?? process.stderr;
|
|
7
|
+
const program = buildProgram(stdout, stderr, opts.stdin, opts.cwd);
|
|
8
|
+
try {
|
|
9
|
+
program.parse(argv, { from: "user" });
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error instanceof CommanderError) {
|
|
14
|
+
return error.exitCode;
|
|
15
|
+
}
|
|
16
|
+
if (error instanceof HandoffError) {
|
|
17
|
+
stderr.write(`${error.message}\n`);
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function buildProgram(stdout, stderr, stdin, cwd) {
|
|
24
|
+
const program = new Command();
|
|
25
|
+
program
|
|
26
|
+
.name("agent-handoff")
|
|
27
|
+
.description("Shared vault handoff memory for Codex and Claude Code.")
|
|
28
|
+
.version("agent-handoff 0.4.0")
|
|
29
|
+
.exitOverride()
|
|
30
|
+
.configureOutput({
|
|
31
|
+
writeOut: (str) => stdout.write(str),
|
|
32
|
+
writeErr: (str) => stderr.write(str),
|
|
33
|
+
})
|
|
34
|
+
.option("--home <path>", "Agent handoff home directory. Defaults to ~/.agent-handoff.");
|
|
35
|
+
program
|
|
36
|
+
.command("setup")
|
|
37
|
+
.description("Create or configure the user vault.")
|
|
38
|
+
.option("--vault <path>", "Vault directory. Defaults to HOME/vault.")
|
|
39
|
+
.option("--sync <url>", "Optional git remote URL for vault sync.")
|
|
40
|
+
.action((options) => {
|
|
41
|
+
const result = setupHome({ home: globalHome(program), vault: options.vault, syncUrl: options.sync });
|
|
42
|
+
stdout.write(`Agent handoff home: ${result.home}\n`);
|
|
43
|
+
stdout.write(`Vault: ${result.vault}\n`);
|
|
44
|
+
});
|
|
45
|
+
program
|
|
46
|
+
.command("install-skill")
|
|
47
|
+
.description("Install the agent-handoff skill into a user skills directory.")
|
|
48
|
+
.option("--skills-home <path>", "Skills home directory. Defaults to ~/.agents/skills.")
|
|
49
|
+
.action((options) => {
|
|
50
|
+
const result = installSkill({ skillsHome: options.skillsHome });
|
|
51
|
+
const verb = result.updated ? "Installed skill" : "Skill already installed";
|
|
52
|
+
stdout.write(`${verb}: ${result.path}\n`);
|
|
53
|
+
});
|
|
54
|
+
program
|
|
55
|
+
.command("init")
|
|
56
|
+
.description("Bootstrap this repo for agent handoff.")
|
|
57
|
+
.option("--project-id <id>", "Override detected project id.")
|
|
58
|
+
.option("--branch <branch>", "Override detected branch.")
|
|
59
|
+
.addOption(new Option("--client <client>", "Client bootstrap to install. Repeat to install multiple. Defaults to both.")
|
|
60
|
+
.choices(["codex", "claude"])
|
|
61
|
+
.argParser(collect))
|
|
62
|
+
.action((options) => {
|
|
63
|
+
const result = initRepo({
|
|
64
|
+
root: cwd,
|
|
65
|
+
home: globalHome(program),
|
|
66
|
+
projectId: options.projectId,
|
|
67
|
+
branch: options.branch,
|
|
68
|
+
clients: options.client,
|
|
69
|
+
});
|
|
70
|
+
stdout.write(`Initialized agent handoff for ${result.projectId}.\n`);
|
|
71
|
+
stdout.write(`Vault project: ${result.vaultProject}\n`);
|
|
72
|
+
});
|
|
73
|
+
program
|
|
74
|
+
.command("start")
|
|
75
|
+
.description("Print context for a new agent session.")
|
|
76
|
+
.option("--branch <branch>", "Override detected branch.")
|
|
77
|
+
.action((options) => {
|
|
78
|
+
stdout.write(`${buildStartPacket({ root: cwd, home: globalHome(program), branch: options.branch })}\n`);
|
|
79
|
+
});
|
|
80
|
+
program
|
|
81
|
+
.command("checkpoint")
|
|
82
|
+
.description("Write a session checkpoint.")
|
|
83
|
+
.option("--note <text>", "Note text. If omitted, stdin is used.")
|
|
84
|
+
.option("--file <path>", "Read note text from a file.")
|
|
85
|
+
.option("--device <name>", "Device name for the checkpoint.")
|
|
86
|
+
.option("--agent <name>", "Agent/client name, such as codex or claude.")
|
|
87
|
+
.option("--branch <branch>", "Override detected branch.")
|
|
88
|
+
.action((options) => {
|
|
89
|
+
const result = writeCheckpoint({
|
|
90
|
+
root: cwd,
|
|
91
|
+
home: globalHome(program),
|
|
92
|
+
note: readNote(options, stdin),
|
|
93
|
+
device: options.device,
|
|
94
|
+
agent: options.agent,
|
|
95
|
+
branch: options.branch,
|
|
96
|
+
});
|
|
97
|
+
stdout.write(`Wrote checkpoint: ${result.path}\n`);
|
|
98
|
+
});
|
|
99
|
+
program
|
|
100
|
+
.command("learn")
|
|
101
|
+
.description("Store durable handoff memory.")
|
|
102
|
+
.option("--note <text>", "Note text. If omitted, stdin is used.")
|
|
103
|
+
.option("--file <path>", "Read note text from a file.")
|
|
104
|
+
.addOption(new Option("--scope <scope>", "Where to store the learned memory.").choices(["global", "project", "branch"]).default("global"))
|
|
105
|
+
.addOption(new Option("--kind <kind>", "Kind of durable memory to write.").choices(["preference", "lesson", "decision", "context"]).default("preference"))
|
|
106
|
+
.option("--branch <branch>", "Branch to use with --scope branch.")
|
|
107
|
+
.action((options) => {
|
|
108
|
+
const result = learn(readNote(options, stdin), {
|
|
109
|
+
root: cwd,
|
|
110
|
+
home: globalHome(program),
|
|
111
|
+
scope: options.scope,
|
|
112
|
+
kind: options.kind,
|
|
113
|
+
branch: options.branch,
|
|
114
|
+
});
|
|
115
|
+
stdout.write(`Learned ${result.kind}: ${result.path}\n`);
|
|
116
|
+
});
|
|
117
|
+
program
|
|
118
|
+
.command("sync")
|
|
119
|
+
.description("Pull and push the vault git repository.")
|
|
120
|
+
.action(() => {
|
|
121
|
+
for (const output of syncVault({ home: globalHome(program) })) {
|
|
122
|
+
if (output)
|
|
123
|
+
stdout.write(`${output}\n`);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
program
|
|
127
|
+
.command("status")
|
|
128
|
+
.description("Show whether handoff is ready here.")
|
|
129
|
+
.action(() => {
|
|
130
|
+
const status = getStatus({ root: cwd, home: globalHome(program) });
|
|
131
|
+
if (status.initialized) {
|
|
132
|
+
stdout.write(`Agent handoff is ready for ${status.projectId}.\n`);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
printProblems(status.problems, stdout);
|
|
136
|
+
throw new CommanderError(1, "agent-handoff.status", "status failed");
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
program
|
|
140
|
+
.command("doctor")
|
|
141
|
+
.description("Check bootstrap and vault health.")
|
|
142
|
+
.action(() => {
|
|
143
|
+
const report = doctor({ root: cwd, home: globalHome(program) });
|
|
144
|
+
if (report.ok) {
|
|
145
|
+
stdout.write(`Agent handoff is healthy for ${report.projectId}.\n`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
printProblems(report.problems, stdout);
|
|
149
|
+
throw new CommanderError(1, "agent-handoff.doctor", "doctor failed");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return program;
|
|
153
|
+
}
|
|
154
|
+
function globalHome(program) {
|
|
155
|
+
return program.opts().home;
|
|
156
|
+
}
|
|
157
|
+
function collect(value, previous) {
|
|
158
|
+
if (value !== "codex" && value !== "claude") {
|
|
159
|
+
throw new InvalidArgumentError("client must be codex or claude");
|
|
160
|
+
}
|
|
161
|
+
return [...(previous ?? []), value];
|
|
162
|
+
}
|
|
163
|
+
function readNote(options, stdin) {
|
|
164
|
+
if (options.note)
|
|
165
|
+
return options.note;
|
|
166
|
+
if (options.file)
|
|
167
|
+
return readFileSync(options.file, "utf8");
|
|
168
|
+
if (stdin?.isTTY ?? process.stdin.isTTY) {
|
|
169
|
+
throw new HandoffError("provide --note or --file, or pipe note text on stdin");
|
|
170
|
+
}
|
|
171
|
+
return readFileSync(0, "utf8");
|
|
172
|
+
}
|
|
173
|
+
function printProblems(problems, stdout) {
|
|
174
|
+
for (const problem of problems) {
|
|
175
|
+
stdout.write(`- ${problem}\n`);
|
|
176
|
+
}
|
|
177
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export declare const DEFAULT_HOME: string;
|
|
2
|
+
export declare const CONFIG_FILE = "config.json";
|
|
3
|
+
export declare const BOOTSTRAP_FILE = ".agent-handoff.yml";
|
|
4
|
+
type Client = "codex" | "claude";
|
|
5
|
+
type LearnScope = "global" | "project" | "branch";
|
|
6
|
+
type LearnKind = "preference" | "lesson" | "decision" | "context";
|
|
7
|
+
export declare class HandoffError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
export interface SetupResult {
|
|
11
|
+
home: string;
|
|
12
|
+
vault: string;
|
|
13
|
+
created: number;
|
|
14
|
+
updated: number;
|
|
15
|
+
}
|
|
16
|
+
export interface InitResult {
|
|
17
|
+
created: number;
|
|
18
|
+
updated: number;
|
|
19
|
+
root: string;
|
|
20
|
+
projectId: string;
|
|
21
|
+
vaultProject: string;
|
|
22
|
+
clients: Client[];
|
|
23
|
+
}
|
|
24
|
+
export interface CheckpointResult {
|
|
25
|
+
path: string;
|
|
26
|
+
projectId: string;
|
|
27
|
+
branch: string;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
}
|
|
30
|
+
export interface LearnResult {
|
|
31
|
+
path: string;
|
|
32
|
+
kind: string;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
}
|
|
35
|
+
export interface InstallSkillResult {
|
|
36
|
+
path: string;
|
|
37
|
+
updated: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface Status {
|
|
40
|
+
initialized: boolean;
|
|
41
|
+
problems: string[];
|
|
42
|
+
root: string;
|
|
43
|
+
projectId: string | null;
|
|
44
|
+
}
|
|
45
|
+
export interface DoctorReport {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
problems: string[];
|
|
48
|
+
root: string;
|
|
49
|
+
projectId: string | null;
|
|
50
|
+
}
|
|
51
|
+
export declare function setupHome(opts?: {
|
|
52
|
+
home?: string;
|
|
53
|
+
vault?: string;
|
|
54
|
+
syncUrl?: string;
|
|
55
|
+
}): SetupResult;
|
|
56
|
+
export declare function normalizeProjectId(value: string): string;
|
|
57
|
+
export declare function installSkill(opts?: {
|
|
58
|
+
skillsHome?: string;
|
|
59
|
+
}): InstallSkillResult;
|
|
60
|
+
export declare function initRepo(opts?: {
|
|
61
|
+
root?: string;
|
|
62
|
+
home?: string;
|
|
63
|
+
projectId?: string;
|
|
64
|
+
branch?: string;
|
|
65
|
+
clients?: string[];
|
|
66
|
+
}): InitResult;
|
|
67
|
+
export declare function deriveProjectId(root?: string, projectId?: string): string;
|
|
68
|
+
export declare function currentBranch(root?: string): string;
|
|
69
|
+
export declare function buildStartPacket(opts?: {
|
|
70
|
+
root?: string;
|
|
71
|
+
home?: string;
|
|
72
|
+
branch?: string;
|
|
73
|
+
maxCheckpoints?: number;
|
|
74
|
+
}): string;
|
|
75
|
+
export declare function writeCheckpoint(opts: {
|
|
76
|
+
root?: string;
|
|
77
|
+
note: string;
|
|
78
|
+
home?: string;
|
|
79
|
+
now?: Date;
|
|
80
|
+
device?: string;
|
|
81
|
+
agent?: string;
|
|
82
|
+
branch?: string;
|
|
83
|
+
}): CheckpointResult;
|
|
84
|
+
export declare function learn(note: string, opts?: {
|
|
85
|
+
home?: string;
|
|
86
|
+
root?: string;
|
|
87
|
+
scope?: LearnScope;
|
|
88
|
+
kind?: LearnKind;
|
|
89
|
+
branch?: string;
|
|
90
|
+
now?: Date;
|
|
91
|
+
}): LearnResult;
|
|
92
|
+
export declare function syncVault(opts?: {
|
|
93
|
+
home?: string;
|
|
94
|
+
}): string[];
|
|
95
|
+
export declare function getStatus(opts?: {
|
|
96
|
+
root?: string;
|
|
97
|
+
home?: string;
|
|
98
|
+
}): Status;
|
|
99
|
+
export declare function doctor(opts?: {
|
|
100
|
+
root?: string;
|
|
101
|
+
home?: string;
|
|
102
|
+
}): DoctorReport;
|
|
103
|
+
export {};
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { hostname, homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
export const DEFAULT_HOME = join(homedir(), ".agent-handoff");
|
|
7
|
+
export const CONFIG_FILE = "config.json";
|
|
8
|
+
export const BOOTSTRAP_FILE = ".agent-handoff.yml";
|
|
9
|
+
const MANAGED_BEGIN = "<!-- BEGIN AGENT-HANDOFF -->";
|
|
10
|
+
const MANAGED_END = "<!-- END AGENT-HANDOFF -->";
|
|
11
|
+
const SECRET_PATTERNS = [
|
|
12
|
+
/(api[_-]?key|token|secret|password)\s*[:=]/i,
|
|
13
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
14
|
+
/\bsk-[A-Za-z0-9_-]{8,}\b/,
|
|
15
|
+
];
|
|
16
|
+
export class HandoffError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "HandoffError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function setupHome(opts = {}) {
|
|
23
|
+
const homePath = resolveHome(opts.home);
|
|
24
|
+
const vaultPath = opts.vault ? resolve(opts.vault) : join(homePath, "vault");
|
|
25
|
+
let created = 0;
|
|
26
|
+
let updated = 0;
|
|
27
|
+
if (!existsSync(homePath)) {
|
|
28
|
+
mkdirSync(homePath, { recursive: true });
|
|
29
|
+
created += 1;
|
|
30
|
+
}
|
|
31
|
+
if (opts.syncUrl && cloneVaultIfNeeded(homePath, vaultPath, opts.syncUrl)) {
|
|
32
|
+
created += 1;
|
|
33
|
+
}
|
|
34
|
+
for (const directory of [vaultPath, join(vaultPath, "global"), join(vaultPath, "projects")]) {
|
|
35
|
+
if (!existsSync(directory)) {
|
|
36
|
+
mkdirSync(directory, { recursive: true });
|
|
37
|
+
created += 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const [filename, contents] of Object.entries(globalSeedFiles())) {
|
|
41
|
+
const path = join(vaultPath, "global", filename);
|
|
42
|
+
if (!existsSync(path)) {
|
|
43
|
+
writeFileSync(path, contents, "utf8");
|
|
44
|
+
created += 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const configPath = join(homePath, CONFIG_FILE);
|
|
48
|
+
const desired = { version: 2, vault: vaultPath };
|
|
49
|
+
if (opts.syncUrl)
|
|
50
|
+
desired.sync_url = opts.syncUrl;
|
|
51
|
+
if (existsSync(configPath)) {
|
|
52
|
+
const existing = JSON.parse(readFileSync(configPath, "utf8"));
|
|
53
|
+
let changed = false;
|
|
54
|
+
if (existing.version !== 2) {
|
|
55
|
+
existing.version = 2;
|
|
56
|
+
changed = true;
|
|
57
|
+
}
|
|
58
|
+
if (existing.vault !== vaultPath) {
|
|
59
|
+
existing.vault = vaultPath;
|
|
60
|
+
changed = true;
|
|
61
|
+
}
|
|
62
|
+
if (opts.syncUrl && existing.sync_url !== opts.syncUrl) {
|
|
63
|
+
existing.sync_url = opts.syncUrl;
|
|
64
|
+
changed = true;
|
|
65
|
+
}
|
|
66
|
+
if (changed) {
|
|
67
|
+
writeFileSync(configPath, json(existing), "utf8");
|
|
68
|
+
updated += 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
writeFileSync(configPath, json(desired), "utf8");
|
|
73
|
+
created += 1;
|
|
74
|
+
}
|
|
75
|
+
if (opts.syncUrl) {
|
|
76
|
+
ensureGitRemote(vaultPath, opts.syncUrl);
|
|
77
|
+
}
|
|
78
|
+
return { home: homePath, vault: vaultPath, created, updated };
|
|
79
|
+
}
|
|
80
|
+
export function normalizeProjectId(value) {
|
|
81
|
+
const raw = value.trim();
|
|
82
|
+
let host = "";
|
|
83
|
+
let path = "";
|
|
84
|
+
if (raw.startsWith("git@") && raw.includes(":")) {
|
|
85
|
+
const [userHost, remotePath] = raw.split(":", 2);
|
|
86
|
+
host = userHost.split("@", 2)[1] ?? "";
|
|
87
|
+
path = remotePath;
|
|
88
|
+
}
|
|
89
|
+
else if (raw.includes("://")) {
|
|
90
|
+
const parsed = new URL(raw);
|
|
91
|
+
host = parsed.hostname || parsed.host;
|
|
92
|
+
path = parsed.pathname;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
return safeProjectId(raw);
|
|
96
|
+
}
|
|
97
|
+
path = path.replace(/^\/+|\/+$/g, "");
|
|
98
|
+
if (path.endsWith(".git")) {
|
|
99
|
+
path = path.slice(0, -4);
|
|
100
|
+
}
|
|
101
|
+
return safeProjectId([host.toLowerCase(), ...path.split("/").filter(Boolean)].join("__"));
|
|
102
|
+
}
|
|
103
|
+
export function installSkill(opts = {}) {
|
|
104
|
+
const skillsHome = opts.skillsHome
|
|
105
|
+
? resolve(opts.skillsHome)
|
|
106
|
+
: resolve(join(homedir(), ".agents", "skills"));
|
|
107
|
+
const skillDir = join(skillsHome, "agent-handoff");
|
|
108
|
+
mkdirSync(skillDir, { recursive: true });
|
|
109
|
+
const path = join(skillDir, "SKILL.md");
|
|
110
|
+
const content = readResource("agent-handoff.SKILL.md");
|
|
111
|
+
const updated = !existsSync(path) || readFileSync(path, "utf8") !== content;
|
|
112
|
+
if (updated) {
|
|
113
|
+
writeFileSync(path, content, "utf8");
|
|
114
|
+
}
|
|
115
|
+
return { path, updated };
|
|
116
|
+
}
|
|
117
|
+
export function initRepo(opts = {}) {
|
|
118
|
+
const rootPath = resolve(opts.root ?? ".");
|
|
119
|
+
const setup = setupHome({ home: opts.home });
|
|
120
|
+
const pid = deriveProjectId(rootPath, opts.projectId);
|
|
121
|
+
const branchName = opts.branch ?? currentBranch(rootPath);
|
|
122
|
+
const selectedClients = normalizeClients(opts.clients);
|
|
123
|
+
let created = 0;
|
|
124
|
+
let updated = 0;
|
|
125
|
+
const bootstrapPath = join(rootPath, BOOTSTRAP_FILE);
|
|
126
|
+
const bootstrapContents = `version: 2\nproject_id: ${pid}\nclients: ${selectedClients.join(",")}\n`;
|
|
127
|
+
if (!existsSync(bootstrapPath)) {
|
|
128
|
+
writeFileSync(bootstrapPath, bootstrapContents, "utf8");
|
|
129
|
+
created += 1;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const data = readBootstrap(bootstrapPath);
|
|
133
|
+
const existingClients = clientsFromBootstrap(data);
|
|
134
|
+
if (data.project_id !== pid ||
|
|
135
|
+
data.version !== "2" ||
|
|
136
|
+
existingClients.join(",") !== selectedClients.join(",")) {
|
|
137
|
+
writeFileSync(bootstrapPath, bootstrapContents, "utf8");
|
|
138
|
+
updated += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const filename of clientInstructionFiles(selectedClients)) {
|
|
142
|
+
const changed = ensureManagedBlock(join(rootPath, filename));
|
|
143
|
+
if (changed.changed) {
|
|
144
|
+
if (changed.created)
|
|
145
|
+
created += 1;
|
|
146
|
+
else
|
|
147
|
+
updated += 1;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const projectPath = vaultProjectPath(setup.vault, pid);
|
|
151
|
+
created += ensureProjectFiles(projectPath, branchName);
|
|
152
|
+
return {
|
|
153
|
+
created,
|
|
154
|
+
updated,
|
|
155
|
+
root: rootPath,
|
|
156
|
+
projectId: pid,
|
|
157
|
+
vaultProject: projectPath,
|
|
158
|
+
clients: selectedClients,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
export function deriveProjectId(root = ".", projectId) {
|
|
162
|
+
const rootPath = resolve(root);
|
|
163
|
+
if (projectId)
|
|
164
|
+
return coerceProjectId(projectId);
|
|
165
|
+
const bootstrapPath = join(rootPath, BOOTSTRAP_FILE);
|
|
166
|
+
if (existsSync(bootstrapPath)) {
|
|
167
|
+
const data = readBootstrap(bootstrapPath);
|
|
168
|
+
if (data.project_id)
|
|
169
|
+
return coerceProjectId(data.project_id);
|
|
170
|
+
}
|
|
171
|
+
const remote = gitOutput(rootPath, ["remote", "get-url", "origin"]);
|
|
172
|
+
if (remote)
|
|
173
|
+
return normalizeProjectId(remote);
|
|
174
|
+
return safeProjectId(rootPath.split(/[\\/]/).pop() ?? "unknown-project");
|
|
175
|
+
}
|
|
176
|
+
export function currentBranch(root = ".") {
|
|
177
|
+
return gitOutput(resolve(root), ["branch", "--show-current"]) ?? "default";
|
|
178
|
+
}
|
|
179
|
+
export function buildStartPacket(opts = {}) {
|
|
180
|
+
const rootPath = resolve(opts.root ?? ".");
|
|
181
|
+
const status = getStatus({ root: rootPath, home: opts.home });
|
|
182
|
+
if (!status.initialized) {
|
|
183
|
+
throw new HandoffError(statusError(status));
|
|
184
|
+
}
|
|
185
|
+
const setup = loadSetup(opts.home);
|
|
186
|
+
const pid = status.projectId ?? deriveProjectId(rootPath);
|
|
187
|
+
const branchName = opts.branch ?? currentBranch(rootPath);
|
|
188
|
+
const projectPath = vaultProjectPath(setup.vault, pid);
|
|
189
|
+
const branchFile = join(projectPath, "branches", `${safeName(branchName)}.md`);
|
|
190
|
+
const sections = [
|
|
191
|
+
["Global Preferences", join(setup.vault, "global", "preferences.md")],
|
|
192
|
+
["Global Lessons", join(setup.vault, "global", "lessons.md")],
|
|
193
|
+
["Project Context", join(projectPath, "project.md")],
|
|
194
|
+
["Project Preferences", join(projectPath, "preferences.md")],
|
|
195
|
+
["Project Decisions", join(projectPath, "decisions.md")],
|
|
196
|
+
["Branch Context", branchFile],
|
|
197
|
+
];
|
|
198
|
+
const lines = [
|
|
199
|
+
"# Agent Handoff Start Packet",
|
|
200
|
+
"",
|
|
201
|
+
`Project: \`${pid}\``,
|
|
202
|
+
`Branch: \`${branchName}\``,
|
|
203
|
+
"",
|
|
204
|
+
"Read this packet before making changes. Use it to recover context from previous Codex and Claude Code sessions.",
|
|
205
|
+
];
|
|
206
|
+
for (const [title, path] of sections) {
|
|
207
|
+
lines.push(...renderSection(title, path));
|
|
208
|
+
}
|
|
209
|
+
const checkpoints = latestCheckpoints(projectPath, opts.maxCheckpoints ?? 5, branchName);
|
|
210
|
+
if (checkpoints.length > 0) {
|
|
211
|
+
lines.push("", "## Recent Checkpoints");
|
|
212
|
+
for (const path of checkpoints) {
|
|
213
|
+
lines.push(...renderSection(path.split(/[\\/]/).pop() ?? path, path, 3));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
lines.push("");
|
|
217
|
+
return lines.join("\n");
|
|
218
|
+
}
|
|
219
|
+
export function writeCheckpoint(opts) {
|
|
220
|
+
const rootPath = resolve(opts.root ?? ".");
|
|
221
|
+
const status = getStatus({ root: rootPath, home: opts.home });
|
|
222
|
+
if (!status.initialized) {
|
|
223
|
+
throw new HandoffError(statusError(status));
|
|
224
|
+
}
|
|
225
|
+
const cleanedNote = cleanNote(opts.note);
|
|
226
|
+
if (!cleanedNote)
|
|
227
|
+
throw new HandoffError("checkpoint note cannot be empty");
|
|
228
|
+
rejectLikelySecret(cleanedNote);
|
|
229
|
+
const setup = loadSetup(opts.home);
|
|
230
|
+
const pid = status.projectId ?? deriveProjectId(rootPath);
|
|
231
|
+
const branchName = opts.branch ?? currentBranch(rootPath);
|
|
232
|
+
const createdAt = timestamp(opts.now);
|
|
233
|
+
const projectPath = vaultProjectPath(setup.vault, pid);
|
|
234
|
+
const checkpoints = join(projectPath, "checkpoints");
|
|
235
|
+
mkdirSync(checkpoints, { recursive: true });
|
|
236
|
+
const deviceLabel = opts.device ?? hostname() ?? "device";
|
|
237
|
+
const agentLabel = opts.agent ?? "agent";
|
|
238
|
+
const filename = `${compactTimestamp(createdAt)}-${safeName(deviceLabel)}-${safeName(agentLabel)}-${safeName(branchName)}.md`;
|
|
239
|
+
const path = join(checkpoints, filename);
|
|
240
|
+
const contents = [
|
|
241
|
+
"# Checkpoint",
|
|
242
|
+
"",
|
|
243
|
+
`created_at: ${createdAt}`,
|
|
244
|
+
`project_id: ${pid}`,
|
|
245
|
+
`branch: ${branchName}`,
|
|
246
|
+
`device: ${deviceLabel}`,
|
|
247
|
+
`agent: ${agentLabel}`,
|
|
248
|
+
"",
|
|
249
|
+
"## Notes",
|
|
250
|
+
"",
|
|
251
|
+
cleanedNote,
|
|
252
|
+
"",
|
|
253
|
+
].join("\n");
|
|
254
|
+
writeFileSync(path, contents, "utf8");
|
|
255
|
+
return { path, projectId: pid, branch: branchName, createdAt };
|
|
256
|
+
}
|
|
257
|
+
export function learn(note, opts = {}) {
|
|
258
|
+
const clean = cleanNote(note);
|
|
259
|
+
if (!clean)
|
|
260
|
+
throw new HandoffError("learn note cannot be empty");
|
|
261
|
+
rejectLikelySecret(clean);
|
|
262
|
+
const scope = opts.scope ?? "global";
|
|
263
|
+
const kind = opts.kind ?? "preference";
|
|
264
|
+
if (!["global", "project", "branch"].includes(scope)) {
|
|
265
|
+
throw new HandoffError("learn scope must be 'global', 'project', or 'branch'");
|
|
266
|
+
}
|
|
267
|
+
if (!["preference", "lesson", "decision", "context"].includes(kind)) {
|
|
268
|
+
throw new HandoffError("learn kind must be 'preference', 'lesson', 'decision', or 'context'");
|
|
269
|
+
}
|
|
270
|
+
const setup = setupHome({ home: opts.home });
|
|
271
|
+
const path = learnTargetPath(setup, resolve(opts.root ?? "."), scope, kind, opts.branch);
|
|
272
|
+
const createdAt = timestamp(opts.now);
|
|
273
|
+
appendFile(path, `\n- ${createdAt}: ${clean}\n`);
|
|
274
|
+
return { path, kind: scope === "global" ? kind : `${scope} ${kind}`, createdAt };
|
|
275
|
+
}
|
|
276
|
+
export function syncVault(opts = {}) {
|
|
277
|
+
const setup = loadSetup(opts.home);
|
|
278
|
+
if (!existsSync(join(setup.vault, ".git"))) {
|
|
279
|
+
throw new HandoffError("vault is not a git repository; run setup --sync first");
|
|
280
|
+
}
|
|
281
|
+
const outputs = [];
|
|
282
|
+
gitChecked(setup.vault, ["add", "-A"]);
|
|
283
|
+
const staged = gitRun(setup.vault, ["diff", "--cached", "--quiet"]).status !== 0;
|
|
284
|
+
if (staged) {
|
|
285
|
+
outputs.push(gitChecked(setup.vault, [
|
|
286
|
+
"-c",
|
|
287
|
+
"user.name=agent-handoff",
|
|
288
|
+
"-c",
|
|
289
|
+
"user.email=agent-handoff@local",
|
|
290
|
+
"commit",
|
|
291
|
+
"-m",
|
|
292
|
+
"chore: sync agent handoff vault",
|
|
293
|
+
]));
|
|
294
|
+
}
|
|
295
|
+
const branch = gitOutput(setup.vault, ["branch", "--show-current"]) ?? "main";
|
|
296
|
+
const pull = gitRun(setup.vault, ["pull", "--rebase", "--autostash", "origin", branch]);
|
|
297
|
+
if (pull.status !== 0 && !isEmptyRemotePull(pull.output)) {
|
|
298
|
+
throw new HandoffError(pull.output.trim() || "git pull failed");
|
|
299
|
+
}
|
|
300
|
+
if (pull.output.trim())
|
|
301
|
+
outputs.push(pull.output.trim());
|
|
302
|
+
const push = gitRun(setup.vault, ["push", "-u", "origin", branch]);
|
|
303
|
+
if (push.status !== 0) {
|
|
304
|
+
throw new HandoffError(push.output.trim() || "git push failed");
|
|
305
|
+
}
|
|
306
|
+
if (push.output.trim())
|
|
307
|
+
outputs.push(push.output.trim());
|
|
308
|
+
return outputs;
|
|
309
|
+
}
|
|
310
|
+
export function getStatus(opts = {}) {
|
|
311
|
+
const rootPath = resolve(opts.root ?? ".");
|
|
312
|
+
const problems = [];
|
|
313
|
+
let projectId = null;
|
|
314
|
+
let clients = ["codex", "claude"];
|
|
315
|
+
const bootstrapPath = join(rootPath, BOOTSTRAP_FILE);
|
|
316
|
+
if (!existsSync(bootstrapPath)) {
|
|
317
|
+
problems.push(`${BOOTSTRAP_FILE} is missing`);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
const data = readBootstrap(bootstrapPath);
|
|
321
|
+
projectId = data.project_id ?? null;
|
|
322
|
+
if (!projectId)
|
|
323
|
+
problems.push(`${BOOTSTRAP_FILE} is missing project_id`);
|
|
324
|
+
clients = clientsFromBootstrap(data);
|
|
325
|
+
}
|
|
326
|
+
for (const filename of clientInstructionFiles(clients)) {
|
|
327
|
+
if (!hasManagedBlock(join(rootPath, filename))) {
|
|
328
|
+
problems.push(`${filename} is missing the managed handoff block`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const config = readConfig(opts.home);
|
|
332
|
+
if (!config) {
|
|
333
|
+
problems.push("vault config is missing; run agent-handoff setup");
|
|
334
|
+
}
|
|
335
|
+
else if (!existsSync(config.vault)) {
|
|
336
|
+
problems.push(`vault directory is missing: ${config.vault}`);
|
|
337
|
+
}
|
|
338
|
+
else if (projectId) {
|
|
339
|
+
const projectPath = vaultProjectPath(config.vault, projectId);
|
|
340
|
+
if (!existsSync(projectPath)) {
|
|
341
|
+
problems.push(`vault project is missing: ${projectId}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { initialized: problems.length === 0, problems, root: rootPath, projectId };
|
|
345
|
+
}
|
|
346
|
+
export function doctor(opts = {}) {
|
|
347
|
+
const status = getStatus(opts);
|
|
348
|
+
return {
|
|
349
|
+
ok: status.initialized,
|
|
350
|
+
problems: status.problems,
|
|
351
|
+
root: status.root,
|
|
352
|
+
projectId: status.projectId,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function globalSeedFiles() {
|
|
356
|
+
return {
|
|
357
|
+
"preferences.md": "# Global Preferences\n\n",
|
|
358
|
+
"lessons.md": "# Global Lessons\n\n",
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function projectSeedFiles() {
|
|
362
|
+
return {
|
|
363
|
+
"project.md": "# Project Context\n\n",
|
|
364
|
+
"decisions.md": "# Decisions\n\n",
|
|
365
|
+
"preferences.md": "# Project Preferences\n\n",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function ensureProjectFiles(projectPath, branch) {
|
|
369
|
+
let created = 0;
|
|
370
|
+
for (const directory of [projectPath, join(projectPath, "branches"), join(projectPath, "checkpoints")]) {
|
|
371
|
+
if (!existsSync(directory)) {
|
|
372
|
+
mkdirSync(directory, { recursive: true });
|
|
373
|
+
created += 1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
for (const [filename, contents] of Object.entries(projectSeedFiles())) {
|
|
377
|
+
const path = join(projectPath, filename);
|
|
378
|
+
if (!existsSync(path)) {
|
|
379
|
+
writeFileSync(path, contents, "utf8");
|
|
380
|
+
created += 1;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const branchPath = join(projectPath, "branches", `${safeName(branch)}.md`);
|
|
384
|
+
if (!existsSync(branchPath)) {
|
|
385
|
+
writeFileSync(branchPath, `# Branch Context: ${branch}\n\n`, "utf8");
|
|
386
|
+
created += 1;
|
|
387
|
+
}
|
|
388
|
+
return created;
|
|
389
|
+
}
|
|
390
|
+
function learnTargetPath(setup, root, scope, kind, branch) {
|
|
391
|
+
if (scope === "global") {
|
|
392
|
+
if (!["preference", "lesson"].includes(kind)) {
|
|
393
|
+
throw new HandoffError("global learn kind must be 'preference' or 'lesson'");
|
|
394
|
+
}
|
|
395
|
+
return join(setup.vault, "global", kind === "preference" ? "preferences.md" : "lessons.md");
|
|
396
|
+
}
|
|
397
|
+
const status = getStatus({ root, home: setup.home });
|
|
398
|
+
if (!status.initialized) {
|
|
399
|
+
throw new HandoffError(statusError(status));
|
|
400
|
+
}
|
|
401
|
+
const pid = status.projectId ?? deriveProjectId(root);
|
|
402
|
+
const projectPath = vaultProjectPath(setup.vault, pid);
|
|
403
|
+
if (scope === "project") {
|
|
404
|
+
if (kind === "preference")
|
|
405
|
+
return join(projectPath, "preferences.md");
|
|
406
|
+
if (kind === "decision")
|
|
407
|
+
return join(projectPath, "decisions.md");
|
|
408
|
+
return join(projectPath, "project.md");
|
|
409
|
+
}
|
|
410
|
+
const branchName = branch ?? currentBranch(root);
|
|
411
|
+
const branchPath = join(projectPath, "branches", `${safeName(branchName)}.md`);
|
|
412
|
+
if (!existsSync(branchPath)) {
|
|
413
|
+
writeFileSync(branchPath, `# Branch Context: ${branchName}\n\n`, "utf8");
|
|
414
|
+
}
|
|
415
|
+
return branchPath;
|
|
416
|
+
}
|
|
417
|
+
function normalizeClients(clients) {
|
|
418
|
+
const selected = clients && clients.length > 0 ? clients : ["codex", "claude"];
|
|
419
|
+
const deduped = [];
|
|
420
|
+
for (const client of selected) {
|
|
421
|
+
if (client !== "codex" && client !== "claude") {
|
|
422
|
+
throw new HandoffError(`unsupported client(s): ${client}`);
|
|
423
|
+
}
|
|
424
|
+
if (!deduped.includes(client))
|
|
425
|
+
deduped.push(client);
|
|
426
|
+
}
|
|
427
|
+
return deduped;
|
|
428
|
+
}
|
|
429
|
+
function clientsFromBootstrap(data) {
|
|
430
|
+
if (!data.clients)
|
|
431
|
+
return ["codex", "claude"];
|
|
432
|
+
return normalizeClients(data.clients.split(",").map((client) => client.trim()).filter(Boolean));
|
|
433
|
+
}
|
|
434
|
+
function clientInstructionFiles(clients) {
|
|
435
|
+
const files = [];
|
|
436
|
+
if (clients.includes("codex"))
|
|
437
|
+
files.push("AGENTS.md");
|
|
438
|
+
if (clients.includes("claude"))
|
|
439
|
+
files.push("CLAUDE.md");
|
|
440
|
+
return files;
|
|
441
|
+
}
|
|
442
|
+
function resolveHome(home) {
|
|
443
|
+
return resolve(home ? expandHome(home) : DEFAULT_HOME);
|
|
444
|
+
}
|
|
445
|
+
function expandHome(path) {
|
|
446
|
+
if (path === "~")
|
|
447
|
+
return homedir();
|
|
448
|
+
if (path.startsWith("~/"))
|
|
449
|
+
return join(homedir(), path.slice(2));
|
|
450
|
+
return path;
|
|
451
|
+
}
|
|
452
|
+
function readConfig(home) {
|
|
453
|
+
const configPath = join(resolveHome(home), CONFIG_FILE);
|
|
454
|
+
if (!existsSync(configPath))
|
|
455
|
+
return null;
|
|
456
|
+
return JSON.parse(readFileSync(configPath, "utf8"));
|
|
457
|
+
}
|
|
458
|
+
function loadSetup(home) {
|
|
459
|
+
const config = readConfig(home);
|
|
460
|
+
if (!config)
|
|
461
|
+
return setupHome({ home });
|
|
462
|
+
return { home: resolveHome(home), vault: resolve(config.vault), created: 0, updated: 0 };
|
|
463
|
+
}
|
|
464
|
+
function vaultProjectPath(vault, projectId) {
|
|
465
|
+
return join(vault, "projects", coerceProjectId(projectId));
|
|
466
|
+
}
|
|
467
|
+
function coerceProjectId(value) {
|
|
468
|
+
if (value.includes("://") || value.startsWith("git@"))
|
|
469
|
+
return normalizeProjectId(value);
|
|
470
|
+
return safeProjectId(value);
|
|
471
|
+
}
|
|
472
|
+
function safeProjectId(value) {
|
|
473
|
+
let normalized = value.trim().replace(/^\/+|\/+$/g, "");
|
|
474
|
+
if (normalized.endsWith(".git"))
|
|
475
|
+
normalized = normalized.slice(0, -4);
|
|
476
|
+
normalized = normalized.replace(/[/:]/g, "__").replace(/[^A-Za-z0-9._-]+/g, "__");
|
|
477
|
+
normalized = normalized.replace(/__+/g, "__").replace(/^_+|_+$/g, "");
|
|
478
|
+
return normalized || "unknown-project";
|
|
479
|
+
}
|
|
480
|
+
function safeName(value) {
|
|
481
|
+
const normalized = value.trim().replace(/[^A-Za-z0-9._-]+/g, "__").replace(/__+/g, "__").replace(/^_+|_+$/g, "");
|
|
482
|
+
return normalized || "default";
|
|
483
|
+
}
|
|
484
|
+
function readBootstrap(path) {
|
|
485
|
+
const data = {};
|
|
486
|
+
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
487
|
+
if (!line.includes(":") || line.trimStart().startsWith("#"))
|
|
488
|
+
continue;
|
|
489
|
+
const index = line.indexOf(":");
|
|
490
|
+
data[line.slice(0, index).trim()] = line.slice(index + 1).trim().replace(/^["']|["']$/g, "");
|
|
491
|
+
}
|
|
492
|
+
return data;
|
|
493
|
+
}
|
|
494
|
+
function managedBlock() {
|
|
495
|
+
return [
|
|
496
|
+
MANAGED_BEGIN,
|
|
497
|
+
"Agent handoff is enabled for this repository.",
|
|
498
|
+
"",
|
|
499
|
+
"At the start of a new Codex or Claude Code session, run `agent-handoff sync` if vault sync is configured, then run:",
|
|
500
|
+
"",
|
|
501
|
+
"```bash",
|
|
502
|
+
"agent-handoff start",
|
|
503
|
+
"```",
|
|
504
|
+
"",
|
|
505
|
+
"Read the returned packet before making changes.",
|
|
506
|
+
"",
|
|
507
|
+
"Before pausing work, switching devices, or ending a useful session, run:",
|
|
508
|
+
"",
|
|
509
|
+
"```bash",
|
|
510
|
+
'agent-handoff checkpoint --note "<current goal, progress, open questions, next step>"',
|
|
511
|
+
"```",
|
|
512
|
+
"",
|
|
513
|
+
"Then run `agent-handoff sync` if vault sync is configured.",
|
|
514
|
+
"",
|
|
515
|
+
'When the user corrects a stable preference or recurring rule, run `agent-handoff learn --kind preference --note "..."`.',
|
|
516
|
+
MANAGED_END,
|
|
517
|
+
"",
|
|
518
|
+
].join("\n");
|
|
519
|
+
}
|
|
520
|
+
function ensureManagedBlock(path) {
|
|
521
|
+
const block = managedBlock();
|
|
522
|
+
if (!existsSync(path)) {
|
|
523
|
+
writeFileSync(path, block, "utf8");
|
|
524
|
+
return { changed: true, created: true };
|
|
525
|
+
}
|
|
526
|
+
const original = readFileSync(path, "utf8");
|
|
527
|
+
let updated;
|
|
528
|
+
if (original.includes(MANAGED_BEGIN) && original.includes(MANAGED_END)) {
|
|
529
|
+
const before = original.split(MANAGED_BEGIN, 1)[0];
|
|
530
|
+
const after = original.split(MANAGED_END, 2)[1] ?? "";
|
|
531
|
+
const parts = [];
|
|
532
|
+
if (before.trimEnd())
|
|
533
|
+
parts.push(before.trimEnd());
|
|
534
|
+
parts.push(block.trimEnd());
|
|
535
|
+
if (after.trimStart())
|
|
536
|
+
parts.push(after.trimStart());
|
|
537
|
+
updated = `${parts.join("\n\n")}\n`;
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
updated = `${original.trimEnd()}\n\n${block}`;
|
|
541
|
+
}
|
|
542
|
+
if (updated !== original) {
|
|
543
|
+
writeFileSync(path, updated, "utf8");
|
|
544
|
+
return { changed: true, created: false };
|
|
545
|
+
}
|
|
546
|
+
return { changed: false, created: false };
|
|
547
|
+
}
|
|
548
|
+
function hasManagedBlock(path) {
|
|
549
|
+
if (!existsSync(path))
|
|
550
|
+
return false;
|
|
551
|
+
const contents = readFileSync(path, "utf8");
|
|
552
|
+
return contents.includes(MANAGED_BEGIN) && contents.includes(MANAGED_END);
|
|
553
|
+
}
|
|
554
|
+
function renderSection(title, path, headingLevel = 2) {
|
|
555
|
+
const heading = "#".repeat(headingLevel);
|
|
556
|
+
if (!existsSync(path)) {
|
|
557
|
+
return ["", `${heading} ${title}`, "", "_Missing._"];
|
|
558
|
+
}
|
|
559
|
+
return ["", `${heading} ${title}`, "", readFileSync(path, "utf8").trimEnd()];
|
|
560
|
+
}
|
|
561
|
+
function latestCheckpoints(projectPath, limit, branch) {
|
|
562
|
+
const checkpointDir = join(projectPath, "checkpoints");
|
|
563
|
+
if (!existsSync(checkpointDir))
|
|
564
|
+
return [];
|
|
565
|
+
let paths = readdirSync(checkpointDir)
|
|
566
|
+
.filter((name) => name.endsWith(".md"))
|
|
567
|
+
.sort()
|
|
568
|
+
.map((name) => join(checkpointDir, name));
|
|
569
|
+
if (branch !== undefined) {
|
|
570
|
+
paths = paths.filter((path) => checkpointBranch(path) === branch);
|
|
571
|
+
}
|
|
572
|
+
return paths.slice(-limit);
|
|
573
|
+
}
|
|
574
|
+
function checkpointBranch(path) {
|
|
575
|
+
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
576
|
+
if (line.startsWith("branch:"))
|
|
577
|
+
return line.split(":", 2)[1].trim();
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
function timestamp(now) {
|
|
582
|
+
const value = now ?? new Date();
|
|
583
|
+
const year = value.getUTCFullYear();
|
|
584
|
+
const month = pad(value.getUTCMonth() + 1);
|
|
585
|
+
const day = pad(value.getUTCDate());
|
|
586
|
+
const hour = pad(value.getUTCHours());
|
|
587
|
+
const minute = pad(value.getUTCMinutes());
|
|
588
|
+
const second = pad(value.getUTCSeconds());
|
|
589
|
+
return `${year}-${month}-${day}T${hour}:${minute}:${second}+00:00`;
|
|
590
|
+
}
|
|
591
|
+
function compactTimestamp(value) {
|
|
592
|
+
return value.replace("+00:00", "Z").replace(/[:.-]/g, "");
|
|
593
|
+
}
|
|
594
|
+
function pad(value) {
|
|
595
|
+
return value.toString().padStart(2, "0");
|
|
596
|
+
}
|
|
597
|
+
function cleanNote(note) {
|
|
598
|
+
return note
|
|
599
|
+
.trim()
|
|
600
|
+
.split(/\r?\n/)
|
|
601
|
+
.map((line) => line.trimEnd())
|
|
602
|
+
.join("\n")
|
|
603
|
+
.trim();
|
|
604
|
+
}
|
|
605
|
+
function rejectLikelySecret(note) {
|
|
606
|
+
if (SECRET_PATTERNS.some((pattern) => pattern.test(note))) {
|
|
607
|
+
throw new HandoffError("handoff notes look like they contain a secret; remove it and try again");
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function statusError(status) {
|
|
611
|
+
return `agent handoff is not ready:\n${status.problems.map((problem) => `- ${problem}`).join("\n")}`;
|
|
612
|
+
}
|
|
613
|
+
function json(data) {
|
|
614
|
+
return `${JSON.stringify(data, null, 2)}\n`;
|
|
615
|
+
}
|
|
616
|
+
function appendFile(path, contents) {
|
|
617
|
+
writeFileSync(path, contents, { encoding: "utf8", flag: "a" });
|
|
618
|
+
}
|
|
619
|
+
function cloneVaultIfNeeded(home, vault, syncUrl) {
|
|
620
|
+
if (existsSync(join(vault, ".git")))
|
|
621
|
+
return false;
|
|
622
|
+
if (existsSync(vault)) {
|
|
623
|
+
if (readdirSync(vault).length === 0) {
|
|
624
|
+
rmSync(vault, { recursive: true, force: true });
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const clone = gitRun(home, ["clone", syncUrl, vault]);
|
|
631
|
+
return clone.status === 0;
|
|
632
|
+
}
|
|
633
|
+
function ensureGitRemote(vault, syncUrl) {
|
|
634
|
+
if (!existsSync(join(vault, ".git"))) {
|
|
635
|
+
gitRun(vault, ["init"]);
|
|
636
|
+
gitRun(vault, ["branch", "-M", "main"]);
|
|
637
|
+
}
|
|
638
|
+
const remotes = gitOutput(vault, ["remote"]);
|
|
639
|
+
if (remotes?.split(/\r?\n/).includes("origin")) {
|
|
640
|
+
gitRun(vault, ["remote", "set-url", "origin", syncUrl]);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
gitRun(vault, ["remote", "add", "origin", syncUrl]);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function gitOutput(root, args) {
|
|
647
|
+
const result = gitRun(root, args);
|
|
648
|
+
if (result.status !== 0)
|
|
649
|
+
return null;
|
|
650
|
+
const output = result.output.trim();
|
|
651
|
+
return output || null;
|
|
652
|
+
}
|
|
653
|
+
function gitChecked(root, args) {
|
|
654
|
+
const result = gitRun(root, args);
|
|
655
|
+
if (result.status !== 0) {
|
|
656
|
+
throw new HandoffError(result.output.trim() || `git ${args.join(" ")} failed`);
|
|
657
|
+
}
|
|
658
|
+
return result.output.trim();
|
|
659
|
+
}
|
|
660
|
+
function gitRun(root, args) {
|
|
661
|
+
const result = spawnSync("git", args, {
|
|
662
|
+
cwd: root,
|
|
663
|
+
encoding: "utf8",
|
|
664
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
665
|
+
});
|
|
666
|
+
return {
|
|
667
|
+
status: result.status ?? 1,
|
|
668
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function isEmptyRemotePull(output) {
|
|
672
|
+
const lowered = output.toLowerCase();
|
|
673
|
+
return lowered.includes("couldn't find remote ref") || lowered.includes("could not find remote ref");
|
|
674
|
+
}
|
|
675
|
+
function readResource(name) {
|
|
676
|
+
const path = fileURLToPath(new URL(`../resources/${name}`, import.meta.url));
|
|
677
|
+
return readFileSync(path, "utf8");
|
|
678
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./core.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./core.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@leantli/agent-handoff",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Shared vault handoff memory for Codex and Claude Code.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "agent-handoff contributors",
|
|
8
|
+
"bin": {
|
|
9
|
+
"agent-handoff": "dist/bin.js"
|
|
10
|
+
},
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"resources",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"prepack": "npm run build",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"ai",
|
|
26
|
+
"agents",
|
|
27
|
+
"codex",
|
|
28
|
+
"claude-code",
|
|
29
|
+
"handoff",
|
|
30
|
+
"context"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"commander": "^12.1.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^20.11.0",
|
|
40
|
+
"typescript": "^5.4.0",
|
|
41
|
+
"vitest": "^1.6.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-handoff
|
|
3
|
+
description: Use when starting, resuming, pausing, checkpointing, or transferring Codex/Claude Code work across sessions, clones, worktrees, or devices with the agent-handoff CLI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Agent Handoff
|
|
7
|
+
|
|
8
|
+
Use `agent-handoff` to restore and preserve coding-agent context.
|
|
9
|
+
|
|
10
|
+
## Start Of Session
|
|
11
|
+
|
|
12
|
+
When beginning work in a repository:
|
|
13
|
+
|
|
14
|
+
1. Run `agent-handoff status`.
|
|
15
|
+
2. If status says the repo is not ready, run `agent-handoff setup` if the vault is missing, then `agent-handoff init`.
|
|
16
|
+
3. If vault sync is configured, run `agent-handoff sync`.
|
|
17
|
+
4. Run `agent-handoff start`.
|
|
18
|
+
5. Read the returned packet before changing files.
|
|
19
|
+
|
|
20
|
+
## During Work
|
|
21
|
+
|
|
22
|
+
Use `learn` only for stable facts that should survive future sessions, clones,
|
|
23
|
+
worktrees, and devices.
|
|
24
|
+
|
|
25
|
+
For a durable user preference or recurring correction:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
agent-handoff learn --kind preference --note "<stable preference>"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For a durable lesson about project or agent behavior:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
agent-handoff learn --kind lesson --note "<stable lesson>"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For project-specific decisions:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
agent-handoff learn --scope project --kind decision --note "<project decision>"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For branch-specific current context:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
agent-handoff learn --scope branch --kind context --note "<branch context>"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Before Pausing
|
|
50
|
+
|
|
51
|
+
Before ending a useful session, switching devices, or handing work to another
|
|
52
|
+
agent, write a concise checkpoint:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
agent-handoff checkpoint --note "<current goal, completed work, open questions, next step>"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
If vault sync is configured, run:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
agent-handoff sync
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
If sync fails, keep the local checkpoint and report the error.
|
|
65
|
+
|
|
66
|
+
## Rules
|
|
67
|
+
|
|
68
|
+
- Do not store secrets, tokens, credentials, or private customer data in handoff notes.
|
|
69
|
+
- Keep checkpoints factual and concise.
|
|
70
|
+
- Do not use `learn` for temporary task state; use `checkpoint` instead.
|
|
71
|
+
- Prefer project or branch scope for project-specific facts instead of global memory.
|
|
72
|
+
- If `agent-handoff` is not installed, tell the user the CLI is missing and continue without pretending context was saved.
|