passalong 0.21.0 → 1.1.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
  }
@@ -168,7 +168,7 @@ async function call(
168
168
  if (auth) {
169
169
  if (!as && needsChoice()) throw new ApiError(409, choiceMessage());
170
170
  const t = as ? as.token : token();
171
- if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
171
+ if (!t) throw new ApiError(401, "not logged in: run `passalong login` to enable sync");
172
172
  headers.authorization = `Bearer ${t}`;
173
173
  }
174
174
  if (body !== undefined) {
@@ -215,58 +215,6 @@ const q = (params) => {
215
215
  return s ? `?${s}` : "";
216
216
  };
217
217
 
218
- /**
219
- * Sign in, or make the account, and come back with a token for this machine.
220
- *
221
- * Three calls, because the API answers a browser and this is not one: signing in sets a session
222
- * cookie, and the CLI carries a bearer token instead — one that shows up in the hub's token list
223
- * with a name on it, and can be revoked there without changing the password. So the cookie is used
224
- * once, to mint the token, and then dropped.
225
- *
226
- * `make` is signup rather than sign-in. It is the caller's decision and not a fallback here: an
227
- * account created because an address was mistyped is worse than being told there is no such
228
- * account.
229
- */
230
- export async function signIn(email, password, { make = false, label = "" } = {}) {
231
- const res = await fetch(`${baseUrl()}${make ? "/v1/auth/signup" : "/v1/auth/login"}`, {
232
- method: "POST",
233
- headers: { "content-type": "application/json" },
234
- body: JSON.stringify({ email, password }),
235
- }).catch((err) => {
236
- throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
237
- });
238
- if (!res.ok) {
239
- const said = await res.json().catch(() => ({}));
240
- throw new ApiError(res.status, said.message || res.statusText);
241
- }
242
- const { account } = await res.json();
243
- // `getSetCookie` keeps the cookies apart; a joined `set-cookie` header cannot be split safely,
244
- // because an Expires date has a comma in it.
245
- const cookie = (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
246
- if (!cookie) throw new ApiError(500, "signed in, but no session came back");
247
-
248
- const minted = await fetch(`${baseUrl()}/v1/tokens`, {
249
- method: "POST",
250
- headers: { "content-type": "application/json", cookie },
251
- body: JSON.stringify({ name: label || "cli" }),
252
- });
253
- if (!minted.ok) {
254
- const said = await minted.json().catch(() => ({}));
255
- throw new ApiError(minted.status, said.message || "could not make a token for this machine");
256
- }
257
- const { token: made } = await minted.json();
258
- return { account, token: made };
259
- }
260
-
261
- /**
262
- * Mint an account with no email and no password.
263
- *
264
- * Nothing in this package calls it any more — `passalong login` asks for an email and a password,
265
- * because an account nobody can sign in to is one that dies with the file it is stored in, and the
266
- * CLI used to make one without ever saying so. It stays exported because the route it calls is
267
- * still live and still right for the invite page, which mints before it claims, and because
268
- * removing a published export is a breaking change for anyone who imported it.
269
- */
270
218
  /**
271
219
  * Sign this machine in from the browser. `challenge` is the sha256, in hex, of a secret kept here;
272
220
  * what comes back is a short code to show, the page to open, and how often to ask whether it was
@@ -293,7 +241,6 @@ export async function devicePoll(id, verifier) {
293
241
  return said;
294
242
  }
295
243
 
296
- export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
297
244
  export const me = () => call("/v1/me");
298
245
  export const updateMe = (patch) => call("/v1/me", { method: "PATCH", body: patch });
299
246
 
@@ -314,101 +261,83 @@ export const removeMember = (slug, who) =>
314
261
  export const join = (code) =>
315
262
  call(`/v1/invites/${encodeURIComponent(code)}/accept`, { method: "POST" });
316
263
 
317
- export const board = () => call("/v1/board");
318
- export const folders = (scope = "all", { query = "", guide = "" } = {}) =>
319
- call(`/v1/folders${q({ scope, q: query, guide })}`);
320
- export const createFolder = (title, description = "", team = "", parent = "", color = "") =>
321
- call("/v1/folders", { method: "POST", body: { title, description, team, parent, color } });
322
- export const folder = (id) => call(`/v1/folders/${encodeURIComponent(id)}`);
323
- export const updateFolder = (id, changes) =>
324
- call(`/v1/folders/${encodeURIComponent(id)}`, { method: "PATCH", body: changes });
325
- export const folderDocument = (folderId, documentId) =>
326
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`);
327
- export const folderAsset = (folderId, assetId) =>
328
- call(`/v1/folders/${encodeURIComponent(folderId)}/assets/${encodeURIComponent(assetId)}/agent`);
329
- 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 = "") {
330
283
  const file = await stat(path);
331
- 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.");
332
285
  if (file.size > 10 * 1024 * 1024) throw new ApiError(413, "That file is over 10 MB.");
333
- return call(`/v1/folders/${encodeURIComponent(folderId)}/assets`, {
286
+ return call(`/v1/projects/${encodeURIComponent(projectId)}/files`, {
334
287
  method: "POST",
335
288
  body: new Uint8Array(await readFile(path)),
336
289
  uploadName: name || basename(path),
337
290
  });
338
291
  }
339
- export const createFolderDocument = (folderId, name, body) =>
340
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents`, {
292
+ export const createProjectDocument = (projectId, name, body) =>
293
+ call(`/v1/projects/${encodeURIComponent(projectId)}/documents`, {
341
294
  method: "POST",
342
295
  body: { name, body },
343
296
  });
344
- export const saveFolderDocument = (folderId, documentId, version, body) =>
345
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
346
- method: "PUT",
347
- body: { version, body },
348
- });
349
- export const renameFolderDocument = (folderId, documentId, name) =>
350
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
351
- method: "PATCH",
352
- body: { name },
353
- });
354
- export const moveFolderDocument = (folderId, documentId, to, name = "") =>
355
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
356
- method: "PATCH",
357
- body: name ? { folder: to, name } : { folder: to },
358
- });
359
- export const folderDocumentHistory = (folderId, documentId, version = 0) =>
297
+ export const saveProjectDocument = (projectId, documentId, version, body) =>
360
298
  call(
361
- `/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
+ },
362
304
  );
363
- export const shareFolder = (folderId) =>
364
- call(`/v1/folders/${encodeURIComponent(folderId)}/share`, { method: "POST" });
365
- export const linkFolderGuide = (folderId, guide) =>
366
- call(`/v1/folders/${encodeURIComponent(folderId)}/guides`, { method: "POST", body: { guide } });
367
- export const unlinkFolderGuide = (folderId, guide) =>
368
- call(`/v1/folders/${encodeURIComponent(folderId)}/guides/${encodeURIComponent(guide)}`, {
369
- method: "DELETE",
370
- });
371
- export const deleteFolderDocument = (folderId, documentId) =>
372
- call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
373
- method: "DELETE",
374
- });
375
- export const deleteFolderAsset = (folderId, assetId) =>
376
- 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)}`, {
377
327
  method: "DELETE",
378
328
  });
379
- /** What you did, newest first. `since` is a date prefix: 2026, 2026-09, 2026-09-11. */
380
- export const log = ({ repo = "", since = "", limit = 0 } = {}) =>
381
- call(`/v1/log${q({ repo, since, limit: limit || "" })}`);
382
- export const notifications = ({ unread = true, limit = 0 } = {}) =>
383
- call(`/v1/notifications${q({ unread: unread ? "1" : "", limit: limit || "" })}`);
384
- /** No ids means "everything unread". */
385
- export const markRead = (ids = []) =>
386
- call("/v1/notifications/read", { method: "POST", body: { ids } });
387
-
388
- export const publish = (id, markdown) =>
389
- call(`/v1/guides/${id}`, { method: "PUT", body: markdown });
390
- export const list = (query = "", scope = "") => call(`/v1/guides${q({ q: query, scope })}`);
391
- export const inbox = () => call("/v1/inbox");
392
- /** Open a report for a set of issues to be filed under. */
393
- export const createReport = (body) => call("/v1/reports", { method: "POST", body });
394
- /** One report and the issues filed under it, grouped by product area. */
395
- export const report = (id) => call(`/v1/reports/${encodeURIComponent(id)}`);
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 })}`);
396
331
  export const get = (id, { peek = false, as = null } = {}) =>
397
- call(`/v1/guides/${id}${q({ peek: peek ? "1" : "" })}`, { raw: true, as });
398
- /** One page of every guide this account can read, for `export`. Records no pull. */
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. */
399
334
  export const exportPage = (after = "", as = null) => call(`/v1/export${q({ after })}`, { as });
400
335
  /**
401
- * A guide's follow-ups, one level down. `markdown` adds each one's content, oldest first and
402
- * capped server-side; neither form counts as opening them.
403
- */
404
- export const children = (id, { markdown = false } = {}) =>
405
- call(`/v1/guides/${encodeURIComponent(id)}/children${q({ markdown: markdown ? "1" : "" })}`);
406
- /**
407
- * The guide this one came out of, with where it has got to. `markdown` adds its content. Like the
408
- * 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.
409
338
  */
410
339
  export const parent = (id, { markdown = false } = {}) =>
411
- call(`/v1/guides/${encodeURIComponent(id)}/parent${q({ markdown: markdown ? "1" : "" })}`);
340
+ call(`/v1/work/${encodeURIComponent(id)}/parent${q({ markdown: markdown ? "1" : "" })}`);
412
341
  /**
413
342
  * Giving a plan away, for whoever the deployment names in `ADMIN_ACCOUNTS`. Every other account
414
343
  * gets a 404 from these, so the CLI does not have to know who the operator is either.
@@ -424,63 +353,31 @@ export const listGifts = () => call("/v1/admin/gifts");
424
353
  export const takeGiftBack = (id) =>
425
354
  call(`/v1/admin/gifts/${encodeURIComponent(id)}`, { method: "DELETE" });
426
355
  export const setStatus = (id, status) =>
427
- call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
428
- export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
429
- /** Does it actually work? `note` is required when it does not. */
430
- export const verdict = (id, ok, note = "", detail = "") =>
431
- call(`/v1/guides/${id}/verdict`, {
432
- method: "PUT",
433
- body: { ok, note, ...(detail ? { detail } : {}) },
434
- });
435
-
356
+ call(`/v1/work/${id}/status`, { method: "PATCH", body: { status } });
357
+ export const remove = (id) => call(`/v1/work/${id}`, { method: "DELETE" });
436
358
  export const tasks = () => call("/v1/tasks");
437
359
  export const nextTask = (body) => call("/v1/tasks/next", { method: "POST", body });
438
- export const taskProgress = (id, body) =>
439
- call(`/v1/tasks/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
440
- export const finishTask = (id, body) =>
441
- call(`/v1/tasks/${encodeURIComponent(id)}/finish`, { method: "POST", body });
442
- export const approveTask = (id) =>
443
- call(`/v1/tasks/${encodeURIComponent(id)}/approve`, { method: "POST", body: {} });
444
- export const rejectTask = (id, why) =>
445
- call(`/v1/tasks/${encodeURIComponent(id)}/reject`, { method: "POST", body: { why } });
360
+ /** The runner saying how it is, for the hub: what it is on, today's count, or that it stopped. */
361
+ export const runnerStatus = (body) => call("/v1/runner", { method: "PUT", body });
446
362
  export const reopen = (id, why) =>
