diff --git a/okf/SPEC.md b/okf/SPEC.md index eb9926a..08cd7df 100644 --- a/okf/SPEC.md +++ b/okf/SPEC.md @@ -630,10 +630,29 @@ 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. +**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: @@ -751,8 +770,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, 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 fba5428..33c2e05 100644 --- a/okf/cli/README.md +++ b/okf/cli/README.md @@ -68,8 +68,9 @@ 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 | +| `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 38c368a..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 @@ -16,13 +16,13 @@ // 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' +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)' @@ -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('', '') ), @@ -172,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/)', @@ -290,6 +299,7 @@ const HANDLERS = { materialize: cmdMaterialize, ingest: cmdIngest, watch: cmdWatch, + status: cmdStatus, wanted: cmdWanted, flags: cmdFlags, compact: cmdCompact, @@ -471,8 +481,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 +559,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` : '' @@ -564,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 9a0d4fd..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' @@ -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,156 @@ 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('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')