passalong 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,8 +35,8 @@ passalong open <id> [--print] view a guide in the browser (or the terminal)
35
35
  passalong hub open your synced guides in the browser
36
36
  passalong works <id> you tried it and it holds up
37
37
  passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
38
- passalong done <id> mark consumed: implemented on the receiving side
39
- passalong promote <id> mark promoted: graduated into a reusable reference
38
+ passalong done <id> deprecated: mark consumed
39
+ passalong promote <id> deprecated: mark promoted
40
40
  passalong rm <id> delete a guide locally and from sync
41
41
  passalong export [dir] dump every guide as plain markdown
42
42
  passalong login [token] create an account, or attach this machine with a token from your hub
package/bin/passalong CHANGED
@@ -1,18 +1,24 @@
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] [--to team[/handle]] publish a guide → id + link; --to hands it to a team or teammate
4
+ // passalong share [file] [--to TARGET] [--follows ID] publish a guide → id + link
5
+ // TARGET is a team, team/@handle for one person, or "team/#group" for the people who do a
6
+ // thing — quote that one, since a shell may read the # as a comment
7
+ // passalong attach <image> upload evidence and print the markdown that points at it
5
8
  // passalong pull <id|link> fetch a guide into ./.passalong/ and print it
9
+ // passalong start <id|link> pull it and take it — one command for "I am doing this"
6
10
  // passalong inbox guides handed to you that you have not pulled yet
7
11
  // passalong board waiting on you, in flight, landed, worth keeping
8
12
  // passalong activity [--all] what happened while you were away; clears unless --all
13
+ // passalong log [repo] what you did, newest first, by month
9
14
  // passalong list [query] your guides and your teams', local and synced
10
15
  // passalong open <id> [--print] view a guide in the browser (or the terminal)
11
16
  // passalong hub open your synced guides in the browser
17
+ // passalong take <id> you are doing it — said before the work, so the sender stops guessing
18
+ // passalong pass <id> <why> it is not yours — hands it back to its author with your reason
12
19
  // passalong works <id> you tried it and it holds up
13
20
  // passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
14
- // passalong done <id> mark consumed: implemented on the receiving side
15
- // passalong promote <id> mark promoted: graduated into a reusable reference
21
+ // passalong done <id> archive it: off your board and out of the free tier's count
16
22
  // passalong rm <id> delete a guide locally and from sync
17
23
  // passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
18
24
  // passalong login [token] create an account, or attach this machine with a token from your hub
