passalong 0.5.0 → 0.8.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 +2 -2
- package/bin/passalong +288 -20
- package/package.json +1 -1
- package/skill/SKILL.md +28 -1
- package/src/api.js +75 -1
- package/src/checks.js +127 -0
- package/src/guide.js +27 -5
- package/src/hooks.js +52 -5
- package/src/mcp.js +256 -135
- package/src/passalong.js +181 -20
- package/src/update.js +116 -0
package/src/checks.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running an Acceptance check, rather than reading the agent's account of running it.
|
|
3
|
+
*
|
|
4
|
+
* A hand-in used to be the agent's word about its own work: `ran` was a string, and the only rule
|
|
5
|
+
* was that it was longer than a verdict. "ran the tests, all green" passed. This module is the
|
|
6
|
+
* other half — when a check names the command that proves it, the command is run here and the exit
|
|
7
|
+
* code is recorded from the process, so the agent reports what it did and the machine reports how
|
|
8
|
+
* it went.
|
|
9
|
+
*
|
|
10
|
+
* **The command comes from the agent's own hand_in call, never from a guide.** A guide is written
|
|
11
|
+
* somewhere else and pulled from a stranger's account; lifting a command out of its Acceptance
|
|
12
|
+
* section and running it would turn every pulled guide into remote code execution. Nothing here
|
|
13
|
+
* parses guide markdown, and nothing should.
|
|
14
|
+
*
|
|
15
|
+
* This runs only where there is a shell. `src/mcp.js` is a local stdio server on the agent's own
|
|
16
|
+
* machine, so it can; `apps/api/src/mcp-http.ts` is a Worker, so it cannot, and a hand-in over HTTP
|
|
17
|
+
* keeps the prose gate that `evidenceProblem()` applies.
|
|
18
|
+
*
|
|
19
|
+
* What this deliberately does NOT do is parse test output. SWE-bench needs 57 per-framework log
|
|
20
|
+
* parsers to tell a skipped test from a passing one, and a half-parser that guesses is worse than
|
|
21
|
+
* none: it would refuse honest runs in shapes nobody thought of, and pass the ones it misread. The
|
|
22
|
+
* exit code is the whole verdict here, which is also how a CI step decides.
|
|
23
|
+
*/
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
|
|
26
|
+
/** How long a check may run before it is killed. Long enough for a real suite, short of a lease. */
|
|
27
|
+
export const CHECK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** The most captured output kept per check. Matches EVIDENCE_MAX in apps/api/src/claims.ts. */
|
|
30
|
+
export const OUTPUT_MAX = 4000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The tail of what a command printed, because that is where a runner puts what went wrong. A head
|
|
34
|
+
* would hold the banner and the first hundred passing lines.
|
|
35
|
+
*/
|
|
36
|
+
function tail(out, max = OUTPUT_MAX) {
|
|
37
|
+
const s = String(out ?? "").replace(/\s+$/, "");
|
|
38
|
+
if (s.length <= max) return s;
|
|
39
|
+
return `… ${s.length - max} earlier characters cut …\n${s.slice(-max)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One check, run if it names a command.
|
|
44
|
+
*
|
|
45
|
+
* Returns the check with three things added: `cmd` as it was run, `exit` — what the process
|
|
46
|
+
* returned — and `ok`, whether the check is met. They are two fields on purpose. SARIF keeps
|
|
47
|
+
* `exitCode` beside `executionSuccessful` "because not all programs exit with an exit code of 0 on
|
|
48
|
+
* success and non-0 on failure", and GitHub Actions keeps `outcome` beside `conclusion` so a
|
|
49
|
+
* policy that forgives a failure never overwrites what happened. Today `ok` is `exit === 0` and
|
|
50
|
+
* nothing else; keeping the pair means a check that needs a different rule later does not have to
|
|
51
|
+
* rewrite what was already recorded.
|
|
52
|
+
*
|
|
53
|
+
* A command that never ran — no such file, a shell that died, a timeout — is not a failing check.
|
|
54
|
+
* It is an unknown, and it is recorded as one so that "it did not run" cannot read as "it ran and
|
|
55
|
+
* found nothing wrong".
|
|
56
|
+
*/
|
|
57
|
+
export function runCheck(check, { cwd = process.cwd(), timeoutMs = CHECK_TIMEOUT_MS } = {}) {
|
|
58
|
+
const cmd = String(check?.cmd ?? "").trim();
|
|
59
|
+
if (!cmd) return { check: check.check, ran: check.ran };
|
|
60
|
+
|
|
61
|
+
const r = spawnSync(cmd, {
|
|
62
|
+
shell: true,
|
|
63
|
+
cwd,
|
|
64
|
+
timeout: timeoutMs,
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const out = tail(`${r.stdout ?? ""}${r.stderr ?? ""}`);
|
|
70
|
+
// `status` is null when the child was killed rather than exited: a timeout, or a signal.
|
|
71
|
+
if (r.error || r.status === null) {
|
|
72
|
+
const why =
|
|
73
|
+
r.signal === "SIGTERM" && r.error?.code === "ETIMEDOUT"
|
|
74
|
+
? `did not finish within ${Math.round(timeoutMs / 1000)}s`
|
|
75
|
+
: r.signal
|
|
76
|
+
? `was killed by ${r.signal}`
|
|
77
|
+
: `could not run: ${r.error?.message ?? "unknown error"}`;
|
|
78
|
+
return {
|
|
79
|
+
check: check.check,
|
|
80
|
+
cmd,
|
|
81
|
+
exit: null,
|
|
82
|
+
ok: false,
|
|
83
|
+
ran: `$ ${cmd}\nthe command ${why}${out ? `\n${out}` : ""}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
check: check.check,
|
|
89
|
+
cmd,
|
|
90
|
+
exit: r.status,
|
|
91
|
+
ok: r.status === 0,
|
|
92
|
+
// The recorded evidence is what the process printed, not what the agent said about it. An
|
|
93
|
+
// empty run is written out rather than left blank: the exit code is what decided it, and a
|
|
94
|
+
// blank `ran` reads like evidence nobody supplied.
|
|
95
|
+
ran: out ? `$ ${cmd}\n${out}` : `$ ${cmd}\n(no output; exited ${r.status})`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Every check, in the order the agent worked them, with the ones naming a command actually run.
|
|
101
|
+
*
|
|
102
|
+
* Stops at the first failure. A suite that fails at step two tells the agent what to fix, and
|
|
103
|
+
* running the remaining eight minutes of checks against a tree that is already wrong buys nothing.
|
|
104
|
+
*/
|
|
105
|
+
export function runChecks(checks = [], opts = {}) {
|
|
106
|
+
const done = [];
|
|
107
|
+
for (const c of checks) {
|
|
108
|
+
const r = runCheck(c, opts);
|
|
109
|
+
done.push(r);
|
|
110
|
+
if (r.ok === false) return { checks: done, failed: r, ran: done.filter((d) => d.cmd).length };
|
|
111
|
+
}
|
|
112
|
+
return { checks: done, failed: null, ran: done.filter((d) => d.cmd).length };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What the agent is told when a check it named did not hold. Its own output is the reason. */
|
|
116
|
+
export function refusal(failed, { ran, total }) {
|
|
117
|
+
const what =
|
|
118
|
+
failed.exit === null
|
|
119
|
+
? "did not run"
|
|
120
|
+
: `exited ${failed.exit}${failed.exit === 0 ? "" : ", so the check does not hold"}`;
|
|
121
|
+
return (
|
|
122
|
+
`not handed in: \`${failed.cmd}\` ${what}.\n\n` +
|
|
123
|
+
`${failed.ran}\n\n` +
|
|
124
|
+
`This was check ${ran} of ${total}, "${failed.check}". Fix what it found and hand in again. ` +
|
|
125
|
+
"Nothing was recorded, and you still hold this."
|
|
126
|
+
);
|
|
127
|
+
}
|
package/src/guide.js
CHANGED
|
@@ -37,8 +37,18 @@ export const SETTABLE = ["draft", "published", "consumed"];
|
|
|
37
37
|
* bug something broken there — do NOT follow anything; fix it
|
|
38
38
|
* task work nobody has done yet — do what Goal asks, done when Acceptance holds
|
|
39
39
|
*
|
|
40
|
-
* A guide with no `kind` is a transfer guide: every guide written before this existed is one,
|
|
41
|
-
*
|
|
40
|
+
* A guide with no `kind` is a transfer guide: every guide written before this existed is one, and
|
|
41
|
+
* defaulting the other way would turn them all into bug reports.
|
|
42
|
+
*
|
|
43
|
+
* That default is applied once, on read, and written back — `parseFrontmatter` seeds it the way it
|
|
44
|
+
* seeds `tags`, so nothing downstream has to decide what an empty kind means. It was decided in six
|
|
45
|
+
* places, each with its own `|| "transfer"`, and one of them said `|| "task"`. A guide either says
|
|
46
|
+
* what it is or is read as a transfer guide and then says so; there is no third state for anything
|
|
47
|
+
* to disagree about.
|
|
48
|
+
*
|
|
49
|
+
* Which is also why publishing an untyped guide is not refused. Refusing it leaves the document
|
|
50
|
+
* exactly as ambiguous as it was and adds a second state — a guide that exists and cannot be
|
|
51
|
+
* shared — where the point was to have fewer.
|
|
42
52
|
*/
|
|
43
53
|
export const KINDS = ["transfer", "bug", "task"];
|
|
44
54
|
|
|
@@ -245,6 +255,10 @@ export function parseFrontmatter(text) {
|
|
|
245
255
|
// Only the lists every guide has. `blocked_by` is a task's, and defaulting it would write an
|
|
246
256
|
// empty one into every guide anybody re-shares.
|
|
247
257
|
for (const k of ["stack_assumptions", "tags"]) if (meta[k] === undefined) meta[k] = [];
|
|
258
|
+
// What it is, said rather than inferred. An unknown spelling is left alone for `validate()` to
|
|
259
|
+
// refuse by name: quietly turning `kind: buggy` into a transfer guide is the same guess this is
|
|
260
|
+
// here to remove.
|
|
261
|
+
if (!String(meta.kind ?? "").trim()) meta.kind = "transfer";
|
|
248
262
|
// Both ends of this module: what it reads and what it writes are in one style, so a guide
|
|
249
263
|
// written before there was a rule comes back normalised and goes out normalised.
|
|
250
264
|
meta.tags = tagList(meta.tags);
|
|
@@ -320,7 +334,11 @@ export function sections(body) {
|
|
|
320
334
|
const h = /^##\s+(.+?)\s*$/.exec(line);
|
|
321
335
|
if (h) {
|
|
322
336
|
current = h[1];
|
|
323
|
-
|
|
337
|
+
// A heading written twice adds to its section rather than replacing it. Resetting here lost
|
|
338
|
+
// the first block outright, so a guide with two `## Verification` headings was validated
|
|
339
|
+
// against only the second — and `splitSections()` in apps/api/src/guide.ts, which the web
|
|
340
|
+
// view reads, kept both. The corpus in fixtures/guides is where the two stopped agreeing.
|
|
341
|
+
if (!(current in out)) out[current] = "";
|
|
324
342
|
continue;
|
|
325
343
|
}
|
|
326
344
|
if (current) out[current] += `${line}\n`;
|
|
@@ -365,8 +383,12 @@ export function validate({ meta, body }) {
|
|
|
365
383
|
if (meta.status && !STATUSES.includes(meta.status)) {
|
|
366
384
|
errors.push(`status must be one of ${STATUSES.join(", ")}`);
|
|
367
385
|
}
|
|
368
|
-
|
|
369
|
-
|
|
386
|
+
// Absent is refused here, and this is the one place it can be. Anything read from a document has
|
|
387
|
+
// a kind — `parseFrontmatter` seeds it — so what reaches this without one was built field by
|
|
388
|
+
// field in code, and code is exactly what should have to say what it is making.
|
|
389
|
+
const kind = meta.kind;
|
|
390
|
+
if (!kind) errors.push(`say what this is: kind must be one of ${KINDS.join(", ")}`);
|
|
391
|
+
else if (!KINDS.includes(kind)) errors.push(`kind must be one of ${KINDS.join(", ")}`);
|
|
370
392
|
const have = sections(body);
|
|
371
393
|
for (const s of REQUIRED[kind] || REQUIRED.transfer) {
|
|
372
394
|
if (!have[s]) errors.push(`missing "## ${s}" section`);
|
package/src/hooks.js
CHANGED
|
@@ -25,14 +25,17 @@ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
|
25
25
|
export function nowText({ held, waiting = {} }) {
|
|
26
26
|
if (held) {
|
|
27
27
|
const lines = [
|
|
28
|
-
`
|
|
28
|
+
// `kind` is required on what /v1/working sends. The `|| "task"` that used to be here was
|
|
29
|
+
// both dead and wrong — absent has meant transfer since kinds existed, never task.
|
|
30
|
+
`Passalong: You hold ${held.id} (${held.kind}): ${held.title || held.id}.`,
|
|
29
31
|
];
|
|
30
32
|
if (held.note) lines.push(`Last note: "${held.note}".`);
|
|
31
33
|
if (held.state === "stalled")
|
|
32
34
|
lines.push("It is marked stalled: nobody has heard from its agent in 30 minutes.");
|
|
33
35
|
lines.push(
|
|
34
36
|
`To carry on, call take ${held.id} (it resumes what you hold), then progress at each ` +
|
|
35
|
-
"milestone. When it is done, hand_in
|
|
37
|
+
"milestone. When it is done, hand_in with evidence — what you ran and what came back — " +
|
|
38
|
+
"so keep it as you go; if it is not yours or you are stuck, pass with the reason.",
|
|
36
39
|
);
|
|
37
40
|
return lines.join("\n");
|
|
38
41
|
}
|
|
@@ -57,9 +60,9 @@ export function stopVerdict({ held, input = {} }) {
|
|
|
57
60
|
decision: "block",
|
|
58
61
|
reason:
|
|
59
62
|
`You still hold ${held.id}: ${held.title || held.id}. Before ending, do one of: ` +
|
|
60
|
-
`hand_in ${held.id} if it is done; pass ${held.id} with the reason if
|
|
61
|
-
`you are stuck; or, if the user is stopping mid-way, progress ${held.id}
|
|
62
|
-
"saying what is left — then end.",
|
|
63
|
+
`hand_in ${held.id} with your evidence if it is done; pass ${held.id} with the reason if ` +
|
|
64
|
+
`it is not yours or you are stuck; or, if the user is stopping mid-way, progress ${held.id} ` +
|
|
65
|
+
"with a note saying what is left — then end.",
|
|
63
66
|
};
|
|
64
67
|
}
|
|
65
68
|
|
|
@@ -80,3 +83,47 @@ export function withHooks(settings = {}) {
|
|
|
80
83
|
}
|
|
81
84
|
return out;
|
|
82
85
|
}
|
|
86
|
+
|
|
87
|
+
// ---- the status line -------------------------------------------------------------------------------
|
|
88
|
+
//
|
|
89
|
+
// Claude Code draws a status line from a command's output, under the prompt, all session long. It is
|
|
90
|
+
// the one place a person sees what their agent holds without asking. The command is
|
|
91
|
+
// `passalong now --statusline`, which prints from a local cache and refreshes it in the background:
|
|
92
|
+
// Claude Code cancels a status line that is still running when the next update arrives, so a
|
|
93
|
+
// network call in the foreground would leave the line blank.
|
|
94
|
+
|
|
95
|
+
/** The status line command, as written into settings. */
|
|
96
|
+
export const STATUS_LINE = "passalong now --statusline";
|
|
97
|
+
|
|
98
|
+
/** Keep a one-line note to what fits beside everything else in a terminal footer. */
|
|
99
|
+
const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One line: what this worktree's agent holds, then what needs you and what is ready here.
|
|
103
|
+
* `needs` counts everything waiting on the person: work handed in for review, agents stuck on
|
|
104
|
+
* them, and guides handed to them.
|
|
105
|
+
*/
|
|
106
|
+
export function statusText({ held, needs = 0, ready = 0 }) {
|
|
107
|
+
const parts = [];
|
|
108
|
+
if (held) {
|
|
109
|
+
const said = held.note ? ` "${clip(held.note, 32)}"` : "";
|
|
110
|
+
parts.push(`▸ ${held.id}${held.state === "stalled" ? " (stalled)" : ""}${said}`);
|
|
111
|
+
} else parts.push("passalong");
|
|
112
|
+
if (needs) parts.push(`${needs} need${needs === 1 ? "s" : ""} you`);
|
|
113
|
+
if (ready) parts.push(`${ready} ready`);
|
|
114
|
+
if (parts.length === 1 && !held) parts.push("clear");
|
|
115
|
+
return parts.join(" · ");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Settings with the status line added, when there is none. A status line someone already has is
|
|
120
|
+
* theirs — a script with their git branch or their context meter — and setup never replaces it.
|
|
121
|
+
* `refreshInterval` keeps it current while the session sits idle.
|
|
122
|
+
*/
|
|
123
|
+
export function withStatusLine(settings = {}) {
|
|
124
|
+
if (settings.statusLine) return settings;
|
|
125
|
+
return {
|
|
126
|
+
...structuredClone(settings),
|
|
127
|
+
statusLine: { type: "command", command: STATUS_LINE, refreshInterval: 30 },
|
|
128
|
+
};
|
|
129
|
+
}
|