feat(discrepancies): Discrepancy Center at scale — overview, real fixes, batch decisions, live-layer git safety - #146
Merged
Merged
Conversation
…epancies.mjs Extract the decision, rule, priority, automatic-rule, and recovery machinery out of service.mjs into createDiscrepancyOperations(caps), following the control/sources.mjs pattern: the HTTP routes are parsing shims, the operations carry every guard, refusal message, and journal transition. Capabilities are injected (file roots, selected layers, stores, projection, git, onWritten) so the CLI can host the same operations without an HTTP service. Behavior-preserving: every status, message, and journal state is unchanged; refusals are ControlError with a stable code and the same HTTP status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…s for discrepancies discrepancies.mjs gains the pure pieces the Discrepancy Center at scale is built from: - buildLinkIndex/candidatesFor: structural repair proposals for a broken link's target — relative, case, extension, slug, moved, title, slug_moved, and a bounded typo search (banded OSA distance, histogram reject, longest- prefix-first scan cap) — attached to every broken_link record as `candidates` (≤5, confidence then id) and `bestCandidate` (≥0.85 and unambiguous). Never part of `revision`. 1,500 links over 3,000 ids in ~300ms, deterministic. - rewriteLinkTarget/removeLink: edit link syntax in section text preserving ./, .md, #anchor, whitespace, and wikilink aliases; images untouched. Consumed by the write actions in the next PR. - ACTIONABLE_STATUSES, summarizeDiscrepancies, compactDiscrepancy, filterDiscrepancies: the summary, list-row, and filter shapes the compact route answers with. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
… and detail reads
The projection (buildDiscrepancies over the resolved corpus plus this
profile's decisions, rules, and priorities) now lives in
control/discrepancies.mjs behind a `corpus` capability and is memoized in one
place, keyed `corpusKey | sourceHealthSig | sidecarRevision` (size:mtimeMs of
the decision log, local rules, team rules, and priorities, plus an in-process
write counter every write path bumps). The list route, the decision guard,
and the automatic-rules job all read the same build; a memo hit still reports
the live generation. Idle-evicted like the corpus memo.
Routes, additive — the bare GET /api/discrepancies envelope is unchanged:
GET /api/discrepancies?fields=compact&status=&kind=&conceptId=&target=&source=&owner=&conceptType=&limit=&offset=
→ compact rows (240-char previews, historyCount + latestDecision) plus
summary, total, filtered, offset, limit, projectionRevision
GET /api/discrepancies?id=<id> → { discrepancy | null, generation, projectionRevision }
GET /api/discrepancies/summary → { summary, coverageComplete, indexing, indexingSources, generation, projectionRevision }
statusApi() is split into statusOf(manifest, pinned) so the corpus provider
hands the projection a corpus and the status it was resolved under from ONE
pin.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
… git-core (F30)
A decision whose contributor sits inside the live team layer used to rename
new bytes into the shared clone with no lock, no commit, and no push — the
next pull saw a dirty tree and teammates never received the change. The
control operation now detects live targets (resolveLiveLayer over the
selected layers), and stages AND commits inside git-core's
commitPathsWithMutation: one pathspec commit per decision
(`chore(contextcake): resolve <kind> <conceptId>#<key> (<action>)`), the
decision records `liveLayerCommit: { layer, paths }`, the response carries
`git: { layer, paths, committed, pushed, queued }`, push runs once per
request after the decision is durable, and an unreachable remote is
`{ pushed: false, queued: true }` — never thrown. LockBusy answers 409
`LIVE_LAYER_BUSY` with nothing staged, journaled, or renamed. Staging moved
inside the lock deliberately: the read that backs a target up must not race
another process's pull.
git-core gains `skipIfClean` so a decision that writes a layer's own winning
bytes back to it (byte-identical file) applies without an empty-commit
refusal. The journal's recover() gains an `onRestored` hook, run after the
backups are copied and before they are dropped, so the crash-between-commit-
and-append edge commits its restore (`chore(contextcake): roll back
uncommitted discrepancy transaction <id>`) and git agrees with the log; a
failed hook leaves the transaction retryable. Journal containment now
compares real paths — the writers record realpaths, and a symlinked layer
path (every macOS temp dir) made recovery refuse its own targets.
POST /api/sources/sync now handles the live layer (withGitSync's sync()
lands the queue). readLayerSection joins layer-files.mjs and is wired as the
`readLiveSection` capability the write actions in the next PR consume.
New suite: discrepancy-live-layer-test.sh (bare + clone; commit/push/tree
clean; skip-if-clean; offline queue + sync; held lock → 409; recovery
commit). Spec, CLAUDE.md, and docs/architecture/notes/discrepancy-projection.md
carry the reasoning.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…are restore after a landed commit
Review follow-ups on the F30 path:
- commitDecisionWrite is now the single stage -> journal -> rename -> append
-> finalize -> push tail. applyDecision uses it, and the legacy "change a
past decision" branch of POST /api/conflict-resolutions goes through it
too, so a changed decision whose contributors include the live team layer
commits under git-core's lock like every other decision (it was the last
decision path still renaming into the clone bare). The live-layer suite
exercises the route.
- If the git commit lands and the decision append then throws, the restore
is itself a commit under the same lock ("roll back uncommitted discrepancy
transaction <id>"), never a bare copy that leaves HEAD holding a write the
log denies. Once the decision is appended nothing rolls back: a journal or
cleanup failure after that point is logged and startup recovery reconciles
the prepared record against the committed decision.
- git-core passes --literal-pathspecs on add/commit/status: the paths are
concept ids, and a "[" in a name is a glob.
- promoteRule guards a host without the git capability the way applyDecision
does; the candidate memo key uses escaped NUL separators (raw NUL bytes
fail the repo's grep-hygiene gate); the scale bound is 3s to catch a
blowup without flaking on a loaded runner; the live-layer suite takes an
unused port.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
… targets
The staged-transaction protocol behind every discrepancy write only knew how
to REPLACE an existing file (stage beside it, back it up with COPYFILE_EXCL,
rename into place, restore from the backup). "Create the missing concept"
for a broken link needs a CREATE: stage the new bytes with no backup, place
them with an exclusive copy so a file that appeared since staging is never
overwritten, and roll back by unlinking exactly what this transaction placed
— never a file it did not (a created target whose commit failed on EEXIST is
someone else's).
- layer-files.mjs: stagePreparedWrites accepts `write.create === true`
(target `{ path, staged, backup: null, created: true }`), `options.probe`
answers the would-be targets without touching the disk (a batch's dry
run), and stageFileCreationTransaction({ layer, rel, text }) is the guarded
creating writer — same sandbox (resolveLayerFile), same size cap, refuses
an existing path (lstat, so a dangling symlink counts) at stage time and
again at commit, creates the parent folder. stageFrontmatterTransaction
passes options through so it can be probed too.
- conflict-resolutions.mjs: recover() restores a `created` target by unlink
(bounded to the prepared window — documented), skips a `null` backup
instead of resolving "null" as a path, and checks containment of every
journaled file before touching it.
- http-util.mjs: assertInsideRoot resolves a not-yet-existing path through
its deepest existing ancestor (realpathLenient, now exported and shared
with the journal). Resolving only the immediate parent refused a contained
file two folders deep whenever the root sits behind a symlink — every
macOS temp dir — which is exactly the shape of a stub in a new subfolder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…neralized suggestions
A rule could only say "acknowledge every broken link to old/decisions from
the team layer in decision#choice" — one rule per section key. It can now say
it for every section: `conceptType` and `key` accept the literal wildcard
"*"; `kind`, `sources`, and a broken link's `target` stay exact. The target
is never wildcarded — a target-blind rule is exactly the auto-acknowledge-a-
typo failure the existing comments warn about — and validateRule refuses
`target: "*"` outright.
- discrepancy-rules.mjs: `{ type: "rewrite_link", newTarget }` is a valid
action for broken_link only; the destination is normalized and must
differ from the missing target; prefer_source is still refused for broken
links. suggestDiscrepancyRules keeps the exact-pattern miner and adds a
generalized suggestion (`generalized: true`, match conceptType "*" / key
"*") only when three or more consistent acknowledge/rewrite_link decisions
against one (sources, target) span at least two distinct (conceptType,
key) pairs, and no equivalent rule exists. serializeRuleDocument round-
trips both shapes.
- discrepancies.mjs: matchRules treats "*" as any for those two fields;
ruleConflict is unchanged — a wildcard and an exact rule that disagree
both match and disable automation, no specificity order is invented. A
resolved broken-link row keeps its `target` (from the decision's
linkTarget) and a rewrite's newTarget as its effective value.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…tomatic rules as one batch
A broken link could only be acknowledged; the engine refused to fix it
("Open the source file to repair this link"). For 412 links to a renamed
concept that was 412 manual edits. control/discrepancies.mjs now applies
three link actions and a batch, and the automatic-rules job runs through
the same batch instead of one apply per index pass.
Write actions (applyDecision → applyLinkDecision):
- `rewrite_link { newTarget }` and `unlink` edit exactly the effective
contributor's section — the one contribution a broken_link record has;
a dissenting lower-layer copy is not a discrepancy today and surfaces as
its own record with the same candidates if precedence flips. The section
is read LIVE (readLiveSection), not from the projection, so two rewrites
into one section in one request both land; `replaced === 0` is 409
LINK_GONE. `newTarget` is normalized and must exist in the projection
(409 LINK_TARGET_MISSING); the layer must be writable (409
SOURCE_NOT_WRITABLE). Same staged transaction and write tail as every
other decision (commitDecisionWrite: journal, rename, git-core inside the
live layer, push).
- `create_stub { layer, title?, type? }` writes a minimal OKF document
(`type`, `title`, `updated`, the H1, one line saying why it exists) into
a writable layer through the create-mode transaction: traversal-guarded
path, 409 TARGET_EXISTS when anything is already there (lstat), the
parent folder created, `created: true` on the journal target and on
`writtenTargets`. Every link to that target then resolves; the created
concept is the audit trail for the ones this decision did not record.
- Kind gate both ways: choose/compose on a broken link → 409
BROKEN_LINK_NOT_WRITABLE ("A broken link has no alternative answer to
choose or compose. Rewrite the link, remove it, create the missing
concept, or acknowledge."); a link action on any other kind → 400
ACTION_INVALID. Decisions carry `sectionKey`, `linkTarget`, `newTarget`
/ `createdTargets`, a human `reason`, and `ruleAction` only for
rewrite_link (unlink and create_stub are deliberately not learnable). A
`resolved` row is NOT_OPEN — it is the audit trail of a past decision,
and its recorded revision would otherwise pass the STALE check into a
write path with no effective source.
Batch (`POST /api/discrepancy-decisions/batch`, decideBatch):
`{ decisions: [≤500], stopOnError?, dryRun? }` → `{ ok, applied, failed,
dryRun, results: [{ discrepancyId, ok, status?, code?, error?, decision?,
written?, git?, wouldWrite? }], git?: { layer, commits, pushed, queued },
suggestions }`. One manifest lock, one settled projection (the whole batch
is 409 COVERAGE_INCOMPLETE while indexing), every item validated first
(shape, NOT_OPEN, STALE, DUPLICATE) — nothing applied until all are looked
at — then applied in order, each its own journal transaction, continuing
past a failure unless stopOnError (the rest come back SKIPPED; a
RECOVERY_REQUIRED stops the batch regardless). dryRun runs every pre-check
through the stage's `probe` and answers `wouldWrite` without touching a
file, the log, or git. The host is told once (onWritten), the live layer
is pushed once, and the response carries the rule suggestions the new
decisions support. Over 500 is 413 BATCH_TOO_LARGE for the whole request.
Automatic rules: the eligible list is built from one projection and applied
as ONE batch under ONE lock (the batch answers from the projection already
built), each item re-checked against the locked projection before it
applies (still auto_ready, single automatic rule with the same id and
action, sources healthy, writable — every existing guard, now including a
rewrite's effective layer), a failed apply appended as `blocked` for that
revision exactly as before, bounded at 500 per pass, one push. rewrite_link
rules map to `{ action: "rewrite_link", newTarget, ruleId }`; a destination
that no longer exists blocks with a record, never acknowledges silently.
The one-per-pass `return` is gone.
The projection memo key now names the aggregate `indexing` flag beside the
per-source health (in the service it is derived from those, so no extra
miss; a host whose flag moves on its own gets a fresh coverage answer).
Tests: discrepancy-link-actions.test.mjs (in-process service: rewrite
touches only the effective file, sequential rewrites in one section, missing
/ invalid target changes no bytes, unlink, create_stub with wx / occupied
path / traversal / new subfolder / projection after refresh, the kind gate
messages, recovery of a created stub after a crash), discrepancy-batch
.test.mjs (stubbed git: mixed batch with per-item results and one push,
stopOnError, dryRun, envelope validation, two rewrites in one section,
automatic rules in one pass with one projection build and a blocked record,
the under-lock re-check), service-test.sh (batch route: 405 gate, 413,
dry run, rewrite + unlink + NOT_OPEN per item, the file), discrepancy-live-
layer-test.sh (a batch of two rewrites into the live layer: two commits,
ONE push counted on the bare's reflog).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…ns, and rule wildcards - specs/contextcake-discrepancy-center: criterion 6 gains rewrite_link / unlink / create_stub through the same staged transaction, scoped to the effective contributor and read live; criterion 11 gains `*` on conceptType/key only from multi-(type, key) evidence, target always pinned, the rewrite_link action; new write-side criteria for the batch (one lock, one projection, validate-all-then-apply, per-item transactions, dry run, one push, suggestions), automatic rules as one batch, and the create_stub recovery caveat; the Web Demo criterion covers a batch; out of scope names the effective-section-only scope and wildcard targets. - specs/contextcake-control-plane §5.8: `discrepancy batch` with `--dry-run` and `--stop-on-error`; the engine ships decide/decideBatch/ runAutomaticRules, the CLI adapter is pending. - docs/architecture/notes/discrepancy-projection.md: a "Batch and write actions" section — why per-item transactions, why dryRun rides the real path, why the automatic job is the same batch, why a rewrite touches only the effective contributor and reads live, why create_stub resolves more than its record, why `target` is never wildcarded; the memo key now names the aggregate indexing flag. - CLAUDE.md: the control/discrepancies.mjs, discrepancies.mjs, and discrepancy-rules.mjs rows; gotchas for the batch, the live read, the creating writer, and the wildcard rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…ck hold, and keeps a target group's candidate Review findings on the batch and the write actions, each with a test: - A resolved broken-link row (now carrying its `target`) entered its target group in summarizeDiscrepancies and, having no candidates, flipped the group's `agree` off — the "rewrite the rest → X" default vanished the moment the first link in a group was fixed. The shared bestCandidate is now agreed over the ACTIONABLE rows only; `count` still includes resolved rows. - Phase 1 of the batch checked shape/NOT_OPEN/STALE/DUPLICATE but left every parameter check (kind gate, reason, source, content, rewrite target existence, stub layer/title/type) to the apply phase — so with stopOnError a bad third item could follow two applied ones, contradicting "validated before applying any". Those disk-free checks now live in validateDecisionParams, run in Phase 1 and again at the top of applyDecision. For an automatic item a parameter failure after the guard is recorded `blocked`, exactly as an apply failure is. - create_stub's dry run was hand-rolled and skipped the creating writer's own TEXT_EXT/size checks; it now goes through probeWrite(stage) like every other action. A stub whose id has an empty, dot-prefixed, or node_modules segment — a file the indexer would never read back, leaving the link broken while the decision says resolved — is TARGET_INVALID up front. - The batch held the manifest lock for its whole length; the lock's waiters give up after 15 s and a source add spins the event loop while it waits. The apply loop now has a wall-clock budget (BATCH_TIME_BUDGET_MS, 10 s, injectable): items not reached come back BATCH_TIME_BUDGET — counted as `notAttempted`, not `failed`, alongside SKIPPED — for the caller to resubmit; never before the first attempt, so automatic work always makes progress. `ok` is `failed === 0 && notAttempted === 0`. - The automatic job swallows COVERAGE_INCOMPLETE from the batch (a pass landing while it waited for the lock is a normal condition, as the old loop's silent return treated it) instead of logging a failure every pass. - Recovery of a `created` target compares the file against the staged copy while that copy is still beside it and leaves a file whose bytes differ alone (someone's edit, not our placement); the unconditional unlink is now only the no-staged-copy case. - The single-route error envelope carries the ControlError `code` (additive), so a client can branch on the single routes as it can on batch items. Unexpected (untyped) errors inside a batch item are logged, not just folded into a 200 per-item result. Tests: the re-check guard test now actually reaches the guard (the job plans while the test holds the manifest lock, the rule is disabled, the lock is released); LINK_GONE and SOURCE_NOT_WRITABLE per item; the time budget (applies one, budgets the rest, dry runs exempt, resubmit finishes); each Phase-1 parameter code; create_stub into the live layer (one pathspec commit of exactly the new file); the target-group candidate with a resolved row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…a bracketed title stays a string stageFileCreationTransaction turned an ENOTDIR from mkdir (a `guides.md` where `guides/` has to be) into a raw 500; it is a 409 naming the file in the way. renderStub quotes a title that starts with `[` or a quote so the engine's own frontmatter parser (which reads `[…]` as a list and strips one pair of quotes) hands it back as the string it is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…ldcards to broken links Safety review of the combined engine branch (PR 2 + PR 3), nine items: 1. The live-layer push ran under the manifest lock (commitDecisionWrite for every decision and the legacy conflict route; decideBatch's summary). A slow or offline remote is up to three 90 s network calls — long enough to time out every concurrent control-plane write, spin the event loop for the sync manifest holders (POST /api/sources, PATCH /api/settings), and past the 60 s stale threshold let another process steal the lock. commitDecisionWrite now returns a `git.pushRoot` marker; decide, decideBatch, runAutomaticRules, and the legacy route push once through pushAfterUnlock after the lock is released (as promoteRule always did). Test: a stubbed slow push — a concurrent priority write completes while the push is pending; the marker never reaches a response. 2. git-core.commitPathsWithMutation ran `await mutate()` outside its try, so a mutation that threw halfway skipped the locked rollback and left the compensating restore to run after the lock was gone. The mutation is inside the try now; commitDecisionWrite's catch already skips a second rollback (rolledBackUnderLock). Test: git-core.test.mjs (new suite) — a halfway failure rolls back under the lock, no litter, error propagates. 3. Startup recovery restored live-layer bytes before taking the repo lock and only then committed. The journal's recover() now hands its restore step to a `restore(tx, targets, applyRestore)` hook; the operation restores live-layer targets inside the commitPathsWithMutation mutate that commits the restore, non-live targets on the plain path. Tests: the hook is entered before any byte moves and finalizes only after it returns; with a stub git the bytes are still the crashed ones when the locked mutation begins and restored when it ends. 4. validateRule accepted conceptType/key "*" for section_content and frontmatter_value — pinned by kind and sources alone, one flip to automatic would overwrite every disagreement between two layers. The wildcard is broken-link only now (the exact target is what still pins it); matchesField unchanged. Tests both directions. 5. create_stub on a case-insensitive filesystem: the resolver folds `Guides/Deploy.md` onto an existing `guides/`, so the created concept would not be the link's target and, in a live layer, the live-relative assertion threw a bare Error. relativeWithinRoot (layer-files.mjs) compares the path that would land against the one requested, byte for byte → 409 TARGET_CASE_CONFLICT; the assertion is a typed 409 LIVE_TARGET_MISMATCH that the rollback path preserves. Unit-tested on the helper and end to end (branching on the fixture's own filesystem). 6. decideBatch re-resolved liveLayer() after the loop and dereferenced it unguarded; it is captured once before the loop and guarded. 7. promoteRule truncated-and-wrote the team rules file every projection parses; it writes temp + rename. 8. `blocked` rows rebuilt from the log alone were still decidable; NOT_OPEN now covers every synthetic status (resolved, blocked). Test. 9. apps/site reference/discrepancy-api.md updated to the shipped contract: compact/summary/?id= reads, the three link actions, error codes, the batch envelope (notAttempted, BATCH_TIME_BUDGET, dryRun, one push), rules `*` (broken-link only) and rewrite_link. Docs: CLAUDE.md gotchas, spec criteria 11 and the live-layer criterion, and the projection note (push after the lock; restores inside git-core's). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…screpancy wire types `discrepancy-summary.ts` is the pure half of the Discrepancy Center: `ACTIONABLE_STATUSES` / `isActionable` (the predicate Overview, Sidebar, Header and Conflicts each carried their own copy of), `summarizeConflicts` (a mirror of the engine's summarizeDiscrepancies so the demo bundle and an engine without the compact route draw the same header), `groupConflicts` (kind / concept / source pair / owner, broken links sub-grouped by target, largest group first), `describeItems` (what a selection has in common — the facts every bulk action is gated on) and `buildHaystack`. types.ts learns the compact row (`truncated` / `valueBytes` / `valueKind`, `historyCount` + `latestDecision`, `compact`), broken-link `candidates` / `bestCandidate`, the summary envelope, the `?id=` detail response, the three link actions and the batch request/response. The Conflict view model gains `detailLoaded`, `conceptTitle`/`conceptType`, and the candidate fields. Overview's Discrepancies tile reads `summary.actionable` with a per-kind subtitle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…isions The store now fetches `/api/discrepancies?fields=compact` — every row's identity, status, revision, candidates and ≤240-char previews plus the engine's summary in one envelope — and loads a row's full record (`?id=`, history + untruncated values) when it is selected, through the same bounded LRU shape as `loadConceptDetail`. An engine that ignores the parameter answers full records with no summary; the adapter reads both, and the store computes the summary locally in that case (and for the demo bundle). `conflictSummary` sits in the data context as state, so its identity only moves when a payload lands (`NO_SUMMARY` before the first). `decideDiscrepancies` posts to `/api/discrepancy-decisions/batch`: one in flight, optimistic status per ok result, ONE refetch afterwards; a dry run changes nothing here. A 404 on the batch route (an engine older than it) falls back to sequential single decisions and says so in the answer, so a dry run against such an engine is honestly not a preview. The demo source runs the same loop over its simulation. `LiveDataError` carries the engine's machine `code` when the body has one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Split views/Conflicts.tsx into views/conflicts/: OverviewHeader (tiles — actionable, per kind, quick wins — each one a filter; status tabs with counts from the summary; group-by), GroupedList (a windowed grouped list over the new components/useVirtualWindow — FileTree's technique generalized to variable heights with a prefix sum and a binary search; group rows 44px, item rows 128px; one roving tab stop, Arrow/Home/End, Left/Right close/open a group, Space toggles selection, Enter opens, Shift+click ranges, a group checkbox selects the group; the active row is spliced in even off-window so focus survives the wheel), BulkBar (act on the selection: acknowledge with a reason, rewrite N links to a candidate, remove N links, create the missing concept in a writable layer, use one source for N — every one previewed through `dryRun` first, then applied; failures stay selected), DecisionPanel (moved; a skeleton until the full record is in; the broken-link panel gains the suggested fix, the other candidates, remove-link and create-stub beside the original acknowledge / open-source pair), Evidence (diffs, history, skeleton) and Rules (`*` reads as "any", rewrite actions as "Rewrite → target"). Search matches against one lowercase haystack per row built when the list changes rather than re-stringifying every contribution per keystroke. The list CSS becomes the windowed container (no more breakpoint rules that turned it into a grid of cards). render-hygiene's row selector is a descendant selector now that rows sit inside a positioned inner element; the tab-label matchers in Conflicts.test tolerate the count. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Conflicts.test: tiles apply filters, group counts and default collapse, Shift+click range and group checkboxes, bulk-bar enablement rules, dry-run confirm then apply (with `newTarget` on a rewrite), a partial failure toast that keeps only the failures selected, the broken-link panel's suggested fix / candidates / unlink / create_stub payloads, the skeleton until the full record loads, keyboard travel across a group boundary, and a 2,000-row window that keeps the focused row mounted off-window (Files.test's virtualization case, ported). store.test: the engine summary from the compact envelope, the selected row's full record through `?id=`, the local summary for an engine that ignored `?fields=compact`, and a batch that flips its ok rows once and refetches exactly once (a dry run does neither). api.test: compact vs full adapters, the compact fetch, `?id=` against new and old engines, the batch route, the 404 sequential fallback and its `code`, a real 409 NOT falling back. Overview.test: the tile's actionable count and per-kind subtitle. render-hygiene.live pins the summary's identity across keystrokes. Also: the demo simulation now keeps an acknowledged/composed row in its tab on the refetch that follows a decision (it used to flip back to Needs review a moment later), and the batch receipt reads "N done · M need attention". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
… shape CLAUDE.md gains the architecture bullet (compact fetch + detail-on-select, the windowed listbox with group-header options, the dry-run-then-apply batch and its sequential fallback, where the pure helpers live) and three gotchas: one actionable predicate, compact rows are previews (never compose or diff from one), and the row heights that live in two places. README's Resolve entry describes the tiles, grouping, bulk bar and the suggested fix; the file map lists useVirtualWindow and views/conflicts/. The batch receipt now quotes the first failure's own error, so "3 need attention" says why. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…column tiles under 980px The "Reset filters" button in the empty state keyed on object identity, so the Actionable tile — which set a fresh copy of the defaults — left it showing at defaults; it now compares field by field and the tile passes the default object through. Below 980px the five tiles wrapped into three columns with two empty cells showing the gap color; two columns fill. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
… findings Engine write side (PR 3) landed under this branch; the console now speaks its final shape. Batch answers carry `notAttempted` (per-item `SKIPPED` / `BATCH_TIME_BUDGET` — the engine's ~10 s lock budget ran out): those are partitioned apart from failures (`partitionBatchResults`), the receipt reads "N done · M need attention · K not attempted", and both tails stay selected for resubmission. `wouldWrite[].created` counts as "(K new files)" in the dry-run sentence. A selection past 500 goes over as consecutive batches whose answers merge in input order (the engine 413s past that); the sequential fallback reports its stopOnError tail as SKIPPED too. Decision records' `newTarget` / `createdTargets` show in the history. `originalKind` rides on the row and every broken-link branch asks `isBrokenLink` (a reopened link is still a link — the engine gates the fix actions on originalKind), and the summary mirror agrees a target's shared candidate over actionable rows only, as the engine now does. From the review: a batch always refetches, even when nothing landed (an all-STALE answer means the rows hold revisions the engine no longer has); `decideDiscrepancy` refuses — throws — while another decision is in flight instead of resolving to nothing (a caller used to show a "Done" receipt for a decision that never happened), and the decision panel parks while ANY decision is in flight; a preview is keyed on revisions too, so a refetch between Preview and Apply invalidates it; a loaded detail is carried across a refetch when its compact row is unchanged (the open compose field no longer drops to a skeleton when content moves elsewhere); focus returns to the list's own tab stop; the selection lives inside the tab/filters, not the search (which only hides, and says how many); item rows are user-select: none so a Shift-click ranges rows, not text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…atch results, finish the isBrokenLink migration
A dry run answers for the selection it was asked about: the bulk bar keys
the request on the selection's ids and revisions before the await and drops
an answer that no longer matches, and the list refuses to (de)select rows
while a batch is in flight (`selectionLocked`). A selection past 500 that
has a LATER batch refused keeps what the earlier batches applied — the
merged answer comes back with `error {chunk, message}` and the rest of the
selection reported SKIPPED, so the store still flips what landed and
refetches, and the receipt reads "500 done · … not attempted. Batch 2
failed: <error>"; a batch that reports RECOVERY_REQUIRED stops the ones
after it regardless of stopOnError, and suggestions merge across batches by
id. The receipt subtracts only the successes from the user's selection
(failures, the not-attempted tail and a create-stub's sibling links all
stay), says when a failure could not be identified, offers "Create rule"
only for a suggestion whose action (and, for links, target) is what the
batch did, and a group create-stub says what it sends: one decision, the N
selected links resolve on the next scan.
Every broken-link branch asks `isBrokenLink` now — the Cascade quick-resolve
popover (which offered a guaranteed-409 "use X's answer everywhere" for a
reopened link, and now offers the suggested fix), the panel's reason list,
the kind filter (`effectiveKind`, so the tile that counted a reopened link
as a broken link is the filter that shows it), and `describeItems`, whose
shared fix is agreed over actionable rows only, like the summary. A loaded
detail carried across a refetch keeps only its bodies and history and
takes candidates, rules, health and status from the fresh compact row; an
optimistic flip (single or batch) recomputes the summary locally so the
header and tab counts move at once; detail-load retries are one timer per
row, cleared on unmount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com> # Conflicts: # apps/console/CLAUDE.md # apps/console/src/api.test.ts # apps/console/src/api.ts # apps/console/src/store.tsx # apps/console/src/styles.css # apps/console/src/views/Overview.tsx # packages/core/src/service.mjs
…erministic The apply loop compared elapsed > batchTimeBudgetMs; with the test's zero budget a runner that finished the first item inside the same millisecond saw 0 > 0 as false and applied everything (CI did). "The budget is used up" includes landing exactly on it; at the 10 s default the change is immaterial. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
siracusa5
added a commit
that referenced
this pull request
Aug 19, 2026
Ships the four adoption-first UX PRs merged today: #142 hides every commerce surface behind a build-time flag, #143 shows the cascade as a reorderable position list, #144 adds seven theme families alongside ContextCake, and #146 rebuilds the Discrepancy Center (candidates, batch decisions, and closes F30 — live-layer writes now go through git-core). #142 is site-only and already live via the ordinary site-deploy path; the other three reach the Mac app. Bumps all four version-aligned package.json files (root, desktop, and the two npm CLI packages) plus distribution-artifacts.test.mjs's hardcoded fixture version, per assertVersionAlignment's four-file check. Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Opening the Review tab on a real vault showed ~1,500 discrepancies and nothing to do about them except acknowledge one at a time. This PR makes the Discrepancy Center usable at that scale — engine and console together (three stacked pieces: read side + F30, write side, console).
Engine — read side (
packages/core)service.mjsintocontrol/discrepancies.mjs(createDiscrepancyOperations), the control-plane pattern; routes are shims.GET /api/discrepancies?fields=compact&status=&kind=&…&limit=&offset=(envelope addssummary,total,filtered,projectionRevision),?id=detail,/api/discrepancies/summary(counts by kind / status / source pair / owner / concept type, top targets, top concepts, quick wins). BareGETbyte-compatible.candidates[]+bestCandidatecomputed structurally (relative path, case, extension, slug, folder move, title alias, bounded edit distance) — never model-inferred; purerewriteLinkTarget/removeLink.commitPathsWithMutation,--literal-pathspecs,skipIfClean), commits only the written paths, pushes after the manifest lock is released (queues offline), and recovery restores under the repo lock and commits the restore.Engine — write side
rewrite_link {newTarget},unlink,create_stub {layer, title?, type?}(create-mode staged writes with journal recovery of created targets; case-conflict and traversal refused). Rewrites touch only the effective contributor's section (rationale in the note).POST /api/discrepancy-decisions/batch(≤500): one lock, one projection, all revisions validated before anything is applied, per-item transactions, continue-on-error,dryRun(wouldWrite), 10 s lock budget →notAttemptedfor resubmission, one push per request, per-item results in input order.runAutomaticRulesruns as one batch (was one item per index pass).conceptType/keymay be"*"only for broken links (target is always exact);rewrite_linkrules; generalized suggestions from ≥3 consistent decisions spanning ≥2 (conceptType, key) pairs.Console (
apps/console)useVirtualWindow, roving tabindex, Shift-range, group checkbox) — 1,500 rows no longer mount 1,500 DOM nodes.<target>in a layer, use<source>for N — every bulk action previews viadryRun("N files change across L layers") before Apply; receipt "N done · M need attention · K not attempted", failures stay selected; batch chunking with partial-result reporting.Docs / specs
docs/architecture/notes/discrepancy-projection.md(memo, F30 placement, batch semantics, wildcard rule); discrepancy-center + control-plane specs amended; sitereference/discrepancy-api.mdrewritten to the shipped contract; rootCLAUDE.mdrows + gotchas.Review
Adversarial reviews at each stage (engine read/write safety pass; console pass) → fixes landed: push moved outside the manifest lock, git-core
mutate()inside its rollback try, recovery restore under the repo lock,*pinned to broken links,create_stubcase-conflict 409, batch git summary guard, atomic team-rules write,blockedrows not decidable; console: stale dry-run selection guard + locked rows during a batch, partial chunk results honored,isBrokenLinkmigration completed (quick-resolve, filters, reason options), optimistic flips update the summary, selection preserved on receipt, create-stub reporting, per-id retry timers, batch-scoped rule suggestions.Verification
node --testover discrepancies / projection / link-actions / batch / transactions / conflict-resolutions / layer-files / git-core → 75/75;bash packages/core/tests/service-test.sh→ pass;bash packages/core/tests/discrepancy-live-layer-test.sh(single + batch commits, offline queue, sync, held lock, recovery commit) → pass;git-sync-test.sh,resolve-all-scale-test.sh→ pass. Rootnpm test: onlyslow/graph-latencyred on this machine — fails identically on the base commit under load (environmental).npm run typecheck && npm test && npm run build→ 550 tests pass; live-verified against this branch's engine: tiles/grouping from the compact envelope, detail via?id=, bulk "Rewrite 3 links → …" dry-run → apply rewrote both files, create-stub resolved its links.bash .github/scripts/check-dco.sh origin/main→ OK (21 commits).Known follow-ups (not in this PR): MCP
list_discrepancies/--resolvewrite tools for agents (plan Issue 8); the Files editor's live-root writes still bypass git-core (documented in CLAUDE.md).🤖 Generated with Claude Code