@@ -86,8 +92,7 @@ function guideLine(r) {
86
92
  const p = r.pulled_by[0];
87
93
  pulled = ` ← ${p.handle ? `@${p.handle}` : "link"} ${relDate(p.at)}${r.pulled_by.length > 1 ? ` +${r.pulled_by.length - 1}` : ""}`;
88
94
  }
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}`;
95
+ return `${r.id} ${r.status.padEnd(9)} ${relDate(r.created).padEnd(10)} ${r.title}${who}${tags}${pulled}`;
91
96
  }
92
97
 
93
98
  function activityLine(n) {
@@ -95,6 +100,10 @@ function activityLine(n) {
95
100
  return `${dot} ${(n.guide || "").padEnd(8)} ${relDate(n.at).padEnd(10)} ${n.text}`;
96
101
  }
97
102
 
103
+ const MONTHS = "January February March April May June July August September October November December".split(" ");
104
+ /** "September 2026" from an ISO timestamp, without pulling in a date library for one heading. */
105
+ const monthOf = (iso) => `${MONTHS[Number(iso.slice(5, 7)) - 1]} ${iso.slice(0, 4)}`;
106
+
98
107
  const commands = {
99
108
  async share(f) {
100
109
  let path = f._[0];
@@ -116,12 +125,14 @@ const commands = {
116
125
  if (!existsSync(path)) die(`no such file: ${path}`);
117
126
  if (tty && !f["no-edit"]) edit(path);
118
127
  const to = typeof f.to === "string" ? f.to : undefined;
128
+ const follows = typeof f.follows === "string" ? f.follows : undefined;
119
129
  const { guide, url, synced, notified, path: saved } = await passalong
120
- .share(readFileSync(path, "utf8"), { to })
130
+ .share(readFileSync(path, "utf8"), { to, follows })
121
131
  .catch((e) => die(e.message));
122
132
  if (fromDrafts) store.removeDraft(path);
123
133
  console.log(guide.meta.id);
124
134
  console.error(`shared "${guide.meta.title}" → ${saved}`);
135
+ if (guide.meta.parent) console.error(`follows ${guide.meta.parent}`);
125
136
  if (guide.meta.team)
126
137
  console.error(
127
138
  `handed to ${guide.meta.team}${guide.meta.to ? `/@${guide.meta.to}${notified ? " (emailed)" : " (in their inbox)"}` : " (team inbox)"}`,
@@ -137,11 +148,40 @@ const commands = {
137
148
  console.error(`\npulled from ${from} → ${path}`);
138
149
  },
139
150
 
151
+ /**
152
+ * Pull and take in one command. `pull` says it arrived, `take` says somebody is doing it, and
153
+ * they stay separate because reading a guide to decide it is not yours is what `pass` is for.
154
+ * This is the pair for the case where you already know: you are picking this up.
155
+ */
156
+ async start(f) {
157
+ const ref = f._[0] || die("usage: passalong start <id|link>");
158
+ const r = await passalong.start(ref).catch((e) => die(e.message));
159
+ process.stdout.write(r.markdown);
160
+ console.error(`\npulled from ${r.from} → ${r.path}`);
161
+ if (r.took) console.error("you are on it, and the author has been told");
162
+ else if (r.own) console.error("your own guide — nothing to take");
163
+ else if (r.ack_error) console.error(`not taken (${r.ack_error}) — run \`passalong take ${r.guide.meta.id}\``);
164
+ // Saying nothing here would be the silence the whole hop exists to end: you would have the
165
+ // guide, believe you had answered for it, and the sender would still be waiting.
166
+ else console.error("not taken — sync is off, so nobody was told (run `passalong login`)");
167
+ },
168
+
169
+ /**
170
+ * Upload an image and print the markdown that points at it. Evidence goes *in* the document:
171
+ * a guide travels as markdown to whoever holds its link, so a file beside it does not travel.
172
+ */
173
+ async attach(f) {
174
+ const file = f._[0] || die("usage: passalong attach <image> [--name LABEL]");
175
+ const shot = await passalong.attach(file, { name: f.name || "" }).catch((e) => die(e.message));
176
+ process.stdout.write(`${shot.markdown}\n`);
177
+ console.error(`uploaded ${shot.bytes} bytes → ${shot.url}`);
178
+ },
179
+
140
180
  async inbox() {
141
181
  const rows = await passalong.inbox().catch((e) => die(e.message));
142
182
  if (!rows.length) return console.error("inbox empty — nothing handed to you that you haven't pulled");
143
183
  for (const r of rows) console.log(guideLine(r));
144
- console.error(`\n${rows.length} waiting. Pull one with: passalong pull <id>`);
184
+ console.error(`\n${rows.length} waiting. Take one with: passalong start <id>`);
145
185
  },
146
186
 
