Skills に戻る
stark-ai-de/agent-skills実行前に内容を確認

SKILL DETAIL

claude-memory-curator

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

Audit and safely curate Claude Code instructions, rules, auto memory, and memory settings. Use when reviewing stale or misplaced Claude Code context, not Claude app memory or API memory stores.

インストール · 1,563出典を見る

Installation

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

スキルファイル

SKILL.md

最終同期 · 2026/10/01

assets/cleanup-plan-template.md›
# Claude 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:
- Claude home:
- Auto memory directory:
- 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 Claude memory and instruction files that may change before edits.
- Do not edit managed policy files unless the user explicitly asks and the environment supports it.
- Redact sensitive values in reports and diffs.
- Re-read changed sections after edits.
assets/review-report-template.md›
# Claude 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

- Claude context files inspected:
- Entries extracted:
- Keep:
- Rewrite:
- Move to CLAUDE.md:
- Move to CLAUDE.local.md:
- Move to Claude rule:
- Move to auto memory topic:
- Move to AGENTS.md:
- Move to repo docs:
- Move to skill:
- Move to settings:
- Move to hook:
- Move to managed policy:
- 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 |
| --- | ------------- | -------- | -------------- |

## Settings And Hook Recommendations

- Settings:
- Hooks:
- Managed policy:

## 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›
# Claude 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, internal hostname, 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 deterministic restriction or automation requirement?
   - Classify `MOVE TO SETTINGS` or `MOVE TO HOOK`.
4. Is the claim a broad Claude Code project instruction?
   - Classify `MOVE TO CLAUDE.md`.
5. Is the claim a personal project-specific Claude preference?
   - Classify `MOVE TO CLAUDE.local.md`.
6. Is the claim path-specific, language-specific, or package-specific Claude guidance?
   - Classify `MOVE TO CLAUDE RULE`.
7. Is the claim a concise learned fact for future Claude Code work?
   - Classify `MOVE TO AUTO MEMORY TOPIC`.
8. Is the claim a cross-agent repo rule?
   - Classify `MOVE TO AGENTS.md`; verify native loading or retain/recommend a `CLAUDE.md` import for unsupported or uncertain sessions.
9. Is the claim longer architecture or operations context?
   - Classify `MOVE TO REPO DOCS`.
10. Is the claim a reusable on-demand workflow?

- Classify `MOVE TO SKILL`.

11. Is the claim organization-wide policy?

- Classify `MOVE TO MANAGED POLICY` as a manual action.

12. Is the claim useful but too broad, vague, or forceful?

- Classify `KEEP BUT REWRITE`.

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

## Primary Classifications

| Classification              | Use when                                                   | Typical action                                                      |
| --------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------- |
| `KEEP`                      | Accurate durable context in the right Claude surface       | Leave as-is                                                         |
| `KEEP BUT REWRITE`          | Useful but broad, vague, stale, duplicated, or over-strong | Rewrite with scope and verification wording                         |
| `MOVE TO CLAUDE.md`         | Broad project or user Claude instruction                   | Add or move to the right `CLAUDE.md`                                |
| `MOVE TO CLAUDE.local.md`   | Private project-specific Claude preference                 | Add or move to `CLAUDE.local.md`                                    |
| `MOVE TO CLAUDE RULE`       | Path-scoped, package-scoped, or topic-scoped instruction   | Create or update `.claude/rules/*.md`                               |
| `MOVE TO AUTO MEMORY TOPIC` | Learned project fact or pattern that should not be a rule  | Add concise index plus topic detail if needed                       |
| `MOVE TO AGENTS.md`         | Cross-agent repo rule                                      | Use `AGENTS.md` with verified native loading or a compatible import |
| `MOVE TO REPO DOCS`         | Detailed architecture or operations context                | Move to repo docs or ADRs                                           |
| `MOVE TO SKILL`             | Reusable workflow should load on demand                    | Create or update a skill                                            |
| `MOVE TO SETTINGS`          | Runtime setting, permission rule, sandbox rule, or toggle  | Move to Claude settings                                             |
| `MOVE TO HOOK`              | Deterministic lifecycle automation or guard                | Create or update a Claude Code hook                                 |
| `MOVE TO MANAGED POLICY`    | Organization-wide non-user policy                          | Recommend manual managed-policy change                              |
| `DELETE`                    | Harmful, stale, duplicated, sensitive, or 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`
- `unenforced`
- `managed-policy`
- `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 block production deploys.    | Add a PreToolUse hook or permission rule that blocks the deploy command.      |
| Remember every API design detail. | Keep a concise memory index and move detailed API notes to a topic file.      |

## Confidence

