passalong 0.21.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/passalong.js CHANGED
@@ -8,28 +8,18 @@ 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";
11
+ import * as runner from "./runner.js";
22
12
  import * as store from "./store.js";
13
+ import { ID_RE, parse, STATUSES, serialize, stamp, unheldFields, validate } from "./work.js";
23
14
 
24
15
  export class PassalongError extends Error {}
25
16
 
26
- /** "team" or "team/handle" → { team, to }. */
27
17
  /**
28
18
  * Split `--to` into a team and who inside it: "team", "team/@handle", or "team/#group".
29
19
  *
30
20
  * Only the `@` is stripped. The `#` is load-bearing and travels into the frontmatter as written —
31
21
  * 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.
22
+ * work for six people into a lookup for a teammate who does not exist.
33
23
  */
34
24
  export function parseTarget(to) {
35
25
  if (!to) return {};
@@ -38,142 +28,124 @@ export function parseTarget(to) {
38
28
  }
39
29
 
40
30
  /**
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 }.
31
+ * Store a piece of work from its markdown: fill in what is missing, validate, and send it. `to`
32
+ * addresses it: "team", "team/@handle", or "team/#group"; with no team it goes to the account's
33
+ * current one. `parent` names the work it came out of. Returns { work, url, notified }.
44
34
  */
45
- export async function share(markdown, { cwd = process.cwd(), to, follows } = {}) {
35
+ export async function publish(markdown, { cwd = process.cwd(), to, parent } = {}) {
46
36
  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
- );
37
+ let work = stamp(parse(markdown), {
38
+ author: c.author,
39
+ source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
40
+ });
55
41
  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
- ) {
42
+ if (target.team) work.meta.team = target.team;
43
+ if (target.to) work.meta.to = target.to;
44
+ if (!work.meta.team && api.loggedIn()) {
63
45
  const team = api.currentTeam();
64
- if (team) guide.meta.team = team;
46
+ if (team) work.meta.team = team;
65
47
  }
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");
48
+ // An id is used as it is; a link or a file is read for the id in its own frontmatter. `validate()`
49
+ // below is what refuses work that names itself.
50
+ if (parent) work.meta.parent = await resolveId(String(parent).trim());
51
+ if (work.meta.to && !work.meta.team)
52
+ throw new PassalongError("`to:` needs a team: address it as team/@handle");
72
53
  const errors = [
73
54
  ...unheldFields(markdown).map(
74
55
  (k) =>
75
- `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)`,
56
+ `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
57
  ),
77
- ...validate(guide),
58
+ ...validate(work),
78
59
  ];
79
60
  if (errors.length)
80
- throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
61
+ throw new PassalongError(`this is not ready to send:\n - ${errors.join("\n - ")}`);
81
62
 
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) };
63
+ needsSync("sending work");
64
+ const res = await api.publish(work.meta.id, serialize(work));
65
+ work = { ...work, meta: { ...work.meta, url: res.url } };
66
+ return { work, url: res.url, notified: Boolean(res.notified) };
88
67
  }
89
68
 
90
69
  /**
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.
70
+ * One piece of work or several, written from fields rather than markdown: what an agent hands over
71
+ * when it writes work down. Each may name the earlier ones it needs by position (`after: [0, 1]`),
72
+ * and those become its `blocked_by`, so they are handed out in an order that works and the
73
+ * independent ones run side by side.
96
74
  *
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.
75
+ * Only earlier ones: work that waits on itself or on a later one never finishes, and it is refused
76
+ * before anything is written rather than discovered as a queue that never moves. `draft` holds them
77
+ * all in Draft, for a plan a person should read before any of it runs.
100
78
  */
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 || "",
79
+ export async function createWork(items, { draft = false, to, cwd = process.cwd() } = {}) {
80
+ if (!Array.isArray(items) || !items.length)
81
+ throw new PassalongError("say what the work is: at least one item");
82
+ items.forEach((item, i) => {
83
+ for (const j of item.after || [])
84
+ if (!Number.isInteger(j) || j < 0 || j >= i)
85
+ throw new PassalongError(
86
+ `item ${i} ("${item.title}") can only wait on an earlier one, not ${j}`,
87
+ );
115
88
  });
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
- );
89
+ const repo = context(cwd).repo;
90
+ const made = [];
91
+ for (const item of items) {
92
+ const meta = {
93
+ title: item.title,
94
+ summary: item.summary,
95
+ ...(draft ? { status: "draft" } : {}),
96
+ target_context: item.repo ?? repo,
97
+ ...(item.tags?.length ? { tags: item.tags } : {}),
98
+ };
99
+ const after = (item.after || []).map((j) => made[j].id);
100
+ if (after.length) meta.blocked_by = after;
101
+ const { work, url } = await publish(serialize({ meta, body: item.body || "" }), {
102
+ cwd,
103
+ to: item.to || to,
104
+ parent: item.parent,
105
+ });
106
+ made.push({ id: work.meta.id, title: work.meta.title, status: work.meta.status, url });
137
107
  }
138
- return { report: { id: report.id, title, environment, team }, issues: filed };
108
+ return made;
139
109
  }
140
110
 
141
111
  /**
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.
112
+ * Replace a piece of work's text, by its id: the whole document, frontmatter first, as `find_work`
113
+ * handed it over. What it is called and what it says are both in there.
114
+ */
115
+ export async function updateText(id, markdown, { cwd = process.cwd() } = {}) {
116
+ const g = parse(markdown);
117
+ if (g.meta.id && g.meta.id !== id)
118
+ throw new PassalongError(`the document says it is ${g.meta.id}, not ${id}`);
119
+ return publish(serialize({ meta: { ...g.meta, id }, body: g.body }), { cwd });
120
+ }
121
+
122
+ /**
123
+ * Resolve a reference (id, share URL, or local file path) to a piece of work. An id is the server's
124
+ * to answer: nothing keeps a copy on this machine. `peek` reads without recording a pull.
144
125
  */
145
126
  export async function resolve(ref, { peek = false } = {}) {
146
127
  if (/^https?:\/\//.test(ref)) {
147
128
  const md = await api.fetchShared(ref);
148
- return { guide: parse(md), from: "link" };
129
+ return { work: parse(md), from: "link" };
149
130
  }
150
131
  if (ref.endsWith(".md") && existsSync(ref)) {
151
- return { guide: parse(readFileSync(ref, "utf8")), from: "file" };
132
+ return { work: parse(readFileSync(ref, "utf8")), from: "file" };
152
133
  }
153
134
  if (!ID_RE.test(ref))
154
135
  throw new PassalongError(`"${ref}" is not a passalong id, share link, or .md file`);
155
- needsSync("reading a guide by id");
136
+ needsSync("reading work by id");
156
137
  try {
157
- return { guide: parse(await api.get(ref, { peek })), from: "sync" };
138
+ return { work: parse(await api.get(ref, { peek })), from: "sync" };
158
139
  } catch (err) {
159
- if (err.status === 404) throw new PassalongError(`no guide "${ref}"`);
140
+ if (err.status === 404) throw new PassalongError(`no work "${ref}"`);
160
141
  throw err;
161
142
  }
162
143
  }
163
144
 
164
145
  /**
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.
146
+ * Resolve a reference to its id alone, without fetching it. A write needs an id and nothing else,
147
+ * and `GET /v1/work/:id` records a pull; a link or a file carries the id in its own frontmatter.
148
+ * A bare id is not checked here: the route the write goes to answers that.
177
149
  */
178
150
  export async function resolveId(ref) {
179
151
  if (/^https?:\/\//.test(ref)) return parse(await api.fetchShared(ref)).meta.id;
@@ -182,18 +154,7 @@ export async function resolveId(ref) {
182
154
  throw new PassalongError(`"${ref}" is not a passalong id, share link, or .md file`);
183
155
  }
184
156
 
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. */
157
+ /** Write a piece of work into `.passalong/<id>.md` under `cwd`, when somebody asked for a file. */
197
158
  function saveTo(cwd, id, markdown) {
198
159
  const path = join(localDir(cwd), `${id}.md`);
199
160
  writeFileSync(path, markdown);
@@ -203,9 +164,9 @@ function saveTo(cwd, id, markdown) {
203
164
  /**
204
165
  * `.passalong/` in a working directory, made if it is missing.
205
166
  *
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.
167
+ * Work saved there carries its own share URL in the frontmatter, and that URL needs no account to
168
+ * read. Committing this directory would publish it to anyone who can see the repo, so the directory
169
+ * ignores itself. Delete its .gitignore if you do want it in the repo.
209
170
  */
210
171
  function localDir(cwd) {
211
172
  const dir = join(cwd, ".passalong");
@@ -216,13 +177,12 @@ function localDir(cwd) {
216
177
  }
217
178
 
218
179
  /**
219
- * Who this worktree is to the task queue, minted the first time it asks.
180
+ * Who this worktree is to the queue, minted the first time it asks.
220
181
  *
221
182
  * 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.
183
+ * piece of work" means nothing if the server cannot tell them apart. The id lives in the worktree's
184
+ * own `.passalong/agent.json`, so a restarted session in the same worktree is the same agent and
185
+ * gets its work back, and a second worktree of the same repo is a different agent.
226
186
  */
227
187
  export function agent(cwd = process.cwd()) {
228
188
  const c = context(cwd);
@@ -253,44 +213,24 @@ function knownAgent(cwd = process.cwd()) {
253
213
  function needsSync(what) {
254
214
  if (!api.loggedIn())
255
215
  throw new PassalongError(
256
- `${what} needs sync — the queue lives on the server (run \`passalong login\`)`,
216
+ `${what} needs sync: work lives on the server (run \`passalong login\`)`,
257
217
  );
258
218
  }
259
219
 
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
220
  /**
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.
221
+ * Take the next piece of work for this worktree's repo — always a new one, since an agent may hold
222
+ * several. `null` when there is nothing to do. `alone` asks only for work a person marked safe to
223
+ * run unattended (the runner).
269
224
  */
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 });
225
+ export async function nextWork({ cwd = process.cwd(), any = false, alone = false } = {}) {
226
+ needsSync("taking work");
227
+ const { task } = await api.nextTask({ ...agent(cwd), any, ...(alone ? { alone: true } : {}) });
228
+ return task || null;
282
229
  }
283
230
 
284
231
  /**
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.
232
+ * The claim generation this worktree was given when it took a piece of work, kept in
233
+ * `.passalong/held.json` beside `agent.json`. See apps/api/migrations/0029_claim_fence.sql.
294
234
  *
295
235
  * The agent never sees this number and is never asked for it — a model echoing an integer back
296
236
  * three calls later is not a lock. `take` writes it, and every call that writes to the claim sends
@@ -310,7 +250,7 @@ function heldFences(cwd) {
310
250
  }
311
251
  }
312
252
 
313
- /** The number this worktree holds for a guide, or undefined when it has none to send. */
253
+ /** The number this worktree holds for a piece of work, or undefined when it has none to send. */
314
254
  function fenceFor(id, cwd) {
315
255
  const n = heldFences(cwd)[id];
316
256
  return Number.isInteger(n) ? n : undefined;
@@ -326,63 +266,35 @@ function rememberFence(id, fence, cwd) {
326
266
  /** A check a runner executed: the command it ran, and what the process returned. See checks.js. */
327
267
  const verified = (c) => Boolean(c?.cmd) && (typeof c?.exit === "number" || c?.exit === null);
328
268
 
269
+ /**
270
+ * What a hand-in has to carry, checked before it is sent. The rule and the wording are the
271
+ * server's — see evidenceProblem() in apps/api/src/claims.ts.
272
+ */
329
273
  function requireEvidence(evidence) {
330
274
  const said = String(evidence || "").trim();
331
275
  if (said.length >= 16) return;
332
276
  throw new PassalongError(
333
- "send `evidence`: what you ran and what came back — the command and the lines that decided " +
277
+ "send `evidence`: what you ran and what came back, with the command and the lines that decided " +
334
278
  "it, a test summary, a link to the change, or a screenshot url. " +
335
279
  `"${said || "nothing"}" is a claim, not evidence.`,
336
280
  );
337
281
  }
338
282
 
339
283
  /**
340
- * The work is done: the task moves to review with a transfer guide about it attached.
284
+ * The calls every piece of work answers to. Each answer carries `next`, the calls that make sense
285
+ * from here, and `say` when the move is to stop.
341
286
  *
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.
375
- *
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.
287
+ * take: this worktree's agent takes work `id` (an id, a share link or a saved .md), or with no id
288
+ * the next one waiting for it. The document comes back in the answer, and is written to
289
+ * `.passalong/<id>.md` only when `save` asks.
378
290
  */
379
291
  export async function take(id, { cwd = process.cwd(), any = false, save = false } = {}) {
380
292
  needsSync("taking work");
381
293
  const who = agent(cwd);
382
294
  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 };
295
+ if (!r.work?.markdown) return r;
296
+ rememberFence(r.work.id, r.work.fence, cwd);
297
+ return { ...r, path: save ? saveTo(who.worktree, r.work.id, r.work.markdown) : null };
386
298
  }
387
299
 
388
300
  /** Word from the agent that holds it: the lease starts again, and `note` is what the hub shows. */
@@ -397,35 +309,25 @@ export async function progress(id, note, { cwd = process.cwd() } = {}) {
397
309
  }
398
310
 
399
311
  /**
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.
312
+ * Finished here: the work goes to Done with what was shown. Every hand-in carries `evidence` or
313
+ * `checks` — what was run and what came back — because the note is the agent's word for its own
314
+ * work and evidence is the part a person can check. `writeup` is what had to be adapted.
406
315
  */
407
316
  export async function handIn(
408
317
  id,
409
318
  {
410
- ok,
411
319
  note = "",
412
320
  evidence = "",
413
321
  checks = [],
414
322
  writeup = "",
415
323
  risk = "",
416
- markdown,
417
- report,
418
324
  pr = "",
419
325
  cwd = process.cwd(),
420
326
  } = {},
421
327
  ) {
422
328
  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.
329
+ // A check that names a command is run here, before anything is sent. Checks that already carry
330
+ // an exit came from the MCP server's own runner and are left alone, so nothing runs twice.
429
331
  const pending = checks.filter((c) => c?.cmd && typeof c?.exit !== "number" && c?.exit !== null);
430
332
  if (pending.length) {
431
333
  const done = runChecks(pending, { cwd });
@@ -435,24 +337,10 @@ export async function handIn(
435
337
  throw new Error(refusal(done.failed, { ran: done.ran, total: checks.length }));
436
338
  }
437
339
  }
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.
340
+ // Checks are evidence, sorted against what they answer: bringing them is bringing it. A check
341
+ // whose command was actually run is exempt from the length rule — its exit code is the evidence.
441
342
  if (checks.length) checks.forEach((c) => (verified(c) ? null : requireEvidence(c?.ran)));
442
343
  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
344
  const fence = fenceFor(id, cwd);
457
345
  return api.handIn(id, {
458
346
  agent: agent(cwd).agent,
@@ -464,54 +352,38 @@ export async function handIn(
464
352
  checks: checks.map((c) => ({
465
353
  check: c.check,
466
354
  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.
355
+ // What the agent says happened, in plain words, for the person's table.
469
356
  ...(c.says ? { says: c.says } : {}),
470
357
  // Only present on a check that was run. The server keeps them apart the same way.
471
358
  ...(verified(c) ? { cmd: c.cmd, exit: c.exit, ok: c.ok } : {}),
472
359
  })),
473
360
  }
474
361
  : {}),
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.
362
+ // Each left out when there is nothing to say, rather than sent as "".
478
363
  ...(writeup ? { writeup } : {}),
479
- // What it could break, for the reviewer. Left out when empty, like the write-up.
480
364
  ...(risk ? { risk } : {}),
481
- ...(typeof ok === "boolean" ? { ok } : {}),
365
+ ...(pr ? { pr } : {}),
482
366
  });
483
367
  }
484
368
 
485
369
  /**
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:`.
370
+ * Give work to someone else in its team: `@handle` for one person, `#group` for the people who do
371
+ * a thing, or "team" (or empty) for everyone. Whoever held it and is left out has it taken back and
372
+ * is told.
490
373
  */
491
374
  export async function assign(ref, to) {
492
- needsSync("reassigning");
375
+ needsSync("giving work to someone");
493
376
  const id = await resolveId(ref);
494
377
  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;
378
+ return api.assign(id, target);
498
379
  }
499
380
 
500
381
  /**
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.
382
+ * Not this agent's to do: what it held is open again, and `why` goes to whoever is next. `runner`
383
+ * says the runner is giving it back, unfinished: the server then clears its Safe to run alone tick,
384
+ * so it waits for a person rather than going straight back to an unattended agent.
504
385
  */
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;
511
- }
512
-
513
- /** Not this agent's to do: what it held is open again, and `why` goes to whoever is next. */
514
- export async function pass(id, why, { cwd = process.cwd() } = {}) {
386
+ export async function pass(id, why, { cwd = process.cwd(), runner = false } = {}) {
515
387
  needsSync("passing work");
516
388
  if (!String(why || "").trim())
517
389
  throw new PassalongError("say why you are passing it, so whoever is next knows");
@@ -520,6 +392,7 @@ export async function pass(id, why, { cwd = process.cwd() } = {}) {
520
392
  agent: agent(cwd).agent,
521
393
  why,
522
394
  ...(fence === undefined ? {} : { fence }),
395
+ ...(runner ? { runner: true } : {}),
523
396
  });
524
397
  }
