passalong 0.1.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,25 +25,72 @@ passalong login # optional: sync guides across machines and get share links
25
25
  ## Commands
26
26
 
27
27
  ```
28
- passalong share [file] publish a guide (newest draft, or a scaffold you edit) → id + link
29
- passalong pull <id|link> fetch a guide into ./.passalong/ and print it
30
- passalong list [query] your guides, local and synced
28
+ passalong share [file] [--to team[/handle]] publish a guide → id + link; --to hands it to a team or teammate
29
+ passalong pull <id|link> fetch a guide into ./.passalong/ (git-ignored) and print it
30
+ passalong inbox guides handed to you that you have not pulled yet
31
+ passalong board waiting on you, in flight, landed, worth keeping
32
+ passalong activity [--all] what happened while you were away; clears unless --all
33
+ passalong list [query] your guides and your teams', local and synced
31
34
  passalong open <id> [--print] view a guide in the browser (or the terminal)
32
35
  passalong hub open your synced guides in the browser
36
+ passalong works <id> you tried it and it holds up
37
+ passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
33
38
  passalong done <id> mark consumed: implemented on the receiving side
34
39
  passalong promote <id> mark promoted: graduated into a reusable reference
35
40
  passalong rm <id> delete a guide locally and from sync
36
41
  passalong export [dir] dump every guide as plain markdown
37
- passalong login [token] create an account, or attach this machine to an existing one
42
+ passalong login [token] create an account, or attach this machine with a token from your hub
43
+ passalong me [--handle H] [--name N] [--email E] who you are to teammates
44
+ passalong team current team and its members
45
+ passalong team create <name> start a team (you become its owner)
46
+ passalong team invite [email] make an invite link (mailed when an email is given)
47
+ passalong team join <link> accept an invite
48
+ passalong team use <slug> switch the current team
38
49
  passalong setup install the Claude Code capture skill + register the MCP server
39
50
  passalong mcp run the MCP server over stdio
40
51
  ```
41
52
 
