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 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
@@ -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 });
@@ -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
- * 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.
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 done(f) {
503
- const id = f._[0] || die("usage: passalong done <id>");
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
- 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.`);
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" || !commands[cmd]) commands.help();
742
- else await commands[cmd](argv).catch((e) => die(e.message));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.6.0",
3
+ "version": "0.8.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,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
- * and defaulting the other way would turn them all into bug reports.
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
- out[current] = "";
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
- const kind = meta.kind || "transfer";
369
- if (!KINDS.includes(kind)) errors.push(`kind must be one of ${KINDS.join(", ")}`);
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`);