# nils-plan-archive

`plan-archive` is the deterministic CLI surface for the plan-archive
workflow described in
[`agent-runtime-kit:docs/plans/2026-05-26-plan-archive-system/`][master-design].
Skills in `agent-runtime-kit` (`meta:plan-archive-migrate` and
`meta:plan-archive-query`) wrap this binary; this crate does not talk
to provider APIs directly.

## Sprint 1 surface

Sprint 1 (Plan 1 of the plan-archive system) ships the three schema
validators that later subcommands depend on:

- `plan-archive validate-hosts --input <path>` — validate an archive
  `config/hosts.yaml`. Enforces the v1 schema (`personal` / `employer`
  classes, employer name on `class: employer`, recognised retention
  values, supported `version`).
- `plan-archive validate-local --input <path>` — validate the
  machine-local config at
  `$XDG_CONFIG_HOME/agent-plan-archive/config.yaml`. Tolerates a
  missing file by returning documented defaults and exit code 0.
- `plan-archive validate-metadata --input <path>` — validate an
  archived plan's `metadata.yaml`. Accepts pre-classification plans
  that omit `captured_classification` (emits the
  `metadata-captured-classification-missing` warning) and rejects
  plans that name no `refs.issue|pr|mr`.

`migrate`, `refresh`, and `query` are declared in the CLI surface but
respond with `subcommand-not-implemented` in Sprint 1; their bodies
land in Sprints 3–5.

## Sprint 2 surface

Sprint 2 (Plan 1) lands the secret-scrub library that
`plan-archive refresh` consumes before it writes a snapshot to
`_index/`. The library is exported from the crate root and has no
CLI subcommand of its own:

- `plan_archive::scrub_text(input)` — scan `input` against the v1
  pattern set, return the redacted text plus per-match metadata.
- `plan_archive::scrub::write_log_if_any(path, matches)` — write the
  stable `<ISO8601>.scrub.log` sibling when at least one redaction
  occurred. No file is written for clean payloads.
- `plan_archive::scrub::pattern_ids()` — read-only view of the
  configured pattern set, stable across patch versions of the same
  `PATTERN_SET` major.

v1 pattern set:

