@leantli/agent-handoff 0.4.0 → 0.5.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/README.md +56 -124
- package/dist/bin.js +0 -0
- package/dist/cli.js +30 -59
- package/dist/core.d.ts +14 -24
- package/dist/core.js +237 -185
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/resources/agent-handoff.SKILL.md +10 -5
package/README.md
CHANGED
|
@@ -1,132 +1,98 @@
|
|
|
1
1
|
# agent-handoff
|
|
2
2
|
|
|
3
|
-
`agent-handoff` gives
|
|
4
|
-
|
|
3
|
+
`agent-handoff` gives coding agents a small shared memory layer for new sessions,
|
|
4
|
+
fresh clones, git worktrees, and devices.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
Long agent sessions accumulate useful context: project background, current task
|
|
7
|
+
state, decisions, preferences, and repeated corrections. Without a handoff
|
|
8
|
+
layer, the next session starts cold.
|
|
9
9
|
|
|
10
|
-
`agent-handoff` stores that context
|
|
11
|
-
|
|
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
|
-
```
|
|
10
|
+
`agent-handoff` stores that context under `~/.agent-handoff` by default. It does
|
|
11
|
+
not modify `AGENTS.md`, `CLAUDE.md`, or other project instruction files.
|
|
19
12
|
|
|
20
13
|
## Install
|
|
21
14
|
|
|
22
|
-
From npm:
|
|
23
|
-
|
|
24
15
|
```bash
|
|
25
16
|
npm install -g @leantli/agent-handoff
|
|
17
|
+
agent-handoff enable
|
|
26
18
|
```
|
|
27
19
|
|
|
28
|
-
|
|
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:
|
|
20
|
+
GitHub direct install is also supported:
|
|
53
21
|
|
|
54
22
|
```bash
|
|
55
|
-
agent-handoff
|
|
23
|
+
npm install -g github:leantli/agent-handoff
|
|
24
|
+
agent-handoff enable
|
|
56
25
|
```
|
|
57
26
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
.agent-handoff.yml
|
|
62
|
-
AGENTS.md
|
|
63
|
-
CLAUDE.md
|
|
64
|
-
```
|
|
27
|
+
`enable` creates local memory and installs the packaged skill under
|
|
28
|
+
`~/.agents/skills` for agents that read user-level skills. The skill tells those
|
|
29
|
+
agents when to run `start`, `checkpoint`, and `learn`.
|
|
65
30
|
|
|
66
|
-
|
|
31
|
+
`agent-handoff status` also tells agents whether cross-device sync is configured.
|
|
67
32
|
|
|
68
33
|
## Daily Workflow
|
|
69
34
|
|
|
70
|
-
At the start of a
|
|
35
|
+
At the start of a coding session:
|
|
71
36
|
|
|
72
37
|
```bash
|
|
73
|
-
agent-handoff
|
|
38
|
+
agent-handoff status
|
|
74
39
|
agent-handoff start
|
|
75
40
|
```
|
|
76
41
|
|
|
77
|
-
|
|
42
|
+
If `status` says sync is configured and you are switching devices or clones, run
|
|
43
|
+
`agent-handoff sync` before `start`.
|
|
78
44
|
|
|
79
|
-
|
|
45
|
+
Before switching sessions, tasks, clones, or devices:
|
|
80
46
|
|
|
81
47
|
```bash
|
|
82
|
-
agent-handoff checkpoint --note "
|
|
83
|
-
agent-handoff sync # only if vault sync is configured
|
|
48
|
+
agent-handoff checkpoint --note "Current goal, completed work, open questions, next step."
|
|
84
49
|
```
|
|
85
50
|
|
|
86
|
-
When the user
|
|
51
|
+
When the user gives a stable preference or recurring correction:
|
|
87
52
|
|
|
88
53
|
```bash
|
|
89
|
-
agent-handoff learn --kind preference --note "Prefer
|
|
54
|
+
agent-handoff learn --kind preference --note "Prefer small focused diffs."
|
|
90
55
|
```
|
|
91
56
|
|
|
92
57
|
For project-specific decisions or branch-specific context:
|
|
93
58
|
|
|
94
59
|
```bash
|
|
95
60
|
agent-handoff learn --scope project --kind decision --note "Use vault-first storage."
|
|
96
|
-
agent-handoff learn --scope branch --kind context --note "
|
|
61
|
+
agent-handoff learn --scope branch --kind context --note "This branch is testing v0.5."
|
|
97
62
|
```
|
|
98
63
|
|
|
99
|
-
|
|
100
|
-
private vault repository.
|
|
101
|
-
|
|
102
|
-
## Why This Solves Cross-Clone Context
|
|
64
|
+
## Cross-Device Sync
|
|
103
65
|
|
|
104
|
-
|
|
105
|
-
|
|
66
|
+
Local cross-session memory works without any git repository. To share memory
|
|
67
|
+
across devices, create a private git repository for the vault, then run:
|
|
106
68
|
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
~/worktrees/repo-feature
|
|
111
|
-
another device's ~/projects/repo
|
|
69
|
+
```bash
|
|
70
|
+
agent-handoff sync init git@github.com:you/agent-handoff-vault.git
|
|
71
|
+
agent-handoff sync
|
|
112
72
|
```
|
|
113
73
|
|
|
114
|
-
|
|
74
|
+
Run `agent-handoff sync init <same-git-url>` once on each device that should
|
|
75
|
+
share the vault. After that, run `agent-handoff sync` before starting on another
|
|
76
|
+
device and after writing useful checkpoints.
|
|
77
|
+
|
|
78
|
+
## How Projects Are Identified
|
|
79
|
+
|
|
80
|
+
By default, `agent-handoff` identifies the current project from the git `origin`
|
|
81
|
+
remote. Different clones, sibling checkouts, or worktrees of the same repository
|
|
82
|
+
map to the same project memory:
|
|
115
83
|
|
|
116
84
|
```text
|
|
117
|
-
https://github.com/
|
|
118
|
-
git@github.com:
|
|
85
|
+
https://github.com/p1cn/loop.git
|
|
86
|
+
git@github.com:p1cn/loop.git
|
|
119
87
|
|
|
120
|
-
github.
|
|
88
|
+
github.com__p1cn__loop
|
|
121
89
|
```
|
|
122
90
|
|
|
123
|
-
|
|
124
|
-
|
|
91
|
+
If `.agent-handoff.yml` exists, its `project_id` is used as an override. The
|
|
92
|
+
tool does not require this file for normal git repositories.
|
|
125
93
|
|
|
126
94
|
## What Gets Stored
|
|
127
95
|
|
|
128
|
-
The vault is private user state:
|
|
129
|
-
|
|
130
96
|
```text
|
|
131
97
|
~/.agent-handoff/
|
|
132
98
|
config.json
|
|
@@ -146,64 +112,30 @@ The vault is private user state:
|
|
|
146
112
|
20260508T103000Z-laptop-codex-main.md
|
|
147
113
|
```
|
|
148
114
|
|
|
149
|
-
The
|
|
115
|
+
The layers are:
|
|
150
116
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
```
|
|
117
|
+
- `global`: preferences and lessons that apply across projects.
|
|
118
|
+
- `project`: durable background, decisions, and preferences for one repository.
|
|
119
|
+
- `branch`: task or branch-specific context.
|
|
120
|
+
- `checkpoints`: recent session handoff notes.
|
|
156
121
|
|
|
157
122
|
## Commands
|
|
158
123
|
|
|
159
124
|
```bash
|
|
160
|
-
agent-handoff
|
|
161
|
-
agent-handoff
|
|
162
|
-
agent-handoff init # bootstrap the current repo
|
|
163
|
-
agent-handoff start # print the context packet for a new session
|
|
125
|
+
agent-handoff enable # create local memory and install the user skill
|
|
126
|
+
agent-handoff start # print context for the current project and branch
|
|
164
127
|
agent-handoff checkpoint # write a session checkpoint
|
|
165
|
-
agent-handoff learn #
|
|
166
|
-
agent-handoff sync
|
|
167
|
-
agent-handoff
|
|
168
|
-
agent-handoff
|
|
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
|
|
128
|
+
agent-handoff learn # store durable global/project/branch memory
|
|
129
|
+
agent-handoff sync init # enable optional cross-device sync
|
|
130
|
+
agent-handoff sync # pull/rebase and push the vault
|
|
131
|
+
agent-handoff status # quick readiness and sync-state check
|
|
185
132
|
```
|
|
186
133
|
|
|
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
134
|
## Development
|
|
197
135
|
|
|
198
|
-
Run tests:
|
|
199
|
-
|
|
200
136
|
```bash
|
|
137
|
+
npm install
|
|
201
138
|
npm test
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
Run type checking and build:
|
|
205
|
-
|
|
206
|
-
```bash
|
|
207
139
|
npm run typecheck
|
|
208
140
|
npm run build
|
|
209
141
|
```
|
package/dist/bin.js
CHANGED
|
File without changes
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { Command, CommanderError,
|
|
3
|
-
import { HandoffError, buildStartPacket,
|
|
2
|
+
import { Command, CommanderError, Option } from "commander";
|
|
3
|
+
import { HandoffError, buildStartPacket, enableHandoff, enableSync, getStatus, learn, syncVault, writeCheckpoint, } from "./core.js";
|
|
4
4
|
export function main(argv = process.argv.slice(2), opts = {}) {
|
|
5
5
|
const stdout = opts.stdout ?? process.stdout;
|
|
6
6
|
const stderr = opts.stderr ?? process.stderr;
|
|
@@ -24,8 +24,8 @@ function buildProgram(stdout, stderr, stdin, cwd) {
|
|
|
24
24
|
const program = new Command();
|
|
25
25
|
program
|
|
26
26
|
.name("agent-handoff")
|
|
27
|
-
.description("Shared vault handoff memory for
|
|
28
|
-
.version("agent-handoff 0.
|
|
27
|
+
.description("Shared vault handoff memory for coding agents.")
|
|
28
|
+
.version("agent-handoff 0.5.0")
|
|
29
29
|
.exitOverride()
|
|
30
30
|
.configureOutput({
|
|
31
31
|
writeOut: (str) => stdout.write(str),
|
|
@@ -33,42 +33,19 @@ function buildProgram(stdout, stderr, stdin, cwd) {
|
|
|
33
33
|
})
|
|
34
34
|
.option("--home <path>", "Agent handoff home directory. Defaults to ~/.agent-handoff.");
|
|
35
35
|
program
|
|
36
|
-
.command("
|
|
37
|
-
.description("
|
|
36
|
+
.command("enable")
|
|
37
|
+
.description("Enable local handoff memory and install the user skill.")
|
|
38
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
39
|
.option("--skills-home <path>", "Skills home directory. Defaults to ~/.agents/skills.")
|
|
49
40
|
.action((options) => {
|
|
50
|
-
const result =
|
|
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,
|
|
41
|
+
const result = enableHandoff({
|
|
65
42
|
home: globalHome(program),
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
clients: options.client,
|
|
43
|
+
vault: options.vault,
|
|
44
|
+
skillsHome: options.skillsHome,
|
|
69
45
|
});
|
|
70
|
-
stdout.write(`
|
|
71
|
-
stdout.write(`Vault
|
|
46
|
+
stdout.write(`Agent handoff enabled: ${result.setup.home}\n`);
|
|
47
|
+
stdout.write(`Vault: ${result.setup.vault}\n`);
|
|
48
|
+
stdout.write(`Skill: ${result.skill.path}\n`);
|
|
72
49
|
});
|
|
73
50
|
program
|
|
74
51
|
.command("start")
|
|
@@ -83,7 +60,7 @@ function buildProgram(stdout, stderr, stdin, cwd) {
|
|
|
83
60
|
.option("--note <text>", "Note text. If omitted, stdin is used.")
|
|
84
61
|
.option("--file <path>", "Read note text from a file.")
|
|
85
62
|
.option("--device <name>", "Device name for the checkpoint.")
|
|
86
|
-
.option("--agent <name>", "Agent/client
|
|
63
|
+
.option("--agent <name>", "Agent/client label for checkpoint metadata.")
|
|
87
64
|
.option("--branch <branch>", "Override detected branch.")
|
|
88
65
|
.action((options) => {
|
|
89
66
|
const result = writeCheckpoint({
|
|
@@ -114,10 +91,17 @@ function buildProgram(stdout, stderr, stdin, cwd) {
|
|
|
114
91
|
});
|
|
115
92
|
stdout.write(`Learned ${result.kind}: ${result.path}\n`);
|
|
116
93
|
});
|
|
117
|
-
program
|
|
118
|
-
|
|
119
|
-
.
|
|
120
|
-
.
|
|
94
|
+
const sync = program.command("sync").description("Sync the handoff vault.");
|
|
95
|
+
sync
|
|
96
|
+
.command("init <git-url>")
|
|
97
|
+
.description("Enable cross-device sync with a private git repository.")
|
|
98
|
+
.option("--vault <path>", "Vault directory. Defaults to HOME/vault.")
|
|
99
|
+
.action((gitUrl, options) => {
|
|
100
|
+
const result = enableSync({ home: globalHome(program), vault: options.vault, syncUrl: gitUrl });
|
|
101
|
+
stdout.write(`Cross-device sync enabled: ${gitUrl}\n`);
|
|
102
|
+
stdout.write(`Vault: ${result.vault}\n`);
|
|
103
|
+
});
|
|
104
|
+
sync.action(() => {
|
|
121
105
|
for (const output of syncVault({ home: globalHome(program) })) {
|
|
122
106
|
if (output)
|
|
123
107
|
stdout.write(`${output}\n`);
|
|
@@ -130,36 +114,23 @@ function buildProgram(stdout, stderr, stdin, cwd) {
|
|
|
130
114
|
const status = getStatus({ root: cwd, home: globalHome(program) });
|
|
131
115
|
if (status.initialized) {
|
|
132
116
|
stdout.write(`Agent handoff is ready for ${status.projectId}.\n`);
|
|
117
|
+
if (status.syncConfigured) {
|
|
118
|
+
stdout.write(`Sync: configured (${status.syncUrl})\n`);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
stdout.write("Sync: not configured\n");
|
|
122
|
+
}
|
|
133
123
|
}
|
|
134
124
|
else {
|
|
135
125
|
printProblems(status.problems, stdout);
|
|
136
126
|
throw new CommanderError(1, "agent-handoff.status", "status failed");
|
|
137
127
|
}
|
|
138
128
|
});
|
|
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
129
|
return program;
|
|
153
130
|
}
|
|
154
131
|
function globalHome(program) {
|
|
155
132
|
return program.opts().home;
|
|
156
133
|
}
|
|
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
134
|
function readNote(options, stdin) {
|
|
164
135
|
if (options.note)
|
|
165
136
|
return options.note;
|
package/dist/core.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export declare const DEFAULT_HOME: string;
|
|
2
2
|
export declare const CONFIG_FILE = "config.json";
|
|
3
3
|
export declare const BOOTSTRAP_FILE = ".agent-handoff.yml";
|
|
4
|
-
type Client = "codex" | "claude";
|
|
5
4
|
type LearnScope = "global" | "project" | "branch";
|
|
6
5
|
type LearnKind = "preference" | "lesson" | "decision" | "context";
|
|
7
6
|
export declare class HandoffError extends Error {
|
|
@@ -13,13 +12,9 @@ export interface SetupResult {
|
|
|
13
12
|
created: number;
|
|
14
13
|
updated: number;
|
|
15
14
|
}
|
|
16
|
-
export interface
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
root: string;
|
|
20
|
-
projectId: string;
|
|
21
|
-
vaultProject: string;
|
|
22
|
-
clients: Client[];
|
|
15
|
+
export interface EnableResult {
|
|
16
|
+
setup: SetupResult;
|
|
17
|
+
skill: InstallSkillResult;
|
|
23
18
|
}
|
|
24
19
|
export interface CheckpointResult {
|
|
25
20
|
path: string;
|
|
@@ -41,12 +36,8 @@ export interface Status {
|
|
|
41
36
|
problems: string[];
|
|
42
37
|
root: string;
|
|
43
38
|
projectId: string | null;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
ok: boolean;
|
|
47
|
-
problems: string[];
|
|
48
|
-
root: string;
|
|
49
|
-
projectId: string | null;
|
|
39
|
+
syncConfigured: boolean;
|
|
40
|
+
syncUrl?: string;
|
|
50
41
|
}
|
|
51
42
|
export declare function setupHome(opts?: {
|
|
52
43
|
home?: string;
|
|
@@ -57,13 +48,16 @@ export declare function normalizeProjectId(value: string): string;
|
|
|
57
48
|
export declare function installSkill(opts?: {
|
|
58
49
|
skillsHome?: string;
|
|
59
50
|
}): InstallSkillResult;
|
|
60
|
-
export declare function
|
|
61
|
-
root?: string;
|
|
51
|
+
export declare function enableHandoff(opts?: {
|
|
62
52
|
home?: string;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
53
|
+
vault?: string;
|
|
54
|
+
skillsHome?: string;
|
|
55
|
+
}): EnableResult;
|
|
56
|
+
export declare function enableSync(opts: {
|
|
57
|
+
home?: string;
|
|
58
|
+
vault?: string;
|
|
59
|
+
syncUrl: string;
|
|
60
|
+
}): SetupResult;
|
|
67
61
|
export declare function deriveProjectId(root?: string, projectId?: string): string;
|
|
68
62
|
export declare function currentBranch(root?: string): string;
|
|
69
63
|
export declare function buildStartPacket(opts?: {
|
|
@@ -96,8 +90,4 @@ export declare function getStatus(opts?: {
|
|
|
96
90
|
root?: string;
|
|
97
91
|
home?: string;
|
|
98
92
|
}): Status;
|
|
99
|
-
export declare function doctor(opts?: {
|
|
100
|
-
root?: string;
|
|
101
|
-
home?: string;
|
|
102
|
-
}): DoctorReport;
|
|
103
93
|
export {};
|
package/dist/core.js
CHANGED
|
@@ -6,13 +6,12 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
export const DEFAULT_HOME = join(homedir(), ".agent-handoff");
|
|
7
7
|
export const CONFIG_FILE = "config.json";
|
|
8
8
|
export const BOOTSTRAP_FILE = ".agent-handoff.yml";
|
|
9
|
-
const MANAGED_BEGIN = "<!-- BEGIN AGENT-HANDOFF -->";
|
|
10
|
-
const MANAGED_END = "<!-- END AGENT-HANDOFF -->";
|
|
11
9
|
const SECRET_PATTERNS = [
|
|
12
10
|
/(api[_-]?key|token|secret|password)\s*[:=]/i,
|
|
13
11
|
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
14
12
|
/\bsk-[A-Za-z0-9_-]{8,}\b/,
|
|
15
13
|
];
|
|
14
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
16
15
|
export class HandoffError extends Error {
|
|
17
16
|
constructor(message) {
|
|
18
17
|
super(message);
|
|
@@ -28,6 +27,13 @@ export function setupHome(opts = {}) {
|
|
|
28
27
|
mkdirSync(homePath, { recursive: true });
|
|
29
28
|
created += 1;
|
|
30
29
|
}
|
|
30
|
+
if (opts.syncUrl) {
|
|
31
|
+
validateSyncRemote(homePath, opts.syncUrl);
|
|
32
|
+
if (hasUnsyncedLocalVault(vaultPath) && remoteHasRefs(homePath, opts.syncUrl)) {
|
|
33
|
+
throw new HandoffError("local vault has unsynced memory and the sync remote already has data; move or back up the local vault before joining this remote");
|
|
34
|
+
}
|
|
35
|
+
ensureExistingGitVaultCompatible(vaultPath, opts.syncUrl);
|
|
36
|
+
}
|
|
31
37
|
if (opts.syncUrl && cloneVaultIfNeeded(homePath, vaultPath, opts.syncUrl)) {
|
|
32
38
|
created += 1;
|
|
33
39
|
}
|
|
@@ -49,7 +55,9 @@ export function setupHome(opts = {}) {
|
|
|
49
55
|
if (opts.syncUrl)
|
|
50
56
|
desired.sync_url = opts.syncUrl;
|
|
51
57
|
if (existsSync(configPath)) {
|
|
52
|
-
const existing =
|
|
58
|
+
const existing = readConfig(opts.home);
|
|
59
|
+
if (!existing)
|
|
60
|
+
throw new HandoffError(`${CONFIG_FILE} is missing`);
|
|
53
61
|
let changed = false;
|
|
54
62
|
if (existing.version !== 2) {
|
|
55
63
|
existing.version = 2;
|
|
@@ -114,49 +122,13 @@ export function installSkill(opts = {}) {
|
|
|
114
122
|
}
|
|
115
123
|
return { path, updated };
|
|
116
124
|
}
|
|
117
|
-
export function
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
};
|
|
125
|
+
export function enableHandoff(opts = {}) {
|
|
126
|
+
const setup = setupHome({ home: opts.home, vault: opts.vault });
|
|
127
|
+
const skill = installSkill({ skillsHome: opts.skillsHome });
|
|
128
|
+
return { setup, skill };
|
|
129
|
+
}
|
|
130
|
+
export function enableSync(opts) {
|
|
131
|
+
return setupHome({ home: opts.home, vault: opts.vault, syncUrl: opts.syncUrl });
|
|
160
132
|
}
|
|
161
133
|
export function deriveProjectId(root = ".", projectId) {
|
|
162
134
|
const rootPath = resolve(root);
|
|
@@ -178,14 +150,11 @@ export function currentBranch(root = ".") {
|
|
|
178
150
|
}
|
|
179
151
|
export function buildStartPacket(opts = {}) {
|
|
180
152
|
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
153
|
const setup = loadSetup(opts.home);
|
|
186
|
-
const pid =
|
|
154
|
+
const pid = deriveProjectId(rootPath);
|
|
187
155
|
const branchName = opts.branch ?? currentBranch(rootPath);
|
|
188
156
|
const projectPath = vaultProjectPath(setup.vault, pid);
|
|
157
|
+
ensureProjectFiles(projectPath, branchName);
|
|
189
158
|
const branchFile = join(projectPath, "branches", `${safeName(branchName)}.md`);
|
|
190
159
|
const sections = [
|
|
191
160
|
["Global Preferences", join(setup.vault, "global", "preferences.md")],
|
|
@@ -201,7 +170,7 @@ export function buildStartPacket(opts = {}) {
|
|
|
201
170
|
`Project: \`${pid}\``,
|
|
202
171
|
`Branch: \`${branchName}\``,
|
|
203
172
|
"",
|
|
204
|
-
"Read this packet before making changes. Use it to recover context from previous
|
|
173
|
+
"Read this packet before making changes. Use it to recover context from previous coding-agent sessions.",
|
|
205
174
|
];
|
|
206
175
|
for (const [title, path] of sections) {
|
|
207
176
|
lines.push(...renderSection(title, path));
|
|
@@ -218,25 +187,22 @@ export function buildStartPacket(opts = {}) {
|
|
|
218
187
|
}
|
|
219
188
|
export function writeCheckpoint(opts) {
|
|
220
189
|
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
190
|
const cleanedNote = cleanNote(opts.note);
|
|
226
191
|
if (!cleanedNote)
|
|
227
192
|
throw new HandoffError("checkpoint note cannot be empty");
|
|
228
193
|
rejectLikelySecret(cleanedNote);
|
|
229
194
|
const setup = loadSetup(opts.home);
|
|
230
|
-
const pid =
|
|
195
|
+
const pid = deriveProjectId(rootPath);
|
|
231
196
|
const branchName = opts.branch ?? currentBranch(rootPath);
|
|
232
197
|
const createdAt = timestamp(opts.now);
|
|
233
198
|
const projectPath = vaultProjectPath(setup.vault, pid);
|
|
199
|
+
ensureProjectFiles(projectPath, branchName);
|
|
234
200
|
const checkpoints = join(projectPath, "checkpoints");
|
|
235
201
|
mkdirSync(checkpoints, { recursive: true });
|
|
236
202
|
const deviceLabel = opts.device ?? hostname() ?? "device";
|
|
237
203
|
const agentLabel = opts.agent ?? "agent";
|
|
238
204
|
const filename = `${compactTimestamp(createdAt)}-${safeName(deviceLabel)}-${safeName(agentLabel)}-${safeName(branchName)}.md`;
|
|
239
|
-
const path =
|
|
205
|
+
const path = uniquePath(checkpoints, filename);
|
|
240
206
|
const contents = [
|
|
241
207
|
"# Checkpoint",
|
|
242
208
|
"",
|
|
@@ -267,7 +233,7 @@ export function learn(note, opts = {}) {
|
|
|
267
233
|
if (!["preference", "lesson", "decision", "context"].includes(kind)) {
|
|
268
234
|
throw new HandoffError("learn kind must be 'preference', 'lesson', 'decision', or 'context'");
|
|
269
235
|
}
|
|
270
|
-
const setup =
|
|
236
|
+
const setup = loadSetup(opts.home);
|
|
271
237
|
const path = learnTargetPath(setup, resolve(opts.root ?? "."), scope, kind, opts.branch);
|
|
272
238
|
const createdAt = timestamp(opts.now);
|
|
273
239
|
appendFile(path, `\n- ${createdAt}: ${clean}\n`);
|
|
@@ -275,9 +241,13 @@ export function learn(note, opts = {}) {
|
|
|
275
241
|
}
|
|
276
242
|
export function syncVault(opts = {}) {
|
|
277
243
|
const setup = loadSetup(opts.home);
|
|
278
|
-
|
|
279
|
-
|
|
244
|
+
const config = readConfig(opts.home);
|
|
245
|
+
if (!config?.sync_url) {
|
|
246
|
+
throw new HandoffError("sync is not configured; run agent-handoff sync init <git-url> first");
|
|
280
247
|
}
|
|
248
|
+
const syncProblem = syncConfigProblem(setup.vault, config.sync_url);
|
|
249
|
+
if (syncProblem)
|
|
250
|
+
throw new HandoffError(syncProblem);
|
|
281
251
|
const outputs = [];
|
|
282
252
|
gitChecked(setup.vault, ["add", "-A"]);
|
|
283
253
|
const staged = gitRun(setup.vault, ["diff", "--cached", "--quiet"]).status !== 0;
|
|
@@ -310,46 +280,49 @@ export function syncVault(opts = {}) {
|
|
|
310
280
|
export function getStatus(opts = {}) {
|
|
311
281
|
const rootPath = resolve(opts.root ?? ".");
|
|
312
282
|
const problems = [];
|
|
313
|
-
|
|
314
|
-
let
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
326
|
-
for (const filename of clientInstructionFiles(clients)) {
|
|
327
|
-
if (!hasManagedBlock(join(rootPath, filename))) {
|
|
328
|
-
problems.push(`${filename} is missing the managed handoff block`);
|
|
283
|
+
const projectId = deriveProjectId(rootPath);
|
|
284
|
+
let syncUrl;
|
|
285
|
+
let config = null;
|
|
286
|
+
try {
|
|
287
|
+
config = readConfig(opts.home);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (error instanceof HandoffError) {
|
|
291
|
+
problems.push(error.message);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
throw error;
|
|
329
295
|
}
|
|
330
296
|
}
|
|
331
|
-
const config = readConfig(opts.home);
|
|
332
297
|
if (!config) {
|
|
333
|
-
problems.
|
|
298
|
+
if (problems.length === 0) {
|
|
299
|
+
problems.push("agent-handoff is not enabled; run agent-handoff enable");
|
|
300
|
+
}
|
|
334
301
|
}
|
|
335
302
|
else if (!existsSync(config.vault)) {
|
|
336
303
|
problems.push(`vault directory is missing: ${config.vault}`);
|
|
337
304
|
}
|
|
338
|
-
else
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
305
|
+
else {
|
|
306
|
+
if (config.sync_url) {
|
|
307
|
+
const syncProblem = syncConfigProblem(config.vault, config.sync_url);
|
|
308
|
+
if (syncProblem) {
|
|
309
|
+
problems.push(syncProblem);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
syncUrl = config.sync_url;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
syncUrl = undefined;
|
|
342
317
|
}
|
|
343
318
|
}
|
|
344
|
-
return { initialized: problems.length === 0, problems, root: rootPath, projectId };
|
|
345
|
-
}
|
|
346
|
-
export function doctor(opts = {}) {
|
|
347
|
-
const status = getStatus(opts);
|
|
348
319
|
return {
|
|
349
|
-
|
|
350
|
-
problems
|
|
351
|
-
root:
|
|
352
|
-
projectId
|
|
320
|
+
initialized: problems.length === 0,
|
|
321
|
+
problems,
|
|
322
|
+
root: rootPath,
|
|
323
|
+
projectId,
|
|
324
|
+
syncConfigured: Boolean(syncUrl),
|
|
325
|
+
syncUrl,
|
|
353
326
|
};
|
|
354
327
|
}
|
|
355
328
|
function globalSeedFiles() {
|
|
@@ -398,8 +371,9 @@ function learnTargetPath(setup, root, scope, kind, branch) {
|
|
|
398
371
|
if (!status.initialized) {
|
|
399
372
|
throw new HandoffError(statusError(status));
|
|
400
373
|
}
|
|
401
|
-
const pid =
|
|
374
|
+
const pid = deriveProjectId(root);
|
|
402
375
|
const projectPath = vaultProjectPath(setup.vault, pid);
|
|
376
|
+
ensureProjectFiles(projectPath, branch ?? currentBranch(root));
|
|
403
377
|
if (scope === "project") {
|
|
404
378
|
if (kind === "preference")
|
|
405
379
|
return join(projectPath, "preferences.md");
|
|
@@ -414,31 +388,6 @@ function learnTargetPath(setup, root, scope, kind, branch) {
|
|
|
414
388
|
}
|
|
415
389
|
return branchPath;
|
|
416
390
|
}
|
|
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
391
|
function resolveHome(home) {
|
|
443
392
|
return resolve(home ? expandHome(home) : DEFAULT_HOME);
|
|
444
393
|
}
|
|
@@ -453,12 +402,58 @@ function readConfig(home) {
|
|
|
453
402
|
const configPath = join(resolveHome(home), CONFIG_FILE);
|
|
454
403
|
if (!existsSync(configPath))
|
|
455
404
|
return null;
|
|
456
|
-
|
|
405
|
+
let raw;
|
|
406
|
+
try {
|
|
407
|
+
raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
411
|
+
throw new HandoffError(`${CONFIG_FILE} is invalid: ${detail}`);
|
|
412
|
+
}
|
|
413
|
+
return validateConfig(raw);
|
|
414
|
+
}
|
|
415
|
+
function validateConfig(raw) {
|
|
416
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
417
|
+
throw new HandoffError(`${CONFIG_FILE} is invalid: expected an object`);
|
|
418
|
+
}
|
|
419
|
+
const data = raw;
|
|
420
|
+
if (typeof data.vault !== "string" || data.vault.trim().length === 0) {
|
|
421
|
+
throw new HandoffError(`${CONFIG_FILE} is invalid: vault must be a non-empty string`);
|
|
422
|
+
}
|
|
423
|
+
if (data.version !== undefined && typeof data.version !== "number") {
|
|
424
|
+
throw new HandoffError(`${CONFIG_FILE} is invalid: version must be a number`);
|
|
425
|
+
}
|
|
426
|
+
if (data.sync_url !== undefined && (typeof data.sync_url !== "string" || data.sync_url.trim().length === 0)) {
|
|
427
|
+
throw new HandoffError(`${CONFIG_FILE} is invalid: sync_url must be a non-empty string`);
|
|
428
|
+
}
|
|
429
|
+
const config = {
|
|
430
|
+
version: typeof data.version === "number" ? data.version : 0,
|
|
431
|
+
vault: data.vault,
|
|
432
|
+
};
|
|
433
|
+
if (typeof data.sync_url === "string") {
|
|
434
|
+
config.sync_url = data.sync_url;
|
|
435
|
+
}
|
|
436
|
+
return config;
|
|
437
|
+
}
|
|
438
|
+
function syncConfigProblem(vault, syncUrl) {
|
|
439
|
+
if (!existsSync(join(vault, ".git"))) {
|
|
440
|
+
return `sync is configured for ${syncUrl} but vault is not a git repository; run agent-handoff sync init ${syncUrl}`;
|
|
441
|
+
}
|
|
442
|
+
const origin = gitOutput(vault, ["remote", "get-url", "origin"]);
|
|
443
|
+
if (!origin) {
|
|
444
|
+
return `sync is configured for ${syncUrl} but vault git remote origin is missing; run agent-handoff sync init ${syncUrl}`;
|
|
445
|
+
}
|
|
446
|
+
if (origin !== syncUrl) {
|
|
447
|
+
return `sync is configured for ${syncUrl} but vault origin is ${origin}; run agent-handoff sync init ${syncUrl}`;
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
457
450
|
}
|
|
458
451
|
function loadSetup(home) {
|
|
459
452
|
const config = readConfig(home);
|
|
460
453
|
if (!config)
|
|
461
|
-
|
|
454
|
+
throw new HandoffError("agent-handoff is not enabled; run agent-handoff enable");
|
|
455
|
+
if (!existsSync(config.vault))
|
|
456
|
+
throw new HandoffError(`vault directory is missing: ${config.vault}`);
|
|
462
457
|
return { home: resolveHome(home), vault: resolve(config.vault), created: 0, updated: 0 };
|
|
463
458
|
}
|
|
464
459
|
function vaultProjectPath(vault, projectId) {
|
|
@@ -491,66 +486,6 @@ function readBootstrap(path) {
|
|
|
491
486
|
}
|
|
492
487
|
return data;
|
|
493
488
|
}
|
|
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
489
|
function renderSection(title, path, headingLevel = 2) {
|
|
555
490
|
const heading = "#".repeat(headingLevel);
|
|
556
491
|
if (!existsSync(path)) {
|
|
@@ -616,11 +551,72 @@ function json(data) {
|
|
|
616
551
|
function appendFile(path, contents) {
|
|
617
552
|
writeFileSync(path, contents, { encoding: "utf8", flag: "a" });
|
|
618
553
|
}
|
|
554
|
+
function uniquePath(directory, filename) {
|
|
555
|
+
const dot = filename.lastIndexOf(".");
|
|
556
|
+
const stem = dot === -1 ? filename : filename.slice(0, dot);
|
|
557
|
+
const extension = dot === -1 ? "" : filename.slice(dot);
|
|
558
|
+
let path = join(directory, filename);
|
|
559
|
+
let index = 2;
|
|
560
|
+
while (existsSync(path)) {
|
|
561
|
+
path = join(directory, `${stem}-${index}${extension}`);
|
|
562
|
+
index += 1;
|
|
563
|
+
}
|
|
564
|
+
return path;
|
|
565
|
+
}
|
|
566
|
+
function validateSyncRemote(home, syncUrl) {
|
|
567
|
+
const result = gitRun(home, ["ls-remote", syncUrl]);
|
|
568
|
+
if (result.status !== 0) {
|
|
569
|
+
throw new HandoffError(result.output.trim() || `cannot access sync remote: ${syncUrl}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function hasUnsyncedLocalVault(vault) {
|
|
573
|
+
return existsSync(vault) && !existsSync(join(vault, ".git")) && !isSeedOnlyVault(vault);
|
|
574
|
+
}
|
|
575
|
+
function remoteHasRefs(home, syncUrl) {
|
|
576
|
+
const result = gitRun(home, ["ls-remote", "--heads", syncUrl]);
|
|
577
|
+
if (result.status !== 0) {
|
|
578
|
+
throw new HandoffError(result.output.trim() || `cannot inspect sync remote: ${syncUrl}`);
|
|
579
|
+
}
|
|
580
|
+
return result.output.trim().length > 0;
|
|
581
|
+
}
|
|
582
|
+
function ensureExistingGitVaultCompatible(vault, syncUrl) {
|
|
583
|
+
if (!existsSync(join(vault, ".git")))
|
|
584
|
+
return;
|
|
585
|
+
if (!remoteHasRefs(vault, syncUrl))
|
|
586
|
+
return;
|
|
587
|
+
const localHead = gitOutput(vault, ["rev-parse", "HEAD"]);
|
|
588
|
+
if (!localHead)
|
|
589
|
+
return;
|
|
590
|
+
const namespace = "refs/remotes/agent-handoff-check";
|
|
591
|
+
const fetch = gitRun(vault, ["fetch", "--no-tags", syncUrl, `+refs/heads/*:${namespace}/*`]);
|
|
592
|
+
if (fetch.status !== 0) {
|
|
593
|
+
throw new HandoffError(fetch.output.trim() || `cannot fetch sync remote: ${syncUrl}`);
|
|
594
|
+
}
|
|
595
|
+
const refs = listRefs(vault, namespace);
|
|
596
|
+
try {
|
|
597
|
+
if (refs.length > 0 && !refs.some((ref) => sharesHistory(vault, localHead, ref))) {
|
|
598
|
+
throw new HandoffError("existing git vault and sync remote do not share history; use a fresh vault or keep the current sync remote");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
for (const ref of refs) {
|
|
603
|
+
gitRun(vault, ["update-ref", "-d", ref]);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function listRefs(vault, namespace) {
|
|
608
|
+
const refs = gitOutput(vault, ["for-each-ref", "--format=%(refname)", namespace]);
|
|
609
|
+
return refs?.split(/\r?\n/).filter(Boolean) ?? [];
|
|
610
|
+
}
|
|
611
|
+
function sharesHistory(vault, localHead, remoteRef) {
|
|
612
|
+
return (gitRun(vault, ["merge-base", "--is-ancestor", localHead, remoteRef]).status === 0 ||
|
|
613
|
+
gitRun(vault, ["merge-base", "--is-ancestor", remoteRef, localHead]).status === 0);
|
|
614
|
+
}
|
|
619
615
|
function cloneVaultIfNeeded(home, vault, syncUrl) {
|
|
620
616
|
if (existsSync(join(vault, ".git")))
|
|
621
617
|
return false;
|
|
622
618
|
if (existsSync(vault)) {
|
|
623
|
-
if (readdirSync(vault).length === 0) {
|
|
619
|
+
if (readdirSync(vault).length === 0 || isSeedOnlyVault(vault)) {
|
|
624
620
|
rmSync(vault, { recursive: true, force: true });
|
|
625
621
|
}
|
|
626
622
|
else {
|
|
@@ -628,19 +624,72 @@ function cloneVaultIfNeeded(home, vault, syncUrl) {
|
|
|
628
624
|
}
|
|
629
625
|
}
|
|
630
626
|
const clone = gitRun(home, ["clone", syncUrl, vault]);
|
|
627
|
+
if (clone.status !== 0) {
|
|
628
|
+
throw new HandoffError(clone.output.trim() || `git clone ${syncUrl} failed`);
|
|
629
|
+
}
|
|
631
630
|
return clone.status === 0;
|
|
632
631
|
}
|
|
632
|
+
function isSeedOnlyVault(vault) {
|
|
633
|
+
const entries = readdirSync(vault).sort();
|
|
634
|
+
if (entries.some((entry) => !["global", "projects"].includes(entry)))
|
|
635
|
+
return false;
|
|
636
|
+
const projects = join(vault, "projects");
|
|
637
|
+
if (existsSync(projects) && !isSeedOnlyProjects(projects))
|
|
638
|
+
return false;
|
|
639
|
+
const global = join(vault, "global");
|
|
640
|
+
if (!existsSync(global))
|
|
641
|
+
return entries.length === 0 || entries.every((entry) => entry === "projects");
|
|
642
|
+
const seeds = globalSeedFiles();
|
|
643
|
+
for (const entry of readdirSync(global)) {
|
|
644
|
+
if (!(entry in seeds))
|
|
645
|
+
return false;
|
|
646
|
+
if (readFileSync(join(global, entry), "utf8") !== seeds[entry])
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
function isSeedOnlyProjects(projects) {
|
|
652
|
+
for (const name of readdirSync(projects)) {
|
|
653
|
+
if (!isSeedOnlyProject(join(projects, name)))
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
function isSeedOnlyProject(projectPath) {
|
|
659
|
+
const allowed = new Set(["project.md", "decisions.md", "preferences.md", "branches", "checkpoints"]);
|
|
660
|
+
for (const entry of readdirSync(projectPath)) {
|
|
661
|
+
if (!allowed.has(entry))
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
const seeds = projectSeedFiles();
|
|
665
|
+
for (const [filename, contents] of Object.entries(seeds)) {
|
|
666
|
+
const path = join(projectPath, filename);
|
|
667
|
+
if (existsSync(path) && readFileSync(path, "utf8") !== contents)
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
const checkpoints = join(projectPath, "checkpoints");
|
|
671
|
+
if (existsSync(checkpoints) && readdirSync(checkpoints).length > 0)
|
|
672
|
+
return false;
|
|
673
|
+
const branches = join(projectPath, "branches");
|
|
674
|
+
if (!existsSync(branches))
|
|
675
|
+
return true;
|
|
676
|
+
return readdirSync(branches).every((name) => {
|
|
677
|
+
if (!name.endsWith(".md"))
|
|
678
|
+
return false;
|
|
679
|
+
return /^# Branch Context: .+\n\n$/.test(readFileSync(join(branches, name), "utf8"));
|
|
680
|
+
});
|
|
681
|
+
}
|
|
633
682
|
function ensureGitRemote(vault, syncUrl) {
|
|
634
683
|
if (!existsSync(join(vault, ".git"))) {
|
|
635
|
-
|
|
636
|
-
|
|
684
|
+
gitChecked(vault, ["init"]);
|
|
685
|
+
gitChecked(vault, ["branch", "-M", "main"]);
|
|
637
686
|
}
|
|
638
687
|
const remotes = gitOutput(vault, ["remote"]);
|
|
639
688
|
if (remotes?.split(/\r?\n/).includes("origin")) {
|
|
640
|
-
|
|
689
|
+
gitChecked(vault, ["remote", "set-url", "origin", syncUrl]);
|
|
641
690
|
}
|
|
642
691
|
else {
|
|
643
|
-
|
|
692
|
+
gitChecked(vault, ["remote", "add", "origin", syncUrl]);
|
|
644
693
|
}
|
|
645
694
|
}
|
|
646
695
|
function gitOutput(root, args) {
|
|
@@ -661,11 +710,14 @@ function gitRun(root, args) {
|
|
|
661
710
|
const result = spawnSync("git", args, {
|
|
662
711
|
cwd: root,
|
|
663
712
|
encoding: "utf8",
|
|
713
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
664
714
|
stdio: ["ignore", "pipe", "pipe"],
|
|
715
|
+
timeout: GIT_TIMEOUT_MS,
|
|
665
716
|
});
|
|
717
|
+
const error = result.error ? `\n${result.error.message}` : "";
|
|
666
718
|
return {
|
|
667
719
|
status: result.status ?? 1,
|
|
668
|
-
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
|
720
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}${error}`,
|
|
669
721
|
};
|
|
670
722
|
}
|
|
671
723
|
function isEmptyRemotePull(output) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export
|
|
1
|
+
export { HandoffError, buildStartPacket, enableHandoff, enableSync, getStatus, learn, normalizeProjectId, syncVault, writeCheckpoint, } from "./core.js";
|
|
2
|
+
export type { CheckpointResult, EnableResult, LearnResult, Status, } from "./core.js";
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export { HandoffError, buildStartPacket, enableHandoff, enableSync, getStatus, learn, normalizeProjectId, syncVault, writeCheckpoint, } from "./core.js";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leantli/agent-handoff",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Shared vault handoff memory for
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Shared vault handoff memory for coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "agent-handoff contributors",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc",
|
|
20
|
-
"
|
|
20
|
+
"prepare": "npm run build",
|
|
21
21
|
"test": "vitest run",
|
|
22
22
|
"typecheck": "tsc --noEmit"
|
|
23
23
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agent-handoff
|
|
3
|
-
description: Use when starting, resuming, pausing, checkpointing, or transferring
|
|
3
|
+
description: Use when starting, resuming, pausing, checkpointing, or transferring coding-agent work across sessions, clones, worktrees, or devices with the agent-handoff CLI.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Agent Handoff
|
|
@@ -12,11 +12,16 @@ Use `agent-handoff` to restore and preserve coding-agent context.
|
|
|
12
12
|
When beginning work in a repository:
|
|
13
13
|
|
|
14
14
|
1. Run `agent-handoff status`.
|
|
15
|
-
2. If status
|
|
16
|
-
|
|
15
|
+
2. If status reports a problem, stop and report it. If the problem is that
|
|
16
|
+
agent-handoff is not enabled, tell the user to run `agent-handoff enable`.
|
|
17
|
+
3. If status says `Sync: configured` and the user is switching devices or clones,
|
|
18
|
+
run `agent-handoff sync`.
|
|
17
19
|
4. Run `agent-handoff start`.
|
|
18
20
|
5. Read the returned packet before changing files.
|
|
19
21
|
|
|
22
|
+
Do not edit `AGENTS.md`, `CLAUDE.md`, or other instruction files to install
|
|
23
|
+
agent-handoff.
|
|
24
|
+
|
|
20
25
|
## During Work
|
|
21
26
|
|
|
22
27
|
Use `learn` only for stable facts that should survive future sessions, clones,
|
|
@@ -55,7 +60,7 @@ agent, write a concise checkpoint:
|
|
|
55
60
|
agent-handoff checkpoint --note "<current goal, completed work, open questions, next step>"
|
|
56
61
|
```
|
|
57
62
|
|
|
58
|
-
If
|
|
63
|
+
If sync is configured, run:
|
|
59
64
|
|
|
60
65
|
```bash
|
|
61
66
|
agent-handoff sync
|
|
@@ -69,4 +74,4 @@ If sync fails, keep the local checkpoint and report the error.
|
|
|
69
74
|
- Keep checkpoints factual and concise.
|
|
70
75
|
- Do not use `learn` for temporary task state; use `checkpoint` instead.
|
|
71
76
|
- Prefer project or branch scope for project-specific facts instead of global memory.
|
|
72
|
-
-
|
|
77
|
+
- Do not modify repository instruction files as part of agent-handoff installation or use.
|