Skills로 돌아가기
stark-ai-de/agent-skills실행 전 동작 확인

SKILL DETAIL

cursor-memory-curator

stark-ai-de/agent-skills/cursor-memory-curator

Audit and safely curate Cursor rules and provided memory-bank context. Use when reviewing stale, conflicting, sensitive, or misplaced Cursor instructions, not generic repository docs.

설치 수 · 1,605출처 보기

Installation

npx skills add https://github.com/stark-ai-de/agent-skills --skill cursor-memory-curator

스킬 파일

SKILL.md

최근 동기화 · 2026. 10. 1.

assets/cleanup-plan-template.md›
# Cursor Context Cleanup Plan

Embed this structure in the `Plan` section of the selected route's chat result or single file record. Do not persist it as a second curation artifact.

## Scope

- Repo:
- Requested by:
- Created:
- Plan approval status and evidence: record current or earlier approval of this unchanged plan
- Host Plan state / actual write permission:
- Material drift since approval:
- Backup required before edits: yes

## Entries

| ID  | Source | Line | Current claim | Classification | Risk tags | Confidence | Conflict source | Proposed action | Proposed replacement | Approved |
| --- | ------ | ---- | ------------- | -------------- | --------- | ---------- | --------------- | --------------- | -------------------- | -------- |

## Manual Actions

| ID  | Surface | Recommendation | Reason |
| --- | ------- | -------------- | ------ |

## Backup Contract

- Mode: `exact`
- Storage policy: `outside-git-worktree`; default to the deterministic per-repository user-state root
- Explicit storage override: `--backup-root PATH --backup-root-alias NAME` only when PATH is outside every Git worktree and NAME is a stable non-sensitive alias
- Repository receipt locator: script-reported portable storage locator plus manifest-relative paths only; absolute paths stay in non-persisted chat
- Exact include paths: one entry for every file that may change
- Required manifest: `backup-manifest.json`
- Receipt rule: Every pre-existing changed file must match exactly one verified manifest source entry; an approved new file is `created-no-preimage` with an explicit rollback

## Edit Rules

- Apply only rows with `Approved` set to `yes` in a plan-run route; direct cleanup uses a separately identified high-confidence atomic set.
- Back up only the exact Cursor context files that may change before edits.
- Do not edit User Rules or Team Rules unless a documented filesystem-backed artifact or explicit exported file path is available.
- Redact sensitive values in reports and diffs.
- Re-read changed sections after edits.
assets/review-report-template.md›
# Cursor Memory Curation Record

Use this entire template as the one redacted record for file routes. Use `not applicable` with a reason for phases the route does not perform. Never create a separate report, plan, or receipt file for the same selection.

## Review

## Top Decisions

1.
2.
3.

## Summary

- Cursor context files inspected:
- Entries extracted:
- Keep:
- Rewrite:
- Move to Cursor Project Rule:
- Move to AGENTS.md:
- Move to repo docs:
- Move to Cursor User Rules:
- Move to Cursor Team Rules:
- Move to skill:
- Move to config:
- Delete:
- Ask user:

## Highest-Risk Context

| ID  | Source | Risk | Recommendation |
| --- | ------ | ---- | -------------- |

## Proposed Cleanup Table

| ID  | Current claim | Classification | Risk tags | Confidence | Reason | Proposed action |
| --- | ------------- | -------------- | --------- | ---------- | ------ | --------------- |

## Conflict Notes

| ID  | Higher source | Conflict | Recommendation |
| --- | ------------- | -------- | -------------- |

## Manual Cursor Settings Actions

- User Rules:
- Team Rules:

## Plan

- Status:
- Approved entries:
- Stop conditions:

## Execution Receipt

- Applied entries:
- Files changed:
- Manifest reconciliation and unmatched paths: each changed file matched exactly one verified source entry; unmatched paths:
- New paths without a preimage (`created-no-preimage`) and rollback:
- Trimmed diff summary:

| Changed path | Backup destination | Bytes | SHA-256 | Verification |
| ------------ | ------------------ | ----: | ------- | ------------ |

Use only manifest-relative backup destinations in this table. Never persist absolute local paths.

## Deferred Work

- Entry:
- Reason:

## Backup

- Portable backup ID (directory basename) or not applicable:
- Portable storage locator (`user-state/.../<backup-id>` or `external-root/<alias>/<backup-id>`):
- Storage policy / root safety (`outside-git-worktree`):
- Mode (`exact` for cleanup):
- Portable manifest locator (`<storage-locator>/backup-manifest.json`):
- Manifest file count / changed-file match count:

Report exact absolute backup and manifest paths only in non-persisted chat, never in this repository record.

## Verification

- Re-read results:
- Backup integrity result:
- Residual risks:

## Recommended Next Action
references/classification-rubric.md›
# Cursor Context Classification Rubric

Use this reference when the classification decision is not obvious from `SKILL.md`.

## Decision Tree

1. Does the claim contain a secret, credential, customer data, private identifier, or sensitive personal data?
   - Classify `DELETE`.
   - Tag `sensitive`.
   - Do not print the full value.
2. Is the claim false, stale, one-off, duplicated, or tied to an old branch/debugging session?
   - Classify `DELETE` unless a scoped rewrite preserves durable value.
3. Is the claim a project-specific Cursor instruction that benefits from rule metadata or file scoping?
   - Classify `MOVE TO CURSOR PROJECT RULE`.
4. Is the claim a simple repo instruction that should work across agents?
   - Classify `MOVE TO AGENTS.md`.
5. Is the claim longer architecture or operations context?
   - Classify `MOVE TO REPO DOCS`.
6. Is the claim a stable user-wide Cursor preference?
   - Classify `MOVE TO CURSOR USER RULES`.
7. Is the claim a durable team-wide standard?
   - Classify `MOVE TO CURSOR TEAM RULES`.
8. Is the claim a reusable on-demand workflow?
   - Classify `MOVE TO SKILL`.
9. Is the claim a tool setting or runtime toggle?
   - Classify `MOVE TO CONFIG`.
10. Is the claim useful but too broad, vague, or forceful?

- Classify `KEEP BUT REWRITE`.

11. If evidence is insufficient, classify `ASK USER`.

## Primary Classifications

| Classification                | Use when                                                    | Typical action                              |
| ----------------------------- | ----------------------------------------------------------- | ------------------------------------------- |
| `KEEP`                        | Accurate durable context in the right Cursor surface        | Leave as-is                                 |
| `KEEP BUT REWRITE`            | Useful but broad, vague, stale, duplicated, or over-strong  | Rewrite with scope and verification wording |
| `MOVE TO CURSOR PROJECT RULE` | Repo guidance needs Cursor `.mdc` metadata or file scoping  | Create or update `.cursor/rules/*.mdc`      |
| `MOVE TO AGENTS.md`           | Simple repo instruction should work across agent runtimes   | Add or move to root or nested `AGENTS.md`   |
| `MOVE TO REPO DOCS`           | Detailed architecture or operations context                 | Move to repo docs or ADRs                   |
| `MOVE TO CURSOR USER RULES`   | Stable user-wide Cursor preference                          | Give manual settings action                 |
| `MOVE TO CURSOR TEAM RULES`   | Durable team-wide Cursor standard                           | Give manual team settings action            |
| `MOVE TO SKILL`               | Reusable workflow should load on demand                     | Create or update a skill                    |
| `MOVE TO CONFIG`              | Runtime setting, MCP setting, tool toggle, or environment   | Move to config and remove from rules        |
| `DELETE`                      | Harmful, stale, duplicated, sensitive, ignored, conflicting | Remove after approval and backup            |
| `ASK USER`                    | Cannot be safely classified from available evidence         | Ask one focused question before editing     |

## Risk Tags

Use risk tags as secondary context only:

- `sensitive`
- `stale`
- `duplicated`
- `too-broad`
- `too-specific`
- `repo-specific`
- `workflow`
- `config`
- `conflicting`
- `ignored`
- `legacy`
- `useful`

## Rewrite Guidance

Prefer scoped rules over absolute rules.

| Over-strong claim                         | Safer rewrite                                                                 |
| ----------------------------------------- | ----------------------------------------------------------------------------- |
| Always use pnpm.                          | Prefer pnpm when the repo has pnpm lockfiles or package metadata.             |
| Never edit generated files.               | Do not hand-edit generated files listed by this repo; regenerate from source. |
| Must use React server components.         | Follow the repo's current Next.js rendering conventions for the touched area. |
| Cursor should remember every past choice. | Persist durable project rules only when they remain true for future work.     |

## Confidence

| Confidence | Use when                                                              |
| ---------- | --------------------------------------------------------------------- |
| `high`     | Current files prove the claim is correct, stale, ignored, or unsafe.  |
| `medium`   | Evidence strongly suggests an action, but user intent may differ.     |
| `low`      | The claim could encode a preference; classify `ASK USER` when unsure. |
references/conflict-resolution.md›
# Cursor Context Conflict Resolution

Use this when Cursor durable context conflicts with the current task, repo instructions, docs, scripts, source, or settings exports.

## Precedence

Prefer sources in this order:

1. Current user request and direct conversation instructions.
2. Active `AGENTS.md`, Cursor Project Rules, and explicit User or Team Rule exports relevant to the task.
3. Current repository files that define behavior, including package scripts, ADRs, docs, source code, and config.
4. Legacy `.cursorrules`.
5. User-maintained memory-bank files without current supporting evidence.

When two current repo surfaces disagree, prefer the more specific scoped file for the touched path, then report the conflict instead of silently choosing.

## Checks

- Read only the repo files needed to verify the disputed claim.
- Prefer live files over remembered commands, branch names, or generated summaries.
- Treat scoped repo guidance as local; do not generalize it into User Rules or Team Rules.
- Treat Team Rules as shared policy. If an exported Team Rule appears wrong, recommend a manual team-rule review rather than editing it from the repo.
- If evidence is insufficient, classify `ASK USER`.

## Report Shape

Include a short conflict note in the review report or cleanup plan:

| ID  | Cursor context claim | Higher source  | Conflict                              | Recommendation                                            |
| --- | -------------------- | -------------- | ------------------------------------- | --------------------------------------------------------- |
| C-1 | `Run npm test here.` | `package.json` | Current script is `npm run validate`. | Move current command to `AGENTS.md` or delete stale rule. |
references/context-surface-anatomy.md›
# Cursor Context Surface Anatomy

Use this reference when deciding which Cursor surface owns a durable instruction.

## Project Rules

Cursor Project Rules live in `.cursor/rules/` and use `.mdc` files. Treat the YAML frontmatter as behavior metadata:

- `description` describes when an intelligent rule should be considered.
- `globs` scopes rules to file patterns.
- `alwaysApply` marks a rule as broadly applied.

Prefer Project Rules for project-specific guidance that benefits from Cursor rule metadata, file scoping, or manual rule attachment.

Flag plain `.md` files under `.cursor/rules` as rule candidates that lack Cursor Project Rule metadata. Recommend converting them to `.mdc` or moving simple instructions to `AGENTS.md`.

## Legacy `.cursorrules`

Treat `.cursorrules` as legacy project context. If modern `.cursor/rules/*.mdc` files cover the same guidance, classify the older claim as `DELETE`, `KEEP BUT REWRITE`, or migration to a Project Rule.

## `AGENTS.md`

Cursor supports `AGENTS.md` in the project root and subdirectories. Prefer `AGENTS.md` for straightforward agent instructions that should be readable across agent runtimes and do not need Cursor rule metadata.

Nested `AGENTS.md` files are more specific than parent files for work in that subtree.

## User Rules

User Rules are global preferences configured in Cursor settings. Treat them as user-level preferences such as communication style or broad coding conventions.