| Confidence | Use when                                                               |
| ---------- | ---------------------------------------------------------------------- |
| `high`     | Current files prove the claim is correct, stale, unsafe, or misplaced. |
| `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›
# Claude Context Conflict Resolution

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

## Precedence

Prefer sources in this order:

1. Current user request and direct conversation instructions.
2. Managed policy and managed settings, treated as read-only unless explicitly in scope.
3. Active Claude settings and hooks that enforce runtime behavior.
4. More specific project `CLAUDE.md`, `CLAUDE.local.md`, and `.claude/rules` for the touched path.
5. Current repository files that define behavior, including package scripts, ADRs, docs, source code, and config.
6. User-level `~/.claude/CLAUDE.md` and `~/.claude/rules`.
7. Auto memory topic files and old learned summaries.

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-level Claude rules.
- If a cross-agent repo rule is currently duplicated between `AGENTS.md` and `CLAUDE.md`, recommend one source of truth with verified native loading or a compatible import; preserve working imports for older or restricted sessions.
- If evidence is insufficient, classify `ASK USER`.

## Report Shape

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

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

Use this reference when deciding which Claude Code surface owns a durable instruction or memory.

## CLAUDE.md Files

Claude Code loads applicable `CLAUDE.md` instructions; native `AGENTS.md` support is conditional, as described below. Project instructions can live at `./CLAUDE.md` or `./.claude/CLAUDE.md`. User instructions live at `~/.claude/CLAUDE.md`. Managed policy instructions can live in system-managed locations such as `/etc/claude-code/CLAUDE.md` on Linux and WSL.

Use `CLAUDE.md` for concise instructions that should be loaded broadly in Claude Code sessions. Keep files focused; long procedures usually belong in skills or path-scoped rules.

## CLAUDE.local.md

Use `CLAUDE.local.md` for personal project-specific preferences that should not be committed. Treat it as local user state. Do not move team or repo rules into it unless the user explicitly wants a private local preference.

## AGENTS.md

Confirm native support from the installed version and session evidence; file presence is not loading proof. As documented on 2026-09-21, direct loading requires v2.1.277+, available feature flags, enabled built-in `agents-md`, and compatible hooks. Older versions, some providers (including Bedrock), disabled telemetry, first sessions after installation/upgrade, `disableAllHooks`, or `allowManagedHooksOnly` can prevent it.

The default `claude-md-or-agents-md` uses `AGENTS.md` only without a project/ancestor `CLAUDE.md`, `.claude/CLAUDE.md`, or `CLAUDE.local.md`. `claude-md-and-agents-md` loads both; `claude-md` and `managed-only` exclude native `AGENTS.md`. Check `pluginConfigs["agents-md@builtin"].options.instructionFiles` in user, explicit, or managed settings; project/local settings do not control it. Inventory reports these as observed signals, not resolved effective settings.

Native candidates include `AGENTS.md` and `.claude/AGENTS.md`; exclude `AGENTS.local.md`, `AGENTS.override.md`, and `.agents/`. Check the startup loading message or session instruction evidence. Absence from `/memory`, `/context` Memory files, or `InstructionsLoaded` hooks is not proof of non-loading.

Retain a working `CLAUDE.md` import (`@AGENTS.md`) for unsupported, restricted, or uncertain hosts. Do not remove it or alter settings/hooks merely because a newer version exists. Verify the actual session before recommending a migration; shared-file changes require named approval.

## .claude/rules

Project rules live under `.claude/rules/**/*.md`. Rules without `paths` frontmatter load broadly. Rules with `paths` frontmatter are path-specific and should contain guidance for matching files.

Prefer path-scoped rules for large repos, language-specific conventions, package-specific workflows, or instructions that should not load into every session.

User-level rules live under `~/.claude/rules/**/*.md` and apply across projects. Use them for stable personal preferences, not repo-specific commands.

## Auto Memory

Claude Code auto memory is local markdown state. A configured `autoMemoryDirectory` overrides the default. Otherwise Claude Code derives a per-project directory under `~/.claude/projects/<project>/memory/`.

`MEMORY.md` is the loaded entrypoint. The first 200 lines or first 25KB are loaded at conversation start. Topic files are read on demand. Prefer `MEMORY.md` for concise indexes and topic files for detail.

## Settings And Hooks

`CLAUDE.md` and auto memory are context, not enforcement. Use settings or hooks when an instruction must be enforced deterministically, such as blocking a command, restricting filesystem access, or running a guard before tool use.

## Managed Policy

Managed policy files and managed settings are organization-controlled. Treat them as higher-precedence read-only evidence by default. Recommend manual managed-policy changes only when the user explicitly asks and the change is team-wide or organization-wide.

## Source Basis

- Claude Code memory docs (conditional loading, verified 2026-09-21): https://code.claude.com/docs/en/memory#agents-md
- Claude Code settings docs: https://code.claude.com/docs/en/settings
- Claude Code skills docs: https://code.claude.com/docs/en/skills
- Claude Code hooks docs: https://code.claude.com/docs/en/hooks
references/example-review-report.md›
# Example Claude Memory Review

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

## Top Decisions

1. Move the API-specific guidance from broad `CLAUDE.md` into a path-scoped `.claude/rules/api.md`.
2. Delete stale auto-memory notes about the old `npm test` command because `package.json` proves the command changed.
3. Recommend a PreToolUse hook for the "never deploy production" rule because instruction text is not enforcement.

## Summary

- Claude context files inspected: 5
- Entries extracted: 7
- Keep: 1
- Rewrite: 1
- Move to Claude rule: 1
- Move to auto memory topic: 1
- Move to settings: 1
- Move to hook: 1
- Delete: 1
- Ask user: 0

## Highest-Risk Context

| ID  | Source                    | Risk       | Recommendation                                  |
| --- | ------------------------- | ---------- | ----------------------------------------------- |
| C-2 | `~/.claude/.../MEMORY.md` | stale      | Delete old command after backup and approval.   |
| C-4 | `CLAUDE.md`               | unenforced | Move hard restriction to a hook recommendation. |
| C-6 | `.claude/rules/deploy.md` | 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 | API handlers use format X. | `MOVE TO CLAUDE RULE` | useful     | medium     | Applies only under `src/api/`. | Move to path-scoped rule.          |
| C-3 | Never deploy production.   | `MOVE TO HOOK`        | unenforced | high       | Context text cannot enforce.   | Recommend a PreToolUse hook guard. |

## Settings And Hook Recommendations

- Settings: consider `claudeMdExcludes` for irrelevant parent repo instructions.
- Hooks: add a PreToolUse hook only after explicit approval.
- Managed policy: no managed-policy change 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 Claude memory or instruction file.

## Route Authority Gate

Do not mutate unless the selected cleanup route is supported by an explicit user cleanup request for the exact Claude 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, managed policy, destructive recovery, external actions, and scope expansion retain separate approval boundaries.

## Backup

Run the bundled script:

```bash
node scripts/backup-claude-memory.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, Claude-home, and auto-memory discovery; zero includes retain that legacy discovery and may use `--claude-home` or `--memory-dir`. Exact paths must be readable regular files, and explicit discovery roots must exist and be readable. Any symlink path component, nested discovery symlink, unreadable directory, traversal failure, malformed discovered settings JSON, or present `autoMemoryDirectory` value that is not a non-empty string stops before backup-root creation. Only an absent `autoMemoryDirectory` key permits the documented derived per-project memory path. 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/claude/<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, imports, and ordering where practical.
- Do not edit managed policy files by default.
- 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 Auto-Memory Schema