53
+ ## Teams
54
+
55
+ A team is the unit of sharing. Members can find, pull, and mark consumed every guide shared to
56
+ the team; a guide can also be handed to one person, who sees it in their inbox (and by email
57
+ when the server has mail configured).
58
+
59
+ ```sh
60
+ passalong me --handle lukman # how teammates address you
61
+ passalong team create Khaime # you become owner; "khaime" is now current
62
+ passalong team invite ada@example.com # or no email: prints a link to send yourself
63
+ passalong team join <link> # on Ada's machine — or she just opens the link
64
+ passalong share --to khaime/ada # hand this guide to Ada
65
+ passalong inbox # on Ada's side: what was handed to you
66
+ passalong activity # back on your side: it landed, and who pulled it
67
+ ```
68
+
69
+ A receiver who tries the work reports back with `passalong works <id>` or `passalong broken <id> <why>`.
70
+ That is separate from `done`, which means *implemented*: a verdict means it actually runs, a failing
71
+ one has to say why, and the author sees it on their board and in their inbox.
72
+
73
+ Sign in at [the hub](https://passalong.dev/hub) with an email and password, and mint
74
+ an API token there for the CLI and MCP servers — named, revocable, and shown once. Your password
75
+ never goes near the terminal. `passalong login` with no arguments still makes an account without
76
+ any of that; add an email and password later to be able to sign in from a browser and recover it.
77
+
78
+ Not everyone who receives a guide implements it. Every guide page has a **Verify** view
79
+ (`?view=verify`) that leads with Problem, Verification and Gotchas and folds the implementation
80
+ away — for a tester or anyone checking the work rather than doing it. Invites are accepted in the
81
+ browser, so a teammate needs no terminal to be part of a team.
82
+
83
+ `passalong activity` is the other half of the transfer: it tells you when someone pulled a guide
84
+ you handed over, when they marked it consumed, and when an invite was taken up. A handoff
85
+ addressed to a person is also emailed; a team-wide share is not, because mail nobody is
86
+ named in is the first thing people filter out. Guides that keep getting pulled get a nudge to
87
+ promote them into the team's small set of maintained references.
88
+
42
89
  ## MCP
43
90
 
44
91
  `passalong setup` registers the server with Claude Code. For other clients, run `passalong mcp` over stdio.
45
92
 
46
- Tools: `search_guides`, `get_guide`, `publish_guide`, `guide_template`, `set_guide_status`.
93
+ Tools: `search_guides`, `inbox`, `get_guide`, `publish_guide` (with `to`), `guide_template`, `set_guide_status`.
47
94
 
48
95
  ## Guides are files
49
96
 
package/bin/passalong CHANGED
@@ -1,16 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  // Passalong CLI. Few flags on purpose: the defaults are the product.
3
3
  //
4
- // passalong share [file] publish a guide (newest draft, or a scaffold you edit) → id + link
4
+ // passalong share [file] [--to team[/handle]] publish a guide → id + link; --to hands it to a team or teammate
5
5
  // passalong pull <id|link> fetch a guide into ./.passalong/ and print it
6
- // passalong list [query] your guides, local and synced
6
+ // passalong inbox guides handed to you that you have not pulled yet
7
+ // passalong board waiting on you, in flight, landed, worth keeping
8
+ // passalong activity [--all] what happened while you were away; clears unless --all
9
+ // passalong list [query] your guides and your teams', local and synced
7
10
  // passalong open <id> [--print] view a guide in the browser (or the terminal)
8
11
  // passalong hub open your synced guides in the browser
12
+ // passalong works <id> you tried it and it holds up
13
+ // passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
9
14
  // passalong done <id> mark consumed: implemented on the receiving side
10
15
  // passalong promote <id> mark promoted: graduated into a reusable reference
11
16
  // passalong rm <id> delete a guide locally and from sync
12
17
  // passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
13
- // passalong login [token] create an account, or attach this machine to an existing one
18
+ // passalong login [token] create an account, or attach this machine with a token from your hub
19
+ // passalong me [--handle H] [--name N] [--email E] who you are to teammates
20
+ // passalong team current team and its members
21
+ // passalong team create <name> start a team (you become its owner)
22
+ // passalong team invite [email] make an invite link (mailed when an email is given)
23
+ // passalong team join <link|code> accept an invite
24
+ // passalong team use <slug> switch the current team
14
25
  // passalong setup install the Claude Code capture skill + register the MCP server
15
26
  // passalong mcp run the MCP server over stdio
16
27
  import { execFileSync, spawnSync } from "node:child_process";
@@ -53,18 +64,37 @@ function edit(path) {
53
64
  }
54
65
 
55
66
  function openInBrowser(url) {
56
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
67
+ const cmd =
68
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
57
69
  spawnSync(cmd, [url], { stdio: "ignore", shell: process.platform === "win32" });
58
70
  }
59
71
 
60
72
  function relDate(iso) {
61
73
  if (!iso) return "";
62
74
  const d = (Date.now() - new Date(iso).getTime()) / 864e5;
63
- if (d < 1) return "today";
75
+ if (d < 1 / 24) return "just now";
76
+ if (d < 1) return `${Math.floor(d * 24)}h ago`;
64
77
  if (d < 30) return `${Math.floor(d)}d ago`;
65
78
  return iso.slice(0, 10);
66
79
  }
67
80
 
81
+ function guideLine(r) {
82
+ const who = r.team ? ` ${r.team}${r.to ? `/@${r.to}` : ""}${r.mine ? "" : ` from @${r.from || "?"}`}` : "";
83
+ const tags = r.tags?.length ? ` #${r.tags.join(" #")}` : "";
84
+ let pulled = "";
85
+ if (r.mine && r.pulled_by?.length) {
86
+ const p = r.pulled_by[0];
87
+ pulled = ` ← ${p.handle ? `@${p.handle}` : "link"} ${relDate(p.at)}${r.pulled_by.length > 1 ? ` +${r.pulled_by.length - 1}` : ""}`;
88
+ }
89
+ const nudge = r.mine && r.status === "published" && r.pulls >= 3 ? " ★ promote?" : "";
90
+ return `${r.id} ${r.status.padEnd(9)} ${relDate(r.created).padEnd(10)} ${r.title}${who}${tags}${pulled}${nudge}`;
91
+ }
92
+
93
+ function activityLine(n) {
94
+ const dot = n.read ? " " : "•";
95
+ return `${dot} ${(n.guide || "").padEnd(8)} ${relDate(n.at).padEnd(10)} ${n.text}`;
96
+ }
97
+
68
98
  const commands = {
69
99
  async share(f) {
70
100
  let path = f._[0];
@@ -79,15 +109,23 @@ const commands = {
79
109
  path = store.draftPath(new Date().toISOString().replace(/[:.]/g, "-"));
80
110
  writeFileSync(path, scaffold());
81
111
  fromDrafts = true;
82
- if (!tty) die(`no draft to share; scaffolded ${path} — fill it in and run \`passalong share ${path}\``);
112
+ if (!tty)
113
+ die(`no draft to share; scaffolded ${path} — fill it in and run \`passalong share ${path}\``);
83
114
  }
84
115
  }
85
116
  if (!existsSync(path)) die(`no such file: ${path}`);
86
117
  if (tty && !f["no-edit"]) edit(path);
87
- const { guide, url, synced, path: saved } = await passalong.share(readFileSync(path, "utf8")).catch((e) => die(e.message));
118
+ const to = typeof f.to === "string" ? f.to : undefined;
119
+ const { guide, url, synced, notified, path: saved } = await passalong
120
+ .share(readFileSync(path, "utf8"), { to })
121
+ .catch((e) => die(e.message));
88
122
  if (fromDrafts) store.removeDraft(path);
89
123
  console.log(guide.meta.id);
90
124
  console.error(`shared "${guide.meta.title}" → ${saved}`);
125
+ if (guide.meta.team)
126
+ console.error(
127
+ `handed to ${guide.meta.team}${guide.meta.to ? `/@${guide.meta.to}${notified ? " (emailed)" : " (in their inbox)"}` : " (team inbox)"}`,
128
+ );
91
129
  if (synced) console.error(`link: ${url}`);
92
130
  else console.error("not synced (run `passalong login` to sync across machines)");
93
131
  },
@@ -99,14 +137,56 @@ const commands = {
99
137
  console.error(`\npulled from ${from} → ${path}`);
100
138
  },
101
139
 
140
+ async inbox() {
141
+ const rows = await passalong.inbox().catch((e) => die(e.message));
142
+ if (!rows.length) return console.error("inbox empty — nothing handed to you that you haven't pulled");
143
+ for (const r of rows) console.log(guideLine(r));
144
+ console.error(`\n${rows.length} waiting. Pull one with: passalong pull <id>`);
145
+ },
146
+
147
+ async board() {
148
+ const b = await passalong.board().catch((e) => die(e.message));
149
+ const queue = (title, note, rows, line) => {
150
+ if (!rows.length) return;
151
+ console.log(`\n${title} · ${rows.length} ${note}`);
152
+ for (const g of rows) console.log(` ${g.id} ${relDate(g.created).padEnd(10)} ${line(g)}`);
153
+ };
154
+ queue("Waiting on you", "handed to you, not pulled yet", b.waiting, (g) => `${g.title} from @${g.from || "?"}`);
155
+ queue("Not working", "someone tried it and it does not hold up", b.failing, (g) =>
156
+ `${g.title} ${g.verdict?.by ? `@${g.verdict.by}` : "someone"}: ${g.verdict?.note || "no reason given"}`);
157
+ queue("In flight", "handed over, nobody has taken it", b.in_flight, (g) =>
158
+ `${g.title} to ${g.team}${g.to ? `/@${g.to}` : ""}${g.stale ? " ← not picked up" : ""}`);
159
+ queue("Landed", "someone has it and hasn't said it shipped", b.landed, (g) => `${g.title} ${g.pulls} pull${g.pulls === 1 ? "" : "s"}`);
160
+ queue("Worth keeping", "pulled enough to graduate into a reference", b.promote, (g) => `${g.title} ${g.pulls} pulls`);
161
+ const total =
162
+ b.waiting.length + b.failing.length + b.in_flight.length + b.landed.length + b.promote.length;
163
+ if (!total) console.error("nothing in flight — every transfer is closed");
164
+ else if (b.unread) console.error(`\n${b.unread} new: passalong activity`);
165
+ },
166
+
167
+ async activity(f) {
168
+ const { notifications, unread } = await passalong.activity({ all: f.all }).catch((e) => die(e.message));
169
+ if (!notifications.length)
170
+ return console.error(
171
+ f.all ? "nothing has happened yet" : "nothing new — your guides and handoffs are quiet",
172
+ );
173
+ for (const n of notifications) console.log(activityLine(n));
174
+ const waiting = notifications.filter((n) => n.kind === "handoff" && !n.read);
175
+ if (waiting.length) console.error(`\nPull one with: passalong pull ${waiting[0].guide}`);
176
+ // Reading the feed is what marks it read: an unread count you have to clear by hand is a
177
+ // chore, and `--all` is there for when you want to look without clearing.
178
+ if (!f.all && unread) await passalong.seen().catch(() => {});
179
+ },
180
+
102
181
  async list(f) {
103
- const rows = await passalong.list(f._.join(" "));
182
+ const scope = typeof f.team === "string" ? f.team : f.mine ? "mine" : "";
183
+ const rows = await passalong.list(f._.join(" "), { scope });
104
184
  if (rows.warning) console.error(`warning: ${rows.warning}`);
105
- if (!rows.length) return console.error("no guides yet — run `passalong share` after your next finished piece of work");
106
- for (const r of rows) {
107
- const tags = r.tags?.length ? ` #${r.tags.join(" #")}` : "";
108
- console.log(`${r.id} ${r.status.padEnd(9)} ${relDate(r.created).padEnd(10)} ${r.title}${tags}`);
109
- }
185
+ if (!rows.length)
186
+ return console.error(
187
+ "no guides yet — run `passalong share` after your next finished piece of work",
188
+ );
189
+ for (const r of rows) console.log(guideLine(r));
110
190
  },
111
191
 
112
192
  async open(f) {
@@ -122,7 +202,8 @@ const commands = {
122
202
  },
123
203
 
124
204
  async hub() {
125
- if (!api.loggedIn()) die("not logged in — run `passalong login` first (the hub shows synced guides)");
205
+ if (!api.loggedIn())
206
+ die("not logged in — run `passalong login` first (the hub shows synced guides)");
126
207
  // The token rides in the URL fragment: the browser never sends it to the server, and the
127
208
  // page moves it into localStorage and scrubs the address bar on load.
128
209
  const url = `${api.baseUrl()}/hub#token=${encodeURIComponent(api.token())}`;
@@ -132,6 +213,20 @@ const commands = {
132
213
  } else console.log(url);
133
214
  },
134
215
 
216
+ async works(f) {
217
+ const id = f._[0] || die("usage: passalong works <id>");
218
+ const r = await passalong.verdict(id, true, f._.slice(1).join(" ")).catch((e) => die(e.message));
219
+ console.error(`${r.id} verified — the author has been told`);
220
+ },
221
+
222
+ async broken(f) {
223
+ const id = f._[0] || die("usage: passalong broken <id> <what happened>");
224
+ const why = f._.slice(1).join(" ");
225
+ if (!why) die("say what went wrong: passalong broken <id> <what happened>");
226
+ const r = await passalong.verdict(id, false, why).catch((e) => die(e.message));
227
+ console.error(`${r.id} reported as not working — the author has been told: "${r.note}"`);
228
+ },
229
+
135
230
  async done(f) {
136
231
  const id = f._[0] || die("usage: passalong done <id>");
137
232
  const g = await passalong.setStatus(id, "consumed").catch((e) => die(e.message));
@@ -177,7 +272,81 @@ const commands = {
177
272
  console.error(`account ${account} created on ${api.baseUrl()}. Sync is on.`);
178
273
  console.error("Attach other machines with:");
179
274
  console.error(` passalong login ${token}`);
180
- console.error("(the token is the account — keep it private)");
275
+ console.error("(keep it private — it is a credential, not a password)");
276
+ console.error("Give teammates a name to address you by: passalong me --handle <you>");
277
+ console.error(`Add an email and password so you can sign in and recover it: ${api.baseUrl()}/hub`);
278
+ },
279
+
280
+ async me(f) {
281
+ const patch = {};
282
+ for (const k of ["handle", "name", "email"]) if (typeof f[k] === "string") patch[k] = f[k];
283
+ const who = Object.keys(patch).length
284
+ ? await api.updateMe(patch).then(api.me).catch((e) => die(e.message))
285
+ : await api.me().catch((e) => die(e.message));
286
+ console.log(`account ${who.account}`);
287
+ console.log(`handle ${who.handle ? `@${who.handle}` : "(none — set one: passalong me --handle <you>)"}`);
288
+ if (who.name) console.log(`name ${who.name}`);
289
+ if (who.email) console.log(`email ${who.email}`);
290
+ console.log(`guides ${who.guides} synced (${who.limit} active on the free tier)`);
291
+ const current = store.readConfig().team;
292
+ console.log(
293
+ `teams ${who.teams.length ? who.teams.map((t) => `${t.slug}${t.slug === current ? "*" : ""} (${t.role})`).join(", ") : "(none — passalong team create <name>)"}`,
294
+ );
295
+ },
296
+
297
+ async team(f) {
298
+ const [sub, ...rest] = f._;
299
+ const cfg = store.readConfig();
300
+ if (!sub) {
301
+ if (!cfg.team) {
302
+ const { teams } = await api.teams().catch((e) => die(e.message));
303
+ if (!teams.length) return console.error("no team yet — passalong team create <name>");
304
+ return console.error(`you are in ${teams.map((t) => t.slug).join(", ")}; pick one: passalong team use <slug>`);
305
+ }
306
+ const t = await api.team(cfg.team).catch((e) => die(e.message));
307
+ console.log(`${t.name} (${t.slug}) — ${t.members.length} member${t.members.length === 1 ? "" : "s"}, ${t.guides} guide${t.guides === 1 ? "" : "s"}`);
308
+ for (const m of t.members)
309
+ console.log(` ${m.handle ? `@${m.handle}` : "(no handle)"}${m.name ? ` ${m.name}` : ""} ${m.role} joined ${relDate(m.joined)}`);
310
+ return;
311
+ }
312
+ if (sub === "create") {
313
+ const name = rest.join(" ") || die("usage: passalong team create <name>");
314
+ const t = await api.createTeam(name).catch((e) => die(e.message));
315
+ store.writeConfig({ team: t.slug });
316
+ console.error(`team ${t.name} created as "${t.slug}" — now current. Invite people: passalong team invite [email]`);
317
+ return;
318
+ }
319
+ if (sub === "invite") {
320
+ const slug = cfg.team || die("no current team — passalong team use <slug>");
321
+ const inv = await api.invite(slug, rest[0] || "").catch((e) => die(e.message));
322
+ console.log(inv.url);
323
+ console.error(
324
+ inv.email
325
+ ? inv.mailed
326
+ ? `emailed ${inv.email}`
327
+ : `not emailed (mail is not configured on the server) — send ${inv.email} the link above`
328
+ : "send this link; they run: passalong team join <link>",
329
+ );
330
+ return;
331
+ }
332
+ if (sub === "join") {
333
+ const ref = rest[0] || die("usage: passalong team join <link|code>");
334
+ const code = ref.includes("/") ? ref.split("/").filter(Boolean).pop() : ref;
335
+ const r = await api.join(code).catch((e) => die(e.message));
336
+ store.writeConfig({ team: r.slug });
337
+ console.error(`${r.joined ? "joined" : "already in"} ${r.name} (${r.slug}) — now current`);
338
+ const who = await api.me().catch(() => null);
339
+ if (who && !who.handle) console.error("teammates address you by handle: passalong me --handle <you>");
340
+ return;
341
+ }
342
+ if (sub === "use") {
343
+ const slug = rest[0] || die("usage: passalong team use <slug>");
344
+ await api.team(slug).catch((e) => die(e.message));
345
+ store.writeConfig({ team: slug });
346
+ console.error(`current team: ${slug}`);
347
+ return;
348
+ }
349
+ die("usage: passalong team [create <name> | invite [email] | join <link> | use <slug>]");
181
350
  },
182
351
 
183
352
  async setup() {
@@ -186,15 +355,21 @@ const commands = {
186
355
  cpSync(join(PKG, "skill"), skillDir, { recursive: true });
187
356
  console.error(`installed Claude Code skill → ${skillDir}`);
188
357
  try {
189
- execFileSync("claude", ["mcp", "add", "--scope", "user", "passalong", "--", "passalong", "mcp"], { stdio: "pipe" });
358
+ execFileSync("claude", ["mcp", "add", "--scope", "user", "passalong", "--", "passalong", "mcp"], {
359
+ stdio: "pipe",
360
+ });
190
361
  console.error("registered MCP server: claude mcp add passalong -- passalong mcp");
191
362
  } catch (err) {
192
363
  const msg = String(err.stderr || err.message);
193
364
  if (/already exists/i.test(msg)) console.error("MCP server already registered");
194
- else console.error(`could not register MCP server automatically (${msg.trim()}).\n run: claude mcp add passalong -- passalong mcp`);
365
+ else
366
+ console.error(
367
+ `could not register MCP server automatically (${msg.trim()}).\n run: claude mcp add passalong -- passalong mcp`,
368
+ );
195
369
  }
196
- if (!api.loggedIn()) console.error("sync is off; run `passalong login` when you want guides to follow you across machines");
197
- console.error("done. In Claude Code, say \"pass this along\" at the end of a task, or run /passalong-capture.");
370
+ if (!api.loggedIn())
371
+ console.error("sync is off; run `passalong login` when you want guides to follow you across machines");
372
+ console.error('done. In Claude Code, say "pass this along" at the end of a task, or run /passalong-capture.');
198
373
  },
199
374
 
200
375
  async mcp() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "Hand a finished implementation from one context to another, in a form an AI agent can act on.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -11,7 +11,7 @@
11
11
  "cli",
12
12
  "markdown"
13
13
  ],
14
- "homepage": "https://passalong.kreativekorna.com",
14
+ "homepage": "https://passalong.dev",
15
15
  "repository": {
16
16
  "type": "git",
17
17
  "url": "https://github.com/codellyson/passalong.git",
package/skill/SKILL.md CHANGED
@@ -11,22 +11,29 @@ session's history. Write for execution, not for permanence.
11
11
 
12
12
  ## Procedure
13
13
 
14
- 1. **Reconstruct the story from the session.** What was the problem, what was tried, what failed,
14
+ 1. **Pick the one thing to transfer.** If the user named it (`/passalong-capture <what>` or
15
+ "pass along the webhook fix"), that is the scope. Otherwise take the most recent completed
16
+ piece of work. A long session usually holds several; do not merge them, and if it is
17
+ genuinely unclear which one the user means, ask before writing.
18
+ 2. **Reconstruct the story from the session.** What was the problem, what was tried, what failed,
15
19
  what was decided and why, what finally worked, and how it was verified. Read the diff
16
20
  (`git diff`, `git log`) only to confirm details; the reasoning is in the transcript.
17
- 2. **Write the guide** with the structure below. Be concrete: file paths, commands, exact error
21
+ 3. **Write the guide** with the structure below. Be concrete: file paths, commands, exact error
18
22
  strings, version numbers. Prefer a short guide with every section filled over a long one.
19
- 3. **Mark context-specific parts** so the receiver can adapt rather than copy. Put
23
+ 4. **Mark context-specific parts** so the receiver can adapt rather than copy. Put
20
24
  `ASSUMES: <thing>. If <alternative>, <what changes>.` at the start of any step that depends on
21
25
  this repo's stack, layout, or conventions.
22
- 4. **Save the draft** to `~/.passalong/drafts/<slug>.md` (create the directory if needed), then run:
26
+ 5. **Save the draft** to `~/.passalong/drafts/<slug>.md` (create the directory if needed), then run:
23
27
 
24
28
  ```bash
25
29
  passalong share ~/.passalong/drafts/<slug>.md --no-edit
26
30
  ```
27
31
 
28
- It prints the guide id on stdout and the share link on stderr.
29
- 5. **Report** the id (and link if synced) and one line on how to use it on the other side:
32
+ If the user named a person or team ("share this with Ada", "hand this to the platform
33
+ team"), add `--to <team>` or `--to <team>/<handle>`; `passalong team` lists the current
34
+ team's members and their handles. It prints the guide id on stdout and the share link (and
35
+ who it was handed to) on stderr.
36
+ 6. **Report** the id (and link if synced) and one line on how to use it on the other side:
30
37
  `passalong pull <id>` in the target repo, or "pull passalong <id>" to an agent with the Passalong MCP server.
31
38
 
32
39
  If the `passalong` command is missing, tell the user to run `npm i -g passalong && passalong setup` and
@@ -70,6 +77,8 @@ Commands to run and what they should print. Test cases. What "done" looks like.
70
77
  wrong without a specific decision. Never leave it empty.
71
78
  - **Verification must be runnable.** A command and an expected output, not "make sure it works".
72
79
  - **Every ASSUMES has an alternative.** The point is adaptation on the other side.
80
+ - **One problem per guide.** A lesson from the same session that is not part of this fix goes in
81
+ its own guide (write and share it separately), not in this one's Gotchas.
73
82
  - **No session narration.** "First I looked at..." is noise. State the finding.
74
83
  - **No secrets.** Redact tokens, keys, and internal hostnames that would not apply elsewhere.
75
84
  - Title, Problem, and Steps are required; the guide will not publish without them.
package/src/api.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Client for the hosted sync API (apps/api). Everything here is optional: with no token the
2
- // CLI is a purely local tool, and every function throws a NotLoggedIn the CLI turns into a hint.
2
+ // CLI is a purely local tool, and every function throws an ApiError the CLI turns into a hint.
3
3
  import { readConfig } from "./store.js";
4
4
 
5
- export const DEFAULT_API = "https://passalong.kreativekorna.com";
5
+ export const DEFAULT_API = "https://passalong.dev";
6
6
 
7
7
  export class ApiError extends Error {
8
8
  constructor(status, message) {
@@ -54,16 +54,42 @@ async function call(path, { method = "GET", body, auth = true, raw = false } = {
54
54
  return raw ? res.text() : res.json();
55
55
  }
56
56
 
57
+ const q = (params) => {
58
+ const s = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString();
59
+ return s ? `?${s}` : "";
60
+ };
61
+
57
62
  /** Mint a fresh account and return its token. No email, no password: the token is the account. */
58
63
  export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
59
64
  export const me = () => call("/v1/me");
65
+ export const updateMe = (patch) => call("/v1/me", { method: "PATCH", body: patch });
66
+
67
+ export const teams = () => call("/v1/teams");
68
+ export const team = (slug) => call(`/v1/teams/${encodeURIComponent(slug)}`);
69
+ export const createTeam = (name) => call("/v1/teams", { method: "POST", body: { name } });
70
+ export const invite = (slug, email = "") =>
71
+ call(`/v1/teams/${encodeURIComponent(slug)}/invites`, { method: "POST", body: { email } });
72
+ export const join = (code) =>
73
+ call(`/v1/invites/${encodeURIComponent(code)}/accept`, { method: "POST" });
74
+
75
+ export const board = () => call("/v1/board");
76
+ export const notifications = ({ unread = true, limit = 0 } = {}) =>
77
+ call(`/v1/notifications${q({ unread: unread ? "1" : "", limit: limit || "" })}`);
78
+ /** No ids means "everything unread". */
79
+ export const markRead = (ids = []) =>
80
+ call("/v1/notifications/read", { method: "POST", body: { ids } });
81
+
60
82
  export const publish = (id, markdown) =>
61
83
  call(`/v1/guides/${id}`, { method: "PUT", body: markdown });
62
- export const list = (q = "") => call(`/v1/guides${q ? `?q=${encodeURIComponent(q)}` : ""}`);
84
+ export const list = (query = "", scope = "") => call(`/v1/guides${q({ q: query, scope })}`);
85
+ export const inbox = () => call("/v1/inbox");
63
86
  export const get = (id) => call(`/v1/guides/${id}`, { raw: true });
64
87
  export const setStatus = (id, status) =>
65
88
  call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
66
89
  export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
90
+ /** Does it actually work? `note` is required when it does not. */
91
+ export const verdict = (id, ok, note = "") =>
92
+ call(`/v1/guides/${id}/verdict`, { method: "PUT", body: { ok, note } });
67
93
 
68
94
  /** Fetch a guide by its share link (no account needed). Accepts the web URL or the .md URL. */
69
95
  export async function fetchShared(url) {
package/src/guide.js CHANGED
@@ -92,6 +92,8 @@ const META_ORDER = [
92
92
  "author",
93
93
  "source_context",
94
94
  "status",
95
+ "team",
96
+ "to",
95
97
  "stack_assumptions",
96
98
  "tags",
97
99
  ];
package/src/mcp.js CHANGED
@@ -1,5 +1,6 @@
1
- // The Passalong MCP server (stdio). This is the rail that makes Passalong tool-agnostic: any MCP-capable
2
- // agent can search, pull, and publish guides without leaving its session.
1
+ // The Passalong MCP server (stdio). This is the rail that makes Passalong tool-agnostic: any
2
+ // MCP-capable agent can search, pull, and publish guides without leaving its session. With a
3
+ // team, the same tools see the team's guides and the user's inbox.
3
4
  //
4
5
  // claude mcp add passalong -- passalong mcp
5
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -14,16 +15,20 @@ const fail = (err) => ({ content: [{ type: "text", text: err.message }], isError
14
15
 
15
16
  export async function serve() {
16
17
  const server = new McpServer(
17
- { name: "passalong", version: "0.1.0" },
18
+ { name: "passalong", version: "0.2.0" },
18
19
  {
19
20
  instructions:
20
- "Passalong hands finished implementations between contexts as transfer guides: markdown with " +
21
- `frontmatter and the sections ${SECTIONS.join(", ")}. ` +
22
- "When the user references a passalong id or link, call get_guide and follow its Steps, adapting " +
23
- "anything marked ASSUMES to this codebase; run its Verification before declaring done. " +
24
- "When the user asks to share, hand off, or passalong what was just done, distill the session " +
25
- "into a guide (guide_template shows the shape) and call publish_guide. Gotchas are the " +
26
- "highest-value section: record what failed and why.",
21
+ "Passalong hands finished implementations between contexts as transfer guides: markdown " +
22
+ `with frontmatter and the sections ${SECTIONS.join(", ")}. ` +
23
+ "When the user references a passalong id or link, call get_guide and follow its Steps, " +
24
+ "adapting anything marked ASSUMES to this codebase; run its Verification before declaring " +
25
+ "done, then set_guide_status consumed and verify_guide with the result. " +
26
+ "When the user asks to pass along, hand off, or " +
27
+ "share what was just done, distill the session into a guide (guide_template shows the " +
28
+ "shape) and call publish_guide, with `to` as team or team/handle when it is for a teammate. " +
29
+ "At the start of work, inbox shows guides teammates have handed to this user, and activity " +
30
+ "shows whether the guides they handed off have landed. Gotchas are " +
31
+ "the highest-value section: record what failed and why.",
27
32
  },
28
33
  );
29
34
 
@@ -32,13 +37,16 @@ export async function serve() {
32
37
  {
33
38
  title: "Search guides",
34
39
  description:
35
- "Search the user's transfer guides (local and synced) by words in the title, tags, stack, " +
36
- "or body. Empty query lists everything, newest first.",
37
- inputSchema: { query: z.string().default("") },
40
+ "Search transfer guides by words in the title, tags, stack, or body: the user's own " +
41
+ "(local and synced) plus every team they belong to. Empty query lists everything, newest first.",
42
+ inputSchema: {
43
+ query: z.string().default(""),
44
+ scope: z.string().optional().describe('"mine", "all" (default), or a team slug'),
45
+ },
38
46
  },
39
- async ({ query }) => {
47
+ async ({ query, scope }) => {
40
48
  try {
41
- const rows = await passalong.list(query);
49
+ const rows = await passalong.list(query, { scope: scope || "" });
42
50
  return json({ guides: rows, warning: rows.warning });
43
51
  } catch (err) {
44
52
  return fail(err);
@@ -46,13 +54,103 @@ export async function serve() {
46
54
  },
47
55
  );
48
56
 
57
+ server.registerTool(
58
+ "inbox",
59
+ {
60
+ title: "Inbox",
61
+ description:
62
+ "Guides handed to this user (or to their teams) that they have not pulled yet. Call it " +
63
+ "when starting work so handoffs are not missed. Needs sync (passalong login).",
64
+ inputSchema: {},
65
+ },
66
+ async () => {
67
+ try {
68
+ return json({ guides: await passalong.inbox() });
69
+ } catch (err) {
70
+ return fail(err);
71
+ }
72
+ },
73
+ );
74
+
75
+ server.registerTool(
76
+ "board",
77
+ {
78
+ title: "Board",
79
+ description:
80
+ "The state of this user's transfers as queues: waiting on you (handed to you, not " +
81
+ "pulled), not working (someone gave it a failing verdict — the most urgent), in flight " +
82
+ "(handed over, nobody has taken it — `stale` means it has sat over a week), landed " +
83
+ "(someone has it and has not marked it consumed), and worth keeping (pulled enough to " +
84
+ "promote into a reference). Use it to answer 'what is outstanding?'.",
85
+ inputSchema: {},
86
+ },
87
+ async () => {
88
+ try {
89
+ return json(await passalong.board());
90
+ } catch (err) {
91
+ return fail(err);
92
+ }
93
+ },
94
+ );
95
+
96
+ server.registerTool(
97
+ "verify_guide",
98
+ {
99
+ title: "Report whether a guide works",
100
+ description:
101
+ "After following a guide's Verification section, report the result. This is the only way " +
102
+ "the author learns their handoff did not land — `set_guide_status consumed` says it was " +
103
+ "implemented, this says it actually works. A failing verdict must say what went wrong.",
104
+ inputSchema: {
105
+ id: z.string().describe("passalong id"),
106
+ ok: z.boolean().describe("true if the Verification steps passed"),
107
+ note: z
108
+ .string()
109
+ .default("")
110
+ .describe("what went wrong (required when ok is false); one line, max 280 chars"),
111
+ },
112
+ },
113
+ async ({ id, ok, note }) => {
114
+ try {
115
+ return json(await passalong.verdict(id, ok, note || ""));
116
+ } catch (err) {
117
+ return fail(err);
118
+ }
119
+ },
120
+ );
121
+
122
+ server.registerTool(
123
+ "activity",
124
+ {
125
+ title: "Activity",
126
+ description:
127
+ "What has happened to this user's guides and handoffs: who pulled one, who marked one " +
128
+ "consumed, who was handed what, who joined a team. Each item has a ready-made `text` " +
129
+ "line. Read-only by default — it does not clear the user's unread feed unless asked.",
130
+ inputSchema: {
131
+ all: z.boolean().default(false).describe("include what the user has already seen"),
132
+ mark_read: z.boolean().default(false).describe("clear the unread feed after reading"),
133
+ },
134
+ },
135
+ async ({ all, mark_read }) => {
136
+ try {
137
+ const res = await passalong.activity({ all });
138
+ if (mark_read && res.unread) await passalong.seen();
139
+ return json(res);
140
+ } catch (err) {
141
+ return fail(err);
142
+ }
143
+ },
144
+ );
145
+
49
146
  server.registerTool(
50
147
  "get_guide",
51
148
  {
52
149
  title: "Get guide",
53
150
  description:
54
- "Fetch a transfer guide by passalong id or share link and return its full markdown. Also writes " +
55
- "it to .passalong/<id>.md in the working directory so it survives the session.",
151
+ "Fetch a transfer guide by passalong id or share link and return its full markdown. Also " +
152
+ "writes it to .passalong/<id>.md in the working directory so it survives the session. " +
153
+ "Pulling a teammate's guide tells them the transfer landed.",
56
154
  inputSchema: {
57
155
  ref: z.string().describe("passalong id (e.g. k3mq2xa7) or share URL"),
58
156
  cwd: z
@@ -77,21 +175,33 @@ export async function serve() {
77
175
  title: "Publish guide",
78
176
  description:
79
177
  "Publish a transfer guide from markdown (frontmatter + sections). Missing id, created, " +
80
- "author, and source_context are filled in. Returns the id and share link.",
178
+ 'author, and source_context are filled in. `to` addresses it to a team ("khaime") or a ' +
179
+ 'teammate ("khaime/lukman"), who is notified. Returns the id and share link.',
81
180
  inputSchema: {
82
181
  markdown: z.string().describe("full guide markdown; start from guide_template"),
182
+ to: z.string().optional().describe("team slug, or team/handle for a specific teammate"),
83
183
  cwd: z
84
184
  .string()
85
185
  .optional()
86
186
  .describe("directory the work happened in, used to infer source_context"),
87
187
  },
88
188
  },
89
- async ({ markdown, cwd }) => {
189
+ async ({ markdown, to, cwd }) => {
90
190
  try {
91
- const { guide, url, synced, path } = await passalong.share(markdown, {
191
+ const { guide, url, synced, notified, path } = await passalong.share(markdown, {
92
192
  cwd: cwd || process.cwd(),
193
+ to,
194
+ });
195
+ return json({
196
+ id: guide.meta.id,
197
+ title: guide.meta.title,
198
+ url,
199
+ synced,
200
+ team: guide.meta.team || "",
201
+ to: guide.meta.to || "",
202
+ notified,
203
+ path,
93
204
  });
94
- return json({ id: guide.meta.id, title: guide.meta.title, url, synced, path });
95
205
  } catch (err) {
96
206
  return fail(err);
97
207
  }
@@ -113,7 +223,8 @@ export async function serve() {
113
223
  {
114
224
  title: "Set guide status",
115
225
  description:
116
- "Mark a guide consumed (implemented on the receiving side) or promoted (a reusable reference).",
226
+ "Mark a guide consumed (implemented on the receiving side) or promoted (a reusable reference). " +
227
+ "Only the author can promote.",
117
228
  inputSchema: { id: z.string(), status: z.enum(["published", "consumed", "promoted"]) },
118
229
  },
119
230
  async ({ id, status }) => {
package/src/passalong.js CHANGED
@@ -1,6 +1,6 @@
1
- // The operations Passalong exposes. Both surfaces (bin/passalong and the MCP server) call these, so
2
- // anything an agent can do through MCP a human can do from the terminal and vice versa.
3
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ // The operations Passalong exposes. Both surfaces (bin/passalong and the MCP server) call these,
2
+ // so anything an agent can do through MCP a human can do from the terminal and vice versa.
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import * as api from "./api.js";
6
6
  import { context } from "./capture.js";
@@ -9,11 +9,18 @@ import * as store from "./store.js";
9
9
 
10
10
  export class PassalongError extends Error {}
11
11
 
12
+ /** "team" or "team/handle" → { team, to }. */
13
+ export function parseTarget(to) {
14
+ if (!to) return {};
15
+ const [team, handle] = String(to).split("/");
16
+ return { team: team.trim(), to: handle ? handle.trim().replace(/^@/, "") : undefined };
17
+ }
18
+
12
19
  /**
13
20
  * Publish a guide from markdown: fill in defaults, validate, store locally, and sync when
14
- * logged in. Returns { guide, path, url, synced }.
21
+ * logged in. `to` addresses it: "team" or "team/handle". Returns { guide, path, url, synced }.
15
22
  */
16
- export async function share(markdown, { cwd = process.cwd() } = {}) {
23
+ export async function share(markdown, { cwd = process.cwd(), to } = {}) {
17
24
  const c = context(cwd);
18
25
  const raw = parse(markdown);
19
26
  let guide = stamp(
@@ -23,6 +30,11 @@ export async function share(markdown, { cwd = process.cwd() } = {}) {
23
30
  source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
24
31
  },
25
32
  );
33
+ const target = parseTarget(to);
34
+ if (target.team) guide.meta.team = target.team;
35
+ if (target.to) guide.meta.to = target.to;
36
+ if (guide.meta.to && !guide.meta.team)
37
+ throw new PassalongError("`to:` needs a team — address a handoff as team/handle");
26
38
  const errors = validate(guide);
27
39
  if (errors.length)
28
40
  throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
@@ -31,14 +43,18 @@ export async function share(markdown, { cwd = process.cwd() } = {}) {
31
43
 
32
44
  let url = guide.meta.url || null;
33
45
  let synced = false;
46
+ let notified = false;
34
47
  if (api.loggedIn()) {
35
48
  const res = await api.publish(guide.meta.id, serialize(guide));
36
49
  url = res.url;
37
50
  synced = true;
51
+ notified = Boolean(res.notified);
38
52
  guide = { ...guide, meta: { ...guide.meta, url } };
53
+ } else if (guide.meta.team) {
54
+ throw new PassalongError("sharing to a team needs sync — run `passalong login` first");
39
55
  }
40
56
  const path = store.save(guide);
41
- return { guide, path, url, synced };
57
+ return { guide, path, url, synced, notified };
42
58
  }
43
59
 
44
60
  /** Resolve a reference (id, share URL, or local file path) to a guide. */
@@ -48,7 +64,6 @@ export async function resolve(ref) {
48
64
  return { guide: parse(md), from: "link" };
49
65
  }
50
66
  if (ref.endsWith(".md") && existsSync(ref)) {
51
- const { readFileSync } = await import("node:fs");
52
67
  return { guide: parse(readFileSync(ref, "utf8")), from: "file" };
53
68
  }
54
69
  if (!ID_RE.test(ref))
@@ -71,15 +86,25 @@ export async function resolve(ref) {
71
86
  }
72
87
 
73
88
  /**
74
- * Pull a guide into a working directory: writes .passalong/<id>.md there and returns the guide so
75
- * the caller can put the text straight into an agent's context.
89
+ * Pull a guide into a working directory: writes .passalong/<id>.md there and returns the guide
90
+ * so the caller can put the text straight into an agent's context. A pull of someone else's
91
+ * guide is also recorded server-side, which is how the sender sees the transfer landed.
76
92
  */
77
93
  export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
78
94
  const { guide, from } = await resolve(ref);
95
+ // A locally cached copy of a teammate's guide still counts as a pull for them.
96
+ if (from === "local" && api.loggedIn() && guide.meta.team) {
97
+ api.get(guide.meta.id).catch(() => {});
98
+ }
79
99
  let path = null;
80
100
  if (write) {
81
101
  const dir = join(cwd, ".passalong");
82
102
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
103
+ // A pulled guide carries its own share URL in the frontmatter, and that URL needs no account
104
+ // to read. Committing this directory would publish the guide to anyone who can see the repo,
105
+ // so the directory ignores itself. Delete this file if you do want guides in the repo.
106
+ const ignore = join(dir, ".gitignore");
107
+ if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
83
108
  path = join(dir, `${guide.meta.id}.md`);
84
109
  writeFileSync(path, serialize(guide));
85
110
  }
@@ -87,20 +112,17 @@ export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
87
112
  }
88
113
 
89
114
  /** Local guides merged with synced ones (by id), newest first, optionally filtered. */
90
- export async function list(query = "", { remote = true } = {}) {
91
- const local = store.search(query);
115
+ export async function list(query = "", { remote = true, scope = "" } = {}) {
116
+ const local = scope && scope !== "all" && scope !== "mine" ? [] : store.search(query);
92
117
  const seen = new Set(local.map((g) => g.meta.id));
93
118
  const rows = local.map((g) => ({ ...summary(g), where: "local" }));
94
119
  if (remote && api.loggedIn()) {
95
120
  try {
96
- const res = await api.list(query);
121
+ const res = await api.list(query, scope);
97
122
  for (const r of res.guides) {
98
- if (seen.has(r.id)) {
99
- const row = rows.find((x) => x.id === r.id);
100
- row.where = "synced";
101
- row.url = r.url;
102
- row.pulls = r.pulls;
103
- } else rows.push({ ...r, where: "synced" });
123
+ const row = seen.has(r.id) ? rows.find((x) => x.id === r.id) : null;
124
+ if (row) Object.assign(row, r, { where: "synced" });
125
+ else rows.push({ ...r, where: "synced" });
104
126
  }
105
127
  } catch (err) {
106
128
  rows.warning = `sync unavailable: ${err.message}`;
@@ -109,6 +131,42 @@ export async function list(query = "", { remote = true } = {}) {
109
131
  return rows.sort((a, b) => (b.created || "").localeCompare(a.created || ""));
110
132
  }
111
133
 
134
+ /** Guides handed to you (or your teams) that you have not pulled yet. Needs sync. */
135
+ export async function inbox() {
136
+ const res = await api.inbox();
137
+ return res.guides;
138
+ }
139
+
140
+ /**
141
+ * The state of your transfers as four queues: waiting on you, in flight (handed over, untouched),
142
+ * landed (someone has it, not closed), and worth keeping (pulled enough to promote). Needs sync.
143
+ */
144
+ export const board = () => api.board();
145
+
146
+ /**
147
+ * What happened while you were away: your guides being pulled and shipped, guides handed to you,
148
+ * invites taken up. Each item carries a rendered `text` line so every surface says the same thing.
149
+ */
150
+ export async function activity({ all = false, limit = 50 } = {}) {
151
+ return api.notifications({ unread: !all, limit });
152
+ }
153
+
154
+ /**
155
+ * Report whether a guide actually works. This is the reader's answer, not the author's status:
156
+ * `done` says "I implemented it", a verdict says "I tried it and it holds up" — or does not.
157
+ * A failing verdict needs a note; "it doesn't work" without a reason helps nobody.
158
+ */
159
+ export async function verdict(id, ok, note = "") {
160
+ if (!api.loggedIn()) throw new PassalongError("verdicts need sync — run `passalong login` first");
161
+ if (!ok && !note.trim())
162
+ throw new PassalongError("say what went wrong: passalong failed <id> <what happened>");
163
+ const { guide } = await resolve(id);
164
+ return api.verdict(guide.meta.id, ok, note.trim());
165
+ }
166
+
167
+ /** Mark notifications seen. No ids means everything unread. */
168
+ export const seen = (ids = []) => api.markRead(ids);
169
+
112
170
  export function summary(g) {
113
171
  return {
114
172
  id: g.meta.id,
@@ -119,6 +177,9 @@ export function summary(g) {
119
177
  tags: g.meta.tags || [],
120
178
  stack_assumptions: g.meta.stack_assumptions || [],
121
179
  url: g.meta.url || null,
180
+ team: g.meta.team || "",
181
+ to: g.meta.to || "",
182
+ mine: true,
122
183
  };
123
184
  }
124
185
 
@@ -134,7 +195,7 @@ export async function setStatus(id, status) {
134
195
  }
135
196
 
136
197
  export async function remove(id) {
137
- store.remove(id);
198
+ // Server first: if it refuses (not the author), the local copy must survive too.
138
199
  if (api.loggedIn()) {
139
200
  try {
140
201
  await api.remove(id);
@@ -142,6 +203,7 @@ export async function remove(id) {
142
203
  if (err.status !== 404) throw err;
143
204
  }
144
205
  }
206
+ store.remove(id);
145
207
  }
146
208
 
147
209
  /** Write every local guide to a directory as plain markdown. Returns the paths written. */