passalong 0.17.0 → 0.19.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
@@ -78,26 +78,13 @@ export async function share(markdown, { cwd = process.cwd(), to, follows } = {})
78
78
  ];
79
79
  if (errors.length)
80
80
  throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
81
- // A task stays a draft when it is shared. Draft is the column a task waits in until a person
82
- // says it is ready for an agent — `ready()` — and an agent that drafts one over MCP comes
83
- // through here too, so publishing on share would let a task queue itself with nobody reading it.
84
- if (guide.meta.status === "draft" && guide.meta.kind !== "task")
85
- guide = { ...guide, meta: { ...guide.meta, status: "published" } };
86
-
87
- let url = guide.meta.url || null;
88
- let synced = false;
89
- let notified = false;
90
- if (api.loggedIn()) {
91
- const res = await api.publish(guide.meta.id, serialize(guide));
92
- url = res.url;
93
- synced = true;
94
- notified = Boolean(res.notified);
95
- guide = { ...guide, meta: { ...guide.meta, url } };
96
- } else if (guide.meta.team) {
97
- throw new PassalongError("sharing to a team needs sync — run `passalong login` first");
98
- }
99
- const path = store.save(guide);
100
- return { guide, path, url, synced, notified };
81
+
82
+ // The server is where a guide lives; nothing is kept on this machine.
83
+ needsSync("sharing a guide");
84
+ const res = await api.publish(guide.meta.id, serialize(guide));
85
+ const url = res.url;
86
+ guide = { ...guide, meta: { ...guide.meta, url } };
87
+ return { guide, url, synced: true, notified: Boolean(res.notified) };
101
88
  }
102
89
 
