passalong 0.1.0 → 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/src/passalong.js CHANGED
@@ -1,19 +1,43 @@
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";
4
- import { join } from "node:path";
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
+ import { basename, join } from "node:path";
5
5
  import * as api from "./api.js";
6
6
  import { context } from "./capture.js";
7
- import { ID_RE, parse, STATUSES, serialize, stamp, stripPlaceholders, validate } from "./guide.js";
7
+ import {
8
+ bugGuide,
9
+ ID_RE,
10
+ parse,
11
+ STATUSES,
12
+ serialize,
13
+ stamp,
14
+ stripPlaceholders,
15
+ validate,
16
+ } from "./guide.js";
8
17
  import * as store from "./store.js";
9
18
 
10
19
  export class PassalongError extends Error {}
11
20
 
21
+ /** "team" or "team/handle" → { team, to }. */
22
+ /**
23
+ * Split `--to` into a team and who inside it: "team", "team/@handle", or "team/#group".
24
+ *
25
+ * Only the `@` is stripped. The `#` is load-bearing and travels into the frontmatter as written —
26
+ * it is the sigil the server reads to tell a person from a group, and dropping it here would turn
27
+ * a handoff to six people into a lookup for a teammate who does not exist.
28
+ */
29
+ export function parseTarget(to) {
30
+ if (!to) return {};
31
+ const [team, who] = String(to).split("/");
32
+ return { team: team.trim(), to: who ? who.trim().replace(/^@/, "") : undefined };
33
+ }
34
+
12
35
  /**
13
36
  * Publish a guide from markdown: fill in defaults, validate, store locally, and sync when
14
- * logged in. Returns { guide, path, url, synced }.
37
+ * logged in. `to` addresses it: "team", "team/@handle", or "team/#group". Returns
38
+ * { guide, path, url, synced }.
15
39
  */
