passalong 0.6.0 → 0.9.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 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 done <id> deprecated: mark consumed
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] create an account, or attach this machine with a token from your hub
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
@@ -12,7 +12,7 @@
12
12
  // passalong work [--once] [--agent CMD] run an agent on each ready task here, one after another
13
13
  // passalong approve <id> the work in review is done
14
14
  // passalong reject <id> <why> not done: back to Ready, and the next agent reads why
15
- // passalong release <id> take a task back from the agent holding it, stalled or not
15
+ // passalong release <id> take work back from whoever holds it, of any kind
16
16
  // passalong attach <image> upload evidence and print the markdown that points at it
17
17
  // passalong pull <id|link> fetch a guide into ./.passalong/ and print it
18
18
  // passalong start <id|link> pull it and take it — one command for "I am doing this"
@@ -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 done <id> archive it: off your board and out of the free tier's count
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] create an account, or attach this machine with a token from your hub
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 });
@@ -292,8 +332,12 @@ const commands = {
292
332
 
293
333
  async release(f) {
294
334
  const id = f._[0] || die("usage: passalong release <id>");
295
- await passalong.releaseTask(id).catch((e) => die(e.message));
296
- console.error(`${id} is back in Ready — it says where the last agent left it`);
335
+ const r = await passalong.release(id).catch((e) => die(e.message));
336
+ const from = Number(r?.from) || 1;
337
+ console.error(
338
+ `${id} is back in Ready${from > 1 ? `, taken back from ${from} places` : ""}` +
339
+ " — whoever had it has been told",
340
+ );
297
341
  },
298
342
 
299
343
  async ready(f) {
@@ -478,6 +522,86 @@ const commands = {
478
522
  console.error(`${r.id} handed back — the author has been told: "${r.note}"`);
479
523
  },
480
524
 
525
+ async assign(f) {
526
+ const id = f._[0] || die('usage: passalong assign <id> <@handle | #group | team>');
527
+ const to = f._[1] || die('say who: @handle for one person, #group, or "team" for everyone');
528
+ const r = await passalong.assign(id, to).catch((e) => die(e.message));
529
+ const back = r.taken_back ? ` — taken back from ${r.taken_back} who had it, and they were told` : "";
530
+ console.error(`${r.id} is now for ${r.to || "everyone in the team"}${back}`);
531
+ },
532
+
533
+ // ---- who runs the product ---------------------------------------------------------------
534
+ // `passalong admin <create|list|grant|revoke>`. An admin signs in like anybody else: the role is
535
+ // a column, so there is no admin password and no admin token — only an account that has it.
536
+ // "admin" is the word everywhere a person reads; `super` stays the stored value.
537
+
538
+ async admin(f) {
539
+ const what = f._[0] || "list";
540
+ if (what === "create") {
541
+ const email = f._[1] || die("usage: passalong admin create <email>");
542
+ const r = await api.makeAdmin(email).catch((e) => die(e.message));
543
+ // Shown once, and only here. The link is a day long because it is handed to somebody
544
+ // rather than clicked on the spot.
545
+ // Three labelled values on stdout, so one can be cut out with awk. "password link", not
546
+ // "password": the value is a URL, and somebody will try to paste it into a password field.
547
+ console.log(`account ${r.account}`);
548
+ console.log(`token ${r.token}`);
549
+ console.log(`password link ${r.password_url}`);
550
+ console.error(
551
+ `\n${r.email} is an admin. The token is for \`passalong login\`, the link sets their ` +
552
+ `password for the hub, and it expires ${r.expires.slice(0, 10)}. Neither is shown again.`,
553
+ );
554
+ return;
555
+ }
556
+ if (what === "grant" || what === "revoke") {
557
+ const who = f._[1] || die(`usage: passalong admin ${what} <@handle | account id>`);
558
+ const call = what === "grant" ? api.grantAdmin : api.revokeAdmin;
559
+ const r = await call(who).catch((e) => die(e.message));
560
+ console.error(
561
+ what === "grant" ? `${r.name} is now an admin` : `${r.name} is no longer an admin`,
562
+ );
563
+ return;
564
+ }
565
+ if (what !== "list") die("usage: passalong admin <create <email> | list | grant | revoke>");
566
+ const { supers = [] } = await api.listAdmins().catch((e) => die(e.message));
567
+ if (!supers.length) return console.error("nobody is an admin yet");
568
+ for (const s of supers)
569
+ console.log(`${s.id} ${s.name}${s.email ? ` ${s.email}` : ""} since ${s.since.slice(0, 10)}`);
570
+ },
571
+
572
+ // ---- giving a plan away -----------------------------------------------------------------
573
+ // Only for whoever the deployment names in ADMIN_ACCOUNTS; for everybody else the routes behind
574
+ // these are 404, and the message says so rather than pretending the command does not exist.
575
+
576
+ async gift(f) {
577
+ const to = f._[0] || die('usage: passalong gift <@handle | team/slug> --until 2027-09-23 [--why "..."]');
578
+ const until = f.until || die("say when it ends: --until 2027-09-23. A gift with no end is a price change");
579
+ const r = await api
580
+ .giveGift({ to, until, why: f.why || "", seats: Number(f.seats || 0) })
581
+ .catch((e) => die(e.message));
582
+ // The seat count is said back whether or not it was asked for: left off, it is the number of
583
+ // members the team has today, and an operator should read which number that turned out to be.
584
+ const seats = r.kind === "team" ? ` for ${r.seats} seat${r.seats === 1 ? "" : "s"}` : "";
585
+ console.error(`${r.to} is on ${r.plan}${seats} until ${r.until.slice(0, 10)} — gift ${r.id}`);
586
+ },
587
+
588
+ async gifts() {
589
+ const { gifts = [] } = await api.listGifts().catch((e) => die(e.message));
590
+ if (!gifts.length) return console.error("no plans given away yet");
591
+ for (const g of gifts) {
592
+ const state = g.live ? `until ${g.until.slice(0, 10)}` : "over";
593
+ console.log(
594
+ `${g.id} ${g.plan.padEnd(5)} ${state.padEnd(16)} ${g.to}${g.why ? ` — ${g.why}` : ""}`,
595
+ );
596
+ }
597
+ },
598
+
599
+ async ungift(f) {
600
+ const id = f._[0] || die("usage: passalong ungift <gift id> (passalong gifts lists them)");
601
+ await api.takeGiftBack(id).catch((e) => die(e.message));
602
+ console.error(`${id} taken back — that plan is lapsed as of now`);
603
+ },
604
+
481
605
  async works(f) {
482
606
  const id = f._[0] || die("usage: passalong works <id>");
483
607
  const r = await passalong.verdict(id, true, f._.slice(1).join(" ")).catch((e) => die(e.message));
@@ -495,16 +619,29 @@ const commands = {
495
619
  /**
496
620
  * Archive a guide: off your board, and out of what the free tier counts.
497
621
  *
498
- * It was deprecated for meaning "I implemented it", which is the reader's judgement and belongs
499
- * to the verdict. As shelf space it has a job nothing else does — it is the only way under the
500
- * sync limit that does not delete the work, and the 402 says so by name.
622
+ * The command was `done`, and the status it sets is still `consumed`, because both once meant "I
623
+ * implemented it". That meaning went to the verdict, where it belongs — it is the reader's
624
+ * judgement, not the author's — and what was left is shelf space, which has a job nothing else
625
+ * does: the only way under the sync limit that does not delete the work, and the 402 says so by
626
+ * name. So the command says what it does now.
627
+ *
628
+ * `consumed` stays on disk. It is a status inside markdown files in other people's
629
+ * repositories, and `validate()` refuses a status it does not know, so renaming the value would
630
+ * make a guide shared a month ago fail to re-share today. The word people read and the token the
631
+ * format stores are allowed to differ; what is not allowed is two words for people.
501
632
  */
502
- async done(f) {
503
- const id = f._[0] || die("usage: passalong done <id>");
633
+ async archive(f) {
634
+ const id = f._[0] || die("usage: passalong archive <id>");
504
635
  const g = await passalong.setStatus(id, "consumed").catch((e) => die(e.message));
505
636
  console.error(`${g.meta.id} archived — off your board, and it no longer counts against sync`);
506
637
  },
507
638
 
639
+ /** What `archive` was called. Kept so a script written against it still runs, and says so. */
640
+ async done(f) {
641
+ console.error("`passalong done` is now `passalong archive` — it archives, it does not mark work done.");
642
+ return commands.archive(f);
643
+ },
644
+
508
645
  async rm(f) {
509
646
  const id = f._[0] || die("usage: passalong rm <id>");
510
647
  await passalong.remove(id).catch((e) => die(e.message));
@@ -533,14 +670,40 @@ const commands = {
533
670
  return;
534
671
  }
535
672
  }
536
- const { token, account } = await api.createAccount().catch((e) => die(e.message));
537
- store.writeConfig({ token, api: api.baseUrl() });
538
- console.error(`account ${account} created on ${api.baseUrl()}. Sync is on.`);
673
+ // An account you cannot sign in to is one that dies with this file. This used to mint one
674
+ // silently and then suggest adding a password afterwards; it asks up front instead.
675
+ if (!tty)
676
+ die(
677
+ "no terminal to ask on. Sign in at " +
678
+ `${api.baseUrl()}/hub, then attach this machine with \`passalong login <token>\``,
679
+ );
680
+
681
+ const email = f.email || (await ask("Email: "));
682
+ if (!email) die("an email is needed to sign in");
683
+ const password = await ask("Password: ", { hidden: true });
684
+ if (!password) die("a password is needed to sign in");
685
+
686
+ const label = `${hostname()} cli`;
687
+ let who = await api.signIn(email, password, { label }).catch((e) => e);
688
+ if (who instanceof Error) {
689
+ // 401 is "no such account, or the wrong password", said the same way on purpose so the API
690
+ // does not confirm which addresses exist. The CLI has to ask rather than guess, because
691
+ // creating an account for a mistyped address is worse than being told to try again.
692
+ if (who.status !== 401) die(who.message);
693
+ const yes = (await ask(`No account signed in as ${email}. Create one? [y/N] `)).toLowerCase();
694
+ if (yes !== "y" && yes !== "yes")
695
+ die("not signed in. If the password was wrong, try again, or reset it at " + `${api.baseUrl()}/hub`);
696
+ const again = await ask("Choose a password: ", { hidden: true });
697
+ if (again !== password) die("those did not match");
698
+ who = await api.signIn(email, password, { make: true, label }).catch((e) => die(e.message));
699
+ }
700
+
701
+ store.writeConfig({ token: who.token, api: api.baseUrl() });
702
+ console.error(`signed in as ${email} (account ${who.account}) on ${api.baseUrl()}. Sync is on.`);
539
703
  console.error("Attach other machines with:");
540
- console.error(` passalong login ${token}`);
704
+ console.error(` passalong login ${who.token}`);
541
705
  console.error("(keep it private — it is a credential, not a password)");
542
706
  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
707
  },
545
708
 
546
709
  async me(f) {
@@ -722,6 +885,15 @@ const commands = {
722
885
  await serve();
723
886
  },
724
887
 
888
+ /**
889
+ * Ask the registry what the latest version is and write it down. Not in the help, because it is
890
+ * not for a person: `passalong` spawns it, detached, when the answer on file is a day old.
891
+ */
892
+ async ["refresh-update"]() {
893
+ const { refresh } = await import("../src/update.js");
894
+ await refresh();
895
+ },
896
+
725
897
  async help() {
726
898
  const src = readFileSync(fileURLToPath(import.meta.url), "utf8");
727
899
  const lines = src.split("\n").filter((l) => l.startsWith("// passalong "));
@@ -738,5 +910,42 @@ const cmd = argv._.shift() || "help";
738
910
  // `passalong -v` can be read by a script comparing it against the registry.
739
911
  if (cmd === "-v" || cmd === "version" || (cmd === "help" && argv.version === true)) {
740
912
  console.log(JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version);
741
- } else if (cmd === "--help" || cmd === "-h" || !commands[cmd]) commands.help();
742
- else await commands[cmd](argv).catch((e) => die(e.message));
913
+ } else if (cmd === "--help" || cmd === "-h" || cmd === "help") commands.help();
914
+ // A command this build does not have used to print the help page and exit 0, which reads as "that
915
+ // command exists and did nothing" — and the usual cause is an older passalong, where the command is
916
+ // in the docs and not in the binary. So it fails, names what it does not know, and says the version
917
+ // it is, which is the fact that explains it.
918
+ else if (!commands[cmd])
919
+ die(
920
+ `unknown command "${cmd}" — run \`passalong help\` for the list. ` +
921
+ `This is passalong ${JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version}; ` +
922
+ "if you expected this command, `npm i -g passalong` for the newest one.",
923
+ );
924
+ else {
925
+ await commands[cmd](argv).catch((e) => die(e.message));
926
+ // After the command, never before it: a version check is not worth a millisecond of somebody's
927
+ // first impression, and it must never be the reason a command felt slow. `mcp` is a server that
928
+ // does not return, and the hooks are read by an agent rather than a person — a line neither of
929
+ // them asked for is noise in a transcript.
930
+ if (!["mcp", "now", "refresh-update"].includes(cmd)) {
931
+ try {
932
+ const { cachedNotice } = await import("../src/update.js");
933
+ const current = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version;
934
+ const { notice, stale } = cachedNotice({ current });
935
+ if (notice) console.error(notice);
936
+ // The refresh is a process this one does not wait for: detached, output thrown away,
937
+ // unref'd so the exit is not held open by it. Measuring the alternative is what settled it —
938
+ // the registry took six to ten seconds to answer, so awaiting it meant every command paid
939
+ // that or the check silently never worked.
940
+ if (stale) {
941
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "refresh-update"], {
942
+ detached: true,
943
+ stdio: "ignore",
944
+ });
945
+ child.unref();
946
+ }
947
+ } catch {
948
+ // Nothing about being out of date is worth failing a command that already worked.
949
+ }
950
+ }
951
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.6.0",
3
+ "version": "0.9.0",
4
4
  "description": "Hand a finished implementation from one context to another, in a form an AI agent can act on.",
5
5
  "keywords": [
6
6
  "mcp",
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. A session that started from a passalong guide
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
- /** Mint a fresh account and return its token. No email, no password: the token is the account. */
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,12 +152,35 @@ 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" });
107
178
  /** Does it actually work? `note` is required when it does not. */
108
- export const verdict = (id, ok, note = "") =>
109
- call(`/v1/guides/${id}/verdict`, { method: "PUT", body: { ok, note } });
179
+ export const verdict = (id, ok, note = "", detail = "") =>
180
+ call(`/v1/guides/${id}/verdict`, {
181
+ method: "PUT",
182
+ body: { ok, note, ...(detail ? { detail } : {}) },
183
+ });
110
184
 
111
185
  export const tasks = () => call("/v1/tasks");
112
186
  export const nextTask = (body) => call("/v1/tasks/next", { method: "POST", body });
@@ -118,11 +192,14 @@ export const approveTask = (id) =>
118
192
  call(`/v1/tasks/${encodeURIComponent(id)}/approve`, { method: "POST", body: {} });
119
193
  export const rejectTask = (id, why) =>
120
194
  call(`/v1/tasks/${encodeURIComponent(id)}/reject`, { method: "POST", body: { why } });
121
- export const releaseTask = (id) =>
122
- call(`/v1/tasks/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
195
+ /** Take work back from whoever holds it, of any kind. `/v1/tasks/…/release` is the old path. */
196
+ export const release = (id) =>
197
+ call(`/v1/guides/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
123
198
  /** The four verbs every guide answers to. See docs/V2.md §11. */
124
199
  export const working = () => call("/v1/working");
125
200
  export const handedIn = () => call("/v1/handed_in");
201
+ export const assign = (id, to) =>
202
+ call(`/v1/guides/${encodeURIComponent(id)}/assign`, { method: "POST", body: { to } });
126
203
  export const take = (body) => call("/v1/take", { method: "POST", body });
127
204
  export const progress = (id, body) =>
128
205
  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
+ }