147
187
  async board() {
@@ -156,14 +196,45 @@ const commands = {
156
196
  `${g.title} ${g.verdict?.by ? `@${g.verdict.by}` : "someone"}: ${g.verdict?.note || "no reason given"}`);
157
197
  queue("In flight", "handed over, nobody has taken it", b.in_flight, (g) =>
158
198
  `${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;
199
+ queue("Landed", "someone else has it", b.landed, (g) => `${g.title} ${g.pulls} pull${g.pulls === 1 ? "" : "s"}`);
200
+ const total = b.waiting.length + b.failing.length + b.in_flight.length + b.landed.length;
163
201
  if (!total) console.error("nothing in flight — every transfer is closed");
164
202
  else if (b.unread) console.error(`\n${b.unread} new: passalong activity`);
165
203
  },
166
204
 
205
+ /**
206
+ * What you did, newest first, under a heading per month.
207
+ *
208
+ * The month headings are the whole point: every other list in this CLI is ordered by what needs
209
+ * you — the board's queues, `list`'s state rank — and none of them can answer "what did I get
210
+ * done in September". Time is the axis nothing else offers.
211
+ *
212
+ * A repo narrows it, positionally and without a flag, for the same reason `list` takes its query
213
+ * that way: it is the one thing you are likely to say, and a flag would make you name it twice.
214
+ */
215
+ async log(f) {
216
+ const repo = f._.join(" ");
217
+ const rows = await passalong.log({ repo }).catch((e) => die(e.message));
218
+ if (!rows.length)
219
+ return console.error(
220
+ repo
221
+ ? `nothing passed along from anything matching "${repo}"`
222
+ : "nothing yet — your log fills up as you share, pull and answer guides",
223
+ );
224
+ let month = "";
225
+ for (const r of rows) {
226
+ const m = monthOf(r.at);
227
+ if (m !== month) {
228
+ console.log(`\n${m}`);
229
+ month = m;
230
+ }
231
+ console.log(` ${r.guide} ${r.at.slice(0, 10)} ${r.text}${r.repo && !repo ? ` ${r.repo}` : ""}`);
232
+ }
233
+ // Said every time, because the list looks like a work log and is not one. A month you shipped
234
+ // without sharing looks identical here to a month you did nothing.
235
+ console.error(`\n${rows.length} thing${rows.length === 1 ? "" : "s"}. Work that never became a guide is not here.`);
236
+ },
237
+
167
238
  async activity(f) {
168
239
  const { notifications, unread } = await passalong.activity({ all: f.all }).catch((e) => die(e.message));
169
240
  if (!notifications.length)
@@ -213,6 +284,29 @@ const commands = {
213
284
  } else console.log(url);
214
285
  },
215
286
 
287
+ /**
288
+ * The first word back, before any of the work.
289
+ *
290
+ * Between a handoff and a pull the sender had no signal at all: an unanswered guide looks
291
+ * exactly like one nobody has noticed. These two are the hop the MCP tool and the hub already
292
+ * had and a terminal did not.
293
+ */
294
+ async take(f) {
295
+ const id = f._[0] || die("usage: passalong take <id>");
296
+ const r = await passalong.ack(id, true).catch((e) => die(e.message));
297
+ console.error(`${r.id} — you are on it, and the author has been told`);
298
+ },
299
+
300
+ async pass(f) {
301
+ const id = f._[0] || die("usage: passalong pass <id> <why it is not yours>");
302
+ const why = f._.slice(1).join(" ");
303
+ // Required, and the reason is the whole point: "not me" without "why" leaves the sender where
304
+ // the silence did, still guessing whether to re-address it, wait, or go and ask.
305
+ if (!why) die("say why you are passing: passalong pass <id> <why it is not yours>");
306
+ const r = await passalong.ack(id, false, why).catch((e) => die(e.message));
307
+ console.error(`${r.id} handed back — the author has been told: "${r.note}"`);
308
+ },
309
+
216
310
  async works(f) {
217
311
  const id = f._[0] || die("usage: passalong works <id>");
218
312
  const r = await passalong.verdict(id, true, f._.slice(1).join(" ")).catch((e) => die(e.message));
@@ -227,16 +321,17 @@ const commands = {
227
321
  console.error(`${r.id} reported as not working — the author has been told: "${r.note}"`);
228
322
  },
229
323
 
324
+ /**
325
+ * Archive a guide: off your board, and out of what the free tier counts.
326
+ *
327
+ * It was deprecated for meaning "I implemented it", which is the reader's judgement and belongs
328
+ * to the verdict. As shelf space it has a job nothing else does — it is the only way under the
329
+ * sync limit that does not delete the work, and the 402 says so by name.
330
+ */
230
331
  async done(f) {
231
332
  const id = f._[0] || die("usage: passalong done <id>");
232
333
  const g = await passalong.setStatus(id, "consumed").catch((e) => die(e.message));
233
- console.error(`${g.meta.id} marked consumed`);
234
- },
235
-
236
- async promote(f) {
237
- const id = f._[0] || die("usage: passalong promote <id>");
238
- const g = await passalong.setStatus(id, "promoted").catch((e) => die(e.message));
239
- console.error(`${g.meta.id} marked promoted`);
334
+ console.error(`${g.meta.id} archived — off your board, and it no longer counts against sync`);
240
335
  },
241
336
 
242
337
  async rm(f) {
@@ -287,7 +382,15 @@ const commands = {
287
382
  console.log(`handle ${who.handle ? `@${who.handle}` : "(none — set one: passalong me --handle <you>)"}`);
288
383
  if (who.name) console.log(`name ${who.name}`);
289
384
  if (who.email) console.log(`email ${who.email}`);
290
- console.log(`guides ${who.guides} synced (${who.limit} active on the free tier)`);
385
+ // The server sends the state by name, because a number cannot tell "no ceiling" apart from
386
+ // "may sync nothing" — both of which arrive as a falsy limit.
387
+ const ceiling =
388
+ who.sync === "free"
389
+ ? ` (${who.limit} active on your plan)`
390
+ : who.sync === "none"
391
+ ? " (no plan — nothing is syncing)"
392
+ : " (no limit on your plan)";
393
+ console.log(`guides ${who.guides} synced${ceiling}`);
291
394
  const current = store.readConfig().team;
292
395
  console.log(
293
396
  `teams ${who.teams.length ? who.teams.map((t) => `${t.slug}${t.slug === current ? "*" : ""} (${t.role})`).join(", ") : "(none — passalong team create <name>)"}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
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",
package/skill/SKILL.md CHANGED
@@ -34,11 +34,35 @@ session's history. Write for execution, not for permanence.
34
34
  team's members and their handles. It prints the guide id on stdout and the share link (and
35
35
  who it was handed to) on stderr.
36
36
  6. **Report** the id (and link if synced) and one line on how to use it on the other side:
37
- `passalong pull <id>` in the target repo, or "pull passalong <id>" to an agent with the Passalong MCP server.
37
+ `passalong start <id>` in the target repo, or "start passalong <id>" to an agent with the
38
+ Passalong MCP server. `start` rather than `pull` on purpose: it takes the handoff as well as
39
+ fetching it, and until somebody says they are on it the sender cannot tell a guide nobody
40
+ noticed from one somebody is deep in.
38
41
 
39
42
  If the `passalong` command is missing, tell the user to run `npm i -g passalong && passalong setup` and
40
43
  still write the draft file so nothing is lost.
41
44
 
45
+ ## Two kinds of guide
46
+
47
+ `kind:` in the frontmatter says what a guide is for, and the receiver behaves differently for
48
+ each. Absent means `transfer`, which is what every guide written before bug reports existed is.
49
+
50
+ | kind | what it is | what the receiver does |
51
+ | --- | --- | --- |
52
+ | `transfer` (default) | finished work to repeat somewhere else | follows the **Steps** |
53
+ | `bug` | a defect to fix where it is | fixes it — **Reproduce** shows the problem, it is not a procedure to apply |
54
+
55
+ To file bugs you found but are not fixing, call `file_bugs` with all of them in one call — it
56
+ opens a report and publishes each issue as its own guide, so any one of them can be handed to
57
+ whoever fixes it. `publish_guide` is for a single guide: work you finished and want repeated, or
58
+ one bug on its own.
59
+
60
+ A bug has no `## Steps` and publishing one with a Steps section is refused. That is the whole
61
+ reason the kinds are separate: "follow its Steps" is what the MCP server tells every agent that
62
+ pulls a guide, and steps that reproduce a defect are the one list that must never be run as a
63
+ remedy — an agent that follows them reproduces the bug, checks the Verification, finds it false
64
+ because the bug is real, and reports that the guide does not work.
65
+
42
66
  ## Guide structure
43
67
 
44
68
  ```markdown
@@ -71,6 +95,34 @@ Commands to run and what they should print. Test cases. What "done" looks like.
71
95
  - What failed along the way, the exact error, and why. What looked right but wasn't.
72
96
  ```
73
97
 
98
+ ## Bug structure
99
+
100
+ ```markdown
101
+ ---
102
+ title: <what is broken, in one line: "Undo leaves section drag handles dead">
103
+ kind: bug
104
+ severity: <s1 blocker | s2 major | s3 minor | s4 cosmetic>
105
+ area: <which surface it is on>
106
+ report: <the report id, when it was filed with others>
107
+ status: published
108
+ tags: [bug, <2 to 4 more>]
109
+ ---
110
+
111
+ > **Bug report.** The steps under Reproduce show the problem — they are not a fix to apply. Fix what Problem describes, then check Verification.
112
+
113
+ ## Problem
114
+ What is broken and what it stops someone doing. The observable symptom, and the error if there is one.
115
+
116
+ ## Reproduce
117
+ 1. The steps that show the bug. These produce the defect — they are not a fix to apply.
118
+
119
+ ## Verification
120
+ The behaviour that should have happened, as something the fixer can check.
121
+
122
+ ## Gotchas
123
+ - Anything already ruled out, or that made it hard to pin down.
124
+ ```
125
+
74
126
  ## Quality bar
75
127
 
76
128
  - **Gotchas is the highest-value section.** If nothing went wrong, say what would have gone
@@ -82,3 +134,7 @@ Commands to run and what they should print. Test cases. What "done" looks like.
82
134
  - **No session narration.** "First I looked at..." is noise. State the finding.
83
135
  - **No secrets.** Redact tokens, keys, and internal hostnames that would not apply elsewhere.
84
136
  - Title, Problem, and Steps are required; the guide will not publish without them.
137
+ - For a bug: title, Problem and **Reproduce**. Never a Steps section — see the two kinds above.
138
+ - The line above Problem is written by `file_bugs` and the hub's form. It is in the document, not
139
+ added by whatever served it, so an agent that fetched the share link over plain HTTP — with no
140
+ MCP server and no knowledge of Passalong — still reads what the document is before acting on it.
package/src/api.js CHANGED
@@ -73,6 +73,9 @@ export const join = (code) =>
73
73
  call(`/v1/invites/${encodeURIComponent(code)}/accept`, { method: "POST" });
74
74
 
75
75
  export const board = () => call("/v1/board");
76
+ /** What you did, newest first. `since` is a date prefix: 2026, 2026-09, 2026-09-11. */
77
+ export const log = ({ repo = "", since = "", limit = 0 } = {}) =>
78
+ call(`/v1/log${q({ repo, since, limit: limit || "" })}`);
76
79
  export const notifications = ({ unread = true, limit = 0 } = {}) =>
77
80
  call(`/v1/notifications${q({ unread: unread ? "1" : "", limit: limit || "" })}`);
78
81
  /** No ids means "everything unread". */
@@ -83,6 +86,10 @@ export const publish = (id, markdown) =>
83
86
  call(`/v1/guides/${id}`, { method: "PUT", body: markdown });
84
87
  export const list = (query = "", scope = "") => call(`/v1/guides${q({ q: query, scope })}`);
85
88
  export const inbox = () => call("/v1/inbox");
89
+ /** Open a report for a set of issues to be filed under. */
90
+ export const createReport = (body) => call("/v1/reports", { method: "POST", body });
91
+ /** One report and the issues filed under it, grouped by product area. */
92
+ export const report = (id) => call(`/v1/reports/${encodeURIComponent(id)}`);
86
93
  export const get = (id) => call(`/v1/guides/${id}`, { raw: true });
87
94
  export const setStatus = (id, status) =>
88
95
  call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
@@ -91,6 +98,40 @@ export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
91
98
  export const verdict = (id, ok, note = "") =>
92
99
  call(`/v1/guides/${id}/verdict`, { method: "PUT", body: { ok, note } });
93
100
 
101
+ export const ack = (id, taken, note = "") =>
102
+ call(`/v1/guides/${id}/ack`, { method: "PUT", body: { taken, note } });
103
+
104
+ /**
105
+ * Upload an image as evidence, and get back the URL a guide points at.
106
+ *
107
+ * Not `call()`: that serialises a body as JSON or markdown, and this route reads bytes and takes
108
+ * the content type as the declaration of what they are. `name` is the label, and it travels in a
109
+ * header rather than the body for the same reason.
110
+ */
111
+ export async function uploadShot(bytes, type, name = "") {
112
+ const t = token();
113
+ if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
114
+ const headers = { authorization: `Bearer ${t}`, "content-type": type };
115
+ // A header is latin-1: a filename with an accent in it throws on the way out rather than at the
116
+ // server, and the label is not worth failing an upload over.
117
+ if (name) headers["x-shot-name"] = String(name).replace(/[^\x20-\x7e]/g, "");
118
+ let res;
119
+ try {
120
+ res = await fetch(`${baseUrl()}/v1/shots`, { method: "POST", headers, body: bytes });
121
+ } catch (err) {
122
+ throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
123
+ }
124
+ if (!res.ok) {
125
+ const body = await res.text();
126
+ let message = body;
127
+ try {
128
+ message = JSON.parse(body).message || body;
129
+ } catch {}
130
+ throw new ApiError(res.status, message || res.statusText);
131
+ }
132
+ return (await res.json()).shot;
133
+ }
134
+
94
135
  /** Fetch a guide by its share link (no account needed). Accepts the web URL or the .md URL. */
95
136
  export async function fetchShared(url) {
96
137
  const u = new URL(url);