35 Commits

Author SHA1 Message Date
ac6288088a fix 2026-06-23 03:53:35 +00:00
2b8b0302a8 feat(E3.1): conflict panel with side-by-side diff + plain-language summary
The first E3 story (Slice 2 — conflict resolution UX). When a matched note is
marked "conflict" (both sides changed since last sync) and the E3_CONFLICT_UX
flag is on, the detail panel renders a dedicated conflict panel: vault (left) +
Foundry (right) side by side, a plain-language summary naming what EACH side did
(not which wins), and three disabled action buttons (wired in E3.2). Neutral
ordering (vault left, Foundry right, no pre-highlighted action).

- src/server.ts: E3_CONFLICT_UX flag (env E3_CONFLICT_UX=1, default off) on
  ServerConfig.features.conflictUx. /api/status exposes conflictUx boolean.
- src/dashboard.html conflictDiff(a,b): LCS-based ordered line diff (preserves
  line order — moved lines aren't mis-reported as del+add). Separate from the
  legacy set-based diff() (left untouched for seed/sync/re-pull previews).
  conflictSummary(vault, foundry, entryName, noteName): plain-language summary
  ("Vault edited body (3 lines changed); Foundry renamed entry"). The conflict
  panel in select() when recommendation==="conflict" + conflictUx flag: two
  columns side by side, the summary, three disabled buttons (title="coming next"),
  "vault file missing" / "Foundry export missing" if a side is absent (no
  actions offered). Read-only at this stage (E3.2 wires the buttons).

tsc clean; 271 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 03:43:21 +00:00
42a9ae4378 feat(E2.6): catch-up-now trigger — immediate shallow+deep sweep + dashboard button
The last E2 story — completes the E2 epic (F→O auto-sync, "safe but silent").
A "Catch up now" button forces an immediate shallow + deep sweep out of cadence,
so the DM doesn't wait for the next jittered tick before trusting the vault is
current. Gated by foundryPoll (default off).

- src/foundry-poll.ts catchUpNow(): cancels pending shallow + deep timers, runs
  an immediate shallowPoll, then on completion immediately triggers a deepPoll
  (no cadence wait). Debounced (returns {skipped: true} if either is in flight).
  Reuses the same shallowPoll/deepPoll methods (no parallel code path — same
  mapPool, same lock, same routing). On completion: activity panel summary
  {shallow: {new, renamed, missing}, deep: {pulled, skipped, conflicts},
  durationMs}, lastPoll updated, regular cadence resumes from now. Persistent
  errors halt; transient errors logged.
- src/server.ts POST /api/foundry-poll/catchup (gated by foundryPoll; 404 when
  off; 400 when poll not enabled): calls catchUpNow(), returns the summary.
- src/dashboard.html: "Catch up now" button in the live-new-entries panel header.
  catchUpNow() POSTs + toasts the result (or "catch-up already running").
- tests/e2-6-catchup.test.ts: 5 tests — catchUpNow returns a summary with
  durationMs; debounced (skipped if in flight); endpoint 200 with summary; 400
  when poll not enabled; 404 when foundryPoll off.

tsc clean; 271 passing project-wide (18 pre-existing fixture-missing unchanged).

E2 epic COMPLETE (all 6 stories E2.1–E2.6).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 03:37:06 +00:00
d006311f3e feat(E2.5): live-new-entries list + one-click import endpoint + dashboard
New Foundry entries from the shallow poll appear in a separate "Live new entries
(from Foundry)" panel with a one-click "Import as new refined note" action. Never
auto-import — each entry sits until the user clicks. Gated by foundryPoll.

- src/server.ts POST /api/foundry-poll/import {uuid}: fetches relay.getEntry(uuid),
  checks name collision against the index (matched + refinedOnly) → 409 if
  collided, builds a refined note via importRow (batch.ts → entryToObsidian +
  foundry block), writes under refined/imported/<subfolder>/, removes from
  liveNewEntries, returns {ok, uuid, name, filename, subfolder}. Gated by
  foundryPoll (404 when off). No live entry → 404.
- src/dashboard.html: "Live new entries (from Foundry)" panel (details/summary,
  purple border) rendered from GET /api/foundry-poll's liveNewEntries array on
  the 2s poll (refreshLiveNewEntries). Each entry has an "Import as new refined
  note" button → POST /api/foundry-poll/import with a confirm dialog. On success
  → toast + refreshIndex. Hidden when foundryPoll is off (GET /api/foundry-poll
  404 → panel hidden).
- tests/e2-5-import.test.ts: 5 tests — import succeeds (file written +
  liveNewEntries cleared); name collision → 409 (NOT cleared); no live entry →
  404; foundryPoll off → 404; GET /api/foundry-poll includes liveNewEntries.

tsc clean; 265 passing project-wide (19 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 03:31:52 +00:00
8f48f356ce feat(E2.4): never-clobber routing — both-diverged/vault-newer → pending conflict
When both sides changed since last sync (both-diverged) OR Foundry's entry is
missing while the vault note exists (vault-newer), the F→O pull is SKIPPED and a
pending conflict row is recorded for E3 to render. No auto-pick, no auto-write.

- src/server.ts PendingConflictRow type: {uuid, name, state:
  "both-diverged"|"vault-newer", detectedAt, lastFHash, lastOHash}.
  AutoSyncController.recordPendingConflict(uuid, name, state, lastFHash,
  lastOHash): records in sync-state.json.pendingConflicts (deduped by uuid),
  removes from fPending, updates parity, saves atomically.
  pullFChanged: the "conflict" return now records a both-diverged pending conflict
  row (with lastFHash=ccHash(liveEntry), lastOHash=contentHash(body)) before
  returning — awaited so the save is durable.
- src/foundry-poll.ts shallowPoll: "missing" entries now route to
  recordPendingConflict("vault-newer") via state.autosync (guarded — falls back
  to fPending-only if autosync is unavailable). GET /api/foundry-poll includes
  pendingConflicts from sync-state.json in the response.
- tests/e2-4-conflict.test.ts: 3 tests — both-diverged → pendingConflicts row
  with correct hashes + removed from fPending; vault-newer (shallow poll missing)
  → pendingConflicts row; recordPendingConflict accessible.

tsc clean; 261 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 03:21:20 +00:00
2457596f26 feat(E2.3): F→O pull for F-changed + O-unchanged notes
When the deep poll detects a F-changed note AND the Obsidian side is unchanged,
the note is pulled from Foundry into the vault: /get → entryToObsidian (via
rePullRow) → writeWithBackup → dual re-baseline (contentHash + ccHash). Removes
from fPending. If O-side also changed → "conflict" (left in fPending for E2.4).

- src/server.ts AutoSyncController.pullFChanged(uuid, liveEntry): finds the
  matching row, checks O-side unchanged (contentHash(body) === foundry.contentHash),
  acquires the per-uuid "pull" lock, calls rePullRow (batch.ts — converts the live
  entry to refined markdown + injects the foundry block), writes via
  writeWithBackup (dev: mirror; apply: real vault + .bak), re-baselines both
  contentHash + ccHash(liveEntry) via baselineNote, removes from fPending +
  updates parity, logs "pulled ← <uuid> · baselined (content+cc)". Returns
  "pulled" | "conflict" | "skipped".
- src/foundry-poll.ts deepPoll: for each F-changed note, calls
  pullFChanged(uuid, liveEntry). "pulled" → removed from fPending (no change
  recorded). "conflict"/"skipped" → recorded in fPending for E2.4 / retry.
  Guard: if autosync isn't available (E2.2 tests), falls back to recording only.
- tests/e2-3-pull.test.ts: 5 tests — F-changed + O-unchanged → pulled +
  re-baselined + fPending cleared; F-changed + O-changed → "conflict" (note
  untouched); no matching row → "skipped"; note missing → "skipped"; unseeded →
  "skipped".

tsc clean; 258 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 03:12:00 +00:00
19a76a0c01 feat(E2.2): deep poll — per-note /get + ccHash compare + folder move detection
The deep poll fetches each linked note's full /get document and compares a
derived ccHash to the stored foundry.ccHash baseline, detecting content edits
and folder moves. Supersedes ADR-005's ~800-calls/min concern: mapPool bounds
concurrency to 4, cadence 5min → ≤48 calls/min regardless of N. Gated by
foundryPoll (default off).

- src/foundry-poll.ts: deep poll on a separate timer (5min cadence, ±20% jitter,
  FOUNDRY_DEEP_POLL_CADENCE_MS / FOUNDRY_DEEP_POLL_CONCURRENCY env). Candidate
  list = intersection of shallow-poll snapshot uuids + linked index uuids.
  mapPool concurrency 4, each: relay.getEntry(uuid) → ccHash(liveEntry) vs
  foundry.ccHash baseline (from the note's frontmatter) + liveEntry.folder vs
  foundry.folder_path. ccHash mismatch → "edited"; folder change on a legacy
  note (no ccHash) → "moved". Changes recorded to sync-state.json.fPending.
  Persistent error (404 No connected clients) → abort round + halt timer.
  Transient error → inline retry (3 attempts, backoff 500/1500/4500ms ±20%,
  retryBackoffs field for test override); final failure → fPending recorded
  (round continues). Overlap guard (deepSkipCounter). Load ceiling documented
  in status() (loadCeilingCallsPerMin = concurrency / round_seconds ≈ 48/min).
  stop()/setEnabled manage BOTH shallow + deep timers.
- tests/e2-2-deep-poll.test.ts: 6 tests — ccHash mismatch → "edited"; ccHash
  match → no change; folder move on legacy note → "moved"; persistent 404 →
  halts; transient 504 → retries + fPending recorded; status() exposes load
  ceiling + deep poll info.

tsc clean; 253 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 02:56:25 +00:00
4f868da9b8 feat(E2.1): shallow /search poll — FoundryPollController + rename/new/missing
The F→O auto-sync track begins. A FoundryPollController periodically snapshots
the relay /search (minified: {uuid,id,name,img,documentType} — no folder/
content) and diffs against the previous snapshot to detect structural changes.
"Safe but silent" — not user-visible until E2+E4+E3 all land. Gated by
foundryPoll (default off).

- src/foundry-poll.ts FoundryPollController: ~10s cadence with ±20% jitter
  (FOUNDRY_POLL_CADENCE_MS env). Diffs the /search snapshot keyed by uuid:
  rename (name change on known uuid), new (uuid absent from prior snapshot +
  not in linked index → liveNewEntries), missing (uuid in linked index but
  absent from /search). Records changes to sync-state.json.fPending (the
  F-pending badge's data from E4.3) + updates parity.fPending count + lastPollAt.
  Overlap guard (skip if a round is in flight, increment skipCounter). Transient
  retry per E1b.7 (inline backoff); persistent errors (404 "No connected Foundry
  clients found") halt the timer. liveNewEntries deduped by uuid; removed when a
  uuid appears in the linked index (after a manual refresh/import).
- src/server.ts: FoundryPollController added to State. features.foundryPoll flag
  (env FOUNDRY_POLL=1, default off). GET /api/foundry-poll (status) + POST
  /api/foundry-poll {enabled} (toggle), both gated by foundryPoll (404 when off).
  Created in startServer alongside AutoSyncController.
- tests/e2-1-shallow-poll.test.ts: 7 tests — rename detection; new entry →
  liveNewEntries; missing entry detection; parity.fPending + lastPollAt updated;
  overlap guard (skipCounter); persistent error halts; status().

tsc clean; 247 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 02:45:17 +00:00
cba0b60798 feat(E4.5): vault .sync-status.md note writer + airtight exclusion
The last E4 story — completes the E4 epic (sync status & parity). A maintained
status note in the vault mirrors the dashboard (on/off, mode, last-sync, parity,
recent activity), carrying a foundry.sync_status:"true" sentinel. The exclusion
is airtight + rename-safe: skip by BOTH path (dotfile) AND sentinel.

- src/server.ts writeStatusNote(state): renders .sync-status.md (frontmatter
  with foundry.sync_status:"true" + a body with on/off, mode, lastSync, parity,
  last 10 activity events) → writeWithBackup to refinedDir/.sync-status.md
  (apply mode backs up the previous). Called from log() (after appendActivity),
  refreshParity, and the mode handler — fire-and-forget.
- src/server.ts onChange: dotfile skip (rel.split("/").pop()?.startsWith("."))
  gated by features.syncStatus. A renamed status note (non-dot) is caught by the
  sentinel in runPushAttempt.
- src/server.ts runPushAttempt: sentinel check — if foundry.sync_status ===
  "true" → skip "sync status note (sentinel)", before the cc_uuid/contentHash
  checks. Gated by features.syncStatus. Rename-safe (sentinel survives a rename).
- src/batch.ts walkMd: exclude dotfiles from the index (!ent.name.startsWith("."))
  so .sync-status.md never enters state.index.matched/refinedOnly (never a row,
  never a push candidate).
- tests/e4-5-statusnote.test.ts: 5 tests — note written with sentinel + content;
  onChange skips .sync-status.md (no timer); sentinel check skips a renamed copy;
  sentinel absent on a normal note; index excludes .sync-status.md (only Roland
  indexed).

tsc clean; 240 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 02:35:24 +00:00
55c03b671f feat(E4.6): activity panel fed from sync-state.json (log() → activity bridge)
AutoSyncController.log() now appends to sync-state.json.activity (when
features.syncStatus on), so the activity panel reads from the persisted state
instead of the in-memory events array. The in-memory events stay for the flag-off
/api/autosync path. Also fixes a concurrent-save race in saveSyncState.

- src/server.ts log(): appends to sync-state.json.activity via appendActivity
  (fire-and-forget async) when features.syncStatus on + syncState loaded. Kind
  inferred from status: pushed→push, skipped→skip, error→error. The in-memory
  events array + counters stay (for /api/autosync when features off).
- src/sync-state.ts saveSyncState: unique tmp path per save
  (tmp-${pid}-${seq}) so concurrent saves (log()→appendActivity + a mode-flip
  handler) don't race on the same tmp file — one rename would consume the
  other's tmp → ENOENT. Each save is atomic; the last rename wins (same content
  since both write the shared in-memory state).
- src/dashboard.html refreshSyncState: renders #autoSyncLog (last 200) +
  #autoSyncCounts (pushed/skipped/errors derived from activity) from
  /api/sync-state when features on. Falls back to refreshAutosync
  (controller.events) when off.

tsc clean; 235 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 02:30:29 +00:00
da59bb8c40 feat(E4.4): loud SYNC PAUSED banner when auto-sync is off in RUN mode
A persistent red banner (not a toast) when mode=RUN-THE-MATCH + autoSyncOn=false:
"SYNC PAUSED — auto-sync is off, vault edits are NOT being pushed to Foundry" +
a "Resume auto-sync" button (calls toggleAutosync). Survives reloads (read from
sync-state.json via refreshSyncState). In PREP, the banner is NOT shown (PREP is
"not available", not "paused" — the syncModeBtn + disabled autoSyncBtn handle it).
Gated by features.syncStatus (flag off → existing silent "Auto-sync: off" label).

tsc clean; 235 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 02:23:41 +00:00
721a348fbb feat(E4.3): sync-status header + parity indicator reading sync-state.json
The dashboard shows at-a-glance sync state: ON/OFF, PREP/RUN mode, watched dir,
parity (in-parity/O-pending/F-pending/conflict/unsynced-linked), last-sync. The
F-pending badge is the Slice 1 demo hook — clickable to expand an "Incoming F→O
changes" list (latent until E2's poll populates fPending). Gated by
features.syncStatus (default off → existing /api/autosync unchanged).

- src/server.ts GET /api/sync-state (gated; 404 when off): returns the full
  SyncState. refreshParity(state) — derives oPending (sync-cc count),
  unsyncedLinked (seed count), conflict (conflict count) from the index rows'
  recommendation on each /api/index fetch; fPending=0/lastPollAt=null (E2 not
  yet); lastSyncAt from the newest push/pull activity event; parity status with
  precedence (conflict > O-pending > F-pending > unsynced-linked > in-parity);
  writes to sync-state.json atomically.
- src/dashboard.html: #syncHeader strip (ON/OFF + mode + watchedDir + lastSync)
  + #parityIndicator badge (color-coded by status) + #fPendingPanel (the
  Incoming F→O changes list, hidden until fPending>0). refreshSyncState() fetches
  /api/sync-state on init + the 2s poll (gated by featuresSyncStatus from
  /api/status). Falls back to existing /api/autosync + /api/status when off.
- tests/e4-3-parity.test.ts: 5 tests — /api/sync-state 200 (features on) / 404
  (off); empty index → counts 0, in-parity, lastSyncAt null; lastSyncAt from a
  push activity event; watchedDir set from cfg.refinedDir.

tsc clean; 235 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 02:22:10 +00:00
d7b06d7071 feat(E4.2): PREP / RUN-THE-MATCH mode flag gating AutoSyncController
In PREP, auto-sync is blocked (the DM is still curating/seeding/linking — must
not push half-curated notes to live Foundry). In RUN-THE-MATCH, auto-sync is
unblocked. The toggle is the only writer of `mode` in sync-state.json. Gated by
features.syncStatus (default off → existing behavior unchanged).

- src/server.ts setEnabled: PREP gate — if features.syncStatus + mode===PREP →
  throw "auto-sync is blocked in PREP mode — switch to RUN-THE-MATCH first".
  Stacks on top of the E1b.5 apply-mode gate (both must pass).
- src/server.ts POST /api/sync-state/mode (gated by features.syncStatus; 404 when
  off): flips mode in sync-state.json; switching to PREP while auto-sync is ON
  tears down the watcher (stop()) + flips autoSyncOn=false first. /api/status
  exposes syncMode + featuresSyncStatus.
- src/server.ts boot reconcile: if mode===PREP + autoSyncOn=true → autoSyncOn
  flipped to false + "PREP mode auto-sync disabled on boot" event (doesn't call
  setEnabled, which would throw the PREP message).
- src/dashboard.html: PREP⇄RUN toggle button in the header (gated by
  featuresSyncStatus from /api/status); autoSyncBtn disabled in PREP with a
  tooltip "Switch to RUN-THE-MATCH mode first"; toggleSyncMode() POSTs the flip.
- tests/e4-2-mode.test.ts: 8 tests — setEnabled in PREP throws / in RUN proceeds
  / flag-off no gate; POST mode flip; switching to PREP while ON → stop + off;
  boot reconcile PREP + autoSyncOn → off + event; invalid mode → 400; features
  off → 404.

tsc clean; 230 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 02:15:15 +00:00
fa4d36dbe4 feat(E4.1): persistent sync-state.json + boot reconcile (Slice 1 foundation)
The first Slice 1 story. A durable aggregate (on/off, mode, parity, activity,
last-sync) that survives restarts — the single status source E2/E3/E4 read
from. E4.1 only delivers load/save + the boot reconcile; the dashboard migration
to read sync-state.json is E4.2-E4.6 (gated behind features.syncStatus, defined
here, default off).

- src/sync-state.ts: loadSyncState/saveSyncState (atomic: tmp+rename, no
  truncation on crash), the shape {syncStateSchemaVersion, mode, autoSyncOn,
  lastSyncAt, parity, watchedDir, activity, updatedAt, conflict:null} (conflict
  reserved for E3 — E4 forces it null), appendActivity (newest-first, trimmed to
  200), defaultSyncState (fresh install: autoSyncOn=false, mode=PREP). Schema
  mismatch (≠ SYNC_STATE_SCHEMA_VERSION from E0.3) → back up to
  sync-state.json.bak-<stamp> + fresh defaults + an error event. (Reconciliation:
  E0.3 froze SYNC_STATE_SCHEMA_VERSION = "sync-state/v1" — E4.1's AC "= 1" is
  superseded; E4.1 persists autoSyncOn, superseding E1b.5's "no persistence until
  E3" — a fresh install still defaults OFF; a user who toggled ON is restored ON.)
- src/server.ts: features.syncStatus flag (env OFS_SYNC_STATUS=1, default off)
  on ServerConfig/State. startServer loads sync-state.json + reconciles
  AutoSyncController.enabled with autoSyncOn — if true, setEnabled(true); on
  throw (dev mode apply-gate / no relay) → autoSyncOn=false + persist + an
  "auto-sync could not resume" error event. State.syncState holds the aggregate.
- tests/e4-1-syncstate.test.ts: 10 tests — load creates defaults; atomic save
  (no .tmp left); schema-mismatch backup + fresh + error event; restart survival
  (autoSyncOn + 17 activity preserved); activity trimmed to 200; conflict forced
  null; boot reconcile (autoSyncOn=true + apply + relay → enabled restored; no
  relay → flipped off + error; dev mode → flipped off; fresh → stays off).

tsc clean; 222 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 01:42:13 +00:00
4ae4876695 feat(E7.4): CSRF / same-origin guard on POST mutation routes
The last E7 story — completes Slice 0 (E1b ∥ E7). Every requireCSRF route (the
POST mutations) is now defended against cross-site forgery: same-origin check
+ a per-session CSRF token. Ships dark (flag default off → no-op).

- src/server.ts checkCSRF(req,res,route): flag off → no-op; requireCSRF routes
  (POST mutations) check (1) same-origin — Origin (or Referer fallback) host must
  equal the request's Host header host; both missing → 403 "origin required"
  (curl/scripts send an explicit Origin); mismatch → 403 "cross-origin
  forbidden"; (2) X-CSRF-Token header === csrf_token cookie (constant-time);
  absent/mismatch → 403 "missing or invalid csrf token". GET/HEAD/OPTIONS exempt
  (requireCSRF is only on POST). Auth (E7.1) runs BEFORE CSRF — 401 fires first.
- src/server.ts issueCSRF + GET /api/auth/csrf (open): issues a random per-session
  token in an HttpOnly SameSite=Strict cookie + returns {csrfToken} (the JS-readable
  mirror; a cross-site can't read the body via CORS nor the HttpOnly cookie, so it
  can't forge X-CSRF-Token). Dispatch runs checkCSRF after authenticate.
- src/dashboard.html: apiFetch attaches X-CSRF-Token on non-GET requests (from
  localStorage, fetched via /api/auth/csrf on init). The browser auto-sends the
  HttpOnly csrf cookie on same-origin POSTs.
- tests: e7-1-dispatch + e7-3-nosecret valid-Bearer POST tests updated to send
  Origin + X-CSRF-Token + cookie (getCSRF helper). e7-4-csrf.test.ts (10 tests):
  csrf issuance (HttpOnly SameSite cookie + token); same-origin+valid → 200;
  cross-origin → 403; no-origin → 403 origin required; missing/invalid token →
  403; missing cookie → 403; auth-before-CSRF (no Bearer → 401); GET exempt; flag
  off → no-op.

tsc clean; 212 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 01:28:27 +00:00
32ed68eb4f feat(E7.3): no-secret-egress — gate data routes + masked presence + regression guard
The dashboard never leaks secrets or sensitive data to the browser, even on a
public bind. /api/auth/status is the only unauthenticated data endpoint in
public mode.

- src/server.ts ROUTES: gate the data-leaking GET routes (/api/index, /api/status,
  /api/file, /api/entries, /api/autosync, /api/autosync/conflicts,
  /api/autosync/last-push) → requireAuth:true. Only / (login page) + /api/auth/*
  stay open in public mode. /api/status leaks dir paths, /api/file leaks file
  contents, /api/index the full index — all gated. (On 127.0.0.1, authenticate
  doesn't enforce, so localhost stays open — the gate only bites a 0.0.0.0 bind.)
- src/dashboard.html: checkAuth reordered — fetch /api/auth/status (open) first;
  only fetch /api/status (gated) if not (authRequired && no token), avoiding a
  pre-login 401-throws. Masked presence chip ("Relay ✓/✗ · Foundry ✓/✗") driven
  by /api/auth/status booleans (relayConfigured/foundryConfigured), never env
  values.
- tests/e7-1-dispatch.test.ts: flag-on read tests updated — /api/auth/status open
  (200 without token); /api/status + /api/autosync gated (401 without token in
  public mode).
- tests/e7-3-nosecret.test.ts: regression guard — server with distinctive secrets
  (relay API key + dashboard token), hits endpoints incl. error paths
  (/api/refresh relay-failure 500, login 401, gated 401, the login page HTML) and
  asserts JSON.stringify of NO response contains either secret substring. Errors
  name env vars, never values; the relay key is a header, never serialized.

tsc clean; 202 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 01:17:45 +00:00
fe925d4aec feat(E7.2): localhost-default bind + refuse-to-start + first-run auth prompt
Safe-by-default dashboard exposure: localhost-only unless you explicitly opt in
to a public bind with a token.

- src/cli.ts: host default 0.0.0.0 → 127.0.0.1 (localhost-only; --host 0.0.0.0 to
  expose, which then requires a token when the auth flag is on).
- src/server.ts startServer guards (before listen): SELF-LOCKOUT (flag on + no
  token → throw — you'd otherwise brick the dashboard with no recovery short of
  editing .env) + PUBLIC-EXPOSURE gate (flag on + 0.0.0.0 + no token → throw).
  Both use __authState (mutable seam) so they're testable. __authState.bound set
  from cfg.host. When the flag is off, the guards are skipped (back-compat
  escape hatch — the bind still defaults to 127.0.0.1).
- src/server.ts authenticate: E7.2 bind-gating — enforce only on a PUBLIC bind
  (0.0.0.0); 127.0.0.1 is localhost-trusted (requireAuth routes stay open even
  with the flag on). The refuse-to-start guard ensures a 0.0.0.0 bind has a token.
- src/server.ts routes: GET /api/auth/status (open; {authRequired, bound,
  relayConfigured, foundryConfigured} — booleans only, no secret values),
  POST /api/auth/login (open; validates token constant-time, sets an HttpOnly
  SameSite=Strict cookie; 401 invalid credentials on mismatch, no leak; empty
  token = unset; no token configured → 401), POST /api/auth/logout (clears
  cookie, Max-Age=0).
- src/dashboard.html: first-run login card (token input, shown when authRequired
  && no stored token), a shared apiFetch wrapper (attaches the stored token as a
  Bearer header, on 401 → show login), checkAuth gating init; bare fetch('/api/')
  calls migrated to apiFetch (auth endpoints stay plain fetch).
- .env.example: documents DASHBOARD_AUTH_TOKEN + ENABLE_AUTH_MIDDLEWARE.
- tests: e7-1-auth/dispatch updated for bind-gating (enforcement tests set
  bound=0.0.0.0; + a 127.0.0.1 no-enforcement test). e7-2-auth.test.ts (13
  tests): auth-status (off/localhost/public/no-secret-leak), login
  (valid-cookie/invalid-401/empty/unset), logout (clears), refuse-to-start
  (self-lockout / 0.0.0.0-no-token / token-set-passes / flag-off-no-guard).

tsc clean; 194 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 01:08:46 +00:00
7207cc887d feat(E7.1): auth middleware contract + ROUTES declaration table
The other half of Slice 0. A single auth contract + a route table so every
endpoint declares its auth/CSRF requirement once — neighbor epics (E2/E3/E4)
register routes via ROUTES["<METHOD> /api/<x>"] = {method, requireAuth,
requireCSRF, handler} with no new auth code. Ships dark (flag default off →
byte-identical); the flag flips at the launch gate.

- src/server.ts auth infra: ENABLE_AUTH_MIDDLEWARE flag (env, default false),
  DASHBOARD_AUTH_TOKEN env, mutable __authState test seam. authenticate(req,res,
  route): flag off → no-op pass-through; flag on → requireAuth routes check a
  Bearer/cookie token (constant-time compare via timingSafeEqual, length-padded).
  Malformed Authorization → 401 bad auth header; missing/wrong token → 401
  unauthorized; token unset → 500. requireCSRF is declared (enforced in E7.4).
  Error responses reuse send() (x-content-type-options + cache-control).
- src/server.ts ROUTES table: every existing endpoint migrated — GET read routes
  (/, /api/index, /api/status, /api/file, /api/entries, /api/autosync,
  /api/autosync/conflicts, /api/autosync/last-push) → requireAuth:false; POST
  mutation routes (/api/action, /api/push, /api/push-all, /api/link,
  /api/refresh, /api/autosync, /api/autosync/revert) → requireAuth:true,
  requireCSRF:true (now gating the previously-unguarded E1b mutation endpoints
  when the flag flips). The dispatch loop consults the table before calling the
  handler. Top-of-file comment documents the ROUTES[...] contract.
- tests/e7-1-auth.test.ts: 8 unit tests (flag off no-op; flag on read-passes /
  no-token-401 / valid-bearer / wrong-401 / malformed-401 / cookie / unset-500).
- tests/e7-1-dispatch.test.ts: 11 integration tests via real startServer (empty
  temp LevelDB) — flag off byte-identical (read+mutation pass, favicon 204, 404);
  flag on (read no-token 200; mutation no-token/wrong/malformed 401; valid Bearer
  → handler 200/400; apply-gate after auth).

tsc clean; 180 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 00:54:57 +00:00
d8517b0ea4 feat(E1b.8): persistent rotated log + flagsSchemaVersion startup migration
The last E1b story. Every auto-sync decision is durably logged (NFR-5), and old
seeded notes are stamped with the current flags schema version at startup
before auto-sync engages (NFR-11) — so the controller can reason about which
notes carry the dual (contentHash + ccHash) baseline.

- src/server.ts persistent log: log() writes a JSON-line
  {time,level,name,status,message} to <outDir>/logs/sync-<YYYY-MM-DD>.log via a
  WriteStream, alongside the in-memory events (the dashboard's fast poll). Daily
  rotation (date change → close+open) + size rotation (10MB → rename to
  sync-<date>.<n>.log + reopen). pruneOldLogs at startup deletes logs/sync-*.log
  older than AUTOSYNC_LOG_RETAIN_DAYS (14). Gated by foundryGuardEnabled (off →
  in-memory only). startServer prunes + opens the log.
- src/server.ts flagsSchemaVersion: baselineFoundryBlock/baselineNote stamp
  foundry.flagsSchemaVersion (FLAGS_SCHEMA_VERSION from E0.3) on every baseline.
  stampFlagsSchemaVersion() (used by the migration) touches ONLY flagsSchemaVersion
  — no contentHash/ccHash change, so it doesn't falsely trigger a push.
- src/server.ts migrateFlagsSchemaVersion(): startup pass walks index.matched,
  stamps notes whose foundry.flagsSchemaVersion is absent/older (idempotent —
  already-current notes skipped), in-flight-safe (mtime changes between read and
  write → skip + log "in-flight edit"), logged to the persistent log. Runs BEFORE
  auto-sync is allowed to start. status() exposes migrationCount/migrationRan/
  schemaVersion.
- src/dashboard.html: "Migrated N notes to flagsSchemaVersion <v>" banner
  (dismissible, shown once on first poll when migrationRan && migrationCount>0).
- tests/e1b8-log-migration.test.ts: 7 tests — log() writes a JSON-line (info +
  error levels); pruneOldLogs runs without error; a clean push stamps
  flagsSchemaVersion; migration stamps absent notes idempotently with NO
  contentHash/ccHash change; already-current notes left alone; status exposes
  migrationCount + schemaVersion.

tsc clean; 161 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 00:43:58 +00:00
9c56b22cc8 feat(E1b.7): transient/persistent retry policy for the O→F push
Transient relay blips retry with backoff; persistent failures surface immediately
without burning Foundry.

- src/server.ts classifyRelayError(err) → "transient" | "persistent". The relay
  STATUS dominates: 408/504/500 → transient; 400/401/403/404 (and any other relay
  status) → persistent. No-status network failures (ECONNRESET/ETIMEDOUT/
  ENOTFOUND/fetch failed/Request timed out) → transient. Non-relay/unknown →
  persistent (safer, no retry). Exported for unit testing.
- src/server.ts PushRetryError (kinds: transient-exhausted/persistent/lock-busy)
  + sleepJitter (±20%). pushWithRetry: 3 attempts when foundryGuardEnabled (1
  when off — no retry); per attempt acquires the per-uuid lock (skip → throw
  lock-busy), runs runPushAttempt (pushNote + TOCTOU + baseline), releases in
  finally. On a transient pushNote failure: log "transient (attempt n/3) —
  retrying", add the uuid to a `retrying` set (so a concurrent save during the
  backoff is dropped, not duplicated), sleep the backoff (500/1500/4500ms ±20%,
  a field so tests shrink it), then retry. On persistent/exhaustion: throw
  PushRetryError. The lock is released between attempts so a concurrent F→O
  pull isn't blocked for the full backoff. A retry that succeeds still runs the
  full TOCTOU + baseline path (runPushAttempt) — no shortcut around the guard.
- runPushBody → runPushAttempt (pushNote failures propagate to pushWithRetry for
  classification; TOCTOU/baseline failures stay internal with their own handling,
  not retried). process flag-on: retrying-set check → pushWithRetry → catch
  PushRetryError → log (lock-busy = skipped; persistent/exhausted = error).
- tests/e1b7-retry.test.ts: 6 tests — classifyRelayError (transient 408/504/500/
  network; persistent 401/404/no-cc_uuid/unknown); 504×2→200 succeeds on attempt
  3 (one PUT, one baseline, TOCTOU re-verify, transient-retrying logs); 401 → zero
  retries, immediate "persistent" error, no PUT; lock-busy → skipped; retrying-set
  drops a concurrent same-uuid save.

tsc clean; 154 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 00:30:51 +00:00
51bbb350e7 feat(E1b.6): env-configurable concurrency/debounce + 50-note burst validation
The watcher fallback, rescanSubs, debounce (700ms), and concurrency (3) already
 existed; this story makes them env-configurable with bounds and adds the burst
 validation harness + the recursive-fallback exercise.

- src/server.ts: AUTOSYNC_CONCURRENCY (default 3, clamped 1-8) +
  AUTOSYNC_DEBOUNCE_MS (default 700, clamped 100-5000) via a clampInt helper
  (replaces the hardcoded concurrency=3 / debounceMs=700).
- tests/e1b6-burst.test.ts: 50-note burst validation harness (drives queue/drain
  directly with a mocked 100ms pushNote + concurrency counter; guard off so no
  relay /get). Asserts: 50 distinct-uuid notes → 50 completions, max concurrent
  pushNote ≤3, no uuid pushed twice, bounded wall time (<30s); a same-uuid pair
  (rename) → the second is skip-dropped, no duplicate PUT ("lock busy" log); a
  push that throws releases its slot in finally and the queue drains the rest
  (one failure does not stall the burst — pushed N-1, error 1, queue empty).
- tests/e1b6-watch.test.ts: recursive-watch fallback — rescanSubs attaches
  watchers to subdirs (excl .obsidian/dotfiles) and picks up a subdir created
  mid-burst; debounce — two rapid onChange for the same rel collapse to one
  timer, different rels arm independent timers.

tsc clean; 148 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 00:18:57 +00:00
3202115735 feat(E1b.5): apply-mode gating + OFF-by-default + dev banner + watcher skips
Auto-sync refuses to go live unless the server is in --apply mode, starts OFF
every restart (opt-in per session until E3), and the watcher never pushes the
status-note / wiki-structure paths.

- src/server.ts setEnabled: throws "auto-sync requires --apply mode..." when
  enabled in dev mode (defense in depth). POST /api/autosync handler pre-checks
  mode → 400 with that message (leaves enabled=false). Apply-mode gating is a
  HARD safety floor (not feature-flagged): in dev mode the refined dir is the
  --out mirror, so enabling would push mirror edits to live Foundry.
- OFF-by-default: constructor sets enabled=false; no startup path auto-enables
  (verified — the only setEnabled call is the POST toggle). Per-session: a
  restart always returns to OFF (no persisted-on state until E3).
- src/server.ts watcher: STATUS_NOTE_PATHS = ["_meta/", "wiki/", ".raw/"]
  (FR-4.3 status-note exclusion); onChange skips rel under these with a logged
  "status-note path" reason, no debounce timer. (Unlinked/unseeded frontmatter
  skips stay in process — already logged there; a hot-path read in onChange
  conflicts with the single-read design and yields no net savings — deferred.)
- src/dashboard.html: dev-mode banner (yellow strip above the auto-sync panel
  when /api/status mode==="dev"); "Auto-sync is opt-in per session — resets to
  OFF on restart" note near the toggle when enabled in apply mode.
- tests/e1b5-applymode.test.ts: 6 tests — constructor enabled=false; dev-mode
  setEnabled throws + stays off; apply-mode setEnabled starts/stops; onChange
  skips _meta/ + wiki/ + .raw/ with logged reason + no timer; a normal note arms
  the debounce.

tsc clean; 141 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 00:08:50 +00:00
f189fd739e feat(E1b.4): per-uuid pre-push backup cache + retention + "Revert last push"
Before any auto/manual push, the pre-push Foundry entry is cached so a wrong
push is one-click undoable from the dashboard (no shell, no hunting backups).

- src/push.ts: optional backupPath in PushDeps (writes there with mkdir -p;
  else the default flat <outDir>/bak/<name>.<stamp>.json for manual push).
  PushOutcome.backupPath returns the actual path.
- src/server.ts runPushBody: auto-sync passes the per-uuid
  <outDir>/foundry-backups/<uuid>/<iso>.json layout (so retention is per-entry,
  distinct from manual push's flat bak/). After a successful push: records a
  last-push {uuid,name,backupPath,time,relPath} in an in-memory map + as
  controller.lastPush (drives the dashboard button); prunes the per-uuid backup
  dir to the last N (AUTOSYNC_BACKUP_RETAIN default 10, by mtime, awaited so the
  post-push state is deterministic). GET /api/autosync/last-push?uuid=… returns
  the record or 404; status() exposes lastPush + conflictCount.
- src/server.ts AutoSyncController.revert(uuid): acquires the per-uuid lock in
  the "pull" direction (queues behind an in-flight push, 5s max); reads the
  backup JSON (400 if no last-push, 409 if the file is missing/corrupt); calls
  relay.updateEntry(uuid, fullBackupDoc) — a FULL /update (not a diff — the one
  place a full PUT is correct, to restore _id/pages/ownership/flags exactly);
  re-baselines the note (contentHash=body, ccHash=ccHash(backupDoc)) via the
  E1b.2 path + records self-write suppression; clears any TOCTOU conflict for
  the uuid. POST /api/autosync/revert endpoint (unguarded — E7 not landed).
  Gated by AUTOSYNC_FOUNDRY_GUARD (404 when off).
- src/dashboard.html: "Revert last push" button in the auto-sync panel (shown
  when a recent push exists + guard on), wired to POST /api/autosync/revert
  with a confirm stating the note keeps the edit while Foundry reverts.
- tests/e1b4-revert.test.ts: 6 tests — per-uuid backup written + last-push
  recorded; retention prunes to N; revert full-PUTs the backup + re-baselines;
  400 (no last-push); 409 (missing backup); 404 (guard off).

tsc clean; 135 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 00:00:48 +00:00
8406a0a52a feat(E1b.3): TOCTOU post-push re-verify + conflict rows + dashboard badge
Closes the TOCTOU window E1b.1/E1b.2 left open: Foundry can be edited by
another client between the pre-push /get and the PUT response.

- src/server.ts runPushBody: after a successful push, issue a SECOND relay.getEntry
  (the one extra /get the epic permits), compare post-push ccHash to
  ccHash(pushedEntry) (what we just wrote). Match → baseline per E1b.2. Mismatch →
  TOCTOU conflict: record a ConflictRow {uuid,name,obsidianHash,foundryPreHash,
  foundryPostHash,time,relPath} in a bounded (50, newest-first) in-memory list,
  log "skipped" "TOCTOU conflict — Foundry edited during push, NOT baselined; use
  Sync / Re-pull", leave baselines + in-memory ccHash untouched so the next save
  re-surfaces the divergence. Re-verify /get failure → error log, no baseline,
  push stays live (no rollback). A successful push clears any prior conflict for
  that uuid. (Documented deviation: the AC said compare to the "pre-PUT captured
  entry", but our own push changes Foundry so that would always differ — the
  correct comparison is post-push /get vs ccHash(pushedEntry), the expected state.)
  Gated by foundryGuardEnabled (flag off → no TOCTOU check, baseline directly).
- src/server.ts: ConflictRow type exported; status() adds conflictCount + the
  conflicts list; GET /api/autosync/conflicts endpoint (registered unguarded —
  E7's auth middleware not landed yet; E7 will gate it).
- src/dashboard.html: conflict badge on the Auto-sync button (⚠ N conflicts) +
  a loud note when non-zero, driven by the existing /api/autosync poll.
- tests: new e1b3-toctou.test.ts (5 tests — clean, TOCTOU mismatch, re-verify
  failure, conflict cleared on next success, status exposes conflicts). Updated
  e1b1/e1b2 mocks to behave like Foundry (/get returns current state, /update
  applies the diff) so the re-/get returns the pushed state (no false conflict);
  e1b1 clean test now expects 2 /gets (push + re-verify).

tsc clean; 129 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 23:49:17 +00:00
70c6d982fa feat(E1b.2): dual re-baseline (contentHash + ccHash) + self-write suppression
After a successful push, re-baseline BOTH foundry.contentHash (body) and
foundry.ccHash so the next save is correctly gated against both sides, and
suppress the watcher's own baseline write so it doesn't re-trigger a push.

- src/push.ts: PushOutcome.pushedEntry (the buildPushPayload `full` — the
  post-push Foundry content). Exposed so the caller can baseline ccHash with no
  extra /get.
- src/server.ts baselineFoundryBlock/baselineNote: extended to write foundry.ccHash
  (insert the line if absent). runPushBody re-baselines both: ccHash =
  ccHash(pushedEntry) — the post-push state. (Documented deviation: the AC said
  "pre-PUT captured entry", but a push changes Foundry so pre-push would
  false-abort the next save; post-push = ccHash(pushedEntry) is correct, no /get.)
  In-memory ccHashBaselines map (uuid→ccHash) is set BEFORE the disk write and
  preferred by the guard, so a disk-write failure still leaves the next save's
  guard correct (frontmatter is the durable fallback on restart). Log
  "baselined (content+cc)".
- src/server.ts self-write suppression: recentlyBaselined map (relPath→{mtime,
  expires}) with TTL AUTOSYNC_BASELINE_SUPPRESS_MS (default 2000). onChange checks
  it BEFORE arming the debounce — if the change's mtime equals the recorded
  baseline mtime, drop with "self-write (baseline)" log and NO timer. A user
  edit within the TTL has a different mtime → processed. stop() clears the map.
- tests/e1b2-baseline.test.ts: 5 tests — dual re-baseline (contentHash + ccHash =
  ccHash(pushedEntry)); drift-abort leaves baselines untouched; same-mtime
  self-write dropped (no re-push); user edit (different mtime) processed; TTL
  expiry re-enables processing.

tsc clean; 124 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 23:35:01 +00:00
348ab30f03 feat(E1b.1): AutoSyncController no-clobber guard via reused /get + ccHash
First story of the E1b controller-hardening epic. Wires the E1b-alt HTML
ccHash into AutoSyncController.process as a Foundry-side divergence guard:
before the PUT, compare ccHash(liveEntry) to the note's foundry.ccHash
baseline; if Foundry drifted since the last sync, ABORT the PUT (fail-safe —
never overwrite a Foundry-side edit).

- src/push.ts: prePushGuard?(liveEntry)=>{abort,reason?} on PushDeps, called
  right after relay.getEntry (the SAME /get pushNote already makes — no extra
  round-trip), before any side effect (image upload/backup/PUT). On abort,
  returns an aborted PushOutcome (no PUT, no backup). PushOutcome gains
  aborted/abortReason/liveEntry (liveEntry exposed so E1b.2 can re-baseline
  ccHash with no second /get).
- src/server.ts: runPushBody passes a prePushGuard that computes ccHash on
  the reused live entry and compares to foundry.ccHash. Absent ccHash (legacy)
  proceeds (one-time migration; E1b.2 writes the post-push baseline).
  Unreadable Foundry side (CcHashError) aborts fail-safe. Feature flag
  AUTOSYNC_FOUNDRY_GUARD (env, default true; controller field for test override;
  off → body-only, documented unsafe).
- tests/e1b1-noclobber.test.ts: 7 tests with a mock relay (real pushNote) —
  clean push PUTs; Foundry-side drift → NO PUT; legacy note proceeds; unreadable
  Foundry side → NO PUT; guard works with sync-state.json ABSENT (no E4 dep);
  flag on/off. Live SM-2 verification stays gated on the operator's headless
  session.

tsc clean; 119 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 23:24:23 +00:00
5d96bf1267 feat(E1b-alt): re-baseline ccHash to canonicalize-HTML contract
E1a proved the markdown round-trip unstable (NO-GO). This re-baselines the
E0.2 ccHash contract to hash Foundry HTML directly — the E1b-alt fork — which
sidesteps all 5 E1a failure reasons (no inverse, no resolver, no blank-line/
case/order sensitivity, no parseBody coupling).

- src/canonicalize-html.ts: canonicalizeHtml(html) — linkedom DOM walk that
  absorbs serialization drift (attribute order/quoting, named-vs-numeric
  entities, inter-tag whitespace, tag case, self-closing) while preserving
  content (structure, attr values, meaningful text). Two inputs parsing to the
  same DOM → same canonical string. Mini-gate: tests/canonicalize-html.test.ts
  (9 tests — serialization variants → same canonical; content change → different).
- src/cchash.ts: rewritten to ccHash = contentHash(canonicalizeHtml(
  data.description) + "\n" + canonicalizeHtml(data.notes ?? "") + "\n" + name
  + "\n" + folder). The HtmlToMarkdown seam is DROPPED; a CanonicalizeHtml seam
  (default = canonicalizeHtml) replaces it. CC_HASH_CONTRACT updated + pinned +
  re-derivation-enforced. CcHashError on missing description kept; direction-
  invariance kept (name/folder from liveEntry); folder = Foundry folder ID,
  distinct from Obsidian foundry.folder_path. tests/cchash.test.ts updated (21
  tests incl. serialization-drift-absorption + no-false-negative).
- src/fromFoundry.ts (the E1a markdown inverse) ships unwired — not consumed by
  ccHash; remains as the spike artifact's inverse.

tsc clean; 67 E0+E1a+E1b-alt tests pass; 112 passing project-wide (18 pre-existing
fixture-missing failures unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 22:35:09 +00:00
d404929a84 feat(E1a): hash spike — NO-GO verdict, pivot to E1b-alt
E1a.1 go/no-go gate. Built the linkedom htmlToMarkdown inverse
(src/fromFoundry.ts) and a round-trip hash-stability fixture suite
(tests/e1a-hash-spike.test.ts) against the real forward transform.

Verdict: NO-GO. 7 of 12 fixtures round-trip; 5 fail for 5 distinct reasons:
1. wikilinks/@UUID — forward converts [[ ]]→@UUID via resolver; the
   (html)=>string seam has no resolver.
2. tables — markdownToHtml has no table branch (forward gap).
3. ## Secrets order — forward moves Secrets to data.notes (always last).
4. ## Secrets heading case — canonicalize doesn't normalize case.
5. bold-leading bodies — parseBody tagline regex matches inside **bold**.

The markdown-hash divergence guard is not viable. E1b adopts the E1b-alt fork
(canonicalize Foundry HTML directly, hash the HTML, never hash markdown).
Findings + recommendation in e1a-spike-findings.md. The spike ships unwired
(fromFoundry.ts not consumed by the push path or ccHash); the forward push
path and canonicalize/contentHash signatures are unchanged. The suite is green
with the 5 unstable fixtures asserting toBe(false) to pin the evidence.

E0.2's markdown ccHash contract is superseded — E1b-alt re-baselines it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 22:22:14 +00:00
8fd56a22d9 feat(E0): shared sync primitives — per-uuid lock, ccHash, schema versions
Foundation for the live-sync epic plan (E0). Frozen-contract primitives that
gate E1a/E1b/E2, grounded in the real code shapes.

- synclock.ts: per-UUID bidirectional lock (push/pull/baseline) replacing the
  per-relPath inflight Set. acquire/release/withLock/isHeld/heldOps, skip|queue
  policy, reentrant-NO, relPath: fallback. Queue path retries until acquired or
  throws LockAcquireTimeout (no silent drop); fairness — acquire defers to
  queued waiters. SYNC_LOCK_ENABLED flag (default on; off = byte-identical
  legacy inflight).
- cchash.ts: ccHash(entry, inverse) + ccHashFromGet, frozen CC_HASH_CONTRACT.
  Contract corrected from epic prose: the body spans data.description +
  data.notes (CcData object, not a string), with ## Secrets re-inserted.
  Throws CcHashError on missing description (not coerced to ""). Three E1a-gate
  assumptions documented (sidebar exclusion, Secrets heading/case, section order).
- schema-version.ts: FLAGS_SCHEMA_VERSION / SYNC_STATE_SCHEMA_VERSION branded
  constants + parseSchemaVersion (branded discriminated union).
- server.ts: AutoSyncController refactored to use the lock (single read; uuid
  resolved from the same read used to gate — no stale-uuid gap; uuidCache powers
  the debounce pre-check without a file read). AutoSyncController/State exported,
  lock public for E2's poll path.
- 43 tests across 4 files (synclock, cchash, schema-version, server-lock
  integration incl. cross-direction block + flag-off byte-identical). tsc clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 22:01:57 +00:00
37dceb9ac5 feat: refine workflow, take first steps towards thiking about other peoples preferences 2026-06-22 16:27:51 +00:00
533f4fdc6b feat(dashboard): uuid auto-match, push-all, auto-cc, link picker, rec filters
Bring every scripts/-only capability into the dashboard UI so nothing
requires a manual shell command, plus dev-mode overlay + UX fixes.

- indexAll: fall back to foundry.cc_uuid when basename doesn't match
  (basename wins; cc claimed once). Resolves refined-only orphans whose
  filename differs from the Foundry entry (e.g. "Angro Harn" vs
  "Angro Harn - Journal") with no new UI.
- Push all changed: POST /api/push-all + header button. Dry-run lists
  vault-newer notes; apply pushes each (live entry backed up to <out>/bak),
  baselines foundry.contentHash, concurrency 4. Replaces scripts/resync.ts.
  pushNote gains skipImageUpload for fast dry-run previews.
- Auto-build cc dir at launch: --cc is now optional; cmdUi synthesizes cc
  stubs from the journal via new src/ccdir.ts (generateCcDir). Replaces
  running scripts/gen-cc-dir.ts by hand.
- Refined-only table + Link-to-Foundry picker: surface the previously
  hidden refined-only rows; GET /api/entries + POST /api/link inject the
  foundry block for a chosen entry and re-index. For notes with no link.
- Dev-mode mirror overlay: index/detail/push read mirror-first so dev
  writes preview as apply would. Overlay is mtime-aware (mirror wins only
  if >= real mtime) so Obsidian edits to the real vault still surface as
  changed instead of being masked by a stale baselined mirror copy.
- Re-scan button + auto-refresh on tab focus so edits show without reload.
- Recommendation panel: "See N" buttons filter the table to a bucket, with
  an active-filter chip and filtered counts in the section headers.
- Row badge wording: short status nouns (cc-only / vault newer / ...) so
  the tag doesn't read like a button.
- Dashboard binds 0.0.0.0 by default (tailnet-reachable); --host to restrict.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-20 22:05:57 +00:00
ce8040c41b feat: file uploads (portrait + body images) + live-relay batch scripts
Portrait upload was broken: it wrote to Data/worlds/<world>/uploads (per-world,
missing the Data/ segment) and did not URL-encode. Fixed to the verified
convention -- copy to the GLOBAL Data/uploads/<world>/ dir (unencoded on disk),
flag stored URL-encoded as uploads/<world>/<encodeURIComponent(file)>. Extracted
a reusable uploadImageAsset helper.

Body ![[image]] embeds were dropped by mdToHtml. Added push-layer pre-processing
(processBodyImages in push.ts): upload co-located files, rewrite the embed to
![](servedPath), drop non-local refs gracefully. mdToHtml renders ![alt](src)
-> <img>. parseBody now returns a preface (preamble content besides the tagline)
so a loose image between the tagline and the first ## section is no longer
dropped before conversion.

scripts/ helpers for the live-relay path (no offline CC export needed):
- seed-by-name: link refined notes to Foundry by name -> uuid, inject foundry block
- import-missing: create Foundry entries with no vault note as new pushable notes
- gen-cc-dir: generate a cc dir from a journal snapshot so the dashboard ui can run
- resync: push every body-changed note and baseline its foundry.contentHash so
  re-runs only catch new edits (idempotent, re-runnable)

Verified end-to-end against the dev Foundry world: portraits + body images
served by Foundry, bidirectional round-trip faithful, push is no-clobber
(diff keys only name + flags.campaign-codex).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-20 21:01:15 +00:00
9252aa9bf6 sync tweaks 2026-06-20 19:39:43 +00:00
595cdad2ef fix: code review 2026-06-20 19:38:51 +00:00
74f76a820d chore: first 2026-06-20 19:15:38 +00:00