16
- export async function share(markdown, { cwd = process.cwd() } = {}) {
40
+ export async function share(markdown, { cwd = process.cwd(), to } = {}) {
17
41
  const c = context(cwd);
18
42
  const raw = parse(markdown);
19
43
  let guide = stamp(
@@ -23,6 +47,11 @@ export async function share(markdown, { cwd = process.cwd() } = {}) {
23
47
  source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
24
48
  },
25
49
  );
50
+ const target = parseTarget(to);
51
+ if (target.team) guide.meta.team = target.team;
52
+ if (target.to) guide.meta.to = target.to;
53
+ if (guide.meta.to && !guide.meta.team)
54
+ throw new PassalongError("`to:` needs a team — address a handoff as team/handle");
26
55
  const errors = validate(guide);
27
56
  if (errors.length)
28
57
  throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
@@ -31,14 +60,68 @@ export async function share(markdown, { cwd = process.cwd() } = {}) {
31
60
 
32
61
  let url = guide.meta.url || null;
33
62
  let synced = false;
63
+ let notified = false;
34
64
  if (api.loggedIn()) {
35
65
  const res = await api.publish(guide.meta.id, serialize(guide));
36
66
  url = res.url;
37
67
  synced = true;
68
+ notified = Boolean(res.notified);
38
69
  guide = { ...guide, meta: { ...guide.meta, url } };
70
+ } else if (guide.meta.team) {
71
+ throw new PassalongError("sharing to a team needs sync — run `passalong login` first");
39
72
  }
40
73
  const path = store.save(guide);
41
- return { guide, path, url, synced };
74
+ return { guide, path, url, synced, notified };
75
+ }
76
+
77
+ /**
78
+ * File a set of bugs as one report.
79
+ *
80
+ * The report is opened first because each issue's frontmatter names it, and the issues go up one
81
+ * at a time — a partial failure that has filed three of five should say which three, since the
82
+ * three that landed are real guides someone can already act on.
83
+ *
84
+ * Unlike `share()`, this needs sync: a report is a server-side parent, and there is nothing local
85
+ * for a set to belong to. One bug on its own does not — that is just `share()` with a bug
86
+ * document, and it works offline like any other guide.
87
+ */
88
+ export async function fileBugs(
89
+ { title = "", environment = "", issues = [] },
90
+ { cwd = process.cwd(), to } = {},
91
+ ) {
92
+ if (!issues.length) throw new PassalongError("a report needs at least one issue");
93
+ if (!api.loggedIn())
94
+ throw new PassalongError("filing a report needs sync — run `passalong login` first");
95
+ const target = parseTarget(to);
96
+ const { report } = await api.createReport({
97
+ title,
98
+ environment,
99
+ team: target.team || "",
100
+ to: target.to || "",
101
+ });
102
+
103
+ const filed = [];
104
+ try {
105
+ for (const issue of issues) {
106
+ const markdown = bugGuide({ ...issue, report: report.id, environment });
107
+ const { guide, url } = await share(markdown, { cwd, to });
108
+ filed.push({
109
+ id: guide.meta.id,
110
+ title: guide.meta.title,
111
+ area: guide.meta.area || "",
112
+ severity: guide.meta.severity || "",
113
+ url,
114
+ });
115
+ }
116
+ } catch (err) {
117
+ if (!filed.length) throw err;
118
+ throw new PassalongError(
119
+ `filed ${filed.length} of ${issues.length} issues (${filed
120
+ .map((f) => f.id)
121
+ .join(", ")}) before failing: ${err.message}`,
122
+ );
123
+ }
124
+ return { report: { id: report.id, title, environment }, issues: filed };
42
125
  }
43
126
 
44
127
  /** Resolve a reference (id, share URL, or local file path) to a guide. */
@@ -48,7 +131,6 @@ export async function resolve(ref) {
48
131
  return { guide: parse(md), from: "link" };
49
132
  }
50
133
  if (ref.endsWith(".md") && existsSync(ref)) {
51
- const { readFileSync } = await import("node:fs");
52
134
  return { guide: parse(readFileSync(ref, "utf8")), from: "file" };
53
135
  }
54
136
  if (!ID_RE.test(ref))
@@ -71,36 +153,157 @@ export async function resolve(ref) {
71
153
  }
72
154
 
73
155
  /**
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.
156
+ * Resolve a reference to its id alone, without fetching the guide.
157
+ *
158
+ * The reader's two answers — an ack and a verdict — are writes that take an id and nothing else,
159
+ * so the guide body they used to fetch on the way was never read. That fetch was not free:
160
+ * `GET /v1/guides/:id` records a pull, which is how somebody saying "not me" on a handoff
161
+ * registered as taking delivery of it — inflating the guide's pull count and moving it into the
162
+ * sender's "landed" queue. An id needs no lookup at all, and a link or a file carries the id in
163
+ * its own frontmatter.
164
+ *
165
+ * Unlike `resolve()`, a bare id is not checked for existence here. The server answers that when
166
+ * the write lands, and "no such guide" from the route the write went to is the same news one
167
+ * round trip earlier.
168
+ */
169
+ export async function resolveId(ref) {
170
+ if (/^https?:\/\//.test(ref)) return parse(await api.fetchShared(ref)).meta.id;
171
+ if (ref.endsWith(".md") && existsSync(ref)) return parse(readFileSync(ref, "utf8")).meta.id;
172
+ if (ID_RE.test(ref)) return ref;
173
+ throw new PassalongError(`"${ref}" is not a passalong id, share link, or .md file`);
174
+ }
175
+
176
+ /**
177
+ * Pull a guide into a working directory: writes .passalong/<id>.md there and returns the guide
178
+ * so the caller can put the text straight into an agent's context. A pull of someone else's
179
+ * guide is also recorded server-side, which is how the sender sees the transfer landed.
76
180
  */
77
181
  export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
78
182
  const { guide, from } = await resolve(ref);
183
+ // A locally cached copy of a teammate's guide still counts as a pull for them.
184
+ if (from === "local" && api.loggedIn() && guide.meta.team) {
185
+ api.get(guide.meta.id).catch(() => {});
186
+ }
79
187
  let path = null;
80
188
  if (write) {
81
189
  const dir = join(cwd, ".passalong");
82
190
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
191
+ // A pulled guide carries its own share URL in the frontmatter, and that URL needs no account
192
+ // to read. Committing this directory would publish the guide to anyone who can see the repo,
193
+ // so the directory ignores itself. Delete this file if you do want guides in the repo.
194
+ const ignore = join(dir, ".gitignore");
195
+ if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
83
196
  path = join(dir, `${guide.meta.id}.md`);
84
197
  writeFileSync(path, serialize(guide));
85
198
  }
86
199
  return { guide, from, path, markdown: serialize(guide) };
87
200
  }
88
201
 
202
+ /**
203
+ * Pull a guide *and* take the handoff: the two halves of starting work, in one call.
204
+ *
205
+ * `pull` says the guide arrived. `ack` says somebody is doing it, which is the thing the sender
206
+ * cannot find out any other way — and the two are deliberately separate, because reading a guide
207
+ * to decide it is not yours is exactly what `pass` is for. Fetching is not committing.
208
+ *
209
+ * For an agent the distinction is real but the ordering never varies: it reads a guide because it
210
+ * is about to follow it. So this is the path an agent takes when it means to do the work, and it
211
+ * is one call rather than two — the mandated thing has to be the cheap thing or it gets skipped.
212
+ * `pull` alone remains the way to read one without answering for it.
213
+ *
214
+ * The ack is best-effort and never costs the caller the markdown, which is what they came for:
215
+ *
216
+ * - the author's own guide is a 403 (`ack` is the reader's answer and an author is not a party
217
+ * to it), and that is a no-op, not a failure — an agent working in its own user's repo hits
218
+ * this constantly;
219
+ * - logged out, there is nobody to tell;
220
+ * - anything else is reported in `ack_error` rather than swallowed, because an ack that quietly
221
+ * did not land leaves the sender in the silence the whole feature exists to end.
222
+ */
223
+ export async function start(ref, { cwd = process.cwd(), write = true } = {}) {
224
+ const pulled = await pull(ref, { cwd, write });
225
+ const id = pulled.guide.meta.id;
226
+ if (!api.loggedIn()) return { ...pulled, took: false, ack_error: "", own: false };
227
+ try {
228
+ await api.ack(id, true, "");
229
+ return { ...pulled, took: true, ack_error: "", own: false };
230
+ } catch (err) {
231
+ if (err.status === 403) return { ...pulled, took: false, ack_error: "", own: true };
232
+ return { ...pulled, took: false, ack_error: err.message, own: false };
233
+ }
234
+ }
235
+
236
+ /**
237
+ * The one line `get_guide` adds to a guide it is handing an agent, or "" when there is nothing to
238
+ * say. Separate from the tool so it can be tested without a server.
239
+ *
240
+ * Only a guide that was addressed to somebody can be taken — one nobody was handed has no handoff
241
+ * to answer for, and nudging about it is noise that teaches an agent to ignore the nudge.
242
+ *
243
+ * It goes after the document, not in front of it. Anything before the opening `---` stops the
244
+ * frontmatter being frontmatter, and an agent that writes what it was handed back out to a file
245
+ * would lose the id along with it.
246
+ */
247
+ export function handoffNudge(meta = {}) {
248
+ if (!meta.to && !meta.team) return "";
249
+ return (
250
+ "<!-- passalong: this guide was handed to someone. If you are about to do the work, call " +
251
+ "start_guide instead of get_guide — it takes the handoff so the sender stops guessing. " +
252
+ "If you are only reading, or it turns out not to be yours, answer with ack_guide taken=false " +
253
+ "and a reason. -->"
254
+ );
255
+ }
256
+
257
+ /**
258
+ * The image types a guide can carry as evidence. Mirrors SHOT_TYPES in apps/api/src/shots.ts —
259
+ * the server is the authority and refuses anything else; this is what lets a local caller find
260
+ * out before spending an upload on it.
261
+ */
262
+ export const SHOT_TYPES = {
263
+ ".png": "image/png",
264
+ ".jpg": "image/jpeg",
265
+ ".jpeg": "image/jpeg",
266
+ ".webp": "image/webp",
267
+ ".gif": "image/gif",
268
+ };
269
+
270
+ /**
271
+ * Attach a screenshot from disk, and return the markdown that points at it.
272
+ *
273
+ * Evidence belongs *in* the document: a guide travels as markdown to anyone holding its link, so
274
+ * an image beside it would not travel at all. `claimShots` binds the upload to whichever guide's
275
+ * markdown names it at publish, which is why this returns a line to paste rather than taking a
276
+ * guide id — there is nothing to attach it to until the document says so.
277
+ *
278
+ * Local because it reads a path. An agent in a terminal has one; a hosted assistant does not, and
279
+ * reaches the same route through `attach_screenshot` on the HTTP server instead.
280
+ */
281
+ export async function attach(file, { name = "" } = {}) {
282
+ if (!api.loggedIn())
283
+ throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
284
+ if (!existsSync(file)) throw new PassalongError(`no file at ${file}`);
285
+ const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
286
+ const type = SHOT_TYPES[ext];
287
+ if (!type)
288
+ throw new PassalongError(
289
+ `screenshots must be ${[...new Set(Object.values(SHOT_TYPES))].join(", ")} — ${ext || file} is not one`,
290
+ );
291
+ const shot = await api.uploadShot(readFileSync(file), type, name || basename(file));
292
+ return { ...shot, markdown: `![${name || basename(file)}](${shot.url})` };
293
+ }
294
+
89
295
  /** 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);
296
+ export async function list(query = "", { remote = true, scope = "" } = {}) {
297
+ const local = scope && scope !== "all" && scope !== "mine" ? [] : store.search(query);
92
298
  const seen = new Set(local.map((g) => g.meta.id));
93
299
  const rows = local.map((g) => ({ ...summary(g), where: "local" }));
94
300
  if (remote && api.loggedIn()) {
95
301
  try {
96
- const res = await api.list(query);
302
+ const res = await api.list(query, scope);
97
303
  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" });
304
+ const row = seen.has(r.id) ? rows.find((x) => x.id === r.id) : null;
305
+ if (row) Object.assign(row, r, { where: "synced" });
306
+ else rows.push({ ...r, where: "synced" });
104
307
  }
105
308
  } catch (err) {
106
309
  rows.warning = `sync unavailable: ${err.message}`;
@@ -109,6 +312,68 @@ export async function list(query = "", { remote = true } = {}) {
109
312
  return rows.sort((a, b) => (b.created || "").localeCompare(a.created || ""));
110
313
  }
111
314
 
315
+ /** Guides handed to you (or your teams) that you have not pulled yet. Needs sync. */
316
+ export async function inbox() {
317
+ const res = await api.inbox();
318
+ return res.guides;
319
+ }
320
+
321
+ /**
322
+ * The state of your transfers as four queues: waiting on you, in flight (handed over, untouched),
323
+ * and landed (someone else has it). Needs sync.
324
+ */
325
+ export const board = () => api.board();
326
+
327
+ /**
328
+ * What you did, newest first: what you published, what you took delivery of, and every verdict and
329
+ * ack you gave. The other half of `activity` — that one is what other people did to your guides and
330
+ * it clears when you read it; this is your own record and it never clears.
331
+ *
332
+ * It is a record of what you passed along, not of what you worked on. Work that never became a
333
+ * guide has no row, so anything printing this has to say so. Needs sync.
334
+ */
335
+ export async function log({ repo = "", since = "" } = {}) {
336
+ const res = await api.log({ repo, since });
337
+ return res.log;
338
+ }
339
+
340
+ /**
341
+ * What happened while you were away: your guides being pulled and shipped, guides handed to you,
342
+ * invites taken up. Each item carries a rendered `text` line so every surface says the same thing.
343
+ */
344
+ export async function activity({ all = false, limit = 50 } = {}) {
345
+ return api.notifications({ unread: !all, limit });
346
+ }
347
+
348
+ /**
349
+ * Report whether a guide actually works. This is the reader's answer, not the author's status:
350
+ * `done` says "I implemented it", a verdict says "I tried it and it holds up" — or does not.
351
+ * A failing verdict needs a note; "it doesn't work" without a reason helps nobody.
352
+ */
353
+ export async function verdict(id, ok, note = "") {
354
+ if (!api.loggedIn()) throw new PassalongError("verdicts need sync — run `passalong login` first");
355
+ if (!ok && !note.trim())
356
+ throw new PassalongError("say what went wrong: passalong failed <id> <what happened>");
357
+ return api.verdict(await resolveId(id), ok, note.trim());
358
+ }
359
+
360
+ /**
361
+ * Say whether you are taking a guide someone handed you — before doing any of it.
362
+ *
363
+ * The hop the product had no signal for: until this, a handoff nobody had answered looked exactly
364
+ * like one nobody had noticed. Passing needs a reason for the same cause a failing verdict does —
365
+ * "not me" without "why" leaves the sender where the silence did.
366
+ */
367
+ export async function ack(id, taken, note = "") {
368
+ if (!api.loggedIn()) throw new PassalongError("acks need sync — run `passalong login` first");
369
+ if (!taken && !note.trim())
370
+ throw new PassalongError("say why you are passing: pass a note with taken=false");
371
+ return api.ack(await resolveId(id), taken, note.trim());
372
+ }
373
+
374
+ /** Mark notifications seen. No ids means everything unread. */
375
+ export const seen = (ids = []) => api.markRead(ids);
376
+
112
377
  export function summary(g) {
113
378
  return {
114
379
  id: g.meta.id,
@@ -119,6 +384,9 @@ export function summary(g) {
119
384
  tags: g.meta.tags || [],
120
385
  stack_assumptions: g.meta.stack_assumptions || [],
121
386
  url: g.meta.url || null,
387
+ team: g.meta.team || "",
388
+ to: g.meta.to || "",
389
+ mine: true,
122
390
  };
123
391
  }
124
392
 
@@ -134,7 +402,7 @@ export async function setStatus(id, status) {
134
402
  }
135
403
 
136
404
  export async function remove(id) {
137
- store.remove(id);
405
+ // Server first: if it refuses (not the author), the local copy must survive too.
138
406
  if (api.loggedIn()) {
139
407
  try {
140
408
  await api.remove(id);
@@ -142,6 +410,7 @@ export async function remove(id) {
142
410
  if (err.status !== 404) throw err;
143
411
  }
144
412
  }
413
+ store.remove(id);
145
414
  }
146
415
 
147
416
  /** Write every local guide to a directory as plain markdown. Returns the paths written. */