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')