525
398
 
@@ -528,7 +401,7 @@ export async function pass(id, why, { cwd = process.cwd() } = {}) {
528
401
  * and not `progress`, which says carry on: the answer to this one is "stop, and take it again when
529
402
  * they have replied", and the reply comes back with that `take`.
530
403
  */
531
- export async function ask(id, question, { cwd = process.cwd() } = {}) {
404
+ export async function ask(id, question, { cwd = process.cwd(), choices } = {}) {
532
405
  needsSync("asking");
533
406
  if (!String(question || "").trim())
534
407
  throw new PassalongError("say what you need to know, so the person can answer it");
@@ -537,46 +410,86 @@ export async function ask(id, question, { cwd = process.cwd() } = {}) {
537
410
  agent: agent(cwd).agent,
538
411
  question,
539
412
  ...(fence === undefined ? {} : { fence }),
413
+ // Options the person picks from with a tap in the hub; the pick comes back as the reply.
414
+ ...(Array.isArray(choices) && choices.length ? { choices } : {}),
540
415
  });
541
416
  }
542
417
 
418
+ /** Why a piece of work needs its author, or "" when it does not. Mirrors needsWhy() in mcp-http.ts. */
419
+ export function needsWhy(t) {
420
+ if (!t.mine) return "";
421
+ if (t.state === "draft") return "in Draft: read it and mark it Ready";
422
+ if (t.state === "stalled") return "its agent went quiet";
423
+ if (t.claim?.asking) return `asking you: ${t.claim.asking}`;
424
+ if (/^BLOCKED:/i.test(t.claim?.note || ""))
425
+ return `stuck on you: ${t.claim.note.replace(/^BLOCKED:\s*/i, "")}`;
426
+ return "";
427
+ }
428
+
543
429
  /**
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.
430
+ * The work board, the way `find_work` with nothing hands it over and `passalong board` prints it:
431
+ * what needs you, who is on what, and what is ready to take. Built from `/v1/tasks` (every piece of
432
+ * work you can see, with its state) and `/v1/working` (everything someone holds).
433
+ */
434
+ export async function board() {
435
+ needsSync("the board");
436
+ const [{ tasks }, { working }] = await Promise.all([api.tasks(), api.working()]);
437
+ const hub = `${api.baseUrl()}/hub`;
438
+ const who = (b = {}) => (b.you ? "You" : b.name || (b.handle ? `@${b.handle}` : "A teammate"));
439
+ const needs = tasks
440
+ .map((t) => ({ id: t.id, title: t.title, why: needsWhy(t), url: `${hub}/w/${t.id}` }))
441
+ .filter((t) => t.why);
442
+ return {
443
+ hub,
444
+ counts: {
445
+ needs: needs.length,
446
+ working: working.length,
447
+ open: tasks.filter((t) => ["ready", "blocked", "draft"].includes(t.state)).length,
448
+ done: tasks.filter((t) => t.state === "done").length,
449
+ },
450
+ needs,
451
+ working: working.map((w) => ({
452
+ id: w.id,
453
+ title: w.title,
454
+ who: who(w.by),
455
+ note: w.note,
456
+ stalled: w.state === "stalled",
457
+ url: w.url,
458
+ })),
459
+ ready: tasks
460
+ .filter((t) => t.state === "ready")
461
+ .map((t) => ({
462
+ id: t.id,
463
+ title: t.title,
464
+ target: t.target || "",
465
+ to: t.to || "",
466
+ url: t.url,
467
+ })),
468
+ };
469
+ }
470
+
471
+ /**
472
+ * What this worktree's agent holds right now, and what is waiting: what the session-start and stop
473
+ * hooks read (src/hooks.js). Counts are best effort — a hook must never fail a session over one.
547
474
  */
548
475
  export async function now({ cwd = process.cwd() } = {}) {
549
476
  needsSync("seeing what you hold");
550
477
  // 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.
478
+ // taken anything has no agent — and should not be given a .passalong/ project for asking.
552
479
  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
- ]);
480
+ const [w, t] = await Promise.allSettled([api.working(), api.tasks()]);
559
481
  if (w.status === "rejected") throw w.reason;