| Pattern id | Detects | Capture |
| --- | --- | --- |
| `github-token` | GitHub personal/OAuth/app tokens (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`) | entire token |
| `gitlab-token` | GitLab personal access tokens (`glpat-`) | entire token |
| `bitbucket-app-password` | Bitbucket app passwords (`ATBB…`) | entire token |
| `aws-access-key-id` | AWS access key ids (`AKIA`, `ASIA`, …) | entire id |
| `generic-secret-kv` | `secret/token/password/api_key`-style key-value pairs | value only |
| `pem-private-key` | `-----BEGIN … PRIVATE KEY-----` blocks | entire block |

Replacement token is `[REDACTED]`. Overlapping matches keep the
earliest, widest span; the same secret is never reported twice. The
scrub log itself never contains the secret value — only pattern id,
byte offset, span length, and replacement length.

## Sprint 3 surface

Sprint 3 (Plan 1) lands `plan-archive migrate`, which moves a closed
plan folder out of a working repo into the archive repo. Dry-run is
the default; `--apply` performs the writes and commits. To enumerate
which folders look ready before migrating one, use the read-only
[`discover`](#discover-surface) preselection scan described below.

```bash
plan-archive migrate \
  --plan docs/plans/2026-05-27-my-plan \
  --issue https://github.com/org/repo/issues/123 \
  [--source-repo <path>] [--archive <path>] [--hosts <path>] \
  [--pr <url>] [--mr <url>] [--apply]
```

- `--source-repo` defaults to the current git repo root; `--archive`
  defaults to the machine-local config's `archive_clone_path`;
  `--hosts` defaults to `<archive>/config/hosts.yaml`.
- At least one of `--issue` / `--pr` / `--mr` is required so the
  archived `metadata.yaml` always carries a provider reference.
- Dry-run resolves the source identity from the `origin` remote
  (host, org/group path, repo, branch, HEAD SHA), classifies the host
  against `config/hosts.yaml`, enumerates the tracked plan files via
  `git ls-files`, and assembles the `metadata.yaml` payload — without
  touching any working tree.
- Apply copies the files under
  `plans/<host>/<org>/<repo>/<folder>/`, writes `metadata.yaml`,
  commits the archive via the released `semantic-commit` binary,
  pushes the archive, and only on push success deletes the source
  folder and commits that deletion in the working repo.
- While copying, an `*-execution-state.md` whose `## Execution State`
  header is still mid-flight (e.g. "delivery pending") gets its
  `Status` / `Current task` / `Next task` bullets rewritten to a
  terminal "archived" status that defers to the issue/PR ref. Migrate
  only archives closed plans, so this stops the archived bundle's
  human-readable header from freezing before merge/closeout. The
  rewrite is purely textual; the rest of the bundle is copied
  verbatim, and the authoritative live state stays in the `_index/`
  issue snapshot and `catalog.json` refs.

Stable error codes (Sprint 3):

| Code | When |
| --- | --- |
| `migrate-source-repo-not-found` | source repo path is not a git repo |
| `migrate-plan-folder-missing` | `--plan` folder absent in the source repo |
| `migrate-archive-clone-missing` | archive clone path not found |
| `migrate-unknown-host` | source host absent from `config/hosts.yaml` |
| `migrate-hosts-load-failed` / `migrate-hosts-parse-failed` | hosts config unreadable/invalid |
| `migrate-no-refs-supplied` | none of `--issue`/`--pr`/`--mr` given |
| `migrate-identity-failed` | could not read remote/branch/HEAD |
| `migrate-archive-target-exists` | archive target already present (apply) |
| `migrate-source-repo-dirty` | uncommitted changes in the plan folder (apply) |
| `migrate-subprocess-failed` | a git or semantic-commit subprocess failed |
| `migrate-io-error` | filesystem failure |

## Discover surface

`plan-archive discover` is a **read-only** scanner that enumerates plan
folders under a working repo and classifies each as an archive
candidate. It never copies, deletes, writes, commits, pushes, or
refreshes provider state — it only reads local files and previews where
each folder *would* land in the archive. It reuses the same source
identity, host classification, and archive-target derivation as
`migrate` (`plans/<host>/<org>/<repo>/<folder>/`), so the two commands
cannot drift.

```bash
plan-archive discover \
  [--source-repo <path>] [--plans-root docs/plans] \
  [--archive <path>] [--hosts <path>] \
  [--include-unknown] [--format json|text]
```

- `--source-repo` defaults to the current git repo root; `--plans-root`
  is repo-relative and defaults to `docs/plans`; `--archive` and
  `--hosts` resolve exactly as for `migrate`.
- `--include-unknown` adds `unknown` candidates to the listing (they are
  omitted by default). The summary always reports `scanned`, `eligible`,
  `blocked`, and `unknown` counts, so nothing is silently hidden.

### Status classes

| Status | Meaning |
| --- | --- |
| `eligible` | ≥1 inferred provider ref, a free archive target, a clean plan folder, a known host, and confident closeout evidence. Carries a ready-to-review `suggested_migrate_command`. |
| `blocked` | Has at least one actionable blocker: `archive-target-exists`, `source-plan-folder-dirty`, `unknown-host`, or `no-provider-refs`. |
| `unknown` | Otherwise migrate-able, but closeout evidence could not be confidently inferred from local files (`closeout-evidence-uncertain`). |

Provider refs are inferred by parsing issue/PR/MR URLs out of the
folder's top-level Markdown; each ref records whether it points at the
source repo or is cross-repo. Discovery never calls a provider.

### Closeout evidence

A folder counts as closed (and so `eligible`, given no blockers) when a
top-level Markdown file carries either:

- a Markdown **Closeout** heading (`## Closeout`, `# Close-out`, …), or
- a `Status:` line whose value contains a terminal keyword
  (`complete`, `completed`, `closed`, `done`, `delivered`, `merged`,
  `archived`, `shipped`, `ready for close`) **and** no in-flight keyword
  (`active`, `in progress`, `not started`, `pending`, `wip`, `todo`,
  `blocked`, `ongoing`, `draft`, `ready to implement`).

The in-flight veto keeps sprint-level notes such as
`Status: Sprint 2 complete; Sprint 3 active` out of `eligible`. When the
signal is absent or ambiguous the folder stays `unknown` rather than
being promoted — discover never invents closeout evidence.

### Example (`--format json`)

```json
{
  "schema_version": "cli.plan-archive.discover.v1",
  "data": {
    "source": {
      "host": "github.com",
      "org_or_group_path": "graysurf",
      "repo": "agent-runtime-kit",
      "branch": "main",
      "commit": "…"
    },
    "plans_root": "docs/plans",
    "archive": "/abs/agent-plan-archive",
    "host_known": true,
    "summary": {
      "scanned": 2,
      "eligible": 1,
      "blocked": 1,
      "unknown": 0,
      "included_unknown": false
    },
    "candidates": [
      {
        "plan_folder": "2026-05-01-ready-plan",
        "source_path": "docs/plans/2026-05-01-ready-plan/",
        "status": "eligible",
        "reasons": [],
        "refs": [
          {
            "url": "https://github.com/graysurf/agent-runtime-kit/issues/10",
            "kind": "issue",
            "source_file": "docs/plans/2026-05-01-ready-plan/plan.md",
            "matches_source_repo": true
          }
        ],
        "archive_target": {
          "relative_path": "plans/github.com/graysurf/agent-runtime-kit/2026-05-01-ready-plan",
          "absolute_path": "/abs/agent-plan-archive/plans/github.com/graysurf/agent-runtime-kit/2026-05-01-ready-plan",
          "exists": false
        },
        "closeout_evidence": {
          "marker": "complete; all sprints delivered",
          "source_file": "docs/plans/2026-05-01-ready-plan/execution-state.md"
        },
        "dirty": false,
        "suggested_migrate_command": "plan-archive migrate --plan docs/plans/2026-05-01-ready-plan --issue https://github.com/graysurf/agent-runtime-kit/issues/10 --format json"
      },
      {
        "plan_folder": "2026-05-02-stuck-plan",
        "source_path": "docs/plans/2026-05-02-stuck-plan/",
        "status": "blocked",
        "reasons": [
          {
            "code": "archive-target-exists",
            "detail": "archive target already exists; resolve the collision before migrating"
          }
        ],
        "refs": [
          {
            "url": "https://github.com/graysurf/agent-runtime-kit/issues/11",
            "kind": "issue",
            "source_file": "docs/plans/2026-05-02-stuck-plan/plan.md",
            "matches_source_repo": true
          }
        ],
        "archive_target": {
          "relative_path": "plans/github.com/graysurf/agent-runtime-kit/2026-05-02-stuck-plan",
          "absolute_path": "/abs/agent-plan-archive/plans/github.com/graysurf/agent-runtime-kit/2026-05-02-stuck-plan",
          "exists": true
        },
        "dirty": false
      }
    ]
  }
}
```

`discover` is a **preselection helper only**. It suggests a
`plan-archive migrate …` command for each eligible folder, but applying a
migration stays with `migrate`, which is dry-run-first and gated on
explicit confirmation. Review each folder's migrate dry-run before
`--apply`; there is no bulk-apply path and discovery never refreshes
provider state.

Stable error codes (discover):

| Code | When |
| --- | --- |
| `discover-source-repo-not-found` | `--source-repo` is not a git repo |
| `discover-archive-clone-missing` | archive clone path not found |
| `discover-hosts-load-failed` / `discover-hosts-parse-failed` | hosts config unreadable/invalid |
| `discover-identity-failed` | could not read source remote/branch/HEAD |
| `discover-plans-root-outside-repo` | `--plans-root` resolves outside the source repo |
| `discover-io-error` | filesystem failure |

## Sprint 4 surface

Sprint 4 (Plan 1) lands `plan-archive refresh`, which fetches
provider payloads through `forge-cli`, scrubs them with the Sprint 2
library, and appends an `<ISO8601>.json` snapshot under `_index/`.
Refresh **writes and scrubs but never commits** — when a snapshot
triggers redactions it emits the `.scrub.log` sibling and flags
`requires_review: true`; the wrapping skill/user reviews the log and
commits separately.

```bash
plan-archive refresh --ref https://github.com/org/repo/issues/123
plan-archive refresh --repo github.com/org/repo [--since 2026-05-01]
  [--archive <path>] [--hosts <path>]
```

- `--ref` refreshes a single issue/PR/MR URL. `--repo` lists open
  refs through `forge-cli … list --state open` and refreshes each;
  `--since YYYY-MM-DD` narrows the batch. The two selectors are
  mutually exclusive.
- Provider API access is delegated to `forge-cli` (override the binary
  with `PLAN_ARCHIVE_FORGE_CLI`); the host is mapped to `github` for
  `github.com` and `gitlab` otherwise. `config/hosts.yaml` gates which
  hosts are allowed and drives the `_index/` path.
- Snapshots are append-only: each refresh writes a new
  `<ISO8601>.json` (basic-format UTC, no colons) and never overwrites
  or deletes a prior one. A batch isolates per-ref failures into the
  `failed` array instead of aborting.

Stable error codes (Sprint 4):

| Code | When |
| --- | --- |
| `refresh-no-selector` | neither `--ref` nor `--repo` supplied |
| `refresh-unparseable-ref` | `--ref` is not an issue/PR/MR URL |
| `refresh-unparseable-repo` | `--repo` is not `host/org/repo` |
| `refresh-unknown-host` | host absent from `config/hosts.yaml` |
| `refresh-invalid-since` | `--since` is not `YYYY-MM-DD` |
| `refresh-archive-clone-missing` | archive clone path not found |
| `refresh-hosts-load-failed` / `refresh-hosts-parse-failed` | hosts config unreadable/invalid |
| `refresh-forge-fetch-failed` | forge-cli fetch failed (per-ref in batch) |
| `refresh-io-error` | filesystem failure |

## Sprint 5 surface

Sprint 5 (Plan 1) lands `plan-archive query`, the read-only cache
surface. It never fetches, writes, or commits. Three mutually
exclusive modes:

```bash
plan-archive query --ref https://github.com/org/repo/issues/123
plan-archive query --host github.com [--org <o>] [--repo <r>] [--since 2026-05-01]
plan-archive query --plan plans/github.com/org/repo/2026-05-27-slug
plan-archive query --refs-from path/to/metadata.yaml
  [--archive <path>]
```

- single-ref (`--ref`): returns the latest snapshot for one
  issue/PR/MR plus its `fetched_at`. A ref with no snapshots returns
  a record with `latest_snapshot: null` (exit 0), not an error.
- aggregate (`--host`/`--org`/`--repo`/`--since`): walks `_index/`
  across every reachable host in one pass and returns each matching
  ref's latest snapshot. A no-match query returns an empty array,
  exit 0.
- link traversal (`--plan` / `--refs-from`): reads an archived plan's
  `metadata.yaml` (`--plan` resolves `<archive>/<plan>/metadata.yaml`;
  `--refs-from` takes the metadata path directly) and resolves each
  `refs.issue|pr|mr` to its latest snapshot.

`fetched_at` is surfaced by default on every record (decoded from the
basic-format snapshot filename into extended ISO8601). Latest is the
lexically-last snapshot filename in the ref folder.

Stable error codes (Sprint 5):

| Code | When |
| --- | --- |
| `query-no-selector` | no `--ref`/filter/`--plan`/`--refs-from` supplied |
| `query-unparseable-ref` | `--ref` is not an issue/PR/MR URL |
| `query-invalid-since` | `--since` is not `YYYY-MM-DD` |
| `query-archive-clone-missing` | archive clone path not found |
| `query-metadata-not-found` | `--plan`/`--refs-from` metadata absent |
| `query-metadata-read-failed` | metadata unreadable/invalid |
| `query-metadata-no-refs` | metadata carries no refs to traverse |
| `query-io-error` | filesystem failure |

## Output contracts

Both human-readable text (default) and a versioned JSON envelope
(`--format json`) are supported, following
`docs/specs/cli-output-contract-v1.md` and the workspace
`nils_common::cli_contract` primitives. Successful runs use the
`cli.plan-archive.<subcommand>.v1` schema version. Failures emit a
`{code, message, hint?}` error envelope on `stderr` with exit code
`65` (`EX_DATAERR`).

Stable error codes (Sprint 1):

| Subcommand | Code | When |
| --- | --- | --- |
| `validate-hosts` | `hosts-parse-error` | YAML parse failure |
| `validate-hosts` | `hosts-unsupported-version` | `version` is not `1` |
| `validate-hosts` | `hosts-missing-hosts` | top-level `hosts` missing or empty |
| `validate-hosts` | `hosts-unknown-class` | `class` is not `personal`/`employer` |
| `validate-hosts` | `hosts-employer-missing-name` | `class: employer` with no `employer` |
| `validate-hosts` | `hosts-unknown-retention` | unrecognised `retention` |
| `validate-local` | `local-parse-error` | YAML parse failure |
| `validate-local` | `local-unsupported-version` | `version` is not `1` |
| `validate-local` | `local-invalid-batch-size` | `refresh_batch_size <= 0` |
| `validate-local` | `local-io-error` | filesystem read failure |
| `validate-metadata` | `metadata-parse-error` | YAML parse failure |
| `validate-metadata` | `metadata-unsupported-version` | `version` is not `1` |
| `validate-metadata` | `metadata-missing-required-field` | required source field missing |
| `validate-metadata` | `metadata-no-refs` | `refs` carries no issue/pr/mr |
| `validate-metadata` | `metadata-unknown-class` | classification class not recognised |
| `validate-metadata` | `metadata-employer-missing-name` | `employer` class with no employer |

Stable warning codes (Sprint 1):

| Subcommand | Code | When |
| --- | --- | --- |
| `validate-local` | `local-defaults-used` | file missing or empty; defaults filled in |
| `validate-metadata` | `metadata-captured-classification-missing` | pre-classification plan without captured classification |

## Boundary

- Provider API access is delegated to `forge-cli`. `plan-archive` never
  duplicates auth or host configuration.
- Commit creation in the `migrate` and `refresh` subcommands (Sprints
  3–4) goes through the released `semantic-commit` binary, not raw
  `git commit`.
- The archive repository is treated as opaque storage; this CLI does
  not enforce repository-level governance beyond what the validators
  check.
- `query` is strictly read-only: it never fetches, writes, or commits.
  `refresh` writes and scrubs but holds the commit so any emitted
  scrub log can be reviewed first.

## Tests

Run the validators:

```bash
cargo test -p nils-plan-archive
```

Validator behaviour is covered by:

- Unit tests inside `src/validate/{hosts,local,metadata}.rs`.
- Integration tests in `tests/validators.rs` driving the shipped
  fixture set under `tests/fixtures/{hosts,local,metadata}/`.

Scrub behaviour is covered by:

- Unit tests inside `src/scrub/{mod,log}.rs`.
- Integration tests in `tests/scrub.rs` driving
  `tests/fixtures/scrub/{all-patterns,clean}.txt`.

## Related

- Master design: `agent-runtime-kit:docs/plans/2026-05-26-plan-archive-system/plan-archive-system-discussion-source.md`
- Plan 1: `agent-plan-archive:plans/github.com/graysurf/agent-runtime-kit/2026-05-27-plan-archive-nils-cli/plan-archive-nils-cli-plan.md`
- Plan 3 (skill bodies): `agent-plan-archive:plans/github.com/graysurf/agent-runtime-kit/2026-05-27-plan-archive-runtime-kit/plan-archive-runtime-kit-plan.md`
- Tracker: <https://github.com/sympoies/nils-cli/issues/571>

[master-design]: https://github.com/graysurf/agent-runtime-kit/blob/main/docs/plans/2026-05-26-plan-archive-system/plan-archive-system-discussion-source.md
