passalong 0.13.0 → 0.14.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/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,15 +1,18 @@
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, Codex, ChatGPT, Claude, 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
11
13
  passalong setup # installs the Claude Code capture skill + registers the MCP server
12
14
  passalong login # optional: sync guides across machines and get share links
15
+ codex mcp add passalong -- passalong mcp # Codex: the same MCP server
13
16
  ```
14
17
 
15
18
  ## The loop
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
@@ -38,7 +39,7 @@
38
39
  // passalong archive <id> off your board and out of the free tier's count (was `done`)
39
40
  // passalong rm <id> delete a guide locally and from sync
40
41
  // passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
41
- // passalong login [token] sign in with your email and password, or attach this machine with a token
42
+ // passalong login [token] sign in from the browser, or attach this machine with a token
42
43
  // passalong me [--handle H] [--name N] [--email E] who you are to teammates
43
44
  // passalong team current team and its members
44
45
  // passalong team create <name> start a team (you become its owner)
@@ -50,7 +51,7 @@
50
51
  // passalong mcp run the MCP server over stdio
51
52
  // passalong version print the installed version (also -v, --version)
52
53
  import { execFileSync, spawn, spawnSync } from "node:child_process";
53
- import { createHash } from "node:crypto";
54
+ import { createHash, randomBytes } from "node:crypto";
54
55
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
55
56
  import { homedir, hostname } from "node:os";
56
57
  import { dirname, join, resolve } from "node:path";
@@ -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;
@@ -90,6 +94,86 @@ const tty = process.stdout.isTTY && process.stdin.isTTY;
90
94
  * Written against readline rather than a dependency because this package has none beyond the MCP
91
95
  * SDK and zod, and asking for a line of text is not where that changes.
92
96
  */
97
+ /** Open a page in the default browser, and say nothing if there is none to open. */
98
+ function openPage(url) {
99
+ const [cmd, args] =
100
+ process.platform === "darwin"
101
+ ? ["open", [url]]
102
+ : process.platform === "win32"
103
+ ? ["cmd", ["/c", "start", "", url]]
104
+ : ["xdg-open", [url]];
105
+ try {
106
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).on("error", () => {}).unref();
107
+ } catch {}
108
+ }
109
+
110
+ /**
111
+ * Sign in by approving in the browser: show a code, wait, and be handed a token. No password is
112
+ * typed here and no token is pasted between windows. Needs no terminal to ask on, so an agent can
113
+ * run it and tell the person which page to open.
114
+ */
115
+ async function browserLogin() {
116
+ const verifier = randomBytes(32).toString("hex");
117
+ const challenge = createHash("sha256").update(verifier).digest("hex");
118
+ const start = await api.deviceStart(challenge, `${hostname()} cli`).catch((e) => die(e.message));
119
+ console.error(`Open ${start.url}`);
120
+ console.error(`and check the page shows the code ${start.code}, then press Approve.`);
121
+ if (tty) openPage(start.url);
122
+ console.error("Waiting for you to approve it here… (Ctrl-C to stop)");
123
+ const until = Date.now() + start.expires_in * 1000;
124
+ while (Date.now() < until) {
125
+ await new Promise((r) => setTimeout(r, start.interval * 1000));
126
+ const got = await api.devicePoll(start.id, verifier).catch((e) => die(e.message));
127
+ if (!got) continue;
128
+ store.writeConfig({ token: got.token, api: api.baseUrl() });
129
+ console.error(`signed in (account ${got.account}) on ${api.baseUrl()}. Sync is on.`);
130
+ console.error("Attach other machines with:");
131
+ console.error(` passalong login ${got.token}`);
132
+ console.error("(keep it private — it is a credential, not a password)");
133
+ console.error("Give teammates a name to address you by: passalong me --handle <you>");
134
+ return;
135
+ }
136
+ die("nobody approved it in time. Run `passalong login` again.");
137
+ }
138
+
139
+ /** The old way, kept for anyone who asks for it with `--email`: an email and a password, typed here. */
140
+ async function passwordLogin(f) {
141
+ // An account you cannot sign in to is one that dies with this file. This used to mint one
142
+ // silently and then suggest adding a password afterwards; it asks up front instead.
143
+ if (!tty)
144
+ die(
145
+ "no terminal to ask on. Sign in at " +
146
+ `${api.baseUrl()}/hub, then attach this machine with \`passalong login <token>\``,
147
+ );
148
+
149
+ const email = f.email || (await ask("Email: "));
150
+ if (!email) die("an email is needed to sign in");
151
+ const password = await ask("Password: ", { hidden: true });
152
+ if (!password) die("a password is needed to sign in");
153
+
154
+ const label = `${hostname()} cli`;
155
+ let who = await api.signIn(email, password, { label }).catch((e) => e);
156
+ if (who instanceof Error) {
157
+ // 401 is "no such account, or the wrong password", said the same way on purpose so the API
158
+ // does not confirm which addresses exist. The CLI has to ask rather than guess, because
159
+ // creating an account for a mistyped address is worse than being told to try again.
160
+ if (who.status !== 401) die(who.message);
161
+ const yes = (await ask(`No account signed in as ${email}. Create one? [y/N] `)).toLowerCase();
162
+ if (yes !== "y" && yes !== "yes")
163
+ die("not signed in. If the password was wrong, try again, or reset it at " + `${api.baseUrl()}/hub`);
164
+ const again = await ask("Choose a password: ", { hidden: true });
165
+ if (again !== password) die("those did not match");
166
+ who = await api.signIn(email, password, { make: true, label }).catch((e) => die(e.message));
167
+ }
168
+
169
+ store.writeConfig({ token: who.token, api: api.baseUrl() });
170
+ console.error(`signed in as ${email} (account ${who.account}) on ${api.baseUrl()}. Sync is on.`);
171
+ console.error("Attach other machines with:");
172
+ console.error(` passalong login ${who.token}`);
173
+ console.error("(keep it private — it is a credential, not a password)");
174
+ console.error("Give teammates a name to address you by: passalong me --handle <you>");
175
+ }
176
+
93
177
  async function ask(question, { hidden = false } = {}) {
94
178
  const { createInterface } = await import("node:readline");
95
179
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
@@ -386,12 +470,33 @@ const commands = {
386
470
  * a guide travels as markdown to whoever holds its link, so a file beside it does not travel.
387
471
  */
388
472
  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));
