@appchy/jarvis 0.1.47 → 0.1.49
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 +613 -573
- package/dist/bin.js.map +1 -1
- package/dist/hooks/config-change.js.map +1 -1
- package/dist/hooks/pre-tool-use.js.map +1 -1
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop.js.map +1 -1
- package/dist/hooks/user-prompt-submit.js.map +1 -1
- package/harness/harness/lint.py +29 -5
- package/harness/harness/model.py +4 -0
- package/harness/harness/tree.py +7 -0
- package/harness/test_work.py +58 -0
- package/package.json +5 -5
|
@@ -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 /** 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":[]}
|
|
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// 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;AA4Df,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;;;AD3CA,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/lint.py
CHANGED
|
@@ -107,11 +107,22 @@ def _shape_lint(root: Path, s: dict) -> list:
|
|
|
107
107
|
|
|
108
108
|
# 2 — a task spanning too many code regions is a version wearing a task
|
|
109
109
|
# costume: it cannot land as one coherent end-to-end slice.
|
|
110
|
+
# ONE class is exempt and it is DECLARED rather than inferred: a task
|
|
111
|
+
# carrying `one_goal:` has said in writing why its width is right, and
|
|
112
|
+
# the sentence is the exemption. Some architectures have shapes that are
|
|
113
|
+
# genuinely one goal across three regions — measured in `../mixbrix`
|
|
114
|
+
# 2026-09-09, six tasks read *work out a measurement (`dsp`), expose it
|
|
115
|
+
# (`cli`), draw it (`daw`)* and five more *a node behaviour needing the
|
|
116
|
+
# document, the engine and an atom* — and splitting those to satisfy a
|
|
117
|
+
# counter produces three tasks nobody can finish alone. It is checked the
|
|
118
|
+
# way `owner:` is: an EMPTY `one_goal:` exempts nothing, so the field
|
|
119
|
+
# cannot be used to silence this without saying something.
|
|
110
120
|
for t in active:
|
|
111
|
-
if len(t.code) >= TASK_REGION_CAP:
|
|
121
|
+
if len(t.code) >= TASK_REGION_CAP and not t.one_goal:
|
|
112
122
|
warns.append(f"{t.name}: spans {len(t.code)} code regions "
|
|
113
123
|
f"({', '.join(t.code)}) — a task is ONE goal; at "
|
|
114
|
-
f"{TASK_REGION_CAP}+ it is an epic, not a task"
|
|
124
|
+
f"{TASK_REGION_CAP}+ it is an epic, not a task. If the "
|
|
125
|
+
f"width IS one goal, say why in `one_goal:`")
|
|
115
126
|
|
|
116
127
|
# 3 — an epic with no `covers:` moves no stated acceptance criterion, so
|
|
117
128
|
# nobody can tell when it is finished. Released epics have no epic.md
|
|
@@ -125,7 +136,17 @@ def _shape_lint(root: Path, s: dict) -> list:
|
|
|
125
136
|
# an `owner:` that resolves to nothing exempts nothing, so the field
|
|
126
137
|
# cannot be used to silence this by writing anything in it. An epic
|
|
127
138
|
# owned by a FEATURE is not exempt: it has criteria available.
|
|
128
|
-
|
|
139
|
+
#
|
|
140
|
+
# A BACKLOG epic is skipped entirely, and that is a second real class
|
|
141
|
+
# rather than a loophole: it is a captured WANT, written down so it is not
|
|
142
|
+
# lost, and which promises it moves is chosen when somebody PLANS it.
|
|
143
|
+
# Asking first means guessing, and the guess is what the planning session
|
|
144
|
+
# then argues with. Two in `../mixbrix` say so in their own text — *"NOT
|
|
145
|
+
# PLANNED. This is a captured want, not a design."* `planned` cannot
|
|
146
|
+
# express this: it means `epic.md` EXISTS (the release-archive flag), so it
|
|
147
|
+
# is true of every backlog epic and the only way to reach the exemption
|
|
148
|
+
# would be deleting the file holding the want.
|
|
149
|
+
for e in [e for v in s["versions"] for e in v.epics]:
|
|
129
150
|
if not e.planned:
|
|
130
151
|
continue
|
|
131
152
|
if not e.covers:
|
|
@@ -149,8 +170,11 @@ def _shape_lint(root: Path, s: dict) -> list:
|
|
|
149
170
|
# the design happens ONCE for a whole goal; at one or two tasks there is
|
|
150
171
|
# no "whole goal", and the board fills with topics wearing epic costumes.
|
|
151
172
|
# Skip a DONE epic (`<v>/complete/`) — it is history, and an epic that
|
|
152
|
-
# shipped two tasks was never the failure this catches.
|
|
153
|
-
for
|
|
173
|
+
# shipped two tasks was never the failure this catches. Skip a BACKLOG
|
|
174
|
+
# epic for the same reason rule 3 does: a want nobody has planned has no
|
|
175
|
+
# slices yet, and "fold it into an epic that fits" is advice about work
|
|
176
|
+
# being cut, not about a note somebody parked.
|
|
177
|
+
for e in [e for v in s["versions"] for e in v.epics]:
|
|
154
178
|
if not e.planned or e.done_tier:
|
|
155
179
|
continue
|
|
156
180
|
n = len(e.all_tasks())
|
package/harness/harness/model.py
CHANGED
|
@@ -70,6 +70,10 @@ class Task:
|
|
|
70
70
|
self.priority = str(self.fm.get("priority", "P2")).strip()
|
|
71
71
|
self.depends_on = as_list(self.fm.get("depends_on"))
|
|
72
72
|
self.code = as_list(self.fm.get("code"))
|
|
73
|
+
# A task may DECLARE that its width is deliberate, with the reason.
|
|
74
|
+
# Read as a string rather than a flag: the region cap is answered by
|
|
75
|
+
# an argument, not by a `true` somebody typed to silence it.
|
|
76
|
+
self.one_goal = str(self.fm.get("one_goal", "")).strip()
|
|
73
77
|
self.covers = as_list(self.fm.get("covers"))
|
|
74
78
|
# `owner:` is canonical; `product:` is the pre-domain spelling still on
|
|
75
79
|
# disk while the rename lands. One reader, so no consumer has to know.
|
package/harness/harness/tree.py
CHANGED
|
@@ -105,6 +105,13 @@ EPIC_FM_ORDER = ("created", "updated", "covers", "continues")
|
|
|
105
105
|
# that cheap are exactly what a line budget deletes. What actually goes wrong —
|
|
106
106
|
# a brief quietly becoming a competing plan — is caught by the artifact lint,
|
|
107
107
|
# which looks for the FILES, not the size.
|
|
108
|
+
# A task may ANSWER this rather than obey it, since 2026-09-09 (founder call):
|
|
109
|
+
# `one_goal: "<why>"` in its frontmatter says the width is deliberate and states
|
|
110
|
+
# the reason, and the lint then leaves it alone. The cap stayed at 3 rather than
|
|
111
|
+
# moving to 4 on purpose — raising it silences the check everywhere and records
|
|
112
|
+
# nothing, where a declared reason is readable by the next person and still lets
|
|
113
|
+
# the warning bite for a task that has not thought about it. An EMPTY `one_goal:`
|
|
114
|
+
# exempts nothing, the same way a dangling `owner:` exempts no epic.
|
|
108
115
|
TASK_REGION_CAP = 3
|
|
109
116
|
# The other end of the same rule, added 2026-08-02 (founder call), and it names a
|
|
110
117
|
# measured failure too: the backlog had reached 14 epics of which NINE held two
|
package/harness/test_work.py
CHANGED
|
@@ -801,6 +801,64 @@ def test_shape_lint_flags_a_task_spanning_three_code_regions():
|
|
|
801
801
|
assert not any("narrow: spans" in x for x in w)
|
|
802
802
|
|
|
803
803
|
|
|
804
|
+
def test_a_task_that_says_its_width_is_deliberate_is_not_flagged():
|
|
805
|
+
"""Some architectures have shapes that genuinely are one goal across three
|
|
806
|
+
regions. The exemption is a SENTENCE, so the reason is readable rather than a
|
|
807
|
+
flag somebody set."""
|
|
808
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
809
|
+
v = _tree(tmp)
|
|
810
|
+
e = _epic(v, "e")
|
|
811
|
+
_task(e / "queue", "wide", fm="priority: P1\ncode: [lesson, tools, web]\n"
|
|
812
|
+
"one_goal: the measurement, the verb that exposes it and the "
|
|
813
|
+
"drawing are one thing a person asks for\n")
|
|
814
|
+
assert not any("wide: spans" in x for x in _warns(Path(tmp)))
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def test_an_empty_one_goal_exempts_nothing():
|
|
818
|
+
"""Checked the way `owner:` is. Otherwise the field is a way to silence the
|
|
819
|
+
lint by writing nothing at all in it."""
|
|
820
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
821
|
+
v = _tree(tmp)
|
|
822
|
+
e = _epic(v, "e")
|
|
823
|
+
_task(e / "queue", "wide",
|
|
824
|
+
fm="priority: P1\ncode: [lesson, tools, web]\none_goal:\n")
|
|
825
|
+
assert any("wide: spans 3 code regions" in x for x in _warns(Path(tmp)))
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def test_a_backlog_epic_is_not_asked_what_it_covers():
|
|
829
|
+
"""A captured want has no criteria yet — they are chosen when it is PLANNED,
|
|
830
|
+
and asking first only produces a guess the planning session argues with."""
|
|
831
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
832
|
+
root = Path(tmp)
|
|
833
|
+
_tree(tmp)
|
|
834
|
+
_epic(root / "backlog", "you-reach-it-from-your-phone", covers="[]")
|
|
835
|
+
assert not any("you-reach-it-from-your-phone: no covers:"
|
|
836
|
+
in w for w in _warns(root))
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def test_a_backlog_epic_is_not_held_to_the_task_floor():
|
|
840
|
+
"""Same reason: a want nobody has planned has no slices, so "fold it into an
|
|
841
|
+
epic that fits" is advice about work being cut rather than a parked note."""
|
|
842
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
843
|
+
root = Path(tmp)
|
|
844
|
+
_tree(tmp)
|
|
845
|
+
_epic(root / "backlog", "it-sings-what-you-write", covers="[]")
|
|
846
|
+
assert not any("it-sings-what-you-write: 0 task(s)"
|
|
847
|
+
in w for w in _warns(root))
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def test_a_version_epic_is_still_held_to_both():
|
|
851
|
+
"""The narrowing is to the BACKLOG, not to the rules. An epic pulled into a
|
|
852
|
+
cut answers for its criteria and its slices as it always did."""
|
|
853
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
854
|
+
root = Path(tmp)
|
|
855
|
+
v = _tree(tmp)
|
|
856
|
+
_epic(v, "in-a-cut", covers="[]")
|
|
857
|
+
w = _warns(root)
|
|
858
|
+
assert any("in-a-cut: no covers:" in x for x in w), w
|
|
859
|
+
assert any("in-a-cut: 0 task(s)" in x for x in w), w
|
|
860
|
+
|
|
861
|
+
|
|
804
862
|
def test_shape_lint_flags_a_thin_epic_below_the_task_floor():
|
|
805
863
|
# The other end of the region cap: a one- or two-task epic is a topic wearing
|
|
806
864
|
# an epic costume, and its epic.md degenerates into a second copy of the brief.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appchy/jarvis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.49",
|
|
4
4
|
"description": "Jarvis — local AI coding assistant CLI",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -49,15 +49,15 @@
|
|
|
49
49
|
"tsup": "^8.5.1",
|
|
50
50
|
"typescript": "^5.7.0",
|
|
51
51
|
"vitest": "^2.1.0",
|
|
52
|
+
"@jarvis/agents": "1.0.0",
|
|
53
|
+
"@jarvis/anthropic": "1.0.0",
|
|
54
|
+
"@jarvis/board": "0.1.0",
|
|
52
55
|
"@jarvis/data": "0.1.0",
|
|
53
56
|
"@jarvis/logger": "1.0.0",
|
|
54
57
|
"@jarvis/rpc": "1.0.0",
|
|
55
58
|
"@jarvis/types": "1.0.0",
|
|
56
|
-
"@jarvis/agents": "1.0.0",
|
|
57
59
|
"@jarvis/typescript-config": "1.0.0",
|
|
58
|
-
"@jarvis/vitest-config": "1.0.0"
|
|
59
|
-
"@jarvis/board": "0.1.0",
|
|
60
|
-
"@jarvis/anthropic": "1.0.0"
|
|
60
|
+
"@jarvis/vitest-config": "1.0.0"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"dev": "tsx watch src/bin.ts start --foreground",
|