passalong 0.3.2 → 0.6.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/bin/passalong CHANGED
@@ -1,9 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // Passalong CLI. Few flags on purpose: the defaults are the product.
3
3
  //
4
- // passalong share [file] [--to TARGET] [--follows ID] publish a guide → id + link
4
+ // passalong share [file] [--to TARGET] [--follows ID] publish a guide → id + link; --follows adds it as context to guide ID
5
+ // --follows ID publishes it as a follow-up: more context for guide ID, listed under it and
6
+ // handed to whoever pulls or starts that guide
5
7
  // TARGET is a team, team/@handle for one person, or "team/#group" for the people who do a
6
8
  // thing — quote that one, since a shell may read the # as a comment
9
+ // passalong task "<what needs doing>" write a task for an agent; it waits in Draft
10
+ // passalong ready <id...|--all> tasks you have read are ready — an agent may take them now
11
+ // passalong tasks every task and where it is: draft, ready, claimed, stalled, review
12
+ // passalong work [--once] [--agent CMD] run an agent on each ready task here, one after another
13
+ // passalong approve <id> the work in review is done
14
+ // passalong reject <id> <why> not done: back to Ready, and the next agent reads why
15
+ // passalong release <id> take a task back from the agent holding it, stalled or not
7
16
  // passalong attach <image> upload evidence and print the markdown that points at it
8
17
  // passalong pull <id|link> fetch a guide into ./.passalong/ and print it
9
18
  // passalong start <id|link> pull it and take it — one command for "I am doing this"
@@ -28,16 +37,18 @@
28
37
  // passalong team invite [email] make an invite link (mailed when an email is given)
29
38
  // passalong team join <link|code> accept an invite
30
39
  // passalong team use <slug> switch the current team
31
- // passalong setup install the Claude Code capture skill + register the MCP server
40
+ // passalong setup install the Claude Code capture skill, MCP server and hooks
41
+ // passalong now what this worktree holds and what is waiting (the hooks call --hook)
32
42
  // passalong mcp run the MCP server over stdio
33
43
  // passalong version print the installed version (also -v, --version)
34
- import { execFileSync, spawnSync } from "node:child_process";
44
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
45
+ import { createHash } from "node:crypto";
35
46
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
36
47
  import { homedir } from "node:os";
37
48
  import { dirname, join, resolve } from "node:path";
38
49
  import { fileURLToPath } from "node:url";
39
50
  import * as api from "../src/api.js";
40
- import { scaffold } from "../src/capture.js";
51
+ import { scaffold, taskScaffold } from "../src/capture.js";
41
52
  import { serialize } from "../src/guide.js";
42
53
  import * as passalong from "../src/passalong.js";
43
54
  import * as store from "../src/store.js";
@@ -101,24 +112,83 @@ function activityLine(n) {
101
112
  return `${dot} ${(n.guide || "").padEnd(8)} ${relDate(n.at).padEnd(10)} ${n.text}`;
102
113
  }
103
114
 
115
+ /**
116
+ * After a pulled guide, the follow-ups written for it: more context, each its own guide. Listed on
117
+ * stderr rather than printed in full, so stdout stays exactly the document; nothing at all when
118
+ * there are none or sync is off.
119
+ */
120
+ async function listFollowUps(meta) {
121
+ const guides = await passalong.followUpGuides(meta);
122
+ if (!guides.length) return;
123
+ console.error(
124
+ `\n${guides.length} follow-up${guides.length === 1 ? "" : "s"} — more context for this guide:`,
125
+ );
126
+ for (const g of guides) console.error(` ${g.id} ${g.title || "untitled"}`);
127
+ console.error("Read one with: passalong pull <id>");
128
+ }
129
+
104
130
  const MONTHS = "January February March April May June July August September October November December".split(" ");
105
131
  /** "September 2026" from an ISO timestamp, without pulling in a date library for one heading. */
106
132
  const monthOf = (iso) => `${MONTHS[Number(iso.slice(5, 7)) - 1]} ${iso.slice(0, 4)}`;
107
133
 