447
- call(`/v1/guides/${encodeURIComponent(id)}/reopen`, { method: "POST", body: { why } });
448
- /** A handoff's or bug's approve and send-back. No `place` names the only hand-in there is. */
449
- export const closeGuide = (id) =>
450
- call(`/v1/guides/${encodeURIComponent(id)}/close`, { method: "POST", body: {} });
451
- export const sendBack = (id, why) =>
452
- call(`/v1/guides/${encodeURIComponent(id)}/send_back`, { method: "POST", body: { why } });
453
- /** Take work back from whoever holds it, of any kind. `/v1/tasks/…/release` is the old path. */
363
+ call(`/v1/work/${encodeURIComponent(id)}/reopen`, { method: "POST", body: { why } });
364
+ /** Take work back from whoever holds it. */
454
365
  export const release = (id) =>
455
- call(`/v1/guides/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
456
- /** The four verbs every guide answers to. See docs/V2.md §11. */
366
+ call(`/v1/work/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
457
367
  export const working = () => call("/v1/working");
458
- export const handedIn = () => call("/v1/handed_in");
459
- export const recall = (id) =>
460
- call(`/v1/guides/${encodeURIComponent(id)}/recall`, { method: "POST", body: {} });
461
368
  export const assign = (id, to) =>
462
- call(`/v1/guides/${encodeURIComponent(id)}/assign`, { method: "POST", body: { to } });
369
+ call(`/v1/work/${encodeURIComponent(id)}/assign`, { method: "POST", body: { to } });
463
370
  export const take = (body) => call("/v1/take", { method: "POST", body });
464
371
  export const progress = (id, body) =>
465
- call(`/v1/guides/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
372
+ call(`/v1/work/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
466
373
  export const handIn = (id, body) =>
467
- call(`/v1/guides/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
374
+ call(`/v1/work/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
468
375
  export const ask = (id, body) =>
469
- call(`/v1/guides/${encodeURIComponent(id)}/ask`, { method: "POST", body });
376
+ call(`/v1/work/${encodeURIComponent(id)}/ask`, { method: "POST", body });
470
377
  export const reply = (id, body) =>
471
- call(`/v1/guides/${encodeURIComponent(id)}/reply`, { method: "POST", body: { body } });
378
+ call(`/v1/work/${encodeURIComponent(id)}/reply`, { method: "POST", body: { body } });
472
379
  export const pass = (id, body) =>
473
- call(`/v1/guides/${encodeURIComponent(id)}/pass`, { method: "POST", body });
474
- export const ack = (id, taken, note = "") =>
475
- call(`/v1/guides/${id}/ack`, { method: "PUT", body: { taken, note } });
476
-
477
- /**
478
- * Upload an image as evidence, and get back the URL a guide points at.
479
- *
480
- * Not `call()`: that serialises a body as JSON or markdown, and this route reads bytes and takes
481
- * the content type as the declaration of what they are. `name` is the label, and it travels in a
482
- * header rather than the body for the same reason.
483
- */
380
+ call(`/v1/work/${encodeURIComponent(id)}/pass`, { method: "POST", body });
484
381
  /**
485
382
  * Upload a file that is not a picture, and get back what the server made of it. The server decides
486
383
  * what the bytes are and refuses what it does not keep; `type` and `name` are hints. The name travels
@@ -489,8 +386,9 @@ export const ack = (id, taken, note = "") =>
489
386
  export async function uploadFile(bytes, type, name = "") {
490
387
  if (needsChoice()) throw new ApiError(409, choiceMessage());
491
388
  const t = token();
492
- if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
389
+ if (!t) throw new ApiError(401, "not logged in: run `passalong login` to enable sync");
493
390
  const headers = {
391
+ ...CLIENT,
494
392
  authorization: `Bearer ${t}`,
495
393
  "content-type": type || "application/octet-stream",
496
394
  };
@@ -499,7 +397,7 @@ export async function uploadFile(bytes, type, name = "") {
499
397
  try {
500
398
  res = await fetch(`${baseUrl()}/v1/attachments`, { method: "POST", headers, body: bytes });
501
399
  } catch (err) {
502
- throw new ApiError(0, `could not reach ${base} (${err.message})`);
400
+ throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
503
401
  }
504
402
  if (!res.ok) {
505
403
  const body = await res.text();
@@ -512,9 +410,13 @@ export async function uploadFile(bytes, type, name = "") {
512
410
  return (await res.json()).attachment;
513
411
  }
514
412
 
413
+ /**
414
+ * Upload a picture, and get back the URL work points at. Not `call()`: that serialises a body as
415
+ * JSON or markdown, and this route reads bytes and takes the content type as what they are.
416
+ */
515
417
  export async function uploadShot(bytes, type, name = "") {
516
418
  const t = token();
517
- if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
419
+ if (!t) throw new ApiError(401, "not logged in: run `passalong login` to enable sync");
518
420
  const headers = { ...CLIENT, authorization: `Bearer ${t}`, "content-type": type };
519
421
  // A header is latin-1: a filename with an accent in it throws on the way out rather than at the
520
422
  // server, and the label is not worth failing an upload over.
@@ -523,7 +425,7 @@ export async function uploadShot(bytes, type, name = "") {
523
425
  try {
524
426
  res = await fetch(`${baseUrl()}/v1/shots`, { method: "POST", headers, body: bytes });
525
427
  } catch (err) {
526
- throw new ApiError(0, `could not reach ${base} (${err.message})`);
428
+ throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
527
429
  }
528
430
  if (!res.ok) {
529
431
  const body = await res.text();
@@ -536,7 +438,7 @@ export async function uploadShot(bytes, type, name = "") {
536
438
  return (await res.json()).shot;
537
439
  }
538
440
 
539
- /** Fetch a guide by its share link (no account needed). Accepts the web URL or the .md URL. */
441
+ /** Fetch work by its share link (no account needed). Accepts the web URL or the .md URL. */
540
442
  export async function fetchShared(url) {
541
443
  const u = new URL(url);
542
444
  if (!u.pathname.endsWith(".md")) u.pathname = `${u.pathname.replace(/\/$/, "")}.md`;
@@ -545,7 +447,3 @@ export async function fetchShared(url) {
545
447
  throw new ApiError(res.status, `could not fetch ${url}: ${res.status} ${res.statusText}`);
546
448
  return res.text();
547
449
  }
548
-
549
- export function shareUrl(id, key) {
550
- return `${baseUrl()}/g/${id}/${key}`;
551
- }
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.
@@ -46,8 +42,8 @@ export function nowText({ held = [], waiting = {} }) {
46
42
  lines.push("It is marked stalled: nobody has heard from its agent in 30 minutes.");
47
43
  lines.push(
48
44
  `If it was yours before this session restarted, call take ${h.id} to carry on, then ` +
49
- "progress at each milestone. When it is done, hand_in with evidence — what you ran and " +
50
- "what came back — so keep it as you go; if it is not yours or you are stuck, pass with " +
45
+ "progress at each milestone. When it is done, hand_in with evidence (what you ran and " +
46
+ "what came back), so keep it as you go; if it is not yours or you are stuck, pass with " +
51
47
  "the reason. If another session here is on it, leave it to that session.",
52
48
  );
53
49
  return lines.join("\n");
@@ -56,8 +52,8 @@ 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" : ""}` +
60
- `${h.replies ? ` — ${plural(h.replies, "message", "messages")} waiting` : ""}`,
55
+ `- ${h.id}: ${h.title || h.id}${h.state === "stalled" ? " (stalled)" : ""}` +
56
+ `${h.replies ? `, ${plural(h.replies, "message", "messages")} waiting` : ""}`,
61
57
  );
62
58
  lines.push(
63
59
  "Several sessions may be working here. Carry on only with what was yours before this " +
@@ -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);
@@ -93,7 +84,7 @@ export function stopVerdict({ held = [], input = {} }) {
93
84
  `This worktree still holds ${ids}. For anything there you worked on in this session, do ` +
94
85
  "one of before ending: hand_in it with your evidence if it is done; pass it with the reason " +
95
86
  "if it is not yours or you are stuck; or, if the user is stopping mid-way, progress it with " +
96
- "a note saying what is left. Leave what other sessions are on — then end.",
87
+ "a note saying what is left. Leave what other sessions are on, then end.",
97
88
  };
98
89
  }
99
90
 
@@ -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 = [];