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/.
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 archiveconfig/hosts.yaml. Enforces the v1 schema (personal/employerclasses, employer name onclass: employer, recognised retention values, supportedversion).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'smetadata.yaml. Accepts pre-classification plans that omitcaptured_classification(emits themetadata-captured-classification-missingwarning) and rejects plans that name norefs.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)— scaninputagainst 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.logsibling 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 samePATTERN_SETmajor.
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 preselection scan described below.
--source-repodefaults to the current git repo root;--archivedefaults to the machine-local config'sarchive_clone_path;--hostsdefaults to<archive>/config/hosts.yaml.- At least one of
--issue/--pr/--mris required so the archivedmetadata.yamlalways carries a provider reference. - Dry-run resolves the source identity from the
originremote (host, org/group path, repo, branch, HEAD SHA), classifies the host againstconfig/hosts.yaml, enumerates the tracked plan files viagit ls-files, and assembles themetadata.yamlpayload — without touching any working tree. - Apply copies the files under
plans/<host>/<org>/<repo>/<folder>/, writesmetadata.yaml, commits the archive via the releasedsemantic-commitbinary, 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.mdwhose## Execution Stateheader is still mid-flight (e.g. "delivery pending") gets itsStatus/Current task/Next taskbullets 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 andcatalog.jsonrefs.
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.
--source-repodefaults to the current git repo root;--plans-rootis repo-relative and defaults todocs/plans;--archiveand--hostsresolve exactly as formigrate.--include-unknownaddsunknowncandidates to the listing (they are omitted by default). The summary always reportsscanned,eligible,blocked, andunknowncounts, 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)
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.
--refrefreshes a single issue/PR/MR URL.--repolists open refs throughforge-cli … list --state openand refreshes each;--since YYYY-MM-DDnarrows the batch. The two selectors are mutually exclusive.- Provider API access is delegated to
forge-cli(override the binary withPLAN_ARCHIVE_FORGE_CLI); the host is mapped togithubforgithub.comandgitlabotherwise.config/hosts.yamlgates 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 thefailedarray 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:
- single-ref (
--ref): returns the latest snapshot for one issue/PR/MR plus itsfetched_at. A ref with no snapshots returns a record withlatest_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'smetadata.yaml(--planresolves<archive>/<plan>/metadata.yaml;--refs-fromtakes the metadata path directly) and resolves eachrefs.issue|pr|mrto 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-archivenever duplicates auth or host configuration. - Commit creation in the
migrateandrefreshsubcommands (Sprints 3–4) goes through the releasedsemantic-commitbinary, not rawgit commit. - The archive repository is treated as opaque storage; this CLI does not enforce repository-level governance beyond what the validators check.
queryis strictly read-only: it never fetches, writes, or commits.refreshwrites and scrubs but holds the commit so any emitted scrub log can be reviewed first.
Tests
Run the validators:
Validator behaviour is covered by:
- Unit tests inside
src/validate/{hosts,local,metadata}.rs. - Integration tests in
tests/validators.rsdriving the shipped fixture set undertests/fixtures/{hosts,local,metadata}/.
Scrub behaviour is covered by:
- Unit tests inside
src/scrub/{mod,log}.rs. - Integration tests in
tests/scrub.rsdrivingtests/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