passalong 0.16.0 → 0.18.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,19 @@ 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
+ // Every guide is a draft when it is first shared, whatever its kind: Draft is where it waits
82
+ // until a person says it is ready — `ready()` — and an agent that writes one over MCP comes
83
+ // through here too, so publishing on share would let it reach people with nobody reading it.
84
+ // Sharing *is* putting it in Draft, so a new guide that names no status says so here; one already
85
+ // published keeps what its document says.
86
+ if (!guide.meta.status) guide = { ...guide, meta: { ...guide.meta, status: "draft" } };
87
+
88
+ // The server is where a guide lives; nothing is kept on this machine.
89
+ needsSync("sharing a guide");
90
+ const res = await api.publish(guide.meta.id, serialize(guide));
91
+ const url = res.url;
92
+ guide = { ...guide, meta: { ...guide.meta, url } };
93
+ return { guide, url, synced: true, notified: Boolean(res.notified) };
101
94
  }
102
95
 
103
96
  /**
@@ -151,8 +144,11 @@ export async function fileBugs(
151
144
  return { report: { id: report.id, title, environment, team }, issues: filed };
152
145
  }
153
146
 
154
- /** Resolve a reference (id, share URL, or local file path) to a guide. */
155
- export async function resolve(ref) {
147
+ /**
148
+ * Resolve a reference (id, share URL, or local file path) to a guide. An id is the server's to
149
+ * answer: nothing keeps a copy on this machine. `peek` reads without recording a pull.
150
+ */
151
+ export async function resolve(ref, { peek = false } = {}) {
156
152
  if (/^https?:\/\//.test(ref)) {
157
153
  const md = await api.fetchShared(ref);
158
154
  return { guide: parse(md), from: "link" };
@@ -162,21 +158,13 @@ export async function resolve(ref) {
162
158
  }
163
159
  if (!ID_RE.test(ref))
164
160
  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
- }
161
+ needsSync("reading a guide by id");
162
+ try {
163
+ return { guide: parse(await api.get(ref, { peek })), from: "sync" };
164
+ } catch (err) {
165
+ if (err.status === 404) throw new PassalongError(`no guide "${ref}"`);
166
+ throw err;
176
167
  }
177
- throw new PassalongError(
178
- `no guide "${ref}" (not local${api.loggedIn() ? " or synced" : "; not logged in, so sync was not checked"})`,
179
- );
180
168
  }
181
169
 
182
170
  /**
@@ -201,22 +189,21 @@ export async function resolveId(ref) {
201
189
  }
202
190
 
203
191
  /**
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.
192
+ * Pull a guide and return it, so the caller can put the text straight into an agent's context. A
193
+ * pull of someone else's guide is recorded server-side, which is how the sender sees the transfer
194
+ * landed. Nothing is written unless `save` asks: then it lands in `.passalong/<id>.md` here.
207
195
  */
208
- export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
196
+ export async function pull(ref, { cwd = process.cwd(), save = false } = {}) {
209
197
  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) };
198
+ const markdown = serialize(guide);
199
+ return { guide, from, path: save ? saveTo(cwd, guide.meta.id, markdown) : null, markdown };
200
+ }
201
+
202
+ /** Write a guide into `.passalong/<id>.md` under `cwd`, when somebody asked for a file. */
203
+ function saveTo(cwd, id, markdown) {
204
+ const path = join(localDir(cwd), `${id}.md`);
205
+ writeFileSync(path, markdown);
206
+ return path;
220
207
  }
221
208
 
222
209
  /**
@@ -284,17 +271,13 @@ export async function tasks() {
284
271
 
285
272
  /**
286
273
  * 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.
274
+ * `null` when there is nothing to do. Written to `.passalong/<id>.md` only when `save` asks.
288
275
  */
289
- export async function nextTask({ cwd = process.cwd(), any = false } = {}) {
276
+ export async function nextTask({ cwd = process.cwd(), any = false, save = false } = {}) {
290
277
  needsSync("taking a task");
291
278
  const { task } = await api.nextTask({ ...agent(cwd), any });
292
279
  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 };
280
+ return { ...task, path: save ? saveTo(agent(cwd).worktree, task.id, task.markdown) : null };
298
281
  }
299
282
 
300
283
  /** Word from the agent holding a task: renews its lease, and `note` becomes the board's line. */
