passalong 0.3.2 → 0.6.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/bin/passalong +250 -9
- package/package.json +3 -2
- package/skill/SKILL.md +8 -7
- package/src/api.js +34 -2
- package/src/capture.js +41 -1
- package/src/guide.js +73 -5
- package/src/hooks.js +126 -0
- package/src/mcp.js +421 -118
- package/src/passalong.js +446 -26
package/bin/passalong
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Passalong CLI. Few flags on purpose: the defaults are the product.
|
|
3
3
|
//
|
|
4
|
-
// passalong share [file] [--to TARGET] [--follows ID] publish a guide → id + link
|
|
4
|
+
// passalong share [file] [--to TARGET] [--follows ID] publish a guide → id + link; --follows adds it as context to guide ID
|
|
5
|
+
// --follows ID publishes it as a follow-up: more context for guide ID, listed under it and
|
|
6
|
+
// handed to whoever pulls or starts that guide
|
|
5
7
|
// TARGET is a team, team/@handle for one person, or "team/#group" for the people who do a
|
|
6
8
|
// thing — quote that one, since a shell may read the # as a comment
|
|
9
|
+
// passalong task "<what needs doing>" write a task for an agent; it waits in Draft
|
|
10
|
+
// passalong ready <id...|--all> tasks you have read are ready — an agent may take them now
|
|
11
|
+
// passalong tasks every task and where it is: draft, ready, claimed, stalled, review
|
|
12
|
+
// passalong work [--once] [--agent CMD] run an agent on each ready task here, one after another
|
|
13
|
+
// passalong approve <id> the work in review is done
|
|
14
|
+
// passalong reject <id> <why> not done: back to Ready, and the next agent reads why
|
|
15
|
+
// passalong release <id> take a task back from the agent holding it, stalled or not
|
|
7
16
|
// passalong attach <image> upload evidence and print the markdown that points at it
|
|
8
17
|
// passalong pull <id|link> fetch a guide into ./.passalong/ and print it
|
|
9
18
|
// passalong start <id|link> pull it and take it — one command for "I am doing this"
|
|
@@ -28,16 +37,18 @@
|
|
|
28
37
|
// passalong team invite [email] make an invite link (mailed when an email is given)
|
|
29
38
|
// passalong team join <link|code> accept an invite
|
|
30
39
|
// passalong team use <slug> switch the current team
|
|
31
|
-
// passalong setup install the Claude Code capture skill
|
|
40
|
+
// passalong setup install the Claude Code capture skill, MCP server and hooks
|
|
41
|
+
// passalong now what this worktree holds and what is waiting (the hooks call --hook)
|
|
32
42
|
// passalong mcp run the MCP server over stdio
|
|
33
43
|
// passalong version print the installed version (also -v, --version)
|
|
34
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
44
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
45
|
+
import { createHash } from "node:crypto";
|
|
35
46
|
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
36
47
|
import { homedir } from "node:os";
|
|
37
48
|
import { dirname, join, resolve } from "node:path";
|
|
38
49
|
import { fileURLToPath } from "node:url";
|
|
39
50
|
import * as api from "../src/api.js";
|
|
40
|
-
import { scaffold } from "../src/capture.js";
|
|
51
|
+
import { scaffold, taskScaffold } from "../src/capture.js";
|
|
41
52
|
import { serialize } from "../src/guide.js";
|
|
42
53
|
import * as passalong from "../src/passalong.js";
|
|
43
54
|
import * as store from "../src/store.js";
|
|
@@ -101,24 +112,83 @@ function activityLine(n) {
|
|
|
101
112
|
return `${dot} ${(n.guide || "").padEnd(8)} ${relDate(n.at).padEnd(10)} ${n.text}`;
|
|
102
113
|
}
|
|
103
114
|
|
|
115
|
+
/**
|
|
116
|
+
* After a pulled guide, the follow-ups written for it: more context, each its own guide. Listed on
|
|
117
|
+
* stderr rather than printed in full, so stdout stays exactly the document; nothing at all when
|
|
118
|
+
* there are none or sync is off.
|
|
119
|
+
*/
|
|
120
|
+
async function listFollowUps(meta) {
|
|
121
|
+
const guides = await passalong.followUpGuides(meta);
|
|
122
|
+
if (!guides.length) return;
|
|
123
|
+
console.error(
|
|
124
|
+
`\n${guides.length} follow-up${guides.length === 1 ? "" : "s"} — more context for this guide:`,
|
|
125
|
+
);
|
|
126
|
+
for (const g of guides) console.error(` ${g.id} ${g.title || "untitled"}`);
|
|
127
|
+
console.error("Read one with: passalong pull <id>");
|
|
128
|
+
}
|
|
129
|
+
|
|
104
130
|
const MONTHS = "January February March April May June July August September October November December".split(" ");
|
|
105
131
|
/** "September 2026" from an ISO timestamp, without pulling in a date library for one heading. */
|
|
106
132
|
const monthOf = (iso) => `${MONTHS[Number(iso.slice(5, 7)) - 1]} ${iso.slice(0, 4)}`;
|
|
107
133
|
|
|
134
|
+
// ---- the status line cache --------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/** One small file per working directory, so two sessions in two repos each see their own. */
|
|
137
|
+
const statusPath = (cwd) =>
|
|
138
|
+
join(store.HOME, "statusline", `${createHash("sha1").update(cwd).digest("hex").slice(0, 16)}.json`);
|
|
139
|
+
|
|
140
|
+
function writeStatus(cwd, text) {
|
|
141
|
+
const p = statusPath(cwd);
|
|
142
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
143
|
+
writeFileSync(p, JSON.stringify({ at: Date.now(), text }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** How old a cached line may be before a refresh is started behind it. */
|
|
147
|
+
const STATUS_TTL_MS = 30_000;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Print the cached line for this directory at once, and when it is stale start a detached refresh
|
|
151
|
+
* that writes the next one. The first run in a directory prints a placeholder; the refresh lands
|
|
152
|
+
* within a second or two, and the next update shows it. Offline or signed out, it says nothing.
|
|
153
|
+
*/
|
|
154
|
+
async function statusLine() {
|
|
155
|
+
let input = {};
|
|
156
|
+
if (!process.stdin.isTTY) {
|
|
157
|
+
let raw = "";
|
|
158
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
159
|
+
try {
|
|
160
|
+
input = JSON.parse(raw || "{}");
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
if (!api.loggedIn()) return;
|
|
164
|
+
const cwd = input.workspace?.current_dir || input.cwd || process.cwd();
|
|
165
|
+
let cached = null;
|
|
166
|
+
try {
|
|
167
|
+
cached = JSON.parse(readFileSync(statusPath(cwd), "utf8"));
|
|
168
|
+
} catch {}
|
|
169
|
+
process.stdout.write(cached?.text ?? "passalong");
|
|
170
|
+
if (!cached || Date.now() - cached.at > STATUS_TTL_MS) {
|
|
171
|
+
// Marked fresh before the refresh starts, so a burst of updates starts one refresh, not ten.
|
|
172
|
+
writeStatus(cwd, cached?.text ?? "passalong");
|
|
173
|
+
spawn(process.execPath, [fileURLToPath(import.meta.url), "now", "--statusline-refresh", cwd], {
|
|
174
|
+
detached: true,
|
|
175
|
+
stdio: "ignore",
|
|
176
|
+
env: process.env,
|
|
177
|
+
}).unref();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
108
181
|
const commands = {
|
|
109
182
|
async share(f) {
|
|
110
183
|
let path = f._[0];
|
|
111
|
-
let fromDrafts = false;
|
|
112
184
|
if (!path) {
|
|
113
185
|
const [latest] = store.drafts();
|
|
114
186
|
if (latest) {
|
|
115
187
|
path = latest;
|
|
116
|
-
fromDrafts = true;
|
|
117
188
|
console.error(`using draft ${path}`);
|
|
118
189
|
} else {
|
|
119
190
|
path = store.draftPath(new Date().toISOString().replace(/[:.]/g, "-"));
|
|
120
191
|
writeFileSync(path, scaffold());
|
|
121
|
-
fromDrafts = true;
|
|
122
192
|
if (!tty)
|
|
123
193
|
die(`no draft to share; scaffolded ${path} — fill it in and run \`passalong share ${path}\``);
|
|
124
194
|
}
|
|
@@ -130,7 +200,10 @@ const commands = {
|
|
|
130
200
|
const { guide, url, synced, notified, path: saved } = await passalong
|
|
131
201
|
.share(readFileSync(path, "utf8"), { to, follows })
|
|
132
202
|
.catch((e) => die(e.message));
|
|
133
|
-
|
|
203
|
+
// Any draft that went out is spent, however it was named — a task that failed validation is
|
|
204
|
+
// re-shared by its path, and leaving it would have the next bare `share` publish it again.
|
|
205
|
+
// removeDraft() touches nothing outside the drafts directory.
|
|
206
|
+
store.removeDraft(resolve(path));
|
|
134
207
|
console.log(guide.meta.id);
|
|
135
208
|
console.error(`shared "${guide.meta.title}" → ${saved}`);
|
|
136
209
|
if (guide.meta.parent) console.error(`follows ${guide.meta.parent}`);
|
|
@@ -142,11 +215,107 @@ const commands = {
|
|
|
142
215
|
else console.error("not synced (run `passalong login` to sync across machines)");
|
|
143
216
|
},
|
|
144
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Write a task. It lands in Draft and stays there until `passalong ready`: the gap between the
|
|
220
|
+
* two is the one moment a person reads what an agent is about to be asked to do.
|
|
221
|
+
*/
|
|
222
|
+
async task(f) {
|
|
223
|
+
const sentence = f._.join(" ").trim() || die('usage: passalong task "<what needs doing>"');
|
|
224
|
+
const path = store.draftPath(`task-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
225
|
+
writeFileSync(path, taskScaffold(sentence));
|
|
226
|
+
if (tty && !f["no-edit"]) edit(path);
|
|
227
|
+
const { guide, synced } = await passalong
|
|
228
|
+
.share(readFileSync(path, "utf8"))
|
|
229
|
+
.catch((e) => die(`${e.message}\ndraft kept at ${path} — fix it and run \`passalong share ${path}\``));
|
|
230
|
+
store.removeDraft(path);
|
|
231
|
+
const id = guide.meta.id;
|
|
232
|
+
console.log(id);
|
|
233
|
+
const target = guide.meta.target_context;
|
|
234
|
+
console.error(`task "${guide.meta.title}" is in Draft${target ? ` for ${target}` : " (no repo)"}`);
|
|
235
|
+
console.error(`when an agent may take it: passalong ready ${id}`);
|
|
236
|
+
if (!synced) console.error("not synced (run `passalong login` — agents elsewhere cannot see it)");
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
async tasks() {
|
|
240
|
+
const rows = await passalong.tasks().catch((e) => die(e.message));
|
|
241
|
+
if (!rows.length) return console.error('no tasks yet — write one with: passalong task "<what needs doing>"');
|
|
242
|
+
for (const t of rows) {
|
|
243
|
+
const c = t.claim;
|
|
244
|
+
const who = c ? ` ${c.host || "?"}:${c.worktree.split("/").pop() || "?"}` : "";
|
|
245
|
+
const said = c?.note ? ` "${c.note}"` : "";
|
|
246
|
+
console.log(`${t.id} ${t.state.padEnd(8)} ${t.title}${t.target ? ` [${t.target}]` : ""}${who}${said}`);
|
|
247
|
+
// What came back, under the task it answers: the thing a reviewer reads before deciding.
|
|
248
|
+
if (t.state === "review" && c?.report)
|
|
249
|
+
console.log(` → ${c.report} ${c.report_title || "(untitled)"}${c.pr ? ` ${c.pr}` : ""}`);
|
|
250
|
+
}
|
|
251
|
+
if (rows.some((t) => t.state === "review"))
|
|
252
|
+
console.error("\nread a write-up with: passalong open <id> — then approve or reject the task");
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Work the queue from this worktree until it is empty. The agent is `claude -p` unless --agent or
|
|
257
|
+
* PASSALONG_AGENT says otherwise, e.g. "claude -p --permission-mode acceptEdits" so it can edit
|
|
258
|
+
* files without asking — which is yours to decide, not this command's.
|
|
259
|
+
*/
|
|
260
|
+
async work(f) {
|
|
261
|
+
const run = await passalong
|
|
262
|
+
.work({
|
|
263
|
+
agent: typeof f.agent === "string" ? f.agent : undefined,
|
|
264
|
+
once: Boolean(f.once),
|
|
265
|
+
any: Boolean(f.any),
|
|
266
|
+
onTask: (t) => console.error(`\n→ ${t.id} ${t.title}${t.resumed ? " (resuming)" : ""}`),
|
|
267
|
+
})
|
|
268
|
+
.catch((e) => die(e.message));
|
|
269
|
+
for (const id of run.finished) console.log(id);
|
|
270
|
+
const n = run.finished.length;
|
|
271
|
+
const done = `${n} task${n === 1 ? "" : "s"} finished and waiting for review`;
|
|
272
|
+
if (run.stopped === "unfinished")
|
|
273
|
+
die(
|
|
274
|
+
`${done}. Stopped: the agent exited with ${run.task} still ${run.state || "open"} — ` +
|
|
275
|
+
`run again to resume it, or \`passalong release ${run.task}\``,
|
|
276
|
+
);
|
|
277
|
+
console.error(`${done}${run.stopped === "empty" ? " — nothing left for this repo" : ""}`);
|
|
278
|
+
if (n) console.error("review them with: passalong tasks");
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
async approve(f) {
|
|
282
|
+
const id = f._[0] || die("usage: passalong approve <id>");
|
|
283
|
+
await passalong.approveTask(id).catch((e) => die(e.message));
|
|
284
|
+
console.error(`${id} is done`);
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
async reject(f) {
|
|
288
|
+
const id = f._[0] || die("usage: passalong reject <id> <why>");
|
|
289
|
+
await passalong.rejectTask(id, f._.slice(1).join(" ")).catch((e) => die(e.message));
|
|
290
|
+
console.error(`${id} is back in Ready — the next agent reads why`);
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
async release(f) {
|
|
294
|
+
const id = f._[0] || die("usage: passalong release <id>");
|
|
295
|
+
await passalong.releaseTask(id).catch((e) => die(e.message));
|
|
296
|
+
console.error(`${id} is back in Ready — it says where the last agent left it`);
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
async ready(f) {
|
|
300
|
+
if (f.all) {
|
|
301
|
+
const ids = await passalong.readyDrafts().catch((e) => die(e.message));
|
|
302
|
+
if (!ids.length) return console.error("no drafts of yours to make ready");
|
|
303
|
+
for (const id of ids) console.log(id);
|
|
304
|
+
return console.error(`${ids.length} task${ids.length === 1 ? "" : "s"} Ready — agents may take them`);
|
|
305
|
+
}
|
|
306
|
+
if (!f._.length) die("usage: passalong ready <id...> | --all");
|
|
307
|
+
for (const id of f._) {
|
|
308
|
+
const g = await passalong.ready(id).catch((e) => die(e.message));
|
|
309
|
+
console.error(`${g.meta.id} is Ready — an agent may take it`);
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
|
|
145
313
|
async pull(f) {
|
|
146
314
|
const ref = f._[0] || die("usage: passalong pull <id|link>");
|
|
147
|
-
const { markdown, path, from } = await passalong.pull(ref).catch((e) => die(e.message));
|
|
315
|
+
const { guide, markdown, path, from } = await passalong.pull(ref).catch((e) => die(e.message));
|
|
148
316
|
process.stdout.write(markdown);
|
|
149
317
|
console.error(`\npulled from ${from} → ${path}`);
|
|
318
|
+
await listFollowUps(guide.meta);
|
|
150
319
|
},
|
|
151
320
|
|
|
152
321
|
/**
|
|
@@ -165,6 +334,7 @@ const commands = {
|
|
|
165
334
|
// Saying nothing here would be the silence the whole hop exists to end: you would have the
|
|
166
335
|
// guide, believe you had answered for it, and the sender would still be waiting.
|
|
167
336
|
else console.error("not taken — sync is off, so nobody was told (run `passalong login`)");
|
|
337
|
+
await listFollowUps(r.guide.meta);
|
|
168
338
|
},
|
|
169
339
|
|
|
170
340
|
/**
|
|
@@ -471,11 +641,82 @@ const commands = {
|
|
|
471
641
|
`could not register MCP server automatically (${msg.trim()}).\n run: claude mcp add passalong -- passalong mcp`,
|
|
472
642
|
);
|
|
473
643
|
}
|
|
644
|
+
// The two hooks that keep an agent on what it holds: told at session start, and stopped once
|
|
645
|
+
// before ending with work nobody has handed in. Merged into the user's settings, not written
|
|
646
|
+
// over them, and a no-op when they are already there.
|
|
647
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
648
|
+
try {
|
|
649
|
+
const { withHooks, withStatusLine } = await import("../src/hooks.js");
|
|
650
|
+
const before = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf8")) : {};
|
|
651
|
+
const after = withStatusLine(withHooks(before));
|
|
652
|
+
if (before.statusLine && before.statusLine.command !== "passalong now --statusline")
|
|
653
|
+
console.error(
|
|
654
|
+
"you already have a status line, so it was left alone. To show Passalong in it, call `passalong now --statusline` from your script",
|
|
655
|
+
);
|
|
656
|
+
if (JSON.stringify(after) === JSON.stringify(before)) console.error("Claude Code hooks already installed");
|
|
657
|
+
else {
|
|
658
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
659
|
+
writeFileSync(settingsPath, `${JSON.stringify(after, null, 2)}\n`);
|
|
660
|
+
console.error(`installed Claude Code hooks (session start, stop) and status line → ${settingsPath}`);
|
|
661
|
+
}
|
|
662
|
+
} catch (err) {
|
|
663
|
+
console.error(`could not install Claude Code hooks (${err.message}); ${settingsPath} left as it was`);
|
|
664
|
+
}
|
|
474
665
|
if (!api.loggedIn())
|
|
475
666
|
console.error("sync is off; run `passalong login` when you want guides to follow you across machines");
|
|
476
667
|
console.error('done. In Claude Code, say "pass this along" at the end of a task, or run /passalong-capture.');
|
|
477
668
|
},
|
|
478
669
|
|
|
670
|
+
/**
|
|
671
|
+
* What this worktree holds and what is waiting. `--hook session|stop` is the form the Claude
|
|
672
|
+
* Code hooks from `setup` call (src/hooks.js): it prints only what the hook protocol reads, and
|
|
673
|
+
* nothing at all when anything goes wrong — a hook must never get in the way of a session.
|
|
674
|
+
*/
|
|
675
|
+
async now(f) {
|
|
676
|
+
const { nowText, statusText, stopVerdict } = await import("../src/hooks.js");
|
|
677
|
+
// The status line: printed from a cache, refreshed behind it. Claude Code cancels a status
|
|
678
|
+
// line still running when the next update comes, so this never waits on the network.
|
|
679
|
+
if (f.statusline) return statusLine(f);
|
|
680
|
+
if (f["statusline-refresh"]) {
|
|
681
|
+
const cwd = typeof f["statusline-refresh"] === "string" ? f["statusline-refresh"] : process.cwd();
|
|
682
|
+
try {
|
|
683
|
+
const r = await passalong.now({ cwd });
|
|
684
|
+
writeStatus(cwd, statusText({ held: r.held, needs: r.needs, ready: r.waiting.ready }));
|
|
685
|
+
} catch {}
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (!f.hook) {
|
|
689
|
+
const r = await passalong.now();
|
|
690
|
+
console.log(nowText(r) || "Nothing held here, and nothing waiting.");
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
let input = {};
|
|
695
|
+
if (f.hook === "stop" && !process.stdin.isTTY) {
|
|
696
|
+
let raw = "";
|
|
697
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
698
|
+
input = JSON.parse(raw || "{}");
|
|
699
|
+
}
|
|
700
|
+
if (!api.loggedIn()) return;
|
|
701
|
+
const r = await Promise.race([
|
|
702
|
+
passalong.now(),
|
|
703
|
+
new Promise((_, no) => setTimeout(() => no(new Error("timeout")), 5000)),
|
|
704
|
+
]);
|
|
705
|
+
if (f.hook === "session") {
|
|
706
|
+
const text = nowText(r);
|
|
707
|
+
if (text)
|
|
708
|
+
console.log(
|
|
709
|
+
JSON.stringify({
|
|
710
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: text },
|
|
711
|
+
}),
|
|
712
|
+
);
|
|
713
|
+
} else if (f.hook === "stop") {
|
|
714
|
+
const v = stopVerdict({ held: r.held, input });
|
|
715
|
+
if (v) console.log(JSON.stringify(v));
|
|
716
|
+
}
|
|
717
|
+
} catch {}
|
|
718
|
+
},
|
|
719
|
+
|
|
479
720
|
async mcp() {
|
|
480
721
|
const { serve } = await import("../src/mcp.js");
|
|
481
722
|
await serve();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "passalong",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Hand a finished implementation from one context to another, in a form an AI agent can act on.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
|
-
"test": "node --test"
|
|
38
|
+
"test": "node --test",
|
|
39
|
+
"test:e2e": "PASSALONG_E2E_API=${PASSALONG_E2E_API:-http://localhost:3001} node --test test/task-flow.e2e.test.js"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
42
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/skill/SKILL.md
CHANGED
|
@@ -34,12 +34,13 @@ session's history. Write for execution, not for permanence.
|
|
|
34
34
|
team's members and their handles. It prints the guide id on stdout and the share link (and
|
|
35
35
|
who it was handed to) on stderr.
|
|
36
36
|
|
|
37
|
-
**If
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
**If what you are capturing is more context for an existing guide** — a missing detail, a step
|
|
38
|
+
that needed explaining, what changed since, what you found doing it — add `--follows <that id>`.
|
|
39
|
+
That publishes it as a follow-up: its own guide, listed under the original, and handed to
|
|
40
|
+
whoever opens the original, person or agent. A session that started from a passalong guide
|
|
41
|
+
(`passalong start` or `pull` on an id, or an agent opened one) usually has context worth adding
|
|
42
|
+
this way. Whether the guide worked is still its own answer: `passalong works <id>` or
|
|
43
|
+
`passalong broken <id> <why>`.
|
|
43
44
|
6. **Report** the id (and link if synced) and one line on how to use it on the other side:
|
|
44
45
|
`passalong start <id>` in the target repo, or "start passalong <id>" to an agent with the
|
|
45
46
|
Passalong MCP server. `start` rather than `pull` on purpose: it takes the handoff as well as
|
|
@@ -56,7 +57,7 @@ each. Absent means `transfer`, which is what every guide written before bug repo
|
|
|
56
57
|
|
|
57
58
|
| kind | what it is | what the receiver does |
|
|
58
59
|
| --- | --- | --- |
|
|
59
|
-
| `transfer` (
|
|
60
|
+
| `transfer` (or no `kind:` line) | finished work to repeat somewhere else | follows the **Steps** |
|
|
60
61
|
| `bug` | a defect to fix where it is | fixes it — **Reproduce** shows the problem, it is not a procedure to apply |
|
|
61
62
|
|
|
62
63
|
To file bugs you found but are not fixing, call `file_bugs` with all of them in one call — it
|
package/src/api.js
CHANGED
|
@@ -46,10 +46,14 @@ async function call(path, { method = "GET", body, auth = true, raw = false } = {
|
|
|
46
46
|
if (!res.ok) {
|
|
47
47
|
const text = await res.text();
|
|
48
48
|
let message = text;
|
|
49
|
+
let parsed = {};
|
|
49
50
|
try {
|
|
50
|
-
|
|
51
|
+
parsed = JSON.parse(text);
|
|
52
|
+
message = parsed.message || text;
|
|
51
53
|
} catch {}
|
|
52
|
-
|
|
54
|
+
// The whole answer rides along: a refusal can carry `next`, `say` or `holder`, which is what
|
|
55
|
+
// an agent needs to decide what to do instead.
|
|
56
|
+
throw Object.assign(new ApiError(res.status, message || res.statusText), { body: parsed });
|
|
53
57
|
}
|
|
54
58
|
return raw ? res.text() : res.json();
|
|
55
59
|
}
|
|
@@ -91,6 +95,12 @@ export const createReport = (body) => call("/v1/reports", { method: "POST", body
|
|
|
91
95
|
/** One report and the issues filed under it, grouped by product area. */
|
|
92
96
|
export const report = (id) => call(`/v1/reports/${encodeURIComponent(id)}`);
|
|
93
97
|
export const get = (id) => call(`/v1/guides/${id}`, { raw: true });
|
|
98
|
+
/**
|
|
99
|
+
* A guide's follow-ups, one level down. `markdown` adds each one's content, oldest first and
|
|
100
|
+
* capped server-side; neither form counts as opening them.
|
|
101
|
+
*/
|
|
102
|
+
export const children = (id, { markdown = false } = {}) =>
|
|
103
|
+
call(`/v1/guides/${encodeURIComponent(id)}/children${q({ markdown: markdown ? "1" : "" })}`);
|
|
94
104
|
export const setStatus = (id, status) =>
|
|
95
105
|
call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
|
|
96
106
|
export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
|
|
@@ -98,6 +108,28 @@ export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
|
|
|
98
108
|
export const verdict = (id, ok, note = "") =>
|
|
99
109
|
call(`/v1/guides/${id}/verdict`, { method: "PUT", body: { ok, note } });
|
|
100
110
|
|
|
111
|
+
export const tasks = () => call("/v1/tasks");
|
|
112
|
+
export const nextTask = (body) => call("/v1/tasks/next", { method: "POST", body });
|
|
113
|
+
export const taskProgress = (id, body) =>
|
|
114
|
+
call(`/v1/tasks/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
|
|
115
|
+
export const finishTask = (id, body) =>
|
|
116
|
+
call(`/v1/tasks/${encodeURIComponent(id)}/finish`, { method: "POST", body });
|
|
117
|
+
export const approveTask = (id) =>
|
|
118
|
+
call(`/v1/tasks/${encodeURIComponent(id)}/approve`, { method: "POST", body: {} });
|
|
119
|
+
export const rejectTask = (id, why) =>
|
|
120
|
+
call(`/v1/tasks/${encodeURIComponent(id)}/reject`, { method: "POST", body: { why } });
|
|
121
|
+
export const releaseTask = (id) =>
|
|
122
|
+
call(`/v1/tasks/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
|
|
123
|
+
/** The four verbs every guide answers to. See docs/V2.md §11. */
|
|
124
|
+
export const working = () => call("/v1/working");
|
|
125
|
+
export const handedIn = () => call("/v1/handed_in");
|
|
126
|
+
export const take = (body) => call("/v1/take", { method: "POST", body });
|
|
127
|
+
export const progress = (id, body) =>
|
|
128
|
+
call(`/v1/guides/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
|
|
129
|
+
export const handIn = (id, body) =>
|
|
130
|
+
call(`/v1/guides/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
|
|
131
|
+
export const pass = (id, body) =>
|
|
132
|
+
call(`/v1/guides/${encodeURIComponent(id)}/pass`, { method: "POST", body });
|
|
101
133
|
export const ack = (id, taken, note = "") =>
|
|
102
134
|
call(`/v1/guides/${id}/ack`, { method: "PUT", body: { taken, note } });
|
|
103
135
|
|
package/src/capture.js
CHANGED
|
@@ -17,6 +17,23 @@ function git(cwd, ...args) {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* One repo, written one way, so a task and the agent asking for work agree on what it is called.
|
|
22
|
+
* A remote URL becomes `owner/repo` whichever form it came in; anything else is kept, lowercase.
|
|
23
|
+
* Mirrors `repoKey()` in apps/api/src/claims.ts, which normalises what it stores the same way.
|
|
24
|
+
*/
|
|
25
|
+
export function repoKey(raw) {
|
|
26
|
+
let s = String(raw ?? "")
|
|
27
|
+
.trim()
|
|
28
|
+
.toLowerCase();
|
|
29
|
+
s = s.replace(/\/+$/, "").replace(/\.git$/, "");
|
|
30
|
+
const url = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?[^:/]+[:/](.+\/[^/]+)$/.exec(s);
|
|
31
|
+
if (url && (s.includes("://") || s.includes("@"))) {
|
|
32
|
+
return url[1].split("/").filter(Boolean).slice(-2).join("/");
|
|
33
|
+
}
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
/** What we can infer about the current context without reading any session. */
|
|
21
38
|
export function context(cwd = process.cwd()) {
|
|
22
39
|
const root = git(cwd, "rev-parse", "--show-toplevel");
|
|
@@ -30,13 +47,36 @@ export function context(cwd = process.cwd()) {
|
|
|
30
47
|
const files = root
|
|
31
48
|
? git(cwd, "diff", "--name-only", "HEAD~5..HEAD").split("\n").filter(Boolean)
|
|
32
49
|
: [];
|
|
33
|
-
|
|
50
|
+
// The repo as the task queue names it: its remote when it has one, because two worktrees of one
|
|
51
|
+
// repo have two folder names and one remote. The folder name when there is no remote.
|
|
52
|
+
const repo = root ? repoKey(git(cwd, "remote", "get-url", "origin") || name) : "";
|
|
53
|
+
return { root, name, repo, branch, author, commits, changed, files };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A task draft from one sentence: the sentence as the title and the Goal, the rest left to fill.
|
|
58
|
+
*
|
|
59
|
+
* `target_context` defaults to the repo this runs in, because that is the likeliest repo a task
|
|
60
|
+
* written from inside one is for — and it is in the editor, in the frontmatter, for the author
|
|
61
|
+
* to change before it goes anywhere. Outside a repo it stays empty, which is a task for no repo.
|
|
62
|
+
*/
|
|
63
|
+
export function taskScaffold(sentence, cwd = process.cwd()) {
|
|
64
|
+
const c = context(cwd);
|
|
65
|
+
const md = template({
|
|
66
|
+
kind: "task",
|
|
67
|
+
title: sentence,
|
|
68
|
+
author: c.author,
|
|
69
|
+
source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
|
|
70
|
+
target_context: c.repo,
|
|
71
|
+
});
|
|
72
|
+
return md.replace(/^## Goal\n/m, `## Goal\n${sentence}\n`);
|
|
34
73
|
}
|
|
35
74
|
|
|
36
75
|
/** A draft pre-filled with repo facts. Recent activity is appended as a comment for reference. */
|
|
37
76
|
export function scaffold(cwd = process.cwd()) {
|
|
38
77
|
const c = context(cwd);
|
|
39
78
|
let md = template({
|
|
79
|
+
kind: "transfer",
|
|
40
80
|
title: "",
|
|
41
81
|
author: c.author,
|
|
42
82
|
source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
|
package/src/guide.js
CHANGED
|
@@ -35,11 +35,12 @@ export const SETTABLE = ["draft", "published", "consumed"];
|
|
|
35
35
|
*
|
|
36
36
|
* transfer work that is finished here and should be repeated there — follow the Steps
|
|
37
37
|
* bug something broken there — do NOT follow anything; fix it
|
|
38
|
+
* task work nobody has done yet — do what Goal asks, done when Acceptance holds
|
|
38
39
|
*
|
|
39
40
|
* A guide with no `kind` is a transfer guide: every guide written before this existed is one,
|
|
40
41
|
* and defaulting the other way would turn them all into bug reports.
|
|
41
42
|
*/
|
|
42
|
-
export const KINDS = ["transfer", "bug"];
|
|
43
|
+
export const KINDS = ["transfer", "bug", "task"];
|
|
43
44
|
|
|
44
45
|
// Body sections in the order a guide should present them. The heading text is what the
|
|
45
46
|
// receiving agent keys on, so keep these stable.
|
|
@@ -98,13 +99,29 @@ export const BUG_LEAD =
|
|
|
98
99
|
"> **Bug report.** The steps under Reproduce show the problem \u2014 they are not a fix to " +
|
|
99
100
|
"apply. Fix what Problem describes, then check Verification.";
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* A task's sections: a brief for work nobody has done yet, written before it starts.
|
|
104
|
+
*
|
|
105
|
+
* `Acceptance` is the section that matters. It is what a person checks the finished work against
|
|
106
|
+
* before approving it, so a task without one has no way to be done — which is why it is required.
|
|
107
|
+
* There is no `Steps`: the agent that takes a task works out how, and what it did comes back as a
|
|
108
|
+
* transfer guide, which is where Steps belong.
|
|
109
|
+
*/
|
|
110
|
+
export const TASK_SECTIONS = ["Goal", "Context", "Constraints", "Acceptance", "Out of scope"];
|
|
111
|
+
|
|
101
112
|
/** The sections a guide of this kind presents, in order. */
|
|
102
113
|
export function sectionsFor(kind) {
|
|
103
|
-
|
|
114
|
+
if (kind === "bug") return BUG_SECTIONS;
|
|
115
|
+
if (kind === "task") return TASK_SECTIONS;
|
|
116
|
+
return SECTIONS;
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
/** The sections it cannot be published without. */
|
|
107
|
-
const REQUIRED = {
|
|
120
|
+
const REQUIRED = {
|
|
121
|
+
transfer: ["Problem", "Steps"],
|
|
122
|
+
bug: ["Problem", "Reproduce"],
|
|
123
|
+
task: ["Goal", "Acceptance"],
|
|
124
|
+
};
|
|
108
125
|
|
|
109
126
|
// Fields that hold a list of strings. Everything else is a plain string.
|
|
110
127
|
/**
|
|
@@ -132,7 +149,7 @@ export function tagList(raw) {
|
|
|
132
149
|
return [...new Set(list.map(tag).filter(Boolean))];
|
|
133
150
|
}
|
|
134
151
|
|
|
135
|
-
const LIST_FIELDS = new Set(["stack_assumptions", "tags"]);
|
|
152
|
+
const LIST_FIELDS = new Set(["stack_assumptions", "tags", "blocked_by"]);
|
|
136
153
|
|
|
137
154
|
// IDs are short enough to type and say out loud. 8 chars from a 31-letter alphabet with the
|
|
138
155
|
// look-alikes removed (0/o, 1/l/i) is ~40 bits: plenty for addressing, not a secret.
|
|
@@ -225,7 +242,9 @@ export function parseFrontmatter(text) {
|
|
|
225
242
|
listKey = null;
|
|
226
243
|
}
|
|
227
244
|
}
|
|
228
|
-
|
|
245
|
+
// Only the lists every guide has. `blocked_by` is a task's, and defaulting it would write an
|
|
246
|
+
// empty one into every guide anybody re-shares.
|
|
247
|
+
for (const k of ["stack_assumptions", "tags"]) if (meta[k] === undefined) meta[k] = [];
|
|
229
248
|
// Both ends of this module: what it reads and what it writes are in one style, so a guide
|
|
230
249
|
// written before there was a rule comes back normalised and goes out normalised.
|
|
231
250
|
meta.tags = tagList(meta.tags);
|
|
@@ -245,6 +264,11 @@ const META_ORDER = [
|
|
|
245
264
|
"created",
|
|
246
265
|
"author",
|
|
247
266
|
"source_context",
|
|
267
|
+
// Tasks only: the repo the work is for. Optional — a task without one is not tied to a repo.
|
|
268
|
+
"target_context",
|
|
269
|
+
// Tasks only: the tasks this one waits for, by id. It is not handed to an agent until a person
|
|
270
|
+
// has approved each of them. See migrations/0022_blocks.sql.
|
|
271
|
+
"blocked_by",
|
|
248
272
|
// Where it came from, in the two senses a guide has one: `source_context` is the repo and branch
|
|
249
273
|
// it was written in, `parent` is the guide it was written *out of*.
|
|
250
274
|
"parent",
|
|
@@ -451,7 +475,10 @@ export function bugGuide({
|
|
|
451
475
|
|
|
452
476
|
/** A draft with the section skeleton. Placeholders are HTML comments so they vanish when rendered. */
|
|
453
477
|
export function template(meta = {}) {
|
|
478
|
+
// A task unless asked otherwise: most guides assign work. The transfer skeleton writes its kind
|
|
479
|
+
// out, since an absent kind: still means transfer to every client already installed.
|
|
454
480
|
if (meta.kind === "bug") return bugTemplate(meta);
|
|
481
|
+
if (meta.kind !== "transfer") return taskTemplate(meta);
|
|
455
482
|
const body = [
|
|
456
483
|
"## Problem",
|
|
457
484
|
"<!-- passalong: What was broken or needed, in two or three sentences. -->",
|
|
@@ -480,6 +507,7 @@ export function template(meta = {}) {
|
|
|
480
507
|
stack_assumptions: [],
|
|
481
508
|
tags: [],
|
|
482
509
|
...meta,
|
|
510
|
+
kind: "transfer",
|
|
483
511
|
},
|
|
484
512
|
body,
|
|
485
513
|
});
|
|
@@ -522,6 +550,46 @@ function bugTemplate(meta = {}) {
|
|
|
522
550
|
});
|
|
523
551
|
}
|
|
524
552
|
|
|
553
|
+
/**
|
|
554
|
+
* One task, as a guide: the brief an agent takes before any work exists.
|
|
555
|
+
*
|
|
556
|
+
* `target_context` is the repo the work is for, and it is left empty rather than defaulted to the
|
|
557
|
+
* repo the task was written in: a task written in one repo is often for another, and one aimed at
|
|
558
|
+
* the wrong repo is picked up by the wrong agent.
|
|
559
|
+
*/
|
|
560
|
+
function taskTemplate(meta = {}) {
|
|
561
|
+
const body = [
|
|
562
|
+
"## Goal",
|
|
563
|
+
"<!-- passalong: What should be true when this is done, in two or three sentences. -->",
|
|
564
|
+
"",
|
|
565
|
+
"## Context",
|
|
566
|
+
"<!-- passalong: What the agent needs to know first: where the code lives, what exists already, links. -->",
|
|
567
|
+
"",
|
|
568
|
+
"## Constraints",
|
|
569
|
+
"<!-- passalong: What must not change, what to use or avoid, limits on scope or approach. -->",
|
|
570
|
+
"",
|
|
571
|
+
"## Acceptance",
|
|
572
|
+
"<!-- passalong: How a person checks it is done: commands, expected output, behaviour to see. -->",
|
|
573
|
+
"",
|
|
574
|
+
"## Out of scope",
|
|
575
|
+
"<!-- passalong: What looks related but is not part of this task. -->",
|
|
576
|
+
].join("\n");
|
|
577
|
+
return serialize({
|
|
578
|
+
meta: {
|
|
579
|
+
title: "",
|
|
580
|
+
kind: "task",
|
|
581
|
+
author: "",
|
|
582
|
+
source_context: "",
|
|
583
|
+
target_context: "",
|
|
584
|
+
status: "draft",
|
|
585
|
+
stack_assumptions: [],
|
|
586
|
+
tags: [],
|
|
587
|
+
...meta,
|
|
588
|
+
},
|
|
589
|
+
body,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
525
593
|
/** Strip the template comments so an untouched section reads as empty rather than as a prompt. */
|
|
526
594
|
export function stripPlaceholders(body) {
|
|
527
595
|
return body.replace(/^<!-- passalong:.*?-->\n?/gm, "").trim();
|