passalong 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/passalong +114 -9
- package/package.json +1 -1
- package/skill/SKILL.md +57 -1
- package/src/api.js +41 -0
- package/src/guide.js +262 -5
- package/src/mcp.js +328 -23
- package/src/passalong.js +215 -8
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
|
|
39
|
-
passalong promote <id> mark promoted
|
|
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,25 @@
|
|
|
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
|
|
4
|
+
// passalong share [file] [--to TARGET] 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>
|
|
15
|
-
// passalong promote <id> mark promoted
|
|
21
|
+
// passalong done <id> archive it: off your board and out of the free tier's count
|
|
22
|
+
// passalong promote <id> deprecated: mark promoted
|
|
16
23
|
// passalong rm <id> delete a guide locally and from sync
|
|
17
24
|
// passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
|
|
18
25
|
// passalong login [token] create an account, or attach this machine with a token from your hub
|
|
@@ -95,6 +102,10 @@ function activityLine(n) {
|
|
|
95
102
|
return `${dot} ${(n.guide || "").padEnd(8)} ${relDate(n.at).padEnd(10)} ${n.text}`;
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
const MONTHS = "January February March April May June July August September October November December".split(" ");
|
|
106
|
+
/** "September 2026" from an ISO timestamp, without pulling in a date library for one heading. */
|
|
107
|
+
const monthOf = (iso) => `${MONTHS[Number(iso.slice(5, 7)) - 1]} ${iso.slice(0, 4)}`;
|
|
108
|
+
|
|
98
109
|
const commands = {
|
|
99
110
|
async share(f) {
|
|
100
111
|
let path = f._[0];
|
|
@@ -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.
|
|
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
|
|
160
|
-
|
|
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,27 @@ 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}
|
|
334
|
+
console.error(`${g.meta.id} archived — off your board, and it no longer counts against sync`);
|
|
234
335
|
},
|
|
235
336
|
|
|
337
|
+
// Still on the way out: it sets an author lifecycle nothing reads, and the board reports pull
|
|
338
|
+
// counts on its own. It stays, and warns, because removing a command from a published package
|
|
339
|
+
// is a breaking change.
|
|
236
340
|
async promote(f) {
|
|
237
341
|
const id = f._[0] || die("usage: passalong promote <id>");
|
|
238
342
|
const g = await passalong.setStatus(id, "promoted").catch((e) => die(e.message));
|
|
239
343
|
console.error(`${g.meta.id} marked promoted`);
|
|
344
|
+
console.error("note: `promote` is deprecated — the board reports pull counts on their own");
|
|
240
345
|
},
|
|
241
346
|
|
|
242
347
|
async rm(f) {
|
package/package.json
CHANGED
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
|
|
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);
|