diff --git a/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/QA.md b/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/QA.md new file mode 100644 index 00000000..bac84d02 --- /dev/null +++ b/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/QA.md @@ -0,0 +1,120 @@ +# V24R9 Durable Ordinary Source Apply Receipt QA + +## Scope and boundary + +V24R9 changes only the Legal Coverage observation of a successful ordinary +source merge. It does not change PilotDeck Core, O1, validator acceptance, +issue taxonomy, Progress Lease thresholds `8/2`, Router, Memory, the model, the +evaluation corpus, repair acceptance, matrix handoffs, or authority closure. + +The success criterion is narrow: a successful ordinary source transaction +must leave a state-bound applied receipt which advances semantic progress once, +while apply readiness, tampering, and replay do not advance it. + +## Static and automated verification + +### Syntax and patch hygiene + +Commands: + +```sh +node --check products/legal/plugins/legal-coverage/hook.mjs +node --check products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs +node --check products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs +node --check .omo/evidence/20260727-v24r9-durable-source-apply-receipt/replay-preserved-case09.mjs +git diff --check +``` + +Observed: all commands exited `0`; `git diff --check` produced no diagnostics. + +Why enough: these checks cover JavaScript parse validity and whitespace +errors. They do not substitute for behavior tests. + +### Focused Legal Coverage and real Gateway suite + +Command: + +```sh +node --test --test-force-exit --test-timeout 60000 \ + dist/tests/products/legal-coverage.spec.js \ + dist/tests/agent/legal-coverage-plugin-runtime.spec.js +``` + +Observed: `45/45` passed. Artifact: `focused-legal-gateway.log`. + +The product regression reproduces the ordinary source transaction following a +successful immutable repair. It proves that apply readiness does not increment +progress; successful CLI apply writes the deterministic receipt; a tampered +receipt is ignored; an unknown source status is rejected even when the receipt +claims that new current state hash; the restored valid receipt increments +exactly once; and its replay does not increment. The four Gateway cases +continue to pass with Router and Memory disabled and O1 enabled in the +source-repair fixture. + +Why enough: this covers the changed hook, CLI, receipt writer, receipt verifier, +immutable repair compatibility, real hook subscription, Gateway tool calls, +Progress Lease projection, and O1 integrity. + +### Complete repository suite + +Command: + +```sh +pnpm test +``` + +Observed: `308/308` passed, with no failures, cancellations, skips, or todos. +Artifact: `full-suite.log`. + +Why enough: the clean complete run covers all repository behavior on the +candidate, including Core Progress Lease, compaction, Gateway, Legal Coverage, +and O1. No unrelated component was changed. + +## Preserved Case 09 failure replay + +The reviewer-readable replay driver is `replay-preserved-case09.mjs`. It copies +the preserved V24R8 Case 09 failure into a disposable directory, installs the +V24R9 Legal Coverage plugin, reconstructs the canonical state immediately +before the ordinary source apply, and verifies that its hash equals the +immutable proposal's expected state before executing anything. + +It then drives the actual Legal Coverage hook and CLI. Observed: + +```text +proposal: source-merge-5dadf825216f.json +receipt: source-merge-applied-5dadf825216f.json +state: 74abe7... -> 4d3a3c... +applied: 4 sources, 23 facts +canonical total: 24 sources, 8 reviewed, 16 pending, 45 facts +progressOrdinal: 1 -> 2 -> 2 +next work group: source-fragment-merge +``` + +Artifact: `case09-replay-result.json`. + +Why enough: these are the exact proposal, source/fact counts, and state hashes +from the V24R8 product failure. The replay proves that the missing durable +identity, rather than error counts or opaque hash churn, now renews progress +once after the real mutation and remains deduplicated on the next hook call. + +## Counterexample coverage + +Automated tests cover apply-ready non-progress, deterministic receipt creation, +receipt tampering, exact source-ID binding, exact reviewed-status binding, +current state binding, immutable proposal hash binding, one-time progress, and +replay. Existing tests continue to cover stale state, changed proposals, +traversal, symlink ancestors, atomic source/fact mutation, immutable repair +receipt validation, and repair replay. + +## Omitted and residual risk + +No API key, provider URL, authorization header, environment dump, private +source content, report text, prompt text, or raw reasoning is included in this +evidence directory. The replay result contains only hashes, counts, stable +artifact basenames, and ordinal transitions. + +The complete production-model Case 09 has not yet run on V24R9. Therefore this +change is code-, protocol-, Gateway-, and exact-failure-replay verified, but it +is not yet a product Gate pass. V25 and the 85-case campaign remain blocked +until a new immutable campaign passes Gate 0, paired smoke, Case 05, and the +complete Case 09 product Gate. diff --git a/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/case09-replay-result.json b/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/case09-replay-result.json new file mode 100644 index 00000000..037a8beb --- /dev/null +++ b/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/case09-replay-result.json @@ -0,0 +1,20 @@ +{ + "passed": true, + "fixture": "preserved-v24r8-case09-failure", + "proposal": "source-merge-5dadf825216f.json", + "receipt": "source-merge-applied-5dadf825216f.json", + "sourceCount": 4, + "factCountApplied": 23, + "totalSources": 24, + "reviewedSources": 8, + "pendingSources": 16, + "totalFacts": 45, + "stateHashBefore": "74abe7a06581a13c456ed97f7124a6157733ba4e0f76ad41737e62a7bf3a49ca", + "stateHashAfter": "4d3a3cc1504b6372b90b8e8472b9f0189b431c9b6e05030d7cef896448d0a059", + "progressOrdinal": [ + 1, + 2, + 2 + ], + "nextGroup": "source-fragment-merge" +} diff --git a/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/focused-legal-gateway.log b/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/focused-legal-gateway.log new file mode 100644 index 00000000..af1f3b9e --- /dev/null +++ b/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/focused-legal-gateway.log @@ -0,0 +1,55 @@ +(node:86784) ExperimentalWarning: SQLite is an experimental feature and might change at any time +(Use `node --trace-warnings ...` to show where the warning was created) +✔ real gateway drives legal plugin milestones through bounded artifact correction (719.626292ms) +✔ real gateway blocks completion when the legal Stop hook cannot read session state (463.595ms) +✔ real gateway executes the state-bound legal authority closure with complete O1 evidence (530.795125ms) +✔ real gateway applies an immutable source repair before renewing legal progress (703.900459ms) +✔ legal coverage validator creates a current proof and removes it when the deliverable changes (174.566958ms) +✔ legal coverage initializer creates a text skeleton before source review and remains idempotent (199.284375ms) +✔ legal coverage initializer binds trusted manifests to original inputs (260.093584ms) +✔ legal coverage source bootstrap creates only deterministic pending manifest rows (293.388542ms) +✔ legal coverage injects deterministic disjoint worker batches for large pending source rooms (3605.968209ms) +✔ legal coverage CLI exposes bundled guidance through stable named references (122.752209ms) +✔ legal coverage initializer creates only explicit text formats and preserves existing content (50.801708ms) +✔ legal coverage initializer rejects traversal and symlink ancestors without external writes (132.611792ms) +✔ legal coverage validator binds runner originals and derivations into its proof (199.513167ms) +✔ legal coverage validator rejects missing lineage and mutable or derived input roots (251.643ms) +✔ legal coverage next-batch exposes one bounded deterministic repair slice (195.337583ms) +✔ legal coverage apply-batch atomically updates only the current bounded slice (288.167083ms) +✔ legal coverage apply-batch rejects record and byte limit violations before writing (183.584584ms) +✔ legal coverage validator rejects orphaned conflicts and incomplete final disclosure (94.980541ms) +✔ legal coverage validator detects un-inventoried sources and cross-fact timeline collisions (99.837208ms) +✔ legal coverage validator rejects reused generic quotes and unsupported fact coverage (94.372ms) +✔ legal coverage validator requires authority links for critical issues and legal-authority matrices (95.0945ms) +✔ legal coverage validator rejects non-object canonical JSON documents (704.504ms) +✔ legal coverage validator binds reviewed source bytes to the ledger and state hash (192.89475ms) +✔ legal coverage validator rejects ancestor symlinks and a symlinked proof (148.394417ms) +✔ legal coverage validator does not read or write through a symlinked state ancestor (119.518542ms) +✔ legal coverage validator requires unresolved disclosure for unverified material facts (95.839791ms) +✔ legal coverage validator rejects unique but irrelevant issue and authority quotes (95.227792ms) +✔ legal coverage validator enforces reciprocal and same-entry ledger relationships (95.058583ms) +✔ legal coverage validator accepts null for an optional threshold assessment (95.436084ms) +✔ legal coverage validator uses locators without quotes for binary deliverables (95.470708ms) +✔ legal coverage validator rejects empty-fact shortcuts and material facts outside matrices (205.9585ms) +✔ legal coverage hook activates only legal work and injects one observable milestone (509.73875ms) +✔ legal coverage hook groups repeated validator errors into one bounded milestone (504.349042ms) +✔ legal coverage pending-matrix selection is bounded across pages and invalid revisions cannot manufacture progress (930.852833ms) +✔ legal coverage progress ordinal advances once for a new legal phase (196.203583ms) +✔ legal coverage permits not-applicable only after exhaustive selection and rejects stale or changed matrix proposals (446.375125ms) +✔ legal coverage rejects oversized matrix evidence before making a selection receipt immutable (154.703ms) +✔ legal coverage fails closed when one matrix index item exceeds the bounded page (148.076041ms) +✔ legal coverage injects a bounded deterministic matrix relation-closure batch (200.088667ms) +✔ legal coverage authority closure uses one bounded state-bound transaction (557.200291ms) +✔ legal coverage rejects changed and symlinked authority closure proposals without ledger mutation (243.581958ms) +✔ legal coverage milestone digest ignores opaque incomplete-state hash churn (0.222709ms) +✔ legal coverage hook activates a malformed configured workspace so the agent can repair it (95.369709ms) +✔ legal coverage hook rejects a symlinked session-state ancestor without writing outside the workspace (47.996458ms) +✔ legal product plugin loads one skill and contains no benchmark-specific controls (2.958375ms) +ℹ tests 45 +ℹ suites 0 +ℹ pass 45 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 12283.896625 diff --git a/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/full-suite.log b/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/full-suite.log new file mode 100644 index 00000000..48629d59 --- /dev/null +++ b/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/full-suite.log @@ -0,0 +1,357 @@ + +> pilotdeck@0.1.0 test /Users/da/Documents/PilotDeck-worktrees/20260727-fix-convergence-v24r9-durable-source-apply-receipt-v1 +> npm run build && node --test --test-force-exit --test-timeout 60000 "dist/tests/**/*.test.js" "dist/tests/**/*.spec.js" + + +> pilotdeck@0.1.0 prebuild +> npm run check:runtime && node scripts/bootstrap-pilotdeck-config.mjs && cd src/context/memory/edgeclaw-memory-core && npm run build + + +> pilotdeck@0.1.0 check:runtime +> node scripts/check-node-runtime.mjs + + +> edgeclaw-memory-core@0.0.0 build +> node -e "require('fs').rmSync('lib',{recursive:true,force:true})" && tsc -p tsconfig.json + + +> pilotdeck@0.1.0 build +> node -e "require('fs').rmSync('dist',{recursive:true,force:true})" && tsc -p tsconfig.json && node -e "require('fs').cpSync('src/extension/plugins/builtin','dist/src/extension/plugins/builtin',{recursive:true})" + +✔ Feishu handles permission replies before the active chat drain finishes (1.299875ms) +✔ ImPermissionHelper resolves all pending permission requests for a chat (1.137ms) +✔ ImPermissionHelper keeps pending requests when the reply is invalid (0.059792ms) +(node:87317) ExperimentalWarning: SQLite is an experimental feature and might change at any time +(Use `node --trace-warnings ...` to show where the warning was created) +✔ PreModelRequest mutations survive a post-routing request rebuild and remain model-only (12.198375ms) +✔ AgentLoop exposes explicit subagent identity to lifecycle hooks (4.385875ms) +✔ evaluation progress lease stops before a third unchanged model request when full compaction is rejected (2.078125ms) +✔ AgentLoop delivers newly surfaced repair feedback once after an applied boundary (4.495541ms) +✔ AgentLoop permits one prepared-target request and then requires genuine progress (3.874167ms) +✔ AgentLoop carries a bounded handoff through the required boundary to genuine progress (8.438166ms) +✔ artifact failure injects one bounded correction turn and succeeds after validation (32.844083ms) +✔ required artifact failure wins over max-turn completion after a final tool call (8.058958ms) +✔ AgentSession restores terminal state and dispatches SessionEnd when the runner throws (1.471625ms) +✔ AgentSession cleanup runs when a consumer stops reading early (0.11275ms) +(node:87319) ExperimentalWarning: SQLite is an experimental feature and might change at any time +(Use `node --trace-warnings ...` to show where the warning was created) +✔ real gateway drives legal plugin milestones through bounded artifact correction (1422.431959ms) +✔ real gateway blocks completion when the legal Stop hook cannot read session state (805.108041ms) +✔ real gateway executes the state-bound legal authority closure with complete O1 evidence (756.399541ms) +✔ real gateway applies an immutable source repair before renewing legal progress (805.633458ms) +(node:87320) ExperimentalWarning: SQLite is an experimental feature and might change at any time +(Use `node --trace-warnings ...` to show where the warning was created) +✔ local gateway applies project hook context and enforces artifact correction (1007.588084ms) +✔ ordinary edit failures after a successful write do not enter large-file repair (1.517708ms) +✔ an explicit post-draft large-file failure starts bounded recovery (0.343084ms) +✔ successful focused write clears an active large-file repair episode (0.261833ms) +✔ permission failures are never reclassified as large-file recovery (0.059459ms) +✔ progress lease stays inert unless explicitly enabled (0.649166ms) +✔ opaque hash churn is stagnant while a smaller remaining count renews the lease (0.133167ms) +✔ only a strictly increasing domain progress ordinal renews the lease (0.056667ms) +✔ a lower remaining count cannot roll the stored progress ordinal backward (0.080167ms) +✔ two stagnant observations require a boundary and allow exactly one post-boundary turn (0.052541ms) +✔ new repair feedback after a boundary gets one delivery turn without renewing progress (0.143042ms) +✔ genuine progress after repair feedback renews the lease (0.043167ms) +✔ a newly prepared repair target gets one non-progress request after feedback (0.050875ms) +✔ genuine progress immediately after repair preparation renews the lease (0.16925ms) +✔ preparation observed before feedback cannot be replayed after the boundary (0.499584ms) +✔ a second repair revision cannot replace missing progress after feedback (0.100416ms) +✔ repair feedback already delivered before a boundary cannot be replayed as grace (0.039375ms) +✔ a new operational handoff gets one bounded request without renewing progress (0.055625ms) +✔ a post-boundary handoff is bounded and its replay still fails closed (0.031584ms) +✔ simultaneous repair and handoff revisions cannot be redeemed on separate turns (0.036584ms) +✔ a lower handoff ordinal cannot be replayed as grace (0.023333ms) +✔ handoff allowance has a hard per-progress-epoch limit and resets only on progress (0.031458ms) +✔ handoff cannot bypass an unavailable required boundary (0.024417ms) +✔ sanitized Case 09 matrix handoff trajectory reaches the next semantic checkpoint (0.791833ms) +✔ cold-start allowance expires after the first explicit domain progress (0.042042ms) +✔ evaluation mode fails closed when the required boundary is rejected or unavailable (0.033667ms) +✔ a completed report releases the tracked scope (0.029791ms) +✔ convergence metadata parser rejects malformed and oversized reports (0.087375ms) +✔ explore subagent does not probe tool safety before execution (27.772042ms) +✔ explore registry ignores an unallowed dynamic execute_code tool without probing it (0.660583ms) +✔ read-only subagent evaluates bash safety from the real command (5.643208ms) +✔ read-only execute_code checks the real code instead of crashing on registry setup (0.533833ms) +✔ subagent budget defaults to ten minutes without a parent deadline (1.823125ms) +✔ subagent budget preserves the parent handoff window (0.226042ms) +✔ subagent budget directive exposes the effective bound and diminishing-return rule (0.172834ms) +✔ composed subagent timeout aborts with a typed reason (8.989625ms) +✔ subagent operation returns control even when the callee ignores abort (6.396833ms) +✔ parent abort wins without being misclassified as a child timeout (0.395ms) +✔ filterIncompleteToolCalls tolerates malformed messages without content (1.824333ms) +✔ fork timeout closes child lifecycle and lets the parent finish (35.221625ms) +✔ turn environment provides an isolated PilotDeck-owned work directory (0.599875ms) +✔ turn environment inherits the process environment when no override is configured (0.145667ms) +✔ validates a required artifact and keeps contracts isolated by session (14.792083ms) +✔ missing or wrong-format artifacts fail with reviewer-readable issues (1.333958ms) +✔ empty required artifacts fail validation (4.418792ms) +✔ rejects traversal and symlink escapes before a domain validator runs (8.083667ms) +✔ legal-specific validator data stays in the plugin validator (1.251875ms) +✔ validator exceptions become structured failures instead of escaping the runtime (2.98275ms) +✔ duplicate validator ids cannot override an existing validator (0.446791ms) +✔ contract registration is atomic when one contract is invalid (0.107375ms) +✔ extension watcher ignores generated Python and Office runtime files (1.012167ms) +✔ config bootstrap does not copy bundled skills into user storage (88.772625ms) +✔ Office attachments are reported unsupported before size checks (7.401333ms) +✔ auto compaction reports summary success separately from rejected oversized output (1.952792ms) +✔ auto compaction marks a budget-clearing summary as applied (0.191667ms) +✔ progress policy can force a full boundary while the token budget is still healthy (0.195459ms) +✔ full compaction distinguishes a protected prefix with no summarizable messages from model failure (470.513708ms) +✔ auto compaction propagates no-summarizable as a stable rejection reason without claiming a summary attempt (0.282542ms) +✔ bounded protected-prefix retention summarizes old agent turns and preserves every kept tool pair (2.646959ms) +✔ full compaction summarizes a real-shaped single-prompt Agent trajectory (1.638167ms) +✔ full compaction aligns a message-count tail boundary to complete tool turns (0.660083ms) +✔ sanitized Case 09 replay retains enough evidence to detect rejected blocking compactions (12.152125ms) +✔ lifecycle context is injected as a transient model-only message (9.691ms) +✔ lifecycle without a dynamic store preserves legacy hook messages (3.89075ms) +✔ SessionEnd clears session-scoped runtime state even when a hook throws (1.271041ms) +✔ merges pending context by priority and consumes it once (1.147458ms) +✔ re-registering the same source and id replaces stale content without changing order (0.111667ms) +✔ expired context is pruned without affecting another session (0.055583ms) +✔ blank context is ignored (0.038875ms) +✔ clearing a session does not disturb another session with a shared prefix (0.085625ms) +✔ context budgets retain higher-priority entries and cap merged prompt size (0.467584ms) +✔ source and id tuples cannot collide through delimiter characters (0.073875ms) +✔ default prompt does not require Markdown links for generated files (2.41825ms) +✔ default prompt does not advertise web search when the tool is unavailable (0.143ms) +✔ available skills include the resolved SKILL.md path and bounded lookup guidance (1.641625ms) +✔ budget snapshot uses conservative budget tokens for ratio while preserving real tokens (0.525166ms) +✔ tool text under token budget remains inline even when over legacy byte threshold (5566.915291ms) +✔ tool text over token budget is persisted with expanded grep-first preview (8.845208ms) +✔ large tool error references preserve error semantics for model replay (2.221333ms) +✔ multibyte truncated tool result references advertise read_file access (1.492708ms) +✔ parses declarative artifact contracts without domain-specific knowledge (1.132542ms) +(node:87381) ExperimentalWarning: SQLite is an experimental feature and might change at any time +(Use `node --trace-warnings ...` to show where the warning was created) +✔ an active domain plugin contributes only generic runtime contracts (1.339208ms) +✔ internal prompts do not reactivate domain detection (0.084291ms) +✔ parses only the supported model request patch fields (1.058709ms) +✔ parses bounded dynamic context controls for hook-driven injection (0.110208ms) +✔ standalone skills expose only their slug without a parent-directory namespace (10.656709ms) +✔ a plugin skill directory used as the configured base never derives a parent namespace (0.281167ms) +✔ standalone skill precedence is project > user > builtin without legacy aliases (21.787209ms) +✔ legacy bundled migration backs up only byte-identical bootstrap copies (29.990917ms) +✔ legacy bundled migration leaves user skills untouched when the release bundle is missing (4.5855ms) +✔ SkillManager lists built-ins separately and describes override relationships (44.210333ms) +✔ SkillManager permits reading but rejects mutations of built-in skills (15.021458ms) +✔ registered plain-text attachments with non-whitelisted names are described as read_file inspectable (8.4635ms) +✔ registered Office attachments are still described as not directly inspectable (2.538166ms) +✔ startPilotDeckServer listens before a background channel finishes starting (33.156334ms) +(node:87391) ExperimentalWarning: SQLite is an experimental feature and might change at any time +(Use `node --trace-warnings ...` to show where the warning was created) +✔ browser-use args do not inherit generic proxy env by default (2.408625ms) +✔ browser-use args use explicit browser proxy server (0.344583ms) +✔ browser-use args only inherit generic proxy env when opted in (0.1305ms) +✔ browser-use args use config proxy when browser env proxy is absent (0.110542ms) +✔ browser-use args allow explicit direct mode to disable config proxy (0.064417ms) +✔ internal prompt dispatch waits for the user turn and fully drains the synthetic turn (3.133708ms) +✔ mapAgentEvent propagates runId to streaming lifecycle boundaries (1.098333ms) +✔ mapAgentEvent projects every convergence ordinal (0.525792ms) +✔ defer returns immediately while a user turn is busy (1.030167ms) +✔ enqueue waits for idle and dispatches exactly once (1.630375ms) +✔ same semantic key coalesces in-flight and dedupes after completion (0.154667ms) +✔ an idle-settle race retries when the gateway reports busy (0.264625ms) +✔ abort while queued does not run an internal turn (0.401458ms) +✔ turn timeouts are reported distinctly from generic execution failures (0.144667ms) +✔ a zero recent-dedupe window disables completed-result deduplication (0.341458ms) +✔ dispose prevents a queued prompt from starting after shutdown (0.245583ms) +✔ the public WebSocket boundary strips trusted in-process turn controls (0.906167ms) +✔ waitForIdle resolves only after the matching turn slot is released (2.530625ms) +✔ waitForIdle honors AbortSignal without releasing the turn (0.57875ms) +✔ mapAgentEvent bounds live tool result previews at the gateway (2.748958ms) +✔ mapAgentEvent bounds large strings inside successful tool data (0.311208ms) +✔ mapAgentEvent bounds subagent tool result content and preview (0.139208ms) +✔ Gateway propagates its absolute turn deadline into the Agent session (3.407959ms) +✔ WeixinChannel.start returns while QR login is waiting (14.643042ms) +✔ channel runtime status reporter persists the latest channel state (2.780458ms) +✔ UI weixin QR route only reads runtime status (1.054417ms) +✔ UI weixin QR begin route delegates to gateway prepare RPC (0.172667ms) +✔ Gateway settings keeps existing status rendered during silent refresh (0.41325ms) +✔ Gateway settings starts weixin QR by begin route and ignores stale runtime errors (0.317166ms) +✔ Gateway protocol exposes prepare_weixin_login RPC (0.436583ms) +✔ applies context, system messages, and restricted model request patches (1.170916ms) +✔ blocks before any request mutation is used (0.054334ms) +✔ McpClient keeps stdio clients idle before connection (1.028959ms) +✔ McpClient constructs streamable_http transport without requiring stdio fields (0.07475ms) +✔ McpClient routes streamable_http fetches with bounded timeouts (0.768709ms) +✔ expandMcpString expands ${env:*} placeholders (0.625042ms) +✔ expandMcpString expands ${userHome} placeholder (0.092375ms) +✔ expandMcpString expands ~ prefix (0.040958ms) +✔ expandMcpString leaves unknown env vars as empty string (0.033042ms) +✔ expandMcpString handles combined placeholders (0.041125ms) +✔ expandMcpConfig recursively expands objects and arrays (0.527167ms) +✔ parsePluginMcpServers expands ${env:*} in stdio env (0.139875ms) +✔ parsePluginMcpServers expands ${env:*} in stdio command before transport detection (0.0415ms) +✔ parsePluginMcpServers drops empty expanded stdio command (0.186917ms) +✔ parsePluginMcpServers expands ${userHome} in stdio cwd (0.23275ms) +✔ parsePluginMcpServers expands ~ in stdio args (backward compat) (0.108125ms) +✔ parsePluginMcpServers expands ${env:*} in streamable_http url (0.042208ms) +✔ parsePluginMcpServers expands ${env:*} in streamable_http headers (0.036083ms) +✔ user config and plugin config resolve same placeholders consistently (0.036ms) +✔ Anthropic-compatible terminated SSE errors remain retryable (1.817542ms) +✔ catalog provider resolves api key from default env var when apiKey is omitted (1.608084ms) +✔ catalog provider resolves api key from default env var when apiKey is blank (0.243542ms) +✔ normalizeModelError classifies common network failures (2.568542ms) +✔ model request failure preserves provider raw message before action guidance (3.393208ms) +✔ model_not_found guidance points users to local model settings (0.904709ms) +✔ stream idle timeout is classified as timeout with network and timeoutMs guidance (0.289042ms) +✔ provider terminated stream is classified as a retryable connection reset (0.199667ms) +✔ billing and rate limit guidance distinguish provider-side fixes (0.089541ms) +✔ unknown provider errors still give actionable settings and provider checks (0.046458ms) +✔ OpenAI Responses usage reads cached tokens from input token details (1.168959ms) +✔ Gemini usage counts thoughts tokens as output consumption (0.173334ms) +✔ Ollama provider uses catalog defaults and does not require apiKey (1.454416ms) +✔ Ollama provider builds OpenAI-compatible chat completions body (1.601792ms) +✔ model request builders tolerate malformed messages with missing content (1.91625ms) +✔ cloneMessages normalizes malformed messages with missing content (0.839042ms) +✔ streamModel retries Anthropic-compatible terminated SSE errors (5.808458ms) +✔ networkFetch retries retryable status responses and then succeeds (2.522ms) +✔ networkFetch uses retry-after when calculating retry delay (0.064292ms) +✔ networkFetch caps retry-after delays with maxDelayMs (0.032541ms) +✔ networkFetch normalizes DNS and reset errors (0.084375ms) +✔ networkFetch times out requests (2.129584ms) +✔ networkFetch honors init.signal abort reasons without options.signal (0.304959ms) +✔ networkFetch preserves parent NetworkFetchError reasons passed through options.signal (0.201333ms) +(node:87424) ExperimentalWarning: SQLite is an experimental feature and might change at any time +(Use `node --trace-warnings ...` to show where the warning was created) +[session-title] generation skipped (invalid_json): Unexpected token 'Q', "QA" is not valid JSON +✔ local gateway writes a complete shadow bundle linked to the final request (662.044584ms) +[session-title] generation skipped (invalid_json): Unexpected token 'Q', "QA" is not valid JSON +[session-title] generation skipped (invalid_json): Unexpected token 'Q', "QA" is not valid JSON +✔ enabling O1 does not change Agent-visible model input (409.650208ms) +(node:87426) ExperimentalWarning: SQLite is an experimental feature and might change at any time +(Use `node --trace-warnings ...` to show where the warning was created) +[session-title] generation skipped (invalid_json): Unexpected token 'B', "Bounded co"... is not valid JSON +✔ local Gateway records a bounded handoff through boundary to progress (1121.602375ms) +(node:87427) ExperimentalWarning: SQLite is an experimental feature and might change at any time +(Use `node --trace-warnings ...` to show where the warning was created) +✔ local Gateway records a complete O1 trajectory when a bounded child times out (1551.916792ms) +✔ subagent tool detection and result pair at the model projection layer (8.234166ms) +✔ main tool detection and result produce one exact O1 pair (0.613208ms) +✔ repair preparation grace is observable without becoming progress (0.07475ms) +✔ recorder finalizes a complete hash-only trajectory (12.651875ms) +✔ queue overflow is explicit and makes integrity partial (6.531ms) +✔ verifier rejects reused model request identity and duplicate terminals (2.814875ms) +✔ verifier rejects duplicate identity and secret-bearing keys (0.25175ms) +[PilotDeck] transientRetry: rate_limit (attempt 1/1, delay=1ms) +✔ router observation closes every fallback and retry attempt (18.207042ms) +✔ router request identity separates sessions sharing a turn id (4.62925ms) +✔ router request identity advances across sequential calls in one turn (4.8235ms) +✔ observability is disabled by default and preserves the O1 diagnostic profile (33.827834ms) +✔ O1 rejects unsupported profiles and unsafe labels (11.617459ms) +✔ web search can be explicitly disabled without discarding provider config (0.983792ms) +✔ web search enabled remains optional for backwards compatibility (0.050541ms) +✔ web search enabled must be a boolean (0.047459ms) +✔ progress lease is disabled by default and opt-in evaluation config is preserved (19.691458ms) +✔ progress lease preserves an explicit cold-start allowance (5.029041ms) +✔ progress lease rejects non-evaluation modes (3.945792ms) +✔ progress lease rejects a cold-start allowance below the steady-state limit (3.879833ms) +✔ legal coverage validator creates a current proof and removes it when the deliverable changes (294.256167ms) +✔ legal coverage initializer creates a text skeleton before source review and remains idempotent (352.140833ms) +✔ legal coverage initializer binds trusted manifests to original inputs (422.06875ms) +✔ legal coverage source bootstrap creates only deterministic pending manifest rows (403.121875ms) +✔ legal coverage injects deterministic disjoint worker batches for large pending source rooms (3877.349083ms) +✔ legal coverage CLI exposes bundled guidance through stable named references (132.604333ms) +✔ legal coverage initializer creates only explicit text formats and preserves existing content (54.072208ms) +✔ legal coverage initializer rejects traversal and symlink ancestors without external writes (143.721917ms) +✔ legal coverage validator binds runner originals and derivations into its proof (209.867958ms) +✔ legal coverage validator rejects missing lineage and mutable or derived input roots (265.315875ms) +✔ legal coverage next-batch exposes one bounded deterministic repair slice (207.869708ms) +✔ legal coverage apply-batch atomically updates only the current bounded slice (306.986667ms) +✔ legal coverage apply-batch rejects record and byte limit violations before writing (197.085833ms) +✔ legal coverage validator rejects orphaned conflicts and incomplete final disclosure (105.339542ms) +✔ legal coverage validator detects un-inventoried sources and cross-fact timeline collisions (107.053417ms) +✔ legal coverage validator rejects reused generic quotes and unsupported fact coverage (102.235375ms) +✔ legal coverage validator requires authority links for critical issues and legal-authority matrices (102.718709ms) +✔ legal coverage validator rejects non-object canonical JSON documents (707.69225ms) +✔ legal coverage validator binds reviewed source bytes to the ledger and state hash (204.643625ms) +✔ legal coverage validator rejects ancestor symlinks and a symlinked proof (152.219916ms) +✔ legal coverage validator does not read or write through a symlinked state ancestor (96.916ms) +✔ legal coverage validator requires unresolved disclosure for unverified material facts (101.911792ms) +✔ legal coverage validator rejects unique but irrelevant issue and authority quotes (103.057167ms) +✔ legal coverage validator enforces reciprocal and same-entry ledger relationships (102.460333ms) +✔ legal coverage validator accepts null for an optional threshold assessment (102.61125ms) +✔ legal coverage validator uses locators without quotes for binary deliverables (103.364209ms) +✔ legal coverage validator rejects empty-fact shortcuts and material facts outside matrices (206.221208ms) +✔ legal coverage hook activates only legal work and injects one observable milestone (552.019333ms) +✔ legal coverage hook groups repeated validator errors into one bounded milestone (540.027542ms) +✔ legal coverage pending-matrix selection is bounded across pages and invalid revisions cannot manufacture progress (1016.662083ms) +✔ legal coverage progress ordinal advances once for a new legal phase (209.293417ms) +✔ legal coverage permits not-applicable only after exhaustive selection and rejects stale or changed matrix proposals (505.988208ms) +✔ legal coverage rejects oversized matrix evidence before making a selection receipt immutable (158.427875ms) +✔ legal coverage fails closed when one matrix index item exceeds the bounded page (157.830667ms) +✔ legal coverage injects a bounded deterministic matrix relation-closure batch (213.654166ms) +✔ legal coverage authority closure uses one bounded state-bound transaction (590.828917ms) +✔ legal coverage rejects changed and symlinked authority closure proposals without ledger mutation (250.715833ms) +✔ legal coverage milestone digest ignores opaque incomplete-state hash churn (0.244208ms) +✔ legal coverage hook activates a malformed configured workspace so the agent can repair it (100.768209ms) +✔ legal coverage hook rejects a symlinked session-state ancestor without writing outside the workspace (50.464333ms) +✔ legal product plugin loads one skill and contains no benchmark-specific controls (3.339ms) +✔ file artifacts include every meaningful workspace change without an extension allowlist (36.820542ms) +✔ file artifact fingerprints are reused for unchanged files across scans and turns (5.043416ms) +✔ TurnRunner emits and persists file artifacts before completing the turn (23.313917ms) +✔ TurnRunner does not collect generated files when artifacts are disabled (2.014041ms) +✔ bash success result is formatted with assertions, stdout, and stderr (3.71925ms) +✔ bash allows hyphenated commands whose names merely start with next (1.886917ms) +✔ bash still rejects exact framework CLI tokens that start long-lived processes (1.412208ms) +✔ bash failure tool result includes raw stdout and stderr tail for UI and model (16.117167ms) +✔ bash failure diagnostic extracts Python traceback location and exception (3.392334ms) +✔ bash success keeps full output for ToolResultBudget persistence (5.090375ms) +✔ agent tool accepts explorer as an alias for explore (1.25825ms) +✔ agent tool defaults general-purpose to explore in ask mode (0.081958ms) +✔ agent tool injects and forwards the parent-bounded timeout (0.154833ms) +✔ agent tool rejects a launch that would consume the parent handoff window (0.230083ms) +✔ agent fallback path enforces the same timeout (6.458209ms) +✔ agent tool preserves unknown custom fallback subagent names (0.584333ms) +✔ execute_code read-only probe handles missing input (0.538667ms) +✔ disabling web search removes it from the registry but keeps web fetch (1.128458ms) +✔ execute_code rejects nested web search calls when web search is disabled (0.123791ms) +✔ read_skill returns the resolved SKILL.md path with the skill body (0.573792ms) +✔ read_skill preserves legacy content-only loading when metadata is unavailable (0.063916ms) +✔ web_search retries transient provider failures (621.539416ms) +✔ web_search turns request timeout into tool_timeout (1.281ms) +✔ web_search turns network timeout errors into tool_timeout (1.868834ms) +✔ write_file freshness error tells the model to read the file first (0.9575ms) +✔ write_file missing content points at the missing field and chunked recovery (0.250542ms) +✔ invalid tool input keeps a bounded original error block (0.206833ms) +✔ edit_file old_string miss tells the model to reread and copy exact text (0.062625ms) +✔ read_file invalid range preserves the concrete issue (0.110375ms) +✔ bash missing command reports the required command parameter (0.098208ms) +✔ bash timeout over max keeps foreground and task_wait guidance (0.141ms) +✔ bash background rejection keeps background-specific guidance (0.081167ms) +✔ write_file can create a new file without a prior read_file call (7.894792ms) +✔ edit_file can create a new file with empty old_string without a prior read_file call (2.458458ms) +✔ read_file auto-pages large text files instead of failing (2105.65075ms) +✔ read_file rejects Office container files during validation (0.936125ms) +✔ read_file explicit limit reads a large file range without auto paging (3.963792ms) +✔ read_file auto-shrinks oversized persisted tool-result ref ranges (22605.420792ms) +✔ read_file keeps explicit oversized ordinary file ranges strict (2.973167ms) +✔ read_file explicit limit records a ranged snapshot for follow-up edits (3.27ms) +✔ read_file auto-paged large files record a ranged snapshot for follow-up edits (1597.957209ms) +✔ read_file returns a head-tail preview for a single oversized line (3.321041ms) +✔ read_file classifies common Office formats as binary (0.200709ms) +✔ read_file rejects ranged binary input instead of hanging (1.601083ms) +✔ read_file range honors an aborted signal (1.198625ms) +✔ glob result text tells the model when more files are available (31.955209ms) +✔ grep result text includes next offset when paginated (18.794209ms) +✔ todo_write returns the actual todo list in model-visible content (0.680417ms) +✔ task_list returns model-visible status and next action hints (0.727334ms) +✔ applyResultSizeLimit keeps both head and tail when truncating text output (0.684209ms) +✔ large tool results are persisted under workspace .pilotdeck and readable by read_file (12.096542ms) +✔ large tool result read_file aliases are short and sequential (4.193666ms) +✔ history replay restores structured agent file artifacts (11.485584ms) +✔ history replay hides Agent file artifacts in general conversations (3.72625ms) +✔ history replay preserves agent status i18n metadata and user hint (11.236458ms) +✔ history token usage restores latest non-empty turn past latest empty turn result (4.152834ms) +✔ history token usage prefers persisted context budget snapshot (3.671542ms) +✔ web reducer merges persisted tool result detail path into existing tool result (1.275166ms) +✔ web reducer bounds huge live tool result previews (0.177083ms) +ℹ tests 308 +ℹ suites 0 +ℹ pass 308 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 28802.224625 diff --git a/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/replay-preserved-case09.mjs b/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/replay-preserved-case09.mjs new file mode 100644 index 00000000..117ae8a5 --- /dev/null +++ b/.omo/evidence/20260727-v24r9-durable-source-apply-receipt/replay-preserved-case09.mjs @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFile, spawn } from "node:child_process"; +import { + cp, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const sourceRun = resolve(process.argv[2] ?? ""); +const candidateRoot = resolve(process.argv[3] ?? ""); +const outputPath = resolve(process.argv[4] ?? ""); + +assert.ok(process.argv[2] && process.argv[3] && process.argv[4], + "usage: replay-preserved-case09.mjs "); + +const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-v24r9-case09-replay-")); +const stateRoot = join(workspace, ".pilotdeck", "work", "legal-coverage"); +const fragmentRoot = join(stateRoot, "fragments"); +const pluginRoot = join(workspace, ".pilotdeck", "plugins", "legal-coverage"); +const candidatePlugin = join(candidateRoot, "products", "legal", "plugins", "legal-coverage"); +const cli = join(pluginRoot, "scripts", "legal-coverage.mjs"); +const hook = join(pluginRoot, "hook.mjs"); + +try { + await cp(sourceRun, workspace, { recursive: true }); + await rm(pluginRoot, { recursive: true, force: true }); + await cp(candidatePlugin, pluginRoot, { recursive: true }); + + const repairReceiptName = (await readdir(fragmentRoot)) + .find((name) => /^source-repair-applied-[a-f0-9]{12}\.json$/u.test(name)); + assert.ok(repairReceiptName, "preserved run must contain the prior applied repair receipt"); + const repairReceipt = JSON.parse(await readFile(join(fragmentRoot, repairReceiptName), "utf8")); + + const proposalNames = (await readdir(fragmentRoot)) + .filter((name) => /^source-merge-[a-f0-9]{12}\.json$/u.test(name)); + const proposals = await Promise.all(proposalNames.map(async (name) => ({ + name, + body: JSON.parse(await readFile(join(fragmentRoot, name), "utf8")), + }))); + const ordinary = proposals.find(({ body }) => body.expectedStateHash === repairReceipt.stateHash); + assert.ok(ordinary, "preserved run must contain the ordinary proposal applied after the repair"); + + const sourcesPath = join(stateRoot, "sources.json"); + const factsPath = join(stateRoot, "facts.json"); + const sources = JSON.parse(await readFile(sourcesPath, "utf8")); + const selectedIds = new Set(ordinary.body.sourceIds); + const appliedFactIds = new Set(); + for (const source of sources.sources) { + if (!selectedIds.has(source.id)) continue; + for (const factId of source.factIds ?? []) appliedFactIds.add(factId); + source.status = "pending"; + delete source.extractionMethod; + delete source.evidenceClass; + delete source.factIds; + delete source.unresolvedItems; + delete source.noMaterialFactsReason; + } + const facts = JSON.parse(await readFile(factsPath, "utf8")); + facts.facts = facts.facts.filter((fact) => !appliedFactIds.has(fact.id)); + await writeJson(sourcesPath, sources); + await writeJson(factsPath, facts); + await rm(join(stateRoot, "completion-proof.json"), { force: true }); + + const legalModule = pathToFileURL(join(candidatePlugin, "scripts", "lib", "legal-coverage.mjs")); + legalModule.searchParams.set("qa", String(Date.now())); + const legal = await import(legalModule.href); + const reconstructed = await legal.validateWorkspace({ workspaceRoot: workspace, writeProof: false }); + assert.equal(reconstructed.stateHash, ordinary.body.expectedStateHash, + "reconstructed pre-apply state must match the immutable proposal"); + + const sessionId = "v24r9-preserved-case09-replay"; + const beforeHook = await runHook({ + hookEventName: "PreModelRequest", + sessionId, + transcriptPath: "", + cwd: workspace, + }); + const beforeEnvelope = legalEnvelope(beforeHook); + assert.equal(beforeEnvelope.workItems.group, "source-fragment-apply"); + const beforeProgress = convergence(beforeHook).progressOrdinal; + + const proposalRelativePath = `.pilotdeck/work/legal-coverage/fragments/${ordinary.name}`; + const proposalBytes = await readFile(join(workspace, proposalRelativePath)); + const proposalSha256 = sha256(proposalBytes); + const applied = await execFileAsync(process.execPath, [ + cli, + "source-merge-apply", + "--input-file", proposalRelativePath, + "--proposal-sha256", proposalSha256, + "--limit", "4", + "--max-bytes", "24576", + "--workspace", workspace, + ], { encoding: "utf8" }); + const appliedResult = JSON.parse(applied.stdout); + assert.equal(appliedResult.applied, true); + + const digest = ordinary.name.match(/^source-merge-([a-f0-9]{12})\.json$/u)?.[1]; + assert.ok(digest); + const receiptPath = join(fragmentRoot, `source-merge-applied-${digest}.json`); + const receipt = JSON.parse(await readFile(receiptPath, "utf8")); + assert.equal(receipt.previousStateHash, reconstructed.stateHash); + assert.equal(receipt.stateHash, appliedResult.stateHash); + assert.equal(receipt.proposalSha256, proposalSha256); + + const afterHook = await runHook({ + hookEventName: "PreModelRequest", + sessionId, + transcriptPath: "", + cwd: workspace, + }); + const afterEnvelope = legalEnvelope(afterHook); + const afterProgress = convergence(afterHook).progressOrdinal; + assert.ok(afterEnvelope.workItems.appliedSource); + assert.equal(afterProgress, beforeProgress + 1); + + const replayHook = await runHook({ + hookEventName: "PreModelRequest", + sessionId, + transcriptPath: "", + cwd: workspace, + }); + const replayProgress = convergence(replayHook).progressOrdinal; + assert.equal(replayProgress, afterProgress); + + const finalSources = JSON.parse(await readFile(sourcesPath, "utf8")); + const finalFacts = JSON.parse(await readFile(factsPath, "utf8")); + const result = { + passed: true, + fixture: "preserved-v24r8-case09-failure", + proposal: basename(ordinary.name), + receipt: basename(receiptPath), + sourceCount: appliedResult.sourceCount, + factCountApplied: appliedResult.factCount, + totalSources: finalSources.sources.length, + reviewedSources: finalSources.sources.filter((source) => source.status === "reviewed").length, + pendingSources: finalSources.sources.filter((source) => source.status === "pending").length, + totalFacts: finalFacts.facts.length, + stateHashBefore: reconstructed.stateHash, + stateHashAfter: appliedResult.stateHash, + progressOrdinal: [beforeProgress, afterProgress, replayProgress], + nextGroup: afterEnvelope.workItems.group, + }; + await mkdir(resolve(outputPath, ".."), { recursive: true }); + await writeJson(outputPath, result); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} finally { + await rm(workspace, { recursive: true, force: true }); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function legalEnvelope(output) { + return JSON.parse((output.hookSpecificOutput.additionalContext ?? "") + .replace(/^\n/u, "") + .replace(/\n<\/legal_coverage_state>$/u, "")); +} + +function convergence(output) { + return output.hookSpecificOutput.modelRequestPatch.metadata.pilotdeckConvergence; +} + +async function runHook(input) { + return new Promise((resolvePromise, reject) => { + const child = spawn(process.execPath, [hook], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let settled = false; + const finish = () => { + if (settled) return; + try { + const output = JSON.parse(stdout); + settled = true; + child.kill(); + resolvePromise(output); + } catch { + // Wait for the complete JSON document. + } + }; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill(); + reject(new Error(`hook output timeout: ${stderr.slice(-1000)}`)); + }, 30_000); + timer.unref(); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + finish(); + }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (code) => { + clearTimeout(timer); + finish(); + if (!settled) reject(new Error(stderr || `hook exited with code ${code}`)); + }); + child.stdin.end(JSON.stringify(input)); + }); +} + +async function writeJson(path, value) { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} diff --git a/docs/PILOTDECK_CONVERGENCE_V24R9_DURABLE_SOURCE_APPLY_RECEIPT.md b/docs/PILOTDECK_CONVERGENCE_V24R9_DURABLE_SOURCE_APPLY_RECEIPT.md new file mode 100644 index 00000000..ee09a8ee --- /dev/null +++ b/docs/PILOTDECK_CONVERGENCE_V24R9_DURABLE_SOURCE_APPLY_RECEIPT.md @@ -0,0 +1,101 @@ +# PilotDeck Convergence V24R9: Durable Ordinary Source Apply Receipt + +## Decision + +V24R9 fixes one Legal Coverage observation gap found by the V24R8 Case 09 +product run. It does not change Agent Core, O1, validator acceptance, issue +taxonomy, Progress Lease thresholds `8/2`, Router, Memory, the model, the +evaluation corpus, immutable source repair, matrix handoffs, or authority +closure. + +V24R8 correctly crossed the rejected-proposal repair boundary and renewed +progress from its verified repair receipt. The next ordinary source proposal +also applied successfully, changing four source rows and adding 23 facts. That +ordinary transaction had no durable applied identity. On the next hook call, +Legal Coverage had already moved to the following source batch, so the former +apply-ready work item was gone and the Lease correctly failed closed. + +## Protocol + +Every successful ordinary `source-merge-apply` now writes one deterministic +receipt next to its immutable proposal: + +```text +.pilotdeck/work/legal-coverage/fragments/source-merge-applied-.json +``` + +The receipt records: + +- the previous canonical state hash; +- the resulting canonical state hash; +- the immutable proposal path and SHA-256; +- the ordered IDs of the sources applied by the transaction. + +The receipt is written only after the existing source and fact ledgers have +been projected and the unchanged workspace validator has computed the +resulting state. If receipt persistence fails, the canonical source and fact +ledgers are rolled back. + +On a later hook call, Legal Coverage accepts the receipt only when all of the +following remain true: + +- its schema and checkpoint type are exact; +- its resulting state hash equals the current validator state; +- its previous state hash equals the proposal's immutable expected state; +- its filename digest and proposal path agree; +- the current proposal bytes still match the recorded SHA-256; +- its source IDs exactly match the proposal source IDs; +- every recorded source exists in the current ledger and has the exact + `reviewed` status; +- record count, receipt size, traversal, and symlink protections pass. + +Malformed, stale, tampered, replayed, out-of-scope, or inaccessible receipts do +not create progress. + +## Lease Semantics + +The ordinary apply-ready state is preparation, not semantic progress, and is +therefore no longer a progress checkpoint. Only the verified durable receipt +creates one `source-fragment-applied` checkpoint. + +The hook's existing session state deduplicates that checkpoint digest, so one +canonical transaction advances `progressOrdinal` exactly once. A repeated +hook call with the same receipt does not advance it again. No grace kind, +threshold, error-count heuristic, or raw state-hash heuristic is added. + +Core remains domain-neutral. It receives only the opaque ordinal and digest; +proposal fields, source IDs, ledger status, receipt validation, and legal state +hashes remain inside Legal Coverage. + +## Counterexamples + +Verification must prove that: + +- apply readiness alone does not advance progress; +- a successful ordinary apply persists the deterministic receipt; +- a changed proposal hash makes the receipt invalid; +- a receipt for a non-current state makes the receipt invalid; +- pending, missing, duplicate, extra, or mismatched source IDs are rejected; +- an unknown source status is rejected even if a receipt claims the resulting + current state hash; +- a tampered receipt does not advance progress; +- the valid current receipt advances progress once; +- replay of the same receipt does not advance progress; +- immutable source repair behavior is unchanged; +- traversal and symlink restrictions remain enforced by the existing safe-path + layer. + +## Verification Gate + +1. Run syntax checks, patch hygiene, and the focused 41 Legal Coverage plus + four real-Gateway tests. +2. Run the complete repository suite. +3. Reconstruct the exact preserved V24R8 Case 09 state immediately before the + ordinary apply. Execute the real Legal Coverage CLI and hook and prove + `apply-ready -> durable receipt -> progress +1 -> replay +0` with the same + four sources, 23 facts, and state hashes observed in the failed campaign. +4. Commit and push the bounded Legal Coverage change as a stacked PR targeting + V24R8. +5. Create a new immutable campaign. Run Gate 0, paired smoke, Case 05, and then + the complete Case 09 product Gate. Do not authorize V25 or the 85-case + campaign unless Case 09 completes and passes its product validation. diff --git a/products/legal/plugins/legal-coverage/hook.mjs b/products/legal/plugins/legal-coverage/hook.mjs index 612f8f4b..06b33556 100644 --- a/products/legal/plugins/legal-coverage/hook.mjs +++ b/products/legal/plugins/legal-coverage/hook.mjs @@ -327,7 +327,19 @@ function advanceProgressState(sessionState, digest, current, checkpointDigest, r function legalProgressCheckpointDigest(workItems) { let checkpoint; - if (workItems?.appliedRepair + if (workItems?.appliedSource + && validStateHash(workItems.appliedSource.stateHash) + && validStateHash(workItems.appliedSource.proposalSha256) + && Array.isArray(workItems.appliedSource.sourceIds)) { + const sourceIds = workItems.appliedSource.sourceIds.filter(nonEmptyString).slice(0, 12).sort(); + if (sourceIds.length === 0 || sourceIds.length !== workItems.appliedSource.sourceIds.length) return undefined; + checkpoint = { + kind: "source-fragment-applied", + stateHash: workItems.appliedSource.stateHash, + sourceIds, + proposalSha256: workItems.appliedSource.proposalSha256, + }; + } else if (workItems?.appliedRepair && validStateHash(workItems.appliedRepair.stateHash) && validStateHash(workItems.appliedRepair.repairSha256) && Array.isArray(workItems.appliedRepair.sourceIds)) { @@ -339,17 +351,6 @@ function legalProgressCheckpointDigest(workItems) { sourceIds, repairSha256: workItems.appliedRepair.repairSha256, }; - } else if (workItems?.group === "source-fragment-apply" - && workItems.proposal?.validated === true - && validStateHash(workItems.proposal.expectedStateHash) - && Array.isArray(workItems.proposal.sourceIds)) { - const sourceIds = workItems.proposal.sourceIds.filter(nonEmptyString).slice(0, 12).sort(); - if (sourceIds.length === 0 || sourceIds.length !== workItems.proposal.sourceIds.length) return undefined; - checkpoint = { - kind: "source-fragment-apply", - expectedStateHash: workItems.proposal.expectedStateHash, - sourceIds, - }; } else if (workItems?.group === "matrix-pending-selection-apply" && workItems.selection?.validated === true && workItems.selection.acceptedSelection?.decision === "finalize" diff --git a/products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs b/products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs index 77b0711f..86fa8e6b 100644 --- a/products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs +++ b/products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs @@ -67,9 +67,11 @@ const SOURCE_REVIEW_FRAGMENT_PATTERN = /^source-review-[a-f0-9]{12}\.json$/u; const SOURCE_MERGE_READINESS_PATTERN = /^source-merge-ready-[a-f0-9]{12}\.json$/u; const SOURCE_MERGE_REPAIR_PATTERN = /^source-repair-[a-f0-9]{12}\.json$/u; const SOURCE_MERGE_REPAIR_APPLIED_PATTERN = /^source-repair-applied-[a-f0-9]{12}\.json$/u; +const SOURCE_MERGE_APPLIED_PATTERN = /^source-merge-applied-[a-f0-9]{12}\.json$/u; const SOURCE_REVIEW_FRAGMENT_TYPE = "legal-evidence-source-batch-review"; const SOURCE_MERGE_READINESS_TYPE = "legal-source-merge-readiness"; const SOURCE_MERGE_REPAIR_APPLIED_TYPE = "legal-source-repair-applied"; +const SOURCE_MERGE_APPLIED_TYPE = "legal-source-merge-applied"; const SOURCE_REVIEW_FRAGMENT_MAX_BYTES = 262144; const SOURCE_MERGE_READINESS_MAX_BYTES = 4096; const SOURCE_REVIEW_MERGE_MAX_SOURCES = 4; @@ -215,12 +217,13 @@ export async function pendingSourceReviewPlan(workspaceRoot, options = {}) { expectedStateHash, sourceRows, ); + const appliedSource = await currentSourceMergeAppliedReceipt(workspaceRoot, expectedStateHash, sourceRows); const readinessReceipt = await validSourceMergeReadinessReceipt(workspaceRoot, proposalPlan); - if (!readinessReceipt) return withAppliedRepairCheckpoint(proposalPlan, appliedRepair); + if (!readinessReceipt) return withAppliedSourceCheckpoints(proposalPlan, appliedRepair, appliedSource); const proposePlan = sourceMergeProposePlanFor(proposalPlan, readinessReceipt); const proposalReceipt = await validSourceMergeProposalReceipt(workspaceRoot, proposePlan, receipts, loaded.state); if (proposalReceipt?.valid) { - return withAppliedRepairCheckpoint(sourceMergeApplyPlanFor(proposePlan, proposalReceipt), appliedRepair); + return withAppliedSourceCheckpoints(sourceMergeApplyPlanFor(proposePlan, proposalReceipt), appliedRepair, appliedSource); } if (proposalReceipt?.error) { const rejectedPlan = { @@ -237,7 +240,7 @@ export async function pendingSourceReviewPlan(workspaceRoot, options = {}) { }, }; const repairPlan = sourceMergeRepairPlanFor(rejectedPlan); - if (!repairPlan) return withAppliedRepairCheckpoint(rejectedPlan, appliedRepair); + if (!repairPlan) return withAppliedSourceCheckpoints(rejectedPlan, appliedRepair, appliedSource); const repairReceipt = await validSourceMergeRepairReceipt( workspaceRoot, repairPlan, @@ -245,23 +248,24 @@ export async function pendingSourceReviewPlan(workspaceRoot, options = {}) { loaded.state, ); if (repairReceipt?.valid) { - return withAppliedRepairCheckpoint( + return withAppliedSourceCheckpoints( sourceMergeRepairApplyPlanFor(repairPlan, repairReceipt), appliedRepair, + appliedSource, ); } if (repairReceipt?.error) { - return withAppliedRepairCheckpoint({ + return withAppliedSourceCheckpoints({ ...repairPlan, repair: { ...repairPlan.repair, validationError: repairReceipt.error, }, - }, appliedRepair); + }, appliedRepair, appliedSource); } - return withAppliedRepairCheckpoint(repairPlan, appliedRepair); + return withAppliedSourceCheckpoints(repairPlan, appliedRepair, appliedSource); } - return withAppliedRepairCheckpoint(proposePlan, appliedRepair); + return withAppliedSourceCheckpoints(proposePlan, appliedRepair, appliedSource); } function sourceReviewPlanFor(sourceRows, options = {}) { @@ -723,8 +727,12 @@ function sourceMergeRepairApplyPlanFor(repairPlan, repairReceipt) { }; } -function withAppliedRepairCheckpoint(plan, appliedRepair) { - return appliedRepair ? { ...plan, appliedRepair } : plan; +function withAppliedSourceCheckpoints(plan, appliedRepair, appliedSource) { + return { + ...plan, + ...(appliedRepair ? { appliedRepair } : {}), + ...(appliedSource ? { appliedSource } : {}), + }; } function boundedReceiptSourceIds(receipt, maxRecords, maxSerializedBytes) { @@ -1217,6 +1225,77 @@ async function currentSourceMergeRepairAppliedReceipt(workspaceRoot, expectedSta return undefined; } +async function currentSourceMergeAppliedReceipt(workspaceRoot, expectedStateHash, sourceRows) { + const fragmentDirectory = `${STATE_DIRECTORY}/fragments`; + const directoryPath = await resolveSafeWorkspacePath(workspaceRoot, fragmentDirectory, { allowMissing: true }); + let entries; + try { + entries = await readdir(directoryPath, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return undefined; + throw error; + } + const sourceById = new Map(sourceRows + .filter((source) => isRecord(source) && nonEmpty(source.id)) + .map((source) => [source.id, source])); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!entry.isFile() || !SOURCE_MERGE_APPLIED_PATTERN.test(entry.name)) continue; + try { + const receiptPath = `${fragmentDirectory}/${entry.name}`; + const path = await resolveSafeWorkspacePath(workspaceRoot, receiptPath); + const receiptInfo = await stat(path); + if (!receiptInfo.isFile() || receiptInfo.size <= 0 + || receiptInfo.size > SOURCE_MERGE_REPAIR_APPLIED_MAX_BYTES) continue; + const receipt = JSON.parse((await readFile(path)).toString("utf8")); + const expectedKeys = [ + "checkpointType", + "previousStateHash", + "proposalPath", + "proposalSha256", + "schemaVersion", + "sourceIds", + "stateHash", + ]; + if (!isRecord(receipt) || !hasOnlyKeys(receipt, expectedKeys) + || receipt.schemaVersion !== 1 + || receipt.checkpointType !== SOURCE_MERGE_APPLIED_TYPE + || receipt.stateHash !== expectedStateHash + || !/^[a-f0-9]{64}$/u.test(String(receipt.previousStateHash ?? "")) + || !/^[a-f0-9]{64}$/u.test(String(receipt.proposalSha256 ?? "")) + || !Array.isArray(receipt.sourceIds) + || receipt.sourceIds.length < 1 + || receipt.sourceIds.length > SOURCE_REVIEW_MERGE_MAX_SOURCES + || new Set(receipt.sourceIds).size !== receipt.sourceIds.length + || receipt.sourceIds.some((sourceId) => !nonEmpty(sourceId) + || !sourceById.has(sourceId) + || sourceById.get(sourceId)?.status !== "reviewed")) continue; + const digest = entry.name.match(/^source-merge-applied-([a-f0-9]{12})\.json$/u)?.[1]; + const expectedProposalPath = `${fragmentDirectory}/source-merge-${digest}.json`; + if (!digest || receipt.proposalPath !== expectedProposalPath) continue; + const proposalPath = await resolveSafeWorkspacePath(workspaceRoot, expectedProposalPath); + const proposalInfo = await stat(proposalPath); + if (!proposalInfo.isFile() || proposalInfo.size <= 0 || proposalInfo.size > 24576) continue; + const proposalBytes = await readFile(proposalPath); + if (sha256(proposalBytes) !== receipt.proposalSha256) continue; + const proposal = JSON.parse(proposalBytes.toString("utf8")); + const proposalSourceIds = Array.isArray(proposal?.sourceIds) ? [...proposal.sourceIds].sort() : []; + if (proposal.expectedStateHash !== receipt.previousStateHash + || proposalSourceIds.length !== receipt.sourceIds.length + || proposalSourceIds.some((sourceId) => !nonEmpty(sourceId)) + || JSON.stringify(proposalSourceIds) !== JSON.stringify([...receipt.sourceIds].sort())) continue; + return { + path: receiptPath, + stateHash: receipt.stateHash, + sourceIds: [...receipt.sourceIds], + proposalSha256: receipt.proposalSha256, + }; + } catch { + continue; + } + } + return undefined; +} + export async function applySourceMergeProposal(workspaceRoot, options = {}) { if (!nonEmpty(options.proposalPath) || !/^source-merge-[a-f0-9]{12}\.json$/u.test(options.proposalPath.split("/").at(-1) ?? "")) { throw batchError("source_merge_proposal_path_invalid", "source-merge-apply requires the injected deterministic proposal path."); @@ -1269,6 +1348,12 @@ export async function applySourceMergeProposal(workspaceRoot, options = {}) { receipt, normalized, group: "source-fragment-merge", + appliedReceipt: { + kind: "proposal", + path: proposalPlan.proposal.path.replace(/source-merge-([a-f0-9]{12})\.json$/u, "source-merge-applied-$1.json"), + proposalPath: proposalPlan.proposal.path, + proposalSha256: options.proposalSha256, + }, }); } @@ -1358,6 +1443,7 @@ export async function applySourceMergeRepair(workspaceRoot, options = {}) { normalized: validated.normalized, group: "source-fragment-repair", appliedReceipt: { + kind: "repair", path: repairPlan.repair.appliedReceiptPath, repairPath: repairPlan.repair.path, repairSha256: options.repairSha256, @@ -1431,18 +1517,25 @@ async function applyNormalizedSourceMerge(workspaceRoot, options) { const after = await validateWorkspace({ workspaceRoot, writeProof: true }); if (appliedReceipt) { try { - if (!SOURCE_MERGE_REPAIR_APPLIED_PATTERN.test(appliedReceipt.path.split("/").at(-1) ?? "")) { - throw batchError("source_repair_receipt_path_invalid", "Applied source repair receipt path is invalid."); + const isRepair = appliedReceipt.kind === "repair"; + const expectedPattern = isRepair ? SOURCE_MERGE_REPAIR_APPLIED_PATTERN : SOURCE_MERGE_APPLIED_PATTERN; + if (!expectedPattern.test(appliedReceipt.path.split("/").at(-1) ?? "")) { + throw batchError("source_apply_receipt_path_invalid", "Applied source receipt path is invalid."); } const receiptPath = await resolveSafeWorkspacePath(workspaceRoot, appliedReceipt.path, { allowMissing: true }); await writeJsonAtomic(receiptPath, { schemaVersion: 1, - checkpointType: SOURCE_MERGE_REPAIR_APPLIED_TYPE, + checkpointType: isRepair ? SOURCE_MERGE_REPAIR_APPLIED_TYPE : SOURCE_MERGE_APPLIED_TYPE, previousStateHash: before.stateHash, stateHash: after.stateHash, sourceIds: [...selectedIds].sort(), - repairPath: appliedReceipt.repairPath, - repairSha256: appliedReceipt.repairSha256, + ...(isRepair ? { + repairPath: appliedReceipt.repairPath, + repairSha256: appliedReceipt.repairSha256, + } : { + proposalPath: appliedReceipt.proposalPath, + proposalSha256: appliedReceipt.proposalSha256, + }), }); } catch (error) { try { @@ -1452,7 +1545,7 @@ async function applyNormalizedSourceMerge(workspaceRoot, options) { await rm(receiptPath, { force: true }); } catch (rollbackError) { throw batchError( - "source_repair_receipt_rollback_failed", + "source_apply_receipt_rollback_failed", `Applied receipt failed (${errorMessage(error)}) and canonical rollback also failed (${errorMessage(rollbackError)}).`, ); } diff --git a/tests/products/legal-coverage.spec.ts b/tests/products/legal-coverage.spec.ts index 1ea62f05..8a58e66e 100644 --- a/tests/products/legal-coverage.spec.ts +++ b/tests/products/legal-coverage.spec.ts @@ -1171,6 +1171,165 @@ test("legal coverage injects deterministic disjoint worker batches for large pen afterRepairProgress.progressOrdinal, ); + const legal = await import(pathToFileURL(join(PLUGIN_ROOT, "scripts", "lib", "legal-coverage.mjs")).href) as any; + const nextMerge = await legal.pendingSourceReviewPlan(workspace); + assert.equal(nextMerge.group, "source-fragment-merge"); + assert.ok(nextMerge.appliedRepair); + const nextPrepared = await runCli( + workspace, + "source-merge-prepare", + "--checkpoint", nextMerge.readiness.path, + "--expected-state-hash", nextMerge.proposal.expectedStateHash, + "--fragment", nextMerge.proposal.fragmentPath, + "--receipt-sha256", nextMerge.proposal.receiptSha256, + ...nextMerge.proposal.sourceIds.flatMap((sourceId: string) => ["--source-id", sourceId]), + "--limit", "4", + "--max-bytes", "24576", + ); + assert.equal(nextPrepared.exitCode, 0, nextPrepared.stderr); + const nextPropose = await legal.pendingSourceReviewPlan(workspace); + assert.equal(nextPropose.group, "source-fragment-propose"); + const nextProposalBody = { + ...nextPropose.proposal.template, + facts: nextPropose.proposal.sourceIds.map((sourceId: string) => { + const source = nextPropose.preparedSlice.sources.find((item: any) => item.sourceId === sourceId); + return { + subject: sourceId, + predicate: "contains reviewed evidence", + value: `Reviewed ${sourceId}.`, + missingTimeReason: "The synthetic source contains no usable date.", + sourceRefs: [{ sourceId, locator: source.facts[0].locator }], + evidenceClass: "other", + verificationStatus: "verified", + conflictStatus: "none", + material: false, + critical: false, + }; + }), + noMaterialFacts: [], + }; + await writeJson(join(workspace, nextPropose.proposal.path), nextProposalBody); + const nextApplyReady = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "large-pending-source-plan", + transcriptPath: "", + cwd: workspace, + }); + const nextApplyEnvelope = JSON.parse((nextApplyReady.hookSpecificOutput.additionalContext ?? "") + .replace(/^\n/u, "") + .replace(/\n<\/legal_coverage_state>$/u, "")) as { + sourceMergeApplyCommand: string; + workItems: { proposal: { path: string; proposalSha256: string } }; + }; + assert.match(nextApplyEnvelope.sourceMergeApplyCommand, /source-merge-apply/u); + const applyReadyProgress = ( + nextApplyReady.hookSpecificOutput.modelRequestPatch?.metadata?.pilotdeckConvergence as { progressOrdinal: number } + ).progressOrdinal; + assert.equal(applyReadyProgress, afterRepairProgress.progressOrdinal); + const nextApplied = await runCli( + workspace, + "source-merge-apply", + "--input-file", nextApplyEnvelope.workItems.proposal.path, + "--proposal-sha256", nextApplyEnvelope.workItems.proposal.proposalSha256, + "--limit", "4", + "--max-bytes", "24576", + ); + assert.equal(nextApplied.exitCode, 0, nextApplied.stderr); + const nextAppliedResult = JSON.parse(nextApplied.stdout) as { applied: boolean; sourceCount: number }; + assert.equal(nextAppliedResult.applied, true); + assert.equal(nextAppliedResult.sourceCount, 4); + const proposalDigest = nextApplyEnvelope.workItems.proposal.path.match(/source-merge-([a-f0-9]{12})\.json$/u)?.[1]; + assert.ok(proposalDigest); + const mergeAppliedReceiptPath = join( + workspace, + STATE_ROOT, + "fragments", + `source-merge-applied-${proposalDigest}.json`, + ); + const mergeAppliedReceiptBytes = await readFile(mergeAppliedReceiptPath); + const tamperedMergeReceipt = JSON.parse(mergeAppliedReceiptBytes.toString("utf8")) as { proposalSha256: string }; + tamperedMergeReceipt.proposalSha256 = "0".repeat(64); + await writeJson(mergeAppliedReceiptPath, tamperedMergeReceipt); + const ignoredMergeReceipt = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "large-pending-source-plan", + transcriptPath: "", + cwd: workspace, + }); + assert.equal( + (ignoredMergeReceipt.hookSpecificOutput.modelRequestPatch?.metadata?.pilotdeckConvergence as { + progressOrdinal: number; + }).progressOrdinal, + applyReadyProgress, + ); + await writeFile(mergeAppliedReceiptPath, mergeAppliedReceiptBytes); + const mergedSourcesPath = join(workspace, STATE_ROOT, "sources.json"); + const mergedSourcesBytes = await readFile(mergedSourcesPath); + const invalidStatusSources = JSON.parse(mergedSourcesBytes.toString("utf8")) as { + sources: Array<{ id: string; status: string }>; + }; + const invalidStatusSource = invalidStatusSources.sources + .find((source) => nextPropose.proposal.sourceIds.includes(source.id)); + assert.ok(invalidStatusSource); + invalidStatusSource.status = "unknown"; + await writeJson(mergedSourcesPath, invalidStatusSources); + const invalidStatusState = await legal.validateWorkspace({ workspaceRoot: workspace, writeProof: false }); + const invalidStatusBaseline = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "invalid-status-source-receipt", + transcriptPath: "", + cwd: workspace, + }); + const invalidStatusBaselineProgress = ( + invalidStatusBaseline.hookSpecificOutput.modelRequestPatch?.metadata?.pilotdeckConvergence as { + progressOrdinal: number; + } + ).progressOrdinal; + assert.doesNotMatch(invalidStatusBaseline.hookSpecificOutput.additionalContext ?? "", /"appliedSource":/u); + const forgedCurrentReceipt = JSON.parse(mergeAppliedReceiptBytes.toString("utf8")) as { stateHash: string }; + forgedCurrentReceipt.stateHash = invalidStatusState.stateHash; + await writeJson(mergeAppliedReceiptPath, forgedCurrentReceipt); + const ignoredInvalidStatusReceipt = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "invalid-status-source-receipt", + transcriptPath: "", + cwd: workspace, + }); + assert.equal( + (ignoredInvalidStatusReceipt.hookSpecificOutput.modelRequestPatch?.metadata?.pilotdeckConvergence as { + progressOrdinal: number; + }).progressOrdinal, + invalidStatusBaselineProgress, + ); + assert.doesNotMatch(ignoredInvalidStatusReceipt.hookSpecificOutput.additionalContext ?? "", /"appliedSource":/u); + await writeFile(mergedSourcesPath, mergedSourcesBytes); + await writeFile(mergeAppliedReceiptPath, mergeAppliedReceiptBytes); + const observedMergeReceipt = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "large-pending-source-plan", + transcriptPath: "", + cwd: workspace, + }); + const observedMergeProgress = ( + observedMergeReceipt.hookSpecificOutput.modelRequestPatch?.metadata?.pilotdeckConvergence as { + progressOrdinal: number; + } + ).progressOrdinal; + assert.equal(observedMergeProgress, applyReadyProgress + 1); + assert.match(observedMergeReceipt.hookSpecificOutput.additionalContext ?? "", /"appliedSource":/u); + const replayedMergeReceipt = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "large-pending-source-plan", + transcriptPath: "", + cwd: workspace, + }); + assert.equal( + (replayedMergeReceipt.hookSpecificOutput.modelRequestPatch?.metadata?.pilotdeckConvergence as { + progressOrdinal: number; + }).progressOrdinal, + observedMergeProgress, + ); + const sourcesBeforeReplay = await readFile(join(workspace, STATE_ROOT, "sources.json")); const factsBeforeReplay = await readFile(join(workspace, STATE_ROOT, "facts.json")); const replay = await runCli(