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/passalong.js CHANGED
@@ -8,28 +8,17 @@ import { basename, join } from "node:path";
8
8
  import * as api from "./api.js";
9
9
  import { context } from "./capture.js";
10
10
  import { refusal, runChecks } from "./checks.js";
11
- import {
12
- bugGuide,
13
- ID_RE,
14
- parse,
15
- STATUSES,
16
- serialize,
17
- stamp,
18
- stripPlaceholders,
19
- unheldFields,
20
- validate,
21
- } from "./guide.js";
22
11
  import * as store from "./store.js";
12
+ import { ID_RE, parse, STATUSES, serialize, stamp, unheldFields, validate } from "./work.js";
23
13
 
24
14
  export class PassalongError extends Error {}
25
15
 
26
- /** "team" or "team/handle" → { team, to }. */
27
16
  /**
28
17
  * Split `--to` into a team and who inside it: "team", "team/@handle", or "team/#group".
29
18
  *
30
19
  * Only the `@` is stripped. The `#` is load-bearing and travels into the frontmatter as written —
31
20
  * it is the sigil the server reads to tell a person from a group, and dropping it here would turn
32
- * a handoff to six people into a lookup for a teammate who does not exist.
21
+ * work for six people into a lookup for a teammate who does not exist.
33
22
  */