If an auto-memory topic file format is unclear, report:

```text
The Claude auto-memory 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 CLAUDE RULE, C-6 DELETE
Skipped: C-4 ASK USER
Manual: C-7 MOVE TO MANAGED POLICY
```

## 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.
references/settings-and-hooks.md›
# Settings And Hooks

Use this when a Claude context entry should become enforceable configuration or automation.

## Settings Destinations

Prefer `MOVE TO SETTINGS` when the claim is about:

- enabling or disabling auto memory with `autoMemoryEnabled`;
- selecting a memory directory with `autoMemoryDirectory`;
- excluding irrelevant instruction files with `claudeMdExcludes`;
- permission allow or deny rules;
- sandbox filesystem or network restrictions;
- plugin, MCP, model, environment, or telemetry settings.

Use `.claude/settings.json` for shared project settings and `.claude/settings.local.json` for private local settings. Use `~/.claude/settings.json` for user-wide preferences. Treat managed settings as manual managed-policy actions unless explicitly in scope.

## Hook Destinations

Prefer `MOVE TO HOOK` when the claim requires lifecycle automation, validation, or a hard guard, such as:

- block a dangerous command before tool use;
- run a formatter or checker after edits;
- expand a prompt with generated context;
- reject or defer work when a policy condition fails;
- notify or log session events.

Use hook recommendations, not immediate hook edits, unless the user explicitly asks to create or modify hooks.

## Context Is Not Enforcement

`CLAUDE.md`, rules, and auto memory guide model behavior. They do not guarantee behavior. When the user says "must block", "never allow", or "enforce", classify the claim toward settings or hooks.

## Manual Managed Policy

Use `MOVE TO MANAGED POLICY` only for organization-wide requirements that should affect every Claude Code session on managed machines. Do not edit managed policy files by default.
scripts/backup-claude-memory.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-claude-memory.mjs [--repo PATH] [--claude-home PATH] [--memory-dir PATH] [--backup-root PATH --backup-root-alias NAME] [--include PATH ...]

Create a no-clobber backup of Claude Code memory and instruction files.

Options:
  --repo PATH         Use PATH instead of the current working directory.
  --claude-home PATH  Use PATH instead of ~/.claude.
  --memory-dir PATH   Discover this readable directory in legacy mode; it must exist.
  --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 Claude
context surfaces. Explicit discovery roots must exist and be readable. Legacy
discovery rejects symlinks, symlinked parents, and directory traversal errors.
Invalid settings JSON fails before backup-root creation. A present
autoMemoryDirectory that is not a non-empty string also fails before 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 .claude-context.backup.<timestamp>.<suffix>, never overwrites
a file, and writes ${manifestName} with size and SHA-256 evidence. Managed policy
files are not discovered. The script 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 expandHome(value) {
  if (value === "~") return os.homedir();
  if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
  return path.resolve(value);
}

