passalong 0.12.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 +12 -8
- package/bin/passalong +45 -11
- package/package.json +8 -3
- package/skill/SKILL.md +15 -1
- package/src/api.js +34 -0
- package/src/capture.js +2 -0
- package/src/checks.js +38 -6
- package/src/guide.js +63 -0
- package/src/hooks.js +10 -1
- package/src/mcp.js +173 -5
- package/src/passalong.js +124 -5
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
|
|
@@ -33,7 +35,7 @@ passalong activity [--all] what happened while you were away; clears unless -
|
|
|
33
35
|
passalong list [query] your guides and your teams', local and synced
|
|
34
36
|
passalong open <id> [--print] view a guide in the browser (or the terminal)
|
|
35
37
|
passalong hub open your synced guides in the browser
|
|
36
|
-
passalong works <id>
|
|
38
|
+
passalong works <id> <shot> you tried it and it holds up, with a screenshot of it working
|
|
37
39
|
passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
|
|
38
40
|
passalong archive <id> off your board and out of the free tier's count (was `done`)
|
|
39
41
|
passalong promote <id> deprecated: mark promoted
|
|
@@ -67,9 +69,11 @@ passalong inbox # on Ada's side: what was handed to you
|
|
|
67
69
|
passalong activity # back on your side: it landed, and who pulled it
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
A receiver who tries the work reports back with `passalong works <id>` or
|
|
71
|
-
That is separate from `done`, which means *implemented*: a verdict
|
|
72
|
-
|
|
72
|
+
A receiver who tries the work reports back with `passalong works <id> <screenshot.png>` or
|
|
73
|
+
`passalong broken <id> <why>`. That is separate from `done`, which means *implemented*: a verdict
|
|
74
|
+
means it actually runs. A passing one has to show it — at least one screenshot of it working, kept
|
|
75
|
+
until 5 days after the guide is closed — a failing one has to say why, and the author sees it on
|
|
76
|
+
their board and in their inbox.
|
|
73
77
|
|
|
74
78
|
Sign in at [the hub](https://passalong.dev/hub) with an email and password, and mint
|
|
75
79
|
an API token there for the CLI and MCP servers — named, revocable, and shown once. Your password
|
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
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
// give Solo to @handle, or Team to team/slug, until a date (operator only)
|
|
34
35
|
// passalong gifts what has been given away, and what is still running
|
|
35
36
|
// passalong ungift <gift id> take one back now
|
|
36
|
-
// passalong works <id>
|
|
37
|
+
// passalong works <id> <shot.png...> you tried it and it holds up — with a screenshot of it working
|
|
37
38
|
// passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
|
|
38
39
|
// passalong archive <id> off your board and out of the free tier's count (was `done`)
|
|
39
40
|
// passalong rm <id> delete a guide locally and from sync
|
|
@@ -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");
|
|
@@ -603,9 +628,12 @@ const commands = {
|
|
|
603
628
|
},
|
|
604
629
|
|
|
605
630
|
async works(f) {
|
|
606
|
-
const id = f._[0] || die("usage: passalong works <id>");
|
|
607
|
-
const
|
|
608
|
-
|
|
631
|
+
const id = f._[0] || die("usage: passalong works <id> <screenshot.png...> [what you checked]");
|
|
632
|
+
const { images, note } = passalong.proofArgs(f._.slice(1));
|
|
633
|
+
const r = await passalong.verdict(id, true, note, { images }).catch((e) => die(e.message));
|
|
634
|
+
console.error(
|
|
635
|
+
`${r.id} verified with ${images.length || "your"} screenshot${images.length === 1 ? "" : "s"} — the author has been told`,
|
|
636
|
+
);
|
|
609
637
|
},
|
|
610
638
|
|
|
611
639
|
async broken(f) {
|
|
@@ -861,10 +889,16 @@ const commands = {
|
|
|
861
889
|
input = JSON.parse(raw || "{}");
|
|
862
890
|
}
|
|
863
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;
|
|
864
896
|
const r = await Promise.race([
|
|
865
897
|
passalong.now(),
|
|
866
|
-
new Promise((_, no) =>
|
|
867
|
-
|
|
898
|
+
new Promise((_, no) => {
|
|
899
|
+
timer = setTimeout(() => no(new Error("timeout")), 5000);
|
|
900
|
+
}),
|
|
901
|
+
]).finally(() => clearTimeout(timer));
|
|
868
902
|
if (f.hook === "session") {
|
|
869
903
|
const text = nowText(r);
|
|
870
904
|
if (text)
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "passalong",
|
|
3
|
-
"version": "0.
|
|
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
|
@@ -66,7 +66,7 @@ machine, another agent session, or a teammate without your branch.
|
|
|
66
66
|
follow-up gets the guide it came out of, and where that guide has got to — so write it as a
|
|
67
67
|
note on that work rather than as a standalone brief. A session that started from a passalong guide
|
|
68
68
|
(`passalong start` or `pull` on an id, or an agent opened one) usually has context worth adding
|
|
69
|
-
this way. Whether the guide worked is still its own answer: `passalong works <id>` or
|
|
69
|
+
this way. Whether the guide worked is still its own answer: `passalong works <id> <screenshot>` or
|
|
70
70
|
`passalong broken <id> <why>`.
|
|
71
71
|
6. **Report** the id (and link if synced) and one line on how to use it on the other side:
|
|
72
72
|
`passalong start <id>` in the target repo, or "start passalong <id>" to an agent with the
|
|
@@ -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
|
@@ -22,6 +22,26 @@
|
|
|
22
22
|
* exit code is the whole verdict here, which is also how a CI step decides.
|
|
23
23
|
*/
|
|
24
24
|
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { existsSync } from "node:fs";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The shell a check runs in: bash with `pipefail` wherever there is one.
|
|
29
|
+
*
|
|
30
|
+
* A plain shell reports a pipeline's last command. `npx jest 2>&1 | grep Tests:` then passes when
|
|
31
|
+
* jest fails, because grep found the summary line, and `git log main..HEAD | head -60` passes when
|
|
32
|
+
* git dies with "fatal: ambiguous argument", because head exited 0 — which is exactly how a hand-in
|
|
33
|
+
* came back marked "ran, it worked here" with the fatal error sitting in its own output. With
|
|
34
|
+
* pipefail, any failing step fails the check.
|
|
35
|
+
*/
|
|
36
|
+
const BASH =
|
|
37
|
+
process.platform === "win32" ? "" : ["/bin/bash", "/usr/bin/bash"].find(existsSync) || "";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 141 is 128 + SIGPIPE: the command was still writing when a later step stopped reading, which is
|
|
41
|
+
* what `| head` does on purpose. Under pipefail that is the pipeline's status, and it is not a
|
|
42
|
+
* failure — git printed what head wanted and was told to stop.
|
|
43
|
+
*/
|
|
44
|
+
const SIGPIPE = 141;
|
|
25
45
|
|
|
26
46
|
/** How long a check may run before it is killed. Long enough for a real suite, short of a lease. */
|
|
27
47
|
export const CHECK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -56,15 +76,20 @@ function tail(out, max = OUTPUT_MAX) {
|
|
|
56
76
|
*/
|
|
57
77
|
export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT_MS } = {}) {
|
|
58
78
|
const cmd = String(check?.cmd ?? "").trim();
|
|
59
|
-
|
|
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 };
|
|
60
83
|
|
|
61
|
-
const
|
|
62
|
-
shell: true,
|
|
84
|
+
const opts = {
|
|
63
85
|
cwd,
|
|
64
86
|
timeout: timeoutMs,
|
|
65
87
|
encoding: "utf8",
|
|
66
88
|
maxBuffer: 64 * 1024 * 1024,
|
|
67
|
-
}
|
|
89
|
+
};
|
|
90
|
+
const r = BASH
|
|
91
|
+
? spawnSync(BASH, ["-o", "pipefail", "-c", cmd], opts)
|
|
92
|
+
: spawnSync(cmd, { ...opts, shell: true });
|
|
68
93
|
|
|
69
94
|
const out = tail(`${r.stdout ?? ""}${r.stderr ?? ""}`);
|
|
70
95
|
// `status` is null when the child was killed rather than exited: a timeout, or a signal.
|
|
@@ -77,6 +102,7 @@ export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT
|
|
|
77
102
|
: `could not run: ${r.error?.message ?? "unknown error"}`;
|
|
78
103
|
return {
|
|
79
104
|
check: check.check,
|
|
105
|
+
...says,
|
|
80
106
|
cmd,
|
|
81
107
|
exit: null,
|
|
82
108
|
ok: false,
|
|
@@ -84,15 +110,21 @@ export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT
|
|
|
84
110
|
};
|
|
85
111
|
}
|
|
86
112
|
|
|
113
|
+
const cutShort = Boolean(BASH) && r.status === SIGPIPE;
|
|
87
114
|
return {
|
|
88
115
|
check: check.check,
|
|
116
|
+
...says,
|
|
89
117
|
cmd,
|
|
90
118
|
exit: r.status,
|
|
91
|
-
ok: r.status === 0,
|
|
119
|
+
ok: r.status === 0 || cutShort,
|
|
92
120
|
// The recorded evidence is what the process printed, not what the agent said about it. An
|
|
93
121
|
// empty run is written out rather than left blank: the exit code is what decided it, and a
|
|
94
122
|
// blank `ran` reads like evidence nobody supplied.
|
|
95
|
-
ran:
|
|
123
|
+
ran:
|
|
124
|
+
(out ? `$ ${cmd}\n${out}` : `$ ${cmd}\n(no output; exited ${r.status})`) +
|
|
125
|
+
(cutShort
|
|
126
|
+
? "\n(exit 141: a later step stopped reading early, as `head` does; not a failure)"
|
|
127
|
+
: ""),
|
|
96
128
|
};
|
|
97
129
|
}
|
|
98
130
|
|
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
|
|
@@ -290,6 +303,37 @@ export function parseFrontmatter(text) {
|
|
|
290
303
|
return meta;
|
|
291
304
|
}
|
|
292
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Frontmatter fields holding more than this format can: a nested map, a block scalar (`|`), a
|
|
308
|
+
* sequence under a field that is not a list. `parseFrontmatter` keeps only the key's own line, so
|
|
309
|
+
* each of these would come back as `""` with its content gone — a design.md's `colors:` block did
|
|
310
|
+
* exactly that, and a publish would have stored the document with every token wiped. The format
|
|
311
|
+
* does not grow to hold them (strings and string lists only); the publish refuses them by name
|
|
312
|
+
* instead, so nothing is lost without its author being told. Mirrors `unheldFields` in
|
|
313
|
+
* apps/api/src/guide.ts, which is the refusal every client meets.
|
|
314
|
+
*/
|
|
315
|
+
export function unheldFields(markdown) {
|
|
316
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown);
|
|
317
|
+
if (!m) return [];
|
|
318
|
+
const out = new Set();
|
|
319
|
+
let key = null;
|
|
320
|
+
let listKey = null;
|
|
321
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
322
|
+
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
323
|
+
if (listKey && /^\s*-\s*/.test(line)) continue;
|
|
324
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
|
|
325
|
+
if (kv) {
|
|
326
|
+
key = kv[1];
|
|
327
|
+
listKey = kv[2].trim() === "" && LIST_FIELDS.has(key) ? key : null;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// Anything else is a line the parser skips: an indented child, or an item under a field
|
|
331
|
+
// that is not a list. It belongs to the key above it, and that is the key to name.
|
|
332
|
+
out.add(key ?? line.trim());
|
|
333
|
+
}
|
|
334
|
+
return [...out];
|
|
335
|
+
}
|
|
336
|
+
|
|
293
337
|
function quote(v) {
|
|
294
338
|
const s = String(v ?? "");
|
|
295
339
|
// Quote anything YAML would misread: colons, leading symbols, or empty.
|
|
@@ -299,6 +343,9 @@ function quote(v) {
|
|
|
299
343
|
const META_ORDER = [
|
|
300
344
|
"id",
|
|
301
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",
|
|
302
349
|
"kind",
|
|
303
350
|
"created",
|
|
304
351
|
"author",
|
|
@@ -416,6 +463,14 @@ export function validate({ meta, body }) {
|
|
|
416
463
|
// a kind — `parseFrontmatter` seeds it — so what reaches this without one was built field by
|
|
417
464
|
// field in code, and code is exactly what should have to say what it is making.
|
|
418
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`);
|
|
419
474
|
if (!kind) errors.push(`say what this is: kind must be one of ${KINDS.join(", ")}`);
|
|
420
475
|
else if (!KINDS.includes(kind)) errors.push(`kind must be one of ${KINDS.join(", ")}`);
|
|
421
476
|
const have = sections(body);
|
|
@@ -458,6 +513,10 @@ export function stamp(guide, defaults = {}) {
|
|
|
458
513
|
*/
|
|
459
514
|
export function bugGuide({
|
|
460
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 = "",
|
|
461
520
|
problem = "",
|
|
462
521
|
reproduce = "",
|
|
463
522
|
verification = "",
|
|
@@ -513,6 +572,7 @@ export function bugGuide({
|
|
|
513
572
|
|
|
514
573
|
const meta = {
|
|
515
574
|
title,
|
|
575
|
+
summary: String(summary || "").trim() || title,
|
|
516
576
|
kind: "bug",
|
|
517
577
|
status,
|
|
518
578
|
tags: ["bug", environment, area].filter(Boolean),
|
|
@@ -553,6 +613,7 @@ export function template(meta = {}) {
|
|
|
553
613
|
return serialize({
|
|
554
614
|
meta: {
|
|
555
615
|
title: "",
|
|
616
|
+
summary: "",
|
|
556
617
|
author: "",
|
|
557
618
|
source_context: "",
|
|
558
619
|
status: "draft",
|
|
@@ -589,6 +650,7 @@ function bugTemplate(meta = {}) {
|
|
|
589
650
|
return serialize({
|
|
590
651
|
meta: {
|
|
591
652
|
title: "",
|
|
653
|
+
summary: "",
|
|
592
654
|
kind: "bug",
|
|
593
655
|
author: "",
|
|
594
656
|
source_context: "",
|
|
@@ -629,6 +691,7 @@ function taskTemplate(meta = {}) {
|
|
|
629
691
|
return serialize({
|
|
630
692
|
meta: {
|
|
631
693
|
title: "",
|
|
694
|
+
summary: "",
|
|
632
695
|
kind: "task",
|
|
633
696
|
author: "",
|
|
634
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
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
serialize,
|
|
17
17
|
stamp,
|
|
18
18
|
stripPlaceholders,
|
|
19
|
+
unheldFields,
|
|
19
20
|
validate,
|
|
20
21
|
} from "./guide.js";
|
|
21
22
|
import * as store from "./store.js";
|
|
@@ -60,7 +61,13 @@ export async function share(markdown, { cwd = process.cwd(), to, follows } = {})
|
|
|
60
61
|
if (follows) guide.meta.parent = await resolveId(String(follows).trim());
|
|
61
62
|
if (guide.meta.to && !guide.meta.team)
|
|
62
63
|
throw new PassalongError("`to:` needs a team — address a handoff as team/handle");
|
|
63
|
-
const errors =
|
|
64
|
+
const errors = [
|
|
65
|
+
...unheldFields(markdown).map(
|
|
66
|
+
(k) =>
|
|
67
|
+
`frontmatter "${k}" holds more than a string or a list of strings, and it would be dropped — move it into the body (a fenced yaml block keeps it as written)`,
|
|
68
|
+
),
|
|
69
|
+
...validate(guide),
|
|
70
|
+
];
|
|
64
71
|
if (errors.length)
|
|
65
72
|
throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
|
|
66
73
|
// A task stays a draft when it is shared. Draft is the column a task waits in until a person
|
|
@@ -474,6 +481,9 @@ export async function handIn(
|
|
|
474
481
|
checks: checks.map((c) => ({
|
|
475
482
|
check: c.check,
|
|
476
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 } : {}),
|
|
477
487
|
// Only present on a check that was run. The server keeps them apart the same way.
|
|
478
488
|
...(verified(c) ? { cmd: c.cmd, exit: c.exit, ok: c.ok } : {}),
|
|
479
489
|
})),
|
|
@@ -517,6 +527,23 @@ export async function pass(id, why, { cwd = process.cwd() } = {}) {
|
|
|
517
527
|
});
|
|
518
528
|
}
|
|
519
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
|
+
|
|
520
547
|
/**
|
|
521
548
|
* What this worktree's agent holds right now, and what is waiting for it: what the session-start
|
|
522
549
|
* and stop hooks read (src/hooks.js). `held` is null when it holds nothing. Counts are best effort
|
|
@@ -548,7 +575,7 @@ export async function now({ cwd = process.cwd() } = {}) {
|
|
|
548
575
|
x.mine &&
|
|
549
576
|
(x.state === "review" ||
|
|
550
577
|
x.state === "stalled" ||
|
|
551
|
-
(x.state === "claimed" && /^BLOCKED:/i.test(x.claim?.note || ""))),
|
|
578
|
+
(x.state === "claimed" && (x.claim?.asking || /^BLOCKED:/i.test(x.claim?.note || "")))),
|
|
552
579
|
).length;
|
|
553
580
|
const handed = h.status === "fulfilled" ? h.value.handed_in.length : 0;
|
|
554
581
|
return { held, waiting, needs: review + handed + waiting.inbox, agent: who.agent };
|
|
@@ -724,6 +751,64 @@ export async function attach(file, { name = "" } = {}) {
|
|
|
724
751
|
return { ...shot, markdown: `` };
|
|
725
752
|
}
|
|
726
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
|
+
|
|
727
812
|
/**
|
|
728
813
|
* The line a guide arrives with, saying what a follow-up is and how to write one.
|
|
729
814
|
*
|
|
@@ -908,13 +993,40 @@ export async function activity({ all = false, limit = 50 } = {}) {
|
|
|
908
993
|
* "Correction: …" instead, because measurements, commands and commit ids do not fit in a tweet.
|
|
909
994
|
* The first line is the summary a row shows; the whole thing is what the guide shows.
|
|
910
995
|
*/
|
|
911
|
-
export async function verdict(id, ok, note = "") {
|
|
996
|
+
export async function verdict(id, ok, note = "", { images = [] } = {}) {
|
|
912
997
|
if (!api.loggedIn()) throw new PassalongError("verdicts need sync — run `passalong login` first");
|
|
913
998
|
const said = note.trim();
|
|
914
999
|
if (!ok && !said)
|
|
915
1000
|
throw new PassalongError("say what went wrong: passalong broken <id> <what happened>");
|
|
1001
|
+
// "It works" is shown, not said: the server refuses one with no screenshot of it working, so
|
|
1002
|
+
// the refusal is said here before anything is uploaded, in the words of the command that fixes it.
|
|
1003
|
+
if (ok && !images.length && !/\/v1\/shots\/[a-z0-9]{6,16}/.test(said))
|
|
1004
|
+
throw new PassalongError(
|
|
1005
|
+
"show it working: passalong works <id> <screenshot.png> [what you checked]",
|
|
1006
|
+
);
|
|
1007
|
+
const guide = await resolveId(id);
|
|
1008
|
+
const shots = [];
|
|
1009
|
+
for (const file of images) shots.push((await attach(file)).markdown);
|
|
916
1010
|
const line = said.split("\n")[0].slice(0, 280);
|
|
917
|
-
|
|
1011
|
+
const detail = [said.length > line.length || shots.length ? said : "", ...shots]
|
|
1012
|
+
.filter(Boolean)
|
|
1013
|
+
.join("\n");
|
|
1014
|
+
return api.verdict(guide, ok, line, detail);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* The words after `passalong works <id>`, split into the screenshots and the note: an argument is a
|
|
1019
|
+
* screenshot when it names an image file that exists, and everything else is what was checked.
|
|
1020
|
+
*/
|
|
1021
|
+
export function proofArgs(args) {
|
|
1022
|
+
const images = [];
|
|
1023
|
+
const words = [];
|
|
1024
|
+
for (const a of args) {
|
|
1025
|
+
const ext = a.slice(a.lastIndexOf(".")).toLowerCase();
|
|
1026
|
+
if (SHOT_TYPES[ext] && existsSync(a)) images.push(a);
|
|
1027
|
+
else words.push(a);
|
|
1028
|
+
}
|
|
1029
|
+
return { images, note: words.join(" ") };
|
|
918
1030
|
}
|
|
919
1031
|
|
|
920
1032
|
/**
|
|
@@ -1010,7 +1122,14 @@ export async function planTasks(steps, { cwd = process.cwd() } = {}) {
|
|
|
1010
1122
|
...section("Acceptance", step.acceptance),
|
|
1011
1123
|
...section("Out of scope", step.out_of_scope),
|
|
1012
1124
|
].join("\n");
|
|
1013
|
-
|
|
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
|
+
};
|
|
1014
1133
|
const after = (step.after || []).map((j) => ids[j]);
|
|
1015
1134
|
if (after.length) meta.blocked_by = after;
|
|
1016
1135
|
ids.push((await share(serialize({ meta, body }), { cwd })).guide.meta.id);
|