34
23
  export function parseTarget(to) {
35
24
  if (!to) return {};
@@ -38,142 +27,124 @@ export function parseTarget(to) {
38
27
  }
39
28
 
40
29
  /**
41
- * Publish a guide from markdown: fill in defaults, validate, store locally, and sync when
42
- * logged in. `to` addresses it: "team", "team/@handle", or "team/#group". Returns
43
- * { guide, path, url, synced }.
30
+ * Store a piece of work from its markdown: fill in what is missing, validate, and send it. `to`
31
+ * addresses it: "team", "team/@handle", or "team/#group"; with no team it goes to the account's
32
+ * current one. `parent` names the work it came out of. Returns { work, url, notified }.
44
33
  */
45
- export async function share(markdown, { cwd = process.cwd(), to, follows } = {}) {
34
+ export async function publish(markdown, { cwd = process.cwd(), to, parent } = {}) {
46
35
  const c = context(cwd);
47
- const raw = parse(markdown);
48
- let guide = stamp(
49
- { meta: raw.meta, body: stripPlaceholders(raw.body) },
50
- {
51
- author: c.author,
52
- source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
53
- },
54
- );
36
+ let work = stamp(parse(markdown), {
37
+ author: c.author,
38
+ source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
39
+ });
55
40
  const target = parseTarget(to);
56
- if (target.team) guide.meta.team = target.team;
57
- if (target.to) guide.meta.to = target.to;
58
- if (
59
- !guide.meta.team &&
60
- (guide.meta.kind === "bug" || guide.meta.kind === "task") &&
61
- api.loggedIn()
62
- ) {
41
+ if (target.team) work.meta.team = target.team;
42
+ if (target.to) work.meta.to = target.to;
43
+ if (!work.meta.team && api.loggedIn()) {
63
44
  const team = api.currentTeam();
64
- if (team) guide.meta.team = team;
45
+ if (team) work.meta.team = team;
65
46
  }
66
- // `follows` names the guide this one came out of: an id, a share link, or a pulled .md file. An id
67
- // is used as it is; a link or a file is read for the id in its own frontmatter. `validate()` below
68
- // is what refuses a guide that names itself.
69
- if (follows) guide.meta.parent = await resolveId(String(follows).trim());
70
- if (guide.meta.to && !guide.meta.team)
71
- throw new PassalongError("`to:` needs a team — address a handoff as team/handle");
47
+ // An id is used as it is; a link or a file is read for the id in its own frontmatter. `validate()`
48
+ // below is what refuses work that names itself.
49
+ if (parent) work.meta.parent = await resolveId(String(parent).trim());
50
+ if (work.meta.to && !work.meta.team)
51
+ throw new PassalongError("`to:` needs a team — address it as team/@handle");
72
52
  const errors = [
73
53
  ...unheldFields(markdown).map(
74
54
  (k) =>
75
55
  `frontmatter "${k}" holds more than a string or a list of strings, and it would be dropped — move it into the body (a fenced yaml block keeps it as written)`,
76
56
  ),
77
- ...validate(guide),
57
+ ...validate(work),
78
58
  ];
79
59
  if (errors.length)
80
- throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
60
+ throw new PassalongError(`this is not ready to send:\n - ${errors.join("\n - ")}`);
81
61
 
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) };
62
+ needsSync("sending work");
63
+ const res = await api.publish(work.meta.id, serialize(work));
64
+ work = { ...work, meta: { ...work.meta, url: res.url } };
65
+ return { work, url: res.url, notified: Boolean(res.notified) };
88
66
  }
89
67
 
90
68
  /**
91
- * File a set of bugs as one report.
92
- *
93
- * The report is opened first because each issue's frontmatter names it, and the issues go up one
94
- * at a time — a partial failure that has filed three of five should say which three, since the
95
- * three that landed are real guides someone can already act on.
69
+ * One piece of work or several, written from fields rather than markdown: what an agent hands over
70
+ * when it writes work down. Each may name the earlier ones it needs by position (`after: [0, 1]`),
71
+ * and those become its `blocked_by`, so they are handed out in an order that works and the
72
+ * independent ones run side by side.
96
73
  *
97
- * Unlike `share()`, this needs sync: a report is a server-side parent, and there is nothing local
98
- * for a set to belong to. One bug on its own does not — that is just `share()` with a bug
99
- * document, and it works offline like any other guide.
74
+ * Only earlier ones: work that waits on itself or on a later one never finishes, and it is refused
75
+ * before anything is written rather than discovered as a queue that never moves. `draft` holds them
76
+ * all in Draft, for a plan a person should read before any of it runs.
100
77
  */
101
- export async function fileBugs(
102
- { title = "", environment = "", issues = [] },
103
- { cwd = process.cwd(), to } = {},
104
- ) {
105
- if (!issues.length) throw new PassalongError("a report needs at least one issue");
106
- if (!api.loggedIn())
107
- throw new PassalongError("filing a report needs sync — run `passalong login` first");
108
- const target = parseTarget(to);
109
- const team = target.team || api.currentTeam();
110
- const { report } = await api.createReport({
111
- title,
112
- environment,
113
- team,
114
- to: target.to || "",
78
+ export async function createWork(items, { draft = false, to, cwd = process.cwd() } = {}) {
79
+ if (!Array.isArray(items) || !items.length)
80
+ throw new PassalongError("say what the work is: at least one item");
81
+ items.forEach((item, i) => {
82
+ for (const j of item.after || [])
83
+ if (!Number.isInteger(j) || j < 0 || j >= i)
84
+ throw new PassalongError(
85
+ `item ${i} ("${item.title}") can only wait on an earlier one, not ${j}`,
86
+ );
115
87
  });
116
-
117
- const filed = [];
118
- try {
119
- for (const issue of issues) {
120
- const markdown = bugGuide({ ...issue, report: report.id, environment });
121
- const { guide, url } = await share(markdown, { cwd, to });
122
- filed.push({
123
- id: guide.meta.id,
124
- title: guide.meta.title,
125
- area: guide.meta.area || "",
126
- severity: guide.meta.severity || "",
127
- url,
128
- });
129
- }
130
- } catch (err) {
131
- if (!filed.length) throw err;
132
- throw new PassalongError(
133
- `filed ${filed.length} of ${issues.length} issues (${filed
134
- .map((f) => f.id)
135
- .join(", ")}) before failing: ${err.message}`,
136
- );
88
+ const repo = context(cwd).repo;
89
+ const made = [];
90
+ for (const item of items) {
91
+ const meta = {
92
+ title: item.title,
93
+ summary: item.summary,
94
+ ...(draft ? { status: "draft" } : {}),
95
+ target_context: item.repo ?? repo,
96
+ ...(item.tags?.length ? { tags: item.tags } : {}),
97
+ };
98
+ const after = (item.after || []).map((j) => made[j].id);
99
+ if (after.length) meta.blocked_by = after;
100
+ const { work, url } = await publish(serialize({ meta, body: item.body || "" }), {
101
+ cwd,
102
+ to: item.to || to,
103
+ parent: item.parent,
104
+ });
105
+ made.push({ id: work.meta.id, title: work.meta.title, status: work.meta.status, url });
137
106
  }
138
- return { report: { id: report.id, title, environment, team }, issues: filed };
107
+ return made;
139
108
  }
140
109
 
141
110
  /**
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.
111
+ * Replace a piece of work's text, by its id: the whole document, frontmatter first, as `find_work`
112
+ * handed it over. What it is called and what it says are both in there.
113
+ */
114
+ export async function updateText(id, markdown, { cwd = process.cwd() } = {}) {
115
+ const g = parse(markdown);
116
+ if (g.meta.id && g.meta.id !== id)
117
+ throw new PassalongError(`the document says it is ${g.meta.id}, not ${id}`);
118
+ return publish(serialize({ meta: { ...g.meta, id }, body: g.body }), { cwd });
119
+ }
120
+
121
+ /**
122
+ * Resolve a reference (id, share URL, or local file path) to a piece of work. An id is the server's
123
+ * to answer: nothing keeps a copy on this machine. `peek` reads without recording a pull.
144
124
  */
145
125
  export async function resolve(ref, { peek = false } = {}) {
146
126
  if (/^https?:\/\//.test(ref)) {
147
127
  const md = await api.fetchShared(ref);
148
- return { guide: parse(md), from: "link" };
128
+ return { work: parse(md), from: "link" };
149
129
  }
150
130
  if (ref.endsWith(".md") && existsSync(ref)) {
151
- return { guide: parse(readFileSync(ref, "utf8")), from: "file" };
131
+ return { work: parse(readFileSync(ref, "utf8")), from: "file" };
152
132
  }
153
133
  if (!ID_RE.test(ref))
154
134
  throw new PassalongError(`"${ref}" is not a passalong id, share link, or .md file`);
155
- needsSync("reading a guide by id");
135
+ needsSync("reading work by id");
156
136
  try {
157
- return { guide: parse(await api.get(ref, { peek })), from: "sync" };
137
+ return { work: parse(await api.get(ref, { peek })), from: "sync" };
158
138
  } catch (err) {
159
- if (err.status === 404) throw new PassalongError(`no guide "${ref}"`);
139
+ if (err.status === 404) throw new PassalongError(`no work "${ref}"`);
160
140
  throw err;
161
141
  }
162
142
  }
163
143
 
164
144
  /**
165
- * Resolve a reference to its id alone, without fetching the guide.
166
- *
167
- * The reader's two answers — an ack and a verdict — are writes that take an id and nothing else,
168
- * so the guide body they used to fetch on the way was never read. That fetch was not free:
169
- * `GET /v1/guides/:id` records a pull, which is how somebody saying "not me" on a handoff
170
- * registered as taking delivery of it — inflating the guide's pull count and moving it into the
171
- * sender's "landed" queue. An id needs no lookup at all, and a link or a file carries the id in
172
- * its own frontmatter.
173
- *
174
- * Unlike `resolve()`, a bare id is not checked for existence here. The server answers that when
175
- * the write lands, and "no such guide" from the route the write went to is the same news one
176
- * round trip earlier.
145
+ * Resolve a reference to its id alone, without fetching it. A write needs an id and nothing else,
146
+ * and `GET /v1/work/:id` records a pull; a link or a file carries the id in its own frontmatter.
147
+ * A bare id is not checked here: the route the write goes to answers that.
177
148
  */
178
149
  export async function resolveId(ref) {
179
150
  if (/^https?:\/\//.test(ref)) return parse(await api.fetchShared(ref)).meta.id;
@@ -182,18 +153,7 @@ export async function resolveId(ref) {
182
153
  throw new PassalongError(`"${ref}" is not a passalong id, share link, or .md file`);
183
154
  }
184
155
 
185
- /**
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.
189
- */
190
- export async function pull(ref, { cwd = process.cwd(), save = false } = {}) {
191
- const { guide, from } = await resolve(ref);
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. */
156
+ /** Write a piece of work into `.passalong/<id>.md` under `cwd`, when somebody asked for a file. */
197
157
  function saveTo(cwd, id, markdown) {
198
158
  const path = join(localDir(cwd), `${id}.md`);
199
159
  writeFileSync(path, markdown);
@@ -203,9 +163,9 @@ function saveTo(cwd, id, markdown) {
203
163
  /**
204
164
  * `.passalong/` in a working directory, made if it is missing.
205
165
  *
206
- * A pulled guide carries its own share URL in the frontmatter, and that URL needs no account to
207
- * read. Committing this directory would publish the guide to anyone who can see the repo, so the
208
- * directory ignores itself. Delete its .gitignore if you do want guides in the repo.
166
+ * Work saved there carries its own share URL in the frontmatter, and that URL needs no account to
167
+ * read. Committing this directory would publish it to anyone who can see the repo, so the directory
168
+ * ignores itself. Delete its .gitignore if you do want it in the repo.
209
169
  */
210
170
  function localDir(cwd) {
211
171
  const dir = join(cwd, ".passalong");
@@ -216,13 +176,12 @@ function localDir(cwd) {
216
176
  }
217
177
 
218
178
  /**
219
- * Who this worktree is to the task queue, minted the first time it asks.
179
+ * Who this worktree is to the queue, minted the first time it asks.
220
180
  *
221
181
  * A claim belongs to an agent, not an account: one person runs several, and "no two agents on one
222
- * task" means nothing if the server cannot tell them apart. The id lives in the worktree's own
223
- * `.passalong/agent.json`, so a restarted session in the same worktree is the same agent and gets
224
- * its task back, and a second worktree of the same repo is a different agent. Per machine would
225
- * let two worktrees on one laptop share a lock; per session would lose the task on every restart.
182
+ * piece of work" means nothing if the server cannot tell them apart. The id lives in the worktree's
183
+ * own `.passalong/agent.json`, so a restarted session in the same worktree is the same agent and
184
+ * gets its work back, and a second worktree of the same repo is a different agent.
226
185
  */
227
186
  export function agent(cwd = process.cwd()) {
228
187
  const c = context(cwd);
@@ -253,44 +212,24 @@ function knownAgent(cwd = process.cwd()) {
253
212
  function needsSync(what) {
254
213
  if (!api.loggedIn())
255
214
  throw new PassalongError(
256
- `${what} needs sync — the queue lives on the server (run \`passalong login\`)`,
215
+ `${what} needs sync — work lives on the server (run \`passalong login\`)`,
257
216
  );
258
217
  }
259
218
 
260
- /** Every task you can see, with where it is and who has it. */
261
- export async function tasks() {
262
- needsSync("the task queue");
263
- return (await api.tasks()).tasks;
264
- }
265
-
266
219
  /**
267
- * Take the next task for this worktree's repo — always a new one, since an agent may hold several.
268
- * `null` when there is nothing to do. Written to `.passalong/<id>.md` only when `save` asks.
220
+ * Take the next piece of work for this worktree's repo — always a new one, since an agent may hold
221
+ * several. `null` when there is nothing to do. `alone` asks only for work a person marked safe to
222
+ * run unattended (the runner).
269
223
  */
270
- export async function nextTask({ cwd = process.cwd(), any = false, save = false } = {}) {
271
- needsSync("taking a task");
272
- const { task } = await api.nextTask({ ...agent(cwd), any });
273
- if (!task) return null;
274
- return { ...task, path: save ? saveTo(agent(cwd).worktree, task.id, task.markdown) : null };
275
- }
276
-
277
- /** Word from the agent holding a task: renews its lease, and `note` becomes the board's line. */
278
- export async function taskProgress(id, note, { cwd = process.cwd() } = {}) {
279
- needsSync("reporting progress");
280
- const { agent: a } = agent(cwd);
281
- return api.taskProgress(id, note ? { agent: a, note } : { agent: a });
224
+ export async function nextWork({ cwd = process.cwd(), any = false, alone = false } = {}) {
225
+ needsSync("taking work");
226
+ const { task } = await api.nextTask({ ...agent(cwd), any, ...(alone ? { alone: true } : {}) });
227
+ return task || null;
282
228
  }
283
229
 
284
230
  /**
285
- * What a hand-in has to carry, checked before anything is published.
286
- *
287
- * The server refuses the call without it anyway; the reason to check first is that publishing the
288
- * write-up and then being refused leaves a guide nobody asked for behind every refused hand-in.
289
- * The rule and the wording are the server's — see evidenceProblem() in apps/api/src/claims.ts.
290
- */
291
- /**
292
- * The claim generation this worktree was given when it took a guide, kept in `.passalong/held.json`
293
- * beside `agent.json`. See apps/api/migrations/0029_claim_fence.sql.
231
+ * The claim generation this worktree was given when it took a piece of work, kept in
232
+ * `.passalong/held.json` beside `agent.json`. See apps/api/migrations/0029_claim_fence.sql.
294
233
  *
295
234
  * The agent never sees this number and is never asked for it — a model echoing an integer back
296
235
  * three calls later is not a lock. `take` writes it, and every call that writes to the claim sends
@@ -310,7 +249,7 @@ function heldFences(cwd) {
310
249
  }
311
250
  }
312
251
 
313
- /** The number this worktree holds for a guide, or undefined when it has none to send. */
252
+ /** The number this worktree holds for a piece of work, or undefined when it has none to send. */
314
253
  function fenceFor(id, cwd) {
315
254
  const n = heldFences(cwd)[id];
316
255
  return Number.isInteger(n) ? n : undefined;
@@ -326,6 +265,10 @@ function rememberFence(id, fence, cwd) {
326
265
  /** A check a runner executed: the command it ran, and what the process returned. See checks.js. */
327
266
  const verified = (c) => Boolean(c?.cmd) && (typeof c?.exit === "number" || c?.exit === null);
328
267
 
268
+ /**
269
+ * What a hand-in has to carry, checked before it is sent. The rule and the wording are the
270
+ * server's — see evidenceProblem() in apps/api/src/claims.ts.
271
+ */
329
272
  function requireEvidence(evidence) {
330
273
  const said = String(evidence || "").trim();
331
274
  if (said.length >= 16) return;
@@ -337,52 +280,20 @@ function requireEvidence(evidence) {
337
280
  }
338
281
 
339
282
  /**
340
- * The work is done: the task moves to review with a transfer guide about it attached.
341
- *
342
- * Pass `markdown` and this writes that guide too, with `parent:` naming the task, so the write-up
343
- * and the finish are one step and neither can be skipped or pointed at the wrong guide. Pass
344
- * `report` instead when the guide is already published.
345
- */
346
- export async function finishTask(
347
- id,
348
- { report, markdown, evidence = "", pr = "", note = "", cwd = process.cwd() } = {},
349
- ) {
350
- needsSync("finishing a task");
351
- requireEvidence(evidence);
352
- if (!report && markdown) {
353
- const g = parse(markdown);
354
- // A task's report is a transfer guide, and this code is what is making one — so it says so
355
- // rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
356
- // agent's hand-in because its write-up left a line off would be this function's omission
357
- // charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
358
- // author's to leave open, because the caller already knows the answer.
359
- report = (
360
- await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
361
- cwd,
362
- })
363
- ).guide.meta.id;
364
- }
365
- if (!report)
366
- throw new PassalongError(
367
- "finishing needs the write-up: `markdown` for a transfer guide about the work, or `report` with the id of one",
368
- );
369
- return api.finishTask(id, { agent: agent(cwd).agent, report, evidence, pr, note });
370
- }
371
-
372
- /**
373
- * The four verbs every guide answers to, whatever its kind (docs/V2.md §11). Each answer carries
374
- * `next`, the calls that make sense from here, and `say` when the move is to stop.
283
+ * The calls every piece of work answers to. Each answer carries `next`, the calls that make sense
284
+ * from here, and `say` when the move is to stop.
375
285
  *
376
- * take: this worktree's agent takes guide `id`, or with no id the next one waiting for it. The
377
- * document comes back in the answer, and is written to `.passalong/<id>.md` only when `save` asks.
286
+ * take: this worktree's agent takes work `id` (an id, a share link or a saved .md), or with no id
287
+ * the next one waiting for it. The document comes back in the answer, and is written to
288
+ * `.passalong/<id>.md` only when `save` asks.
378
289
  */
379
290
  export async function take(id, { cwd = process.cwd(), any = false, save = false } = {}) {
380
291
  needsSync("taking work");
381
292
  const who = agent(cwd);
382
293
  const r = await api.take({ ...who, ...(id ? { id: await resolveId(id) } : {}), any });
383
- if (!r.guide?.markdown) return r;
384
- rememberFence(r.guide.id, r.guide.fence, cwd);
385
- return { ...r, path: save ? saveTo(who.worktree, r.guide.id, r.guide.markdown) : null };
294
+ if (!r.work?.markdown) return r;
295
+ rememberFence(r.work.id, r.work.fence, cwd);
296
+ return { ...r, path: save ? saveTo(who.worktree, r.work.id, r.work.markdown) : null };
386
297
  }
387
298
 
388
299
  /** Word from the agent that holds it: the lease starts again, and `note` is what the hub shows. */
@@ -397,35 +308,25 @@ export async function progress(id, note, { cwd = process.cwd() } = {}) {
397
308
  }
398
309
 
399
310
  /**
400
- * Done here. A task hands in a write-up — `markdown`, published with `parent:` naming the task, or
401
- * `report`, the id of one already published — for its author to review against Acceptance. Any
402
- * other guide hands in whether its Verification held: `ok`, and a `note` when it did not.
403
- *
404
- * Every hand-in carries `evidence`: what was run and what came back. The write-up and the `ok` are
405
- * both the agent's word for its own work; evidence is the part the reviewer can check.
311
+ * Finished here: the work goes to Done with what was shown. Every hand-in carries `evidence` or
312
+ * `checks` — what was run and what came back — because the note is the agent's word for its own
313
+ * work and evidence is the part a person can check. `writeup` is what had to be adapted.
406
314
  */
407
315
  export async function handIn(
408
316
  id,
409
317
  {
410
- ok,
411
318
  note = "",
412
319
  evidence = "",
413
320
  checks = [],
414
321
  writeup = "",
415
322
  risk = "",
416
- markdown,
417
- report,
418
323
  pr = "",
419
324
  cwd = process.cwd(),
420
325
  } = {},
421
326
  ) {
422
327
  needsSync("handing work in");
423
- // A check that names a command is run here, before anything is sent.
424
- //
425
- // The MCP server did this and this function did not, so "put the command in `cmd` and it is
426
- // executed here" was true over one transport and a quiet lie over the other: a `cmd` handed to
427
- // this function was passed along as a string nothing had executed. Checks that already carry an
428
- // exit came from the server's own runner and are left alone, so nothing runs twice.
328
+ // A check that names a command is run here, before anything is sent. Checks that already carry
329
+ // an exit came from the MCP server's own runner and are left alone, so nothing runs twice.
429
330
  const pending = checks.filter((c) => c?.cmd && typeof c?.exit !== "number" && c?.exit !== null);
430
331
  if (pending.length) {
431
332
  const done = runChecks(pending, { cwd });
@@ -435,24 +336,10 @@ export async function handIn(
435
336
  throw new Error(refusal(done.failed, { ran: done.ran, total: checks.length }));
436
337
  }
437
338
  }
438
- // Checks are evidence, sorted against the lines they answer: bringing them is bringing it. A
439
- // check whose command was actually run is exempt from the length rule — its exit code is the
440
- // evidence, and `test -f dist/app.js` exiting 0 says more than any sentence about it would.
339
+ // Checks are evidence, sorted against what they answer: bringing them is bringing it. A check
340
+ // whose command was actually run is exempt from the length rule — its exit code is the evidence.
441
341
  if (checks.length) checks.forEach((c) => (verified(c) ? null : requireEvidence(c?.ran)));
442
342
  else requireEvidence(evidence);
443
- if (!report && markdown) {
444
- const g = parse(markdown);
445
- // A task's report is a transfer guide, and this code is what is making one — so it says so
446
- // rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
447
- // agent's hand-in because its write-up left a line off would be this function's omission
448
- // charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
449
- // author's to leave open, because the caller already knows the answer.
450
- report = (
451
- await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
452
- cwd,
453
- })
454
- ).guide.meta.id;
455
- }
456
343
  const fence = fenceFor(id, cwd);
457
344
  return api.handIn(id, {
458
345
  agent: agent(cwd).agent,
@@ -464,50 +351,30 @@ export async function handIn(
464
351
  checks: checks.map((c) => ({
465
352
  check: c.check,
466
353
  ran: c.ran,
467
- // What the agent says happened, in plain words, for the person's table. Left out when
468
- // not said: the row then shows the line that was asked and keeps its run folded.
354
+ // What the agent says happened, in plain words, for the person's table.
469
355
  ...(c.says ? { says: c.says } : {}),
470
356
  // Only present on a check that was run. The server keeps them apart the same way.
471
357
  ...(verified(c) ? { cmd: c.cmd, exit: c.exit, ok: c.ok } : {}),
472
358
  })),
473
359
  }
474
360
  : {}),
475
- ...(report ? { report, pr } : {}),
476
- // Left out when there is nothing to say, rather than sent as "": most hand-ins have nothing
477
- // to adapt, and the field is for the ones that do.
361
+ // Each left out when there is nothing to say, rather than sent as "".
478
362
  ...(writeup ? { writeup } : {}),
479
- // What it could break, for the reviewer. Left out when empty, like the write-up.
480
363
  ...(risk ? { risk } : {}),
481
- ...(typeof ok === "boolean" ? { ok } : {}),
364
+ ...(pr ? { pr } : {}),
482
365
  });
483
366
  }
484
367
 
485
368
  /**
486
- * Give a guide or task you wrote to someone else in its team: `@handle` for one person, `#group`
487
- * for the people who do a thing, or "team" (or empty) for everyone. Whoever held it and is left out
488
- * has it taken back and is told; a task then only goes to the new assignee's agents. The local copy
489
- * is dropped so the next read shows the new `to:`.
369
+ * Give work to someone else in its team: `@handle` for one person, `#group` for the people who do
370
+ * a thing, or "team" (or empty) for everyone. Whoever held it and is left out has it taken back and
371
+ * is told.
490
372
  */
491
373
  export async function assign(ref, to) {
492
- needsSync("reassigning");
374
+ needsSync("giving work to someone");
493
375
  const id = await resolveId(ref);
494
376
  const target = !to || to === "team" || to === "everyone" ? "" : String(to).trim();
495
- const r = await api.assign(id, target);
496
- store.remove(id);
497
- return r;
498
- }
499
-
500
- /**
501
- * Take back something you sent: it is private again and gone from every inbox it was in. Refused
502
- * while somebody is on it. Whoever already opened it keeps what they pulled. The local copy is
503
- * dropped so the next read shows it without the address.
504
- */
505
- export async function recall(ref) {
506
- needsSync("taking a guide back");
507
- const id = await resolveId(ref);
508
- const r = await api.recall(id);
509
- store.remove(id);
510
- return r;
377
+ return api.assign(id, target);
511
378
  }
512
379
 
513
380
  /** Not this agent's to do: what it held is open again, and `why` goes to whoever is next. */
@@ -540,43 +407,81 @@ export async function ask(id, question, { cwd = process.cwd() } = {}) {
540
407
  });
541
408
  }
542
409
 
410
+ /** Why a piece of work needs its author, or "" when it does not. Mirrors needsWhy() in mcp-http.ts. */
411
+ export function needsWhy(t) {
412
+ if (!t.mine) return "";
413
+ if (t.state === "draft") return "in Draft: read it and mark it Ready";
414
+ if (t.state === "stalled") return "its agent went quiet";
415
+ if (t.claim?.asking) return `asking you: ${t.claim.asking}`;
416
+ if (/^BLOCKED:/i.test(t.claim?.note || ""))
417
+ return `stuck on you: ${t.claim.note.replace(/^BLOCKED:\s*/i, "")}`;
418
+ return "";
419
+ }
420
+
421
+ /**
422
+ * The work board, the way `find_work` with nothing hands it over and `passalong board` prints it:
423
+ * what needs you, who is on what, and what is ready to take. Built from `/v1/tasks` (every piece of
424
+ * work you can see, with its state) and `/v1/working` (everything someone holds).
425
+ */
426
+ export async function board() {
427
+ needsSync("the board");
428
+ const [{ tasks }, { working }] = await Promise.all([api.tasks(), api.working()]);
429
+ const hub = `${api.baseUrl()}/hub`;
430
+ const who = (b = {}) => (b.you ? "You" : b.name || (b.handle ? `@${b.handle}` : "A teammate"));
431
+ const needs = tasks
432
+ .map((t) => ({ id: t.id, title: t.title, why: needsWhy(t), url: `${hub}/w/${t.id}` }))
433
+ .filter((t) => t.why);
434
+ return {
435
+ hub,
436
+ counts: {
437
+ needs: needs.length,
438
+ working: working.length,
439
+ open: tasks.filter((t) => ["ready", "blocked", "draft"].includes(t.state)).length,
440
+ done: tasks.filter((t) => t.state === "done").length,
441
+ },
442
+ needs,
443
+ working: working.map((w) => ({
444
+ id: w.id,
445
+ title: w.title,
446
+ who: who(w.by),
447
+ note: w.note,
448
+ stalled: w.state === "stalled",
449
+ url: w.url,
450
+ })),
451
+ ready: tasks
452
+ .filter((t) => t.state === "ready")
453
+ .map((t) => ({
454
+ id: t.id,
455
+ title: t.title,
456
+ target: t.target || "",
457
+ to: t.to || "",
458
+ url: t.url,
459
+ })),
460
+ };
461
+ }
462
+
543
463
  /**
544
- * What this worktree's agent holds right now, and what is waiting for it: what the session-start
545
- * and stop hooks read (src/hooks.js). `held` lists everything it holds, empty when nothing. Counts are best effort
546
- * — a hook must never fail a session over a count.
464
+ * What this worktree's agent holds right now, and what is waiting: what the session-start and stop
465
+ * hooks read (src/hooks.js). Counts are best effort — a hook must never fail a session over one.
547
466
  */
548
467
  export async function now({ cwd = process.cwd() } = {}) {
549
468
  needsSync("seeing what you hold");
550
469
  // Read, never minted: the hooks run in every repo a session opens, and a worktree that has never
551
- // taken anything has no agent — and should not be given a .passalong/ folder for asking.
470
+ // taken anything has no agent — and should not be given a .passalong/ project for asking.
552
471
  const who = knownAgent(cwd);
553
- const [w, t, i, h] = await Promise.allSettled([
554
- api.working(),
555
- api.tasks(),
556
- api.inbox(),
557
- api.handedIn(),
558
- ]);
472
+ const [w, t] = await Promise.allSettled([api.working(), api.tasks()]);
559
473
  if (w.status === "rejected") throw w.reason;
560
474
  const held = who.agent ? w.value.working.filter((x) => x.agent === who.agent && x.by?.you) : [];
561
475
  const tasks = t.status === "fulfilled" ? t.value.tasks : [];
562
- const waiting = {
476
+ return {
477
+ held,
563
478
  ready: tasks.filter((x) => x.state === "ready" && x.target === who.repo).length,
564
- inbox: i.status === "fulfilled" ? i.value.guides.length : 0,
479
+ needs: tasks.filter((x) => needsWhy(x)).length,
480
+ agent: who.agent,
565
481
  };
566
- // Everything waiting on the person, the way the hub's Needs you counts it: their tasks handed in
567
- // or stuck, handoffs of theirs handed in, and guides handed to them.
568
- const review = tasks.filter(
569
- (x) =>
570
- x.mine &&
571
- (x.state === "review" ||
572
- x.state === "stalled" ||
573
- (x.state === "claimed" && (x.claim?.asking || /^BLOCKED:/i.test(x.claim?.note || "")))),
574
- ).length;
575
- const handed = h.status === "fulfilled" ? h.value.handed_in.length : 0;
576
- return { held, waiting, needs: review + handed + waiting.inbox, agent: who.agent };
577
482
  }
578
483
 
579
- /** Finished work back to To do, with why (docs/REVIEW.md §4). */
484
+ /** Finished work back to To do, with why. */
580
485
  export async function reopen(id, why) {
581
486
  needsSync("reopening work");
582
487
  if (!String(why || "").trim())
@@ -584,98 +489,16 @@ export async function reopen(id, why) {
584
489
  return api.reopen(await resolveId(id), why);
585
490
  }
586
491
 
587
- /**
588
- * The gate, a person's three answers to a task. Approve: the work in review is done. Reject: it
589
- * goes back to Ready with `why` on it for the next agent. Release: a task an agent holds, live or
590
- * stalled, goes back to Ready with a note saying where that agent left it.
591
- *
592
- * The local copy is dropped after each, so the next read of the task fetches what the server made
593
- * of it — a reject or a release rewrites the document, and a stale copy would hide exactly the
594
- * line the next reader needs.
595
- */
596
- export async function approveTask(id) {
597
- needsSync("approving work");
598
- const { guide } = await resolve(id, { peek: true });
599
- const r = await (guide.meta.kind === "task" ? api.approveTask(id) : api.closeGuide(id));
600
- store.remove(id);
601
- return r;
602
- }
603
-
604
- export async function rejectTask(id, why) {
605
- needsSync("sending work back");
606
- if (!String(why || "").trim())
607
- throw new PassalongError("say why: whoever takes it next reads it");
608
- const { guide } = await resolve(id, { peek: true });
609
- const r = await (guide.meta.kind === "task" ? api.rejectTask(id, why) : api.sendBack(id, why));
610
- store.remove(id);
611
- return r;
612
- }
613
-
614
- /**
615
- * Take it back from whoever is holding it — a task, a handoff or a bug, and from all of them at
616
- * once when several people hold the same handoff in different repos.
617
- *
618
- * It was tasks only, which left an asymmetry nobody chose: the author of a handoff could mark it
619
- * done or give it to somebody else, and had no way to simply have it back.
620
- */
492
+ /** Take it back from whoever is holding it. */
621
493
  export async function release(id) {
622
494
  needsSync("taking work back");
623
- const r = await api.release(id);
624
- store.remove(id);
625
- return r;
626
- }
627
-
628
- /**
629
- * Pull a guide *and* take the handoff: the two halves of starting work, in one call.
630
- *
631
- * `pull` says the guide arrived. `ack` says somebody is doing it, which is the thing the sender
632
- * cannot find out any other way — and the two are deliberately separate, because reading a guide
633
- * to decide it is not yours is exactly what `pass` is for. Fetching is not committing.
634
- *
635
- * For an agent the distinction is real but the ordering never varies: it reads a guide because it
636
- * is about to follow it. So this is the path an agent takes when it means to do the work, and it
637
- * is one call rather than two — the mandated thing has to be the cheap thing or it gets skipped.
638
- * `pull` alone remains the way to read one without answering for it.
639
- *
640
- * The ack is best-effort and never costs the caller the markdown, which is what they came for:
641
- *
642
- * - the author's own guide is a 403 (`ack` is the reader's answer and an author is not a party
643
- * to it), and that is a no-op, not a failure — an agent working in its own user's repo hits
644
- * this constantly;
645
- * - logged out, there is nobody to tell;
646
- * - anything else is reported in `ack_error` rather than swallowed, because an ack that quietly
647
- * did not land leaves the sender in the silence the whole feature exists to end.
648
- */
649
- export async function start(ref, { cwd = process.cwd(), save = false } = {}) {
650
- const r = await take(await resolveId(ref), { cwd, save });
651
- if (!r.guide?.markdown) return r;
652
- return { ...r, markdown: r.guide.markdown };
653
- }
654
-
655
- /**
656
- * The one line `get_guide` adds to a guide it is handing an agent, or "" when there is nothing to
657
- * say. Separate from the tool so it can be tested without a server.
658
- *
659
- * Only a guide that was addressed to somebody can be taken — one nobody was handed has no handoff
660
- * to answer for, and nudging about it is noise that teaches an agent to ignore the nudge.
661
- *
662
- * It goes after the document, not in front of it. Anything before the opening `---` stops the
663
- * frontmatter being frontmatter, and an agent that writes what it was handed back out to a file
664
- * would lose the id along with it.
665
- */
666
- export function handoffNudge(meta = {}) {
667
- if (!meta.to && !meta.team) return "";
668
- return (
669
- "<!-- passalong: this guide was handed to someone. If you are about to do the work, call " +
670
- "take instead of get_guide — it takes the handoff so the sender stops guessing. " +
671
- "If you are only reading, or it turns out not to be yours, answer with pass with a reason. -->"
672
- );
495
+ return api.release(await resolveId(id));
673
496
  }
674
497
 
675
498
  /**
676
- * The image types a guide can carry as evidence. Mirrors SHOT_TYPES in apps/api/src/shots.ts —
677
- * the server is the authority and refuses anything else; this is what lets a local caller find
678
- * out before spending an upload on it.
499
+ * The image types work can carry. Mirrors SHOT_TYPES in apps/api/src/shots.ts — the server is the
500
+ * authority and refuses anything else; this is what lets a local caller find out before spending an
501
+ * upload on it.
679
502
  */
680
503
  export const SHOT_TYPES = {
681
504
  ".png": "image/png",
@@ -686,65 +509,42 @@ export const SHOT_TYPES = {
686
509
  };
687
510
 
688
511
  /**
689
- * Attach a screenshot from disk, and return the markdown that points at it.
690
- *
691
- * Evidence belongs *in* the document: a guide travels as markdown to anyone holding its link, so
692
- * an image beside it would not travel at all. `claimShots` binds the upload to whichever guide's
693
- * markdown names it at publish, which is why this returns a line to paste rather than taking a
694
- * guide id — there is nothing to attach it to until the document says so.
695
- *
696
- * Local because it reads a path. An agent in a terminal has one; a hosted assistant does not, and
697
- * reaches the same route through `attach_screenshot` on the HTTP server instead.
698
- */
699
- /**
700
- * The same upload, for an agent that has the image and not a path to it.
701
- *
702
- * `attach()` takes a path because a screenshot usually lands on disk. An agent driving a browser
703
- * often has the opposite: the pane hands back an image as a tool result and never writes a file, so
704
- * the only door into evidence was one it could not open. It did what anyone would — swapped to
705
- * `grep` and `tsc` checks and put what it saw in `writeup` — and said so itself: "a grep proves the
706
- * code changed, not that the screen renders right."
707
- *
708
- * Base64 in a tool call, which the HTTP server's instructions rightly warn against, because there
709
- * the alternative is `create_upload` and a curl. Here there is no alternative for bytes held in
710
- * memory, and this server is a local process talking to the same upload the file path uses.
512
+ * The same upload as `attach()`, for an agent that has the image and not a path to it: a browser
513
+ * pane hands an image back as a tool result and never writes a file. Base64 in a tool call is fine
514
+ * here, where this server is a local process talking to the same upload the file path uses.
711
515
  */
712
516
  export async function attachBytes(data, { name = "", type = "image/png" } = {}) {
713
- if (!api.loggedIn())
714
- throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
517
+ needsSync("adding a file");
715
518
  const allowed = new Set(Object.values(SHOT_TYPES));
716
519
  if (!allowed.has(type))
717
- throw new PassalongError(`screenshots must be ${[...allowed].join(", ")} — ${type} is not one`);
718
- let bytes;
719
- try {
720
- // A data: URL is what several browser tools hand back, so it is accepted rather than made the
721
- // caller's problem to strip.
722
- bytes = Buffer.from(String(data).replace(/^data:[^,]*,/, ""), "base64");
723
- } catch {
724
- throw new PassalongError("`data` is not base64");
725
- }
520
+ throw new PassalongError(`pictures must be ${[...allowed].join(", ")} — ${type} is not one`);
521
+ // A data: URL is what several browser tools hand back, so it is accepted rather than made the
522
+ // caller's problem to strip.
523
+ const bytes = Buffer.from(String(data).replace(/^data:[^,]*,/, ""), "base64");
726
524
  if (!bytes.length) throw new PassalongError("`data` decoded to nothing");
727
- // The server's own ceiling, checked here so a large paste fails before it is sent rather than
728
- // after. Anything bigger belongs in create_upload, which streams from a file.
525
+ // The server's own ceiling, checked here so a large paste fails before it is sent.
729
526
  if (bytes.length > 5 * 1024 * 1024)
730
527
  throw new PassalongError(
731
- `that image is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 5MB — ` +
732
- "write it to a file and use create_upload, which streams",
528
+ `that image is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 5MB — write it to a file and pass its path`,
733
529
  );
734
530
  const label = name || "screenshot";
735
531
  const shot = await api.uploadShot(bytes, type, label);
736
532
  return { ...shot, markdown: `![${label}](${shot.url})` };
737
533
  }
738
534
 
535
+ /**
536
+ * Upload a picture from disk, and return the markdown that points at it. It belongs *in* the work's
537
+ * text or a hand-in check: `claimShots` binds the upload to whichever document names it, which is
538
+ * why this returns a line to paste rather than taking an id.
539
+ */
739
540
  export async function attach(file, { name = "" } = {}) {
740
- if (!api.loggedIn())
741
- throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
541
+ needsSync("adding a file");
742
542
  if (!existsSync(file)) throw new PassalongError(`no file at ${file}`);
743
543
  const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
744
544
  const type = SHOT_TYPES[ext];
745
545
  if (!type)
746
546
  throw new PassalongError(
747
- `screenshots must be ${[...new Set(Object.values(SHOT_TYPES))].join(", ")} — ${ext || file} is not one`,
547
+ `pictures must be ${[...new Set(Object.values(SHOT_TYPES))].join(", ")} — ${ext || file} is not one`,
748
548
  );
749
549
  const shot = await api.uploadShot(readFileSync(file), type, name || basename(file));
750
550
  return { ...shot, markdown: `![${name || basename(file)}](${shot.url})` };
@@ -752,7 +552,7 @@ export async function attach(file, { name = "" } = {}) {
752
552
 
753
553
  /**
754
554
  * What a file's extension suggests it is. A hint only: the server reads the bytes and decides, and
755
- * refuses what it does not keep. It exists so a `.csv` is filed as CSV and not as generic text.
555
+ * refuses what it does not keep.
756
556
  */
757
557
  const FILE_HINTS = {
758
558
  ".pdf": "application/pdf",
@@ -768,14 +568,11 @@ const FILE_HINTS = {
768
568
  const FILE_MAX = 10 * 1024 * 1024;
769
569
 
770
570
  /**
771
- * Attach a file that is not a picture — a log, a PDF, a CSV, a zip — and return the markdown line that
772
- * points at it. For a guide body or a bug report, where an agent has the file and nothing else to
773
- * show it with. It is private: whoever can read the guide can download it, and nobody else can, so
774
- * it is not evidence on a page that travels by link.
571
+ * Upload a file that is not a picture — a log, a PDF, a CSV, a zip — and return the markdown line
572
+ * that points at it. It is private: whoever can read the work can download it, and nobody else.
775
573
  */
776
574
  export async function attachFile(file, { name = "" } = {}) {
777
- if (!api.loggedIn())
778
- throw new PassalongError("attaching a file needs sync — run `passalong login` first");
575
+ needsSync("adding a file");
779
576
  if (!existsSync(file)) throw new PassalongError(`no file at ${file}`);
780
577
  const bytes = readFileSync(file);
781
578
  if (!bytes.length) throw new PassalongError(`${file} is empty`);
@@ -789,330 +586,149 @@ export async function attachFile(file, { name = "" } = {}) {
789
586
  return { ...attachment, markdown: `[${label}](${attachment.url})` };
790
587
  }
791
588
 
589
+ /** A picture or any other file from disk, whichever it is, as the line that points at it. */
590
+ export function attachAny(file, opts = {}) {
591
+ const isImage = Object.keys(SHOT_TYPES).some((x) => file.toLowerCase().endsWith(x));
592
+ return (isImage ? attach : attachFile)(file, opts);
593
+ }
594
+
792
595
  /**
793
- * Write to whoever holds a guide, or leave a note on one nobody holds yet: an answer to a question,
794
- * or something thought of since. `files` are paths — a picture is uploaded as one and drawn in the
795
- * thread, anything else as a file to download — and each becomes a line of the message. The server
796
- * decides who may write and whether anyone is listening, and says so in words.
596
+ * Write to whoever holds a piece of work, or leave a note on one nobody holds yet. `files` are
597
+ * paths, each uploaded and added as a line of the message. The server decides who may write and
598
+ * whether anyone is listening, and says so in words.
797
599
  */
798
600
  export async function reply(ref, text, { files = [] } = {}) {
799
601
  needsSync("replying");
800
602
  const lines = [];
801
- for (const file of files) {
802
- const ext = file.includes(".") ? file.slice(file.lastIndexOf(".")).toLowerCase() : "";
803
- const got = SHOT_TYPES[ext] ? await attach(file) : await attachFile(file);
804
- lines.push(got.markdown);
805
- }
603
+ for (const file of files) lines.push((await attachAny(file)).markdown);
806
604
  const body = [String(text || "").trim(), ...lines].filter(Boolean).join("\n");
807
605
  if (!body) throw new PassalongError("say something, or attach a file");
808
606
  return api.reply(await resolveId(ref), body);
809
607
  }
810
608
 
811
609
  /**
812
- * The line a guide arrives with, saying what a follow-up is and how to write one.
813
- *
814
- * A follow-up is more context for a guide, written as its own guide with `parent` naming the
815
- * original — a missing detail, a step that needed explaining, what changed since, what someone
816
- * found doing it. Whoever opens the original, person or agent, gets its follow-ups with it, which
817
- * is why context belongs there rather than in a one-line verdict note.
818
- *
819
- * What an agent did and found working on the guide is not context for it: that is the hand-in,
820
- * and the note says so. It used to list "what you found doing it" among the things to publish, on
821
- * every `take`, while hand_in said a follow-up was not for answering — the agent followed the one
822
- * it read last, and the author got a second guide to review with the evidence inside it.
823
- *
824
- * Trailing, like the handoff nudge, so it never sits in front of the frontmatter. It is here and
825
- * not only in the server's instructions because an agent reads the payload it is working from and
826
- * skims everything else — the same reason the bug lead is inside the document. For a bug, the
827
- * context worth adding is usually the fix, when it is worth repeating somewhere else: that is a
828
- * transfer guide, and the bug is what it adds context to.
610
+ * What an agent is told after the work it opened: where what it learns goes. Knowledge belongs in
611
+ * the project's documents, where the next person finds it; work somebody else should now do is new
612
+ * work. Neither is a second document handed to one person. Mirrors AFTER in apps/api/src/mcp-http.ts.
829
613
  */
830
- export function followUpNote(meta = {}) {
831
- if (!meta.id) return "";
832
- if (meta.kind === "bug")
833
- return (
834
- "<!-- passalong: once this is fixed, if the fix is worth repeating somewhere else, that is " +
835
- "more context for this bug: publish it as a transfer guide with publish_guide " +
836
- `parent=${meta.id}, and whoever opens this bug gets it too. -->`
837
- );
838
- return (
839
- "<!-- passalong: a follow-up is more context for this guide, written as its own guide. If " +
840
- "this guide needs more — a missing detail, a step that needed explaining, what changed since " +
841
- `— publish that with publish_guide parent=${meta.id}, and whoever opens this guide gets it ` +
842
- "too. What you did and found working on it is not a follow-up: it goes on hand_in, in " +
843
- "`checks` and `writeup`. -->"
844
- );
845
- }
846
-
847
- /** What heads a guide's follow-ups when they are handed to an agent. Same words on both servers. */
848
- export const FOLLOW_UPS_LEAD =
849
- "FOLLOW-UPS — more context added to this guide, oldest first. Read them before acting; where " +
850
- "one contradicts the original, the follow-up is newer.";
851
-
852
- /**
853
- * A guide's follow-ups as the server lists them, oldest first, or [] when there are none, when
854
- * sync is off, or when anything goes wrong. Context is never worth failing a pull over: the guide
855
- * itself is what was asked for, and it is already in hand.
856
- *
857
- * Only with sync, because follow-ups live on the server and are scoped to what this account can
858
- * read — the same condition `resolve()` uses to look a guide up there. Fetching them records no
859
- * pull on them: reading context for a guide is not opening those guides.
860
- *
861
- * `markdown` asks for each one's content too (capped server-side). The api calls are injectable
862
- * so this can be tested without a network.
863
- */
864
- export async function followUpGuides(
865
- meta = {},
866
- { markdown = false, children = api.children, loggedIn = api.loggedIn } = {},
867
- ) {
868
- if (!meta.id || !loggedIn()) return [];
869
- try {
870
- const { guides = [] } = await children(meta.id, { markdown });
871
- const listed = (Array.isArray(guides) ? guides : []).filter((g) => g?.id);
872
- // With content the server already sends oldest first; the plain listing is newest first.
873
- return markdown ? listed : listed.reverse();
874
- } catch {
875
- return [];
876
- }
877
- }
878
-
879
- /**
880
- * A guide's follow-ups with their content, formatted to go after the document for an agent — the
881
- * same shape the remote server's get_guide returns — or "" when there are none.
882
- */
883
- export async function followUps(meta = {}, deps = {}) {
884
- const guides = (await followUpGuides(meta, { ...deps, markdown: true })).filter(
885
- (g) => typeof g.markdown === "string",
886
- );
887
- if (!guides.length) return "";
888
- return [
889
- FOLLOW_UPS_LEAD,
890
- ...guides.map(
891
- (g) => `--- follow-up ${g.id}: ${g.title || "untitled"} ---\n${g.markdown.trimEnd()}`,
892
- ),
893
- ].join("\n\n");
894
- }
614
+ export const AFTER =
615
+ "<!-- passalong: what you learn doing this — how something works, a decision and why — goes " +
616
+ "into the project's documents (find_projects, read_document, save_document), not into a new " +
617
+ "piece of work. Work somebody else should now do is create_work with `parent` set to this id. " +
618
+ "What you did and how you checked it goes on hand_in. -->";
895
619
 
896
620
  /**
897
- * The guide a follow-up came out of, formatted to go in front of it — or "" when there is none.
621
+ * The work this one came out of, formatted to go in front of it — or "" when there is none.
898
622
  *
899
- * Follow-ups travelled one way: opening a guide handed over what was written under it, and opening
900
- * one of those handed over nothing. An agent given a follow-up on its own reads a document that
901
- * assumes a piece of work it has never seen, and either goes looking for it or treats the note as
902
- * the whole job. Where the parent has got to is part of the context, not a separate question.
903
- *
904
- * The same shape the hosted server's parentOf() returns. Injectable for testing, and never worth
905
- * failing the call over: the guide itself is what was asked for.
623
+ * A piece of work read on its own can look like the whole job when it is one part of a bigger one,
624
+ * and where the bigger one has got to is part of the context. Injectable for testing, and never
625
+ * worth failing the call over: the work itself is what was asked for. Mirrors parentOf() in
626
+ * apps/api/src/mcp-http.ts.
906
627
  */
907
- export async function parentGuide(
908
- meta = {},
909
- { parent = api.parent, loggedIn = api.loggedIn } = {},
910
- ) {
628
+ export async function parentWork(meta = {}, { parent = api.parent, loggedIn = api.loggedIn } = {}) {
911
629
  if (!meta.id || !meta.parent || !loggedIn()) return "";
912
630
  try {
913
- const { guide } = await parent(meta.id, { markdown: true });
914
- if (!guide?.id) return "";
915
- const who = guide.by?.name || (guide.by?.handle ? `@${guide.by.handle}` : "");
916
- const state = guide.state === "held" && who ? `held by ${who}` : guide.state || "open";
631
+ const { work } = await parent(meta.id, { markdown: true });
632
+ if (!work?.id) return "";
633
+ const who = work.by?.name || (work.by?.handle ? `@${work.by.handle}` : "");
634
+ const state = work.state === "held" && who ? `held by ${who}` : work.state || "open";
917
635
  return (
918
- `THIS IS A FOLLOW-UP TO ${guide.id}: ${guide.title || "untitled"} — ${state}. It is more ` +
919
- "context for that guide, not a piece of work on its own. Read the guide it follows first; " +
920
- "it is below, and where the two disagree this follow-up is newer. If what it asks for " +
921
- `depends on ${guide.id} being done and it is not, say so rather than starting.\n\n` +
922
- `--- the guide it follows: ${guide.id} ---\n${String(guide.markdown ?? "").trimEnd()}\n\n`
636
+ `THIS CAME OUT OF ${work.id}: ${work.title || "untitled"} — ${state}. It is part of that ` +
637
+ "work, which is below; read it first. If this depends on " +
638
+ `${work.id} being done and it is not, say so rather than starting.\n\n` +
639
+ `--- the work it came out of: ${work.id} ---\n${String(work.markdown ?? "").trimEnd()}\n\n`
923
640
  );
924
641
  } catch {
925
642
  return "";
926
643
  }
927
644
  }
928
645
 
929
- /** Guides on the server this account can see, newest first, optionally filtered. */
646
+ /** Work on the server this account can see, newest first, optionally filtered. */
930
647
  export async function list(query = "", { scope = "" } = {}) {
931
- needsSync("listing guides");
932
- const res = await api.list(query, scope);
933
- return res.guides.map((r) => ({ ...r, where: "synced" }));
934
- }
935
-
936
- /** Guides handed to you (or your teams) that you have not pulled yet. Needs sync. */
937
- export async function inbox() {
938
- const res = await api.inbox();
939
- return res.guides;
940
- }
941
-
942
- /**
943
- * The state of your transfers as four queues: waiting on you, in flight (handed over, untouched),
944
- * and landed (someone else has it). Needs sync.
945
- */
946
- export const board = () => api.board();
947
-
948
- /**
949
- * What you did, newest first: what you published, what you took delivery of, and every verdict and
950
- * ack you gave. The other half of `activity` — that one is what other people did to your guides and
951
- * it clears when you read it; this is your own record and it never clears.
952
- *
953
- * It is a record of what you passed along, not of what you worked on. Work that never became a
954
- * guide has no row, so anything printing this has to say so. Needs sync.
955
- */
956
- export async function log({ repo = "", since = "" } = {}) {
957
- const res = await api.log({ repo, since });
958
- return res.log;
959
- }
960
-
961
- /**
962
- * What happened while you were away: your guides being pulled and shipped, guides handed to you,
963
- * invites taken up. Each item carries a rendered `text` line so every surface says the same thing.
964
- */
965
- export async function activity({ all = false, limit = 50 } = {}) {
966
- return api.notifications({ unread: !all, limit });
648
+ needsSync("finding work");
649
+ return (await api.list(query, scope)).work;
967
650
  }
968
651
 
969
- /**
970
- * Report whether a guide actually works. This is the reader's answer, not the author's status:
971
- * `done` says "I implemented it", a verdict says "I tried it and it holds up" — or does not.
972
- * A failing verdict needs a note; "it doesn't work" without a reason helps nobody.
973
- */
974
- /** Mark notifications seen. No ids means everything unread. */
975
- export const seen = (ids = []) => api.markRead(ids);
976
-
977
- export function summary(g) {
978
- return {
979
- id: g.meta.id,
980
- title: g.meta.title,
981
- status: g.meta.status,
982
- created: g.meta.created,
983
- source_context: g.meta.source_context,
984
- tags: g.meta.tags || [],
985
- stack_assumptions: g.meta.stack_assumptions || [],
986
- url: g.meta.url || null,
987
- team: g.meta.team || "",
988
- to: g.meta.to || "",
989
- mine: true,
990
- };
991
- }
992
-
993
- /** Archive a guide (`consumed`) or put it back (`published`). The server refuses `promoted`. */
652
+ /** Archive a piece of work (`done`), make it Ready, or put it in Draft. */
994
653
  export async function setStatus(id, status) {
995
654
  if (!STATUSES.includes(status))
996
655
  throw new PassalongError(`status must be one of ${STATUSES.join(", ")}`);
997
- const { guide } = await resolve(id, { peek: true });
998
- const next = { ...guide, meta: { ...guide.meta, status } };
999
- // The server first. Saved the other way round, a refusal left the local copy saying what the
1000
- // server had just refused — a teammate's `ready` on someone else's task read as done here.
1001
- await api.setStatus(id, status);
1002
- return next;
656
+ needsSync("changing work");
657
+ return api.setStatus(await resolveId(id), status);
1003
658
  }
1004
659
 
1005
- /**
1006
- * Move a guide from Draft to Ready: a person has read it, and whoever it is for may take it now.
1007
- * Every kind: a handoff or bug is a draft until somebody says so, as a task always was.
1008
- */
660
+ /** Move work from Draft to Ready: a person has read it, and whoever it is for may take it now. */
1009
661
  export async function ready(id) {
1010
- const { guide } = await resolve(id, { peek: true });
1011
- if (guide.meta.status !== "draft")
1012
- throw new PassalongError(`${guide.meta.id} is not in Draft (status: ${guide.meta.status})`);
1013
- return setStatus(guide.meta.id, "published");
662
+ const { work } = await resolve(id, { peek: true });
663
+ if (work.meta.status !== "draft")
664
+ throw new PassalongError(`${work.meta.id} is not in Draft (status: ${work.meta.status})`);
665
+ await setStatus(work.meta.id, "ready");
666
+ return work.meta.id;
1014
667
  }
1015
668
 
1016
- /**
1017
- * One goal as several tasks, written as drafts in order. Each step may name the earlier steps it
1018
- * needs by position (`after: [0, 1]`), and those become its `blocked_by`, so the queue hands them
1019
- * out in an order that works and runs the independent ones side by side.
1020
- *
1021
- * Only earlier steps: a step that waits on itself or on a later one is a plan that never finishes,
1022
- * and it is refused before anything is written rather than discovered as a queue that never moves.
1023
- * Every step lands in Draft — a wrong decomposition is several agents building the wrong thing at
1024
- * once, so a person reads the plan before any of it runs.
1025
- */
1026
- export async function planTasks(steps, { cwd = process.cwd() } = {}) {
1027
- if (!Array.isArray(steps) || !steps.length)
1028
- throw new PassalongError("a plan needs at least one step");
1029
- steps.forEach((step, i) => {
1030
- for (const j of step.after || [])
1031
- if (!Number.isInteger(j) || j < 0 || j >= i)
1032
- throw new PassalongError(
1033
- `step ${i} ("${step.title}") can only wait on an earlier step, not ${j}`,
1034
- );
1035
- });
1036
- const repo = context(cwd).repo;
1037
- const ids = [];
1038
- for (const step of steps) {
1039
- const section = (name, text) =>
1040
- String(text || "").trim() ? [`## ${name}`, String(text).trim(), ""] : [];
1041
- const body = [
1042
- ...section("Goal", step.goal),
1043
- ...section("Context", step.context),
1044
- ...section("Constraints", step.constraints),
1045
- ...section("Acceptance", step.acceptance),
1046
- ...section("Out of scope", step.out_of_scope),
1047
- ].join("\n");
1048
- // The summary is the step's own, said by whoever planned it: it is what a person reads about the
1049
- // task, and this builds the document without guessing one (the server refuses a task without).
1050
- // A plan is the case Draft is for: several tasks at once, which a person reads before any of
1051
- // them runs. Stated here, by the code that knows it is building one.
1052
- const meta = {
1053
- title: step.title,
1054
- summary: step.summary,
1055
- kind: "task",
1056
- status: "draft",
1057
- target_context: step.target_context ?? repo,
1058
- };
1059
- const after = (step.after || []).map((j) => ids[j]);
1060
- if (after.length) meta.blocked_by = after;
1061
- ids.push((await share(serialize({ meta, body }), { cwd })).guide.meta.id);
1062
- }
1063
- return ids;
1064
- }
1065
-
1066
- /** What an agent started by `work()` is told. The task itself goes in too, so it cannot start blind. */
669
+ /** What an agent started by `work()` is told. The work itself goes in too, so it cannot start blind. */
1067
670
  export function workPrompt(t) {
1068
671
  return [
1069
- `You hold passalong task ${t.id}, and no other agent can take it while you do. Here it is:`,
672
+ `You hold passalong work ${t.id}, and no other agent can take it while you do. Here it is:`,
1070
673
  "",
1071
674
  t.markdown.trim(),
1072
675
  "",
1073
- "Do it in this repo. There are no Steps: work out how to reach Goal within Constraints, and",
1074
- "leave Out of scope alone. Call the passalong MCP tool progress with id",
1075
- `${t.id} and a one-line status at each milestone — 30 minutes of silence stalls the task.`,
1076
- "When every Acceptance check holds, commit what you changed as one commit whose message",
1077
- `starts with "${t.id}: " — several tasks share this worktree, and a reviewer reads each one's`,
1078
- "change on its own. Then call hand_in with id",
1079
- `${t.id}, \`pr\` set to that commit's hash, \`markdown\`: a transfer guide saying what you`,
1080
- "did, what you decided and why, and how you checked each Acceptance line, and `evidence`:",
1081
- "what you ran and what came back — the commands and the lines that decided it, the test",
1082
- "summary, the commit. Keep that as you go; at the end you would be writing it from memory,",
1083
- "and it is the one part of the hand-in a reviewer can check. If either call says you no",
1084
- "longer hold the task, stop.",
676
+ "Do it in this repo: read it, work out what done means, and get there. Call the passalong MCP",
677
+ `tool progress with id ${t.id} and a one-line status at each milestone — 30 minutes of`,
678
+ "silence stalls it. When it is done, commit what you changed as one commit whose message",
679
+ `starts with "${t.id}: " — several pieces of work share this worktree, and a person reads each`,
680
+ `one's change on its own. Then call hand_in with id ${t.id}, \`pr\` set to that commit's hash,`,
681
+ "`note`: one plain sentence saying what you did, and `evidence`: what you ran and what came",
682
+ "back — the commands and the lines that decided it, the test summary, the commit. Keep that",
683
+ "as you go; at the end you would be writing it from memory. What you learned about the",
684
+ "project goes into its documents (save_document). If a call says you no longer hold it, stop.",
1085
685
  "",
1086
686
  "Nobody reads what you print: this session runs unattended. If something only a person can",
1087
- "do stands between you and an Acceptance line — a permission, a secret, a decision — call",
687
+ "do stands between you and done — a permission, a secret, a decision — call",
1088
688
  `progress with id ${t.id} and a note starting "BLOCKED: " that says exactly what you`,
1089
- "need, leave the task unfinished and uncommitted, and stop. The board shows that note.",
689
+ "need, leave it unfinished and uncommitted, and stop. The board shows that note.",
1090
690
  ].join("\n");
1091
691
  }
1092
692
 
1093
693
  /**
1094
- * Work the queue from this worktree: take the next task, hand it to an agent, and when the agent
1095
- * has finished it take the next, until there is nothing left.
694
+ * Work the queue from this worktree: take the next piece of work, hand it to an agent, and when the
695
+ * agent has finished it take the next, until there is nothing left.
1096
696
  *
1097
697
  * The agent runs in this worktree, so it is the same agent to the queue — its progress and
1098
698
  * hand_in land on the claim this took. `agent` is a command the prompt is appended to;
1099
699
  * `claude -p` by default, or whatever PASSALONG_AGENT says.
1100
700
  *
1101
- * An agent that exits without finishing stops the loop, rather than piling up half-done work. The
1102
- * task stays claimed here, for a person to see on the board and resume (take it) or release.
701
+ * An agent that exits without finishing stops the loop, rather than piling up half-done work. It
702
+ * stays held here, for a person to see on the board and resume (take it) or release.
703
+ *
704
+ * `watch` is the runner (docs/LOOP.md §1a): left running on an always-on machine with the repo
705
+ * checked out, it takes only work a person marked safe to run alone, waits `every` seconds when
706
+ * there is none, and keeps going past work its agent did not finish — that stays held here, the
707
+ * sweep puts it back in the queue once it goes quiet, and a second time a person is told it is
708
+ * stuck. Nobody is watching, so nothing stops for a person.
1103
709
  */
1104
710
  export async function work({
1105
711
  cwd = process.cwd(),
1106
712
  agent = process.env.PASSALONG_AGENT || "claude -p",
1107
713
  any = false,
1108
714
  once = false,
715
+ watch = false,
716
+ every = 60,
1109
717
  onTask = () => {},
718
+ onIdle = () => {},
719
+ onUnfinished = () => {},
720
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
1110
721
  } = {}) {
1111
722
  needsSync("working the queue");
1112
723
  const [command, ...args] = agent.trim().split(/\s+/);
1113
724
  const finished = [];
1114
725
  for (;;) {
1115
- const t = await nextTask({ cwd, any });
726
+ const t = await nextWork({ cwd, any, alone: watch });
727
+ if (!t && watch) {
728
+ onIdle();
729
+ await sleep(Math.max(10, every) * 1000);
730
+ continue;
731
+ }
1116
732
  if (!t) return { finished, stopped: "empty" };
1117
733
  onTask(t);
1118
734
  const run = spawnSync(command, [...args, workPrompt(t)], {
@@ -1120,9 +736,12 @@ export async function work({
1120
736
  stdio: "inherit",
1121
737
  env: { ...process.env, PASSALONG_TASK: t.id },
1122
738
  });
1123
- // Finishing is final, so a finished task is Done; "review" is a server from before that.
1124
- const state = (await tasks()).find((x) => x.id === t.id)?.state;
1125
- if (state !== "done" && state !== "review")
739
+ const state = (await api.tasks()).tasks.find((x) => x.id === t.id)?.state;
740
+ if (state !== "done" && watch) {
741
+ onUnfinished(t, state, run.status ?? run.error?.message);
742
+ continue;
743
+ }
744
+ if (state !== "done")
1126
745
  return {
1127
746
  finished,
1128
747
  stopped: "unfinished",
@@ -1136,13 +755,13 @@ export async function work({
1136
755
  }
1137
756
 
1138
757
  /**
1139
- * Make every guide you wrote that is still in Draft ready, and return their ids. For the moment a
1140
- * planner has written several and you have read them all. Only your own: sending a guide on is its
1141
- * author's call.
758
+ * Make every piece of work you wrote that is still in Draft ready, and return their ids. For the
759
+ * moment a planner has written several and you have read them all. Only your own: sending work on
760
+ * is its author's call.
1142
761
  */
1143
762
  export async function readyDrafts() {
1144
763
  const drafts = (await list("", { scope: "mine" })).filter((g) => g.status === "draft");
1145
- for (const g of drafts) await ready(g.id);
764
+ for (const g of drafts) await setStatus(g.id, "ready");
1146
765
  return drafts.map((g) => g.id);
1147
766
  }
1148
767
 
@@ -1159,8 +778,8 @@ export async function remove(id) {
1159
778
  }
1160
779
 
1161
780
  /**
1162
- * Write every guide to a directory as plain markdown: signed in, every one the server lets this
1163
- * account read, and the local store's own besides. Returns the paths written.
781
+ * Write every piece of work to a directory as plain markdown: signed in, every one the server lets
782
+ * this account read, and the local store's own besides. Returns the paths written.
1164
783
  */
1165
784
  export async function exportAll(dir) {
1166
785
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
@@ -1169,7 +788,7 @@ export async function exportAll(dir) {
1169
788
  let after = "";
1170
789
  do {
1171
790
  const page = await api.exportPage(after);
1172
- for (const g of page.guides) paths.set(g.id, g.markdown);
791
+ for (const g of page.work) paths.set(g.id, g.markdown);
1173
792
  after = page.next;
1174
793
  } while (after);
1175
794
  }
@@ -1181,26 +800,70 @@ export async function exportAll(dir) {
1181
800
  });
1182
801
  }
1183
802
 
803
+ // The host this product answered on before passalong.dev. It serves the same data, so a copy
804
+ // whose url names it belongs to the default API.
805
+ const OLD_HOST = "passalong.kreativekorna.com";
806
+ const hostOf = (url) => {
807
+ try {
808
+ const { host, hostname } = new URL(url);
809
+ if (["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return "";
810
+ return host === OLD_HOST ? new URL(api.DEFAULT_API).host : host;
811
+ } catch {
812
+ return "";
813
+ }
814
+ };
815
+ // Raised whenever tidy learns to remove something it used to keep, so machines that tidied run it once more.
816
+ const TIDIED = 2;
817
+
1184
818
  /**
1185
819
  * Once per machine, signed in: drop the local copies the server says it holds. They were written
1186
- * by every share, pull and take before `keep()`, and nothing ever removed one. What the server
1187
- * does not return — shared while signed out, or from another account — is left where it is.
820
+ * by every share, pull and take before `keep()`, and nothing ever removed one. Every login on the
821
+ * machine is asked, since a copy may be any of theirs. A copy whose `url:` is on a login's own host
822
+ * and that the server answers 404 for was since deleted, and goes too. Anything else — no url,
823
+ * localhost, another host, or an answer that was neither 200 nor 404 — is left where it is, and an
824
+ * export that failed leaves the flag unset so the next start tries again.
1188
825
  */
1189
826
  export async function tidy() {
1190
- if (!api.loggedIn() || store.readConfig().tidied) return 0;
1191
- const local = new Set(store.list().map((g) => g.meta.id));
827
+ const logins = api.logins();
828
+ if (!logins.length || store.readConfig().tidied === TIDIED) return 0;
829
+ const local = new Map(store.list().map((g) => [g.meta.id, hostOf(g.meta.url)]));
1192
830
  let removed = 0;
1193
- let after = "";
1194
- while (local.size) {
1195
- const page = await api.exportPage(after);
1196
- for (const g of page.guides)
1197
- if (local.delete(g.id)) {
1198
- store.remove(g.id);
1199
- removed++;
1200
- }
1201
- after = page.next;
1202
- if (!after) break;
831
+ let sure = true;
832
+ const drop = (id) => {
833
+ if (!local.delete(id)) return;
834
+ store.remove(id);
835
+ removed++;
836
+ };
837
+ for (const as of logins) {
838
+ try {
839
+ let after = "";
840
+ do {
841
+ const page = await api.exportPage(after, as);
842
+ for (const g of page.work) drop(g.id);
843
+ after = page.next;
844
+ } while (after && local.size);
845
+ } catch {
846
+ sure = false;
847
+ }
848
+ }
849
+ const said = new Map();
850
+ for (const as of logins) {
851
+ const host = hostOf(as.api);
852
+ const ids = [...local].filter(([, h]) => h && h === host).map(([id]) => id);
853
+ // Eight at a time: a few hundred copies is a few seconds, not a few hundred open requests.
854
+ for (let i = 0; i < ids.length; i += 8)
855
+ await Promise.all(
856
+ ids.slice(i, i + 8).map(async (id) => {
857
+ const status = await api.get(id, { peek: true, as }).then(
858
+ () => 200,
859
+ (err) => err.status ?? 0,
860
+ );
861
+ said.set(id, [...(said.get(id) ?? []), status]);
862
+ }),
863
+ );
1203
864
  }
1204
- store.writeConfig({ tidied: true });
865
+ for (const [id, statuses] of said)
866
+ if (statuses.includes(200) || statuses.every((s) => s === 404)) drop(id);
867
+ if (sure) store.writeConfig({ tidied: TIDIED });
1205
868
  return removed;
1206
869
  }