passalong 0.18.0 → 0.20.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 CHANGED
@@ -28,7 +28,7 @@ codex mcp add passalong -- passalong mcp # Codex: the same MCP server
28
28
  ## Commands
29
29
 
30
30
  ```
31
- passalong share [file] [--to team[/handle]] publish a guide as a draft → id + link; --to addresses it
31
+ passalong share [file] [--to team[/handle]] publish a guide → id + link, Ready; --to addresses it
32
32
  passalong pull <id|link> print a guide; --save also writes ./.passalong/<id>.md (git-ignored)
33
33
  passalong inbox guides handed to you that you have not pulled yet
34
34
  passalong board waiting on you, in flight, landed, worth keeping
@@ -73,8 +73,8 @@ passalong inbox # on Ada's side: what was handed to you
73
73
  passalong activity # back on your side: it landed, and who pulled it
74
74
  ```
75
75
 
76
- Every guide, whatever its kind, moves the same way: it is a draft until a person marks it Ready
77
- (`passalong ready <id>`), then whoever takes it (`passalong take <id>`) holds it, hands it in with
76
+ Every guide, whatever its kind, moves the same way: it is Ready when shared (or a draft, with
77
+ `status: draft`, until a person runs `passalong ready <id>`), then whoever takes it (`passalong take <id>`) holds it, hands it in with
78
78
  the evidence, and a person approves it or sends it back with the reason (`passalong approve` /
79
79
  `passalong reject`).
80
80
 
package/bin/passalong CHANGED
@@ -6,12 +6,11 @@
6
6
  // handed to whoever pulls or starts that guide
7
7
  // TARGET is a team, team/@handle for one person, or "team/#group" for the people who do a
8
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
9
+ // passalong task "<what needs doing>" write a task for an agent; Ready at once
10
10
  // passalong ready <id...|--all> guides you have read are ready — whoever they are for may take them now
11
11
  // passalong tasks every task and where it is: draft, ready, claimed, stalled, review
12
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, of any kind
14
- // passalong reject <id> <why> not done: back to Ready, and whoever takes it next reads why
13
+ // passalong reopen <id> <why> finished work isn't right: back to To do, and whoever takes it next reads why
15
14
  // passalong release <id> take work back from whoever holds it, of any kind
16
15
  // passalong attach <file> upload a picture or a file and print the markdown that points at it
17
16
  // passalong reply <id> <text> write to whoever holds it (--file PATH to attach); or leave a note
@@ -337,9 +336,8 @@ const commands = {
337
336
  const id = guide.meta.id;
338
337
  console.log(id);
339
338
  const target = guide.meta.target_context;
340
- console.error(`task "${guide.meta.title}" is in Draft${target ? ` for ${target}` : " (no repo)"}`);
339
+ console.error(`task "${guide.meta.title}" is Ready${target ? ` for ${target}` : " (no repo)"}`);
341
340
  if (guide.meta.team) console.error(`handed to ${guide.meta.team} (team inbox)`);
342
- console.error(`when an agent may take it: passalong ready ${id}`);
343
341
  },
344
342
 
