passalong 0.13.0 → 0.13.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lukman Isiaka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # passalong
2
2
 
3
- Hand finished work to another context.
3
+ Hand work to your AI coding agents, and get back what they did and how they checked it.
4
4
 
5
- Solve something non-trivial in one agent session. Run one command. Open a session in another
6
- repo, on another machine, or with a teammate, and the agent there already knows the whole story:
7
- the problem, the decisions and why, the steps, how to verify, and what went wrong along the way.
5
+ Write a task, a bug or a finished change once. The agent that takes it starts knowing what done
6
+ looks like and what was already decided, reports as it goes, and hands back a write-up with the
7
+ evidence for each thing you asked for, which you approve or send back. One agent per job, and
8
+ everyone can see who has what. Works with Claude Code, Cursor, Gemini, ChatGPT, or anything that
9
+ speaks MCP or HTTP. See [passalong.dev](https://passalong.dev).
8
10
 
9
11
  ```sh
10
12
  npm i -g passalong
package/bin/passalong CHANGED
@@ -13,7 +13,8 @@
13
13
  // passalong approve <id> the work in review is done
14
14
  // passalong reject <id> <why> not done: back to Ready, and the next agent reads why
15
15
  // passalong release <id> take work back from whoever holds it, of any kind
16
- // passalong attach <image> upload evidence and print the markdown that points at it
16
+ // passalong attach <file> upload a picture or a file and print the markdown that points at it
17
+ // passalong reply <id> <text> write to whoever holds it (--file PATH to attach); or leave a note
17
18
  // passalong pull <id|link> fetch a guide into ./.passalong/ and print it
18
19
  // passalong start <id|link> pull it and take it — one command for "I am doing this"
19
20
  // passalong inbox guides handed to you that you have not pulled yet
@@ -69,8 +70,11 @@ function flags(argv) {
69
70
  const t = argv[i];
70
71
  if (t.startsWith("--")) {
71
72
  const next = argv[i + 1];
72
- if (next === undefined || next.startsWith("-")) out[t.slice(2)] = true;
73
- else out[t.slice(2)] = argv[++i];
73
+ const key = t.slice(2);
74
+ const value = next === undefined || next.startsWith("-") ? true : argv[++i];
75
+ // A flag given twice keeps both, so `--file a.pdf --file b.csv` is two files. Nothing else
76
+ // repeats a flag, so nothing else sees an array.
77
+ out[key] = key in out && value !== true ? [out[key], value].flat() : value;
74
78
  } else out._.push(t);
75
79
  }
76
80
  return out;
@@ -386,12 +390,33 @@ const commands = {
386
390
  * a guide travels as markdown to whoever holds its link, so a file beside it does not travel.
387
391
  */
388
392
  async attach(f) {
389
- const file = f._[0] || die("usage: passalong attach <image> [--name LABEL]");
390
- const shot = await passalong.attach(file, { name: f.name || "" }).catch((e) => die(e.message));
393
+ const file = f._[0] || die("usage: passalong attach <file> [--name LABEL]");
394
+ // A picture is evidence a page draws; anything else is a file somebody downloads.
395
+ const isImage = Object.keys(passalong.SHOT_TYPES).some((x) => file.toLowerCase().endsWith(x));
396
+ const shot = await (isImage ? passalong.attach : passalong.attachFile)(file, {
397
+ name: f.name || "",
398
+ }).catch((e) => die(e.message));
391
399
  process.stdout.write(`${shot.markdown}\n`);
392
400
  console.error(`uploaded ${shot.bytes} bytes → ${shot.url}`);
393
401
  },
394
402
 
403
+ /**
404
+ * Write to whoever holds a guide, or leave a note on one nobody holds yet. The person's side of
405
+ * `ask`: an agent that stopped to wait hears this when it takes the guide again.
406
+ */
407
+ async reply(f) {
408
+ const id = f._[0] || die("usage: passalong reply <id> <message> [--file PATH ...]");
409
+ const files = [f.file].flat().filter((x) => typeof x === "string");
410
+ const said = f._.slice(1).join(" ");
411
+ if (!said && !files.length) die("say something, or attach a file: passalong reply <id> <message>");
412
+ const r = await passalong.reply(id, said, { files }).catch((e) => die(e.message));
413
+ console.error(
414
+ r?.noted
415
+ ? `${id} — nobody holds it yet, so this is a note: whoever takes it is handed it first`
416
+ : `${id} — sent. Whoever holds it reads it the next time it checks in`,
417
+ );
418
+ },
419
+
395
420
  async inbox() {
396
421
  const rows = await passalong.inbox().catch((e) => die(e.message));
397
422
  if (!rows.length) return console.error("inbox empty — nothing handed to you that you haven't pulled");
@@ -864,10 +889,16 @@ const commands = {
864
889
  input = JSON.parse(raw || "{}");
865
890
  }
866
891
  if (!api.loggedIn()) return;
892
+ // The timeout is cleared as soon as the answer is in. Left running it holds the process open
893
+ // for the full five seconds after the hook has printed, and a session start or a stop that
894
+ // waits five seconds for nothing is the hook getting in the way, which it must never do.
895
+ let timer;
867
896
  const r = await Promise.race([
868
897
  passalong.now(),
869
- new Promise((_, no) => setTimeout(() => no(new Error("timeout")), 5000)),
870
- ]);
898
+ new Promise((_, no) => {
899
+ timer = setTimeout(() => no(new Error("timeout")), 5000);
900
+ }),
901
+ ]).finally(() => clearTimeout(timer));
871
902
  if (f.hook === "session") {
872
903
  const text = nowText(r);
873
904
  if (text)
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.13.0",
4
- "description": "Hand a finished implementation from one context to another, in a form an AI agent can act on.",
3
+ "version": "0.13.1",
4
+ "description": "Hand tasks, bugs and finished work to AI coding agents, and get back a write-up with evidence.",
5
+ "mcpName": "io.github.codellyson/passalong",
5
6
  "keywords": [
6
7
  "mcp",
7
8
  "claude-code",
@@ -9,7 +10,11 @@
9
10
  "handoff",
10
11
  "knowledge-transfer",
11
12
  "cli",
12
- "markdown"
13
+ "markdown",
14
+ "mcp-server",
15
+ "coding-agents",
16
+ "task-management",
17
+ "cursor"
13
18
  ],
14
19
  "homepage": "https://passalong.dev",
15
20
  "repository": {
package/skill/SKILL.md CHANGED
@@ -98,11 +98,24 @@ pulls a guide, and steps that reproduce a defect are the one list that must neve
98
98
  remedy — an agent that follows them reproduces the bug, checks the Verification, finds it false
99
99
  because the bug is real, and reports that the guide does not work.
100
100
 
101
+ ## Say it to a person
102
+
103
+ Everything below is written for another agent: exact, dense, full of ids and paths. A person reading
104
+ their hub wants two sentences. So every guide carries a `summary:` in its frontmatter — plain
105
+ language, 400 characters at most, no ids, paths or jargon, saying what this is and whether anybody
106
+ has to do anything. It is what they see first; your document is behind it. A guide with no summary is
107
+ refused.
108
+
109
+ Write it the way you would tell a colleague in the corridor: "Webhook signatures are now checked
110
+ before anything is stored. Done and working; nothing to do." Not: "Implemented HMAC-SHA512 verify in
111
+ billing.ts per the Paystack spec, see PR 412."
112
+
101
113
  ## Guide structure
102
114
 
103
115
  ```markdown
104
116
  ---
105
117
  title: <what this accomplishes, as a verb phrase: "Add Paystack webhook verification">
118
+ summary: <one or two plain sentences for a PERSON, 400 characters at most, no ids or paths: what this is and whether anyone has to act>
106
119
  author: <git user.name, if known>
107
120
  source_context: <repo or product this came from>
108
121
  status: published
@@ -135,6 +148,7 @@ Commands to run and what they should print. Test cases. What "done" looks like.
135
148
  ```markdown
136
149
  ---
137
150
  title: <what is broken, in one line: "Undo leaves section drag handles dead">
151
+ summary: <one or two plain sentences for a PERSON: what is broken and whether anyone has to act>
138
152
  kind: bug
139
153
  severity: <s1 blocker | s2 major | s3 minor | s4 cosmetic>
140
154
  area: <which surface it is on>
package/src/api.js CHANGED
@@ -216,6 +216,10 @@ export const progress = (id, body) =>
216
216
  call(`/v1/guides/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
217
217
  export const handIn = (id, body) =>
218
218
  call(`/v1/guides/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
219
+ export const ask = (id, body) =>
220
+ call(`/v1/guides/${encodeURIComponent(id)}/ask`, { method: "POST", body });
221
+ export const reply = (id, body) =>
222
+ call(`/v1/guides/${encodeURIComponent(id)}/reply`, { method: "POST", body: { body } });
219
223
  export const pass = (id, body) =>
220
224
  call(`/v1/guides/${encodeURIComponent(id)}/pass`, { method: "POST", body });
221
225
  export const ack = (id, taken, note = "") =>
@@ -228,6 +232,36 @@ export const ack = (id, taken, note = "") =>
228
232
  * the content type as the declaration of what they are. `name` is the label, and it travels in a
229
233
  * header rather than the body for the same reason.
230
234
  */
235
+ /**
236
+ * Upload a file that is not a picture, and get back what the server made of it. The server decides
237
+ * what the bytes are and refuses what it does not keep; `type` and `name` are hints. The name travels
238
+ * percent-encoded because a header cannot hold what a filename can.
239
+ */
240
+ export async function uploadFile(bytes, type, name = "") {
241
+ const t = token();
242
+ if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
243
+ const headers = {
244
+ authorization: `Bearer ${t}`,
245
+ "content-type": type || "application/octet-stream",
246
+ };
247
+ if (name) headers["x-file-name"] = encodeURIComponent(String(name));
248
+ let res;
249
+ try {
250
+ res = await fetch(`${baseUrl()}/v1/attachments`, { method: "POST", headers, body: bytes });
251
+ } catch (err) {
252
+ throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
253
+ }
254
+ if (!res.ok) {
255
+ const body = await res.text();
256
+ let message = body;
257
+ try {
258
+ message = JSON.parse(body).message || body;
259
+ } catch {}
260
+ throw new ApiError(res.status, message || res.statusText);
261
+ }
262
+ return (await res.json()).attachment;
263
+ }
264
+
231
265
  export async function uploadShot(bytes, type, name = "") {
232
266
  const t = token();
233
267
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
package/src/capture.js CHANGED
@@ -65,6 +65,8 @@ export function taskScaffold(sentence, cwd = process.cwd()) {
65
65
  const md = template({
66
66
  kind: "task",
67
67
  title: sentence,
68
+ // The person typed this sentence, so it is theirs to say to a person as well as to name the task.
69
+ summary: String(sentence).slice(0, 400),
68
70
  author: c.author,
69
71
  source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
70
72
  target_context: c.repo,
package/src/checks.js CHANGED
@@ -76,7 +76,10 @@ function tail(out, max = OUTPUT_MAX) {
76
76
  */
77
77
  export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT_MS } = {}) {
78
78
  const cmd = String(check?.cmd ?? "").trim();
79
- if (!cmd) return { check: check.check, ran: check.ran };
79
+ // What the agent says happened, in plain words: the person's line of the table. It is the agent's
80
+ // account and a run never touches it, so it rides along on every shape this returns.
81
+ const says = check?.says ? { says: check.says } : {};
82
+ if (!cmd) return { check: check.check, ran: check.ran, ...says };
80
83
 
81
84
  const opts = {
82
85
  cwd,
@@ -99,6 +102,7 @@ export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT
99
102
  : `could not run: ${r.error?.message ?? "unknown error"}`;
100
103
  return {
101
104
  check: check.check,
105
+ ...says,
102
106
  cmd,
103
107
  exit: null,
104
108
  ok: false,
@@ -109,6 +113,7 @@ export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT
109
113
  const cutShort = Boolean(BASH) && r.status === SIGPIPE;
110
114
  return {
111
115
  check: check.check,
116
+ ...says,
112
117
  cmd,
113
118
  exit: r.status,
114
119
  ok: r.status === 0 || cutShort,
package/src/guide.js CHANGED
@@ -50,6 +50,19 @@ export const SETTABLE = ["draft", "published", "consumed"];
50
50
  * exactly as ambiguous as it was and adds a second state — a guide that exists and cannot be
51
51
  * shared — where the point was to have fewer.
52
52
  */
53
+ /**
54
+ * What a guide says to a person, in a sentence or two, and the longest that may be.
55
+ *
56
+ * Everything an agent writes is written for another agent: exact, dense, full of ids and paths. A
57
+ * person scanning their work wants "what is this, and do I need to do anything", so every guide
58
+ * carries both — the document is the agent's form, and `summary:` is the human one, shown by default
59
+ * in the hub with the document behind "Show details".
60
+ *
61
+ * Said by the author and refused when absent, like `kind`, for the same reason: a summary written
62
+ * afterwards by something that was not there is a second voice that can say what nobody claimed.
63
+ */
64
+ export const SUMMARY_MAX = 400;
65
+
53
66
  export const KINDS = ["transfer", "bug", "task"];
54
67
 
55
68
  // Body sections in the order a guide should present them. The heading text is what the
@@ -330,6 +343,9 @@ function quote(v) {
330
343
  const META_ORDER = [
331
344
  "id",
332
345
  "title",
346
+ // Right under the title: it is what a person reads first about it, so it is what the document
347
+ // opens with.
348
+ "summary",
333
349
  "kind",
334
350
  "created",
335
351
  "author",
@@ -447,6 +463,14 @@ export function validate({ meta, body }) {
447
463
  // a kind — `parseFrontmatter` seeds it — so what reaches this without one was built field by
448
464
  // field in code, and code is exactly what should have to say what it is making.
449
465
  const kind = meta.kind;
466
+ const said = String(meta.summary ?? "").trim();
467
+ if (!said)
468
+ errors.push(
469
+ "say it to a person: add `summary:` — one or two plain sentences, no ids or paths, saying what " +
470
+ `this is and whether anybody needs to act (${SUMMARY_MAX} characters at most)`,
471
+ );
472
+ else if (said.length > SUMMARY_MAX)
473
+ errors.push(`summary is ${said.length} characters; ${SUMMARY_MAX} at most`);
450
474
  if (!kind) errors.push(`say what this is: kind must be one of ${KINDS.join(", ")}`);
451
475
  else if (!KINDS.includes(kind)) errors.push(`kind must be one of ${KINDS.join(", ")}`);
452
476
  const have = sections(body);
@@ -489,6 +513,10 @@ export function stamp(guide, defaults = {}) {
489
513
  */
490
514
  export function bugGuide({
491
515
  title = "",
516
+ // What a person reads about it. A bug's own title is that when nothing else was said, and it is
517
+ // the author's own words, stated here by the caller that knows it is building a bug — not a
518
+ // default for something an author left off.
519
+ summary = "",
492
520
  problem = "",
493
521
  reproduce = "",
494
522
  verification = "",
@@ -544,6 +572,7 @@ export function bugGuide({
544
572
 
545
573
  const meta = {
546
574
  title,
575
+ summary: String(summary || "").trim() || title,
547
576
  kind: "bug",
548
577
  status,
549
578
  tags: ["bug", environment, area].filter(Boolean),
@@ -584,6 +613,7 @@ export function template(meta = {}) {
584
613
  return serialize({
585
614
  meta: {
586
615
  title: "",
616
+ summary: "",
587
617
  author: "",
588
618
  source_context: "",
589
619
  status: "draft",
@@ -620,6 +650,7 @@ function bugTemplate(meta = {}) {
620
650
  return serialize({
621
651
  meta: {
622
652
  title: "",
653
+ summary: "",
623
654
  kind: "bug",
624
655
  author: "",
625
656
  source_context: "",
@@ -660,6 +691,7 @@ function taskTemplate(meta = {}) {
660
691
  return serialize({
661
692
  meta: {
662
693
  title: "",
694
+ summary: "",
663
695
  kind: "task",
664
696
  author: "",
665
697
  source_context: "",
package/src/hooks.js CHANGED
@@ -30,6 +30,13 @@ export function nowText({ held, waiting = {} }) {
30
30
  `Passalong: You hold ${held.id} (${held.kind}): ${held.title || held.id}.`,
31
31
  ];
32
32
  if (held.note) lines.push(`Last note: "${held.note}".`);
33
+ // The one thing here that is new information rather than a reminder: the person has written, and
34
+ // nothing else in the session will say so until the agent next checks in.
35
+ if (held.replies)
36
+ 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.`,
39
+ );
33
40
  if (held.state === "stalled")
34
41
  lines.push("It is marked stalled: nobody has heard from its agent in 30 minutes.");
35
42
  lines.push(
@@ -55,7 +62,9 @@ export function nowText({ held, waiting = {} }) {
55
62
  * would leave it no way to end.
56
63
  */
57
64
  export function stopVerdict({ held, input = {} }) {
58
- if (!held || input.stop_hook_active) return null;
65
+ // An agent that asked a question was told to stop and wait, so it must be let: blocking it would
66
+ // 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;
59
68
  return {
60
69
  decision: "block",
61
70
  reason:
package/src/mcp.js CHANGED
@@ -226,6 +226,21 @@ export function nextNote({ next = [], say = "" } = {}, id = "") {
226
226
  return lines.length ? `<!-- passalong: next:\n${lines.join("\n")}\n-->` : "";
227
227
  }
228
228
 
229
+ /**
230
+ * What the person said to this agent since it last heard, in front of everything else: a reply is
231
+ * the thing the agent stopped for, and burying it under the guide it already knows is how it gets
232
+ * missed. See claims.deliver() in apps/api/src/claims.ts.
233
+ */
234
+ export function repliesNote(replies = []) {
235
+ if (!Array.isArray(replies) || !replies.length) return "";
236
+ const lines = replies.map((r) => ` - ${String(r.body).replace(/\n/g, "\n ")}`);
237
+ const files = replies.some((r) => String(r.body).includes("/v1/attachments/"))
238
+ ? "\n Attached files are private: fetch one with your passalong token, e.g. " +
239
+ 'curl -H "authorization: Bearer $PASSALONG_TOKEN (or the token in ~/.passalong/config.json)" -o <name> <url>.'
240
+ : "";
241
+ return `<!-- passalong: the person replied:\n${lines.join("\n")}${files}\n-->`;
242
+ }
243
+
229
244
  /** A refusal that carries the server's own next move, when it sent one. */
230
245
  const failWith = (err) => {
231
246
  const said = nextNote(err.body || {});
@@ -252,7 +267,9 @@ export function buildServer() {
252
267
  "returns it; nobody else can take it here while you hold it, and its sender sees you are " +
253
268
  "on it. progress, with a one-line note at each milestone — 30 minutes of silence marks it " +
254
269
  "stalled. hand_in when done here. pass, with the reason, when it is not yours or you are " +
255
- "stuck. Every answer ends with `next`: what to call now. Follow it, and when it says to " +
270
+ "stuck. ask, with a question, when you need an answer from the person: you keep what you " +
271
+ "hold, stop and wait, and take it again once they have replied. Every answer ends with " +
272
+ "`next`: what to call now. Follow it, and when it says to " +
256
273
  "stop, stop. OPEN A GUIDE YOU MEAN TO ACT ON WITH take, NOT get_guide: get_guide only " +
257
274
  "reads, and the sender learns nothing. If take says somebody else has it, tell the user " +
258
275
  "instead of doing the work twice.\n" +
@@ -261,6 +278,11 @@ export function buildServer() {
261
278
  "Collect it as you work rather than writing it from memory at the end. hand_in without " +
262
279
  "it is refused, because the write-up and the verdict are both your word for your own " +
263
280
  "work and evidence is the part the person reviewing it can check.\n" +
281
+ "EVERY GUIDE SAYS WHAT IT IS TO A PERSON. The document is for agents: exact, dense, full of " +
282
+ "ids and paths. Put `summary:` in its frontmatter — one or two plain sentences, 400 " +
283
+ "characters at most, no ids, paths or jargon, saying what this is and whether anybody has " +
284
+ "to act. It is what the person sees first in their hub; the document is behind it. A guide " +
285
+ "with no summary is refused.\n" +
264
286
  "SAY WHICH KIND IT IS. There is no default: a guide with no `kind:` line is refused, and so " +
265
287
  "is a spelling that is not one of the three. " +
266
288
  `kind: task is work nobody has done yet. Sections: ${TASK_SECTIONS.join(", ")}. ` +
@@ -331,8 +353,13 @@ export function buildServer() {
331
353
  const note = nextNote({ next, say }, id);
332
354
  // The note is what the agent acts on and it is prose, so it stays in the text half only. The
333
355
  // structured half is the answer itself, which is what `outputSchema` below describes.
356
+ // A reply goes first, in prose: it is what the agent stopped for, and inside the JSON it reads
357
+ // as one more field.
358
+ const said = repliesNote(rest.replies);
334
359
  return {
335
- ...text(`${JSON.stringify(rest, null, 2)}${note ? `\n${note}` : ""}`),
360
+ ...text(
361
+ `${said ? `${said}\n` : ""}${JSON.stringify(rest, null, 2)}${note ? `\n${note}` : ""}`,
362
+ ),
336
363
  structuredContent: rest,
337
364
  };
338
365
  };
@@ -359,8 +386,9 @@ export function buildServer() {
359
386
  `<!-- passalong: ${r.guide.id} is yours` +
360
387
  `${r.guide.resumed ? " (you already held it — carry on from where it was left)" : ""}; ` +
361
388
  `written to ${r.path}. -->`;
389
+ const said = repliesNote(r.replies);
362
390
  return text(
363
- `${from}${leadFor(meta)}${r.guide.markdown}${siblings}${context ? `\n\n${context}` : ""}` +
391
+ `${said ? `${said}\n\n` : ""}${from}${leadFor(meta)}${r.guide.markdown}${siblings}${context ? `\n\n${context}` : ""}` +
364
392
  `\n\n${held}\n${nextNote(r, r.guide.id)}\n${passalong.followUpNote(meta)}`,
365
393
  );
366
394
  } catch (err) {
@@ -428,6 +456,14 @@ export function buildServer() {
428
456
  }
429
457
  }
430
458
 
459
+ async function doAsk({ id, question, cwd }) {
460
+ try {
461
+ return answer(await passalong.ask(id, question, { cwd: cwd || process.cwd() }), id);
462
+ } catch (err) {
463
+ return failWith(err);
464
+ }
465
+ }
466
+
431
467
  async function doPass({ id, why, cwd }) {
432
468
  try {
433
469
  return answer(await passalong.pass(id, why, { cwd: cwd || process.cwd() }), id);
@@ -623,6 +659,12 @@ export function buildServer() {
623
659
  .array(
624
660
  z.object({
625
661
  title: z.string(),
662
+ summary: z
663
+ .string()
664
+ .describe(
665
+ "one or two plain sentences for a PERSON, no ids or paths: what this task is and " +
666
+ "why it matters. The goal below is for the agent; this is what a person reads",
667
+ ),
626
668
  goal: z.string().describe("what is true when this step is done"),
627
669
  acceptance: z.string().describe("checks a person can run, one per line"),
628
670
  context: z.string().optional(),
@@ -694,7 +736,14 @@ export function buildServer() {
694
736
  "without one marks it stalled. If the answer says you no longer hold it, stop.",
695
737
  inputSchema: {
696
738
  id: z.string().describe("the id of what you hold"),
697
- note: z.string().optional().describe('one line, e.g. "migrating schema, 2 of 5 steps"'),
739
+ note: z
740
+ .string()
741
+ .optional()
742
+ .describe(
743
+ 'one plain sentence in the first person, as you would tell the person: "Reading ' +
744
+ 'the settings page to find where the toggle goes." Start it with BLOCKED: when ' +
745
+ "you are stopped and need an answer",
746
+ ),
698
747
  cwd: z
699
748
  .string()
700
749
  .optional()
@@ -745,6 +794,15 @@ export function buildServer() {
745
794
  "the line this answers, in the guide's own words: an Acceptance line on a task, " +
746
795
  "a Verification line on a handoff or a bug",
747
796
  ),
797
+ says: z
798
+ .string()
799
+ .optional()
800
+ .describe(
801
+ 'what happened, in one plain sentence for a PERSON: "Swagger lists all six ' +
802
+ 'endpoints, each with request and response schemas." It is the row they read ' +
803
+ "in the review table; the evidence stays folded behind it. Strongly " +
804
+ "recommended: a check without one shows only the line it answers",
805
+ ),
748
806
  ran: z
749
807
  .string()
750
808
  .optional()
@@ -768,7 +826,13 @@ export function buildServer() {
768
826
  "running it and is refused",
769
827
  ),
770
828
  ok: z.boolean().optional().describe("handoff or bug: did its Verification hold"),
771
- note: z.string().optional().describe("one line; required when ok is false"),
829
+ note: z
830
+ .string()
831
+ .describe(
832
+ "one plain sentence, in the first person, saying what you did and how it went: " +
833
+ '"Added the toggle to Settings; it survives a reload." It is the first thing the ' +
834
+ "person reads, and the evidence is behind it. Say what went wrong when ok is false",
835
+ ),
772
836
  writeup: z
773
837
  .string()
774
838
  .optional()
@@ -805,6 +869,37 @@ export function buildServer() {
805
869
  async (args) => doHandIn(args),
806
870
  );
807
871
 
872
+ server.registerTool(
873
+ "ask",
874
+ {
875
+ title: "Ask the person",
876
+ outputSchema: openObject({ id: z.string(), lease_until: z.string() }),
877
+ annotations: ADDS,
878
+ description:
879
+ "You need the person's answer before you can go on. Ask one clear question, with the " +
880
+ "context it needs to be answered without opening your terminal. You keep what you hold " +
881
+ "and nobody else can take it. Then STOP and tell the user you are waiting: do not carry " +
882
+ "on guessing. When they have replied, call take with this id and their reply comes back " +
883
+ "with it. Use `pass` instead when the work is not yours; a progress note starting " +
884
+ "BLOCKED: is the older way of saying this, and is not heard as a question.",
885
+ inputSchema: {
886
+ id: z.string().describe("passalong id"),
887
+ question: z
888
+ .string()
889
+ .describe(
890
+ "one clear question in the first person, a few sentences at most, plain text: " +
891
+ '"Should the dark-mode toggle live in Settings or the header? The header is ' +
892
+ 'crowded on mobile." What you were about to do, if they just say yes, helps',
893
+ ),
894
+ cwd: z
895
+ .string()
896
+ .optional()
897
+ .describe("the worktree you are working in; default is the server's cwd"),
898
+ },
899
+ },
900
+ async (args) => doAsk(args),
901
+ );
902
+
808
903
  server.registerTool(
809
904
  "pass",
810
905
  {
@@ -930,6 +1025,72 @@ export function buildServer() {
930
1025
  },
931
1026
  );
932
1027
 
1028
+ server.registerTool(
1029
+ "attach_file",
1030
+ {
1031
+ title: "Attach a file",
1032
+ annotations: { ...ADDS, openWorldHint: true },
1033
+ description:
1034
+ "Upload a file that is not a picture — a log, a PDF, a CSV, a zip, up to 10MB — and get " +
1035
+ "back the markdown line that points at it. For a guide body or a bug report: put the " +
1036
+ "line there and publishing claims the file. It is private, so only people who can read " +
1037
+ "that guide can download it; for something a reviewer must SEE, use attach_screenshot. " +
1038
+ "Pictures belong to attach_screenshot, not here.",
1039
+ inputSchema: {
1040
+ file: z.string().describe("path to the file on this machine"),
1041
+ name: z.string().default("").describe("label for the file; defaults to its filename"),
1042
+ },
1043
+ },
1044
+ async ({ file, name }) => {
1045
+ try {
1046
+ const got = await passalong.attachFile(file, { name: name || "" });
1047
+ return text(
1048
+ `${JSON.stringify({ id: got.id, url: got.url, bytes: got.bytes }, null, 2)}\n\n` +
1049
+ `Put this in the guide body:\n${got.markdown}`,
1050
+ );
1051
+ } catch (err) {
1052
+ return fail(err);
1053
+ }
1054
+ },
1055
+ );
1056
+
1057
+ server.registerTool(
1058
+ "reply",
1059
+ {
1060
+ title: "Write to the agent",
1061
+ annotations: ADDS,
1062
+ description:
1063
+ "A person's tool, for someone driving their work from an assistant: write to whoever " +
1064
+ "holds a guide — an answer to a question it asked, or something you thought of since — " +
1065
+ "or, when nobody holds it yet and it is yours, leave a note for whoever takes it. Plain " +
1066
+ "text, 1000 characters. `files` are paths on this machine: a picture is drawn in the " +
1067
+ "thread and anything else is a file to download. Only its author, whoever it is " +
1068
+ "assigned to, or whoever holds it can write; the agent reads it the next time it " +
1069
+ "checks in. Agents answering their own questions do not use this: they use ask and take.",
1070
+ inputSchema: {
1071
+ id: z.string().describe("passalong id"),
1072
+ text: z.string().default("").describe("what to say; may be empty when attaching a file"),
1073
+ files: z
1074
+ .array(z.string())
1075
+ .max(3)
1076
+ .default([])
1077
+ .describe("paths to up to three files or pictures to attach"),
1078
+ },
1079
+ },
1080
+ async ({ id, text: said, files }) => {
1081
+ try {
1082
+ const r = await passalong.reply(id, said, { files });
1083
+ return text(
1084
+ r?.noted
1085
+ ? `Nobody holds ${id} yet, so this is a note: whoever takes it is handed it first.`
1086
+ : `Sent to whoever holds ${id}. It reads this the next time it checks in.`,
1087
+ );
1088
+ } catch (err) {
1089
+ return failWith(err);
1090
+ }
1091
+ },
1092
+ );
1093
+
933
1094
  server.registerTool(
934
1095
  "publish_guide",
935
1096
  {
@@ -1036,6 +1197,13 @@ export function buildServer() {
1036
1197
  "they go under Problem, where a reader looks first",
1037
1198
  ),
1038
1199
  title: z.string().describe("what is broken, in one line"),
1200
+ summary: z
1201
+ .string()
1202
+ .optional()
1203
+ .describe(
1204
+ "one or two plain sentences for a PERSON, no ids or paths: what is broken and " +
1205
+ "whether anyone needs to act. Defaults to the title",
1206
+ ),
1039
1207
  problem: z
1040
1208
  .string()
1041
1209
  .describe(
package/src/passalong.js CHANGED
@@ -481,6 +481,9 @@ export async function handIn(
481
481
  checks: checks.map((c) => ({
482
482
  check: c.check,
483
483
  ran: c.ran,
484
+ // What the agent says happened, in plain words, for the person's table. Left out when
485
+ // not said: the row then shows the line that was asked and keeps its run folded.
486
+ ...(c.says ? { says: c.says } : {}),
484
487
  // Only present on a check that was run. The server keeps them apart the same way.
485
488
  ...(verified(c) ? { cmd: c.cmd, exit: c.exit, ok: c.ok } : {}),
486
489
  })),
@@ -524,6 +527,23 @@ export async function pass(id, why, { cwd = process.cwd() } = {}) {
524
527
  });
525
528
  }
526
529
 
530
+ /**
531
+ * Ask the person a question and wait, keeping what you hold. Not `pass`, which gives the work back,
532
+ * and not `progress`, which says carry on: the answer to this one is "stop, and take it again when
533
+ * they have replied", and the reply comes back with that `take`.
534
+ */
535
+ export async function ask(id, question, { cwd = process.cwd() } = {}) {
536
+ needsSync("asking");
537
+ if (!String(question || "").trim())
538
+ throw new PassalongError("say what you need to know, so the person can answer it");
539
+ const fence = fenceFor(id, cwd);
540
+ return api.ask(id, {
541
+ agent: agent(cwd).agent,
542
+ question,
543
+ ...(fence === undefined ? {} : { fence }),
544
+ });
545
+ }
546
+
527
547
  /**
528
548
  * What this worktree's agent holds right now, and what is waiting for it: what the session-start
529
549
  * and stop hooks read (src/hooks.js). `held` is null when it holds nothing. Counts are best effort
@@ -555,7 +575,7 @@ export async function now({ cwd = process.cwd() } = {}) {
555
575
  x.mine &&
556
576
  (x.state === "review" ||
557
577
  x.state === "stalled" ||
558
- (x.state === "claimed" && /^BLOCKED:/i.test(x.claim?.note || ""))),
578
+ (x.state === "claimed" && (x.claim?.asking || /^BLOCKED:/i.test(x.claim?.note || "")))),
559
579
  ).length;
560
580
  const handed = h.status === "fulfilled" ? h.value.handed_in.length : 0;
561
581
  return { held, waiting, needs: review + handed + waiting.inbox, agent: who.agent };
@@ -731,6 +751,64 @@ export async function attach(file, { name = "" } = {}) {
731
751
  return { ...shot, markdown: `![${name || basename(file)}](${shot.url})` };
732
752
  }
733
753
 
754
+ /**
755
+ * What a file's extension suggests it is. A hint only: the server reads the bytes and decides, and
756
+ * refuses what it does not keep. It exists so a `.csv` is filed as CSV and not as generic text.
757
+ */
758
+ const FILE_HINTS = {
759
+ ".pdf": "application/pdf",
760
+ ".zip": "application/zip",
761
+ ".txt": "text/plain",
762
+ ".log": "text/plain",
763
+ ".csv": "text/csv",
764
+ ".json": "application/json",
765
+ ".md": "text/markdown",
766
+ };
767
+
768
+ /** The most the server keeps of one file. Mirrors ATTACH_MAX in apps/api/src/attachments.ts. */
769
+ const FILE_MAX = 10 * 1024 * 1024;
770
+
771
+ /**
772
+ * Attach a file that is not a picture — a log, a PDF, a CSV, a zip — and return the markdown line that
773
+ * points at it. For a guide body or a bug report, where an agent has the file and nothing else to
774
+ * show it with. It is private: whoever can read the guide can download it, and nobody else can, so
775
+ * it is not evidence on a page that travels by link.
776
+ */
777
+ export async function attachFile(file, { name = "" } = {}) {
778
+ if (!api.loggedIn())
779
+ throw new PassalongError("attaching a file needs sync — run `passalong login` first");
780
+ if (!existsSync(file)) throw new PassalongError(`no file at ${file}`);
781
+ const bytes = readFileSync(file);
782
+ if (!bytes.length) throw new PassalongError(`${file} is empty`);
783
+ if (bytes.length > FILE_MAX)
784
+ throw new PassalongError(
785
+ `${file} is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 10MB — send a link instead`,
786
+ );
787
+ const ext = file.includes(".") ? file.slice(file.lastIndexOf(".")).toLowerCase() : "";
788
+ const label = (name || basename(file)).replace(/[[\]()]/g, "");
789
+ const attachment = await api.uploadFile(bytes, FILE_HINTS[ext] || "", name || basename(file));
790
+ return { ...attachment, markdown: `[${label}](${attachment.url})` };
791
+ }
792
+
793
+ /**
794
+ * Write to whoever holds a guide, or leave a note on one nobody holds yet: an answer to a question,
795
+ * or something thought of since. `files` are paths — a picture is uploaded as one and drawn in the
796
+ * thread, anything else as a file to download — and each becomes a line of the message. The server
797
+ * decides who may write and whether anyone is listening, and says so in words.
798
+ */
799
+ export async function reply(ref, text, { files = [] } = {}) {
800
+ needsSync("replying");
801
+ const lines = [];
802
+ for (const file of files) {
803
+ const ext = file.includes(".") ? file.slice(file.lastIndexOf(".")).toLowerCase() : "";
804
+ const got = SHOT_TYPES[ext] ? await attach(file) : await attachFile(file);
805
+ lines.push(got.markdown);
806
+ }
807
+ const body = [String(text || "").trim(), ...lines].filter(Boolean).join("\n");
808
+ if (!body) throw new PassalongError("say something, or attach a file");
809
+ return api.reply(await resolveId(ref), body);
810
+ }
811
+
734
812
  /**
735
813
  * The line a guide arrives with, saying what a follow-up is and how to write one.
736
814
  *
@@ -1044,7 +1122,14 @@ export async function planTasks(steps, { cwd = process.cwd() } = {}) {
1044
1122
  ...section("Acceptance", step.acceptance),
1045
1123
  ...section("Out of scope", step.out_of_scope),
1046
1124
  ].join("\n");
1047
- const meta = { title: step.title, kind: "task", target_context: step.target_context ?? repo };
1125
+ // The summary is the step's own, said by whoever planned it: it is what a person reads about the
1126
+ // task, and this builds the document without guessing one (the server refuses a task without).
1127
+ const meta = {
1128
+ title: step.title,
1129
+ summary: step.summary,
1130
+ kind: "task",
1131
+ target_context: step.target_context ?? repo,
1132
+ };
1048
1133
  const after = (step.after || []).map((j) => ids[j]);
1049
1134
  if (after.length) meta.blocked_by = after;
1050
1135
  ids.push((await share(serialize({ meta, body }), { cwd })).guide.meta.id);