diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 0683c2533..8a2341797 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -16,7 +16,7 @@ { "name": "continual-learning", "source": "continual-learning", - "description": "Incremental transcript-driven memory updates for AGENTS.md using high-signal bullet points only." + "description": "Incrementally learns durable user preferences and workspace facts from transcript changes and writes them to user-scoped AGENTS.local.md (safe for team repos)." }, { "name": "cursor-team-kit", diff --git a/continual-learning/.cursor-plugin/plugin.json b/continual-learning/.cursor-plugin/plugin.json index 3255db70a..c884d8853 100644 --- a/continual-learning/.cursor-plugin/plugin.json +++ b/continual-learning/.cursor-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "continual-learning", "displayName": "Continual Learning", - "version": "1.0.0", - "description": "Incrementally learns durable user preferences and workspace facts from transcript changes and keeps AGENTS.md up to date with plain bullet points.", + "version": "1.1.0", + "description": "Incrementally learns durable user preferences and workspace facts from transcript changes and writes them to user-scoped AGENTS.local.md (safe for team repos).", "author": { "name": "Eric Zakariasson" }, diff --git a/continual-learning/CHANGELOG.md b/continual-learning/CHANGELOG.md new file mode 100644 index 000000000..3d628e5ee --- /dev/null +++ b/continual-learning/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 1.1.0 + +- Default memory file is `~/.cursor/projects//AGENTS.local.md`. +- Stop-hook followup embeds resolved absolute paths and forbids writing the + repo-tracked `AGENTS.md` unless an allowed shared workspace file is set. +- `preToolUse` denies Write/StrReplace of `## Learned *` sections into + repo-tracked `AGENTS.md` / `CLAUDE.md` / `GEMINI.md`, except the configured + workspace file when it is gitignored or `CONTINUAL_LEARNING_ALLOW_SHARED=1`. +- Cadence/index default to `~/.cursor/projects//continual-learning/`. + Legacy files are copied; tracked originals are not deleted. +- Keep `## Learned Workspace Facts` (no `(local)` heading rename). diff --git a/continual-learning/README.md b/continual-learning/README.md index 410fe9252..015f9abd3 100644 --- a/continual-learning/README.md +++ b/continual-learning/README.md @@ -1,16 +1,33 @@ # Continual Learning -Automatically and incrementally keeps `AGENTS.md` up to date from transcript changes. +Automatically and incrementally keeps a **user-scoped** memory file up to date +from transcript changes. Safe to enable in repositories that already track a +team-owned `AGENTS.md`. + +Default write target: + +```text +~/.cursor/projects//AGENTS.local.md +``` + +`` is the absolute workspace path with the leading `/` stripped and `/` +replaced by `-` (same layout as `agent-transcripts/`). Cursor stacks this file +with the repo `AGENTS.md`. The plugin combines: -- A `stop` hook that decides when to trigger learning. +- A `stop` hook that decides when to trigger learning and embeds absolute + memory/index paths in the followup. - A `continual-learning` skill that orchestrates the learning flow. -- An `agents-memory-updater` subagent that mines new or changed transcripts and updates `AGENTS.md`. +- An `agents-memory-updater` subagent that mines new or changed transcripts + and updates `AGENTS.local.md`. +- A `preToolUse` hook that denies Write/StrReplace of `## Learned *` sections + into a repo-tracked `AGENTS.md` / `CLAUDE.md` / `GEMINI.md`, unless that path + is an explicit shared workspace file that is allowed. It is designed to avoid noisy rewrites by: -- Reading existing `AGENTS.md` first and updating matching bullets in place. +- Reading the existing memory file first and updating matching bullets in place. - Processing only new or changed transcript files. - Writing plain bullet points only (no evidence/confidence metadata). @@ -20,19 +37,43 @@ It is designed to avoid noisy rewrites by: /add-plugin continual-learning ``` +## Safe defaults for team repos + +Learned bullets no longer go into the workspace `AGENTS.md` by default. That +file is often owned by the whole team; writing personal preferences there +creates dirty trees and accidental commits. + +Cadence and the incremental transcript index also default to: + +```text +~/.cursor/projects//continual-learning/ +``` + +instead of `.cursor/hooks/state/` inside the repo. + +Section titles stay: + +- `## Learned User Preferences` +- `## Learned Workspace Facts` + +Existing `AGENTS.local.md` files keep working; headings are not renamed. + ## How it works -On eligible `stop` events, the hook may emit a `followup_message` that asks the agent to run the `continual-learning` skill. +On eligible `stop` events, the hook may emit a `followup_message` that asks the +agent to run the `continual-learning` skill. -The skill is marked `disable-model-invocation: true`, so it will not be auto-selected during normal model invocation. When it does run, it delegates the full memory update flow to `agents-memory-updater`. +The skill is marked `disable-model-invocation: true`, so it will not be +auto-selected during normal model invocation. When it does run, it delegates +the full memory update flow to `agents-memory-updater`. The hook keeps local runtime state in: -- `.cursor/hooks/state/continual-learning.json` (cadence state) +- `~/.cursor/projects//continual-learning/cadence.json` The updater uses an incremental transcript index at: -- `.cursor/hooks/state/continual-learning-index.json` +- `~/.cursor/projects//continual-learning/index.json` ## Trigger cadence @@ -48,7 +89,22 @@ Trial mode defaults (enabled in this plugin hook config): - minimum 15 minutes - automatically expires after 24 hours, then falls back to default cadence -## Optional env overrides +## Configuration (all optional) + +**Memory targets** + +- `CONTINUAL_LEARNING_USER_FILE` — personal memory file. Default: + `~/.cursor/projects//AGENTS.local.md`. +- `CONTINUAL_LEARNING_WORKSPACE_FILE` — opt-in shared file inside the repo. + Unset by default. Refused when the path is in git and not gitignored, + unless `CONTINUAL_LEARNING_ALLOW_SHARED=1`. +- `CONTINUAL_LEARNING_STATE_DIR` — `cadence.json` + `index.json`. Default: + `~/.cursor/projects//continual-learning/`. +- `CONTINUAL_LEARNING_ALLOW_SHARED` — bypass the git-leak check for the + configured workspace file (also allows the `preToolUse` deny hook to let + that write through). + +**Cadence** (unchanged) - `CONTINUAL_LEARNING_MIN_TURNS` (or legacy `CONTINUOUS_LEARNING_MIN_TURNS`) - `CONTINUAL_LEARNING_MIN_MINUTES` (or legacy `CONTINUOUS_LEARNING_MIN_MINUTES`) @@ -57,7 +113,7 @@ Trial mode defaults (enabled in this plugin hook config): - `CONTINUAL_LEARNING_TRIAL_MIN_MINUTES` (or legacy `CONTINUOUS_LEARNING_TRIAL_MIN_MINUTES`) - `CONTINUAL_LEARNING_TRIAL_DURATION_MINUTES` (or legacy `CONTINUOUS_LEARNING_TRIAL_DURATION_MINUTES`) -## Output format in AGENTS.md +## Output format The memory updater writes only: @@ -66,6 +122,18 @@ The memory updater writes only: Each item is a plain bullet point. +## Migration + +Older versions stored cadence/index at +`.cursor/hooks/state/continual-learning.json` and +`continual-learning-index.json`. On first run the plugin copies them into the +user-scoped state dir. Tracked originals are left in place; untracked +originals are removed so they stop showing up in `git status`. + +To restore the old shared-file behavior, set +`CONTINUAL_LEARNING_WORKSPACE_FILE` to the repo `AGENTS.md` and +`CONTINUAL_LEARNING_ALLOW_SHARED=1`. + ## License MIT diff --git a/continual-learning/agents/agents-memory-updater.md b/continual-learning/agents/agents-memory-updater.md index 631429f85..0aa9daf65 100644 --- a/continual-learning/agents/agents-memory-updater.md +++ b/continual-learning/agents/agents-memory-updater.md @@ -1,10 +1,10 @@ --- name: agents-memory-updater -description: Mine high-signal transcript deltas, update `AGENTS.md`, and keep the incremental transcript index in sync. +description: Mine high-signal transcript deltas, update the user-scoped AGENTS.local.md memory file, and keep the incremental transcript index in sync. model: inherit --- -# AGENTS.md memory updater +# Memory updater Own the full memory update flow for continual learning. @@ -12,24 +12,53 @@ Own the full memory update flow for continual learning. Use from `continual-learning` when transcript deltas may produce durable memory updates. +## Target files + +**User memory file** (always written, personal, never committed): + +- Path from the followup message when present; otherwise + `$CONTINUAL_LEARNING_USER_FILE` or + `~/.cursor/projects//AGENTS.local.md`. +- `` is the absolute workspace path with the leading `/` stripped and + remaining `/` replaced by `-`. +- Owns `## Learned User Preferences` and `## Learned Workspace Facts`. +- Do **not** rename the workspace-facts heading to `(local)`. + +**Workspace memory file** (opt-in only): + +- `$CONTINUAL_LEARNING_WORKSPACE_FILE`. Unset by default — skip it. +- Refuse the write when `git check-ignore -q` exits `1` unless + `CONTINUAL_LEARNING_ALLOW_SHARED=1`. + +**Never** write `## Learned User Preferences` or `## Learned Workspace Facts` +into a repo-tracked `AGENTS.md` / `CLAUDE.md` / `GEMINI.md`. + +**Index**: `$CONTINUAL_LEARNING_STATE_DIR/index.json` if set; otherwise +`~/.cursor/projects//continual-learning/index.json`. Prefer the absolute +index path embedded in the followup message. + ## Workflow -1. Read existing `AGENTS.md` first. If it does not exist, create it with only: +1. Resolve target paths from the followup message when present. +2. Read the existing user memory file. If it does not exist, create it with only: - `## Learned User Preferences` - `## Learned Workspace Facts` -2. Load the incremental index if present. -3. Inspect only transcript files under `~/.cursor/projects//agent-transcripts/` that are new or have newer mtimes than the index. -4. Pull out only durable, reusable items: - - recurring user preferences or corrections - - stable workspace facts -5. Update `AGENTS.md` carefully: +3. Load the incremental index. +4. Inspect only transcript files under + `~/.cursor/projects//agent-transcripts/` that are new or + have newer mtimes than the index. +5. Extract only durable, reusable items. +6. Update the user memory file: - update matching bullets in place - add only net-new bullets - deduplicate semantically similar bullets - - keep each learned section to at most 12 bullets -6. Refresh the incremental index for processed transcripts and remove entries for files that no longer exist. -7. If the merge produces no `AGENTS.md` changes, leave `AGENTS.md` unchanged but still refresh the index. -8. If no meaningful updates exist, respond exactly: `No high-signal memory updates.` + - keep each section to at most 12 bullets +7. Refresh the index for processed transcripts and remove entries for files + that no longer exist. +8. If the merge produces no file changes, leave the memory file unchanged + but still refresh the index. +9. If no meaningful updates exist, respond exactly: + `No high-signal memory updates.` ## Guardrails @@ -37,11 +66,10 @@ Use from `continual-learning` when transcript deltas may produce durable memory - Keep only these sections: - `## Learned User Preferences` - `## Learned Workspace Facts` -- Do not write evidence/confidence tags. -- Do not write process instructions, rationale, or metadata blocks. +- Do not write evidence/confidence tags, process instructions, rationale, or metadata. - Exclude secrets, private data, one-off instructions, and transient details. ## Output -- Updated `AGENTS.md` and `.cursor/hooks/state/continual-learning-index.json` when needed +- Updated the user memory file and the index when needed. - Otherwise exactly `No high-signal memory updates.` diff --git a/continual-learning/hooks/block-repo-agents-md.mjs b/continual-learning/hooks/block-repo-agents-md.mjs new file mode 100644 index 000000000..dd0a4a3ae --- /dev/null +++ b/continual-learning/hooks/block-repo-agents-md.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * preToolUse: deny Write/StrReplace that append Learned sections to a + * repo-tracked AGENTS.md / CLAUDE.md / GEMINI.md, unless that path is the + * configured workspace file and the write is allowed. + */ + +import { spawnSync } from "node:child_process"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; + +const BLOCKED_NAMES = new Set(["AGENTS.md", "CLAUDE.md", "GEMINI.md"]); +const LEARNED_RE = /## Learned (User Preferences|Workspace Facts)/; + +function parseBoolean(value) { + if (!value) { + return false; + } + const normalized = value.trim().toLowerCase(); + return ( + normalized === "1" || + normalized === "true" || + normalized === "yes" || + normalized === "on" + ); +} + +function expandPath(value, workspaceCwd) { + let expanded = value; + if (expanded.startsWith("~/") || expanded === "~") { + expanded = expanded === "~" ? homedir() : join(homedir(), expanded.slice(2)); + } + return isAbsolute(expanded) ? expanded : resolve(workspaceCwd, expanded); +} + +function collectEditText(toolInput) { + if (!toolInput || typeof toolInput !== "object") { + return ""; + } + const chunks = []; + for (const key of ["contents", "new_string", "old_string"]) { + if (typeof toolInput[key] === "string") { + chunks.push(toolInput[key]); + } + } + if (Array.isArray(toolInput.edits)) { + for (const edit of toolInput.edits) { + if (edit && typeof edit.new_string === "string") { + chunks.push(edit.new_string); + } + } + } + return chunks.join("\n"); +} + +function extractPath(data) { + const toolInput = data.tool_input ?? data.arguments ?? {}; + const candidates = [ + data.file_path, + data.path, + toolInput.path, + toolInput.file_path, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.length > 0) { + return candidate; + } + } + return ""; +} + +function isLearnedAgentFile(filePath) { + const name = basename(filePath); + return BLOCKED_NAMES.has(name); +} + +function isInsideGitWorkTree(filePath) { + const result = spawnSync( + "git", + ["-C", dirname(resolve(filePath)), "rev-parse", "--is-inside-work-tree"], + { stdio: "ignore" } + ); + return result.status === 0; +} + +function isGitIgnored({ workspaceCwd, filePath }) { + const result = spawnSync( + "git", + ["-C", workspaceCwd, "check-ignore", "-q", "--", filePath], + { stdio: "ignore" } + ); + return result.status === 0; +} + +function isAllowedSharedWorkspaceFile(filePath) { + const raw = process.env.CONTINUAL_LEARNING_WORKSPACE_FILE; + if (!raw || !raw.trim()) { + return false; + } + const workspaceCwd = process.cwd(); + const configured = expandPath(raw.trim(), workspaceCwd); + if (resolve(filePath) !== resolve(configured)) { + return false; + } + if (parseBoolean(process.env.CONTINUAL_LEARNING_ALLOW_SHARED)) { + return true; + } + return isGitIgnored({ workspaceCwd, filePath: configured }); +} + +const raw = await new Promise((resolvePromise) => { + const chunks = []; + process.stdin.on("data", (chunk) => chunks.push(chunk)); + process.stdin.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8"))); +}); + +let data = {}; +try { + data = raw.trim() ? JSON.parse(raw) : {}; +} catch { + console.log("{}"); + process.exit(0); +} + +const filePath = extractPath(data); +const toolInput = data.tool_input ?? data.arguments ?? {}; +const editText = collectEditText(toolInput); + +if ( + isLearnedAgentFile(filePath) && + LEARNED_RE.test(editText) && + isInsideGitWorkTree(filePath) && + !isAllowedSharedWorkspaceFile(filePath) +) { + console.log( + JSON.stringify({ + permission: "deny", + agent_message: + "Do not write ## Learned User Preferences / ## Learned Workspace Facts into the repo-tracked AGENTS.md. Write those bullets only to ~/.cursor/projects//AGENTS.local.md (or the configured workspace file when it is allowed).", + user_message: + "Blocked a continual-learning write to a tracked agent file. Memory belongs in AGENTS.local.md unless a shared workspace file is explicitly allowed.", + }) + ); + process.exit(0); +} + +console.log("{}"); diff --git a/continual-learning/hooks/continual-learning-stop.ts b/continual-learning/hooks/continual-learning-stop.ts index fa6f5d95b..3967ca04b 100644 --- a/continual-learning/hooks/continual-learning-stop.ts +++ b/continual-learning/hooks/continual-learning-stop.ts @@ -1,22 +1,17 @@ /// import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { dirname } from "node:path"; import { stdin } from "bun"; -const STATE_PATH = resolve(".cursor/hooks/state/continual-learning.json"); -const INCREMENTAL_INDEX_PATH = resolve( - ".cursor/hooks/state/continual-learning-index.json" -); +import { migrateLegacyState, resolveMemoryTargets } from "./lib/memory-targets.ts"; + const DEFAULT_MIN_TURNS = 10; const DEFAULT_MIN_MINUTES = 120; const TRIAL_DEFAULT_MIN_TURNS = 3; const TRIAL_DEFAULT_MIN_MINUTES = 15; const TRIAL_DEFAULT_DURATION_MINUTES = 24 * 60; -const FOLLOWUP_MESSAGE = - `Run the \`continual-learning\` skill now. Use the \`agents-memory-updater\` subagent for the full memory update flow. Use incremental transcript processing with index file \`${INCREMENTAL_INDEX_PATH}\`: only consider transcripts not in the index or transcripts whose mtime is newer than indexed mtime. Have the subagent refresh index mtimes, remove entries for deleted transcripts, and update \`AGENTS.md\` only for high-signal recurring user corrections and durable workspace facts. Exclude one-off/transient details and secrets. If no meaningful updates exist, respond exactly: No high-signal memory updates.`; - interface StopHookInput { conversation_id: string; generation_id?: string; @@ -62,7 +57,7 @@ function readEnvValue(primary: string, legacy: string): string | undefined { return process.env[primary] ?? process.env[legacy]; } -function loadState(): ContinuousLearningState { +function loadState(statePath: string): ContinuousLearningState { const fallback: ContinuousLearningState = { version: 1, lastRunAtMs: 0, @@ -72,12 +67,12 @@ function loadState(): ContinuousLearningState { trialStartedAtMs: null, }; - if (!existsSync(STATE_PATH)) { + if (!existsSync(statePath)) { return fallback; } try { - const raw = readFileSync(STATE_PATH, "utf-8"); + const raw = readFileSync(statePath, "utf-8"); const parsed = JSON.parse(raw) as Partial; if (parsed.version !== 1) { return fallback; @@ -114,12 +109,12 @@ function loadState(): ContinuousLearningState { } } -function saveState(state: ContinuousLearningState): void { - const directory = dirname(STATE_PATH); +function saveState(statePath: string, state: ContinuousLearningState): void { + const directory = dirname(statePath); if (!existsSync(directory)) { mkdirSync(directory, { recursive: true }); } - writeFileSync(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf-8"); + writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf-8"); } function getTranscriptMtimeMs(transcriptPath: string | null | undefined): number | null { @@ -143,10 +138,47 @@ async function parseHookInput(): Promise { return JSON.parse(text) as T; } +function buildFollowupMessage( + targets: ReturnType +): string { + const sharedWorkspaceFileAllowed = Boolean( + targets.workspaceFile && !targets.workspaceFileBlocked + ); + + const workspaceLine = targets.workspaceFile + ? targets.workspaceFileBlocked + ? `Workspace memory file (\`${targets.workspaceFile}\`) is BLOCKED: ${targets.workspaceFileBlockReason} Do not write that file. Put workspace facts in the user memory file under \`## Learned Workspace Facts\`.` + : `Opted-in workspace memory file (shared, may be committed): \`${targets.workspaceFile}\`. Write only durable team-shareable workspace facts here.` + : "No workspace memory file is configured. Do NOT write any file inside the git workspace, especially not the repo-tracked AGENTS.md."; + + const routingLine = sharedWorkspaceFileAllowed + ? `Write user preferences to the user memory file. Workspace facts may go to the opted-in workspace memory file (\`${targets.workspaceFile}\`); anything not team-shareable stays in the user memory file. Never append \`## Learned User Preferences\` or \`## Learned Workspace Facts\` to any other repo-tracked AGENTS.md / CLAUDE.md / GEMINI.md.` + : "Write learned memory ONLY to the user memory file. Never append `## Learned User Preferences` or `## Learned Workspace Facts` to a repo-tracked AGENTS.md / CLAUDE.md / GEMINI.md."; + + return [ + "Run the `continual-learning` skill now. Use the `agents-memory-updater` subagent for the full memory update flow.", + "", + "Resolved targets for this run:", + `- User memory file (personal, never committed): \`${targets.userFile}\``, + `- ${workspaceLine}`, + `- Incremental transcript index: \`${targets.indexFile}\``, + "", + routingLine, + "Keep existing section titles: `## Learned User Preferences` and `## Learned Workspace Facts` (do not rename to a `(local)` heading).", + "", + "Use incremental transcript processing: only consider transcripts not in the index, or transcripts whose mtime is newer than the indexed mtime. After processing, refresh index mtimes and remove entries for deleted transcripts.", + "", + "Exclude one-off / transient details and secrets. If no meaningful updates exist, respond exactly: No high-signal memory updates.", + ].join("\n"); +} + async function main(): Promise { try { const input = await parseHookInput(); - const state = loadState(); + const targets = resolveMemoryTargets(); + migrateLegacyState(targets); + + const state = loadState(targets.cadenceFile); if (input.generation_id && input.generation_id === state.lastProcessedGenerationId) { console.log(JSON.stringify({})); @@ -222,18 +254,18 @@ async function main(): Promise { state.lastRunAtMs = now; state.turnsSinceLastRun = 0; state.lastTranscriptMtimeMs = transcriptMtimeMs; - saveState(state); + saveState(targets.cadenceFile, state); console.log( JSON.stringify({ - followup_message: FOLLOWUP_MESSAGE, + followup_message: buildFollowupMessage(targets), }) ); return 0; } state.turnsSinceLastRun = turnsSinceLastRun; - saveState(state); + saveState(targets.cadenceFile, state); console.log(JSON.stringify({})); return 0; } catch (error) { diff --git a/continual-learning/hooks/hooks.json b/continual-learning/hooks/hooks.json index 11a0f739f..25141578c 100644 --- a/continual-learning/hooks/hooks.json +++ b/continual-learning/hooks/hooks.json @@ -5,6 +5,12 @@ { "command": "bun run ${CURSOR_PLUGIN_ROOT}/hooks/continual-learning-stop.ts" } + ], + "preToolUse": [ + { + "command": "node ${CURSOR_PLUGIN_ROOT}/hooks/block-repo-agents-md.mjs", + "matcher": "Write|StrReplace" + } ] } } diff --git a/continual-learning/hooks/lib/memory-targets.ts b/continual-learning/hooks/lib/memory-targets.ts new file mode 100644 index 000000000..cfb9d4d2d --- /dev/null +++ b/continual-learning/hooks/lib/memory-targets.ts @@ -0,0 +1,170 @@ +/// + +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; + +/** + * On-disk locations this plugin writes to. + * + * Default is always the personal file under ~/.cursor/projects// so a + * team-owned AGENTS.md is never the learning target. + */ +export interface MemoryTargets { + readonly workspaceCwd: string; + readonly workspaceSlug: string; + readonly stateDir: string; + readonly cadenceFile: string; + readonly indexFile: string; + readonly userFile: string; + readonly workspaceFile: string | null; + readonly workspaceFileBlocked: boolean; + readonly workspaceFileBlockReason: string | null; +} + +export function computeWorkspaceSlug(workspaceCwd: string): string { + const abs = resolve(workspaceCwd); + return abs.replace(/^\//, "").replace(/\//g, "-"); +} + +function readEnv(name: string): string | undefined { + const raw = process.env[name]; + return raw && raw.trim().length > 0 ? raw : undefined; +} + +function parseBoolean(value: string | undefined): boolean { + if (!value) { + return false; + } + const normalized = value.trim().toLowerCase(); + return ( + normalized === "1" || + normalized === "true" || + normalized === "yes" || + normalized === "on" + ); +} + +function expandPath(value: string, workspaceCwd: string): string { + let expanded = value; + if (expanded.startsWith("~/") || expanded === "~") { + expanded = expanded === "~" ? homedir() : join(homedir(), expanded.slice(2)); + } + return isAbsolute(expanded) ? expanded : resolve(workspaceCwd, expanded); +} + +/** + * git check-ignore -q: exit 0 = ignored, 1 = not ignored (would leak if written). + */ +export function isPathTeamShared(args: { + workspaceCwd: string; + filePath: string; +}): boolean { + const result = spawnSync( + "git", + ["-C", args.workspaceCwd, "check-ignore", "-q", "--", args.filePath], + { stdio: "ignore" } + ); + if (result.error || typeof result.status !== "number") { + return false; + } + return result.status === 1; +} + +export function resolveMemoryTargets(workspaceCwd = process.cwd()): MemoryTargets { + const slug = computeWorkspaceSlug(workspaceCwd); + const userProjectRoot = join(homedir(), ".cursor", "projects", slug); + + const stateDir = readEnv("CONTINUAL_LEARNING_STATE_DIR") + ? expandPath(readEnv("CONTINUAL_LEARNING_STATE_DIR")!, workspaceCwd) + : join(userProjectRoot, "continual-learning"); + + const userFile = readEnv("CONTINUAL_LEARNING_USER_FILE") + ? expandPath(readEnv("CONTINUAL_LEARNING_USER_FILE")!, workspaceCwd) + : join(userProjectRoot, "AGENTS.local.md"); + + const allowShared = parseBoolean(readEnv("CONTINUAL_LEARNING_ALLOW_SHARED")); + + let workspaceFile: string | null = null; + let workspaceFileBlocked = false; + let workspaceFileBlockReason: string | null = null; + + const workspaceFileRaw = readEnv("CONTINUAL_LEARNING_WORKSPACE_FILE"); + if (workspaceFileRaw) { + workspaceFile = expandPath(workspaceFileRaw, workspaceCwd); + if (!allowShared && isPathTeamShared({ workspaceCwd, filePath: workspaceFile })) { + workspaceFileBlocked = true; + workspaceFileBlockReason = + `${workspaceFile} is inside a git repo and not gitignored; ` + + "set CONTINUAL_LEARNING_ALLOW_SHARED=1 to write to it anyway."; + } + } + + return { + workspaceCwd: resolve(workspaceCwd), + workspaceSlug: slug, + stateDir, + cadenceFile: join(stateDir, "cadence.json"), + indexFile: join(stateDir, "index.json"), + userFile, + workspaceFile, + workspaceFileBlocked, + workspaceFileBlockReason, + }; +} + +/** + * Copy legacy repo state into the user-scoped dir. Do not delete the originals + * when they are tracked — that would create a noisy git diff. + */ +export function migrateLegacyState(targets: MemoryTargets): void { + const legacyCadence = resolve( + targets.workspaceCwd, + ".cursor/hooks/state/continual-learning.json" + ); + const legacyIndex = resolve( + targets.workspaceCwd, + ".cursor/hooks/state/continual-learning-index.json" + ); + + const pairs: ReadonlyArray<[string, string]> = [ + [legacyCadence, targets.cadenceFile], + [legacyIndex, targets.indexFile], + ]; + + for (const [from, to] of pairs) { + if (!existsSync(from)) { + continue; + } + try { + const contents = readFileSync(from, "utf-8"); + mkdirSync(dirname(to), { recursive: true }); + if (!existsSync(to)) { + writeFileSync(to, contents, "utf-8"); + } + const backup = join(targets.stateDir, `legacy.${basename(from)}`); + if (!existsSync(backup)) { + writeFileSync(backup, contents, "utf-8"); + } + if (!isGitTracked({ workspaceCwd: targets.workspaceCwd, filePath: from })) { + try { + rmSync(from, { force: true }); + } catch { + // best effort + } + } + } catch { + // best effort + } + } +} + +function isGitTracked(args: { workspaceCwd: string; filePath: string }): boolean { + const result = spawnSync( + "git", + ["-C", args.workspaceCwd, "ls-files", "--error-unmatch", "--", args.filePath], + { stdio: "ignore" } + ); + return result.status === 0; +} diff --git a/continual-learning/skills/continual-learning/SKILL.md b/continual-learning/skills/continual-learning/SKILL.md index f1beb5c7e..ceedc3c41 100644 --- a/continual-learning/skills/continual-learning/SKILL.md +++ b/continual-learning/skills/continual-learning/SKILL.md @@ -1,16 +1,18 @@ --- name: continual-learning -description: Orchestrate continual learning by delegating transcript mining and AGENTS.md updates to `agents-memory-updater`. +description: Orchestrate continual learning by delegating transcript mining and AGENTS.local.md updates to `agents-memory-updater`. disable-model-invocation: true --- # Continual Learning -Keep `AGENTS.md` current by delegating the memory update flow to one subagent. +Keep the user-scoped memory file current by delegating the memory update flow +to one subagent. ## Trigger -Use when the user asks to mine prior chats, maintain `AGENTS.md`, or run the continual-learning loop. +Use when the user asks to mine prior chats, maintain memory, or run the +continual-learning loop. ## Workflow @@ -22,3 +24,5 @@ Use when the user asks to mine prior chats, maintain `AGENTS.md`, or run the con - Keep the parent skill orchestration-only. - Do not mine transcripts or edit files in the parent flow. - Do not bypass the subagent. +- The updater must write `~/.cursor/projects//AGENTS.local.md`, never + the repo-tracked `AGENTS.md`.