passalong 0.13.0 → 0.13.1
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/LICENSE +21 -0
- package/README.md +6 -4
- package/bin/passalong +38 -7
- package/package.json +8 -3
- package/skill/SKILL.md +14 -0
- package/src/api.js +34 -0
- package/src/capture.js +2 -0
- package/src/checks.js +6 -1
- package/src/guide.js +32 -0
- package/src/hooks.js +10 -1
- package/src/mcp.js +173 -5
- package/src/passalong.js +87 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lukman Isiaka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# passalong
|
|
2
2
|
|
|
3
|
-
Hand
|
|
3
|
+
Hand work to your AI coding agents, and get back what they did and how they checked it.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Write a task, a bug or a finished change once. The agent that takes it starts knowing what done
|
|
6
|
+
looks like and what was already decided, reports as it goes, and hands back a write-up with the
|
|
7
|
+
evidence for each thing you asked for, which you approve or send back. One agent per job, and
|
|
8
|
+
everyone can see who has what. Works with Claude Code, Cursor, Gemini, ChatGPT, or anything that
|
|
9
|
+
speaks MCP or HTTP. See [passalong.dev](https://passalong.dev).
|
|
8
10
|
|
|
9
11
|
```sh
|
|
10
12
|
npm i -g passalong
|
package/bin/passalong
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// passalong approve <id> the work in review is done
|
|
14
14
|
// passalong reject <id> <why> not done: back to Ready, and the next agent reads why
|
|
15
15
|
// passalong release <id> take work back from whoever holds it, of any kind
|
|
16
|
-
// passalong attach <
|
|
16
|
+
// passalong attach <file> upload a picture or a file and print the markdown that points at it
|
|
17
|
+
// passalong reply <id> <text> write to whoever holds it (--file PATH to attach); or leave a note
|
|
17
18
|
// passalong pull <id|link> fetch a guide into ./.passalong/ and print it
|
|
18
19
|
// passalong start <id|link> pull it and take it — one command for "I am doing this"
|
|
19
20
|
// passalong inbox guides handed to you that you have not pulled yet
|
|
@@ -69,8 +70,11 @@ function flags(argv) {
|
|
|
69
70
|
const t = argv[i];
|
|
70
71
|
if (t.startsWith("--")) {
|
|
71
72
|
const next = argv[i + 1];
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
const key = t.slice(2);
|
|
74
|
+
const value = next === undefined || next.startsWith("-") ? true : argv[++i];
|
|
75
|
+
// A flag given twice keeps both, so `--file a.pdf --file b.csv` is two files. Nothing else
|
|
76
|
+
// repeats a flag, so nothing else sees an array.
|
|
77
|
+
out[key] = key in out && value !== true ? [out[key], value].flat() : value;
|
|
74
78
|
} else out._.push(t);
|
|
75
79
|
}
|
|
76
80
|
return out;
|
|
@@ -386,12 +390,33 @@ const commands = {
|
|
|
386
390
|
* a guide travels as markdown to whoever holds its link, so a file beside it does not travel.
|
|
387
391
|
*/
|
|
388
392
|
async attach(f) {
|
|
389
|
-
const file = f._[0] || die("usage: passalong attach <
|
|
390
|
-
|
|
393
|
+
const file = f._[0] || die("usage: passalong attach <file> [--name LABEL]");
|
|
394
|
+
// A picture is evidence a page draws; anything else is a file somebody downloads.
|
|
395
|
+
const isImage = Object.keys(passalong.SHOT_TYPES).some((x) => file.toLowerCase().endsWith(x));
|
|
396
|
+
const shot = await (isImage ? passalong.attach : passalong.attachFile)(file, {
|
|
397
|
+
name: f.name || "",
|
|
398
|
+
}).catch((e) => die(e.message));
|
|
391
399
|
process.stdout.write(`${shot.markdown}\n`);
|
|
392
400
|
console.error(`uploaded ${shot.bytes} bytes → ${shot.url}`);
|
|
393
401
|
},
|
|
394
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Write to whoever holds a guide, or leave a note on one nobody holds yet. The person's side of
|
|
405
|
+
* `ask`: an agent that stopped to wait hears this when it takes the guide again.
|
|
406
|
+
*/
|
|
407
|
+
async reply(f) {
|
|
408
|
+
const id = f._[0] || die("usage: passalong reply <id> <message> [--file PATH ...]");
|
|
409
|
+
const files = [f.file].flat().filter((x) => typeof x === "string");
|
|
410
|
+
const said = f._.slice(1).join(" ");
|
|
411
|
+
if (!said && !files.length) die("say something, or attach a file: passalong reply <id> <message>");
|
|
412
|
+
const r = await passalong.reply(id, said, { files }).catch((e) => die(e.message));
|
|
413
|
+
console.error(
|
|
414
|
+
r?.noted
|
|
415
|
+
? `${id} — nobody holds it yet, so this is a note: whoever takes it is handed it first`
|
|
416
|
+
: `${id} — sent. Whoever holds it reads it the next time it checks in`,
|
|
417
|
+
);
|
|
418
|
+
},
|
|
419
|
+
|
|
395
420
|
async inbox() {
|
|
396
421
|
const rows = await passalong.inbox().catch((e) => die(e.message));
|
|
397
422
|
if (!rows.length) return console.error("inbox empty — nothing handed to you that you haven't pulled");
|
|
@@ -864,10 +889,16 @@ const commands = {
|
|
|
864
889
|
input = JSON.parse(raw || "{}");
|
|
865
890
|
}
|
|
866
891
|
if (!api.loggedIn()) return;
|
|
892
|
+
// The timeout is cleared as soon as the answer is in. Left running it holds the process open
|
|
893
|
+
// for the full five seconds after the hook has printed, and a session start or a stop that
|
|
894
|
+
// waits five seconds for nothing is the hook getting in the way, which it must never do.
|
|
895
|
+
let timer;
|
|
867
896
|
const r = await Promise.race([
|
|
868
897
|
passalong.now(),
|
|
869
|
-
new Promise((_, no) =>
|
|
870
|
-
|
|
898
|
+
new Promise((_, no) => {
|
|
899
|
+
timer = setTimeout(() => no(new Error("timeout")), 5000);
|
|
900
|
+
}),
|
|
901
|
+
]).finally(() => clearTimeout(timer));
|
|
871
902
|
if (f.hook === "session") {
|
|
872
903
|
const text = nowText(r);
|
|
873
904
|
if (text)
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "passalong",
|
|
3
|
-
"version": "0.13.
|
|
4
|
-
"description": "Hand
|
|
3
|
+
"version": "0.13.1",
|
|
4
|
+
"description": "Hand tasks, bugs and finished work to AI coding agents, and get back a write-up with evidence.",
|
|
5
|
+
"mcpName": "io.github.codellyson/passalong",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"mcp",
|
|
7
8
|
"claude-code",
|
|
@@ -9,7 +10,11 @@
|
|
|
9
10
|
"handoff",
|
|
10
11
|
"knowledge-transfer",
|
|
11
12
|
"cli",
|
|
12
|
-
"markdown"
|
|
13
|
+
"markdown",
|
|
14
|
+
"mcp-server",
|
|
15
|
+
"coding-agents",
|
|
16
|
+
"task-management",
|
|
17
|
+
"cursor"
|
|
13
18
|
],
|
|
14
19
|
"homepage": "https://passalong.dev",
|
|
15
20
|
"repository": {
|
package/skill/SKILL.md
CHANGED
|
@@ -98,11 +98,24 @@ pulls a guide, and steps that reproduce a defect are the one list that must neve
|
|
|
98
98
|
remedy — an agent that follows them reproduces the bug, checks the Verification, finds it false
|
|
99
99
|
because the bug is real, and reports that the guide does not work.
|
|
100
100
|
|
|
101
|
+
## Say it to a person
|
|
102
|
+
|
|
103
|
+
Everything below is written for another agent: exact, dense, full of ids and paths. A person reading
|
|
104
|
+
their hub wants two sentences. So every guide carries a `summary:` in its frontmatter — plain
|
|
105
|
+
language, 400 characters at most, no ids, paths or jargon, saying what this is and whether anybody
|
|
106
|
+
has to do anything. It is what they see first; your document is behind it. A guide with no summary is
|
|
107
|
+
refused.
|
|
108
|
+
|
|
109
|
+
Write it the way you would tell a colleague in the corridor: "Webhook signatures are now checked
|
|
110
|
+
before anything is stored. Done and working; nothing to do." Not: "Implemented HMAC-SHA512 verify in
|
|
111
|
+
billing.ts per the Paystack spec, see PR 412."
|
|
112
|
+
|
|
101
113
|
## Guide structure
|
|
102
114
|
|
|
103
115
|
```markdown
|
|
104
116
|
---
|
|
105
117
|
title: <what this accomplishes, as a verb phrase: "Add Paystack webhook verification">
|
|
118
|
+
summary: <one or two plain sentences for a PERSON, 400 characters at most, no ids or paths: what this is and whether anyone has to act>
|
|
106
119
|
author: <git user.name, if known>
|
|
107
120
|
source_context: <repo or product this came from>
|
|
108
121
|
status: published
|
|
@@ -135,6 +148,7 @@ Commands to run and what they should print. Test cases. What "done" looks like.
|
|
|
135
148
|
```markdown
|
|
136
149
|
---
|
|
137
150
|
title: <what is broken, in one line: "Undo leaves section drag handles dead">
|
|
151
|
+
summary: <one or two plain sentences for a PERSON: what is broken and whether anyone has to act>
|
|
138
152
|
kind: bug
|
|
139
153
|
severity: <s1 blocker | s2 major | s3 minor | s4 cosmetic>
|
|
140
154
|
area: <which surface it is on>
|
package/src/api.js
CHANGED
|
@@ -216,6 +216,10 @@ export const progress = (id, body) =>
|
|
|
216
216
|
call(`/v1/guides/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
|
|
217
217
|
export const handIn = (id, body) =>
|
|
218
218
|
call(`/v1/guides/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
|
|
219
|
+
export const ask = (id, body) =>
|
|
220
|
+
call(`/v1/guides/${encodeURIComponent(id)}/ask`, { method: "POST", body });
|
|
221
|
+
export const reply = (id, body) =>
|
|
222
|
+
call(`/v1/guides/${encodeURIComponent(id)}/reply`, { method: "POST", body: { body } });
|
|
219
223
|
export const pass = (id, body) =>
|
|
220
224
|
call(`/v1/guides/${encodeURIComponent(id)}/pass`, { method: "POST", body });
|
|
221
225
|
export const ack = (id, taken, note = "") =>
|
|
@@ -228,6 +232,36 @@ export const ack = (id, taken, note = "") =>
|
|
|
228
232
|
* the content type as the declaration of what they are. `name` is the label, and it travels in a
|
|
229
233
|
* header rather than the body for the same reason.
|
|
230
234
|
*/
|
|
235
|
+
/**
|
|
236
|
+
* Upload a file that is not a picture, and get back what the server made of it. The server decides
|
|
237
|
+
* what the bytes are and refuses what it does not keep; `type` and `name` are hints. The name travels
|
|
238
|
+
* percent-encoded because a header cannot hold what a filename can.
|
|
239
|
+
*/
|
|
240
|
+
export async function uploadFile(bytes, type, name = "") {
|
|
241
|
+
const t = token();
|
|
242
|
+
if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
|
|
243
|
+
const headers = {
|
|
244
|
+
authorization: `Bearer ${t}`,
|
|
245
|
+
"content-type": type || "application/octet-stream",
|
|
246
|
+
};
|
|
247
|
+
if (name) headers["x-file-name"] = encodeURIComponent(String(name));
|
|
248
|
+
let res;
|
|
249
|
+
try {
|
|
250
|
+
res = await fetch(`${baseUrl()}/v1/attachments`, { method: "POST", headers, body: bytes });
|
|
251
|
+
} catch (err) {
|
|
252
|
+
throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
|
|
253
|
+
}
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
const body = await res.text();
|
|
256
|
+
let message = body;
|
|
257
|
+
try {
|
|
258
|
+
message = JSON.parse(body).message || body;
|
|
259
|
+
} catch {}
|
|
260
|
+
throw new ApiError(res.status, message || res.statusText);
|
|
261
|
+
}
|
|
262
|
+
return (await res.json()).attachment;
|
|
263
|
+
}
|
|
264
|
+
|
|
231
265
|
export async function uploadShot(bytes, type, name = "") {
|
|
232
266
|
const t = token();
|
|
233
267
|
if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
|
package/src/capture.js
CHANGED
|
@@ -65,6 +65,8 @@ export function taskScaffold(sentence, cwd = process.cwd()) {
|
|
|
65
65
|
const md = template({
|
|
66
66
|
kind: "task",
|
|
67
67
|
title: sentence,
|
|
68
|
+
// The person typed this sentence, so it is theirs to say to a person as well as to name the task.
|
|
69
|
+
summary: String(sentence).slice(0, 400),
|
|
68
70
|
author: c.author,
|
|
69
71
|
source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
|
|
70
72
|
target_context: c.repo,
|
package/src/checks.js
CHANGED
|
@@ -76,7 +76,10 @@ function tail(out, max = OUTPUT_MAX) {
|
|
|
76
76
|
*/
|
|
77
77
|
export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT_MS } = {}) {
|
|
78
78
|
const cmd = String(check?.cmd ?? "").trim();
|
|
79
|
-
|
|
79
|
+
// What the agent says happened, in plain words: the person's line of the table. It is the agent's
|
|
80
|
+
// account and a run never touches it, so it rides along on every shape this returns.
|
|
81
|
+
const says = check?.says ? { says: check.says } : {};
|
|
82
|
+
if (!cmd) return { check: check.check, ran: check.ran, ...says };
|
|
80
83
|
|
|
81
84
|
const opts = {
|
|
82
85
|
cwd,
|
|
@@ -99,6 +102,7 @@ export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT
|
|
|
99
102
|
: `could not run: ${r.error?.message ?? "unknown error"}`;
|
|
100
103
|
return {
|
|
101
104
|
check: check.check,
|
|
105
|
+
...says,
|
|
102
106
|
cmd,
|
|
103
107
|
exit: null,
|
|
104
108
|
ok: false,
|
|
@@ -109,6 +113,7 @@ export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT
|
|
|
109
113
|
const cutShort = Boolean(BASH) && r.status === SIGPIPE;
|
|
110
114
|
return {
|
|
111
115
|
check: check.check,
|
|
116
|
+
...says,
|
|
112
117
|
cmd,
|
|
113
118
|
exit: r.status,
|
|
114
119
|
ok: r.status === 0 || cutShort,
|
package/src/guide.js
CHANGED
|
@@ -50,6 +50,19 @@ export const SETTABLE = ["draft", "published", "consumed"];
|
|
|
50
50
|
* exactly as ambiguous as it was and adds a second state — a guide that exists and cannot be
|
|
51
51
|
* shared — where the point was to have fewer.
|
|
52
52
|
*/
|
|
53
|
+
/**
|
|
54
|
+
* What a guide says to a person, in a sentence or two, and the longest that may be.
|
|
55
|
+
*
|
|
56
|
+
* Everything an agent writes is written for another agent: exact, dense, full of ids and paths. A
|
|
57
|
+
* person scanning their work wants "what is this, and do I need to do anything", so every guide
|
|
58
|
+
* carries both — the document is the agent's form, and `summary:` is the human one, shown by default
|
|
59
|
+
* in the hub with the document behind "Show details".
|
|
60
|
+
*
|
|
61
|
+
* Said by the author and refused when absent, like `kind`, for the same reason: a summary written
|
|
62
|
+
* afterwards by something that was not there is a second voice that can say what nobody claimed.
|
|
63
|
+
*/
|
|
64
|
+
export const SUMMARY_MAX = 400;
|
|
65
|
+
|
|
53
66
|
export const KINDS = ["transfer", "bug", "task"];
|
|
54
67
|
|
|
55
68
|
// Body sections in the order a guide should present them. The heading text is what the
|
|
@@ -330,6 +343,9 @@ function quote(v) {
|
|
|
330
343
|
const META_ORDER = [
|
|
331
344
|
"id",
|
|
332
345
|
"title",
|
|
346
|
+
// Right under the title: it is what a person reads first about it, so it is what the document
|
|
347
|
+
// opens with.
|
|
348
|
+
"summary",
|
|
333
349
|
"kind",
|
|
334
350
|
"created",
|
|
335
351
|
"author",
|
|
@@ -447,6 +463,14 @@ export function validate({ meta, body }) {
|
|
|
447
463
|
// a kind — `parseFrontmatter` seeds it — so what reaches this without one was built field by
|
|
448
464
|
// field in code, and code is exactly what should have to say what it is making.
|
|
449
465
|
const kind = meta.kind;
|
|
466
|
+
const said = String(meta.summary ?? "").trim();
|
|
467
|
+
if (!said)
|
|
468
|
+
errors.push(
|
|
469
|
+
"say it to a person: add `summary:` — one or two plain sentences, no ids or paths, saying what " +
|
|
470
|
+
`this is and whether anybody needs to act (${SUMMARY_MAX} characters at most)`,
|
|
471
|
+
);
|
|
472
|
+
else if (said.length > SUMMARY_MAX)
|
|
473
|
+
errors.push(`summary is ${said.length} characters; ${SUMMARY_MAX} at most`);
|
|
450
474
|
if (!kind) errors.push(`say what this is: kind must be one of ${KINDS.join(", ")}`);
|
|
451
475
|
else if (!KINDS.includes(kind)) errors.push(`kind must be one of ${KINDS.join(", ")}`);
|
|
452
476
|
const have = sections(body);
|
|
@@ -489,6 +513,10 @@ export function stamp(guide, defaults = {}) {
|
|
|
489
513
|
*/
|
|
490
514
|
export function bugGuide({
|
|
491
515
|
title = "",
|
|
516
|
+
// What a person reads about it. A bug's own title is that when nothing else was said, and it is
|
|
517
|
+
// the author's own words, stated here by the caller that knows it is building a bug — not a
|
|
518
|
+
// default for something an author left off.
|
|
519
|
+
summary = "",
|
|
492
520
|
problem = "",
|
|
493
521
|
reproduce = "",
|
|
494
522
|
verification = "",
|
|
@@ -544,6 +572,7 @@ export function bugGuide({
|
|
|
544
572
|
|
|
545
573
|
const meta = {
|
|
546
574
|
title,
|
|
575
|
+
summary: String(summary || "").trim() || title,
|
|
547
576
|
kind: "bug",
|
|
548
577
|
status,
|
|
549
578
|
tags: ["bug", environment, area].filter(Boolean),
|
|
@@ -584,6 +613,7 @@ export function template(meta = {}) {
|
|
|
584
613
|
return serialize({
|
|
585
614
|
meta: {
|
|
586
615
|
title: "",
|
|
616
|
+
summary: "",
|
|
587
617
|
author: "",
|
|
588
618
|
source_context: "",
|
|
589
619
|
status: "draft",
|
|
@@ -620,6 +650,7 @@ function bugTemplate(meta = {}) {
|
|
|
620
650
|
return serialize({
|
|
621
651
|
meta: {
|
|
622
652
|
title: "",
|
|
653
|
+
summary: "",
|
|
623
654
|
kind: "bug",
|
|
624
655
|
author: "",
|
|
625
656
|
source_context: "",
|
|
@@ -660,6 +691,7 @@ function taskTemplate(meta = {}) {
|
|
|
660
691
|
return serialize({
|
|
661
692
|
meta: {
|
|
662
693
|
title: "",
|
|
694
|
+
summary: "",
|
|
663
695
|
kind: "task",
|
|
664
696
|
author: "",
|
|
665
697
|
source_context: "",
|
package/src/hooks.js
CHANGED
|
@@ -30,6 +30,13 @@ export function nowText({ held, waiting = {} }) {
|
|
|
30
30
|
`Passalong: You hold ${held.id} (${held.kind}): ${held.title || held.id}.`,
|
|
31
31
|
];
|
|
32
32
|
if (held.note) lines.push(`Last note: "${held.note}".`);
|
|
33
|
+
// The one thing here that is new information rather than a reminder: the person has written, and
|
|
34
|
+
// nothing else in the session will say so until the agent next checks in.
|
|
35
|
+
if (held.replies)
|
|
36
|
+
lines.push(
|
|
37
|
+
`${plural(held.replies, "message", "messages")} from the person ${held.replies === 1 ? "is" : "are"} ` +
|
|
38
|
+
`waiting for you: call take ${held.id} to read ${held.replies === 1 ? "it" : "them"} before anything else.`,
|
|
39
|
+
);
|
|
33
40
|
if (held.state === "stalled")
|
|
34
41
|
lines.push("It is marked stalled: nobody has heard from its agent in 30 minutes.");
|
|
35
42
|
lines.push(
|
|
@@ -55,7 +62,9 @@ export function nowText({ held, waiting = {} }) {
|
|
|
55
62
|
* would leave it no way to end.
|
|
56
63
|
*/
|
|
57
64
|
export function stopVerdict({ held, input = {} }) {
|
|
58
|
-
|
|
65
|
+
// An agent that asked a question was told to stop and wait, so it must be let: blocking it would
|
|
66
|
+
// make the instruction and the hook contradict each other, and the agent would pick one.
|
|
67
|
+
if (!held || held.asking || input.stop_hook_active) return null;
|
|
59
68
|
return {
|
|
60
69
|
decision: "block",
|
|
61
70
|
reason:
|
package/src/mcp.js
CHANGED
|
@@ -226,6 +226,21 @@ export function nextNote({ next = [], say = "" } = {}, id = "") {
|
|
|
226
226
|
return lines.length ? `<!-- passalong: next:\n${lines.join("\n")}\n-->` : "";
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* What the person said to this agent since it last heard, in front of everything else: a reply is
|
|
231
|
+
* the thing the agent stopped for, and burying it under the guide it already knows is how it gets
|
|
232
|
+
* missed. See claims.deliver() in apps/api/src/claims.ts.
|
|
233
|
+
*/
|
|
234
|
+
export function repliesNote(replies = []) {
|
|
235
|
+
if (!Array.isArray(replies) || !replies.length) return "";
|
|
236
|
+
const lines = replies.map((r) => ` - ${String(r.body).replace(/\n/g, "\n ")}`);
|
|
237
|
+
const files = replies.some((r) => String(r.body).includes("/v1/attachments/"))
|
|
238
|
+
? "\n Attached files are private: fetch one with your passalong token, e.g. " +
|
|
239
|
+
'curl -H "authorization: Bearer $PASSALONG_TOKEN (or the token in ~/.passalong/config.json)" -o <name> <url>.'
|
|
240
|
+
: "";
|
|
241
|
+
return `<!-- passalong: the person replied:\n${lines.join("\n")}${files}\n-->`;
|
|
242
|
+
}
|
|
243
|
+
|
|
229
244
|
/** A refusal that carries the server's own next move, when it sent one. */
|
|
230
245
|
const failWith = (err) => {
|
|
231
246
|
const said = nextNote(err.body || {});
|
|
@@ -252,7 +267,9 @@ export function buildServer() {
|
|
|
252
267
|
"returns it; nobody else can take it here while you hold it, and its sender sees you are " +
|
|
253
268
|
"on it. progress, with a one-line note at each milestone — 30 minutes of silence marks it " +
|
|
254
269
|
"stalled. hand_in when done here. pass, with the reason, when it is not yours or you are " +
|
|
255
|
-
"stuck.
|
|
270
|
+
"stuck. ask, with a question, when you need an answer from the person: you keep what you " +
|
|
271
|
+
"hold, stop and wait, and take it again once they have replied. Every answer ends with " +
|
|
272
|
+
"`next`: what to call now. Follow it, and when it says to " +
|
|
256
273
|
"stop, stop. OPEN A GUIDE YOU MEAN TO ACT ON WITH take, NOT get_guide: get_guide only " +
|
|
257
274
|
"reads, and the sender learns nothing. If take says somebody else has it, tell the user " +
|
|
258
275
|
"instead of doing the work twice.\n" +
|
|
@@ -261,6 +278,11 @@ export function buildServer() {
|
|
|
261
278
|
"Collect it as you work rather than writing it from memory at the end. hand_in without " +
|
|
262
279
|
"it is refused, because the write-up and the verdict are both your word for your own " +
|
|
263
280
|
"work and evidence is the part the person reviewing it can check.\n" +
|
|
281
|
+
"EVERY GUIDE SAYS WHAT IT IS TO A PERSON. The document is for agents: exact, dense, full of " +
|
|
282
|
+
"ids and paths. Put `summary:` in its frontmatter — one or two plain sentences, 400 " +
|
|
283
|
+
"characters at most, no ids, paths or jargon, saying what this is and whether anybody has " +
|
|
284
|
+
"to act. It is what the person sees first in their hub; the document is behind it. A guide " +
|
|
285
|
+
"with no summary is refused.\n" +
|
|
264
286
|
"SAY WHICH KIND IT IS. There is no default: a guide with no `kind:` line is refused, and so " +
|
|
265
287
|
"is a spelling that is not one of the three. " +
|
|
266
288
|
`kind: task is work nobody has done yet. Sections: ${TASK_SECTIONS.join(", ")}. ` +
|
|
@@ -331,8 +353,13 @@ export function buildServer() {
|
|
|
331
353
|
const note = nextNote({ next, say }, id);
|
|
332
354
|
// The note is what the agent acts on and it is prose, so it stays in the text half only. The
|
|
333
355
|
// structured half is the answer itself, which is what `outputSchema` below describes.
|
|
356
|
+
// A reply goes first, in prose: it is what the agent stopped for, and inside the JSON it reads
|
|
357
|
+
// as one more field.
|
|
358
|
+
const said = repliesNote(rest.replies);
|
|
334
359
|
return {
|
|
335
|
-
...text(
|
|
360
|
+
...text(
|
|
361
|
+
`${said ? `${said}\n` : ""}${JSON.stringify(rest, null, 2)}${note ? `\n${note}` : ""}`,
|
|
362
|
+
),
|
|
336
363
|
structuredContent: rest,
|
|
337
364
|
};
|
|
338
365
|
};
|
|
@@ -359,8 +386,9 @@ export function buildServer() {
|
|
|
359
386
|
`<!-- passalong: ${r.guide.id} is yours` +
|
|
360
387
|
`${r.guide.resumed ? " (you already held it — carry on from where it was left)" : ""}; ` +
|
|
361
388
|
`written to ${r.path}. -->`;
|
|
389
|
+
const said = repliesNote(r.replies);
|
|
362
390
|
return text(
|
|
363
|
-
`${from}${leadFor(meta)}${r.guide.markdown}${siblings}${context ? `\n\n${context}` : ""}` +
|
|
391
|
+
`${said ? `${said}\n\n` : ""}${from}${leadFor(meta)}${r.guide.markdown}${siblings}${context ? `\n\n${context}` : ""}` +
|
|
364
392
|
`\n\n${held}\n${nextNote(r, r.guide.id)}\n${passalong.followUpNote(meta)}`,
|
|
365
393
|
);
|
|
366
394
|
} catch (err) {
|
|
@@ -428,6 +456,14 @@ export function buildServer() {
|
|
|
428
456
|
}
|
|
429
457
|
}
|
|
430
458
|
|
|
459
|
+
async function doAsk({ id, question, cwd }) {
|
|
460
|
+
try {
|
|
461
|
+
return answer(await passalong.ask(id, question, { cwd: cwd || process.cwd() }), id);
|
|
462
|
+
} catch (err) {
|
|
463
|
+
return failWith(err);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
431
467
|
async function doPass({ id, why, cwd }) {
|
|
432
468
|
try {
|
|
433
469
|
return answer(await passalong.pass(id, why, { cwd: cwd || process.cwd() }), id);
|
|
@@ -623,6 +659,12 @@ export function buildServer() {
|
|
|
623
659
|
.array(
|
|
624
660
|
z.object({
|
|
625
661
|
title: z.string(),
|
|
662
|
+
summary: z
|
|
663
|
+
.string()
|
|
664
|
+
.describe(
|
|
665
|
+
"one or two plain sentences for a PERSON, no ids or paths: what this task is and " +
|
|
666
|
+
"why it matters. The goal below is for the agent; this is what a person reads",
|
|
667
|
+
),
|
|
626
668
|
goal: z.string().describe("what is true when this step is done"),
|
|
627
669
|
acceptance: z.string().describe("checks a person can run, one per line"),
|
|
628
670
|
context: z.string().optional(),
|
|
@@ -694,7 +736,14 @@ export function buildServer() {
|
|
|
694
736
|
"without one marks it stalled. If the answer says you no longer hold it, stop.",
|
|
695
737
|
inputSchema: {
|
|
696
738
|
id: z.string().describe("the id of what you hold"),
|
|
697
|
-
note: z
|
|
739
|
+
note: z
|
|
740
|
+
.string()
|
|
741
|
+
.optional()
|
|
742
|
+
.describe(
|
|
743
|
+
'one plain sentence in the first person, as you would tell the person: "Reading ' +
|
|
744
|
+
'the settings page to find where the toggle goes." Start it with BLOCKED: when ' +
|
|
745
|
+
"you are stopped and need an answer",
|
|
746
|
+
),
|
|
698
747
|
cwd: z
|
|
699
748
|
.string()
|
|
700
749
|
.optional()
|
|
@@ -745,6 +794,15 @@ export function buildServer() {
|
|
|
745
794
|
"the line this answers, in the guide's own words: an Acceptance line on a task, " +
|
|
746
795
|
"a Verification line on a handoff or a bug",
|
|
747
796
|
),
|
|
797
|
+
says: z
|
|
798
|
+
.string()
|
|
799
|
+
.optional()
|
|
800
|
+
.describe(
|
|
801
|
+
'what happened, in one plain sentence for a PERSON: "Swagger lists all six ' +
|
|
802
|
+
'endpoints, each with request and response schemas." It is the row they read ' +
|
|
803
|
+
"in the review table; the evidence stays folded behind it. Strongly " +
|
|
804
|
+
"recommended: a check without one shows only the line it answers",
|
|
805
|
+
),
|
|
748
806
|
ran: z
|
|
749
807
|
.string()
|
|
750
808
|
.optional()
|
|
@@ -768,7 +826,13 @@ export function buildServer() {
|
|
|
768
826
|
"running it and is refused",
|
|
769
827
|
),
|
|
770
828
|
ok: z.boolean().optional().describe("handoff or bug: did its Verification hold"),
|
|
771
|
-
note: z
|
|
829
|
+
note: z
|
|
830
|
+
.string()
|
|
831
|
+
.describe(
|
|
832
|
+
"one plain sentence, in the first person, saying what you did and how it went: " +
|
|
833
|
+
'"Added the toggle to Settings; it survives a reload." It is the first thing the ' +
|
|
834
|
+
"person reads, and the evidence is behind it. Say what went wrong when ok is false",
|
|
835
|
+
),
|
|
772
836
|
writeup: z
|
|
773
837
|
.string()
|
|
774
838
|
.optional()
|
|
@@ -805,6 +869,37 @@ export function buildServer() {
|
|
|
805
869
|
async (args) => doHandIn(args),
|
|
806
870
|
);
|
|
807
871
|
|
|
872
|
+
server.registerTool(
|
|
873
|
+
"ask",
|
|
874
|
+
{
|
|
875
|
+
title: "Ask the person",
|
|
876
|
+
outputSchema: openObject({ id: z.string(), lease_until: z.string() }),
|
|
877
|
+
annotations: ADDS,
|
|
878
|
+
description:
|
|
879
|
+
"You need the person's answer before you can go on. Ask one clear question, with the " +
|
|
880
|
+
"context it needs to be answered without opening your terminal. You keep what you hold " +
|
|
881
|
+
"and nobody else can take it. Then STOP and tell the user you are waiting: do not carry " +
|
|
882
|
+
"on guessing. When they have replied, call take with this id and their reply comes back " +
|
|
883
|
+
"with it. Use `pass` instead when the work is not yours; a progress note starting " +
|
|
884
|
+
"BLOCKED: is the older way of saying this, and is not heard as a question.",
|
|
885
|
+
inputSchema: {
|
|
886
|
+
id: z.string().describe("passalong id"),
|
|
887
|
+
question: z
|
|
888
|
+
.string()
|
|
889
|
+
.describe(
|
|
890
|
+
"one clear question in the first person, a few sentences at most, plain text: " +
|
|
891
|
+
'"Should the dark-mode toggle live in Settings or the header? The header is ' +
|
|
892
|
+
'crowded on mobile." What you were about to do, if they just say yes, helps',
|
|
893
|
+
),
|
|
894
|
+
cwd: z
|
|
895
|
+
.string()
|
|
896
|
+
.optional()
|
|
897
|
+
.describe("the worktree you are working in; default is the server's cwd"),
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
async (args) => doAsk(args),
|
|
901
|
+
);
|
|
902
|
+
|
|
808
903
|
server.registerTool(
|
|
809
904
|
"pass",
|
|
810
905
|
{
|
|
@@ -930,6 +1025,72 @@ export function buildServer() {
|
|
|
930
1025
|
},
|
|
931
1026
|
);
|
|
932
1027
|
|
|
1028
|
+
server.registerTool(
|
|
1029
|
+
"attach_file",
|
|
1030
|
+
{
|
|
1031
|
+
title: "Attach a file",
|
|
1032
|
+
annotations: { ...ADDS, openWorldHint: true },
|
|
1033
|
+
description:
|
|
1034
|
+
"Upload a file that is not a picture — a log, a PDF, a CSV, a zip, up to 10MB — and get " +
|
|
1035
|
+
"back the markdown line that points at it. For a guide body or a bug report: put the " +
|
|
1036
|
+
"line there and publishing claims the file. It is private, so only people who can read " +
|
|
1037
|
+
"that guide can download it; for something a reviewer must SEE, use attach_screenshot. " +
|
|
1038
|
+
"Pictures belong to attach_screenshot, not here.",
|
|
1039
|
+
inputSchema: {
|
|
1040
|
+
file: z.string().describe("path to the file on this machine"),
|
|
1041
|
+
name: z.string().default("").describe("label for the file; defaults to its filename"),
|
|
1042
|
+
},
|
|
1043
|
+
},
|
|
1044
|
+
async ({ file, name }) => {
|
|
1045
|
+
try {
|
|
1046
|
+
const got = await passalong.attachFile(file, { name: name || "" });
|
|
1047
|
+
return text(
|
|
1048
|
+
`${JSON.stringify({ id: got.id, url: got.url, bytes: got.bytes }, null, 2)}\n\n` +
|
|
1049
|
+
`Put this in the guide body:\n${got.markdown}`,
|
|
1050
|
+
);
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
return fail(err);
|
|
1053
|
+
}
|
|
1054
|
+
},
|
|
1055
|
+
);
|
|
1056
|
+
|
|
1057
|
+
server.registerTool(
|
|
1058
|
+
"reply",
|
|
1059
|
+
{
|
|
1060
|
+
title: "Write to the agent",
|
|
1061
|
+
annotations: ADDS,
|
|
1062
|
+
description:
|
|
1063
|
+
"A person's tool, for someone driving their work from an assistant: write to whoever " +
|
|
1064
|
+
"holds a guide — an answer to a question it asked, or something you thought of since — " +
|
|
1065
|
+
"or, when nobody holds it yet and it is yours, leave a note for whoever takes it. Plain " +
|
|
1066
|
+
"text, 1000 characters. `files` are paths on this machine: a picture is drawn in the " +
|
|
1067
|
+
"thread and anything else is a file to download. Only its author, whoever it is " +
|
|
1068
|
+
"assigned to, or whoever holds it can write; the agent reads it the next time it " +
|
|
1069
|
+
"checks in. Agents answering their own questions do not use this: they use ask and take.",
|
|
1070
|
+
inputSchema: {
|
|
1071
|
+
id: z.string().describe("passalong id"),
|
|
1072
|
+
text: z.string().default("").describe("what to say; may be empty when attaching a file"),
|
|
1073
|
+
files: z
|
|
1074
|
+
.array(z.string())
|
|
1075
|
+
.max(3)
|
|
1076
|
+
.default([])
|
|
1077
|
+
.describe("paths to up to three files or pictures to attach"),
|
|
1078
|
+
},
|
|
1079
|
+
},
|
|
1080
|
+
async ({ id, text: said, files }) => {
|
|
1081
|
+
try {
|
|
1082
|
+
const r = await passalong.reply(id, said, { files });
|
|
1083
|
+
return text(
|
|
1084
|
+
r?.noted
|
|
1085
|
+
? `Nobody holds ${id} yet, so this is a note: whoever takes it is handed it first.`
|
|
1086
|
+
: `Sent to whoever holds ${id}. It reads this the next time it checks in.`,
|
|
1087
|
+
);
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
return failWith(err);
|
|
1090
|
+
}
|
|
1091
|
+
},
|
|
1092
|
+
);
|
|
1093
|
+
|
|
933
1094
|
server.registerTool(
|
|
934
1095
|
"publish_guide",
|
|
935
1096
|
{
|
|
@@ -1036,6 +1197,13 @@ export function buildServer() {
|
|
|
1036
1197
|
"they go under Problem, where a reader looks first",
|
|
1037
1198
|
),
|
|
1038
1199
|
title: z.string().describe("what is broken, in one line"),
|
|
1200
|
+
summary: z
|
|
1201
|
+
.string()
|
|
1202
|
+
.optional()
|
|
1203
|
+
.describe(
|
|
1204
|
+
"one or two plain sentences for a PERSON, no ids or paths: what is broken and " +
|
|
1205
|
+
"whether anyone needs to act. Defaults to the title",
|
|
1206
|
+
),
|
|
1039
1207
|
problem: z
|
|
1040
1208
|
.string()
|
|
1041
1209
|
.describe(
|
package/src/passalong.js
CHANGED
|
@@ -481,6 +481,9 @@ export async function handIn(
|
|
|
481
481
|
checks: checks.map((c) => ({
|
|
482
482
|
check: c.check,
|
|
483
483
|
ran: c.ran,
|
|
484
|
+
// What the agent says happened, in plain words, for the person's table. Left out when
|
|
485
|
+
// not said: the row then shows the line that was asked and keeps its run folded.
|
|
486
|
+
...(c.says ? { says: c.says } : {}),
|
|
484
487
|
// Only present on a check that was run. The server keeps them apart the same way.
|
|
485
488
|
...(verified(c) ? { cmd: c.cmd, exit: c.exit, ok: c.ok } : {}),
|
|
486
489
|
})),
|
|
@@ -524,6 +527,23 @@ export async function pass(id, why, { cwd = process.cwd() } = {}) {
|
|
|
524
527
|
});
|
|
525
528
|
}
|
|
526
529
|
|
|
530
|
+
/**
|
|
531
|
+
* Ask the person a question and wait, keeping what you hold. Not `pass`, which gives the work back,
|
|
532
|
+
* and not `progress`, which says carry on: the answer to this one is "stop, and take it again when
|
|
533
|
+
* they have replied", and the reply comes back with that `take`.
|
|
534
|
+
*/
|
|
535
|
+
export async function ask(id, question, { cwd = process.cwd() } = {}) {
|
|
536
|
+
needsSync("asking");
|
|
537
|
+
if (!String(question || "").trim())
|
|
538
|
+
throw new PassalongError("say what you need to know, so the person can answer it");
|
|
539
|
+
const fence = fenceFor(id, cwd);
|
|
540
|
+
return api.ask(id, {
|
|
541
|
+
agent: agent(cwd).agent,
|
|
542
|
+
question,
|
|
543
|
+
...(fence === undefined ? {} : { fence }),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
527
547
|
/**
|
|
528
548
|
* What this worktree's agent holds right now, and what is waiting for it: what the session-start
|
|
529
549
|
* and stop hooks read (src/hooks.js). `held` is null when it holds nothing. Counts are best effort
|
|
@@ -555,7 +575,7 @@ export async function now({ cwd = process.cwd() } = {}) {
|
|
|
555
575
|
x.mine &&
|
|
556
576
|
(x.state === "review" ||
|
|
557
577
|
x.state === "stalled" ||
|
|
558
|
-
(x.state === "claimed" && /^BLOCKED:/i.test(x.claim?.note || ""))),
|
|
578
|
+
(x.state === "claimed" && (x.claim?.asking || /^BLOCKED:/i.test(x.claim?.note || "")))),
|
|
559
579
|
).length;
|
|
560
580
|
const handed = h.status === "fulfilled" ? h.value.handed_in.length : 0;
|
|
561
581
|
return { held, waiting, needs: review + handed + waiting.inbox, agent: who.agent };
|
|
@@ -731,6 +751,64 @@ export async function attach(file, { name = "" } = {}) {
|
|
|
731
751
|
return { ...shot, markdown: `` };
|
|
732
752
|
}
|
|
733
753
|
|
|
754
|
+
/**
|
|
755
|
+
* What a file's extension suggests it is. A hint only: the server reads the bytes and decides, and
|
|
756
|
+
* refuses what it does not keep. It exists so a `.csv` is filed as CSV and not as generic text.
|
|
757
|
+
*/
|
|
758
|
+
const FILE_HINTS = {
|
|
759
|
+
".pdf": "application/pdf",
|
|
760
|
+
".zip": "application/zip",
|
|
761
|
+
".txt": "text/plain",
|
|
762
|
+
".log": "text/plain",
|
|
763
|
+
".csv": "text/csv",
|
|
764
|
+
".json": "application/json",
|
|
765
|
+
".md": "text/markdown",
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
/** The most the server keeps of one file. Mirrors ATTACH_MAX in apps/api/src/attachments.ts. */
|
|
769
|
+
const FILE_MAX = 10 * 1024 * 1024;
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Attach a file that is not a picture — a log, a PDF, a CSV, a zip — and return the markdown line that
|
|
773
|
+
* points at it. For a guide body or a bug report, where an agent has the file and nothing else to
|
|
774
|
+
* show it with. It is private: whoever can read the guide can download it, and nobody else can, so
|
|
775
|
+
* it is not evidence on a page that travels by link.
|
|
776
|
+
*/
|
|
777
|
+
export async function attachFile(file, { name = "" } = {}) {
|
|
778
|
+
if (!api.loggedIn())
|
|
779
|
+
throw new PassalongError("attaching a file needs sync — run `passalong login` first");
|
|
780
|
+
if (!existsSync(file)) throw new PassalongError(`no file at ${file}`);
|
|
781
|
+
const bytes = readFileSync(file);
|
|
782
|
+
if (!bytes.length) throw new PassalongError(`${file} is empty`);
|
|
783
|
+
if (bytes.length > FILE_MAX)
|
|
784
|
+
throw new PassalongError(
|
|
785
|
+
`${file} is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 10MB — send a link instead`,
|
|
786
|
+
);
|
|
787
|
+
const ext = file.includes(".") ? file.slice(file.lastIndexOf(".")).toLowerCase() : "";
|
|
788
|
+
const label = (name || basename(file)).replace(/[[\]()]/g, "");
|
|
789
|
+
const attachment = await api.uploadFile(bytes, FILE_HINTS[ext] || "", name || basename(file));
|
|
790
|
+
return { ...attachment, markdown: `[${label}](${attachment.url})` };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Write to whoever holds a guide, or leave a note on one nobody holds yet: an answer to a question,
|
|
795
|
+
* or something thought of since. `files` are paths — a picture is uploaded as one and drawn in the
|
|
796
|
+
* thread, anything else as a file to download — and each becomes a line of the message. The server
|
|
797
|
+
* decides who may write and whether anyone is listening, and says so in words.
|
|
798
|
+
*/
|
|
799
|
+
export async function reply(ref, text, { files = [] } = {}) {
|
|
800
|
+
needsSync("replying");
|
|
801
|
+
const lines = [];
|
|
802
|
+
for (const file of files) {
|
|
803
|
+
const ext = file.includes(".") ? file.slice(file.lastIndexOf(".")).toLowerCase() : "";
|
|
804
|
+
const got = SHOT_TYPES[ext] ? await attach(file) : await attachFile(file);
|
|
805
|
+
lines.push(got.markdown);
|
|
806
|
+
}
|
|
807
|
+
const body = [String(text || "").trim(), ...lines].filter(Boolean).join("\n");
|
|
808
|
+
if (!body) throw new PassalongError("say something, or attach a file");
|
|
809
|
+
return api.reply(await resolveId(ref), body);
|
|
810
|
+
}
|
|
811
|
+
|
|
734
812
|
/**
|
|
735
813
|
* The line a guide arrives with, saying what a follow-up is and how to write one.
|
|
736
814
|
*
|
|
@@ -1044,7 +1122,14 @@ export async function planTasks(steps, { cwd = process.cwd() } = {}) {
|
|
|
1044
1122
|
...section("Acceptance", step.acceptance),
|
|
1045
1123
|
...section("Out of scope", step.out_of_scope),
|
|
1046
1124
|
].join("\n");
|
|
1047
|
-
|
|
1125
|
+
// The summary is the step's own, said by whoever planned it: it is what a person reads about the
|
|
1126
|
+
// task, and this builds the document without guessing one (the server refuses a task without).
|
|
1127
|
+
const meta = {
|
|
1128
|
+
title: step.title,
|
|
1129
|
+
summary: step.summary,
|
|
1130
|
+
kind: "task",
|
|
1131
|
+
target_context: step.target_context ?? repo,
|
|
1132
|
+
};
|
|
1048
1133
|
const after = (step.after || []).map((j) => ids[j]);
|
|
1049
1134
|
if (after.length) meta.blocked_by = after;
|
|
1050
1135
|
ids.push((await share(serialize({ meta, body }), { cwd })).guide.meta.id);
|