473
+ const file = f._[0] || die("usage: passalong attach <file> [--name LABEL]");
474
+ // A picture is evidence a page draws; anything else is a file somebody downloads.
475
+ const isImage = Object.keys(passalong.SHOT_TYPES).some((x) => file.toLowerCase().endsWith(x));
476
+ const shot = await (isImage ? passalong.attach : passalong.attachFile)(file, {
477
+ name: f.name || "",
478
+ }).catch((e) => die(e.message));
391
479
  process.stdout.write(`${shot.markdown}\n`);
392
480
  console.error(`uploaded ${shot.bytes} bytes → ${shot.url}`);
393
481
  },
394
482
 
483
+ /**
484
+ * Write to whoever holds a guide, or leave a note on one nobody holds yet. The person's side of
485
+ * `ask`: an agent that stopped to wait hears this when it takes the guide again.
486
+ */
487
+ async reply(f) {
488
+ const id = f._[0] || die("usage: passalong reply <id> <message> [--file PATH ...]");
489
+ const files = [f.file].flat().filter((x) => typeof x === "string");
490
+ const said = f._.slice(1).join(" ");
491
+ if (!said && !files.length) die("say something, or attach a file: passalong reply <id> <message>");
492
+ const r = await passalong.reply(id, said, { files }).catch((e) => die(e.message));
493
+ console.error(
494
+ r?.noted
495
+ ? `${id} — nobody holds it yet, so this is a note: whoever takes it is handed it first`
496
+ : `${id} — sent. Whoever holds it reads it the next time it checks in`,
497
+ );
498
+ },
499
+
395
500
  async inbox() {
396
501
  const rows = await passalong.inbox().catch((e) => die(e.message));
397
502
  if (!rows.length) return console.error("inbox empty — nothing handed to you that you haven't pulled");
@@ -673,40 +778,11 @@ const commands = {
673
778
  return;
674
779
  }
675
780
  }
676
- // An account you cannot sign in to is one that dies with this file. This used to mint one
677
- // silently and then suggest adding a password afterwards; it asks up front instead.
678
- if (!tty)
679
- die(
680
- "no terminal to ask on. Sign in at " +
681
- `${api.baseUrl()}/hub, then attach this machine with \`passalong login <token>\``,
682
- );
683
-
684
- const email = f.email || (await ask("Email: "));
685
- if (!email) die("an email is needed to sign in");
686
- const password = await ask("Password: ", { hidden: true });
687
- if (!password) die("a password is needed to sign in");
688
-
689
- const label = `${hostname()} cli`;
690
- let who = await api.signIn(email, password, { label }).catch((e) => e);
691
- if (who instanceof Error) {
692
- // 401 is "no such account, or the wrong password", said the same way on purpose so the API
693
- // does not confirm which addresses exist. The CLI has to ask rather than guess, because
694
- // creating an account for a mistyped address is worse than being told to try again.
695
- if (who.status !== 401) die(who.message);
696
- const yes = (await ask(`No account signed in as ${email}. Create one? [y/N] `)).toLowerCase();
697
- if (yes !== "y" && yes !== "yes")
698
- die("not signed in. If the password was wrong, try again, or reset it at " + `${api.baseUrl()}/hub`);
699
- const again = await ask("Choose a password: ", { hidden: true });
700
- if (again !== password) die("those did not match");
701
- who = await api.signIn(email, password, { make: true, label }).catch((e) => die(e.message));
702
- }
703
-
704
- store.writeConfig({ token: who.token, api: api.baseUrl() });
705
- console.error(`signed in as ${email} (account ${who.account}) on ${api.baseUrl()}. Sync is on.`);
706
- console.error("Attach other machines with:");
707
- console.error(` passalong login ${who.token}`);
708
- console.error("(keep it private — it is a credential, not a password)");
709
- console.error("Give teammates a name to address you by: passalong me --handle <you>");
781
+ // Passwords are for someone who asks: `--email` keeps the prompts. Everyone else is sent to the
782
+ // browser, which works with no terminal at all — an agent can start it, and the person it is
783
+ // working for clicks once.
784
+ if (f.email || f.password) return passwordLogin(f);
785
+ return browserLogin();
710
786
  },
