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/src/passalong.js CHANGED
@@ -1,24 +1,41 @@
1
1
  // The operations Passalong exposes. Both surfaces (bin/passalong and the MCP server) call these,
2
2
  // so anything an agent can do through MCP a human can do from the terminal and vice versa.
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { join } from "node:path";
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
 
12
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
+ */
13
29
  export function parseTarget(to) {
14
30
  if (!to) return {};
15
- const [team, handle] = String(to).split("/");
16
- return { team: team.trim(), to: handle ? handle.trim().replace(/^@/, "") : undefined };
31
+ const [team, who] = String(to).split("/");
32
+ return { team: team.trim(), to: who ? who.trim().replace(/^@/, "") : undefined };
17
33
  }
18
34
 
19
35
  /**
20
36
  * Publish a guide from markdown: fill in defaults, validate, store locally, and sync when
21
- * logged in. `to` addresses it: "team" or "team/handle". Returns { guide, path, url, synced }.
37
+ * logged in. `to` addresses it: "team", "team/@handle", or "team/#group". Returns
38
+ * { guide, path, url, synced }.
22
39
  */
23
40
  export async function share(markdown, { cwd = process.cwd(), to } = {}) {
24
41
  const c = context(cwd);
@@ -57,6 +74,56 @@ export async function share(markdown, { cwd = process.cwd(), to } = {}) {
57
74
  return { guide, path, url, synced, notified };
58
75
  }
59
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 };
125
+ }
126
+
60
127
  /** Resolve a reference (id, share URL, or local file path) to a guide. */
61
128
  export async function resolve(ref) {
62
129
  if (/^https?:\/\//.test(ref)) {
@@ -85,6 +152,27 @@ export async function resolve(ref) {
85
152
  );
86
153
  }
87
154
 
155
+ /**
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
+
88
176
  /**
89
177
  * Pull a guide into a working directory: writes .passalong/<id>.md there and returns the guide
90
178
  * so the caller can put the text straight into an agent's context. A pull of someone else's
@@ -111,6 +199,99 @@ export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
111
199
  return { guide, from, path, markdown: serialize(guide) };
112
200
  }
113
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
+
114
295
  /** Local guides merged with synced ones (by id), newest first, optionally filtered. */
115
296
  export async function list(query = "", { remote = true, scope = "" } = {}) {
116
297
  const local = scope && scope !== "all" && scope !== "mine" ? [] : store.search(query);
@@ -139,10 +320,23 @@ export async function inbox() {
139
320
 
140
321
  /**
141
322
  * 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.
323
+ * and landed (someone else has it). Needs sync.
143
324
  */
144
325
  export const board = () => api.board();
145
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
+
146
340
  /**
147
341
  * What happened while you were away: your guides being pulled and shipped, guides handed to you,
148
342
  * invites taken up. Each item carries a rendered `text` line so every surface says the same thing.
@@ -160,8 +354,21 @@ export async function verdict(id, ok, note = "") {
160
354
  if (!api.loggedIn()) throw new PassalongError("verdicts need sync — run `passalong login` first");
161
355
  if (!ok && !note.trim())
162
356
  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());
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());
165
372
  }
166
373
 
167
374
  /** Mark notifications seen. No ids means everything unread. */