103
90
  /**
@@ -151,8 +138,11 @@ export async function fileBugs(
151
138
  return { report: { id: report.id, title, environment, team }, issues: filed };
152
139
  }
153
140
 
154
- /** Resolve a reference (id, share URL, or local file path) to a guide. */
155
- export async function resolve(ref) {
141
+ /**
142
+ * Resolve a reference (id, share URL, or local file path) to a guide. An id is the server's to
143
+ * answer: nothing keeps a copy on this machine. `peek` reads without recording a pull.
144
+ */
145
+ export async function resolve(ref, { peek = false } = {}) {
156
146
  if (/^https?:\/\//.test(ref)) {
157
147
  const md = await api.fetchShared(ref);
158
148
  return { guide: parse(md), from: "link" };
@@ -162,21 +152,13 @@ export async function resolve(ref) {
162
152
  }
163
153
  if (!ID_RE.test(ref))
164
154
  throw new PassalongError(`"${ref}" is not a passalong id, share link, or .md file`);
165
- const local = store.get(ref);
166
- if (local) return { guide: local, from: "local" };
167
- if (api.loggedIn()) {
168
- try {
169
- const md = await api.get(ref);
170
- const guide = parse(md);
171
- store.save(guide); // cache so the next pull is offline
172
- return { guide, from: "sync" };
173
- } catch (err) {
174
- if (err.status !== 404) throw err;
175
- }
155
+ needsSync("reading a guide by id");
156
+ try {
157
+ return { guide: parse(await api.get(ref, { peek })), from: "sync" };
158
+ } catch (err) {
159
+ if (err.status === 404) throw new PassalongError(`no guide "${ref}"`);
160
+ throw err;
176
161
  }
177
- throw new PassalongError(
178
- `no guide "${ref}" (not local${api.loggedIn() ? " or synced" : "; not logged in, so sync was not checked"})`,
179
- );
180
162
  }
181
163
 
182
164
  /**
@@ -201,22 +183,21 @@ export async function resolveId(ref) {
201
183
  }
202
184
 
203
185
  /**
204
- * Pull a guide into a working directory: writes .passalong/<id>.md there and returns the guide
205
- * so the caller can put the text straight into an agent's context. A pull of someone else's
206
- * guide is also recorded server-side, which is how the sender sees the transfer landed.
186
+ * Pull a guide and return it, so the caller can put the text straight into an agent's context. A
187
+ * pull of someone else's guide is recorded server-side, which is how the sender sees the transfer
188
+ * landed. Nothing is written unless `save` asks: then it lands in `.passalong/<id>.md` here.
207
189
  */
208
- export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
190
+ export async function pull(ref, { cwd = process.cwd(), save = false } = {}) {
209
191
  const { guide, from } = await resolve(ref);
210
- // A locally cached copy of a teammate's guide still counts as a pull for them.
211
- if (from === "local" && api.loggedIn() && guide.meta.team) {
212
- api.get(guide.meta.id).catch(() => {});
213
- }
214
- let path = null;
215
- if (write) {
216
- path = join(localDir(cwd), `${guide.meta.id}.md`);
217
- writeFileSync(path, serialize(guide));
218
- }
219
- return { guide, from, path, markdown: serialize(guide) };
192
+ const markdown = serialize(guide);
193
+ return { guide, from, path: save ? saveTo(cwd, guide.meta.id, markdown) : null, markdown };
194
+ }
195
+
196
+ /** Write a guide into `.passalong/<id>.md` under `cwd`, when somebody asked for a file. */
197
+ function saveTo(cwd, id, markdown) {
198
+ const path = join(localDir(cwd), `${id}.md`);
199
+ writeFileSync(path, markdown);
200
+ return path;
220
201
  }
221
202
 
222
203
  /**
@@ -284,17 +265,13 @@ export async function tasks() {
284
265
 
285
266
  /**
286
267
  * Take the next task for this worktree's repo, or get back the one this worktree already holds.
287
- * `null` when there is nothing to do. The guide is written to `.passalong/<id>.md` like a pull.
268
+ * `null` when there is nothing to do. Written to `.passalong/<id>.md` only when `save` asks.
288
269
  */
289
- export async function nextTask({ cwd = process.cwd(), any = false } = {}) {
270
+ export async function nextTask({ cwd = process.cwd(), any = false, save = false } = {}) {
290
271
  needsSync("taking a task");
291
272
  const { task } = await api.nextTask({ ...agent(cwd), any });
292
273
  if (!task) return null;
293
- const guide = parse(task.markdown);
294
- const path = join(localDir(agent(cwd).worktree), `${task.id}.md`);
295
- writeFileSync(path, task.markdown);
296
- store.save(guide);
297
- return { ...task, path };
274
+ return { ...task, path: save ? saveTo(agent(cwd).worktree, task.id, task.markdown) : null };
298
275
  }
299
276
 
300
277
  /** Word from the agent holding a task: renews its lease, and `note` becomes the board's line. */
@@ -397,18 +374,15 @@ export async function finishTask(
397
374
  * `next`, the calls that make sense from here, and `say` when the move is to stop.
398
375
  *
399
376
  * take: this worktree's agent takes guide `id`, or with no id the next one waiting for it. The
400
- * document is written to `.passalong/<id>.md`, like a pull, so the agent works from a file.
377
+ * document comes back in the answer, and is written to `.passalong/<id>.md` only when `save` asks.
401
378
  */
402
- export async function take(id, { cwd = process.cwd(), any = false } = {}) {
379
+ export async function take(id, { cwd = process.cwd(), any = false, save = false } = {}) {
403
380
  needsSync("taking work");
404
381
  const who = agent(cwd);
405
382
  const r = await api.take({ ...who, ...(id ? { id: await resolveId(id) } : {}), any });
406
383
  if (!r.guide?.markdown) return r;
407
384
  rememberFence(r.guide.id, r.guide.fence, cwd);
408
- const path = join(localDir(who.worktree), `${r.guide.id}.md`);
409
- writeFileSync(path, r.guide.markdown);
410
- store.save(parse(r.guide.markdown));
411
- return { ...r, path };
385
+ return { ...r, path: save ? saveTo(who.worktree, r.guide.id, r.guide.markdown) : null };
412
386
  }
413
387
 
414
388
  /** Word from the agent that holds it: the lease starts again, and `note` is what the hub shows. */
@@ -603,6 +577,14 @@ export async function now({ cwd = process.cwd() } = {}) {
603
577
  return { held, waiting, needs: review + handed + waiting.inbox, agent: who.agent };
604
578
  }
605
579
 
580
+ /** Finished work back to To do, with why (docs/REVIEW.md §4). */
581
+ export async function reopen(id, why) {
582
+ needsSync("reopening work");
583
+ if (!String(why || "").trim())
584
+ throw new PassalongError("say why: whoever takes it next reads it");
585
+ return api.reopen(await resolveId(id), why);
586
+ }
587
+
606
588
  /**
607
589
  * The gate, a person's three answers to a task. Approve: the work in review is done. Reject: it
608
590
  * goes back to Ready with `why` on it for the next agent. Release: a task an agent holds, live or
@@ -613,16 +595,19 @@ export async function now({ cwd = process.cwd() } = {}) {
613
595
  * line the next reader needs.
614
596
  */
615
597
  export async function approveTask(id) {
616
- needsSync("approving a task");
617
- const r = await api.approveTask(id);
598
+ needsSync("approving work");
599
+ const { guide } = await resolve(id, { peek: true });
600
+ const r = await (guide.meta.kind === "task" ? api.approveTask(id) : api.closeGuide(id));
618
601
  store.remove(id);
619
602
  return r;
620
603
  }
621
604
 
622
605
  export async function rejectTask(id, why) {
623
- needsSync("rejecting a task");
624
- if (!String(why || "").trim()) throw new PassalongError("say why: the next agent reads it");
625
- const r = await api.rejectTask(id, why);
606
+ needsSync("sending work back");
607
+ if (!String(why || "").trim())
608
+ throw new PassalongError("say why: whoever takes it next reads it");
609
+ const { guide } = await resolve(id, { peek: true });
610
+ const r = await (guide.meta.kind === "task" ? api.rejectTask(id, why) : api.sendBack(id, why));
626
611
  store.remove(id);
627
612
  return r;
628
613
  }
@@ -662,17 +647,10 @@ export async function release(id) {
662
647
  * - anything else is reported in `ack_error` rather than swallowed, because an ack that quietly
663
648
  * did not land leaves the sender in the silence the whole feature exists to end.
664
649
  */
665
- export async function start(ref, { cwd = process.cwd(), write = true } = {}) {
666
- const pulled = await pull(ref, { cwd, write });
667
- const id = pulled.guide.meta.id;
668
- if (!api.loggedIn()) return { ...pulled, took: false, ack_error: "", own: false };
669
- try {
670
- await api.ack(id, true, "");
671
- return { ...pulled, took: true, ack_error: "", own: false };
672
- } catch (err) {
673
- if (err.status === 403) return { ...pulled, took: false, ack_error: "", own: true };
674
- return { ...pulled, took: false, ack_error: err.message, own: false };
675
- }
650
+ export async function start(ref, { cwd = process.cwd(), save = false } = {}) {
651
+ const r = await take(await resolveId(ref), { cwd, save });
652
+ if (!r.guide?.markdown) return r;
653
+ return { ...r, markdown: r.guide.markdown };
676
654
  }
677
655
 
678
656
  /**
@@ -949,24 +927,11 @@ export async function parentGuide(
949
927
  }
950
928
  }
951
929
 
952
- /** Local guides merged with synced ones (by id), newest first, optionally filtered. */
953
- export async function list(query = "", { remote = true, scope = "" } = {}) {
954
- const local = scope && scope !== "all" && scope !== "mine" ? [] : store.search(query);
955
- const seen = new Set(local.map((g) => g.meta.id));
956
- const rows = local.map((g) => ({ ...summary(g), where: "local" }));
957
- if (remote && api.loggedIn()) {
958
- try {
959
- const res = await api.list(query, scope);
960
- for (const r of res.guides) {
961
- const row = seen.has(r.id) ? rows.find((x) => x.id === r.id) : null;
962
- if (row) Object.assign(row, r, { where: "synced" });
963
- else rows.push({ ...r, where: "synced" });
964
- }
965
- } catch (err) {
966
- rows.warning = `sync unavailable: ${err.message}`;
967
- }
968
- }
969
- return rows.sort((a, b) => (b.created || "").localeCompare(a.created || ""));
930
+ /** Guides on the server this account can see, newest first, optionally filtered. */
931
+ export async function list(query = "", { scope = "" } = {}) {
932
+ needsSync("listing guides");
933
+ const res = await api.list(query, scope);
934
+ return res.guides.map((r) => ({ ...r, where: "synced" }));
970
935
  }
971
936
 
972
937
  /** Guides handed to you (or your teams) that you have not pulled yet. Needs sync. */
@@ -1007,64 +972,6 @@ export async function activity({ all = false, limit = 50 } = {}) {
1007
972
  * `done` says "I implemented it", a verdict says "I tried it and it holds up" — or does not.
1008
973
  * A failing verdict needs a note; "it doesn't work" without a reason helps nobody.
1009
974
  */
1010
- /**
1011
- * Say whether a guide held, and why at the length that takes.
1012
- *
1013
- * The reason used to be one field, capped at 280 characters by the row it was written for — so an
1014
- * agent that had run the Verification and found it did not hold published a guide called
1015
- * "Correction: …" instead, because measurements, commands and commit ids do not fit in a tweet.
1016
- * The first line is the summary a row shows; the whole thing is what the guide shows.
1017
- */
1018
- export async function verdict(id, ok, note = "", { images = [] } = {}) {
1019
- if (!api.loggedIn()) throw new PassalongError("verdicts need sync — run `passalong login` first");
1020
- const said = note.trim();
1021
- if (!ok && !said)
1022
- throw new PassalongError("say what went wrong: passalong broken <id> <what happened>");
1023
- // "It works" is shown, not said: the server refuses one with no screenshot of it working, so
1024
- // the refusal is said here before anything is uploaded, in the words of the command that fixes it.
1025
- if (ok && !images.length && !/\/v1\/shots\/[a-z0-9]{6,16}/.test(said))
1026
- throw new PassalongError(
1027
- "show it working: passalong works <id> <screenshot.png> [what you checked]",
1028
- );
1029
- const guide = await resolveId(id);
1030
- const shots = [];
1031
- for (const file of images) shots.push((await attach(file)).markdown);
1032
- const line = said.split("\n")[0].slice(0, 280);
1033
- const detail = [said.length > line.length || shots.length ? said : "", ...shots]
1034
- .filter(Boolean)
1035
- .join("\n");
1036
- return api.verdict(guide, ok, line, detail);
1037
- }
1038
-
1039
- /**
1040
- * The words after `passalong works <id>`, split into the screenshots and the note: an argument is a
1041
- * screenshot when it names an image file that exists, and everything else is what was checked.
1042
- */
1043
- export function proofArgs(args) {
1044
- const images = [];
1045
- const words = [];
1046
- for (const a of args) {
1047
- const ext = a.slice(a.lastIndexOf(".")).toLowerCase();
1048
- if (SHOT_TYPES[ext] && existsSync(a)) images.push(a);
1049
- else words.push(a);
1050
- }
1051
- return { images, note: words.join(" ") };
1052
- }
1053
-
1054
- /**
1055
- * Say whether you are taking a guide someone handed you — before doing any of it.
1056
- *
1057
- * The hop the product had no signal for: until this, a handoff nobody had answered looked exactly
1058
- * like one nobody had noticed. Passing needs a reason for the same cause a failing verdict does —
1059
- * "not me" without "why" leaves the sender where the silence did.
1060
- */
1061
- export async function ack(id, taken, note = "") {
1062
- if (!api.loggedIn()) throw new PassalongError("acks need sync — run `passalong login` first");
1063
- if (!taken && !note.trim())
1064
- throw new PassalongError("say why you are passing: pass a note with taken=false");
1065
- return api.ack(await resolveId(id), taken, note.trim());
1066
- }
1067
-
1068
975
  /** Mark notifications seen. No ids means everything unread. */
1069
976
  export const seen = (ids = []) => api.markRead(ids);
1070
977
 
@@ -1088,25 +995,20 @@ export function summary(g) {
1088
995
  export async function setStatus(id, status) {
1089
996
  if (!STATUSES.includes(status))
1090
997
  throw new PassalongError(`status must be one of ${STATUSES.join(", ")}`);
1091
- const { guide } = await resolve(id);
998
+ const { guide } = await resolve(id, { peek: true });
1092
999
  const next = { ...guide, meta: { ...guide.meta, status } };
1093
1000
  // The server first. Saved the other way round, a refusal left the local copy saying what the
1094
1001
  // server had just refused — a teammate's `ready` on someone else's task read as done here.
1095
- if (api.loggedIn() && guide.meta.url) await api.setStatus(id, status);
1096
- store.save(next);
1002
+ await api.setStatus(id, status);
1097
1003
  return next;
1098
1004
  }
1099
1005
 
1100
1006
  /**
1101
- * Move a task from Draft to Ready: a person has read it and an agent may take it.
1102
- *
1103
- * Only tasks. Every other kind is published the moment it is shared, so "ready" on one would
1104
- * either do nothing or reopen something archived under a name that does not say so.
1007
+ * Move a guide from Draft to Ready: a person has read it, and whoever it is for may take it now.
1008
+ * Every kind: a handoff or bug is a draft until somebody says so, as a task always was.
1105
1009
  */
1106
1010
  export async function ready(id) {
1107
- const { guide } = await resolve(id);
1108
- if (guide.meta.kind !== "task")
1109
- throw new PassalongError(`${guide.meta.id || id} is not a task — ready is for tasks in Draft`);
1011
+ const { guide } = await resolve(id, { peek: true });
1110
1012
  if (guide.meta.status !== "draft")
1111
1013
  throw new PassalongError(`${guide.meta.id} is not in Draft (status: ${guide.meta.status})`);
1112
1014
  return setStatus(guide.meta.id, "published");
@@ -1146,10 +1048,13 @@ export async function planTasks(steps, { cwd = process.cwd() } = {}) {
1146
1048
  ].join("\n");
1147
1049
  // The summary is the step's own, said by whoever planned it: it is what a person reads about the
1148
1050
  // task, and this builds the document without guessing one (the server refuses a task without).
1051
+ // A plan is the case Draft is for: several tasks at once, which a person reads before any of
1052
+ // them runs. Stated here, by the code that knows it is building one.
1149
1053
  const meta = {
1150
1054
  title: step.title,
1151
1055
  summary: step.summary,
1152
1056
  kind: "task",
1057
+ status: "draft",
1153
1058
  target_context: step.target_context ?? repo,
1154
1059
  };
1155
1060
  const after = (step.after || []).map((j) => ids[j]);
@@ -1162,8 +1067,7 @@ export async function planTasks(steps, { cwd = process.cwd() } = {}) {
1162
1067
  /** What an agent started by `work()` is told. The task itself goes in too, so it cannot start blind. */
1163
1068
  export function workPrompt(t) {
1164
1069
  return [
1165
- `You hold passalong task ${t.id}, and no other agent can take it while you do.`,
1166
- `It is written to ${t.path}; here it is:`,
1070
+ `You hold passalong task ${t.id}, and no other agent can take it while you do. Here it is:`,
1167
1071
  "",
1168
1072
  t.markdown.trim(),
1169
1073
  "",
@@ -1216,7 +1120,7 @@ export async function work({
1216
1120
  const run = spawnSync(command, [...args, workPrompt(t)], {
1217
1121
  cwd,
1218
1122
  stdio: "inherit",
1219
- env: { ...process.env, PASSALONG_TASK: t.id, PASSALONG_TASK_PATH: t.path },
1123
+ env: { ...process.env, PASSALONG_TASK: t.id },
1220
1124
  });
1221
1125
  const state = (await tasks()).find((x) => x.id === t.id)?.state;
1222
1126
  if (state !== "review")
@@ -1233,14 +1137,14 @@ export async function work({
1233
1137
  }
1234
1138
 
1235
1139
  /**
1236
- * Make every task you wrote that is still in Draft ready, and return their ids. For the moment a
1237
- * planner has written several and you have read them all. Only your own: moving a task into the
1238
- * queue is its author's call.
1140
+ * Make every guide you wrote that is still in Draft ready, and return their ids. For the moment a
1141
+ * planner has written several and you have read them all. Only your own: sending a guide on is its
1142
+ * author's call.
1239
1143
  */
1240
1144
  export async function readyDrafts() {
1241
- const drafts = (await tasks()).filter((t) => t.mine && t.state === "draft");
1242
- for (const t of drafts) await ready(t.id);
1243
- return drafts.map((t) => t.id);
1145
+ const drafts = (await list("", { scope: "mine" })).filter((g) => g.status === "draft");
1146
+ for (const g of drafts) await ready(g.id);
1147
+ return drafts.map((g) => g.id);
1244
1148
  }
1245
1149
 
1246
1150
  export async function remove(id) {
@@ -1255,14 +1159,49 @@ export async function remove(id) {
1255
1159
  store.remove(id);
1256
1160
  }
1257
1161
 
1258
- /** Write every local guide to a directory as plain markdown. Returns the paths written. */
1259
- export function exportAll(dir) {
1162
+ /**
1163
+ * Write every guide to a directory as plain markdown: signed in, every one the server lets this
1164
+ * account read, and the local store's own besides. Returns the paths written.
1165
+ */
1166
+ export async function exportAll(dir) {
1260
1167
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1261
- const paths = [];
1262
- for (const g of store.list()) {
1263
- const p = join(dir, `${g.meta.id}.md`);
1264
- writeFileSync(p, serialize(g));
1265
- paths.push(p);
1168
+ const paths = new Map();
1169
+ if (api.loggedIn()) {
1170
+ let after = "";
1171
+ do {
1172
+ const page = await api.exportPage(after);
1173
+ for (const g of page.guides) paths.set(g.id, g.markdown);
1174
+ after = page.next;
1175
+ } while (after);
1176
+ }
1177
+ for (const g of store.list()) if (!paths.has(g.meta.id)) paths.set(g.meta.id, serialize(g));
1178
+ return [...paths].map(([id, md]) => {
1179
+ const p = join(dir, `${id}.md`);
1180
+ writeFileSync(p, md);
1181
+ return p;
1182
+ });
1183
+ }
1184
+
1185
+ /**
1186
+ * Once per machine, signed in: drop the local copies the server says it holds. They were written
1187
+ * by every share, pull and take before `keep()`, and nothing ever removed one. What the server
1188
+ * does not return — shared while signed out, or from another account — is left where it is.
1189
+ */
1190
+ export async function tidy() {
1191
+ if (!api.loggedIn() || store.readConfig().tidied) return 0;
1192
+ const local = new Set(store.list().map((g) => g.meta.id));
1193
+ let removed = 0;
1194
+ let after = "";
1195
+ while (local.size) {
1196
+ const page = await api.exportPage(after);
1197
+ for (const g of page.guides)
1198
+ if (local.delete(g.id)) {
1199
+ store.remove(g.id);
1200
+ removed++;
1201
+ }
1202
+ after = page.next;
1203
+ if (!after) break;
1266
1204
  }
1267
- return paths;
1205
+ store.writeConfig({ tidied: true });
1206
+ return removed;
1268
1207
  }