function parseArgs() {
  let repo = process.cwd();
  let claudeHome = path.join(os.homedir(), ".claude");
  let claudeHomeWasExplicit = false;
  let explicitMemoryDir = null;
  let backupRoot = null;
  let backupRootAlias = null;
  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 === "--claude-home") {
      const value = args[i + 1];
      if (!value) fail("--claude-home requires a path.");
      claudeHome = value;
      claudeHomeWasExplicit = true;
      i += 1;
      continue;
    }
    if (arg === "--memory-dir") {
      const value = args[i + 1];
      if (!value) fail("--memory-dir requires a path.");
      explicitMemoryDir = 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(expandHome(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 {
    claudeHome: expandHome(claudeHome),
    claudeHomeWasExplicit,
    backupRoot: backupRoot ? expandHome(backupRoot) : null,
    backupRootAlias,
    explicitMemoryDir: explicitMemoryDir ? expandHome(explicitMemoryDir) : null,
    includePaths,
    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", "claude", 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 readableDirectoryIfPresent(value, label) {
  try {
    const stat = inspectPath(value, label, { allowMissing: true });
    if (!stat || !stat.isDirectory()) return null;
    fs.accessSync(value, fs.constants.R_OK | fs.constants.X_OK);
    return path.resolve(value);
  } 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 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(".claude-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 [
    path.join(repo, "CLAUDE.md"),
    path.join(repo, ".claude", "CLAUDE.md"),
    path.join(repo, "CLAUDE.local.md"),
    path.join(repo, ".claude", "settings.json"),
    path.join(repo, ".claude", "settings.local.json"),
    ...walk(path.join(repo, ".claude", "rules")),
    ...walk(repo, (file) => {
      const name = path.basename(file);
      return name === "CLAUDE.md" || name === "CLAUDE.local.md";
    }),
  ];
}

function collectUserFiles(claudeHome) {
  return [
    path.join(claudeHome, "CLAUDE.md"),
    path.join(claudeHome, "settings.json"),
    ...walk(path.join(claudeHome, "rules")),
  ];
}

function readSettingsJson(file) {
  let data;
  try {
    data = JSON.parse(readStablePath(file, "Claude settings").toString("utf8"));
  } catch (error) {
    fail(`could not parse Claude settings JSON: ${file}: ${error.message}`, 1);
  }
  if (data === null || typeof data !== "object" || Array.isArray(data)) {
    fail(`Claude settings JSON must contain an object: ${file}`, 1);
  }
  return data;
}

function configuredAutoMemory(repo, claudeHome) {
  const settings = [
    path.join(claudeHome, "settings.json"),
    path.join(repo, ".claude", "settings.json"),
    path.join(repo, ".claude", "settings.local.json"),
  ]
    .filter((file) => isLegacyRegularFile(file))
    .map((file) => ({ data: readSettingsJson(file), file }));

  for (const { data, file } of settings.reverse()) {
    if (!Object.hasOwn(data, "autoMemoryDirectory")) continue;
    if (typeof data.autoMemoryDirectory !== "string") {
      fail(`configured autoMemoryDirectory must be a string: ${file}`, 1);
    }
    const configured = data.autoMemoryDirectory.trim();
    if (!configured) {
      fail(`configured autoMemoryDirectory must be a non-empty path: ${file}`, 1);
    }
    return expandHome(configured);
  }
  return null;
}

function derivedMemoryCandidate(repo, claudeHome) {
  const slug = repo.replace(/^[\\/]+/, "").replace(/[:\\/]+/g, "-");
  return path.join(claudeHome, "projects", slug, "memory");
}

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 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,
  claudeHome,
  claudeHomeWasExplicit,
  explicitMemoryDir,
  includePaths,
  repo,
} = parseArgs();
const exactMode = includePaths.length > 0;

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

let memoryDir = null;
if (!exactMode) {
  if (claudeHomeWasExplicit) requireReadableDirectory(claudeHome, "--claude-home");
  if (explicitMemoryDir) {
    requireReadableDirectory(explicitMemoryDir, "--memory-dir");
    memoryDir = explicitMemoryDir;
  } else {
    const configuredMemoryDir = configuredAutoMemory(repo, claudeHome);
    if (configuredMemoryDir) {
      requireReadableDirectory(configuredMemoryDir, "configured auto-memory directory");
      memoryDir = configuredMemoryDir;
    } else {
      memoryDir = readableDirectoryIfPresent(
        derivedMemoryCandidate(repo, claudeHome),
        "derived auto-memory directory",
      );
    }
  }
}

const files = exactMode
  ? includePaths
  : [...collectProjectFiles(repo), ...collectUserFiles(claudeHome), ...walk(memoryDir)].filter(
      (file) => isLegacyRegularFile(file),
    );
if (files.length === 0) fail("No Claude 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);
  if (isWithin(claudeHome, file)) {
    return path.join("user-claude-home", path.relative(claudeHome, file));
  }
  if (memoryDir && isWithin(memoryDir, file)) {
    return path.join("auto-memory", path.relative(memoryDir, file));
  }
  fail(`discovered Claude context escaped its configured roots: ${file}`, 1);
});
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", "claude", repoIdentityHash);
const backup = createBackup(
  backupStorage,
  ".claude-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-claude-memory.mjs›
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { collectProjectFiles, isDirectory, isFile, walk } from "./memory-source-discovery.mjs";

function usage() {
  console.log(`Usage: inventory-claude-memory.mjs [--repo PATH] [--claude-home PATH] [--memory-dir PATH] [--json]

List Claude Code memory and instruction files with size, modification metadata,
rule frontmatter, and visible settings signals.

Options:
  --repo PATH         Use PATH instead of the current working directory.
  --claude-home PATH  Use PATH instead of ~/.claude.
  --memory-dir PATH   Include an explicit auto memory directory.
  --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();
  let claudeHome = path.join(os.homedir(), ".claude");
  let explicitMemoryDir = null;
  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 === "--claude-home") {
      const value = args[i + 1];
      if (!value) fail("--claude-home requires a path.");
      claudeHome = value;
      i += 1;
      continue;
    }

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

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

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

  return {
    claudeHome: expandHome(claudeHome),
    explicitMemoryDir: explicitMemoryDir ? expandHome(explicitMemoryDir) : null,
    format,
    repo: path.resolve(repo),
  };
}

function expandHome(value) {
  if (value === "~") return os.homedir();
  if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
  return path.resolve(value);
}

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 relativeToBase(base, file) {
  const relative = path.relative(base, file);
  if (relative === "") return ".";
  if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
    return file;
  }
  return relative;
}

function relativeInside(base, file) {
  const relative = path.relative(base, file);
  if (relative === "") return ".";
  if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
    return null;
  }
  return 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 {
    paths: data.paths ?? null,
  };
}

function readJson(file) {
  try {
    return JSON.parse(fs.readFileSync(file, "utf8"));
  } catch {
    return null;
  }
}

function settingsSignals(file) {
  const data = readJson(file);
  if (!data || typeof data !== "object") return null;

  const mode = data.pluginConfigs?.["agents-md@builtin"]?.options?.instructionFiles;
  const knownModes = [
    "claude-md-or-agents-md",
    "claude-md-and-agents-md",
    "claude-md",
    "managed-only",
  ];
  return {
    instructionFiles: knownModes.includes(mode) ? mode : null,
    disableAllHooks: typeof data.disableAllHooks === "boolean" ? data.disableAllHooks : null,
    allowManagedHooksOnly:
      typeof data.allowManagedHooksOnly === "boolean" ? data.allowManagedHooksOnly : null,
    agentsMdPluginEnabled:
      typeof data.enabledPlugins?.["agents-md@builtin"] === "boolean"
        ? data.enabledPlugins["agents-md@builtin"]
        : null,
    autoMemoryEnabled: Object.hasOwn(data, "autoMemoryEnabled")
      ? Boolean(data.autoMemoryEnabled)
      : null,
    autoMemoryDirectory:
      typeof data.autoMemoryDirectory === "string" ? data.autoMemoryDirectory : null,
    claudeMdExcludes: Array.isArray(data.claudeMdExcludes) ? data.claudeMdExcludes.length : null,
    hooks: data.hooks && typeof data.hooks === "object" ? Object.keys(data.hooks).length : null,
    permissions:
      data.permissions && typeof data.permissions === "object"
        ? Object.keys(data.permissions).length
        : null,
  };
}

function managedPolicyFiles() {
  const baseDirs = [
    "/etc/claude-code",
    "/Library/Application Support/ClaudeCode",
    "C:\\Program Files\\ClaudeCode",
  ];
  const files = [];

  for (const dir of baseDirs) {
    files.push(path.join(dir, "CLAUDE.md"));
    files.push(path.join(dir, "managed-settings.json"));
    const dropIns = path.join(dir, "managed-settings.d");
    files.push(...walk(dropIns, (file) => file.endsWith(".json")));
  }

  return files.filter((file) => isFile(file));
}

function collectUserFiles(claudeHome) {
  return [
    path.join(claudeHome, "CLAUDE.md"),
    path.join(claudeHome, "settings.json"),
    ...walk(path.join(claudeHome, "rules"), (file) => file.endsWith(".md")),
  ];
}

function expandConfiguredPath(value) {
  if (!value || typeof value !== "string") return null;
  return expandHome(value);
}

function visibleSettingsFiles(repo, claudeHome) {
  return [
    { path: path.join(claudeHome, "settings.json"), precedence: 1, source: "user" },
    { path: path.join(repo, ".claude", "settings.json"), precedence: 2, source: "project" },
    { path: path.join(repo, ".claude", "settings.local.json"), precedence: 3, source: "local" },
    ...managedPolicyFiles()
      .filter((file) => path.basename(file) !== "CLAUDE.md")
      .map((file) => ({ path: file, precedence: 4, source: "managed-policy" })),
  ].filter((entry) => isFile(entry.path));
}

function configuredAutoMemory(repo, claudeHome) {
  const settings = visibleSettingsFiles(repo, claudeHome)
    .map((entry) => ({ ...entry, signals: settingsSignals(entry.path) }))
    .filter((entry) => entry.signals?.autoMemoryDirectory)
    .sort((a, b) => b.precedence - a.precedence)[0];

  if (!settings) return null;

  return {
    dir: expandConfiguredPath(settings.signals.autoMemoryDirectory),
    source: settings.path,
    source_scope: settings.source,
  };
}

function derivedMemoryCandidate(repo, claudeHome) {
  const slug = repo.replace(/^[\\/]+/, "").replace(/[:\\/]+/g, "-");
  return path.join(claudeHome, "projects", slug, "memory");
}

function collectMemoryFiles(memoryDir) {
  if (!memoryDir || !isDirectory(memoryDir)) return [];
  return walk(memoryDir, (file) => file.endsWith(".md"));
}

function surfaceFor(repo, claudeHome, memoryDir, file) {
  const repoRelative = relativeInside(repo, file)?.replaceAll("\\", "/") ?? null;
  const homeRelative = relativeInside(claudeHome, file)?.replaceAll("\\", "/") ?? null;
  const memoryRelative = memoryDir
    ? (relativeInside(memoryDir, file)?.replaceAll("\\", "/") ?? null)
    : null;

  if (memoryDir && memoryRelative !== null) {
    return memoryRelative.toLowerCase() === "memory.md"
      ? "claude-auto-memory-entrypoint"
      : "claude-auto-memory-topic";
  }
  if (managedPolicyFiles().includes(file)) {
    return path.basename(file) === "CLAUDE.md"
      ? "claude-managed-policy"
      : "claude-managed-settings";
  }
  if (homeRelative !== null) {
    if (homeRelative === "CLAUDE.md") return "claude-user-md";
    if (homeRelative === "settings.json") return "claude-user-settings";
    if (homeRelative.startsWith("rules/")) return "claude-user-rule";
  }
  if (repoRelative !== null) {
    if (/(^|\/)\.claude\/rules\//.test(repoRelative)) return "claude-project-rule";
    if (path.basename(file) === "AGENTS.md") return "claude-agents-md-candidate";
    if (repoRelative === "CLAUDE.md" || repoRelative === ".claude/CLAUDE.md") {
      return "claude-project-md";
    }
    if (repoRelative.endsWith("CLAUDE.local.md")) return "claude-local-md";
    if (repoRelative === ".claude/settings.json") return "claude-project-settings";
    if (repoRelative === ".claude/settings.local.json") return "claude-local-settings";
  }
  if (path.basename(file) === "AGENTS.md") return "claude-agents-md-candidate";
  return "unknown";
}

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

  const surface = surfaceFor(repo, claudeHome, memoryDir, file);
  return {
    path: file,
    relative_path: relativeToBase(repo, file),
    surface,
    size_bytes: stats.size,
    size: formatBytes(stats.size),
    modified_at: stats.mtime.toISOString(),
    frontmatter:
      surface === "claude-project-rule" || surface === "claude-user-rule"
        ? parseFrontmatter(file)
        : null,
    settings:
      surface.includes("settings") || surface === "claude-managed-settings"
        ? settingsSignals(file)
        : null,
    loading: surface === "claude-agents-md-candidate" ? "unverified" : null,
    instruction_files_setting_scope: surface.includes("settings")
      ? ["claude-user-settings", "claude-managed-settings"].includes(surface)
        ? "eligible"
        : "ignored"
      : null,
    read_only: surface === "claude-managed-policy" || surface === "claude-managed-settings",
  };
}

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

const { claudeHome, explicitMemoryDir, format, repo } = parseArgs();

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

const configured = configuredAutoMemory(repo, claudeHome);
const derived = derivedMemoryCandidate(repo, claudeHome);
const memoryDir = explicitMemoryDir ?? configured?.dir ?? (isDirectory(derived) ? derived : null);

const candidateFiles = [
  ...collectProjectFiles(repo),
  ...collectUserFiles(claudeHome),
  ...managedPolicyFiles(),
  ...collectMemoryFiles(memoryDir),
].filter((file) => isFile(file));

const records = [...new Set(candidateFiles)]
  .sort()
  .map((file) => recordFor(repo, claudeHome, memoryDir, file))
  .filter(Boolean);

const payload = {
  repo,
  claude_home: claudeHome,
  auto_memory_dir: memoryDir,
  auto_memory_source: explicitMemoryDir
    ? "explicit --memory-dir"
    : configured
      ? `${configured.source_scope}: ${configured.source}`
      : isDirectory(derived)
        ? "derived candidate"
        : null,
  auto_memory_note: memoryDir
    ? null
    : "No auto memory directory found. Use /memory or pass --memory-dir when Claude Code uses a project-derived path that cannot be proven statically.",
  agents_md_note:
    "AGENTS.md candidates and observed settings do not prove effective loading. Confirm version, provider/feature flags, hook/plugin restrictions, and session evidence; preserve imports for unsupported hosts.",
  files_count: records.length,
  files: records,
  message:
    records.length === 0
      ? "No Claude context files found. Provide --claude-home, --memory-dir, or /memory evidence if needed."
      : undefined,
};

if (format === "json") {
  printJson(payload);
} else {
  console.log(`Repo: ${repo}`);
  console.log(`Claude home: ${claudeHome}`);
  console.log(payload.agents_md_note);
  if (payload.auto_memory_dir) console.log(`Auto memory: ${payload.auto_memory_dir}`);
  if (payload.auto_memory_note) console.log(payload.auto_memory_note);
  if (records.length === 0) {
    console.log(payload.message);
  } else {
    for (const record of records) {
      const frontmatter = record.frontmatter?.paths
        ? ` paths=${JSON.stringify(record.frontmatter.paths)}`
        : "";
      const settings = record.settings
        ? ` settings=${JSON.stringify(Object.fromEntries(Object.entries(record.settings).filter(([, value]) => value !== null)))}`
        : "";
      const readOnly = record.read_only ? " read-only" : "";
      console.log(
        `${record.size} ${record.modified_at} ${record.surface}${readOnly} ${record.relative_path}${frontmatter}${settings}`,
      );
    }
  }
}
scripts/memory-source-discovery.mjs›
import fs from "node:fs";
import path from "node:path";

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

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

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

export 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();
}

export function collectAncestorAgentFiles(repo) {
  const files = [];
  let directory = path.dirname(path.resolve(repo));

  while (true) {
    const candidate = path.join(directory, "AGENTS.md");
    if (isFile(candidate)) files.push(candidate);

    const parent = path.dirname(directory);
    if (parent === directory) break;
    directory = parent;
  }

  return files;
}

export function collectProjectFiles(repo) {
  return [
    ...collectAncestorAgentFiles(repo),
    path.join(repo, "CLAUDE.md"),
    path.join(repo, ".claude", "CLAUDE.md"),
    path.join(repo, "CLAUDE.local.md"),
    path.join(repo, ".claude", "settings.json"),
    path.join(repo, ".claude", "settings.local.json"),
    ...walk(repo, (file) => {
      const name = path.basename(file);
      const relative = path.relative(repo, file).split(path.sep);
      const isProjectRule = relative.some(
        (part, index) => part === ".claude" && relative[index + 1] === "rules",
      );
      return (
        (isProjectRule && file.endsWith(".md")) ||
        name === "CLAUDE.md" ||
        name === "CLAUDE.local.md" ||
        (name === "AGENTS.md" && !path.relative(repo, file).split(path.sep).includes(".agents"))
      );
    }),
  ];
}
scripts/scan-claude-memory-risks.mjs›
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { collectProjectFiles, isDirectory, isFile, walk } from "./memory-source-discovery.mjs";

function usage() {
  console.log(`Usage: scan-claude-memory-risks.mjs [--repo PATH] [--claude-home PATH] [--memory-dir PATH] [--json] [--max-findings N]

Scan Claude Code memory and instruction files for high-risk lines that deserve review.

Options:
  --repo PATH         Use PATH instead of the current working directory.
  --claude-home PATH  Use PATH instead of ~/.claude.
  --memory-dir PATH   Include an explicit auto memory directory.
  --json              Print structured JSON instead of text.
  --max-findings N    Limit returned findings; default is 100.
  -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();
  let claudeHome = path.join(os.homedir(), ".claude");
  let explicitMemoryDir = null;
  let format = "text";
  let maxFindings = DEFAULT_MAX_FINDINGS;
  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 === "--claude-home") {
      const value = args[i + 1];
      if (!value) fail("--claude-home requires a path.");
      claudeHome = value;
      i += 1;
      continue;
    }

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

    if (arg === "--json") {
      format = "json";
      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 {
    claudeHome: expandHome(claudeHome),
    explicitMemoryDir: explicitMemoryDir ? expandHome(explicitMemoryDir) : null,
    format,
    maxFindings,
    repo: path.resolve(repo),
  };
}

function expandHome(value) {
  if (value === "~") return os.homedir();
  if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
  return path.resolve(value);
}

function relativeToBase(base, file) {
  const relative = path.relative(base, file);
  if (relative === "") return ".";
  if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
    return file;
  }
  return relative;
}

function relativeInside(base, file) {
  const relative = path.relative(base, file);
  if (relative === "") return ".";
  if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
    return null;
  }
  return relative;
}

function collectUserFiles(claudeHome) {
  return [
    path.join(claudeHome, "CLAUDE.md"),
    path.join(claudeHome, "settings.json"),
    ...walk(path.join(claudeHome, "rules"), (file) => file.endsWith(".md")),
  ];
}

function managedPolicyFiles() {
  const baseDirs = [
    "/etc/claude-code",
    "/Library/Application Support/ClaudeCode",
    "C:\\Program Files\\ClaudeCode",
  ];
  const files = [];

  for (const dir of baseDirs) {
    files.push(path.join(dir, "CLAUDE.md"));
    files.push(path.join(dir, "managed-settings.json"));
    files.push(...walk(path.join(dir, "managed-settings.d"), (file) => file.endsWith(".json")));
  }

  return files.filter((file) => isFile(file));
}

function collectMemoryFiles(memoryDir) {
  if (!memoryDir || !isDirectory(memoryDir)) return [];
  return walk(memoryDir, (file) => file.endsWith(".md"));
}

function readJson(file) {
  try {
    return JSON.parse(fs.readFileSync(file, "utf8"));
  } catch {
    return null;
  }
}

function configuredAutoMemory(repo, claudeHome) {
  const files = [
    path.join(claudeHome, "settings.json"),
    path.join(repo, ".claude", "settings.json"),
    path.join(repo, ".claude", "settings.local.json"),
    ...managedPolicyFiles().filter((file) => path.basename(file) !== "CLAUDE.md"),
  ].filter((file) => isFile(file));

  for (const file of files.reverse()) {
    const data = readJson(file);
    if (typeof data?.autoMemoryDirectory === "string") {
      return expandHome(data.autoMemoryDirectory);
    }
  }

  return null;
}

function derivedMemoryCandidate(repo, claudeHome) {
  const slug = repo.replace(/^[\\/]+/, "").replace(/[:\\/]+/g, "-");
  return path.join(claudeHome, "projects", slug, "memory");
}

function surfaceFor(repo, claudeHome, memoryDir, file) {
  const repoRelative = relativeInside(repo, file)?.replaceAll("\\", "/") ?? null;
  const homeRelative = relativeInside(claudeHome, file)?.replaceAll("\\", "/") ?? null;
  const memoryRelative = memoryDir
    ? (relativeInside(memoryDir, file)?.replaceAll("\\", "/") ?? null)
    : null;

  if (memoryDir && memoryRelative !== null) {
    return memoryRelative.toLowerCase() === "memory.md"
      ? "claude-auto-memory-entrypoint"
      : "claude-auto-memory-topic";
  }
  if (managedPolicyFiles().includes(file)) {
    return path.basename(file) === "CLAUDE.md"
      ? "claude-managed-policy"
      : "claude-managed-settings";
  }
  if (homeRelative !== null) {
    if (homeRelative === "CLAUDE.md") return "claude-user-md";
    if (homeRelative === "settings.json") return "claude-user-settings";
    if (homeRelative.startsWith("rules/")) return "claude-user-rule";
  }
  if (repoRelative !== null) {
    if (/(^|\/)\.claude\/rules\//.test(repoRelative)) return "claude-project-rule";
    if (path.basename(file) === "AGENTS.md") return "claude-agents-md-candidate";
    if (repoRelative === "CLAUDE.md" || repoRelative === ".claude/CLAUDE.md") {
      return "claude-project-md";
    }
    if (repoRelative.endsWith("CLAUDE.local.md")) return "claude-local-md";
    if (repoRelative === ".claude/settings.json") return "claude-project-settings";
    if (repoRelative === ".claude/settings.local.json") return "claude-local-settings";
  }
  if (path.basename(file) === "AGENTS.md") return "claude-agents-md-candidate";
  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|block|enforce)\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|autoMemoryEnabled|autoMemoryDirectory|claudeMdExcludes|permissions|sandbox|hooks)\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));
}