345
343
  async tasks() {
@@ -384,16 +382,25 @@ const commands = {
384
382
  if (n) console.error("review them with: passalong tasks");
385
383
  },
386
384
 
387
- async approve(f) {
388
- const id = f._[0] || die("usage: passalong approve <id>");
389
- await passalong.approveTask(id).catch((e) => die(e.message));
390
- console.error(`${id} is done`);
385
+ // Nothing waits for approval now (docs/REVIEW.md): finishing is final. Kept so a script that
386
+ // calls it is told, not refused.
387
+ async approve() {
388
+ console.error(
389
+ "nothing to approve: finished work is already Done. If it isn't right: passalong reopen <id> <why>",
390
+ );
391
+ },
392
+
393
+ /** Finished work back to To do, with why at its top; whoever finished it is told. */
394
+ async reopen(f) {
395
+ const id = f._[0] || die("usage: passalong reopen <id> <why>");
396
+ const why = f._.slice(1).join(" ");
397
+ if (!why) die("say why: passalong reopen <id> <what is wrong>");
398
+ await passalong.reopen(id, why).catch((e) => die(e.message));
399
+ console.error(`${id} is back in To do — whoever takes it next reads why`);
391
400
  },
392
401
 
393
402
  async reject(f) {
394
- const id = f._[0] || die("usage: passalong reject <id> <why>");
395
- await passalong.rejectTask(id, f._.slice(1).join(" ")).catch((e) => die(e.message));
396
- console.error(`${id} is back in Ready — the next agent reads why`);
403
+ return commands.reopen(f);
397
404
  },
398
405
 
399
406
  async release(f) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Hand tasks, bugs and finished work to AI coding agents, and get back a write-up with evidence.",
5
5
  "mcpName": "io.github.codellyson/passalong",
6
6
  "keywords": [
package/skill/SKILL.md CHANGED
@@ -67,8 +67,7 @@ machine, another agent session, or a teammate without your branch.
67
67
  follow-up gets the guide it came out of, and where that guide has got to — so write it as a
68
68
  note on that work rather than as a standalone brief. What you did or found while working on a
69
69
  guide belongs in its `hand_in`, not a follow-up.
70
- 6. **Report** the id (and link) and say it is a **draft**: it reaches nobody until the person reads
71
- it and marks it Ready (`passalong ready <id>`, or Ready in the hub). Then one line on how to use
70
+ 6. **Report** the id (and link). It is Ready and has reached whoever it is for. Then one line on how to use
72
71
  it on the other side: `passalong take <id>` in the target repo, or "take passalong <id>" to an
73
72
  agent with the Passalong MCP server. `take` rather than `pull` on purpose: it holds the work as
74
73
  well as fetching it, and until somebody takes it the sender cannot tell a guide nobody noticed
package/src/api.js CHANGED
@@ -429,6 +429,8 @@ export const approveTask = (id) =>
429
429
  call(`/v1/tasks/${encodeURIComponent(id)}/approve`, { method: "POST", body: {} });
430
430
  export const rejectTask = (id, why) =>
431
431
  call(`/v1/tasks/${encodeURIComponent(id)}/reject`, { method: "POST", body: { why } });
432
+ export const reopen = (id, why) =>
433
+ call(`/v1/guides/${encodeURIComponent(id)}/reopen`, { method: "POST", body: { why } });
432
434
  /** A handoff's or bug's approve and send-back. No `place` names the only hand-in there is. */
433
435
  export const closeGuide = (id) =>
434
436
  call(`/v1/guides/${encodeURIComponent(id)}/close`, { method: "POST", body: {} });
package/src/guide.js CHANGED
@@ -497,7 +497,9 @@ export function stamp(guide, defaults = {}) {
497
497
  const meta = { ...guide.meta };
498
498
  if (!meta.id) meta.id = newId();
499
499
  if (!meta.created) meta.created = new Date().toISOString();
500
- if (!meta.status) meta.status = "draft";
500
+ // Ready unless whoever wrote it said draft: a guide sent to someone is meant to reach them, and
501
+ // drafts were where addressed work sat forgotten (every draft in production on 2026-10-15).
502
+ if (!meta.status) meta.status = "published";
501
503
  for (const [k, v] of Object.entries(defaults))
502
504
  if (meta[k] === undefined || meta[k] === "") meta[k] = v;
503
505
  return { meta, body: guide.body };
@@ -525,8 +527,7 @@ export function bugGuide({
525
527
  area = "",
526
528
  report = "",
527
529
  environment = "",
528
- // A draft like every new guide: it reaches people when a person marks it Ready.
529
- status = "draft",
530
+ status = "published",
530
531
  evidence = [],
531
532
  } = {}) {
532
533
  /**
@@ -617,7 +618,7 @@ export function template(meta = {}) {
617
618
  summary: "",
618
619
  author: "",
619
620
  source_context: "",
620
- status: "draft",
621
+ status: "published",
621
622
  stack_assumptions: [],
622
623
  tags: [],
623
624
  ...meta,
@@ -655,7 +656,7 @@ function bugTemplate(meta = {}) {
655
656
  kind: "bug",
656
657
  author: "",
657
658
  source_context: "",
658
- status: "draft",
659
+ status: "published",
659
660
  severity: "s3",
660
661
  stack_assumptions: [],
661
662
  tags: ["bug"],
@@ -697,7 +698,7 @@ function taskTemplate(meta = {}) {
697
698
  author: "",
698
699
  source_context: "",
699
700
  target_context: "",
700
- status: "draft",
701
+ status: "published",
701
702
  stack_assumptions: [],
702
703
  tags: [],
703
704
  ...meta,
package/src/hooks.js CHANGED
@@ -21,28 +21,47 @@ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
21
21
  /**
22
22
  * What this worktree's agent holds and what is waiting, as a few lines for an agent to read.
23
23
  * Empty when there is nothing to say, so a session in an unrelated repo is not told anything.
24
+ *
25
+ * `held` is a list: an agent may hold several things, and several sessions in one worktree are one
26
+ * agent to the queue. So a session is told what the worktree holds, and to carry on only with what
27
+ * was its own before a restart — never to resume another session's work.
24
28
  */
25
- export function nowText({ held, waiting = {} }) {
26
- if (held) {
29
+ export function nowText({ held = [], waiting = {} }) {
30
+ if (held.length === 1) {
31
+ const [h] = held;
27
32
  const lines = [
28
33
  // `kind` is required on what /v1/working sends. The `|| "task"` that used to be here was
29
34
  // both dead and wrong — absent has meant transfer since kinds existed, never task.
30
- `Passalong: You hold ${held.id} (${held.kind}): ${held.title || held.id}.`,
35
+ `Passalong: This worktree holds ${h.id} (${h.kind}): ${h.title || h.id}.`,
31
36
  ];
32
- if (held.note) lines.push(`Last note: "${held.note}".`);
37
+ if (h.note) lines.push(`Last note: "${h.note}".`);
33
38
  // The one thing here that is new information rather than a reminder: the person has written, and
34
39
  // nothing else in the session will say so until the agent next checks in.
35
- if (held.replies)
40
+ if (h.replies)
36
41
  lines.push(
37
- `${plural(held.replies, "message", "messages")} from the person ${held.replies === 1 ? "is" : "are"} ` +
38
- `waiting for you: call take ${held.id} to read ${held.replies === 1 ? "it" : "them"} before anything else.`,
42
+ `${plural(h.replies, "message", "messages")} from the person ${h.replies === 1 ? "is" : "are"} ` +
43
+ `waiting: call take ${h.id} to read ${h.replies === 1 ? "it" : "them"} before anything else.`,
39
44
  );
40
- if (held.state === "stalled")
45
+ if (h.state === "stalled")
41
46
  lines.push("It is marked stalled: nobody has heard from its agent in 30 minutes.");
42
47
  lines.push(
43
- `To carry on, call take ${held.id} (it resumes what you hold), then progress at each ` +
44
- "milestone. When it is done, hand_in with evidence — what you ran and what came back — " +
45
- "so keep it as you go; if it is not yours or you are stuck, pass with the reason.",
48
+ `If it was yours before this session restarted, call take ${h.id} to carry on, then ` +
49
+ "progress at each milestone. When it is done, hand_in with evidence — what you ran and " +
50
+ "what came back — so keep it as you go; if it is not yours or you are stuck, pass with " +
51
+ "the reason. If another session here is on it, leave it to that session.",
52
+ );
53
+ return lines.join("\n");
54
+ }
55
+ if (held.length > 1) {
56
+ const lines = [`Passalong: This worktree holds ${held.length} things:`];
57
+ for (const h of held)
58
+ lines.push(
59
+ `- ${h.id} (${h.kind}): ${h.title || h.id}${h.state === "stalled" ? " — stalled" : ""}` +
60
+ `${h.replies ? ` — ${plural(h.replies, "message", "messages")} waiting` : ""}`,
61
+ );
62
+ lines.push(
63
+ "Several sessions may be working here. Carry on only with what was yours before this " +
64
+ "session restarted (take <id> resumes it) and leave the rest to the sessions on them.",
46
65
  );
47
66
  return lines.join("\n");
48
67
  }
@@ -59,19 +78,22 @@ export function nowText({ held, waiting = {} }) {
59
78
  * Whether a session may stop. `input` is what Claude Code sends the Stop hook on stdin. Null lets
60
79
  * it stop; an object is the hook's answer, blocking once with what to call. `stop_hook_active`
61
80
  * means this stop is already the session continuing because of this hook, and blocking again
62
- * would leave it no way to end.
81
+ * would leave it no way to end. The hook cannot tell which session took what, so it names
82
+ * everything still open here and lets the session settle its own.
63
83
  */
64
- export function stopVerdict({ held, input = {} }) {
84
+ export function stopVerdict({ held = [], input = {} }) {
65
85
  // An agent that asked a question was told to stop and wait, so it must be let: blocking it would
66
86
  // make the instruction and the hook contradict each other, and the agent would pick one.
67
- if (!held || held.asking || input.stop_hook_active) return null;
87
+ const open = held.filter((h) => !h.asking);
88
+ if (!open.length || input.stop_hook_active) return null;
89
+ const ids = open.map((h) => h.id).join(", ");
68
90
  return {
69
91
  decision: "block",
70
92
  reason:
71
- `You still hold ${held.id}: ${held.title || held.id}. Before ending, do one of: ` +
72
- `hand_in ${held.id} with your evidence if it is done; pass ${held.id} with the reason if ` +
73
- `it is not yours or you are stuck; or, if the user is stopping mid-way, progress ${held.id} ` +
74
- "with a note saying what is left — then end.",
93
+ `This worktree still holds ${ids}. For anything there you worked on in this session, do ` +
94
+ "one of before ending: hand_in it with your evidence if it is done; pass it with the reason " +
95
+ "if it is not yours or you are stuck; or, if the user is stopping mid-way, progress it with " +
96
+ "a note saying what is left. Leave what other sessions are on — then end.",
75
97
  };
76
98
  }
77
99
 
@@ -112,15 +134,19 @@ const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
112
134
  * `needs` counts everything waiting on the person: work handed in for review, agents stuck on
113
135
  * them, and guides handed to them.
114
136
  */
115
- export function statusText({ held, needs = 0, ready = 0 }) {
137
+ export function statusText({ held = [], needs = 0, ready = 0 }) {
116
138
  const parts = [];
117
- if (held) {
118
- const said = held.note ? ` "${clip(held.note, 32)}"` : "";
119
- parts.push(`▸ ${held.id}${held.state === "stalled" ? " (stalled)" : ""}${said}`);
139
+ if (held.length === 1) {
140
+ const [h] = held;
141
+ const said = h.note ? ` "${clip(h.note, 32)}"` : "";
142
+ parts.push(`▸ ${h.id}${h.state === "stalled" ? " (stalled)" : ""}${said}`);
143
+ } else if (held.length > 1) {
144
+ const stalled = held.filter((h) => h.state === "stalled").length;
145
+ parts.push(`▸ ${held.length} held${stalled ? ` (${stalled} stalled)` : ""}`);
120
146
  } else parts.push("passalong");
121
147
  if (needs) parts.push(`${needs} need${needs === 1 ? "s" : ""} you`);
122
148
  if (ready) parts.push(`${ready} ready`);
123
- if (parts.length === 1 && !held) parts.push("clear");
149
+ if (parts.length === 1 && !held.length) parts.push("clear");
124
150
  return parts.join(" · ");
125
151
  }
126
152
 
package/src/mcp.js CHANGED
@@ -297,15 +297,16 @@ export function buildServer() {
297
297
  "to act. It is what the person sees first in their hub; the document is behind it. A guide " +
298
298
  "with no summary is refused.\n" +
299
299
  "SAY WHICH KIND IT IS. There is no default: a guide with no `kind:` line is refused, and so " +
300
- "is a spelling that is not one of the three. EVERY NEW GUIDE IS A DRAFT, whatever its kind: " +
301
- "it reaches nobody until a person reads it and marks it Ready, and every kind then moves the " +
302
- "same way — Ready, taken, handed in, approved. " +
300
+ "is a spelling that is not one of the three. A NEW GUIDE IS READY: it reaches whoever it is " +
301
+ "for at once. Say `status: draft` only when the person should read it first — something you " +
302
+ "are unsure of — and tell them; plan_tasks drafts for you. Every kind moves the same way — " +
303
+ "Ready, taken, handed in, approved. " +
303
304
  `kind: task is work nobody has done yet. Sections: ${TASK_SECTIONS.join(", ")}. ` +
304
305
  "It has no Steps: work out how to reach Goal within Constraints, leave Out of scope " +
305
306
  "alone, and treat Acceptance as the definition of done. A proposal awaiting a team decision " +
306
- "stays Draft until a person makes it Ready. hand_in with `markdown`: a " +
307
- "transfer guide about what you did (guide_template kind transfer shows the shape) — its " +
308
- "author reviews it against Acceptance.\n" +
307
+ "stays Draft until a person makes it Ready. hand_in when every Acceptance line holds: it is " +
308
+ "final — the task goes to Done with what you showed, and nobody reviews it after you — so " +
309
+ "show it, and say anything you had to adapt in `writeup` (500 characters).\n" +
309
310
  `kind: transfer (or no kind: line) is a finished implementation to repeat here. Sections: ${SECTIONS.join(", ")}. ` +
310
311
  "Follow its Steps, adapting anything marked ASSUMES to this codebase; run its " +
311
312
  "Verification, then hand_in with ok and, when it failed, a note saying what went wrong.\n" +
@@ -333,8 +334,8 @@ export function buildServer() {
333
334
  "session into a guide (guide_template kind transfer shows the shape) and call " +
334
335
  "publish_guide, with `to` as team, team/@handle for one teammate, or team/#group for the " +
335
336
  "people who do a thing. " +
336
- "BEFORE PUBLISHING ANYTHING, CHECK WHAT IS ALREADY OPEN: call take with no id, or read the " +
337
- "inbox, and see what you hold. If this session's work answers something you hold, hand_in " +
337
+ "BEFORE PUBLISHING ANYTHING, CHECK WHAT IS ALREADY OPEN: call board (it lists what this " +
338
+ "worktree holds) or read the inbox. If this session's work answers something you hold, hand_in " +
338
339
  "that — never publish a second guide about it. A transfer guide is for work that has to " +
339
340
  "cross a boundary: another repo, another machine, a teammate without your branch. Work you " +
340
341
  "committed and pushed where the team can already see it has crossed no boundary, and a " +
@@ -943,12 +944,17 @@ export function buildServer() {
943
944
  "The state of this user's transfers as queues: waiting on you (handed to you, not " +
944
945
  "pulled), not working (someone gave it a failing verdict — the most urgent), in flight " +
945
946
  "(handed over, nobody has taken it — `stale` means it has sat over a week), and landed " +
946
- "(someone else has it). Use it to answer 'what is outstanding?'.",
947
+ "(someone else has it), and `held`: what this worktree's agents hold now. Use it to answer " +
948
+ "'what is outstanding?' and to see what you hold, without taking anything.",
947
949
  inputSchema: {},
948
950
  },
949
951
  async () => {
950
952
  try {
951
- return json(await passalong.board());
953
+ const [board, now] = await Promise.all([
954
+ passalong.board(),
955
+ passalong.now().catch(() => ({ held: [] })),
956
+ ]);
957
+ return json({ ...board, held: now.held });
952
958
  } catch (err) {
953
959
  return fail(err);
954
960
  }
@@ -1084,7 +1090,7 @@ export function buildServer() {
1084
1090
  const ids = await passalong.planTasks(steps, { cwd: cwd || process.cwd() });
1085
1091
  return json({
1086
1092
  drafts: ids.map((id, i) => ({ id, title: steps[i].title, after: steps[i].after || [] })),
1087
- next: "all in Draft: ask the user to read them, then run `passalong ready --all`",
1093
+ next: `all in Draft: ask the user to read them and mark them Ready on the board at ${api.baseUrl()}/hub/work, or run \`passalong ready --all\``,
1088
1094
  });
1089
1095
  } catch (err) {
1090
1096
  return fail(err);
@@ -1099,9 +1105,9 @@ export function buildServer() {
1099
1105
  annotations: ADDS,
1100
1106
  description:
1101
1107
  "Say you are doing it, and get it. With `id`, that guide — any kind: a task, a bug, a " +
1102
- "handoff. With no id, the next thing waiting for this worktree's agent. While you hold it " +
1103
- "no other agent can take it here, and its sender sees you are on it. One thing at a time: " +
1104
- "if you already hold something, finish or pass that first. If somebody else has it, the " +
1108
+ "handoff. With no id, the next thing waiting — always something new, never what you hold. " +
1109
+ "While you hold it no other agent can take it here, and its sender sees you are on it. You " +
1110
+ "may hold several at once; the person watches them on the board. If somebody else has it, the " +
1105
1111
  "answer says who — tell the user rather than doing the work twice. Every answer ends with " +
1106
1112
  "what to call next.",
1107
1113
  inputSchema: {
@@ -1157,7 +1163,7 @@ export function buildServer() {
1157
1163
  outputSchema: handInOut,
1158
1164
  annotations: ADDS,
1159
1165
  description:
1160
- "Done here, with proof, sorted against the line it answers. Send `checks`: one entry per " +
1166
+ "Finished here, with proof. IT IS FINAL: the work goes to Done with what you showed, and nobody reviews it after you (anyone can reopen it with a reason). So SHOW IT: a screenshot for anything with a screen — before and after, for a bug — or, for backend work, the one command and the line of its output that shows it works. Send `checks`: one entry per " +
1161
1167
  "line the guide asks for — `## Acceptance` on a task, `## Verification` on a handoff or a " +
1162
1168
  "bug — each with that line and the evidence for it. That is what its author reads, line " +
1163
1169
  "against line, instead of hunting through a wall of output for the part that answers each " +
@@ -1165,8 +1171,7 @@ export function buildServer() {
1165
1171
  "command proves it: the command is run here before the hand-in lands, a non-zero exit " +
1166
1172
  "refuses it, and what it printed is recorded instead of your account of it. Use `ran` for " +
1167
1173
  "a check no command can settle. `evidence` as one block is the older shape and still " +
1168
- "works. A task also takes `markdown`, a transfer guide about what you did and decided — " +
1169
- "this publishes it and attaches it. A handoff or a bug takes `ok`, whether its " +
1174
+ "works. A task needs no write-up guide any more. A handoff or a bug takes `ok`, whether its " +
1170
1175
  "Verification held here, and `note` saying what went wrong when it did not. DO NOT " +
1171
1176
  "publish a guide to carry your evidence: it belongs on this call, and a second document " +
1172
1177
  'titled "Hand-in evidence" is the thing `checks` exists to replace. If it worked but you ' +
@@ -1242,10 +1247,10 @@ export function buildServer() {
1242
1247
  .string()
1243
1248
  .optional()
1244
1249
  .describe(
1245
- "handoff or bug: what you had to adapt to make it work here — a version, a name, a " +
1246
- "step that needed something the guide does not mention. Prose, and optional: leave " +
1247
- "it out when it worked as written. The next person to open the guide is shown it, " +
1248
- "which is why it does not need to be a guide of its own",
1250
+ "what you had to adapt to make it work here — a version, a name, a step that needed " +
1251
+ "something the guide does not mention. 500 characters at most; anything longer " +
1252
+ "belongs in the change. Optional: leave it out when it worked as written. The next " +
1253
+ "person to open the guide is shown it",
1249
1254
  ),
1250
1255
  risk: z
1251
1256
  .string()
@@ -1259,11 +1264,11 @@ export function buildServer() {
1259
1264
  markdown: z
1260
1265
  .string()
1261
1266
  .optional()
1262
- .describe("task: the write-up; start from guide_template kind transfer"),
1267
+ .describe("older shape, not needed: say what you adapted in `writeup` instead"),
1263
1268
  report: z
1264
1269
  .string()
1265
1270
  .optional()
1266
- .describe("task, instead of markdown: id of a write-up already published"),
1271
+ .describe("older shape, not needed: say what you adapted in `writeup` instead"),
1267
1272
  pr: z.string().optional().describe("task: PR or branch link, when there is one"),
1268
1273
  cwd: z
1269
1274
  .string()
@@ -1525,8 +1530,8 @@ export function buildServer() {
1525
1530
  title: "Publish guide",
1526
1531
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
1527
1532
  description:
1528
- "Publish a guide from markdown (frontmatter + sections). Check what you hold first (take " +
1529
- "with no id): if this session answers something you hold, hand_in that instead, and if the " +
1533
+ "Publish a guide from markdown (frontmatter + sections). Check what you hold first " +
1534
+ "(board): if this session answers something you hold, hand_in that instead, and if the " +
1530
1535
  "work never left a branch the team can see, publish what is still open as a bug or a task " +
1531
1536
  "rather than a write-up of the fix. A transfer guide by default, or " +
1532
1537
  "a single bug with `kind: bug`; use file_bugs for more than one; or a task for work nobody " +
@@ -1579,7 +1584,8 @@ export function buildServer() {
1579
1584
  ...(guide.meta.status === "draft"
1580
1585
  ? {
1581
1586
  status: "draft",
1582
- next: `in Draft: ask the user to read it, then run \`passalong ready ${guide.meta.id}\``,
1587
+ // The hub page, never the share link: the share page has no Ready button.
1588
+ next: `in Draft: ask the user to read it and mark it Ready at ${api.baseUrl()}/hub/g/${guide.meta.id}, or run \`passalong ready ${guide.meta.id}\``,
1583
1589
  }
1584
1590
  : {}),
1585
1591
  });
@@ -1677,11 +1683,7 @@ export function buildServer() {
1677
1683
  { title: title || "", environment: environment || "", issues },
1678
1684
  { cwd: cwd || process.cwd(), to },
1679
1685
  );
1680
- return json({
1681
- ...filed,
1682
- // Filed is not sent: every bug is a draft until a person reads it.
1683
- next: "all in Draft: ask the user to read them, then run `passalong ready --all`",
1684
- });
1686
+ return json(filed);
1685
1687
  } catch (err) {
1686
1688
  return fail(err);
1687
1689
  }
package/src/passalong.js CHANGED
@@ -78,12 +78,6 @@ export async function share(markdown, { cwd = process.cwd(), to, follows } = {})
78
78
  ];
79
79
  if (errors.length)
80
80
  throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
81
- // Every guide is a draft when it is first shared, whatever its kind: Draft is where it waits
82
- // until a person says it is ready — `ready()` — and an agent that writes one over MCP comes
83
- // through here too, so publishing on share would let it reach people with nobody reading it.
84
- // Sharing *is* putting it in Draft, so a new guide that names no status says so here; one already
85
- // published keeps what its document says.
86
- if (!guide.meta.status) guide = { ...guide, meta: { ...guide.meta, status: "draft" } };
87
81
 
88
82
  // The server is where a guide lives; nothing is kept on this machine.
89
83
  needsSync("sharing a guide");
@@ -270,7 +264,7 @@ export async function tasks() {
270
264
  }
271
265
 
272
266
  /**
273
- * Take the next task for this worktree's repo, or get back the one this worktree already holds.
267
+ * Take the next task for this worktree's repo — always a new one, since an agent may hold several.
274
268
  * `null` when there is nothing to do. Written to `.passalong/<id>.md` only when `save` asks.
275
269
  */
276
270
  export async function nextTask({ cwd = process.cwd(), any = false, save = false } = {}) {
@@ -548,7 +542,7 @@ export async function ask(id, question, { cwd = process.cwd() } = {}) {
548
542
 
549
543
  /**
550
544
  * What this worktree's agent holds right now, and what is waiting for it: what the session-start
551
- * and stop hooks read (src/hooks.js). `held` is null when it holds nothing. Counts are best effort
545
+ * and stop hooks read (src/hooks.js). `held` lists everything it holds, empty when nothing. Counts are best effort
552
546
  * — a hook must never fail a session over a count.
553
547
  */
554
548
  export async function now({ cwd = process.cwd() } = {}) {
@@ -563,8 +557,7 @@ export async function now({ cwd = process.cwd() } = {}) {
563
557
  api.handedIn(),
564
558
  ]);
565
559
  if (w.status === "rejected") throw w.reason;
566
- const held =
567
- (who.agent && w.value.working.find((x) => x.agent === who.agent && x.by?.you)) || null;
560
+ const held = who.agent ? w.value.working.filter((x) => x.agent === who.agent && x.by?.you) : [];
568
561
  const tasks = t.status === "fulfilled" ? t.value.tasks : [];
569
562
  const waiting = {
570
563
  ready: tasks.filter((x) => x.state === "ready" && x.target === who.repo).length,
@@ -583,6 +576,14 @@ export async function now({ cwd = process.cwd() } = {}) {
583
576
  return { held, waiting, needs: review + handed + waiting.inbox, agent: who.agent };
584
577
  }
585
578
 
579
+ /** Finished work back to To do, with why (docs/REVIEW.md §4). */
580
+ export async function reopen(id, why) {
581
+ needsSync("reopening work");
582
+ if (!String(why || "").trim())
583
+ throw new PassalongError("say why: whoever takes it next reads it");
584
+ return api.reopen(await resolveId(id), why);
585
+ }
586
+
586
587
  /**
587
588
  * The gate, a person's three answers to a task. Approve: the work in review is done. Reject: it
588
589
  * goes back to Ready with `why` on it for the next agent. Release: a task an agent holds, live or
@@ -1046,10 +1047,13 @@ export async function planTasks(steps, { cwd = process.cwd() } = {}) {
1046
1047
  ].join("\n");
1047
1048
  // The summary is the step's own, said by whoever planned it: it is what a person reads about the
1048
1049
  // task, and this builds the document without guessing one (the server refuses a task without).
1050
+ // A plan is the case Draft is for: several tasks at once, which a person reads before any of
1051
+ // them runs. Stated here, by the code that knows it is building one.
1049
1052
  const meta = {
1050
1053
  title: step.title,
1051
1054
  summary: step.summary,
1052
1055
  kind: "task",
1056
+ status: "draft",
1053
1057
  target_context: step.target_context ?? repo,
1054
1058
  };
1055
1059
  const after = (step.after || []).map((j) => ids[j]);
@@ -1094,9 +1098,8 @@ export function workPrompt(t) {
1094
1098
  * hand_in land on the claim this took. `agent` is a command the prompt is appended to;
1095
1099
  * `claude -p` by default, or whatever PASSALONG_AGENT says.
1096
1100
  *
1097
- * An agent that exits without finishing stops the loop. Asking for the next task would hand the
1098
- * same one back, since a worktree holds one task at a time, and the loop would run the agent on it
1099
- * forever. The task stays claimed here, for the next run to resume or a person to release.
1101
+ * An agent that exits without finishing stops the loop, rather than piling up half-done work. The
1102
+ * task stays claimed here, for a person to see on the board and resume (take it) or release.
1100
1103
  */
1101
1104
  export async function work({
1102
1105
  cwd = process.cwd(),
@@ -1117,8 +1120,9 @@ export async function work({
1117
1120
  stdio: "inherit",
1118
1121
  env: { ...process.env, PASSALONG_TASK: t.id },
1119
1122
  });
1123
+ // Finishing is final, so a finished task is Done; "review" is a server from before that.
1120
1124
  const state = (await tasks()).find((x) => x.id === t.id)?.state;
1121
- if (state !== "review")
1125
+ if (state !== "done" && state !== "review")
1122
1126
  return {
1123
1127
  finished,
1124
1128
  stopped: "unfinished",
package/src/update.js CHANGED
@@ -15,7 +15,31 @@
15
15
  // feature would have shipped dead while looking fine in review. So the line is read from a file and
16
16
  // the file is refreshed by a process nobody waits for. What a person sees is a day old at worst,
17
17
  // which is the right age for "there is a new version".
18
- import { readConfig, writeConfig } from "./store.js";
18
+ //
19
+ // The answer is kept in its own file, never in config.json. The refresh runs beside whatever
20
+ // command started it, and config.json is read, patched and written whole: a refresh that read it
21
+ // just before `logout` or `use` wrote it, and wrote it back after, put the old accounts and the old
22
+ // default back. This file has one writer, so there is nothing to lose.
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { HOME } from "./store.js";
26
+
27
+ const FILE = join(HOME, "update.json");
28
+
29
+ function readUpdate() {
30
+ try {
31
+ return JSON.parse(readFileSync(FILE, "utf8"));
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ function writeUpdate(held) {
38
+ if (!existsSync(HOME)) mkdirSync(HOME, { recursive: true, mode: 0o700 });
39
+ const tmp = `${FILE}.${process.pid}.tmp`;
40
+ writeFileSync(tmp, `${JSON.stringify(held)}\n`, { mode: 0o600 });
41
+ renameSync(tmp, FILE);
42
+ }
19
43
 
20
44
  /** How long a cached answer lasts before a refresh is worth starting. */
21
45
  export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
@@ -70,7 +94,7 @@ export function cachedNotice({
70
94
  current,
71
95
  now = Date.now(),
72
96
  env = process.env,
73
- config = readConfig,
97
+ config = readUpdate,
74
98
  }) {
75
99
  if (env.PASSALONG_NO_UPDATE_CHECK === "1") return { notice: "", stale: false };
76
100
  const held = config();
@@ -90,7 +114,7 @@ export function cachedNotice({
90
114
  * trying again — `cachedNotice` reads an empty `update_latest` as a failure and uses the shorter
91
115
  * gap.
92
116
  */
93
- export async function refresh({ now = Date.now(), fetchImpl = fetch, save = writeConfig } = {}) {
117
+ export async function refresh({ now = Date.now(), fetchImpl = fetch, save = writeUpdate } = {}) {
94
118
  let latest = "";
95
119
  try {
96
120
  const res = await fetchImpl(REGISTRY, {