passalong 0.20.0 → 1.0.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/api.js CHANGED
@@ -1,5 +1,5 @@
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 an ApiError the CLI turns into a hint.
1
+ // Client for the hosted API (apps/api). Work lives there, so nearly every call needs a login, and
2
+ // every function throws an ApiError the CLI turns into a hint.
3
3
  import { readFileSync } from "node:fs";
4
4
  import { readFile, stat } from "node:fs/promises";
5
5
  import { basename } from "node:path";
@@ -27,7 +27,7 @@ export class ApiError extends Error {
27
27
  // ---- which account -----------------------------------------------------------------------------
28
28
  //
29
29
  // A machine may be signed in to several (store.js). Who a call is made as comes from, in order:
30
- // PASSALONG_TOKEN, an account named by PASSALONG_ACCOUNT, `--as` or `use_account` (this process
30
+ // PASSALONG_TOKEN, an account named by PASSALONG_ACCOUNT, `--as` or `accounts` `use` (this process
31
31
  // only), then the machine's default. With two or more logins and nobody having said which, a
32
32
  // person at a terminal gets the default and an agent — a process with no terminal to ask on — is
33
33
  // refused with the names, so it asks the person rather than choosing. That refusal is the prompt:
@@ -102,7 +102,7 @@ export function choiceMessage() {
102
102
  .join(", ");
103
103
  return (
104
104
  `More than one Passalong account is signed in on this machine (${list}) and nothing says which ` +
105
- "this session is for. Ask the person which one to use, then call use_account with its name " +
105
+ "this session is for. Ask the person which one to use, then call accounts with `use` set to its name " +
106
106
  "(or run the command with --as <name>). Do not choose one yourself."
107
107
  );
108
108
  }
@@ -120,6 +120,19 @@ export function loggedIn() {
120
120
  return Boolean(token());
121
121
  }
122
122
 
123
+ /**
124
+ * Every login this machine holds, as `{ token, api }` for a call's `as`: each kept account and the
125
+ * one in use, once each. For work that belongs to all of them, like `tidy`, rather than to one.
126
+ */
127
+ export function logins() {
128
+ const all = Object.values(readAccounts())
129
+ .filter((a) => a.token)
130
+ .map((a) => ({ token: a.token, api: a.api || readConfig().api || DEFAULT_API }));
131
+ if (token()) all.push({ token: token(), api: baseUrl() });
132
+ const seen = new Set();
133
+ return all.filter((a) => !seen.has(`${a.token} ${a.api}`) && seen.add(`${a.token} ${a.api}`));
134
+ }
135
+
123
136
  /** The team this account works in. It belongs to the account, not the machine. */
124
137
  export function currentTeam() {
125
138
  const p = picked();
@@ -149,12 +162,12 @@ export { nameFor };
149
162
 
150
163
  async function call(
151
164
  path,
152
- { method = "GET", body, auth = true, raw = false, uploadName = "" } = {},
165
+ { method = "GET", body, auth = true, raw = false, uploadName = "", as = null } = {},
153
166
  ) {
154
167
  const headers = { ...CLIENT };
155
168
  if (auth) {
156
- if (needsChoice()) throw new ApiError(409, choiceMessage());
157
- const t = token();
169
+ if (!as && needsChoice()) throw new ApiError(409, choiceMessage());
170
+ const t = as ? as.token : token();
158
171
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
159
172
  headers.authorization = `Bearer ${t}`;
160
173
  }
@@ -166,9 +179,10 @@ async function call(
166
179
  : "application/json";
167
180
  }
168
181
  if (uploadName) headers["x-file-name"] = uploadName.replace(/[^\x20-\x7e]/g, "").slice(0, 120);
182
+ const base = as ? as.api.replace(/\/$/, "") : baseUrl();
169
183
  let res;
170
184
  try {
171
- res = await fetch(`${baseUrl()}${path}`, {
185
+ res = await fetch(`${base}${path}`, {
172
186
  method,
173
187
  headers,
174
188
  body:
@@ -179,7 +193,7 @@ async function call(
179
193
  : JSON.stringify(body),
180
194
  });
181
195
  } catch (err) {
182
- throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
196
+ throw new ApiError(0, `could not reach ${base} (${err.message})`);
183
197
  }
184
198
  if (!res.ok) {
185
199
  const text = await res.text();
@@ -201,58 +215,6 @@ const q = (params) => {
201
215
  return s ? `?${s}` : "";
202
216
  };
203
217
 
204
- /**
205
- * Sign in, or make the account, and come back with a token for this machine.
206
- *
207
- * Three calls, because the API answers a browser and this is not one: signing in sets a session
208
- * cookie, and the CLI carries a bearer token instead — one that shows up in the hub's token list
209
- * with a name on it, and can be revoked there without changing the password. So the cookie is used
210
- * once, to mint the token, and then dropped.
211
- *
212
- * `make` is signup rather than sign-in. It is the caller's decision and not a fallback here: an
213
- * account created because an address was mistyped is worse than being told there is no such
214
- * account.
215
- */
216
- export async function signIn(email, password, { make = false, label = "" } = {}) {
217
- const res = await fetch(`${baseUrl()}${make ? "/v1/auth/signup" : "/v1/auth/login"}`, {
218
- method: "POST",
219
- headers: { "content-type": "application/json" },
220
- body: JSON.stringify({ email, password }),
221
- }).catch((err) => {
222
- throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
223
- });
224
- if (!res.ok) {
225
- const said = await res.json().catch(() => ({}));
226
- throw new ApiError(res.status, said.message || res.statusText);
227
- }
228
- const { account } = await res.json();
229
- // `getSetCookie` keeps the cookies apart; a joined `set-cookie` header cannot be split safely,
230
- // because an Expires date has a comma in it.
231
- const cookie = (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
232
- if (!cookie) throw new ApiError(500, "signed in, but no session came back");
233
-
234
- const minted = await fetch(`${baseUrl()}/v1/tokens`, {
235
- method: "POST",
236
- headers: { "content-type": "application/json", cookie },
237
- body: JSON.stringify({ name: label || "cli" }),
238
- });
239
- if (!minted.ok) {
240
- const said = await minted.json().catch(() => ({}));
241
- throw new ApiError(minted.status, said.message || "could not make a token for this machine");
242
- }
243
- const { token: made } = await minted.json();
244
- return { account, token: made };
245
- }
246
-
247
- /**
248
- * Mint an account with no email and no password.
249
- *
250
- * Nothing in this package calls it any more — `passalong login` asks for an email and a password,
251
- * because an account nobody can sign in to is one that dies with the file it is stored in, and the
252
- * CLI used to make one without ever saying so. It stays exported because the route it calls is
253
- * still live and still right for the invite page, which mints before it claims, and because
254
- * removing a published export is a breaking change for anyone who imported it.
255
- */
256
218
  /**
257
219
  * Sign this machine in from the browser. `challenge` is the sha256, in hex, of a secret kept here;
258
220
  * what comes back is a short code to show, the page to open, and how often to ask whether it was
@@ -279,7 +241,6 @@ export async function devicePoll(id, verifier) {
279
241
  return said;
280
242
  }
281
243
 
282
- export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
283
244
  export const me = () => call("/v1/me");
284
245
  export const updateMe = (patch) => call("/v1/me", { method: "PATCH", body: patch });
285
246
 
@@ -300,101 +261,83 @@ export const removeMember = (slug, who) =>
300
261
  export const join = (code) =>
301
262
  call(`/v1/invites/${encodeURIComponent(code)}/accept`, { method: "POST" });
302
263
 
303
- export const board = () => call("/v1/board");
304
- export const folders = (scope = "all", { query = "", guide = "" } = {}) =>
305
- call(`/v1/folders${q({ scope, q: query, guide })}`);
306
- export const createFolder = (title, description = "", team = "", parent = "", color = "") =>
307
- call("/v1/folders", { method: "POST", body: { title, description, team, parent, color } });
308
- export const folder = (id) => call(`/v1/folders/${encodeURIComponent(id)}`);
309
- export const updateFolder = (id, changes) =>
310
- call(`/v1/folders/${encodeURIComponent(id)}`, { method: "PATCH", body: changes });
311
- export const folderDocument = (folderId, documentId) =>
312
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`);
313
- export const folderAsset = (folderId, assetId) =>
314
- call(`/v1/folders/${encodeURIComponent(folderId)}/assets/${encodeURIComponent(assetId)}/agent`);
315
- export async function addFolderAsset(folderId, path, name = "") {
264
+ export const projects = (scope = "all", query = "") =>
265
+ call(`/v1/projects${q({ scope, q: query })}`);
266
+ export const createProject = (
267
+ title,
268
+ description = "",
269
+ team = "",
270
+ parent = "",
271
+ color = "",
272
+ repo = "",
273
+ ) =>
274
+ call("/v1/projects", { method: "POST", body: { title, description, team, parent, color, repo } });
275
+ export const project = (id) => call(`/v1/projects/${encodeURIComponent(id)}`);
276
+ export const updateProject = (id, changes) =>
277
+ call(`/v1/projects/${encodeURIComponent(id)}`, { method: "PATCH", body: changes });
278
+ export const projectDocument = (projectId, documentId) =>
279
+ call(`/v1/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}`);
280
+ export const projectFile = (projectId, fileId) =>
281
+ call(`/v1/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileId)}/agent`);
282
+ export async function addProjectFile(projectId, path, name = "") {
316
283
  const file = await stat(path);
317
- if (!file.isFile()) throw new ApiError(400, "Choose a file to add to the folder.");
284
+ if (!file.isFile()) throw new ApiError(400, "Choose a file to add to the project.");
318
285
  if (file.size > 10 * 1024 * 1024) throw new ApiError(413, "That file is over 10 MB.");
319
- return call(`/v1/folders/${encodeURIComponent(folderId)}/assets`, {
286
+ return call(`/v1/projects/${encodeURIComponent(projectId)}/files`, {
320
287
  method: "POST",
321
288
  body: new Uint8Array(await readFile(path)),
322
289
  uploadName: name || basename(path),
323
290
  });
324
291
  }
325
- export const createFolderDocument = (folderId, name, body) =>
326
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents`, {
292
+ export const createProjectDocument = (projectId, name, body) =>
293
+ call(`/v1/projects/${encodeURIComponent(projectId)}/documents`, {
327
294
  method: "POST",
328
295
  body: { name, body },
329
296
  });
330
- export const saveFolderDocument = (folderId, documentId, version, body) =>
331
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
332
- method: "PUT",
333
- body: { version, body },
334
- });
335
- export const renameFolderDocument = (folderId, documentId, name) =>
336
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
337
- method: "PATCH",
338
- body: { name },
339
- });
340
- export const moveFolderDocument = (folderId, documentId, to, name = "") =>
341
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
342
- method: "PATCH",
343
- body: name ? { folder: to, name } : { folder: to },
344
- });
345
- export const folderDocumentHistory = (folderId, documentId, version = 0) =>
297
+ export const saveProjectDocument = (projectId, documentId, version, body) =>
346
298
  call(
347
- `/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}/revisions${version ? `/${version}` : ""}`,
299
+ `/v1/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}`,
300
+ {
301
+ method: "PUT",
302
+ body: { version, body },
303
+ },
348
304
  );
349
- export const shareFolder = (folderId) =>
350
- call(`/v1/folders/${encodeURIComponent(folderId)}/share`, { method: "POST" });
351
- export const linkFolderGuide = (folderId, guide) =>
352
- call(`/v1/folders/${encodeURIComponent(folderId)}/guides`, { method: "POST", body: { guide } });
353
- export const unlinkFolderGuide = (folderId, guide) =>
354
- call(`/v1/folders/${encodeURIComponent(folderId)}/guides/${encodeURIComponent(guide)}`, {
355
- method: "DELETE",
356
- });
357
- export const deleteFolderDocument = (folderId, documentId) =>
358
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
359
- method: "DELETE",
360
- });
361
- export const deleteFolderAsset = (folderId, assetId) =>
362
- call(`/v1/folders/${encodeURIComponent(folderId)}/assets/${encodeURIComponent(assetId)}`, {
305
+ /** Rename a document, move it to another project (`project`), or both. Its id and history stay. */
306
+ export const patchProjectDocument = (projectId, documentId, changes) =>
307
+ call(
308
+ `/v1/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}`,
309
+ {
310
+ method: "PATCH",
311
+ body: changes,
312
+ },
313
+ );
314
+ export const projectDocumentHistory = (projectId, documentId, version = 0) =>
315
+ call(
316
+ `/v1/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}/revisions${version ? `/${version}` : ""}`,
317
+ );
318
+ export const deleteProjectDocument = (projectId, documentId) =>
319
+ call(
320
+ `/v1/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}`,
321
+ {
322
+ method: "DELETE",
323
+ },
324
+ );
325
+ export const deleteProjectFile = (projectId, fileId) =>
326
+ call(`/v1/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileId)}`, {
363
327
  method: "DELETE",
364
328
  });
365
- /** What you did, newest first. `since` is a date prefix: 2026, 2026-09, 2026-09-11. */
366
- export const log = ({ repo = "", since = "", limit = 0 } = {}) =>
367
- call(`/v1/log${q({ repo, since, limit: limit || "" })}`);
368
- export const notifications = ({ unread = true, limit = 0 } = {}) =>
369
- call(`/v1/notifications${q({ unread: unread ? "1" : "", limit: limit || "" })}`);
370
- /** No ids means "everything unread". */
371
- export const markRead = (ids = []) =>
372
- call("/v1/notifications/read", { method: "POST", body: { ids } });
373
-
374
- export const publish = (id, markdown) =>
375
- call(`/v1/guides/${id}`, { method: "PUT", body: markdown });
376
- export const list = (query = "", scope = "") => call(`/v1/guides${q({ q: query, scope })}`);
377
- export const inbox = () => call("/v1/inbox");
378
- /** Open a report for a set of issues to be filed under. */
379
- export const createReport = (body) => call("/v1/reports", { method: "POST", body });
380
- /** One report and the issues filed under it, grouped by product area. */
381
- export const report = (id) => call(`/v1/reports/${encodeURIComponent(id)}`);
382
- export const get = (id, { peek = false } = {}) =>
383
- call(`/v1/guides/${id}${q({ peek: peek ? "1" : "" })}`, { raw: true });
384
- /** One page of every guide this account can read, for `export`. Records no pull. */
385
- export const exportPage = (after = "") => call(`/v1/export${q({ after })}`);
386
- /**
387
- * A guide's follow-ups, one level down. `markdown` adds each one's content, oldest first and
388
- * capped server-side; neither form counts as opening them.
389
- */
390
- export const children = (id, { markdown = false } = {}) =>
391
- call(`/v1/guides/${encodeURIComponent(id)}/children${q({ markdown: markdown ? "1" : "" })}`);
329
+ export const publish = (id, markdown) => call(`/v1/work/${id}`, { method: "PUT", body: markdown });
330
+ export const list = (query = "", scope = "") => call(`/v1/work${q({ q: query, scope })}`);
331
+ export const get = (id, { peek = false, as = null } = {}) =>
332
+ call(`/v1/work/${id}${q({ peek: peek ? "1" : "" })}`, { raw: true, as });
333
+ /** One page of every piece of work this account can read, for `export`. Records no pull. */
334
+ export const exportPage = (after = "", as = null) => call(`/v1/export${q({ after })}`, { as });
392
335
  /**
393
- * The guide this one came out of, with where it has got to. `markdown` adds its content. Like the
394
- * follow-ups, reading it is not opening it: no pull is recorded on the parent.
336
+ * The work this one came out of, with where it has got to. `markdown` adds its content. Reading it
337
+ * is not opening it: no pull is recorded on the parent.
395
338
  */
396
339
  export const parent = (id, { markdown = false } = {}) =>
397
- call(`/v1/guides/${encodeURIComponent(id)}/parent${q({ markdown: markdown ? "1" : "" })}`);
340
+ call(`/v1/work/${encodeURIComponent(id)}/parent${q({ markdown: markdown ? "1" : "" })}`);
398
341
  /**
399
342
  * Giving a plan away, for whoever the deployment names in `ADMIN_ACCOUNTS`. Every other account
400
343
  * gets a 404 from these, so the CLI does not have to know who the operator is either.
@@ -410,63 +353,29 @@ export const listGifts = () => call("/v1/admin/gifts");
410
353
  export const takeGiftBack = (id) =>
411
354
  call(`/v1/admin/gifts/${encodeURIComponent(id)}`, { method: "DELETE" });
412
355
  export const setStatus = (id, status) =>
413
- call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
414
- export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
415
- /** Does it actually work? `note` is required when it does not. */
416
- export const verdict = (id, ok, note = "", detail = "") =>
417
- call(`/v1/guides/${id}/verdict`, {
418
- method: "PUT",
419
- body: { ok, note, ...(detail ? { detail } : {}) },
420
- });
421
-
356
+ call(`/v1/work/${id}/status`, { method: "PATCH", body: { status } });
357
+ export const remove = (id) => call(`/v1/work/${id}`, { method: "DELETE" });
422
358
  export const tasks = () => call("/v1/tasks");
423
359
  export const nextTask = (body) => call("/v1/tasks/next", { method: "POST", body });
424
- export const taskProgress = (id, body) =>
425
- call(`/v1/tasks/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
426
- export const finishTask = (id, body) =>
427
- call(`/v1/tasks/${encodeURIComponent(id)}/finish`, { method: "POST", body });
428
- export const approveTask = (id) =>
429
- call(`/v1/tasks/${encodeURIComponent(id)}/approve`, { method: "POST", body: {} });
430
- export const rejectTask = (id, why) =>
431
- call(`/v1/tasks/${encodeURIComponent(id)}/reject`, { method: "POST", body: { why } });
432
360
  export const reopen = (id, why) =>
433
- call(`/v1/guides/${encodeURIComponent(id)}/reopen`, { method: "POST", body: { why } });
434
- /** A handoff's or bug's approve and send-back. No `place` names the only hand-in there is. */
435
- export const closeGuide = (id) =>
436
- call(`/v1/guides/${encodeURIComponent(id)}/close`, { method: "POST", body: {} });
437
- export const sendBack = (id, why) =>
438
- call(`/v1/guides/${encodeURIComponent(id)}/send_back`, { method: "POST", body: { why } });
439
- /** Take work back from whoever holds it, of any kind. `/v1/tasks/…/release` is the old path. */
361
+ call(`/v1/work/${encodeURIComponent(id)}/reopen`, { method: "POST", body: { why } });
362
+ /** Take work back from whoever holds it. */
440
363
  export const release = (id) =>
441
- call(`/v1/guides/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
442
- /** The four verbs every guide answers to. See docs/V2.md §11. */
364
+ call(`/v1/work/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
443
365
  export const working = () => call("/v1/working");
444
- export const handedIn = () => call("/v1/handed_in");
445
- export const recall = (id) =>
446
- call(`/v1/guides/${encodeURIComponent(id)}/recall`, { method: "POST", body: {} });
447
366
  export const assign = (id, to) =>
448
- call(`/v1/guides/${encodeURIComponent(id)}/assign`, { method: "POST", body: { to } });
367
+ call(`/v1/work/${encodeURIComponent(id)}/assign`, { method: "POST", body: { to } });
449
368
  export const take = (body) => call("/v1/take", { method: "POST", body });
450
369
  export const progress = (id, body) =>
451
- call(`/v1/guides/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
370
+ call(`/v1/work/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
452
371
  export const handIn = (id, body) =>
453
- call(`/v1/guides/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
372
+ call(`/v1/work/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
454
373
  export const ask = (id, body) =>
455
- call(`/v1/guides/${encodeURIComponent(id)}/ask`, { method: "POST", body });
374
+ call(`/v1/work/${encodeURIComponent(id)}/ask`, { method: "POST", body });
456
375
  export const reply = (id, body) =>
457
- call(`/v1/guides/${encodeURIComponent(id)}/reply`, { method: "POST", body: { body } });
376
+ call(`/v1/work/${encodeURIComponent(id)}/reply`, { method: "POST", body: { body } });
458
377
  export const pass = (id, body) =>
459
- call(`/v1/guides/${encodeURIComponent(id)}/pass`, { method: "POST", body });
460
- export const ack = (id, taken, note = "") =>
461
- call(`/v1/guides/${id}/ack`, { method: "PUT", body: { taken, note } });
462
-
463
- /**
464
- * Upload an image as evidence, and get back the URL a guide points at.
465
- *
466
- * Not `call()`: that serialises a body as JSON or markdown, and this route reads bytes and takes
467
- * the content type as the declaration of what they are. `name` is the label, and it travels in a
468
- * header rather than the body for the same reason.
469
- */
378
+ call(`/v1/work/${encodeURIComponent(id)}/pass`, { method: "POST", body });
470
379
  /**
471
380
  * Upload a file that is not a picture, and get back what the server made of it. The server decides
472
381
  * what the bytes are and refuses what it does not keep; `type` and `name` are hints. The name travels
@@ -477,6 +386,7 @@ export async function uploadFile(bytes, type, name = "") {
477
386
  const t = token();
478
387
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
479
388
  const headers = {
389
+ ...CLIENT,
480
390
  authorization: `Bearer ${t}`,
481
391
  "content-type": type || "application/octet-stream",
482
392
  };
@@ -498,6 +408,10 @@ export async function uploadFile(bytes, type, name = "") {
498
408
  return (await res.json()).attachment;
499
409
  }
500
410
 
411
+ /**
412
+ * Upload a picture, and get back the URL work points at. Not `call()`: that serialises a body as
413
+ * JSON or markdown, and this route reads bytes and takes the content type as what they are.
414
+ */
501
415
  export async function uploadShot(bytes, type, name = "") {
502
416
  const t = token();
503
417
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
@@ -522,7 +436,7 @@ export async function uploadShot(bytes, type, name = "") {
522
436
  return (await res.json()).shot;
523
437
  }
524
438
 
525
- /** Fetch a guide by its share link (no account needed). Accepts the web URL or the .md URL. */
439
+ /** Fetch work by its share link (no account needed). Accepts the web URL or the .md URL. */
526
440
  export async function fetchShared(url) {
527
441
  const u = new URL(url);
528
442
  if (!u.pathname.endsWith(".md")) u.pathname = `${u.pathname.replace(/\/$/, "")}.md`;
@@ -531,7 +445,3 @@ export async function fetchShared(url) {
531
445
  throw new ApiError(res.status, `could not fetch ${url}: ${res.status} ${res.statusText}`);
532
446
  return res.text();
533
447
  }
534
-
535
- export function shareUrl(id, key) {
536
- return `${baseUrl()}/g/${id}/${key}`;
537
- }
package/src/capture.js CHANGED
@@ -1,9 +1,8 @@
1
- // Fallback capture when no agent wrote a draft: scaffold one from the working directory so the
2
- // developer starts from "what changed" instead of a blank page. The Claude Code skill in
3
- // skills/passalong-capture does the real distillation from a session transcript.
1
+ // What can be learned about where a command runs, without reading any session: the repo, its
2
+ // branch, who is committing. Work written from inside a repo is for that repo.
4
3
  import { execFileSync } from "node:child_process";
5
4
  import { basename } from "node:path";
6
- import { template } from "./guide.js";
5
+ import { serialize } from "./work.js";
7
6
 
8
7
  function git(cwd, ...args) {
9
8
  try {
@@ -40,59 +39,24 @@ export function context(cwd = process.cwd()) {
40
39
  const name = basename(root || cwd);
41
40
  const branch = root ? git(cwd, "rev-parse", "--abbrev-ref", "HEAD") : "";
42
41
  const author = git(cwd, "config", "user.name");
43
- const commits = root ? git(cwd, "log", "--oneline", "-8").split("\n").filter(Boolean) : [];
44
- const changed = root
45
- ? git(cwd, "diff", "--stat", "HEAD~5..HEAD").split("\n").filter(Boolean).slice(-1)[0] || ""
46
- : "";
47
- const files = root
48
- ? git(cwd, "diff", "--name-only", "HEAD~5..HEAD").split("\n").filter(Boolean)
49
- : [];
50
42
  // The repo as the task queue names it: its remote when it has one, because two worktrees of one
51
- // repo have two folder names and one remote. The folder name when there is no remote.
43
+ // repo have two project names and one remote. The project name when there is no remote.
52
44
  const repo = root ? repoKey(git(cwd, "remote", "get-url", "origin") || name) : "";
53
- return { root, name, repo, branch, author, commits, changed, files };
45
+ return { root, name, repo, branch, author };
54
46
  }
55
47
 
56
48
  /**
57
- * A task draft from one sentence: the sentence as the title and the Goal, the rest left to fill.
49
+ * A piece of work from one sentence: the sentence is its title, what it says to a person, and its
50
+ * text, for whoever wrote it to add to before it goes.
58
51
  *
59
- * `target_context` defaults to the repo this runs in, because that is the likeliest repo a task
60
- * written from inside one is for — and it is in the editor, in the frontmatter, for the author
61
- * to change before it goes anywhere. Outside a repo it stays empty, which is a task for no repo.
52
+ * `target_context` is the repo this runs in, because that is the likeliest repo work written from
53
+ * inside one is for — and it is in the frontmatter, for the author to change before it goes
54
+ * anywhere. Outside a repo it stays empty, which is work for no repo.
62
55
  */
63
- export function taskScaffold(sentence, cwd = process.cwd()) {
64
- const c = context(cwd);
65
- const md = template({
66
- kind: "task",
67
- title: sentence,
68
- // The person typed this sentence, so it is theirs to say to a person as well as to name the task.
69
- summary: String(sentence).slice(0, 400),
70
- author: c.author,
71
- source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
72
- target_context: c.repo,
56
+ export function workScaffold(sentence, cwd = process.cwd()) {
57
+ const said = String(sentence).trim();
58
+ return serialize({
59
+ meta: { title: said, summary: said.slice(0, 400), target_context: context(cwd).repo },
60
+ body: `${said}\n`,
73
61
  });
74
- return md.replace(/^## Goal\n/m, `## Goal\n${sentence}\n`);
75
- }
76
-
77
- /** A draft pre-filled with repo facts. Recent activity is appended as a comment for reference. */
78
- export function scaffold(cwd = process.cwd()) {
79
- const c = context(cwd);
80
- let md = template({
81
- kind: "transfer",
82
- title: "",
83
- author: c.author,
84
- source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
85
- });
86
- if (c.commits.length) {
87
- md += [
88
- "",
89
- "<!-- passalong: recent activity in this repo, for reference while you write. Delete when done.",
90
- ...c.commits.map((l) => ` ${l}`),
91
- c.changed ? ` ${c.changed}` : "",
92
- ...c.files.slice(0, 30).map((f) => ` ${f}`),
93
- "-->",
94
- "",
95
- ].join("\n");
96
- }
97
- return md;
98
62
  }
package/src/checks.js CHANGED
@@ -2,15 +2,15 @@
2
2
  * Running an Acceptance check, rather than reading the agent's account of running it.
3
3
  *
4
4
  * A hand-in used to be the agent's word about its own work: `ran` was a string, and the only rule
5
- * was that it was longer than a verdict. "ran the tests, all green" passed. This module is the
5
+ * was that it was longer than a yes. "ran the tests, all green" passed. This module is the
6
6
  * other half — when a check names the command that proves it, the command is run here and the exit
7
7
  * code is recorded from the process, so the agent reports what it did and the machine reports how
8
8
  * it went.
9
9
  *
10
- * **The command comes from the agent's own hand_in call, never from a guide.** A guide is written
10
+ * **The command comes from the agent's own hand_in call, never from work.** Work is written
11
11
  * somewhere else and pulled from a stranger's account; lifting a command out of its Acceptance
12
- * section and running it would turn every pulled guide into remote code execution. Nothing here
13
- * parses guide markdown, and nothing should.
12
+ * section and running it would turn every pulled work into remote code execution. Nothing here
13
+ * parses work markdown, and nothing should.
14
14
  *
15
15
  * This runs only where there is a shell. `src/mcp.js` is a local stdio server on the agent's own
16
16
  * machine, so it can; `apps/api/src/mcp-http.ts` is a Worker, so it cannot, and a hand-in over HTTP
@@ -19,7 +19,7 @@
19
19
  * What this deliberately does NOT do is parse test output. SWE-bench needs 57 per-framework log
20
20
  * parsers to tell a skipped test from a passing one, and a half-parser that guesses is worse than
21
21
  * none: it would refuse honest runs in shapes nobody thought of, and pass the ones it misread. The
22
- * exit code is the whole verdict here, which is also how a CI step decides.
22
+ * exit code is the whole answer here, which is also how a CI step decides.
23
23
  */
24
24
  import { spawnSync } from "node:child_process";
25
25
  import { existsSync } from "node:fs";
package/src/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- // The Claude Code hooks `passalong setup` installs. See docs/V2.md §11.
1
+ // The Claude Code hooks `passalong setup` installs.
2
2
  //
3
3
  // Two moments an agent forgets what it holds: when a session starts (a restart, a /clear, a
4
4
  // compaction), and when it decides it is done. SessionStart runs `passalong now --hook session`
@@ -26,14 +26,10 @@ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
26
26
  * agent to the queue. So a session is told what the worktree holds, and to carry on only with what
27
27
  * was its own before a restart — never to resume another session's work.
28
28
  */
29
- export function nowText({ held = [], waiting = {} }) {
29
+ export function nowText({ held = [], ready = 0 }) {
30
30
  if (held.length === 1) {
31
31
  const [h] = held;
32
- const lines = [
33
- // `kind` is required on what /v1/working sends. The `|| "task"` that used to be here was
34
- // both dead and wrong — absent has meant transfer since kinds existed, never task.
35
- `Passalong: This worktree holds ${h.id} (${h.kind}): ${h.title || h.id}.`,
36
- ];
32
+ const lines = [`Passalong: This worktree holds ${h.id}: ${h.title || h.id}.`];
37
33
  if (h.note) lines.push(`Last note: "${h.note}".`);
38
34
  // The one thing here that is new information rather than a reminder: the person has written, and
39
35
  // nothing else in the session will say so until the agent next checks in.
@@ -56,7 +52,7 @@ export function nowText({ held = [], waiting = {} }) {
56
52
  const lines = [`Passalong: This worktree holds ${held.length} things:`];
57
53
  for (const h of held)
58
54
  lines.push(
59
- `- ${h.id} (${h.kind}): ${h.title || h.id}${h.state === "stalled" ? " — stalled" : ""}` +
55
+ `- ${h.id}: ${h.title || h.id}${h.state === "stalled" ? " — stalled" : ""}` +
60
56
  `${h.replies ? ` — ${plural(h.replies, "message", "messages")} waiting` : ""}`,
61
57
  );
62
58
  lines.push(
@@ -65,13 +61,8 @@ export function nowText({ held = [], waiting = {} }) {
65
61
  );
66
62
  return lines.join("\n");
67
63
  }
68
- const parts = [];
69
- if (waiting.ready)
70
- parts.push(plural(waiting.ready, "ready task for this repo", "ready tasks for this repo"));
71
- if (waiting.inbox)
72
- parts.push(plural(waiting.inbox, "guide handed to you", "guides handed to you"));
73
- if (!parts.length) return "";
74
- return `Passalong: ${parts.join(" and ")}. Call take with no id to start the next one, when the user wants that.`;
64
+ if (!ready) return "";
65
+ return `Passalong: ${plural(ready, "piece of work is", "pieces of work are")} ready for this repo. Call take with no id to start the next one, when the user wants that.`;
75
66
  }
76
67
 
77
68
  /**
@@ -81,7 +72,7 @@ export function nowText({ held = [], waiting = {} }) {
81
72
  * would leave it no way to end. The hook cannot tell which session took what, so it names
82
73
  * everything still open here and lets the session settle its own.
83
74
  */
84
- export function stopVerdict({ held = [], input = {} }) {
75
+ export function stopDecision({ held = [], input = {} }) {
85
76
  // An agent that asked a question was told to stop and wait, so it must be let: blocking it would
86
77
  // make the instruction and the hook contradict each other, and the agent would pick one.
87
78
  const open = held.filter((h) => !h.asking);
@@ -131,8 +122,8 @@ const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
131
122
 
132
123
  /**
133
124
  * One line: what this worktree's agent holds, then what needs you and what is ready here.
134
- * `needs` counts everything waiting on the person: work handed in for review, agents stuck on
135
- * them, and guides handed to them.
125
+ * `needs` counts everything waiting on the person: drafts to read, agents asking or stuck on them,
126
+ * and agents gone quiet.
136
127
  */
137
128
  export function statusText({ held = [], needs = 0, ready = 0 }) {
138
129
  const parts = [];