711
787
 
712
788
  async me(f) {
@@ -864,10 +940,16 @@ const commands = {
864
940
  input = JSON.parse(raw || "{}");
865
941
  }
866
942
  if (!api.loggedIn()) return;
943
+ // The timeout is cleared as soon as the answer is in. Left running it holds the process open
944
+ // for the full five seconds after the hook has printed, and a session start or a stop that
945
+ // waits five seconds for nothing is the hook getting in the way, which it must never do.
946
+ let timer;
867
947
  const r = await Promise.race([
868
948
  passalong.now(),
869
- new Promise((_, no) => setTimeout(() => no(new Error("timeout")), 5000)),
870
- ]);
949
+ new Promise((_, no) => {
950
+ timer = setTimeout(() => no(new Error("timeout")), 5000);
951
+ }),
952
+ ]).finally(() => clearTimeout(timer));
871
953
  if (f.hook === "session") {
872
954
  const text = nowText(r);
873
955
  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.14.0",
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
+ "codex"
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
@@ -126,6 +126,32 @@ export async function signIn(email, password, { make = false, label = "" } = {})
126
126
  * still live and still right for the invite page, which mints before it claims, and because
127
127
  * removing a published export is a breaking change for anyone who imported it.
128
128
  */
129
+ /**
130
+ * Sign this machine in from the browser. `challenge` is the sha256, in hex, of a secret kept here;
131
+ * what comes back is a short code to show, the page to open, and how often to ask whether it was
132
+ * approved. Nothing in the answer is a credential.
133
+ */
134
+ export const deviceStart = (challenge, label) =>
135
+ call("/v1/device/start", { method: "POST", auth: false, body: { challenge, label } });
136
+
137
+ /** `null` while nobody has said yes; the account and its token once somebody has. */
138
+ export async function devicePoll(id, verifier) {
139
+ let res;
140
+ try {
141
+ res = await fetch(`${baseUrl()}/v1/device/poll`, {
142
+ method: "POST",
143
+ headers: { ...CLIENT, "content-type": "application/json" },
144
+ body: JSON.stringify({ id, verifier }),
145
+ });
146
+ } catch (err) {
147
+ throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
148
+ }
149
+ if (res.status === 202) return null;
150
+ const said = await res.json().catch(() => ({}));
151
+ if (!res.ok) throw new ApiError(res.status, said.message || res.statusText);
152
+ return said;
153
+ }
154
+
129
155
  export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
130
156
  export const me = () => call("/v1/me");
131
157
  export const updateMe = (patch) => call("/v1/me", { method: "PATCH", body: patch });
@@ -216,6 +242,10 @@ export const progress = (id, body) =>
216
242
  call(`/v1/guides/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
217
243
  export const handIn = (id, body) =>
218
244
  call(`/v1/guides/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
245
+ export const ask = (id, body) =>
246
+ call(`/v1/guides/${encodeURIComponent(id)}/ask`, { method: "POST", body });
247
+ export const reply = (id, body) =>
248
+ call(`/v1/guides/${encodeURIComponent(id)}/reply`, { method: "POST", body: { body } });
219
249
  export const pass = (id, body) =>
220
250
  call(`/v1/guides/${encodeURIComponent(id)}/pass`, { method: "POST", body });
221
251
  export const ack = (id, taken, note = "") =>
@@ -228,6 +258,36 @@ export const ack = (id, taken, note = "") =>
228
258
  * the content type as the declaration of what they are. `name` is the label, and it travels in a
229
259
  * header rather than the body for the same reason.
230
260
  */
261
+ /**
262
+ * Upload a file that is not a picture, and get back what the server made of it. The server decides
263
+ * what the bytes are and refuses what it does not keep; `type` and `name` are hints. The name travels
264
+ * percent-encoded because a header cannot hold what a filename can.
265
+ */
266
+ export async function uploadFile(bytes, type, name = "") {
267
+ const t = token();
268
+ if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
269
+ const headers = {
270
+ authorization: `Bearer ${t}`,
271
+ "content-type": type || "application/octet-stream",
272
+ };
273
+ if (name) headers["x-file-name"] = encodeURIComponent(String(name));
274
+ let res;
275
+ try {
276
+ res = await fetch(`${baseUrl()}/v1/attachments`, { method: "POST", headers, body: bytes });
277
+ } catch (err) {
278
+ throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
279
+ }
280
+ if (!res.ok) {
281
+ const body = await res.text();
282
+ let message = body;
283
+ try {
284
+ message = JSON.parse(body).message || body;
285
+ } catch {}
286
+ throw new ApiError(res.status, message || res.statusText);
287
+ }
288
+ return (await res.json()).attachment;
289
+ }
290
+
231
291
  export async function uploadShot(bytes, type, name = "") {
232
292
  const t = token();
233
293
  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: "",
@@ -677,3 +709,101 @@ function taskTemplate(meta = {}) {
677
709
  export function stripPlaceholders(body) {
678
710
  return body.replace(/^<!-- passalong:.*?-->\n?/gm, "").trim();
679
711
  }
712
+
713
+ // ---- reading a guide in parts, and how big one may be -----------------------------------------
714
+ // Mirrors apps/api/src/sections.ts — apps/api/test/sections.test.mjs holds the two to the same
715
+ // answers. Most of what is stored is a few very large guides (six of 123 held 72% of the text), and
716
+ // an agent that wants only the Acceptance of a long task should not read the rest first.
717
+
718
+ /** Over this a guide is large: its readers are offered the outline first, and its author a warning. */
719
+ export const GUIDE_WARN = 20_000;
720
+ /** Over this a new guide is refused by the server. A guide stored before it existed may keep its size. */
721
+ export const GUIDE_MAX = 60_000;
722
+
723
+ /** Where each heading starts, outside the frontmatter and outside code fences. */
724
+ function headingLines(markdown) {
725
+ const fm = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(markdown);
726
+ const bodyStart = fm ? fm[0].length : 0;
727
+ const lines = [];
728
+ let fence = "";
729
+ let at = bodyStart;
730
+ for (const raw of markdown.slice(bodyStart).split("\n")) {
731
+ const mark = /^\s*(```|~~~)/.exec(raw)?.[1];
732
+ if (mark) fence = fence ? (fence === mark ? "" : fence) : mark;
733
+ const m = !fence && !mark ? /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw) : null;
734
+ if (m) lines.push({ heading: m[2].trim(), level: m[1].length, start: at });
735
+ at += raw.length + 1;
736
+ }
737
+ return lines;
738
+ }
739
+
740
+ /** The end of the section that begins at lines[i]: the next heading at its level or above. */
741
+ function sectionEnd(lines, i, total) {
742
+ for (let j = i + 1; j < lines.length; j++)
743
+ if (lines[j].level <= lines[i].level) return lines[j].start;
744
+ return total;
745
+ }
746
+
747
+ /** Every heading, with the characters in its section (nested ones included). */
748
+ export function outline(markdown) {
749
+ const lines = headingLines(markdown);
750
+ return lines.map((l, i) => ({
751
+ heading: l.heading,
752
+ level: l.level,
753
+ chars: sectionEnd(lines, i, markdown.length) - l.start,
754
+ }));
755
+ }
756
+
757
+ /** One section by its heading — exact, then by its start, then by what it contains. */
758
+ export function section(markdown, name) {
759
+ const lines = headingLines(markdown);
760
+ const want = String(name ?? "")
761
+ .trim()
762
+ .toLowerCase();
763
+ if (!want) return null;
764
+ const at = (test) => lines.findIndex((l) => test(l.heading.toLowerCase()));
765
+ const i = [
766
+ at((h) => h === want),
767
+ at((h) => h.startsWith(want)),
768
+ at((h) => h.includes(want)),
769
+ ].find((n) => n >= 0);
770
+ if (i === undefined) return null;
771
+ return {
772
+ heading: lines[i].heading,
773
+ text: markdown.slice(lines[i].start, sectionEnd(lines, i, markdown.length)).trimEnd(),
774
+ };
775
+ }
776
+
777
+ /** What a guide's author is told when it is on the large side. */
778
+ export function sizeWarning(chars) {
779
+ return chars > GUIDE_WARN
780
+ ? `this guide is ${chars.toLocaleString("en")} characters, and over ${GUIDE_WARN.toLocaleString("en")} ` +
781
+ "it is read by outline first. Put logs, dumps and long output in a file (attach_file) and " +
782
+ "link it; keep the guide to what the next agent has to act on."
783
+ : null;
784
+ }
785
+
786
+ /**
787
+ * What an agent is shown instead of a large guide: who it is, what it is, and where its parts are.
788
+ * `path` is where the whole of it was written, for a search rather than a read.
789
+ */
790
+ export function outlineText(markdown, { id = "", path = "" } = {}) {
791
+ const { meta } = parse(markdown);
792
+ const rows = outline(markdown)
793
+ .filter((h) => h.level <= 3)
794
+ .map(
795
+ (h) => `${" ".repeat(h.level - 1)}- ${h.heading} (${h.chars.toLocaleString("en")} chars)`,
796
+ );
797
+ return [
798
+ `${meta.title || id} — ${meta.kind || "guide"}, ${markdown.length.toLocaleString("en")} characters`,
799
+ meta.summary ? `Summary: ${meta.summary}` : "",
800
+ "",
801
+ "Sections:",
802
+ ...rows,
803
+ "",
804
+ `Read one with get_guide ref=${id} section="<heading>", or the whole thing with full=true.` +
805
+ (path ? ` The full text is also on disk at ${path}, if you only need to search it.` : ""),
806
+ ]
807
+ .filter((l, i, a) => l !== "" || a[i - 1] !== "")
808
+ .join("\n");
809
+ }
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
@@ -9,7 +9,18 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
9
9
  import { z } from "zod";
10
10
  import * as api from "./api.js";
11
11
  import { refusal, runChecks } from "./checks.js";
12
- import { AREAS, BUG_SECTIONS, parse, SECTIONS, TASK_SECTIONS, template } from "./guide.js";
12
+ import {
13
+ AREAS,
14
+ BUG_SECTIONS,
15
+ GUIDE_WARN,
16
+ outlineText,
17
+ parse,
18
+ SECTIONS,
19
+ section as sectionOf,
20
+ sizeWarning,
21
+ TASK_SECTIONS,
22
+ template,
23
+ } from "./guide.js";
13
24
  import * as passalong from "./passalong.js";
14
25
 
15
26
  /**
@@ -226,6 +237,21 @@ export function nextNote({ next = [], say = "" } = {}, id = "") {
226
237
  return lines.length ? `<!-- passalong: next:\n${lines.join("\n")}\n-->` : "";
227
238
  }
228
239
 
240
+ /**
241
+ * What the person said to this agent since it last heard, in front of everything else: a reply is
242
+ * the thing the agent stopped for, and burying it under the guide it already knows is how it gets
243
+ * missed. See claims.deliver() in apps/api/src/claims.ts.
244
+ */
245
+ export function repliesNote(replies = []) {
246
+ if (!Array.isArray(replies) || !replies.length) return "";
247
+ const lines = replies.map((r) => ` - ${String(r.body).replace(/\n/g, "\n ")}`);
248
+ const files = replies.some((r) => String(r.body).includes("/v1/attachments/"))
249
+ ? "\n Attached files are private: fetch one with your passalong token, e.g. " +
250
+ 'curl -H "authorization: Bearer $PASSALONG_TOKEN (or the token in ~/.passalong/config.json)" -o <name> <url>.'
251
+ : "";
252
+ return `<!-- passalong: the person replied:\n${lines.join("\n")}${files}\n-->`;
253
+ }
254
+
229
255
  /** A refusal that carries the server's own next move, when it sent one. */
230
256
  const failWith = (err) => {
231
257
  const said = nextNote(err.body || {});
@@ -252,7 +278,9 @@ export function buildServer() {
252
278
  "returns it; nobody else can take it here while you hold it, and its sender sees you are " +
253
279
  "on it. progress, with a one-line note at each milestone — 30 minutes of silence marks it " +
254
280
  "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 " +
281
+ "stuck. ask, with a question, when you need an answer from the person: you keep what you " +
282
+ "hold, stop and wait, and take it again once they have replied. Every answer ends with " +
283
+ "`next`: what to call now. Follow it, and when it says to " +
256
284
  "stop, stop. OPEN A GUIDE YOU MEAN TO ACT ON WITH take, NOT get_guide: get_guide only " +
257
285
  "reads, and the sender learns nothing. If take says somebody else has it, tell the user " +
258
286
  "instead of doing the work twice.\n" +
@@ -261,6 +289,11 @@ export function buildServer() {
261
289
  "Collect it as you work rather than writing it from memory at the end. hand_in without " +
262
290
  "it is refused, because the write-up and the verdict are both your word for your own " +
263
291
  "work and evidence is the part the person reviewing it can check.\n" +
292
+ "EVERY GUIDE SAYS WHAT IT IS TO A PERSON. The document is for agents: exact, dense, full of " +
293
+ "ids and paths. Put `summary:` in its frontmatter — one or two plain sentences, 400 " +
294
+ "characters at most, no ids, paths or jargon, saying what this is and whether anybody has " +
295
+ "to act. It is what the person sees first in their hub; the document is behind it. A guide " +
296
+ "with no summary is refused.\n" +
264
297
  "SAY WHICH KIND IT IS. There is no default: a guide with no `kind:` line is refused, and so " +
265
298
  "is a spelling that is not one of the three. " +
266
299
  `kind: task is work nobody has done yet. Sections: ${TASK_SECTIONS.join(", ")}. ` +
@@ -331,8 +364,13 @@ export function buildServer() {
331
364
  const note = nextNote({ next, say }, id);
332
365
  // The note is what the agent acts on and it is prose, so it stays in the text half only. The
333
366
  // structured half is the answer itself, which is what `outputSchema` below describes.
367
+ // A reply goes first, in prose: it is what the agent stopped for, and inside the JSON it reads
368
+ // as one more field.
369
+ const said = repliesNote(rest.replies);
334
370
  return {
335
- ...text(`${JSON.stringify(rest, null, 2)}${note ? `\n${note}` : ""}`),
371
+ ...text(
372
+ `${said ? `${said}\n` : ""}${JSON.stringify(rest, null, 2)}${note ? `\n${note}` : ""}`,
373
+ ),
336
374
  structuredContent: rest,
337
375
  };
338
376
  };
@@ -359,8 +397,9 @@ export function buildServer() {
359
397
  `<!-- passalong: ${r.guide.id} is yours` +
360
398
  `${r.guide.resumed ? " (you already held it — carry on from where it was left)" : ""}; ` +
361
399
  `written to ${r.path}. -->`;
400
+ const said = repliesNote(r.replies);
362
401
  return text(
363
- `${from}${leadFor(meta)}${r.guide.markdown}${siblings}${context ? `\n\n${context}` : ""}` +
402
+ `${said ? `${said}\n\n` : ""}${from}${leadFor(meta)}${r.guide.markdown}${siblings}${context ? `\n\n${context}` : ""}` +
364
403
  `\n\n${held}\n${nextNote(r, r.guide.id)}\n${passalong.followUpNote(meta)}`,
365
404
  );
366
405
  } catch (err) {
@@ -368,7 +407,7 @@ export function buildServer() {
368
407
  if (h)
369
408
  return fail({
370
409
  message:
371
- `${err.message}. Somebody else is on it here${h.note ? ` (last said: "${h.note}")` : ""}. ` +
410
+ `${err.message}. Somebody else is on it here${h.agent ? ` (agent ${h.agent}${h.host ? ` on ${h.host}` : ""})` : ""}${h.note ? ` (last said: "${h.note}")` : ""}. ` +
372
411
  "Tell the user before doing this work too; get_guide reads it without taking it.",
373
412
  });
374
413
  return failWith(err);
@@ -428,6 +467,14 @@ export function buildServer() {
428
467
  }
429
468
  }
430
469
 
470
+ async function doAsk({ id, question, cwd }) {
471
+ try {
472
+ return answer(await passalong.ask(id, question, { cwd: cwd || process.cwd() }), id);
473
+ } catch (err) {
474
+ return failWith(err);
475
+ }
476
+ }
477
+
431
478
  async function doPass({ id, why, cwd }) {
432
479
  try {
433
480
  return answer(await passalong.pass(id, why, { cwd: cwd || process.cwd() }), id);
@@ -623,6 +670,12 @@ export function buildServer() {
623
670
  .array(
624
671
  z.object({
625
672
  title: z.string(),
673
+ summary: z
674
+ .string()
675
+ .describe(
676
+ "one or two plain sentences for a PERSON, no ids or paths: what this task is and " +
677
+ "why it matters. The goal below is for the agent; this is what a person reads",
678
+ ),
626
679
  goal: z.string().describe("what is true when this step is done"),
627
680
  acceptance: z.string().describe("checks a person can run, one per line"),
628
681
  context: z.string().optional(),
@@ -694,7 +747,14 @@ export function buildServer() {
694
747
  "without one marks it stalled. If the answer says you no longer hold it, stop.",
695
748
  inputSchema: {
696
749
  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"'),
750
+ note: z
751
+ .string()
752
+ .optional()
753
+ .describe(
754
+ 'one plain sentence in the first person, as you would tell the person: "Reading ' +
755
+ 'the settings page to find where the toggle goes." Start it with BLOCKED: when ' +
756
+ "you are stopped and need an answer",
757
+ ),
698
758
  cwd: z
699
759
  .string()
700
760
  .optional()
@@ -738,27 +798,44 @@ export function buildServer() {
738
798
  ),
739
799
  checks: z
740
800
  .array(
741
- z.object({
742
- check: z
743
- .string()
744
- .describe(
745
- "the line this answers, in the guide's own words: an Acceptance line on a task, " +
746
- "a Verification line on a handoff or a bug",
747
- ),
748
- ran: z
749
- .string()
750
- .optional()
751
- .describe("what you ran for it, and what came back; not needed when `cmd` is set"),
752
- cmd: z
753
- .string()
754
- .optional()
755
- .describe(
756
- "the shell command that proves this line, run here before the hand-in lands — " +
757
- "its exit code decides the check and its output is recorded as the evidence. " +
758
- "Non-zero refuses the hand-in. Leave it out for a check nobody can run, like " +
759
- '"the badge reads 3" — then SHOW it with attach_screenshot and put the line it returns in `ran`; a check is run or shown, and describing what you saw is refused',
760
- ),
761
- }),
801
+ // A bare string is a check line with nothing behind it. Let it through to the server's
802
+ // refusal, which says what to send, rather than a schema error that says only "expected
803
+ // object".
804
+ z.preprocess(
805
+ (v) => (typeof v === "string" ? { check: v } : v),
806
+ z.object({
807
+ check: z
808
+ .string()
809
+ .describe(
810
+ "the line this answers, in the guide's own words: an Acceptance line on a task, " +
811
+ "a Verification line on a handoff or a bug",
812
+ ),
813
+ says: z
814
+ .string()
815
+ .optional()
816
+ .describe(
817
+ 'what happened, in one plain sentence for a PERSON: "Swagger lists all six ' +
818
+ 'endpoints, each with request and response schemas." It is the row they read ' +
819
+ "in the review table; the evidence stays folded behind it. Strongly " +
820
+ "recommended: a check without one shows only the line it answers",
821
+ ),
822
+ ran: z
823
+ .string()
824
+ .optional()
825
+ .describe(
826
+ "what you ran for it, and what came back; not needed when `cmd` is set",
827
+ ),
828
+ cmd: z
829
+ .string()
830
+ .optional()
831
+ .describe(
832
+ "the shell command that proves this line, run here before the hand-in lands — " +
833
+ "its exit code decides the check and its output is recorded as the evidence. " +
834
+ "Non-zero refuses the hand-in. Leave it out for a check nobody can run, like " +
835
+ '"the badge reads 3" — then SHOW it with attach_screenshot and put the line it returns in `ran`; a check is run or shown, and describing what you saw is refused',
836
+ ),
837
+ }),
838
+ ),
762
839
  )
763
840
  .optional()
764
841
  .describe(
@@ -768,7 +845,13 @@ export function buildServer() {
768
845
  "running it and is refused",
769
846
  ),
770
847
  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"),
848
+ note: z
849
+ .string()
850
+ .describe(
851
+ "one plain sentence, in the first person, saying what you did and how it went: " +
852
+ '"Added the toggle to Settings; it survives a reload." It is the first thing the ' +
853
+ "person reads, and the evidence is behind it. Say what went wrong when ok is false",
854
+ ),
772
855
  writeup: z
773
856
  .string()
774
857
  .optional()
@@ -805,6 +888,37 @@ export function buildServer() {
805
888
  async (args) => doHandIn(args),
806
889
  );
807
890
 
891
+ server.registerTool(
892
+ "ask",
893
+ {
894
+ title: "Ask the person",
895
+ outputSchema: openObject({ id: z.string(), lease_until: z.string() }),
896
+ annotations: ADDS,
897
+ description:
898
+ "You need the person's answer before you can go on. Ask one clear question, with the " +
899
+ "context it needs to be answered without opening your terminal. You keep what you hold " +
900
+ "and nobody else can take it. Then STOP and tell the user you are waiting: do not carry " +
901
+ "on guessing. When they have replied, call take with this id and their reply comes back " +
902
+ "with it. Use `pass` instead when the work is not yours; a progress note starting " +
903
+ "BLOCKED: is the older way of saying this, and is not heard as a question.",
904
+ inputSchema: {
905
+ id: z.string().describe("passalong id"),
906
+ question: z
907
+ .string()
908
+ .describe(
909
+ "one clear question in the first person, a few sentences at most, plain text: " +
910
+ '"Should the dark-mode toggle live in Settings or the header? The header is ' +
911
+ 'crowded on mobile." What you were about to do, if they just say yes, helps',
912
+ ),
913
+ cwd: z
914
+ .string()
915
+ .optional()
916
+ .describe("the worktree you are working in; default is the server's cwd"),
917
+ },
918
+ },
919
+ async (args) => doAsk(args),
920
+ );
921
+
808
922
  server.registerTool(
809
923
  "pass",
810
924
  {
@@ -833,7 +947,9 @@ export function buildServer() {
833
947
  title: "Get guide",
834
948
  annotations: READS,
835
949
  description:
836
- "READ a transfer guide by passalong id or share link and return its full markdown. Also " +
950
+ "READ a transfer guide by passalong id or share link and return its markdown. A guide over " +
951
+ "20,000 characters comes back as an outline (its headings and their sizes) unless you ask " +
952
+ "for `section`, for one part, or `full`, for all of it. Also " +
837
953
  "writes it to .passalong/<id>.md in the working directory so it survives the session. " +
838
954
  "Pulling a teammate's guide tells them the transfer landed. Use this to look at a guide " +
839
955
  "you have not committed to. If you are about to DO the work, call take instead: " +
@@ -845,12 +961,38 @@ export function buildServer() {
845
961
  .string()
846
962
  .optional()
847
963
  .describe("directory to write .passalong/<id>.md into; default is the server's cwd"),
964
+ section: z
965
+ .string()
966
+ .optional()
967
+ .describe("read only the part under this heading, e.g. Acceptance; see the outline"),
968
+ outline: z
969
+ .boolean()
970
+ .optional()
971
+ .describe("just the headings and their sizes, whatever the guide's length"),
972
+ full: z
973
+ .boolean()
974
+ .optional()
975
+ .describe("the whole document, even one over 20,000 characters"),
848
976
  },
849
977
  },
850
- async ({ ref, cwd }) => {
978
+ async ({ ref, cwd, section, outline, full }) => {
851
979
  try {
852
980
  const { markdown, path, from } = await passalong.pull(ref, { cwd: cwd || process.cwd() });
853
981
  const meta = parse(markdown).meta;
982
+ if (section) {
983
+ const part = sectionOf(markdown, section);
984
+ if (!part)
985
+ return text(
986
+ `No heading matching "${section}".\n\n${outlineText(markdown, { id: meta.id || ref, path })}`,
987
+ );
988
+ return text(
989
+ `${part.text}\n\n<!-- passalong: ${meta.id || ref}, "${part.heading}" only; the whole guide is at ${path} -->`,
990
+ );
991
+ }
992
+ if (outline || (!full && markdown.length > GUIDE_WARN))
993
+ return text(
994
+ `${outlineText(markdown, { id: meta.id || ref, path })}\n\n<!-- passalong: ${from}; written to ${path} -->`,
995
+ );
854
996
  const lead = leadFor(meta);
855
997
  const siblings = await related(meta);
856
998
  // Follow-ups are more context for this guide, so they come with it — after the document,
@@ -930,6 +1072,72 @@ export function buildServer() {
930
1072
  },
931
1073
  );
932
1074
 
1075
+ server.registerTool(
1076
+ "attach_file",
1077
+ {
1078
+ title: "Attach a file",
1079
+ annotations: { ...ADDS, openWorldHint: true },
1080
+ description:
1081
+ "Upload a file that is not a picture — a log, a PDF, a CSV, a zip, up to 10MB — and get " +
1082
+ "back the markdown line that points at it. For a guide body or a bug report: put the " +
1083
+ "line there and publishing claims the file. It is private, so only people who can read " +
1084
+ "that guide can download it; for something a reviewer must SEE, use attach_screenshot. " +
1085
+ "Pictures belong to attach_screenshot, not here.",
1086
+ inputSchema: {
1087
+ file: z.string().describe("path to the file on this machine"),
1088
+ name: z.string().default("").describe("label for the file; defaults to its filename"),
1089
+ },
1090
+ },
1091
+ async ({ file, name }) => {
1092
+ try {
1093
+ const got = await passalong.attachFile(file, { name: name || "" });
1094
+ return text(
1095
+ `${JSON.stringify({ id: got.id, url: got.url, bytes: got.bytes }, null, 2)}\n\n` +
1096
+ `Put this in the guide body:\n${got.markdown}`,
1097
+ );
1098
+ } catch (err) {
1099
+ return fail(err);
1100
+ }
1101
+ },
1102
+ );
1103
+
1104
+ server.registerTool(
1105
+ "reply",
1106
+ {
1107
+ title: "Write to the agent",
1108
+ annotations: ADDS,
1109
+ description:
1110
+ "A person's tool, for someone driving their work from an assistant: write to whoever " +
1111
+ "holds a guide — an answer to a question it asked, or something you thought of since — " +
1112
+ "or, when nobody holds it yet and it is yours, leave a note for whoever takes it. Plain " +
1113
+ "text, 1000 characters. `files` are paths on this machine: a picture is drawn in the " +
1114
+ "thread and anything else is a file to download. Only its author, whoever it is " +
1115
+ "assigned to, or whoever holds it can write; the agent reads it the next time it " +
1116
+ "checks in. Agents answering their own questions do not use this: they use ask and take.",
1117
+ inputSchema: {
1118
+ id: z.string().describe("passalong id"),
1119
+ text: z.string().default("").describe("what to say; may be empty when attaching a file"),
1120
+ files: z
1121
+ .array(z.string())
1122
+ .max(3)
1123
+ .default([])
1124
+ .describe("paths to up to three files or pictures to attach"),
1125
+ },
1126
+ },
1127
+ async ({ id, text: said, files }) => {
1128
+ try {
1129
+ const r = await passalong.reply(id, said, { files });
1130
+ return text(
1131
+ r?.noted
1132
+ ? `Nobody holds ${id} yet, so this is a note: whoever takes it is handed it first.`
1133
+ : `Sent to whoever holds ${id}. It reads this the next time it checks in.`,
1134
+ );
1135
+ } catch (err) {
1136
+ return failWith(err);
1137
+ }
1138
+ },
1139
+ );
1140
+
933
1141
  server.registerTool(
934
1142
  "publish_guide",
935
1143
  {
@@ -983,6 +1191,7 @@ export function buildServer() {
983
1191
  parent: guide.meta.parent || "",
984
1192
  notified,
985
1193
  path,
1194
+ ...(sizeWarning(markdown.length) ? { warning: sizeWarning(markdown.length) } : {}),
986
1195
  // A task does not go live on publish, and an agent that reports "queued it" has told
987
1196
  // the user something untrue. Said in the result so it is said to them.
988
1197
  ...(guide.meta.kind === "task" && guide.meta.status === "draft"
@@ -1036,6 +1245,13 @@ export function buildServer() {
1036
1245
  "they go under Problem, where a reader looks first",
1037
1246
  ),
1038
1247
  title: z.string().describe("what is broken, in one line"),
1248
+ summary: z
1249
+ .string()
1250
+ .optional()
1251
+ .describe(
1252
+ "one or two plain sentences for a PERSON, no ids or paths: what is broken and " +
1253
+ "whether anyone needs to act. Defaults to the title",
1254
+ ),
1039
1255
  problem: z
1040
1256
  .string()
1041
1257
  .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);