560
482
  const held = who.agent ? w.value.working.filter((x) => x.agent === who.agent && x.by?.you) : [];
561
483
  const tasks = t.status === "fulfilled" ? t.value.tasks : [];
562
- const waiting = {
484
+ return {
485
+ held,
563
486
  ready: tasks.filter((x) => x.state === "ready" && x.target === who.repo).length,
564
- inbox: i.status === "fulfilled" ? i.value.guides.length : 0,
487
+ needs: tasks.filter((x) => needsWhy(x)).length,
488
+ agent: who.agent,
565
489
  };
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
490
  }
578
491
 
579
- /** Finished work back to To do, with why (docs/REVIEW.md §4). */
492
+ /** Finished work back to To do, with why. */
580
493
  export async function reopen(id, why) {
581
494
  needsSync("reopening work");
582
495
  if (!String(why || "").trim())
@@ -584,98 +497,16 @@ export async function reopen(id, why) {
584
497
  return api.reopen(await resolveId(id), why);
585
498
  }
586
499
 
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
- */
500
+ /** Take it back from whoever is holding it. */
621
501
  export async function release(id) {
622
502
  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 };
503
+ return api.release(await resolveId(id));
653
504
  }
654
505
 
655
506
  /**
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
- );
673
- }
674
-
675
- /**
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.
507
+ * The image types work can carry. Mirrors SHOT_TYPES in apps/api/src/shots.ts — the server is the
508
+ * authority and refuses anything else; this is what lets a local caller find out before spending an
509
+ * upload on it.
679
510
  */
