From 69f841e9dc2155b45e107c62e7c37e3ebfe4c28e Mon Sep 17 00:00:00 2001 From: indexzero Date: Sat, 1 Aug 2026 01:14:33 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(okf/cli):=20path-scoped=20ingest=20?= =?UTF-8?q?=E2=80=94=20trailing=20paths=20restrict=20the=20scan=20set=20(A?= =?UTF-8?q?DR-001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auto-okf ingest [paths…]: optional trailing bundle-relative paths restrict the one-shot scan to exactly those concept files, so one agent's ingest never sweeps up another writer's mid-write pending file. All named paths are validated before any op is appended (all-or-nothing): a path that does not exist, resolves outside the bundle root, or is not a .md file exits 1 naming the path, zero ops appended. Zero paths = full scan, unchanged. Judgment calls: literal paths only — the ADR permits globs, but paparam's rest capture is plain strings and the shell already expands globs, so an in-process matcher would only add a second, subtly different glob dialect; a named non-.md path (or directory) errors rather than being silently skipped, since silent skips of explicitly named paths are the confusion this ADR exists to kill. paparam's rest is greedy (once both positionals fill, remaining flags land in rest), so cmdIngest re-recognizes --confirm-deletes/--help there to keep the pre-ADR flag-last forms working. Co-Authored-By: Claude Fable 5 --- okf/SPEC.md | 12 ++++++-- okf/cli/README.md | 2 +- okf/cli/lib/run.js | 56 +++++++++++++++++++++++++++++++++---- okf/test/cli.test.js | 66 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 8 deletions(-) diff --git a/okf/SPEC.md b/okf/SPEC.md index eb9926a..d4161da 100644 --- a/okf/SPEC.md +++ b/okf/SPEC.md @@ -630,6 +630,13 @@ saga is the cautionary tale). The rules are frozen: actual size, before any op for that file is appended; sibling files in the same run are unaffected. Apply's `oversize-op` reject stays untouched — validate at the boundary, defend in depth. +- **Path-scoped one-shot ingest (ADR-001).** The one-shot scan set is the + whole bundle, restricted to the argument set when trailing + bundle-relative paths are given (concurrent-writer politeness: one + agent's ingest never sweeps up another's mid-write pending file). A + named path that does not exist, or that resolves outside the bundle + root, is an error — exit 1, path named, zero ops appended. The + zero-path form is byte-identical in behavior to the full scan. - Debounce, write-settle, in-flight-lock coordination with materialize. - `index.md`/`log.md` are reserved: edits to them are ignored (regenerated faces), with a single narrated log entry. @@ -751,8 +758,9 @@ okf/store vault lifecycle on autobee, metadata pinning (incl. the swarm replication, backpressure option okf/faces materialize (bundle + index.md + log.md), ingest watcher, wanted index, generation-cycle compact command -okf/cli init, join, add-writer, remove-writer, materialize, ingest, - watch, wanted, flags, resolve, undelete, compact +okf/cli init, join, add-writer, remove-writer, materialize, ingest + (optionally path-scoped, ADR-001), watch, wanted, flags, + resolve, undelete, compact okf/root the `auto-okf` root placeholder package (published as auto-okf@0.0.1 alongside the scope — intent D7) okf/test unit + invariant suite (I1..I11 each ≥ 1 test) diff --git a/okf/cli/README.md b/okf/cli/README.md index fba5428..4fe4a38 100644 --- a/okf/cli/README.md +++ b/okf/cli/README.md @@ -68,7 +68,7 @@ pnpm add @auto-okf/cli # or run the repo-local bin: okf/cli/bin/auto-okf.js | `add-writer [--indexer]` | append an `AddWriter` governance op; reports whether it took effect (apply rejects issuers that are not confirmed indexers, §8) | | `remove-writer ` | append a `RemoveWriter` op; revocation is causal, never positional (§8) | | `materialize ` | project the OKF v0.1 bundle (concepts + per-dir `index.md` + root `log.md`); prints written/unchanged/removed counts; pending-ingest and stale files are `notice:` lines on stderr, unsafe paths `error:` lines. Exits 3 when everything owned was projected but human-authored file(s) are pending ingest (N3); an unsafe path is a real failure, exit 1. `--force-mirror` restores unconditional mirroring | -| `ingest ` | one-shot: fold on-disk edits under `` back into the log as minimal ops (§9); `--confirm-deletes` lets a file deleted on disk emit `DeleteConcept` (off by default) | +| `ingest [paths…]` | one-shot: fold on-disk edits under `` back into the log as minimal ops (§9). Trailing bundle-relative paths restrict the scan to exactly those concept files (ADR-001 — concurrent-writer politeness; the shell expands globs): a named path that does not exist, escapes the bundle root, or is not a `.md` file exits 1 naming the path, with zero ops appended. Zero paths = full scan, unchanged. `--confirm-deletes` lets a file deleted on disk emit `DeleteConcept` (off by default) | | `watch [--swarm]` | foreground ingest watcher: human edits re-enter as ops as files change; `--swarm` also joins the vault's hyperswarm topic for replication; `--confirm-deletes`, `--debounce `. No daemon — stop with Ctrl-C | | `wanted ` | print the ranked demand index (`wanted/`): `\t\t<- `, most-referenced first; `--json` for the structured rows | | `flags [family]` | list/inspect the flag queue (`flag/`), optionally one family (`conflict`, `clobber`, `pathcollision`, `staletag`, `writeafterdelete`, `undelete`, `rejected`, `squat`, `copydetect`, …); `--json`. Nothing is ever lost silently (I4) | diff --git a/okf/cli/lib/run.js b/okf/cli/lib/run.js index 38c368a..432996e 100644 --- a/okf/cli/lib/run.js +++ b/okf/cli/lib/run.js @@ -16,10 +16,10 @@ // flag/command, extra arguments, bad flag values, and (via per-command // validate()) missing required arguments — to 2, never letting paparam pick // the code. Unknown flags are rejected strictly, as before. -import { readFile, access } from 'node:fs/promises' -import { join } from 'node:path' +import { readFile, access, stat } from 'node:fs/promises' +import { join, relative, resolve, sep } from 'node:path' import b4a from 'b4a' -import { command, flag, arg, summary, header, footer, bail, validate } from 'paparam' +import { command, flag, arg, rest, summary, header, footer, bail, validate } from 'paparam' import { keyspace as K } from '@auto-okf/core' import { Bundle, METADATA_FILE } from '@auto-okf/store' import { materialize, createIngestWatcher, wanted, compact } from '@auto-okf/faces' @@ -159,6 +159,7 @@ function buildCli() { 'fold on-disk edits back in as ops (one-shot)', arg('', 'vault storage directory'), arg('', 'OKF bundle directory'), + rest('[...paths]', 'bundle-relative concept files restricting the scan set (ADR-001)'), flag('--confirm-deletes', 'let a file deleted on disk emit DeleteConcept'), requiredArgs('', '') ), @@ -471,8 +472,52 @@ async function cmdMaterialize({ args, flags }) { }) } -async function cmdIngest({ args, flags }) { +/** + * ADR-001: resolve the trailing path arguments to bundle-relative concept + * files, validating BEFORE any op is appended. Literal paths only — the + * shell expands globs; a path that does not exist, resolves outside the + * bundle root, or is not a concept `.md` file is an error naming it. + * Returns null for the zero-path form (full scan, unchanged). + */ +async function scopedIngestPaths(bundleDir, given) { + if (given.length === 0) return null + const root = resolve(bundleDir) + const rels = new Set() + for (const p of given) { + const abs = resolve(root, p) + if (abs !== root && !abs.startsWith(root + sep)) { + throw new Error(`path resolves outside the bundle root: ${p}`) + } + let st = null + try { + st = await stat(abs) + } catch (err) { + if (err.code !== 'ENOENT') throw err + } + if (st === null) throw new Error(`no such file in the bundle: ${p}`) + if (!st.isFile() || !abs.endsWith('.md')) throw new Error(`not a concept file (.md): ${p}`) + rels.add(relative(root, abs).split(sep).join('/')) + } + return [...rels].sort() +} + +async function cmdIngest(parsed) { + const { args, flags } = parsed const { dir, bundleDir } = args + // paparam's rest is greedy: once both positionals are filled, everything + // remaining — flags included — lands in rest. Re-recognize ingest's own + // flags so the pre-ADR-001 `ingest --confirm-deletes` form + // keeps working; the remainder is the ADR-001 path set. + const trailing = [] + for (const t of parsed.rest || []) { + if (t === '--confirm-deletes') flags.confirmDeletes = true + else if (t === '--help' || t === '-h') { + process.stdout.write(parsed.help()) + return 0 + } else if (t.startsWith('-')) throw new UsageError(`unknown flag ${t}`) + else trailing.push(t) + } + const paths = await scopedIngestPaths(bundleDir, trailing) return withVault(dir, {}, async (vault) => { const watcher = await createIngestWatcher(vault, bundleDir, { watch: false, @@ -505,7 +550,8 @@ async function cmdIngest({ args, flags }) { process.stderr.write(`delete skipped (no --confirm-deletes): ${path}\n`) ) try { - await watcher.scan() + if (paths === null) await watcher.scan() + else for (const rel of paths) await watcher.ingestFile(rel) await vault.update() // The skip clause is omitted (never "0") when no skips occurred. const skips = upToDate > 0 ? `; ${upToDate} file(s) already up to date` : '' diff --git a/okf/test/cli.test.js b/okf/test/cli.test.js index 9a0d4fd..73ff44e 100644 --- a/okf/test/cli.test.js +++ b/okf/test/cli.test.js @@ -33,6 +33,16 @@ function cli(args) { }) } +/** Full-view hash (store I2/I3 surface), for zero-ops/read-only assertions. */ +async function snapshotHash(storage) { + const v = await Bundle.open(storage) + try { + return await v.snapshotHash() + } finally { + await v.close() + } +} + const KEY_LINE = /^vault key: {3}([0-9a-f]{64})$/m const WRITER_LINE = /^writer key: {2}([0-9a-f]{64})$/m @@ -223,6 +233,62 @@ test('ingest preflights maxOpBytes: oversize file named, siblings unaffected (AD } }) +test('path-scoped ingest folds only the named file (ADR-001)', async () => { + const base = await tmpDir('scoped') + const storage = join(base, 'vault') + const bundle = join(base, 'bundle') + await cli(['init', storage]) + await mkdir(join(bundle, 'drafts'), { recursive: true }) + await writeFile(join(bundle, 'drafts', 'mine.md'), '---\ntype: note\n---\nMine.\n') + await writeFile(join(bundle, 'drafts', 'theirs.md'), '---\ntype: note\n---\nTheirs, mid-write.\n') + + const ing = await cli(['ingest', storage, bundle, 'drafts/mine.md']) + assert.equal(ing.code, 0, ing.stderr) + assert.match(ing.stdout, /ingested drafts\/mine\.md: create-concept/) + assert.doesNotMatch(ing.stdout, /theirs\.md/, "the other writer's file was not swept up") + assert.match(ing.stdout, /^ingest complete: \d+ op\(s\) from 1 file\(s\)$/m) + + const v = await Bundle.open(storage) + try { + await v.update() + assert.notEqual(await v.val(K.path('drafts/mine')), undefined) + assert.equal(await v.val(K.path('drafts/theirs')), undefined, 'the unnamed file stays out of the log') + } finally { + await v.close() + } + + // the unnamed file remains pending-ingest: materialize leaves it in place + const mat = await cli(['materialize', storage, bundle]) + assert.equal(mat.code, 3, mat.stderr) + assert.match(mat.stderr, /pending ingest .*: drafts\/theirs\.md/) +}) + +test('path-scoped ingest refuses bad paths by name, appending zero ops (ADR-001)', async () => { + const base = await tmpDir('scoped-bad') + const storage = join(base, 'vault') + const bundle = join(base, 'bundle') + await cli(['init', storage]) + await mkdir(join(bundle, 'drafts'), { recursive: true }) + await writeFile(join(base, 'outside.md'), '---\ntype: note\n---\nNot in the bundle.\n') + await writeFile(join(bundle, 'drafts', 'real.md'), '---\ntype: note\n---\nReal.\n') + const baseline = await snapshotHash(storage) + + const esc = await cli(['ingest', storage, bundle, '../outside.md']) + assert.equal(esc.code, 1, 'a path escaping the bundle root is an error') + assert.match(esc.stderr, /outside the bundle root: \.\.\/outside\.md/) + + const ghost = await cli(['ingest', storage, bundle, 'drafts/ghost.md']) + assert.equal(ghost.code, 1, 'a nonexistent path is an error') + assert.match(ghost.stderr, /no such file in the bundle: drafts\/ghost\.md/) + + // one bad path aborts the whole run before any op — the good sibling too + const mixed = await cli(['ingest', storage, bundle, 'drafts/real.md', 'drafts/ghost.md']) + assert.equal(mixed.code, 1) + assert.match(mixed.stderr, /drafts\/ghost\.md/) + + assert.equal(await snapshotHash(storage), baseline, 'zero ops appended across all three refusals') +}) + test('wanted lists an unresolved link, ranked', async () => { const base = await tmpDir('wanted') const storage = join(base, 'vault') From db805cb7c32743c6e10a6348465baa4b8140d00a Mon Sep 17 00:00:00 2001 From: indexzero Date: Sat, 1 Aug 2026 01:19:44 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(okf/cli):=20status=20=E2=80=94=20read-?= =?UTF-8?q?only=20per-file=20vault/bundle=20state=20porcelain=20(ADR-002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auto-okf status [--json]: one -TAB- line per .md file in the union of {planned projection, emission manifest, disk}, sorted by path; --json emits the same rows as a stable [{ path, state }] array (the schema is contract, kept to exactly those two fields). Strictly read-only: no ops appended, no files written, no manifest touched — status never even mkdirs a missing bundle dir (the new faces status() skips rootRegistry for that reason). The states are judged against the exact bytes materialize would write: materialize's plan steps 1-4 are extracted into planBundle(vault), shared by materialize and status, so nothing is reinvented. Mapping, from the strictest reading of the ADR table plus the ingest watcher's own classification as the pending-ingest/dirty tiebreaker: - in-log: on-disk bytes hash-match the current planned projection. - dirty: manifest-tracked (so the concept is in the log) but bytes diverge from the projection — either a hand edit the watcher would fold in as diffs against the existing concept (it hashes against the manifest, not the plan), or a log that moved past the last materialize. - pending-ingest: untracked hand-authored file the watcher WOULD establish a concept for (same non-empty-type gate as IngestWatcher._fmType). - absent: planned but missing from disk (materialize would restore). - foreign: untracked and not establishable (e.g. a mid-write WIP with no parseable type) — ingest will not put it in the log as it stands. Derived faces (index.md/log.md) are part of the projection and report in-log/absent/dirty like any planned file; a manifest entry with neither a file nor a projection is retired state and gets no row. Co-Authored-By: Claude Fable 5 --- okf/SPEC.md | 16 +++++- okf/cli/README.md | 28 ++++++++++- okf/cli/lib/run.js | 36 ++++++++++++-- okf/faces/README.md | 9 +++- okf/faces/index.js | 3 ++ okf/faces/lib/materialize.js | 28 ++++++++--- okf/faces/lib/status.js | 90 +++++++++++++++++++++++++++++++++ okf/faces/package.json | 1 + okf/test/cli.test.js | 96 +++++++++++++++++++++++++++++++++++- 9 files changed, 288 insertions(+), 19 deletions(-) create mode 100644 okf/faces/lib/status.js diff --git a/okf/SPEC.md b/okf/SPEC.md index d4161da..08cd7df 100644 --- a/okf/SPEC.md +++ b/okf/SPEC.md @@ -641,6 +641,18 @@ saga is the cautionary tale). The rules are frozen: - `index.md`/`log.md` are reserved: edits to them are ignored (regenerated faces), with a single narrated log entry. +**Per-file state vocabulary (ADR-002)** — the states implicit in the +materialize/ingest rules above are named contract, reported read-only by +the CLI `status` command (appends no ops, writes no files, touches no +manifest): `in-log` (on-disk bytes match the log's canonical projection — +hash match), `pending-ingest` (hand-authored, not yet in the log; ingest +would establish a concept), `dirty` (in the log but on-disk bytes diverge +from the projection — a hand edit ingest folds in as diffs, or a log that +moved past the last materialize), `absent` (in the log, missing from disk; +materialize would restore), `foreign` (not in the log, not +manifest-tracked, not establishable by ingest — e.g. another writer's +mid-write WIP). + ## 10. Compaction: generation cycling (the CouchDB homage, part two) Byte reclamation is an **operator act via the hydrated face**, never an op: @@ -759,8 +771,8 @@ okf/store vault lifecycle on autobee, metadata pinning (incl. the okf/faces materialize (bundle + index.md + log.md), ingest watcher, wanted index, generation-cycle compact command okf/cli init, join, add-writer, remove-writer, materialize, ingest - (optionally path-scoped, ADR-001), watch, wanted, flags, - resolve, undelete, compact + (optionally path-scoped, ADR-001), watch, status (ADR-002), + wanted, flags, resolve, undelete, compact okf/root the `auto-okf` root placeholder package (published as auto-okf@0.0.1 alongside the scope — intent D7) okf/test unit + invariant suite (I1..I11 each ≥ 1 test) diff --git a/okf/cli/README.md b/okf/cli/README.md index 4fe4a38..33c2e05 100644 --- a/okf/cli/README.md +++ b/okf/cli/README.md @@ -70,6 +70,7 @@ pnpm add @auto-okf/cli # or run the repo-local bin: okf/cli/bin/auto-okf.js | `materialize ` | project the OKF v0.1 bundle (concepts + per-dir `index.md` + root `log.md`); prints written/unchanged/removed counts; pending-ingest and stale files are `notice:` lines on stderr, unsafe paths `error:` lines. Exits 3 when everything owned was projected but human-authored file(s) are pending ingest (N3); an unsafe path is a real failure, exit 1. `--force-mirror` restores unconditional mirroring | | `ingest [paths…]` | one-shot: fold on-disk edits under `` back into the log as minimal ops (§9). Trailing bundle-relative paths restrict the scan to exactly those concept files (ADR-001 — concurrent-writer politeness; the shell expands globs): a named path that does not exist, escapes the bundle root, or is not a `.md` file exits 1 naming the path, with zero ops appended. Zero paths = full scan, unchanged. `--confirm-deletes` lets a file deleted on disk emit `DeleteConcept` (off by default) | | `watch [--swarm]` | foreground ingest watcher: human edits re-enter as ops as files change; `--swarm` also joins the vault's hyperswarm topic for replication; `--confirm-deletes`, `--debounce `. No daemon — stop with Ctrl-C | +| `status ` | read-only per-file state porcelain (ADR-002): one `\t` line per file, sorted by path, states `in-log`, `pending-ingest`, `dirty`, `absent`, `foreign`; `--json` for the stable `{ path, state }` rows. Appends no ops, writes no files, touches no manifest | | `wanted ` | print the ranked demand index (`wanted/`): `\t\t<- `, most-referenced first; `--json` for the structured rows | | `flags [family]` | list/inspect the flag queue (`flag/`), optionally one family (`conflict`, `clobber`, `pathcollision`, `staletag`, `writeafterdelete`, `undelete`, `rejected`, `squat`, `copydetect`, …); `--json`. Nothing is ever lost silently (I4) | | `compact [--force]` | generation-cycle compaction (§10): materialize the live bundle, init a fresh vault at ``, and ingest with `_id` adoption. Live state and identity survive; history is shed. Refuses while any `flag/conflict`/`flag/pathcollision` is open unless `--force`. The hydrated face is written to `.face` (override with `--face `). The predecessor is never deleted | @@ -79,9 +80,31 @@ pnpm add @auto-okf/cli # or run the repo-local bin: okf/cli/bin/auto-okf.js There is no content-write command: content flows in through `ingest`/`watch` (edit the markdown) or through the `@auto-okf/store` API (agents). +## Per-file state: `status` + +`auto-okf status ./vault ./bundle` answers the per-file state question +*before* you run anything — one `\t` line per file, sorted by +path, strictly read-only (it appends no ops, writes no files, touches no +manifest; ADR-002). Five states: + +- `in-log` — on-disk bytes match the log's canonical projection (hash match) +- `pending-ingest` — hand-authored, not yet in the log; `ingest` would fold + it in as a new concept +- `dirty` — in the log but on-disk bytes diverge from the projection (a hand + edit `ingest` folds in as diffs, or a log that moved past the last + `materialize`) +- `absent` — in the log, missing from disk (`materialize` would restore it) +- `foreign` — not in the log, not manifest-tracked, and not (yet) ingestable + (e.g. another writer's mid-write WIP without a parseable `type`) + +`--json` emits the same rows as a stable array of `{ path, state }` objects — +byte-identical across runs with no intervening ops (the schema is contract). + ## Verifying content is in the log -A vault has no privileged bundle directory; the emission manifest +`status` is the quick check: `in-log` means the on-disk bytes hash-match the +log's projection. For proof *from* the log, materialize to a scratch +directory — a vault has no privileged bundle directory; the emission manifest (`.okf-manifest.json`) lives in each target directory, so materializing to a scratch directory never disturbs another bundle: @@ -127,7 +150,8 @@ per command) and exits 0. From the repo root: `pnpm test` (or `pnpm test:okf`). The CLI smoke suite (`okf/test/cli.test.js`) spawns the real `auto-okf` binary against real vaults on disk: init→materialize produces a bundle; a hand-authored file -ingests to the expected ops; add-writer/remove-writer round trip; `wanted` +ingests to the expected ops; path-scoped ingest folds only the named files; +`status` reports the five per-file states read-only; add-writer/remove-writer round trip; `wanted` lists an unresolved link; `flags` lists a seeded conflict; `compact` refuses on an open conflict and succeeds after `resolve`; `undelete` restores a tombstone; `watch` ingests a foreground edit and exits cleanly on SIGINT. diff --git a/okf/cli/lib/run.js b/okf/cli/lib/run.js index 432996e..6152dc6 100644 --- a/okf/cli/lib/run.js +++ b/okf/cli/lib/run.js @@ -1,12 +1,12 @@ // auto-okf CLI (okf SPEC §13): a thin veneer over @auto-okf/store (vault // lifecycle + §8 governance ops) and @auto-okf/faces (materialize, ingest -// watcher, wanted index, generation-cycle compact). The CLI never writes the view -// or the face itself — every effect flows through the packages, so everything -// it does is exactly what a library caller does. +// watcher, per-file status, wanted index, generation-cycle compact). The +// CLI never writes the view or the face itself — every effect flows through +// the packages, so everything it does is exactly what a library caller does. // // The command surface is declared once as a paparam command tree (paparam is // Holepunch's own parser — pear's CLI runs on it): a root command composing -// the 12 subcommands from command()/flag()/arg(), with --help|-h and all help +// the 13 subcommands from command()/flag()/arg(), with --help|-h and all help // text generated from the declarations. // // Exit codes: 0 success, 1 operational error, 2 usage error, 3 materialize @@ -22,7 +22,7 @@ import b4a from 'b4a' import { command, flag, arg, rest, summary, header, footer, bail, validate } from 'paparam' import { keyspace as K } from '@auto-okf/core' import { Bundle, METADATA_FILE } from '@auto-okf/store' -import { materialize, createIngestWatcher, wanted, compact } from '@auto-okf/faces' +import { materialize, createIngestWatcher, wanted, compact, status } from '@auto-okf/faces' const TITLE = 'auto-okf — multi-writer OKF bundles on autobee (okf SPEC §13)' const FOOTER = 'exit codes: 0 ok, 1 error, 2 usage, 3 pending-ingest only (materialize)' @@ -173,6 +173,14 @@ function buildCli() { flag('--debounce ', 'ingest debounce in milliseconds'), requiredArgs('', '') ), + sub( + 'status', + 'per-file vault/bundle state, read-only (in-log, pending-ingest, dirty, absent, foreign)', + arg('', 'vault storage directory'), + arg('', 'OKF bundle directory'), + flag('--json', 'print the structured rows'), + requiredArgs('', '') + ), sub( 'wanted', 'print the ranked demand index (wanted/)', @@ -291,6 +299,7 @@ const HANDLERS = { materialize: cmdMaterialize, ingest: cmdIngest, watch: cmdWatch, + status: cmdStatus, wanted: cmdWanted, flags: cmdFlags, compact: cmdCompact, @@ -610,6 +619,23 @@ async function cmdWatch({ args, flags }) { } } +async function cmdStatus({ args, flags }) { + const { dir, bundleDir } = args + return withVault(dir, {}, async (vault) => { + // ADR-002: strictly read-only — status appends no ops, writes no files, + // touches no manifest. The five-state vocabulary and the --json + // [{ path, state }] rows are contract. + const rows = await status(vault, bundleDir) + if (flags.json) { + console.log(JSON.stringify(rows, null, 2)) + } else { + for (const { path, state } of rows) console.log(`${state}\t${path}`) + process.stderr.write(`${rows.length} file(s)\n`) + } + return 0 + }) +} + async function cmdWanted({ args, flags }) { return withVault(args.dir, {}, async (vault) => { await vault.update() diff --git a/okf/faces/README.md b/okf/faces/README.md index 42ed04c..9a68861 100644 --- a/okf/faces/README.md +++ b/okf/faces/README.md @@ -6,7 +6,8 @@ multi-writer op log and a plain bundle on disk. `materialize` projects a converged view into a byte-deterministic directory of markdown files; the ingest watcher folds human edits back into the log as *minimal* ops; `wanted` reports what the -graph is asking to have written. The log stays the source of truth — a face +graph is asking to have written; `status` reports each file's state against +the log, read-only (ADR-002). The log stays the source of truth — a face can always be regenerated, byte-for-byte, and human edits always re-enter as ops (SPEC §2, §9). @@ -100,7 +101,11 @@ mirroring for operator use. ### Verifying content is in the log -A vault has no privileged bundle directory; the emission manifest +`status(vault, dir)` is the quick check: one `{ path, state }` row per file, +with `state` one of `in-log`, `pending-ingest`, `dirty`, `absent`, `foreign` +(ADR-002) — strictly read-only (no ops, no writes, no manifest touch). For +proof *from* the log, use a scratch materialize: a vault has no privileged +bundle directory; the emission manifest (`.okf-manifest.json`) lives in each target directory, so materializing to a scratch directory never disturbs another bundle. That makes verification trivial: materialize to an empty scratch directory — an empty dir that diff --git a/okf/faces/index.js b/okf/faces/index.js index 238b35e..56cba41 100644 --- a/okf/faces/index.js +++ b/okf/faces/index.js @@ -7,6 +7,8 @@ // ops (per-key frontmatter diff, body diff citing _rev, the §6 _id // rules, conflict-copy awareness, guarded deletes). // - wanted(vault): the ranked demand index from wanted/ + wantedrank/. +// - status(vault, dir): read-only per-file bundle state porcelain +// (in-log / pending-ingest / dirty / absent / foreign, ADR-002). // // The bundle format is normative to the OKF v0.1 SPEC; the canonical YAML // emitter is hand-rolled for byte-determinism (I9). Nothing here imports @@ -16,6 +18,7 @@ export { materialize, renderIndex, renderLog } from './lib/materialize.js' export { createIngestWatcher, IngestWatcher } from './lib/ingest.js' export { compact } from './lib/compact.js' export { wanted } from './lib/wanted.js' +export { status } from './lib/status.js' export { readView, readLog, memViewReader } from './lib/view.js' export { renderNote, diff --git a/okf/faces/lib/materialize.js b/okf/faces/lib/materialize.js index 0bbd2b2..8b2df35 100644 --- a/okf/faces/lib/materialize.js +++ b/okf/faces/lib/materialize.js @@ -105,15 +105,14 @@ function conflictHash(id) { } /** - * Walk the view and emit the bundle. `opts.forceMirror` restores - * unconditional mirroring (overwrite/delete regardless of manifest). - * Returns a report: - * { root, ok, written, unchanged, pendingIngest, removed, stale, unsafe } + * The full byte plan for a converged view (materialize steps 1–4: concept + * files, per-directory index.md, root log.md) WITHOUT touching disk. + * Returns Map. Shared with status() (ADR-002) so the + * porcelain's states are judged against the exact bytes materialize would + * write. */ -export async function materialize(vault, dir, opts = {}) { +export async function planBundle(vault) { if (typeof vault.update === 'function') await vault.update() - const { realRoot, registry } = await rootRegistry(dir) - const forceMirror = opts.forceMirror === true const { concepts, collisions } = await readView(vault) @@ -187,6 +186,21 @@ export async function materialize(vault, dir, opts = {}) { // ---- 4. root log.md (OKF §7) ---- planned.set('log.md', Buffer.from(await renderLog(vault, concepts), 'utf8')) + return planned +} + +/** + * Walk the view and emit the bundle. `opts.forceMirror` restores + * unconditional mirroring (overwrite/delete regardless of manifest). + * Returns a report: + * { root, ok, written, unchanged, pendingIngest, removed, stale, unsafe } + */ +export async function materialize(vault, dir, opts = {}) { + const { realRoot, registry } = await rootRegistry(dir) + const forceMirror = opts.forceMirror === true + + const planned = await planBundle(vault) + // ---- 5. reconcile to disk through the manifest (§9) ---- const report = { root: realRoot, diff --git a/okf/faces/lib/status.js b/okf/faces/lib/status.js new file mode 100644 index 0000000..8501b39 --- /dev/null +++ b/okf/faces/lib/status.js @@ -0,0 +1,90 @@ +// status(vault, dir) — the per-file vault/bundle state porcelain (ADR-002). +// Strictly read-only: appends no ops, writes no files, touches no manifest — +// the plan comes from planBundle (the exact bytes materialize would write), +// and the emission manifest and on-disk bytes are only read. One row per +// `.md` file in the union of {planned projection, emission manifest, disk}, +// in canonical byte order, each carrying one state from the fixed ADR-002 +// vocabulary: +// +// in-log on-disk bytes match the log's canonical projection +// pending-ingest hand-authored, untracked; ingest would establish a +// concept for it (mint/adopt) +// dirty manifest-tracked (in the log) but diverged from the +// projection — a hand edit ingest would fold in as diffs +// against the existing concept, or a log that has moved +// past the last materialize +// absent projected by the log, missing from disk (materialize +// would restore it) +// foreign not in the log, not manifest-tracked, and not +// establishable by ingest (e.g. another writer's +// mid-write WIP with no parseable non-empty `type`) +import { readFile, realpath } from 'node:fs/promises' +import { join } from 'node:path' +import { canonical } from '@auto-okf/core' +import { planBundle } from './materialize.js' +import { parseNote } from './yaml.js' +import { loadManifest, sha256, walkNotes, isReservedFile } from './fsatomic.js' + +const { nfc } = canonical + +/** Per-file states, sorted by path: [{ path, state }] (the --json contract). */ +export async function status(vault, dir) { + const planned = await planBundle(vault) + // No rootRegistry here: it mkdirs the bundle dir, and status writes + // nothing. A missing bundle dir is simply all-absent. + let realRoot = null + try { + realRoot = await realpath(dir) + } catch (err) { + if (err.code !== 'ENOENT') throw err + } + const manifest = realRoot === null ? { files: {} } : await loadManifest(realRoot) + const disk = [] + if (realRoot !== null) await walkNotes(realRoot, '', disk) + + const rels = new Set([...planned.keys(), ...Object.keys(manifest.files), ...disk]) + const rows = [] + for (const rel of [...rels].sort(byteCmp)) { + const state = await classify(realRoot, rel, planned.get(rel), manifest.files[rel]) + if (state !== null) rows.push({ path: rel, state }) + } + return rows +} + +async function classify(realRoot, rel, plannedBuf, trackedHash) { + let raw = null + if (realRoot !== null) { + try { + raw = await readFile(join(realRoot, rel)) + } catch (err) { + if (err.code !== 'ENOENT') throw err + } + } + if (raw === null) { + // A manifest entry with neither a file nor a projection is retired + // state the next materialize drops — no file, no row. + return plannedBuf !== undefined ? 'absent' : null + } + if (plannedBuf !== undefined && sha256(raw) === sha256(plannedBuf)) return 'in-log' + if (trackedHash !== undefined) return 'dirty' + // Untracked derived face (index.md/log.md before any materialize, or + // hand-created): materialize reconciles it, ingest ignores it. + if (isReservedFile(rel)) return 'dirty' + // Untracked hand-authored file: pending-ingest iff ingest could establish + // a concept for it — the same non-empty-`type` gate as the watcher. + const { fm } = parseNote(raw.toString('utf8')) + return fmType(fm) === null ? 'foreign' : 'pending-ingest' +} + +/** Mirror of IngestWatcher._fmType: the `type` ingest needs to mint/adopt. */ +function fmType(fm) { + const raw = fm ? fm.type : undefined + const v = Array.isArray(raw) ? raw[0] : raw + if (typeof v !== 'string') return null + const t = nfc(v) + return t === '' ? null : t +} + +function byteCmp(a, b) { + return Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')) +} diff --git a/okf/faces/package.json b/okf/faces/package.json index ad98f00..b93e516 100644 --- a/okf/faces/package.json +++ b/okf/faces/package.json @@ -11,6 +11,7 @@ "./ingest": "./lib/ingest.js", "./compact": "./lib/compact.js", "./wanted": "./lib/wanted.js", + "./status": "./lib/status.js", "./yaml": "./lib/yaml.js", "./view": "./lib/view.js" }, diff --git a/okf/test/cli.test.js b/okf/test/cli.test.js index 73ff44e..2953ba4 100644 --- a/okf/test/cli.test.js +++ b/okf/test/cli.test.js @@ -7,7 +7,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { execFile, spawn } from 'node:child_process' -import { mkdir, readdir, readFile, writeFile, access } from 'node:fs/promises' +import { mkdir, readdir, readFile, rm, writeFile, access } from 'node:fs/promises' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { Bundle } from '../store/index.js' @@ -289,6 +289,100 @@ test('path-scoped ingest refuses bad paths by name, appending zero ops (ADR-001) assert.equal(await snapshotHash(storage), baseline, 'zero ops appended across all three refusals') }) +test('status: a file another process ingested reports in-log, not pending-ingest (ADR-002)', async () => { + const base = await tmpDir('status') + const storage = join(base, 'vault') + const bundle = join(base, 'bundle') + await cli(['init', storage]) + await mkdir(bundle, { recursive: true }) + await writeFile( + join(bundle, 'note.md'), + '---\ntype: note\ntitle: Clerk\n---\nSwept while I looked away.\n' + ) + + // hand-authored, not yet ingested: pending-ingest + const before = await cli(['status', storage, bundle]) + assert.equal(before.code, 0, before.stderr) + assert.match(before.stdout, /^pending-ingest\tnote\.md$/m) + + // the swept-writer incident: a SECOND process ingests + materializes it + // (every cli() call spawns its own auto-okf process) + await cli(['ingest', storage, bundle]) + await cli(['materialize', storage, bundle]) + + const after = await cli(['status', storage, bundle]) + assert.equal(after.code, 0, after.stderr) + assert.match(after.stdout, /^in-log\tnote\.md$/m) + assert.doesNotMatch(after.stdout, /pending-ingest/) + // the derived faces are part of the projection and equally in-log + assert.match(after.stdout, /^in-log\tindex\.md$/m) + assert.match(after.stdout, /^in-log\tlog\.md$/m) +}) + +test('status reports a foreign mid-write file and never ingests it (ADR-002)', async () => { + const base = await tmpDir('status-foreign') + const storage = join(base, 'vault') + const bundle = join(base, 'bundle') + await cli(['init', storage]) + await mkdir(bundle, { recursive: true }) + await writeFile(join(bundle, 'note.md'), '---\ntype: note\n---\nSettled.\n') + await cli(['ingest', storage, bundle]) + await cli(['materialize', storage, bundle]) + + // another writer's WIP: no parseable frontmatter/type yet + await writeFile(join(bundle, 'wip.md'), '# scratch\nmid-write, no frontmatter yet\n') + const manifestBefore = await readFile(join(bundle, '.okf-manifest.json'), 'utf8') + const viewBefore = await snapshotHash(storage) + + const st = await cli(['status', storage, bundle]) + assert.equal(st.code, 0, st.stderr) + assert.match(st.stdout, /^foreign\twip\.md$/m) + + // strictly read-only: no ops appended, no manifest write, file untouched + assert.equal(await snapshotHash(storage), viewBefore, 'status appended no ops') + assert.equal( + await readFile(join(bundle, '.okf-manifest.json'), 'utf8'), + manifestBefore, + 'status touched no manifest' + ) + assert.match(await readFile(join(bundle, 'wip.md'), 'utf8'), /mid-write/) +}) + +test('status --json is stable across runs and covers dirty and absent (ADR-002)', async () => { + const base = await tmpDir('status-json') + const storage = join(base, 'vault') + const bundle = join(base, 'bundle') + await cli(['init', storage]) + await mkdir(bundle, { recursive: true }) + await writeFile(join(bundle, 'note.md'), '---\ntype: note\ntitle: Stable\n---\nBody.\n') + await cli(['ingest', storage, bundle]) + await cli(['materialize', storage, bundle]) + + const j1 = await cli(['status', storage, bundle, '--json']) + const j2 = await cli(['status', storage, bundle, '--json']) + assert.equal(j1.code, 0, j1.stderr) + assert.equal(j1.stdout, j2.stdout, 'byte-identical across consecutive runs with no intervening ops') + const rows = JSON.parse(j1.stdout) + for (const r of rows) { + assert.deepEqual(Object.keys(r), ['path', 'state'], 'the schema is exactly { path, state }') + assert.ok(['in-log', 'pending-ingest', 'dirty', 'absent', 'foreign'].includes(r.state)) + } + assert.ok(rows.some((r) => r.path === 'note.md' && r.state === 'in-log')) + const paths = rows.map((r) => r.path) + assert.deepEqual(paths, [...paths].sort(), 'rows are sorted by path') + + // dirty: in the log, hand-edited after materialize + const note = await readFile(join(bundle, 'note.md'), 'utf8') + await writeFile(join(bundle, 'note.md'), note + '\nA hand edit.\n') + const dirty = await cli(['status', storage, bundle]) + assert.match(dirty.stdout, /^dirty\tnote\.md$/m) + + // absent: in the log, missing from disk (materialize would restore) + await rm(join(bundle, 'note.md')) + const absent = await cli(['status', storage, bundle]) + assert.match(absent.stdout, /^absent\tnote\.md$/m) +}) + test('wanted lists an unresolved link, ranked', async () => { const base = await tmpDir('wanted') const storage = join(base, 'vault')