Do not claim a local editable User Rules file unless current Cursor evidence proves one. If the user provides exported text, review it and return manual settings changes unless they approve editing the exported artifact.

## Team Rules

Team Rules are shared organization or team instructions managed through Cursor. Treat them as high-impact shared policy. Do not edit them directly from repo files unless the user provides an explicit exported artifact and approval.

Classify team-wide standards as `MOVE TO CURSOR TEAM RULES` only when they are durable, non-secret, and broader than one repo.

## Memory-Bank Artifacts

Some users keep Cursor memory-bank files in locations such as `memory-bank/`, `docs/memory/`, or `.cursor/memory-bank/`. There is no assumed official `~/.cursor/memories` store for this skill.

Treat memory-bank files as user-maintained artifacts:

- Review them when the user provides a path.
- Preserve their schema if it is clear.
- Defer the proposal in chat or the single curation record instead of editing or creating a sibling context file when the schema is unclear.

## Source Basis

- Cursor Rules docs: https://cursor.com/docs/rules
- Cursor Agent Skills docs: https://cursor.com/docs/skills
references/example-review-report.md›
# Example Cursor Memory Review

Selected workflow: `review-chat`. This example creates no durable curation report.

## Top Decisions

1. Convert the ignored plain Markdown rule to `.mdc` or move its simple guidance to `AGENTS.md`.
2. Delete the stale package-manager rule because `package.json` and the lockfile prove the repo uses pnpm now.
3. Keep User Rules as a manual settings action because no editable User Rules file was provided.

## Summary

- Cursor context files inspected: 4
- Entries extracted: 6
- Keep: 1
- Rewrite: 1
- Move to Cursor Project Rule: 1
- Move to AGENTS.md: 1
- Delete: 2
- Ask user: 0

## Highest-Risk Context

| ID  | Source                        | Risk      | Recommendation                                  |
| --- | ----------------------------- | --------- | ----------------------------------------------- |
| C-2 | `.cursor/rules/testing.md`    | ignored   | Convert to `.mdc` or move to `AGENTS.md`.       |
| C-4 | `.cursorrules`                | legacy    | Delete after migrating current guidance.        |
| C-5 | `.cursor/rules/deploy.mdc:12` | sensitive | Remove redacted token-like value and rotate it. |

## Proposed Cleanup Table

| ID  | Current claim               | Classification              | Risk tags | Confidence | Reason                         | Proposed action                        |
| --- | --------------------------- | --------------------------- | --------- | ---------- | ------------------------------ | -------------------------------------- |
| C-1 | Use npm for all commands.   | `DELETE`                    | stale     | high       | `package.json` uses pnpm.      | Remove after backup and approval.      |
| C-2 | Testing rule in plain `.md` | `MOVE TO AGENTS.md`         | ignored   | high       | It lacks Cursor rule metadata. | Move short instruction to `AGENTS.md`. |
| C-3 | Use concise responses.      | `MOVE TO CURSOR USER RULES` | useful    | medium     | User-wide preference.          | Add manually in Cursor settings.       |

## Manual Cursor Settings Actions

- User Rules: add concise response preference manually if the user confirms it is global.
- Team Rules: no team-wide changes proposed.

## Recommended Next Action

Invoke a cleanup route explicitly if C-1 and C-2 should be applied; `plan-run-cleanup-file` is Recommended.
references/plan-lifecycle.md›
# Curation Plan Lifecycle

Read for a `plan-*` route, a pending material decision, or a native Plan transition.

The four `plan-*` routes require a reviewable cleanup plan and approval, not a mandatory manual mode switch:

1. Detect native Plan mode and actual permissions separately. Respect active or explicitly requested native Plan mode. Recommend it for substantial ambiguous planning, but continue permissible read-only discovery and conversation when it is inactive, unavailable, declined, or indeterminate; do not claim a mode change.
2. Use the same conversational planning contract without suitable native controls. Unknown mode or permission state never permits writes. Native Plan restrictions apply to reports, backups, and cleanup alike; defer file delivery until writes are permitted.
3. Resolve material choices using available structured or asynchronous question tools, otherwise ordinary conversation. While an answer is pending, continue only independent authorized work. Silence, timeouts, and preselected options are not approval.
4. Prepare the complete reviewable plan with exact paths, report creation, backup, and cleanup actions before requesting any missing approval. Reuse earlier approval of the unchanged plan and named actions. A native approval can cover this checkpoint when it confirms both content and write scope; a mode toggle alone cannot.
5. Before any write, recheck target files and protected state and exit active Plan mode through the host's supported control. Preserve approval across that transition. If content, scope, destination, or target state materially changes, reconfirm only the affected action; otherwise proceed within actual permissions without a second approval.

Plan-only routes still stop after delivering the approved plan. Execution authority, file delivery authority, and host write permission remain separate.
references/safe-editing-procedure.md›
# Safe Editing Procedure

Use this before modifying any Cursor context file.

## Route Authority Gate

Do not mutate unless the selected cleanup route is supported by an explicit user cleanup request for the exact Cursor context scope. A direct cleanup route authorizes only high-confidence atomic changes. A plan-run route authorizes only the unchanged user-approved plan after the state recheck and exit from active Plan mode, with actual write permission. Earlier approval of the same unchanged plan and named writes remains valid; only material content, scope, destination, or target-state drift requires a new decision. Do not add a generic second cleanup question after those gates.

Whole-file deletion, User/Team settings, destructive recovery, external actions, and scope expansion retain separate approval boundaries.

## Backup

Run the bundled script:

```bash
node scripts/backup-cursor-context.mjs --repo . --include PATH [--include PATH ...] [--backup-root PATH --backup-root-alias NAME]
```

Pass every file that may change as an exact repeatable `--include PATH`. Supplying any include selects exact mode and excludes project-rule, `AGENTS.md`, and memory-bank discovery; zero includes retain that legacy discovery and may use `--memory-bank`. Exact paths must be readable regular files, and every explicit memory-bank file or directory must exist and be readable. Any symlink path component, nested discovery symlink, unreadable directory, or traversal failure stops before backup-root creation. Before editing, verify that `backup-manifest.json` contains exactly one source entry for every intended changed file and that the copied size and SHA-256 match.

Unredacted backup payloads and their manifests must remain outside Git worktrees. The script defaults to a deterministic per-repository root below the portable user state directory (`XDG_STATE_HOME` when configured) and reports `outside-git-worktree`. Use `--backup-root PATH --backup-root-alias NAME` only for an external durable location and a stable non-sensitive operator-known alias; an override inside the target or any other Git worktree, a missing alias, or a path-like alias is rejected before root creation or copying. Verify the reported storage root and policy before editing, and never place a backup below the repository report directory.

Report exact absolute backup and manifest paths only in non-persisted chat. A file-route receipt persists the script-reported `user-state/agent-memory-curator-backups/cursor/<identity-hash>/<backup-id>` or `external-root/<alias>/<backup-id>` storage locator, `<storage-locator>/backup-manifest.json`, and manifest-relative destinations; never copy absolute home, repository, source, or storage-root paths into a repository artifact.

Before editing, record the authorized entry IDs from the direct-cleanup set or approved plan. Do not edit an entry outside that set.

## Editing Rules

- Apply only changes authorized by the selected route and exact scope.
- Apply changes by entry ID from the review report or cleanup plan, not by broad pattern.
- Prefer the smallest edit that removes risk.
- Preserve existing file format, headings, frontmatter, and ordering where practical.
- Do not edit User Rules or Team Rules from chat-only summaries.
- Never delete the only copy of a context file.
- Do not print secrets or full sensitive values in diffs or summaries.
- If a line contains a real secret, remove or redact it after backup and tell the user to rotate it.
- Re-read each changed section after editing to verify the approved action was applied.

## Unknown Memory-Bank Schema

If the memory-bank file format is unclear, report:

```text
The Cursor memory-bank file format is unclear. I will not edit it directly. I will defer proposed replacements in the current curation result.
```

Record replacement entries or deletion notes in chat or the single curation record. Do not create a sibling context file during direct cleanup.

## Diff

After approved edits, show only a trimmed diff or summarize the changed paths and actions. If the diff includes sensitive values, summarize the location and action instead of printing the value.

Reconcile the final changed-file set against the manifest. Stop and report the mismatch when any pre-existing changed file lacks exactly one manifest entry or any manifest source was outside the approved edit set. Mark an approved new path `created-no-preimage`, record its rollback, and never describe it as backed up.

Include the approved entry IDs in the summary:

```text
Applied: C-2 MOVE TO AGENTS.md, C-6 DELETE
Skipped: C-4 ASK USER
Manual: C-7 MOVE TO CURSOR USER RULES
```

## Recovery

- If backup creation fails, do not edit.
- For each pre-existing changed file, locate the one manifest row whose `source` is that file. Before restore, verify that its copied `destination` still matches the recorded `size` and `sha256`; restore that file preimage only to the row's exact `source`, then verify the restored size and SHA-256 again.
- A `created-no-preimage` path has no backup. Roll it back separately according to the approved plan; never restore it from another manifest row.
- If manifest lookup, backup verification, restore, or restored-file verification fails, stop immediately and report the manifest path and command output.
- If the user changes scope mid-cleanup, stop and re-confirm the remaining edit set.
scripts/backup-cursor-context.mjs›
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const manifestName = "backup-manifest.json";
const forceNoFollowFallback =
  process.env.NODE_ENV === "test" &&
  process.env.AGENT_MEMORY_CURATOR_TEST_FORCE_NO_NOFOLLOW === "1";
const noFollowFlag = forceNoFollowFallback ? undefined : fs.constants.O_NOFOLLOW;
const nonBlockFlag = fs.constants.O_NONBLOCK;
const directoryFlag = fs.constants.O_DIRECTORY;
const testCheckpointName = process.env.AGENT_MEMORY_CURATOR_TEST_CHECKPOINT;
let testCheckpointOccurrences = 0;

function usage() {
  fs.writeSync(
    1,
    `Usage: backup-cursor-context.mjs [--repo PATH] [--memory-bank PATH] [--backup-root PATH --backup-root-alias NAME] [--include PATH ...]

Create a no-clobber backup of Cursor context files.

Options:
  --repo PATH         Use PATH instead of the current working directory.
  --memory-bank PATH  Discover this readable regular file or directory in legacy mode.
                      May be passed more than once.
  --backup-root PATH  Store backups below this directory instead of the deterministic
                      per-repository user state directory. The root must be outside
                      the target and every other Git worktree.
  --backup-root-alias NAME
                      Required with --backup-root. Use a stable, non-sensitive name
                      for portable recovery records; path separators are rejected.
  --include PATH      Back up one exact regular file. May be passed more than once.
                      Globs, directories, symlinks, and symlinked parents are rejected.
  -h, --help          Show this help.

One or more --include values select exact mode: only the deduplicated explicit
files are copied. With no --include, legacy mode discovers the established Cursor
context surfaces and any --memory-bank inputs. Explicit discovery roots must exist
and be readable. Legacy discovery rejects symlinks, symlinked parents, and directory
traversal errors before backup-root creation. By default, backups use a deterministic
per-repository root below XDG_STATE_HOME (or the user's portable local-state
directory). The script rejects any default or explicit backup root inside a Git
worktree before copying. It atomically creates
.cursor-context.backup.<timestamp>.<suffix>, never overwrites a file, and writes
${manifestName} with size and SHA-256 evidence. It does not edit or delete context
files.\n`,
  );
}

function fail(message, code = 2) {
  fs.writeSync(2, `Error: ${message}\n`);
  process.exit(code);
}

function output(message) {
  fs.writeSync(1, `${message}\n`);
}

