passalong 0.1.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 ADDED
@@ -0,0 +1,53 @@
1
+ # passalong
2
+
3
+ Hand finished work to another context.
4
+
5
+ Solve something non-trivial in one agent session. Run one command. Open a session in another
6
+ repo, on another machine, or with a teammate, and the agent there already knows the whole story:
7
+ the problem, the decisions and why, the steps, how to verify, and what went wrong along the way.
8
+
9
+ ```sh
10
+ npm i -g passalong
11
+ passalong setup # installs the Claude Code capture skill + registers the MCP server
12
+ passalong login # optional: sync guides across machines and get share links
13
+ ```
14
+
15
+ ## The loop
16
+
17
+ 1. **Finish work** in Claude Code (or any agent). Say *"pass this along"*, run `/passalong-capture`, or
18
+ run `passalong share` yourself.
19
+ 2. **Trim and publish.** The draft opens in your editor. Save it and you get a short id and a link.
20
+ 3. **Pull on the other side.** `passalong pull <id>` in the target repo, or paste the link to any
21
+ agent with the Passalong MCP server. The guide lands in its context; it implements, adapting the
22
+ parts marked `ASSUMES:`.
23
+ 4. **Close.** `passalong done <id>` when it landed. `passalong promote <id>` if it keeps getting pulled.
24
+
25
+ ## Commands
26
+
27
+ ```
28
+ passalong share [file] publish a guide (newest draft, or a scaffold you edit) → id + link
29
+ passalong pull <id|link> fetch a guide into ./.passalong/ and print it
30
+ passalong list [query] your guides, local and synced
31
+ passalong open <id> [--print] view a guide in the browser (or the terminal)
32
+ passalong hub open your synced guides in the browser
33
+ passalong done <id> mark consumed: implemented on the receiving side
34
+ passalong promote <id> mark promoted: graduated into a reusable reference
35
+ passalong rm <id> delete a guide locally and from sync
36
+ passalong export [dir] dump every guide as plain markdown
37
+ passalong login [token] create an account, or attach this machine to an existing one
38
+ passalong setup install the Claude Code capture skill + register the MCP server
39
+ passalong mcp run the MCP server over stdio
40
+ ```
41
+
42
+ ## MCP
43
+
44
+ `passalong setup` registers the server with Claude Code. For other clients, run `passalong mcp` over stdio.
45
+
46
+ Tools: `search_guides`, `get_guide`, `publish_guide`, `guide_template`, `set_guide_status`.
47
+
48
+ ## Guides are files
49
+
50
+ Plain markdown with frontmatter in `~/.passalong/guides`. Yours to edit, grep, and commit.
51
+ `passalong export` dumps everything. Works with no account; `passalong login` adds sync and share links.
52
+
53
+ Point at a self-hosted server with `PASSALONG_API=https://your-host` before `passalong login`.
package/bin/passalong ADDED
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ // Passalong CLI. Few flags on purpose: the defaults are the product.
3
+ //
4
+ // passalong share [file] publish a guide (newest draft, or a scaffold you edit) → id + link
5
+ // passalong pull <id|link> fetch a guide into ./.passalong/ and print it
6
+ // passalong list [query] your guides, local and synced
7
+ // passalong open <id> [--print] view a guide in the browser (or the terminal)
8
+ // passalong hub open your synced guides in the browser
9
+ // passalong done <id> mark consumed: implemented on the receiving side
10
+ // passalong promote <id> mark promoted: graduated into a reusable reference
11
+ // passalong rm <id> delete a guide locally and from sync
12
+ // passalong export [dir] dump every guide as plain markdown (default ./passalong-export)
13
+ // passalong login [token] create an account, or attach this machine to an existing one
14
+ // passalong setup install the Claude Code capture skill + register the MCP server
15
+ // passalong mcp run the MCP server over stdio
16
+ import { execFileSync, spawnSync } from "node:child_process";
17
+ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import * as api from "../src/api.js";
22
+ import { scaffold } from "../src/capture.js";
23
+ import { serialize } from "../src/guide.js";
24
+ import * as passalong from "../src/passalong.js";
25
+ import * as store from "../src/store.js";
26
+
27
+ const PKG = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
+
29
+ function flags(argv) {
30
+ const out = { _: [] };
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const t = argv[i];
33
+ if (t.startsWith("--")) {
34
+ const next = argv[i + 1];
35
+ if (next === undefined || next.startsWith("-")) out[t.slice(2)] = true;
36
+ else out[t.slice(2)] = argv[++i];
37
+ } else out._.push(t);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function die(msg) {
43
+ console.error(`error: ${msg}`);
44
+ process.exit(1);
45
+ }
46
+
47
+ const tty = process.stdout.isTTY && process.stdin.isTTY;
48
+
49
+ function edit(path) {
50
+ const editor = process.env.VISUAL || process.env.EDITOR || "vi";
51
+ const r = spawnSync(editor, [path], { stdio: "inherit", shell: true });
52
+ if (r.status !== 0) die(`${editor} exited with ${r.status}`);
53
+ }
54
+
55
+ function openInBrowser(url) {
56
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
57
+ spawnSync(cmd, [url], { stdio: "ignore", shell: process.platform === "win32" });
58
+ }
59
+
60
+ function relDate(iso) {
61
+ if (!iso) return "";
62
+ const d = (Date.now() - new Date(iso).getTime()) / 864e5;
63
+ if (d < 1) return "today";
64
+ if (d < 30) return `${Math.floor(d)}d ago`;
65
+ return iso.slice(0, 10);
66
+ }
67
+
68
+ const commands = {
69
+ async share(f) {
70
+ let path = f._[0];
71
+ let fromDrafts = false;
72
+ if (!path) {
73
+ const [latest] = store.drafts();
74
+ if (latest) {
75
+ path = latest;
76
+ fromDrafts = true;
77
+ console.error(`using draft ${path}`);
78
+ } else {
79
+ path = store.draftPath(new Date().toISOString().replace(/[:.]/g, "-"));
80
+ writeFileSync(path, scaffold());
81
+ fromDrafts = true;
82
+ if (!tty) die(`no draft to share; scaffolded ${path} — fill it in and run \`passalong share ${path}\``);
83
+ }
84
+ }
85
+ if (!existsSync(path)) die(`no such file: ${path}`);
86
+ if (tty && !f["no-edit"]) edit(path);
87
+ const { guide, url, synced, path: saved } = await passalong.share(readFileSync(path, "utf8")).catch((e) => die(e.message));
88
+ if (fromDrafts) store.removeDraft(path);
89
+ console.log(guide.meta.id);
90
+ console.error(`shared "${guide.meta.title}" → ${saved}`);
91
+ if (synced) console.error(`link: ${url}`);
92
+ else console.error("not synced (run `passalong login` to sync across machines)");
93
+ },
94
+
95
+ async pull(f) {
96
+ const ref = f._[0] || die("usage: passalong pull <id|link>");
97
+ const { markdown, path, from } = await passalong.pull(ref).catch((e) => die(e.message));
98
+ process.stdout.write(markdown);
99
+ console.error(`\npulled from ${from} → ${path}`);
100
+ },
101
+
102
+ async list(f) {
103
+ const rows = await passalong.list(f._.join(" "));
104
+ if (rows.warning) console.error(`warning: ${rows.warning}`);
105
+ if (!rows.length) return console.error("no guides yet — run `passalong share` after your next finished piece of work");
106
+ for (const r of rows) {
107
+ const tags = r.tags?.length ? ` #${r.tags.join(" #")}` : "";
108
+ console.log(`${r.id} ${r.status.padEnd(9)} ${relDate(r.created).padEnd(10)} ${r.title}${tags}`);
109
+ }
110
+ },
111
+
112
+ async open(f) {
113
+ const id = f._[0] || die("usage: passalong open <id>");
114
+ const { guide } = await passalong.resolve(id).catch((e) => die(e.message));
115
+ if (guide.meta.url && tty && !f.print) {
116
+ openInBrowser(guide.meta.url);
117
+ console.error(guide.meta.url);
118
+ } else {
119
+ process.stdout.write(serialize(guide));
120
+ if (guide.meta.url) console.error(`\nlink: ${guide.meta.url}`);
121
+ }
122
+ },
123
+
124
+ async hub() {
125
+ if (!api.loggedIn()) die("not logged in — run `passalong login` first (the hub shows synced guides)");
126
+ // The token rides in the URL fragment: the browser never sends it to the server, and the
127
+ // page moves it into localStorage and scrubs the address bar on load.
128
+ const url = `${api.baseUrl()}/hub#token=${encodeURIComponent(api.token())}`;
129
+ if (tty) {
130
+ openInBrowser(url);
131
+ console.error(`${api.baseUrl()}/hub`);
132
+ } else console.log(url);
133
+ },
134
+
135
+ async done(f) {
136
+ const id = f._[0] || die("usage: passalong done <id>");
137
+ const g = await passalong.setStatus(id, "consumed").catch((e) => die(e.message));
138
+ console.error(`${g.meta.id} marked consumed`);
139
+ },
140
+
141
+ async promote(f) {
142
+ const id = f._[0] || die("usage: passalong promote <id>");
143
+ const g = await passalong.setStatus(id, "promoted").catch((e) => die(e.message));
144
+ console.error(`${g.meta.id} marked promoted`);
145
+ },
146
+
147
+ async rm(f) {
148
+ const id = f._[0] || die("usage: passalong rm <id>");
149
+ await passalong.remove(id).catch((e) => die(e.message));
150
+ console.error(`${id} removed`);
151
+ },
152
+
153
+ async export(f) {
154
+ const dir = resolve(f._[0] || "passalong-export");
155
+ const paths = passalong.exportAll(dir);
156
+ console.error(`exported ${paths.length} guide${paths.length === 1 ? "" : "s"} → ${dir}`);
157
+ },
158
+
159
+ async login(f) {
160
+ const given = f._[0];
161
+ if (given) {
162
+ store.writeConfig({ token: given, api: api.baseUrl() });
163
+ const who = await api.me().catch((e) => die(`token rejected: ${e.message}`));
164
+ console.error(`logged in (account ${who.account}) — ${api.baseUrl()}`);
165
+ return;
166
+ }
167
+ if (api.loggedIn() && !f.new) {
168
+ const who = await api.me().catch(() => null);
169
+ if (who) {
170
+ console.error(`already logged in (account ${who.account}). Attach another machine with:`);
171
+ console.error(` passalong login ${api.token()}`);
172
+ return;
173
+ }
174
+ }
175
+ const { token, account } = await api.createAccount().catch((e) => die(e.message));
176
+ store.writeConfig({ token, api: api.baseUrl() });
177
+ console.error(`account ${account} created on ${api.baseUrl()}. Sync is on.`);
178
+ console.error("Attach other machines with:");
179
+ console.error(` passalong login ${token}`);
180
+ console.error("(the token is the account — keep it private)");
181
+ },
182
+
183
+ async setup() {
184
+ const skillDir = join(homedir(), ".claude", "skills", "passalong-capture");
185
+ mkdirSync(skillDir, { recursive: true });
186
+ cpSync(join(PKG, "skill"), skillDir, { recursive: true });
187
+ console.error(`installed Claude Code skill → ${skillDir}`);
188
+ try {
189
+ execFileSync("claude", ["mcp", "add", "--scope", "user", "passalong", "--", "passalong", "mcp"], { stdio: "pipe" });
190
+ console.error("registered MCP server: claude mcp add passalong -- passalong mcp");
191
+ } catch (err) {
192
+ const msg = String(err.stderr || err.message);
193
+ if (/already exists/i.test(msg)) console.error("MCP server already registered");
194
+ else console.error(`could not register MCP server automatically (${msg.trim()}).\n run: claude mcp add passalong -- passalong mcp`);
195
+ }
196
+ if (!api.loggedIn()) console.error("sync is off; run `passalong login` when you want guides to follow you across machines");
197
+ console.error("done. In Claude Code, say \"pass this along\" at the end of a task, or run /passalong-capture.");
198
+ },
199
+
200
+ async mcp() {
201
+ const { serve } = await import("../src/mcp.js");
202
+ await serve();
203
+ },
204
+
205
+ async help() {
206
+ const src = readFileSync(fileURLToPath(import.meta.url), "utf8");
207
+ const lines = src.split("\n").filter((l) => l.startsWith("// passalong "));
208
+ console.log("passalong — hand finished work to another context\n");
209
+ console.log(lines.map((l) => l.slice(3)).join("\n"));
210
+ },
211
+ };
212
+
213
+ const argv = flags(process.argv.slice(2));
214
+ const cmd = argv._.shift() || "help";
215
+ if (cmd === "--help" || cmd === "-h" || !commands[cmd]) commands.help();
216
+ else await commands[cmd](argv).catch((e) => die(e.message));
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "passalong",
3
+ "version": "0.1.0",
4
+ "description": "Hand a finished implementation from one context to another, in a form an AI agent can act on.",
5
+ "keywords": [
6
+ "mcp",
7
+ "claude-code",
8
+ "agent",
9
+ "handoff",
10
+ "knowledge-transfer",
11
+ "cli",
12
+ "markdown"
13
+ ],
14
+ "homepage": "https://passalong.kreativekorna.com",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/codellyson/passalong.git",
18
+ "directory": "packages/passalong"
19
+ },
20
+ "author": "Lukman Isiaka",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "bin": {
24
+ "passalong": "bin/passalong"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "src",
29
+ "skill"
30
+ ],
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "test": "node --test"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.29.0",
42
+ "zod": "^3.24.0"
43
+ }
44
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: passalong-capture
3
+ description: Distill what was just accomplished in this session into a Passalong transfer guide and publish it, so another repo, machine, agent session, or teammate can implement the same thing. Use when the user says "pass this along", "passalong this", "hand this off", "share this with <repo/person>", "capture this for later", "write a transfer guide", or runs /passalong-capture.
4
+ ---
5
+
6
+ # Passalong capture
7
+
8
+ You are turning this session into a **transfer guide**: executable context for an agent in a
9
+ different place. The reader is an AI agent (with a developer watching) that has none of this
10
+ session's history. Write for execution, not for permanence.
11
+
12
+ ## Procedure
13
+
14
+ 1. **Reconstruct the story from the session.** What was the problem, what was tried, what failed,
15
+ what was decided and why, what finally worked, and how it was verified. Read the diff
16
+ (`git diff`, `git log`) only to confirm details; the reasoning is in the transcript.
17
+ 2. **Write the guide** with the structure below. Be concrete: file paths, commands, exact error
18
+ strings, version numbers. Prefer a short guide with every section filled over a long one.
19
+ 3. **Mark context-specific parts** so the receiver can adapt rather than copy. Put
20
+ `ASSUMES: <thing>. If <alternative>, <what changes>.` at the start of any step that depends on
21
+ this repo's stack, layout, or conventions.
22
+ 4. **Save the draft** to `~/.passalong/drafts/<slug>.md` (create the directory if needed), then run:
23
+
24
+ ```bash
25
+ passalong share ~/.passalong/drafts/<slug>.md --no-edit
26
+ ```
27
+
28
+ It prints the guide id on stdout and the share link on stderr.
29
+ 5. **Report** the id (and link if synced) and one line on how to use it on the other side:
30
+ `passalong pull <id>` in the target repo, or "pull passalong <id>" to an agent with the Passalong MCP server.
31
+
32
+ If the `passalong` command is missing, tell the user to run `npm i -g passalong && passalong setup` and
33
+ still write the draft file so nothing is lost.
34
+
35
+ ## Guide structure
36
+
37
+ ```markdown
38
+ ---
39
+ title: <what this accomplishes, as a verb phrase: "Add Paystack webhook verification">
40
+ author: <git user.name, if known>
41
+ source_context: <repo or product this came from>
42
+ status: published
43
+ stack_assumptions: [<runtime/framework/db/service versions the steps depend on>]
44
+ tags: [<3 to 6 lowercase keywords>]
45
+ ---
46
+
47
+ ## Problem
48
+ What was broken or needed. Two or three sentences. Include the observable symptom.
49
+
50
+ ## Solution shape
51
+ The approach at a high level, before any code. One short paragraph.
52
+
53
+ ## Decisions and rationale
54
+ - **Chose X over Y** because Z. (Each real decision, with what was rejected.)
55
+
56
+ ## Steps
57
+ 1. ASSUMES: <stack>. If <other>, <adjust>. Then the concrete step, with the file path and the code or command.
58
+ 2. ...
59
+
60
+ ## Verification
61
+ Commands to run and what they should print. Test cases. What "done" looks like.
62
+
63
+ ## Gotchas
64
+ - What failed along the way, the exact error, and why. What looked right but wasn't.
65
+ ```
66
+
67
+ ## Quality bar
68
+
69
+ - **Gotchas is the highest-value section.** If nothing went wrong, say what would have gone
70
+ wrong without a specific decision. Never leave it empty.
71
+ - **Verification must be runnable.** A command and an expected output, not "make sure it works".
72
+ - **Every ASSUMES has an alternative.** The point is adaptation on the other side.
73
+ - **No session narration.** "First I looked at..." is noise. State the finding.
74
+ - **No secrets.** Redact tokens, keys, and internal hostnames that would not apply elsewhere.
75
+ - Title, Problem, and Steps are required; the guide will not publish without them.
package/src/api.js ADDED
@@ -0,0 +1,80 @@
1
+ // Client for the hosted sync API (apps/api). Everything here is optional: with no token the
2
+ // CLI is a purely local tool, and every function throws a NotLoggedIn the CLI turns into a hint.
3
+ import { readConfig } from "./store.js";
4
+
5
+ export const DEFAULT_API = "https://passalong.kreativekorna.com";
6
+
7
+ export class ApiError extends Error {
8
+ constructor(status, message) {
9
+ super(message);
10
+ this.status = status;
11
+ }
12
+ }
13
+
14
+ export function baseUrl() {
15
+ return (process.env.PASSALONG_API || readConfig().api || DEFAULT_API).replace(/\/$/, "");
16
+ }
17
+
18
+ export function token() {
19
+ return process.env.PASSALONG_TOKEN || readConfig().token || null;
20
+ }
21
+
22
+ export function loggedIn() {
23
+ return Boolean(token());
24
+ }
25
+
26
+ async function call(path, { method = "GET", body, auth = true, raw = false } = {}) {
27
+ const headers = {};
28
+ if (auth) {
29
+ const t = token();
30
+ if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
31
+ headers.authorization = `Bearer ${t}`;
32
+ }
33
+ if (body !== undefined) {
34
+ headers["content-type"] = typeof body === "string" ? "text/markdown" : "application/json";
35
+ }
36
+ let res;
37
+ try {
38
+ res = await fetch(`${baseUrl()}${path}`, {
39
+ method,
40
+ headers,
41
+ body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
42
+ });
43
+ } catch (err) {
44
+ throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
45
+ }
46
+ if (!res.ok) {
47
+ const text = await res.text();
48
+ let message = text;
49
+ try {
50
+ message = JSON.parse(text).message || text;
51
+ } catch {}
52
+ throw new ApiError(res.status, message || res.statusText);
53
+ }
54
+ return raw ? res.text() : res.json();
55
+ }
56
+
57
+ /** Mint a fresh account and return its token. No email, no password: the token is the account. */
58
+ export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
59
+ export const me = () => call("/v1/me");
60
+ export const publish = (id, markdown) =>
61
+ call(`/v1/guides/${id}`, { method: "PUT", body: markdown });
62
+ export const list = (q = "") => call(`/v1/guides${q ? `?q=${encodeURIComponent(q)}` : ""}`);
63
+ export const get = (id) => call(`/v1/guides/${id}`, { raw: true });
64
+ export const setStatus = (id, status) =>
65
+ call(`/v1/guides/${id}/status`, { method: "PATCH", body: { status } });
66
+ export const remove = (id) => call(`/v1/guides/${id}`, { method: "DELETE" });
67
+
68
+ /** Fetch a guide by its share link (no account needed). Accepts the web URL or the .md URL. */
69
+ export async function fetchShared(url) {
70
+ const u = new URL(url);
71
+ if (!u.pathname.endsWith(".md")) u.pathname = `${u.pathname.replace(/\/$/, "")}.md`;
72
+ const res = await fetch(u);
73
+ if (!res.ok)
74
+ throw new ApiError(res.status, `could not fetch ${url}: ${res.status} ${res.statusText}`);
75
+ return res.text();
76
+ }
77
+
78
+ export function shareUrl(id, key) {
79
+ return `${baseUrl()}/g/${id}/${key}`;
80
+ }
package/src/capture.js ADDED
@@ -0,0 +1,56 @@
1
+ // Fallback capture when no agent wrote a draft: scaffold one from the working directory so the
2
+ // developer starts from "what changed" instead of a blank page. The Claude Code skill in
3
+ // skills/passalong-capture does the real distillation from a session transcript.
4
+ import { execFileSync } from "node:child_process";
5
+ import { basename } from "node:path";
6
+ import { template } from "./guide.js";
7
+
8
+ function git(cwd, ...args) {
9
+ try {
10
+ return execFileSync("git", args, {
11
+ cwd,
12
+ stdio: ["ignore", "pipe", "ignore"],
13
+ encoding: "utf8",
14
+ }).trim();
15
+ } catch {
16
+ return "";
17
+ }
18
+ }
19
+
20
+ /** What we can infer about the current context without reading any session. */
21
+ export function context(cwd = process.cwd()) {
22
+ const root = git(cwd, "rev-parse", "--show-toplevel");
23
+ const name = basename(root || cwd);
24
+ const branch = root ? git(cwd, "rev-parse", "--abbrev-ref", "HEAD") : "";
25
+ const author = git(cwd, "config", "user.name");
26
+ const commits = root ? git(cwd, "log", "--oneline", "-8").split("\n").filter(Boolean) : [];
27
+ const changed = root
28
+ ? git(cwd, "diff", "--stat", "HEAD~5..HEAD").split("\n").filter(Boolean).slice(-1)[0] || ""
29
+ : "";
30
+ const files = root
31
+ ? git(cwd, "diff", "--name-only", "HEAD~5..HEAD").split("\n").filter(Boolean)
32
+ : [];
33
+ return { root, name, branch, author, commits, changed, files };
34
+ }
35
+
36
+ /** A draft pre-filled with repo facts. Recent activity is appended as a comment for reference. */
37
+ export function scaffold(cwd = process.cwd()) {
38
+ const c = context(cwd);
39
+ let md = template({
40
+ title: "",
41
+ author: c.author,
42
+ source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
43
+ });
44
+ if (c.commits.length) {
45
+ md += [
46
+ "",
47
+ "<!-- passalong: recent activity in this repo, for reference while you write. Delete when done.",
48
+ ...c.commits.map((l) => ` ${l}`),
49
+ c.changed ? ` ${c.changed}` : "",
50
+ ...c.files.slice(0, 30).map((f) => ` ${f}`),
51
+ "-->",
52
+ "",
53
+ ].join("\n");
54
+ }
55
+ return md;
56
+ }
package/src/guide.js ADDED
@@ -0,0 +1,209 @@
1
+ // The transfer guide format: plain markdown with a small YAML-ish frontmatter block.
2
+ //
3
+ // No proprietary format and no YAML library. The frontmatter only ever holds strings and
4
+ // lists of strings, so a deliberately tiny parser covers it and stays readable when hand-edited.
5
+ import { randomBytes } from "node:crypto";
6
+
7
+ export const STATUSES = ["draft", "published", "consumed", "promoted"];
8
+
9
+ // Body sections in the order a guide should present them. The heading text is what the
10
+ // receiving agent keys on, so keep these stable.
11
+ export const SECTIONS = [
12
+ "Problem",
13
+ "Solution shape",
14
+ "Decisions and rationale",
15
+ "Steps",
16
+ "Verification",
17
+ "Gotchas",
18
+ ];
19
+
20
+ // Fields that hold a list of strings. Everything else is a plain string.
21
+ const LIST_FIELDS = new Set(["stack_assumptions", "tags"]);
22
+
23
+ // IDs are short enough to type and say out loud. 8 chars from a 31-letter alphabet with the
24
+ // look-alikes removed (0/o, 1/l/i) is ~40 bits: plenty for addressing, not a secret.
25
+ const ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
26
+ export function newId(length = 8) {
27
+ const bytes = randomBytes(length);
28
+ let out = "";
29
+ for (let i = 0; i < length; i++) out += ID_ALPHABET[bytes[i] % ID_ALPHABET.length];
30
+ return out;
31
+ }
32
+ export const ID_RE = /^[a-z0-9]{6,12}$/;
33
+
34
+ function scalar(raw) {
35
+ const v = raw.trim();
36
+ if (v === "") return "";
37
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
38
+ return v.slice(1, -1).replace(/\\"/g, '"');
39
+ }
40
+ return v;
41
+ }
42
+
43
+ function inlineList(raw) {
44
+ const inner = raw.trim().slice(1, -1).trim();
45
+ if (inner === "") return [];
46
+ return inner
47
+ .split(",")
48
+ .map(scalar)
49
+ .filter((s) => s !== "");
50
+ }
51
+
52
+ /** Parse a frontmatter block (the text between the --- fences) into an object. */
53
+ export function parseFrontmatter(text) {
54
+ const meta = {};
55
+ const lines = text.split(/\r?\n/);
56
+ let listKey = null;
57
+ for (const line of lines) {
58
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
59
+ const item = /^\s+-\s*(.*)$/.exec(line);
60
+ if (item && listKey) {
61
+ meta[listKey].push(scalar(item[1]));
62
+ continue;
63
+ }
64
+ const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
65
+ if (!kv) continue;
66
+ const [, key, rest] = kv;
67
+ if (rest.trim() === "" && LIST_FIELDS.has(key)) {
68
+ meta[key] = [];
69
+ listKey = key;
70
+ } else if (rest.trim().startsWith("[") && rest.trim().endsWith("]")) {
71
+ meta[key] = inlineList(rest);
72
+ listKey = null;
73
+ } else {
74
+ meta[key] = LIST_FIELDS.has(key) ? [scalar(rest)] : scalar(rest);
75
+ listKey = null;
76
+ }
77
+ }
78
+ for (const k of LIST_FIELDS) if (meta[k] === undefined) meta[k] = [];
79
+ return meta;
80
+ }
81
+
82
+ function quote(v) {
83
+ const s = String(v ?? "");
84
+ // Quote anything YAML would misread: colons, leading symbols, or empty.
85
+ return /[:#[\]{}"'|>&*!%@`,]|^\s|\s$|^$/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
86
+ }
87
+
88
+ const META_ORDER = [
89
+ "id",
90
+ "title",
91
+ "created",
92
+ "author",
93
+ "source_context",
94
+ "status",
95
+ "stack_assumptions",
96
+ "tags",
97
+ ];
98
+
99
+ export function serializeFrontmatter(meta) {
100
+ const keys = [
101
+ ...META_ORDER.filter((k) => k in meta),
102
+ ...Object.keys(meta).filter((k) => !META_ORDER.includes(k)),
103
+ ];
104
+ const out = [];
105
+ for (const key of keys) {
106
+ const v = meta[key];
107
+ if (Array.isArray(v)) {
108
+ out.push(v.length ? `${key}: [${v.map(quote).join(", ")}]` : `${key}: []`);
109
+ } else {
110
+ out.push(`${key}: ${quote(v)}`);
111
+ }
112
+ }
113
+ return out.join("\n");
114
+ }
115
+
116
+ /** Split a markdown document into { meta, body }. A document with no frontmatter has empty meta. */
117
+ export function parse(markdown) {
118
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(markdown);
119
+ if (!m) return { meta: parseFrontmatter(""), body: markdown.trim() };
120
+ return { meta: parseFrontmatter(m[1]), body: m[2].trim() };
121
+ }
122
+
123
+ export function serialize({ meta, body }) {
124
+ return `---\n${serializeFrontmatter(meta)}\n---\n\n${body.trim()}\n`;
125
+ }
126
+
127
+ /** Map of "## Heading" → section text, so callers can look at Verification or Gotchas alone. */
128
+ export function sections(body) {
129
+ const out = {};
130
+ let current = null;
131
+ for (const line of body.split(/\r?\n/)) {
132
+ const h = /^##\s+(.+?)\s*$/.exec(line);
133
+ if (h) {
134
+ current = h[1];
135
+ out[current] = "";
136
+ continue;
137
+ }
138
+ if (current) out[current] += `${line}\n`;
139
+ }
140
+ for (const k of Object.keys(out)) out[k] = out[k].trim();
141
+ return out;
142
+ }
143
+
144
+ /** Problems that make a guide unfit to publish. Empty array means it is fine. */
145
+ export function validate({ meta, body }) {
146
+ const errors = [];
147
+ if (!meta.title) errors.push("frontmatter needs a title");
148
+ if (meta.id && !ID_RE.test(meta.id)) errors.push(`id "${meta.id}" is not a valid passalong id`);
149
+ if (meta.status && !STATUSES.includes(meta.status)) {
150
+ errors.push(`status must be one of ${STATUSES.join(", ")}`);
151
+ }
152
+ const have = sections(body);
153
+ for (const s of ["Problem", "Steps"]) {
154
+ if (!have[s]) errors.push(`missing "## ${s}" section`);
155
+ }
156
+ if (body.includes("<!-- passalong:")) errors.push("template placeholders are still in the body");
157
+ return errors;
158
+ }
159
+
160
+ /** Fill in what a fresh guide needs before it can be stored. Returns a new object. */
161
+ export function stamp(guide, defaults = {}) {
162
+ const meta = { ...guide.meta };
163
+ if (!meta.id) meta.id = newId();
164
+ if (!meta.created) meta.created = new Date().toISOString();
165
+ if (!meta.status) meta.status = "draft";
166
+ for (const [k, v] of Object.entries(defaults))
167
+ if (meta[k] === undefined || meta[k] === "") meta[k] = v;
168
+ return { meta, body: guide.body };
169
+ }
170
+
171
+ /** A draft with the section skeleton. Placeholders are HTML comments so they vanish when rendered. */
172
+ export function template(meta = {}) {
173
+ const body = [
174
+ "## Problem",
175
+ "<!-- passalong: What was broken or needed, in two or three sentences. -->",
176
+ "",
177
+ "## Solution shape",
178
+ "<!-- passalong: The approach at a high level, before any code. -->",
179
+ "",
180
+ "## Decisions and rationale",
181
+ "<!-- passalong: What was chosen, what was rejected, and why. This lets the receiver adapt instead of copy. -->",
182
+ "",
183
+ "## Steps",
184
+ "<!-- passalong: Concrete implementation steps. Mark context-specific parts: ASSUMES: Postgres. If MySQL, adjust X. -->",
185
+ "",
186
+ "## Verification",
187
+ "<!-- passalong: Commands, expected outputs, test cases that prove it worked. -->",
188
+ "",
189
+ "## Gotchas",
190
+ "<!-- passalong: What failed along the way and why. Often the highest-value section. -->",
191
+ ].join("\n");
192
+ return serialize({
193
+ meta: {
194
+ title: "",
195
+ author: "",
196
+ source_context: "",
197
+ status: "draft",
198
+ stack_assumptions: [],
199
+ tags: [],
200
+ ...meta,
201
+ },
202
+ body,
203
+ });
204
+ }
205
+
206
+ /** Strip the template comments so an untouched section reads as empty rather than as a prompt. */
207
+ export function stripPlaceholders(body) {
208
+ return body.replace(/^<!-- passalong:.*?-->\n?/gm, "").trim();
209
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,130 @@
1
+ // The Passalong MCP server (stdio). This is the rail that makes Passalong tool-agnostic: any MCP-capable
2
+ // agent can search, pull, and publish guides without leaving its session.
3
+ //
4
+ // claude mcp add passalong -- passalong mcp
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { z } from "zod";
8
+ import { SECTIONS, template } from "./guide.js";
9
+ import * as passalong from "./passalong.js";
10
+
11
+ const text = (s) => ({ content: [{ type: "text", text: s }] });
12
+ const json = (data) => text(JSON.stringify(data, null, 2));
13
+ const fail = (err) => ({ content: [{ type: "text", text: err.message }], isError: true });
14
+
15
+ export async function serve() {
16
+ const server = new McpServer(
17
+ { name: "passalong", version: "0.1.0" },
18
+ {
19
+ instructions:
20
+ "Passalong hands finished implementations between contexts as transfer guides: markdown with " +
21
+ `frontmatter and the sections ${SECTIONS.join(", ")}. ` +
22
+ "When the user references a passalong id or link, call get_guide and follow its Steps, adapting " +
23
+ "anything marked ASSUMES to this codebase; run its Verification before declaring done. " +
24
+ "When the user asks to share, hand off, or passalong what was just done, distill the session " +
25
+ "into a guide (guide_template shows the shape) and call publish_guide. Gotchas are the " +
26
+ "highest-value section: record what failed and why.",
27
+ },
28
+ );
29
+
30
+ server.registerTool(
31
+ "search_guides",
32
+ {
33
+ title: "Search guides",
34
+ description:
35
+ "Search the user's transfer guides (local and synced) by words in the title, tags, stack, " +
36
+ "or body. Empty query lists everything, newest first.",
37
+ inputSchema: { query: z.string().default("") },
38
+ },
39
+ async ({ query }) => {
40
+ try {
41
+ const rows = await passalong.list(query);
42
+ return json({ guides: rows, warning: rows.warning });
43
+ } catch (err) {
44
+ return fail(err);
45
+ }
46
+ },
47
+ );
48
+
49
+ server.registerTool(
50
+ "get_guide",
51
+ {
52
+ title: "Get guide",
53
+ description:
54
+ "Fetch a transfer guide by passalong id or share link and return its full markdown. Also writes " +
55
+ "it to .passalong/<id>.md in the working directory so it survives the session.",
56
+ inputSchema: {
57
+ ref: z.string().describe("passalong id (e.g. k3mq2xa7) or share URL"),
58
+ cwd: z
59
+ .string()
60
+ .optional()
61
+ .describe("directory to write .passalong/<id>.md into; default is the server's cwd"),
62
+ },
63
+ },
64
+ async ({ ref, cwd }) => {
65
+ try {
66
+ const { markdown, path, from } = await passalong.pull(ref, { cwd: cwd || process.cwd() });
67
+ return text(`${markdown}\n\n<!-- passalong: ${from}; written to ${path} -->`);
68
+ } catch (err) {
69
+ return fail(err);
70
+ }
71
+ },
72
+ );
73
+
74
+ server.registerTool(
75
+ "publish_guide",
76
+ {
77
+ title: "Publish guide",
78
+ description:
79
+ "Publish a transfer guide from markdown (frontmatter + sections). Missing id, created, " +
80
+ "author, and source_context are filled in. Returns the id and share link.",
81
+ inputSchema: {
82
+ markdown: z.string().describe("full guide markdown; start from guide_template"),
83
+ cwd: z
84
+ .string()
85
+ .optional()
86
+ .describe("directory the work happened in, used to infer source_context"),
87
+ },
88
+ },
89
+ async ({ markdown, cwd }) => {
90
+ try {
91
+ const { guide, url, synced, path } = await passalong.share(markdown, {
92
+ cwd: cwd || process.cwd(),
93
+ });
94
+ return json({ id: guide.meta.id, title: guide.meta.title, url, synced, path });
95
+ } catch (err) {
96
+ return fail(err);
97
+ }
98
+ },
99
+ );
100
+
101
+ server.registerTool(
102
+ "guide_template",
103
+ {
104
+ title: "Guide template",
105
+ description: "The empty transfer guide skeleton with guidance comments for each section.",
106
+ inputSchema: {},
107
+ },
108
+ async () => text(template()),
109
+ );
110
+
111
+ server.registerTool(
112
+ "set_guide_status",
113
+ {
114
+ title: "Set guide status",
115
+ description:
116
+ "Mark a guide consumed (implemented on the receiving side) or promoted (a reusable reference).",
117
+ inputSchema: { id: z.string(), status: z.enum(["published", "consumed", "promoted"]) },
118
+ },
119
+ async ({ id, status }) => {
120
+ try {
121
+ const g = await passalong.setStatus(id, status);
122
+ return json(passalong.summary(g));
123
+ } catch (err) {
124
+ return fail(err);
125
+ }
126
+ },
127
+ );
128
+
129
+ await server.connect(new StdioServerTransport());
130
+ }
@@ -0,0 +1,157 @@
1
+ // The operations Passalong exposes. Both surfaces (bin/passalong and the MCP server) call these, so
2
+ // anything an agent can do through MCP a human can do from the terminal and vice versa.
3
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import * as api from "./api.js";
6
+ import { context } from "./capture.js";
7
+ import { ID_RE, parse, STATUSES, serialize, stamp, stripPlaceholders, validate } from "./guide.js";
8
+ import * as store from "./store.js";
9
+
10
+ export class PassalongError extends Error {}
11
+
12
+ /**
13
+ * Publish a guide from markdown: fill in defaults, validate, store locally, and sync when
14
+ * logged in. Returns { guide, path, url, synced }.
15
+ */
16
+ export async function share(markdown, { cwd = process.cwd() } = {}) {
17
+ const c = context(cwd);
18
+ const raw = parse(markdown);
19
+ let guide = stamp(
20
+ { meta: raw.meta, body: stripPlaceholders(raw.body) },
21
+ {
22
+ author: c.author,
23
+ source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
24
+ },
25
+ );
26
+ const errors = validate(guide);
27
+ if (errors.length)
28
+ throw new PassalongError(`guide is not ready to share:\n - ${errors.join("\n - ")}`);
29
+ if (guide.meta.status === "draft")
30
+ guide = { ...guide, meta: { ...guide.meta, status: "published" } };
31
+
32
+ let url = guide.meta.url || null;
33
+ let synced = false;
34
+ if (api.loggedIn()) {
35
+ const res = await api.publish(guide.meta.id, serialize(guide));
36
+ url = res.url;
37
+ synced = true;
38
+ guide = { ...guide, meta: { ...guide.meta, url } };
39
+ }
40
+ const path = store.save(guide);
41
+ return { guide, path, url, synced };
42
+ }
43
+
44
+ /** Resolve a reference (id, share URL, or local file path) to a guide. */
45
+ export async function resolve(ref) {
46
+ if (/^https?:\/\//.test(ref)) {
47
+ const md = await api.fetchShared(ref);
48
+ return { guide: parse(md), from: "link" };
49
+ }
50
+ if (ref.endsWith(".md") && existsSync(ref)) {
51
+ const { readFileSync } = await import("node:fs");
52
+ return { guide: parse(readFileSync(ref, "utf8")), from: "file" };
53
+ }
54
+ if (!ID_RE.test(ref))
55
+ throw new PassalongError(`"${ref}" is not a passalong id, share link, or .md file`);
56
+ const local = store.get(ref);
57
+ if (local) return { guide: local, from: "local" };
58
+ if (api.loggedIn()) {
59
+ try {
60
+ const md = await api.get(ref);
61
+ const guide = parse(md);
62
+ store.save(guide); // cache so the next pull is offline
63
+ return { guide, from: "sync" };
64
+ } catch (err) {
65
+ if (err.status !== 404) throw err;
66
+ }
67
+ }
68
+ throw new PassalongError(
69
+ `no guide "${ref}" (not local${api.loggedIn() ? " or synced" : "; not logged in, so sync was not checked"})`,
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Pull a guide into a working directory: writes .passalong/<id>.md there and returns the guide so
75
+ * the caller can put the text straight into an agent's context.
76
+ */
77
+ export async function pull(ref, { cwd = process.cwd(), write = true } = {}) {
78
+ const { guide, from } = await resolve(ref);
79
+ let path = null;
80
+ if (write) {
81
+ const dir = join(cwd, ".passalong");
82
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
83
+ path = join(dir, `${guide.meta.id}.md`);
84
+ writeFileSync(path, serialize(guide));
85
+ }
86
+ return { guide, from, path, markdown: serialize(guide) };
87
+ }
88
+
89
+ /** Local guides merged with synced ones (by id), newest first, optionally filtered. */
90
+ export async function list(query = "", { remote = true } = {}) {
91
+ const local = store.search(query);
92
+ const seen = new Set(local.map((g) => g.meta.id));
93
+ const rows = local.map((g) => ({ ...summary(g), where: "local" }));
94
+ if (remote && api.loggedIn()) {
95
+ try {
96
+ const res = await api.list(query);
97
+ for (const r of res.guides) {
98
+ if (seen.has(r.id)) {
99
+ const row = rows.find((x) => x.id === r.id);
100
+ row.where = "synced";
101
+ row.url = r.url;
102
+ row.pulls = r.pulls;
103
+ } else rows.push({ ...r, where: "synced" });
104
+ }
105
+ } catch (err) {
106
+ rows.warning = `sync unavailable: ${err.message}`;
107
+ }
108
+ }
109
+ return rows.sort((a, b) => (b.created || "").localeCompare(a.created || ""));
110
+ }
111
+
112
+ export function summary(g) {
113
+ return {
114
+ id: g.meta.id,
115
+ title: g.meta.title,
116
+ status: g.meta.status,
117
+ created: g.meta.created,
118
+ source_context: g.meta.source_context,
119
+ tags: g.meta.tags || [],
120
+ stack_assumptions: g.meta.stack_assumptions || [],
121
+ url: g.meta.url || null,
122
+ };
123
+ }
124
+
125
+ /** Move a guide through its lifecycle: published → consumed → promoted (any order allowed). */
126
+ export async function setStatus(id, status) {
127
+ if (!STATUSES.includes(status))
128
+ throw new PassalongError(`status must be one of ${STATUSES.join(", ")}`);
129
+ const { guide } = await resolve(id);
130
+ const next = { ...guide, meta: { ...guide.meta, status } };
131
+ store.save(next);
132
+ if (api.loggedIn() && guide.meta.url) await api.setStatus(id, status);
133
+ return next;
134
+ }
135
+
136
+ export async function remove(id) {
137
+ store.remove(id);
138
+ if (api.loggedIn()) {
139
+ try {
140
+ await api.remove(id);
141
+ } catch (err) {
142
+ if (err.status !== 404) throw err;
143
+ }
144
+ }
145
+ }
146
+
147
+ /** Write every local guide to a directory as plain markdown. Returns the paths written. */
148
+ export function exportAll(dir) {
149
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
150
+ const paths = [];
151
+ for (const g of store.list()) {
152
+ const p = join(dir, `${g.meta.id}.md`);
153
+ writeFileSync(p, serialize(g));
154
+ paths.push(p);
155
+ }
156
+ return paths;
157
+ }
package/src/store.js ADDED
@@ -0,0 +1,113 @@
1
+ // The local store: ~/.passalong (override with PASSALONG_HOME). Guides are one markdown file each so
2
+ // the whole thing is greppable, git-friendly, and there is nothing to export that isn't
3
+ // already a file you own.
4
+ //
5
+ // ~/.passalong/config.json api url + token
6
+ // ~/.passalong/guides/<id>.md
7
+ // ~/.passalong/drafts/*.md captured but not yet shared
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ statSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { parse, serialize } from "./guide.js";
20
+
21
+ export const HOME = process.env.PASSALONG_HOME || join(homedir(), ".passalong");
22
+ export const GUIDES = join(HOME, "guides");
23
+ export const DRAFTS = join(HOME, "drafts");
24
+ const CONFIG = join(HOME, "config.json");
25
+
26
+ function ensure() {
27
+ for (const d of [HOME, GUIDES, DRAFTS])
28
+ if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o700 });
29
+ }
30
+
31
+ export function readConfig() {
32
+ if (!existsSync(CONFIG)) return {};
33
+ try {
34
+ return JSON.parse(readFileSync(CONFIG, "utf8"));
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ export function writeConfig(patch) {
41
+ ensure();
42
+ const next = { ...readConfig(), ...patch };
43
+ writeFileSync(CONFIG, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
44
+ return next;
45
+ }
46
+
47
+ export function guidePath(id) {
48
+ return join(GUIDES, `${id}.md`);
49
+ }
50
+
51
+ export function get(id) {
52
+ const p = guidePath(id);
53
+ if (!existsSync(p)) return null;
54
+ return parse(readFileSync(p, "utf8"));
55
+ }
56
+
57
+ export function save(guide) {
58
+ ensure();
59
+ writeFileSync(guidePath(guide.meta.id), serialize(guide));
60
+ return guidePath(guide.meta.id);
61
+ }
62
+
63
+ export function remove(id) {
64
+ const p = guidePath(id);
65
+ if (existsSync(p)) unlinkSync(p);
66
+ }
67
+
68
+ /** All local guides, newest first. */
69
+ export function list() {
70
+ ensure();
71
+ return readdirSync(GUIDES)
72
+ .filter((f) => f.endsWith(".md"))
73
+ .map((f) => parse(readFileSync(join(GUIDES, f), "utf8")))
74
+ .filter((g) => g.meta.id)
75
+ .sort((a, b) => (b.meta.created || "").localeCompare(a.meta.created || ""));
76
+ }
77
+
78
+ /** Draft files, newest first. */
79
+ export function drafts() {
80
+ ensure();
81
+ return readdirSync(DRAFTS)
82
+ .filter((f) => f.endsWith(".md"))
83
+ .map((f) => join(DRAFTS, f))
84
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
85
+ }
86
+
87
+ export function draftPath(name) {
88
+ ensure();
89
+ return join(DRAFTS, `${name}.md`);
90
+ }
91
+
92
+ export function removeDraft(path) {
93
+ if (path.startsWith(DRAFTS) && existsSync(path)) unlinkSync(path);
94
+ }
95
+
96
+ /** Case-insensitive match on title, tags, source context, and body. */
97
+ export function search(query, guides = list()) {
98
+ const q = query.trim().toLowerCase();
99
+ if (!q) return guides;
100
+ const terms = q.split(/\s+/);
101
+ return guides.filter((g) => {
102
+ const hay = [
103
+ g.meta.title,
104
+ g.meta.source_context,
105
+ ...(g.meta.tags || []),
106
+ ...(g.meta.stack_assumptions || []),
107
+ g.body,
108
+ ]
109
+ .join("\n")
110
+ .toLowerCase();
111
+ return terms.every((t) => hay.includes(t));
112
+ });
113
+ }