passalong 0.21.0 → 1.1.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 +63 -72
- package/bin/passalong +241 -378
- package/package.json +4 -4
- package/skill/SKILL.md +55 -167
- package/src/api.js +86 -188
- package/src/capture.js +15 -51
- package/src/checks.js +5 -5
- package/src/hooks.js +13 -22
- package/src/mcp.js +724 -1483
- package/src/passalong.js +602 -691
- package/src/runner.js +243 -0
- package/src/store.js +18 -28
- package/src/update.js +1 -1
- package/src/work.js +416 -0
- package/src/guide.js +0 -811
package/src/runner.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// The runner: `passalong work --watch` (docs/LOOP.md §1a). Left running with a repo checked out,
|
|
2
|
+
// it takes work a person ticked Safe to run alone and starts Claude Code on each piece, with nobody
|
|
3
|
+
// watching.
|
|
4
|
+
//
|
|
5
|
+
// Each piece gets its own git worktree, on a branch of its own, so up to three agents can work at
|
|
6
|
+
// once without touching each other's files, and each one's change arrives as a pull request a
|
|
7
|
+
// person reviews. At most ten runs start in a day. A run that ends without the work done (the agent
|
|
8
|
+
// gave up, said it was blocked, or ran out of time) gives the work back to whoever wrote it, with
|
|
9
|
+
// the agent's last note, and clears its Safe to run alone tick, so it is not tried again unwatched
|
|
10
|
+
// until a person has looked (2026-11-06).
|
|
11
|
+
//
|
|
12
|
+
// Everything that touches git, the clock or a process is passed in, so the tests can run it.
|
|
13
|
+
|
|
14
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
15
|
+
import { mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { basename, dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
/** How many agents at once, how many runs a day, and how long one run may take. */
|
|
19
|
+
export const LIMITS = { atOnce: 3, perDay: 10, minutes: 60 };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What an unattended agent may do. `dontAsk` refuses anything not listed here, since there is
|
|
23
|
+
* nobody to ask: read and edit files in its worktree, use Passalong, run the usual build and test
|
|
24
|
+
* tools, commit, push its own branch and open a pull request. Never push anywhere else, force a
|
|
25
|
+
* push, or throw work away.
|
|
26
|
+
*/
|
|
27
|
+
export const PERMISSIONS = {
|
|
28
|
+
allow: [
|
|
29
|
+
"Read",
|
|
30
|
+
"Edit",
|
|
31
|
+
"Write",
|
|
32
|
+
"Glob",
|
|
33
|
+
"Grep",
|
|
34
|
+
"mcp__passalong",
|
|
35
|
+
"Bash(git status*)",
|
|
36
|
+
"Bash(git diff*)",
|
|
37
|
+
"Bash(git log*)",
|
|
38
|
+
"Bash(git show*)",
|
|
39
|
+
"Bash(git add *)",
|
|
40
|
+
"Bash(git commit *)",
|
|
41
|
+
"Bash(git push -u origin passalong/*)",
|
|
42
|
+
"Bash(gh pr create *)",
|
|
43
|
+
"Bash(gh pr view *)",
|
|
44
|
+
"Bash(npm *)",
|
|
45
|
+
"Bash(pnpm *)",
|
|
46
|
+
"Bash(yarn *)",
|
|
47
|
+
"Bash(npx *)",
|
|
48
|
+
"Bash(node *)",
|
|
49
|
+
"Bash(make *)",
|
|
50
|
+
"Bash(python *)",
|
|
51
|
+
"Bash(python3 *)",
|
|
52
|
+
"Bash(pytest *)",
|
|
53
|
+
"Bash(go *)",
|
|
54
|
+
"Bash(cargo *)",
|
|
55
|
+
],
|
|
56
|
+
deny: [
|
|
57
|
+
"Bash(git push --force*)",
|
|
58
|
+
"Bash(git push -f*)",
|
|
59
|
+
"Bash(git push * --force*)",
|
|
60
|
+
"Bash(git reset --hard*)",
|
|
61
|
+
"Bash(rm -rf *)",
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The branch a piece of work's change goes on. */
|
|
66
|
+
export const branchOf = (id) => `passalong/${id}`;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Claude Code, started for one piece of work: headless, refusing anything not allowed above, and
|
|
70
|
+
* with Passalong's MCP server named here, because `passalong setup` registers it for one folder and
|
|
71
|
+
* a fresh worktree is another. The server runs in the worktree, so it is that worktree's agent: the
|
|
72
|
+
* one that took the work. The prompt goes in on stdin.
|
|
73
|
+
*/
|
|
74
|
+
export function claudeCommand({ node = process.execPath, cli } = {}) {
|
|
75
|
+
return {
|
|
76
|
+
command: "claude",
|
|
77
|
+
args: [
|
|
78
|
+
"-p",
|
|
79
|
+
"--permission-mode",
|
|
80
|
+
"dontAsk",
|
|
81
|
+
"--settings",
|
|
82
|
+
JSON.stringify({ permissions: PERMISSIONS }),
|
|
83
|
+
"--mcp-config",
|
|
84
|
+
JSON.stringify({ mcpServers: { passalong: { command: node, args: [cli, "mcp"] } } }),
|
|
85
|
+
],
|
|
86
|
+
stdin: true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** What the agent is told: the work itself, and how to finish it as a pull request. */
|
|
91
|
+
export function runnerPrompt(t, branch = branchOf(t.id)) {
|
|
92
|
+
return [
|
|
93
|
+
`You hold passalong work ${t.id}, and no other agent can take it while you do. Here it is:`,
|
|
94
|
+
"",
|
|
95
|
+
String(t.markdown || "").trim(),
|
|
96
|
+
"",
|
|
97
|
+
`Do it in this worktree, which is on its own branch, ${branch}. Read it, work out what done`,
|
|
98
|
+
`means, and get there. Call the passalong MCP tool progress with id ${t.id} and a one-line`,
|
|
99
|
+
"status at each milestone; 30 minutes of silence stalls it.",
|
|
100
|
+
"",
|
|
101
|
+
`When it is done, commit what you changed with a message starting "${t.id}: ", then run`,
|
|
102
|
+
`exactly \`git push -u origin ${branch}\` and open a pull request with`,
|
|
103
|
+
`\`gh pr create --fill --head ${branch}\`. Then call hand_in with id ${t.id}, \`pr\` set to the`,
|
|
104
|
+
"pull request's URL (or the commit hash if the push or the pull request failed, and say why in",
|
|
105
|
+
"the note), `note`: one plain sentence saying what you did, and `evidence`: what you ran and",
|
|
106
|
+
"what came back, with the commands and the lines that decided it, the test summary, the commit.",
|
|
107
|
+
"Keep that as you go. What you learned about the project goes into its documents",
|
|
108
|
+
"(save_document). If a call says you no longer hold it, stop.",
|
|
109
|
+
"",
|
|
110
|
+
"Nobody reads what you print: this session runs unattended, and anything not allowed is",
|
|
111
|
+
"refused rather than asked.",
|
|
112
|
+
"",
|
|
113
|
+
"When a decision is the person's to make (which of two layouts, which of three fixes), ask",
|
|
114
|
+
`them: call ask with id ${t.id}, one clear question, and \`choices\`: two to four options,`,
|
|
115
|
+
"each with a label and a line of detail. When an option is something to look at, take a",
|
|
116
|
+
"screenshot of it if you can, upload it with add_file, and give its link as that option's",
|
|
117
|
+
"`picture`. Then stop. They pick one with a tap in the hub, and you are started again here to",
|
|
118
|
+
"read their pick (take) and carry on.",
|
|
119
|
+
"",
|
|
120
|
+
"If something only a person can do stands between you and done (a permission, a secret), call",
|
|
121
|
+
`progress with id ${t.id} and a note starting "BLOCKED: " that says exactly what you need,`,
|
|
122
|
+
"and stop. The work goes back to the person who wrote it, with that note.",
|
|
123
|
+
].join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What an agent is told when its run starts again after a person answered its question: read the
|
|
128
|
+
* answer, then finish exactly as the first run was told to.
|
|
129
|
+
*/
|
|
130
|
+
export function resumePrompt(t, branch = branchOf(t.id)) {
|
|
131
|
+
return [
|
|
132
|
+
`You hold passalong work ${t.id} (${t.title || t.id}), and you stopped earlier to ask the person`,
|
|
133
|
+
`a question. They have answered. Call the passalong MCP tool take with id ${t.id} first: their`,
|
|
134
|
+
"answer comes back with it. Then carry on in this worktree, where your earlier changes are, and",
|
|
135
|
+
"finish as before:",
|
|
136
|
+
"",
|
|
137
|
+
runnerPrompt({ ...t, markdown: "" }, branch)
|
|
138
|
+
.split("\n")
|
|
139
|
+
.slice(4)
|
|
140
|
+
.join("\n"),
|
|
141
|
+
].join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Today, as the runner counts days: the machine's own date. */
|
|
145
|
+
export const dayOf = (now) => {
|
|
146
|
+
const d = new Date(now);
|
|
147
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** How many runs have started today, kept in `file` beside the rest of the CLI's state. */
|
|
151
|
+
export function runsToday(file, day) {
|
|
152
|
+
try {
|
|
153
|
+
const s = JSON.parse(readFileSync(file, "utf8"));
|
|
154
|
+
return s.day === day ? Number(s.runs) || 0 : 0;
|
|
155
|
+
} catch {
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function countRun(file, day) {
|
|
161
|
+
const runs = runsToday(file, day) + 1;
|
|
162
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
163
|
+
writeFileSync(file, `${JSON.stringify({ day, runs })}\n`);
|
|
164
|
+
return runs;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Where the runs' worktrees and logs live: beside the checkout, never inside it. */
|
|
168
|
+
export const runsDirOf = (root) => join(dirname(root), `${basename(root)}.passalong-runs`);
|
|
169
|
+
|
|
170
|
+
/** Why a run gave its work back, said to whoever wrote it. */
|
|
171
|
+
export function passBack({ minutes, timedOut, exit, note, said, dir, branch, failed }) {
|
|
172
|
+
const how = failed
|
|
173
|
+
? `couldn't start (${failed})`
|
|
174
|
+
: timedOut
|
|
175
|
+
? `ran out of its ${minutes} minutes`
|
|
176
|
+
: `stopped without finishing${exit === undefined || exit === null ? "" : ` (exit ${exit})`}`;
|
|
177
|
+
return [
|
|
178
|
+
`The runner's agent ${how}, so this is back with you and no longer marked safe to run alone.`,
|
|
179
|
+
note ? `Its last note: "${note}".` : "It left no note.",
|
|
180
|
+
said ? `The last thing it printed: "${said}".` : "",
|
|
181
|
+
failed
|
|
182
|
+
? ""
|
|
183
|
+
: `What it changed is kept on branch ${branch}, in ${dir}, on the machine that ran it.`,
|
|
184
|
+
]
|
|
185
|
+
.filter(Boolean)
|
|
186
|
+
.join(" ");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The last line an agent printed, from its log: often the reason it stopped. "" when none. */
|
|
190
|
+
export function lastLine(log) {
|
|
191
|
+
try {
|
|
192
|
+
const lines = readFileSync(log, "utf8").trim().split("\n");
|
|
193
|
+
return (lines.at(-1) || "").trim().slice(0, 300);
|
|
194
|
+
} catch {
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Run a git command, quietly; its output, trimmed. Throws when git fails. */
|
|
200
|
+
export function git(args) {
|
|
201
|
+
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Start an agent and wait for it, writing what it prints to `log`. Killed after `minutes`.
|
|
206
|
+
* Resolves `{ exit, timedOut }`, never rejects: an agent that would not start is a run that
|
|
207
|
+
* ended without the work done.
|
|
208
|
+
*/
|
|
209
|
+
export function runAgent({ command, args, stdin, prompt, cwd, log, minutes, env }) {
|
|
210
|
+
return new Promise((done) => {
|
|
211
|
+
const out = openSync(log, "a");
|
|
212
|
+
let timedOut = false;
|
|
213
|
+
let child;
|
|
214
|
+
try {
|
|
215
|
+
child = spawn(command, stdin ? args : [...args, prompt], {
|
|
216
|
+
cwd,
|
|
217
|
+
env,
|
|
218
|
+
stdio: [stdin ? "pipe" : "ignore", out, out],
|
|
219
|
+
});
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return done({ exit: err.message, timedOut });
|
|
222
|
+
}
|
|
223
|
+
const timer = setTimeout(
|
|
224
|
+
() => {
|
|
225
|
+
timedOut = true;
|
|
226
|
+
child.kill("SIGTERM");
|
|
227
|
+
},
|
|
228
|
+
minutes * 60 * 1000,
|
|
229
|
+
);
|
|
230
|
+
child.on("error", (err) => {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
done({ exit: err.message, timedOut });
|
|
233
|
+
});
|
|
234
|
+
child.on("close", (code) => {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
done({ exit: code, timedOut });
|
|
237
|
+
});
|
|
238
|
+
if (stdin) {
|
|
239
|
+
child.stdin.on("error", () => {});
|
|
240
|
+
child.stdin.end(prompt);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
package/src/store.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// The local store: ~/.passalong (override with PASSALONG_HOME).
|
|
1
|
+
// The local store: ~/.passalong (override with PASSALONG_HOME). Work are one markdown file each so
|
|
2
2
|
// the whole thing is greppable, git-friendly, and there is nothing to export that isn't
|
|
3
3
|
// already a file you own.
|
|
4
4
|
//
|
|
5
5
|
// ~/.passalong/config.json api url + token
|
|
6
|
-
// ~/.passalong/
|
|
6
|
+
// ~/.passalong/work/<id>.md
|
|
7
7
|
// ~/.passalong/drafts/*.md captured but not yet shared
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
@@ -11,21 +11,20 @@ import {
|
|
|
11
11
|
readdirSync,
|
|
12
12
|
readFileSync,
|
|
13
13
|
renameSync,
|
|
14
|
-
statSync,
|
|
15
14
|
unlinkSync,
|
|
16
15
|
writeFileSync,
|
|
17
16
|
} from "node:fs";
|
|
18
17
|
import { homedir } from "node:os";
|
|
19
18
|
import { join } from "node:path";
|
|
20
|
-
import { parse, serialize } from "./
|
|
19
|
+
import { parse, serialize } from "./work.js";
|
|
21
20
|
|
|
22
21
|
export const HOME = process.env.PASSALONG_HOME || join(homedir(), ".passalong");
|
|
23
|
-
export const
|
|
22
|
+
export const WORKS = join(HOME, "work");
|
|
24
23
|
export const DRAFTS = join(HOME, "drafts");
|
|
25
24
|
const CONFIG = join(HOME, "config.json");
|
|
26
25
|
|
|
27
26
|
function ensure() {
|
|
28
|
-
for (const d of [HOME,
|
|
27
|
+
for (const d of [HOME, WORKS, DRAFTS])
|
|
29
28
|
if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
30
29
|
}
|
|
31
30
|
|
|
@@ -146,46 +145,37 @@ export function removeAccount(name) {
|
|
|
146
145
|
return { removed: true, active: "" };
|
|
147
146
|
}
|
|
148
147
|
|
|
149
|
-
export function
|
|
150
|
-
return join(
|
|
148
|
+
export function workPath(id) {
|
|
149
|
+
return join(WORKS, `${id}.md`);
|
|
151
150
|
}
|
|
152
151
|
|
|
153
152
|
export function get(id) {
|
|
154
|
-
const p =
|
|
153
|
+
const p = workPath(id);
|
|
155
154
|
if (!existsSync(p)) return null;
|
|
156
155
|
return parse(readFileSync(p, "utf8"));
|
|
157
156
|
}
|
|
158
157
|
|
|
159
|
-
export function save(
|
|
158
|
+
export function save(work) {
|
|
160
159
|
ensure();
|
|
161
|
-
writeFileSync(
|
|
162
|
-
return
|
|
160
|
+
writeFileSync(workPath(work.meta.id), serialize(work));
|
|
161
|
+
return workPath(work.meta.id);
|
|
163
162
|
}
|
|
164
163
|
|
|
165
164
|
export function remove(id) {
|
|
166
|
-
const p =
|
|
165
|
+
const p = workPath(id);
|
|
167
166
|
if (existsSync(p)) unlinkSync(p);
|
|
168
167
|
}
|
|
169
168
|
|
|
170
|
-
/** All local
|
|
169
|
+
/** All local work, newest first. */
|
|
171
170
|
export function list() {
|
|
172
171
|
ensure();
|
|
173
|
-
return readdirSync(
|
|
172
|
+
return readdirSync(WORKS)
|
|
174
173
|
.filter((f) => f.endsWith(".md"))
|
|
175
|
-
.map((f) => parse(readFileSync(join(
|
|
174
|
+
.map((f) => parse(readFileSync(join(WORKS, f), "utf8")))
|
|
176
175
|
.filter((g) => g.meta.id)
|
|
177
176
|
.sort((a, b) => (b.meta.created || "").localeCompare(a.meta.created || ""));
|
|
178
177
|
}
|
|
179
178
|
|
|
180
|
-
/** Draft files, newest first. */
|
|
181
|
-
export function drafts() {
|
|
182
|
-
ensure();
|
|
183
|
-
return readdirSync(DRAFTS)
|
|
184
|
-
.filter((f) => f.endsWith(".md"))
|
|
185
|
-
.map((f) => join(DRAFTS, f))
|
|
186
|
-
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
179
|
export function draftPath(name) {
|
|
190
180
|
ensure();
|
|
191
181
|
return join(DRAFTS, `${name}.md`);
|
|
@@ -196,11 +186,11 @@ export function removeDraft(path) {
|
|
|
196
186
|
}
|
|
197
187
|
|
|
198
188
|
/** Case-insensitive match on title, tags, source context, and body. */
|
|
199
|
-
export function search(query,
|
|
189
|
+
export function search(query, work = list()) {
|
|
200
190
|
const q = query.trim().toLowerCase();
|
|
201
|
-
if (!q) return
|
|
191
|
+
if (!q) return work;
|
|
202
192
|
const terms = q.split(/\s+/);
|
|
203
|
-
return
|
|
193
|
+
return work.filter((g) => {
|
|
204
194
|
const hay = [
|
|
205
195
|
g.meta.title,
|
|
206
196
|
g.meta.source_context,
|
package/src/update.js
CHANGED
|
@@ -81,7 +81,7 @@ export function isNewer(latest, current) {
|
|
|
81
81
|
/** The one line somebody sees, or "" when there is nothing to say. */
|
|
82
82
|
export function noticeFor(latest, current) {
|
|
83
83
|
if (!isNewer(latest, current)) return "";
|
|
84
|
-
return `passalong ${latest} is out (you have ${current})
|
|
84
|
+
return `passalong ${latest} is out (you have ${current}): npm i -g passalong`;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|