passalong 0.6.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 +220 -15
- package/package.json +1 -1
- package/skill/SKILL.md +28 -1
- package/src/api.js +74 -1
- package/src/checks.js +127 -0
- package/src/guide.js +27 -5
- package/src/hooks.js +8 -5
- package/src/mcp.js +256 -135
- package/src/passalong.js +156 -8
- package/src/update.js +116 -0
package/README.md
CHANGED
|
@@ -35,11 +35,11 @@ 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
36
|
passalong works <id> you tried it and it holds up
|
|
37
37
|
passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
|
|
38
|
-
passalong
|
|
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
|
|
40
40
|
passalong rm <id> delete a guide locally and from sync
|
|
41
41
|
passalong export [dir] dump every guide as plain markdown
|
|
42
|
-
passalong login [token]
|
|
42
|
+
passalong login [token] sign in with your email and password, or attach this machine with a token
|
|
43
43
|
passalong me [--handle H] [--name N] [--email E] who you are to teammates
|
|
44
44
|
passalong team current team and its members
|
|
45
45
|
passalong team create <name> start a team (you become its owner)
|
package/bin/passalong
CHANGED
|
@@ -25,12 +25,20 @@
|
|
|
25
25
|
// passalong hub open your synced guides in the browser
|
|
26
26
|
// passalong take <id> you are doing it — said before the work, so the sender stops guessing
|
|
27
27
|
// passalong pass <id> <why> it is not yours — hands it back to its author with your reason
|
|
28
|
+
// passalong assign <id> <to> give it to @handle, a #group, or "team" — yours, or assigned to you
|
|
29
|
+
// passalong admin create <email> make an account that runs Passalong (owner only)
|
|
30
|
+
// passalong admin list who runs it
|
|
31
|
+
// passalong admin grant|revoke <@handle> make an existing account an admin, or stop it being one
|
|
32
|
+
// passalong gift <who> --until <date> [--why "..."] [--seats N]
|
|
33
|
+
// give Solo to @handle, or Team to team/slug, until a date (operator only)
|
|
34
|
+
// passalong gifts what has been given away, and what is still running
|
|
35
|
+
// passalong ungift <gift id> take one back now
|
|
28
36
|
// passalong works <id> you tried it and it holds up
|
|
29
37
|
// passalong broken <id> <why> you tried it and it does not — the author is told, with your reason
|
|
30
|
-
// passalong
|
|
38
|
+
// passalong archive <id> off your board and out of the free tier's count (was `done`)
|
|
31
39
|
// passalong rm <id> delete a guide locally and from sync
|
|
32
40
|
// passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
|
|
33
|
-
// passalong login [token]
|
|
41
|
+
// passalong login [token] sign in with your email and password, or attach this machine with a token
|
|
34
42
|
// passalong me [--handle H] [--name N] [--email E] who you are to teammates
|
|
35
43
|
// passalong team current team and its members
|
|
36
44
|
// passalong team create <name> start a team (you become its owner)
|
|
@@ -44,7 +52,7 @@
|
|
|
44
52
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
45
53
|
import { createHash } from "node:crypto";
|
|
46
54
|
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
47
|
-
import { homedir } from "node:os";
|
|
55
|
+
import { homedir, hostname } from "node:os";
|
|
48
56
|
import { dirname, join, resolve } from "node:path";
|
|
49
57
|
import { fileURLToPath } from "node:url";
|
|
50
58
|
import * as api from "../src/api.js";
|
|
@@ -75,6 +83,38 @@ function die(msg) {
|
|
|
75
83
|
|
|
76
84
|
const tty = process.stdout.isTTY && process.stdin.isTTY;
|
|
77
85
|
|
|
86
|
+
/**
|
|
87
|
+
* One line typed at the terminal. `hidden` turns the echo off, so a password does not end up in a
|
|
88
|
+
* screen share, a scrollback or somebody's terminal recording.
|
|
89
|
+
*
|
|
90
|
+
* Written against readline rather than a dependency because this package has none beyond the MCP
|
|
91
|
+
* SDK and zod, and asking for a line of text is not where that changes.
|
|
92
|
+
*/
|
|
93
|
+
async function ask(question, { hidden = false } = {}) {
|
|
94
|
+
const { createInterface } = await import("node:readline");
|
|
95
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
96
|
+
if (hidden) {
|
|
97
|
+
// readline still writes the prompt itself; everything after it is swallowed. The newline the
|
|
98
|
+
// user's Return would have echoed is put back by hand, or the next line starts mid-prompt.
|
|
99
|
+
rl.output.write(question);
|
|
100
|
+
rl._writeToOutput = () => {};
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
// `close` as well as an answer: stdin reaching EOF part-way through — a pipe, a closed pty, a
|
|
104
|
+
// ^D — never calls the question's callback, and without this the promise is simply never
|
|
105
|
+
// settled and the process exits on a warning about it rather than saying what happened.
|
|
106
|
+
const answer = await new Promise((done) => {
|
|
107
|
+
rl.on("close", () => done(null));
|
|
108
|
+
rl.question(hidden ? "" : question, done);
|
|
109
|
+
});
|
|
110
|
+
if (hidden) process.stdout.write("\n");
|
|
111
|
+
if (answer === null) die("nothing to read from — sign in at the terminal, or pass a token");
|
|
112
|
+
return answer.trim();
|
|
113
|
+
} finally {
|
|
114
|
+
rl.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
78
118
|
function edit(path) {
|
|
79
119
|
const editor = process.env.VISUAL || process.env.EDITOR || "vi";
|
|
80
120
|
const r = spawnSync(editor, [path], { stdio: "inherit", shell: true });
|
|
@@ -478,6 +518,86 @@ const commands = {
|
|
|
478
518
|
console.error(`${r.id} handed back — the author has been told: "${r.note}"`);
|
|
479
519
|
},
|
|
480
520
|
|
|
521
|
+
async assign(f) {
|
|
522
|
+
const id = f._[0] || die('usage: passalong assign <id> <@handle | #group | team>');
|
|
523
|
+
const to = f._[1] || die('say who: @handle for one person, #group, or "team" for everyone');
|
|
524
|
+
const r = await passalong.assign(id, to).catch((e) => die(e.message));
|
|
525
|
+
const back = r.taken_back ? ` — taken back from ${r.taken_back} who had it, and they were told` : "";
|
|
526
|
+
console.error(`${r.id} is now for ${r.to || "everyone in the team"}${back}`);
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
// ---- who runs the product ---------------------------------------------------------------
|
|
530
|
+
// `passalong admin <create|list|grant|revoke>`. An admin signs in like anybody else: the role is
|
|
531
|
+
// a column, so there is no admin password and no admin token — only an account that has it.
|
|
532
|
+
// "admin" is the word everywhere a person reads; `super` stays the stored value.
|
|
533
|
+
|
|
534
|
+
async admin(f) {
|
|
535
|
+
const what = f._[0] || "list";
|
|
536
|
+
if (what === "create") {
|
|
537
|
+
const email = f._[1] || die("usage: passalong admin create <email>");
|
|
538
|
+
const r = await api.makeAdmin(email).catch((e) => die(e.message));
|
|
539
|
+
// Shown once, and only here. The link is a day long because it is handed to somebody
|
|
540
|
+
// rather than clicked on the spot.
|
|
541
|
+
// Three labelled values on stdout, so one can be cut out with awk. "password link", not
|
|
542
|
+
// "password": the value is a URL, and somebody will try to paste it into a password field.
|
|
543
|
+
console.log(`account ${r.account}`);
|
|
544
|
+
console.log(`token ${r.token}`);
|
|
545
|
+
console.log(`password link ${r.password_url}`);
|
|
546
|
+
console.error(
|
|
547
|
+
`\n${r.email} is an admin. The token is for \`passalong login\`, the link sets their ` +
|
|
548
|
+
`password for the hub, and it expires ${r.expires.slice(0, 10)}. Neither is shown again.`,
|
|
549
|
+
);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (what === "grant" || what === "revoke") {
|
|
553
|
+
const who = f._[1] || die(`usage: passalong admin ${what} <@handle | account id>`);
|
|
554
|
+
const call = what === "grant" ? api.grantAdmin : api.revokeAdmin;
|
|
555
|
+
const r = await call(who).catch((e) => die(e.message));
|
|
556
|
+
console.error(
|
|
557
|
+
what === "grant" ? `${r.name} is now an admin` : `${r.name} is no longer an admin`,
|
|
558
|
+
);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (what !== "list") die("usage: passalong admin <create <email> | list | grant | revoke>");
|
|
562
|
+
const { supers = [] } = await api.listAdmins().catch((e) => die(e.message));
|
|
563
|
+
if (!supers.length) return console.error("nobody is an admin yet");
|
|
564
|
+
for (const s of supers)
|
|
565
|
+
console.log(`${s.id} ${s.name}${s.email ? ` ${s.email}` : ""} since ${s.since.slice(0, 10)}`);
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
// ---- giving a plan away -----------------------------------------------------------------
|
|
569
|
+
// Only for whoever the deployment names in ADMIN_ACCOUNTS; for everybody else the routes behind
|
|
570
|
+
// these are 404, and the message says so rather than pretending the command does not exist.
|
|
571
|
+
|
|
572
|
+
async gift(f) {
|
|
573
|
+
const to = f._[0] || die('usage: passalong gift <@handle | team/slug> --until 2027-09-23 [--why "..."]');
|
|
574
|
+
const until = f.until || die("say when it ends: --until 2027-09-23. A gift with no end is a price change");
|
|
575
|
+
const r = await api
|
|
576
|
+
.giveGift({ to, until, why: f.why || "", seats: Number(f.seats || 0) })
|
|
577
|
+
.catch((e) => die(e.message));
|
|
578
|
+
// The seat count is said back whether or not it was asked for: left off, it is the number of
|
|
579
|
+
// members the team has today, and an operator should read which number that turned out to be.
|
|
580
|
+
const seats = r.kind === "team" ? ` for ${r.seats} seat${r.seats === 1 ? "" : "s"}` : "";
|
|
581
|
+
console.error(`${r.to} is on ${r.plan}${seats} until ${r.until.slice(0, 10)} — gift ${r.id}`);
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
async gifts() {
|
|
585
|
+
const { gifts = [] } = await api.listGifts().catch((e) => die(e.message));
|
|
586
|
+
if (!gifts.length) return console.error("no plans given away yet");
|
|
587
|
+
for (const g of gifts) {
|
|
588
|
+
const state = g.live ? `until ${g.until.slice(0, 10)}` : "over";
|
|
589
|
+
console.log(
|
|
590
|
+
`${g.id} ${g.plan.padEnd(5)} ${state.padEnd(16)} ${g.to}${g.why ? ` — ${g.why}` : ""}`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
async ungift(f) {
|
|
596
|
+
const id = f._[0] || die("usage: passalong ungift <gift id> (passalong gifts lists them)");
|
|
597
|
+
await api.takeGiftBack(id).catch((e) => die(e.message));
|
|
598
|
+
console.error(`${id} taken back — that plan is lapsed as of now`);
|
|
599
|
+
},
|
|
600
|
+
|
|
481
601
|
async works(f) {
|
|
482
602
|
const id = f._[0] || die("usage: passalong works <id>");
|
|
483
603
|
const r = await passalong.verdict(id, true, f._.slice(1).join(" ")).catch((e) => die(e.message));
|
|
@@ -495,16 +615,29 @@ const commands = {
|
|
|
495
615
|
/**
|
|
496
616
|
* Archive a guide: off your board, and out of what the free tier counts.
|
|
497
617
|
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
618
|
+
* The command was `done`, and the status it sets is still `consumed`, because both once meant "I
|
|
619
|
+
* implemented it". That meaning went to the verdict, where it belongs — it is the reader's
|
|
620
|
+
* judgement, not the author's — and what was left is shelf space, which has a job nothing else
|
|
621
|
+
* does: the only way under the sync limit that does not delete the work, and the 402 says so by
|
|
622
|
+
* name. So the command says what it does now.
|
|
623
|
+
*
|
|
624
|
+
* `consumed` stays on disk. It is a status inside markdown files in other people's
|
|
625
|
+
* repositories, and `validate()` refuses a status it does not know, so renaming the value would
|
|
626
|
+
* make a guide shared a month ago fail to re-share today. The word people read and the token the
|
|
627
|
+
* format stores are allowed to differ; what is not allowed is two words for people.
|
|
501
628
|
*/
|
|
502
|
-
async
|
|
503
|
-
const id = f._[0] || die("usage: passalong
|
|
629
|
+
async archive(f) {
|
|
630
|
+
const id = f._[0] || die("usage: passalong archive <id>");
|
|
504
631
|
const g = await passalong.setStatus(id, "consumed").catch((e) => die(e.message));
|
|
505
632
|
console.error(`${g.meta.id} archived — off your board, and it no longer counts against sync`);
|
|
506
633
|
},
|
|
507
634
|
|
|
635
|
+
/** What `archive` was called. Kept so a script written against it still runs, and says so. */
|
|
636
|
+
async done(f) {
|
|
637
|
+
console.error("`passalong done` is now `passalong archive` — it archives, it does not mark work done.");
|
|
638
|
+
return commands.archive(f);
|
|
639
|
+
},
|
|
640
|
+
|
|
508
641
|
async rm(f) {
|
|
509
642
|
const id = f._[0] || die("usage: passalong rm <id>");
|
|
510
643
|
await passalong.remove(id).catch((e) => die(e.message));
|
|
@@ -533,14 +666,40 @@ const commands = {
|
|
|
533
666
|
return;
|
|
534
667
|
}
|
|
535
668
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
669
|
+
// An account you cannot sign in to is one that dies with this file. This used to mint one
|
|
670
|
+
// silently and then suggest adding a password afterwards; it asks up front instead.
|
|
671
|
+
if (!tty)
|
|
672
|
+
die(
|
|
673
|
+
"no terminal to ask on. Sign in at " +
|
|
674
|
+
`${api.baseUrl()}/hub, then attach this machine with \`passalong login <token>\``,
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
const email = f.email || (await ask("Email: "));
|
|
678
|
+
if (!email) die("an email is needed to sign in");
|
|
679
|
+
const password = await ask("Password: ", { hidden: true });
|
|
680
|
+
if (!password) die("a password is needed to sign in");
|
|
681
|
+
|
|
682
|
+
const label = `${hostname()} cli`;
|
|
683
|
+
let who = await api.signIn(email, password, { label }).catch((e) => e);
|
|
684
|
+
if (who instanceof Error) {
|
|
685
|
+
// 401 is "no such account, or the wrong password", said the same way on purpose so the API
|
|
686
|
+
// does not confirm which addresses exist. The CLI has to ask rather than guess, because
|
|
687
|
+
// creating an account for a mistyped address is worse than being told to try again.
|
|
688
|
+
if (who.status !== 401) die(who.message);
|
|
689
|
+
const yes = (await ask(`No account signed in as ${email}. Create one? [y/N] `)).toLowerCase();
|
|
690
|
+
if (yes !== "y" && yes !== "yes")
|
|
691
|
+
die("not signed in. If the password was wrong, try again, or reset it at " + `${api.baseUrl()}/hub`);
|
|
692
|
+
const again = await ask("Choose a password: ", { hidden: true });
|
|
693
|
+
if (again !== password) die("those did not match");
|
|
694
|
+
who = await api.signIn(email, password, { make: true, label }).catch((e) => die(e.message));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
store.writeConfig({ token: who.token, api: api.baseUrl() });
|
|
698
|
+
console.error(`signed in as ${email} (account ${who.account}) on ${api.baseUrl()}. Sync is on.`);
|
|
539
699
|
console.error("Attach other machines with:");
|
|
540
|
-
console.error(` passalong login ${token}`);
|
|
700
|
+
console.error(` passalong login ${who.token}`);
|
|
541
701
|
console.error("(keep it private — it is a credential, not a password)");
|
|
542
702
|
console.error("Give teammates a name to address you by: passalong me --handle <you>");
|
|
543
|
-
console.error(`Add an email and password so you can sign in and recover it: ${api.baseUrl()}/hub`);
|
|
544
703
|
},
|
|
545
704
|
|
|
546
705
|
async me(f) {
|
|
@@ -722,6 +881,15 @@ const commands = {
|
|
|
722
881
|
await serve();
|
|
723
882
|
},
|
|
724
883
|
|
|
884
|
+
/**
|
|
885
|
+
* Ask the registry what the latest version is and write it down. Not in the help, because it is
|
|
886
|
+
* not for a person: `passalong` spawns it, detached, when the answer on file is a day old.
|
|
887
|
+
*/
|
|
888
|
+
async ["refresh-update"]() {
|
|
889
|
+
const { refresh } = await import("../src/update.js");
|
|
890
|
+
await refresh();
|
|
891
|
+
},
|
|
892
|
+
|
|
725
893
|
async help() {
|
|
726
894
|
const src = readFileSync(fileURLToPath(import.meta.url), "utf8");
|
|
727
895
|
const lines = src.split("\n").filter((l) => l.startsWith("// passalong "));
|
|
@@ -738,5 +906,42 @@ const cmd = argv._.shift() || "help";
|
|
|
738
906
|
// `passalong -v` can be read by a script comparing it against the registry.
|
|
739
907
|
if (cmd === "-v" || cmd === "version" || (cmd === "help" && argv.version === true)) {
|
|
740
908
|
console.log(JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version);
|
|
741
|
-
} else if (cmd === "--help" || cmd === "-h" ||
|
|
742
|
-
|
|
909
|
+
} else if (cmd === "--help" || cmd === "-h" || cmd === "help") commands.help();
|
|
910
|
+
// A command this build does not have used to print the help page and exit 0, which reads as "that
|
|
911
|
+
// command exists and did nothing" — and the usual cause is an older passalong, where the command is
|
|
912
|
+
// in the docs and not in the binary. So it fails, names what it does not know, and says the version
|
|
913
|
+
// it is, which is the fact that explains it.
|
|
914
|
+
else if (!commands[cmd])
|
|
915
|
+
die(
|
|
916
|
+
`unknown command "${cmd}" — run \`passalong help\` for the list. ` +
|
|
917
|
+
`This is passalong ${JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version}; ` +
|
|
918
|
+
"if you expected this command, `npm i -g passalong` for the newest one.",
|
|
919
|
+
);
|
|
920
|
+
else {
|
|
921
|
+
await commands[cmd](argv).catch((e) => die(e.message));
|
|
922
|
+
// After the command, never before it: a version check is not worth a millisecond of somebody's
|
|
923
|
+
// first impression, and it must never be the reason a command felt slow. `mcp` is a server that
|
|
924
|
+
// does not return, and the hooks are read by an agent rather than a person — a line neither of
|
|
925
|
+
// them asked for is noise in a transcript.
|
|
926
|
+
if (!["mcp", "now", "refresh-update"].includes(cmd)) {
|
|
927
|
+
try {
|
|
928
|
+
const { cachedNotice } = await import("../src/update.js");
|
|
929
|
+
const current = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version;
|
|
930
|
+
const { notice, stale } = cachedNotice({ current });
|
|
931
|
+
if (notice) console.error(notice);
|
|
932
|
+
// The refresh is a process this one does not wait for: detached, output thrown away,
|
|
933
|
+
// unref'd so the exit is not held open by it. Measuring the alternative is what settled it —
|
|
934
|
+
// the registry took six to ten seconds to answer, so awaiting it meant every command paid
|
|
935
|
+
// that or the check silently never worked.
|
|
936
|
+
if (stale) {
|
|
937
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "refresh-update"], {
|
|
938
|
+
detached: true,
|
|
939
|
+
stdio: "ignore",
|
|
940
|
+
});
|
|
941
|
+
child.unref();
|
|
942
|
+
}
|
|
943
|
+
} catch {
|
|
944
|
+
// Nothing about being out of date is worth failing a command that already worked.
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -9,6 +9,31 @@ You are turning this session into a **transfer guide**: executable context for a
|
|
|
9
9
|
different place. The reader is an AI agent (with a developer watching) that has none of this
|
|
10
10
|
session's history. Write for execution, not for permanence.
|
|
11
11
|
|
|
12
|
+
## Before you write anything
|
|
13
|
+
|
|
14
|
+
**Check what is already open, and whether a guide is wanted at all.** Run `passalong now` (or
|
|
15
|
+
`take` with no id over MCP) to see what this worktree holds and what was handed to you.
|
|
16
|
+
|
|
17
|
+
- **The session answers something you hold** — hand it in (`passalong hand_in <id> …`) instead.
|
|
18
|
+
Publishing a second guide about it leaves two records of one piece of work. Every hand-in carries
|
|
19
|
+
what you ran and what came back — the command and the lines that decided it, a test summary, a
|
|
20
|
+
link to the change, or a screenshot url. Copy each one out of the session as you go; at the end
|
|
21
|
+
you would be writing from memory, which is what evidence exists to replace. On a **task**, send
|
|
22
|
+
it as `checks`: one entry per Acceptance line, each with that line and the evidence for it. Its
|
|
23
|
+
author reads them line against line, and evidence filed under the check it answers is worth more
|
|
24
|
+
than the same output in one block. On a handoff or a bug, which have no Acceptance lines, send
|
|
25
|
+
`evidence` as one block.
|
|
26
|
+
- **The work never left this repo** — committed and pushed on a branch the team can already see,
|
|
27
|
+
with a pull request to review — then it has crossed no boundary, and a transfer guide is one more
|
|
28
|
+
thing for somebody to read. What is worth publishing from a session like that is what is *still
|
|
29
|
+
open*: file it as a bug (`kind: bug`) or a task, not as a write-up of the fix.
|
|
30
|
+
- **The ask is vague** — "update the passalong", "add this to passalong", "log this" — it may mean
|
|
31
|
+
hand in what you hold, add a follow-up to a guide, or publish something new. Ask which, in one
|
|
32
|
+
line. Publishing the wrong thing costs somebody a review, and the author a guide to delete.
|
|
33
|
+
|
|
34
|
+
A transfer guide earns its place when the work has to cross a boundary: another repo, another
|
|
35
|
+
machine, another agent session, or a teammate without your branch.
|
|
36
|
+
|
|
12
37
|
## Procedure
|
|
13
38
|
|
|
14
39
|
1. **Pick the one thing to transfer.** If the user named it (`/passalong-capture <what>` or
|
|
@@ -37,7 +62,9 @@ session's history. Write for execution, not for permanence.
|
|
|
37
62
|
**If what you are capturing is more context for an existing guide** — a missing detail, a step
|
|
38
63
|
that needed explaining, what changed since, what you found doing it — add `--follows <that id>`.
|
|
39
64
|
That publishes it as a follow-up: its own guide, listed under the original, and handed to
|
|
40
|
-
whoever opens the original, person or agent.
|
|
65
|
+
whoever opens the original, person or agent. It travels the other way too — whoever opens the
|
|
66
|
+
follow-up gets the guide it came out of, and where that guide has got to — so write it as a
|
|
67
|
+
note on that work rather than as a standalone brief. A session that started from a passalong guide
|
|
41
68
|
(`passalong start` or `pull` on an id, or an agent opened one) usually has context worth adding
|
|
42
69
|
this way. Whether the guide worked is still its own answer: `passalong works <id>` or
|
|
43
70
|
`passalong broken <id> <why>`.
|
package/src/api.js
CHANGED
|
@@ -63,7 +63,58 @@ const q = (params) => {
|
|
|
63
63
|
return s ? `?${s}` : "";
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Sign in, or make the account, and come back with a token for this machine.
|
|
68
|
+
*
|
|
69
|
+
* Three calls, because the API answers a browser and this is not one: signing in sets a session
|
|
70
|
+
* cookie, and the CLI carries a bearer token instead — one that shows up in the hub's token list
|
|
71
|
+
* with a name on it, and can be revoked there without changing the password. So the cookie is used
|
|
72
|
+
* once, to mint the token, and then dropped.
|
|
73
|
+
*
|
|
74
|
+
* `make` is signup rather than sign-in. It is the caller's decision and not a fallback here: an
|
|
75
|
+
* account created because an address was mistyped is worse than being told there is no such
|
|
76
|
+
* account.
|
|
77
|
+
*/
|
|
78
|
+
export async function signIn(email, password, { make = false, label = "" } = {}) {
|
|
79
|
+
const res = await fetch(`${baseUrl()}${make ? "/v1/auth/signup" : "/v1/auth/login"}`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ email, password }),
|
|
83
|
+
}).catch((err) => {
|
|
84
|
+
throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const said = await res.json().catch(() => ({}));
|
|
88
|
+
throw new ApiError(res.status, said.message || res.statusText);
|
|
89
|
+
}
|
|
90
|
+
const { account } = await res.json();
|
|
91
|
+
// `getSetCookie` keeps the cookies apart; a joined `set-cookie` header cannot be split safely,
|
|
92
|
+
// because an Expires date has a comma in it.
|
|
93
|
+
const cookie = (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
|
|
94
|
+
if (!cookie) throw new ApiError(500, "signed in, but no session came back");
|
|
95
|
+
|
|
96
|
+
const minted = await fetch(`${baseUrl()}/v1/tokens`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "content-type": "application/json", cookie },
|
|
99
|
+
body: JSON.stringify({ name: label || "cli" }),
|
|
100
|
+
});
|
|
101
|
+
if (!minted.ok) {
|
|
102
|
+
const said = await minted.json().catch(() => ({}));
|
|
103
|
+
throw new ApiError(minted.status, said.message || "could not make a token for this machine");
|
|
104
|
+
}
|
|
105
|
+
const { token: made } = await minted.json();
|
|
106
|
+
return { account, token: made };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Mint an account with no email and no password.
|
|
111
|
+
*
|
|
112
|
+
* Nothing in this package calls it any more — `passalong login` asks for an email and a password,
|
|
113
|
+
* because an account nobody can sign in to is one that dies with the file it is stored in, and the
|
|
114
|
+
* CLI used to make one without ever saying so. It stays exported because the route it calls is
|
|
115
|
+
* still live and still right for the invite page, which mints before it claims, and because
|
|
116
|
+
* removing a published export is a breaking change for anyone who imported it.
|
|
117
|
+
*/
|
|
67
118
|
export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
|
|
68
119
|
export const me = () => call("/v1/me");
|
|
69
120
|
export const updateMe = (patch) => call("/v1/me", { method: "PATCH", body: patch });
|
|
@@ -101,6 +152,26 @@ export const get = (id) => call(`/v1/guides/${id}`, { raw: true });
|
|
|
101
152
|
*/
|
|
102
153
|
export const children = (id, { markdown = false } = {}) =>
|
|
103
154
|
call(`/v1/guides/${encodeURIComponent(id)}/children${q({ markdown: markdown ? "1" : "" })}`);
|
|
155
|
+
/**
|
|
156
|
+
* The guide this one came out of, with where it has got to. `markdown` adds its content. Like the
|
|
157
|
+
* follow-ups, reading it is not opening it: no pull is recorded on the parent.
|
|
158
|
+
*/
|
|
159
|
+
export const parent = (id, { markdown = false } = {}) =>
|
|
160
|
+
call(`/v1/guides/${encodeURIComponent(id)}/parent${q({ markdown: markdown ? "1" : "" })}`);
|
|
161
|
+
/**
|
|
162
|
+
* Giving a plan away, for whoever the deployment names in `ADMIN_ACCOUNTS`. Every other account
|
|
163
|
+
* gets a 404 from these, so the CLI does not have to know who the operator is either.
|
|
164
|
+
*/
|
|
165
|
+
export const makeAdmin = (email) => call("/v1/admin/accounts", { method: "POST", body: { email } });
|
|
166
|
+
export const listAdmins = () => call("/v1/admin/accounts");
|
|
167
|
+
export const grantAdmin = (who) =>
|
|
168
|
+
call(`/v1/admin/accounts/${encodeURIComponent(who)}`, { method: "PUT", body: {} });
|
|
169
|
+
export const revokeAdmin = (who) =>
|
|
170
|
+
call(`/v1/admin/accounts/${encodeURIComponent(who)}`, { method: "DELETE" });
|
|
171
|
+
export const giveGift = (body) => call("/v1/admin/gifts", { method: "POST", body });
|
|
172
|
+
export const listGifts = () => call("/v1/admin/gifts");
|
|
173
|
+
export const takeGiftBack = (id) =>
|
|
174
|
+
call(`/v1/admin/gifts/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
104
175
|
export const setStatus = (id, status) =>
|
|
105
176
|
call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
|
|
106
177
|
export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
|
|
@@ -123,6 +194,8 @@ export const releaseTask = (id) =>
|
|
|
123
194
|
/** The four verbs every guide answers to. See docs/V2.md §11. */
|
|
124
195
|
export const working = () => call("/v1/working");
|
|
125
196
|
export const handedIn = () => call("/v1/handed_in");
|
|
197
|
+
export const assign = (id, to) =>
|
|
198
|
+
call(`/v1/guides/${encodeURIComponent(id)}/assign`, { method: "POST", body: { to } });
|
|
126
199
|
export const take = (body) => call("/v1/take", { method: "POST", body });
|
|
127
200
|
export const progress = (id, body) =>
|
|
128
201
|
call(`/v1/guides/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
|
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`);
|