function parseArgs() {
  let repo = process.cwd();
  let backupRoot = null;
  let backupRootAlias = null;
  const memoryBanks = [];
  const includePaths = [];
  const args = process.argv.slice(2);

  for (let i = 0; i < args.length; i += 1) {
    const arg = args[i];
    if (arg === "-h" || arg === "--help") {
      usage();
      process.exit(0);
    }
    if (arg === "--repo") {
      const value = args[i + 1];
      if (!value) fail("--repo requires a path.");
      repo = value;
      i += 1;
      continue;
    }
    if (arg === "--memory-bank") {
      const value = args[i + 1];
      if (!value) fail("--memory-bank requires a path.");
      memoryBanks.push(path.resolve(value));
      i += 1;
      continue;
    }
    if (arg === "--backup-root") {
      const value = args[i + 1];
      if (!value) fail("--backup-root requires a path.");
      backupRoot = value;
      i += 1;
      continue;
    }
    if (arg === "--backup-root-alias") {
      const value = args[i + 1];
      if (!value) fail("--backup-root-alias requires a name.");
      backupRootAlias = value;
      i += 1;
      continue;
    }
    if (arg === "--include") {
      const value = args[i + 1];
      if (!value) fail("--include requires a path.");
      includePaths.push(path.resolve(value));
      i += 1;
      continue;
    }
    fail(`unknown argument: ${arg}`);
  }

  if (backupRoot && !backupRootAlias) {
    fail("--backup-root requires --backup-root-alias for portable recovery.");
  }
  if (!backupRoot && backupRootAlias) {
    fail("--backup-root-alias requires --backup-root.");
  }
  if (backupRootAlias && !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(backupRootAlias)) {
    fail("--backup-root-alias must be a portable name without path separators.");
  }

  return {
    backupRoot: backupRoot ? path.resolve(backupRoot) : null,
    backupRootAlias,
    includePaths,
    memoryBanks,
    repo: path.resolve(repo),
  };
}

function pad(value) {
  return String(value).padStart(2, "0");
}

function timestamp() {
  const now = new Date();
  return [
    now.getFullYear(),
    pad(now.getMonth() + 1),
    pad(now.getDate()),
    "-",
    pad(now.getHours()),
    pad(now.getMinutes()),
    pad(now.getSeconds()),
  ].join("");
}

function stateHome() {
  const configured = process.env.XDG_STATE_HOME;
  if (!configured) return path.join(os.homedir(), ".local", "state");
  if (!path.isAbsolute(configured)) fail("XDG_STATE_HOME must be an absolute path.", 1);
  return path.resolve(configured);
}

function identityHash(value) {
  return crypto.createHash("sha256").update(path.resolve(value)).digest("hex").slice(0, 20);
}

function defaultBackupRoot(repo) {
  return path.join(stateHome(), "agent-memory-curator-backups", "cursor", identityHash(repo));
}

function statIdentity(stat) {
  return `${stat.dev}:${stat.ino}:${stat.mode & BigInt(fs.constants.S_IFMT)}`;
}