@@ -397,18 +380,15 @@ export async function finishTask(
397
380
  * `next`, the calls that make sense from here, and `say` when the move is to stop.
398
381
  *
399
382
  * 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.
383
+ * document comes back in the answer, and is written to `.passalong/<id>.md` only when `save` asks.
401
384
  */
402
- export async function take(id, { cwd = process.cwd(), any = false } = {}) {
385
+ export async function take(id, { cwd = process.cwd(), any = false, save = false } = {}) {
403
386
  needsSync("taking work");
404
387
  const who = agent(cwd);
405
388
  const r = await api.take({ ...who, ...(id ? { id: await resolveId(id) } : {}), any });
406
389
  if (!r.guide?.markdown) return r;
407
390
  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 };
391
+ return { ...r, path: save ? saveTo(who.worktree, r.guide.id, r.guide.markdown) : null };
412
392
  }
413
393
 
414
394
  /** Word from the agent that holds it: the lease starts again, and `note` is what the hub shows. */
@@ -613,16 +593,19 @@ export async function now({ cwd = process.cwd() } = {}) {
613
593
  * line the next reader needs.
614
594
  */
615
595
  export async function approveTask(id) {
616
- needsSync("approving a task");
617
- const r = await api.approveTask(id);
596
+ needsSync("approving work");
597
+ const { guide } = await resolve(id, { peek: true });
598
+ const r = await (guide.meta.kind === "task" ? api.approveTask(id) : api.closeGuide(id));
618
599
  store.remove(id);
619
600
  return r;
620
601
  }
621
602
 
622
603
  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);
604
+ needsSync("sending work back");
605
+ if (!String(why || "").trim())
606
+ throw new PassalongError("say why: whoever takes it next reads it");
607
+ const { guide } = await resolve(id, { peek: true });
608
+ const r = await (guide.meta.kind === "task" ? api.rejectTask(id, why) : api.sendBack(id, why));
626
609
  store.remove(id);
627
610
  return r;
628
611
  }
@@ -662,17 +645,10 @@ export async function release(id) {
662
645
  * - anything else is reported in `ack_error` rather than swallowed, because an ack that quietly
663
646
  * did not land leaves the sender in the silence the whole feature exists to end.
664
647
  */
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
- }
648
+ export async function start(ref, { cwd = process.cwd(), save = false } = {}) {
649
+ const r = await take(await resolveId(ref), { cwd, save });
650
+ if (!r.guide?.markdown) return r;
651
+ return { ...r, markdown: r.guide.markdown };
676
652
  }
677
653
 
678
654
  /**
@@ -949,24 +925,11 @@ export async function parentGuide(
949
925
  }
950
926
  }
951
927
 
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 || ""));
928
+ /** Guides on the server this account can see, newest first, optionally filtered. */
929
+ export async function list(query = "", { scope = "" } = {}) {
930
+ needsSync("listing guides");
931
+ const res = await api.list(query, scope);
932
+ return res.guides.map((r) => ({ ...r, where: "synced" }));
970
933
  }
971
934
 
972
935
  /** Guides handed to you (or your teams) that you have not pulled yet. Needs sync. */
