diff --git a/AGENTS.md b/AGENTS.md index 81d5917..3bcfdcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,12 @@ Package manager: **Bun** (`bun.lock`). Verify with `bun run check` (typecheck + ## Permission boundaries -| Mode | Scope | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| READ | Whole repo; method details in `skills/agent-memory/vendor/memory/instructions.md` | -| WRITE | `skills/agent-memory/`, `hooks/`, `install.ts`, `lib/cli/`, `tests/`, `bin/cli.js` (via `bun run build`), root docs (`README.md`, `CHANGELOG.md`, this file), `package.json` / `bun.lock` when asked | -| NEVER | Invent a repo-root `agent-memory/` path for writes; push; create git tags or npm publish unless the user explicitly asks; install hooks into a consumer project from this agent (print commands only) | -| HUMAN_CHECKPOINT | Version bump; release tag; npm publish; any change that rewrites consumer memory Markdown outside this meta-repo | +| Mode | Scope | +| ---------------- | ----- | +| READ | Whole repo; method details in `skills/agent-memory/vendor/memory/instructions.md` | +| WRITE | `skills/agent-memory/`, `hooks/`, `install.ts`, `lib/cli/`, `tests/`, `bin/cli.js` (via `bun run build`), root docs (`README.md`, `CHANGELOG.md`, `SECURITY.md`, this file), `package.json` / `bun.lock` when asked | +| NEVER | Invent a repo-root `agent-memory/` path for writes; push; create git tags or npm publish unless the user explicitly asks; install hooks into a consumer project from this agent (print commands only); add `shell: true` on child processes; forward full `process.env` to hook children; write Markdown from hooks | +| HUMAN_CHECKPOINT | Version bump; release tag; npm publish; any change that rewrites consumer memory Markdown outside this meta-repo | ## Precedence @@ -29,6 +29,7 @@ If blocked (missing permission, ambiguous SemVer, conflict between docs and code - Agent-memory block: `skills/agent-memory/references/agent-block.md` - Installed memory shape: `skills/agent-memory/vendor/memory/` - Harness parity (hooks vs agent): `skills/agent-memory/vendor/memory/instructions.md` → _Harness parity — memory contract_ +- Trust boundary / intentional capabilities (CLI + hooks): `SECURITY.md` - Migrations: `skills/agent-memory/vendor/UPDATE.md` - Release history: `CHANGELOG.md` ([Keep a Changelog][kac], [SemVer][semver]) - Package / skill / hooks version: `package.json` `version` → mirror `skills/agent-memory/SKILL.md` → `metadata.version` @@ -37,7 +38,8 @@ If blocked (missing permission, ambiguous SemVer, conflict between docs and code ## Conventions - **Skill boundary** — `/agent-memory` is manual-only (`disable-model-invocation: true`). Never auto-trigger it. Follow `SKILL.md` + `references/.md`. The skill **never** installs hooks (print instructions only). -- **Hooks** — under `hooks/` (not inside the skill). Shared scripts in `hooks/agent-memory-hooks/`; per-host config in `hooks//`. User installs via `hooks/install-hooks.sh` or `npx` CLI. Deterministic checkpoint: ephemeral evidence in `.hook-sync-state` only — **no Markdown writes**, no LLM loops (`followup_message` on Cursor `stop` unused). Upgrade notes: [Known issues](#known-issues). +- **Hooks** — under `hooks/` (not inside the skill). Shared scripts in `hooks/agent-memory-hooks/`; per-host config in `hooks//`. User installs via `hooks/install-hooks.sh` or `npx` CLI. Deterministic checkpoint: ephemeral evidence in `.hook-sync-state` only — **no Markdown writes**, no LLM loops (`followup_message` on Cursor `stop` unused). Trust model and audit path: `SECURITY.md`. Upgrade notes: [Known issues](#known-issues). +- **Security (CLI / OpenCode spawn)** — details in `SECURITY.md`. Keep `ENV_ALLOWLIST_EXACT` aligned (`lib/cli/constants.ts` ↔ `hooks/opencode/agent-memory.ts`). OpenCode spawn must go through `hooks/opencode/safe-script.ts` before `execFileSync`. Do not add `--minify` to `bun run build` (auditability; `bun run build:check`). Closure for spawn/security edits: `bun run test` (includes `tests/opencode-safe-script.test.ts`). - **Markdown** — normal paragraphs (no hard-wrap for line length); `.markdownlint.json` (MD013 off). - **Content language** — English in repo docs and commits. diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b4a03..de1bca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ Migration details for `/agent-memory update` live in [`skills/agent-memory/vendo ## [Unreleased] +### Security + +- OpenCode plugin: refuse symlink hook scripts, confine resolved paths under `.opencode/hooks`, and validate session/conversation binding IDs before env/stdin. +- Document trust boundary and intentional capabilities in `SECURITY.md`. + +### Changed + +- `instructions.md` slimmed for always-load: permission boundaries, numbered precedence, task-organized sections, observable turn closure, and formats linked to templates (`TEMPLATE.md`, `log.md`, `decisions.md`) instead of duplicated inventories. +- CLI build publishes `bin/cli.js` without minify for supply-chain auditability. + ## [0.1.0] - 2026-07-27 ### Breaking diff --git a/README.md b/README.md index 6864635..45cf1de 100644 --- a/README.md +++ b/README.md @@ -127,4 +127,6 @@ agent-memory/ MIT. See [LICENSE](./LICENSE). +Security and trust model: [SECURITY.md](./SECURITY.md). + [llm-wiki]: https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..52e7776 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,47 @@ +# Security + +This document explains intentional capabilities in `@dosx/agent-memory`, the trust boundary for hooks, and how to audit the published package. + +## Intentional capabilities + +The CLI and OpenCode plugin use Node.js APIs that security scanners often flag: + +| Capability | Where | Why | +| ------------------------------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Filesystem** (`node:fs`) | `install.ts`, `lib/cli/`, `hooks/opencode/` | Install the skill and hooks; check for `.agents/memory` and hook scripts under the project. | +| **Child processes** (`node:child_process`) | `lib/cli/hooks-run.ts`, `hooks/opencode/agent-memory.ts` | Run the hook installer (`install-hooks.sh`) and shared sync scripts. Always **argv form** — `spawnSync` / `execFileSync` with `shell: false` (no shell metachar parsing). | +| **Environment variables** | CLI + OpenCode plugin | Forward an **allowlisted** subset of the parent env to hook children (`ENV_ALLOWLIST_EXACT` in `lib/cli/constants.ts`); set `AGENT_MEMORY_*` for project dir, host, event, and session binding. | + +Hooks write only gitignored `.agents/memory/.hook-sync-state` (ephemeral evidence). They **never** edit Markdown under `.agents/memory/`. + +## Trust boundary + +Installing agent-memory hooks is equivalent to trusting the project directory — the same model as **git hooks**: + +- Hook scripts are copied into harness paths (e.g. `.cursor/hooks/`, `.opencode/hooks/`) under the consumer project. +- Cursor, Claude Code, Codex, Copilot, Gemini, and OpenCode all run those local scripts on lifecycle events. +- Anyone who can modify hook scripts or the project working directory already has local code execution in that project. + +The OpenCode plugin spawns the same shared bash sync script as other harnesses; it adds runtime checks (regular file only, `realpath` confinement under `.opencode/hooks`, binding ID charset validation) before `execFileSync`. + +## What we do not do + +- No network calls from the CLI or hook scripts. +- No `shell: true` on child processes. +- No full parent `process.env` forwarded to hook children. +- No Markdown writes from hooks (semantic memory is agent-owned only). + +## How to audit + +1. **CLI source** — `install.ts` and `lib/cli/` (TypeScript). +2. **Published CLI** — `bin/cli.js` is a Bun bundle (CJS, **not minified**) generated by `bun run build`; verify with `bun run build:check`. +3. **Hook scripts** — `hooks/agent-memory-hooks/*.sh` and `hooks/install-hooks.sh`. +4. **OpenCode plugin** — `hooks/opencode/agent-memory.ts` and `hooks/opencode/safe-script.ts`. +5. **Tests** — `bun run test` includes security fixtures (`tests/opencode-safe-script.test.ts`, symlink refusal in `tests/hooks-checkpoint.sh`). + +Report vulnerabilities via GitHub issues on the repository. Do not open public issues for undisclosed critical findings without coordination. + +## See also + +- [hooks/README.md](./hooks/README.md) — hook install and events +- [CHANGELOG.md](./CHANGELOG.md) — security-related release notes diff --git a/bin/cli.js b/bin/cli.js index cca0f11..6c53d6d 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,36 +1,1116 @@ #!/usr/bin/env node -var _y=Object.create;var{getPrototypeOf:vy,defineProperty:s,getOwnPropertyNames:Ay}=Object;var My=Object.prototype.hasOwnProperty;function Ly(y){return this[y]}var Vy,Dy,D=(y,j,J)=>{var F=y!=null&&typeof y==="object";if(F){var q=j?Vy??=new WeakMap:Dy??=new WeakMap,z=q.get(y);if(z)return z}J=y!=null?_y(vy(y)):{};let Q=j||!y||!y.__esModule?s(J,"default",{value:y,enumerable:!0}):J;for(let B of Ay(y))if(!My.call(Q,B))s(Q,B,{get:Ly.bind(y,B),enumerable:!0});if(F)q.set(y,Q);return Q};var h=D(require("node:fs")),T=D(require("node:path"));var I=["cursor","claude","codex","opencode","copilot","gemini"],e={"claude-code":"claude",github:"copilot"},yy=new Set(I),m={cursor:".cursor/hooks",claude:".claude/hooks",codex:".codex/hooks",opencode:".opencode/hooks",copilot:".github/hooks",gemini:".gemini/hooks"},jy=new Set(["PATH","HOME","USER","SHELL","TMPDIR","TMP","TEMP","LANG","TZ","SystemRoot","SYSTEMROOT","windir","WINDIR","USERPROFILE","HOMEDRIVE","HOMEPATH","ComSpec","COMSPEC","PATHEXT","XDG_CONFIG_HOME","XDG_DATA_HOME","GIT_CONFIG_GLOBAL","GIT_CONFIG_SYSTEM","GIT_CONFIG"]);var U=D(require("node:fs")),P=D(require("node:path"));function A(){return process.env.AGENT_MEMORY_PROJECT_DIR||process.cwd()}function o(){return P.default.join(A(),".agents","skills","agent-memory")}function Cy(y){let j=P.default.join(y,"SKILL.md");if(!U.default.existsSync(j))return null;let J=U.default.readFileSync(j,"utf8"),F=J.match(/^metadata:\s*\n(?:[ \t]+.+\n)*?[ \t]+version:\s*["']?([0-9]+\.[0-9]+\.[0-9]+)["']?/m);if(F)return F[1];let q=J.match(/version:\s*["']([0-9]+\.[0-9]+\.[0-9]+)["']/);return q?q[1]:null}function Jy(){return Cy(o())}function Fy(y){let j=P.default.join(A(),m[y],".version");if(!U.default.existsSync(j))return null;return U.default.readFileSync(j,"utf8").trim()||null}function xy(y,j){try{return U.default.readFileSync(y,"utf8").includes(j)}catch{return!1}}function qy(){let y=A(),j=[],J=(F,q)=>{if(q)j.push(F)};return J("cursor",U.default.existsSync(P.default.join(y,".cursor","hooks","agent-memory-sync.sh"))||xy(P.default.join(y,".cursor","hooks.json"),"agent-memory")),J("claude",U.default.existsSync(P.default.join(y,".claude","hooks","agent-memory-sync.sh"))),J("codex",U.default.existsSync(P.default.join(y,".codex","hooks","agent-memory-sync.sh"))),J("opencode",U.default.existsSync(P.default.join(y,".opencode","plugin","agent-memory.ts"))||U.default.existsSync(P.default.join(y,".opencode","hooks","agent-memory-sync.sh"))),J("copilot",U.default.existsSync(P.default.join(y,".github","hooks","agent-memory.json"))||U.default.existsSync(P.default.join(y,".github","hooks","agent-memory-sync.sh"))),J("gemini",U.default.existsSync(P.default.join(y,".gemini","hooks","agent-memory-sync.sh"))||xy(P.default.join(y,".gemini","settings.json"),"agent-memory")),j}function S(){return U.default.existsSync(P.default.join(A(),".agents","memory"))}function n(y){if(yy.has(y))return y;return e[y]??null}var $=D(require("node:fs")),_=D(require("node:path"));function zy(y){try{return $.default.lstatSync(y).isSymbolicLink()}catch{return!1}}function Hy(y,j){let J=_.default.resolve(A()),F=_.default.resolve(y);while(!0){if(zy(F))j(`refusing symlink in destination path: ${F}`);if(F===J||F===_.default.parse(F).root)break;let q=_.default.dirname(F);if(q===F)break;F=q}}function fy(y){let j=_.default.resolve(A()),J=_.default.resolve(y);if(J===j)return".";if(J.startsWith(j+_.default.sep))return _.default.relative(j,J);return J}function gy(y){let j=0,J=(F)=>{for(let q of $.default.readdirSync(F,{withFileTypes:!0})){let z=_.default.join(F,q.name);if(q.isDirectory())J(z);else if(q.isFile())j+=1}};return J(y),j}function uy(y,j){if(!$.default.existsSync(y))return{ok:$.default.existsSync(j),method:"none"};if($.default.existsSync(j))try{$.default.rmSync(j,{recursive:!0,force:!0})}catch{return{ok:!1,method:"none"}}try{return $.default.renameSync(y,j),{ok:!0,method:"rename"}}catch{try{return $.default.cpSync(y,j,{recursive:!0,force:!0}),{ok:!0,method:"copy"}}catch{return{ok:!1,method:"none"}}}}function Qy(y){let{skillSource:j,onError:J}=y;if(!$.default.existsSync(j))J(`missing skill at ${j}`);let F=_.default.join(A(),".agents","skills","agent-memory");if(Hy(F,J),$.default.existsSync(F)&&zy(F))J(`refusing to overwrite symlink: ${F}`);let q=$.default.existsSync(F),z=_.default.dirname(F);$.default.mkdirSync(z,{recursive:!0});let Q=$.default.mkdtempSync(_.default.join(z,".agent-memory-skill-")),B=`${F}.bak-${process.pid}-${Date.now()}`,K=!1;try{$.default.cpSync(j,Q,{recursive:!0,force:!0})}catch(Z){$.default.rmSync(Q,{recursive:!0,force:!0}),J(`skill install failed: ${Z instanceof Error?Z.message:String(Z)}`)}if(q)try{$.default.renameSync(F,B),K=!0}catch(Z){$.default.rmSync(Q,{recursive:!0,force:!0}),J(`skill install failed: ${Z instanceof Error?Z.message:String(Z)}`)}try{$.default.renameSync(Q,F)}catch{try{$.default.cpSync(Q,F,{recursive:!0,force:!0}),$.default.rmSync(Q,{recursive:!0,force:!0})}catch(Z){if(K){let W=uy(B,F);if($.default.rmSync(Q,{recursive:!0,force:!0}),!W.ok)J(`skill install failed and restore failed; previous skill left at ${B}`);J(`skill install failed: ${Z instanceof Error?Z.message:String(Z)}`)}if(!$.default.existsSync(F)&&$.default.existsSync(Q))try{$.default.renameSync(Q,F)}catch{try{$.default.cpSync(Q,F,{recursive:!0,force:!0}),$.default.rmSync(Q,{recursive:!0,force:!0})}catch{$.default.rmSync(Q,{recursive:!0,force:!0}),J(`skill install failed: ${Z instanceof Error?Z.message:String(Z)}`)}}else $.default.rmSync(Q,{recursive:!0,force:!0}),J(`skill install failed: ${Z instanceof Error?Z.message:String(Z)}`)}}if(K&&$.default.existsSync(B))try{$.default.rmSync(B,{recursive:!0,force:!0})}catch{}return{destRel:fy(F),files:gy(F),existed:q}}var Zy=require("node:child_process");function $y(y,j,J){let F=Zy.spawnSync(y,j,{encoding:"utf8",env:J.env,shell:!1});if(F.error)J.onSpawnError(F.error.message);if(F.signal)process.exit(1);let q=F.stdout??"",z=F.stderr??"";if(F.status===null||F.status!==0)J.onCommandFail(z,F.status);return{stdout:q,stderr:z}}function By(y){let j={AGENT_MEMORY_PROJECT_DIR:A(),AGENT_MEMORY_VERSION:y};for(let J of Object.keys(process.env))if(jy.has(J)||J.startsWith("LC_")){let F=process.env[J];if(F!==void 0)j[J]=F}return j}var R=D(require("node:fs")),f=D(require("node:path"));function Gy(){let y=process.argv[1];if(y){let j=f.default.resolve(y);try{j=R.default.realpathSync(j)}catch{}let J=f.default.dirname(j);if(f.default.basename(J)==="bin")return f.default.resolve(J,"..");let F=J;for(;;){let q=f.default.join(F,"package.json");if(R.default.existsSync(q))try{if(JSON.parse(R.default.readFileSync(q,"utf8")).name==="@dosx/agent-memory")return F}catch{}let z=f.default.dirname(F);if(z===F)break;F=z}}console.error("error: unable to resolve @dosx/agent-memory package root from process.argv[1]"),process.exit(1)}function p(y,j){let J=y.split(".").map((q)=>parseInt(q,10)||0),F=j.split(".").map((q)=>parseInt(q,10)||0);for(let q=0;q<3;q++){let z=J[q]??0,Q=F[q]??0;if(zQ)return 1}return 0}function b(){return process.stdin.isTTY===!0&&process.stdout.isTTY===!0}function wy(){if(process.env.NO_COLOR!==void 0&&process.env.NO_COLOR!=="")return!1;if(process.env.FORCE_COLOR!==void 0&&process.env.FORCE_COLOR!=="")return process.env.FORCE_COLOR!=="0";return process.stdout.isTTY===!0}var Iy=wy();function L(y,j){if(!Iy)return j;return`\x1B[${y}m${j}\x1B[0m`}var x={bold:(y)=>L("1",y),dim:(y)=>L("2",y),cyan:(y)=>L("36",y),green:(y)=>L("32",y),yellow:(y)=>L("33",y),red:(y)=>L("31",y),magenta:(y)=>L("35",y),boldCyan:(y)=>L("1;36",y),boldGreen:(y)=>L("1;32",y),boldMagenta:(y)=>L("1;35",y)};function my(y,j,J){let F=[x.bold(y),""];for(let q=0;q=64&&u<=126}function K(G){return/[0-9A-Za-z]/.test(G)}function Z(){Q(),J=setTimeout(()=>{if(J=null,j==="esc"){j="normal",y.onAbort();return}if(j==="csi"||j==="ss3")j="normal"},50)}function W(){Q(),z(),q.pause(),q.removeListener("data",V),q.removeListener("end",E)}function v(G){if(j==="esc"){if(Q(),G==="["){j="csi",Z();return}if(G==="O"){j="ss3",Z();return}if(K(G))j="normal";else{j="normal",y.onAbort();return}}if(j==="csi"){if(!B(G)){Z();return}if(Q(),j="normal",G==="A"){y.onUp();return}if(G==="B"){y.onDown();return}return}if(j==="ss3"){if(!B(G)){Z();return}if(Q(),j="normal",G==="A"){y.onUp();return}if(G==="B"){y.onDown();return}return}if(G==="\x03"){y.onAbort();return}if(G==="\x1B"){j="esc",Z();return}if(G==="k"){y.onUp();return}if(G==="j"){y.onDown();return}if(G===" "&&y.onSpace){y.onSpace();return}if(G==="\r"||G===` -`)y.onConfirm()}function V(G){for(let u=0;u{let F=0,q=0,z;function Q(){z.cleanup(),process.stdout.write(` -`),process.exit(1)}function B(){let Z=my(y,j,F);Z.forEach((W)=>process.stdout.write(`${W} -`)),q=Z.length}function K(){Wy(q),B()}z=Xy({onAbort:Q,onRedraw:B,onUp:()=>{F=(F-1+j.length)%j.length,K()},onDown:()=>{F=(F+1)%j.length,K()},onConfirm:()=>{z.cleanup(),process.stdout.write(` -`),J(j[F].value)}}),z.start()})}function c(y,j){return new Promise((J)=>{let F=0,q=new Set,z=0,Q="",B;function K(){B.cleanup(),process.stdout.write(` -`),process.exit(1)}function Z(){let v=Ry(y,j,F,q);if(Q)v.push(x.yellow(Q));v.forEach((V)=>process.stdout.write(`${V} -`)),z=v.length}function W(){Wy(z),Z()}B=Xy({onAbort:K,onRedraw:Z,onUp:()=>{F=(F-1+j.length)%j.length,Q="",W()},onDown:()=>{F=(F+1)%j.length,Q="",W()},onSpace:()=>{if(q.has(F))q.delete(F);else q.add(F);Q="",W()},onConfirm:()=>{if(q.size===0){Q="Select at least one harness (Space), then Enter.",W();return}B.cleanup(),process.stdout.write(` -`);let v=[...q].sort((V,E)=>V-E).map((V)=>j[V].value);J(v)}}),B.start()})}var l=Gy(),X=JSON.parse(h.default.readFileSync(T.default.join(l,"package.json"),"utf8")).version,a=T.default.join(l,"hooks","install-hooks.sh"),by=T.default.join(l,"skills","agent-memory");function i(y){console.error(`${x.red("error:")} ${y}`),process.exit(1)}function H(){console.log("")}function g(y){H(),console.log(`${x.boldMagenta("agent-memory")} ${x.dim(`v${X}`)} ${x.dim("·")} ${x.cyan(y)}`),console.log(x.dim(`project ${T.default.resolve(A())}`))}function r(y){H(),console.log(`${x.cyan("●")} ${x.bold(y)}`)}function Y(y,j){let J=y.padEnd(10);console.log(` ${x.dim(J)} ${j}`)}function M(y){console.log(` ${x.dim("·")} ${y}`)}function Ky(y){console.log(` ${x.green("✓")} ${y}`)}function Ny(y){if(H(),console.log(`${x.boldGreen("✓")} ${x.bold("Install complete")} ${x.dim(`v${X}`)}`),y.skillPath)Y("skill",y.skillPath);if(y.hooks.length>0)Y("hooks",y.hooks.map((j)=>`${j} ${x.dim(`(${m[j]})`)}`).join(", "));t(S()?"update":"init")}function t(y){if(H(),console.log(x.bold("Next steps")),console.log(` ${x.dim("In your coding agent chat")} ${x.dim("(Cursor, Claude Code, Codex, … — not this terminal):")}`),y==="update")M(`${x.cyan("/agent-memory update")} migrate .agents/memory/ via vendor/UPDATE.md`);else M(`${x.cyan("/agent-memory init")} create .agents/memory/ and wire the agent block`);M(`${x.dim("/agent-memory help")} list skill subcommands`),H()}function N(){let y=I.join(", ");console.log(`${x.boldMagenta("agent-memory")} ${x.dim(X)} - -${x.cyan("Installer")} for @dosx/agent-memory — copies the skill into the +var __create = Object.create; +var __getProtoOf = Object.getPrototypeOf; +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +function __accessProp(key) { + return this[key]; +} +var __toESMCache_node; +var __toESMCache_esm; +var __toESM = (mod, isNodeMode, target) => { + var canCache = mod != null && typeof mod === "object"; + if (canCache) { + var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap; + var cached = cache.get(mod); + if (cached) + return cached; + } + target = mod != null ? __create(__getProtoOf(mod)) : {}; + const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; + for (let key of __getOwnPropNames(mod)) + if (!__hasOwnProp.call(to, key)) + __defProp(to, key, { + get: __accessProp.bind(mod, key), + enumerable: true + }); + if (canCache) + cache.set(mod, to); + return to; +}; + +// install.ts +var import_node_fs4 = __toESM(require("node:fs")); +var import_node_path4 = __toESM(require("node:path")); + +// lib/cli/constants.ts +var CANONICAL_HARNESSES = [ + "cursor", + "claude", + "codex", + "opencode", + "copilot", + "gemini" +]; +var HARNESS_ALIASES = { + "claude-code": "claude", + github: "copilot" +}; +var HARNESS_SET = new Set(CANONICAL_HARNESSES); +var HARNESS_HOOKS_DIR = { + cursor: ".cursor/hooks", + claude: ".claude/hooks", + codex: ".codex/hooks", + opencode: ".opencode/hooks", + copilot: ".github/hooks", + gemini: ".gemini/hooks" +}; +var ENV_ALLOWLIST_EXACT = new Set([ + "PATH", + "HOME", + "USER", + "SHELL", + "TMPDIR", + "TMP", + "TEMP", + "LANG", + "TZ", + "SystemRoot", + "SYSTEMROOT", + "windir", + "WINDIR", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "ComSpec", + "COMSPEC", + "PATHEXT", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "GIT_CONFIG" +]); + +// lib/cli/detect.ts +var import_node_fs = __toESM(require("node:fs")); +var import_node_path = __toESM(require("node:path")); +function projectDir() { + return process.env.AGENT_MEMORY_PROJECT_DIR || process.cwd(); +} +function installedSkillDir() { + return import_node_path.default.join(projectDir(), ".agents", "skills", "agent-memory"); +} +function readSkillVersionFromDir(skillDir) { + const skillMd = import_node_path.default.join(skillDir, "SKILL.md"); + if (!import_node_fs.default.existsSync(skillMd)) + return null; + const text = import_node_fs.default.readFileSync(skillMd, "utf8"); + const m = text.match(/^metadata:\s*\n(?:[ \t]+.+\n)*?[ \t]+version:\s*["']?([0-9]+\.[0-9]+\.[0-9]+)["']?/m); + if (m) + return m[1]; + const loose = text.match(/version:\s*["']([0-9]+\.[0-9]+\.[0-9]+)["']/); + return loose ? loose[1] : null; +} +function readInstalledSkillVersion() { + return readSkillVersionFromDir(installedSkillDir()); +} +function readInstalledHooksVersion(harness) { + const stamp = import_node_path.default.join(projectDir(), HARNESS_HOOKS_DIR[harness], ".version"); + if (!import_node_fs.default.existsSync(stamp)) + return null; + const v = import_node_fs.default.readFileSync(stamp, "utf8").trim(); + return v || null; +} +function fileContains(filePath, needle) { + try { + return import_node_fs.default.readFileSync(filePath, "utf8").includes(needle); + } catch { + return false; + } +} +function detectInstalledHarnesses() { + const root = projectDir(); + const found = []; + const check = (harness, ok) => { + if (ok) + found.push(harness); + }; + check("cursor", import_node_fs.default.existsSync(import_node_path.default.join(root, ".cursor", "hooks", "agent-memory-sync.sh")) || fileContains(import_node_path.default.join(root, ".cursor", "hooks.json"), "agent-memory")); + check("claude", import_node_fs.default.existsSync(import_node_path.default.join(root, ".claude", "hooks", "agent-memory-sync.sh"))); + check("codex", import_node_fs.default.existsSync(import_node_path.default.join(root, ".codex", "hooks", "agent-memory-sync.sh"))); + check("opencode", import_node_fs.default.existsSync(import_node_path.default.join(root, ".opencode", "plugin", "agent-memory.ts")) || import_node_fs.default.existsSync(import_node_path.default.join(root, ".opencode", "hooks", "agent-memory-sync.sh"))); + check("copilot", import_node_fs.default.existsSync(import_node_path.default.join(root, ".github", "hooks", "agent-memory.json")) || import_node_fs.default.existsSync(import_node_path.default.join(root, ".github", "hooks", "agent-memory-sync.sh"))); + check("gemini", import_node_fs.default.existsSync(import_node_path.default.join(root, ".gemini", "hooks", "agent-memory-sync.sh")) || fileContains(import_node_path.default.join(root, ".gemini", "settings.json"), "agent-memory")); + return found; +} +function memoryExists() { + return import_node_fs.default.existsSync(import_node_path.default.join(projectDir(), ".agents", "memory")); +} +function normalizeHarness(name) { + if (HARNESS_SET.has(name)) + return name; + return HARNESS_ALIASES[name] ?? null; +} + +// lib/cli/fs-install.ts +var import_node_fs2 = __toESM(require("node:fs")); +var import_node_path2 = __toESM(require("node:path")); +function isSymlink(p) { + try { + return import_node_fs2.default.lstatSync(p).isSymbolicLink(); + } catch { + return false; + } +} +function refuseSymlinkComponents(dest, onError) { + const project = import_node_path2.default.resolve(projectDir()); + let cur = import_node_path2.default.resolve(dest); + while (true) { + if (isSymlink(cur)) { + onError(`refusing symlink in destination path: ${cur}`); + } + if (cur === project || cur === import_node_path2.default.parse(cur).root) + break; + const parent = import_node_path2.default.dirname(cur); + if (parent === cur) + break; + cur = parent; + } +} +function relPath(p) { + const root = import_node_path2.default.resolve(projectDir()); + const abs = import_node_path2.default.resolve(p); + if (abs === root) + return "."; + if (abs.startsWith(root + import_node_path2.default.sep)) + return import_node_path2.default.relative(root, abs); + return abs; +} +function countFiles(dir) { + let n = 0; + const walk = (d) => { + for (const ent of import_node_fs2.default.readdirSync(d, { withFileTypes: true })) { + const full = import_node_path2.default.join(d, ent.name); + if (ent.isDirectory()) + walk(full); + else if (ent.isFile()) + n += 1; + } + }; + walk(dir); + return n; +} +function restoreBackup(backup, dest) { + if (!import_node_fs2.default.existsSync(backup)) { + return { ok: import_node_fs2.default.existsSync(dest), method: "none" }; + } + if (import_node_fs2.default.existsSync(dest)) { + try { + import_node_fs2.default.rmSync(dest, { recursive: true, force: true }); + } catch { + return { ok: false, method: "none" }; + } + } + try { + import_node_fs2.default.renameSync(backup, dest); + return { ok: true, method: "rename" }; + } catch { + try { + import_node_fs2.default.cpSync(backup, dest, { recursive: true, force: true }); + return { ok: true, method: "copy" }; + } catch { + return { ok: false, method: "none" }; + } + } +} +function installSkillAtomic(opts) { + const { skillSource, onError } = opts; + if (!import_node_fs2.default.existsSync(skillSource)) { + onError(`missing skill at ${skillSource}`); + } + const dest = import_node_path2.default.join(projectDir(), ".agents", "skills", "agent-memory"); + refuseSymlinkComponents(dest, onError); + if (import_node_fs2.default.existsSync(dest) && isSymlink(dest)) { + onError(`refusing to overwrite symlink: ${dest}`); + } + const existed = import_node_fs2.default.existsSync(dest); + const parent = import_node_path2.default.dirname(dest); + import_node_fs2.default.mkdirSync(parent, { recursive: true }); + const staging = import_node_fs2.default.mkdtempSync(import_node_path2.default.join(parent, ".agent-memory-skill-")); + const backup = `${dest}.bak-${process.pid}-${Date.now()}`; + let movedAside = false; + try { + import_node_fs2.default.cpSync(skillSource, staging, { recursive: true, force: true }); + } catch (err) { + import_node_fs2.default.rmSync(staging, { recursive: true, force: true }); + onError(`skill install failed: ${err instanceof Error ? err.message : String(err)}`); + } + if (existed) { + try { + import_node_fs2.default.renameSync(dest, backup); + movedAside = true; + } catch (err) { + import_node_fs2.default.rmSync(staging, { recursive: true, force: true }); + onError(`skill install failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + try { + import_node_fs2.default.renameSync(staging, dest); + } catch { + try { + import_node_fs2.default.cpSync(staging, dest, { recursive: true, force: true }); + import_node_fs2.default.rmSync(staging, { recursive: true, force: true }); + } catch (err) { + if (movedAside) { + const restored = restoreBackup(backup, dest); + import_node_fs2.default.rmSync(staging, { recursive: true, force: true }); + if (!restored.ok) { + onError(`skill install failed and restore failed; previous skill left at ${backup}`); + } + onError(`skill install failed: ${err instanceof Error ? err.message : String(err)}`); + } + if (!import_node_fs2.default.existsSync(dest) && import_node_fs2.default.existsSync(staging)) { + try { + import_node_fs2.default.renameSync(staging, dest); + } catch { + try { + import_node_fs2.default.cpSync(staging, dest, { recursive: true, force: true }); + import_node_fs2.default.rmSync(staging, { recursive: true, force: true }); + } catch { + import_node_fs2.default.rmSync(staging, { recursive: true, force: true }); + onError(`skill install failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + } else { + import_node_fs2.default.rmSync(staging, { recursive: true, force: true }); + onError(`skill install failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + if (movedAside && import_node_fs2.default.existsSync(backup)) { + try { + import_node_fs2.default.rmSync(backup, { recursive: true, force: true }); + } catch {} + } + return { + destRel: relPath(dest), + files: countFiles(dest), + existed + }; +} + +// lib/cli/hooks-run.ts +var import_node_child_process = require("node:child_process"); +function runCaptured(command, args, options) { + const result = import_node_child_process.spawnSync(command, args, { + encoding: "utf8", + env: options.env, + shell: false + }); + if (result.error) { + options.onSpawnError(result.error.message); + } + if (result.signal) { + process.exit(1); + } + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + if (result.status === null || result.status !== 0) { + options.onCommandFail(stderr, result.status); + } + return { stdout, stderr }; +} +function buildInstallerEnv(version) { + const env = { + AGENT_MEMORY_PROJECT_DIR: projectDir(), + AGENT_MEMORY_VERSION: version + }; + for (const key of Object.keys(process.env)) { + if (ENV_ALLOWLIST_EXACT.has(key) || key.startsWith("LC_")) { + const val = process.env[key]; + if (val !== undefined) + env[key] = val; + } + } + return env; +} + +// lib/cli/package-root.ts +var import_node_fs3 = __toESM(require("node:fs")); +var import_node_path3 = __toESM(require("node:path")); +function resolvePackageRoot() { + const entry = process.argv[1]; + if (entry) { + let scriptPath = import_node_path3.default.resolve(entry); + try { + scriptPath = import_node_fs3.default.realpathSync(scriptPath); + } catch {} + const dir = import_node_path3.default.dirname(scriptPath); + if (import_node_path3.default.basename(dir) === "bin") { + return import_node_path3.default.resolve(dir, ".."); + } + let cur = dir; + for (;; ) { + const pkgPath = import_node_path3.default.join(cur, "package.json"); + if (import_node_fs3.default.existsSync(pkgPath)) { + try { + const name = JSON.parse(import_node_fs3.default.readFileSync(pkgPath, "utf8")).name; + if (name === "@dosx/agent-memory") + return cur; + } catch {} + } + const parent = import_node_path3.default.dirname(cur); + if (parent === cur) + break; + cur = parent; + } + } + console.error("error: unable to resolve @dosx/agent-memory package root from process.argv[1]"); + process.exit(1); +} + +// lib/cli/semver.ts +function compareSemver(a, b) { + const pa = a.split(".").map((n) => parseInt(n, 10) || 0); + const pb = b.split(".").map((n) => parseInt(n, 10) || 0); + for (let i = 0;i < 3; i++) { + const x = pa[i] ?? 0; + const y = pb[i] ?? 0; + if (x < y) + return -1; + if (x > y) + return 1; + } + return 0; +} + +// lib/cli/tty.ts +var ESC = "\x1B"; +var CSI = `${ESC}[`; +function isTTY() { + return process.stdin.isTTY === true && process.stdout.isTTY === true; +} +function useColor() { + if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") { + return false; + } + if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== "") { + return process.env.FORCE_COLOR !== "0"; + } + return process.stdout.isTTY === true; +} +var colorEnabled = useColor(); +function wrap(code, text) { + if (!colorEnabled) + return text; + return `${CSI}${code}m${text}${CSI}0m`; +} +var c = { + bold: (t) => wrap("1", t), + dim: (t) => wrap("2", t), + cyan: (t) => wrap("36", t), + green: (t) => wrap("32", t), + yellow: (t) => wrap("33", t), + red: (t) => wrap("31", t), + magenta: (t) => wrap("35", t), + boldCyan: (t) => wrap("1;36", t), + boldGreen: (t) => wrap("1;32", t), + boldMagenta: (t) => wrap("1;35", t) +}; +function renderSelectLines(title, options, selected) { + const lines = [c.bold(title), ""]; + for (let i = 0;i < options.length; i++) { + const active = i === selected; + const prefix = active ? `${c.cyan("›")} ` : " "; + const label = active ? c.bold(options[i].label) : options[i].label; + lines.push(`${prefix}${label}`); + } + lines.push("", c.dim("↑/↓ or j/k · Enter confirm · Ctrl+C/Esc cancel")); + return lines; +} +function renderMultiSelectLines(title, options, cursor, checked) { + const lines = [c.bold(title), ""]; + for (let i = 0;i < options.length; i++) { + const active = i === cursor; + const box = checked.has(i) ? c.green("[x]") : "[ ]"; + const prefix = active ? `${c.cyan("›")} ` : " "; + const label = active ? c.bold(options[i].label) : options[i].label; + lines.push(`${prefix}${box} ${label}`); + } + lines.push("", c.dim("↑/↓ or j/k · Space toggle · Enter confirm · Ctrl+C/Esc cancel")); + return lines; +} +function clearRenderedLines(lineCount) { + for (let i = 0;i < lineCount; i++) { + process.stdout.write(`${CSI}1A${CSI}2K`); + } +} +function createRawMenuController(opts) { + let escState = "normal"; + let escTimer = null; + let rawModeEnabled = false; + const stdin = process.stdin; + function disableRawMode() { + if (rawModeEnabled && typeof stdin.setRawMode === "function") { + stdin.setRawMode(false); + rawModeEnabled = false; + } + } + function clearEscTimer() { + if (escTimer) { + clearTimeout(escTimer); + escTimer = null; + } + } + function isCsiFinal(ch) { + const code = ch.charCodeAt(0); + return code >= 64 && code <= 126; + } + function isAlphanumeric(ch) { + return /[0-9A-Za-z]/.test(ch); + } + function startEscTimer() { + clearEscTimer(); + escTimer = setTimeout(() => { + escTimer = null; + if (escState === "esc") { + escState = "normal"; + opts.onAbort(); + return; + } + if (escState === "csi" || escState === "ss3") { + escState = "normal"; + } + }, 50); + } + function cleanup() { + clearEscTimer(); + disableRawMode(); + stdin.pause(); + stdin.removeListener("data", onData); + stdin.removeListener("end", onEnd); + } + function processChar(ch) { + if (escState === "esc") { + clearEscTimer(); + if (ch === "[") { + escState = "csi"; + startEscTimer(); + return; + } + if (ch === "O") { + escState = "ss3"; + startEscTimer(); + return; + } + if (isAlphanumeric(ch)) { + escState = "normal"; + } else { + escState = "normal"; + opts.onAbort(); + return; + } + } + if (escState === "csi") { + if (!isCsiFinal(ch)) { + startEscTimer(); + return; + } + clearEscTimer(); + escState = "normal"; + if (ch === "A") { + opts.onUp(); + return; + } + if (ch === "B") { + opts.onDown(); + return; + } + return; + } + if (escState === "ss3") { + if (!isCsiFinal(ch)) { + startEscTimer(); + return; + } + clearEscTimer(); + escState = "normal"; + if (ch === "A") { + opts.onUp(); + return; + } + if (ch === "B") { + opts.onDown(); + return; + } + return; + } + if (ch === "\x03") { + opts.onAbort(); + return; + } + if (ch === ESC) { + escState = "esc"; + startEscTimer(); + return; + } + if (ch === "k") { + opts.onUp(); + return; + } + if (ch === "j") { + opts.onDown(); + return; + } + if (ch === " " && opts.onSpace) { + opts.onSpace(); + return; + } + if (ch === "\r" || ch === ` +`) { + opts.onConfirm(); + } + } + function onData(chunk) { + for (let i = 0;i < chunk.length; i++) { + processChar(chunk[i]); + } + } + function onEnd() { + opts.onAbort(); + } + function start() { + let setupComplete = false; + try { + if (typeof stdin.setRawMode === "function") { + stdin.setRawMode(true); + rawModeEnabled = true; + } + stdin.resume(); + stdin.setEncoding("utf8"); + stdin.on("data", onData); + stdin.on("end", onEnd); + opts.onRedraw(); + setupComplete = true; + } finally { + if (!setupComplete) { + disableRawMode(); + } + } + } + return { start, cleanup }; +} +function selectPrompt(title, options) { + return new Promise((resolve) => { + let selected = 0; + let renderedLineCount = 0; + let controller; + function abort() { + controller.cleanup(); + process.stdout.write(` +`); + process.exit(1); + } + function printMenu() { + const lines = renderSelectLines(title, options, selected); + lines.forEach((line) => process.stdout.write(`${line} +`)); + renderedLineCount = lines.length; + } + function redraw() { + clearRenderedLines(renderedLineCount); + printMenu(); + } + controller = createRawMenuController({ + onAbort: abort, + onRedraw: printMenu, + onUp: () => { + selected = (selected - 1 + options.length) % options.length; + redraw(); + }, + onDown: () => { + selected = (selected + 1) % options.length; + redraw(); + }, + onConfirm: () => { + controller.cleanup(); + process.stdout.write(` +`); + resolve(options[selected].value); + } + }); + controller.start(); + }); +} +function multiSelectPrompt(title, options) { + return new Promise((resolve) => { + let cursor = 0; + const checked = new Set; + let renderedLineCount = 0; + let hint = ""; + let controller; + function abort() { + controller.cleanup(); + process.stdout.write(` +`); + process.exit(1); + } + function printMenu() { + const lines = renderMultiSelectLines(title, options, cursor, checked); + if (hint) + lines.push(c.yellow(hint)); + lines.forEach((line) => process.stdout.write(`${line} +`)); + renderedLineCount = lines.length; + } + function redraw() { + clearRenderedLines(renderedLineCount); + printMenu(); + } + controller = createRawMenuController({ + onAbort: abort, + onRedraw: printMenu, + onUp: () => { + cursor = (cursor - 1 + options.length) % options.length; + hint = ""; + redraw(); + }, + onDown: () => { + cursor = (cursor + 1) % options.length; + hint = ""; + redraw(); + }, + onSpace: () => { + if (checked.has(cursor)) + checked.delete(cursor); + else + checked.add(cursor); + hint = ""; + redraw(); + }, + onConfirm: () => { + if (checked.size === 0) { + hint = "Select at least one harness (Space), then Enter."; + redraw(); + return; + } + controller.cleanup(); + process.stdout.write(` +`); + const values = [...checked].sort((a, b) => a - b).map((i) => options[i].value); + resolve(values); + } + }); + controller.start(); + }); +} + +// install.ts +var ROOT = resolvePackageRoot(); +var VERSION = JSON.parse(import_node_fs4.default.readFileSync(import_node_path4.default.join(ROOT, "package.json"), "utf8")).version; +var INSTALL_HOOKS_SH = import_node_path4.default.join(ROOT, "hooks", "install-hooks.sh"); +var SKILL_SOURCE = import_node_path4.default.join(ROOT, "skills", "agent-memory"); +function fatal(message) { + console.error(`${c.red("error:")} ${message}`); + process.exit(1); +} +function blank() { + console.log(""); +} +function printHeader(action) { + blank(); + console.log(`${c.boldMagenta("agent-memory")} ${c.dim(`v${VERSION}`)} ${c.dim("·")} ${c.cyan(action)}`); + console.log(c.dim(`project ${import_node_path4.default.resolve(projectDir())}`)); +} +function printSection(title) { + blank(); + console.log(`${c.cyan("●")} ${c.bold(title)}`); +} +function printDetail(label, value) { + const pad = label.padEnd(10); + console.log(` ${c.dim(pad)} ${value}`); +} +function printStep(line) { + console.log(` ${c.dim("·")} ${line}`); +} +function printOk(message) { + console.log(` ${c.green("✓")} ${message}`); +} +function printSummary(report) { + blank(); + console.log(`${c.boldGreen("✓")} ${c.bold("Install complete")} ${c.dim(`v${VERSION}`)}`); + if (report.skillPath) { + printDetail("skill", report.skillPath); + } + if (report.hooks.length > 0) { + printDetail("hooks", report.hooks.map((h) => `${h} ${c.dim(`(${HARNESS_HOOKS_DIR[h]})`)}`).join(", ")); + } + printAgentNextSteps(memoryExists() ? "update" : "init"); +} +function printAgentNextSteps(primary) { + blank(); + console.log(c.bold("Next steps")); + console.log(` ${c.dim("In your coding agent chat")} ${c.dim("(Cursor, Claude Code, Codex, … — not this terminal):")}`); + if (primary === "update") { + printStep(`${c.cyan("/agent-memory update")} migrate .agents/memory/ via vendor/UPDATE.md`); + } else { + printStep(`${c.cyan("/agent-memory init")} create .agents/memory/ and wire the agent block`); + } + printStep(`${c.dim("/agent-memory help")} list skill subcommands`); + blank(); +} +function printHelp() { + const harnessList = CANONICAL_HARNESSES.join(", "); + console.log(`${c.boldMagenta("agent-memory")} ${c.dim(VERSION)} + +${c.cyan("Installer")} for @dosx/agent-memory — copies the skill into the project and installs harness lifecycle hooks. -${x.bold("Usage")} - ${x.cyan("agent-memory install")} ${x.dim("# TTY: pick harnesses + skill/hooks")} - ${x.cyan("agent-memory install skill")} ${x.dim("# copy skill → .agents/skills/")} - ${x.cyan("agent-memory install hooks")} ${x.dim("# TTY: multi-select harnesses")} - ${x.cyan("agent-memory install hooks ")} ${x.dim("# headless: one harness")} - ${x.cyan("agent-memory install ")} ${x.dim("# TTY menu for that harness")} - ${x.cyan("agent-memory update")} ${x.dim("# refresh skill + installed hooks")} - ${x.cyan("agent-memory update --yes")} ${x.dim("# non-interactive update")} - ${x.cyan("agent-memory help")} - -${x.bold("Harnesses")} ${y} - ${x.dim("aliases: claude-code → claude, github → copilot")} - -${x.bold("Examples")} - ${x.dim("npx @dosx/agent-memory install")} - ${x.dim("npx @dosx/agent-memory install skill")} - ${x.dim("npx @dosx/agent-memory install hooks cursor")} - ${x.dim("npx @dosx/agent-memory update")} - ${x.dim("npx @dosx/agent-memory update --yes")} -`)}function w(){let y=Qy({skillSource:by,onError:i});return r("Skill"),Y("name",x.bold("agent-memory")),Y("version",X),Y("path",y.destRel),Y("files",`${y.files} files ${x.dim(y.existed?"(updated)":"(new)")}`),Ky(`skill ready at ${x.cyan(y.destRel)}`),y.destRel}function O(y){if(!h.default.existsSync(a))i(`missing installer at ${a}`);let j=process.platform==="win32"?"bash.exe":"bash";r(`Hooks · ${y}`),Y("target",m[y]);let{stdout:J,stderr:F}=$y(j,[a,y],{env:By(X),onSpawnError:i,onCommandFail:(q,z)=>{if(q.trim())for(let Q of q.trim().split(` -`))console.error(` ${x.red("✗")} ${Q}`);process.exit(z??1)}});for(let q of J.split(` -`)){let z=q.trim();if(!z)continue;if(z.startsWith("done:"))continue;if(z.startsWith("error:")){console.error(` ${x.red("✗")} ${z}`);continue}M(z)}for(let q of F.split(` -`)){let z=q.trim();if(!z)continue;console.error(` ${x.red("✗")} ${z}`)}Ky(`hooks ready for ${x.bold(y)}`)}function Uy(y){for(let j of y)O(j)}function C(y){Ny(y)}async function Oy(y){return await k(y,[{label:"Yes, update",value:"yes"},{label:"Cancel",value:"no"}])==="yes"}function Ty(y){if(H(),console.log(`${x.boldGreen("✓")} ${x.bold("Update complete")} ${x.dim(`cli ${X}`)}`),y.skillMissing)Y("skill",x.dim("not installed (skipped)"));else if(y.skillUpdated)Y("skill",`${y.skillFrom??"?"} → ${y.skillTo} ${x.dim("(.agents/skills/agent-memory)")}`);else Y("skill",`${y.skillTo} ${x.dim("(already current)")}`);if(y.hooksRefreshed.length>0)Y("hooks",y.hooksRefreshed.map((j)=>`${j} ${x.dim(`→ ${X}`)}`).join(", "));if(y.hooksSkipped.length>0)Y("skipped",y.hooksSkipped.map((j)=>`${j} ${x.dim("(current)")}`).join(", "));t(S()?"update":"init")}async function Ey(y){let j=Jy(),J=o(),F=!h.default.existsSync(J)||!j;if(g("update"),r("Versions"),Y("package",X),F)Y("skill",x.dim("not installed")),M(x.yellow("skill missing — hooks-only update; install with: npx @dosx/agent-memory install skill"));else Y("skill",`${j} ${x.dim("installed")} · ${X} ${x.dim("package")}`);Y("hooks",`${X} ${x.dim("package")}`);let q=!1;if(!F&&j){let W=p(X,j);if(W>0)q=!0,M(`skill upgrade available ${x.yellow(`${j} → ${X}`)}`);else if(W<0)console.log(` ${x.yellow("!")} installed skill (${j}) is newer than package (${X}); will not downgrade`);else M(`skill already at ${X}`)}let z=qy(),Q=[],B=[];if(z.length===0)M(x.dim("no hooks detected"));else for(let W of z){let v=Fy(W);if(!v||p(X,v)>0)Q.push(W),M(`hooks ${W}: ${x.yellow(`${v??"none"} → ${X}`)}`);else B.push(W),M(`hooks ${W}: ${v} ${x.dim("(current)")}`)}if(F&&Q.length===0&&z.length===0)console.error(`${x.red("error:")} nothing to update — install the skill and/or hooks first`),console.error(` ${x.dim("npx @dosx/agent-memory install skill")}`),console.error(` ${x.dim("npx @dosx/agent-memory install hooks ")}`),process.exit(1);if(!q&&Q.length===0){if(H(),console.log(`${x.boldGreen("✓")} ${x.bold("Already up to date")} ${x.dim(X)}`),F)M(x.dim("optional: npx @dosx/agent-memory install skill"));t(S()?"update":"init");return}let K=[];if(q)K.push(`skill ${j} → ${X}`);if(Q.length>0)K.push(`hooks ${Q.join(", ")} → ${X}`);if(!y.yes){if(!b())d("interactive update requires a TTY (or pass --yes).",["npx @dosx/agent-memory update --yes"]);if(H(),!await Oy(`Apply update? (${K.join("; ")})`))console.log(x.dim("Cancelled.")),process.exit(0)}let Z=!1;if(q)w(),Z=!0;for(let W of Q)O(W);Ty({skillUpdated:Z,skillFrom:j,skillTo:X,skillMissing:F,hooksRefreshed:Q,hooksSkipped:B})}function Sy(y){let j=!1;for(let J of y){if(J==="--yes"||J==="-y"){j=!0;continue}console.error(`${x.red("error:")} unexpected argument: ${J}`),N(),process.exit(1)}return{yes:j}}function Yy(){return I.map((y)=>({label:y,value:y}))}function d(y,j){console.error(`${x.red("error:")} ${y}`);for(let J of j)console.error(` ${x.dim(J)}`);process.exit(1)}async function ky(y){if(!b())d("interactive install requires a TTY.",[`Hooks: npx @dosx/agent-memory install hooks ${y}`,"Skill: npx @dosx/agent-memory install skill"]);let j=await k(`Install agent-memory for ${y}:`,[{label:"Skill + hooks",value:"both"},{label:"Skill only",value:"skill"},{label:"Hooks only",value:"hooks"}]);if(g(`install · ${y}`),j==="both"){let J=w();O(y),C({skillPath:J,hooks:[y]});return}if(j==="skill"){C({skillPath:w(),hooks:[]});return}O(y),C({hooks:[y]})}async function hy(){if(!b())d("interactive install requires a TTY.",["Skill: npx @dosx/agent-memory install skill","Hooks: npx @dosx/agent-memory install hooks "]);let y=await k("What do you want to install?",[{label:"Skill + hooks",value:"both"},{label:"Skill only",value:"skill"},{label:"Hooks only",value:"hooks"}]);if(y==="skill"){g("install · skill"),C({skillPath:w(),hooks:[]});return}let j=await c("Select harnesses (Space to toggle):",Yy());g(y==="both"?`install · skill + ${j.join(", ")}`:`install · hooks · ${j.join(", ")}`);let J={hooks:j};if(y==="both")J.skillPath=w();Uy(j),C(J)}async function dy(){if(!b())d("interactive install hooks requires a TTY.",["Hooks: npx @dosx/agent-memory install hooks "]);let y=await c("Select harnesses (Space to toggle):",Yy());g(`install · hooks · ${y.join(", ")}`),Uy(y),C({hooks:y})}async function oy(y){let j=y.slice(2);if(j.length===0||j[0]==="help"||j[0]==="--help"||j[0]==="-h"){N();return}if(j[0]==="update"){await Ey(Sy(j.slice(1)));return}if(j[0]!=="install")console.error(`${x.red("error:")} unknown command: ${j[0]}`),N(),process.exit(1);let J=j.slice(1);if(J.length===0){await hy();return}if(J[0]==="skill"){if(J.length>1)console.error(`${x.red("error:")} install skill does not accept arguments`),process.exit(1);g("install · skill"),C({skillPath:w(),hooks:[]});return}if(J[0]==="hooks"){if(J.length===1){await dy();return}let q=J[1];if(J.length>2)console.error(`${x.red("error:")} unexpected argument: ${J[2]}`),process.exit(1);let z=n(q);if(!z)console.error(`${x.red("error:")} unknown harness: ${q}`),N(),process.exit(1);g(`install · hooks · ${z}`),O(z),C({hooks:[z]});return}let F=n(J[0]);if(F){if(J.length>1)console.error(`${x.red("error:")} unexpected argument: ${J[1]}`),process.exit(1);await ky(F);return}console.error(`${x.red("error:")} unknown install target: ${J[0]}`),N(),process.exit(1)}oy(process.argv).catch((y)=>{console.error(y),process.exit(1)}); +${c.bold("Usage")} + ${c.cyan("agent-memory install")} ${c.dim("# TTY: pick harnesses + skill/hooks")} + ${c.cyan("agent-memory install skill")} ${c.dim("# copy skill → .agents/skills/")} + ${c.cyan("agent-memory install hooks")} ${c.dim("# TTY: multi-select harnesses")} + ${c.cyan("agent-memory install hooks ")} ${c.dim("# headless: one harness")} + ${c.cyan("agent-memory install ")} ${c.dim("# TTY menu for that harness")} + ${c.cyan("agent-memory update")} ${c.dim("# refresh skill + installed hooks")} + ${c.cyan("agent-memory update --yes")} ${c.dim("# non-interactive update")} + ${c.cyan("agent-memory help")} + +${c.bold("Harnesses")} ${harnessList} + ${c.dim("aliases: claude-code → claude, github → copilot")} + +${c.bold("Examples")} + ${c.dim("npx @dosx/agent-memory install")} + ${c.dim("npx @dosx/agent-memory install skill")} + ${c.dim("npx @dosx/agent-memory install hooks cursor")} + ${c.dim("npx @dosx/agent-memory update")} + ${c.dim("npx @dosx/agent-memory update --yes")} +`); +} +function installSkill() { + const result = installSkillAtomic({ + skillSource: SKILL_SOURCE, + onError: fatal + }); + printSection("Skill"); + printDetail("name", c.bold("agent-memory")); + printDetail("version", VERSION); + printDetail("path", result.destRel); + printDetail("files", `${result.files} files ${c.dim(result.existed ? "(updated)" : "(new)")}`); + printOk(`skill ready at ${c.cyan(result.destRel)}`); + return result.destRel; +} +function installHooks(harness) { + if (!import_node_fs4.default.existsSync(INSTALL_HOOKS_SH)) { + fatal(`missing installer at ${INSTALL_HOOKS_SH}`); + } + const bash = process.platform === "win32" ? "bash.exe" : "bash"; + printSection(`Hooks · ${harness}`); + printDetail("target", HARNESS_HOOKS_DIR[harness]); + const { stdout, stderr } = runCaptured(bash, [INSTALL_HOOKS_SH, harness], { + env: buildInstallerEnv(VERSION), + onSpawnError: fatal, + onCommandFail: (errText, status) => { + if (errText.trim()) { + for (const line of errText.trim().split(` +`)) { + console.error(` ${c.red("✗")} ${line}`); + } + } + process.exit(status ?? 1); + } + }); + for (const line of stdout.split(` +`)) { + const trimmed = line.trim(); + if (!trimmed) + continue; + if (trimmed.startsWith("done:")) + continue; + if (trimmed.startsWith("error:")) { + console.error(` ${c.red("✗")} ${trimmed}`); + continue; + } + printStep(trimmed); + } + for (const line of stderr.split(` +`)) { + const trimmed = line.trim(); + if (!trimmed) + continue; + console.error(` ${c.red("✗")} ${trimmed}`); + } + printOk(`hooks ready for ${c.bold(harness)}`); +} +function installHooksMany(harnesses) { + for (const h of harnesses) { + installHooks(h); + } +} +function finishInstall(report) { + printSummary(report); +} +async function confirmPrompt(message) { + const choice = await selectPrompt(message, [ + { label: "Yes, update", value: "yes" }, + { label: "Cancel", value: "no" } + ]); + return choice === "yes"; +} +function printUpdateSummary(opts) { + blank(); + console.log(`${c.boldGreen("✓")} ${c.bold("Update complete")} ${c.dim(`cli ${VERSION}`)}`); + if (opts.skillMissing) { + printDetail("skill", c.dim("not installed (skipped)")); + } else if (opts.skillUpdated) { + printDetail("skill", `${opts.skillFrom ?? "?"} → ${opts.skillTo} ${c.dim("(.agents/skills/agent-memory)")}`); + } else { + printDetail("skill", `${opts.skillTo} ${c.dim("(already current)")}`); + } + if (opts.hooksRefreshed.length > 0) { + printDetail("hooks", opts.hooksRefreshed.map((h) => `${h} ${c.dim(`→ ${VERSION}`)}`).join(", ")); + } + if (opts.hooksSkipped.length > 0) { + printDetail("skipped", opts.hooksSkipped.map((h) => `${h} ${c.dim("(current)")}`).join(", ")); + } + printAgentNextSteps(memoryExists() ? "update" : "init"); +} +async function cmdUpdate(flags) { + const installedSkill = readInstalledSkillVersion(); + const skillDir = installedSkillDir(); + const skillMissing = !import_node_fs4.default.existsSync(skillDir) || !installedSkill; + printHeader("update"); + printSection("Versions"); + printDetail("package", VERSION); + if (skillMissing) { + printDetail("skill", c.dim("not installed")); + printStep(c.yellow(`skill missing — hooks-only update; install with: npx @dosx/agent-memory install skill`)); + } else { + printDetail("skill", `${installedSkill} ${c.dim("installed")} · ${VERSION} ${c.dim("package")}`); + } + printDetail("hooks", `${VERSION} ${c.dim("package")}`); + let needSkill = false; + if (!skillMissing && installedSkill) { + const skillCmp = compareSemver(VERSION, installedSkill); + if (skillCmp > 0) { + needSkill = true; + printStep(`skill upgrade available ${c.yellow(`${installedSkill} → ${VERSION}`)}`); + } else if (skillCmp < 0) { + console.log(` ${c.yellow("!")} installed skill (${installedSkill}) is newer than package (${VERSION}); will not downgrade`); + } else { + printStep(`skill already at ${VERSION}`); + } + } + const installedHarnesses = detectInstalledHarnesses(); + const hooksToRefresh = []; + const hooksSkipped = []; + if (installedHarnesses.length === 0) { + printStep(c.dim("no hooks detected")); + } else { + for (const h of installedHarnesses) { + const stamp = readInstalledHooksVersion(h); + if (!stamp || compareSemver(VERSION, stamp) > 0) { + hooksToRefresh.push(h); + printStep(`hooks ${h}: ${c.yellow(`${stamp ?? "none"} → ${VERSION}`)}`); + } else { + hooksSkipped.push(h); + printStep(`hooks ${h}: ${stamp} ${c.dim("(current)")}`); + } + } + } + if (skillMissing && hooksToRefresh.length === 0 && installedHarnesses.length === 0) { + console.error(`${c.red("error:")} nothing to update — install the skill and/or hooks first`); + console.error(` ${c.dim("npx @dosx/agent-memory install skill")}`); + console.error(` ${c.dim("npx @dosx/agent-memory install hooks ")}`); + process.exit(1); + } + if (!needSkill && hooksToRefresh.length === 0) { + blank(); + console.log(`${c.boldGreen("✓")} ${c.bold("Already up to date")} ${c.dim(VERSION)}`); + if (skillMissing) { + printStep(c.dim(`optional: npx @dosx/agent-memory install skill`)); + } + printAgentNextSteps(memoryExists() ? "update" : "init"); + return; + } + const planParts = []; + if (needSkill) + planParts.push(`skill ${installedSkill} → ${VERSION}`); + if (hooksToRefresh.length > 0) { + planParts.push(`hooks ${hooksToRefresh.join(", ")} → ${VERSION}`); + } + if (!flags.yes) { + if (!isTTY()) { + failNonTTY("interactive update requires a TTY (or pass --yes).", [ + "npx @dosx/agent-memory update --yes" + ]); + } + blank(); + const ok = await confirmPrompt(`Apply update? (${planParts.join("; ")})`); + if (!ok) { + console.log(c.dim("Cancelled.")); + process.exit(0); + } + } + let skillUpdated = false; + if (needSkill) { + installSkill(); + skillUpdated = true; + } + for (const h of hooksToRefresh) { + installHooks(h); + } + printUpdateSummary({ + skillUpdated, + skillFrom: installedSkill, + skillTo: VERSION, + skillMissing, + hooksRefreshed: hooksToRefresh, + hooksSkipped + }); +} +function parseUpdateFlags(args) { + let yes = false; + for (const a of args) { + if (a === "--yes" || a === "-y") { + yes = true; + continue; + } + console.error(`${c.red("error:")} unexpected argument: ${a}`); + printHelp(); + process.exit(1); + } + return { yes }; +} +function harnessOptions() { + return CANONICAL_HARNESSES.map((h) => ({ label: h, value: h })); +} +function failNonTTY(message, hints) { + console.error(`${c.red("error:")} ${message}`); + for (const h of hints) { + console.error(` ${c.dim(h)}`); + } + process.exit(1); +} +async function promptInstallChoice(harness) { + if (!isTTY()) { + failNonTTY("interactive install requires a TTY.", [ + `Hooks: npx @dosx/agent-memory install hooks ${harness}`, + "Skill: npx @dosx/agent-memory install skill" + ]); + } + const choice = await selectPrompt(`Install agent-memory for ${harness}:`, [ + { label: "Skill + hooks", value: "both" }, + { label: "Skill only", value: "skill" }, + { label: "Hooks only", value: "hooks" } + ]); + printHeader(`install · ${harness}`); + if (choice === "both") { + const skillPath = installSkill(); + installHooks(harness); + finishInstall({ skillPath, hooks: [harness] }); + return; + } + if (choice === "skill") { + finishInstall({ skillPath: installSkill(), hooks: [] }); + return; + } + installHooks(harness); + finishInstall({ hooks: [harness] }); +} +async function promptInstallBare() { + if (!isTTY()) { + failNonTTY("interactive install requires a TTY.", [ + "Skill: npx @dosx/agent-memory install skill", + "Hooks: npx @dosx/agent-memory install hooks " + ]); + } + const mode = await selectPrompt("What do you want to install?", [ + { label: "Skill + hooks", value: "both" }, + { label: "Skill only", value: "skill" }, + { label: "Hooks only", value: "hooks" } + ]); + if (mode === "skill") { + printHeader("install · skill"); + finishInstall({ skillPath: installSkill(), hooks: [] }); + return; + } + const selected = await multiSelectPrompt("Select harnesses (Space to toggle):", harnessOptions()); + printHeader(mode === "both" ? `install · skill + ${selected.join(", ")}` : `install · hooks · ${selected.join(", ")}`); + const report = { hooks: selected }; + if (mode === "both") { + report.skillPath = installSkill(); + } + installHooksMany(selected); + finishInstall(report); +} +async function promptHooksMultiSelect() { + if (!isTTY()) { + failNonTTY("interactive install hooks requires a TTY.", [ + "Hooks: npx @dosx/agent-memory install hooks " + ]); + } + const selected = await multiSelectPrompt("Select harnesses (Space to toggle):", harnessOptions()); + printHeader(`install · hooks · ${selected.join(", ")}`); + installHooksMany(selected); + finishInstall({ hooks: selected }); +} +async function main(argv) { + const args = argv.slice(2); + if (args.length === 0 || args[0] === "help" || args[0] === "--help" || args[0] === "-h") { + printHelp(); + return; + } + if (args[0] === "update") { + await cmdUpdate(parseUpdateFlags(args.slice(1))); + return; + } + if (args[0] !== "install") { + console.error(`${c.red("error:")} unknown command: ${args[0]}`); + printHelp(); + process.exit(1); + } + const rest = args.slice(1); + if (rest.length === 0) { + await promptInstallBare(); + return; + } + if (rest[0] === "skill") { + if (rest.length > 1) { + console.error(`${c.red("error:")} install skill does not accept arguments`); + process.exit(1); + } + printHeader("install · skill"); + finishInstall({ skillPath: installSkill(), hooks: [] }); + return; + } + if (rest[0] === "hooks") { + if (rest.length === 1) { + await promptHooksMultiSelect(); + return; + } + const raw = rest[1]; + if (rest.length > 2) { + console.error(`${c.red("error:")} unexpected argument: ${rest[2]}`); + process.exit(1); + } + const harness2 = normalizeHarness(raw); + if (!harness2) { + console.error(`${c.red("error:")} unknown harness: ${raw}`); + printHelp(); + process.exit(1); + } + printHeader(`install · hooks · ${harness2}`); + installHooks(harness2); + finishInstall({ hooks: [harness2] }); + return; + } + const harness = normalizeHarness(rest[0]); + if (harness) { + if (rest.length > 1) { + console.error(`${c.red("error:")} unexpected argument: ${rest[1]}`); + process.exit(1); + } + await promptInstallChoice(harness); + return; + } + console.error(`${c.red("error:")} unknown install target: ${rest[0]}`); + printHelp(); + process.exit(1); +} +main(process.argv).catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/hooks/README.md b/hooks/README.md index f7d169e..de1d6b9 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -60,6 +60,8 @@ hooks/ Copy **all three** files from `hooks/agent-memory-hooks/` — never sync+session alone. Re-run the installer when hook scripts change. +**Trust boundary:** hooks run scripts from your project directory (same trust model as git hooks). Only install when you trust the project and the `@dosx/agent-memory` package version you install. See [SECURITY.md](../SECURITY.md). + Hooks write **only** `.hook-sync-state` (session, branch, paths, HEAD). They never edit Markdown, promote decisions/learnings, or consolidate. State uses a short portable lock and atomic replace; lock contention is fail-open. The git `pre-commit` hook reminds (stderr, non-blocking) when Checkpoint is behind HEAD or staged work has no `.agents/memory/` change. OpenCode uses a Bun plugin (not `hooks.json`) that spawns the sync script on `session.idle` / `experimental.session.compacting` with an allowlisted env. Details and resolution order for project dir / session id live in the shared scripts and in `instructions.md` → _Harness parity_. diff --git a/hooks/opencode/agent-memory.ts b/hooks/opencode/agent-memory.ts index 939a05b..c57cdf3 100644 --- a/hooks/opencode/agent-memory.ts +++ b/hooks/opencode/agent-memory.ts @@ -14,6 +14,10 @@ import { execFileSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { + assertSafeHookScript, + isValidBindingId, +} from './safe-script'; const HOOKS_DIR = '.opencode/hooks'; const SYNC_SCRIPT = `${HOOKS_DIR}/agent-memory-sync.sh`; @@ -35,10 +39,12 @@ function extractSessionId(input: unknown): string | undefined { props?.sessionID, props?.session_id, ]) { - if (typeof candidate === 'string' && candidate.length > 0) return candidate; + if (typeof candidate === 'string' && candidate.length > 0) { + return isValidBindingId(candidate) ? candidate : undefined; + } } const fromEnv = process.env.AGENT_MEMORY_SESSION_ID; - return fromEnv && fromEnv.length > 0 ? fromEnv : undefined; + return fromEnv && isValidBindingId(fromEnv) ? fromEnv : undefined; } function extractConversationId(input: unknown): string | undefined { @@ -54,7 +60,9 @@ function extractConversationId(input: unknown): string | undefined { props?.conversationID, props?.conversation_id, ]) { - if (typeof candidate === 'string' && candidate.length > 0) return candidate; + if (typeof candidate === 'string' && candidate.length > 0) { + return isValidBindingId(candidate) ? candidate : undefined; + } } return undefined; } @@ -119,8 +127,8 @@ function runScript( conversationId?: string ): boolean { const cwd = process.cwd(); - const scriptPath = path.join(cwd, script); - if (!fs.existsSync(scriptPath)) return false; + const scriptPath = assertSafeHookScript(cwd, script, HOOKS_DIR); + if (!scriptPath) return false; const payload: Record = {}; if (sessionId) payload.session_id = sessionId; else if (conversationId) payload.conversation_id = conversationId; diff --git a/hooks/opencode/safe-script.ts b/hooks/opencode/safe-script.ts new file mode 100644 index 0000000..179c8f7 --- /dev/null +++ b/hooks/opencode/safe-script.ts @@ -0,0 +1,46 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** Session / conversation binding ids from harness stdin or env. */ +const BINDING_ID_RE = /^[A-Za-z0-9._:@/-]{1,128}$/; + +export function isValidBindingId(id: string): boolean { + return BINDING_ID_RE.test(id); +} + +/** + * Resolve a hook script under hooksDir; refuse symlinks and paths outside hooksDir. + * Returns absolute script path, or null when unsafe / missing. + */ +export function assertSafeHookScript( + cwd: string, + scriptRel: string, + hooksDir: string +): string | null { + const scriptPath = path.join(cwd, scriptRel); + let stat: fs.Stats; + try { + stat = fs.lstatSync(scriptPath); + } catch { + return null; + } + if (!stat.isFile() || stat.isSymbolicLink()) return null; + + let scriptReal: string; + let hooksReal: string; + try { + scriptReal = fs.realpathSync(scriptPath); + hooksReal = fs.realpathSync(path.join(cwd, hooksDir)); + } catch { + return null; + } + + const prefix = hooksReal.endsWith(path.sep) + ? hooksReal + : hooksReal + path.sep; + if (scriptReal !== hooksReal && !scriptReal.startsWith(prefix)) { + return null; + } + + return scriptReal; +} diff --git a/package.json b/package.json index f1b06b7..58a1f14 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "agent-memory": "bin/cli.js" }, "scripts": { - "build": "bun build ./install.ts --outfile bin/cli.js --target node --format cjs --banner '#!/usr/bin/env node' --minify", + "build": "bun build ./install.ts --outfile bin/cli.js --target node --format cjs --banner '#!/usr/bin/env node'", "typecheck": "bunx tsc -p tsconfig.json --noEmit", "lint:md": "command -v markdownlint >/dev/null && markdownlint '**/*.md' --ignore node_modules --ignore bin || printf 'skip: markdownlint not installed\\n'", - "build:check": "bun build ./install.ts --outfile /tmp/agent-memory-cli-check.js --target node --format cjs --banner '#!/usr/bin/env node' --minify && cmp -s bin/cli.js /tmp/agent-memory-cli-check.js", - "test": "bash tests/reference-first-contract.sh && bash tests/hooks-checkpoint.sh && bash tests/version-parity.sh && bash tests/hooks-merge.sh && bash tests/cli-install.sh && bash tests/lockfile-only.sh && bash tests/migration-smoke.sh", + "build:check": "bun build ./install.ts --outfile /tmp/agent-memory-cli-check.js --target node --format cjs --banner '#!/usr/bin/env node' && cmp -s bin/cli.js /tmp/agent-memory-cli-check.js", + "test": "bash tests/reference-first-contract.sh && bash tests/hooks-checkpoint.sh && bash tests/version-parity.sh && bash tests/hooks-merge.sh && bash tests/cli-install.sh && bash tests/lockfile-only.sh && bash tests/migration-smoke.sh && bun test tests/opencode-safe-script.test.ts", "check": "bun run typecheck && bun run lint:md && bun run test && bun run build && bun run build:check", "prepublishOnly": "bun run build" }, diff --git a/skills/agent-memory/vendor/memory/index.md b/skills/agent-memory/vendor/memory/index.md index e3611a7..e61bc01 100644 --- a/skills/agent-memory/vendor/memory/index.md +++ b/skills/agent-memory/vendor/memory/index.md @@ -7,7 +7,7 @@ Keep aligned with useful entry points only (what they own + when to read). `/age ## Read first - [current.md](./current.md) — shared active state. -- `active-work/.md` — branch scratchpad when work is resumable (see `instructions.md` → _Branch work_). +- `active-work/.md` — branch scratchpad when work is resumable (see `instructions.md` → _When starting or resuming work_). ## Canonical project sources diff --git a/skills/agent-memory/vendor/memory/instructions.md b/skills/agent-memory/vendor/memory/instructions.md index 11944fa..f8a36c0 100644 --- a/skills/agent-memory/vendor/memory/instructions.md +++ b/skills/agent-memory/vendor/memory/instructions.md @@ -4,9 +4,25 @@ Workspace Memory in `.agents/memory/` is a Git-versioned **recall layer**, not a ## Always load -Harness context must load this file. Before every task, read `index.md` and `current.md`. Read the branch `active-work` file when it exists (create from `active-work/TEMPLATE.md` only when work is resumable across sessions). Follow canonical sources listed in `index.md`; load `decisions.md`, `log.md`, and optional recall only when needed. Keep always-loaded files short: one fact per bullet, update before create, link instead of copy. +Harness context must load this file. Before every task, read `index.md` and `current.md`. Read the branch `active-work` file when it exists. Follow canonical sources in `index.md`; load `decisions.md`, `log.md`, and optional recall only when needed. Keep always-loaded files short: one fact per bullet, update before create, link instead of copy. Hot path: `index.md` + `current.md` + branch `active-work` (when present). `decisions.md`, `log.md`, and `learnings.md` are on-demand. -Hot path: `index.md` + `current.md` + branch `active-work` (when present). `decisions.md`, `log.md`, and `learnings.md` are on-demand. +## Permission boundaries + +| Mode | Scope | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------- | +| READ | `.agents/memory/**` and canonical sources listed in `index.md` | +| WRITE | Versioned Markdown under `.agents/memory/` — **agent only** | +| NEVER | Hooks/plugin create or edit Markdown; invent progress or log bullets; copy docs into memory | +| HUMAN_CHECKPOINT | `/agent-memory consolidate` (promote/prune); resolve conflicting appends in `decisions.md` / `log.md` / `learnings.md` | + +Multi-dev: edit only your `active-work/` (delete on merge); change `current.md` with the PR that changes shared active state; keep `decisions.md` / `learnings.md` / working `log.md` oldest-first and append-oriented — on conflict keep both valid contributions and mark supersession; prune closed log only via consolidate in a dedicated change (never the current session). + +## Precedence + +1. Canonical sources, code, tests, and config beat memory. +2. If memory diverges from an authoritative source: do not pick silently — record under `current.md` _Blockers / attention_ or branch _Blockers_; consult the source or user; then fix or remove the stale entry. +3. Primary write in-turn beats deferring meaning to a later sync. +4. Sync is catch-up only — never invents progress, roadmaps, or log bullets without meaning. ## Authority by information type @@ -20,8 +36,6 @@ No global source of truth — authority follows the fact: - Recent activity → `log.md` - Reusable knowledge with no better source → on-demand `learnings.md` -If memory diverges from an authoritative source: do not pick silently; record under `current.md` _Blockers / attention_ or branch _Blockers_; consult the source or user; then fix or remove the stale entry. - ## Retention gate and lifecycle Before recording: @@ -29,60 +43,35 @@ Before recording: 1. Reusable in another session? If no → skip (keep in the current log only if useful to resume this session, until consolidate). 2. Already in a canonical source? → store only `link + delta/relevance` (optional `relevant when:` / `verified: YYYY-MM-DD` for code/config inferences). 3. Current-task state only? → branch `active-work`; shared active state → `current.md`. -4. Non-trivial decision? → pointer to the project decision system; local fallback in `decisions.md` only when none exists. +4. Non-trivial decision? → pointer to the project decision system; local fallback in `decisions.md` only when none exists. Formats in `decisions.md`. 5. Stable, non-obvious, evidenced, undocumented, secret-free learning or pitfall? → `learnings.md`. 6. Transient, Git-reconstructible, or unevidenced? → do not make durable. -Lifecycle: `active-work → log → canonical pointer | decision | learning | discard`. Record decisions/learnings when discovered; never remove durable knowledge without reason. Delete branch active-work on merge. Only manual `/agent-memory consolidate` may promote or prune closed sessions — never hooks, never mid-session automation, never the current session. - -Legacy mirrors may exist; do not create, auto-delete, or prefer them over canonical sources. Lint/consolidate may propose pointer conversion. +Lifecycle: `active-work → log → canonical pointer | decision | learning | discard`. Record decisions/learnings when discovered; never remove durable knowledge without reason. Delete branch active-work on merge. Only manual `/agent-memory consolidate` may promote or prune closed sessions — never hooks, never mid-session automation, never the current session. Legacy mirrors may exist; do not create, auto-delete, or prefer them over canonical sources. Lint/consolidate may propose pointer conversion. Minimum pointer line: `- [topic] useful delta — source: [doc](../../path); relevant when: trigger; verified: YYYY-MM-DD` -## Branch work +**Log** — one `## [YYYY-MM-DD] [session-id] [type] short outcome` per session with useful outcomes (oldest first); semantic bullets only; never path lists, empty headings, or transcripts. Details and types in `log.md`. -One file per branch, only when work is resumable. Sanitize the real branch name (or `local`) to `active-work/.md` by replacing every character outside `[A-Za-z0-9._-]` with `-`. Copy `TEMPLATE.md`, keep the unsanitized name in the `Branch:` header, replace placeholders with a meaningful task. +**Learning / pitfall** — create `learnings.md` only when the gate passes, link from `index.md`, one line: `- [YYYY-MM-DD] [learning|pitfall] [topic] insight — evidence: path|link; use when: trigger; verified: YYYY-MM-DD; invalidate when: condition.` Append `pending-doc` when it belongs in official docs; keep until that source exists, then pointer or remove via consolidate. Code/config inferences need evidence + date. -Keep these sections current and distinct: +## When starting or resuming work -- _Progress_ — confirmed facts / outcomes -- _Next step_ — one concrete next action -- _Validation_ — exact command + expected result -- _Assumptions / open questions_ — hypotheses, never presented as facts -- _Blockers_ — shared or branch impediments -- _Rejected approaches_ — tried paths that failed, with why -- _References_ — path/link + why it matters (not copied docs) - -Update the `Checkpoint: YYYY-MM-DD @ ` line on every semantic sync (HEAD short SHA from `git`). Do not invent roadmaps in _Handoff_ or _Next step_. - -## Minimum durable formats - -- **Log** — one `## [YYYY-MM-DD] [session-id] [type] short outcome` per session with useful outcomes (oldest first); append semantic bullets only. Never path lists, empty headings, or conversation transcripts. Types live in `log.md`. -- **Decision** — `## [YYYY-MM-DD] Short title` with `Status`, `Source` + `Relevance` (pointer), or local `Context` / `Decision` / `Why` / `Rejected` / `Consequence` when no ADR system. Use `Supersedes` / `Superseded by` when replacing a prior decision; keep the old entry marked superseded. Details in `decisions.md`. -- **Learning / pitfall** — create `learnings.md` only when the gate passes, link from `index.md`, one line: `- [YYYY-MM-DD] [learning|pitfall] [topic] insight — evidence: path|link; use when: trigger; verified: YYYY-MM-DD; invalidate when: condition.` Append `pending-doc` when it belongs in official docs; keep until that source exists, then pointer or remove via consolidate. Code/config inferences need evidence + date. +Create `active-work/.md` only when work is resumable: Next step and Validation can be filled for a future session. Sanitize the real branch name (or `local`) by replacing every character outside `[A-Za-z0-9._-]` with `-`. Copy `active-work/TEMPLATE.md`, keep the unsanitized name in the `Branch:` header, replace placeholders. Keep TEMPLATE sections current and distinct; update `Checkpoint: YYYY-MM-DD @ ` on every semantic sync (HEAD short SHA from `git`). Do not invent roadmaps in _Handoff_ or _Next step_. ## Workflow -**Primary write path (agent, in the turn):** when a turn produces durable progress, update branch `active-work` resume fields (facts vs hypotheses, next step, validation) and append a semantic `log.md` outcome before stopping. Record decisions and gated learnings when discovered; align `index.md` when entry points change. Do not defer meaning to a later sync — hooks only accumulate evidence. +**Primary write path (agent, in the turn):** when a turn produces durable progress, before stopping: update branch `active-work` resume fields (facts vs hypotheses, Next step, Validation) **and** append a semantic `log.md` outcome — or skip both only when the retention gate says the turn left nothing durable. Record decisions and gated learnings when discovered; align `index.md` when entry points change. Do not defer meaning to a later sync — hooks only accumulate evidence. -**Catch-up (`/agent-memory sync`):** at end of turn / before compact / before commit / end of session, or when picking work back up — consistency pass over `current.md`, branch active-work, `log.md`, and `index.md`. It may read `.hook-sync-state` and `git` as evidence but never invents progress or log bullets without meaning. Update `current.md` only when shared active state changed; _Handoff_ must be explicit/evidenced — never an invented roadmap. Sync never replaces decision/learning duties, invents roadmaps, or copies docs. - -You may follow the skill's `references/sync.md` steps and edit those four files directly without invoking the skill command. +**Catch-up (`/agent-memory sync`):** at end of turn / before compact / before commit / end of session, or when picking work back up — consistency pass over `current.md`, branch active-work, `log.md`, and `index.md`. It may read `.hook-sync-state` and `git` as evidence but never invents progress or log bullets without meaning. Update `current.md` only when shared active state changed; _Handoff_ must be explicit/evidenced. Sync never replaces decision/learning duties or copies docs. You may follow the skill's `references/sync.md` steps and edit those four files directly without invoking the skill command. ### Harness parity — memory contract Every supported harness targets the same memory shape. **Context layer** injects the obligation to read/maintain memory; **checkpoint layer** (hooks/plugin) collects **ephemeral evidence only** in `.hook-sync-state` (gitignored). Harness config controls timing, not meaning — see the [hooks README](https://github.com/diegoos/agent-memory/blob/0.1.0/hooks/README.md). Differing outcomes are bugs. -**Hooks own ephemeral evidence only:** session id binding; branch cache; session-cumulative touched paths; `last_processed_head` / commit range markers. Hooks never create or edit Markdown under `.agents/memory/` (no `active-work`, `log.md`, `current.md`, decisions, learnings, or consolidation). - -**Agent owns all versioned Markdown:** create/refine active-work and shared state; semantic log outcomes and headings; progress / next step / validation / assumptions / blockers / rejected approaches / references; source/recall links; decisions; gated learnings/pitfalls. Meaning is written in-turn (primary); sync is catch-up. Without hooks, use the same checkpoints and supply both evidence (from `git`) and meaning — via `/agent-memory sync` or by following `references/sync.md` directly. - -## Multi-developer safety +**Hooks own ephemeral evidence only:** session id binding; branch cache; session-cumulative touched paths; `last_processed_head` / commit range markers. Hooks never create or edit Markdown under `.agents/memory/`. -- `active-work/` — per branch; edit only yours; delete on merge. -- `current.md` — shared; change with the PR that changes shared active state. -- `decisions.md` / `learnings.md` / working `log.md` — oldest-first, append-oriented; on conflict keep both valid contributions and mark supersession when one replaces another. -- Prune closed log only via consolidate in a dedicated change — never hooks, never the current session. +**Agent owns all versioned Markdown:** create/refine active-work and shared state; semantic log outcomes; resume fields (see `active-work/TEMPLATE.md`); source/recall links; decisions; gated learnings/pitfalls. Meaning is written in-turn (primary); sync is catch-up. Without hooks, use the same checkpoints and supply both evidence (from `git`) and meaning — via `/agent-memory sync` or by following `references/sync.md` directly. ## Memory lint boundaries diff --git a/tests/opencode-safe-script.test.ts b/tests/opencode-safe-script.test.ts new file mode 100644 index 0000000..9e836ca --- /dev/null +++ b/tests/opencode-safe-script.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + assertSafeHookScript, + isValidBindingId, +} from '../hooks/opencode/safe-script'; + +const HOOKS_DIR = '.opencode/hooks'; +const SYNC_SCRIPT = `${HOOKS_DIR}/agent-memory-sync.sh`; + +describe('isValidBindingId', () => { + test('accepts harness-style ids', () => { + expect(isValidBindingId('ses_abc')).toBe(true); + expect(isValidBindingId('conv-stable')).toBe(true); + expect(isValidBindingId('a:b/c@d.e-1')).toBe(true); + }); + + test('rejects metacharacters and empty', () => { + expect(isValidBindingId('')).toBe(false); + expect(isValidBindingId('id;rm')).toBe(false); + expect(isValidBindingId('id\nx')).toBe(false); + expect(isValidBindingId('a'.repeat(129))).toBe(false); + }); +}); + +describe('assertSafeHookScript', () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'am-safe-')); + fs.mkdirSync(path.join(tmp, HOOKS_DIR), { recursive: true }); + fs.writeFileSync( + path.join(tmp, SYNC_SCRIPT), + '#!/usr/bin/env bash\nexit 0\n', + { mode: 0o755 } + ); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + test('allows regular file under hooks dir', () => { + const resolved = assertSafeHookScript(tmp, SYNC_SCRIPT, HOOKS_DIR); + expect(resolved).toBe(fs.realpathSync(path.join(tmp, SYNC_SCRIPT))); + }); + + test('refuses symlink escape', () => { + const outside = path.join(tmp, 'outside.sh'); + fs.writeFileSync(outside, '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 }); + fs.unlinkSync(path.join(tmp, SYNC_SCRIPT)); + fs.symlinkSync(outside, path.join(tmp, SYNC_SCRIPT)); + expect(assertSafeHookScript(tmp, SYNC_SCRIPT, HOOKS_DIR)).toBeNull(); + }); + + test('returns null for missing script', () => { + fs.unlinkSync(path.join(tmp, SYNC_SCRIPT)); + expect(assertSafeHookScript(tmp, SYNC_SCRIPT, HOOKS_DIR)).toBeNull(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index f12675b..c291f16 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,5 +8,10 @@ "skipLibCheck": true, "types": ["node", "bun-types"] }, - "include": ["install.ts", "lib/cli/**/*.ts", "hooks/opencode/agent-memory.ts"] + "include": [ + "install.ts", + "lib/cli/**/*.ts", + "hooks/opencode/**/*.ts", + "tests/**/*.ts" + ] }