Files
JaySon 58aba1808c Make manifest source keys machine-portable (#217)
* docs(contract): define the portable source key contract

A vault can be checked out on more than one machine and synced over git, so a
stored source key must be machine-independent. Replace the "keys MUST be absolute
paths with ~ expanded" rule with three canonical forms:

- vault-relative for sources inside the vault (`Raw/x.pdf`),
- home-relative for sources under `$HOME` (`~/.claude/...`),
- a pseudo-key (any `scheme:` / `://` identifier) for anything that is not a file.

Never store a bare absolute path; normalize before comparing; identity must
survive a path change. The pseudo-key namespaces are recommended names, not a
closed list — what matters is the shape, so a custom scheme still works. Project
identity follows the same rule: `source_repo` (`host/owner/name`), with an
optional `~`-relative `source_cwd_hint` for a machine-local checkout.

Legacy manifests full of absolute keys stay readable, and `scripts/manifest.py
migrate` converts them (merging collisions, keeping the newest `ingested_at`);
after a cross-machine move the old vault root is supplied with `--from-root`.
This is the single canonical definition — the other skills reference it.

* feat(cache): resolve portable keys and classify cross-machine sources

Read and write manifest source keys in the portable forms instead of treating
the raw string as the identity.

Write side: `stored_key` maps a source to vault-relative (`Raw/x.pdf`) or
home-relative (`~/.claude/...`), and `update_source` records that form. A source
with no portable representation keeps its path only as a fallback and now warns
on stderr; an explicit `key=` from the caller is authoritative and re-keys an
entry the manifest previously tracked by path.

Read side: `resolve_key` normalizes every stored form to an absolute path (or
None for a pseudo-key), and `_same_source` / `_missing_on_disk` compare through
it, so an absolute query matches a vault-relative or `~`-relative key and legacy
absolute keys keep working unchanged.

Classification: `check_sources` gains an `unavailable` bucket. A key that is
machine-specific and points outside this vault may simply not exist here, so it
is reported as `unavailable` rather than `missing` — `missing` narrows to
vault-local sources genuinely gone, which is what `wiki-ingest` reads as
"optionally clean up". A relative key counts as vault-local only when its first
segment names a real top-level vault entry; a bare vault-root filename is
vault-local by construction, while a legacy ingest-root key, an out-of-vault
namespace, and a key written for another OS (drive-letter or backslash form) are
not. When the vault cannot be listed the lexical answer stands, so an I/O hiccup
never hides a real loss.

CLI: `cache-update --key` records an explicit pseudo-key
(`repo:` / `url:` / `agent:`) for a source outside the vault and `$HOME`, and
the JSON receipt reports the normalized key.

* feat(manifest): mirror the key helper, resolve delta keys, add migrate

`scripts/manifest.py` is a standalone stdlib helper that cannot import the
package, so it carries its own copies of `resolve_key` / `stored_key` (kept
honest by the parity test in `tests/test_manifest_portable_keys.py`).

`delta` now resolves every stored key to an absolute path before matching, so a
vault-relative or home-relative key is directly hit by a scanned absolute path
instead of showing up as new.

`migrate` (with `normalize` kept as an alias for existing instructions) rewrites
legacy absolute keys to the portable form: in-vault keys become vault-relative,
`$HOME` keys become `~`-relative, and pseudo-keys and legacy ingest-root-relative
keys are preserved untouched. A path with no portable form is kept as-is with a
warning rather than dropped, so provenance is never lost, and collisions merge by
keeping the newest `ingested_at` while unioning `pages_created` / `pages_updated`
/ `pages_produced`.

After a vault moves between machines its old keys are rooted at the *old* vault
path, which matches neither the new root nor `$HOME`; `--from-root
<old-vault-root>` (repeatable) strips it explicitly. Without it the summary says
`nothing portable to write — N key(s) kept non-portable` rather than claiming
success. The old root is never guessed — a wrong guess would write
plausible-looking but incorrect relative keys — so the hint names the flag to
pass instead.

* feat(lint): report machine paths in page sources frontmatter

A page `sources:` entry holding a machine absolute path cannot resolve on another
machine, so a synced vault silently loses the trail. Add a read-only finding,
`machine_path_sources`, that names the page and the offending entries.

It is reported at warn level and rewrites nothing — cleaning existing pages is a
one-time, owner-reviewed pass, not something a lint run should do. All three
frontmatter shapes are handled (flow list, scalar value, block list), and both
POSIX and Windows-drive absolute paths are recognized. `~`-relative and
vault-relative entries are fine and are not reported.

* docs: converge skills on the key contract and document the new surface

Point the dependent skills at the contract instead of restating (and diverging
from) it:

- `wiki-status`: describe portable keys, document the `Unavailable`
  classification, and identify a project by `source_repo` + `source_cwd_hint` in
  the manifest example.
- `wiki-ingest`: document the `unavailable` bucket and state that the manifest
  key and page `sources:` must be portable; note `cache-update --key` for a
  source with no portable path form.
- `wiki-update`: record project identity as `source_repo` and an optional
  `~`-relative `source_cwd_hint`.
- `wiki-query`: resolve a project's source from `source_repo`, falling back
  through `source_cwd_hint` -> legacy `source_cwd` -> page `source_path`, and
  report the repository rather than a machine path.
- `claude-history-ingest`: replace the "keys are absolute paths" instruction.
- `wiki-rebuild`: use an `$OBSIDIAN_VAULT_PATH` placeholder instead of a user path.

User docs get the same treatment: `docs/cli.md` documents `cache-update --key`,
`manifest.py migrate` / `--from-root`, the `unavailable` bucket and the lint
finding, and `docs/architecture.md` states the key rule. Structural doc tests
pin the contract section, the documented CLI surface, the portable manifest
example, and that all four skills agree on the project identity fields.

* fix(manifest): read the ingest timestamp under either field name

cache.py writes `last_ingested`; `_newest` and `cmd_delta` read only
`ingested_at`. On a real manifest both comparisons saw "", so a migrate
collision kept the first-seen entry rather than the newest, and every
already-ingested file came back MOD.

---------

Co-authored-by: ar9av <ar9avg@gmail.com>
2026-09-11 00:32:53 -07:00
..