passalong 0.3.1 → 0.5.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/README.md CHANGED
@@ -48,6 +48,7 @@ passalong team join <link> accept an invite
48
48
  passalong team use <slug> switch the current team
49
49
  passalong setup install the Claude Code capture skill + register the MCP server
50
50
  passalong mcp run the MCP server over stdio
51
+ passalong version print the installed version (also -v, --version)
51
52
  ```
52
53
 
53
54
  ## Teams
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,15 +37,17 @@
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
43
+ // passalong version print the installed version (also -v, --version)
33
44
  import { execFileSync, spawnSync } from "node:child_process";
34
45
  import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
35
46
  import { homedir } from "node:os";
36
47
  import { dirname, join, resolve } from "node:path";
37
48
  import { fileURLToPath } from "node:url";
38
49
  import * as api from "../src/api.js";
39
- import { scaffold } from "../src/capture.js";
50
+ import { scaffold, taskScaffold } from "../src/capture.js";
40
51
  import { serialize } from "../src/guide.js";
41
52
  import * as passalong from "../src/passalong.js";
42
53
  import * as store from "../src/store.js";
@@ -100,6 +111,21 @@ function activityLine(n) {
100
111
  return `${dot} ${(n.guide || "").padEnd(8)} ${relDate(n.at).padEnd(10)} ${n.text}`;
101
112
  }
102
113
 
114
+ /**
115
+ * After a pulled guide, the follow-ups written for it: more context, each its own guide. Listed on
116
+ * stderr rather than printed in full, so stdout stays exactly the document; nothing at all when
117
+ * there are none or sync is off.
118
+ */
119
+ async function listFollowUps(meta) {
120
+ const guides = await passalong.followUpGuides(meta);
121
+ if (!guides.length) return;
122
+ console.error(
123
+ `\n${guides.length} follow-up${guides.length === 1 ? "" : "s"} — more context for this guide:`,
124
+ );
125
+ for (const g of guides) console.error(` ${g.id} ${g.title || "untitled"}`);
126
+ console.error("Read one with: passalong pull <id>");
127
+ }
128
+
103
129
  const MONTHS = "January February March April May June July August September October November December".split(" ");
104
130
  /** "September 2026" from an ISO timestamp, without pulling in a date library for one heading. */
105
131
  const monthOf = (iso) => `${MONTHS[Number(iso.slice(5, 7)) - 1]} ${iso.slice(0, 4)}`;
@@ -107,17 +133,14 @@ const monthOf = (iso) => `${MONTHS[Number(iso.slice(5, 7)) - 1]} ${iso.slice(0,
107
133
  const commands = {
108
134
  async share(f) {
109
135
  let path = f._[0];
110
- let fromDrafts = false;
111
136
  if (!path) {
112
137
  const [latest] = store.drafts();
113
138
  if (latest) {
114
139
  path = latest;
115
- fromDrafts = true;
116
140
  console.error(`using draft ${path}`);
117
141
  } else {
118
142
  path = store.draftPath(new Date().toISOString().replace(/[:.]/g, "-"));
119
143
  writeFileSync(path, scaffold());
120
- fromDrafts = true;
121
144
  if (!tty)
122
145
  die(`no draft to share; scaffolded ${path} — fill it in and run \`passalong share ${path}\``);
123
146
  }
@@ -129,7 +152,10 @@ const commands = {
129
152
  const { guide, url, synced, notified, path: saved } = await passalong
130
153
  .share(readFileSync(path, "utf8"), { to, follows })
131
154
  .catch((e) => die(e.message));
132
- if (fromDrafts) store.removeDraft(path);
155
+ // Any draft that went out is spent, however it was named — a task that failed validation is
156
+ // re-shared by its path, and leaving it would have the next bare `share` publish it again.
157
+ // removeDraft() touches nothing outside the drafts directory.
158
+ store.removeDraft(resolve(path));
133
159
  console.log(guide.meta.id);
134
160
  console.error(`shared "${guide.meta.title}" → ${saved}`);
135
161
  if (guide.meta.parent) console.error(`follows ${guide.meta.parent}`);
@@ -141,11 +167,107 @@ const commands = {
141
167
  else console.error("not synced (run `passalong login` to sync across machines)");
142
168
  },
143
169
 
170
+ /**
171
+ * Write a task. It lands in Draft and stays there until `passalong ready`: the gap between the
172
+ * two is the one moment a person reads what an agent is about to be asked to do.
173
+ */
174
+ async task(f) {
175
+ const sentence = f._.join(" ").trim() || die('usage: passalong task "<what needs doing>"');
176
+ const path = store.draftPath(`task-${new Date().toISOString().replace(/[:.]/g, "-")}`);
177
+ writeFileSync(path, taskScaffold(sentence));
178
+ if (tty && !f["no-edit"]) edit(path);
179
+ const { guide, synced } = await passalong
180
+ .share(readFileSync(path, "utf8"))
181
+ .catch((e) => die(`${e.message}\ndraft kept at ${path} — fix it and run \`passalong share ${path}\``));
182
+ store.removeDraft(path);
183
+ const id = guide.meta.id;
184
+ console.log(id);
185
+ const target = guide.meta.target_context;
186
+ console.error(`task "${guide.meta.title}" is in Draft${target ? ` for ${target}` : " (no repo)"}`);
187
+ console.error(`when an agent may take it: passalong ready ${id}`);
188
+ if (!synced) console.error("not synced (run `passalong login` — agents elsewhere cannot see it)");
189
+ },
190
+
191
+ async tasks() {
192
+ const rows = await passalong.tasks().catch((e) => die(e.message));
193
+ if (!rows.length) return console.error('no tasks yet — write one with: passalong task "<what needs doing>"');
194
+ for (const t of rows) {
195
+ const c = t.claim;
196
+ const who = c ? ` ${c.host || "?"}:${c.worktree.split("/").pop() || "?"}` : "";
197
+ const said = c?.note ? ` "${c.note}"` : "";
198
+ console.log(`${t.id} ${t.state.padEnd(8)} ${t.title}${t.target ? ` [${t.target}]` : ""}${who}${said}`);
199
+ // What came back, under the task it answers: the thing a reviewer reads before deciding.
200
+ if (t.state === "review" && c?.report)
201
+ console.log(` → ${c.report} ${c.report_title || "(untitled)"}${c.pr ? ` ${c.pr}` : ""}`);
202
+ }
203
+ if (rows.some((t) => t.state === "review"))
204
+ console.error("\nread a write-up with: passalong open <id> — then approve or reject the task");
205
+ },
206
+
207
+ /**
208
+ * Work the queue from this worktree until it is empty. The agent is `claude -p` unless --agent or
209
+ * PASSALONG_AGENT says otherwise, e.g. "claude -p --permission-mode acceptEdits" so it can edit
210
+ * files without asking — which is yours to decide, not this command's.
211
+ */
212
+ async work(f) {
213
+ const run = await passalong
214
+ .work({
215
+ agent: typeof f.agent === "string" ? f.agent : undefined,
216
+ once: Boolean(f.once),
217
+ any: Boolean(f.any),
218
+ onTask: (t) => console.error(`\n→ ${t.id} ${t.title}${t.resumed ? " (resuming)" : ""}`),
219
+ })
220
+ .catch((e) => die(e.message));
221
+ for (const id of run.finished) console.log(id);
222
+ const n = run.finished.length;
223
+ const done = `${n} task${n === 1 ? "" : "s"} finished and waiting for review`;
224
+ if (run.stopped === "unfinished")
225
+ die(
226
+ `${done}. Stopped: the agent exited with ${run.task} still ${run.state || "open"} — ` +
227
+ `run again to resume it, or \`passalong release ${run.task}\``,
228
+ );
229
+ console.error(`${done}${run.stopped === "empty" ? " — nothing left for this repo" : ""}`);
230
+ if (n) console.error("review them with: passalong tasks");
231
+ },
232
+
233
+ async approve(f) {
234
+ const id = f._[0] || die("usage: passalong approve <id>");
235
+ await passalong.approveTask(id).catch((e) => die(e.message));
236
+ console.error(`${id} is done`);
237
+ },
238
+
239
+ async reject(f) {
240
+ const id = f._[0] || die("usage: passalong reject <id> <why>");
241
+ await passalong.rejectTask(id, f._.slice(1).join(" ")).catch((e) => die(e.message));
242
+ console.error(`${id} is back in Ready — the next agent reads why`);
243
+ },
244
+
245
+ async release(f) {
246
+ const id = f._[0] || die("usage: passalong release <id>");
247
+ await passalong.releaseTask(id).catch((e) => die(e.message));
248
+ console.error(`${id} is back in Ready — it says where the last agent left it`);
249
+ },
250
+
251
+ async ready(f) {
252
+ if (f.all) {
253
+ const ids = await passalong.readyDrafts().catch((e) => die(e.message));
254
+ if (!ids.length) return console.error("no drafts of yours to make ready");
255
+ for (const id of ids) console.log(id);
256
+ return console.error(`${ids.length} task${ids.length === 1 ? "" : "s"} Ready — agents may take them`);
257
+ }
258
+ if (!f._.length) die("usage: passalong ready <id...> | --all");
259
+ for (const id of f._) {
260
+ const g = await passalong.ready(id).catch((e) => die(e.message));
261
+ console.error(`${g.meta.id} is Ready — an agent may take it`);
262
+ }
263
+ },
264
+
144
265
  async pull(f) {
145
266
  const ref = f._[0] || die("usage: passalong pull <id|link>");
146
- const { markdown, path, from } = await passalong.pull(ref).catch((e) => die(e.message));
267
+ const { guide, markdown, path, from } = await passalong.pull(ref).catch((e) => die(e.message));
147
268
  process.stdout.write(markdown);
148
269
  console.error(`\npulled from ${from} → ${path}`);
270
+ await listFollowUps(guide.meta);
149
271
  },
150
272
 
151
273
  /**
@@ -164,6 +286,7 @@ const commands = {
164
286
  // Saying nothing here would be the silence the whole hop exists to end: you would have the
165
287
  // guide, believe you had answered for it, and the sender would still be waiting.
166
288
  else console.error("not taken — sync is off, so nobody was told (run `passalong login`)");
289
+ await listFollowUps(r.guide.meta);
167
290
  },
168
291
 
169
292
  /**
@@ -470,11 +593,67 @@ const commands = {
470
593
  `could not register MCP server automatically (${msg.trim()}).\n run: claude mcp add passalong -- passalong mcp`,
471
594
  );
472
595
  }
596
+ // The two hooks that keep an agent on what it holds: told at session start, and stopped once
597
+ // before ending with work nobody has handed in. Merged into the user's settings, not written
598
+ // over them, and a no-op when they are already there.
599
+ const settingsPath = join(homedir(), ".claude", "settings.json");
600
+ try {
601
+ const { withHooks } = await import("../src/hooks.js");
602
+ const before = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf8")) : {};
603
+ const after = withHooks(before);
604
+ if (JSON.stringify(after) === JSON.stringify(before)) console.error("Claude Code hooks already installed");
605
+ else {
606
+ mkdirSync(dirname(settingsPath), { recursive: true });
607
+ writeFileSync(settingsPath, `${JSON.stringify(after, null, 2)}\n`);
608
+ console.error(`installed Claude Code hooks (session start, stop) → ${settingsPath}`);
609
+ }
610
+ } catch (err) {
611
+ console.error(`could not install Claude Code hooks (${err.message}); ${settingsPath} left as it was`);
612
+ }
473
613
  if (!api.loggedIn())
474
614
  console.error("sync is off; run `passalong login` when you want guides to follow you across machines");
475
615
  console.error('done. In Claude Code, say "pass this along" at the end of a task, or run /passalong-capture.');
476
616
  },
477
617
 
618
+ /**
619
+ * What this worktree holds and what is waiting. `--hook session|stop` is the form the Claude
620
+ * Code hooks from `setup` call (src/hooks.js): it prints only what the hook protocol reads, and
621
+ * nothing at all when anything goes wrong — a hook must never get in the way of a session.
622
+ */
623
+ async now(f) {
624
+ const { nowText, stopVerdict } = await import("../src/hooks.js");
625
+ if (!f.hook) {
626
+ const r = await passalong.now();
627
+ console.log(nowText(r) || "Nothing held here, and nothing waiting.");
628
+ return;
629
+ }
630
+ try {
631
+ let input = {};
632
+ if (f.hook === "stop" && !process.stdin.isTTY) {
633
+ let raw = "";
634
+ for await (const chunk of process.stdin) raw += chunk;
635
+ input = JSON.parse(raw || "{}");
636
+ }
637
+ if (!api.loggedIn()) return;
638
+ const r = await Promise.race([
639
+ passalong.now(),
640
+ new Promise((_, no) => setTimeout(() => no(new Error("timeout")), 5000)),
641
+ ]);
642
+ if (f.hook === "session") {
643
+ const text = nowText(r);
644
+ if (text)
645
+ console.log(
646
+ JSON.stringify({
647
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: text },
648
+ }),
649
+ );
650
+ } else if (f.hook === "stop") {
651
+ const v = stopVerdict({ held: r.held, input });
652
+ if (v) console.log(JSON.stringify(v));
653
+ }
654
+ } catch {}
655
+ },
656
+
478
657
  async mcp() {
479
658
  const { serve } = await import("../src/mcp.js");
480
659
  await serve();
@@ -490,5 +669,11 @@ const commands = {
490
669
 
491
670
  const argv = flags(process.argv.slice(2));
492
671
  const cmd = argv._.shift() || "help";
493
- if (cmd === "--help" || cmd === "-h" || !commands[cmd]) commands.help();
672
+ // The first thing anyone runs when something is off, and until now it printed the help page. The
673
+ // parser only knows `--x`, so `--version` arrives as a flag on an empty command and `-v` as a word;
674
+ // both are caught here, with `version`, before dispatch. Only the number goes to stdout, so
675
+ // `passalong -v` can be read by a script comparing it against the registry.
676
+ if (cmd === "-v" || cmd === "version" || (cmd === "help" && argv.version === true)) {
677
+ console.log(JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version);
678
+ } else if (cmd === "--help" || cmd === "-h" || !commands[cmd]) commands.help();
494
679
  else await commands[cmd](argv).catch((e) => die(e.message));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.3.1",
3
+ "version": "0.5.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
@@ -33,6 +33,14 @@ session's history. Write for execution, not for permanence.
33
33
  team"), add `--to <team>` or `--to <team>/<handle>`; `passalong team` lists the current
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
+
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>`.
36
44
  6. **Report** the id (and link if synced) and one line on how to use it on the other side:
37
45
  `passalong start <id>` in the target repo, or "start passalong <id>" to an agent with the
38
46
  Passalong MCP server. `start` rather than `pull` on purpose: it takes the handoff as well as
@@ -49,7 +57,7 @@ each. Absent means `transfer`, which is what every guide written before bug repo
49
57
 
50
58
  | kind | what it is | what the receiver does |
51
59
  | --- | --- | --- |
52
- | `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** |
53
61
  | `bug` | a defect to fix where it is | fixes it — **Reproduce** shows the problem, it is not a procedure to apply |
54
62
 
55
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,27 @@ 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 take = (body) => call("/v1/take", { method: "POST", body });
126
+ export const progress = (id, body) =>
127
+ call(`/v1/guides/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
128
+ export const handIn = (id, body) =>
129
+ call(`/v1/guides/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
130
+ export const pass = (id, body) =>
131
+ call(`/v1/guides/${encodeURIComponent(id)}/pass`, { method: "POST", body });
101
132
  export const ack = (id, taken, note = "") =>
102
133
  call(`/v1/guides/${id}/ack`, { method: "PUT", body: { taken, note } });
103
134
 
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();