680
511
  export const SHOT_TYPES = {
681
512
  ".png": "image/png",
@@ -686,65 +517,42 @@ export const SHOT_TYPES = {
686
517
  };
687
518
 
688
519
  /**
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.
520
+ * The same upload as `attach()`, for an agent that has the image and not a path to it: a browser
521
+ * pane hands an image back as a tool result and never writes a file. Base64 in a tool call is fine
522
+ * here, where this server is a local process talking to the same upload the file path uses.
711
523
  */
712
524
  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");
525
+ needsSync("adding a file");
715
526
  const allowed = new Set(Object.values(SHOT_TYPES));
716
527
  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
- }
528
+ throw new PassalongError(`pictures must be ${[...allowed].join(", ")}: ${type} is not one`);
529
+ // A data: URL is what several browser tools hand back, so it is accepted rather than made the
530
+ // caller's problem to strip.
531
+ const bytes = Buffer.from(String(data).replace(/^data:[^,]*,/, ""), "base64");
726
532
  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.
533
+ // The server's own ceiling, checked here so a large paste fails before it is sent.
729
534
  if (bytes.length > 5 * 1024 * 1024)
730
535
  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",
536
+ `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
537
  );
734
538
  const label = name || "screenshot";
735
539
  const shot = await api.uploadShot(bytes, type, label);
736
540
  return { ...shot, markdown: `![${label}](${shot.url})` };
737
541
  }
738
542
 
543
+ /**
544
+ * Upload a picture from disk, and return the markdown that points at it. It belongs *in* the work's
545
+ * text or a hand-in check: `claimShots` binds the upload to whichever document names it, which is
546
+ * why this returns a line to paste rather than taking an id.
547
+ */
739
548
  export async function attach(file, { name = "" } = {}) {
740
- if (!api.loggedIn())
741
- throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
549
+ needsSync("adding a file");
742
550
  if (!existsSync(file)) throw new PassalongError(`no file at ${file}`);
743
551
  const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
744
552
  const type = SHOT_TYPES[ext];
745
553
  if (!type)
746
554
  throw new PassalongError(
747
- `screenshots must be ${[...new Set(Object.values(SHOT_TYPES))].join(", ")} — ${ext || file} is not one`,
555
+ `pictures must be ${[...new Set(Object.values(SHOT_TYPES))].join(", ")}: ${ext || file} is not one`,
748
556
  );
749
557
  const shot = await api.uploadShot(readFileSync(file), type, name || basename(file));
750
558
  return { ...shot, markdown: `![${name || basename(file)}](${shot.url})` };
@@ -752,7 +560,7 @@ export async function attach(file, { name = "" } = {}) {
752
560
 
753
561
  /**
754
562
  * 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.
563
+ * refuses what it does not keep.
756
564
  */
757
565
  const FILE_HINTS = {
758
566
  ".pdf": "application/pdf",
@@ -768,20 +576,17 @@ const FILE_HINTS = {
768
576
  const FILE_MAX = 10 * 1024 * 1024;
769
577
 
770
578
  /**
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.
579
+ * Upload a file that is not a picture — a log, a PDF, a CSV, a zip — and return the markdown line
580
+ * that points at it. It is private: whoever can read the work can download it, and nobody else.
775
581
  */
776
582
  export async function attachFile(file, { name = "" } = {}) {
777
- if (!api.loggedIn())
778
- throw new PassalongError("attaching a file needs sync — run `passalong login` first");
583
+ needsSync("adding a file");
779
584
  if (!existsSync(file)) throw new PassalongError(`no file at ${file}`);
780
585
  const bytes = readFileSync(file);
781
586
  if (!bytes.length) throw new PassalongError(`${file} is empty`);
782
587
  if (bytes.length > FILE_MAX)
783
588
  throw new PassalongError(
784
- `${file} is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 10MB — send a link instead`,
589
+ `${file} is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 10MB: send a link instead`,
785
590
  );
786
591
  const ext = file.includes(".") ? file.slice(file.lastIndexOf(".")).toLowerCase() : "";
787
592
  const label = (name || basename(file)).replace(/[[\]()]/g, "");
@@ -789,317 +594,151 @@ export async function attachFile(file, { name = "" } = {}) {
789
594
  return { ...attachment, markdown: `[${label}](${attachment.url})` };
790
595
  }
791
596
 
597
+ /** A picture or any other file from disk, whichever it is, as the line that points at it. */
598
+ export function attachAny(file, opts = {}) {
599
+ const isImage = Object.keys(SHOT_TYPES).some((x) => file.toLowerCase().endsWith(x));
600
+ return (isImage ? attach : attachFile)(file, opts);
601
+ }
602
+
792
603
  /**
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.
604
+ * Write to whoever holds a piece of work, or leave a note on one nobody holds yet. `files` are
605
+ * paths, each uploaded and added as a line of the message. The server decides who may write and
606
+ * whether anyone is listening, and says so in words.
797
607
  */
798
608
  export async function reply(ref, text, { files = [] } = {}) {
799
609
  needsSync("replying");
800
610
  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
- }
611
+ for (const file of files) lines.push((await attachAny(file)).markdown);
806
612
  const body = [String(text || "").trim(), ...lines].filter(Boolean).join("\n");
807
613
  if (!body) throw new PassalongError("say something, or attach a file");
808
614
  return api.reply(await resolveId(ref), body);
809
615
  }
810
616
 
811
617
  /**
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.
618
+ * What an agent is told after the work it opened: where what it learns goes. Knowledge belongs in
619
+ * the project's documents, where the next person finds it; work somebody else should now do is new
620
+ * work. Neither is a second document handed to one person. Mirrors AFTER in apps/api/src/mcp-http.ts.
829
621
  */
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.";
622
+ export const AFTER =
623
+ "<!-- passalong: what you learn doing this (how something works, a decision and why) goes " +
624
+ "into the project's documents (find_projects, read_document, save_document), not into a new " +
625
+ "piece of work. Work somebody else should now do is create_work with `parent` set to this id. " +
626
+ "What you did and how you checked it goes on hand_in. -->";
851
627
 
852
628
  /**
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.
629
+ * The work this one came out of, formatted to go in front of it — or "" when there is none.
856
630
  *
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.
631
+ * A piece of work read on its own can look like the whole job when it is one part of a bigger one,
632
+ * and where the bigger one has got to is part of the context. Injectable for testing, and never
633
+ * worth failing the call over: the work itself is what was asked for. Mirrors parentOf() in
634
+ * apps/api/src/mcp-http.ts.
863
635
  */
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
636
  /**
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.
637
+ * What to know before starting, as the server hands it over on take: each project the work is filed
638
+ * in, its README and the names of its other documents — or "" when it is in none. It sits in front
639
+ * of the work, after the work it came out of. Mirrors projectsNote in apps/api/src/mcp-http.ts.
882
640
  */
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");
641
+ export function projectsNote(projects) {
642
+ if (!Array.isArray(projects) || !projects.length) return "";
643
+ const blocks = projects.map((p) => {
644
+ const name = p.title || p.id;
645
+ const where = `${name} (${p.id})${p.repo ? `, built from ${p.repo}` : ""}`;
646
+ const about = p.description ? ` ${p.description}` : "";
647
+ const lead = p.readme
648
+ ? `WHAT TO KNOW FIRST: this work is filed in the project ${where}.${about} Its README is ` +
649
+ "below: read it before you start."
650
+ : `WHAT TO KNOW FIRST: this work is filed in the project ${where}.${about} It has no README yet.`;
651
+ const readme = p.readme
652
+ ? `\n\n--- README of ${name}: document ${p.readme.id} ---\n${p.readme.body.trimEnd()}\n` +
653
+ (p.readme.cut ? `…it goes on: read_document ${p.readme.id} for the rest.\n` : "")
654
+ : "\n";
655
+ const docs = p.documents?.length
656
+ ? "Its other documents: open the ones that bear on this with read_document: " +
657
+ `${p.documents.map((d) => `"${d.name}" (${d.id})`).join(", ")}` +
658
+ `${p.more ? `, and ${p.more} more (find_projects id ${p.id})` : ""}.\n`
659
+ : "";
660
+ return `${lead}${readme}${docs}`;
661
+ });
662
+ return `${blocks.join("\n")}\n`;
894
663
  }
895
664
 
896
- /**
897
- * The guide a follow-up came out of, formatted to go in front of it — or "" when there is none.
898
- *
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.
906
- */
907
- export async function parentGuide(
908
- meta = {},
909
- { parent = api.parent, loggedIn = api.loggedIn } = {},
910
- ) {
665
+ export async function parentWork(meta = {}, { parent = api.parent, loggedIn = api.loggedIn } = {}) {
911
666
  if (!meta.id || !meta.parent || !loggedIn()) return "";
912
667
  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";
668
+ const { work } = await parent(meta.id, { markdown: true });
669
+ if (!work?.id) return "";
670
+ const who = work.by?.name || (work.by?.handle ? `@${work.by.handle}` : "");
671
+ const state = work.state === "held" && who ? `held by ${who}` : work.state || "open";
917
672
  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`