function statVersion(stat) {
  return `${statIdentity(stat)}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
}

function sameIdentity(left, right) {
  return statIdentity(left) === statIdentity(right);
}

function capturePathSnapshot(value, label) {
  const resolved = path.resolve(value);
  const filesystemRoot = path.parse(resolved).root;
  const components = path.relative(filesystemRoot, resolved).split(path.sep).filter(Boolean);
  const snapshots = [];
  let current = filesystemRoot;

  for (const component of [null, ...components]) {
    if (component !== null) current = path.join(current, component);
    let stat;
    try {
      stat = fs.lstatSync(current, { bigint: true });
    } catch (error) {
      throw new Error(`${label} is not accessible: ${current}: ${error.message}`);
    }
    if (stat.isSymbolicLink()) throw new Error(`${label} found symlink component: ${current}`);
    snapshots.push({ identity: statIdentity(stat), path: current });
  }

  return snapshots;
}

function assertPathSnapshot(value, expected, label) {
  const current = capturePathSnapshot(value, label);
  if (
    current.length !== expected.length ||
    current.some(
      (entry, index) =>
        entry.path !== expected[index]?.path || entry.identity !== expected[index]?.identity,
    )
  ) {
    throw new Error(`${label} path identity changed during backup: ${path.resolve(value)}`);
  }
}

function secureOpenFlags(base, label, { directory = false, nonBlocking = false } = {}) {
  let flags = base;
  if (Number.isInteger(noFollowFlag)) flags |= noFollowFlag;
  if (nonBlocking) {
    if (!Number.isInteger(nonBlockFlag)) {
      throw new Error(`${label} cannot be opened safely because O_NONBLOCK is unavailable`);
    }
    flags |= nonBlockFlag;
  }
  if (directory && Number.isInteger(directoryFlag)) flags |= directoryFlag;
  return flags;
}

function openStableRegularFile(value, label) {
  const resolved = path.resolve(value);
  const before = capturePathSnapshot(resolved, label);
  let descriptor;
  try {
    descriptor = fs.openSync(
      resolved,
      secureOpenFlags(fs.constants.O_RDONLY, label, { nonBlocking: true }),
    );
    const descriptorStat = fs.fstatSync(descriptor, { bigint: true });
    const after = capturePathSnapshot(resolved, label);
    const leafStat = fs.lstatSync(resolved, { bigint: true });
    if (
      !descriptorStat.isFile() ||
      !sameIdentity(descriptorStat, leafStat) ||
      before.length !== after.length ||
      before.some(
        (entry, index) =>
          entry.path !== after[index]?.path || entry.identity !== after[index]?.identity,
      )
    ) {
      throw new Error(`${label} changed while it was opened: ${resolved}`);
    }
    return { descriptor, descriptorStat, pathSnapshot: after, source: resolved };
  } catch (error) {
    if (descriptor !== undefined) fs.closeSync(descriptor);
    throw error;
  }
}

function readStableDescriptor(opened, label) {
  const before = fs.fstatSync(opened.descriptor, { bigint: true });
  const chunks = [];
  const buffer = Buffer.allocUnsafe(64 * 1024);
  let position = 0;
  while (true) {
    const bytesRead = fs.readSync(opened.descriptor, buffer, 0, buffer.length, position);
    if (bytesRead === 0) break;
    chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
    position += bytesRead;
  }
  const after = fs.fstatSync(opened.descriptor, { bigint: true });
  if (statVersion(before) !== statVersion(after)) {
    throw new Error(`${label} changed while it was read: ${opened.source}`);
  }
  return Buffer.concat(chunks);
}

function readStablePath(value, label) {
  const opened = openStableRegularFile(value, label);
  try {
    const content = readStableDescriptor(opened, label);
    assertPathSnapshot(opened.source, opened.pathSnapshot, label);
    return content;
  } finally {
    fs.closeSync(opened.descriptor);
  }
}

function testCheckpoint(name) {
  if (testCheckpointName !== name) return;
  if (process.env.NODE_ENV !== "test") {
    throw new Error("test checkpoints require NODE_ENV=test");
  }
  testCheckpointOccurrences += 1;
  const requestedOccurrence = Number.parseInt(
    process.env.AGENT_MEMORY_CURATOR_TEST_CHECKPOINT_OCCURRENCE ?? "1",
    10,
  );
  if (!Number.isSafeInteger(requestedOccurrence) || requestedOccurrence < 1) {
    throw new Error("test checkpoint occurrence must be a positive integer");
  }
  if (testCheckpointOccurrences !== requestedOccurrence) return;
  fs.writeSync(2, `TEST_CHECKPOINT:${name}\n`);
  const resume = Buffer.alloc(1);
  if (fs.readSync(0, resume, 0, 1, null) !== 1 || resume[0] !== 0x63) {
    throw new Error(`test checkpoint did not receive continue byte: ${name}`);
  }
}

function inspectPath(value, label, { allowMissing = false } = {}) {
  const resolved = path.resolve(value);
  const filesystemRoot = path.parse(resolved).root;
  const components = path.relative(filesystemRoot, resolved).split(path.sep).filter(Boolean);
  let current = filesystemRoot;
  let stat = fs.lstatSync(filesystemRoot);

  for (const component of components) {
    current = path.join(current, component);
    try {
      stat = fs.lstatSync(current);
    } catch (error) {
      if (allowMissing && error.code === "ENOENT") return null;
      throw new Error(`${label} is not accessible: ${current}: ${error.message}`);
    }
    if (stat.isSymbolicLink()) throw new Error(`${label} found symlink component: ${current}`);
  }

  return stat;
}

function requireReadableDirectory(value, label) {
  try {
    const stat = inspectPath(value, label);
    if (!stat.isDirectory()) throw new Error(`${label} must be an existing directory: ${value}`);
    fs.accessSync(value, fs.constants.R_OK | fs.constants.X_OK);
    return stat;
  } catch (error) {
    fail(error.message, 1);
  }
}

function requireReadableRegularFile(value, label) {
  try {
    const opened = openStableRegularFile(value, label);
    fs.closeSync(opened.descriptor);
    return opened.descriptorStat;
  } catch (error) {
    try {
      const stat = inspectPath(value, label);
      if (!stat.isFile()) {
        throw new Error(`${label} must name an existing regular, non-symlink file: ${value}`);
      }
    } catch (typeError) {
      fail(typeError.message, 1);
    }
    fail(error.message, 1);
  }
}

function requireReadableFileOrDirectory(value, label) {
  try {
    const stat = inspectPath(value, label);
    if (!stat.isFile() && !stat.isDirectory()) {
      throw new Error(`${label} must name an existing regular file or directory: ${value}`);
    }
    fs.accessSync(
      value,
      stat.isDirectory() ? fs.constants.R_OK | fs.constants.X_OK : fs.constants.R_OK,
    );
    if (stat.isFile()) {
      const opened = openStableRegularFile(value, label);
      fs.closeSync(opened.descriptor);
    }
    return stat;
  } catch (error) {
    fail(error.message, 1);
  }
}

function isLegacyRegularFile(value) {
  try {
    const stat = inspectPath(value, "legacy discovery", { allowMissing: true });
    if (!stat) return false;
    if (!stat.isFile()) return false;
    const opened = openStableRegularFile(value, "legacy discovery");
    fs.closeSync(opened.descriptor);
    return true;
  } catch (error) {
    fail(error.message, 1);
  }
}

const ignoredDirectories = new Set([
  ".git",
  ".hg",
  ".svn",
  ".next",
  ".turbo",
  "dist",
  "build",
  "coverage",
  "node_modules",
]);

function walk(directory, predicate = () => true) {
  const files = [];

  if (!directory) return files;

  let rootStat;
  try {
    rootStat = inspectPath(directory, "legacy discovery", { allowMissing: true });
  } catch (error) {
    fail(error.message, 1);
  }
  if (!rootStat) return files;
  if (!rootStat.isDirectory()) {
    fail(`legacy discovery root must be a directory: ${directory}`, 1);
  }

  function visit(current) {
    requireReadableDirectory(current, "legacy discovery directory");
    let entries;
    try {
      entries = fs.readdirSync(current, { withFileTypes: true });
    } catch (error) {
      fail(`legacy discovery could not read directory: ${current}: ${error.message}`, 1);
    }

    for (const entry of entries) {
      const fullPath = path.join(current, entry.name);
      const ignored =
        ignoredDirectories.has(entry.name) || entry.name.startsWith(".cursor-context.backup.");
      if (entry.isSymbolicLink()) {
        if (!ignored) fail(`legacy discovery found symlink: ${fullPath}`, 1);
        continue;
      }
      if (entry.isDirectory()) {
        if (!ignored) visit(fullPath);
      } else if (entry.isFile() && predicate(fullPath)) {
        files.push(path.resolve(fullPath));
      }
    }
  }

  visit(directory);
  return files.sort();
}

function collectProjectFiles(repo) {
  return [
    ...walk(path.join(repo, ".cursor", "rules")),
    path.join(repo, ".cursorrules"),
    ...walk(repo, (file) => path.basename(file) === "AGENTS.md"),
  ];
}

function isWithin(root, candidate) {
  const relative = path.relative(root, candidate);
  return (
    relative === "" ||
    (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
  );
}

function findGitWorktreeRoot(start) {
  let current = path.resolve(start);
  while (true) {
    try {
      const markerPath = path.join(current, ".git");
      const marker = fs.lstatSync(markerPath);
      const head = marker.isDirectory() ? fs.lstatSync(path.join(markerPath, "HEAD")) : null;
      if (
        marker.isSymbolicLink() ||
        (head && (head.isFile() || head.isSymbolicLink())) ||
        (marker.isFile() && /^gitdir:\s*\S+/m.test(readStablePath(markerPath, "Git marker")))
      ) {
        return current;
      }
    } catch (error) {
      if (error.code !== "ENOENT" && error.code !== "ENOTDIR") {
        fail(`could not inspect Git worktree boundary at ${current}: ${error.message}`, 1);
      }
    }
    const parent = path.dirname(current);
    if (parent === current) return null;
    current = parent;
  }
}

function enterDirectory(name, { checkpoint = null, create = false, label }) {
  if (!name || name === "." || name === ".." || name.includes(path.sep)) {
    throw new Error(`${label} has an unsafe directory segment: ${name}`);
  }
  const parentBefore = fs.statSync(".", { bigint: true });
  const childPath = path.resolve(name);
  if (create) {
    try {
      fs.mkdirSync(name, { mode: 0o700 });
    } catch (error) {
      if (error.code !== "EEXIST") throw error;
    }
  }
  const before = fs.lstatSync(name, { bigint: true });
  if (before.isSymbolicLink() || !before.isDirectory()) {
    throw new Error(`${label} must contain only real directories: ${name}`);
  }
  process.chdir(name);
  try {
    if (checkpoint) testCheckpoint(checkpoint);
    const current = fs.statSync(".", { bigint: true });
    const after = fs.lstatSync(childPath, { bigint: true });
    const parentAfter = fs.statSync("..", { bigint: true });
    if (
      !current.isDirectory() ||
      !sameIdentity(before, current) ||
      !sameIdentity(after, current) ||
      !sameIdentity(parentBefore, parentAfter)
    ) {
      throw new Error(`${label} directory identity changed while entering: ${name}`);
    }
    return { path: childPath, stat: current };
  } catch (error) {
    let unwindError = null;
    try {
      process.chdir("..");
      const restoredParent = fs.statSync(".", { bigint: true });
      if (!sameIdentity(parentBefore, restoredParent)) {
        unwindError = new Error(`${label} parent identity changed while unwinding: ${name}`);
      }
    } catch (candidate) {
      unwindError = candidate;
    }
    if (unwindError) {
      throw new Error(
        `${error.message}; could not return to the verified ${label} parent: ${unwindError.message}`,
      );
    }
    throw error;
  }
}

function assertStorageCustody(storage, targetRepo) {
  assertPathSnapshot(storage.path, storage.pathSnapshot, "backup root");
  const leafStat = fs.lstatSync(storage.path, { bigint: true });
  if (!sameIdentity(leafStat, storage.stat)) {
    throw new Error(`backup root path no longer names the anchored directory: ${storage.path}`);
  }
  const physicalPath = fs.realpathSync.native(storage.path);
  if (physicalPath !== storage.physicalPath) {
    throw new Error(`backup root physical custody changed: ${storage.path}`);
  }
  const targetBoundary = findGitWorktreeRoot(targetRepo) ?? path.resolve(targetRepo);
  if (isWithin(targetBoundary, physicalPath) || findGitWorktreeRoot(physicalPath)) {
    throw new Error(`backup root moved inside a Git worktree: ${physicalPath}`);
  }
}

function ensurePrivateBackupRoot(value, targetRepo) {
  const resolved = path.resolve(value);
  const targetBoundary = findGitWorktreeRoot(targetRepo) ?? path.resolve(targetRepo);
  const containingWorktree = findGitWorktreeRoot(resolved);
  if (isWithin(targetBoundary, resolved) || containingWorktree) {
    fail(`backup root must be outside Git worktrees; rejected ${resolved}`, 1);
  }

  const filesystemRoot = path.parse(resolved).root;
  const components = path.relative(filesystemRoot, resolved).split(path.sep).filter(Boolean);
  const originalCwd = process.cwd();
  const originalCwdSnapshot = capturePathSnapshot(originalCwd, "original working directory");
  const originalCwdStat = fs.statSync(originalCwd, { bigint: true });
  try {
    const filesystemRootSnapshot = capturePathSnapshot(filesystemRoot, "filesystem root");
    process.chdir(filesystemRoot);
    const filesystemRootStat = fs.statSync(".", { bigint: true });
    if (
      !filesystemRootStat.isDirectory() ||
      filesystemRootSnapshot.at(-1)?.identity !== statIdentity(filesystemRootStat)
    ) {
      throw new Error(`filesystem root identity changed while opening: ${filesystemRoot}`);
    }
    for (const component of components) {
      enterDirectory(component, {
        create: true,
        label: "backup root",
      });
    }
    const storageStat = fs.statSync(".", { bigint: true });
    fs.accessSync(".", fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
    const pathSnapshot = capturePathSnapshot(resolved, "backup root");
    const leafStat = fs.lstatSync(resolved, { bigint: true });
    if (!sameIdentity(leafStat, storageStat)) {
      throw new Error(`backup root path identity changed while opening: ${resolved}`);
    }
    const storage = {
      originalCwd,
      originalCwdSnapshot,
      originalCwdStat,
      path: resolved,
      pathSnapshot,
      physicalPath: fs.realpathSync.native("."),
      stat: storageStat,
    };
    assertStorageCustody(storage, targetRepo);
    return storage;
  } catch (error) {
    try {
      process.chdir(originalCwd);
    } catch {
      // The original error remains authoritative.
    }
    fail(`backup root is not writable: ${resolved}: ${error.message}`, 1);
  }
}

function describeMemoryBanks(inputs) {
  return [...new Set(inputs.map((item) => path.resolve(item)))].sort().map((input, index) => {
    const stat = requireReadableFileOrDirectory(input, "--memory-bank");
    if (stat.isFile()) {
      return {
        files: [input],
        index,
        input,
        kind: "file",
        ownerName: path.basename(path.dirname(input)),
      };
    }
    return {
      files: walk(input),
      index,
      input,
      kind: "directory",
      ownerName: path.basename(input),
    };
  });
}

function bankOwns(descriptor, file) {
  return descriptor.kind === "file" ? descriptor.input === file : isWithin(descriptor.input, file);
}

function bankRelativePath(descriptor, file) {
  return descriptor.kind === "file" ? path.basename(file) : path.relative(descriptor.input, file);
}

function sanitizeSegment(value) {
  return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || "artifact";
}

function sha256(content) {
  return crypto.createHash("sha256").update(content).digest("hex");
}

function toManifestPath(value) {
  return value.split(path.sep).join("/");
}

function buildPlan(files, destinationFor) {
  const sources = [...new Set(files.map((file) => path.resolve(file)))].sort();
  const destinations = new Map();

  return sources.map((source, index) => {
    const destination = path.normalize(destinationFor(source, index));
    if (!destination || path.isAbsolute(destination) || !isWithin(".", destination)) {
      fail(`unsafe backup destination for ${source}: ${destination}`);
    }
    for (const [existing, otherSource] of destinations) {
      if (isWithin(existing, destination) || isWithin(destination, existing)) {
        fail(
          `destination collision: ${source} and ${otherSource} map to ${destination} and ${existing}`,
          1,
        );
      }
    }
    destinations.set(destination, source);

    let opened;
    try {
      opened = openStableRegularFile(source, "source");
      const content = readStableDescriptor(opened, "source");
      assertPathSnapshot(source, opened.pathSnapshot, "source");
      const descriptorStat = fs.fstatSync(opened.descriptor, { bigint: true });
      return {
        content,
        destination: toManifestPath(path.join("files", destination)),
        sha256: sha256(content),
        size: content.length,
        source,
        sourceObservation: {
          identity: statIdentity(descriptorStat),
          pathSnapshot: opened.pathSnapshot,
          version: statVersion(descriptorStat),
        },
      };
    } catch (error) {
      fail(error.message, 1);
    } finally {
      if (opened?.descriptor !== undefined) fs.closeSync(opened.descriptor);
    }
  });
}

function revalidateSource(entry) {
  let opened;
  try {
    opened = openStableRegularFile(entry.source, "source revalidation");
    const content = readStableDescriptor(opened, "source revalidation");
    assertPathSnapshot(entry.source, entry.sourceObservation.pathSnapshot, "source revalidation");
    const descriptorStat = fs.fstatSync(opened.descriptor, { bigint: true });
    if (
      statIdentity(descriptorStat) !== entry.sourceObservation.identity ||
      statVersion(descriptorStat) !== entry.sourceObservation.version ||
      content.length !== entry.size ||
      sha256(content) !== entry.sha256
    ) {
      throw new Error(`source changed after immutable snapshot: ${entry.source}`);
    }
  } finally {
    if (opened?.descriptor !== undefined) fs.closeSync(opened.descriptor);
  }
}

function revalidatePlanSources(plan) {
  for (const entry of plan) revalidateSource(entry);
}

function writeExclusiveFile(name, content, expectedHash, label, relativePath, taskOwnedFiles) {
  if (!name || name === "." || name === ".." || name.includes(path.sep)) {
    throw new Error(`${label} has an unsafe file segment: ${name}`);
  }
  const parentBefore = fs.statSync(".", { bigint: true });
  const destination = path.resolve(name);
  let descriptor;
  try {
    descriptor = fs.openSync(
      name,
      secureOpenFlags(fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL, label),
      0o600,
    );
    const before = fs.fstatSync(descriptor, { bigint: true });
    const pathBefore = fs.lstatSync(name, { bigint: true });
    if (!before.isFile() || !sameIdentity(before, pathBefore) || before.size !== 0n) {
      throw new Error(`${label} destination identity was not exclusively created: ${name}`);
    }
    const observation = {
      identity: statIdentity(before),
      path: relativePath,
      sha256: sha256(Buffer.alloc(0)),
      size: 0,
    };
    taskOwnedFiles.set(relativePath, observation);
    testCheckpoint(label === "backup payload" ? "before-destination-write" : "");
    let written = 0;
    while (written < content.length) {
      const count = fs.writeSync(descriptor, content, written, content.length - written, written);
      if (count === 0) throw new Error(`${label} destination made no write progress: ${name}`);
      written += count;
    }
    fs.fsyncSync(descriptor);
    const afterWrite = fs.fstatSync(descriptor, { bigint: true });
    const pathAfter = fs.lstatSync(name, { bigint: true });
    const parentAfter = fs.statSync(".", { bigint: true });
    if (
      !sameIdentity(before, afterWrite) ||
      !sameIdentity(afterWrite, pathAfter) ||
      !sameIdentity(parentBefore, parentAfter) ||
      afterWrite.size !== BigInt(content.length)
    ) {
      throw new Error(`${label} destination identity changed while writing: ${name}`);
    }
    const copied = readStableDescriptor(
      { descriptor, source: destination },
      `${label} destination`,
    );
    if (sha256(copied) !== expectedHash) {
      throw new Error(`${label} content failed verification: ${name}`);
    }
    observation.sha256 = expectedHash;
    observation.size = content.length;
    return observation;
  } finally {
    if (descriptor !== undefined) fs.closeSync(descriptor);
  }
}

function observeDirectory(observations, relativePath, stat) {
  const identity = statIdentity(stat);
  const existing = observations.get(relativePath);
  if (existing && existing !== identity) {
    throw new Error(`backup directory identity changed: ${relativePath}`);
  }
  observations.set(relativePath, identity);
}

function enterTrackedDirectory(name, relativePath, directoryObservations, directoryStack) {
  const entered = enterDirectory(name, {
    checkpoint: "after-backup-destination-chdir",
    create: true,
    label: "backup destination",
  });
  directoryStack.push({ identity: statIdentity(entered.stat), relativePath });
  observeDirectory(directoryObservations, relativePath, entered.stat);
}

function ascendToBackupRoot(directoryStack, backup) {
  while (directoryStack.length > 1) {
    const currentWitness = directoryStack.at(-1);
    const current = fs.statSync(".", { bigint: true });
    if (statIdentity(current) !== currentWitness.identity)
      throw new Error(`backup traversal identity changed: ${currentWitness.relativePath}`);
    process.chdir("..");
    directoryStack.pop();
    const parentWitness = directoryStack.at(-1);
    const parent = fs.statSync(".", { bigint: true });
    if (statIdentity(parent) !== parentWitness.identity)
      throw new Error(`backup parent identity changed: ${parentWitness.relativePath || "."}`);
  }
  if (!sameIdentity(fs.statSync(".", { bigint: true }), backup.stat))
    throw new Error(`could not return to anchored backup root: ${backup.path}`);
}

function scanAnchoredBackupTree() {
  const tree = { directories: new Map(), files: new Map() };
  function visit(relativeDirectory) {
    const parentStat = fs.statSync(".", { bigint: true });
    const entries = fs
      .readdirSync(".", { withFileTypes: true })
      .sort((left, right) => left.name.localeCompare(right.name));
    for (const entry of entries) {
      const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
      const before = fs.lstatSync(entry.name, { bigint: true });
      if (before.isSymbolicLink())
        throw new Error(`backup reconciliation found symlink: ${relativePath}`);
      if (before.isDirectory()) {
        const childPath = path.resolve(entry.name);
        process.chdir(entry.name);
        try {
          const current = fs.statSync(".", { bigint: true });
          const after = fs.lstatSync(childPath, { bigint: true });
          if (!sameIdentity(before, current) || !sameIdentity(after, current))
            throw new Error(`backup directory changed during reconciliation: ${relativePath}`);
          tree.directories.set(relativePath, statIdentity(current));
          visit(relativePath);
        } finally {
          process.chdir("..");
        }
        if (!sameIdentity(parentStat, fs.statSync(".", { bigint: true })))
          throw new Error(`backup parent changed during reconciliation: ${relativeDirectory}`);
        continue;
      }
      if (!before.isFile())
        throw new Error(`backup reconciliation found unsupported entry: ${relativePath}`);
      let opened;
      try {
        opened = openStableRegularFile(path.resolve(entry.name), "backup reconciliation");
        const content = readStableDescriptor(opened, "backup reconciliation");
        const after = fs.fstatSync(opened.descriptor, { bigint: true });
        tree.files.set(relativePath, {
          identity: statIdentity(after),
          sha256: sha256(content),
          size: content.length,
        });
      } finally {
        if (opened?.descriptor !== undefined) fs.closeSync(opened.descriptor);
      }
    }
  }
  visit("");
  return tree;
}

function reconcileBackupTree(directoryObservations, fileObservations) {
  const tree = scanAnchoredBackupTree();
  const expectedDirectories = [...directoryObservations.keys()].sort();
  const actualDirectories = [...tree.directories.keys()].sort();
  const expectedFiles = [...fileObservations.keys()].sort();
  const actualFiles = [...tree.files.keys()].sort();
  if (expectedDirectories.join("\n") !== actualDirectories.join("\n"))
    throw new Error("backup directory set differs from recorded observations");
  if (expectedFiles.join("\n") !== actualFiles.join("\n"))
    throw new Error("backup file set differs from recorded observations");
  for (const [relativePath, identity] of directoryObservations) {
    if (tree.directories.get(relativePath) !== identity)
      throw new Error(`backup directory identity differs: ${relativePath}`);
  }
  for (const [relativePath, expected] of fileObservations) {
    const actual = tree.files.get(relativePath);
    if (
      !actual ||
      actual.identity !== expected.identity ||
      actual.size !== expected.size ||
      actual.sha256 !== expected.sha256
    )
      throw new Error(`backup leaf differs from recorded observation: ${relativePath}`);
  }
}

function scrubTaskOwnedFiles(taskOwnedFiles) {
  const ownedIdentities = new Set(
    [...taskOwnedFiles.values()].map((observation) => observation.identity),
  );
  const sensitiveHashes = new Set(
    [...taskOwnedFiles.values()]
      .filter((observation) => observation.size > 0)
      .map((observation) => observation.sha256),
  );
  function visit() {
    const parentStat = fs.statSync(".", { bigint: true });
    const entries = fs
      .readdirSync(".", { withFileTypes: true })
      .sort((left, right) => left.name.localeCompare(right.name));
    for (const entry of entries) {
      const before = fs.lstatSync(entry.name, { bigint: true });
      if (before.isSymbolicLink()) continue;
      if (before.isDirectory()) {
        const childPath = path.resolve(entry.name);
        process.chdir(entry.name);
        try {
          const current = fs.statSync(".", { bigint: true });
          if (
            !sameIdentity(before, current) ||
            !sameIdentity(current, fs.lstatSync(childPath, { bigint: true }))
          )
            throw new Error(`scrub directory identity changed: ${childPath}`);
          visit();
        } finally {
          process.chdir("..");
        }
        if (!sameIdentity(parentStat, fs.statSync(".", { bigint: true })))
          throw new Error("scrub parent identity changed");
        continue;
      }
      if (!before.isFile() || !ownedIdentities.has(statIdentity(before))) continue;
      let descriptor;
      try {
        descriptor = fs.openSync(entry.name, secureOpenFlags(fs.constants.O_RDWR, "backup scrub"));
        const opened = fs.fstatSync(descriptor, { bigint: true });
        if (!sameIdentity(before, opened)) throw new Error("scrub leaf identity changed");
        const zeroes = Buffer.alloc(64 * 1024);
        let position = 0;
        while (position < Number(opened.size)) {
          const length = Math.min(zeroes.length, Number(opened.size) - position);
          const written = fs.writeSync(descriptor, zeroes, 0, length, position);
          if (written === 0) throw new Error("scrub made no write progress");
          position += written;
        }
        fs.fsyncSync(descriptor);
        fs.ftruncateSync(descriptor, 0);
        fs.fsyncSync(descriptor);
        const scrubbed = fs.fstatSync(descriptor, { bigint: true });
        if (!sameIdentity(opened, scrubbed) || scrubbed.size !== 0n)
          throw new Error("scrub verification failed");
      } finally {
        if (descriptor !== undefined) fs.closeSync(descriptor);
      }
      const after = fs.lstatSync(entry.name, { bigint: true });
      if (!sameIdentity(before, after)) throw new Error("scrub leaf changed before unlink");
      fs.unlinkSync(entry.name);
    }
  }
  visit();
  const remaining = scanAnchoredBackupTree();
  for (const file of remaining.files.values()) {
    if (ownedIdentities.has(file.identity) || sensitiveHashes.has(file.sha256))
      throw new Error("sensitive task-owned payload remained after scrub");
  }
}

function restoreOriginalWorkingDirectory(storage) {
  process.chdir(storage.originalCwd);
  if (!sameIdentity(fs.statSync(".", { bigint: true }), storage.originalCwdStat)) {
    throw new Error("original working directory identity changed before restoration");
  }
  assertPathSnapshot(
    storage.originalCwd,
    storage.originalCwdSnapshot,
    "original working directory",
  );
}

function createBackup(storage, prefix, storageLocatorRoot, mode, plan, repo) {
  let backupId;
  let backupDir;
  let backup;
  let result;
  let failure;
  const directoryObservations = new Map();
  const taskOwnedFiles = new Map();
  const directoryStack = [];
  try {
    assertStorageCustody(storage, repo);
    testCheckpoint("before-backup-mkdtemp");
    const temporaryPath = fs.mkdtempSync(`./${prefix}.${timestamp()}.`);
    backupId = path.basename(temporaryPath);
    enterDirectory(backupId, { label: "backup directory" });
    fs.chmodSync(".", 0o700);
    backupDir = path.join(storage.path, backupId);
    assertStorageCustody(storage, repo);
    const backupPathSnapshot = capturePathSnapshot(backupDir, "backup directory");
    const backupStat = fs.statSync(".", { bigint: true });
    if (!sameIdentity(backupStat, fs.lstatSync(backupDir, { bigint: true }))) {
      throw new Error(`backup directory path identity changed: ${backupDir}`);
    }
    backup = { id: backupId, path: backupDir, pathSnapshot: backupPathSnapshot, stat: backupStat };
    directoryStack.push({ identity: statIdentity(backupStat), relativePath: "" });

    for (const entry of plan) {
      assertStorageCustody(storage, repo);
      assertPathSnapshot(backupDir, backupPathSnapshot, "backup directory");
      revalidateSource(entry);
      const parts = entry.destination.split("/");
      const name = parts.pop();
      let relativeDirectory = "";
      try {
        for (const component of parts) {
          relativeDirectory = relativeDirectory ? `${relativeDirectory}/${component}` : component;
          enterTrackedDirectory(
            component,
            relativeDirectory,
            directoryObservations,
            directoryStack,
          );
        }
        writeExclusiveFile(
          name,
          entry.content,
          entry.sha256,
          "backup payload",
          entry.destination,
          taskOwnedFiles,
        );
      } finally {
        ascendToBackupRoot(directoryStack, backup);
      }
      revalidateSource(entry);
      assertStorageCustody(storage, repo);
      assertPathSnapshot(backupDir, backupPathSnapshot, "backup directory");
    }

    testCheckpoint("before-pre-manifest-reconciliation");
    reconcileBackupTree(directoryObservations, taskOwnedFiles);
    assertStorageCustody(storage, repo);
    const storageLocator = toManifestPath(path.join(storageLocatorRoot, backupId));
    const manifestFiles = plan.map(({ destination, sha256: hash, size, source }) => ({
      destination,
      sha256: hash,
      size,
      source,
    }));
    const manifest = {
      schemaVersion: 1,
      mode,
      storagePolicy: "outside-git-worktree",
      backupId,
      storageLocator,
      fileCount: plan.length,
      files: manifestFiles,
    };
    const manifestContent = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
    writeExclusiveFile(
      manifestName,
      manifestContent,
      sha256(manifestContent),
      "backup manifest",
      manifestName,
      taskOwnedFiles,
    );
    testCheckpoint("after-manifest-publication");
    reconcileBackupTree(directoryObservations, taskOwnedFiles);
    assertStorageCustody(storage, repo);
    assertPathSnapshot(backupDir, backupPathSnapshot, "backup directory");
    result = { backupDir, backupId, storageLocator };
  } catch (error) {
    failure = error;
    if (backup) {
      try {
        ascendToBackupRoot(directoryStack, backup);
        scrubTaskOwnedFiles(taskOwnedFiles);
      } catch (scrubError) {
        failure = new Error(`${error.message}; anchored scrub failed: ${scrubError.message}`);
      }
    }
  } finally {
    try {
      restoreOriginalWorkingDirectory(storage);
    } catch (restoreError) {
      failure ??= restoreError;
    }
  }

  if (failure) fail(`Could not create complete backup: ${failure.message}`, 1);
  return result;
}

const {
  backupRoot: requestedBackupRoot,
  backupRootAlias,
  includePaths,
  memoryBanks,
  repo,
} = parseArgs();
const exactMode = includePaths.length > 0;

requireReadableDirectory(repo, "repo path");
for (const file of includePaths) {
  requireReadableRegularFile(file, "--include");
}

const bankDescriptors = exactMode ? [] : describeMemoryBanks(memoryBanks);
const files = exactMode
  ? includePaths
  : [
      ...collectProjectFiles(repo),
      ...bankDescriptors.flatMap((descriptor) => descriptor.files),
    ].filter((file) => isLegacyRegularFile(file));
if (files.length === 0) fail("No Cursor context files found to back up.", 1);

const plan = buildPlan(files, (file, index) => {
  if (exactMode) {
    return path.join(
      "exact-includes",
      `${String(index + 1).padStart(3, "0")}-${sanitizeSegment(path.basename(file))}`,
    );
  }
  if (isWithin(repo, file)) return path.relative(repo, file);

  const owner = bankDescriptors
    .filter((descriptor) => bankOwns(descriptor, file))
    .sort((left, right) => right.input.length - left.input.length || left.index - right.index)[0];
  if (!owner) fail(`discovered Cursor context escaped its configured roots: ${file}`, 1);
  return path.join(
    "external-memory-bank",
    `${String(owner.index + 1).padStart(3, "0")}-${sanitizeSegment(owner.ownerName)}`,
    bankRelativePath(owner, file),
  );
});
testCheckpoint("after-source-plan");
try {
  revalidatePlanSources(plan);
} catch (error) {
  fail(`source revalidation failed before backup-root creation: ${error.message}`, 1);
}

const repoIdentityHash = identityHash(repo);
const backupStorage = ensurePrivateBackupRoot(requestedBackupRoot ?? defaultBackupRoot(repo), repo);
const storageLocatorRoot = requestedBackupRoot
  ? path.join("external-root", backupRootAlias)
  : path.join("user-state", "agent-memory-curator-backups", "cursor", repoIdentityHash);
const backup = createBackup(
  backupStorage,
  ".cursor-context.backup",
  storageLocatorRoot,
  exactMode ? "exact" : "legacy",
  plan,
  repo,
);
output(`Backup created at ${backup.backupDir}`);
output(`Backup ID: ${backup.backupId}`);
output(`Backup storage root: ${backupStorage.path}`);
output(`Backup storage locator: ${backup.storageLocator}`);
output("Backup storage policy: outside-git-worktree");
output(`Backup mode: ${exactMode ? "exact" : "legacy"}`);
output(`Files backed up: ${plan.length}`);
output(`Exact includes backed up: ${exactMode ? plan.length : 0}`);
output(`Manifest: ${path.join(backup.backupDir, manifestName)}`);
scripts/inventory-cursor-context.mjs›
import fs from "node:fs";
import path from "node:path";

function usage() {
  console.log(`Usage: inventory-cursor-context.mjs [--repo PATH] [--memory-bank PATH] [--json]

List Cursor context files with size, modification metadata, and rule frontmatter.

Options:
  --repo PATH         Use PATH instead of the current working directory.
  --memory-bank PATH  Include an explicit user-maintained memory-bank file or directory.
                      May be passed more than once.
  --json              Print structured JSON instead of text.
  -h, --help          Show this help.

This script is read-only. It does not print context file contents.`);
}

function fail(message) {
  console.error(`Error: ${message}`);
  process.exit(2);
}

function parseArgs() {
  let repo = process.cwd();
  const memoryBanks = [];
  let format = "text";
  const args = process.argv.slice(2);

  for (let i = 0; i < args.length; i += 1) {
    const arg = args[i];

    if (arg === "-h" || arg === "--help") {
      usage();
      process.exit(0);
    }

    if (arg === "--repo") {
      const value = args[i + 1];
      if (!value) fail("--repo requires a path.");
      repo = value;
      i += 1;
      continue;
    }

    if (arg === "--memory-bank") {
      const value = args[i + 1];
      if (!value) fail("--memory-bank requires a path.");
      memoryBanks.push(value);
      i += 1;
      continue;
    }

    if (arg === "--json") {
      format = "json";
      continue;
    }

    fail(`unknown argument: ${arg}`);
  }

  return {
    format,
    memoryBanks,
    repo: path.resolve(repo),
  };
}

function isDirectory(value) {
  try {
    return fs.statSync(value).isDirectory();
  } catch {
    return false;
  }
}

function isFile(value) {
  try {
    return fs.statSync(value).isFile();
  } catch {
    return false;
  }
}

const ignoredDirectories = new Set([
  ".git",
  ".hg",
  ".svn",
  ".next",
  ".turbo",
  "dist",
  "build",
  "coverage",
  "node_modules",
]);

function walk(dir, predicate = () => true) {
  const files = [];

  function visit(current) {
    let entries;
    try {
      entries = fs.readdirSync(current, { withFileTypes: true });
    } catch {
      return;
    }

    for (const entry of entries) {
      const fullPath = path.join(current, entry.name);
      if (entry.isDirectory()) {
        if (!ignoredDirectories.has(entry.name)) visit(fullPath);
      } else if (entry.isFile() && predicate(fullPath)) {
        files.push(fullPath);
      }
    }
  }

  if (isDirectory(dir)) visit(dir);
  return files.sort();
}

function collectMemoryBankFiles(inputPath) {
  const fullPath = path.resolve(inputPath);
  if (isFile(fullPath)) return [fullPath];
  if (isDirectory(fullPath)) return walk(fullPath);
  return [];
}

function collectProjectRuleFiles(repo) {
  const rulesDir = path.join(repo, ".cursor", "rules");
  return walk(rulesDir, (file) => /\.(mdc|md)$/i.test(file));
}

function collectAgentsFiles(repo) {
  return walk(repo, (file) => path.basename(file) === "AGENTS.md");
}

function formatBytes(bytes) {
  const units = ["B", "KB", "MB", "GB"];
  let value = bytes;
  let unitIndex = 0;

  while (value >= 1024 && unitIndex < units.length - 1) {
    value /= 1024;
    unitIndex += 1;
  }

  return `${unitIndex === 0 ? value : value.toFixed(1)}${units[unitIndex]}`;
}

function relativeToRepo(repo, file) {
  const relative = path.relative(repo, file);
  return relative.startsWith("..") ? file : relative || ".";
}

function parseFrontmatter(file) {
  let text;
  try {
    text = fs.readFileSync(file, "utf8");
  } catch {
    return null;
  }

  const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
  if (!match) return null;

  const data = {};
  for (const line of match[1].split(/\r?\n/)) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith("#")) continue;
    const separatorIndex = trimmed.indexOf(":");
    if (separatorIndex === -1) continue;
    const key = trimmed.slice(0, separatorIndex).trim();
    const value = trimmed
      .slice(separatorIndex + 1)
      .trim()
      .replace(/^["']|["']$/g, "");
    data[key] = value;
  }

  return {
    alwaysApply: data.alwaysApply ?? null,
    description: data.description ?? null,
    globs: data.globs ?? null,
  };
}

function classifySurface(repo, file, sourceKind) {
  const relative = relativeToRepo(repo, file).replaceAll("\\", "/");
  if (sourceKind === "memory-bank") return "memory-bank";
  if (relative === ".cursorrules") return "legacy-cursorrules";
  if (path.basename(file) === "AGENTS.md") return "agents-md";
  if (relative.startsWith(".cursor/rules/") && file.endsWith(".mdc")) {
    return "cursor-project-rule";
  }
  if (relative.startsWith(".cursor/rules/") && file.endsWith(".md")) {
    return "cursor-project-rule-plain-md";
  }
  return "unknown";
}

function recordFor(repo, file, sourceKind = "repo") {
  let stats;
  try {
    stats = fs.statSync(file);
  } catch {
    return null;
  }

  const surface = classifySurface(repo, file, sourceKind);
  return {
    path: file,
    relative_path: relativeToRepo(repo, file),
    surface,
    size_bytes: stats.size,
    size: formatBytes(stats.size),
    modified_at: stats.mtime.toISOString(),
    frontmatter: file.endsWith(".mdc") ? parseFrontmatter(file) : null,
    note:
      surface === "cursor-project-rule-plain-md"
        ? "Plain Markdown under .cursor/rules lacks Cursor Project Rule metadata; consider .mdc or AGENTS.md."
        : null,
  };
}

function printJson(payload) {
  console.log(JSON.stringify(payload, null, 2));
}

const { format, memoryBanks, repo } = parseArgs();

if (!isDirectory(repo)) {
  fail(`repo path is not a directory: ${repo}`);
}

const candidateFiles = [
  ...collectProjectRuleFiles(repo),
  path.join(repo, ".cursorrules"),
  ...collectAgentsFiles(repo),
].filter((file) => isFile(file));

for (const memoryBank of memoryBanks) {
  candidateFiles.push(...collectMemoryBankFiles(memoryBank));
}

const records = [...new Set(candidateFiles)]
  .sort()
  .map((file) =>
    recordFor(
      repo,
      file,
      memoryBanks.some((item) => file.startsWith(path.resolve(item))) ? "memory-bank" : "repo",
    ),
  )
  .filter(Boolean);

const payload = {
  repo,
  files_count: records.length,
  files: records,
  message:
    records.length === 0
      ? "No Cursor context files found. Provide User Rules, Team Rules, or memory-bank exports if needed."
      : undefined,
};

if (format === "json") {
  printJson(payload);
} else {
  console.log(`Repo: ${repo}`);
  if (records.length === 0) {
    console.log(payload.message);
  } else {
    for (const record of records) {
      const frontmatter = record.frontmatter
        ? ` description=${JSON.stringify(record.frontmatter.description)} globs=${JSON.stringify(record.frontmatter.globs)} alwaysApply=${JSON.stringify(record.frontmatter.alwaysApply)}`
        : "";
      console.log(
        `${record.size} ${record.modified_at} ${record.surface} ${record.relative_path}${frontmatter}`,
      );
      if (record.note) console.log(`  note: ${record.note}`);
    }
  }
}
scripts/scan-cursor-context-risks.mjs›
import fs from "node:fs";
import path from "node:path";

function usage() {
  console.log(`Usage: scan-cursor-context-risks.mjs [--repo PATH] [--memory-bank PATH] [--json] [--max-findings N] [--exclude-agents]

Scan Cursor context files for high-risk lines that deserve review.

Options:
  --repo PATH         Use PATH instead of the current working directory.
  --memory-bank PATH  Include an explicit user-maintained memory-bank file or directory.
                      May be passed more than once.
  --json              Print structured JSON instead of text.
  --max-findings N    Limit returned findings; default is 100.
  --exclude-agents    Do not scan AGENTS.md files.
  -h, --help          Show this help.

This script is read-only. Output is redacted by default and never includes
the raw matching context line.

Exit codes:
  0  scan completed and no risky lines were found, or no context files exist
  1  scan completed and one or more risky lines were found
  2  usage or input error`);
}

function fail(message) {
  console.error(`Error: ${message}`);
  process.exit(2);
}

const DEFAULT_MAX_FINDINGS = 100;

function parseArgs() {
  let repo = process.cwd();
  const memoryBanks = [];
  let format = "text";
  let maxFindings = DEFAULT_MAX_FINDINGS;
  let includeAgents = true;
  const args = process.argv.slice(2);

  for (let i = 0; i < args.length; i += 1) {
    const arg = args[i];

    if (arg === "-h" || arg === "--help") {
      usage();
      process.exit(0);
    }

    if (arg === "--repo") {
      const value = args[i + 1];
      if (!value) fail("--repo requires a path.");
      repo = value;
      i += 1;
      continue;
    }

    if (arg === "--memory-bank") {
      const value = args[i + 1];
      if (!value) fail("--memory-bank requires a path.");
      memoryBanks.push(value);
      i += 1;
      continue;
    }

    if (arg === "--json") {
      format = "json";
      continue;
    }

    if (arg === "--exclude-agents") {
      includeAgents = false;
      continue;
    }

    if (arg === "--max-findings") {
      const value = args[i + 1];
      if (!value) fail("--max-findings requires a positive integer.");
      maxFindings = Number.parseInt(value, 10);
      if (!Number.isSafeInteger(maxFindings) || maxFindings < 1) {
        fail("--max-findings requires a positive integer.");
      }
      i += 1;
      continue;
    }

    fail(`unknown argument: ${arg}`);
  }

  return {
    format,
    includeAgents,
    maxFindings,
    memoryBanks,
    repo: path.resolve(repo),
  };
}

function isDirectory(value) {
  try {
    return fs.statSync(value).isDirectory();
  } catch {
    return false;
  }
}

function isFile(value) {
  try {
    return fs.statSync(value).isFile();
  } catch {
    return false;
  }
}

const ignoredDirectories = new Set([
  ".git",
  ".hg",
  ".svn",
  ".next",
  ".turbo",
  "dist",
  "build",
  "coverage",
  "node_modules",
]);

function walk(dir, predicate = () => true) {
  const files = [];

  function visit(current) {
    let entries;
    try {
      entries = fs.readdirSync(current, { withFileTypes: true });
    } catch {
      return;
    }

    for (const entry of entries) {
      const fullPath = path.join(current, entry.name);
      if (entry.isDirectory()) {
        if (!ignoredDirectories.has(entry.name)) visit(fullPath);
      } else if (entry.isFile() && predicate(fullPath)) {
        files.push(fullPath);
      }
    }
  }

  if (isDirectory(dir)) visit(dir);
  return files.sort();
}

function collectMemoryBankFiles(inputPath) {
  const fullPath = path.resolve(inputPath);
  if (isFile(fullPath)) return [fullPath];
  if (isDirectory(fullPath)) return walk(fullPath);
  return [];
}

function collectProjectRuleFiles(repo) {
  const rulesDir = path.join(repo, ".cursor", "rules");
  return walk(rulesDir, (file) => /\.(mdc|md)$/i.test(file));
}

function collectAgentsFiles(repo) {
  return walk(repo, (file) => path.basename(file) === "AGENTS.md");
}

function relativeToRepo(repo, file) {
  const relative = path.relative(repo, file);
  return relative.startsWith("..") ? file : relative || ".";
}

function surfaceFor(repo, file, memoryBankPaths) {
  const relative = relativeToRepo(repo, file).replaceAll("\\", "/");
  if (memoryBankPaths.some((item) => file.startsWith(path.resolve(item)))) return "memory-bank";
  if (relative === ".cursorrules") return "legacy-cursorrules";
  if (path.basename(file) === "AGENTS.md") return "agents-md";
  if (relative.startsWith(".cursor/rules/") && file.endsWith(".mdc")) {
    return "cursor-project-rule";
  }
  if (relative.startsWith(".cursor/rules/") && file.endsWith(".md")) {
    return "cursor-project-rule-plain-md";
  }
  return "unknown";
}

const riskRules = [
  {
    category: "sensitive",
    pattern:
      /\b(token|secret|password|passwd|api[_-]?key|private key|credential|client[_-]?secret|access[_-]?token|refresh[_-]?token|bearer)\b/i,
  },
  {
    category: "absolute-language",
    pattern: /\b(always|never|must|required|forbidden)\b/i,
  },
  {
    category: "temporary",
    pattern: /\b(branch|temporary|one-off|todo|debug|old branch|migration branch|workaround)\b/i,
  },
  {
    category: "repo-specific",
    pattern:
      /\b(apps[\\/]|packages[\\/]|pnpm|npm run|yarn|bun|turbo|workspace|repo|repository|pr #|issue #)\b/i,
  },
  {
    category: "local-environment",
    pattern: /\b(localhost|127\.0\.0\.1|\/home\/|\/users\/|[a-z]:\\|wsl|\.env)\b/i,
  },
  {
    category: "config",
    pattern: /\b(settings\.json|mcp\.json|cli-config\.json|\.cursor|\.vscode|cursor-agent)\b/i,
  },
];

function categoriesFor(line) {
  return riskRules.filter((rule) => rule.pattern.test(line)).map((rule) => rule.category);
}

function redact(line) {
  return line
    .replace(/(https?:\/\/)[^/\s:@]+:[^/\s@]+@/gi, "$1[REDACTED]@")
    .replace(
      /\b(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|secret|token|password|passwd|credential)\b(\s*[:=]\s*)(["']?)[^"',\s]+(["']?)/gi,
      "$1$2$3[REDACTED]$4",
    )
    .replace(
      /(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|secret|token|password|passwd|credential)\b[^`'":=]{0,80}\b(?:is|was)\s+)([`'"]?)[^`'"\s.,;]+([`'"]?)/gi,
      "$1$2[REDACTED]$3",
    )
    .replace(
      /(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|secret|token|password|passwd|credential|private key)\b[^`'"]{0,60}[`'"])[^`'"]+([`'"])/gi,
      "$1[REDACTED]$2",
    )
    .replace(/-----BEGIN [^-]*PRIVATE KEY-----/gi, "[REDACTED_PRIVATE_KEY_BEGIN]")
    .replace(/-----END [^-]*PRIVATE KEY-----/gi, "[REDACTED_PRIVATE_KEY_END]")
    .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[REDACTED_EMAIL]")
    .replace(/(\/home\/)[^\s`'"]+/gi, "$1[REDACTED_PATH]")
    .replace(/(\/Users\/)[^\s`'"]+/gi, "$1[REDACTED_PATH]");
}

function trimLine(line) {
  const normalized = line.trim();
  if (normalized.length <= 360) return normalized;
  return `${normalized.slice(0, 360)} ... [truncated]`;
}

function summarize(findings) {
  const counts = {};
  for (const finding of findings) {
    for (const category of finding.categories) {
      counts[category] = (counts[category] ?? 0) + 1;
    }
  }
  return counts;
}

function printJson(payload) {
  console.log(JSON.stringify(payload, null, 2));
}

function addFileLevelFindings(findings, repo, file, surface) {
  if (surface === "legacy-cursorrules") {
    findings.push({
      id: `C-${String(findings.length + 1).padStart(3, "0")}`,
      path: file,
      relative_path: relativeToRepo(repo, file),
      surface,
      line: 1,
      categories: ["legacy"],
      redacted_line:
        "Legacy .cursorrules file exists; review for migration to .cursor/rules/*.mdc or AGENTS.md.",
    });
  }

  if (surface === "cursor-project-rule-plain-md") {
    findings.push({
      id: `C-${String(findings.length + 1).padStart(3, "0")}`,
      path: file,
      relative_path: relativeToRepo(repo, file),
      surface,
      line: 1,
      categories: ["ignored"],
      redacted_line:
        "Plain Markdown under .cursor/rules lacks Cursor Project Rule metadata; review for .mdc conversion or AGENTS.md relocation.",
    });
  }
}

const { format, includeAgents, maxFindings, memoryBanks, repo } = parseArgs();

if (!isDirectory(repo)) {
  fail(`repo path is not a directory: ${repo}`);
}

const candidateFiles = [
  ...collectProjectRuleFiles(repo),
  path.join(repo, ".cursorrules"),
  ...(includeAgents ? collectAgentsFiles(repo) : []),
].filter((file) => isFile(file));

for (const memoryBank of memoryBanks) {
  candidateFiles.push(...collectMemoryBankFiles(memoryBank));
}

const files = [...new Set(candidateFiles)].sort();
const findings = [];
let filesScanned = 0;
let truncated = false;

for (const file of files) {
  const surface = surfaceFor(repo, file, memoryBanks);
  addFileLevelFindings(findings, repo, file, surface);
  if (findings.length >= maxFindings) {
    truncated = true;
    break;
  }

  let text;
  try {
    text = fs.readFileSync(file, "utf8");
  } catch {
    continue;
  }
  filesScanned += 1;

  const lines = text.split(/\r?\n/);
  for (let index = 0; index < lines.length; index += 1) {
    const line = lines[index];
    const categories = categoriesFor(line);
    if (categories.length === 0) continue;

    findings.push({
      id: `C-${String(findings.length + 1).padStart(3, "0")}`,
      path: file,
      relative_path: relativeToRepo(repo, file),
      surface,
      line: index + 1,
      categories,
      redacted_line: trimLine(redact(line)),
    });

    if (findings.length >= maxFindings) {
      truncated = true;
      break;
    }
  }

  if (truncated) break;
}

const payload = {
  repo,
  files_considered: files.length,
  files_scanned: filesScanned,
  max_findings: maxFindings,
  truncated,
  findings_count: findings.length,
  summary_by_category: summarize(findings),
  findings,
};

if (format === "json") {
  printJson(payload);
} else if (findings.length === 0) {
  console.log("No Cursor context risks found.");
} else {
  console.log(`Cursor context risks found: ${findings.length}`);
  for (const finding of findings) {
    console.log(
      `${finding.id} ${finding.relative_path}:${finding.line} ${finding.categories.join(",")} ${finding.redacted_line}`,
    );
  }
  if (truncated) console.log(`Findings truncated at ${maxFindings}.`);
}

process.exit(findings.length > 0 ? 1 : 0);
SKILL.md›
---
name: cursor-memory-curator
description: Audit and safely curate Cursor rules and provided memory-bank context. Use when reviewing stale, conflicting, sensitive, or misplaced Cursor instructions, not generic repository docs.
license: Apache-2.0
compatibility: Designed for Cursor Agent and Cursor Agent Skills. Works in other agents when inspecting Cursor rule files and user-provided Cursor context exports.
metadata:
  author: stark-ai-de
  category: cursor-operations
  version: "0.2.2"
---

# Cursor Memory Curator

## Goal

Audit Cursor durable context as user-owned agent state: expose stale, unsafe, duplicated, ignored, conflicting, or misplaced rules; propose better destinations; and route review, planning, persistence, and cleanup through one explicit contract.

Keep the subject scoped to Cursor Project Rules, legacy `.cursorrules`, `AGENTS.md`, User Rules, Team Rules, and user-maintained Cursor memory-bank artifacts. Do not treat this as a Codex memory curator.

## When to use

- Use for review, placement, planning, or cleanup of the Cursor durable surfaces named in the description.
- Use when those surfaces are stale, conflicting, sensitive, ignored, over-broad, or causing Cursor to forget or reuse old guidance.

## When not to use

- Do not use for Codex memory, ordinary docs, or generic prompt work without Cursor durable context.
- Keep review requests read-only. Keep User/Team Rules manual unless a documented file-backed artifact or explicitly approved export path is in scope.

## Workflow selection

Expose all eight workflows in this stable order, compactly when intent is clear. Recommend the route matching the requested outcome and delivery; for a bare invocation recommend `review-chat` while asking the user to choose:

| Workflow                | Delivery and result                                                          |
| ----------------------- | ---------------------------------------------------------------------------- |
| `plan-run-cleanup-file` | One record: review, approved plan, backup, cleanup, verification.            |
| `review-chat`           | Chat: read-only review and recommendations.                                  |
| `review-file`           | One record: read-only review and recommendations.                            |
| `cleanup-chat`          | Chat and backup: review, high-confidence atomic cleanup, verification.       |
| `cleanup-file`          | One record and backup: review, high-confidence atomic cleanup, verification. |
| `plan-cleanup-chat`     | Chat: review and approved plan; no cleanup.                                  |
| `plan-cleanup-file`     | One record: review and approved plan; no cleanup.                            |
| `plan-run-cleanup-chat` | Chat and backup: review, approved plan, cleanup, verification.               |

Route from intent instead of adding an `auto` workflow:

- Direct review defaults to `review-chat`; explicit persistence selects `review-file`.
- Explicit cleanup without a delivery preference selects `plan-run-cleanup-file`.
- A clear direct request may select another matching route when delivery and execution intent are explicit.
- Agent-initiated activation may select only a relevant read-only route. Use `review-file` only when the existing task already requests persistence; never infer cleanup.
- A bare invocation, conflicting cues, or ambiguity about review versus cleanup, chat versus file, execution, target paths, or mutation authority exposes the table and asks the user to choose.
- A mutating route may be selected only when the user already requested cleanup of the identified Cursor context scope.

Before substantive inspection, disclose the complete finite options once and state `Selected`, `Reason`, target paths, write scope, expected artifacts, protected state, Plan-mode capability, and unresolved approvals. Reuse facts and exact authority already established in the task; combine known fields into a short announcement. If selection is unambiguous, announce it and proceed. If it is ambiguous, stop before inventory and ask only the unresolved choice. Do not repeat the selector or ask for an unchanged selection again.

Workflow selection does not authorize whole-file deletion, destructive recovery, User/Team settings edits, paid or external actions, deployment, publication, or scope expansion.

## Inputs to inspect

- Resolve the repo and only applicable `.cursor/rules`, `.cursorrules`, `AGENTS.md`, provided User/Team Rule exports, and provided memory-bank paths.
- Inspect current repository evidence only as needed to verify a disputed claim.
- Load the placement, classification, conflict, and safe-editing references below only when their decision is active. Load the report or plan asset whenever producing that artifact.

## Workflow

Every route applies the same review quality to the explicitly requested scope before planning or cleanup. A request about one file or claim does not authorize inspecting the entire store: use targeted reads instead of whole-root inventory/scanner commands, and consult other evidence only to resolve an in-scope conflict. Delivery never reduces review quality:

1. Resolve the selected route, target repo and context paths, persistence path when applicable, and protected state.
2. Inventory Cursor context without dumping contents:

   ```bash
   node scripts/inventory-cursor-context.mjs --repo .
   ```

   Add `--memory-bank PATH` for explicit memory-bank artifacts. Use `--json` when structured evidence is useful.

3. Run the redacted risk scanner when looking for sensitive, stale, broad, ignored, legacy, local, or conflict-prone rules:

   ```bash
   node scripts/scan-cursor-context-risks.mjs --repo . --json
   ```

   Exit code `1` means findings were found, not that the scan failed. Summarize counts and highest-signal redacted findings instead of pasting the full payload.

4. Parse `.mdc` frontmatter fields that affect Cursor behavior: `description`, `globs`, and `alwaysApply`.
5. Flag plain `.md` files under `.cursor/rules` as ignored by Cursor Project Rules metadata and recommend conversion to `.mdc` or relocation to `AGENTS.md`.
6. Treat User Rules and Team Rules as settings evidence or user-provided exports unless a current documented filesystem path is available.
7. Read context files in bounded chunks and redact sensitive values.
8. Extract one atomic claim per row. Split compound rules before classification.
9. Verify disputed claims against only the repo files needed for the dispute. Load `references/conflict-resolution.md` when precedence is unclear.
10. Assign exactly one primary classification per atomic claim: `KEEP`, `KEEP BUT REWRITE`, `MOVE TO CURSOR PROJECT RULE`, `MOVE TO AGENTS.md`, `MOVE TO REPO DOCS`, `MOVE TO CURSOR USER RULES`, `MOVE TO CURSOR TEAM RULES`, `MOVE TO SKILL`, `MOVE TO CONFIG`, `DELETE`, or `ASK USER`.
11. Tag high-risk entries as useful context only: `stale`, `duplicated`, `too-broad`, `too-specific`, `repo-specific`, `workflow`, `config`, `sensitive`, `conflicting`, `ignored`, `legacy`, or `useful`.
12. Add confidence (`high`, `medium`, or `low`) and a proposed action to every entry.
13. Produce the complete review before planning or editing. Route delivery must not reduce review quality or expand the requested scope.

## Route execution

- `review-chat`: return the review in chat and create no durable curation report.
- `review-file`: persist the single curation record and make no context change.
- `cleanup-chat`: derive only high-confidence atomic actions from the completed review, back up every exact file to be changed, apply them, re-read changed sections, and report verification in chat. Create no durable curation report.
- `cleanup-file`: create the curation record before mutation; if persistence fails, stop. Then back up exact files, apply only high-confidence atomic actions, and complete the same record with execution and verification.
- `plan-cleanup-chat` and `plan-cleanup-file`: enter the Plan lifecycle, resolve the cleanup plan with the user, and stop after approval without changing Cursor context.
- `plan-run-cleanup-chat` and `plan-run-cleanup-file`: enter the Plan lifecycle, resolve and approve the complete cleanup plan, recheck state, exit Plan mode, back up exact files, execute only the unchanged plan, and verify. Do not ask a generic second cleanup question after plan approval.

Direct cleanup (`cleanup-chat` or `cleanup-file`) is limited to high-confidence atomic edits, moves, or entry deletion in existing, editable, runtime-owned Cursor context. Defer whole-file deletion, new context files, config, User Rules, Team Rules, `AGENTS.md`, repository docs, skills, uncertain memory-bank schemas, medium/low-confidence changes, and any scope expansion. A plan-run route may execute broader curation changes only when the approved plan names each destination, write path, backup, rollback, and separate approval boundary. User and Team Rules remain manual unless a documented editable export is explicitly in scope.

## Plan lifecycle

Respect active/requested native Plan mode and actual write permissions on every route: no report, backup, or cleanup write while Plan is active or permission is unknown. Planning may continue read-only without a manual mode switch. For `plan-*` routes or pending material questions, read [references/plan-lifecycle.md](references/plan-lifecycle.md). Reuse unchanged approval; mode exit alone is not approval, and unanswered questions grant no authority. Recheck state before writes and reconfirm only material changes. Plan-only routes never authorize cleanup.

Do not invoke `cursor-spec-interviewer` inside this curation workflow. If findings require a broader durable rule, repository spec, or unresolved product decision, finish the selected curation route and offer the interviewer as a separate follow-up.

## File delivery contract

File routes persist exactly one redacted curation record. Prefer an existing repository-native report location; otherwise use `<repo>/.agent-reports/cursor-memory-curation/<UTC timestamp>-<selection-id>.md`. Create a new path without overwriting and keep all route output in that record.

The record contains `Review`, `Plan`, `Execution Receipt`, `Deferred Work`, `Backup`, and `Verification`. Use `not applicable` with a reason for phases the route does not perform. Create the record before mutation for `cleanup-file` and `plan-run-cleanup-file`; persistence failure blocks cleanup. Chat routes create no report file. Backup directories remain mandatory safety artifacts and do not count as curation reports.

`Explicit --backup-root requires a stable non-sensitive --backup-root-alias; file routes persist the script-reported portable storage locator and <storage-locator>/backup-manifest.json.` Report exact absolute backup and manifest paths only in non-persisted chat, never in repository artifacts.

## Safety rules

- Do not inspect when the route is unresolved, and do not mutate unless the selected route and user request authorize cleanup of the exact target scope.
- Never silently delete, rewrite, truncate, or move Cursor context files.
- Back up every exact file before an approved edit and report the backup path.
- Do not print full secrets, tokens, credentials, customer data, private identifiers, or sensitive personal data.
- If secret-like data is found, redact values in output, identify file and line when possible, recommend removal, and recommend rotation for real credentials.
- If a memory-bank schema is unclear, do not edit it directly. Defer it in the current record or chat result.
- Do not edit User Rules or Team Rules from chat-only summaries; give manual action recommendations instead.
- Do not apply repo-specific assumptions globally. Prefer `AGENTS.md`, Cursor Project Rules, or repo docs for repo rules.
- Do not run broad destructive commands.

## References

Read only when needed:

- Placement and classification: `references/context-surface-anatomy.md`, `references/classification-rubric.md`.
- Conflicts and safe mutation: `references/conflict-resolution.md`, `references/safe-editing-procedure.md`.
- Examples and output artifacts: `references/example-review-report.md`, `assets/review-report-template.md`, `assets/cleanup-plan-template.md`.

## Scripts

Use only when needed. All scripts are non-interactive, use Node.js stdlib only, and accept `--help`.

```bash
node scripts/inventory-cursor-context.mjs [--repo PATH] [--memory-bank PATH] [--json]
node scripts/scan-cursor-context-risks.mjs [--repo PATH] [--memory-bank PATH] [--json] [--max-findings N]
node scripts/backup-cursor-context.mjs [--repo PATH] [--memory-bank PATH] [--backup-root PATH --backup-root-alias NAME] [--include PATH ...]
```

- Inventory is read-only. The scanner is read-only, redacts by default, bounds findings, and uses exit `1` for findings rather than execution failure.
- `backup-cursor-context.mjs` creates a no-clobber backup plus `backup-manifest.json` without editing sources. Unredacted backup payloads and manifests stay outside Git worktrees. Preflight rejects unsafe roots and every symlink path component; any traversal error fails before root creation. Use exact `--include` paths for edits; zero includes retains legacy discovery. Before invoking it, read `references/safe-editing-procedure.md` for root safety, preflight, manifest reconciliation, and recovery.

## Output format

Use the workflow announcement above; report only unresolved approval gates.

Before producing a report, load and follow [`assets/review-report-template.md`](assets/review-report-template.md) as the canonical heading and field contract. File routes copy that complete template into the one curation record; chat routes render only applicable sections in chat and create no report file. Populate every applicable field, use `not applicable` with a reason for skipped phases, and redact sensitive values.

Before edits, complete the review and decision tables. After edits, complete the same record's receipt, including Manifest reconciliation and unmatched paths; New paths (`created-no-preimage`) and rollback; Backup mode and manifest path; Backup integrity result; and the row schema `| Changed path | Backup destination | Bytes | SHA-256 | Verification |`.

## Completion criteria

- One of the eight canonical workflows was selected from clear authority or resolved ambiguity; agent activation never inferred cleanup.
- Applicable context and `.mdc` metadata were inventoried or reported missing; User/Team Rules remained evidence or approved exports rather than silent edits.
- Every atomic claim has one classification, risk tags, confidence, action, and higher-precedence conflict evidence when applicable.
- Plan and direct-cleanup boundaries remain satisfied.
- Chat/file delivery matches the canonical template contract, and every authorized edit has an exact-file backup, manifest reconciliation, re-read, and integrity result.

## Failure modes

- Missing surfaces or settings exports: report what is unavailable and provide manual User/Team Rule recommendations when needed.
- Plain `.md` under `.cursor/rules` or unknown memory-bank schema: flag/defer rather than silently editing; recommend `.mdc` conversion or `AGENTS.md` relocation when applicable.
- Sensitive or conflicting content: redact; cite location and higher source; recommend removal/rotation or classify for rewrite, move, deletion, or user decision.
- Persistence, backup, or approved-state recheck failure: stop before mutation (or before further mutation), report the failure, and return to planning after drift.