passalong 0.9.0 → 0.12.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/package.json +1 -1
- package/src/api.js +13 -2
- package/src/guide.js +37 -7
- package/src/mcp.js +118 -12
- package/src/passalong.js +88 -7
package/package.json
CHANGED
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/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,10 +269,21 @@ 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);
|
|
@@ -305,6 +330,10 @@ export function serializeFrontmatter(meta) {
|
|
|
305
330
|
];
|
|
306
331
|
const out = [];
|
|
307
332
|
for (const key of keys) {
|
|
333
|
+
// A kind nobody stated is left unstated. Writing `kind: ""` would put an answer in the
|
|
334
|
+
// document that its author never gave, and the next parse would read it back as a stated
|
|
335
|
+
// empty rather than an absent one — which is the whole distinction validate() turns on.
|
|
336
|
+
if (key === "kind" && !String(meta[key] ?? "").trim()) continue;
|
|
308
337
|
const v = key === "tags" ? tagList(meta[key]) : meta[key];
|
|
309
338
|
if (Array.isArray(v)) {
|
|
310
339
|
out.push(v.length ? `${key}: [${v.map(quote).join(", ")}]` : `${key}: []`);
|
|
@@ -497,8 +526,9 @@ export function bugGuide({
|
|
|
497
526
|
|
|
498
527
|
/** A draft with the section skeleton. Placeholders are HTML comments so they vanish when rendered. */
|
|
499
528
|
export function template(meta = {}) {
|
|
500
|
-
// A task unless asked otherwise: most guides assign work.
|
|
501
|
-
//
|
|
529
|
+
// A task unless asked otherwise: most guides assign work. Every skeleton writes its kind out —
|
|
530
|
+
// absent is now refused rather than read as transfer, so a template that left it off would
|
|
531
|
+
// produce a document its own author could not publish.
|
|
502
532
|
if (meta.kind === "bug") return bugTemplate(meta);
|
|
503
533
|
if (meta.kind !== "transfer") return taskTemplate(meta);
|
|
504
534
|
const body = [
|
package/src/mcp.js
CHANGED
|
@@ -29,11 +29,62 @@ const text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
|
29
29
|
* evidence is collected while the work happens, not reconstructed from memory once it is done.
|
|
30
30
|
* Mirrors KEEP_EVIDENCE in apps/api/src/mcp-http.ts.
|
|
31
31
|
*/
|
|
32
|
+
const WHERE_THE_LINE_GOES =
|
|
33
|
+
"WHERE THE LINE GOES DEPENDS ON WHAT THE IMAGE IS EVIDENCE OF. Proof that work you are handing i" +
|
|
34
|
+
"n holds — a screen that renders right, a total that matches — goes in that check's `ran` on han" +
|
|
35
|
+
"d_in, and nothing is published: a check answered with a picture is shown, which is what the han" +
|
|
36
|
+
"d-in asks for. Only an image that is part of a DOCUMENT goes in a guide body — the screenshot i" +
|
|
37
|
+
"n a bug report, a diagram a guide is explaining — and then it must be in the markdown, because " +
|
|
38
|
+
"a guide travels as markdown to whoever holds its link and evidence beside the document does not" +
|
|
39
|
+
" travel at all; publishing claims whatever the markdown names, so attach first and publish afte" +
|
|
40
|
+
"r. DO NOT PUBLISH A GUIDE TO CARRY SCREENSHOTS. Six images and a caption each is a hand-in, not" +
|
|
41
|
+
" a document, and wrapping them in Problem / Solution shape / Decisions / Steps / Verification t" +
|
|
42
|
+
"o make them publishable is how a set of pictures becomes fifteen hundred words nobody asked for" +
|
|
43
|
+
". png, jpg, webp or gif.";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a screenshot has to be a screenshot OF.
|
|
47
|
+
*
|
|
48
|
+
* "A check is run or shown" made a picture mandatory, and an agent short of one built a page to
|
|
49
|
+
* photograph: it added `src/app/notes-preview`, shot that, then `rm -rf`'d it and published the
|
|
50
|
+
* shots as evidence that the real screen rendered. The rule was satisfied and almost nothing was
|
|
51
|
+
* proved — the measure had become the target, and the page the picture showed no longer existed.
|
|
52
|
+
*
|
|
53
|
+
* Mirrors SHOOT_THE_REAL_THING in apps/api/src/mcp-http.ts.
|
|
54
|
+
*/
|
|
55
|
+
const SHOOT_THE_REAL_THING =
|
|
56
|
+
"SHOOT THE RUNNING THING, NOT SOMETHING YOU BUILT TO SHOOT. A screenshot is worth what the sc" +
|
|
57
|
+
"reen behind it is worth: the real route in the real app, reached the way a reader would reac" +
|
|
58
|
+
"h it. A scratch page, a story, a harness or a component rendered on its own is a picture of " +
|
|
59
|
+
"your scaffolding, and it can look right while the thing the guide is about does not. If the " +
|
|
60
|
+
"only way you could get a picture was to build something, that is not evidence the real scree" +
|
|
61
|
+
"n works — say what you built and why in `writeup`, and leave the check to the commands that " +
|
|
62
|
+
"do hold. NEVER DELETE WHAT YOUR EVIDENCE POINTS AT: a shot of a page you removed afterwards " +
|
|
63
|
+
"is one nobody can take again, including you.";
|
|
64
|
+
|
|
32
65
|
const KEEP_EVIDENCE =
|
|
33
66
|
"KEEP YOUR EVIDENCE AS YOU GO. hand_in needs it: the commands you ran and what came back, the " +
|
|
34
67
|
"test summary, the link to the change. Copy each one when it happens — at the end you will be " +
|
|
35
68
|
"writing from memory, which is the thing evidence is here to replace.\n\n";
|
|
36
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Said in front of every guide an agent has to locate things in for itself — a bug and a task.
|
|
72
|
+
*
|
|
73
|
+
* The failure it is here to stop: an agent lists one directory, or opens the one file it expected,
|
|
74
|
+
* and concludes from that. It then reports that a thing is absent, or that it found the only
|
|
75
|
+
* occurrence, having never looked past the place it happened to start. The conclusion is confident
|
|
76
|
+
* and wrong, and nothing downstream can tell, because the evidence it brings is real — it is just
|
|
77
|
+
* evidence of a search that was too small.
|
|
78
|
+
*
|
|
79
|
+
* Mirrors SEARCH_WIDE in apps/api/src/mcp-http.ts.
|
|
80
|
+
*/
|
|
81
|
+
const SEARCH_WIDE =
|
|
82
|
+
"SEARCH THE WHOLE TREE BEFORE YOU CONCLUDE. Listing a directory, or reading the one file you " +
|
|
83
|
+
"expected, tells you what is in that directory — not whether the thing exists, and not whether " +
|
|
84
|
+
"you have found all of it. Before you report that something is absent, is the only one, or is " +
|
|
85
|
+
'already handled, search across the repository and let the output be your reason. "I looked" ' +
|
|
86
|
+
"is a claim; the search and what it printed is evidence.\n\n";
|
|
87
|
+
|
|
37
88
|
/**
|
|
38
89
|
* What to say in front of a guide whose kind changes what the reader should do with it.
|
|
39
90
|
*
|
|
@@ -48,6 +99,8 @@ function leadFor(meta) {
|
|
|
48
99
|
"THIS IS A BUG REPORT, NOT WORK TO REPEAT. Do not follow Reproduce as instructions " +
|
|
49
100
|
"— those steps produce the defect. Fix what Problem describes, then check " +
|
|
50
101
|
"Verification and answer with hand_in.\n\n" +
|
|
102
|
+
SEARCH_WIDE +
|
|
103
|
+
SHOOT_THE_REAL_THING +
|
|
51
104
|
KEEP_EVIDENCE
|
|
52
105
|
);
|
|
53
106
|
if (meta.kind === "task")
|
|
@@ -57,6 +110,8 @@ function leadFor(meta) {
|
|
|
57
110
|
"under Acceptance holds. Opening it here does not make it yours: to work on it, call take " +
|
|
58
111
|
"with its id in the repo it is for, so no other agent can. Then progress, and hand_in " +
|
|
59
112
|
"with a write-up.\n\n" +
|
|
113
|
+
SEARCH_WIDE +
|
|
114
|
+
SHOOT_THE_REAL_THING +
|
|
60
115
|
KEEP_EVIDENCE
|
|
61
116
|
);
|
|
62
117
|
// A transfer guide is handed over untouched: it is a document to follow, and anything in front
|
|
@@ -206,7 +261,9 @@ export function buildServer() {
|
|
|
206
261
|
"Collect it as you work rather than writing it from memory at the end. hand_in without " +
|
|
207
262
|
"it is refused, because the write-up and the verdict are both your word for your own " +
|
|
208
263
|
"work and evidence is the part the person reviewing it can check.\n" +
|
|
209
|
-
|
|
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(", ")}. ` +
|
|
210
267
|
"It has no Steps: work out how to reach Goal within Constraints, leave Out of scope " +
|
|
211
268
|
"alone, and treat Acceptance as the definition of done. hand_in with `markdown`: a " +
|
|
212
269
|
"transfer guide about what you did (guide_template kind transfer shows the shape) — its " +
|
|
@@ -326,7 +383,19 @@ export function buildServer() {
|
|
|
326
383
|
}
|
|
327
384
|
}
|
|
328
385
|
|
|
329
|
-
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
|
+
}) {
|
|
330
399
|
try {
|
|
331
400
|
// A check that names a command is run here, before anything is recorded, and its exit code
|
|
332
401
|
// decides it rather than the agent's account of it. A failure is refused with the command's
|
|
@@ -347,6 +416,7 @@ export function buildServer() {
|
|
|
347
416
|
evidence: evidence || "",
|
|
348
417
|
checks: done.checks,
|
|
349
418
|
writeup: writeup || "",
|
|
419
|
+
risk: risk || "",
|
|
350
420
|
markdown,
|
|
351
421
|
report,
|
|
352
422
|
pr: pr || "",
|
|
@@ -686,14 +756,16 @@ export function buildServer() {
|
|
|
686
756
|
"the shell command that proves this line, run here before the hand-in lands — " +
|
|
687
757
|
"its exit code decides the check and its output is recorded as the evidence. " +
|
|
688
758
|
"Non-zero refuses the hand-in. Leave it out for a check nobody can run, like " +
|
|
689
|
-
'"the badge reads 3"
|
|
759
|
+
'"the badge reads 3" — then SHOW it with attach_screenshot and put the line it returns in `ran`; a check is run or shown, and describing what you saw is refused',
|
|
690
760
|
),
|
|
691
761
|
}),
|
|
692
762
|
)
|
|
693
763
|
.optional()
|
|
694
764
|
.describe(
|
|
695
765
|
"one entry per line the guide asks for — Acceptance on a task, Verification on a " +
|
|
696
|
-
"handoff or a bug — in the order you worked them"
|
|
766
|
+
"handoff or a bug — in the order you worked them. Each is RUN (`cmd`) or SHOWN (an " +
|
|
767
|
+
"attach_screenshot line in `ran`). Pasting a command you typed into `ran` is not " +
|
|
768
|
+
"running it and is refused",
|
|
697
769
|
),
|
|
698
770
|
ok: z.boolean().optional().describe("handoff or bug: did its Verification hold"),
|
|
699
771
|
note: z.string().optional().describe("one line; required when ok is false"),
|
|
@@ -706,6 +778,15 @@ export function buildServer() {
|
|
|
706
778
|
"it out when it worked as written. The next person to open the guide is shown it, " +
|
|
707
779
|
"which is why it does not need to be a guide of its own",
|
|
708
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
|
+
),
|
|
709
790
|
markdown: z
|
|
710
791
|
.string()
|
|
711
792
|
.optional()
|
|
@@ -801,19 +882,44 @@ export function buildServer() {
|
|
|
801
882
|
title: "Attach a screenshot",
|
|
802
883
|
annotations: { ...ADDS, openWorldHint: true },
|
|
803
884
|
description:
|
|
804
|
-
"Upload an image
|
|
805
|
-
"
|
|
806
|
-
|
|
807
|
-
"claims whatever the markdown names, so attach first and publish after. png, jpg, webp " +
|
|
808
|
-
"or gif.",
|
|
885
|
+
"Upload an image as evidence — a path in `file`, or the bytes in `data` when you have " +
|
|
886
|
+
"the image and no file — and get back the markdown line that points at it. " +
|
|
887
|
+
WHERE_THE_LINE_GOES,
|
|
809
888
|
inputSchema: {
|
|
810
|
-
file: z
|
|
889
|
+
file: z
|
|
890
|
+
.string()
|
|
891
|
+
.default("")
|
|
892
|
+
.describe("path to the image on this machine; leave out when sending `data`"),
|
|
893
|
+
data: z
|
|
894
|
+
.string()
|
|
895
|
+
.default("")
|
|
896
|
+
.describe(
|
|
897
|
+
"the image itself, base64 (a data: URL is fine), for when you have the bytes and no " +
|
|
898
|
+
"file — a browser tool that hands back an image inline and never writes to disk. " +
|
|
899
|
+
"5MB; larger belongs in create_upload",
|
|
900
|
+
),
|
|
901
|
+
type: z
|
|
902
|
+
.string()
|
|
903
|
+
.default("image/png")
|
|
904
|
+
.describe("media type of `data`: image/png, image/jpeg, image/webp or image/gif"),
|
|
811
905
|
name: z.string().default("").describe("label for the image; defaults to its filename"),
|
|
812
906
|
},
|
|
813
907
|
},
|
|
814
|
-
async ({ file, name }) => {
|
|
908
|
+
async ({ file, data, type, name }) => {
|
|
815
909
|
try {
|
|
816
|
-
|
|
910
|
+
if (!file && !data) {
|
|
911
|
+
return fail(
|
|
912
|
+
new Error(
|
|
913
|
+
"send `file` (a path on this machine) or `data` (the image, base64). If your " +
|
|
914
|
+
"screenshot is a tool result you cannot write out, capture it to a file instead " +
|
|
915
|
+
"— a headless browser's page.screenshot({ path }) or your platform's capture " +
|
|
916
|
+
"command — and pass that path.",
|
|
917
|
+
),
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
const shot = data
|
|
921
|
+
? await passalong.attachBytes(data, { name: name || "", type: type || "image/png" })
|
|
922
|
+
: await passalong.attach(file, { name: name || "" });
|
|
817
923
|
return text(
|
|
818
924
|
`${JSON.stringify({ id: shot.id, url: shot.url, bytes: shot.bytes }, null, 2)}\n\n` +
|
|
819
925
|
`Put this in the guide body:\n${shot.markdown}`,
|
package/src/passalong.js
CHANGED
|
@@ -7,6 +7,7 @@ import { hostname } from "node:os";
|
|
|
7
7
|
import { basename, join } from "node:path";
|
|
8
8
|
import * as api from "./api.js";
|
|
9
9
|
import { context } from "./capture.js";
|
|
10
|
+
import { refusal, runChecks } from "./checks.js";
|
|
10
11
|
import {
|
|
11
12
|
bugGuide,
|
|
12
13
|
ID_RE,
|
|
@@ -357,8 +358,16 @@ export async function finishTask(
|
|
|
357
358
|
requireEvidence(evidence);
|
|
358
359
|
if (!report && markdown) {
|
|
359
360
|
const g = parse(markdown);
|
|
360
|
-
|
|
361
|
-
|
|
361
|
+
// A task's report is a transfer guide, and this code is what is making one — so it says so
|
|
362
|
+
// rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
|
|
363
|
+
// agent's hand-in because its write-up left a line off would be this function's omission
|
|
364
|
+
// charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
|
|
365
|
+
// author's to leave open, because the caller already knows the answer.
|
|
366
|
+
report = (
|
|
367
|
+
await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
|
|
368
|
+
cwd,
|
|
369
|
+
})
|
|
370
|
+
).guide.meta.id;
|
|
362
371
|
}
|
|
363
372
|
if (!report)
|
|
364
373
|
throw new PassalongError(
|
|
@@ -413,6 +422,7 @@ export async function handIn(
|
|
|
413
422
|
evidence = "",
|
|
414
423
|
checks = [],
|
|
415
424
|
writeup = "",
|
|
425
|
+
risk = "",
|
|
416
426
|
markdown,
|
|
417
427
|
report,
|
|
418
428
|
pr = "",
|
|
@@ -420,6 +430,21 @@ export async function handIn(
|
|
|
420
430
|
} = {},
|
|
421
431
|
) {
|
|
422
432
|
needsSync("handing work in");
|
|
433
|
+
// A check that names a command is run here, before anything is sent.
|
|
434
|
+
//
|
|
435
|
+
// The MCP server did this and this function did not, so "put the command in `cmd` and it is
|
|
436
|
+
// executed here" was true over one transport and a quiet lie over the other: a `cmd` handed to
|
|
437
|
+
// this function was passed along as a string nothing had executed. Checks that already carry an
|
|
438
|
+
// exit came from the server's own runner and are left alone, so nothing runs twice.
|
|
439
|
+
const pending = checks.filter((c) => c?.cmd && typeof c?.exit !== "number" && c?.exit !== null);
|
|
440
|
+
if (pending.length) {
|
|
441
|
+
const done = runChecks(pending, { cwd });
|
|
442
|
+
const byCheck = new Map(done.checks.map((c) => [c.check, c]));
|
|
443
|
+
checks = checks.map((c) => byCheck.get(c.check) ?? c);
|
|
444
|
+
if (done.failed) {
|
|
445
|
+
throw new Error(refusal(done.failed, { ran: done.ran, total: checks.length }));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
423
448
|
// Checks are evidence, sorted against the lines they answer: bringing them is bringing it. A
|
|
424
449
|
// check whose command was actually run is exempt from the length rule — its exit code is the
|
|
425
450
|
// evidence, and `test -f dist/app.js` exiting 0 says more than any sentence about it would.
|
|
@@ -427,8 +452,16 @@ export async function handIn(
|
|
|
427
452
|
else requireEvidence(evidence);
|
|
428
453
|
if (!report && markdown) {
|
|
429
454
|
const g = parse(markdown);
|
|
430
|
-
|
|
431
|
-
|
|
455
|
+
// A task's report is a transfer guide, and this code is what is making one — so it says so
|
|
456
|
+
// rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
|
|
457
|
+
// agent's hand-in because its write-up left a line off would be this function's omission
|
|
458
|
+
// charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
|
|
459
|
+
// author's to leave open, because the caller already knows the answer.
|
|
460
|
+
report = (
|
|
461
|
+
await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
|
|
462
|
+
cwd,
|
|
463
|
+
})
|
|
464
|
+
).guide.meta.id;
|
|
432
465
|
}
|
|
433
466
|
const fence = fenceFor(id, cwd);
|
|
434
467
|
return api.handIn(id, {
|
|
@@ -450,6 +483,8 @@ export async function handIn(
|
|
|
450
483
|
// Left out when there is nothing to say, rather than sent as "": most hand-ins have nothing
|
|
451
484
|
// to adapt, and the field is for the ones that do.
|
|
452
485
|
...(writeup ? { writeup } : {}),
|
|
486
|
+
// What it could break, for the reviewer. Left out when empty, like the write-up.
|
|
487
|
+
...(risk ? { risk } : {}),
|
|
453
488
|
...(typeof ok === "boolean" ? { ok } : {}),
|
|
454
489
|
});
|
|
455
490
|
}
|
|
@@ -635,6 +670,46 @@ export const SHOT_TYPES = {
|
|
|
635
670
|
* Local because it reads a path. An agent in a terminal has one; a hosted assistant does not, and
|
|
636
671
|
* reaches the same route through `attach_screenshot` on the HTTP server instead.
|
|
637
672
|
*/
|
|
673
|
+
/**
|
|
674
|
+
* The same upload, for an agent that has the image and not a path to it.
|
|
675
|
+
*
|
|
676
|
+
* `attach()` takes a path because a screenshot usually lands on disk. An agent driving a browser
|
|
677
|
+
* often has the opposite: the pane hands back an image as a tool result and never writes a file, so
|
|
678
|
+
* the only door into evidence was one it could not open. It did what anyone would — swapped to
|
|
679
|
+
* `grep` and `tsc` checks and put what it saw in `writeup` — and said so itself: "a grep proves the
|
|
680
|
+
* code changed, not that the screen renders right."
|
|
681
|
+
*
|
|
682
|
+
* Base64 in a tool call, which the HTTP server's instructions rightly warn against, because there
|
|
683
|
+
* the alternative is `create_upload` and a curl. Here there is no alternative for bytes held in
|
|
684
|
+
* memory, and this server is a local process talking to the same upload the file path uses.
|
|
685
|
+
*/
|
|
686
|
+
export async function attachBytes(data, { name = "", type = "image/png" } = {}) {
|
|
687
|
+
if (!api.loggedIn())
|
|
688
|
+
throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
|
|
689
|
+
const allowed = new Set(Object.values(SHOT_TYPES));
|
|
690
|
+
if (!allowed.has(type))
|
|
691
|
+
throw new PassalongError(`screenshots must be ${[...allowed].join(", ")} — ${type} is not one`);
|
|
692
|
+
let bytes;
|
|
693
|
+
try {
|
|
694
|
+
// A data: URL is what several browser tools hand back, so it is accepted rather than made the
|
|
695
|
+
// caller's problem to strip.
|
|
696
|
+
bytes = Buffer.from(String(data).replace(/^data:[^,]*,/, ""), "base64");
|
|
697
|
+
} catch {
|
|
698
|
+
throw new PassalongError("`data` is not base64");
|
|
699
|
+
}
|
|
700
|
+
if (!bytes.length) throw new PassalongError("`data` decoded to nothing");
|
|
701
|
+
// The server's own ceiling, checked here so a large paste fails before it is sent rather than
|
|
702
|
+
// after. Anything bigger belongs in create_upload, which streams from a file.
|
|
703
|
+
if (bytes.length > 5 * 1024 * 1024)
|
|
704
|
+
throw new PassalongError(
|
|
705
|
+
`that image is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 5MB — ` +
|
|
706
|
+
"write it to a file and use create_upload, which streams",
|
|
707
|
+
);
|
|
708
|
+
const label = name || "screenshot";
|
|
709
|
+
const shot = await api.uploadShot(bytes, type, label);
|
|
710
|
+
return { ...shot, markdown: `` };
|
|
711
|
+
}
|
|
712
|
+
|
|
638
713
|
export async function attach(file, { name = "" } = {}) {
|
|
639
714
|
if (!api.loggedIn())
|
|
640
715
|
throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
|
|
@@ -657,6 +732,11 @@ export async function attach(file, { name = "" } = {}) {
|
|
|
657
732
|
* found doing it. Whoever opens the original, person or agent, gets its follow-ups with it, which
|
|
658
733
|
* is why context belongs there rather than in a one-line verdict note.
|
|
659
734
|
*
|
|
735
|
+
* What an agent did and found working on the guide is not context for it: that is the hand-in,
|
|
736
|
+
* and the note says so. It used to list "what you found doing it" among the things to publish, on
|
|
737
|
+
* every `take`, while hand_in said a follow-up was not for answering — the agent followed the one
|
|
738
|
+
* it read last, and the author got a second guide to review with the evidence inside it.
|
|
739
|
+
*
|
|
660
740
|
* Trailing, like the handoff nudge, so it never sits in front of the frontmatter. It is here and
|
|
661
741
|
* not only in the server's instructions because an agent reads the payload it is working from and
|
|
662
742
|
* skims everything else — the same reason the bug lead is inside the document. For a bug, the
|
|
@@ -673,9 +753,10 @@ export function followUpNote(meta = {}) {
|
|
|
673
753
|
);
|
|
674
754
|
return (
|
|
675
755
|
"<!-- passalong: a follow-up is more context for this guide, written as its own guide. If " +
|
|
676
|
-
"this guide needs more — a missing detail, a step that needed explaining, what changed since
|
|
677
|
-
|
|
678
|
-
"
|
|
756
|
+
"this guide needs more — a missing detail, a step that needed explaining, what changed since " +
|
|
757
|
+
`— publish that with publish_guide parent=${meta.id}, and whoever opens this guide gets it ` +
|
|
758
|
+
"too. What you did and found working on it is not a follow-up: it goes on hand_in, in " +
|
|
759
|
+
"`checks` and `writeup`. -->"
|
|
679
760
|
);
|
|
680
761
|
}
|
|
681
762
|
|