@@ -1007,64 +970,6 @@ export async function activity({ all = false, limit = 50 } = {}) {
1007
970
  * `done` says "I implemented it", a verdict says "I tried it and it holds up" — or does not.
1008
971
  * A failing verdict needs a note; "it doesn't work" without a reason helps nobody.
1009
972
  */
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
973
  /** Mark notifications seen. No ids means everything unread. */
1069
974
  export const seen = (ids = []) => api.markRead(ids);
1070
975
 
@@ -1088,25 +993,20 @@ export function summary(g) {
1088
993
  export async function setStatus(id, status) {
1089
994
  if (!STATUSES.includes(status))
1090
995
  throw new PassalongError(`status must be one of ${STATUSES.join(", ")}`);
1091
- const { guide } = await resolve(id);
996
+ const { guide } = await resolve(id, { peek: true });
1092
997
  const next = { ...guide, meta: { ...guide.meta, status } };
1093
998
  // The server first. Saved the other way round, a refusal left the local copy saying what the
1094
999
  // 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);
1000
+ await api.setStatus(id, status);
1097
1001
  return next;
1098
1002
  }
1099
1003
 
1100
1004
  /**
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.
1005
+ * Move a guide from Draft to Ready: a person has read it, and whoever it is for may take it now.
1006
+ * Every kind: a handoff or bug is a draft until somebody says so, as a task always was.
1105
1007
  */
1106
1008
  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`);
1009
+ const { guide } = await resolve(id, { peek: true });
1110
1010
  if (guide.meta.status !== "draft")
1111
1011
  throw new PassalongError(`${guide.meta.id} is not in Draft (status: ${guide.meta.status})`);
1112
1012
  return setStatus(guide.meta.id, "published");
@@ -1162,8 +1062,7 @@ export async function planTasks(steps, { cwd = process.cwd() } = {}) {
1162
1062
  /** What an agent started by `work()` is told. The task itself goes in too, so it cannot start blind. */
1163
1063
  export function workPrompt(t) {
1164
1064
  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:`,
1065
+ `You hold passalong task ${t.id}, and no other agent can take it while you do. Here it is:`,
1167
1066
  "",
1168
1067
  t.markdown.trim(),
1169
1068
  "",
@@ -1216,7 +1115,7 @@ export async function work({
1216
1115
  const run = spawnSync(command, [...args, workPrompt(t)], {
1217
1116
  cwd,
1218
1117
  stdio: "inherit",
1219
- env: { ...process.env, PASSALONG_TASK: t.id, PASSALONG_TASK_PATH: t.path },
1118
+ env: { ...process.env, PASSALONG_TASK: t.id },
1220
1119
  });
1221
1120
  const state = (await tasks()).find((x) => x.id === t.id)?.state;
1222
1121
  if (state !== "review")
@@ -1233,14 +1132,14 @@ export async function work({
1233
1132
  }
1234
1133
 
1235
1134
  /**
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.
1135
+ * Make every guide you wrote that is still in Draft ready, and return their ids. For the moment a
1136
+ * planner has written several and you have read them all. Only your own: sending a guide on is its
1137
+ * author's call.
1239
1138
  */
1240
1139
  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);
1140
+ const drafts = (await list("", { scope: "mine" })).filter((g) => g.status === "draft");
1141
+ for (const g of drafts) await ready(g.id);
1142
+ return drafts.map((g) => g.id);
1244
1143
  }
1245
1144
 
1246
1145
  export async function remove(id) {
@@ -1255,14 +1154,49 @@ export async function remove(id) {
1255
1154
  store.remove(id);
1256
1155
  }
1257
1156
 
1258
- /** Write every local guide to a directory as plain markdown. Returns the paths written. */
1259
- export function exportAll(dir) {
1157
+ /**
1158
+ * Write every guide to a directory as plain markdown: signed in, every one the server lets this
1159
+ * account read, and the local store's own besides. Returns the paths written.
1160
+ */
1161
+ export async function exportAll(dir) {
1260
1162
  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);
1163
+ const paths = new Map();
1164
+ if (api.loggedIn()) {
1165
+ let after = "";
1166
+ do {
1167
+ const page = await api.exportPage(after);
1168
+ for (const g of page.guides) paths.set(g.id, g.markdown);
1169
+ after = page.next;
1170
+ } while (after);
1171
+ }
1172
+ for (const g of store.list()) if (!paths.has(g.meta.id)) paths.set(g.meta.id, serialize(g));
1173
+ return [...paths].map(([id, md]) => {
1174
+ const p = join(dir, `${id}.md`);
1175
+ writeFileSync(p, md);
1176
+ return p;
1177
+ });
1178
+ }
1179
+
1180
+ /**
1181
+ * Once per machine, signed in: drop the local copies the server says it holds. They were written
1182
+ * by every share, pull and take before `keep()`, and nothing ever removed one. What the server
1183
+ * does not return — shared while signed out, or from another account — is left where it is.
1184
+ */
1185
+ export async function tidy() {
1186
+ if (!api.loggedIn() || store.readConfig().tidied) return 0;
1187
+ const local = new Set(store.list().map((g) => g.meta.id));
1188
+ let removed = 0;
1189
+ let after = "";
1190
+ while (local.size) {
1191
+ const page = await api.exportPage(after);
1192
+ for (const g of page.guides)
1193
+ if (local.delete(g.id)) {
1194
+ store.remove(g.id);
1195
+ removed++;
1196
+ }
1197
+ after = page.next;
1198
+ if (!after) break;
1266
1199
  }
1267
- return paths;
1200
+ store.writeConfig({ tidied: true });
1201
+ return removed;
1268
1202
  }