673
+ `THIS CAME OUT OF ${work.id}: ${work.title || "untitled"} (${state}). It is part of that ` +
674
+ "work, which is below; read it first. If this depends on " +
675
+ `${work.id} being done and it is not, say so rather than starting.\n\n` +
676
+ `--- the work it came out of: ${work.id} ---\n${String(work.markdown ?? "").trimEnd()}\n\n`
923
677
  );
924
678
  } catch {
925
679
  return "";
926
680
  }
927
681
  }
928
682
 
929
- /** Guides on the server this account can see, newest first, optionally filtered. */
683
+ /** Work on the server this account can see, newest first, optionally filtered. */
930
684
  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;
685
+ needsSync("finding work");
686
+ return (await api.list(query, scope)).work;
959
687
  }
960
688
 
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 });
967
- }
968
-
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`. */
689
+ /** Archive a piece of work (`done`), make it Ready, or put it in Draft. */
994
690
  export async function setStatus(id, status) {
995
691
  if (!STATUSES.includes(status))
996
692
  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;
693
+ needsSync("changing work");
694
+ return api.setStatus(await resolveId(id), status);
1003
695
  }
1004
696
 
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
- */
697
+ /** Move work from Draft to Ready: a person has read it, and whoever it is for may take it now. */
1009
698
  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");
1014
- }
1015
-
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;
699
+ const { work } = await resolve(id, { peek: true });
700
+ if (work.meta.status !== "draft")
701
+ throw new PassalongError(`${work.meta.id} is not in Draft (status: ${work.meta.status})`);
702
+ await setStatus(work.meta.id, "ready");
703
+ return work.meta.id;
1064
704
  }
1065
705
 
1066
- /** What an agent started by `work()` is told. The task itself goes in too, so it cannot start blind. */
706
+ /** What an agent started by `work()` is told. The work itself goes in too, so it cannot start blind. */
1067
707
  export function workPrompt(t) {
1068
708
  return [
1069
- `You hold passalong task ${t.id}, and no other agent can take it while you do. Here it is:`,
709
+ `You hold passalong work ${t.id}, and no other agent can take it while you do. Here it is:`,
1070
710
  "",
1071
711
  t.markdown.trim(),
1072
712
  "",
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.",
713
+ "Do it in this repo: read it, work out what done means, and get there. Call the passalong MCP",
714
+ `tool progress with id ${t.id} and a one-line status at each milestone; 30 minutes of`,
715
+ "silence stalls it. When it is done, commit what you changed as one commit whose message",
716
+ `starts with "${t.id}: ": several pieces of work share this worktree, and a person reads each`,
717
+ `one's change on its own. Then call hand_in with id ${t.id}, \`pr\` set to that commit's hash,`,
718
+ "`note`: one plain sentence saying what you did, and `evidence`: what you ran and what came",
719
+ "back, with the commands and the lines that decided it, the test summary, the commit. Keep that",
720
+ "as you go; at the end you would be writing it from memory. What you learned about the",
721
+ "project goes into its documents (save_document). If a call says you no longer hold it, stop.",
1085
722
  "",
1086
723
  "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",
724
+ "do stands between you and done (a permission, a secret, a decision), call",
1088
725
  `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.",
