// GoGoPortal's lessons script: the one program of the gogoportal skill // (docs/plans/remote-connector.md, sections 1, 2 and 6). Plain ESM with // nothing beyond Node itself (18 or later) and the cleaner beside it, so it // runs as it is in Claude Code, in any agent that reads ~/.agents/skills, // and in Claude's own sandbox on claude.ai. // // The member's own agent runs it. It keeps the lessons the agent notes in // ~/.gogoportal/lessons/.jsonl, cleaned, with the project's // state beside it (.json) and a version the member can read // (.md): the files and keys of the local connector // (connector.ts: lessonProjectKey, saveLessonFiles), so lessons noted there // carry over. It says when a batch is due (owner, 2026-10-12: "I would // rather wait until its expected that most of the research is done before // submitting"), prints a batch cleaned and stamped with cleaner.mjs for the // remote submit_lessons tool, cleans and stamps lessons the agent wrote at // the member's request or a correction, checks a file the member named // (refused, never edited), and records what submit_lessons answered. // // It makes no network call and reads nothing of a project except the files // named and the member's two lists at the folder's top (and a client list in // a folder above it); the project folder is otherwise only a name for its // lessons. What it prints is plain facts for the agent, never orders framed // as system text. `allow` is for the member to run at their own terminal. import { createHash, randomBytes } from "node:crypto"; import { realpathSync } from "node:fs"; import { copyFile, lstat, mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { BATCH_FORMAT, cleanBatch, cleanLessonText, findInText, isSecretFileName, lessonTopicProblem, stampBatch } from "./cleaner.mjs"; // ─── The rules ────────────────────────────────────────────────── /** * When a project's batch of lessons is due (owner, 2026-10-12; the plan's * section 2). Research is done when lessons stop arriving: due when at least * minLessons are pending, minDays have passed since the project's last batch * (or since its oldest pending lesson, before any), and no lesson was noted * for quietDays; or at once when the member says the project or its research * is finished and at least finishedMinLessons are pending. At maxLessons the * batch is due at the next natural stopping point, never in the middle of * research. The credits-used trigger is retired: it could fire mid-research. */ export const LESSON_BATCH = Object.freeze({ minLessons: 10, minDays: 7, quietDays: 3, finishedMinLessons: 3, maxLessons: 40 }); /** One noted lesson's size: at most bodyMaxChars, and at least minWords so it counts on its own at the site's check (the connector's LESSON_LIMITS). */ export const LESSON_LIMITS = Object.freeze({ bodyMaxChars: 2000, minWords: 25, sourceMaxChars: 300, appliesToMaxChars: 300 }); /** The site's published kinds (src/lib/marketplace/network-states.ts; a test keeps them equal). */ export const ITEM_KINDS = Object.freeze(["requirement", "research", "decision", "pitfall", "failure", "structure", "template", "checklist"]); export const SOURCE_KINDS = Object.freeze(["official_docs", "changelog", "release_note"]); export const CORRECTION_KINDS = Object.freeze(["changed_since", "wrong"]); /** * A batch's limits: the site's (src/lib/marketplace/config.ts and * lessons-batch.ts; a test keeps them equal; filesMax is the most material * files a shared project keeps, so clean takes no more); and * printedMaxChars, the most a printed batch holds, so it fits an agent's * command output (Claude Code keeps 30,000 characters of it) and one tool * call. Noted lessons past it wait for the next batch; clean refuses a batch * past it, and a named file larger than it. */ export const BATCH_LIMITS = Object.freeze({ itemsMax: 60, kindsOfProjectMax: 5, kindOfProjectMaxChars: 80, descriptionMaxChars: 2000, topicMaxChars: 80, bodyMaxChars: 8000, circumstancesMaxChars: 1000, buildsOnMax: 10, appliesToMax: 10, appliesToMaxChars: 80, identifyingTermsMax: 200, identifyingTermMinChars: 2, identifyingTermMaxChars: 80, filesMax: 19, fileMaxBytes: 5_000_000, printedMaxChars: 25_000, }); /** The member's client names, one per line, at the project folder's top. */ export const CLIENT_LIST = ".gogoportal-clients"; /** The member's list of what to keep out, one path or pattern per line, at the project folder's top (read only for a named file). */ export const PRIVATE_LIST = ".gogoportal-private"; /** What a batch of noted lessons adds (the connector's LESSONS_ADDS). */ export const LESSONS_ADDS = "Lessons the member's own agent noted while it worked on this kind of project: pitfalls, decisions and why, version changes, requirements and what failed."; /** What a correction adds, by its kind (the connector's words). */ export const CORRECTION_ADDS = Object.freeze({ changed_since: "A correction of one takeaway that changed, with since when and its source.", wrong: "A correction of one takeaway that did not hold, with its source.", }); /** * Claude Code's rule for every tool of the plugin's server, under * permissions.allow. A plugin's tools are named * mcp__plugin____, "the name to use in permission * rules" (code.claude.com/docs/en/mcp, "Plugin-provided MCP servers"), and * "Allow rules accept tool-name globs only after a literal mcp____ * prefix" (code.claude.com/docs/en/permissions). The bare server form * (mcp__plugin_gogoportal_gogoportal) is documented only for a server * configured by name, so it counts as already covering but is not the one * added. */ export const PLUGIN_ALLOW_RULE = "mcp__plugin_gogoportal_gogoportal__*"; const COVERING_RULES = Object.freeze([PLUGIN_ALLOW_RULE, "mcp__plugin_gogoportal_gogoportal"]); /** The rule when the server was added by its address instead (claude mcp add --transport http gogoportal
). */ export const ADDRESS_ALLOW_RULE = "mcp__gogoportal"; const DAY_MS = 86_400_000; /** Batches printed and not recorded as sent that a project keeps, so sent can record any of them (two sessions may each print one). */ const PRINTED_KEPT = 5; const NOTE_MAX_BYTES = 64_000; const CLEAN_MAX_BYTES = 2_000_000; const LOCK_WAIT_MS = 5_000; const LOCK_STALE_MS = 30_000; // ─── Small helpers ────────────────────────────────────────────── const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v); const counted = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`; const day = (ms) => new Date(ms).toISOString().slice(0, 10); const daysText = (days) => counted(Math.floor(days), "day"); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sha256 = (text) => createHash("sha256").update(text).digest("hex"); function problemOf(e) { return e instanceof Error ? e.message : String(e); } function sizeText(bytes) { if (bytes < 1000) return `${bytes} B`; if (bytes < 1_000_000) return `${(bytes / 1000).toFixed(1)} KB`; return `${(bytes / 1_000_000).toFixed(1)} MB`; } function shellPath(p) { return /[\s"'$`]/.test(p) ? `"${p.replace(/(["\\$`])/g, "\\$1")}"` : p; } /** A refusal: one plain sentence, exit code 1, nothing changed. */ class Refusal extends Error {} const refuse = (text) => { throw new Refusal(text); }; // ─── Where lessons are kept (the connector's files and keys) ──── /** The folder lessons are kept in: /.gogoportal/lessons. */ export function lessonsDir(homeDir) { return path.join(homeDir, ".gogoportal", "lessons"); } /** A project's key: its folder's name, made safe, and a hash of its full path with links resolved (without regard to case on Windows). */ export function lessonProjectKey(realFolder, platform = process.platform) { const full = platform === "win32" ? path.resolve(realFolder).toLowerCase() : path.resolve(realFolder); const slug = path.basename(full).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "project"; return `${slug}-${sha256(full).slice(0, 12)}`; } /** * A project's state: the connector's fields, then the skill's own (the * member's client names given with --client, kept only here; the batches * printed and not recorded as sent; and finishedAt, when a batch went * because the member said the project is finished, so the lessons it left * pending go too, until a new lesson is noted). */ function newProject(folder) { return { folder, site: null, description: null, kindsOfProject: [], identifyingTerms: [], sharedProjectId: null, sentToProject: 0, lastSubmittedAt: null, openDraft: null, retryAt: null, lastProblem: null, clientNames: [], printedBatches: [], finishedAt: null, }; } /** The batches printed and not recorded as sent, oldest first: at most PRINTED_KEPT. */ function printedBatches(project) { const list = Array.isArray(project.printedBatches) ? project.printedBatches : []; return list.filter((p) => isObject(p) && typeof p.fingerprint === "string" && Array.isArray(p.lessonIds) && typeof p.printedAt === "string"); } function projectFiles(homeDir, key) { const dir = lessonsDir(homeDir); return { dir, key, jsonl: path.join(dir, `${key}.jsonl`), state: path.join(dir, `${key}.json`), md: path.join(dir, `${key}.md`), lock: path.join(dir, `${key}.lock`) }; } async function readLessons(file) { const text = await readFile(file, "utf8").catch(() => ""); const out = []; for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue; try { const l = JSON.parse(line); if (isObject(l) && typeof l.id === "string" && typeof l.topic === "string" && typeof l.body === "string") out.push(l); } catch { // A line that doesn't read is left alone, never put in a batch. } } return out; } /** A project's state: the connector's fields, kept as they are (any it added too), and the batch printed last. */ async function readProject(file, folder) { const text = await readFile(file, "utf8").catch(() => null); if (text === null) return newProject(folder); try { const saved = JSON.parse(text); return isObject(saved) ? { ...newProject(folder), ...saved } : newProject(folder); } catch { return newProject(folder); } } /** Writes a file whole: a new file, then renamed over the old one (again, briefly, while Windows holds the old one open). */ async function writeWhole(file, text) { const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`; await writeFile(tmp, text, { mode: 0o600 }); for (let attempt = 0; ; attempt++) { try { await rename(tmp, file); return; } catch (e) { if (attempt >= 5 || !["EPERM", "EBUSY", "EACCES"].includes(e?.code)) { await rm(tmp, { force: true }); throw e; } await sleep(20 * 2 ** attempt); } } } /** What the member can open: what is pending, then each batch sent. */ export function lessonsMarkdown(project, lessons) { const pending = lessons.filter((l) => l.status === "pending"); const sent = lessons.filter((l) => l.status === "sent"); const one = (l) => { const facts = [l.kind, `noted ${String(l.notedAt).slice(0, 10)}`, l.appliesTo?.length ? `applies to ${l.appliesTo.join(", ")}` : "", l.source ? `source: ${l.source}` : ""].filter(Boolean).join(" · "); return [`### ${l.topic}`, "", facts, "", l.body, ""].join("\n"); }; const lines = [ `# Lessons for ${project.folder}`, "", "Your own agent noted these as it worked, and GoGoPortal's skill keeps them here, on this computer, cleaned. Your agent submits them in batches, once the research has gone quiet or you say the project is finished. Each batch waits for your sign-off on the site unless you chose otherwise in your settings there.", "", `## Pending (${pending.length})`, "", ...(pending.length ? pending.map(one) : ["None.", ""]), ]; const batches = new Map(); for (const l of sent) { const k = l.draftId ?? "unknown"; batches.set(k, [...(batches.get(k) ?? []), l]); } if (batches.size) lines.push("## Sent", ""); for (const [draftId, list] of [...batches.entries()].reverse()) { lines.push(`### Batch of ${String(list[0].sentAt ?? "").slice(0, 10)}: ${counted(list.length, "lesson")}${draftId !== "unknown" ? ` (draft ${draftId})` : ""}`, ""); for (const l of list) lines.push(one(l).replace(/^### /, "#### ")); } return `${lines.join("\n").trimEnd()}\n`; } async function saveProject(files, project, lessons) { await mkdir(files.dir, { recursive: true, mode: 0o700 }); await writeWhole(files.jsonl, lessons.map((l) => JSON.stringify(l)).join("\n") + (lessons.length ? "\n" : "")); await writeWhole(files.state, `${JSON.stringify(project, null, 2)}\n`); await writeWhole(files.md, lessonsMarkdown(project, lessons)); } /** Runs one change to a project's lessons at a time, across processes: a lock file beside them, taken over when a run left it behind. */ async function withLock(files, fn) { await mkdir(files.dir, { recursive: true, mode: 0o700 }); const started = Date.now(); for (;;) { try { await (await open(files.lock, "wx")).close(); break; } catch (e) { if (e?.code !== "EEXIST") throw e; const info = await stat(files.lock).catch(() => null); if (info && Date.now() - info.mtimeMs > LOCK_STALE_MS) { await rm(files.lock, { force: true }); continue; } if (Date.now() - started > LOCK_WAIT_MS) refuse("Another run of this script is changing these lessons. Run the command again in a moment."); await sleep(50); } } try { return await fn(); } finally { await rm(files.lock, { force: true }); } } /** * A text file as the member saved it: UTF-8 (a leading byte order mark * dropped), or UTF-16 with its byte order mark (what Windows PowerShell 5.1 * writes with > and Out-File). Anything else that is not UTF-8 text is * refused in one sentence naming the file, so a list is never read as * something that matches nothing. */ export function decodeMemberText(bytes, name) { let text; if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) text = new TextDecoder("utf-16le").decode(bytes.subarray(2)); else if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) text = new TextDecoder("utf-16be").decode(bytes.subarray(2)); else text = new TextDecoder("utf-8").decode(bytes).replace(/^\uFEFF/, ""); if (text.includes("\u0000") || text.includes("\uFFFD")) { refuse(`${name} isn't saved as UTF-8 text, so it can't be read. Save it as UTF-8 (in PowerShell: Set-Content -Encoding utf8), then run the command again.`); } return text; } async function linesOf(file) { const bytes = await readFile(file).catch(() => null); if (!bytes) return []; return decodeMemberText(bytes, file) .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l && !l.startsWith("#")); } /** The member's client names: .gogoportal-clients at the project folder's top and in each folder above it (a folder for one client's projects, say). */ async function clientList(folder) { const names = []; for (let at = folder; ; at = path.dirname(at)) { names.push(...(await linesOf(path.join(at, CLIENT_LIST)))); if (path.dirname(at) === at) return names; } } /** The terms the cleaning takes out: the member's client names (their lists, and any given with --client) and the names that identify the project. */ async function cleaningTerms(folder, project) { const kept = (v) => (Array.isArray(v) ? v.filter((t) => typeof t === "string") : []); return [...(folder ? await clientList(folder) : []), ...kept(project.clientNames), ...kept(project.identifyingTerms)]; } const samePath = (a, b, platform) => (platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b); /** * Whether a folder is a project's top: it holds .git or .gogoportal-private, * or has lessons kept already. A client list does not make one: it may sit * in a folder above several projects (one client's), and is read from there * for the cleaning. */ async function isProjectTop(dir, io) { for (const name of [".git", PRIVATE_LIST]) if (await stat(path.join(dir, name)).catch(() => null)) return true; return Boolean(await stat(projectFiles(io.homeDir, lessonProjectKey(dir, io.platform)).state).catch(() => null)); } /** * The project folder, with links resolved: --project as given; or else the * nearest folder that is a project's top, from the folder the command runs * in up to (not including) the home folder, so a command run in a subfolder * finds the project's lists and its lessons; or the folder it runs in when * none is. */ async function projectFolder(given, io) { const dir = path.resolve(io.cwd, given ?? "."); const info = await stat(dir).catch(() => null); if (!info) refuse(`There is no folder at ${dir}. Pass --project with the project's folder.`); if (!info.isDirectory()) refuse(`${dir} is a file, not a folder. Pass --project with the project's folder.`); const here = await realpath(dir).catch(() => dir); if (given !== undefined) return here; const home = await realpath(io.homeDir).catch(() => path.resolve(io.homeDir)); for (let at = here; ; at = path.dirname(at)) { if (at !== here && samePath(at, home, io.platform)) return here; if (await isProjectTop(at, io)) return at; if (path.dirname(at) === at) return here; } } const nameOf = (project, key) => path.basename(project.folder || "") || key; // ─── When a batch is due ──────────────────────────────────────── /** * Where a project's pending lessons stand against the rule (LESSON_BATCH): * none pending; held until the day the site's checks take a batch again (a * date the local connector kept); due, because the research has gone quiet * or the member said the project is finished; due at the next natural * stopping point; or not due yet, with when it would be. */ export function batchState(project, lessons, now, opts = {}) { const rule = LESSON_BATCH; const nowMs = now.getTime(); const pending = lessons.filter((l) => l.status === "pending"); const n = pending.length; if (n === 0) return { kind: "none", pending: 0 }; const finishedAt = typeof project.finishedAt === "string" ? Date.parse(project.finishedAt) : NaN; const times = pending.map((l) => Date.parse(l.notedAt)).filter(Number.isFinite); const oldest = times.length ? Math.min(...times) : nowMs; const newest = times.length ? Math.max(...times) : nowMs; const last = project.lastSubmittedAt ? Date.parse(project.lastSubmittedAt) : NaN; const hadBatch = Number.isFinite(last); const since = hadBatch ? last : oldest; const facts = { pending: n, now: nowMs, hadBatch, since, newest, daysSinceBatch: (nowMs - since) / DAY_MS, daysQuiet: (nowMs - newest) / DAY_MS }; const retry = project.retryAt ? Date.parse(project.retryAt) : NaN; if (Number.isFinite(retry) && nowMs < retry) return { kind: "retry_later", ...facts, from: retry }; // A batch held until the site's checks take one again goes on that day, whatever the quiet rule says. if (Number.isFinite(retry)) return { kind: "due", reason: "retry", ...facts, from: retry }; if (opts.finished && n >= rule.finishedMinLessons) return { kind: "due", reason: "finished", ...facts }; // The member said the project is finished and its batch went: what that batch left pending goes too. if (Number.isFinite(finishedAt)) return { kind: "due", reason: "finished", ...facts }; if (n >= rule.minLessons && facts.daysSinceBatch >= rule.minDays && facts.daysQuiet >= rule.quietDays) return { kind: "due", reason: "quiet", ...facts }; if (n >= rule.maxLessons) return { kind: "stopping_point", ...facts }; return { kind: "not_due", ...facts }; } /** The state in one or two sentences; `label` names the project. */ export function stateText(state, label) { const rule = LESSON_BATCH; const n = state.pending; switch (state.kind) { case "none": return `No lessons are pending for ${label}.`; case "retry_later": return `${counted(n, "lesson")} pending for ${label}. The site's checks ran their most times this week, so the next batch can go from ${day(state.from)}.`; case "due": if (state.reason === "retry") return `${counted(n, "lesson")} from ${label} ${n === 1 ? "is" : "are"} ready to submit again: the site's checks take a batch again from ${day(state.from)}.`; return state.reason === "finished" ? `${counted(n, "lesson")} from ${label} ${n === 1 ? "is" : "are"} ready to submit: the member says the project is finished.` : `${counted(n, "lesson")} from ${label} are ready to submit: no new lesson for ${daysText(state.daysQuiet)}, and ${daysText(state.daysSinceBatch)} since ${state.hadBatch ? "the last batch" : "the first was noted"}.`; case "stopping_point": return `${counted(n, "lesson")} from ${label} are waiting, the most a batch waits for: the batch is due at the next natural stopping point (a finished task, or the member wrapping up), not in the middle of research.`; default: { const more = n < rule.minLessons ? ` (${rule.minLessons - n} more)` : ""; const batchFrom = state.since + rule.minDays * DAY_MS; const quietFrom = state.newest + rule.quietDays * DAY_MS; const from = (ms) => (ms > state.now ? ` (from ${day(ms)})` : ""); const finished = n >= rule.finishedMinLessons ? "" : ` (at least ${rule.finishedMinLessons} pending)`; return [ `${counted(n, "lesson")} pending for ${label}.`, `A batch is due once the research has gone quiet: at least ${rule.minLessons} pending${more}, ${rule.minDays} days since ${state.hadBatch ? "the last batch" : "the first was noted"}${from(batchFrom)}, and no new lesson for ${rule.quietDays} days${from(quietFrom)}.`, `Or at once when the member says the project is finished${finished}.`, ].join(" "); } } } /** The batch printed last and not recorded as sent, in one sentence; empty when there is none. */ function printedText(project) { const p = printedBatches(project).at(-1); if (!p) return ""; return `A batch printed on ${p.printedAt.slice(0, 10)} was not recorded as sent: if submit_lessons took it, record its answer with sent --fingerprint ${p.fingerprint}.`; } async function allProjects(homeDir) { const names = await readdir(lessonsDir(homeDir)).catch(() => []); return names.filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -".json".length)).sort(); } /** The due command: one line for each project whose batch is due, now or at the next stopping point. */ async function dueLines(io) { const lines = []; for (const key of await allProjects(io.homeDir)) { const files = projectFiles(io.homeDir, key); const project = await readProject(files.state, ""); const state = batchState(project, await readLessons(files.jsonl), io.now()); if (state.kind !== "due" && state.kind !== "stopping_point") continue; const label = project.folder ? `${nameOf(project, key)} (${project.folder})` : key; lines.push(["GoGoPortal:", stateText(state, label), "The gogoportal skill's batch command prints them.", printedText(project)].filter(Boolean).join(" ")); } return lines; } // ─── Commands ─────────────────────────────────────────────────── /** * Reads one JSON object: from the file --input names (as UTF-8 or UTF-16 * text, so Windows PowerShell's own encodings reach it whole), or else on * standard input. A leading byte order mark is dropped either way. */ async function jsonInput(io, command, maxBytes, opts = {}) { let text; if (opts.input) { const file = path.resolve(io.cwd, opts.input); const info = await stat(file).catch(() => null); if (!info || !info.isFile()) refuse(`There is no file at ${file} for --input.`); if (info.size > maxBytes) refuse(`${command}'s input is larger than ${sizeText(maxBytes)}.`); text = decodeMemberText(await readFile(file), file); if (!text.trim()) refuse(`${file} is empty: write the JSON object in it.`); } else { text = await io.readStdin(maxBytes); if (text === null) refuse(`${command} reads JSON on standard input, or from a file with --input , and none came.`); if (text === undefined) refuse(`${command}'s input is larger than ${sizeText(maxBytes)}.`); text = text.replace(/^\uFEFF/, ""); } let value; try { value = JSON.parse(text); } catch { refuse(`${command}'s input is not valid JSON.`); } if (!isObject(value)) refuse(`${command}'s input is one JSON object.`); return value; } function unknownKeys(value, allowed, what) { const extra = Object.keys(value).filter((k) => !allowed.includes(k)); if (extra.length) refuse(`${what} has ${extra.length === 1 ? "a key" : "keys"} this script doesn't take: ${extra.join(", ")}. It takes ${allowed.join(", ")}.`); } const NOTE_KEYS = ["topic", "body", "kind", "source", "appliesTo", "failed"]; /** note: one lesson, checked, cleaned and kept; counts only in the answer. */ async function noteCommand(opts, io) { const input = await jsonInput(io, "note", NOTE_MAX_BYTES, opts); unknownKeys(input, NOTE_KEYS, "The lesson"); const str = (v) => (typeof v === "string" ? v : ""); const topic = str(input.topic).trim(); const body = str(input.body).trim(); const topicProblem = lessonTopicProblem(topic); if (topicProblem) refuse(`Nothing was noted. ${topicProblem}`); if (body.length > LESSON_LIMITS.bodyMaxChars) refuse(`Nothing was noted. Keep a lesson to ${LESSON_LIMITS.bodyMaxChars} characters: one lesson each time.`); if (body.split(/\s+/).filter(Boolean).length < LESSON_LIMITS.minWords) { refuse(`Nothing was noted. Write at least ${LESSON_LIMITS.minWords} words: what happened, what to do and why, so it stands without the project.`); } const given = str(input.kind); if (given && !ITEM_KINDS.includes(given)) refuse(`Nothing was noted. kind is one of ${ITEM_KINDS.join(", ")}.`); const failed = input.failed === true; const kind = given || (failed ? "failure" : "research"); const appliesTo = Array.isArray(input.appliesTo) ? input.appliesTo.filter((v) => typeof v === "string").join(",") : str(input.appliesTo); const folder = await projectFolder(opts.project, io); const key = lessonProjectKey(folder, io.platform); const files = projectFiles(io.homeDir, key); const noted = await withLock(files, async () => { const lessons = await readLessons(files.jsonl); const project = await readProject(files.state, folder); project.folder = folder; // A new lesson: the research goes on, so a finished batch's rule no longer holds. project.finishedAt = null; const terms = await cleaningTerms(folder, project); const removed = { secrets: 0, personalData: 0, clientNames: 0 }; const clean = (text) => { const c = cleanLessonText(text, terms); removed.secrets += c.found.secrets; removed.personalData += c.found.personalData; removed.clientNames += c.found.clientNames; return c.text.trim(); }; const lesson = { id: randomBytes(6).toString("hex"), notedAt: io.now().toISOString(), kind, topic: clean(topic), body: clean(body), source: clean(str(input.source).slice(0, LESSON_LIMITS.sourceMaxChars)) || null, appliesTo: appliesTo .slice(0, LESSON_LIMITS.appliesToMaxChars) .split(/[,;\n]/) .map((v) => clean(v).slice(0, BATCH_LIMITS.appliesToMaxChars)) .filter(Boolean) .slice(0, BATCH_LIMITS.appliesToMax), failed, removed, status: "pending", }; // "[removed]" can be longer than the name it takes the place of: the topic must still fit the site's limit. if (lesson.topic.length > BATCH_LIMITS.topicMaxChars) refuse(`Nothing was noted. With names taken out the topic is over ${BATCH_LIMITS.topicMaxChars} characters: write a shorter topic.`); // The same topic noted again while it is pending takes the earlier one's place. const same = lessons.findIndex((l) => l.status === "pending" && l.kind === lesson.kind && l.topic.toLowerCase() === lesson.topic.toLowerCase()); if (same >= 0) lessons[same] = { ...lesson, id: lessons[same].id }; else lessons.push(lesson); await saveProject(files, project, lessons); return { replaced: same >= 0, removed, pending: lessons.filter((l) => l.status === "pending").length, name: nameOf(project, key) }; }); const r = noted.removed; const took = r.secrets + r.personalData + r.clientNames > 0 ? ` Removed (counts only): ${r.secrets} secrets or IDs, ${r.personalData} personal data or paths, ${r.clientNames} client names.` : ""; return `${noted.replaced ? "Noted, in place of the pending lesson with the same topic." : "Noted."} ${counted(noted.pending, "lesson")} pending for ${noted.name}.${took} The member can read every lesson in ${files.md}.`; } /** status: the project's pending lessons and when its next batch is due. */ async function statusCommand(opts, io) { const folder = await projectFolder(opts.project, io); const key = lessonProjectKey(folder, io.platform); const files = projectFiles(io.homeDir, key); const lessons = await readLessons(files.jsonl); const project = await readProject(files.state, folder); const state = batchState(project, lessons, io.now()); const lines = [stateText(state, nameOf({ folder }, key))]; if (state.kind === "due" || state.kind === "stopping_point") lines.push("The batch command prints them, cleaned and stamped."); const printed = printedText(project); if (printed) lines.push(printed); if (lessons.length) lines.push(`The member can read every lesson in ${files.md}.`); return lines.join("\n"); } /** The kinds of project, identifying terms and client names given with --kind, --term and --client, checked. */ function projectFacts(opts) { const kinds = opts.kinds.map((k) => k.trim()); if (kinds.some((k) => !k || k.length > BATCH_LIMITS.kindOfProjectMaxChars)) refuse(`Each --kind is a kind of project in ${BATCH_LIMITS.kindOfProjectMaxChars} characters or fewer, such as "building a Quest VR app".`); if (kinds.length > BATCH_LIMITS.kindsOfProjectMax) refuse(`Give at most ${BATCH_LIMITS.kindsOfProjectMax} kinds of project.`); const named = (list, flag) => { const out = list.map((t) => t.trim()); if (out.some((t) => t.length < BATCH_LIMITS.identifyingTermMinChars || t.length > BATCH_LIMITS.identifyingTermMaxChars)) { refuse(`Each ${flag} is a name of ${BATCH_LIMITS.identifyingTermMinChars} to ${BATCH_LIMITS.identifyingTermMaxChars} characters.`); } return out; }; return { kinds, terms: named(opts.terms, "--term"), clients: named(opts.clients, "--client") }; } /** A text cut at a word to at most max characters (a name taken out can leave a text longer than the site's limit). */ function cutAtWord(text, max) { if (text.length <= max) return text; const at = text.slice(0, max + 1).lastIndexOf(" "); return (at > 0 ? text.slice(0, at) : text.slice(0, max)).trimEnd(); } /** * A cleaned batch of noted lessons fitted to the site's limits: each text a * name taken out made too long (a topic, a body, a version, a kind of * project) cut at a word, and how many were. A stored lesson can't be * written again, so it is cut rather than left to block every batch. */ function fitBatch(batch) { let cut = 0; const fit = (text, max) => { const out = cutAtWord(text, max); if (out !== text) cut++; return out; }; const kindsOfProject = batch.kindsOfProject.map((k) => fit(k, BATCH_LIMITS.kindOfProjectMaxChars)); const items = batch.items.map((item) => ({ ...item, topic: fit(item.topic, BATCH_LIMITS.topicMaxChars), body: fit(item.body, BATCH_LIMITS.bodyMaxChars), appliesTo: item.appliesTo.map((v) => fit(v, BATCH_LIMITS.appliesToMaxChars)), })); return { batch: { ...batch, kindsOfProject, items }, cut }; } /** Each text of a batch over the site's limit for it (lessons-batch.ts), by field. */ function overLimits(batch) { const over = []; const check = (v, field, max) => { if (typeof v === "string" && v.trim().length > max) over.push(`${field} is over ${max} characters`); }; check(batch.description, "description", BATCH_LIMITS.descriptionMaxChars); batch.kindsOfProject.forEach((k, i) => check(k, `kindsOfProject[${i}]`, BATCH_LIMITS.kindOfProjectMaxChars)); batch.items.forEach((item, i) => { check(item.topic, `items[${i}].topic`, BATCH_LIMITS.topicMaxChars); check(item.body, `items[${i}].body`, BATCH_LIMITS.bodyMaxChars); check(item.circumstances, `items[${i}].circumstances`, BATCH_LIMITS.circumstancesMaxChars); item.buildsOn.forEach((t, j) => check(t, `items[${i}].buildsOn[${j}]`, BATCH_LIMITS.topicMaxChars)); item.appliesTo.forEach((v, j) => check(v, `items[${i}].appliesTo[${j}]`, BATCH_LIMITS.appliesToMaxChars)); }); return over; } /** A noted lesson as an item of the batch: where it came from goes at the end of its body. */ function lessonItem(l) { const date = String(l.notedAt ?? "").slice(0, 10); const appliesTo = Array.isArray(l.appliesTo) ? l.appliesTo.filter((v) => typeof v === "string" && v.trim()) : []; return { kind: ITEM_KINDS.includes(l.kind) ? l.kind : "research", topic: l.topic, body: l.source ? `${l.body}\n\nSource: ${l.source}` : l.body, circumstances: null, buildsOn: [], learnedAt: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, appliesTo: appliesTo.map((v) => v.slice(0, BATCH_LIMITS.appliesToMaxChars)).slice(0, BATCH_LIMITS.appliesToMax), costToLearn: null, }; } function addRemoved(sum, found) { sum.secrets += found?.secrets ?? 0; sum.personalData += found?.personalData ?? 0; sum.clientNames += found?.clientNames ?? 0; } /** batch: the project's pending lessons as one batch, cleaned and stamped, when it is due. */ async function batchCommand(opts, io) { const facts = projectFacts(opts); const folder = await projectFolder(opts.project, io); const key = lessonProjectKey(folder, io.platform); const files = projectFiles(io.homeDir, key); return withLock(files, async () => { const lessons = await readLessons(files.jsonl); const project = await readProject(files.state, folder); project.folder = folder; const name = nameOf(project, key); const added = (kept, given) => [...new Set([...(Array.isArray(kept) ? kept : []), ...given])].slice(0, BATCH_LIMITS.identifyingTermsMax); // The shared project kept from an earlier answer is one this site doesn't have (another site or account): start a new one. if (opts.newProject && project.sharedProjectId !== null) { project.sharedProjectId = null; project.sentToProject = 0; await saveProject(files, project, lessons); } if (facts.kinds.length) project.kindsOfProject = facts.kinds; if (facts.terms.length) project.identifyingTerms = added(project.identifyingTerms, facts.terms); // The member's client names are kept here and used for the cleaning only: a batch never carries them. if (facts.clients.length) project.clientNames = added(project.clientNames, facts.clients); if (facts.kinds.length || facts.terms.length || facts.clients.length) await saveProject(files, project, lessons); const state = batchState(project, lessons, io.now(), { finished: opts.finished }); if (state.kind === "none" || state.kind === "not_due" || state.kind === "retry_later") { const finished = opts.finished && state.pending < LESSON_BATCH.finishedMinLessons && state.pending > 0 ? ` A batch for a finished project needs at least ${LESSON_BATCH.finishedMinLessons}; they stay here.` : ""; refuse(`No batch was printed. ${stateText(state, name)}${finished}`); } if (!Array.isArray(project.kindsOfProject) || project.kindsOfProject.length === 0) { refuse( `No batch was printed: the first batch for ${name} needs the kind of project. Run batch again with --kind "" (1 to ${BATCH_LIMITS.kindsOfProjectMax}, such as "building a Quest VR app"), from what you know of the project, --term "" for each name that identifies the member's own project (its name, product, company or domain), and --client "" for each of the member's clients you know of. Don't ask the member.`, ); } const pending = lessons.filter((l) => l.status === "pending"); const terms = await cleaningTerms(folder, project); const build = (chosen) => { // A shared project takes at most itemsMax lessons in all: past that, these start a new one. const room = BATCH_LIMITS.itemsMax - (project.sentToProject ?? 0); const sharedProjectId = typeof project.sharedProjectId === "string" && chosen.length <= room ? project.sharedProjectId : null; const removed = { secrets: 0, personalData: 0, clientNames: 0 }; for (const l of chosen) addRemoved(removed, l.removed); const cleaned = cleanBatch( { format: BATCH_FORMAT, kind: "lessons", description: LESSONS_ADDS, kindsOfProject: project.kindsOfProject.slice(0, BATCH_LIMITS.kindsOfProjectMax), identifyingTerms: [...new Set(project.identifyingTerms ?? [])], items: chosen.map(lessonItem), files: [], sharedProjectId, removed, }, terms, ); addRemoved(cleaned.batch.removed, cleaned.found); const fitted = fitBatch(cleaned.batch); return { batch: stampBatch(fitted.batch), cut: fitted.cut }; }; let chosen = pending.slice(0, BATCH_LIMITS.itemsMax); let built = build(chosen); while (chosen.length > 1 && JSON.stringify(built.batch).length > BATCH_LIMITS.printedMaxChars) { chosen = chosen.slice(0, -1); built = build(chosen); } const { batch } = built; const fingerprint = batch.stamp.fingerprint; // Kept until sent records its answer, and an earlier one too: another session may have submitted it. const earlier = printedBatches(project).filter((p) => p.fingerprint !== fingerprint); project.printedBatches = [...earlier, { fingerprint, lessonIds: chosen.map((l) => l.id), printedAt: io.now().toISOString() }].slice(-PRINTED_KEPT); // The member's word is kept: what this batch leaves pending, or a retry of it, goes too. if (state.reason === "finished" && opts.finished) project.finishedAt = io.now().toISOString(); await saveProject(files, project, lessons); const ids = new Set(chosen.map((l) => l.id)); const overlap = earlier.filter((p) => p.lessonIds.some((id) => ids.has(id))).at(-1); const left = pending.length - chosen.length; return { stdout: JSON.stringify(batch), stderr: [ `${counted(chosen.length, "lesson")} from ${name}, cleaned and stamped${left ? `; ${left} ${left === 1 ? "stays" : "stay"} pending for the next batch` : ""}.`, built.cut ? `With names taken out, ${counted(built.cut, "text")} went over the site's limit and ${built.cut === 1 ? "was" : "were"} cut at a word.` : "", overlap ? `An earlier batch with some of these lessons (printed on ${overlap.printedAt.slice(0, 10)}, fingerprint ${overlap.fingerprint}) is not recorded as sent: if submit_lessons took it, record that answer with sent and run batch again, so no lesson goes twice.` : "", `Pass the JSON above to submit_lessons exactly as it is, then record its answer: sent --fingerprint ${fingerprint} --draft --shared-project ${opts.project !== undefined ? ` --project ${shellPath(folder)}` : ""}. If the answer's status is checks_failed with a checkAgainFrom date, add --check-again-from .`, ] .filter(Boolean) .join(" "), }; }); } /** * sent: records submit_lessons's answer for a batch the batch command * printed, found by its fingerprint: its lessons marked sent and the shared * project kept. When the site's checks ran their most times that week (the * answer's checkAgainFrom), the lessons stay pending until that day instead, * as the local connector kept them, and the answer's shared project is kept * too: the next batch names it, and the site puts its draft in the failed * one's place, in that same project, even when it was the first batch (so * the member's page keeps no failed draft and draft project beside a new * one). A fingerprint the batch command did not print (a correction's, or * that of lessons from clean) has nothing to record. */ async function sentCommand(opts, io) { const ok = (v) => typeof v === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(v); if (typeof opts.fingerprint !== "string" || !/^[0-9a-f]{64}$/.test(opts.fingerprint) || !ok(opts.draft) || !ok(opts.sharedProject)) { refuse( "sent records submit_lessons's answer for a batch the batch command printed: sent --fingerprint --draft --shared-project . A correction, or lessons written at the member's request, need no record.", ); } let retryFrom = null; if (opts.checkAgainFrom !== null) { const ms = Date.parse(opts.checkAgainFrom); if (!Number.isFinite(ms)) refuse("--check-again-from takes the answer's checkAgainFrom date as it is, such as 2026-10-15T09:00:00.000Z."); // A date already gone by holds nothing: the lessons can go again now. retryFrom = new Date(Math.max(ms, io.now().getTime())).toISOString(); } // The project whose batch command printed this fingerprint: this folder's, or else any project's on this computer. const here = lessonProjectKey(await projectFolder(opts.project, io), io.platform); const keys = [here, ...(await allProjects(io.homeDir)).filter((k) => k !== here)]; let key = null; for (const k of keys) { const state = await readProject(projectFiles(io.homeDir, k).state, ""); if (printedBatches(state).some((p) => p.fingerprint === opts.fingerprint)) { key = k; break; } } if (key === null) { for (const k of keys) { if ((await readLessons(projectFiles(io.homeDir, k).jsonl)).some((l) => l.status === "sent" && l.draftId === opts.draft)) { return `Already recorded: the lessons in draft ${opts.draft} are marked sent.`; } } refuse( `Nothing was recorded: no batch the batch command printed on this computer has the fingerprint ${opts.fingerprint}. Pass the fingerprint from the submit_lessons answer exactly. Lessons from the clean command, and a correction, need no record.`, ); } const files = projectFiles(io.homeDir, key); return withLock(files, async () => { const lessons = await readLessons(files.jsonl); const project = await readProject(files.state, ""); const name = nameOf(project, key); const printed = printedBatches(project); const entry = printed.find((p) => p.fingerprint === opts.fingerprint); if (!entry) return `Already recorded: no batch with that fingerprint waits for a record for ${name}.`; const ids = new Set(entry.lessonIds); if (retryFrom) { // The connector's rule: the lessons stay pending, and no batch goes before that day. const held = lessons.filter((l) => ids.has(l.id) && l.status === "pending").length; project.retryAt = retryFrom; project.lastProblem = `The site's checks ran their most times on the member's batches this week; these lessons go again from ${retryFrom.slice(0, 10)}.`; project.printedBatches = printed.filter((p) => p !== entry); // The failed draft's project: the next batch names it, and its draft takes the failed one's place there. project.sentToProject = project.sharedProjectId === opts.sharedProject ? (project.sentToProject ?? 0) : 0; project.sharedProjectId = opts.sharedProject; await saveProject(files, project, lessons); const when = Date.parse(retryFrom) <= io.now().getTime() ? "now" : `from ${retryFrom.slice(0, 10)}`; return `Recorded: the site's checks ran their most times this week, so ${counted(held, "lesson")} from ${name} ${held === 1 ? "stays" : "stay"} pending, and the next batch can go ${when}. It goes to the same project, in place of draft ${opts.draft}.`; } const at = io.now().toISOString(); let marked = 0; for (const l of lessons) { if (!ids.has(l.id) || l.status !== "pending") continue; l.status = "sent"; l.sentAt = at; l.draftId = opts.draft; l.sharedProjectId = opts.sharedProject; marked++; } const stillPending = new Set(lessons.filter((l) => l.status === "pending").map((l) => l.id)); project.sentToProject = (project.sharedProjectId === opts.sharedProject ? (project.sentToProject ?? 0) : 0) + marked; project.sharedProjectId = opts.sharedProject; project.lastSubmittedAt = at; // A printed batch whose lessons are all sent now has nothing left to record. project.printedBatches = printed.filter((p) => p !== entry && p.lessonIds.some((id) => stillPending.has(id))); project.openDraft = null; project.retryAt = null; project.lastProblem = null; // Nothing left of a finished project's lessons: its rule ends with them. if (stillPending.size === 0) project.finishedAt = null; await saveProject(files, project, lessons); const left = stillPending.size; return `Recorded: ${counted(marked, "lesson")} from ${name} went in draft ${opts.draft}. ${left ? `${counted(left, "lesson")} ${left === 1 ? "stays" : "stay"} pending.` : "None is pending."}`; }); } // ─── A file the member named ──────────────────────────────────── function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** A line of .gogoportal-private as a matcher of paths inside the folder (the connector's rule). */ function globToRegExp(pattern) { let p = pattern.trim().replace(/\\/g, "/").replace(/^\.\//, ""); const anchored = p.startsWith("/"); if (anchored) p = p.slice(1); if (p.endsWith("/")) p += "**"; let re = ""; for (let i = 0; i < p.length; i++) { const c = p[i]; if (c === "*" && p[i + 1] === "*") { re += ".*"; i++; if (p[i + 1] === "/") i++; } else if (c === "*") re += "[^/]*"; else if (c === "?") re += "[^/]"; else re += escapeRegExp(c); } return new RegExp(anchored || p.includes("/") ? `^${re}$` : `(^|/)${re}(/.*)?$`); } function inside(root, relative) { const full = path.resolve(root, relative); return full === root || full.startsWith(root + path.sep) ? full : null; } /** What a named file is checked against: the project folder, its .gogoportal-private and the terms to keep out. */ async function fileRules(folder, terms) { return { root: folder, privatePatterns: (await linesOf(path.join(folder, PRIVATE_LIST))).map(globToRegExp), terms }; } /** The most a named file holds: what fits in a printed batch, under the site's limit for a file. */ const FILE_MAX_BYTES = Math.min(BATCH_LIMITS.fileMaxBytes, BATCH_LIMITS.printedMaxChars); /** * One file the member named, by its path inside the project folder: the * connector's rules for a named file (checkShareFile). Inside the folder * and reached without a link, a plain file, not named like a key or a * password file, not hidden, not listed in .gogoportal-private, its path * naming no client and holding no personal data or secret (the batch carries * the path as it is), not empty, at most FILE_MAX_BYTES, text, and holding * no secret, personal data or one of the terms. Refused, never edited; a * refusal never says what was found. */ export async function checkFile(rules, given) { const rel = String(given).trim().replace(/\\/g, "/").replace(/^(?:\.\/)+/, ""); const no = (reason) => ({ ok: false, path: rel || String(given), reason }); if (!rel) return no("names no file: give its path inside the project folder."); if (path.isAbsolute(rel) || path.win32.isAbsolute(rel)) return no("is a full path: name it by its path inside the project folder."); const full = inside(rules.root, rel); if (!full) return no("is outside the project folder."); const info = await lstat(full).catch(() => null); if (!info) return no("does not exist in the project folder."); if (info.isSymbolicLink()) return no("is a link, and links are not followed."); const real = await realpath(full).catch(() => null); if (!real || !inside(rules.root, path.relative(rules.root, real))) return no("is outside the project folder (through a link)."); // A different spelling of the same path on a computer that ignores case is the same file; anything else went through a link. if (real !== full && real.toLowerCase() !== full.toLowerCase()) return no("is reached through a link, and links are not followed."); if (!info.isFile()) return no("is not a file."); const own = path.relative(rules.root, real).split(path.sep).join("/"); const parts = own.split("/"); if (isSecretFileName(own)) return no("is named like a key or a password file."); if (parts.some((part) => part.startsWith("."))) return no("is hidden (a part of its path starts with a dot)."); const onItsPath = parts.map((_, i) => parts.slice(0, i + 1).join("/")); if (rules.privatePatterns.some((re) => onItsPath.some((p) => re.test(p)))) return no(`is listed in ${PRIVATE_LIST}.`); // The cleaning leaves paths as they are, so a name in one would go with the batch. const inPath = findInText(own, rules.terms); if (inPath.clientNames > 0) return no(`has a path that names a client or the project (a name the member gave, or one in ${CLIENT_LIST}).`); if (inPath.personalData > 0) return no("has a path that holds personal data (an email address or a phone number)."); if (inPath.secrets > 0) return no("has a path that holds something that looks like a key, a password or a token."); if (info.size === 0) return no("is empty."); if (info.size > FILE_MAX_BYTES) return no(`is larger than ${sizeText(FILE_MAX_BYTES)}, the most a printed batch holds.`); const bytes = await readFile(real); if (bytes.byteLength === 0 || bytes.byteLength > FILE_MAX_BYTES) return no("changed while it was read: name it again."); if (bytes.subarray(0, 8192).includes(0)) return no("is not text."); const text = bytes.toString("utf8"); const found = findInText(text, rules.terms); if (found.secrets > 0) return no("holds something that looks like a key, a password or a token."); if (found.personalData > 0) return no("holds personal data (an email address or a phone number)."); if (found.clientNames > 0) return no(`names a client or the project (a name the member gave, or one in ${CLIENT_LIST}).`); return { ok: true, path: own, bytes: bytes.byteLength, text }; } /** check-file: whether one file the member named can go with the lessons. */ async function checkFileCommand(opts, io) { if (opts.positional.length !== 1) refuse("check-file takes one path: the file's path inside the project folder."); const folder = await projectFolder(opts.project, io); const key = lessonProjectKey(folder, io.platform); const project = await readProject(projectFiles(io.homeDir, key).state, folder); const rules = await fileRules(folder, await cleaningTerms(folder, project)); const checked = await checkFile(rules, opts.positional[0]); if (!checked.ok) refuse(`${checked.path} ${checked.reason} It stays out: never edit a file to make it pass.`); return `${checked.path} can go with the lessons (${sizeText(checked.bytes)}).`; } // ─── Lessons the member asked for, or a correction ────────────── /** The files clean takes: the batch's limit, the most material files a shared project keeps. */ const CLEAN_FILES_MAX = BATCH_LIMITS.filesMax; const CLEAN_KEYS = ["kind", "description", "kindsOfProject", "identifyingTerms", "clientNames", "items", "questions", "correction", "files", "sharedProjectId"]; const ITEM_KEYS = ["kind", "topic", "body", "circumstances", "buildsOn", "learnedAt", "appliesTo", "costToLearn"]; const strings = (v, what) => { if (v === undefined || v === null) return []; if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) refuse(`${what} is a list of texts.`); return v.map((x) => x.trim()); }; /** An item as the batch takes it: its keys checked, the ones it leaves out filled with their empty values. */ function itemOf(raw, i) { const at = `items[${i}]`; if (!isObject(raw)) refuse(`${at} is an object.`); unknownKeys(raw, ITEM_KEYS, at); if (!ITEM_KINDS.includes(raw.kind)) refuse(`${at}.kind is one of ${ITEM_KINDS.join(", ")}.`); const topic = typeof raw.topic === "string" ? raw.topic.trim() : ""; const topicProblem = lessonTopicProblem(topic); if (topicProblem) refuse(`${at}.topic: ${topicProblem}`); const body = typeof raw.body === "string" ? raw.body.trim() : ""; if (!body || body.length > BATCH_LIMITS.bodyMaxChars) refuse(`${at}.body is the lesson, in 1 to ${BATCH_LIMITS.bodyMaxChars} characters.`); const circumstances = raw.circumstances === undefined || raw.circumstances === null ? null : raw.circumstances; if (circumstances !== null && (typeof circumstances !== "string" || circumstances.length > BATCH_LIMITS.circumstancesMaxChars)) refuse(`${at}.circumstances is a text of ${BATCH_LIMITS.circumstancesMaxChars} characters or fewer, or null.`); const buildsOn = strings(raw.buildsOn, `${at}.buildsOn`); if (buildsOn.length > BATCH_LIMITS.buildsOnMax || buildsOn.some((t) => !t || t.length > BATCH_LIMITS.topicMaxChars)) refuse(`${at}.buildsOn lists at most ${BATCH_LIMITS.buildsOnMax} topics.`); const appliesTo = strings(raw.appliesTo, `${at}.appliesTo`); if (appliesTo.length > BATCH_LIMITS.appliesToMax || appliesTo.some((t) => !t || t.length > BATCH_LIMITS.appliesToMaxChars)) { refuse(`${at}.appliesTo lists at most ${BATCH_LIMITS.appliesToMax} versions, each in ${BATCH_LIMITS.appliesToMaxChars} characters or fewer.`); } const learnedAt = raw.learnedAt === undefined || raw.learnedAt === null ? null : raw.learnedAt; if (learnedAt !== null && (typeof learnedAt !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(learnedAt))) refuse(`${at}.learnedAt is a date written YYYY-MM-DD, or null.`); const costToLearn = raw.costToLearn === undefined || raw.costToLearn === null ? null : raw.costToLearn; if (costToLearn !== null && !isObject(costToLearn)) refuse(`${at}.costToLearn is { micros, hours, source }, or null.`); return { kind: raw.kind, topic, body, circumstances, buildsOn, learnedAt, appliesTo, costToLearn }; } /** A correction's target, kind, since and source, checked for their shape (the site checks the rest). */ function correctionOf(raw) { if (!isObject(raw)) refuse("A correction carries correction: { target: { agentId, topicId }, kind, since, source: { url, kind } }."); if (!isObject(raw.target) || typeof raw.target.agentId !== "string" || typeof raw.target.topicId !== "string") refuse("correction.target names the AI Agent and the outline topic: { agentId, topicId }."); if (!CORRECTION_KINDS.includes(raw.kind)) refuse(`correction.kind is one of ${CORRECTION_KINDS.join(", ")}.`); if (!isObject(raw.source) || typeof raw.source.url !== "string" || !raw.source.url.startsWith("https://") || !SOURCE_KINDS.includes(raw.source.kind)) { refuse(`correction.source is the https address of official documentation, a changelog or a release note: { url, kind }, kind one of ${SOURCE_KINDS.join(", ")}.`); } if (raw.kind === "changed_since" && !(isObject(raw.since) && (raw.since.version || raw.since.date))) refuse('A correction of kind changed_since says since when: since: { version, date } with one of them, such as { "version": "Unity 6.1", "date": null }.'); if (raw.kind === "wrong" && isObject(raw.since) && (raw.since.version || raw.since.date)) refuse("A takeaway that was wrong has no since: set since to null, or use kind changed_since."); return raw; } /** clean: lessons written at the member's request, or a correction, cleaned and stamped; named files checked and carried as text. */ async function cleanCommand(opts, io) { const input = await jsonInput(io, "clean", CLEAN_MAX_BYTES, opts); unknownKeys(input, CLEAN_KEYS, "The batch"); const kind = input.kind ?? "lessons"; if (kind !== "lessons" && kind !== "correction") refuse('kind is "lessons" or "correction".'); const correction = kind === "correction" ? correctionOf(input.correction) : null; if (kind === "lessons" && input.correction !== undefined && input.correction !== null) refuse("Only a correction carries correction."); const description = typeof input.description === "string" && input.description.trim() ? input.description.trim() : kind === "correction" ? CORRECTION_ADDS[correction.kind] : ""; if (!description || description.length > BATCH_LIMITS.descriptionMaxChars) refuse(`description says what these lessons add, in 1 to ${BATCH_LIMITS.descriptionMaxChars} characters.`); const kindsOfProject = strings(input.kindsOfProject, "kindsOfProject"); if (kindsOfProject.length < 1 || kindsOfProject.length > BATCH_LIMITS.kindsOfProjectMax || kindsOfProject.some((k) => !k || k.length > BATCH_LIMITS.kindOfProjectMaxChars)) { refuse(`kindsOfProject lists 1 to ${BATCH_LIMITS.kindsOfProjectMax} kinds of project, each in ${BATCH_LIMITS.kindOfProjectMaxChars} characters or fewer, such as "building a Quest VR app".`); } const identifyingTerms = [...new Set(strings(input.identifyingTerms, "identifyingTerms"))]; if (identifyingTerms.length > BATCH_LIMITS.identifyingTermsMax || identifyingTerms.some((t) => t.length < BATCH_LIMITS.identifyingTermMinChars || t.length > BATCH_LIMITS.identifyingTermMaxChars)) { refuse(`identifyingTerms lists at most ${BATCH_LIMITS.identifyingTermsMax} names, each of ${BATCH_LIMITS.identifyingTermMinChars} to ${BATCH_LIMITS.identifyingTermMaxChars} characters.`); } const clientNames = strings(input.clientNames, "clientNames").filter(Boolean); if (!Array.isArray(input.items) || input.items.length < 1 || input.items.length > BATCH_LIMITS.itemsMax) refuse(`items lists 1 to ${BATCH_LIMITS.itemsMax} lessons.`); if (kind === "correction" && input.items.length !== 1) refuse("A correction has exactly one item: what is true now."); const items = input.items.map(itemOf); const named = strings(input.files, "files"); if (kind === "correction" && named.length) refuse("A correction carries no files."); if (named.length > CLEAN_FILES_MAX) refuse(`Name at most ${CLEAN_FILES_MAX} files: a project on GoGoPortal keeps no more.`); if (input.questions !== undefined && input.questions !== null && !isObject(input.questions)) refuse("questions is { questions, requiredDetails }, or null."); if (kind === "correction" && isObject(input.questions)) refuse("A correction adds no questions."); const given = input.sharedProjectId ?? null; if (given !== null && (typeof given !== "string" || !given)) refuse("sharedProjectId is the id of a shared project these lessons add to, or null."); if (kind === "correction" && given !== null) refuse("A correction adds to no shared project."); // Lessons with files start a project of their own: added to an existing one, they would take the place of the lessons it holds. const sharedProjectId = named.length ? null : given; const folder = await projectFolder(opts.project, io); // The project's own lists and the names kept for its noted lessons are taken out too. const project = await readProject(projectFiles(io.homeDir, lessonProjectKey(folder, io.platform)).state, folder); const terms = [...clientNames, ...identifyingTerms, ...(await cleaningTerms(folder, project))]; const files = []; if (named.length) { const rules = await fileRules(folder, terms); const refused = []; for (const given of named) { const checked = await checkFile(rules, given); if (!checked.ok) refused.push(`${checked.path} ${checked.reason}`); else if (files.some((f) => f.path === checked.path)) refused.push(`${checked.path} is named twice.`); else files.push({ path: checked.path, text: checked.text }); } if (refused.length) refuse(`Nothing was stamped. ${refused.join(" ")} Leave ${refused.length === 1 ? "it" : "them"} out, or name other files; never edit a file to make it pass.`); } const batch = { format: BATCH_FORMAT, kind, description, kindsOfProject, identifyingTerms, items }; if (isObject(input.questions)) batch.questions = input.questions; if (correction) batch.correction = correction; Object.assign(batch, { files, sharedProjectId, removed: { secrets: 0, personalData: 0, clientNames: 0 } }); const cleaned = cleanBatch(batch, terms); // "[removed]" can be longer than what it takes the place of: each text must still fit the site's limit. const over = overLimits(cleaned.batch); if (over.length) refuse(`Nothing was stamped. With names and personal data taken out, ${over.join("; ")}: shorten ${over.length === 1 ? "it" : "them"}.`); cleaned.batch.removed = { ...cleaned.found }; const stamped = stampBatch(cleaned.batch); const printed = JSON.stringify(stamped); if (printed.length > BATCH_LIMITS.printedMaxChars) { refuse( `Nothing was stamped: the batch is ${printed.length} characters, and a printed batch holds at most ${BATCH_LIMITS.printedMaxChars}, so it can be passed on whole. Leave out at least ${printed.length - BATCH_LIMITS.printedMaxChars} characters: fewer or shorter files or lessons.`, ); } const r = cleaned.found; const ownProject = given !== null && sharedProjectId === null ? " sharedProjectId is null: lessons with files start a project of their own on GoGoPortal." : ""; return { stdout: printed, stderr: `Cleaned and stamped: ${counted(items.length, kind === "correction" ? "correction" : "lesson")}${files.length ? ` and ${counted(files.length, "file")}` : ""}. Removed (counts only): ${r.secrets} secrets or IDs, ${r.personalData} personal data or paths, ${r.clientNames} client names.${ownProject} Pass the JSON above to ${kind === "correction" ? "submit_correction" : "submit_lessons"} exactly as it is; its answer needs no record with sent.`, }; } // ─── Letting Claude Code use the tools: the member runs it ───── /** Where Claude Code keeps the member's own settings: ~/.claude/settings.json, or settings.json in CLAUDE_CONFIG_DIR. */ export function claudeSettingsFile(homeDir, env) { const dir = String(env.CLAUDE_CONFIG_DIR ?? "").trim(); return dir ? path.join(dir, "settings.json") : path.join(homeDir, ".claude", "settings.json"); } /** * Adding the rule by hand, the one way that needs no terminal: Claude Code's * desktop app has no /permissions (it answers that it "isn't available in * this environment"), and every Claude Code on the computer reads the same * settings file. */ const BY_HAND = `add "${PLUGIN_ALLOW_RULE}" under permissions.allow`; /** * Claude Code's settings with the rule added under permissions.allow, as * text: everything else kept in its order, in the file's own indentation, * line endings and final newline (the connector's mergeAllowRule). A file * that isn't a JSON object of settings is left as it is (unreadable); a rule * that already covers every tool of the server is not added again (present). */ export function mergeAllowRule(existing) { const justTheRule = `${JSON.stringify({ permissions: { allow: [PLUGIN_ALLOW_RULE] } }, null, 2)}\n`; if (existing === null) return { kind: "create", text: justTheRule }; const bom = existing.startsWith("\uFEFF") ? "\uFEFF" : ""; const body = bom ? existing.slice(1) : existing; // An empty file holds no settings yet. if (body.trim() === "") return { kind: "add", text: bom + justTheRule }; let settings; try { settings = JSON.parse(body); } catch { return { kind: "unreadable", why: "it isn't valid JSON" }; } if (!isObject(settings)) return { kind: "unreadable", why: "it doesn't hold a JSON object" }; const permissions = settings.permissions; if (permissions !== undefined && !isObject(permissions)) return { kind: "unreadable", why: "its permissions isn't an object" }; const allow = permissions?.allow; if (allow !== undefined && !Array.isArray(allow)) return { kind: "unreadable", why: "its permissions.allow isn't a list" }; const found = (allow ?? []).find((r) => typeof r === "string" && COVERING_RULES.includes(r.trim())); if (found) return { kind: "present", rule: found.trim() }; const next = { ...settings, permissions: { ...(permissions ?? {}), allow: [...(allow ?? []), PLUGIN_ALLOW_RULE] } }; // The file's own indentation: the first indented line's (a tab or its spaces); a file on one line stays on one line. const indented = /\n([ \t]+)\S/.exec(body); const indent = !/\n/.test(body.trim()) ? 0 : indented ? (indented[1].startsWith("\t") ? "\t" : indented[1].length) : 2; const eol = body.includes("\r\n") ? "\r\n" : "\n"; let text = JSON.stringify(next, null, indent); if (eol === "\r\n") text = text.replace(/\n/g, "\r\n"); if (/\r?\n$/.test(body)) text += eol; return { kind: "add", text: bom + text }; } /** What allow prints when no person can answer it: where to run it, and the rule to add by hand instead. */ export function allowNotInteractiveText(scriptPath) { return [ "Nothing was changed. This adds a permission rule, so it asks first, and it runs only in a terminal you type in.", `Run this in your own terminal (PowerShell on Windows, Terminal on a Mac), not through your agent: node ${shellPath(scriptPath)} allow`, `Or ${BY_HAND} in Claude Code's settings file, ~/.claude/settings.json, yourself. (For a server added by its address instead, the rule is ${ADDRESS_ALLOW_RULE}.)`, ].join("\n"); } async function readSettingsText(file) { try { return await readFile(file, "utf8"); } catch (e) { return e?.code === "ENOENT" ? null : { problem: problemOf(e) }; } } /** * allow: adds Claude Code's rule for the plugin's tools to the member's own * settings, after they type y or yes in their own terminal. Without a * terminal on both ends it changes nothing and prints the steps; it takes no * option that answers for the member. It shows the one line it adds and the * file, re-reads the file after the question, and keeps the file as it was * beside it. */ async function allowCommand(io) { if (!io.interactive) refuse(allowNotInteractiveText(io.scriptPath)); const file = claudeSettingsFile(io.homeDir, io.env); const unchanged = (why) => refuse(`${file} ${why}. Nothing was changed. You can ${BY_HAND} in it yourself.`); const before = await readSettingsText(file); if (isObject(before)) unchanged(`can't be read: ${before.problem}`); const merge = mergeAllowRule(before); if (merge.kind === "present") return `${file} already lets Claude Code use GoGoPortal's tools (${merge.rule} under permissions.allow). Nothing was changed.`; if (merge.kind === "unreadable") unchanged(`can't be read as Claude Code's settings: ${merge.why}`); const answer = await io.ask( [ "This adds one rule to Claude Code's settings, so your own agent can use GoGoPortal's tools without asking each time:", ` "${PLUGIN_ALLOW_RULE}" under permissions.allow`, merge.kind === "create" ? `in a new file, ${file}.` : `in ${file}. Everything else in it stays as it is, and the file as it is now is kept beside it as a backup.`, "Add it? [y/N] ", ].join("\n"), ); if (!/^(y|yes)$/i.test(String(answer).trim())) return "Nothing was changed."; // The file may have changed while the question waited: the rule goes into it as it is now. const now = await readSettingsText(file); if (isObject(now)) unchanged(`can't be read: ${now.problem}`); const final = mergeAllowRule(now); if (final.kind === "present") return `${file} already lets Claude Code use GoGoPortal's tools (${final.rule} under permissions.allow). Nothing was changed.`; if (final.kind === "unreadable") unchanged(`can't be read as Claude Code's settings: ${final.why}`); const picksUp = "A new Claude Code session picks it up."; try { if (now === null) { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, final.text, { flag: "wx" }); return `Made ${file} with "${PLUGIN_ALLOW_RULE}" under permissions.allow. ${picksUp}`; } const backup = `${file}.gogoportal-backup-${io.now().toISOString().replace(/[:.]/g, "-")}`; await copyFile(file, backup); const mode = (await stat(file)).mode & 0o777; const tmp = `${file}.gogoportal-${randomBytes(4).toString("hex")}.tmp`; await writeFile(tmp, final.text, { mode, flag: "wx" }); await rename(tmp, file); return `Added "${PLUGIN_ALLOW_RULE}" under permissions.allow in ${file}. The file as it was is kept in ${backup}. ${picksUp}`; } catch (e) { unchanged(`couldn't be written: ${problemOf(e)}`); } } // ─── The program ──────────────────────────────────────────────── const USAGE = [ "GoGoPortal's lessons script. Run it in the project's folder (it finds the top: the nearest folder with .git or the member's lists), or add --project .", " note one lesson as JSON on standard input, or in the file --input names: cleaned and kept on this computer", " status the lessons pending for this project, and when the next batch is due", " due [--hook] each project whose batch is due, one line each (--hook: nothing when none is)", " batch [--finished] the batch for submit_lessons, cleaned and stamped, when it is due (--finished: the member says the project is finished; the first batch also takes --kind, --term and --client; --new-project when submit_lessons said its shared project doesn't exist)", " clean lessons written at the member's request, or a correction, as JSON on standard input or with --input : cleaned and stamped", " check-file whether a file the member named can go with the lessons", " sent --fingerprint --draft --shared-project [--check-again-from ] records submit_lessons's answer for a printed batch", " allow for the member to run in their own terminal, never through an agent: lets Claude Code use GoGoPortal's tools without asking", ].join("\n"); /** Which options each command takes. */ const OPTIONS = { note: ["--project", "--input"], status: ["--project"], due: ["--hook"], batch: ["--project", "--finished", "--new-project", "--kind", "--term", "--client"], clean: ["--project", "--input"], "check-file": ["--project"], sent: ["--project", "--fingerprint", "--draft", "--shared-project", "--check-again-from"], allow: [], }; const FLAGS = ["--finished", "--hook", "--new-project"]; function parseArgs(command, rest) { const opts = { project: undefined, input: undefined, finished: false, newProject: false, hook: false, kinds: [], terms: [], clients: [], draft: null, sharedProject: null, fingerprint: null, checkAgainFrom: null, positional: [] }; for (let i = 0; i < rest.length; i++) { const a = rest[i]; if (!a.startsWith("--")) { opts.positional.push(a); continue; } if (!OPTIONS[command].includes(a)) refuse(`${command} takes no ${a}.\n${USAGE}`); if (FLAGS.includes(a)) { if (a === "--finished") opts.finished = true; else if (a === "--new-project") opts.newProject = true; else opts.hook = true; continue; } const value = rest[++i]; if (value === undefined || value.startsWith("--")) refuse(`${a} takes a value.`); if (a === "--project") opts.project = value; else if (a === "--input") opts.input = value; else if (a === "--kind") opts.kinds.push(value); else if (a === "--term") opts.terms.push(value); else if (a === "--client") opts.clients.push(value); else if (a === "--draft") opts.draft = value; else if (a === "--shared-project") opts.sharedProject = value; else if (a === "--fingerprint") opts.fingerprint = value; else if (a === "--check-again-from") opts.checkAgainFrom = value; } if (command !== "check-file" && opts.positional.length) refuse(`${command} takes no ${opts.positional[0]}.\n${USAGE}`); return opts; } /** * Runs one command: what it prints on standard output and standard error, * and its exit code (0 done, 1 refused with one plain sentence). The * SessionStart hook's `due --hook` never fails: on any problem it prints * nothing, so a session never starts with an error from it. */ export async function run(argv, io) { const [command, ...rest] = argv; if (command === "due" && rest.includes("--hook")) { try { parseArgs(command, rest); const lines = await dueLines(io); return { exitCode: 0, stdout: lines.length ? `${lines.join("\n")}\n` : "", stderr: "" }; } catch { return { exitCode: 0, stdout: "", stderr: "" }; } } try { if (!command || command === "help" || command === "--help") return { exitCode: command ? 0 : 1, stdout: `${USAGE}\n`, stderr: "" }; if (!Object.prototype.hasOwnProperty.call(OPTIONS, command)) refuse(`${command} is not a command.\n${USAGE}`); // allow takes nothing that could answer for the member. if (command === "allow" && rest.length) refuse(`allow takes nothing after it: it asks you before it changes anything.\n${allowNotInteractiveText(io.scriptPath)}`); const opts = parseArgs(command, rest); let out; if (command === "note") out = await noteCommand(opts, io); else if (command === "status") out = await statusCommand(opts, io); else if (command === "due") { const lines = await dueLines(io); out = lines.length ? lines.join("\n") : "No batch of lessons is due."; } else if (command === "batch") out = await batchCommand(opts, io); else if (command === "clean") out = await cleanCommand(opts, io); else if (command === "check-file") out = await checkFileCommand(opts, io); else if (command === "sent") out = await sentCommand(opts, io); else out = await allowCommand(io); if (typeof out === "string") return { exitCode: 0, stdout: `${out}\n`, stderr: "" }; return { exitCode: 0, stdout: `${out.stdout}\n`, stderr: `${out.stderr}\n` }; } catch (e) { if (e instanceof Refusal) return { exitCode: 1, stdout: `${e.message}\n`, stderr: "" }; return { exitCode: 1, stdout: `The command failed: ${problemOf(e)}\n`, stderr: "" }; } } /** Reads standard input to its end: null when it is a terminal or empty, undefined when it is larger than maxBytes. */ async function readStdin(maxBytes) { if (process.stdin.isTTY) return null; const chunks = []; let size = 0; for await (const chunk of process.stdin) { size += chunk.length; if (size > maxBytes) return undefined; chunks.push(chunk); } const text = Buffer.concat(chunks).toString("utf8").replace(/^\uFEFF/, ""); return text.trim() ? text : null; } /** Asks on the terminal and reads one line; Ctrl+C or Ctrl+D is no answer. */ function askOnTerminal(text) { return new Promise((resolve) => { const rl = createInterface({ input: process.stdin, output: process.stdout }); let answered = false; rl.on("SIGINT", () => rl.close()); rl.on("close", () => { if (!answered) resolve(""); }); rl.question(text, (answer) => { answered = true; rl.close(); resolve(answer); }); }); } const SCRIPT_PATH = fileURLToPath(import.meta.url); function isMain() { try { return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(SCRIPT_PATH); } catch { return false; } } if (isMain()) { const home = (process.env.GOGOPORTAL_HOME_DIR ?? "").trim(); run(process.argv.slice(2), { // GOGOPORTAL_HOME_DIR stands in for the home folder (tests use it, never the member's own). homeDir: home || os.homedir(), cwd: process.cwd(), env: process.env, platform: process.platform, now: () => new Date(), readStdin, interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), ask: askOnTerminal, scriptPath: SCRIPT_PATH, }).then((r) => { if (r.stdout) process.stdout.write(r.stdout); if (r.stderr) process.stderr.write(r.stderr); process.exitCode = r.exitCode; }); }