passalong 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/bin/passalong +7 -4
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/src/api.js +13 -2
- package/src/checks.js +32 -5
- package/src/guide.js +68 -7
- package/src/mcp.js +26 -2
- package/src/passalong.js +69 -10
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ passalong activity [--all] what happened while you were away; clears unless -
|
|
|
33
33
|
passalong list [query] your guides and your teams', local and synced
|
|
34
34
|
passalong open <id> [--print] view a guide in the browser (or the terminal)
|
|
35
35
|
passalong hub open your synced guides in the browser
|
|
36
|
-
passalong works <id>
|
|
36
|
+
passalong works <id> <shot> you tried it and it holds up, with a screenshot of it working
|
|
37
37
|
passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
|
|
38
38
|
passalong archive <id> off your board and out of the free tier's count (was `done`)
|
|
39
39
|
passalong promote <id> deprecated: mark promoted
|
|
@@ -67,9 +67,11 @@ passalong inbox # on Ada's side: what was handed to you
|
|
|
67
67
|
passalong activity # back on your side: it landed, and who pulled it
|
|
68
68
|
```
|
|
69
69
|
|
|
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
|
-
|
|
70
|
+
A receiver who tries the work reports back with `passalong works <id> <screenshot.png>` or
|
|
71
|
+
`passalong broken <id> <why>`. That is separate from `done`, which means *implemented*: a verdict
|
|
72
|
+
means it actually runs. A passing one has to show it — at least one screenshot of it working, kept
|
|
73
|
+
until 5 days after the guide is closed — a failing one has to say why, and the author sees it on
|
|
74
|
+
their board and in their inbox.
|
|
73
75
|
|
|
74
76
|
Sign in at [the hub](https://passalong.dev/hub) with an email and password, and mint
|
|
75
77
|
an API token there for the CLI and MCP servers — named, revocable, and shown once. Your password
|
package/bin/passalong
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
// give Solo to @handle, or Team to team/slug, until a date (operator only)
|
|
34
34
|
// passalong gifts what has been given away, and what is still running
|
|
35
35
|
// passalong ungift <gift id> take one back now
|
|
36
|
-
// passalong works <id>
|
|
36
|
+
// passalong works <id> <shot.png...> you tried it and it holds up — with a screenshot of it working
|
|
37
37
|
// passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
|
|
38
38
|
// passalong archive <id> off your board and out of the free tier's count (was `done`)
|
|
39
39
|
// passalong rm <id> delete a guide locally and from sync
|
|
@@ -603,9 +603,12 @@ const commands = {
|
|
|
603
603
|
},
|
|
604
604
|
|
|
605
605
|
async works(f) {
|
|
606
|
-
const id = f._[0] || die("usage: passalong works <id>");
|
|
607
|
-
const
|
|
608
|
-
|
|
606
|
+
const id = f._[0] || die("usage: passalong works <id> <screenshot.png...> [what you checked]");
|
|
607
|
+
const { images, note } = passalong.proofArgs(f._.slice(1));
|
|
608
|
+
const r = await passalong.verdict(id, true, note, { images }).catch((e) => die(e.message));
|
|
609
|
+
console.error(
|
|
610
|
+
`${r.id} verified with ${images.length || "your"} screenshot${images.length === 1 ? "" : "s"} — the author has been told`,
|
|
611
|
+
);
|
|
609
612
|
},
|
|
610
613
|
|
|
611
614
|
async broken(f) {
|
package/package.json
CHANGED
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
|
package/src/api.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
// Client for the hosted sync API (apps/api). Everything here is optional: with no token the
|
|
2
2
|
// CLI is a purely local tool, and every function throws an ApiError the CLI turns into a hint.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { readConfig } from "./store.js";
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* This build's version, sent on every authenticated call as `x-passalong-version`. The server
|
|
8
|
+
* refuses a CLI below its floor (apps/api/src/clients.ts), because the rules it enforces deploy on
|
|
9
|
+
* merge and the text telling an agent how to follow them only arrives with a reinstall.
|
|
10
|
+
*/
|
|
11
|
+
export const VERSION = JSON.parse(
|
|
12
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
13
|
+
).version;
|
|
14
|
+
const CLIENT = { "x-passalong-version": VERSION };
|
|
15
|
+
|
|
5
16
|
export const DEFAULT_API = "https://passalong.dev";
|
|
6
17
|
|
|
7
18
|
export class ApiError extends Error {
|
|
@@ -24,7 +35,7 @@ export function loggedIn() {
|
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
async function call(path, { method = "GET", body, auth = true, raw = false } = {}) {
|
|
27
|
-
const headers = {};
|
|
38
|
+
const headers = { ...CLIENT };
|
|
28
39
|
if (auth) {
|
|
29
40
|
const t = token();
|
|
30
41
|
if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
|
|
@@ -220,7 +231,7 @@ export const ack = (id, taken, note = "") =>
|
|
|
220
231
|
export async function uploadShot(bytes, type, name = "") {
|
|
221
232
|
const t = token();
|
|
222
233
|
if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
|
|
223
|
-
const headers = { authorization: `Bearer ${t}`, "content-type": type };
|
|
234
|
+
const headers = { ...CLIENT, authorization: `Bearer ${t}`, "content-type": type };
|
|
224
235
|
// A header is latin-1: a filename with an accent in it throws on the way out rather than at the
|
|
225
236
|
// server, and the label is not worth failing an upload over.
|
|
226
237
|
if (name) headers["x-shot-name"] = String(name).replace(/[^\x20-\x7e]/g, "");
|
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;
|
|
@@ -58,13 +78,15 @@ export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT
|
|
|
58
78
|
const cmd = String(check?.cmd ?? "").trim();
|
|
59
79
|
if (!cmd) return { check: check.check, ran: check.ran };
|
|
60
80
|
|
|
61
|
-
const
|
|
62
|
-
shell: true,
|
|
81
|
+
const opts = {
|
|
63
82
|
cwd,
|
|
64
83
|
timeout: timeoutMs,
|
|
65
84
|
encoding: "utf8",
|
|
66
85
|
maxBuffer: 64 * 1024 * 1024,
|
|
67
|
-
}
|
|
86
|
+
};
|
|
87
|
+
const r = BASH
|
|
88
|
+
? spawnSync(BASH, ["-o", "pipefail", "-c", cmd], opts)
|
|
89
|
+
: spawnSync(cmd, { ...opts, shell: true });
|
|
68
90
|
|
|
69
91
|
const out = tail(`${r.stdout ?? ""}${r.stderr ?? ""}`);
|
|
70
92
|
// `status` is null when the child was killed rather than exited: a timeout, or a signal.
|
|
@@ -84,15 +106,20 @@ export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT
|
|
|
84
106
|
};
|
|
85
107
|
}
|
|
86
108
|
|
|
109
|
+
const cutShort = Boolean(BASH) && r.status === SIGPIPE;
|
|
87
110
|
return {
|
|
88
111
|
check: check.check,
|
|
89
112
|
cmd,
|
|
90
113
|
exit: r.status,
|
|
91
|
-
ok: r.status === 0,
|
|
114
|
+
ok: r.status === 0 || cutShort,
|
|
92
115
|
// The recorded evidence is what the process printed, not what the agent said about it. An
|
|
93
116
|
// empty run is written out rather than left blank: the exit code is what decided it, and a
|
|
94
117
|
// blank `ran` reads like evidence nobody supplied.
|
|
95
|
-
ran:
|
|
118
|
+
ran:
|
|
119
|
+
(out ? `$ ${cmd}\n${out}` : `$ ${cmd}\n(no output; exited ${r.status})`) +
|
|
120
|
+
(cutShort
|
|
121
|
+
? "\n(exit 141: a later step stopped reading early, as `head` does; not a failure)"
|
|
122
|
+
: ""),
|
|
96
123
|
};
|
|
97
124
|
}
|
|
98
125
|
|
package/src/guide.js
CHANGED
|
@@ -127,8 +127,22 @@ export function sectionsFor(kind) {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
/** The sections it cannot be published without. */
|
|
130
|
+
/**
|
|
131
|
+
* The sections a kind cannot do without, and the one that has none.
|
|
132
|
+
*
|
|
133
|
+
* A transfer is context exchange. Context is whatever the next agent needs to not start cold —
|
|
134
|
+
* a decision and why, a constraint, where the bodies are — and none of that arrives in six fixed
|
|
135
|
+
* headings. Requiring `## Problem` and `## Steps` made every short note into a document: of 56
|
|
136
|
+
* follow-ups in real use, the ones that fill this template average 599 words and ten of sixteen
|
|
137
|
+
* were never opened by anyone, while the ones that ignored it average 137 words and every single
|
|
138
|
+
* one was opened. The template was not raising the floor, it was padding to reach it.
|
|
139
|
+
*
|
|
140
|
+
* A task and a bug keep theirs, because theirs are not prose. `## Acceptance` is the list a
|
|
141
|
+
* hand-in answers line by line, and `## Reproduce` is how a reader sees the defect. Those earn
|
|
142
|
+
* their requirement by being read mechanically; `## Steps` on a piece of context does not.
|
|
143
|
+
*/
|
|
130
144
|
const REQUIRED = {
|
|
131
|
-
transfer: [
|
|
145
|
+
transfer: [],
|
|
132
146
|
bug: ["Problem", "Reproduce"],
|
|
133
147
|
task: ["Goal", "Acceptance"],
|
|
134
148
|
};
|
|
@@ -255,16 +269,58 @@ export function parseFrontmatter(text) {
|
|
|
255
269
|
// Only the lists every guide has. `blocked_by` is a task's, and defaulting it would write an
|
|
256
270
|
// empty one into every guide anybody re-shares.
|
|
257
271
|
for (const k of ["stack_assumptions", "tags"]) if (meta[k] === undefined) meta[k] = [];
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
272
|
+
// Absent is absent. It is NOT a transfer guide.
|
|
273
|
+
//
|
|
274
|
+
// This line, and the sentence publish_guide tells every agent — "kind: task (the default)" —
|
|
275
|
+
// disagreed for thirteen days. An agent that left `kind:` off, believing what it had been told,
|
|
276
|
+
// got a transfer guide. 162 of 200 guides in real use are labelled transfer; their titles are
|
|
277
|
+
// tasks and bug reports. Everything downstream followed from that: a transfer has no states, so
|
|
278
|
+
// nothing could show as in progress; it demands Problem and Steps, so a two-line correction was
|
|
279
|
+
// padded to six sections; and there is nowhere to report into one, so agents published a second
|
|
280
|
+
// guide to carry what they had found.
|
|
281
|
+
//
|
|
282
|
+
// So it stays empty and `validate()` refuses it by name. The back-compat this default protected
|
|
283
|
+
// is a guide written before kinds existed being re-shared; that guide is re-shared once with an
|
|
284
|
+
// explicit `kind: transfer`, which is a sentence its author can write, unlike thirteen days of
|
|
285
|
+
// mislabelled work nobody could see.
|
|
286
|
+
meta.kind = String(meta.kind ?? "").trim();
|
|
262
287
|
// Both ends of this module: what it reads and what it writes are in one style, so a guide
|
|
263
288
|
// written before there was a rule comes back normalised and goes out normalised.
|
|
264
289
|
meta.tags = tagList(meta.tags);
|
|
265
290
|
return meta;
|
|
266
291
|
}
|
|
267
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Frontmatter fields holding more than this format can: a nested map, a block scalar (`|`), a
|
|
295
|
+
* sequence under a field that is not a list. `parseFrontmatter` keeps only the key's own line, so
|
|
296
|
+
* each of these would come back as `""` with its content gone — a design.md's `colors:` block did
|
|
297
|
+
* exactly that, and a publish would have stored the document with every token wiped. The format
|
|
298
|
+
* does not grow to hold them (strings and string lists only); the publish refuses them by name
|
|
299
|
+
* instead, so nothing is lost without its author being told. Mirrors `unheldFields` in
|
|
300
|
+
* apps/api/src/guide.ts, which is the refusal every client meets.
|
|
301
|
+
*/
|
|
302
|
+
export function unheldFields(markdown) {
|
|
303
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown);
|
|
304
|
+
if (!m) return [];
|
|
305
|
+
const out = new Set();
|
|
306
|
+
let key = null;
|
|
307
|
+
let listKey = null;
|
|
308
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
309
|
+
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
310
|
+
if (listKey && /^\s*-\s*/.test(line)) continue;
|
|
311
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
|
|
312
|
+
if (kv) {
|
|
313
|
+
key = kv[1];
|
|
314
|
+
listKey = kv[2].trim() === "" && LIST_FIELDS.has(key) ? key : null;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
// Anything else is a line the parser skips: an indented child, or an item under a field
|
|
318
|
+
// that is not a list. It belongs to the key above it, and that is the key to name.
|
|
319
|
+
out.add(key ?? line.trim());
|
|
320
|
+
}
|
|
321
|
+
return [...out];
|
|
322
|
+
}
|
|
323
|
+
|
|
268
324
|
function quote(v) {
|
|
269
325
|
const s = String(v ?? "");
|
|
270
326
|
// Quote anything YAML would misread: colons, leading symbols, or empty.
|
|
@@ -305,6 +361,10 @@ export function serializeFrontmatter(meta) {
|
|
|
305
361
|
];
|
|
306
362
|
const out = [];
|
|
307
363
|
for (const key of keys) {
|
|
364
|
+
// A kind nobody stated is left unstated. Writing `kind: ""` would put an answer in the
|
|
365
|
+
// document that its author never gave, and the next parse would read it back as a stated
|
|
366
|
+
// empty rather than an absent one — which is the whole distinction validate() turns on.
|
|
367
|
+
if (key === "kind" && !String(meta[key] ?? "").trim()) continue;
|
|
308
368
|
const v = key === "tags" ? tagList(meta[key]) : meta[key];
|
|
309
369
|
if (Array.isArray(v)) {
|
|
310
370
|
out.push(v.length ? `${key}: [${v.map(quote).join(", ")}]` : `${key}: []`);
|
|
@@ -497,8 +557,9 @@ export function bugGuide({
|
|
|
497
557
|
|
|
498
558
|
/** A draft with the section skeleton. Placeholders are HTML comments so they vanish when rendered. */
|
|
499
559
|
export function template(meta = {}) {
|
|
500
|
-
// A task unless asked otherwise: most guides assign work.
|
|
501
|
-
//
|
|
560
|
+
// A task unless asked otherwise: most guides assign work. Every skeleton writes its kind out —
|
|
561
|
+
// absent is now refused rather than read as transfer, so a template that left it off would
|
|
562
|
+
// produce a document its own author could not publish.
|
|
502
563
|
if (meta.kind === "bug") return bugTemplate(meta);
|
|
503
564
|
if (meta.kind !== "transfer") return taskTemplate(meta);
|
|
504
565
|
const body = [
|
package/src/mcp.js
CHANGED
|
@@ -261,7 +261,9 @@ export function buildServer() {
|
|
|
261
261
|
"Collect it as you work rather than writing it from memory at the end. hand_in without " +
|
|
262
262
|
"it is refused, because the write-up and the verdict are both your word for your own " +
|
|
263
263
|
"work and evidence is the part the person reviewing it can check.\n" +
|
|
264
|
-
|
|
264
|
+
"SAY WHICH KIND IT IS. There is no default: a guide with no `kind:` line is refused, and so " +
|
|
265
|
+
"is a spelling that is not one of the three. " +
|
|
266
|
+
`kind: task is work nobody has done yet. Sections: ${TASK_SECTIONS.join(", ")}. ` +
|
|
265
267
|
"It has no Steps: work out how to reach Goal within Constraints, leave Out of scope " +
|
|
266
268
|
"alone, and treat Acceptance as the definition of done. hand_in with `markdown`: a " +
|
|
267
269
|
"transfer guide about what you did (guide_template kind transfer shows the shape) — its " +
|
|
@@ -381,7 +383,19 @@ export function buildServer() {
|
|
|
381
383
|
}
|
|
382
384
|
}
|
|
383
385
|
|
|
384
|
-
async function doHandIn({
|
|
386
|
+
async function doHandIn({
|
|
387
|
+
id,
|
|
388
|
+
ok,
|
|
389
|
+
note,
|
|
390
|
+
evidence,
|
|
391
|
+
checks,
|
|
392
|
+
writeup,
|
|
393
|
+
risk,
|
|
394
|
+
markdown,
|
|
395
|
+
report,
|
|
396
|
+
pr,
|
|
397
|
+
cwd,
|
|
398
|
+
}) {
|
|
385
399
|
try {
|
|
386
400
|
// A check that names a command is run here, before anything is recorded, and its exit code
|
|
387
401
|
// decides it rather than the agent's account of it. A failure is refused with the command's
|
|
@@ -402,6 +416,7 @@ export function buildServer() {
|
|
|
402
416
|
evidence: evidence || "",
|
|
403
417
|
checks: done.checks,
|
|
404
418
|
writeup: writeup || "",
|
|
419
|
+
risk: risk || "",
|
|
405
420
|
markdown,
|
|
406
421
|
report,
|
|
407
422
|
pr: pr || "",
|
|
@@ -763,6 +778,15 @@ export function buildServer() {
|
|
|
763
778
|
"it out when it worked as written. The next person to open the guide is shown it, " +
|
|
764
779
|
"which is why it does not need to be a guide of its own",
|
|
765
780
|
),
|
|
781
|
+
risk: z
|
|
782
|
+
.string()
|
|
783
|
+
.optional()
|
|
784
|
+
.describe(
|
|
785
|
+
"what this could break, in one line — the part of a PR template's Risk that is " +
|
|
786
|
+
"worth reading: a shared helper changed, a migration that cannot be undone, a " +
|
|
787
|
+
"route other clients call. Optional; leave it out when there is nothing specific, " +
|
|
788
|
+
'rather than writing "low risk". Its author reads it before accepting the work',
|
|
789
|
+
),
|
|
766
790
|
markdown: z
|
|
767
791
|
.string()
|
|
768
792
|
.optional()
|
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
|
|
@@ -358,8 +365,16 @@ export async function finishTask(
|
|
|
358
365
|
requireEvidence(evidence);
|
|
359
366
|
if (!report && markdown) {
|
|
360
367
|
const g = parse(markdown);
|
|
361
|
-
|
|
362
|
-
|
|
368
|
+
// A task's report is a transfer guide, and this code is what is making one — so it says so
|
|
369
|
+
// rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
|
|
370
|
+
// agent's hand-in because its write-up left a line off would be this function's omission
|
|
371
|
+
// charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
|
|
372
|
+
// author's to leave open, because the caller already knows the answer.
|
|
373
|
+
report = (
|
|
374
|
+
await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
|
|
375
|
+
cwd,
|
|
376
|
+
})
|
|
377
|
+
).guide.meta.id;
|
|
363
378
|
}
|
|
364
379
|
if (!report)
|
|
365
380
|
throw new PassalongError(
|
|
@@ -414,6 +429,7 @@ export async function handIn(
|
|
|
414
429
|
evidence = "",
|
|
415
430
|
checks = [],
|
|
416
431
|
writeup = "",
|
|
432
|
+
risk = "",
|
|
417
433
|
markdown,
|
|
418
434
|
report,
|
|
419
435
|
pr = "",
|
|
@@ -443,8 +459,16 @@ export async function handIn(
|
|
|
443
459
|
else requireEvidence(evidence);
|
|
444
460
|
if (!report && markdown) {
|
|
445
461
|
const g = parse(markdown);
|
|
446
|
-
|
|
447
|
-
|
|
462
|
+
// A task's report is a transfer guide, and this code is what is making one — so it says so
|
|
463
|
+
// rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
|
|
464
|
+
// agent's hand-in because its write-up left a line off would be this function's omission
|
|
465
|
+
// charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
|
|
466
|
+
// author's to leave open, because the caller already knows the answer.
|
|
467
|
+
report = (
|
|
468
|
+
await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
|
|
469
|
+
cwd,
|
|
470
|
+
})
|
|
471
|
+
).guide.meta.id;
|
|
448
472
|
}
|
|
449
473
|
const fence = fenceFor(id, cwd);
|
|
450
474
|
return api.handIn(id, {
|
|
@@ -466,6 +490,8 @@ export async function handIn(
|
|
|
466
490
|
// Left out when there is nothing to say, rather than sent as "": most hand-ins have nothing
|
|
467
491
|
// to adapt, and the field is for the ones that do.
|
|
468
492
|
...(writeup ? { writeup } : {}),
|
|
493
|
+
// What it could break, for the reviewer. Left out when empty, like the write-up.
|
|
494
|
+
...(risk ? { risk } : {}),
|
|
469
495
|
...(typeof ok === "boolean" ? { ok } : {}),
|
|
470
496
|
});
|
|
471
497
|
}
|
|
@@ -713,6 +739,11 @@ export async function attach(file, { name = "" } = {}) {
|
|
|
713
739
|
* found doing it. Whoever opens the original, person or agent, gets its follow-ups with it, which
|
|
714
740
|
* is why context belongs there rather than in a one-line verdict note.
|
|
715
741
|
*
|
|
742
|
+
* What an agent did and found working on the guide is not context for it: that is the hand-in,
|
|
743
|
+
* and the note says so. It used to list "what you found doing it" among the things to publish, on
|
|
744
|
+
* every `take`, while hand_in said a follow-up was not for answering — the agent followed the one
|
|
745
|
+
* it read last, and the author got a second guide to review with the evidence inside it.
|
|
746
|
+
*
|
|
716
747
|
* Trailing, like the handoff nudge, so it never sits in front of the frontmatter. It is here and
|
|
717
748
|
* not only in the server's instructions because an agent reads the payload it is working from and
|
|
718
749
|
* skims everything else — the same reason the bug lead is inside the document. For a bug, the
|
|
@@ -729,9 +760,10 @@ export function followUpNote(meta = {}) {
|
|
|
729
760
|
);
|
|
730
761
|
return (
|
|
731
762
|
"<!-- passalong: a follow-up is more context for this guide, written as its own guide. If " +
|
|
732
|
-
"this guide needs more — a missing detail, a step that needed explaining, what changed since
|
|
733
|
-
|
|
734
|
-
"
|
|
763
|
+
"this guide needs more — a missing detail, a step that needed explaining, what changed since " +
|
|
764
|
+
`— publish that with publish_guide parent=${meta.id}, and whoever opens this guide gets it ` +
|
|
765
|
+
"too. What you did and found working on it is not a follow-up: it goes on hand_in, in " +
|
|
766
|
+
"`checks` and `writeup`. -->"
|
|
735
767
|
);
|
|
736
768
|
}
|
|
737
769
|
|
|
@@ -883,13 +915,40 @@ export async function activity({ all = false, limit = 50 } = {}) {
|
|
|
883
915
|
* "Correction: …" instead, because measurements, commands and commit ids do not fit in a tweet.
|
|
884
916
|
* The first line is the summary a row shows; the whole thing is what the guide shows.
|
|
885
917
|
*/
|
|
886
|
-
export async function verdict(id, ok, note = "") {
|
|
918
|
+
export async function verdict(id, ok, note = "", { images = [] } = {}) {
|
|
887
919
|
if (!api.loggedIn()) throw new PassalongError("verdicts need sync — run `passalong login` first");
|
|
888
920
|
const said = note.trim();
|
|
889
921
|
if (!ok && !said)
|
|
890
922
|
throw new PassalongError("say what went wrong: passalong broken <id> <what happened>");
|
|
923
|
+
// "It works" is shown, not said: the server refuses one with no screenshot of it working, so
|
|
924
|
+
// the refusal is said here before anything is uploaded, in the words of the command that fixes it.
|
|
925
|
+
if (ok && !images.length && !/\/v1\/shots\/[a-z0-9]{6,16}/.test(said))
|
|
926
|
+
throw new PassalongError(
|
|
927
|
+
"show it working: passalong works <id> <screenshot.png> [what you checked]",
|
|
928
|
+
);
|
|
929
|
+
const guide = await resolveId(id);
|
|
930
|
+
const shots = [];
|
|
931
|
+
for (const file of images) shots.push((await attach(file)).markdown);
|
|
891
932
|
const line = said.split("\n")[0].slice(0, 280);
|
|
892
|
-
|
|
933
|
+
const detail = [said.length > line.length || shots.length ? said : "", ...shots]
|
|
934
|
+
.filter(Boolean)
|
|
935
|
+
.join("\n");
|
|
936
|
+
return api.verdict(guide, ok, line, detail);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* The words after `passalong works <id>`, split into the screenshots and the note: an argument is a
|
|
941
|
+
* screenshot when it names an image file that exists, and everything else is what was checked.
|
|
942
|
+
*/
|
|
943
|
+
export function proofArgs(args) {
|
|
944
|
+
const images = [];
|
|
945
|
+
const words = [];
|
|
946
|
+
for (const a of args) {
|
|
947
|
+
const ext = a.slice(a.lastIndexOf(".")).toLowerCase();
|
|
948
|
+
if (SHOT_TYPES[ext] && existsSync(a)) images.push(a);
|
|
949
|
+
else words.push(a);
|
|
950
|
+
}
|
|
951
|
+
return { images, note: words.join(" ") };
|
|
893
952
|
}
|
|
894
953
|
|
|
895
954
|
/**
|