@appchy/jarvis 0.1.44 → 0.1.46
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/dist/bin.js +55 -33
- package/dist/bin.js.map +1 -1
- package/dist/data/backends.mjs +2 -2
- package/dist/data/{chunk-AKQQC5IT.mjs → chunk-AA75N6NR.mjs} +3 -3
- package/dist/data/{chunk-7REP35VA.mjs → chunk-LYW75USL.mjs} +9 -0
- package/dist/data/embedders.mjs +10 -49
- package/dist/data/index.mjs +2 -2
- package/dist/data/labelers.mjs +107 -0
- package/dist/data/linkers.mjs +1 -1
- package/dist/data/mcp.mjs +2 -2
- package/dist/data/stores.mjs +2 -2
- package/dist/hooks/config-change.js.map +1 -1
- package/dist/hooks/pre-tool-use.js +6 -4
- package/dist/hooks/pre-tool-use.js.map +1 -1
- package/dist/hooks/session-start.js +5 -4
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop.js +5 -6
- package/dist/hooks/stop.js.map +1 -1
- package/dist/hooks/user-prompt-submit.js.map +1 -1
- package/harness/harness/gate.py +85 -16
- package/harness/harness/git.py +28 -37
- package/harness/test_work.py +126 -10
- package/package.json +1 -1
- package/dist/data/rerankers.mjs +0 -52
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/harness.ts","../../src/hooks/drop.ts","../../src/config.ts","../../src/hooks/session-start.ts"],"sourcesContent":["/**\n * The work harness — where it ships, and what runs it.\n *\n * The harness owns the `work/` tree's invariants: bucket-is-status, tier derivation,\n * the id allocator and the completion gate. It used to live in a Claude Code plugin,\n * which made every write to the board require one vendor's plugin to be installed —\n * so an agent that speaks MCP but is not Claude Code could read the board and could\n * not touch it. It ships inside this package now, and `jarvis work` is the one door\n * onto it.\n *\n * It is Python, and this file is the whole of what Node needs to know about that: a\n * payload directory and an interpreter. Either can be missing, and a missing one is\n * REPORTED — `harness()` still answers, so a board stays readable and a write fails\n * where writes fail. What must not happen is what used to: a raw shell error, several\n * calls after the point where anything could be done about it.\n */\n\nimport { spawnSync } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\n/** The entry inside the payload. */\nconst ENTRY = join(\"harness\", \"work.py\");\n\n/** Interpreters to try, in order. Both names, because a machine that has Python\n * under only one of them is common enough that failing on it would be a bug. */\nconst INTERPRETERS = [\"python3\", \"python\"] as const;\n\n/**\n * The oldest Python the payload runs on, proven by running its own suite there.\n *\n * 3.9 rather than something newer on purpose: it is what macOS ships at\n * `/usr/bin/python3`, and therefore what a machine with nothing installed actually\n * has — which is precisely the machine this whole arrangement exists to serve. Two\n * annotations in the payload had to go to keep it, and that was the cheaper side of\n * the trade by a wide margin.\n */\nconst OLDEST = [3, 9] as const;\n\n/** How to run the harness: argv, up to but not including its subcommand. */\nexport type Harness = [command: string, ...args: string[]];\n\n/** What a consumer outside this repo has on its PATH, and the honest thing to\n * attempt when the payload cannot be resolved from here. */\nconst INSTALLED: Harness = [\"jarvis\", \"work\"];\n\n/**\n * The payload's entry script, or null when this build does not carry one.\n *\n * Resolved by walking up from this module rather than from `process.argv`, because\n * the answer must not depend on how the process was started: `tsx src/bin.ts`, the\n * bundled `dist/bin.js` and a re-spawned daemon all sit at different depths under\n * the same package root, and all three have to find the same files.\n */\nexport function payload(): string | null {\n let dir = dirname(fileURLToPath(import.meta.url));\n for (let up = 0; up < 6; up++) {\n if (existsSync(join(dir, ENTRY))) return join(dir, ENTRY);\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** The first interpreter on PATH new enough to run the payload, else null. */\nexport function interpreter(): string | null {\n for (const name of INTERPRETERS) {\n // The VERSION, not just an exit code. A name can exist on PATH as a stub that is\n // not a working interpreter — which is what macOS has until the command line\n // tools are installed — and an interpreter too old to parse the payload fails\n // with a traceback from inside it, which is the shape of error this whole file\n // exists to replace.\n const said = spawnSync(name, [\"--version\"], { encoding: \"utf-8\" });\n if (said.status !== 0) continue;\n const version = /(\\d+)\\.(\\d+)/.exec(`${said.stdout}${said.stderr}`);\n if (!version) continue;\n const [major, minor] = [Number(version[1]), Number(version[2])];\n if (major > OLDEST[0] || (major === OLDEST[0] && minor >= OLDEST[1])) return name;\n }\n return null;\n}\n\n/**\n * Why the harness cannot be reached from here, or null when it can.\n *\n * Phrased as what to do about it. `jarvis serve` reports this at startup, so it is\n * read by somebody who has just pointed an agent at their repo and has no other clue\n * that the board is about to refuse every write.\n */\nexport function harnessProblem(): string | null {\n if (!payload()) {\n return (\n \"this jarvis build carries no work harness — the board can be read but not \" +\n \"changed. Reinstall with `npm i -g @appchy/jarvis`.\"\n );\n }\n if (!interpreter()) {\n return (\n `the work harness needs Python ${OLDEST[0]}.${OLDEST[1]} or newer and this ` +\n \"machine has none on its PATH — the board can be read but not changed. \" +\n \"Install it, then try again.\"\n );\n }\n return null;\n}\n\n/**\n * How to run the harness.\n *\n * Never throws. A board whose harness is unreachable is still a board worth reading,\n * and refusing to build one would take the reads down with the writes; the caller\n * that needs to know asks `harnessProblem()`. The fallback names what a consumer\n * outside this repo actually has, so the failure a caller meets is a missing command\n * rather than a missing file.\n */\nexport function harness(): Harness {\n const script = payload();\n const python = interpreter();\n return script && python ? [python, script] : INSTALLED;\n}\n\n/**\n * The block a session opens with, for the repo at `repo` — or null when there is none.\n *\n * The harness composes every byte of it from that repo's own config: the board, its\n * standards, the decisions it has already taken, what each release is for. Nothing here\n * adds a word, and no caller of this may either — several doors serve these bytes now,\n * and a sentence any one of them typed itself is a sentence they could disagree about.\n *\n * **It never throws and never reports its own failure**, which is unlike everything else\n * in this file. Its first caller is a `SessionStart` hook: a message there greets somebody\n * with an error before they have typed anything, and a missing block is a far smaller harm\n * than a session that opens broken. A caller with somewhere to put a diagnosis asks\n * `harnessProblem()` for one.\n */\nexport function sessionContext(repo: string): string | null {\n return say([\"context\", \"--project\", repo]);\n}\n\n/**\n * The METHOD in full — how work is done here, on request.\n *\n * Split from {@link sessionContext} rather than folded into it, because the two are\n * asked at different moments and cost two orders of magnitude apart: the block a\n * session opens with is ~670 tokens of derived fact, and this is ~11,000 of prose. One\n * reader still, so a session cannot end up having read a different method than the\n * block told it to go and read.\n */\nexport function sessionMethod(repo: string): string | null {\n return say([\"method\", \"--project\", repo]);\n}\n\nexport interface EditContextRequest {\n repo: string;\n /** Repo-relative path the session is about to write. */\n file: string;\n /** Absent means the harness stays silent: it cannot promise once without one. */\n sessionId?: string;\n}\n\n/**\n * What a session must be told now that it is about to write this file — or null.\n *\n * The judgements no gate can catch, on the first write of a session, and which part of\n * the code it has walked into, the first time it writes under one. Both are the\n * harness's to word and to ration; this hands over a file and a session id and nothing\n * else, the same split {@link wrapReminder} uses.\n *\n * Never throws, for the reason {@link sessionContext} does not: its caller is a hook\n * firing before somebody's edit, and a hook that fails loudly interrupts them to report\n * a problem with a reminder.\n */\nexport function editContext(req: EditContextRequest): string | null {\n if (!req.file || !req.sessionId) return null;\n // Tighter than the shared ceiling: this one fires before somebody's edit, and a\n // rule that arrives seconds after the file was written has already missed.\n return say(\n [\"applies\", \"--file\", req.file, \"--project\", req.repo, \"--session\", req.sessionId],\n 2_000,\n );\n}\n\n/**\n * Everything a machine knows about finishing this session cleanly, for `repo` — or null\n * when the harness cannot answer.\n *\n * It writes nothing. What comes back is what only a machine can say: work that is not\n * in git, items the board still says somebody is on, the descriptions of the world this\n * run changed, what the tree disagrees with itself about, and the derived prompt that\n * opens the next session. The prose is the agent's to write, because a handoff is\n * judgement and a generated one reads as considered while being wrong.\n */\nexport function wrapBrief(repo: string): string | null {\n return say([\"wrap\", \"--project\", repo]);\n}\n\nexport interface WrapReminderRequest {\n repo: string;\n /** Tokens the session is holding right now. Zero or less means unknown. */\n used: number;\n sessionId?: string;\n}\n\nexport interface WrapReminderResponse {\n /** One line, for a surface that shows the reader a notice. */\n headline: string;\n /** The whole reminder, for the agent to act on. */\n note: string;\n}\n\n/**\n * Whether a session this full should be wrapping up, and what that means in `repo`.\n * Null when it should not, or when nothing can be said with confidence.\n *\n * **The measurement is the caller's and the judgement is the harness's**, and the\n * split is not arbitrary. How many tokens a session is holding is a thing only its\n * own client can answer, and every client answers it differently — while the\n * threshold, the wording, the once-a-session rule and whatever the repo adds are the\n * same whoever is asking. So this hands over a number, and a harness command that\n * took one vendor's log file is a harness that works for one vendor.\n *\n * Never throws, for the same reason {@link sessionContext} does not: its caller is a\n * hook, and a hook that fails loudly interrupts somebody mid-thought to report a\n * problem with a reminder.\n */\nexport function wrapReminder(req: WrapReminderRequest): WrapReminderResponse | null {\n if (!(req.used > 0)) return null;\n const said = say([\n \"remind\",\n \"--used\",\n String(req.used),\n \"--project\",\n req.repo,\n ...(req.sessionId ? [\"--session\", req.sessionId] : []),\n ]);\n if (!said) return null;\n try {\n const parsed = JSON.parse(said) as Partial<WrapReminderResponse>;\n if (!parsed.headline || !parsed.note) return null;\n return { headline: parsed.headline, note: parsed.note };\n } catch {\n return null;\n }\n}\n\n/**\n * How long a harness call may take before it is killed and read as \"no answer\".\n *\n * **Every caller here is a hook, and `installHookTimeout` cannot save one of them.**\n * That watchdog is a `setTimeout`, and a timer does not fire while a synchronous\n * `spawnSync` holds the thread — so an interpreter that is slow to start (a loaded\n * machine, a scanner intercepting the spawn, a repo on a network mount) blocks the\n * session for as long as it likes, past any ceiling the hook believes it set. The\n * bound has to be on the spawn itself. Generous, because being killed mid-answer is\n * the one failure worse than being slow.\n */\nconst PATIENCE = 10_000;\n\n/** What the harness said, or null — for a caller with nowhere to put a failure. */\nfunction say(args: string[], timeout = PATIENCE): string | null {\n try {\n const [command, ...prefix] = harness();\n const ran = spawnSync(command, [...prefix, ...args], {\n encoding: \"utf-8\",\n timeout,\n env: { ...process.env, PYTHONDONTWRITEBYTECODE: \"1\" },\n });\n // A killed child leaves a non-zero status (or none at all), so the timeout reads\n // as every other failure does: no answer, and nothing said about it.\n if (ran.status !== 0) return null;\n return ran.stdout.trim() ? ran.stdout.trimEnd() : null;\n } catch {\n return null;\n }\n}\n","/**\n * Shared helpers for CC hook scripts.\n *\n * Each hook script (pre-tool-use, session-start, stop, …) is a tiny\n * stdin → append-line → exit binary. They all write to the same per-\n * session JSONL at `<hooksDir>/<sessionId>.jsonl`. This module\n * concentrates the envelope shape and the atomic append so the per-\n * hook scripts stay <30 lines each.\n *\n * Hot-path constraints: NEVER block, NEVER network, NEVER read user\n * input. Pure stdin parse + filesystem append + exit. Worst-case one\n * `mkdir` + one `appendFile` per fire.\n */\n\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { getHooksDir } from \"../config\";\n\n/** Wire-format envelope written one-per-line to the hook JSONL.\n * The watcher in `sessions.ts` parses this exact shape — keep in sync. */\nexport interface HookDropEnvelope<TData = unknown> {\n type: string;\n sessionId: string;\n ts: string;\n /** Idempotency key. Lets the watcher's replay-on-restart skip drops\n * whose effects were already emitted (see `HookCursor.applied`). */\n uniqId: string;\n data: TData;\n}\n\nexport interface AppendDropRequest<TData> {\n type: string;\n sessionId: string;\n uniqId: string;\n data: TData;\n}\n\n/** Append one envelope line to the per-session hook JSONL. Atomic between\n * hook processes via POSIX `O_APPEND` (Node's `fs.appendFile`) — concurrent\n * appends from different CC sessions / hook types can't interleave bytes\n * within a line as long as the line stays < `PIPE_BUF` (4KB). Drops are\n * <1KB in practice, so this is safe without locks.\n *\n * Stderr logs are written so `tail -f ~/.jarvis/hooks/hook.log` (when\n * CC pipes hook stderr there, which it does by default) shows what fired.\n * Stderr never breaks CC — it only watches stdout for decision JSON. */\nexport async function appendDrop<TData>(req: AppendDropRequest<TData>): Promise<void> {\n if (!req.sessionId || !req.type || !req.uniqId) {\n process.stderr.write(\n `[hook] skip ${req.type ?? \"?\"} missing field ` +\n `sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}\\n`,\n );\n return;\n }\n const dir = getHooksDir();\n await mkdir(dir, { recursive: true }).catch((err) => {\n process.stderr.write(`[hook] mkdir failed ${dir}: ${err}\\n`);\n });\n const envelope: HookDropEnvelope<TData> = {\n type: req.type,\n sessionId: req.sessionId,\n ts: new Date().toISOString(),\n uniqId: req.uniqId,\n data: req.data,\n };\n const file = join(dir, `${req.sessionId}.jsonl`);\n try {\n await appendFile(file, JSON.stringify(envelope) + \"\\n\");\n process.stderr.write(\n `[hook] ${req.type} appended sessionId=${req.sessionId} uniqId=${req.uniqId}\\n`,\n );\n } catch (err) {\n process.stderr.write(`[hook] append failed ${file}: ${err}\\n`);\n }\n}\n\n/** Read CC's hook payload from stdin. Returns `null` on parse failure\n * (never throws — the hook must never block CC). */\nexport async function readHookStdin<T = Record<string, unknown>>(): Promise<T | null> {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n const raw = Buffer.concat(chunks).toString(\"utf-8\");\n if (!raw) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Hard cap any hook so an exotic stdin stall can't pile up forever\n * before CC's terminal prompt fires. We exit 0 with no output → CC\n * proceeds with its normal permission flow. */\nexport function installHookTimeout(ms = 750): void {\n const timer = setTimeout(() => process.exit(0), ms);\n timer.unref();\n}\n","/**\n * Agent Config\n *\n * Manages ~/.jarvis/config.json — saved by `jarvis connect <token>`,\n * read on `jarvis start`.\n */\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface AgentConfig {\n /** The jarvis this machine joined — the address a person opens in a browser,\n * and what pairing talks to. Ours by default; `jarvis init --url <url>` joins\n * someone else's. */\n url?: string;\n /** WS hub URL this machine dials. Written once, by pairing, from the hub the\n * joined instance named — and never from a flag, an environment variable or\n * any later command. Re-pointing a machine means unpairing it first. */\n hubUrl?: string;\n /** JWT auth token (from connect token) */\n token?: string;\n /** Long-lived refresh token for obtaining new access tokens */\n refreshToken?: string;\n /** User ID */\n userId: string;\n /** Environment ID */\n envId?: string;\n /** Workspace root path for repo operations */\n workspacePath?: string;\n /** Anthropic API key (for local-only use without cloud) */\n anthropicApiKey?: string;\n /** Use Anthropic subscription instead of API key */\n useSubscription?: boolean;\n /** When the config was last updated */\n connectedAt?: string;\n}\n\nexport interface ConnectToken {\n hubUrl: string;\n jwt: string;\n refreshToken: string;\n userId: string;\n envId: string;\n}\n\n// =============================================================================\n// Environment\n// =============================================================================\n\n/** The jarvis a machine pairs with when nothing else is named. Only ever read\n * by pairing: `jarvis init --url <url>` joins someone else's instead, and the\n * hub URL is whatever that instance hands back. */\nexport const DEFAULT_URL = \"https://jarvis.appchy.com\";\n\n// =============================================================================\n// Config directory\n// =============================================================================\n\n/** The config dir is the machine's identity: what it paired as, and therefore\n * which hub it dials. One daemon per dir, so a second identity on the same\n * machine — a storage rig, a localhost instance — is a second dir, named by\n * `JARVIS_CONFIG_DIR`. A development build is not a second identity and does carry\n * its own name: it reads this same dir, so it is the same machine.\n *\n * Called fresh on every read so a test that sets `JARVIS_CONFIG_DIR` AFTER\n * module load still sees the override — caching at module init would lock it\n * onto the host's real `~/.jarvis/` and leak production state into the\n * in-memory infra. */\nfunction configDir(): string {\n if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;\n return path.join(os.homedir(), \".jarvis\");\n}\n\nfunction configFile(): string {\n return path.join(configDir(), \"config.json\");\n}\n\n/** Absolute path to the per-daemon config directory — `~/.jarvis/` unless\n * `JARVIS_CONFIG_DIR` names another. */\nexport function getConfigDir(): string {\n return configDir();\n}\n\n/** Per-daemon directory for PreToolUse hook drop files. The CC hook script\n * writes `<sessionId>/<toolUseId>.json` here; the sessions watcher consumes\n * them. Each daemon owns the subtree under its own config dir, so a hook\n * installed for one never feeds another. */\nexport function getHooksDir(): string {\n return path.join(configDir(), \"hooks\");\n}\n\n// =============================================================================\n// Operations\n// =============================================================================\n\n/** Read the machine's config, accepting the names these two fields used to\n * carry. `appUrl`/`apiUrl` were renamed on 2026-08-15 — `apiUrl` never held an\n * API URL, it held the hub's. A config written before that still loads, and\n * `saveConfig` only ever writes the current names, so a machine heals itself on\n * its next write with nothing to run. Delete this tolerance once no config\n * predating the rename is in use. */\nexport function loadConfig(): AgentConfig | null {\n try {\n const stored = JSON.parse(fs.readFileSync(configFile(), \"utf-8\")) as AgentConfig &\n Partial<{ appUrl: string; apiUrl: string }>;\n const { appUrl, apiUrl, ...config } = stored;\n const url = config.url ?? appUrl;\n const hubUrl = config.hubUrl ?? apiUrl;\n return {\n ...config,\n ...(url !== undefined ? { url } : {}),\n ...(hubUrl !== undefined ? { hubUrl } : {}),\n };\n } catch {\n return null;\n }\n}\n\n/** Persist the machine's config.\n *\n * Refuses to move an already-paired machine to a different hub. A machine that\n * joins the wrong control plane hands it credentials, so re-pointing one is a\n * deliberate act — `jarvis unpair` (which clears the config) and then pair\n * again — rather than something a stale token or a mistyped command can do on\n * its way past. Re-pairing to the same hub is unaffected. */\nexport function saveConfig(config: AgentConfig): void {\n const paired = loadConfig();\n if (paired?.hubUrl && config.hubUrl && config.hubUrl !== paired.hubUrl) {\n throw new Error(\n `This machine is paired with ${paired.hubUrl} and cannot be moved to ${config.hubUrl}.\\n` +\n ` Run 'jarvis unpair' first if you mean to join a different jarvis.`,\n );\n }\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(configFile(), JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nexport function clearConfig(): void {\n try {\n fs.unlinkSync(configFile());\n } catch {}\n}\n\nexport function parseConnectToken(token: string): ConnectToken {\n try {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n const parsed = JSON.parse(decoded);\n if (!parsed.hubUrl || !parsed.jwt || !parsed.userId) {\n throw new Error(\"Invalid token: missing required fields (hubUrl, jwt, userId)\");\n }\n return parsed as ConnectToken;\n } catch (err) {\n if (err instanceof SyntaxError) {\n throw new Error(\"Invalid token: not valid base64-encoded JSON\");\n }\n throw err;\n }\n}\n\nexport function getConfigPath(): string {\n return configFile();\n}\n","/**\n * SessionStart hook — two jobs, and neither may cost the other.\n *\n * It PRINTS the standing obligations for the repo the session is opening in, and it\n * drops an envelope the daemon uses to surface new external CC sessions in web/mobile\n * before CC's JSONL has been fully parsed — eliminating the chokidar parse latency for\n * fresh sessions.\n *\n * The print used to belong to a Claude Code plugin, which meant the one surface a\n * session cannot skip was the one surface that required a plugin. It moved here so the\n * plugin can go.\n *\n * **The print is not conditional on anything the envelope needs.** A payload with no\n * session id still gets the block, and so does one that fails to parse at all: the\n * envelope is a message to a daemon that may not be running, while the block is what\n * the person in front of the terminal is about to work from.\n */\n\nimport { sessionContext } from \"../harness\";\nimport { appendDrop, installHookTimeout, readHookStdin } from \"./drop\";\n\ninterface CcSessionStartInput {\n session_id?: string;\n transcript_path?: string;\n cwd?: string;\n /** \"startup\" (fresh CLI), \"resume\" (--resume <id>), \"clear\" (/clear), \"compact\". */\n source?: string;\n}\n\ninterface SessionStartDropData {\n source: string;\n transcriptPath?: string;\n cwd?: string;\n}\n\n// Longer than the other hooks', because here the deadline can cost something. The\n// watchdog exits 0 with no output, and for this hook no output is a session opening\n// with no standards, no board and no method — so it is set to outlast the harness\n// (~0.4s on a warm repo) rather than to the reflex of a hook that only appends a line.\ninstallHookTimeout(8_000);\n\nasync function main(): Promise<void> {\n const p = await readHookStdin<CcSessionStartInput>();\n\n // Read from the environment first: CC sets it to the project ROOT, while `cwd` is\n // wherever the session happens to have been opened, which may be a subdirectory with\n // no config of its own.\n const repo = process.env.CLAUDE_PROJECT_DIR ?? p?.cwd ?? process.cwd();\n const block = sessionContext(repo);\n if (block) process.stdout.write(`${block}\\n`);\n\n const sessionId = p?.session_id;\n if (!sessionId) return;\n\n const ts = new Date().toISOString();\n const source = p?.source ?? \"startup\";\n const data: SessionStartDropData = {\n source,\n ...(p?.transcript_path ? { transcriptPath: p.transcript_path } : {}),\n ...(p?.cwd ? { cwd: p.cwd } : {}),\n };\n\n await appendDrop<SessionStartDropData>({\n type: \"session-start\",\n sessionId,\n // ts-based uniqId — SessionStart can fire multiple times per session\n // over its lifetime (e.g. one `startup` then later `resume`).\n uniqId: `session-start-${ts}`,\n data,\n });\n}\n\nvoid main().finally(() => process.exit(0));\n"],"mappings":"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","names":["join","join"]}
|
|
1
|
+
{"version":3,"sources":["../../src/harness.ts","../../src/hooks/drop.ts","../../src/config.ts","../../src/hooks/session-start.ts"],"sourcesContent":["/**\n * The work harness — where it ships, and what runs it.\n *\n * The harness owns the `work/` tree's invariants: bucket-is-status, tier derivation,\n * the id allocator and the completion gate. It used to live in a Claude Code plugin,\n * which made every write to the board require one vendor's plugin to be installed —\n * so an agent that speaks MCP but is not Claude Code could read the board and could\n * not touch it. It ships inside this package now, and `jarvis work` is the one door\n * onto it.\n *\n * It is Python, and this file is the whole of what Node needs to know about that: a\n * payload directory and an interpreter. Either can be missing, and a missing one is\n * REPORTED — `harness()` still answers, so a board stays readable and a write fails\n * where writes fail. What must not happen is what used to: a raw shell error, several\n * calls after the point where anything could be done about it.\n */\n\nimport { spawnSync } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\n/** The entry inside the payload. */\nconst ENTRY = join(\"harness\", \"work.py\");\n\n/** Interpreters to try, in order. Both names, because a machine that has Python\n * under only one of them is common enough that failing on it would be a bug. */\nconst INTERPRETERS = [\"python3\", \"python\"] as const;\n\n/**\n * The oldest Python the payload runs on, proven by running its own suite there.\n *\n * 3.9 rather than something newer on purpose: it is what macOS ships at\n * `/usr/bin/python3`, and therefore what a machine with nothing installed actually\n * has — which is precisely the machine this whole arrangement exists to serve. Two\n * annotations in the payload had to go to keep it, and that was the cheaper side of\n * the trade by a wide margin.\n */\nconst OLDEST = [3, 9] as const;\n\n/** How to run the harness: argv, up to but not including its subcommand. */\nexport type Harness = [command: string, ...args: string[]];\n\n/** What a consumer outside this repo has on its PATH, and the honest thing to\n * attempt when the payload cannot be resolved from here. */\nconst INSTALLED: Harness = [\"jarvis\", \"work\"];\n\n/**\n * The payload's entry script, or null when this build does not carry one.\n *\n * Resolved by walking up from this module rather than from `process.argv`, because\n * the answer must not depend on how the process was started: `tsx src/bin.ts`, the\n * bundled `dist/bin.js` and a re-spawned daemon all sit at different depths under\n * the same package root, and all three have to find the same files.\n */\nexport function payload(): string | null {\n let dir = dirname(fileURLToPath(import.meta.url));\n for (let up = 0; up < 6; up++) {\n if (existsSync(join(dir, ENTRY))) return join(dir, ENTRY);\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** The first interpreter on PATH new enough to run the payload, else null. */\nexport function interpreter(): string | null {\n for (const name of INTERPRETERS) {\n // The VERSION, not just an exit code. A name can exist on PATH as a stub that is\n // not a working interpreter — which is what macOS has until the command line\n // tools are installed — and an interpreter too old to parse the payload fails\n // with a traceback from inside it, which is the shape of error this whole file\n // exists to replace.\n const said = spawnSync(name, [\"--version\"], { encoding: \"utf-8\" });\n if (said.status !== 0) continue;\n const version = /(\\d+)\\.(\\d+)/.exec(`${said.stdout}${said.stderr}`);\n if (!version) continue;\n const [major, minor] = [Number(version[1]), Number(version[2])];\n if (major > OLDEST[0] || (major === OLDEST[0] && minor >= OLDEST[1])) return name;\n }\n return null;\n}\n\n/**\n * Why the harness cannot be reached from here, or null when it can.\n *\n * Phrased as what to do about it. `jarvis serve` reports this at startup, so it is\n * read by somebody who has just pointed an agent at their repo and has no other clue\n * that the board is about to refuse every write.\n */\nexport function harnessProblem(): string | null {\n if (!payload()) {\n return (\n \"this jarvis build carries no work harness — the board can be read but not \" +\n \"changed. Reinstall with `npm i -g @appchy/jarvis`.\"\n );\n }\n if (!interpreter()) {\n return (\n `the work harness needs Python ${OLDEST[0]}.${OLDEST[1]} or newer and this ` +\n \"machine has none on its PATH — the board can be read but not changed. \" +\n \"Install it, then try again.\"\n );\n }\n return null;\n}\n\n/**\n * How to run the harness.\n *\n * Never throws. A board whose harness is unreachable is still a board worth reading,\n * and refusing to build one would take the reads down with the writes; the caller\n * that needs to know asks `harnessProblem()`. The fallback names what a consumer\n * outside this repo actually has, so the failure a caller meets is a missing command\n * rather than a missing file.\n */\nexport function harness(): Harness {\n const script = payload();\n const python = interpreter();\n return script && python ? [python, script] : INSTALLED;\n}\n\n/**\n * The block a session opens with, for the repo at `repo` — or null when there is none.\n *\n * The harness composes every byte of it from that repo's own config: the board, its\n * standards, the decisions it has already taken, what each release is for. Nothing here\n * adds a word, and no caller of this may either — several doors serve these bytes now,\n * and a sentence any one of them typed itself is a sentence they could disagree about.\n *\n * **It never throws and never reports its own failure**, which is unlike everything else\n * in this file. Its first caller is a `SessionStart` hook: a message there greets somebody\n * with an error before they have typed anything, and a missing block is a far smaller harm\n * than a session that opens broken. A caller with somewhere to put a diagnosis asks\n * `harnessProblem()` for one.\n */\nexport function sessionContext(repo: string): string | null {\n return say(repo, [\"context\"]);\n}\n\n/**\n * The METHOD in full — how work is done here, on request.\n *\n * Split from {@link sessionContext} rather than folded into it, because the two are\n * asked at different moments and cost two orders of magnitude apart: the block a\n * session opens with is ~670 tokens of derived fact, and this is ~11,000 of prose. One\n * reader still, so a session cannot end up having read a different method than the\n * block told it to go and read.\n */\nexport function sessionMethod(repo: string): string | null {\n return say(repo, [\"method\"]);\n}\n\nexport interface EditContextRequest {\n repo: string;\n /** Repo-relative path the session is about to write. */\n file: string;\n /** Absent means the harness stays silent: it cannot promise once without one. */\n sessionId?: string;\n}\n\n/**\n * What a session must be told now that it is about to write this file — or null.\n *\n * The judgements no gate can catch, on the first write of a session, and which part of\n * the code it has walked into, the first time it writes under one. Both are the\n * harness's to word and to ration; this hands over a file and a session id and nothing\n * else, the same split {@link wrapReminder} uses.\n *\n * Never throws, for the reason {@link sessionContext} does not: its caller is a hook\n * firing before somebody's edit, and a hook that fails loudly interrupts them to report\n * a problem with a reminder.\n */\nexport function editContext(req: EditContextRequest): string | null {\n if (!req.file || !req.sessionId) return null;\n // Tighter than the shared ceiling: this one fires before somebody's edit, and a\n // rule that arrives seconds after the file was written has already missed.\n return say(\n req.repo,\n [\"applies\", \"--file\", req.file, \"--session\", req.sessionId],\n 2_000,\n );\n}\n\n/**\n * Everything a machine knows about finishing this session cleanly, for `repo` — or null\n * when the harness cannot answer.\n *\n * It writes nothing. What comes back is what only a machine can say: work that is not\n * in git, items the board still says somebody is on, the descriptions of the world this\n * run changed, what the tree disagrees with itself about, and the derived prompt that\n * opens the next session. The prose is the agent's to write, because a handoff is\n * judgement and a generated one reads as considered while being wrong.\n */\nexport function wrapBrief(repo: string): string | null {\n return say(repo, [\"wrap\"]);\n}\n\nexport interface WrapReminderRequest {\n repo: string;\n /** Tokens the session is holding right now. Zero or less means unknown. */\n used: number;\n sessionId?: string;\n}\n\nexport interface WrapReminderResponse {\n /** One line, for a surface that shows the reader a notice. */\n headline: string;\n /** The whole reminder, for the agent to act on. */\n note: string;\n}\n\n/**\n * Whether a session this full should be wrapping up, and what that means in `repo`.\n * Null when it should not, or when nothing can be said with confidence.\n *\n * **The measurement is the caller's and the judgement is the harness's**, and the\n * split is not arbitrary. How many tokens a session is holding is a thing only its\n * own client can answer, and every client answers it differently — while the\n * threshold, the wording, the once-a-session rule and whatever the repo adds are the\n * same whoever is asking. So this hands over a number, and a harness command that\n * took one vendor's log file is a harness that works for one vendor.\n *\n * Never throws, for the same reason {@link sessionContext} does not: its caller is a\n * hook, and a hook that fails loudly interrupts somebody mid-thought to report a\n * problem with a reminder.\n */\nexport function wrapReminder(req: WrapReminderRequest): WrapReminderResponse | null {\n if (!(req.used > 0)) return null;\n const said = say(req.repo, [\n \"remind\",\n \"--used\",\n String(req.used),\n ...(req.sessionId ? [\"--session\", req.sessionId] : []),\n ]);\n if (!said) return null;\n try {\n const parsed = JSON.parse(said) as Partial<WrapReminderResponse>;\n if (!parsed.headline || !parsed.note) return null;\n return { headline: parsed.headline, note: parsed.note };\n } catch {\n return null;\n }\n}\n\n/**\n * How long a harness call may take before it is killed and read as \"no answer\".\n *\n * **Every caller here is a hook, and `installHookTimeout` cannot save one of them.**\n * That watchdog is a `setTimeout`, and a timer does not fire while a synchronous\n * `spawnSync` holds the thread — so an interpreter that is slow to start (a loaded\n * machine, a scanner intercepting the spawn, a repo on a network mount) blocks the\n * session for as long as it likes, past any ceiling the hook believes it set. The\n * bound has to be on the spawn itself. Generous, because being killed mid-answer is\n * the one failure worse than being slow.\n */\nconst PATIENCE = 10_000;\n\n/**\n * What the harness said about ONE repo, or null — for a caller with nowhere to put a\n * failure.\n *\n * **The repo is a parameter rather than a flag a caller remembers to pass**, because\n * naming it in the arguments alone does not steer the harness. `--project` picks the\n * config; which `work/` tree is READ comes from `find_work_root()`, which ranks\n * `WORK_DIR` and then `CLAUDE_PROJECT_DIR` above the working directory — and Claude\n * Code sets the latter on everything it spawns. So a call naming repo A from a session\n * open on repo B used to answer with A's git state and B's board, in one reply, with\n * nothing saying so. Measured 2026-09-08 against `wrap`.\n *\n * Taking the repo here and building both the flag and the environment from it makes\n * the two halves unable to disagree. `packages/board`'s tree spawner pins the same\n * pair for the same reason.\n */\nfunction say(repo: string, args: string[], timeout = PATIENCE): string | null {\n try {\n const [command, ...prefix] = harness();\n const { WORK_DIR: _inherited, ...ambient } = process.env;\n const ran = spawnSync(command, [...prefix, ...args, \"--project\", repo], {\n encoding: \"utf-8\",\n timeout,\n env: { ...ambient, CLAUDE_PROJECT_DIR: repo, PYTHONDONTWRITEBYTECODE: \"1\" },\n });\n // A killed child leaves a non-zero status (or none at all), so the timeout reads\n // as every other failure does: no answer, and nothing said about it.\n if (ran.status !== 0) return null;\n return ran.stdout.trim() ? ran.stdout.trimEnd() : null;\n } catch {\n return null;\n }\n}\n","/**\n * Shared helpers for CC hook scripts.\n *\n * Each hook script (pre-tool-use, session-start, stop, …) is a tiny\n * stdin → append-line → exit binary. They all write to the same per-\n * session JSONL at `<hooksDir>/<sessionId>.jsonl`. This module\n * concentrates the envelope shape and the atomic append so the per-\n * hook scripts stay <30 lines each.\n *\n * Hot-path constraints: NEVER block, NEVER network, NEVER read user\n * input. Pure stdin parse + filesystem append + exit. Worst-case one\n * `mkdir` + one `appendFile` per fire.\n */\n\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { getHooksDir } from \"../config\";\n\n/** Wire-format envelope written one-per-line to the hook JSONL.\n * The watcher in `sessions.ts` parses this exact shape — keep in sync. */\nexport interface HookDropEnvelope<TData = unknown> {\n type: string;\n sessionId: string;\n ts: string;\n /** Idempotency key. Lets the watcher's replay-on-restart skip drops\n * whose effects were already emitted (see `HookCursor.applied`). */\n uniqId: string;\n data: TData;\n}\n\nexport interface AppendDropRequest<TData> {\n type: string;\n sessionId: string;\n uniqId: string;\n data: TData;\n}\n\n/** Append one envelope line to the per-session hook JSONL. Atomic between\n * hook processes via POSIX `O_APPEND` (Node's `fs.appendFile`) — concurrent\n * appends from different CC sessions / hook types can't interleave bytes\n * within a line as long as the line stays < `PIPE_BUF` (4KB). Drops are\n * <1KB in practice, so this is safe without locks.\n *\n * Stderr logs are written so `tail -f ~/.jarvis/hooks/hook.log` (when\n * CC pipes hook stderr there, which it does by default) shows what fired.\n * Stderr never breaks CC — it only watches stdout for decision JSON. */\nexport async function appendDrop<TData>(req: AppendDropRequest<TData>): Promise<void> {\n if (!req.sessionId || !req.type || !req.uniqId) {\n process.stderr.write(\n `[hook] skip ${req.type ?? \"?\"} missing field ` +\n `sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}\\n`,\n );\n return;\n }\n const dir = getHooksDir();\n await mkdir(dir, { recursive: true }).catch((err) => {\n process.stderr.write(`[hook] mkdir failed ${dir}: ${err}\\n`);\n });\n const envelope: HookDropEnvelope<TData> = {\n type: req.type,\n sessionId: req.sessionId,\n ts: new Date().toISOString(),\n uniqId: req.uniqId,\n data: req.data,\n };\n const file = join(dir, `${req.sessionId}.jsonl`);\n try {\n await appendFile(file, JSON.stringify(envelope) + \"\\n\");\n process.stderr.write(\n `[hook] ${req.type} appended sessionId=${req.sessionId} uniqId=${req.uniqId}\\n`,\n );\n } catch (err) {\n process.stderr.write(`[hook] append failed ${file}: ${err}\\n`);\n }\n}\n\n/** Read CC's hook payload from stdin. Returns `null` on parse failure\n * (never throws — the hook must never block CC). */\nexport async function readHookStdin<T = Record<string, unknown>>(): Promise<T | null> {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n const raw = Buffer.concat(chunks).toString(\"utf-8\");\n if (!raw) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Hard cap any hook so an exotic stdin stall can't pile up forever\n * before CC's terminal prompt fires. We exit 0 with no output → CC\n * proceeds with its normal permission flow. */\nexport function installHookTimeout(ms = 750): void {\n const timer = setTimeout(() => process.exit(0), ms);\n timer.unref();\n}\n","/**\n * Agent Config\n *\n * Manages ~/.jarvis/config.json — saved by `jarvis connect <token>`,\n * read on `jarvis start`.\n */\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface AgentConfig {\n /** The jarvis this machine joined — the address a person opens in a browser,\n * and what pairing talks to. Ours by default; `jarvis init --url <url>` joins\n * someone else's. */\n url?: string;\n /** WS hub URL this machine dials. Written once, by pairing, from the hub the\n * joined instance named — and never from a flag, an environment variable or\n * any later command. Re-pointing a machine means unpairing it first. */\n hubUrl?: string;\n /** JWT auth token (from connect token) */\n token?: string;\n /** Long-lived refresh token for obtaining new access tokens */\n refreshToken?: string;\n /** User ID */\n userId: string;\n /** Environment ID */\n envId?: string;\n /** Workspace root path for repo operations */\n workspacePath?: string;\n /** Anthropic API key (for local-only use without cloud) */\n anthropicApiKey?: string;\n /** OpenAI key the map's semantic index embeds with. Kept here rather than in the environment\n * on purpose: the map's extractor inherits this process's environment and picks a naming\n * model out of whatever key it finds there, so a key that never enters it cannot start work\n * nobody asked for. Absent → the map builds with no semantic index and search stays lexical. */\n openaiApiKey?: string;\n /** Use Anthropic subscription instead of API key */\n useSubscription?: boolean;\n /** When the config was last updated */\n connectedAt?: string;\n}\n\nexport interface ConnectToken {\n hubUrl: string;\n jwt: string;\n refreshToken: string;\n userId: string;\n envId: string;\n}\n\n// =============================================================================\n// Environment\n// =============================================================================\n\n/** The jarvis a machine pairs with when nothing else is named. Only ever read\n * by pairing: `jarvis init --url <url>` joins someone else's instead, and the\n * hub URL is whatever that instance hands back. */\nexport const DEFAULT_URL = \"https://jarvis.appchy.com\";\n\n// =============================================================================\n// Config directory\n// =============================================================================\n\n/** The config dir is the machine's identity: what it paired as, and therefore\n * which hub it dials. One daemon per dir, so a second identity on the same\n * machine — a storage rig, a localhost instance — is a second dir, named by\n * `JARVIS_CONFIG_DIR`. A development build is not a second identity and does carry\n * its own name: it reads this same dir, so it is the same machine.\n *\n * Called fresh on every read so a test that sets `JARVIS_CONFIG_DIR` AFTER\n * module load still sees the override — caching at module init would lock it\n * onto the host's real `~/.jarvis/` and leak production state into the\n * in-memory infra. */\nfunction configDir(): string {\n if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;\n return path.join(os.homedir(), \".jarvis\");\n}\n\nfunction configFile(): string {\n return path.join(configDir(), \"config.json\");\n}\n\n/** Absolute path to the per-daemon config directory — `~/.jarvis/` unless\n * `JARVIS_CONFIG_DIR` names another. */\nexport function getConfigDir(): string {\n return configDir();\n}\n\n/** Per-daemon directory for PreToolUse hook drop files. The CC hook script\n * writes `<sessionId>/<toolUseId>.json` here; the sessions watcher consumes\n * them. Each daemon owns the subtree under its own config dir, so a hook\n * installed for one never feeds another. */\nexport function getHooksDir(): string {\n return path.join(configDir(), \"hooks\");\n}\n\n// =============================================================================\n// Operations\n// =============================================================================\n\n/** Read the machine's config, accepting the names these two fields used to\n * carry. `appUrl`/`apiUrl` were renamed on 2026-08-15 — `apiUrl` never held an\n * API URL, it held the hub's. A config written before that still loads, and\n * `saveConfig` only ever writes the current names, so a machine heals itself on\n * its next write with nothing to run. Delete this tolerance once no config\n * predating the rename is in use. */\nexport function loadConfig(): AgentConfig | null {\n try {\n const stored = JSON.parse(fs.readFileSync(configFile(), \"utf-8\")) as AgentConfig &\n Partial<{ appUrl: string; apiUrl: string }>;\n const { appUrl, apiUrl, ...config } = stored;\n const url = config.url ?? appUrl;\n const hubUrl = config.hubUrl ?? apiUrl;\n return {\n ...config,\n ...(url !== undefined ? { url } : {}),\n ...(hubUrl !== undefined ? { hubUrl } : {}),\n };\n } catch {\n return null;\n }\n}\n\n/** Persist the machine's config.\n *\n * Refuses to move an already-paired machine to a different hub. A machine that\n * joins the wrong control plane hands it credentials, so re-pointing one is a\n * deliberate act — `jarvis unpair` (which clears the config) and then pair\n * again — rather than something a stale token or a mistyped command can do on\n * its way past. Re-pairing to the same hub is unaffected. */\nexport function saveConfig(config: AgentConfig): void {\n const paired = loadConfig();\n if (paired?.hubUrl && config.hubUrl && config.hubUrl !== paired.hubUrl) {\n throw new Error(\n `This machine is paired with ${paired.hubUrl} and cannot be moved to ${config.hubUrl}.\\n` +\n ` Run 'jarvis unpair' first if you mean to join a different jarvis.`,\n );\n }\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(configFile(), JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nexport function clearConfig(): void {\n try {\n fs.unlinkSync(configFile());\n } catch {}\n}\n\nexport function parseConnectToken(token: string): ConnectToken {\n try {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n const parsed = JSON.parse(decoded);\n if (!parsed.hubUrl || !parsed.jwt || !parsed.userId) {\n throw new Error(\"Invalid token: missing required fields (hubUrl, jwt, userId)\");\n }\n return parsed as ConnectToken;\n } catch (err) {\n if (err instanceof SyntaxError) {\n throw new Error(\"Invalid token: not valid base64-encoded JSON\");\n }\n throw err;\n }\n}\n\nexport function getConfigPath(): string {\n return configFile();\n}\n","/**\n * SessionStart hook — two jobs, and neither may cost the other.\n *\n * It PRINTS the standing obligations for the repo the session is opening in, and it\n * drops an envelope the daemon uses to surface new external CC sessions in web/mobile\n * before CC's JSONL has been fully parsed — eliminating the chokidar parse latency for\n * fresh sessions.\n *\n * The print used to belong to a Claude Code plugin, which meant the one surface a\n * session cannot skip was the one surface that required a plugin. It moved here so the\n * plugin can go.\n *\n * **The print is not conditional on anything the envelope needs.** A payload with no\n * session id still gets the block, and so does one that fails to parse at all: the\n * envelope is a message to a daemon that may not be running, while the block is what\n * the person in front of the terminal is about to work from.\n */\n\nimport { sessionContext } from \"../harness\";\nimport { appendDrop, installHookTimeout, readHookStdin } from \"./drop\";\n\ninterface CcSessionStartInput {\n session_id?: string;\n transcript_path?: string;\n cwd?: string;\n /** \"startup\" (fresh CLI), \"resume\" (--resume <id>), \"clear\" (/clear), \"compact\". */\n source?: string;\n}\n\ninterface SessionStartDropData {\n source: string;\n transcriptPath?: string;\n cwd?: string;\n}\n\n// Longer than the other hooks', because here the deadline can cost something. The\n// watchdog exits 0 with no output, and for this hook no output is a session opening\n// with no standards, no board and no method — so it is set to outlast the harness\n// (~0.4s on a warm repo) rather than to the reflex of a hook that only appends a line.\ninstallHookTimeout(8_000);\n\nasync function main(): Promise<void> {\n const p = await readHookStdin<CcSessionStartInput>();\n\n // Read from the environment first: CC sets it to the project ROOT, while `cwd` is\n // wherever the session happens to have been opened, which may be a subdirectory with\n // no config of its own.\n const repo = process.env.CLAUDE_PROJECT_DIR ?? p?.cwd ?? process.cwd();\n const block = sessionContext(repo);\n if (block) process.stdout.write(`${block}\\n`);\n\n const sessionId = p?.session_id;\n if (!sessionId) return;\n\n const ts = new Date().toISOString();\n const source = p?.source ?? \"startup\";\n const data: SessionStartDropData = {\n source,\n ...(p?.transcript_path ? { transcriptPath: p.transcript_path } : {}),\n ...(p?.cwd ? { cwd: p.cwd } : {}),\n };\n\n await appendDrop<SessionStartDropData>({\n type: \"session-start\",\n sessionId,\n // ts-based uniqId — SessionStart can fire multiple times per session\n // over its lifetime (e.g. one `startup` then later `resume`).\n uniqId: `session-start-${ts}`,\n data,\n });\n}\n\nvoid main().finally(() => process.exit(0));\n"],"mappings":";;;AAiBA,SAAS,iBAAiB;AAC1B,SAAS,kBAAkB;AAC3B,SAAS,SAAS,YAAY;AAC9B,SAAS,qBAAqB;AAG9B,IAAM,QAAQ,KAAK,WAAW,SAAS;AAIvC,IAAM,eAAe,CAAC,WAAW,QAAQ;AAWzC,IAAM,SAAS,CAAC,GAAG,CAAC;AAOpB,IAAM,YAAqB,CAAC,UAAU,MAAM;AAUrC,SAAS,UAAyB;AACvC,MAAI,MAAM,QAAQ,cAAc,YAAY,GAAG,CAAC;AAChD,WAAS,KAAK,GAAG,KAAK,GAAG,MAAM;AAC7B,QAAI,WAAW,KAAK,KAAK,KAAK,CAAC,EAAG,QAAO,KAAK,KAAK,KAAK;AACxD,UAAM,SAAS,QAAQ,GAAG;AAC1B,QAAI,WAAW,IAAK;AACpB,UAAM;AAAA,EACR;AACA,SAAO;AACT;AAGO,SAAS,cAA6B;AAC3C,aAAW,QAAQ,cAAc;AAM/B,UAAM,OAAO,UAAU,MAAM,CAAC,WAAW,GAAG,EAAE,UAAU,QAAQ,CAAC;AACjE,QAAI,KAAK,WAAW,EAAG;AACvB,UAAM,UAAU,eAAe,KAAK,GAAG,KAAK,MAAM,GAAG,KAAK,MAAM,EAAE;AAClE,QAAI,CAAC,QAAS;AACd,UAAM,CAAC,OAAO,KAAK,IAAI,CAAC,OAAO,QAAQ,CAAC,CAAC,GAAG,OAAO,QAAQ,CAAC,CAAC,CAAC;AAC9D,QAAI,QAAQ,OAAO,CAAC,KAAM,UAAU,OAAO,CAAC,KAAK,SAAS,OAAO,CAAC,EAAI,QAAO;AAAA,EAC/E;AACA,SAAO;AACT;AAmCO,SAAS,UAAmB;AACjC,QAAM,SAAS,QAAQ;AACvB,QAAM,SAAS,YAAY;AAC3B,SAAO,UAAU,SAAS,CAAC,QAAQ,MAAM,IAAI;AAC/C;AAgBO,SAAS,eAAe,MAA6B;AAC1D,SAAO,IAAI,MAAM,CAAC,SAAS,CAAC;AAC9B;AAsHA,IAAM,WAAW;AAkBjB,SAAS,IAAI,MAAc,MAAgB,UAAU,UAAyB;AAC5E,MAAI;AACF,UAAM,CAAC,SAAS,GAAG,MAAM,IAAI,QAAQ;AACrC,UAAM,EAAE,UAAU,YAAY,GAAG,QAAQ,IAAI,QAAQ;AACrD,UAAM,MAAM,UAAU,SAAS,CAAC,GAAG,QAAQ,GAAG,MAAM,aAAa,IAAI,GAAG;AAAA,MACtE,UAAU;AAAA,MACV;AAAA,MACA,KAAK,EAAE,GAAG,SAAS,oBAAoB,MAAM,yBAAyB,IAAI;AAAA,IAC5E,CAAC;AAGD,QAAI,IAAI,WAAW,EAAG,QAAO;AAC7B,WAAO,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,QAAQ,IAAI;AAAA,EACpD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;;;ACrRA,SAAS,YAAY,aAAa;AAClC,SAAS,QAAAA,aAAY;;;ACPrB,OAAO,UAAU;AACjB,OAAO,QAAQ;AAqEf,SAAS,YAAoB;AAC3B,MAAI,QAAQ,IAAI,kBAAmB,QAAO,QAAQ,IAAI;AACtD,SAAO,KAAK,KAAK,GAAG,QAAQ,GAAG,SAAS;AAC1C;AAgBO,SAAS,cAAsB;AACpC,SAAO,KAAK,KAAK,UAAU,GAAG,OAAO;AACvC;;;ADpDA,eAAsB,WAAkB,KAA8C;AACpF,MAAI,CAAC,IAAI,aAAa,CAAC,IAAI,QAAQ,CAAC,IAAI,QAAQ;AAC9C,YAAQ,OAAO;AAAA,MACb,eAAe,IAAI,QAAQ,GAAG,4BACf,CAAC,CAAC,IAAI,SAAS,WAAW,CAAC,CAAC,IAAI,MAAM;AAAA;AAAA,IACvD;AACA;AAAA,EACF;AACA,QAAM,MAAM,YAAY;AACxB,QAAM,MAAM,KAAK,EAAE,WAAW,KAAK,CAAC,EAAE,MAAM,CAAC,QAAQ;AACnD,YAAQ,OAAO,MAAM,uBAAuB,GAAG,KAAK,GAAG;AAAA,CAAI;AAAA,EAC7D,CAAC;AACD,QAAM,WAAoC;AAAA,IACxC,MAAM,IAAI;AAAA,IACV,WAAW,IAAI;AAAA,IACf,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,IAC3B,QAAQ,IAAI;AAAA,IACZ,MAAM,IAAI;AAAA,EACZ;AACA,QAAM,OAAOC,MAAK,KAAK,GAAG,IAAI,SAAS,QAAQ;AAC/C,MAAI;AACF,UAAM,WAAW,MAAM,KAAK,UAAU,QAAQ,IAAI,IAAI;AACtD,YAAQ,OAAO;AAAA,MACb,UAAU,IAAI,IAAI,uBAAuB,IAAI,SAAS,WAAW,IAAI,MAAM;AAAA;AAAA,IAC7E;AAAA,EACF,SAAS,KAAK;AACZ,YAAQ,OAAO,MAAM,wBAAwB,IAAI,KAAK,GAAG;AAAA,CAAI;AAAA,EAC/D;AACF;AAIA,eAAsB,gBAAgE;AACpF,MAAI;AACF,UAAM,SAAmB,CAAC;AAC1B,qBAAiB,SAAS,QAAQ,OAAO;AACvC,aAAO,KAAK,OAAO,UAAU,WAAW,OAAO,KAAK,KAAK,IAAI,KAAK;AAAA,IACpE;AACA,UAAM,MAAM,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO;AAClD,QAAI,CAAC,IAAK,QAAO;AACjB,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAKO,SAAS,mBAAmB,KAAK,KAAW;AACjD,QAAM,QAAQ,WAAW,MAAM,QAAQ,KAAK,CAAC,GAAG,EAAE;AAClD,QAAM,MAAM;AACd;;;AE5DA,mBAAmB,GAAK;AAExB,eAAe,OAAsB;AACnC,QAAM,IAAI,MAAM,cAAmC;AAKnD,QAAM,OAAO,QAAQ,IAAI,sBAAsB,GAAG,OAAO,QAAQ,IAAI;AACrE,QAAM,QAAQ,eAAe,IAAI;AACjC,MAAI,MAAO,SAAQ,OAAO,MAAM,GAAG,KAAK;AAAA,CAAI;AAE5C,QAAM,YAAY,GAAG;AACrB,MAAI,CAAC,UAAW;AAEhB,QAAM,MAAK,oBAAI,KAAK,GAAE,YAAY;AAClC,QAAM,SAAS,GAAG,UAAU;AAC5B,QAAM,OAA6B;AAAA,IACjC;AAAA,IACA,GAAI,GAAG,kBAAkB,EAAE,gBAAgB,EAAE,gBAAgB,IAAI,CAAC;AAAA,IAClE,GAAI,GAAG,MAAM,EAAE,KAAK,EAAE,IAAI,IAAI,CAAC;AAAA,EACjC;AAEA,QAAM,WAAiC;AAAA,IACrC,MAAM;AAAA,IACN;AAAA;AAAA;AAAA,IAGA,QAAQ,iBAAiB,EAAE;AAAA,IAC3B;AAAA,EACF,CAAC;AACH;AAEA,KAAK,KAAK,EAAE,QAAQ,MAAM,QAAQ,KAAK,CAAC,CAAC;","names":["join","join"]}
|
package/dist/hooks/stop.js
CHANGED
|
@@ -42,12 +42,10 @@ function harness() {
|
|
|
42
42
|
}
|
|
43
43
|
function wrapReminder(req) {
|
|
44
44
|
if (!(req.used > 0)) return null;
|
|
45
|
-
const said = say([
|
|
45
|
+
const said = say(req.repo, [
|
|
46
46
|
"remind",
|
|
47
47
|
"--used",
|
|
48
48
|
String(req.used),
|
|
49
|
-
"--project",
|
|
50
|
-
req.repo,
|
|
51
49
|
...req.sessionId ? ["--session", req.sessionId] : []
|
|
52
50
|
]);
|
|
53
51
|
if (!said) return null;
|
|
@@ -60,13 +58,14 @@ function wrapReminder(req) {
|
|
|
60
58
|
}
|
|
61
59
|
}
|
|
62
60
|
var PATIENCE = 1e4;
|
|
63
|
-
function say(args, timeout = PATIENCE) {
|
|
61
|
+
function say(repo, args, timeout = PATIENCE) {
|
|
64
62
|
try {
|
|
65
63
|
const [command, ...prefix] = harness();
|
|
66
|
-
const
|
|
64
|
+
const { WORK_DIR: _inherited, ...ambient } = process.env;
|
|
65
|
+
const ran = spawnSync(command, [...prefix, ...args, "--project", repo], {
|
|
67
66
|
encoding: "utf-8",
|
|
68
67
|
timeout,
|
|
69
|
-
env: { ...
|
|
68
|
+
env: { ...ambient, CLAUDE_PROJECT_DIR: repo, PYTHONDONTWRITEBYTECODE: "1" }
|
|
70
69
|
});
|
|
71
70
|
if (ran.status !== 0) return null;
|
|
72
71
|
return ran.stdout.trim() ? ran.stdout.trimEnd() : null;
|
package/dist/hooks/stop.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/stop.ts","../../src/harness.ts","../../src/hooks/drop.ts","../../src/config.ts"],"sourcesContent":["/**\n * Stop hook — two jobs, and only one of them is the daemon's.\n *\n * It drops an envelope when CC's main agent finishes responding, which flips\n * \"thinking…\" indicators off in web/mobile instantly instead of waiting for CC's JSONL\n * writes to settle and the watcher's debounced tail to fire. And it REMINDS a session\n * to finish cleanly once it is running out of room — the thing that used to live in a\n * Claude Code plugin, and that the session-opening block promises in every repo.\n *\n * **Why Stop and not PreCompact.** PreCompact is the obvious candidate and cannot do\n * this job: it may only CANCEL a compaction, never hand back an instruction. Stop\n * fires at the end of a turn, carries the transcript path, and is allowed to inject.\n * So the trigger is \"a turn ended and the conversation is large\" rather than\n * \"compaction is imminent\" — which is earlier, and earlier is the whole point.\n *\n * **The measurement is here and the judgement is the harness's.** Reading Claude\n * Code's own transcript is a Claude Code fact, so it happens in Claude Code's corner\n * of the product; the threshold, the wording and the once-a-session rule are the same\n * for every client and live where every client can reach them.\n */\n\nimport { open } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\nimport { wrapReminder } from \"../harness\";\nimport { appendDrop, installHookTimeout, readHookStdin } from \"./drop\";\n\ninterface CcStopInput {\n session_id?: string;\n transcript_path?: string;\n cwd?: string;\n /** True iff this Stop was triggered by a hook (recursion guard). We\n * forward it so the daemon can ignore self-induced stops if needed. */\n stop_hook_active?: boolean;\n}\n\ninterface StopDropData {\n stopHookActive: boolean;\n transcriptPath?: string;\n cwd?: string;\n}\n\n/** How much of the transcript's tail to read looking for the newest usage record.\n * Transcripts reach tens of megabytes and this runs at the end of every turn, so\n * reading the whole file would tax each one to answer a question the last few lines\n * already settle. Doubles up to the max when a single record is larger than the\n * window — one tool result can exceed a megabyte on its own. */\nconst TAIL_BYTES = 256 * 1024;\nconst TAIL_MAX = 8 * 1024 * 1024;\n\n// Longer than a hook that only appends a line, because this one asks the harness a\n// question. Still far under CC's own limit: the watchdog is here for a stdin that\n// never closes, not to race the work.\ninstallHookTimeout(5_000);\n\nasync function main(): Promise<void> {\n const p = await readHookStdin<CcStopInput>();\n if (!p) return;\n const sessionId = p.session_id;\n if (!sessionId) return;\n\n const ts = new Date().toISOString();\n const data: StopDropData = {\n stopHookActive: !!p.stop_hook_active,\n ...(p.transcript_path ? { transcriptPath: p.transcript_path } : {}),\n ...(p.cwd ? { cwd: p.cwd } : {}),\n };\n\n await appendDrop<StopDropData>({\n type: \"stop\",\n sessionId,\n uniqId: `stop-${ts}`,\n data,\n });\n\n await remind(p, sessionId);\n}\n\nasync function remind(p: CcStopInput, sessionId: string): Promise<void> {\n const repo = process.env.CLAUDE_PROJECT_DIR ?? p.cwd;\n if (!repo || !p.transcript_path) return;\n // A repo that never configured the harness cannot have set a threshold, and asking\n // costs a process at the end of every turn. An existence check, not a read — the\n // harness stays the only thing that parses that file.\n if (!existsSync(join(repo, \".claude\", \"work.config.json\"))) return;\n\n const used = await measureContext(p.transcript_path);\n const said = wrapReminder({ repo, used, sessionId });\n if (!said) return;\n\n process.stdout.write(\n JSON.stringify({\n systemMessage: said.headline,\n hookSpecificOutput: { hookEventName: \"Stop\", additionalContext: said.note },\n }),\n );\n}\n\n/**\n * Tokens the session is holding, from the newest main-chain assistant turn — or 0.\n *\n * **Exact or nothing.** Claude Code records the API's own `usage` on every assistant\n * turn, so the live context is `input + cache_creation + cache_read` off the most\n * recent one: the number the model was actually charged for. Dividing the transcript's\n * file size by four is the tempting alternative and is wrong twice over — the\n * transcript is an append-only log of everything that ever happened, tool output long\n * since dropped from the window included, and it carries JSON framing that is not\n * context at all. Measured on a real session it read 452k where the truth was 277k.\n *\n * Sidechain turns are skipped: a subagent runs in its own window, and counting one\n * reports a context this session never had.\n */\nasync function measureContext(transcript: string): Promise<number> {\n let handle;\n try {\n handle = await open(transcript, \"r\");\n const { size } = await handle.stat();\n let want = TAIL_BYTES;\n for (;;) {\n const from = Math.max(0, size - want);\n const buffer = Buffer.alloc(Math.min(want, size));\n await handle.read(buffer, 0, buffer.length, from);\n const lines = buffer.toString(\"utf-8\").split(\"\\n\");\n // A partial first line is unparseable; drop it unless we hold the whole file.\n if (from > 0) lines.shift();\n for (let i = lines.length - 1; i >= 0; i--) {\n const used = tokensIn(lines[i] ?? \"\");\n if (used) return used;\n }\n if (from === 0 || want >= TAIL_MAX) return 0;\n want = Math.min(want * 2, TAIL_MAX);\n }\n } catch {\n return 0;\n } finally {\n await handle?.close().catch(() => {});\n }\n}\n\nfunction tokensIn(line: string): number {\n if (!line.trim()) return 0;\n try {\n const record = JSON.parse(line) as {\n isSidechain?: boolean;\n message?: { usage?: Record<string, number> };\n };\n if (record.isSidechain) return 0;\n const usage = record.message?.usage;\n if (!usage) return 0;\n return (\n (usage.input_tokens ?? 0) +\n (usage.cache_creation_input_tokens ?? 0) +\n (usage.cache_read_input_tokens ?? 0)\n );\n } catch {\n return 0;\n }\n}\n\nvoid main().finally(() => process.exit(0));\n","/**\n * The work harness — where it ships, and what runs it.\n *\n * The harness owns the `work/` tree's invariants: bucket-is-status, tier derivation,\n * the id allocator and the completion gate. It used to live in a Claude Code plugin,\n * which made every write to the board require one vendor's plugin to be installed —\n * so an agent that speaks MCP but is not Claude Code could read the board and could\n * not touch it. It ships inside this package now, and `jarvis work` is the one door\n * onto it.\n *\n * It is Python, and this file is the whole of what Node needs to know about that: a\n * payload directory and an interpreter. Either can be missing, and a missing one is\n * REPORTED — `harness()` still answers, so a board stays readable and a write fails\n * where writes fail. What must not happen is what used to: a raw shell error, several\n * calls after the point where anything could be done about it.\n */\n\nimport { spawnSync } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\n/** The entry inside the payload. */\nconst ENTRY = join(\"harness\", \"work.py\");\n\n/** Interpreters to try, in order. Both names, because a machine that has Python\n * under only one of them is common enough that failing on it would be a bug. */\nconst INTERPRETERS = [\"python3\", \"python\"] as const;\n\n/**\n * The oldest Python the payload runs on, proven by running its own suite there.\n *\n * 3.9 rather than something newer on purpose: it is what macOS ships at\n * `/usr/bin/python3`, and therefore what a machine with nothing installed actually\n * has — which is precisely the machine this whole arrangement exists to serve. Two\n * annotations in the payload had to go to keep it, and that was the cheaper side of\n * the trade by a wide margin.\n */\nconst OLDEST = [3, 9] as const;\n\n/** How to run the harness: argv, up to but not including its subcommand. */\nexport type Harness = [command: string, ...args: string[]];\n\n/** What a consumer outside this repo has on its PATH, and the honest thing to\n * attempt when the payload cannot be resolved from here. */\nconst INSTALLED: Harness = [\"jarvis\", \"work\"];\n\n/**\n * The payload's entry script, or null when this build does not carry one.\n *\n * Resolved by walking up from this module rather than from `process.argv`, because\n * the answer must not depend on how the process was started: `tsx src/bin.ts`, the\n * bundled `dist/bin.js` and a re-spawned daemon all sit at different depths under\n * the same package root, and all three have to find the same files.\n */\nexport function payload(): string | null {\n let dir = dirname(fileURLToPath(import.meta.url));\n for (let up = 0; up < 6; up++) {\n if (existsSync(join(dir, ENTRY))) return join(dir, ENTRY);\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** The first interpreter on PATH new enough to run the payload, else null. */\nexport function interpreter(): string | null {\n for (const name of INTERPRETERS) {\n // The VERSION, not just an exit code. A name can exist on PATH as a stub that is\n // not a working interpreter — which is what macOS has until the command line\n // tools are installed — and an interpreter too old to parse the payload fails\n // with a traceback from inside it, which is the shape of error this whole file\n // exists to replace.\n const said = spawnSync(name, [\"--version\"], { encoding: \"utf-8\" });\n if (said.status !== 0) continue;\n const version = /(\\d+)\\.(\\d+)/.exec(`${said.stdout}${said.stderr}`);\n if (!version) continue;\n const [major, minor] = [Number(version[1]), Number(version[2])];\n if (major > OLDEST[0] || (major === OLDEST[0] && minor >= OLDEST[1])) return name;\n }\n return null;\n}\n\n/**\n * Why the harness cannot be reached from here, or null when it can.\n *\n * Phrased as what to do about it. `jarvis serve` reports this at startup, so it is\n * read by somebody who has just pointed an agent at their repo and has no other clue\n * that the board is about to refuse every write.\n */\nexport function harnessProblem(): string | null {\n if (!payload()) {\n return (\n \"this jarvis build carries no work harness — the board can be read but not \" +\n \"changed. Reinstall with `npm i -g @appchy/jarvis`.\"\n );\n }\n if (!interpreter()) {\n return (\n `the work harness needs Python ${OLDEST[0]}.${OLDEST[1]} or newer and this ` +\n \"machine has none on its PATH — the board can be read but not changed. \" +\n \"Install it, then try again.\"\n );\n }\n return null;\n}\n\n/**\n * How to run the harness.\n *\n * Never throws. A board whose harness is unreachable is still a board worth reading,\n * and refusing to build one would take the reads down with the writes; the caller\n * that needs to know asks `harnessProblem()`. The fallback names what a consumer\n * outside this repo actually has, so the failure a caller meets is a missing command\n * rather than a missing file.\n */\nexport function harness(): Harness {\n const script = payload();\n const python = interpreter();\n return script && python ? [python, script] : INSTALLED;\n}\n\n/**\n * The block a session opens with, for the repo at `repo` — or null when there is none.\n *\n * The harness composes every byte of it from that repo's own config: the board, its\n * standards, the decisions it has already taken, what each release is for. Nothing here\n * adds a word, and no caller of this may either — several doors serve these bytes now,\n * and a sentence any one of them typed itself is a sentence they could disagree about.\n *\n * **It never throws and never reports its own failure**, which is unlike everything else\n * in this file. Its first caller is a `SessionStart` hook: a message there greets somebody\n * with an error before they have typed anything, and a missing block is a far smaller harm\n * than a session that opens broken. A caller with somewhere to put a diagnosis asks\n * `harnessProblem()` for one.\n */\nexport function sessionContext(repo: string): string | null {\n return say([\"context\", \"--project\", repo]);\n}\n\n/**\n * The METHOD in full — how work is done here, on request.\n *\n * Split from {@link sessionContext} rather than folded into it, because the two are\n * asked at different moments and cost two orders of magnitude apart: the block a\n * session opens with is ~670 tokens of derived fact, and this is ~11,000 of prose. One\n * reader still, so a session cannot end up having read a different method than the\n * block told it to go and read.\n */\nexport function sessionMethod(repo: string): string | null {\n return say([\"method\", \"--project\", repo]);\n}\n\nexport interface EditContextRequest {\n repo: string;\n /** Repo-relative path the session is about to write. */\n file: string;\n /** Absent means the harness stays silent: it cannot promise once without one. */\n sessionId?: string;\n}\n\n/**\n * What a session must be told now that it is about to write this file — or null.\n *\n * The judgements no gate can catch, on the first write of a session, and which part of\n * the code it has walked into, the first time it writes under one. Both are the\n * harness's to word and to ration; this hands over a file and a session id and nothing\n * else, the same split {@link wrapReminder} uses.\n *\n * Never throws, for the reason {@link sessionContext} does not: its caller is a hook\n * firing before somebody's edit, and a hook that fails loudly interrupts them to report\n * a problem with a reminder.\n */\nexport function editContext(req: EditContextRequest): string | null {\n if (!req.file || !req.sessionId) return null;\n // Tighter than the shared ceiling: this one fires before somebody's edit, and a\n // rule that arrives seconds after the file was written has already missed.\n return say(\n [\"applies\", \"--file\", req.file, \"--project\", req.repo, \"--session\", req.sessionId],\n 2_000,\n );\n}\n\n/**\n * Everything a machine knows about finishing this session cleanly, for `repo` — or null\n * when the harness cannot answer.\n *\n * It writes nothing. What comes back is what only a machine can say: work that is not\n * in git, items the board still says somebody is on, the descriptions of the world this\n * run changed, what the tree disagrees with itself about, and the derived prompt that\n * opens the next session. The prose is the agent's to write, because a handoff is\n * judgement and a generated one reads as considered while being wrong.\n */\nexport function wrapBrief(repo: string): string | null {\n return say([\"wrap\", \"--project\", repo]);\n}\n\nexport interface WrapReminderRequest {\n repo: string;\n /** Tokens the session is holding right now. Zero or less means unknown. */\n used: number;\n sessionId?: string;\n}\n\nexport interface WrapReminderResponse {\n /** One line, for a surface that shows the reader a notice. */\n headline: string;\n /** The whole reminder, for the agent to act on. */\n note: string;\n}\n\n/**\n * Whether a session this full should be wrapping up, and what that means in `repo`.\n * Null when it should not, or when nothing can be said with confidence.\n *\n * **The measurement is the caller's and the judgement is the harness's**, and the\n * split is not arbitrary. How many tokens a session is holding is a thing only its\n * own client can answer, and every client answers it differently — while the\n * threshold, the wording, the once-a-session rule and whatever the repo adds are the\n * same whoever is asking. So this hands over a number, and a harness command that\n * took one vendor's log file is a harness that works for one vendor.\n *\n * Never throws, for the same reason {@link sessionContext} does not: its caller is a\n * hook, and a hook that fails loudly interrupts somebody mid-thought to report a\n * problem with a reminder.\n */\nexport function wrapReminder(req: WrapReminderRequest): WrapReminderResponse | null {\n if (!(req.used > 0)) return null;\n const said = say([\n \"remind\",\n \"--used\",\n String(req.used),\n \"--project\",\n req.repo,\n ...(req.sessionId ? [\"--session\", req.sessionId] : []),\n ]);\n if (!said) return null;\n try {\n const parsed = JSON.parse(said) as Partial<WrapReminderResponse>;\n if (!parsed.headline || !parsed.note) return null;\n return { headline: parsed.headline, note: parsed.note };\n } catch {\n return null;\n }\n}\n\n/**\n * How long a harness call may take before it is killed and read as \"no answer\".\n *\n * **Every caller here is a hook, and `installHookTimeout` cannot save one of them.**\n * That watchdog is a `setTimeout`, and a timer does not fire while a synchronous\n * `spawnSync` holds the thread — so an interpreter that is slow to start (a loaded\n * machine, a scanner intercepting the spawn, a repo on a network mount) blocks the\n * session for as long as it likes, past any ceiling the hook believes it set. The\n * bound has to be on the spawn itself. Generous, because being killed mid-answer is\n * the one failure worse than being slow.\n */\nconst PATIENCE = 10_000;\n\n/** What the harness said, or null — for a caller with nowhere to put a failure. */\nfunction say(args: string[], timeout = PATIENCE): string | null {\n try {\n const [command, ...prefix] = harness();\n const ran = spawnSync(command, [...prefix, ...args], {\n encoding: \"utf-8\",\n timeout,\n env: { ...process.env, PYTHONDONTWRITEBYTECODE: \"1\" },\n });\n // A killed child leaves a non-zero status (or none at all), so the timeout reads\n // as every other failure does: no answer, and nothing said about it.\n if (ran.status !== 0) return null;\n return ran.stdout.trim() ? ran.stdout.trimEnd() : null;\n } catch {\n return null;\n }\n}\n","/**\n * Shared helpers for CC hook scripts.\n *\n * Each hook script (pre-tool-use, session-start, stop, …) is a tiny\n * stdin → append-line → exit binary. They all write to the same per-\n * session JSONL at `<hooksDir>/<sessionId>.jsonl`. This module\n * concentrates the envelope shape and the atomic append so the per-\n * hook scripts stay <30 lines each.\n *\n * Hot-path constraints: NEVER block, NEVER network, NEVER read user\n * input. Pure stdin parse + filesystem append + exit. Worst-case one\n * `mkdir` + one `appendFile` per fire.\n */\n\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { getHooksDir } from \"../config\";\n\n/** Wire-format envelope written one-per-line to the hook JSONL.\n * The watcher in `sessions.ts` parses this exact shape — keep in sync. */\nexport interface HookDropEnvelope<TData = unknown> {\n type: string;\n sessionId: string;\n ts: string;\n /** Idempotency key. Lets the watcher's replay-on-restart skip drops\n * whose effects were already emitted (see `HookCursor.applied`). */\n uniqId: string;\n data: TData;\n}\n\nexport interface AppendDropRequest<TData> {\n type: string;\n sessionId: string;\n uniqId: string;\n data: TData;\n}\n\n/** Append one envelope line to the per-session hook JSONL. Atomic between\n * hook processes via POSIX `O_APPEND` (Node's `fs.appendFile`) — concurrent\n * appends from different CC sessions / hook types can't interleave bytes\n * within a line as long as the line stays < `PIPE_BUF` (4KB). Drops are\n * <1KB in practice, so this is safe without locks.\n *\n * Stderr logs are written so `tail -f ~/.jarvis/hooks/hook.log` (when\n * CC pipes hook stderr there, which it does by default) shows what fired.\n * Stderr never breaks CC — it only watches stdout for decision JSON. */\nexport async function appendDrop<TData>(req: AppendDropRequest<TData>): Promise<void> {\n if (!req.sessionId || !req.type || !req.uniqId) {\n process.stderr.write(\n `[hook] skip ${req.type ?? \"?\"} missing field ` +\n `sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}\\n`,\n );\n return;\n }\n const dir = getHooksDir();\n await mkdir(dir, { recursive: true }).catch((err) => {\n process.stderr.write(`[hook] mkdir failed ${dir}: ${err}\\n`);\n });\n const envelope: HookDropEnvelope<TData> = {\n type: req.type,\n sessionId: req.sessionId,\n ts: new Date().toISOString(),\n uniqId: req.uniqId,\n data: req.data,\n };\n const file = join(dir, `${req.sessionId}.jsonl`);\n try {\n await appendFile(file, JSON.stringify(envelope) + \"\\n\");\n process.stderr.write(\n `[hook] ${req.type} appended sessionId=${req.sessionId} uniqId=${req.uniqId}\\n`,\n );\n } catch (err) {\n process.stderr.write(`[hook] append failed ${file}: ${err}\\n`);\n }\n}\n\n/** Read CC's hook payload from stdin. Returns `null` on parse failure\n * (never throws — the hook must never block CC). */\nexport async function readHookStdin<T = Record<string, unknown>>(): Promise<T | null> {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n const raw = Buffer.concat(chunks).toString(\"utf-8\");\n if (!raw) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Hard cap any hook so an exotic stdin stall can't pile up forever\n * before CC's terminal prompt fires. We exit 0 with no output → CC\n * proceeds with its normal permission flow. */\nexport function installHookTimeout(ms = 750): void {\n const timer = setTimeout(() => process.exit(0), ms);\n timer.unref();\n}\n","/**\n * Agent Config\n *\n * Manages ~/.jarvis/config.json — saved by `jarvis connect <token>`,\n * read on `jarvis start`.\n */\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface AgentConfig {\n /** The jarvis this machine joined — the address a person opens in a browser,\n * and what pairing talks to. Ours by default; `jarvis init --url <url>` joins\n * someone else's. */\n url?: string;\n /** WS hub URL this machine dials. Written once, by pairing, from the hub the\n * joined instance named — and never from a flag, an environment variable or\n * any later command. Re-pointing a machine means unpairing it first. */\n hubUrl?: string;\n /** JWT auth token (from connect token) */\n token?: string;\n /** Long-lived refresh token for obtaining new access tokens */\n refreshToken?: string;\n /** User ID */\n userId: string;\n /** Environment ID */\n envId?: string;\n /** Workspace root path for repo operations */\n workspacePath?: string;\n /** Anthropic API key (for local-only use without cloud) */\n anthropicApiKey?: string;\n /** Use Anthropic subscription instead of API key */\n useSubscription?: boolean;\n /** When the config was last updated */\n connectedAt?: string;\n}\n\nexport interface ConnectToken {\n hubUrl: string;\n jwt: string;\n refreshToken: string;\n userId: string;\n envId: string;\n}\n\n// =============================================================================\n// Environment\n// =============================================================================\n\n/** The jarvis a machine pairs with when nothing else is named. Only ever read\n * by pairing: `jarvis init --url <url>` joins someone else's instead, and the\n * hub URL is whatever that instance hands back. */\nexport const DEFAULT_URL = \"https://jarvis.appchy.com\";\n\n// =============================================================================\n// Config directory\n// =============================================================================\n\n/** The config dir is the machine's identity: what it paired as, and therefore\n * which hub it dials. One daemon per dir, so a second identity on the same\n * machine — a storage rig, a localhost instance — is a second dir, named by\n * `JARVIS_CONFIG_DIR`. A development build is not a second identity and does carry\n * its own name: it reads this same dir, so it is the same machine.\n *\n * Called fresh on every read so a test that sets `JARVIS_CONFIG_DIR` AFTER\n * module load still sees the override — caching at module init would lock it\n * onto the host's real `~/.jarvis/` and leak production state into the\n * in-memory infra. */\nfunction configDir(): string {\n if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;\n return path.join(os.homedir(), \".jarvis\");\n}\n\nfunction configFile(): string {\n return path.join(configDir(), \"config.json\");\n}\n\n/** Absolute path to the per-daemon config directory — `~/.jarvis/` unless\n * `JARVIS_CONFIG_DIR` names another. */\nexport function getConfigDir(): string {\n return configDir();\n}\n\n/** Per-daemon directory for PreToolUse hook drop files. The CC hook script\n * writes `<sessionId>/<toolUseId>.json` here; the sessions watcher consumes\n * them. Each daemon owns the subtree under its own config dir, so a hook\n * installed for one never feeds another. */\nexport function getHooksDir(): string {\n return path.join(configDir(), \"hooks\");\n}\n\n// =============================================================================\n// Operations\n// =============================================================================\n\n/** Read the machine's config, accepting the names these two fields used to\n * carry. `appUrl`/`apiUrl` were renamed on 2026-08-15 — `apiUrl` never held an\n * API URL, it held the hub's. A config written before that still loads, and\n * `saveConfig` only ever writes the current names, so a machine heals itself on\n * its next write with nothing to run. Delete this tolerance once no config\n * predating the rename is in use. */\nexport function loadConfig(): AgentConfig | null {\n try {\n const stored = JSON.parse(fs.readFileSync(configFile(), \"utf-8\")) as AgentConfig &\n Partial<{ appUrl: string; apiUrl: string }>;\n const { appUrl, apiUrl, ...config } = stored;\n const url = config.url ?? appUrl;\n const hubUrl = config.hubUrl ?? apiUrl;\n return {\n ...config,\n ...(url !== undefined ? { url } : {}),\n ...(hubUrl !== undefined ? { hubUrl } : {}),\n };\n } catch {\n return null;\n }\n}\n\n/** Persist the machine's config.\n *\n * Refuses to move an already-paired machine to a different hub. A machine that\n * joins the wrong control plane hands it credentials, so re-pointing one is a\n * deliberate act — `jarvis unpair` (which clears the config) and then pair\n * again — rather than something a stale token or a mistyped command can do on\n * its way past. Re-pairing to the same hub is unaffected. */\nexport function saveConfig(config: AgentConfig): void {\n const paired = loadConfig();\n if (paired?.hubUrl && config.hubUrl && config.hubUrl !== paired.hubUrl) {\n throw new Error(\n `This machine is paired with ${paired.hubUrl} and cannot be moved to ${config.hubUrl}.\\n` +\n ` Run 'jarvis unpair' first if you mean to join a different jarvis.`,\n );\n }\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(configFile(), JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nexport function clearConfig(): void {\n try {\n fs.unlinkSync(configFile());\n } catch {}\n}\n\nexport function parseConnectToken(token: string): ConnectToken {\n try {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n const parsed = JSON.parse(decoded);\n if (!parsed.hubUrl || !parsed.jwt || !parsed.userId) {\n throw new Error(\"Invalid token: missing required fields (hubUrl, jwt, userId)\");\n }\n return parsed as ConnectToken;\n } catch (err) {\n if (err instanceof SyntaxError) {\n throw new Error(\"Invalid token: not valid base64-encoded JSON\");\n }\n throw err;\n }\n}\n\nexport function getConfigPath(): string {\n return configFile();\n}\n"],"mappings":"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","names":["existsSync","join","join","join","existsSync","join"]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/stop.ts","../../src/harness.ts","../../src/hooks/drop.ts","../../src/config.ts"],"sourcesContent":["/**\n * Stop hook — two jobs, and only one of them is the daemon's.\n *\n * It drops an envelope when CC's main agent finishes responding, which flips\n * \"thinking…\" indicators off in web/mobile instantly instead of waiting for CC's JSONL\n * writes to settle and the watcher's debounced tail to fire. And it REMINDS a session\n * to finish cleanly once it is running out of room — the thing that used to live in a\n * Claude Code plugin, and that the session-opening block promises in every repo.\n *\n * **Why Stop and not PreCompact.** PreCompact is the obvious candidate and cannot do\n * this job: it may only CANCEL a compaction, never hand back an instruction. Stop\n * fires at the end of a turn, carries the transcript path, and is allowed to inject.\n * So the trigger is \"a turn ended and the conversation is large\" rather than\n * \"compaction is imminent\" — which is earlier, and earlier is the whole point.\n *\n * **The measurement is here and the judgement is the harness's.** Reading Claude\n * Code's own transcript is a Claude Code fact, so it happens in Claude Code's corner\n * of the product; the threshold, the wording and the once-a-session rule are the same\n * for every client and live where every client can reach them.\n */\n\nimport { open } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\nimport { wrapReminder } from \"../harness\";\nimport { appendDrop, installHookTimeout, readHookStdin } from \"./drop\";\n\ninterface CcStopInput {\n session_id?: string;\n transcript_path?: string;\n cwd?: string;\n /** True iff this Stop was triggered by a hook (recursion guard). We\n * forward it so the daemon can ignore self-induced stops if needed. */\n stop_hook_active?: boolean;\n}\n\ninterface StopDropData {\n stopHookActive: boolean;\n transcriptPath?: string;\n cwd?: string;\n}\n\n/** How much of the transcript's tail to read looking for the newest usage record.\n * Transcripts reach tens of megabytes and this runs at the end of every turn, so\n * reading the whole file would tax each one to answer a question the last few lines\n * already settle. Doubles up to the max when a single record is larger than the\n * window — one tool result can exceed a megabyte on its own. */\nconst TAIL_BYTES = 256 * 1024;\nconst TAIL_MAX = 8 * 1024 * 1024;\n\n// Longer than a hook that only appends a line, because this one asks the harness a\n// question. Still far under CC's own limit: the watchdog is here for a stdin that\n// never closes, not to race the work.\ninstallHookTimeout(5_000);\n\nasync function main(): Promise<void> {\n const p = await readHookStdin<CcStopInput>();\n if (!p) return;\n const sessionId = p.session_id;\n if (!sessionId) return;\n\n const ts = new Date().toISOString();\n const data: StopDropData = {\n stopHookActive: !!p.stop_hook_active,\n ...(p.transcript_path ? { transcriptPath: p.transcript_path } : {}),\n ...(p.cwd ? { cwd: p.cwd } : {}),\n };\n\n await appendDrop<StopDropData>({\n type: \"stop\",\n sessionId,\n uniqId: `stop-${ts}`,\n data,\n });\n\n await remind(p, sessionId);\n}\n\nasync function remind(p: CcStopInput, sessionId: string): Promise<void> {\n const repo = process.env.CLAUDE_PROJECT_DIR ?? p.cwd;\n if (!repo || !p.transcript_path) return;\n // A repo that never configured the harness cannot have set a threshold, and asking\n // costs a process at the end of every turn. An existence check, not a read — the\n // harness stays the only thing that parses that file.\n if (!existsSync(join(repo, \".claude\", \"work.config.json\"))) return;\n\n const used = await measureContext(p.transcript_path);\n const said = wrapReminder({ repo, used, sessionId });\n if (!said) return;\n\n process.stdout.write(\n JSON.stringify({\n systemMessage: said.headline,\n hookSpecificOutput: { hookEventName: \"Stop\", additionalContext: said.note },\n }),\n );\n}\n\n/**\n * Tokens the session is holding, from the newest main-chain assistant turn — or 0.\n *\n * **Exact or nothing.** Claude Code records the API's own `usage` on every assistant\n * turn, so the live context is `input + cache_creation + cache_read` off the most\n * recent one: the number the model was actually charged for. Dividing the transcript's\n * file size by four is the tempting alternative and is wrong twice over — the\n * transcript is an append-only log of everything that ever happened, tool output long\n * since dropped from the window included, and it carries JSON framing that is not\n * context at all. Measured on a real session it read 452k where the truth was 277k.\n *\n * Sidechain turns are skipped: a subagent runs in its own window, and counting one\n * reports a context this session never had.\n */\nasync function measureContext(transcript: string): Promise<number> {\n let handle;\n try {\n handle = await open(transcript, \"r\");\n const { size } = await handle.stat();\n let want = TAIL_BYTES;\n for (;;) {\n const from = Math.max(0, size - want);\n const buffer = Buffer.alloc(Math.min(want, size));\n await handle.read(buffer, 0, buffer.length, from);\n const lines = buffer.toString(\"utf-8\").split(\"\\n\");\n // A partial first line is unparseable; drop it unless we hold the whole file.\n if (from > 0) lines.shift();\n for (let i = lines.length - 1; i >= 0; i--) {\n const used = tokensIn(lines[i] ?? \"\");\n if (used) return used;\n }\n if (from === 0 || want >= TAIL_MAX) return 0;\n want = Math.min(want * 2, TAIL_MAX);\n }\n } catch {\n return 0;\n } finally {\n await handle?.close().catch(() => {});\n }\n}\n\nfunction tokensIn(line: string): number {\n if (!line.trim()) return 0;\n try {\n const record = JSON.parse(line) as {\n isSidechain?: boolean;\n message?: { usage?: Record<string, number> };\n };\n if (record.isSidechain) return 0;\n const usage = record.message?.usage;\n if (!usage) return 0;\n return (\n (usage.input_tokens ?? 0) +\n (usage.cache_creation_input_tokens ?? 0) +\n (usage.cache_read_input_tokens ?? 0)\n );\n } catch {\n return 0;\n }\n}\n\nvoid main().finally(() => process.exit(0));\n","/**\n * The work harness — where it ships, and what runs it.\n *\n * The harness owns the `work/` tree's invariants: bucket-is-status, tier derivation,\n * the id allocator and the completion gate. It used to live in a Claude Code plugin,\n * which made every write to the board require one vendor's plugin to be installed —\n * so an agent that speaks MCP but is not Claude Code could read the board and could\n * not touch it. It ships inside this package now, and `jarvis work` is the one door\n * onto it.\n *\n * It is Python, and this file is the whole of what Node needs to know about that: a\n * payload directory and an interpreter. Either can be missing, and a missing one is\n * REPORTED — `harness()` still answers, so a board stays readable and a write fails\n * where writes fail. What must not happen is what used to: a raw shell error, several\n * calls after the point where anything could be done about it.\n */\n\nimport { spawnSync } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\n/** The entry inside the payload. */\nconst ENTRY = join(\"harness\", \"work.py\");\n\n/** Interpreters to try, in order. Both names, because a machine that has Python\n * under only one of them is common enough that failing on it would be a bug. */\nconst INTERPRETERS = [\"python3\", \"python\"] as const;\n\n/**\n * The oldest Python the payload runs on, proven by running its own suite there.\n *\n * 3.9 rather than something newer on purpose: it is what macOS ships at\n * `/usr/bin/python3`, and therefore what a machine with nothing installed actually\n * has — which is precisely the machine this whole arrangement exists to serve. Two\n * annotations in the payload had to go to keep it, and that was the cheaper side of\n * the trade by a wide margin.\n */\nconst OLDEST = [3, 9] as const;\n\n/** How to run the harness: argv, up to but not including its subcommand. */\nexport type Harness = [command: string, ...args: string[]];\n\n/** What a consumer outside this repo has on its PATH, and the honest thing to\n * attempt when the payload cannot be resolved from here. */\nconst INSTALLED: Harness = [\"jarvis\", \"work\"];\n\n/**\n * The payload's entry script, or null when this build does not carry one.\n *\n * Resolved by walking up from this module rather than from `process.argv`, because\n * the answer must not depend on how the process was started: `tsx src/bin.ts`, the\n * bundled `dist/bin.js` and a re-spawned daemon all sit at different depths under\n * the same package root, and all three have to find the same files.\n */\nexport function payload(): string | null {\n let dir = dirname(fileURLToPath(import.meta.url));\n for (let up = 0; up < 6; up++) {\n if (existsSync(join(dir, ENTRY))) return join(dir, ENTRY);\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** The first interpreter on PATH new enough to run the payload, else null. */\nexport function interpreter(): string | null {\n for (const name of INTERPRETERS) {\n // The VERSION, not just an exit code. A name can exist on PATH as a stub that is\n // not a working interpreter — which is what macOS has until the command line\n // tools are installed — and an interpreter too old to parse the payload fails\n // with a traceback from inside it, which is the shape of error this whole file\n // exists to replace.\n const said = spawnSync(name, [\"--version\"], { encoding: \"utf-8\" });\n if (said.status !== 0) continue;\n const version = /(\\d+)\\.(\\d+)/.exec(`${said.stdout}${said.stderr}`);\n if (!version) continue;\n const [major, minor] = [Number(version[1]), Number(version[2])];\n if (major > OLDEST[0] || (major === OLDEST[0] && minor >= OLDEST[1])) return name;\n }\n return null;\n}\n\n/**\n * Why the harness cannot be reached from here, or null when it can.\n *\n * Phrased as what to do about it. `jarvis serve` reports this at startup, so it is\n * read by somebody who has just pointed an agent at their repo and has no other clue\n * that the board is about to refuse every write.\n */\nexport function harnessProblem(): string | null {\n if (!payload()) {\n return (\n \"this jarvis build carries no work harness — the board can be read but not \" +\n \"changed. Reinstall with `npm i -g @appchy/jarvis`.\"\n );\n }\n if (!interpreter()) {\n return (\n `the work harness needs Python ${OLDEST[0]}.${OLDEST[1]} or newer and this ` +\n \"machine has none on its PATH — the board can be read but not changed. \" +\n \"Install it, then try again.\"\n );\n }\n return null;\n}\n\n/**\n * How to run the harness.\n *\n * Never throws. A board whose harness is unreachable is still a board worth reading,\n * and refusing to build one would take the reads down with the writes; the caller\n * that needs to know asks `harnessProblem()`. The fallback names what a consumer\n * outside this repo actually has, so the failure a caller meets is a missing command\n * rather than a missing file.\n */\nexport function harness(): Harness {\n const script = payload();\n const python = interpreter();\n return script && python ? [python, script] : INSTALLED;\n}\n\n/**\n * The block a session opens with, for the repo at `repo` — or null when there is none.\n *\n * The harness composes every byte of it from that repo's own config: the board, its\n * standards, the decisions it has already taken, what each release is for. Nothing here\n * adds a word, and no caller of this may either — several doors serve these bytes now,\n * and a sentence any one of them typed itself is a sentence they could disagree about.\n *\n * **It never throws and never reports its own failure**, which is unlike everything else\n * in this file. Its first caller is a `SessionStart` hook: a message there greets somebody\n * with an error before they have typed anything, and a missing block is a far smaller harm\n * than a session that opens broken. A caller with somewhere to put a diagnosis asks\n * `harnessProblem()` for one.\n */\nexport function sessionContext(repo: string): string | null {\n return say(repo, [\"context\"]);\n}\n\n/**\n * The METHOD in full — how work is done here, on request.\n *\n * Split from {@link sessionContext} rather than folded into it, because the two are\n * asked at different moments and cost two orders of magnitude apart: the block a\n * session opens with is ~670 tokens of derived fact, and this is ~11,000 of prose. One\n * reader still, so a session cannot end up having read a different method than the\n * block told it to go and read.\n */\nexport function sessionMethod(repo: string): string | null {\n return say(repo, [\"method\"]);\n}\n\nexport interface EditContextRequest {\n repo: string;\n /** Repo-relative path the session is about to write. */\n file: string;\n /** Absent means the harness stays silent: it cannot promise once without one. */\n sessionId?: string;\n}\n\n/**\n * What a session must be told now that it is about to write this file — or null.\n *\n * The judgements no gate can catch, on the first write of a session, and which part of\n * the code it has walked into, the first time it writes under one. Both are the\n * harness's to word and to ration; this hands over a file and a session id and nothing\n * else, the same split {@link wrapReminder} uses.\n *\n * Never throws, for the reason {@link sessionContext} does not: its caller is a hook\n * firing before somebody's edit, and a hook that fails loudly interrupts them to report\n * a problem with a reminder.\n */\nexport function editContext(req: EditContextRequest): string | null {\n if (!req.file || !req.sessionId) return null;\n // Tighter than the shared ceiling: this one fires before somebody's edit, and a\n // rule that arrives seconds after the file was written has already missed.\n return say(\n req.repo,\n [\"applies\", \"--file\", req.file, \"--session\", req.sessionId],\n 2_000,\n );\n}\n\n/**\n * Everything a machine knows about finishing this session cleanly, for `repo` — or null\n * when the harness cannot answer.\n *\n * It writes nothing. What comes back is what only a machine can say: work that is not\n * in git, items the board still says somebody is on, the descriptions of the world this\n * run changed, what the tree disagrees with itself about, and the derived prompt that\n * opens the next session. The prose is the agent's to write, because a handoff is\n * judgement and a generated one reads as considered while being wrong.\n */\nexport function wrapBrief(repo: string): string | null {\n return say(repo, [\"wrap\"]);\n}\n\nexport interface WrapReminderRequest {\n repo: string;\n /** Tokens the session is holding right now. Zero or less means unknown. */\n used: number;\n sessionId?: string;\n}\n\nexport interface WrapReminderResponse {\n /** One line, for a surface that shows the reader a notice. */\n headline: string;\n /** The whole reminder, for the agent to act on. */\n note: string;\n}\n\n/**\n * Whether a session this full should be wrapping up, and what that means in `repo`.\n * Null when it should not, or when nothing can be said with confidence.\n *\n * **The measurement is the caller's and the judgement is the harness's**, and the\n * split is not arbitrary. How many tokens a session is holding is a thing only its\n * own client can answer, and every client answers it differently — while the\n * threshold, the wording, the once-a-session rule and whatever the repo adds are the\n * same whoever is asking. So this hands over a number, and a harness command that\n * took one vendor's log file is a harness that works for one vendor.\n *\n * Never throws, for the same reason {@link sessionContext} does not: its caller is a\n * hook, and a hook that fails loudly interrupts somebody mid-thought to report a\n * problem with a reminder.\n */\nexport function wrapReminder(req: WrapReminderRequest): WrapReminderResponse | null {\n if (!(req.used > 0)) return null;\n const said = say(req.repo, [\n \"remind\",\n \"--used\",\n String(req.used),\n ...(req.sessionId ? [\"--session\", req.sessionId] : []),\n ]);\n if (!said) return null;\n try {\n const parsed = JSON.parse(said) as Partial<WrapReminderResponse>;\n if (!parsed.headline || !parsed.note) return null;\n return { headline: parsed.headline, note: parsed.note };\n } catch {\n return null;\n }\n}\n\n/**\n * How long a harness call may take before it is killed and read as \"no answer\".\n *\n * **Every caller here is a hook, and `installHookTimeout` cannot save one of them.**\n * That watchdog is a `setTimeout`, and a timer does not fire while a synchronous\n * `spawnSync` holds the thread — so an interpreter that is slow to start (a loaded\n * machine, a scanner intercepting the spawn, a repo on a network mount) blocks the\n * session for as long as it likes, past any ceiling the hook believes it set. The\n * bound has to be on the spawn itself. Generous, because being killed mid-answer is\n * the one failure worse than being slow.\n */\nconst PATIENCE = 10_000;\n\n/**\n * What the harness said about ONE repo, or null — for a caller with nowhere to put a\n * failure.\n *\n * **The repo is a parameter rather than a flag a caller remembers to pass**, because\n * naming it in the arguments alone does not steer the harness. `--project` picks the\n * config; which `work/` tree is READ comes from `find_work_root()`, which ranks\n * `WORK_DIR` and then `CLAUDE_PROJECT_DIR` above the working directory — and Claude\n * Code sets the latter on everything it spawns. So a call naming repo A from a session\n * open on repo B used to answer with A's git state and B's board, in one reply, with\n * nothing saying so. Measured 2026-09-08 against `wrap`.\n *\n * Taking the repo here and building both the flag and the environment from it makes\n * the two halves unable to disagree. `packages/board`'s tree spawner pins the same\n * pair for the same reason.\n */\nfunction say(repo: string, args: string[], timeout = PATIENCE): string | null {\n try {\n const [command, ...prefix] = harness();\n const { WORK_DIR: _inherited, ...ambient } = process.env;\n const ran = spawnSync(command, [...prefix, ...args, \"--project\", repo], {\n encoding: \"utf-8\",\n timeout,\n env: { ...ambient, CLAUDE_PROJECT_DIR: repo, PYTHONDONTWRITEBYTECODE: \"1\" },\n });\n // A killed child leaves a non-zero status (or none at all), so the timeout reads\n // as every other failure does: no answer, and nothing said about it.\n if (ran.status !== 0) return null;\n return ran.stdout.trim() ? ran.stdout.trimEnd() : null;\n } catch {\n return null;\n }\n}\n","/**\n * Shared helpers for CC hook scripts.\n *\n * Each hook script (pre-tool-use, session-start, stop, …) is a tiny\n * stdin → append-line → exit binary. They all write to the same per-\n * session JSONL at `<hooksDir>/<sessionId>.jsonl`. This module\n * concentrates the envelope shape and the atomic append so the per-\n * hook scripts stay <30 lines each.\n *\n * Hot-path constraints: NEVER block, NEVER network, NEVER read user\n * input. Pure stdin parse + filesystem append + exit. Worst-case one\n * `mkdir` + one `appendFile` per fire.\n */\n\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { getHooksDir } from \"../config\";\n\n/** Wire-format envelope written one-per-line to the hook JSONL.\n * The watcher in `sessions.ts` parses this exact shape — keep in sync. */\nexport interface HookDropEnvelope<TData = unknown> {\n type: string;\n sessionId: string;\n ts: string;\n /** Idempotency key. Lets the watcher's replay-on-restart skip drops\n * whose effects were already emitted (see `HookCursor.applied`). */\n uniqId: string;\n data: TData;\n}\n\nexport interface AppendDropRequest<TData> {\n type: string;\n sessionId: string;\n uniqId: string;\n data: TData;\n}\n\n/** Append one envelope line to the per-session hook JSONL. Atomic between\n * hook processes via POSIX `O_APPEND` (Node's `fs.appendFile`) — concurrent\n * appends from different CC sessions / hook types can't interleave bytes\n * within a line as long as the line stays < `PIPE_BUF` (4KB). Drops are\n * <1KB in practice, so this is safe without locks.\n *\n * Stderr logs are written so `tail -f ~/.jarvis/hooks/hook.log` (when\n * CC pipes hook stderr there, which it does by default) shows what fired.\n * Stderr never breaks CC — it only watches stdout for decision JSON. */\nexport async function appendDrop<TData>(req: AppendDropRequest<TData>): Promise<void> {\n if (!req.sessionId || !req.type || !req.uniqId) {\n process.stderr.write(\n `[hook] skip ${req.type ?? \"?\"} missing field ` +\n `sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}\\n`,\n );\n return;\n }\n const dir = getHooksDir();\n await mkdir(dir, { recursive: true }).catch((err) => {\n process.stderr.write(`[hook] mkdir failed ${dir}: ${err}\\n`);\n });\n const envelope: HookDropEnvelope<TData> = {\n type: req.type,\n sessionId: req.sessionId,\n ts: new Date().toISOString(),\n uniqId: req.uniqId,\n data: req.data,\n };\n const file = join(dir, `${req.sessionId}.jsonl`);\n try {\n await appendFile(file, JSON.stringify(envelope) + \"\\n\");\n process.stderr.write(\n `[hook] ${req.type} appended sessionId=${req.sessionId} uniqId=${req.uniqId}\\n`,\n );\n } catch (err) {\n process.stderr.write(`[hook] append failed ${file}: ${err}\\n`);\n }\n}\n\n/** Read CC's hook payload from stdin. Returns `null` on parse failure\n * (never throws — the hook must never block CC). */\nexport async function readHookStdin<T = Record<string, unknown>>(): Promise<T | null> {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n const raw = Buffer.concat(chunks).toString(\"utf-8\");\n if (!raw) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Hard cap any hook so an exotic stdin stall can't pile up forever\n * before CC's terminal prompt fires. We exit 0 with no output → CC\n * proceeds with its normal permission flow. */\nexport function installHookTimeout(ms = 750): void {\n const timer = setTimeout(() => process.exit(0), ms);\n timer.unref();\n}\n","/**\n * Agent Config\n *\n * Manages ~/.jarvis/config.json — saved by `jarvis connect <token>`,\n * read on `jarvis start`.\n */\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface AgentConfig {\n /** The jarvis this machine joined — the address a person opens in a browser,\n * and what pairing talks to. Ours by default; `jarvis init --url <url>` joins\n * someone else's. */\n url?: string;\n /** WS hub URL this machine dials. Written once, by pairing, from the hub the\n * joined instance named — and never from a flag, an environment variable or\n * any later command. Re-pointing a machine means unpairing it first. */\n hubUrl?: string;\n /** JWT auth token (from connect token) */\n token?: string;\n /** Long-lived refresh token for obtaining new access tokens */\n refreshToken?: string;\n /** User ID */\n userId: string;\n /** Environment ID */\n envId?: string;\n /** Workspace root path for repo operations */\n workspacePath?: string;\n /** Anthropic API key (for local-only use without cloud) */\n anthropicApiKey?: string;\n /** OpenAI key the map's semantic index embeds with. Kept here rather than in the environment\n * on purpose: the map's extractor inherits this process's environment and picks a naming\n * model out of whatever key it finds there, so a key that never enters it cannot start work\n * nobody asked for. Absent → the map builds with no semantic index and search stays lexical. */\n openaiApiKey?: string;\n /** Use Anthropic subscription instead of API key */\n useSubscription?: boolean;\n /** When the config was last updated */\n connectedAt?: string;\n}\n\nexport interface ConnectToken {\n hubUrl: string;\n jwt: string;\n refreshToken: string;\n userId: string;\n envId: string;\n}\n\n// =============================================================================\n// Environment\n// =============================================================================\n\n/** The jarvis a machine pairs with when nothing else is named. Only ever read\n * by pairing: `jarvis init --url <url>` joins someone else's instead, and the\n * hub URL is whatever that instance hands back. */\nexport const DEFAULT_URL = \"https://jarvis.appchy.com\";\n\n// =============================================================================\n// Config directory\n// =============================================================================\n\n/** The config dir is the machine's identity: what it paired as, and therefore\n * which hub it dials. One daemon per dir, so a second identity on the same\n * machine — a storage rig, a localhost instance — is a second dir, named by\n * `JARVIS_CONFIG_DIR`. A development build is not a second identity and does carry\n * its own name: it reads this same dir, so it is the same machine.\n *\n * Called fresh on every read so a test that sets `JARVIS_CONFIG_DIR` AFTER\n * module load still sees the override — caching at module init would lock it\n * onto the host's real `~/.jarvis/` and leak production state into the\n * in-memory infra. */\nfunction configDir(): string {\n if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;\n return path.join(os.homedir(), \".jarvis\");\n}\n\nfunction configFile(): string {\n return path.join(configDir(), \"config.json\");\n}\n\n/** Absolute path to the per-daemon config directory — `~/.jarvis/` unless\n * `JARVIS_CONFIG_DIR` names another. */\nexport function getConfigDir(): string {\n return configDir();\n}\n\n/** Per-daemon directory for PreToolUse hook drop files. The CC hook script\n * writes `<sessionId>/<toolUseId>.json` here; the sessions watcher consumes\n * them. Each daemon owns the subtree under its own config dir, so a hook\n * installed for one never feeds another. */\nexport function getHooksDir(): string {\n return path.join(configDir(), \"hooks\");\n}\n\n// =============================================================================\n// Operations\n// =============================================================================\n\n/** Read the machine's config, accepting the names these two fields used to\n * carry. `appUrl`/`apiUrl` were renamed on 2026-08-15 — `apiUrl` never held an\n * API URL, it held the hub's. A config written before that still loads, and\n * `saveConfig` only ever writes the current names, so a machine heals itself on\n * its next write with nothing to run. Delete this tolerance once no config\n * predating the rename is in use. */\nexport function loadConfig(): AgentConfig | null {\n try {\n const stored = JSON.parse(fs.readFileSync(configFile(), \"utf-8\")) as AgentConfig &\n Partial<{ appUrl: string; apiUrl: string }>;\n const { appUrl, apiUrl, ...config } = stored;\n const url = config.url ?? appUrl;\n const hubUrl = config.hubUrl ?? apiUrl;\n return {\n ...config,\n ...(url !== undefined ? { url } : {}),\n ...(hubUrl !== undefined ? { hubUrl } : {}),\n };\n } catch {\n return null;\n }\n}\n\n/** Persist the machine's config.\n *\n * Refuses to move an already-paired machine to a different hub. A machine that\n * joins the wrong control plane hands it credentials, so re-pointing one is a\n * deliberate act — `jarvis unpair` (which clears the config) and then pair\n * again — rather than something a stale token or a mistyped command can do on\n * its way past. Re-pairing to the same hub is unaffected. */\nexport function saveConfig(config: AgentConfig): void {\n const paired = loadConfig();\n if (paired?.hubUrl && config.hubUrl && config.hubUrl !== paired.hubUrl) {\n throw new Error(\n `This machine is paired with ${paired.hubUrl} and cannot be moved to ${config.hubUrl}.\\n` +\n ` Run 'jarvis unpair' first if you mean to join a different jarvis.`,\n );\n }\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(configFile(), JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nexport function clearConfig(): void {\n try {\n fs.unlinkSync(configFile());\n } catch {}\n}\n\nexport function parseConnectToken(token: string): ConnectToken {\n try {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n const parsed = JSON.parse(decoded);\n if (!parsed.hubUrl || !parsed.jwt || !parsed.userId) {\n throw new Error(\"Invalid token: missing required fields (hubUrl, jwt, userId)\");\n }\n return parsed as ConnectToken;\n } catch (err) {\n if (err instanceof SyntaxError) {\n throw new Error(\"Invalid token: not valid base64-encoded JSON\");\n }\n throw err;\n }\n}\n\nexport function getConfigPath(): string {\n return configFile();\n}\n"],"mappings":"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","names":["existsSync","join","join","join","existsSync","join"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/drop.ts","../../src/config.ts","../../src/hooks/user-prompt-submit.ts"],"sourcesContent":["/**\n * Shared helpers for CC hook scripts.\n *\n * Each hook script (pre-tool-use, session-start, stop, …) is a tiny\n * stdin → append-line → exit binary. They all write to the same per-\n * session JSONL at `<hooksDir>/<sessionId>.jsonl`. This module\n * concentrates the envelope shape and the atomic append so the per-\n * hook scripts stay <30 lines each.\n *\n * Hot-path constraints: NEVER block, NEVER network, NEVER read user\n * input. Pure stdin parse + filesystem append + exit. Worst-case one\n * `mkdir` + one `appendFile` per fire.\n */\n\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { getHooksDir } from \"../config\";\n\n/** Wire-format envelope written one-per-line to the hook JSONL.\n * The watcher in `sessions.ts` parses this exact shape — keep in sync. */\nexport interface HookDropEnvelope<TData = unknown> {\n type: string;\n sessionId: string;\n ts: string;\n /** Idempotency key. Lets the watcher's replay-on-restart skip drops\n * whose effects were already emitted (see `HookCursor.applied`). */\n uniqId: string;\n data: TData;\n}\n\nexport interface AppendDropRequest<TData> {\n type: string;\n sessionId: string;\n uniqId: string;\n data: TData;\n}\n\n/** Append one envelope line to the per-session hook JSONL. Atomic between\n * hook processes via POSIX `O_APPEND` (Node's `fs.appendFile`) — concurrent\n * appends from different CC sessions / hook types can't interleave bytes\n * within a line as long as the line stays < `PIPE_BUF` (4KB). Drops are\n * <1KB in practice, so this is safe without locks.\n *\n * Stderr logs are written so `tail -f ~/.jarvis/hooks/hook.log` (when\n * CC pipes hook stderr there, which it does by default) shows what fired.\n * Stderr never breaks CC — it only watches stdout for decision JSON. */\nexport async function appendDrop<TData>(req: AppendDropRequest<TData>): Promise<void> {\n if (!req.sessionId || !req.type || !req.uniqId) {\n process.stderr.write(\n `[hook] skip ${req.type ?? \"?\"} missing field ` +\n `sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}\\n`,\n );\n return;\n }\n const dir = getHooksDir();\n await mkdir(dir, { recursive: true }).catch((err) => {\n process.stderr.write(`[hook] mkdir failed ${dir}: ${err}\\n`);\n });\n const envelope: HookDropEnvelope<TData> = {\n type: req.type,\n sessionId: req.sessionId,\n ts: new Date().toISOString(),\n uniqId: req.uniqId,\n data: req.data,\n };\n const file = join(dir, `${req.sessionId}.jsonl`);\n try {\n await appendFile(file, JSON.stringify(envelope) + \"\\n\");\n process.stderr.write(\n `[hook] ${req.type} appended sessionId=${req.sessionId} uniqId=${req.uniqId}\\n`,\n );\n } catch (err) {\n process.stderr.write(`[hook] append failed ${file}: ${err}\\n`);\n }\n}\n\n/** Read CC's hook payload from stdin. Returns `null` on parse failure\n * (never throws — the hook must never block CC). */\nexport async function readHookStdin<T = Record<string, unknown>>(): Promise<T | null> {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n const raw = Buffer.concat(chunks).toString(\"utf-8\");\n if (!raw) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Hard cap any hook so an exotic stdin stall can't pile up forever\n * before CC's terminal prompt fires. We exit 0 with no output → CC\n * proceeds with its normal permission flow. */\nexport function installHookTimeout(ms = 750): void {\n const timer = setTimeout(() => process.exit(0), ms);\n timer.unref();\n}\n","/**\n * Agent Config\n *\n * Manages ~/.jarvis/config.json — saved by `jarvis connect <token>`,\n * read on `jarvis start`.\n */\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface AgentConfig {\n /** The jarvis this machine joined — the address a person opens in a browser,\n * and what pairing talks to. Ours by default; `jarvis init --url <url>` joins\n * someone else's. */\n url?: string;\n /** WS hub URL this machine dials. Written once, by pairing, from the hub the\n * joined instance named — and never from a flag, an environment variable or\n * any later command. Re-pointing a machine means unpairing it first. */\n hubUrl?: string;\n /** JWT auth token (from connect token) */\n token?: string;\n /** Long-lived refresh token for obtaining new access tokens */\n refreshToken?: string;\n /** User ID */\n userId: string;\n /** Environment ID */\n envId?: string;\n /** Workspace root path for repo operations */\n workspacePath?: string;\n /** Anthropic API key (for local-only use without cloud) */\n anthropicApiKey?: string;\n /** Use Anthropic subscription instead of API key */\n useSubscription?: boolean;\n /** When the config was last updated */\n connectedAt?: string;\n}\n\nexport interface ConnectToken {\n hubUrl: string;\n jwt: string;\n refreshToken: string;\n userId: string;\n envId: string;\n}\n\n// =============================================================================\n// Environment\n// =============================================================================\n\n/** The jarvis a machine pairs with when nothing else is named. Only ever read\n * by pairing: `jarvis init --url <url>` joins someone else's instead, and the\n * hub URL is whatever that instance hands back. */\nexport const DEFAULT_URL = \"https://jarvis.appchy.com\";\n\n// =============================================================================\n// Config directory\n// =============================================================================\n\n/** The config dir is the machine's identity: what it paired as, and therefore\n * which hub it dials. One daemon per dir, so a second identity on the same\n * machine — a storage rig, a localhost instance — is a second dir, named by\n * `JARVIS_CONFIG_DIR`. A development build is not a second identity and does carry\n * its own name: it reads this same dir, so it is the same machine.\n *\n * Called fresh on every read so a test that sets `JARVIS_CONFIG_DIR` AFTER\n * module load still sees the override — caching at module init would lock it\n * onto the host's real `~/.jarvis/` and leak production state into the\n * in-memory infra. */\nfunction configDir(): string {\n if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;\n return path.join(os.homedir(), \".jarvis\");\n}\n\nfunction configFile(): string {\n return path.join(configDir(), \"config.json\");\n}\n\n/** Absolute path to the per-daemon config directory — `~/.jarvis/` unless\n * `JARVIS_CONFIG_DIR` names another. */\nexport function getConfigDir(): string {\n return configDir();\n}\n\n/** Per-daemon directory for PreToolUse hook drop files. The CC hook script\n * writes `<sessionId>/<toolUseId>.json` here; the sessions watcher consumes\n * them. Each daemon owns the subtree under its own config dir, so a hook\n * installed for one never feeds another. */\nexport function getHooksDir(): string {\n return path.join(configDir(), \"hooks\");\n}\n\n// =============================================================================\n// Operations\n// =============================================================================\n\n/** Read the machine's config, accepting the names these two fields used to\n * carry. `appUrl`/`apiUrl` were renamed on 2026-08-15 — `apiUrl` never held an\n * API URL, it held the hub's. A config written before that still loads, and\n * `saveConfig` only ever writes the current names, so a machine heals itself on\n * its next write with nothing to run. Delete this tolerance once no config\n * predating the rename is in use. */\nexport function loadConfig(): AgentConfig | null {\n try {\n const stored = JSON.parse(fs.readFileSync(configFile(), \"utf-8\")) as AgentConfig &\n Partial<{ appUrl: string; apiUrl: string }>;\n const { appUrl, apiUrl, ...config } = stored;\n const url = config.url ?? appUrl;\n const hubUrl = config.hubUrl ?? apiUrl;\n return {\n ...config,\n ...(url !== undefined ? { url } : {}),\n ...(hubUrl !== undefined ? { hubUrl } : {}),\n };\n } catch {\n return null;\n }\n}\n\n/** Persist the machine's config.\n *\n * Refuses to move an already-paired machine to a different hub. A machine that\n * joins the wrong control plane hands it credentials, so re-pointing one is a\n * deliberate act — `jarvis unpair` (which clears the config) and then pair\n * again — rather than something a stale token or a mistyped command can do on\n * its way past. Re-pairing to the same hub is unaffected. */\nexport function saveConfig(config: AgentConfig): void {\n const paired = loadConfig();\n if (paired?.hubUrl && config.hubUrl && config.hubUrl !== paired.hubUrl) {\n throw new Error(\n `This machine is paired with ${paired.hubUrl} and cannot be moved to ${config.hubUrl}.\\n` +\n ` Run 'jarvis unpair' first if you mean to join a different jarvis.`,\n );\n }\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(configFile(), JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nexport function clearConfig(): void {\n try {\n fs.unlinkSync(configFile());\n } catch {}\n}\n\nexport function parseConnectToken(token: string): ConnectToken {\n try {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n const parsed = JSON.parse(decoded);\n if (!parsed.hubUrl || !parsed.jwt || !parsed.userId) {\n throw new Error(\"Invalid token: missing required fields (hubUrl, jwt, userId)\");\n }\n return parsed as ConnectToken;\n } catch (err) {\n if (err instanceof SyntaxError) {\n throw new Error(\"Invalid token: not valid base64-encoded JSON\");\n }\n throw err;\n }\n}\n\nexport function getConfigPath(): string {\n return configFile();\n}\n","/**\n * UserPromptSubmit hook — drops an envelope each time a `claude` user submits\n * a prompt. Jarvis starts no sessions, so every drop observed here is a\n * person's turn.\n *\n * Used to flip `SessionCacheEntry.controller` to `\"claudeCode\"` on the\n * watcher side, surfacing the controller change in the web UI's pill.\n */\n\nimport { appendDrop, installHookTimeout, readHookStdin } from \"./drop\";\n\ninterface CcUserPromptSubmitInput {\n session_id?: string;\n transcript_path?: string;\n cwd?: string;\n prompt?: string;\n}\n\ninterface UserPromptSubmitDropData {\n transcriptPath?: string;\n cwd?: string;\n}\n\ninstallHookTimeout();\n\nasync function main(): Promise<void> {\n const p = await readHookStdin<CcUserPromptSubmitInput>();\n if (!p) return;\n const sessionId = p.session_id;\n if (!sessionId) return;\n\n const ts = new Date().toISOString();\n const data: UserPromptSubmitDropData = {\n ...(p.transcript_path ? { transcriptPath: p.transcript_path } : {}),\n ...(p.cwd ? { cwd: p.cwd } : {}),\n };\n\n await appendDrop<UserPromptSubmitDropData>({\n type: \"user-prompt-submit\",\n sessionId,\n uniqId: `user-prompt-submit-${ts}`,\n data,\n });\n}\n\nvoid main().finally(() => process.exit(0));\n"],"mappings":";;;AAcA,SAAS,YAAY,aAAa;AAClC,SAAS,YAAY;;;ACPrB,OAAO,UAAU;AACjB,OAAO,QAAQ;AAgEf,SAAS,YAAoB;AAC3B,MAAI,QAAQ,IAAI,kBAAmB,QAAO,QAAQ,IAAI;AACtD,SAAO,KAAK,KAAK,GAAG,QAAQ,GAAG,SAAS;AAC1C;AAgBO,SAAS,cAAsB;AACpC,SAAO,KAAK,KAAK,UAAU,GAAG,OAAO;AACvC;;;AD/CA,eAAsB,WAAkB,KAA8C;AACpF,MAAI,CAAC,IAAI,aAAa,CAAC,IAAI,QAAQ,CAAC,IAAI,QAAQ;AAC9C,YAAQ,OAAO;AAAA,MACb,eAAe,IAAI,QAAQ,GAAG,4BACf,CAAC,CAAC,IAAI,SAAS,WAAW,CAAC,CAAC,IAAI,MAAM;AAAA;AAAA,IACvD;AACA;AAAA,EACF;AACA,QAAM,MAAM,YAAY;AACxB,QAAM,MAAM,KAAK,EAAE,WAAW,KAAK,CAAC,EAAE,MAAM,CAAC,QAAQ;AACnD,YAAQ,OAAO,MAAM,uBAAuB,GAAG,KAAK,GAAG;AAAA,CAAI;AAAA,EAC7D,CAAC;AACD,QAAM,WAAoC;AAAA,IACxC,MAAM,IAAI;AAAA,IACV,WAAW,IAAI;AAAA,IACf,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,IAC3B,QAAQ,IAAI;AAAA,IACZ,MAAM,IAAI;AAAA,EACZ;AACA,QAAM,OAAO,KAAK,KAAK,GAAG,IAAI,SAAS,QAAQ;AAC/C,MAAI;AACF,UAAM,WAAW,MAAM,KAAK,UAAU,QAAQ,IAAI,IAAI;AACtD,YAAQ,OAAO;AAAA,MACb,UAAU,IAAI,IAAI,uBAAuB,IAAI,SAAS,WAAW,IAAI,MAAM;AAAA;AAAA,IAC7E;AAAA,EACF,SAAS,KAAK;AACZ,YAAQ,OAAO,MAAM,wBAAwB,IAAI,KAAK,GAAG;AAAA,CAAI;AAAA,EAC/D;AACF;AAIA,eAAsB,gBAAgE;AACpF,MAAI;AACF,UAAM,SAAmB,CAAC;AAC1B,qBAAiB,SAAS,QAAQ,OAAO;AACvC,aAAO,KAAK,OAAO,UAAU,WAAW,OAAO,KAAK,KAAK,IAAI,KAAK;AAAA,IACpE;AACA,UAAM,MAAM,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO;AAClD,QAAI,CAAC,IAAK,QAAO;AACjB,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAKO,SAAS,mBAAmB,KAAK,KAAW;AACjD,QAAM,QAAQ,WAAW,MAAM,QAAQ,KAAK,CAAC,GAAG,EAAE;AAClD,QAAM,MAAM;AACd;;;AE5EA,mBAAmB;AAEnB,eAAe,OAAsB;AACnC,QAAM,IAAI,MAAM,cAAuC;AACvD,MAAI,CAAC,EAAG;AACR,QAAM,YAAY,EAAE;AACpB,MAAI,CAAC,UAAW;AAEhB,QAAM,MAAK,oBAAI,KAAK,GAAE,YAAY;AAClC,QAAM,OAAiC;AAAA,IACrC,GAAI,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,gBAAgB,IAAI,CAAC;AAAA,IACjE,GAAI,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,IAAI,CAAC;AAAA,EAChC;AAEA,QAAM,WAAqC;AAAA,IACzC,MAAM;AAAA,IACN;AAAA,IACA,QAAQ,sBAAsB,EAAE;AAAA,IAChC;AAAA,EACF,CAAC;AACH;AAEA,KAAK,KAAK,EAAE,QAAQ,MAAM,QAAQ,KAAK,CAAC,CAAC;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/drop.ts","../../src/config.ts","../../src/hooks/user-prompt-submit.ts"],"sourcesContent":["/**\n * Shared helpers for CC hook scripts.\n *\n * Each hook script (pre-tool-use, session-start, stop, …) is a tiny\n * stdin → append-line → exit binary. They all write to the same per-\n * session JSONL at `<hooksDir>/<sessionId>.jsonl`. This module\n * concentrates the envelope shape and the atomic append so the per-\n * hook scripts stay <30 lines each.\n *\n * Hot-path constraints: NEVER block, NEVER network, NEVER read user\n * input. Pure stdin parse + filesystem append + exit. Worst-case one\n * `mkdir` + one `appendFile` per fire.\n */\n\nimport { appendFile, mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { getHooksDir } from \"../config\";\n\n/** Wire-format envelope written one-per-line to the hook JSONL.\n * The watcher in `sessions.ts` parses this exact shape — keep in sync. */\nexport interface HookDropEnvelope<TData = unknown> {\n type: string;\n sessionId: string;\n ts: string;\n /** Idempotency key. Lets the watcher's replay-on-restart skip drops\n * whose effects were already emitted (see `HookCursor.applied`). */\n uniqId: string;\n data: TData;\n}\n\nexport interface AppendDropRequest<TData> {\n type: string;\n sessionId: string;\n uniqId: string;\n data: TData;\n}\n\n/** Append one envelope line to the per-session hook JSONL. Atomic between\n * hook processes via POSIX `O_APPEND` (Node's `fs.appendFile`) — concurrent\n * appends from different CC sessions / hook types can't interleave bytes\n * within a line as long as the line stays < `PIPE_BUF` (4KB). Drops are\n * <1KB in practice, so this is safe without locks.\n *\n * Stderr logs are written so `tail -f ~/.jarvis/hooks/hook.log` (when\n * CC pipes hook stderr there, which it does by default) shows what fired.\n * Stderr never breaks CC — it only watches stdout for decision JSON. */\nexport async function appendDrop<TData>(req: AppendDropRequest<TData>): Promise<void> {\n if (!req.sessionId || !req.type || !req.uniqId) {\n process.stderr.write(\n `[hook] skip ${req.type ?? \"?\"} missing field ` +\n `sessionId=${!!req.sessionId} uniqId=${!!req.uniqId}\\n`,\n );\n return;\n }\n const dir = getHooksDir();\n await mkdir(dir, { recursive: true }).catch((err) => {\n process.stderr.write(`[hook] mkdir failed ${dir}: ${err}\\n`);\n });\n const envelope: HookDropEnvelope<TData> = {\n type: req.type,\n sessionId: req.sessionId,\n ts: new Date().toISOString(),\n uniqId: req.uniqId,\n data: req.data,\n };\n const file = join(dir, `${req.sessionId}.jsonl`);\n try {\n await appendFile(file, JSON.stringify(envelope) + \"\\n\");\n process.stderr.write(\n `[hook] ${req.type} appended sessionId=${req.sessionId} uniqId=${req.uniqId}\\n`,\n );\n } catch (err) {\n process.stderr.write(`[hook] append failed ${file}: ${err}\\n`);\n }\n}\n\n/** Read CC's hook payload from stdin. Returns `null` on parse failure\n * (never throws — the hook must never block CC). */\nexport async function readHookStdin<T = Record<string, unknown>>(): Promise<T | null> {\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n }\n const raw = Buffer.concat(chunks).toString(\"utf-8\");\n if (!raw) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Hard cap any hook so an exotic stdin stall can't pile up forever\n * before CC's terminal prompt fires. We exit 0 with no output → CC\n * proceeds with its normal permission flow. */\nexport function installHookTimeout(ms = 750): void {\n const timer = setTimeout(() => process.exit(0), ms);\n timer.unref();\n}\n","/**\n * Agent Config\n *\n * Manages ~/.jarvis/config.json — saved by `jarvis connect <token>`,\n * read on `jarvis start`.\n */\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport os from \"os\";\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface AgentConfig {\n /** The jarvis this machine joined — the address a person opens in a browser,\n * and what pairing talks to. Ours by default; `jarvis init --url <url>` joins\n * someone else's. */\n url?: string;\n /** WS hub URL this machine dials. Written once, by pairing, from the hub the\n * joined instance named — and never from a flag, an environment variable or\n * any later command. Re-pointing a machine means unpairing it first. */\n hubUrl?: string;\n /** JWT auth token (from connect token) */\n token?: string;\n /** Long-lived refresh token for obtaining new access tokens */\n refreshToken?: string;\n /** User ID */\n userId: string;\n /** Environment ID */\n envId?: string;\n /** Workspace root path for repo operations */\n workspacePath?: string;\n /** Anthropic API key (for local-only use without cloud) */\n anthropicApiKey?: string;\n /** OpenAI key the map's semantic index embeds with. Kept here rather than in the environment\n * on purpose: the map's extractor inherits this process's environment and picks a naming\n * model out of whatever key it finds there, so a key that never enters it cannot start work\n * nobody asked for. Absent → the map builds with no semantic index and search stays lexical. */\n openaiApiKey?: string;\n /** Use Anthropic subscription instead of API key */\n useSubscription?: boolean;\n /** When the config was last updated */\n connectedAt?: string;\n}\n\nexport interface ConnectToken {\n hubUrl: string;\n jwt: string;\n refreshToken: string;\n userId: string;\n envId: string;\n}\n\n// =============================================================================\n// Environment\n// =============================================================================\n\n/** The jarvis a machine pairs with when nothing else is named. Only ever read\n * by pairing: `jarvis init --url <url>` joins someone else's instead, and the\n * hub URL is whatever that instance hands back. */\nexport const DEFAULT_URL = \"https://jarvis.appchy.com\";\n\n// =============================================================================\n// Config directory\n// =============================================================================\n\n/** The config dir is the machine's identity: what it paired as, and therefore\n * which hub it dials. One daemon per dir, so a second identity on the same\n * machine — a storage rig, a localhost instance — is a second dir, named by\n * `JARVIS_CONFIG_DIR`. A development build is not a second identity and does carry\n * its own name: it reads this same dir, so it is the same machine.\n *\n * Called fresh on every read so a test that sets `JARVIS_CONFIG_DIR` AFTER\n * module load still sees the override — caching at module init would lock it\n * onto the host's real `~/.jarvis/` and leak production state into the\n * in-memory infra. */\nfunction configDir(): string {\n if (process.env.JARVIS_CONFIG_DIR) return process.env.JARVIS_CONFIG_DIR;\n return path.join(os.homedir(), \".jarvis\");\n}\n\nfunction configFile(): string {\n return path.join(configDir(), \"config.json\");\n}\n\n/** Absolute path to the per-daemon config directory — `~/.jarvis/` unless\n * `JARVIS_CONFIG_DIR` names another. */\nexport function getConfigDir(): string {\n return configDir();\n}\n\n/** Per-daemon directory for PreToolUse hook drop files. The CC hook script\n * writes `<sessionId>/<toolUseId>.json` here; the sessions watcher consumes\n * them. Each daemon owns the subtree under its own config dir, so a hook\n * installed for one never feeds another. */\nexport function getHooksDir(): string {\n return path.join(configDir(), \"hooks\");\n}\n\n// =============================================================================\n// Operations\n// =============================================================================\n\n/** Read the machine's config, accepting the names these two fields used to\n * carry. `appUrl`/`apiUrl` were renamed on 2026-08-15 — `apiUrl` never held an\n * API URL, it held the hub's. A config written before that still loads, and\n * `saveConfig` only ever writes the current names, so a machine heals itself on\n * its next write with nothing to run. Delete this tolerance once no config\n * predating the rename is in use. */\nexport function loadConfig(): AgentConfig | null {\n try {\n const stored = JSON.parse(fs.readFileSync(configFile(), \"utf-8\")) as AgentConfig &\n Partial<{ appUrl: string; apiUrl: string }>;\n const { appUrl, apiUrl, ...config } = stored;\n const url = config.url ?? appUrl;\n const hubUrl = config.hubUrl ?? apiUrl;\n return {\n ...config,\n ...(url !== undefined ? { url } : {}),\n ...(hubUrl !== undefined ? { hubUrl } : {}),\n };\n } catch {\n return null;\n }\n}\n\n/** Persist the machine's config.\n *\n * Refuses to move an already-paired machine to a different hub. A machine that\n * joins the wrong control plane hands it credentials, so re-pointing one is a\n * deliberate act — `jarvis unpair` (which clears the config) and then pair\n * again — rather than something a stale token or a mistyped command can do on\n * its way past. Re-pairing to the same hub is unaffected. */\nexport function saveConfig(config: AgentConfig): void {\n const paired = loadConfig();\n if (paired?.hubUrl && config.hubUrl && config.hubUrl !== paired.hubUrl) {\n throw new Error(\n `This machine is paired with ${paired.hubUrl} and cannot be moved to ${config.hubUrl}.\\n` +\n ` Run 'jarvis unpair' first if you mean to join a different jarvis.`,\n );\n }\n fs.mkdirSync(configDir(), { recursive: true });\n fs.writeFileSync(configFile(), JSON.stringify(config, null, 2) + \"\\n\");\n}\n\nexport function clearConfig(): void {\n try {\n fs.unlinkSync(configFile());\n } catch {}\n}\n\nexport function parseConnectToken(token: string): ConnectToken {\n try {\n const decoded = Buffer.from(token, \"base64\").toString(\"utf-8\");\n const parsed = JSON.parse(decoded);\n if (!parsed.hubUrl || !parsed.jwt || !parsed.userId) {\n throw new Error(\"Invalid token: missing required fields (hubUrl, jwt, userId)\");\n }\n return parsed as ConnectToken;\n } catch (err) {\n if (err instanceof SyntaxError) {\n throw new Error(\"Invalid token: not valid base64-encoded JSON\");\n }\n throw err;\n }\n}\n\nexport function getConfigPath(): string {\n return configFile();\n}\n","/**\n * UserPromptSubmit hook — drops an envelope each time a `claude` user submits\n * a prompt. Jarvis starts no sessions, so every drop observed here is a\n * person's turn.\n *\n * Used to flip `SessionCacheEntry.controller` to `\"claudeCode\"` on the\n * watcher side, surfacing the controller change in the web UI's pill.\n */\n\nimport { appendDrop, installHookTimeout, readHookStdin } from \"./drop\";\n\ninterface CcUserPromptSubmitInput {\n session_id?: string;\n transcript_path?: string;\n cwd?: string;\n prompt?: string;\n}\n\ninterface UserPromptSubmitDropData {\n transcriptPath?: string;\n cwd?: string;\n}\n\ninstallHookTimeout();\n\nasync function main(): Promise<void> {\n const p = await readHookStdin<CcUserPromptSubmitInput>();\n if (!p) return;\n const sessionId = p.session_id;\n if (!sessionId) return;\n\n const ts = new Date().toISOString();\n const data: UserPromptSubmitDropData = {\n ...(p.transcript_path ? { transcriptPath: p.transcript_path } : {}),\n ...(p.cwd ? { cwd: p.cwd } : {}),\n };\n\n await appendDrop<UserPromptSubmitDropData>({\n type: \"user-prompt-submit\",\n sessionId,\n uniqId: `user-prompt-submit-${ts}`,\n data,\n });\n}\n\nvoid main().finally(() => process.exit(0));\n"],"mappings":";;;AAcA,SAAS,YAAY,aAAa;AAClC,SAAS,YAAY;;;ACPrB,OAAO,UAAU;AACjB,OAAO,QAAQ;AAqEf,SAAS,YAAoB;AAC3B,MAAI,QAAQ,IAAI,kBAAmB,QAAO,QAAQ,IAAI;AACtD,SAAO,KAAK,KAAK,GAAG,QAAQ,GAAG,SAAS;AAC1C;AAgBO,SAAS,cAAsB;AACpC,SAAO,KAAK,KAAK,UAAU,GAAG,OAAO;AACvC;;;ADpDA,eAAsB,WAAkB,KAA8C;AACpF,MAAI,CAAC,IAAI,aAAa,CAAC,IAAI,QAAQ,CAAC,IAAI,QAAQ;AAC9C,YAAQ,OAAO;AAAA,MACb,eAAe,IAAI,QAAQ,GAAG,4BACf,CAAC,CAAC,IAAI,SAAS,WAAW,CAAC,CAAC,IAAI,MAAM;AAAA;AAAA,IACvD;AACA;AAAA,EACF;AACA,QAAM,MAAM,YAAY;AACxB,QAAM,MAAM,KAAK,EAAE,WAAW,KAAK,CAAC,EAAE,MAAM,CAAC,QAAQ;AACnD,YAAQ,OAAO,MAAM,uBAAuB,GAAG,KAAK,GAAG;AAAA,CAAI;AAAA,EAC7D,CAAC;AACD,QAAM,WAAoC;AAAA,IACxC,MAAM,IAAI;AAAA,IACV,WAAW,IAAI;AAAA,IACf,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,IAC3B,QAAQ,IAAI;AAAA,IACZ,MAAM,IAAI;AAAA,EACZ;AACA,QAAM,OAAO,KAAK,KAAK,GAAG,IAAI,SAAS,QAAQ;AAC/C,MAAI;AACF,UAAM,WAAW,MAAM,KAAK,UAAU,QAAQ,IAAI,IAAI;AACtD,YAAQ,OAAO;AAAA,MACb,UAAU,IAAI,IAAI,uBAAuB,IAAI,SAAS,WAAW,IAAI,MAAM;AAAA;AAAA,IAC7E;AAAA,EACF,SAAS,KAAK;AACZ,YAAQ,OAAO,MAAM,wBAAwB,IAAI,KAAK,GAAG;AAAA,CAAI;AAAA,EAC/D;AACF;AAIA,eAAsB,gBAAgE;AACpF,MAAI;AACF,UAAM,SAAmB,CAAC;AAC1B,qBAAiB,SAAS,QAAQ,OAAO;AACvC,aAAO,KAAK,OAAO,UAAU,WAAW,OAAO,KAAK,KAAK,IAAI,KAAK;AAAA,IACpE;AACA,UAAM,MAAM,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO;AAClD,QAAI,CAAC,IAAK,QAAO;AACjB,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAKO,SAAS,mBAAmB,KAAK,KAAW;AACjD,QAAM,QAAQ,WAAW,MAAM,QAAQ,KAAK,CAAC,GAAG,EAAE;AAClD,QAAM,MAAM;AACd;;;AE5EA,mBAAmB;AAEnB,eAAe,OAAsB;AACnC,QAAM,IAAI,MAAM,cAAuC;AACvD,MAAI,CAAC,EAAG;AACR,QAAM,YAAY,EAAE;AACpB,MAAI,CAAC,UAAW;AAEhB,QAAM,MAAK,oBAAI,KAAK,GAAE,YAAY;AAClC,QAAM,OAAiC;AAAA,IACrC,GAAI,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,gBAAgB,IAAI,CAAC;AAAA,IACjE,GAAI,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,IAAI,CAAC;AAAA,EAChC;AAEA,QAAM,WAAqC;AAAA,IACzC,MAAM;AAAA,IACN;AAAA,IACA,QAAQ,sBAAsB,EAAE;AAAA,IAChC;AAAA,EACF,CAAC;AACH;AAEA,KAAK,KAAK,EAAE,QAAQ,MAAM,QAAQ,KAAK,CAAC,CAAC;","names":[]}
|
package/harness/harness/gate.py
CHANGED
|
@@ -132,8 +132,29 @@ def _elapsed(data) -> int:
|
|
|
132
132
|
return 0
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
#: How much of a failing gate's output is kept, per gate. A gate that fails is
|
|
136
|
+
#: usually failing for something in its last few dozen lines, and the whole of a
|
|
137
|
+
#: test run or a bundle build is megabytes — this is the amount that answers "why"
|
|
138
|
+
#: without turning the run file into a log archive or a caller's context into one.
|
|
139
|
+
_LOG_LINES = 40
|
|
140
|
+
_LOG_CHARS = 4000
|
|
141
|
+
|
|
142
|
+
|
|
135
143
|
def run_verify(root, names=None, progress=None) -> tuple:
|
|
136
|
-
"""Run the configured verify commands. Returns (results, ok)
|
|
144
|
+
"""Run the configured verify commands. Returns (results, ok), where a result is
|
|
145
|
+
(name, status, said, log).
|
|
146
|
+
|
|
147
|
+
**A gate that FAILS keeps its output; one that passes keeps none.** For a long
|
|
148
|
+
time only `said` survived — the last line, cut at 160 characters — and every
|
|
149
|
+
other byte the gate produced was captured and dropped on the floor. That is why
|
|
150
|
+
`build` could fail under `jarvis work verify`, pass when run by hand, and stay
|
|
151
|
+
undiagnosed across three sessions: the evidence was destroyed at the moment it
|
|
152
|
+
was produced, so the only way to learn anything was to run the thing again by
|
|
153
|
+
hand and hope it failed the same way. A gate nobody can diagnose is a gate
|
|
154
|
+
somebody eventually waives.
|
|
155
|
+
|
|
156
|
+
Passing gates keep nothing on purpose. Their output is noise, it is the common
|
|
157
|
+
case, and storing it would put megabytes through a file rewritten on every tick.
|
|
137
158
|
|
|
138
159
|
A SKIP is reported separately and never counts as a pass — "there was no lint
|
|
139
160
|
configured" and "lint passed" are different facts, and collapsing them is how a
|
|
@@ -153,30 +174,50 @@ def run_verify(root, names=None, progress=None) -> tuple:
|
|
|
153
174
|
try:
|
|
154
175
|
argv = shlex.split(cmd)
|
|
155
176
|
except ValueError as e:
|
|
156
|
-
results.append((name, "SKIP", f"unparseable command: {e}"))
|
|
177
|
+
results.append((name, "SKIP", f"unparseable command: {e}", ""))
|
|
157
178
|
continue
|
|
158
179
|
if not argv:
|
|
159
|
-
results.append((name, "SKIP", "empty command"))
|
|
180
|
+
results.append((name, "SKIP", "empty command", ""))
|
|
160
181
|
continue
|
|
161
182
|
try:
|
|
162
183
|
proc = subprocess.run(argv, cwd=repo, capture_output=True, text=True,
|
|
163
184
|
timeout=1800)
|
|
164
185
|
except FileNotFoundError:
|
|
165
|
-
results.append((name, "SKIP", f"{argv[0]}: not found"))
|
|
186
|
+
results.append((name, "SKIP", f"{argv[0]}: not found", ""))
|
|
166
187
|
continue
|
|
167
188
|
except subprocess.TimeoutExpired:
|
|
168
|
-
results.append((name, "FAIL", "timed out after 30m"))
|
|
189
|
+
results.append((name, "FAIL", "timed out after 30m", ""))
|
|
169
190
|
continue
|
|
170
191
|
except OSError as e:
|
|
171
|
-
results.append((name, "SKIP", str(e)))
|
|
192
|
+
results.append((name, "SKIP", str(e), ""))
|
|
172
193
|
continue
|
|
173
194
|
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
|
|
174
|
-
|
|
175
|
-
|
|
195
|
+
failed = proc.returncode != 0
|
|
196
|
+
results.append((name, "FAIL" if failed else "PASS",
|
|
197
|
+
tail[-1][:160] if tail else f"exit {proc.returncode}",
|
|
198
|
+
# BOTH streams, in that order: a build writes its diagnosis
|
|
199
|
+
# to stderr and its progress to stdout, and a tool suite does
|
|
200
|
+
# the opposite. Reading one of them is how a failure comes
|
|
201
|
+
# back as "exit 1" with nothing attached.
|
|
202
|
+
_keep(proc.stdout, proc.stderr) if failed else ""))
|
|
176
203
|
ok = bool(results) and all(r[1] == "PASS" for r in results)
|
|
177
204
|
return results, ok
|
|
178
205
|
|
|
179
206
|
|
|
207
|
+
def _keep(stdout: str, stderr: str) -> str:
|
|
208
|
+
"""The tail of what a failing gate said, capped by lines and then by characters.
|
|
209
|
+
|
|
210
|
+
Both caps are needed and neither is enough alone: a Next.js build prints few,
|
|
211
|
+
enormous lines while a test suite prints thousands of short ones, so a line
|
|
212
|
+
budget alone lets one through and a character budget alone cuts the other to a
|
|
213
|
+
fragment of its last line.
|
|
214
|
+
"""
|
|
215
|
+
text = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())
|
|
216
|
+
lines = text.splitlines()[-_LOG_LINES:]
|
|
217
|
+
kept = "\n".join(lines)
|
|
218
|
+
return kept if len(kept) <= _LOG_CHARS else "…" + kept[-_LOG_CHARS:]
|
|
219
|
+
|
|
220
|
+
|
|
180
221
|
def _record(root, name, results, ok, sha) -> bool:
|
|
181
222
|
"""Write the outcome onto the task. False when there is no such task."""
|
|
182
223
|
task = locate(root, name)
|
|
@@ -184,7 +225,7 @@ def _record(root, name, results, ok, sha) -> bool:
|
|
|
184
225
|
return False
|
|
185
226
|
entry = (f"{date.today().isoformat()} {'pass' if ok else 'FAIL'} "
|
|
186
227
|
f"{sha[:12] or 'no-git'} "
|
|
187
|
-
+ " ".join(f"{n}={s}" for n, s, _ in results))
|
|
228
|
+
+ " ".join(f"{n}={s}" for n, s, *_ in results))
|
|
188
229
|
|
|
189
230
|
def mutate(d):
|
|
190
231
|
d["verified"] = as_list(d.get("verified")) + [entry]
|
|
@@ -192,7 +233,7 @@ def _record(root, name, results, ok, sha) -> bool:
|
|
|
192
233
|
|
|
193
234
|
rewrite_file(task.folder / "task.md", mutate)
|
|
194
235
|
events.append(root, "verified", name, ok=ok, sha=sha[:12] or None,
|
|
195
|
-
results={n: s for n, s, _ in results})
|
|
236
|
+
results={n: s for n, s, *_ in results})
|
|
196
237
|
return True
|
|
197
238
|
|
|
198
239
|
|
|
@@ -224,14 +265,15 @@ def _execute(root, name) -> tuple:
|
|
|
224
265
|
|
|
225
266
|
def progress(gate, so_far):
|
|
226
267
|
data["current"] = gate
|
|
227
|
-
data["done"] = [n for n, _
|
|
268
|
+
data["done"] = [n for n, *_ in so_far]
|
|
228
269
|
_write_run(root, data)
|
|
229
270
|
|
|
230
271
|
results, ok = run_verify(root, progress=progress)
|
|
231
272
|
sha = _head(root.parent)
|
|
232
|
-
data.update(current="", done=[n for n, _
|
|
273
|
+
data.update(current="", done=[n for n, *_ in results],
|
|
233
274
|
finished=_now().isoformat(), passed=ok, sha=sha,
|
|
234
|
-
results=[{"name": n, "status": s, "said": d
|
|
275
|
+
results=[{"name": n, "status": s, "said": d, "log": g}
|
|
276
|
+
for n, s, d, g in results])
|
|
235
277
|
_write_run(root, data)
|
|
236
278
|
|
|
237
279
|
if name and not _record(root, name, results, ok, sha):
|
|
@@ -240,11 +282,19 @@ def _execute(root, name) -> tuple:
|
|
|
240
282
|
|
|
241
283
|
|
|
242
284
|
def _print_report(results, sha) -> None:
|
|
243
|
-
for n, status, detail in results:
|
|
285
|
+
for n, status, detail, *_ in results:
|
|
244
286
|
mark = {"PASS": "✓", "FAIL": "✗"}.get(status, "·")
|
|
245
287
|
print(f" {mark} {status:5} {n:14} {detail}")
|
|
246
288
|
print(f"\n {sum(1 for r in results if r[1] == 'PASS')}/{len(results)} passed"
|
|
247
289
|
+ (f" · at {sha[:8]}" if sha else ""))
|
|
290
|
+
# What the failing gates actually said, after the table rather than inside it —
|
|
291
|
+
# the table is a shape somebody scans, and a gate's output is paragraphs. Only
|
|
292
|
+
# failures print: on a green run this adds nothing at all.
|
|
293
|
+
for n, status, _, log in ((r + ("",))[:4] for r in results):
|
|
294
|
+
if status == "FAIL" and log:
|
|
295
|
+
print(f"\n ── {n} ──")
|
|
296
|
+
for line in log.splitlines():
|
|
297
|
+
print(f" {line}")
|
|
248
298
|
|
|
249
299
|
|
|
250
300
|
def _busy(running) -> str:
|
|
@@ -272,8 +322,27 @@ def _describe(data) -> str:
|
|
|
272
322
|
return f"All {len(results)} gates passed{f' at {sha}' if sha else ''}."
|
|
273
323
|
head = (f"{len(failed)} of {len(results)} gates did not pass"
|
|
274
324
|
f"{f' at {sha}' if sha else ''} — fix these, then ask again:")
|
|
275
|
-
return "\n".join([head] + [
|
|
276
|
-
|
|
325
|
+
return "\n".join([head] + [_failure(r) for r in failed])
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
#: How many of a failing gate's kept lines reach a CALLER, as opposed to the run
|
|
329
|
+
#: file. Deliberately smaller: a caller pays for these in context on every failed
|
|
330
|
+
#: verify, and the point here is to say enough that the next step is obvious without
|
|
331
|
+
#: re-running anything. The rest is in the run file for whoever needs the whole of it.
|
|
332
|
+
_SAID_LINES = 10
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _failure(row) -> str:
|
|
336
|
+
"""One failing gate, as much as a caller needs to act without re-running it.
|
|
337
|
+
|
|
338
|
+
A caller told only `build: exit 1` has to leave the surface and run the build
|
|
339
|
+
itself to learn anything — which is the loop this exists to end, and the reason
|
|
340
|
+
three sessions in a row recorded `build` as "not diagnosed here".
|
|
341
|
+
"""
|
|
342
|
+
name, said = row.get("name"), row.get("said") or "no output"
|
|
343
|
+
log = (row.get("log") or "").splitlines()[-_SAID_LINES:]
|
|
344
|
+
body = "".join(f"\n {line}" for line in log)
|
|
345
|
+
return f" {name}: {said}{body}"
|
|
277
346
|
|
|
278
347
|
|
|
279
348
|
def _still_current(repo, sha: str) -> bool:
|