passalong 0.3.1 → 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 +1 -0
- package/bin/passalong +194 -9
- package/package.json +3 -2
- package/skill/SKILL.md +9 -1
- package/src/api.js +33 -2
- package/src/capture.js +41 -1
- package/src/guide.js +73 -5
- package/src/hooks.js +82 -0
- package/src/mcp.js +423 -109
- package/src/passalong.js +447 -13
package/src/passalong.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// The operations Passalong exposes. Both surfaces (bin/passalong and the MCP server) call these,
|
|
2
2
|
// so anything an agent can do through MCP a human can do from the terminal and vice versa.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
3
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { hostname } from "node:os";
|
|
4
7
|
import { basename, join } from "node:path";
|
|
5
8
|
import * as api from "./api.js";
|
|
6
9
|
import { context } from "./capture.js";
|
|
@@ -59,7 +62,10 @@ export async function share(markdown, { cwd = process.cwd(), to, follows } = {})
|
|
|
59
62
|
const errors = validate(guide);
|
|
60
63
|
if (errors.length)
|
|
61
64
|
throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
|
|
62
|
-
|
|
65
|
+
// A task stays a draft when it is shared. Draft is the column a task waits in until a person
|
|
66
|
+
// says it is ready for an agent — `ready()` — and an agent that drafts one over MCP comes
|
|
67
|
+
// through here too, so publishing on share would let a task queue itself with nobody reading it.
|
|
68
|
+
if (guide.meta.status === "draft" && guide.meta.kind !== "task")
|
|
63
69
|
guide = { ...guide, meta: { ...guide.meta, status: "published" } };
|
|
64
70
|
|
|
65
71
|
let url = guide.meta.url || null;
|
|
@@ -190,19 +196,231 @@ export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
|
|
|
190
196
|
}
|
|
191
197
|
let path = null;
|
|
192
198
|
if (write) {
|
|
193
|
-
|
|
194
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
195
|
-
// A pulled guide carries its own share URL in the frontmatter, and that URL needs no account
|
|
196
|
-
// to read. Committing this directory would publish the guide to anyone who can see the repo,
|
|
197
|
-
// so the directory ignores itself. Delete this file if you do want guides in the repo.
|
|
198
|
-
const ignore = join(dir, ".gitignore");
|
|
199
|
-
if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
|
|
200
|
-
path = join(dir, `${guide.meta.id}.md`);
|
|
199
|
+
path = join(localDir(cwd), `${guide.meta.id}.md`);
|
|
201
200
|
writeFileSync(path, serialize(guide));
|
|
202
201
|
}
|
|
203
202
|
return { guide, from, path, markdown: serialize(guide) };
|
|
204
203
|
}
|
|
205
204
|
|
|
205
|
+
/**
|
|
206
|
+
* `.passalong/` in a working directory, made if it is missing.
|
|
207
|
+
*
|
|
208
|
+
* A pulled guide carries its own share URL in the frontmatter, and that URL needs no account to
|
|
209
|
+
* read. Committing this directory would publish the guide to anyone who can see the repo, so the
|
|
210
|
+
* directory ignores itself. Delete its .gitignore if you do want guides in the repo.
|
|
211
|
+
*/
|
|
212
|
+
function localDir(cwd) {
|
|
213
|
+
const dir = join(cwd, ".passalong");
|
|
214
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
215
|
+
const ignore = join(dir, ".gitignore");
|
|
216
|
+
if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
|
|
217
|
+
return dir;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Who this worktree is to the task queue, minted the first time it asks.
|
|
222
|
+
*
|
|
223
|
+
* A claim belongs to an agent, not an account: one person runs several, and "no two agents on one
|
|
224
|
+
* task" means nothing if the server cannot tell them apart. The id lives in the worktree's own
|
|
225
|
+
* `.passalong/agent.json`, so a restarted session in the same worktree is the same agent and gets
|
|
226
|
+
* its task back, and a second worktree of the same repo is a different agent. Per machine would
|
|
227
|
+
* let two worktrees on one laptop share a lock; per session would lose the task on every restart.
|
|
228
|
+
*/
|
|
229
|
+
export function agent(cwd = process.cwd()) {
|
|
230
|
+
const c = context(cwd);
|
|
231
|
+
const root = c.root || cwd;
|
|
232
|
+
const file = join(localDir(root), "agent.json");
|
|
233
|
+
let id = "";
|
|
234
|
+
try {
|
|
235
|
+
id = String(JSON.parse(readFileSync(file, "utf8")).id || "");
|
|
236
|
+
} catch {}
|
|
237
|
+
if (!/^[a-z0-9]{8,64}$/.test(id)) {
|
|
238
|
+
id = randomBytes(12).toString("hex");
|
|
239
|
+
writeFileSync(file, `${JSON.stringify({ id }, null, 2)}\n`);
|
|
240
|
+
}
|
|
241
|
+
return { agent: id, host: hostname(), repo: c.repo, worktree: root };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** This worktree's agent if it has one, without creating it. `agent` is "" when there is none. */
|
|
245
|
+
function knownAgent(cwd = process.cwd()) {
|
|
246
|
+
const c = context(cwd);
|
|
247
|
+
const root = c.root || cwd;
|
|
248
|
+
let id = "";
|
|
249
|
+
try {
|
|
250
|
+
id = String(JSON.parse(readFileSync(join(root, ".passalong", "agent.json"), "utf8")).id || "");
|
|
251
|
+
} catch {}
|
|
252
|
+
return { agent: /^[a-z0-9]{8,64}$/.test(id) ? id : "", repo: c.repo };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function needsSync(what) {
|
|
256
|
+
if (!api.loggedIn())
|
|
257
|
+
throw new PassalongError(
|
|
258
|
+
`${what} needs sync — the queue lives on the server (run \`passalong login\`)`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Every task you can see, with where it is and who has it. */
|
|
263
|
+
export async function tasks() {
|
|
264
|
+
needsSync("the task queue");
|
|
265
|
+
return (await api.tasks()).tasks;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Take the next task for this worktree's repo, or get back the one this worktree already holds.
|
|
270
|
+
* `null` when there is nothing to do. The guide is written to `.passalong/<id>.md` like a pull.
|
|
271
|
+
*/
|
|
272
|
+
export async function nextTask({ cwd = process.cwd(), any = false } = {}) {
|
|
273
|
+
needsSync("taking a task");
|
|
274
|
+
const { task } = await api.nextTask({ ...agent(cwd), any });
|
|
275
|
+
if (!task) return null;
|
|
276
|
+
const guide = parse(task.markdown);
|
|
277
|
+
const path = join(localDir(agent(cwd).worktree), `${task.id}.md`);
|
|
278
|
+
writeFileSync(path, task.markdown);
|
|
279
|
+
store.save(guide);
|
|
280
|
+
return { ...task, path };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Word from the agent holding a task: renews its lease, and `note` becomes the board's line. */
|
|
284
|
+
export async function taskProgress(id, note, { cwd = process.cwd() } = {}) {
|
|
285
|
+
needsSync("reporting progress");
|
|
286
|
+
const { agent: a } = agent(cwd);
|
|
287
|
+
return api.taskProgress(id, note ? { agent: a, note } : { agent: a });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The work is done: the task moves to review with a transfer guide about it attached.
|
|
292
|
+
*
|
|
293
|
+
* Pass `markdown` and this writes that guide too, with `parent:` naming the task, so the write-up
|
|
294
|
+
* and the finish are one step and neither can be skipped or pointed at the wrong guide. Pass
|
|
295
|
+
* `report` instead when the guide is already published.
|
|
296
|
+
*/
|
|
297
|
+
export async function finishTask(
|
|
298
|
+
id,
|
|
299
|
+
{ report, markdown, pr = "", note = "", cwd = process.cwd() } = {},
|
|
300
|
+
) {
|
|
301
|
+
needsSync("finishing a task");
|
|
302
|
+
if (!report && markdown) {
|
|
303
|
+
const g = parse(markdown);
|
|
304
|
+
report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
|
|
305
|
+
.guide.meta.id;
|
|
306
|
+
}
|
|
307
|
+
if (!report)
|
|
308
|
+
throw new PassalongError(
|
|
309
|
+
"finishing needs the write-up: `markdown` for a transfer guide about the work, or `report` with the id of one",
|
|
310
|
+
);
|
|
311
|
+
return api.finishTask(id, { agent: agent(cwd).agent, report, pr, note });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The four verbs every guide answers to, whatever its kind (docs/V2.md §11). Each answer carries
|
|
316
|
+
* `next`, the calls that make sense from here, and `say` when the move is to stop.
|
|
317
|
+
*
|
|
318
|
+
* take: this worktree's agent takes guide `id`, or with no id the next one waiting for it. The
|
|
319
|
+
* document is written to `.passalong/<id>.md`, like a pull, so the agent works from a file.
|
|
320
|
+
*/
|
|
321
|
+
export async function take(id, { cwd = process.cwd(), any = false } = {}) {
|
|
322
|
+
needsSync("taking work");
|
|
323
|
+
const who = agent(cwd);
|
|
324
|
+
const r = await api.take({ ...who, ...(id ? { id: await resolveId(id) } : {}), any });
|
|
325
|
+
if (!r.guide?.markdown) return r;
|
|
326
|
+
const path = join(localDir(who.worktree), `${r.guide.id}.md`);
|
|
327
|
+
writeFileSync(path, r.guide.markdown);
|
|
328
|
+
store.save(parse(r.guide.markdown));
|
|
329
|
+
return { ...r, path };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Word from the agent that holds it: the lease starts again, and `note` is what the hub shows. */
|
|
333
|
+
export async function progress(id, note, { cwd = process.cwd() } = {}) {
|
|
334
|
+
needsSync("reporting progress");
|
|
335
|
+
return api.progress(id, { agent: agent(cwd).agent, ...(note ? { note } : {}) });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Done here. A task hands in a write-up — `markdown`, published with `parent:` naming the task, or
|
|
340
|
+
* `report`, the id of one already published — for its author to review against Acceptance. Any
|
|
341
|
+
* other guide hands in whether its Verification held: `ok`, and a `note` when it did not.
|
|
342
|
+
*/
|
|
343
|
+
export async function handIn(
|
|
344
|
+
id,
|
|
345
|
+
{ ok, note = "", markdown, report, pr = "", cwd = process.cwd() } = {},
|
|
346
|
+
) {
|
|
347
|
+
needsSync("handing work in");
|
|
348
|
+
if (!report && markdown) {
|
|
349
|
+
const g = parse(markdown);
|
|
350
|
+
report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
|
|
351
|
+
.guide.meta.id;
|
|
352
|
+
}
|
|
353
|
+
return api.handIn(id, {
|
|
354
|
+
agent: agent(cwd).agent,
|
|
355
|
+
note,
|
|
356
|
+
...(report ? { report, pr } : {}),
|
|
357
|
+
...(typeof ok === "boolean" ? { ok } : {}),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Not this agent's to do: what it held is open again, and `why` goes to whoever is next. */
|
|
362
|
+
export async function pass(id, why, { cwd = process.cwd() } = {}) {
|
|
363
|
+
needsSync("passing work");
|
|
364
|
+
if (!String(why || "").trim())
|
|
365
|
+
throw new PassalongError("say why you are passing it, so whoever is next knows");
|
|
366
|
+
return api.pass(id, { agent: agent(cwd).agent, why });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* What this worktree's agent holds right now, and what is waiting for it: what the session-start
|
|
371
|
+
* and stop hooks read (src/hooks.js). `held` is null when it holds nothing. Counts are best effort
|
|
372
|
+
* — a hook must never fail a session over a count.
|
|
373
|
+
*/
|
|
374
|
+
export async function now({ cwd = process.cwd() } = {}) {
|
|
375
|
+
needsSync("seeing what you hold");
|
|
376
|
+
// Read, never minted: the hooks run in every repo a session opens, and a worktree that has never
|
|
377
|
+
// taken anything has no agent — and should not be given a .passalong/ folder for asking.
|
|
378
|
+
const who = knownAgent(cwd);
|
|
379
|
+
const { working } = await api.working();
|
|
380
|
+
const held = (who.agent && working.find((w) => w.agent === who.agent && w.by?.you)) || null;
|
|
381
|
+
const waiting = { ready: 0, inbox: 0 };
|
|
382
|
+
if (!held) {
|
|
383
|
+
const [t, i] = await Promise.allSettled([api.tasks(), api.inbox()]);
|
|
384
|
+
if (t.status === "fulfilled")
|
|
385
|
+
waiting.ready = t.value.tasks.filter(
|
|
386
|
+
(x) => x.state === "ready" && x.target === who.repo,
|
|
387
|
+
).length;
|
|
388
|
+
if (i.status === "fulfilled") waiting.inbox = i.value.guides.length;
|
|
389
|
+
}
|
|
390
|
+
return { held, waiting, agent: who.agent };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The gate, a person's three answers to a task. Approve: the work in review is done. Reject: it
|
|
395
|
+
* goes back to Ready with `why` on it for the next agent. Release: a task an agent holds, live or
|
|
396
|
+
* stalled, goes back to Ready with a note saying where that agent left it.
|
|
397
|
+
*
|
|
398
|
+
* The local copy is dropped after each, so the next read of the task fetches what the server made
|
|
399
|
+
* of it — a reject or a release rewrites the document, and a stale copy would hide exactly the
|
|
400
|
+
* line the next reader needs.
|
|
401
|
+
*/
|
|
402
|
+
export async function approveTask(id) {
|
|
403
|
+
needsSync("approving a task");
|
|
404
|
+
const r = await api.approveTask(id);
|
|
405
|
+
store.remove(id);
|
|
406
|
+
return r;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function rejectTask(id, why) {
|
|
410
|
+
needsSync("rejecting a task");
|
|
411
|
+
if (!String(why || "").trim()) throw new PassalongError("say why: the next agent reads it");
|
|
412
|
+
const r = await api.rejectTask(id, why);
|
|
413
|
+
store.remove(id);
|
|
414
|
+
return r;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function releaseTask(id) {
|
|
418
|
+
needsSync("releasing a task");
|
|
419
|
+
const r = await api.releaseTask(id);
|
|
420
|
+
store.remove(id);
|
|
421
|
+
return r;
|
|
422
|
+
}
|
|
423
|
+
|
|
206
424
|
/**
|
|
207
425
|
* Pull a guide *and* take the handoff: the two halves of starting work, in one call.
|
|
208
426
|
*
|
|
@@ -252,9 +470,8 @@ export function handoffNudge(meta = {}) {
|
|
|
252
470
|
if (!meta.to && !meta.team) return "";
|
|
253
471
|
return (
|
|
254
472
|
"<!-- passalong: this guide was handed to someone. If you are about to do the work, call " +
|
|
255
|
-
"
|
|
256
|
-
"If you are only reading, or it turns out not to be yours, answer with
|
|
257
|
-
"and a reason. -->"
|
|
473
|
+
"take instead of get_guide — it takes the handoff so the sender stops guessing. " +
|
|
474
|
+
"If you are only reading, or it turns out not to be yours, answer with pass with a reason. -->"
|
|
258
475
|
);
|
|
259
476
|
}
|
|
260
477
|
|
|
@@ -296,6 +513,85 @@ export async function attach(file, { name = "" } = {}) {
|
|
|
296
513
|
return { ...shot, markdown: `` };
|
|
297
514
|
}
|
|
298
515
|
|
|
516
|
+
/**
|
|
517
|
+
* The line a guide arrives with, saying what a follow-up is and how to write one.
|
|
518
|
+
*
|
|
519
|
+
* A follow-up is more context for a guide, written as its own guide with `parent` naming the
|
|
520
|
+
* original — a missing detail, a step that needed explaining, what changed since, what someone
|
|
521
|
+
* found doing it. Whoever opens the original, person or agent, gets its follow-ups with it, which
|
|
522
|
+
* is why context belongs there rather than in a one-line verdict note.
|
|
523
|
+
*
|
|
524
|
+
* Trailing, like the handoff nudge, so it never sits in front of the frontmatter. It is here and
|
|
525
|
+
* not only in the server's instructions because an agent reads the payload it is working from and
|
|
526
|
+
* skims everything else — the same reason the bug lead is inside the document. For a bug, the
|
|
527
|
+
* context worth adding is usually the fix, when it is worth repeating somewhere else: that is a
|
|
528
|
+
* transfer guide, and the bug is what it adds context to.
|
|
529
|
+
*/
|
|
530
|
+
export function followUpNote(meta = {}) {
|
|
531
|
+
if (!meta.id) return "";
|
|
532
|
+
if (meta.kind === "bug")
|
|
533
|
+
return (
|
|
534
|
+
"<!-- passalong: once this is fixed, if the fix is worth repeating somewhere else, that is " +
|
|
535
|
+
"more context for this bug: publish it as a transfer guide with publish_guide " +
|
|
536
|
+
`parent=${meta.id}, and whoever opens this bug gets it too. -->`
|
|
537
|
+
);
|
|
538
|
+
return (
|
|
539
|
+
"<!-- passalong: a follow-up is more context for this guide, written as its own guide. If " +
|
|
540
|
+
"this guide needs more — a missing detail, a step that needed explaining, what changed since, " +
|
|
541
|
+
`what you found doing it — publish that with publish_guide parent=${meta.id}, and whoever ` +
|
|
542
|
+
"opens this guide gets it too. -->"
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** What heads a guide's follow-ups when they are handed to an agent. Same words on both servers. */
|
|
547
|
+
export const FOLLOW_UPS_LEAD =
|
|
548
|
+
"FOLLOW-UPS — more context added to this guide, oldest first. Read them before acting; where " +
|
|
549
|
+
"one contradicts the original, the follow-up is newer.";
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* A guide's follow-ups as the server lists them, oldest first, or [] when there are none, when
|
|
553
|
+
* sync is off, or when anything goes wrong. Context is never worth failing a pull over: the guide
|
|
554
|
+
* itself is what was asked for, and it is already in hand.
|
|
555
|
+
*
|
|
556
|
+
* Only with sync, because follow-ups live on the server and are scoped to what this account can
|
|
557
|
+
* read — the same condition `resolve()` uses to look a guide up there. Fetching them records no
|
|
558
|
+
* pull on them: reading context for a guide is not opening those guides.
|
|
559
|
+
*
|
|
560
|
+
* `markdown` asks for each one's content too (capped server-side). The api calls are injectable
|
|
561
|
+
* so this can be tested without a network.
|
|
562
|
+
*/
|
|
563
|
+
export async function followUpGuides(
|
|
564
|
+
meta = {},
|
|
565
|
+
{ markdown = false, children = api.children, loggedIn = api.loggedIn } = {},
|
|
566
|
+
) {
|
|
567
|
+
if (!meta.id || !loggedIn()) return [];
|
|
568
|
+
try {
|
|
569
|
+
const { guides = [] } = await children(meta.id, { markdown });
|
|
570
|
+
const listed = (Array.isArray(guides) ? guides : []).filter((g) => g?.id);
|
|
571
|
+
// With content the server already sends oldest first; the plain listing is newest first.
|
|
572
|
+
return markdown ? listed : listed.reverse();
|
|
573
|
+
} catch {
|
|
574
|
+
return [];
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* A guide's follow-ups with their content, formatted to go after the document for an agent — the
|
|
580
|
+
* same shape the remote server's get_guide returns — or "" when there are none.
|
|
581
|
+
*/
|
|
582
|
+
export async function followUps(meta = {}, deps = {}) {
|
|
583
|
+
const guides = (await followUpGuides(meta, { ...deps, markdown: true })).filter(
|
|
584
|
+
(g) => typeof g.markdown === "string",
|
|
585
|
+
);
|
|
586
|
+
if (!guides.length) return "";
|
|
587
|
+
return [
|
|
588
|
+
FOLLOW_UPS_LEAD,
|
|
589
|
+
...guides.map(
|
|
590
|
+
(g) => `--- follow-up ${g.id}: ${g.title || "untitled"} ---\n${g.markdown.trimEnd()}`,
|
|
591
|
+
),
|
|
592
|
+
].join("\n\n");
|
|
593
|
+
}
|
|
594
|
+
|
|
299
595
|
/** Local guides merged with synced ones (by id), newest first, optionally filtered. */
|
|
300
596
|
export async function list(query = "", { remote = true, scope = "" } = {}) {
|
|
301
597
|
const local = scope && scope !== "all" && scope !== "mine" ? [] : store.search(query);
|
|
@@ -400,11 +696,149 @@ export async function setStatus(id, status) {
|
|
|
400
696
|
throw new PassalongError(`status must be one of ${STATUSES.join(", ")}`);
|
|
401
697
|
const { guide } = await resolve(id);
|
|
402
698
|
const next = { ...guide, meta: { ...guide.meta, status } };
|
|
403
|
-
|
|
699
|
+
// The server first. Saved the other way round, a refusal left the local copy saying what the
|
|
700
|
+
// server had just refused — a teammate's `ready` on someone else's task read as done here.
|
|
404
701
|
if (api.loggedIn() && guide.meta.url) await api.setStatus(id, status);
|
|
702
|
+
store.save(next);
|
|
405
703
|
return next;
|
|
406
704
|
}
|
|
407
705
|
|
|
706
|
+
/**
|
|
707
|
+
* Move a task from Draft to Ready: a person has read it and an agent may take it.
|
|
708
|
+
*
|
|
709
|
+
* Only tasks. Every other kind is published the moment it is shared, so "ready" on one would
|
|
710
|
+
* either do nothing or reopen something archived under a name that does not say so.
|
|
711
|
+
*/
|
|
712
|
+
export async function ready(id) {
|
|
713
|
+
const { guide } = await resolve(id);
|
|
714
|
+
if (guide.meta.kind !== "task")
|
|
715
|
+
throw new PassalongError(`${guide.meta.id || id} is not a task — ready is for tasks in Draft`);
|
|
716
|
+
if (guide.meta.status !== "draft")
|
|
717
|
+
throw new PassalongError(`${guide.meta.id} is not in Draft (status: ${guide.meta.status})`);
|
|
718
|
+
return setStatus(guide.meta.id, "published");
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* One goal as several tasks, written as drafts in order. Each step may name the earlier steps it
|
|
723
|
+
* needs by position (`after: [0, 1]`), and those become its `blocked_by`, so the queue hands them
|
|
724
|
+
* out in an order that works and runs the independent ones side by side.
|
|
725
|
+
*
|
|
726
|
+
* Only earlier steps: a step that waits on itself or on a later one is a plan that never finishes,
|
|
727
|
+
* and it is refused before anything is written rather than discovered as a queue that never moves.
|
|
728
|
+
* Every step lands in Draft — a wrong decomposition is several agents building the wrong thing at
|
|
729
|
+
* once, so a person reads the plan before any of it runs.
|
|
730
|
+
*/
|
|
731
|
+
export async function planTasks(steps, { cwd = process.cwd() } = {}) {
|
|
732
|
+
if (!Array.isArray(steps) || !steps.length)
|
|
733
|
+
throw new PassalongError("a plan needs at least one step");
|
|
734
|
+
steps.forEach((step, i) => {
|
|
735
|
+
for (const j of step.after || [])
|
|
736
|
+
if (!Number.isInteger(j) || j < 0 || j >= i)
|
|
737
|
+
throw new PassalongError(
|
|
738
|
+
`step ${i} ("${step.title}") can only wait on an earlier step, not ${j}`,
|
|
739
|
+
);
|
|
740
|
+
});
|
|
741
|
+
const repo = context(cwd).repo;
|
|
742
|
+
const ids = [];
|
|
743
|
+
for (const step of steps) {
|
|
744
|
+
const section = (name, text) =>
|
|
745
|
+
String(text || "").trim() ? [`## ${name}`, String(text).trim(), ""] : [];
|
|
746
|
+
const body = [
|
|
747
|
+
...section("Goal", step.goal),
|
|
748
|
+
...section("Context", step.context),
|
|
749
|
+
...section("Constraints", step.constraints),
|
|
750
|
+
...section("Acceptance", step.acceptance),
|
|
751
|
+
...section("Out of scope", step.out_of_scope),
|
|
752
|
+
].join("\n");
|
|
753
|
+
const meta = { title: step.title, kind: "task", target_context: step.target_context ?? repo };
|
|
754
|
+
const after = (step.after || []).map((j) => ids[j]);
|
|
755
|
+
if (after.length) meta.blocked_by = after;
|
|
756
|
+
ids.push((await share(serialize({ meta, body }), { cwd })).guide.meta.id);
|
|
757
|
+
}
|
|
758
|
+
return ids;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** What an agent started by `work()` is told. The task itself goes in too, so it cannot start blind. */
|
|
762
|
+
export function workPrompt(t) {
|
|
763
|
+
return [
|
|
764
|
+
`You hold passalong task ${t.id}, and no other agent can take it while you do.`,
|
|
765
|
+
`It is written to ${t.path}; here it is:`,
|
|
766
|
+
"",
|
|
767
|
+
t.markdown.trim(),
|
|
768
|
+
"",
|
|
769
|
+
"Do it in this repo. There are no Steps: work out how to reach Goal within Constraints, and",
|
|
770
|
+
"leave Out of scope alone. Call the passalong MCP tool progress with id",
|
|
771
|
+
`${t.id} and a one-line status at each milestone — 30 minutes of silence stalls the task.`,
|
|
772
|
+
"When every Acceptance check holds, commit what you changed as one commit whose message",
|
|
773
|
+
`starts with "${t.id}: " — several tasks share this worktree, and a reviewer reads each one's`,
|
|
774
|
+
"change on its own. Then call hand_in with id",
|
|
775
|
+
`${t.id}, \`pr\` set to that commit's hash, and \`markdown\`: a transfer guide saying what you`,
|
|
776
|
+
"did, what you decided and why, and how you checked each Acceptance line. If either call",
|
|
777
|
+
"says you no longer hold the task, stop.",
|
|
778
|
+
"",
|
|
779
|
+
"Nobody reads what you print: this session runs unattended. If something only a person can",
|
|
780
|
+
"do stands between you and an Acceptance line — a permission, a secret, a decision — call",
|
|
781
|
+
`progress with id ${t.id} and a note starting "BLOCKED: " that says exactly what you`,
|
|
782
|
+
"need, leave the task unfinished and uncommitted, and stop. The board shows that note.",
|
|
783
|
+
].join("\n");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Work the queue from this worktree: take the next task, hand it to an agent, and when the agent
|
|
788
|
+
* has finished it take the next, until there is nothing left.
|
|
789
|
+
*
|
|
790
|
+
* The agent runs in this worktree, so it is the same agent to the queue — its progress and
|
|
791
|
+
* hand_in land on the claim this took. `agent` is a command the prompt is appended to;
|
|
792
|
+
* `claude -p` by default, or whatever PASSALONG_AGENT says.
|
|
793
|
+
*
|
|
794
|
+
* An agent that exits without finishing stops the loop. Asking for the next task would hand the
|
|
795
|
+
* same one back, since a worktree holds one task at a time, and the loop would run the agent on it
|
|
796
|
+
* forever. The task stays claimed here, for the next run to resume or a person to release.
|
|
797
|
+
*/
|
|
798
|
+
export async function work({
|
|
799
|
+
cwd = process.cwd(),
|
|
800
|
+
agent = process.env.PASSALONG_AGENT || "claude -p",
|
|
801
|
+
any = false,
|
|
802
|
+
once = false,
|
|
803
|
+
onTask = () => {},
|
|
804
|
+
} = {}) {
|
|
805
|
+
needsSync("working the queue");
|
|
806
|
+
const [command, ...args] = agent.trim().split(/\s+/);
|
|
807
|
+
const finished = [];
|
|
808
|
+
for (;;) {
|
|
809
|
+
const t = await nextTask({ cwd, any });
|
|
810
|
+
if (!t) return { finished, stopped: "empty" };
|
|
811
|
+
onTask(t);
|
|
812
|
+
const run = spawnSync(command, [...args, workPrompt(t)], {
|
|
813
|
+
cwd,
|
|
814
|
+
stdio: "inherit",
|
|
815
|
+
env: { ...process.env, PASSALONG_TASK: t.id, PASSALONG_TASK_PATH: t.path },
|
|
816
|
+
});
|
|
817
|
+
const state = (await tasks()).find((x) => x.id === t.id)?.state;
|
|
818
|
+
if (state !== "review")
|
|
819
|
+
return {
|
|
820
|
+
finished,
|
|
821
|
+
stopped: "unfinished",
|
|
822
|
+
task: t.id,
|
|
823
|
+
state,
|
|
824
|
+
exit: run.status ?? run.error?.message,
|
|
825
|
+
};
|
|
826
|
+
finished.push(t.id);
|
|
827
|
+
if (once) return { finished, stopped: "once" };
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Make every task you wrote that is still in Draft ready, and return their ids. For the moment a
|
|
833
|
+
* planner has written several and you have read them all. Only your own: moving a task into the
|
|
834
|
+
* queue is its author's call.
|
|
835
|
+
*/
|
|
836
|
+
export async function readyDrafts() {
|
|
837
|
+
const drafts = (await tasks()).filter((t) => t.mine && t.state === "draft");
|
|
838
|
+
for (const t of drafts) await ready(t.id);
|
|
839
|
+
return drafts.map((t) => t.id);
|
|
840
|
+
}
|
|
841
|
+
|
|
408
842
|
export async function remove(id) {
|
|
409
843
|
// Server first: if it refuses (not the author), the local copy must survive too.
|
|
410
844
|
if (api.loggedIn()) {
|