726
+ "need, leave it unfinished and uncommitted, and stop. The board shows that note.",
1090
727
  ].join("\n");
1091
728
  }
1092
729
 
1093
730
  /**
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.
731
+ * Work the queue from this worktree: take the next piece of work, hand it to an agent, and when the
732
+ * agent has finished it take the next, until there is nothing left.
1096
733
  *
1097
734
  * The agent runs in this worktree, so it is the same agent to the queue — its progress and
1098
735
  * hand_in land on the claim this took. `agent` is a command the prompt is appended to;
1099
736
  * `claude -p` by default, or whatever PASSALONG_AGENT says.
1100
737
  *
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.
738
+ * An agent that exits without finishing stops the loop, rather than piling up half-done work. It
739
+ * stays held here, for a person to see on the board and resume (take it) or release.
740
+ *
741
+ * The runner, which takes work marked safe to run alone with nobody watching, is watch() below.
1103
742
  */
1104
743
  export async function work({
1105
744
  cwd = process.cwd(),
@@ -1112,7 +751,7 @@ export async function work({
1112
751
  const [command, ...args] = agent.trim().split(/\s+/);
1113
752
  const finished = [];
1114
753
  for (;;) {
1115
- const t = await nextTask({ cwd, any });
754
+ const t = await nextWork({ cwd, any });
1116
755
  if (!t) return { finished, stopped: "empty" };
1117
756
  onTask(t);
1118
757
  const run = spawnSync(command, [...args, workPrompt(t)], {
@@ -1120,9 +759,8 @@ export async function work({
1120
759
  stdio: "inherit",
1121
760
  env: { ...process.env, PASSALONG_TASK: t.id },
1122
761
  });
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")
762
+ const state = (await api.tasks()).tasks.find((x) => x.id === t.id)?.state;
763
+ if (state !== "done")
1126
764
  return {
1127
765
  finished,
1128
766
  stopped: "unfinished",
@@ -1136,13 +774,286 @@ export async function work({
1136
774
  }
1137
775
 
1138
776
  /**
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.
777
+ * The runner (src/runner.js, docs/LOOP.md §1a): work a person ticked Safe to run alone, each piece in
778
+ * a git worktree of its own beside this checkout, on its own branch, by an agent that finishes with
779
+ * a pull request. Up to `atOnce` at a time and `perDay` a day; a run that ends without the work done
780
+ * passes it back to its author. Runs until stopped; `sleep` is how the tests stop it.
781
+ *
782
+ * A worktree is made before asking the queue, because taking work is done by a worktree's agent and
783
+ * the agent that takes it must be the one that does it. When the queue has nothing, the worktree
784
+ * goes again.
785
+ */
786
+ export async function watch({
787
+ cwd = process.cwd(),
788
+ agent: command,
789
+ every = 60,
790
+ atOnce = runner.LIMITS.atOnce,
791
+ perDay = runner.LIMITS.perDay,
792
+ minutes = runner.LIMITS.minutes,
793
+ cli,
794
+ git = runner.git,
795
+ runAgent = runner.runAgent,
796
+ now = () => Date.now(),
797
+ countFile = join(store.HOME, "runner.json"),
798
+ onTask = () => {},
799
+ onIdle = () => {},
800
+ onCapped = () => {},
801
+ onFinished = () => {},
802
+ onPassed = () => {},
803
+ onWaiting = () => {},
804
+ onResumed = () => {},
805
+ onTrouble = () => {},
806
+ report = api.runnerStatus,
807
+ signals = false,
808
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
809
+ } = {}) {
810
+ needsSync("running work");
811
+ const { root, repo } = context(cwd);
812
+ if (!root) throw new PassalongError("run the runner inside a git checkout of the repo");
813
+ let remote = "";
814
+ try {
815
+ remote = git(["-C", root, "remote", "get-url", "origin"]);
816
+ } catch {}
817
+ if (!remote)
818
+ throw new PassalongError(
819
+ "this checkout has no `origin` remote: the runner pushes each change to a branch there",
820
+ );
821
+ const runs = runner.runsDirOf(root);
822
+ mkdirSync(runs, { recursive: true });
823
+
824
+ // What it is on, said to the hub once a minute and whenever it changes (PUT /v1/runner), so
825
+ // nobody has to look at this terminal to know. A failed report never stops a run.
826
+ const onNow = new Map();
827
+ const say = (stopped = false) =>
828
+ Promise.resolve(
829
+ report({
830
+ host: hostname(),
831
+ repo,
832
+ running: [...onNow].map(([id, title]) => ({ id, title })),
833
+ runs_today: runner.runsToday(countFile, runner.dayOf(now())),
834
+ per_day: perDay,
835
+ at_once: atOnce,
836
+ stopped,
837
+ }),
838
+ ).catch(() => {});
839
+ // Ctrl-C: the hub hears it stopped. Agents running then stop with it, since they share the
840
+ // terminal; what they held stays held, and the sweep puts it back once it goes quiet.
841
+ if (signals)
842
+ process.once("SIGINT", async () => {
843
+ await Promise.race([say(true), new Promise((r) => setTimeout(r, 2000))]);
844
+ process.exit(130);
845
+ });
846
+ const agentRun = command
847
+ ? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
848
+ : runner.claudeCommand({ cli });
849
+
850
+ /**
851
+ * One slot: a worktree and a piece of work taken for it, with its agent started. `null` when
852
+ * nothing waits. The run itself is handed back unawaited, so several go at once.
853
+ */
854
+ async function startOne(n) {
855
+ try {
856
+ git(["-C", root, "fetch", "--quiet", "origin"]);
857
+ } catch {}
858
+ let base = "HEAD";
859
+ try {
860
+ base = git(["-C", root, "rev-parse", "--verify", "--quiet", "origin/HEAD"])
861
+ ? "origin/HEAD"
862
+ : base;
863
+ } catch {}
864
+ const dir = join(runs, `run-${now()}-${n}`);
865
+ git(["-C", root, "worktree", "add", "--detach", "--quiet", dir, base]);
866
+ let t = null;
867
+ try {
868
+ t = await nextWork({ cwd: dir, alone: true });
869
+ } catch (err) {
870
+ onTrouble(err);
871
+ }
872
+ if (!t) {
873
+ git(["-C", root, "worktree", "remove", "--force", dir]);
874
+ return null;
875
+ }
876
+ // Its branch, or a fresh one beside it when that one is still checked out in the worktree of
877
+ // an earlier run that didn't finish (kept for its author), as when a person ticks it again.
878
+ let branch = runner.branchOf(t.id);
879
+ try {
880
+ try {
881
+ git(["-C", dir, "switch", "--quiet", "-C", branch]);
882
+ } catch {
883
+ branch = `${branch}-${now().toString(36)}`;
884
+ git(["-C", dir, "switch", "--quiet", "-c", branch]);
885
+ }
886
+ } catch (err) {
887
+ // Taken and then stuck before any agent started: never left held with nobody on it.
888
+ const why = runner.passBack({ minutes, failed: err.message.split("\n")[0], dir, branch });
889
+ await pass(t.id, why, { cwd: dir, runner: true }).catch((e) => onTrouble(e, t));
890
+ onPassed(t, { why, dir, log: "" });
891
+ return { run: Promise.resolve() };
892
+ }
893
+ runner.countRun(countFile, runner.dayOf(now()));
894
+ onNow.set(t.id, t.title || t.id);
895
+ onTask(t, { dir, branch });
896
+ say();
897
+ return {
898
+ run: finish(t, dir, branch).finally(() => {
899
+ onNow.delete(t.id);
900
+ say();
901
+ }),
902
+ };
903
+ }
904
+
905
+ async function finish(t, dir, branch, { resumed = false } = {}) {
906
+ const log = join(runs, `${t.id}.log`);
907
+ const ran = await runAgent({
908
+ ...agentRun,
909
+ prompt: resumed ? runner.resumePrompt(t, branch) : runner.runnerPrompt(t, branch),
910
+ cwd: dir,
911
+ log,
912
+ minutes,
913
+ env: { ...process.env, PASSALONG_TASK: t.id },
914
+ });
915
+ let held = null;
916
+ try {
917
+ held = (await api.tasks()).tasks.find((x) => x.id === t.id) || null;
918
+ } catch {}
919
+ if (held?.state === "done") {
920
+ try {
921
+ git(["-C", root, "worktree", "remove", "--force", dir]);
922
+ } catch {}
923
+ onFinished(t, { branch, log });
924
+ return;
925
+ }
926
+ // It stopped to ask, and the question is waiting on a person: not given up. The work stays held
927
+ // here with its worktree, and the run picks up again once they answer (resumeAnswered).
928
+ if (held?.claim?.asking) {
929
+ waiting.set(t.id, { t, dir, branch });
930
+ onWaiting(t, { question: held.claim.asking, dir });
931
+ return;
932
+ }
933
+ const why = runner.passBack({
934
+ minutes,
935
+ ...ran,
936
+ note: held?.claim?.note || "",
937
+ said: runner.lastLine(log),
938
+ dir,
939
+ branch,
940
+ });
941
+ try {
942
+ await pass(t.id, why, { cwd: dir, runner: true });
943
+ onPassed(t, { why, dir, log });
944
+ } catch (err) {
945
+ onTrouble(err, t);
946
+ }
947
+ }
948
+
949
+ const running = new Set();
950
+ /** Runs stopped on a question, by work id: their worktree and branch, kept to resume in. */
951
+ const waiting = new Map();
952
+ /** Start an agent again where it stopped: the same worktree, branch and claim. */
953
+ function resume(t, dir, branch) {
954
+ runner.countRun(countFile, runner.dayOf(now()));
955
+ onNow.set(t.id, t.title || t.id);
956
+ onResumed(t, { dir, branch });
957
+ say();
958
+ const run = finish(t, dir, branch, { resumed: true })
959
+ .catch((err) => onTrouble(err, t))
960
+ .finally(() => {
961
+ onNow.delete(t.id);
962
+ running.delete(run);
963
+ say();
964
+ });
965
+ running.add(run);
966
+ }
967
+
968
+ /**
969
+ * Questions answered since the last look start their run again, while there is a slot and the
970
+ * day's allowance lasts. Work that was finished, taken back or given away meanwhile is let go.
971
+ */
972
+ async function resumeAnswered() {
973
+ if (!waiting.size) return;
974
+ let rows = [];
975
+ try {
976
+ rows = (await api.tasks()).tasks;
977
+ } catch (err) {
978
+ return onTrouble(err);
979
+ }
980
+ for (const [id, w] of [...waiting]) {
981
+ const row = rows.find((x) => x.id === id);
982
+ if (row?.state === "done") {
983
+ waiting.delete(id);
984
+ try {
985
+ git(["-C", root, "worktree", "remove", "--force", w.dir]);
986
+ } catch {}
987
+ onFinished(w.t, { branch: w.branch, log: join(runs, `${id}.log`) });
988
+ continue;
989
+ }
990
+ if (!row?.claim) {
991
+ waiting.delete(id);
992
+ onTrouble(new Error(`no longer held here; its worktree is kept in ${w.dir}`), w.t);
993
+ continue;
994
+ }
995
+ if (row.claim.asking) continue;
996
+ if (running.size >= atOnce) return;
997
+ if (runner.runsToday(countFile, runner.dayOf(now())) >= perDay) return;
998
+ waiting.delete(id);
999
+ resume(w.t, w.dir, w.branch);
1000
+ }
1001
+ }
1002
+
1003
+ // A runner started again finds the questions its earlier runs left waiting: their worktrees are
1004
+ // still beside the checkout, and the work is still held by them.
1005
+ try {
1006
+ for (const row of (await api.tasks()).tasks) {
1007
+ const leaf = row.claim?.worktree || "";
1008
+ const dir = leaf.startsWith("run-") ? join(runs, leaf) : "";
1009
+ if (!dir || !existsSync(dir) || row.state === "done") continue;
1010
+ let branch = runner.branchOf(row.id);
1011
+ try {
1012
+ branch = git(["-C", dir, "branch", "--show-current"]) || branch;
1013
+ } catch {}
1014
+ waiting.set(row.id, { t: { id: row.id, title: row.title, markdown: "" }, dir, branch });
1015
+ }
1016
+ } catch {}
1017
+
1018
+ let n = 0;
1019
+ let cappedOn = "";
1020
+ for (;;) {
1021
+ await resumeAnswered();
1022
+ while (running.size < atOnce) {
1023
+ const day = runner.dayOf(now());
1024
+ if (runner.runsToday(countFile, day) >= perDay) {
1025
+ if (cappedOn !== day) onCapped(perDay);
1026
+ cappedOn = day;
1027
+ break;
1028
+ }
1029
+ let slot;
1030
+ try {
1031
+ slot = await startOne(++n);
1032
+ } catch (err) {
1033
+ onTrouble(err);
1034
+ break;
1035
+ }
1036
+ if (!slot) {
1037
+ if (!running.size) onIdle(repo);
1038
+ break;
1039
+ }
1040
+ const run = slot.run.catch((err) => onTrouble(err)).finally(() => running.delete(run));
1041
+ running.add(run);
1042
+ }
1043
+ // Wait for a run to end or for the next look, whichever is first.
1044
+ await say();
1045
+ await Promise.race([sleep(Math.max(10, every) * 1000), ...running]);
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Make every piece of work you wrote that is still in Draft ready, and return their ids. For the
1051
+ * moment a planner has written several and you have read them all. Only your own: sending work on
1052
+ * is its author's call.
1142
1053
  */
1143
1054
  export async function readyDrafts() {
1144
1055
  const drafts = (await list("", { scope: "mine" })).filter((g) => g.status === "draft");
1145
- for (const g of drafts) await ready(g.id);
1056
+ for (const g of drafts) await setStatus(g.id, "ready");
1146
1057
  return drafts.map((g) => g.id);
1147
1058
  }
1148
1059
 
@@ -1159,8 +1070,8 @@ export async function remove(id) {
1159
1070
  }
1160
1071
 
1161
1072
  /**
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.
1073
+ * Write every piece of work to a directory as plain markdown: signed in, every one the server lets
1074
+ * this account read, and the local store's own besides. Returns the paths written.
1164
1075
  */
1165
1076
  export async function exportAll(dir) {
1166
1077
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
@@ -1169,7 +1080,7 @@ export async function exportAll(dir) {
1169
1080
  let after = "";
1170
1081
  do {
1171
1082
  const page = await api.exportPage(after);
1172
- for (const g of page.guides) paths.set(g.id, g.markdown);
1083
+ for (const g of page.work) paths.set(g.id, g.markdown);
1173
1084
  after = page.next;
1174
1085
  } while (after);
1175
1086
  }
@@ -1200,9 +1111,9 @@ const TIDIED = 2;
1200
1111
  * Once per machine, signed in: drop the local copies the server says it holds. They were written
1201
1112
  * by every share, pull and take before `keep()`, and nothing ever removed one. Every login on the
1202
1113
  * machine is asked, since a copy may be any of theirs. A copy whose `url:` is on a login's own host
1203
- * and that the server answers 404 for is a guide since deleted, and goes too. Anything else — no
1204
- * url, localhost, another host, or an answer that was neither 200 nor 404 — is left where it is,
1205
- * and an export that failed leaves the flag unset so the next start tries again.
1114
+ * and that the server answers 404 for was since deleted, and goes too. Anything else — no url,
1115
+ * localhost, another host, or an answer that was neither 200 nor 404 — is left where it is, and an
1116
+ * export that failed leaves the flag unset so the next start tries again.
1206
1117
  */
1207
1118
  export async function tidy() {
1208
1119
  const logins = api.logins();
@@ -1220,7 +1131,7 @@ export async function tidy() {
1220
1131
  let after = "";
1221
1132
  do {
1222
1133
  const page = await api.exportPage(after, as);
1223
- for (const g of page.guides) drop(g.id);
1134
+ for (const g of page.work) drop(g.id);
1224
1135
  after = page.next;
1225
1136
  } while (after && local.size);
1226
1137
  } catch {