134
+ // ---- the status line cache --------------------------------------------------------------------------
135
+
136
+ /** One small file per working directory, so two sessions in two repos each see their own. */
137
+ const statusPath = (cwd) =>
138
+ join(store.HOME, "statusline", `${createHash("sha1").update(cwd).digest("hex").slice(0, 16)}.json`);
139
+
140
+ function writeStatus(cwd, text) {
141
+ const p = statusPath(cwd);
142
+ mkdirSync(dirname(p), { recursive: true });
143
+ writeFileSync(p, JSON.stringify({ at: Date.now(), text }));
144
+ }
145
+
146
+ /** How old a cached line may be before a refresh is started behind it. */
147
+ const STATUS_TTL_MS = 30_000;
148
+
149
+ /**
150
+ * Print the cached line for this directory at once, and when it is stale start a detached refresh
151
+ * that writes the next one. The first run in a directory prints a placeholder; the refresh lands
152
+ * within a second or two, and the next update shows it. Offline or signed out, it says nothing.
153
+ */
154
+ async function statusLine() {
155
+ let input = {};
156
+ if (!process.stdin.isTTY) {
157
+ let raw = "";
158
+ for await (const chunk of process.stdin) raw += chunk;
159
+ try {
160
+ input = JSON.parse(raw || "{}");
161
+ } catch {}
162
+ }
163
+ if (!api.loggedIn()) return;
164
+ const cwd = input.workspace?.current_dir || input.cwd || process.cwd();
165
+ let cached = null;
166
+ try {
167
+ cached = JSON.parse(readFileSync(statusPath(cwd), "utf8"));
168
+ } catch {}
169
+ process.stdout.write(cached?.text ?? "passalong");
170
+ if (!cached || Date.now() - cached.at > STATUS_TTL_MS) {
171
+ // Marked fresh before the refresh starts, so a burst of updates starts one refresh, not ten.
172
+ writeStatus(cwd, cached?.text ?? "passalong");
173
+ spawn(process.execPath, [fileURLToPath(import.meta.url), "now", "--statusline-refresh", cwd], {
174
+ detached: true,
175
+ stdio: "ignore",
176
+ env: process.env,
177
+ }).unref();
178
+ }
179
+ }
180
+
108
181
  const commands = {
109
182
  async share(f) {
110
183
  let path = f._[0];
111
- let fromDrafts = false;
112
184
  if (!path) {
113
185
  const [latest] = store.drafts();
114
186
  if (latest) {
115
187
  path = latest;
116
- fromDrafts = true;
117
188
  console.error(`using draft ${path}`);
118
189
  } else {
119
190
  path = store.draftPath(new Date().toISOString().replace(/[:.]/g, "-"));
120
191
  writeFileSync(path, scaffold());
121
- fromDrafts = true;
122
192
  if (!tty)
123
193
  die(`no draft to share; scaffolded ${path} — fill it in and run \`passalong share ${path}\``);
124
194
  }
@@ -130,7 +200,10 @@ const commands = {
130
200
  const { guide, url, synced, notified, path: saved } = await passalong
131
201
  .share(readFileSync(path, "utf8"), { to, follows })
132
202
  .catch((e) => die(e.message));
133
- if (fromDrafts) store.removeDraft(path);
203
+ // Any draft that went out is spent, however it was named — a task that failed validation is
204
+ // re-shared by its path, and leaving it would have the next bare `share` publish it again.
205
+ // removeDraft() touches nothing outside the drafts directory.
206
+ store.removeDraft(resolve(path));
134
207
  console.log(guide.meta.id);
135
208
  console.error(`shared "${guide.meta.title}" → ${saved}`);
136
209
  if (guide.meta.parent) console.error(`follows ${guide.meta.parent}`);
@@ -142,11 +215,107 @@ const commands = {
142
215
  else console.error("not synced (run `passalong login` to sync across machines)");
143
216
  },
144
217
 
218
+ /**
219
+ * Write a task. It lands in Draft and stays there until `passalong ready`: the gap between the
220
+ * two is the one moment a person reads what an agent is about to be asked to do.
221
+ */
222
+ async task(f) {
223
+ const sentence = f._.join(" ").trim() || die('usage: passalong task "<what needs doing>"');
224
+ const path = store.draftPath(`task-${new Date().toISOString().replace(/[:.]/g, "-")}`);
225
+ writeFileSync(path, taskScaffold(sentence));
226
+ if (tty && !f["no-edit"]) edit(path);
227
+ const { guide, synced } = await passalong
228
+ .share(readFileSync(path, "utf8"))
229
+ .catch((e) => die(`${e.message}\ndraft kept at ${path} — fix it and run \`passalong share ${path}\``));
230
+ store.removeDraft(path);
231
+ const id = guide.meta.id;
232
+ console.log(id);
233
+ const target = guide.meta.target_context;
234
+ console.error(`task "${guide.meta.title}" is in Draft${target ? ` for ${target}` : " (no repo)"}`);
235
+ console.error(`when an agent may take it: passalong ready ${id}`);
236
+ if (!synced) console.error("not synced (run `passalong login` — agents elsewhere cannot see it)");
237
+ },
238
+
239
+ async tasks() {
240
+ const rows = await passalong.tasks().catch((e) => die(e.message));
241
+ if (!rows.length) return console.error('no tasks yet — write one with: passalong task "<what needs doing>"');
242
+ for (const t of rows) {
243
+ const c = t.claim;
244
+ const who = c ? ` ${c.host || "?"}:${c.worktree.split("/").pop() || "?"}` : "";
245
+ const said = c?.note ? ` "${c.note}"` : "";
246
+ console.log(`${t.id} ${t.state.padEnd(8)} ${t.title}${t.target ? ` [${t.target}]` : ""}${who}${said}`);
247
+ // What came back, under the task it answers: the thing a reviewer reads before deciding.
248
+ if (t.state === "review" && c?.report)
249
+ console.log(` → ${c.report} ${c.report_title || "(untitled)"}${c.pr ? ` ${c.pr}` : ""}`);
250
+ }
251
+ if (rows.some((t) => t.state === "review"))
252
+ console.error("\nread a write-up with: passalong open <id> — then approve or reject the task");
253
+ },
254
+
255
+ /**
256
+ * Work the queue from this worktree until it is empty. The agent is `claude -p` unless --agent or
257
+ * PASSALONG_AGENT says otherwise, e.g. "claude -p --permission-mode acceptEdits" so it can edit
258
+ * files without asking — which is yours to decide, not this command's.
259
+ */
260
+ async work(f) {
261
+ const run = await passalong
262
+ .work({
263
+ agent: typeof f.agent === "string" ? f.agent : undefined,
264
+ once: Boolean(f.once),
265
+ any: Boolean(f.any),
266
+ onTask: (t) => console.error(`\n→ ${t.id} ${t.title}${t.resumed ? " (resuming)" : ""}`),
267
+ })
268
+ .catch((e) => die(e.message));
269
+ for (const id of run.finished) console.log(id);
270
+ const n = run.finished.length;
271
+ const done = `${n} task${n === 1 ? "" : "s"} finished and waiting for review`;
272
+ if (run.stopped === "unfinished")
273
+ die(
274
+ `${done}. Stopped: the agent exited with ${run.task} still ${run.state || "open"} — ` +
275
+ `run again to resume it, or \`passalong release ${run.task}\``,
276
+ );
277
+ console.error(`${done}${run.stopped === "empty" ? " — nothing left for this repo" : ""}`);
278
+ if (n) console.error("review them with: passalong tasks");
279
+ },
280
+
281
+ async approve(f) {
282
+ const id = f._[0] || die("usage: passalong approve <id>");
283
+ await passalong.approveTask(id).catch((e) => die(e.message));
284
+ console.error(`${id} is done`);
285
+ },
286
+
287
+ async reject(f) {
288
+ const id = f._[0] || die("usage: passalong reject <id> <why>");
289
+ await passalong.rejectTask(id, f._.slice(1).join(" ")).catch((e) => die(e.message));
290
+ console.error(`${id} is back in Ready — the next agent reads why`);
291
+ },
292
+
293
+ async release(f) {
294
+ const id = f._[0] || die("usage: passalong release <id>");
295
+ await passalong.releaseTask(id).catch((e) => die(e.message));
296
+ console.error(`${id} is back in Ready — it says where the last agent left it`);
297
+ },
298
+
299
+ async ready(f) {
300
+ if (f.all) {
301
+ const ids = await passalong.readyDrafts().catch((e) => die(e.message));
302
+ if (!ids.length) return console.error("no drafts of yours to make ready");
303
+ for (const id of ids) console.log(id);
304
+ return console.error(`${ids.length} task${ids.length === 1 ? "" : "s"} Ready — agents may take them`);
305
+ }
306
+ if (!f._.length) die("usage: passalong ready <id...> | --all");
307
+ for (const id of f._) {
308
+ const g = await passalong.ready(id).catch((e) => die(e.message));
309
+ console.error(`${g.meta.id} is Ready — an agent may take it`);
310
+ }
311
+ },
312
+
145
313
  async pull(f) {
146
314
  const ref = f._[0] || die("usage: passalong pull <id|link>");
147
- const { markdown, path, from } = await passalong.pull(ref).catch((e) => die(e.message));
315
+ const { guide, markdown, path, from } = await passalong.pull(ref).catch((e) => die(e.message));
148
316
  process.stdout.write(markdown);
149
317
  console.error(`\npulled from ${from} → ${path}`);
318
+ await listFollowUps(guide.meta);
150
319
  },
151
320
 
152
321
  /**
@@ -165,6 +334,7 @@ const commands = {
165
334
  // Saying nothing here would be the silence the whole hop exists to end: you would have the
166
335
  // guide, believe you had answered for it, and the sender would still be waiting.
167
336
  else console.error("not taken — sync is off, so nobody was told (run `passalong login`)");
337
+ await listFollowUps(r.guide.meta);
168
338
  },
169
339
 
170
340
  /**
@@ -471,11 +641,82 @@ const commands = {
471
641
  `could not register MCP server automatically (${msg.trim()}).\n run: claude mcp add passalong -- passalong mcp`,
472
642
  );
473
643
  }
644
+ // The two hooks that keep an agent on what it holds: told at session start, and stopped once
645
+ // before ending with work nobody has handed in. Merged into the user's settings, not written
646
+ // over them, and a no-op when they are already there.
647
+ const settingsPath = join(homedir(), ".claude", "settings.json");
648
+ try {
649
+ const { withHooks, withStatusLine } = await import("../src/hooks.js");
650
+ const before = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf8")) : {};
651
+ const after = withStatusLine(withHooks(before));
652
+ if (before.statusLine && before.statusLine.command !== "passalong now --statusline")
653
+ console.error(
654
+ "you already have a status line, so it was left alone. To show Passalong in it, call `passalong now --statusline` from your script",
655
+ );
656
+ if (JSON.stringify(after) === JSON.stringify(before)) console.error("Claude Code hooks already installed");
657
+ else {
658
+ mkdirSync(dirname(settingsPath), { recursive: true });
659
+ writeFileSync(settingsPath, `${JSON.stringify(after, null, 2)}\n`);
660
+ console.error(`installed Claude Code hooks (session start, stop) and status line → ${settingsPath}`);
661
+ }
662
+ } catch (err) {
663
+ console.error(`could not install Claude Code hooks (${err.message}); ${settingsPath} left as it was`);
664
+ }
474
665
  if (!api.loggedIn())
475
666
  console.error("sync is off; run `passalong login` when you want guides to follow you across machines");
476
667
  console.error('done. In Claude Code, say "pass this along" at the end of a task, or run /passalong-capture.');
477
668
  },
478
669
 
670
+ /**
671
+ * What this worktree holds and what is waiting. `--hook session|stop` is the form the Claude
672
+ * Code hooks from `setup` call (src/hooks.js): it prints only what the hook protocol reads, and
673
+ * nothing at all when anything goes wrong — a hook must never get in the way of a session.
674
+ */
675
+ async now(f) {
676
+ const { nowText, statusText, stopVerdict } = await import("../src/hooks.js");
677
+ // The status line: printed from a cache, refreshed behind it. Claude Code cancels a status
678
+ // line still running when the next update comes, so this never waits on the network.
679
+ if (f.statusline) return statusLine(f);
680
+ if (f["statusline-refresh"]) {
681
+ const cwd = typeof f["statusline-refresh"] === "string" ? f["statusline-refresh"] : process.cwd();
682
+ try {
683
+ const r = await passalong.now({ cwd });
684
+ writeStatus(cwd, statusText({ held: r.held, needs: r.needs, ready: r.waiting.ready }));
685
+ } catch {}
686
+ return;
687
+ }
688
+ if (!f.hook) {
689
+ const r = await passalong.now();
690
+ console.log(nowText(r) || "Nothing held here, and nothing waiting.");
691
+ return;
692
+ }
693
+ try {
694
+ let input = {};
695
+ if (f.hook === "stop" && !process.stdin.isTTY) {
696
+ let raw = "";
697
+ for await (const chunk of process.stdin) raw += chunk;
698
+ input = JSON.parse(raw || "{}");
699
+ }
700
+ if (!api.loggedIn()) return;
701
+ const r = await Promise.race([
702
+ passalong.now(),
703
+ new Promise((_, no) => setTimeout(() => no(new Error("timeout")), 5000)),
704
+ ]);
705
+ if (f.hook === "session") {
706
+ const text = nowText(r);
707
+ if (text)
708
+ console.log(
709
+ JSON.stringify({
710
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: text },
711
+ }),
712
+ );
713
+ } else if (f.hook === "stop") {
714
+ const v = stopVerdict({ held: r.held, input });
715
+ if (v) console.log(JSON.stringify(v));
716
+ }
717
+ } catch {}
718
+ },
719
+
479
720
  async mcp() {
480
721
  const { serve } = await import("../src/mcp.js");
481
722
  await serve();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.3.2",
3
+ "version": "0.6.0",
4
4
  "description": "Hand a finished implementation from one context to another, in a form an AI agent can act on.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -35,7 +35,8 @@
35
35
  "access": "public"
36
36
  },
37
37
  "scripts": {
38
- "test": "node --test"
38
+ "test": "node --test",
39
+ "test:e2e": "PASSALONG_E2E_API=${PASSALONG_E2E_API:-http://localhost:3001} node --test test/task-flow.e2e.test.js"
39
40
  },
40
41
  "dependencies": {
41
42
  "@modelcontextprotocol/sdk": "^1.29.0",
package/skill/SKILL.md CHANGED
@@ -34,12 +34,13 @@ session's history. Write for execution, not for permanence.
34
34
  team's members and their handles. It prints the guide id on stdout and the share link (and
35
35
  who it was handed to) on stderr.
36
36
 
37
- **If this session started from a passalong guide** — you ran `passalong start` or `pull` on an
38
- id, or an agent opened one — and what you are capturing is what doing it taught you, add
39
- `--follows <that id>`. The original then lists this one as a follow-up. That is the case when
40
- you departed from it: changed or skipped a Step, adapted an `ASSUMES` for this stack, found the
41
- fix a failing guide did not have, or hit a Gotcha it does not list. If it worked exactly as
42
- written there is nothing to capture — say so with `passalong works <id>` instead.
37
+ **If what you are capturing is more context for an existing guide** — a missing detail, a step
38
+ that needed explaining, what changed since, what you found doing it — add `--follows <that id>`.
39
+ That publishes it as a follow-up: its own guide, listed under the original, and handed to
40
+ whoever opens the original, person or agent. A session that started from a passalong guide
41
+ (`passalong start` or `pull` on an id, or an agent opened one) usually has context worth adding
42
+ this way. Whether the guide worked is still its own answer: `passalong works <id>` or
43
+ `passalong broken <id> <why>`.
43
44
  6. **Report** the id (and link if synced) and one line on how to use it on the other side:
44
45
  `passalong start <id>` in the target repo, or "start passalong <id>" to an agent with the
45
46
  Passalong MCP server. `start` rather than `pull` on purpose: it takes the handoff as well as
@@ -56,7 +57,7 @@ each. Absent means `transfer`, which is what every guide written before bug repo
56
57
 
57
58
  | kind | what it is | what the receiver does |
58
59
  | --- | --- | --- |
59
- | `transfer` (default) | finished work to repeat somewhere else | follows the **Steps** |
60
+ | `transfer` (or no `kind:` line) | finished work to repeat somewhere else | follows the **Steps** |
60
61
  | `bug` | a defect to fix where it is | fixes it — **Reproduce** shows the problem, it is not a procedure to apply |
61
62
 
62
63
  To file bugs you found but are not fixing, call `file_bugs` with all of them in one call — it
package/src/api.js CHANGED
@@ -46,10 +46,14 @@ async function call(path, { method = "GET", body, auth = true, raw = false } = {
46
46
  if (!res.ok) {
47
47
  const text = await res.text();
48
48
  let message = text;
49
+ let parsed = {};
49
50
  try {
50
- message = JSON.parse(text).message || text;
51
+ parsed = JSON.parse(text);
52
+ message = parsed.message || text;
51
53
  } catch {}
52
- throw new ApiError(res.status, message || res.statusText);
54
+ // The whole answer rides along: a refusal can carry `next`, `say` or `holder`, which is what
55
+ // an agent needs to decide what to do instead.
56
+ throw Object.assign(new ApiError(res.status, message || res.statusText), { body: parsed });
53
57
  }
54
58
  return raw ? res.text() : res.json();
55
59
  }
@@ -91,6 +95,12 @@ export const createReport = (body) => call("/v1/reports", { method: "POST", body
91
95
  /** One report and the issues filed under it, grouped by product area. */
92
96
  export const report = (id) => call(`/v1/reports/${encodeURIComponent(id)}`);
93
97
  export const get = (id) => call(`/v1/guides/${id}`, { raw: true });
98
+ /**
99
+ * A guide's follow-ups, one level down. `markdown` adds each one's content, oldest first and
100
+ * capped server-side; neither form counts as opening them.
101
+ */
102
+ export const children = (id, { markdown = false } = {}) =>
103
+ call(`/v1/guides/${encodeURIComponent(id)}/children${q({ markdown: markdown ? "1" : "" })}`);
94
104
  export const setStatus = (id, status) =>
95
105
  call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
96
106
  export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
@@ -98,6 +108,28 @@ export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
98
108
  export const verdict = (id, ok, note = "") =>
99
109
  call(`/v1/guides/${id}/verdict`, { method: "PUT", body: { ok, note } });
100
110
 
111
+ export const tasks = () => call("/v1/tasks");
112
+ export const nextTask = (body) => call("/v1/tasks/next", { method: "POST", body });
113
+ export const taskProgress = (id, body) =>
114
+ call(`/v1/tasks/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
115
+ export const finishTask = (id, body) =>
116
+ call(`/v1/tasks/${encodeURIComponent(id)}/finish`, { method: "POST", body });
117
+ export const approveTask = (id) =>
118
+ call(`/v1/tasks/${encodeURIComponent(id)}/approve`, { method: "POST", body: {} });
119
+ export const rejectTask = (id, why) =>
120
+ call(`/v1/tasks/${encodeURIComponent(id)}/reject`, { method: "POST", body: { why } });
121
+ export const releaseTask = (id) =>
122
+ call(`/v1/tasks/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
123
+ /** The four verbs every guide answers to. See docs/V2.md §11. */
124
+ export const working = () => call("/v1/working");
125
+ export const handedIn = () => call("/v1/handed_in");
126
+ export const take = (body) => call("/v1/take", { method: "POST", body });
127
+ export const progress = (id, body) =>
128
+ call(`/v1/guides/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
129
+ export const handIn = (id, body) =>
130
+ call(`/v1/guides/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
131
+ export const pass = (id, body) =>
132
+ call(`/v1/guides/${encodeURIComponent(id)}/pass`, { method: "POST", body });
101
133
  export const ack = (id, taken, note = "") =>
102
134
  call(`/v1/guides/${id}/ack`, { method: "PUT", body: { taken, note } });
103
135
 
package/src/capture.js CHANGED
@@ -17,6 +17,23 @@ function git(cwd, ...args) {
17
17
  }
18
18
  }
19
19
 
20
+ /**
21
+ * One repo, written one way, so a task and the agent asking for work agree on what it is called.
22
+ * A remote URL becomes `owner/repo` whichever form it came in; anything else is kept, lowercase.
23
+ * Mirrors `repoKey()` in apps/api/src/claims.ts, which normalises what it stores the same way.
24
+ */
25
+ export function repoKey(raw) {
26
+ let s = String(raw ?? "")
27
+ .trim()
28
+ .toLowerCase();
29
+ s = s.replace(/\/+$/, "").replace(/\.git$/, "");
30
+ const url = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?[^:/]+[:/](.+\/[^/]+)$/.exec(s);
31
+ if (url && (s.includes("://") || s.includes("@"))) {
32
+ return url[1].split("/").filter(Boolean).slice(-2).join("/");
33
+ }
34
+ return s;
35
+ }
36
+
20
37
  /** What we can infer about the current context without reading any session. */
21
38
  export function context(cwd = process.cwd()) {
22
39
  const root = git(cwd, "rev-parse", "--show-toplevel");
@@ -30,13 +47,36 @@ export function context(cwd = process.cwd()) {
30
47
  const files = root
31
48
  ? git(cwd, "diff", "--name-only", "HEAD~5..HEAD").split("\n").filter(Boolean)
32
49
  : [];
33
- return { root, name, branch, author, commits, changed, files };
50
+ // The repo as the task queue names it: its remote when it has one, because two worktrees of one
51
+ // repo have two folder names and one remote. The folder name when there is no remote.
52
+ const repo = root ? repoKey(git(cwd, "remote", "get-url", "origin") || name) : "";
53
+ return { root, name, repo, branch, author, commits, changed, files };
54
+ }
55
+
56
+ /**
57
+ * A task draft from one sentence: the sentence as the title and the Goal, the rest left to fill.
58
+ *
59
+ * `target_context` defaults to the repo this runs in, because that is the likeliest repo a task
60
+ * written from inside one is for — and it is in the editor, in the frontmatter, for the author
61
+ * to change before it goes anywhere. Outside a repo it stays empty, which is a task for no repo.
62
+ */
63
+ export function taskScaffold(sentence, cwd = process.cwd()) {
64
+ const c = context(cwd);
65
+ const md = template({
66
+ kind: "task",
67
+ title: sentence,
68
+ author: c.author,
69
+ source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
70
+ target_context: c.repo,
71
+ });
72
+ return md.replace(/^## Goal\n/m, `## Goal\n${sentence}\n`);
34
73
  }
35
74
 
36
75
  /** A draft pre-filled with repo facts. Recent activity is appended as a comment for reference. */
37
76
  export function scaffold(cwd = process.cwd()) {
38
77
  const c = context(cwd);
39
78
  let md = template({
79
+ kind: "transfer",
40
80
  title: "",
41
81
  author: c.author,
42
82
  source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
package/src/guide.js CHANGED
@@ -35,11 +35,12 @@ export const SETTABLE = ["draft", "published", "consumed"];
35
35
  *
36
36
  * transfer work that is finished here and should be repeated there — follow the Steps
37
37
  * bug something broken there — do NOT follow anything; fix it
38
+ * task work nobody has done yet — do what Goal asks, done when Acceptance holds
38
39
  *
39
40
  * A guide with no `kind` is a transfer guide: every guide written before this existed is one,
40
41
  * and defaulting the other way would turn them all into bug reports.
41
42
  */
42
- export const KINDS = ["transfer", "bug"];
43
+ export const KINDS = ["transfer", "bug", "task"];
43
44
 
44
45
  // Body sections in the order a guide should present them. The heading text is what the
45
46
  // receiving agent keys on, so keep these stable.
@@ -98,13 +99,29 @@ export const BUG_LEAD =
98
99
  "> **Bug report.** The steps under Reproduce show the problem \u2014 they are not a fix to " +
99
100
  "apply. Fix what Problem describes, then check Verification.";
100
101
 
102
+ /**
103
+ * A task's sections: a brief for work nobody has done yet, written before it starts.
104
+ *
105
+ * `Acceptance` is the section that matters. It is what a person checks the finished work against
106
+ * before approving it, so a task without one has no way to be done — which is why it is required.
107
+ * There is no `Steps`: the agent that takes a task works out how, and what it did comes back as a
108
+ * transfer guide, which is where Steps belong.
109
+ */
110
+ export const TASK_SECTIONS = ["Goal", "Context", "Constraints", "Acceptance", "Out of scope"];
111
+
101
112
  /** The sections a guide of this kind presents, in order. */
102
113
  export function sectionsFor(kind) {
103
- return kind === "bug" ? BUG_SECTIONS : SECTIONS;
114
+ if (kind === "bug") return BUG_SECTIONS;
115
+ if (kind === "task") return TASK_SECTIONS;
116
+ return SECTIONS;
104
117
  }
105
118
 
106
119
  /** The sections it cannot be published without. */
107
- const REQUIRED = { transfer: ["Problem", "Steps"], bug: ["Problem", "Reproduce"] };
120
+ const REQUIRED = {
121
+ transfer: ["Problem", "Steps"],
122
+ bug: ["Problem", "Reproduce"],
123
+ task: ["Goal", "Acceptance"],
124
+ };
108
125
 
109
126
  // Fields that hold a list of strings. Everything else is a plain string.
110
127
  /**
@@ -132,7 +149,7 @@ export function tagList(raw) {
132
149
  return [...new Set(list.map(tag).filter(Boolean))];
133
150
  }
134
151
 
135
- const LIST_FIELDS = new Set(["stack_assumptions", "tags"]);
152
+ const LIST_FIELDS = new Set(["stack_assumptions", "tags", "blocked_by"]);
136
153
 
137
154
  // IDs are short enough to type and say out loud. 8 chars from a 31-letter alphabet with the
138
155
  // look-alikes removed (0/o, 1/l/i) is ~40 bits: plenty for addressing, not a secret.
@@ -225,7 +242,9 @@ export function parseFrontmatter(text) {
225
242
  listKey = null;
226
243
  }
227
244
  }
228
- for (const k of LIST_FIELDS) if (meta[k] === undefined) meta[k] = [];
245
+ // Only the lists every guide has. `blocked_by` is a task's, and defaulting it would write an
246
+ // empty one into every guide anybody re-shares.
247
+ for (const k of ["stack_assumptions", "tags"]) if (meta[k] === undefined) meta[k] = [];
229
248
  // Both ends of this module: what it reads and what it writes are in one style, so a guide
230
249
  // written before there was a rule comes back normalised and goes out normalised.
231
250
  meta.tags = tagList(meta.tags);
@@ -245,6 +264,11 @@ const META_ORDER = [
245
264
  "created",
246
265
  "author",
247
266
  "source_context",
267
+ // Tasks only: the repo the work is for. Optional — a task without one is not tied to a repo.
268
+ "target_context",
269
+ // Tasks only: the tasks this one waits for, by id. It is not handed to an agent until a person
270
+ // has approved each of them. See migrations/0022_blocks.sql.
271
+ "blocked_by",
248
272
  // Where it came from, in the two senses a guide has one: `source_context` is the repo and branch
249
273
  // it was written in, `parent` is the guide it was written *out of*.
250
274
  "parent",
@@ -451,7 +475,10 @@ export function bugGuide({
451
475
 
452
476
  /** A draft with the section skeleton. Placeholders are HTML comments so they vanish when rendered. */
453
477
  export function template(meta = {}) {
478
+ // A task unless asked otherwise: most guides assign work. The transfer skeleton writes its kind
479
+ // out, since an absent kind: still means transfer to every client already installed.
454
480
  if (meta.kind === "bug") return bugTemplate(meta);
481
+ if (meta.kind !== "transfer") return taskTemplate(meta);
455
482
  const body = [
456
483
  "## Problem",
457
484
  "<!-- passalong: What was broken or needed, in two or three sentences. -->",
@@ -480,6 +507,7 @@ export function template(meta = {}) {
480
507
  stack_assumptions: [],
481
508
  tags: [],
482
509
  ...meta,
510
+ kind: "transfer",
483
511
  },
484
512
  body,
485
513
  });
@@ -522,6 +550,46 @@ function bugTemplate(meta = {}) {
522
550
  });
523
551
  }
524
552
 
553
+ /**
554
+ * One task, as a guide: the brief an agent takes before any work exists.
555
+ *
556
+ * `target_context` is the repo the work is for, and it is left empty rather than defaulted to the
557
+ * repo the task was written in: a task written in one repo is often for another, and one aimed at
558
+ * the wrong repo is picked up by the wrong agent.
559
+ */
560
+ function taskTemplate(meta = {}) {
561
+ const body = [
562
+ "## Goal",
563
+ "<!-- passalong: What should be true when this is done, in two or three sentences. -->",
564
+ "",
565
+ "## Context",
566
+ "<!-- passalong: What the agent needs to know first: where the code lives, what exists already, links. -->",
567
+ "",
568
+ "## Constraints",
569
+ "<!-- passalong: What must not change, what to use or avoid, limits on scope or approach. -->",
570
+ "",
571
+ "## Acceptance",
572
+ "<!-- passalong: How a person checks it is done: commands, expected output, behaviour to see. -->",
573
+ "",
574
+ "## Out of scope",
575
+ "<!-- passalong: What looks related but is not part of this task. -->",
576
+ ].join("\n");
577
+ return serialize({
578
+ meta: {
579
+ title: "",
580
+ kind: "task",
581
+ author: "",
582
+ source_context: "",
583
+ target_context: "",
584
+ status: "draft",
585
+ stack_assumptions: [],
586
+ tags: [],
587
+ ...meta,
588
+ },
589
+ body,
590
+ });
591
+ }
592
+
525
593
  /** Strip the template comments so an untouched section reads as empty rather than as a prompt. */
526
594
  export function stripPlaceholders(body) {
527
595
  return body.replace(/^<!-- passalong:.*?-->\n?/gm, "").trim();