const { claudeHome, explicitMemoryDir, format, maxFindings, repo } = parseArgs();

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

const configuredMemoryDir = configuredAutoMemory(repo, claudeHome);
const derived = derivedMemoryCandidate(repo, claudeHome);
const memoryDir =
  explicitMemoryDir ?? configuredMemoryDir ?? (isDirectory(derived) ? derived : null);

const files = [
  ...collectProjectFiles(repo),
  ...collectUserFiles(claudeHome),
  ...managedPolicyFiles(),
  ...collectMemoryFiles(memoryDir),
]
  .filter((file) => isFile(file))
  .filter((file, index, array) => array.indexOf(file) === index)
  .sort();

const findings = [];
let filesScanned = 0;
let truncated = false;

for (const file of files) {
  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: relativeToBase(repo, file),
      surface: surfaceFor(repo, claudeHome, memoryDir, file),
      line: index + 1,
      categories,
      redacted_line: trimLine(redact(line)),
    });

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

  if (truncated) break;
}

const payload = {
  repo,
  claude_home: claudeHome,
  auto_memory_dir: memoryDir,
  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 Claude context risks found.");
} else {
  console.log(`Claude 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: claude-memory-curator
description: Audit and safely curate Claude Code instructions, rules, auto memory, and memory settings. Use when reviewing stale or misplaced Claude Code context, not Claude app memory or API memory stores.
license: Apache-2.0
compatibility: Designed for Claude Code and Claude Code skills. Works in other agents when inspecting Claude Code instruction files, settings, rules, and auto-memory markdown stores.
metadata:
  author: stark-ai-de
  category: claude-operations
  version: "0.2.2"
---

# Claude Memory Curator

## Goal

Audit Claude Code durable context as user-owned agent state: expose stale, unsafe, duplicated, over-broad, conflicting, misplaced, or unenforceable entries; propose better destinations; and route review, planning, persistence, and cleanup through one explicit contract.

Keep the subject scoped to Claude Code surfaces: `CLAUDE.md`, `CLAUDE.local.md`, conditionally loaded `AGENTS.md`, `.claude/rules/`, user-level Claude rules, settings, hooks, managed policy evidence, and auto memory files. Do not treat this as a Codex, Cursor, Claude app, or Anthropic API Memory Stores curator.

## When to use

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

## When not to use

- Do not use for Codex, Cursor, Claude web/app, Anthropic API Memory Stores, generic prompt engineering, or repo-doc cleanup without Claude durable context.
- Keep review requests read-only. Treat managed policy, including `/etc/claude-code/CLAUDE.md` and `managed-settings.json`, as read-only unless exact edit authority is explicit.

## 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 Claude 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, managed-policy edits, paid or external actions, deployment, publication, or scope expansion.

## Inputs to inspect

- When `AGENTS.md` is relevant, read `references/context-surface-anatomy.md`. Inventory presence separately from effective loading: confirm Claude version, provider/feature availability, settings, hook/plugin restrictions, and session loading evidence. Keep a working `@AGENTS.md` import for older or restricted hosts.
- Resolve the repo, Claude home, configured auto-memory directory, and only the applicable project/user instruction, rule, setting, hook, managed-policy, and memory surfaces.
- Inspect current repository evidence only as needed to verify a disputed claim.
- Load the placement, classification, conflict, enforcement, 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, Claude home, auto-memory path, persistence path when applicable, and protected state.
2. Inventory Claude context without dumping contents:

   ```bash
   node scripts/inventory-claude-memory.mjs --repo .
   ```

   Add `--claude-home PATH` or `--memory-dir PATH` when the user provides non-default locations. Use `--json` when structured evidence is useful.

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

   ```bash
   node scripts/scan-claude-memory-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 `.claude/rules/**/*.md` frontmatter for `paths` so path-scoped guidance is not treated like always-loaded guidance.
5. Inspect visible settings for `autoMemoryEnabled`, `autoMemoryDirectory`, `claudeMdExcludes`, permission rules, and hooks when they affect placement.
6. Treat managed policy files and managed settings as higher-precedence read-only evidence by default.
7. Treat `MEMORY.md` as the auto-memory entrypoint. Treat topic files as on-demand detail unless the user asks to audit the whole memory directory.
8. Read context files in bounded chunks and redact sensitive values.
9. Extract one atomic claim per row. Split compound entries before classification.
10. Verify disputed claims against only the repo files needed for the dispute. Load `references/conflict-resolution.md` when precedence is unclear.
11. Assign exactly one primary classification per atomic claim: `KEEP`, `KEEP BUT REWRITE`, `MOVE TO CLAUDE.md`, `MOVE TO CLAUDE.local.md`, `MOVE TO CLAUDE RULE`, `MOVE TO AUTO MEMORY TOPIC`, `MOVE TO AGENTS.md`, `MOVE TO REPO DOCS`, `MOVE TO SKILL`, `MOVE TO SETTINGS`, `MOVE TO HOOK`, `MOVE TO MANAGED POLICY`, `DELETE`, or `ASK USER`.
12. Tag high-risk entries as useful context only: `stale`, `duplicated`, `too-broad`, `too-specific`, `repo-specific`, `workflow`, `config`, `sensitive`, `conflicting`, `unenforced`, `managed-policy`, or `useful`.
13. Add confidence (`high`, `medium`, or `low`) and a proposed action to every entry.
14. 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 Claude 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 Claude context. Defer whole-file deletion, shared `AGENTS.md`, new context files, settings, hooks, managed policy, `AGENTS.md`, repository docs, skills, UI-only User/Team settings, uncertain 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.

## 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 `claude-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/claude-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 Claude 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, internal hostnames, 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 an auto-memory topic schema is unclear, do not edit it directly. Defer it in the current record or chat result.
- Treat `CLAUDE.md` and auto memory as context, not enforcement. Recommend settings or hooks for deterministic restrictions.
- Do not apply repo-specific assumptions globally. Prefer project `CLAUDE.md`, `.claude/rules`, `AGENTS.md`, 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 enforcement: `references/conflict-resolution.md`, `references/settings-and-hooks.md`.
- Safe mutation and examples: `references/safe-editing-procedure.md`, `references/example-review-report.md`.
- Output artifacts: `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-claude-memory.mjs [--repo PATH] [--claude-home PATH] [--memory-dir PATH] [--json]
node scripts/scan-claude-memory-risks.mjs [--repo PATH] [--claude-home PATH] [--memory-dir PATH] [--json] [--max-findings N]
node scripts/backup-claude-memory.mjs [--repo PATH] [--claude-home PATH] [--memory-dir 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-claude-memory.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, `.claude/rules` metadata, settings, and auto-memory boundaries were inventoried or reported missing.
- Every atomic claim has one classification, risk tags, confidence, action, and higher-precedence conflict evidence when applicable.
- Deterministic restrictions route to settings/hooks; 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 auto-memory path: report what is unavailable and request `/memory`, `claude --version`, or an explicit `--memory-dir` only when needed.
- Managed policy or unknown schema: keep read-only and recommend/defer the manual action.
- 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.