From f7da217677eb60427a8493f3ba5cb47560733f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Thu, 23 Jul 2026 23:22:40 +0800 Subject: [PATCH 01/14] feat(legal): add fail-closed coverage validator --- .../legal-coverage/scripts/legal-coverage.mjs | 55 ++ .../scripts/lib/legal-coverage.mjs | 844 ++++++++++++++++++ .../references/data-contracts.txt | 67 ++ 3 files changed, 966 insertions(+) create mode 100644 products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs create mode 100644 products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs create mode 100644 products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/data-contracts.txt diff --git a/products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs b/products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs new file mode 100644 index 00000000..c43edd90 --- /dev/null +++ b/products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + ensureWorkspace, + validateWorkspace, +} from "./lib/legal-coverage.mjs"; + +const [command = "status", ...args] = process.argv.slice(2); +const workspaceRoot = resolve(readOption(args, "--workspace") ?? process.cwd()); + +if (command === "init") { + const initialized = await ensureWorkspace(workspaceRoot); + const config = JSON.parse(await readFile(initialized.paths.config, "utf8")); + const inputs = readOptions(args, "--input"); + const deliverables = readOptions(args, "--deliverable"); + const jurisdiction = readOption(args, "--jurisdiction"); + const basisDate = readOption(args, "--basis-date"); + if (inputs.length > 0) config.inputRoots = [...new Set(inputs)]; + if (deliverables.length > 0) { + config.deliverables = deliverables.map((value, index) => { + const separator = value.indexOf("="); + return separator > 0 + ? { id: value.slice(0, separator), path: value.slice(separator + 1), required: true } + : { id: `deliverable-${index + 1}`, path: value, required: true }; + }); + } + if (jurisdiction) config.jurisdiction = jurisdiction; + if (basisDate) config.basisDate = basisDate; + if (args.includes("--allow-no-material-facts")) config.allowNoMaterialFacts = true; + await writeFile(initialized.paths.config, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + console.log(JSON.stringify({ initialized: true, workspaceRoot, stateDirectory: ".pilotdeck/work/legal-coverage" }, null, 2)); + process.exitCode = 0; +} else if (command === "validate" || command === "status") { + await ensureWorkspace(workspaceRoot); + const result = await validateWorkspace({ workspaceRoot, writeProof: args.includes("--write-proof") }); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.passed || command === "status" ? 0 : 2; +} else { + console.error("Usage: legal-coverage.mjs [--workspace PATH] [--input PATH] [--deliverable ID=PATH] [--jurisdiction NAME] [--basis-date DATE] [--allow-no-material-facts] [--write-proof]"); + process.exitCode = 1; +} + +function readOption(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function readOptions(args, name) { + const values = []; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === name && typeof args[index + 1] === "string") values.push(args[index + 1]); + } + return values; +} diff --git a/products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs b/products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs new file mode 100644 index 00000000..b12ed570 --- /dev/null +++ b/products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs @@ -0,0 +1,844 @@ +import { createHash } from "node:crypto"; +import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path"; + +export const VALIDATOR_VERSION = "1.1.0"; +export const STATE_DIRECTORY = ".pilotdeck/work/legal-coverage"; +export const PROOF_PATH = `${STATE_DIRECTORY}/completion-proof.json`; + +export const REQUIRED_MATRICES = [ + "equity-capital-timeline", + "holding-platform-special-rights", + "governance-personnel-timeline", + "contract-key-terms", + "debt-collateral-liquidity", + "employment-ip-timeline", + "legal-authority", +]; + +export const ISSUE_RULES = { + "timeline-collision": "timeline_collision", + "threshold-breach": "threshold_breach", + "rights-governance-conflict": "rights_governance_conflict", + "liquidity-relationship": "liquidity_relationship", + "employment-ip-ownership": "employment_ip_ownership", + "source-contradiction": "source_contradiction", +}; + +const STATE_FILES = { + config: "config.json", + sources: "sources.json", + facts: "facts.json", + matrices: "matrices.json", + issues: "issues.json", + authorities: "authorities.json", + coverage: "coverage.json", +}; + +const EVIDENCE_CLASSES = new Set([ + "official-record", + "executed-contract", + "company-disclosure", + "financial-record", + "third-party-record", + "interview", + "image-or-scan", + "other", +]); +const SOURCE_STATUSES = new Set(["reviewed", "unreadable", "pending"]); +const VERIFICATION_STATUSES = new Set(["verified", "partially-verified", "unverified"]); +const CONFLICT_STATUSES = new Set(["none", "resolved", "unresolved"]); +const ISSUE_STATUSES = new Set(["open", "mitigated", "unresolved"]); +const COVERAGE_STATUSES = new Set(["covered", "unresolved"]); +const AUTHORITY_STATUSES = new Set(["verified", "pending-verification", "not-applicable"]); +const MATRIX_STATUSES = new Set(["pending", "complete", "not-applicable"]); +const TEXT_EXTENSIONS = new Set([".md", ".txt", ".html", ".htm", ".csv"]); + +export async function ensureWorkspace(workspaceRoot) { + const workspace = resolve(workspaceRoot); + const stateRoot = resolveWithinWorkspace(workspace, STATE_DIRECTORY); + await mkdir(stateRoot, { recursive: true }); + const templates = { + config: { + schemaVersion: 1, + enabled: true, + jurisdiction: "", + basisDate: "", + allowNoMaterialFacts: false, + inputRoots: [], + deliverables: [], + }, + sources: { schemaVersion: 1, sources: [] }, + facts: { schemaVersion: 1, facts: [] }, + matrices: { + schemaVersion: 1, + matrices: REQUIRED_MATRICES.map((id) => ({ id, status: "pending", entries: [] })), + }, + issues: { schemaVersion: 1, issues: [] }, + authorities: { schemaVersion: 1, authorities: [] }, + coverage: { schemaVersion: 1, deliverables: [], sources: [], facts: [], issues: [], authorities: [] }, + }; + for (const [key, template] of Object.entries(templates)) { + const filePath = resolve(stateRoot, STATE_FILES[key]); + if (!await pathExists(filePath)) await writeJsonAtomic(filePath, template); + } + return { workspace, stateRoot, paths: statePaths(workspace) }; +} + +export async function readWorkspaceState(workspaceRoot) { + const workspace = resolve(workspaceRoot); + const paths = statePaths(workspace); + const state = {}; + const readErrors = []; + for (const [key, filePath] of Object.entries(paths)) { + if (key === "proof") continue; + try { + state[key] = JSON.parse(await readFile(filePath, "utf8")); + } catch (error) { + state[key] = undefined; + readErrors.push(issue( + phaseForStateKey(key), + "state_file_invalid", + `${relative(workspace, filePath)} is missing or is not valid JSON: ${errorMessage(error)}`, + relative(workspace, filePath), + )); + } + } + return { workspace, paths, state, readErrors }; +} + +export async function validateWorkspace(options) { + const workspace = resolve(options.workspaceRoot); + const loaded = await readWorkspaceState(workspace); + const errors = [...loaded.readErrors]; + const warnings = []; + const context = { + workspace, + state: loaded.state, + errors, + warnings, + sourceIds: new Set(), + factIds: new Set(), + issueIds: new Set(), + authorityIds: new Set(), + deliverables: new Map(), + deliverableContents: new Map(), + }; + + await validateConfig(context); + await validateSources(context); + validateFacts(context); + validateMatrices(context); + validateIssues(context); + validateAuthorities(context); + await validateCoverage(context); + + const stateHash = await computeStateHash(context); + const passed = errors.length === 0; + if (options.writeProof) { + if (passed) { + const proof = { + schemaVersion: 1, + validatorVersion: VALIDATOR_VERSION, + validatedAt: new Date().toISOString(), + stateHash, + deliverables: [...context.deliverables.entries()] + .map(([path, value]) => ({ path, sha256: value.sha256, bytes: value.bytes })) + .sort((a, b) => a.path.localeCompare(b.path)), + }; + await writeJsonAtomic(loaded.paths.proof, proof); + } else { + await rm(loaded.paths.proof, { force: true }); + } + } + + return { + passed, + stateHash, + errors, + warnings, + counts: { + sources: context.sourceIds.size, + facts: context.factIds.size, + issues: context.issueIds.size, + authorities: context.authorityIds.size, + deliverables: context.deliverables.size, + }, + proofPath: PROOF_PATH, + }; +} + +export function milestoneFor(result, cliPath) { + if (result.passed) { + return [ + "Legal coverage validation is complete and the completion proof matches the current deliverables.", + "Satisfy any other active domain skill, artifact contract, and deliverable QA before stopping.", + "If any bound ledger or deliverable changes, rerun the validator because the current proof will become stale.", + ].join("\n"); + } + const first = result.errors[0]; + const sameCode = result.errors.filter((error) => error.code === first?.code); + const command = `node ${JSON.stringify(cliPath)} validate --workspace \"$PWD\" --write-proof`; + const initialize = `node ${JSON.stringify(cliPath)} init --workspace \"$PWD\" --input --deliverable = --jurisdiction --basis-date `; + return [ + `Legal coverage milestone (${first?.phase ?? "configuration"}): fix validator code ${first?.code ?? "state_file_invalid"} now.`, + sameCode.length > 1 + ? `This code occurs ${sameCode.length} times. Fix all occurrences in one bounded edit before rerunning validation.` + : "Fix this occurrence in one bounded edit before rerunning validation.", + first?.message ?? "Initialize the legal coverage workspace.", + sameCode.length > 1 + ? `Representative paths: ${sameCode.slice(0, 4).map((error) => error.path).filter(Boolean).join(", ")}.` + : undefined, + first?.phase === "configuration" ? `Use this initializer with task-specific values: ${initialize}` : undefined, + `After the fix, run: ${command}`, + "Do not claim completion and do not create completion-proof.json manually.", + ].filter(Boolean).join("\n"); +} + +export function activationMatches(prompt) { + return /(?:法律.{0,8}(?:尽调|尽职调查|意见书|风险审查)|尽职调查.{0,8}(?:法律|意见)|legal\s+(?:due\s+diligence|opinion|risk\s+review)|transaction\s+legal\s+review)/iu.test(prompt); +} + +export function statePaths(workspaceRoot) { + const root = resolveWithinWorkspace(resolve(workspaceRoot), STATE_DIRECTORY); + return { + config: resolve(root, STATE_FILES.config), + sources: resolve(root, STATE_FILES.sources), + facts: resolve(root, STATE_FILES.facts), + matrices: resolve(root, STATE_FILES.matrices), + issues: resolve(root, STATE_FILES.issues), + authorities: resolve(root, STATE_FILES.authorities), + coverage: resolve(root, STATE_FILES.coverage), + proof: resolve(root, "completion-proof.json"), + }; +} + +export function resolveWithinWorkspace(workspaceRoot, candidate) { + if (typeof candidate !== "string" || candidate.trim() === "" || isAbsolute(candidate)) { + throw new Error(`Path must be a non-empty workspace-relative path: ${String(candidate)}`); + } + const workspace = resolve(workspaceRoot); + const resolved = resolve(workspace, candidate); + if (resolved !== workspace && !resolved.startsWith(`${workspace}${sep}`)) { + throw new Error(`Path escapes the workspace: ${candidate}`); + } + return resolved; +} + +async function validateConfig(context) { + const config = context.state.config; + if (!isRecord(config)) return; + requireSchemaVersion(context, "configuration", config, STATE_FILES.config); + if (config.enabled !== true) add(context, "configuration", "plugin_not_enabled", "Set config.enabled to true.", STATE_FILES.config); + if (!nonEmpty(config.jurisdiction)) add(context, "configuration", "jurisdiction_missing", "Record the governing jurisdiction in config.jurisdiction.", STATE_FILES.config); + if (!nonEmpty(config.basisDate)) add(context, "configuration", "basis_date_missing", "Record the legal review basis date in config.basisDate.", STATE_FILES.config); + if (!Array.isArray(config.inputRoots) || config.inputRoots.length === 0) { + add(context, "configuration", "input_roots_missing", "Add at least one workspace-relative source path to config.inputRoots.", STATE_FILES.config); + } + if (!Array.isArray(config.deliverables) || config.deliverables.length === 0) { + add(context, "configuration", "deliverables_missing", "Add at least one required legal deliverable to config.deliverables.", STATE_FILES.config); + return; + } + const ids = new Set(); + const paths = new Set(); + for (const [index, deliverable] of config.deliverables.entries()) { + const at = `${STATE_FILES.config}#deliverables[${index}]`; + if (!isRecord(deliverable) || !nonEmpty(deliverable.id) || !nonEmpty(deliverable.path)) { + add(context, "configuration", "deliverable_invalid", "Each deliverable requires non-empty id and path fields.", at); + continue; + } + if (ids.has(deliverable.id)) add(context, "configuration", "deliverable_id_duplicate", `Duplicate deliverable id: ${deliverable.id}.`, at); + if (paths.has(deliverable.path)) add(context, "configuration", "deliverable_path_duplicate", `Duplicate deliverable path: ${deliverable.path}.`, at); + ids.add(deliverable.id); + paths.add(deliverable.path); + try { + const filePath = resolveWithinWorkspace(context.workspace, deliverable.path); + if (deliverable.required !== false) { + const info = await stat(filePath).catch(() => undefined); + if (!info?.isFile() || info.size === 0) { + add(context, "coverage", "deliverable_missing", `Required deliverable is missing or empty: ${deliverable.path}.`, deliverable.path); + } else { + const data = await readFile(filePath); + context.deliverables.set(deliverable.path, { sha256: sha256(data), bytes: data.byteLength }); + if (TEXT_EXTENSIONS.has(extname(filePath).toLowerCase())) { + context.deliverableContents.set(deliverable.path, data.toString("utf8")); + } + } + } + } catch (error) { + add(context, "configuration", "deliverable_path_invalid", errorMessage(error), at); + } + } +} + +async function validateSources(context) { + const ledger = context.state.sources; + if (!isRecord(ledger)) return; + requireSchemaVersion(context, "sources", ledger, STATE_FILES.sources); + if (!Array.isArray(ledger.sources)) { + add(context, "sources", "sources_not_array", "sources.json must contain a sources array.", STATE_FILES.sources); + return; + } + const paths = new Set(); + for (const [index, source] of ledger.sources.entries()) { + const at = `${STATE_FILES.sources}#sources[${index}]`; + if (!isRecord(source) || !nonEmpty(source.id) || !nonEmpty(source.path)) { + add(context, "sources", "source_invalid", "Each source requires non-empty id and path fields.", at); + continue; + } + uniqueId(context, context.sourceIds, source.id, "sources", "source_id_duplicate", at); + if (paths.has(source.path)) add(context, "sources", "source_path_duplicate", `Source path is duplicated: ${source.path}.`, at); + paths.add(source.path); + if (!SOURCE_STATUSES.has(source.status)) add(context, "sources", "source_status_invalid", `Source ${source.id} has an invalid status.`, at); + if (source.status === "pending") add(context, "sources", "source_pending", `Source ${source.id} is still pending review.`, at); + if (source.status === "reviewed") { + if (!nonEmpty(source.extractionMethod)) add(context, "sources", "extraction_method_missing", `Source ${source.id} requires extractionMethod.`, at); + if (!EVIDENCE_CLASSES.has(source.evidenceClass)) add(context, "sources", "evidence_class_invalid", `Source ${source.id} requires a recognized evidenceClass.`, at); + const factIds = stringArray(source.factIds); + if (factIds.length === 0 && !nonEmpty(source.noMaterialFactsReason)) { + add(context, "sources", "source_disposition_missing", `Source ${source.id} must list factIds or explain why it contains no material facts.`, at); + } + } + if (source.status === "unreadable" && stringArray(source.unresolvedItems).length === 0) { + add(context, "sources", "unreadable_source_unresolved", `Unreadable source ${source.id} must record unresolvedItems.`, at); + } + try { + const sourcePath = resolveWithinWorkspace(context.workspace, source.path); + const info = await lstat(sourcePath).catch(() => undefined); + if (!info?.isFile()) add(context, "sources", "source_file_missing", `Inventoried source is missing or not a file: ${source.path}.`, source.path); + } catch (error) { + add(context, "sources", "source_path_invalid", errorMessage(error), at); + } + } + + const config = context.state.config; + if (!isRecord(config) || !Array.isArray(config.inputRoots)) return; + const discovered = new Set(); + for (const inputRoot of config.inputRoots) { + if (!nonEmpty(inputRoot)) { + add(context, "configuration", "input_root_invalid", "Every input root must be a non-empty workspace-relative path.", STATE_FILES.config); + continue; + } + try { + const root = resolveWithinWorkspace(context.workspace, inputRoot); + for (const path of await listSourceFiles(context.workspace, root)) discovered.add(path); + } catch (error) { + add(context, "sources", "input_root_unreadable", `Cannot inventory input root ${inputRoot}: ${errorMessage(error)}`, inputRoot); + } + } + for (const path of discovered) { + if (!paths.has(path)) add(context, "sources", "source_not_inventoried", `Source file is not represented in sources.json: ${path}.`, path); + } + for (const path of paths) { + if (!discovered.has(path)) add(context, "sources", "source_outside_inputs", `Ledger source is not under a configured input root: ${path}.`, path); + } +} + +function validateFacts(context) { + const ledger = context.state.facts; + if (!isRecord(ledger)) return; + requireSchemaVersion(context, "facts", ledger, STATE_FILES.facts); + if (!Array.isArray(ledger.facts)) { + add(context, "facts", "facts_not_array", "facts.json must contain a facts array.", STATE_FILES.facts); + return; + } + if (ledger.facts.length === 0 && context.state.config?.allowNoMaterialFacts !== true) { + add(context, "facts", "material_facts_missing", "Record source-grounded legal facts, or set config.allowNoMaterialFacts to true only when the entire reviewed source set is genuinely non-responsive.", STATE_FILES.facts); + } + for (const [index, fact] of ledger.facts.entries()) { + const at = `${STATE_FILES.facts}#facts[${index}]`; + if (!isRecord(fact) || !nonEmpty(fact.id)) { + add(context, "facts", "fact_invalid", "Each fact requires a non-empty id.", at); + continue; + } + uniqueId(context, context.factIds, fact.id, "facts", "fact_id_duplicate", at); + for (const field of ["subject", "predicate"]) { + if (!nonEmpty(fact[field])) add(context, "facts", `fact_${field}_missing`, `Fact ${fact.id} requires ${field}.`, at); + } + if (!hasValue(fact.value)) add(context, "facts", "fact_value_missing", `Fact ${fact.id} requires value.`, at); + if (!nonEmpty(fact.dateOrPeriod) && !nonEmpty(fact.missingTimeReason)) { + add(context, "facts", "fact_time_missing", `Fact ${fact.id} requires dateOrPeriod or missingTimeReason.`, at); + } + if (!EVIDENCE_CLASSES.has(fact.evidenceClass)) add(context, "facts", "fact_evidence_class_invalid", `Fact ${fact.id} requires a recognized evidenceClass.`, at); + if (!VERIFICATION_STATUSES.has(fact.verificationStatus)) add(context, "facts", "fact_verification_invalid", `Fact ${fact.id} requires verificationStatus.`, at); + if (!CONFLICT_STATUSES.has(fact.conflictStatus)) add(context, "facts", "fact_conflict_invalid", `Fact ${fact.id} requires conflictStatus.`, at); + if (typeof fact.material !== "boolean" || typeof fact.critical !== "boolean") { + add(context, "facts", "fact_materiality_missing", `Fact ${fact.id} requires boolean material and critical fields.`, at); + } + if (!Array.isArray(fact.sourceRefs) || fact.sourceRefs.length === 0) { + add(context, "facts", "fact_sources_missing", `Fact ${fact.id} requires at least one source reference.`, at); + } else { + for (const ref of fact.sourceRefs) { + if (!isRecord(ref) || !nonEmpty(ref.sourceId) || !nonEmpty(ref.locator)) { + add(context, "facts", "fact_source_ref_invalid", `Fact ${fact.id} has an incomplete source reference.`, at); + } else if (!context.sourceIds.has(ref.sourceId)) { + add(context, "facts", "fact_source_unknown", `Fact ${fact.id} references unknown source ${ref.sourceId}.`, at); + } + } + } + validateThresholdAssessment(context, fact, at); + } + + const sourceRows = Array.isArray(context.state.sources?.sources) ? context.state.sources.sources : []; + for (const source of sourceRows) { + if (!isRecord(source)) continue; + for (const factId of stringArray(source.factIds)) { + if (!context.factIds.has(factId)) add(context, "facts", "source_fact_unknown", `Source ${source.id} references unknown fact ${factId}.`, STATE_FILES.sources); + } + } +} + +function validateMatrices(context) { + const ledger = context.state.matrices; + if (!isRecord(ledger)) return; + requireSchemaVersion(context, "matrices", ledger, STATE_FILES.matrices); + if (!Array.isArray(ledger.matrices)) { + add(context, "matrices", "matrices_not_array", "matrices.json must contain a matrices array.", STATE_FILES.matrices); + return; + } + const byId = new Map(); + const matrixFactIds = new Set(); + for (const [index, matrix] of ledger.matrices.entries()) { + const at = `${STATE_FILES.matrices}#matrices[${index}]`; + if (!isRecord(matrix) || !nonEmpty(matrix.id)) { + add(context, "matrices", "matrix_invalid", "Each matrix requires a non-empty id.", at); + continue; + } + if (byId.has(matrix.id)) add(context, "matrices", "matrix_duplicate", `Duplicate matrix ${matrix.id}.`, at); + byId.set(matrix.id, matrix); + if (!MATRIX_STATUSES.has(matrix.status)) add(context, "matrices", "matrix_status_invalid", `Matrix ${matrix.id} has an invalid status.`, at); + if (matrix.status === "pending") add(context, "matrices", "matrix_pending", `Matrix ${matrix.id} is still pending.`, at); + const entries = Array.isArray(matrix.entries) ? matrix.entries : []; + if (matrix.status === "complete" && entries.length === 0) add(context, "matrices", "matrix_empty", `Complete matrix ${matrix.id} requires at least one entry.`, at); + if (matrix.status === "not-applicable" && !nonEmpty(matrix.notApplicableReason)) { + add(context, "matrices", "matrix_na_reason_missing", `Matrix ${matrix.id} requires notApplicableReason.`, at); + } + for (const entry of entries) { + for (const factId of stringArray(entry?.factIds)) matrixFactIds.add(factId); + validateMatrixEntry(context, matrix.id, entry, at); + } + } + for (const id of REQUIRED_MATRICES) { + if (!byId.has(id)) add(context, "matrices", "required_matrix_missing", `Required legal matrix is missing: ${id}.`, STATE_FILES.matrices); + } + const facts = Array.isArray(context.state.facts?.facts) ? context.state.facts.facts : []; + for (const fact of facts) { + if (isRecord(fact) && (fact.material === true || fact.critical === true) && !matrixFactIds.has(fact.id)) { + add(context, "matrices", "material_fact_matrix_orphaned", `Material fact ${fact.id} must appear in at least one legal matrix entry.`, STATE_FILES.matrices); + } + } +} + +function validateMatrixEntry(context, matrixId, entry, at) { + if (!isRecord(entry) || !nonEmpty(entry.id) || !nonEmpty(entry.summary)) { + add(context, "matrices", "matrix_entry_invalid", `Matrix ${matrixId} has an entry without id or summary.`, at); + return; + } + const factIds = stringArray(entry.factIds); + if (factIds.length === 0) add(context, "matrices", "matrix_entry_facts_missing", `Matrix entry ${entry.id} requires factIds.`, at); + for (const factId of factIds) { + if (!context.factIds.has(factId)) add(context, "matrices", "matrix_fact_unknown", `Matrix entry ${entry.id} references unknown fact ${factId}.`, at); + } + const signals = stringArray(entry.riskSignals); + for (const signal of signals) { + if (!Object.values(ISSUE_RULES).includes(signal)) add(context, "matrices", "risk_signal_unknown", `Matrix entry ${entry.id} has unknown risk signal ${signal}.`, at); + } + if (signals.length > 0 && stringArray(entry.issueIds).length === 0) { + add(context, "issues", "risk_signal_orphaned", `Matrix entry ${entry.id} has risk signals but no issueIds.`, at); + } + if (matrixId === "legal-authority" && stringArray(entry.authorityIds).length === 0) { + add(context, "authorities", "legal_authority_links_missing", `Legal-authority matrix entry ${entry.id} requires authorityIds.`, at); + } +} + +function validateIssues(context) { + const ledger = context.state.issues; + if (!isRecord(ledger)) return; + requireSchemaVersion(context, "issues", ledger, STATE_FILES.issues); + if (!Array.isArray(ledger.issues)) { + add(context, "issues", "issues_not_array", "issues.json must contain an issues array.", STATE_FILES.issues); + return; + } + const issuesByRule = new Map(); + for (const [index, legalIssue] of ledger.issues.entries()) { + const at = `${STATE_FILES.issues}#issues[${index}]`; + if (!isRecord(legalIssue) || !nonEmpty(legalIssue.id)) { + add(context, "issues", "issue_invalid", "Each issue requires a non-empty id.", at); + continue; + } + uniqueId(context, context.issueIds, legalIssue.id, "issues", "issue_id_duplicate", at); + if (!Object.hasOwn(ISSUE_RULES, legalIssue.ruleId)) { + add(context, "issues", "issue_rule_invalid", `Issue ${legalIssue.id} requires one of these ruleId values: ${Object.keys(ISSUE_RULES).join(", ")}. Matrix riskSignals use the separate underscore values.`, at); + } + if (!ISSUE_STATUSES.has(legalIssue.status)) add(context, "issues", "issue_status_invalid", `Issue ${legalIssue.id} requires status.`, at); + if (!nonEmpty(legalIssue.analysis) || !nonEmpty(legalIssue.conclusion)) add(context, "issues", "issue_reasoning_missing", `Issue ${legalIssue.id} requires analysis and conclusion.`, at); + if (typeof legalIssue.critical !== "boolean" || !nonEmpty(legalIssue.severity)) add(context, "issues", "issue_severity_missing", `Issue ${legalIssue.id} requires severity and critical.`, at); + const factIds = stringArray(legalIssue.factIds); + if (factIds.length === 0) add(context, "issues", "issue_facts_missing", `Issue ${legalIssue.id} requires factIds.`, at); + for (const factId of factIds) { + if (!context.factIds.has(factId)) add(context, "issues", "issue_fact_unknown", `Issue ${legalIssue.id} references unknown fact ${factId}.`, at); + } + if (stringArray(legalIssue.recommendations).length === 0) add(context, "issues", "issue_recommendations_missing", `Issue ${legalIssue.id} requires at least one recommendation or transaction control.`, at); + const authorityIds = stringArray(legalIssue.authorityIds); + if (legalIssue.critical === true && authorityIds.length === 0) { + add(context, "authorities", "critical_issue_authority_missing", `Critical issue ${legalIssue.id} requires at least one authorityId; authorityNotRequiredReason cannot waive authority support for a critical legal conclusion.`, at); + } + const minimumFacts = ["timeline-collision", "rights-governance-conflict", "liquidity-relationship", "source-contradiction"].includes(legalIssue.ruleId) ? 2 : 1; + if (factIds.length < minimumFacts) { + add(context, "issues", "issue_relationship_incomplete", `Issue ${legalIssue.id} rule ${legalIssue.ruleId} requires at least ${minimumFacts} linked fact${minimumFacts === 1 ? "" : "s"}.`, at); + } + const list = issuesByRule.get(legalIssue.ruleId) ?? []; + list.push(legalIssue); + issuesByRule.set(legalIssue.ruleId, list); + } + + const facts = Array.isArray(context.state.facts?.facts) ? context.state.facts.facts : []; + for (const fact of facts) { + if (!isRecord(fact) || !nonEmpty(fact.id)) continue; + if (fact.conflictStatus === "unresolved" && !issueCoversFact(issuesByRule.get("source-contradiction"), fact.id)) { + add(context, "issues", "unresolved_conflict_orphaned", `Unresolved fact conflict ${fact.id} requires a source-contradiction issue.`, STATE_FILES.issues); + } + if (fact.thresholdAssessment?.breached === true && !issueCoversFact(issuesByRule.get("threshold-breach"), fact.id)) { + add(context, "issues", "threshold_breach_orphaned", `Threshold breach ${fact.id} requires a threshold-breach issue.`, STATE_FILES.issues); + } + } + for (const collision of detectTimelineCollisions(facts)) { + const covered = (issuesByRule.get("timeline-collision") ?? []).some((item) => collision.every((id) => stringArray(item.factIds).includes(id))); + if (!covered) add(context, "issues", "timeline_collision_orphaned", `Conflicting dated facts ${collision.join(", ")} require a timeline-collision issue.`, STATE_FILES.issues); + } + const matrices = Array.isArray(context.state.matrices?.matrices) ? context.state.matrices.matrices : []; + for (const matrix of matrices) { + for (const entry of Array.isArray(matrix?.entries) ? matrix.entries : []) { + for (const issueId of stringArray(entry?.issueIds)) { + if (!context.issueIds.has(issueId)) add(context, "issues", "matrix_issue_unknown", `Matrix entry ${entry.id} references unknown issue ${issueId}.`, STATE_FILES.matrices); + } + for (const signal of stringArray(entry?.riskSignals)) { + const ruleId = Object.keys(ISSUE_RULES).find((key) => ISSUE_RULES[key] === signal); + const linked = stringArray(entry?.issueIds).some((issueId) => (ledger.issues ?? []).some((item) => item?.id === issueId && item?.ruleId === ruleId)); + if (!linked) add(context, "issues", "risk_signal_rule_mismatch", `Risk signal ${signal} on ${entry.id} requires a linked ${ruleId} issue.`, STATE_FILES.matrices); + } + } + } +} + +function validateAuthorities(context) { + const ledger = context.state.authorities; + if (!isRecord(ledger)) return; + requireSchemaVersion(context, "authorities", ledger, STATE_FILES.authorities); + if (!Array.isArray(ledger.authorities)) { + add(context, "authorities", "authorities_not_array", "authorities.json must contain an authorities array.", STATE_FILES.authorities); + return; + } + for (const [index, authority] of ledger.authorities.entries()) { + const at = `${STATE_FILES.authorities}#authorities[${index}]`; + if (!isRecord(authority) || !nonEmpty(authority.id)) { + add(context, "authorities", "authority_invalid", "Each authority requires a non-empty id.", at); + continue; + } + uniqueId(context, context.authorityIds, authority.id, "authorities", "authority_id_duplicate", at); + if (!AUTHORITY_STATUSES.has(authority.verificationStatus)) add(context, "authorities", "authority_status_invalid", `Authority ${authority.id} requires verificationStatus.`, at); + if (!nonEmpty(authority.name) || !nonEmpty(authority.supportedConclusion)) add(context, "authorities", "authority_content_missing", `Authority ${authority.id} requires name and supportedConclusion.`, at); + if (authority.verificationStatus === "verified") { + for (const field of ["article", "effectiveVersion", "effectiveDate", "sourceLocator"]) { + if (!nonEmpty(authority[field])) add(context, "authorities", `authority_${field}_missing`, `Verified authority ${authority.id} requires ${field}.`, at); + } + } + if (authority.verificationStatus === "pending-verification" && !nonEmpty(authority.pendingReason)) { + add(context, "authorities", "authority_pending_reason_missing", `Pending authority ${authority.id} requires pendingReason.`, at); + } + const issueIds = stringArray(authority.supportedIssueIds); + if (issueIds.length === 0 && authority.verificationStatus !== "not-applicable") add(context, "authorities", "authority_issues_missing", `Authority ${authority.id} must identify supportedIssueIds.`, at); + for (const issueId of issueIds) { + if (!context.issueIds.has(issueId)) add(context, "authorities", "authority_issue_unknown", `Authority ${authority.id} references unknown issue ${issueId}.`, at); + } + } + const issues = Array.isArray(context.state.issues?.issues) ? context.state.issues.issues : []; + for (const legalIssue of issues) { + if (!isRecord(legalIssue)) continue; + for (const authorityId of stringArray(legalIssue.authorityIds)) { + if (!context.authorityIds.has(authorityId)) add(context, "authorities", "issue_authority_unknown", `Issue ${legalIssue.id} references unknown authority ${authorityId}.`, STATE_FILES.issues); + const authority = ledger.authorities.find((item) => item?.id === authorityId); + if (authority && !stringArray(authority.supportedIssueIds).includes(legalIssue.id)) { + add(context, "authorities", "authority_issue_backlink_missing", `Authority ${authorityId} must backlink issue ${legalIssue.id}.`, STATE_FILES.authorities); + } + } + } + const matrices = Array.isArray(context.state.matrices?.matrices) ? context.state.matrices.matrices : []; + const legalAuthorityMatrix = matrices.find((matrix) => matrix?.id === "legal-authority"); + for (const entry of Array.isArray(legalAuthorityMatrix?.entries) ? legalAuthorityMatrix.entries : []) { + for (const authorityId of stringArray(entry?.authorityIds)) { + if (!context.authorityIds.has(authorityId)) { + add(context, "authorities", "matrix_authority_unknown", `Legal-authority matrix entry ${entry.id} references unknown authority ${authorityId}.`, STATE_FILES.matrices); + } + } + } +} + +async function validateCoverage(context) { + const manifest = context.state.coverage; + if (!isRecord(manifest)) return; + requireSchemaVersion(context, "coverage", manifest, STATE_FILES.coverage); + const configDeliverables = Array.isArray(context.state.config?.deliverables) ? context.state.config.deliverables : []; + const manifestDeliverables = Array.isArray(manifest.deliverables) ? manifest.deliverables : []; + for (const deliverable of configDeliverables) { + if (!isRecord(deliverable) || deliverable.required === false || !nonEmpty(deliverable.path)) continue; + const actual = context.deliverables.get(deliverable.path); + const record = manifestDeliverables.find((item) => item?.path === deliverable.path); + if (!record) { + add(context, "coverage", "deliverable_hash_missing", `Coverage manifest must bind required deliverable ${deliverable.path}.`, STATE_FILES.coverage); + } else if (actual && record.sha256 !== actual.sha256) { + add(context, "coverage", "deliverable_hash_stale", `Coverage hash is stale for ${deliverable.path}.`, STATE_FILES.coverage); + } + } + + const sourceCoverage = coverageMap(context, manifest.sources, "sourceId", "sources", context.sourceIds); + const factCoverage = coverageMap(context, manifest.facts, "factId", "facts", context.factIds); + const issueCoverage = coverageMap(context, manifest.issues, "issueId", "issues", context.issueIds); + const authorityCoverage = coverageMap(context, manifest.authorities, "authorityId", "authorities", context.authorityIds); + const sources = Array.isArray(context.state.sources?.sources) ? context.state.sources.sources : []; + for (const source of sources) { + if (!isRecord(source) || !nonEmpty(source.id)) continue; + if ((source.status === "unreadable" || stringArray(source.unresolvedItems).length > 0) && sourceCoverage.get(source.id)?.status !== "unresolved") { + add(context, "coverage", "unresolved_source_not_disclosed", `Source ${source.id} has unresolved review items and must be mapped as unresolved in final coverage.`, STATE_FILES.coverage); + } + } + const facts = Array.isArray(context.state.facts?.facts) ? context.state.facts.facts : []; + for (const fact of facts) { + if (!isRecord(fact) || !(fact.material === true || fact.critical === true)) continue; + const coverage = factCoverage.get(fact.id); + if (!coverage) add(context, "coverage", "material_fact_orphaned", `Material fact ${fact.id} is not mapped to a final deliverable.`, STATE_FILES.coverage); + if (coverage && context.deliverableContents.has(coverage.deliverablePath) && !factCoverageQuoteSupports(fact, coverage.quote)) { + add(context, "coverage", "fact_coverage_quote_unsupported", `Coverage quote for material fact ${fact.id} must contain its subject and either its predicate, value, or date/period.`, STATE_FILES.coverage); + } + if (fact.conflictStatus === "unresolved" && coverage?.status !== "unresolved") { + add(context, "coverage", "conflict_not_disclosed", `Unresolved fact ${fact.id} must be marked unresolved in final coverage.`, STATE_FILES.coverage); + } + } + const issues = Array.isArray(context.state.issues?.issues) ? context.state.issues.issues : []; + for (const legalIssue of issues) { + if (!isRecord(legalIssue)) continue; + const coverage = issueCoverage.get(legalIssue.id); + if (!coverage) add(context, "coverage", "issue_orphaned", `Legal issue ${legalIssue.id} is not mapped to a final deliverable.`, STATE_FILES.coverage); + if (legalIssue.status === "unresolved" && coverage?.status !== "unresolved") { + add(context, "coverage", "unresolved_issue_not_disclosed", `Unresolved issue ${legalIssue.id} must be marked unresolved in final coverage.`, STATE_FILES.coverage); + } + } + const authorities = Array.isArray(context.state.authorities?.authorities) ? context.state.authorities.authorities : []; + for (const authority of authorities) { + if (!isRecord(authority) || authority.verificationStatus === "not-applicable") continue; + const coverage = authorityCoverage.get(authority.id); + if (!coverage) add(context, "coverage", "authority_orphaned", `Authority ${authority.id} is not mapped to a final deliverable.`, STATE_FILES.coverage); + if (authority.verificationStatus === "pending-verification" && coverage?.status !== "unresolved") { + add(context, "coverage", "pending_authority_not_disclosed", `Pending authority ${authority.id} must be marked unresolved in final coverage.`, STATE_FILES.coverage); + } + } +} + +function coverageMap(context, rows, idField, group, knownIds) { + const map = new Map(); + const quoteOwners = new Map(); + if (!Array.isArray(rows)) { + add(context, "coverage", `${group}_coverage_not_array`, `coverage.json must contain a ${group} array.`, STATE_FILES.coverage); + return map; + } + for (const [index, row] of rows.entries()) { + const at = `${STATE_FILES.coverage}#${group}[${index}]`; + if (!isRecord(row) || !nonEmpty(row[idField])) { + add(context, "coverage", `${group}_coverage_invalid`, `Each ${group} coverage row requires ${idField}.`, at); + continue; + } + if (map.has(row[idField])) add(context, "coverage", `${group}_coverage_duplicate`, `Duplicate coverage row for ${row[idField]}.`, at); + map.set(row[idField], row); + if (!knownIds.has(row[idField])) add(context, "coverage", `${group}_coverage_unknown`, `Coverage row references unknown ${idField} ${row[idField]}.`, at); + if (!COVERAGE_STATUSES.has(row.status) || !nonEmpty(row.deliverablePath) || !nonEmpty(row.section) || !nonEmpty(row.claim) || !nonEmpty(row.locator)) { + add(context, "coverage", `${group}_coverage_incomplete`, `Coverage row ${row[idField]} requires status (covered or unresolved), deliverablePath, section, claim, and locator.`, at); + continue; + } + const deliverable = context.deliverables.get(row.deliverablePath); + if (!deliverable) add(context, "coverage", "coverage_deliverable_unknown", `Coverage row ${row[idField]} references an unavailable deliverable ${row.deliverablePath}.`, at); + const text = context.deliverableContents.get(row.deliverablePath); + if (text !== undefined) { + if (!nonEmpty(row.quote)) { + add(context, "coverage", "coverage_quote_missing", `Text deliverable coverage for ${row[idField]} requires an exact quote.`, at); + } else if (!text.includes(row.quote)) { + add(context, "coverage", "coverage_quote_not_found", `Coverage quote for ${row[idField]} is not present in ${row.deliverablePath}.`, at); + } else { + const quoteKey = normalizeEvidenceText(row.quote); + const owner = quoteOwners.get(quoteKey); + if (owner && owner !== row[idField]) { + add(context, "coverage", "coverage_quote_reused", `Coverage quote for ${row[idField]} is already used by ${owner}; each ${group} row needs distinct supporting text.`, at); + } else { + quoteOwners.set(quoteKey, row[idField]); + } + } + } + } + return map; +} + +function validateThresholdAssessment(context, fact, at) { + if (fact.thresholdAssessment === undefined || fact.thresholdAssessment === null) return; + const assessment = fact.thresholdAssessment; + if (!isRecord(assessment) || !["gt", "gte", "lt", "lte", "eq"].includes(assessment.operator) + || typeof assessment.actual !== "number" || typeof assessment.threshold !== "number" || typeof assessment.breached !== "boolean") { + add(context, "facts", "threshold_assessment_invalid", `Fact ${fact.id} has an invalid thresholdAssessment.`, at); + return; + } + const calculated = compareThreshold(assessment.actual, assessment.operator, assessment.threshold); + if (calculated !== assessment.breached) add(context, "facts", "threshold_assessment_mismatch", `Fact ${fact.id} thresholdAssessment.breached does not match its numeric comparison.`, at); +} + +function factCoverageQuoteSupports(fact, quote) { + if (!nonEmpty(quote)) return false; + const normalizedQuote = normalizeEvidenceText(quote); + const subject = normalizeEvidenceText(fact.subject); + if (subject.length < 2 || !normalizedQuote.includes(subject)) return false; + const details = [fact.predicate, fact.dateOrPeriod, ...scalarValues(fact.value)] + .map(normalizeEvidenceText) + .filter((value) => value.length >= 2 && value !== subject); + return details.some((value) => normalizedQuote.includes(value)); +} + +function scalarValues(value) { + if (Array.isArray(value)) return value.flatMap(scalarValues); + if (isRecord(value)) return Object.values(value).flatMap(scalarValues); + if (typeof value === "string" || typeof value === "number") return [String(value)]; + return []; +} + +function normalizeEvidenceText(value) { + return normalize(value).replace(/[\p{P}\p{S}\s]+/gu, ""); +} + +function compareThreshold(actual, operator, threshold) { + if (operator === "gt") return actual > threshold; + if (operator === "gte") return actual >= threshold; + if (operator === "lt") return actual < threshold; + if (operator === "lte") return actual <= threshold; + return actual === threshold; +} + +function detectTimelineCollisions(facts) { + const groups = new Map(); + for (const fact of facts) { + if (!isRecord(fact) || !nonEmpty(fact.id) || !nonEmpty(fact.subject) || !nonEmpty(fact.predicate) || !nonEmpty(fact.dateOrPeriod)) continue; + const key = [normalize(fact.subject), normalize(fact.predicate), normalize(fact.dateOrPeriod)].join("|"); + const list = groups.get(key) ?? []; + list.push(fact); + groups.set(key, list); + } + const collisions = []; + for (const group of groups.values()) { + if (group.length < 2) continue; + const values = new Set(group.map((fact) => stableStringify(fact.value))); + if (values.size > 1) collisions.push(group.map((fact) => fact.id).sort()); + } + return collisions; +} + +async function listSourceFiles(workspace, root) { + const rootInfo = await lstat(root); + if (rootInfo.isSymbolicLink()) throw new Error("Input roots may not be symbolic links."); + if (rootInfo.isFile()) return [toWorkspacePath(workspace, root)]; + if (!rootInfo.isDirectory()) throw new Error("Input root must be a file or directory."); + const files = []; + const entries = await readdir(root, { withFileTypes: true }); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name === ".DS_Store" || entry.name === ".git") continue; + const fullPath = resolve(root, entry.name); + if (entry.isSymbolicLink()) throw new Error(`Symbolic link is not allowed in an input root: ${toWorkspacePath(workspace, fullPath)}`); + if (entry.isDirectory()) files.push(...await listSourceFiles(workspace, fullPath)); + if (entry.isFile()) files.push(toWorkspacePath(workspace, fullPath)); + } + return files; +} + +async function computeStateHash(context) { + const state = {}; + for (const key of Object.keys(STATE_FILES)) state[key] = context.state[key]; + const deliverables = [...context.deliverables.entries()] + .map(([path, value]) => ({ path, sha256: value.sha256, bytes: value.bytes })) + .sort((a, b) => a.path.localeCompare(b.path)); + return sha256(stableStringify({ validatorVersion: VALIDATOR_VERSION, state, deliverables })); +} + +async function writeJsonAtomic(filePath, value) { + await mkdir(dirname(filePath), { recursive: true }); + const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + await rename(temporary, filePath); +} + +function requireSchemaVersion(context, phase, value, path) { + if (value.schemaVersion !== 1) add(context, phase, "schema_version_invalid", `${path} must use schemaVersion 1.`, path); +} + +function uniqueId(context, set, id, phase, code, path) { + if (set.has(id)) add(context, phase, code, `Duplicate id: ${id}.`, path); + set.add(id); +} + +function add(context, phase, code, message, path) { + context.errors.push(issue(phase, code, message, path)); +} + +function issue(phase, code, message, path) { + return { phase, code, message, path }; +} + +function phaseForStateKey(key) { + return key === "config" ? "configuration" : key; +} + +function issueCoversFact(issues, factId) { + return (issues ?? []).some((item) => stringArray(item.factIds).includes(factId)); +} + +function toWorkspacePath(workspace, filePath) { + return relative(workspace, filePath).split(sep).join("/"); +} + +function stringArray(value) { + return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim() !== "") : []; +} + +function nonEmpty(value) { + return typeof value === "string" && value.trim() !== ""; +} + +function hasValue(value) { + return value !== undefined && value !== null && (!(typeof value === "string") || value.trim() !== ""); +} + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalize(value) { + return String(value).normalize("NFKC").trim().toLowerCase().replace(/\s+/gu, " "); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (isRecord(value)) { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function pathExists(path) { + try { + await stat(path); + return true; + } catch { + return false; + } +} diff --git a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/data-contracts.txt b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/data-contracts.txt new file mode 100644 index 00000000..b4eeacdb --- /dev/null +++ b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/data-contracts.txt @@ -0,0 +1,67 @@ +LEGAL COVERAGE DATA CONTRACTS + +All documents use schemaVersion 1 and live under .pilotdeck/work/legal-coverage. + +Canonical writer rule +- Only the main agent writes the seven canonical JSON ledgers and final deliverables. +- For input rooms over 20 files, use two to four disjoint read-only worker batches. Workers write evidence fragments, never canonical ledgers. +- A fragment records source path/ID, inspection method, locator-grounded facts, evidence class, verification state, conflicts, unresolved items, and proposed materiality. + +config.json +- enabled: true +- jurisdiction: non-empty governing jurisdiction +- basisDate: non-empty review basis date or period +- allowNoMaterialFacts: false by default; true only after the entire reviewed source set is genuinely non-responsive +- inputRoots: non-empty array of workspace-relative file or directory paths +- deliverables: non-empty array of { id, path, required } + +sources.json +- sources: one row per physical file under inputRoots +- row: { id, path, status, extractionMethod, evidenceClass, factIds, noMaterialFactsReason, unresolvedItems } +- status: reviewed | unreadable | pending +- evidenceClass: official-record | executed-contract | company-disclosure | financial-record | third-party-record | interview | image-or-scan | other +- A reviewed row needs extractionMethod, evidenceClass, and either factIds or noMaterialFactsReason. +- An unreadable row needs unresolvedItems. Pending rows block completion. + +facts.json +- facts: atomic fact rows +- row: { id, subject, predicate, value, unit, dateOrPeriod, missingTimeReason, sourceRefs, evidenceClass, verificationStatus, conflictStatus, material, critical, thresholdAssessment } +- sourceRefs: non-empty array of { sourceId, locator } +- verificationStatus: verified | partially-verified | unverified +- conflictStatus: none | resolved | unresolved +- dateOrPeriod is required unless missingTimeReason explains why the source has no usable time. +- thresholdAssessment is optional: { operator, actual, threshold, unit, breached }, where operator is gt | gte | lt | lte | eq. +- Omit thresholdAssessment or use null when the fact has no analytical threshold. +- material is true only when the fact changes a legal conclusion, risk severity, transaction control, or unresolved disclosure. critical is reserved for facts that may block or materially restructure the transaction. + +matrices.json +- matrices: exactly one row for each required matrix ID listed in issue-rules.txt +- row: { id, status, entries, notApplicableReason } +- status: pending | complete | not-applicable +- entry: { id, summary, factIds, riskSignals, issueIds, authorityIds } +- A complete matrix needs entries. A not-applicable matrix needs a specific reason. +- Every entry in a complete legal-authority matrix needs authorityIds. +- Every material or critical fact must appear in at least one matrix entry. + +issues.json +- issues: { id, ruleId, status, severity, critical, factIds, authorityIds, authorityNotRequiredReason, analysis, conclusion, recommendations } +- status: open | mitigated | unresolved +- recommendations is a non-empty array of legal or transaction controls. +- Every critical issue requires authorityIds. authorityNotRequiredReason cannot waive authority support for a critical issue. + +authorities.json +- authorities: { id, name, article, effectiveVersion, effectiveDate, verificationStatus, sourceLocator, pendingReason, supportedIssueIds, supportedConclusion } +- verificationStatus: verified | pending-verification | not-applicable +- Verified authorities require article, effectiveVersion, effectiveDate, and sourceLocator. + +coverage.json +- deliverables: { path, sha256 } +- sources: { sourceId, status, deliverablePath, section, locator, claim, quote } +- facts: { factId, status, deliverablePath, section, locator, claim, quote } +- issues: { issueId, status, deliverablePath, section, locator, claim, quote } +- authorities: { authorityId, status, deliverablePath, section, locator, claim, quote } +- status: covered | unresolved +- Every unreadable source or source with unresolvedItems must have an unresolved source coverage row. +- quote is required and must occur exactly in text deliverables. Quotes must be distinct within each coverage group; one generic sentence cannot cover multiple records. +- A material-fact quote must contain the fact subject and either its predicate, value, or date/period. +- Binary deliverables still require a precise locator and claim. From 9435bc5d96bfc3bf4622c2b1874fc3c8c8a29100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Thu, 23 Jul 2026 23:22:40 +0800 Subject: [PATCH 02/14] feat(legal): add due diligence workflow skill --- .../conduct-legal-due-diligence/SKILL.md | 58 +++++++++++++++++++ .../agents/openai.yaml | 4 ++ .../references/issue-rules.txt | 28 +++++++++ 3 files changed, 90 insertions(+) create mode 100644 products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md create mode 100644 products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/agents/openai.yaml create mode 100644 products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/issue-rules.txt diff --git a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md new file mode 100644 index 00000000..f0f4e9c8 --- /dev/null +++ b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md @@ -0,0 +1,58 @@ +--- +name: conduct-legal-due-diligence +description: Conduct source-grounded legal due diligence with structured source and fact ledgers, required legal matrices, cross-fact issue analysis, verified legal authorities, transaction controls, and fail-closed final-opinion coverage. Use for investment, acquisition, financing, compliance, or transaction legal reviews that require a formal legal opinion or risk report based on a document room. +--- + +# Conduct Legal Due Diligence + +Create the legal analysis; let the bundled validator enforce structure and coverage. Never create or edit `completion-proof.json` manually. + +## Start The Workspace + +1. Follow the current `` legal coverage milestone as the single next action. +2. If configuration is incomplete, run the injected `legal-coverage.mjs init` command with every source root, required deliverable, jurisdiction, and basis date. +3. Read `references/data-contracts.txt` before writing ledger data. Read `references/issue-rules.txt` before completing matrices or issues. +4. Keep all working state under `.pilotdeck/work/legal-coverage/`; keep user deliverables at the configured paths. + +## Keep Canonical State Single-Writer + +1. The main agent is the only writer for `config.json`, `sources.json`, `facts.json`, `matrices.json`, `issues.json`, `authorities.json`, `coverage.json`, and every final deliverable. +2. When an input room has more than 20 files, inventory paths first, then delegate two to four disjoint source batches before reading full source contents into the main context. For smaller rooms, delegate only when it reduces context pressure. +3. Use no more than four concurrent delegated workers. Give each worker a disjoint source batch or legal topic and an explicit output path under `.pilotdeck/work/legal-coverage/fragments/` (or a task-required evidence path). +4. Each fragment must list source path/ID, inspection method, locator-grounded atomic facts, evidence class, verification state, conflicts, unresolved items, and proposed materiality. The worker returns only the fragment path and a summary under 1,000 characters. +5. Delegated workers may inspect sources and write only their assigned evidence fragment. They must not edit canonical ledgers, the completion proof, or a final deliverable. +6. After workers return, the main agent reads fragments instead of replaying all raw source text and serially merges supported facts into canonical state. A failed worker is retried only for its missing batch; never restart completed batches. + +## Build Evidence Before Conclusions + +1. Inventory every file under every configured input root in `sources.json`. Use one stable source ID per file. +2. Inspect every machine-readable source completely, including all spreadsheet sheets and presentation slides. Mark a source `unreadable` only after deterministic extraction or inspection fails; record the unresolved items. +3. Record atomic facts in `facts.json`. Preserve the subject, predicate, value, unit, date or period, source locator, evidence class, verification state, conflict state, and materiality. Do not merge conflicting statements into one fact. +4. Set `material: true` only when the fact changes a legal conclusion, risk severity, transaction control, or unresolved disclosure. Set `critical: true` only when it may block or materially restructure the transaction. Do not default every extracted fact to material or critical. +5. Link each reviewed source to extracted fact IDs or give a specific `noMaterialFactsReason`. +6. Do not set `config.allowNoMaterialFacts` to true for a responsive diligence room. It exists only for a genuinely non-responsive source set after every file was reviewed. +7. Create the configured deliverable skeleton early and update it incrementally. Do not wait until research is complete to start the formal output. + +## Complete Legal Analysis + +1. Complete every required matrix in `matrices.json`, or record a fact-grounded not-applicable reason. Link every entry to facts. +2. Link every material or critical fact into at least one matrix. Never mark all matrices not-applicable merely to obtain a structural pass. +3. Apply the cross-fact rules. Create an issue for every timeline collision, threshold breach, rights or governance conflict, liquidity relationship, employment or IP ownership risk, and unresolved source contradiction. +4. Separate facts, assumptions, analysis, unresolved matters, conclusions, and recommendations. Preserve uncertainty instead of choosing an unsupported version. +5. Translate each material risk into concrete controls such as conditions precedent, remediation, representations, warranties, indemnities, price or structure changes, covenants, or post-closing monitoring. +6. Record every relied-on legal authority in `authorities.json`. Every critical issue requires at least one authority. For verified authorities, retain the name, article, effective version and date, source locator, and supported conclusion. Mark unverifiable citations pending instead of fabricating them. +7. Link every complete `legal-authority` matrix entry to `authorityIds`; do not use `authorityNotRequiredReason` to bypass authority support for a critical issue. + +## Bind Final Coverage + +1. Finish every configured deliverable before final coverage. +2. Compute each deliverable SHA-256 and record it in `coverage.json`. +3. Map every material or critical fact, every issue, and every used authority to a deliverable section and locator. +4. For text deliverables, copy an exact supporting quote into each coverage row. Each row needs distinct supporting text; do not reuse a generic sentence across facts or issues. +5. A material-fact quote must contain the fact subject and either its predicate, value, or date/period. Add a concise evidence appendix when the main analysis would otherwise become unreadable. +6. Mark unresolved facts, issues, and pending authorities as `unresolved` in coverage. Never hide a conflict or verification gap. +7. Run the injected validator command with `--write-proof`. Fix the first reported blocking condition, rerun, and stop only when it passes. + +## Completion Rule + +Completion is permitted only when the validator generated a current `.pilotdeck/work/legal-coverage/completion-proof.json`, the dynamic milestone reports validated state, and every other active domain skill and artifact contract has passed. A missing, manually created, stale, or prematurely generated proof is not completion. diff --git a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/agents/openai.yaml b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/agents/openai.yaml new file mode 100644 index 00000000..75cff5c5 --- /dev/null +++ b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Legal Due Diligence" + short_description: "Build auditable legal diligence opinions" + default_prompt: "Use $conduct-legal-due-diligence to produce a source-grounded legal due diligence opinion." diff --git a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/issue-rules.txt b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/issue-rules.txt new file mode 100644 index 00000000..9fb54ae2 --- /dev/null +++ b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/issue-rules.txt @@ -0,0 +1,28 @@ +LEGAL MATRICES AND CROSS-FACT ISSUE RULES + +Required matrix IDs +1. equity-capital-timeline +2. holding-platform-special-rights +3. governance-personnel-timeline +4. contract-key-terms +5. debt-collateral-liquidity +6. employment-ip-timeline +7. legal-authority + +Rule IDs and matrix riskSignals +- timeline-collision -> timeline_collision + Use when facts for the same subject, predicate, and date or period contain different values. The validator also detects this collision automatically. +- threshold-breach -> threshold_breach + Use when a normalized numeric threshold assessment is breached. The validator recomputes the comparison. +- rights-governance-conflict -> rights_governance_conflict + Use when ownership, special rights, voting, appointment, reserved matters, or actual control do not align. +- liquidity-relationship -> liquidity_relationship + Use when debt maturity, collateral, guarantees, cash, receivables, or operating obligations interact to create liquidity risk. +- employment-ip-ownership -> employment_ip_ownership + Use when personnel history, confidentiality, invention assignment, licensing, registration, or chain of title creates an ownership or continuity risk. +- source-contradiction -> source_contradiction + Use for every unresolved contradiction between source records. The validator requires this issue for facts whose conflictStatus is unresolved. + +For every riskSignal on a matrix entry, link an issue with the matching ruleId. Link all facts needed to explain the relationship, not only the fact that contains a number. State the legal significance and a concrete conclusion; a restatement of source text is not legal reasoning. + +Use the hyphenated value on the left only in issues.json ruleId. Use the underscored value on the right only in matrices.json riskSignals. Every critical issue must link at least one authority; a free-text waiver is not accepted. From 5493d46898015cf0b2d5219c830af12f73e24266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Thu, 23 Jul 2026 23:22:41 +0800 Subject: [PATCH 03/14] feat(legal): inject state-driven coverage milestones --- .../legal/plugins/legal-coverage/hook.mjs | 109 ++++++++++++++++++ .../plugins/legal-coverage/hooks/hooks.json | 42 +++++++ .../legal/plugins/legal-coverage/plugin.json | 7 ++ 3 files changed, 158 insertions(+) create mode 100644 products/legal/plugins/legal-coverage/hook.mjs create mode 100644 products/legal/plugins/legal-coverage/hooks/hooks.json create mode 100644 products/legal/plugins/legal-coverage/plugin.json diff --git a/products/legal/plugins/legal-coverage/hook.mjs b/products/legal/plugins/legal-coverage/hook.mjs new file mode 100644 index 00000000..32d71840 --- /dev/null +++ b/products/legal/plugins/legal-coverage/hook.mjs @@ -0,0 +1,109 @@ +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + PROOF_PATH, + activationMatches, + ensureWorkspace, + milestoneFor, + validateWorkspace, +} from "./scripts/lib/legal-coverage.mjs"; + +let body = ""; +for await (const chunk of process.stdin) body += chunk; +const input = JSON.parse(body); +const output = { hookSpecificOutput: { hookEventName: input.hookEventName } }; +const sessionFile = sessionStatePath(input.cwd, input.sessionId); +const cliPath = fileURLToPath(new URL("./scripts/legal-coverage.mjs", import.meta.url)); + +if (input.hookEventName === "UserPromptSubmit" && input.internal !== true) { + const configured = await hasConfiguredWorkspace(input.cwd); + const active = configured || activationMatches(String(input.prompt ?? "")); + if (active) { + await ensureWorkspace(input.cwd); + await writeSessionState(sessionFile, { active: true }); + output.hookSpecificOutput.dynamicContext = [{ + id: "legal-coverage-activation", + priority: "critical", + ttlMs: 60 * 60 * 1000, + content: [ + "Legal coverage controls are active for this task.", + "Load and apply the project skill legal-coverage:conduct-legal-due-diligence before substantive analysis.", + "Keep legal facts, issue rules, authorities, and coverage mappings under .pilotdeck/work/legal-coverage.", + "Use one main-agent writer for canonical ledgers and deliverables; delegated workers may write only disjoint evidence fragments.", + "The completion proof is generated only by the bundled validator and is required before completion.", + ].join("\n"), + }]; + output.hookSpecificOutput.artifactContracts = [{ + id: "legal-coverage-completion-proof", + path: PROOF_PATH, + required: true, + expectedExtensions: [".json"], + validatorIds: ["core:file-exists"], + domainId: "legal-due-diligence", + }]; + } +} + +const active = await readSessionState(sessionFile) + || await pathExists(`${input.cwd}/.pilotdeck/work/legal-coverage/config.json`) + || await pathExists(`${input.cwd}/${PROOF_PATH}`); +if (active && input.hookEventName === "PreModelRequest") { + const result = await validateWorkspace({ workspaceRoot: input.cwd, writeProof: true }); + output.hookSpecificOutput.additionalContext = milestoneFor(result, cliPath); + output.hookSpecificOutput.modelRequestPatch = { + metadata: { + legalCoverageActive: true, + legalCoverageState: result.passed ? "validated" : result.errors[0]?.phase ?? "incomplete", + }, + }; +} + +if (active && input.hookEventName === "Stop") { + const result = await validateWorkspace({ workspaceRoot: input.cwd, writeProof: true }); + if (!result.passed) { + output.continue = false; + output.stopReason = "legal_coverage_incomplete"; + output.reason = milestoneFor(result, cliPath); + } +} + +if (input.hookEventName === "SessionEnd") await rm(sessionFile, { force: true }); +console.log(JSON.stringify(output)); + +async function hasConfiguredWorkspace(workspaceRoot) { + try { + const config = JSON.parse(await readFile(`${workspaceRoot}/.pilotdeck/work/legal-coverage/config.json`, "utf8")); + return config?.enabled === true; + } catch { + return false; + } +} + +async function writeSessionState(path, value) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value)}\n`, { mode: 0o600 }); +} + +async function readSessionState(path) { + try { + const value = JSON.parse(await readFile(path, "utf8")); + return value?.active === true; + } catch { + return false; + } +} + +function sessionStatePath(workspaceRoot, sessionId) { + const safe = String(sessionId ?? "unknown").normalize("NFKC").replace(/[^a-zA-Z0-9._-]+/gu, "-").slice(0, 96) || "unknown"; + return `${workspaceRoot}/.pilotdeck/work/legal-coverage/sessions/${safe}.json`; +} + +async function pathExists(path) { + try { + await stat(path); + return true; + } catch { + return false; + } +} diff --git a/products/legal/plugins/legal-coverage/hooks/hooks.json b/products/legal/plugins/legal-coverage/hooks/hooks.json new file mode 100644 index 00000000..8c04669c --- /dev/null +++ b/products/legal/plugins/legal-coverage/hooks/hooks.json @@ -0,0 +1,42 @@ +{ + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node hook.mjs" + } + ] + } + ], + "PreModelRequest": [ + { + "hooks": [ + { + "type": "command", + "command": "node hook.mjs" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node hook.mjs" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "node hook.mjs" + } + ] + } + ] +} diff --git a/products/legal/plugins/legal-coverage/plugin.json b/products/legal/plugins/legal-coverage/plugin.json new file mode 100644 index 00000000..aec7bdd4 --- /dev/null +++ b/products/legal/plugins/legal-coverage/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "legal-coverage", + "version": "1.0.0", + "description": "Legal due-diligence coverage ledgers, dynamic milestones, and fail-closed completion proof.", + "skills": ["skills"], + "hooks": "hooks/hooks.json" +} From d3fcbd39251a8d89ab0df1bdd2ebad9a7c163630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Thu, 23 Jul 2026 23:22:41 +0800 Subject: [PATCH 04/14] docs(legal): document coverage plugin workflow --- products/legal/README.md | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 products/legal/README.md diff --git a/products/legal/README.md b/products/legal/README.md new file mode 100644 index 00000000..d32041fd --- /dev/null +++ b/products/legal/README.md @@ -0,0 +1,52 @@ +# PilotDeck Legal Product Bundle + +This product bundle keeps legal-domain knowledge outside PilotDeck Core. The +`legal-coverage` plugin provides the legal workflow, schemas, dynamic milestone +selection, deterministic validation, and completion proof. PilotDeck Core +continues to own only generic hooks, context delivery, tools, and artifact +correction. + +## Install + +Link the plugin into a project or PilotDeck home: + +```bash +ln -s "$(pwd)/products/legal/plugins/legal-coverage" \ + /path/to/project/.pilotdeck/plugins/legal-coverage +``` + +The plugin activates when the project already has an enabled legal coverage +configuration or when a user submits a legal due-diligence/opinion task. + +## Initialize + +```bash +node products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs init \ + --workspace /path/to/project \ + --input source-room \ + --deliverable opinion=deliverables/legal-opinion.md \ + --jurisdiction "Applicable jurisdiction" \ + --basis-date "Review basis date" +``` + +The command creates state under `.pilotdeck/work/legal-coverage/`. Source legal +materials and generated work state remain project-local; they are not part of +this product bundle. + +## Validate + +```bash +node products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs validate \ + --workspace /path/to/project \ + --write-proof +``` + +Validation fails closed on incomplete source inventory, partial facts, orphaned +risks, unverified citations that are not disclosed, stale deliverable hashes, or +missing or generic final coverage. Canonical ledgers use a single main-agent +writer; delegated workers produce disjoint evidence fragments for serial merge. +Only the validator writes `completion-proof.json`. + +For document rooms larger than 20 files, the legal Skill requires two to four +read-only evidence batches. This keeps raw extraction out of the main context +while preserving a single writer for canonical state. From bf6bf1c8739b0091769992eadf9755216b56c2f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Thu, 23 Jul 2026 23:22:41 +0800 Subject: [PATCH 05/14] test(legal): cover coverage guards and runtime wiring --- .../legal-coverage-plugin-runtime.spec.ts | 178 ++++++ tests/products/legal-coverage.spec.ts | 553 ++++++++++++++++++ 2 files changed, 731 insertions(+) create mode 100644 tests/agent/legal-coverage-plugin-runtime.spec.ts create mode 100644 tests/products/legal-coverage.spec.ts diff --git a/tests/agent/legal-coverage-plugin-runtime.spec.ts b/tests/agent/legal-coverage-plugin-runtime.spec.ts new file mode 100644 index 00000000..dbfe25e9 --- /dev/null +++ b/tests/agent/legal-coverage-plugin-runtime.spec.ts @@ -0,0 +1,178 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createLocalGateway } from "../../src/cli/createLocalGateway.js"; +import type { CanonicalMessage, CanonicalModelRequest, ModelRuntime } from "../../src/model/index.js"; +import { DEFAULT_MODEL_CAPABILITIES } from "../../src/model/protocol/capabilities.js"; +import { DEFAULT_MULTIMODAL_CONSTRAINTS } from "../../src/model/protocol/multimodal.js"; + +const PLUGIN_ROOT = resolve("products/legal/plugins/legal-coverage"); +const STATE_ROOT = join(".pilotdeck", "work", "legal-coverage"); + +test("real gateway drives legal plugin milestones through bounded artifact correction", async () => { + const root = await mkdtemp(join(tmpdir(), "pilotdeck-legal-gateway-")); + const projectRoot = join(root, "project"); + const pilotHome = join(root, "home"); + const installedPlugin = join(projectRoot, ".pilotdeck", "plugins", "legal-coverage"); + const requests: CanonicalModelRequest[] = []; + await mkdir(projectRoot, { recursive: true }); + await mkdir(pilotHome, { recursive: true }); + await cp(PLUGIN_ROOT, installedPlugin, { recursive: true }); + await writeFile(join(pilotHome, "pilotdeck.yaml"), TEST_CONFIG); + + const runtime = createLocalGateway({ + projectRoot, + fallbackProjectRoot: projectRoot, + pilotHome, + env: { ...process.env, PILOT_HOME: pilotHome }, + __testModelFactory: () => fakeModelRuntime(requests, projectRoot), + }); + try { + const events = []; + for await (const event of runtime.gateway.submitTurn({ + sessionKey: "legal-runtime-session", + channelKey: "test", + projectKey: projectRoot, + message: "Conduct legal due diligence and produce a legal opinion.", + canPrompt: false, + })) { + events.push(event); + } + + const agentRequests = requests.filter((request) => !messageText(request.messages).includes("Summarize the conversation so far")); + assert.equal(agentRequests.length, 2); + assert.match(messageText(agentRequests[0]?.messages ?? []), /Legal coverage controls are active/u); + assert.match(messageText(agentRequests[0]?.messages ?? []), /completion-proof\.json/u); + assert.match(messageText(agentRequests[0]?.messages ?? []), /Legal coverage milestone/u); + assert.match(messageText(agentRequests[1]?.messages ?? []), /Artifact validation failed/u); + assert.match(messageText(agentRequests[1]?.messages ?? []), /Legal coverage validation is complete/u); + assert.equal(agentRequests[1]?.metadata?.legalCoverageState, "validated"); + assert.equal(events.some((event) => event.type === "turn_completed" && event.finishReason === "completed"), true); + + const proof = JSON.parse(await readFile(join(projectRoot, STATE_ROOT, "completion-proof.json"), "utf8")) as { stateHash: string }; + assert.match(proof.stateHash, /^[a-f0-9]{64}$/u); + assert.equal((await stat(join(projectRoot, "deliverables", "opinion.md"))).size > 0, true); + } finally { + await runtime.dispose(); + await rm(root, { recursive: true, force: true }); + } +}); + +function fakeModelRuntime(requests: CanonicalModelRequest[], projectRoot: string): ModelRuntime { + return { + async *stream(request) { + requests.push(request); + if (requests.length === 1) await writeMinimalValidState(projectRoot); + yield { type: "message_start", role: "assistant" }; + yield { type: "text_delta", text: requests.length === 1 ? "Initial legal completion." : "Validated legal completion." }; + yield { type: "usage", usage: { inputTokens: 20, outputTokens: 5, totalTokens: 25 } }; + yield { type: "message_end", finishReason: "stop" }; + }, + async complete() { + return { role: "assistant", content: [{ type: "text", text: '{"title":"Legal runtime QA"}' }], finishReason: "stop" }; + }, + getCapabilities: () => DEFAULT_MODEL_CAPABILITIES, + getMultimodal: () => DEFAULT_MULTIMODAL_CONSTRAINTS, + getProviderProtocol: () => "openai", + getProviderBaseUrl: () => "https://example.invalid", + }; +} + +async function writeMinimalValidState(workspace: string): Promise { + const root = join(workspace, STATE_ROOT); + const sourcePath = join(workspace, "source-room", "record.txt"); + const opinionPath = join(workspace, "deliverables", "opinion.md"); + const opinion = "# Legal Opinion\nNo material legal facts were identified in the synthetic source.\n"; + await mkdir(join(workspace, "source-room"), { recursive: true }); + await mkdir(join(workspace, "deliverables"), { recursive: true }); + await writeFile(sourcePath, "Synthetic source with no material legal facts.\n"); + await writeFile(opinionPath, opinion); + await writeJson(join(root, "config.json"), { + schemaVersion: 1, + enabled: true, + jurisdiction: "Synthetic jurisdiction", + basisDate: "Synthetic basis date", + allowNoMaterialFacts: true, + inputRoots: ["source-room"], + deliverables: [{ id: "opinion", path: "deliverables/opinion.md", required: true }], + }); + await writeJson(join(root, "sources.json"), { + schemaVersion: 1, + sources: [{ + id: "S-001", + path: "source-room/record.txt", + status: "reviewed", + extractionMethod: "plain-text inspection", + evidenceClass: "official-record", + factIds: [], + noMaterialFactsReason: "The synthetic source contains no material legal facts.", + unresolvedItems: [], + }], + }); + await writeJson(join(root, "facts.json"), { schemaVersion: 1, facts: [] }); + await writeJson(join(root, "matrices.json"), { + schemaVersion: 1, + matrices: [ + "equity-capital-timeline", + "holding-platform-special-rights", + "governance-personnel-timeline", + "contract-key-terms", + "debt-collateral-liquidity", + "employment-ip-timeline", + "legal-authority", + ].map((id) => ({ id, status: "not-applicable", entries: [], notApplicableReason: "No responsive facts in the synthetic source." })), + }); + await writeJson(join(root, "issues.json"), { schemaVersion: 1, issues: [] }); + await writeJson(join(root, "authorities.json"), { schemaVersion: 1, authorities: [] }); + await writeJson(join(root, "coverage.json"), { + schemaVersion: 1, + deliverables: [{ path: "deliverables/opinion.md", sha256: sha256(opinion) }], + sources: [], + facts: [], + issues: [], + authorities: [], + }); +} + +function messageText(messages: readonly CanonicalMessage[]): string { + return messages.flatMap((message) => message.content) + .map((block) => block.type === "text" ? block.text : "") + .filter(Boolean) + .join("\n"); +} + +async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +const TEST_CONFIG = `schemaVersion: 1 +agent: + model: test/test-model + maxOutputTokens: 4096 +model: + providers: + test: + protocol: openai + url: https://example.invalid + apiKey: test-key + models: + test-model: + capabilities: + maxContextTokens: 32768 + maxOutputTokens: 8192 +router: + enabled: false + scenarios: + default: test/test-model +memory: + enabled: false +telemetry: + enabled: false +`; diff --git a/tests/products/legal-coverage.spec.ts b/tests/products/legal-coverage.spec.ts new file mode 100644 index 00000000..e72ddd6b --- /dev/null +++ b/tests/products/legal-coverage.spec.ts @@ -0,0 +1,553 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFile as execFileCallback, spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { loadPluginFromPath } from "../../src/extension/plugins/loading/PluginLoader.js"; + +const execFile = promisify(execFileCallback); +const PLUGIN_ROOT = resolve("products/legal/plugins/legal-coverage"); +const CLI = join(PLUGIN_ROOT, "scripts", "legal-coverage.mjs"); +const HOOK = join(PLUGIN_ROOT, "hook.mjs"); +const STATE_ROOT = join(".pilotdeck", "work", "legal-coverage"); + +test("legal coverage validator creates a current proof and removes it when the deliverable changes", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-valid-")); + try { + await writeCompleteFixture(workspace); + const validation = await runCli(workspace, "validate", "--write-proof"); + assert.equal(validation.exitCode, 0, validation.stderr); + const result = JSON.parse(validation.stdout) as { passed: boolean; counts: Record }; + assert.equal(result.passed, true); + assert.deepEqual(result.counts, { sources: 1, facts: 1, issues: 1, authorities: 1, deliverables: 1 }); + + const proofPath = join(workspace, STATE_ROOT, "completion-proof.json"); + const proof = JSON.parse(await readFile(proofPath, "utf8")) as { stateHash: string; deliverables: Array<{ sha256: string }> }; + assert.match(proof.stateHash, /^[a-f0-9]{64}$/u); + assert.match(proof.deliverables[0]?.sha256 ?? "", /^[a-f0-9]{64}$/u); + + await writeFile(join(workspace, "deliverables", "opinion.md"), "# Changed after coverage\n"); + const stale = await runCli(workspace, "validate", "--write-proof"); + assert.equal(stale.exitCode, 2); + const staleResult = JSON.parse(stale.stdout) as { errors: Array<{ code: string }> }; + assert.equal(staleResult.errors.some((error) => error.code === "deliverable_hash_stale"), true); + await assert.rejects(stat(proofPath), { code: "ENOENT" }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage validator rejects orphaned conflicts and incomplete final disclosure", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-conflict-")); + try { + await writeCompleteFixture(workspace); + const factsPath = join(workspace, STATE_ROOT, "facts.json"); + const facts = JSON.parse(await readFile(factsPath, "utf8")) as { facts: Array> }; + facts.facts[0]!.conflictStatus = "unresolved"; + await writeJson(factsPath, facts); + + const validation = await runCli(workspace, "validate", "--write-proof"); + assert.equal(validation.exitCode, 2); + const result = JSON.parse(validation.stdout) as { errors: Array<{ code: string }> }; + const codes = new Set(result.errors.map((error) => error.code)); + assert.equal(codes.has("unresolved_conflict_orphaned"), true); + assert.equal(codes.has("conflict_not_disclosed"), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage validator detects un-inventoried sources and cross-fact timeline collisions", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-omission-")); + try { + await writeCompleteFixture(workspace); + await writeFile(join(workspace, "source-room", "omitted.txt"), "An additional source that was not inventoried.\n"); + + const root = join(workspace, STATE_ROOT); + const sources = JSON.parse(await readFile(join(root, "sources.json"), "utf8")) as { sources: Array> }; + sources.sources[0]!.factIds = ["F-001", "F-002"]; + await writeJson(join(root, "sources.json"), sources); + const facts = JSON.parse(await readFile(join(root, "facts.json"), "utf8")) as { facts: Array> }; + facts.facts.push({ + ...facts.facts[0], + id: "F-002", + value: 80, + thresholdAssessment: undefined, + }); + await writeJson(join(root, "facts.json"), facts); + const coverage = JSON.parse(await readFile(join(root, "coverage.json"), "utf8")) as { facts: Array> }; + coverage.facts.push({ ...coverage.facts[0], factId: "F-002" }); + await writeJson(join(root, "coverage.json"), coverage); + + const validation = await runCli(workspace, "validate", "--write-proof"); + assert.equal(validation.exitCode, 2); + const result = JSON.parse(validation.stdout) as { errors: Array<{ code: string }> }; + const codes = new Set(result.errors.map((error) => error.code)); + assert.equal(codes.has("source_not_inventoried"), true); + assert.equal(codes.has("timeline_collision_orphaned"), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage validator rejects reused generic quotes and unsupported fact coverage", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-generic-")); + try { + await writeCompleteFixture(workspace); + const root = join(workspace, STATE_ROOT); + const sources = JSON.parse(await readFile(join(root, "sources.json"), "utf8")) as { sources: Array> }; + sources.sources[0]!.factIds = ["F-001", "F-002"]; + await writeJson(join(root, "sources.json"), sources); + + const facts = JSON.parse(await readFile(join(root, "facts.json"), "utf8")) as { facts: Array> }; + facts.facts.push({ + ...facts.facts[0], + id: "F-002", + predicate: "employee count", + value: 42, + material: true, + critical: false, + thresholdAssessment: null, + }); + await writeJson(join(root, "facts.json"), facts); + + const coverage = JSON.parse(await readFile(join(root, "coverage.json"), "utf8")) as { facts: Array> }; + coverage.facts.push({ ...coverage.facts[0], factId: "F-002" }); + await writeJson(join(root, "coverage.json"), coverage); + + const validation = await runCli(workspace, "validate", "--write-proof"); + assert.equal(validation.exitCode, 2); + const result = JSON.parse(validation.stdout) as { errors: Array<{ code: string }> }; + const codes = new Set(result.errors.map((error) => error.code)); + assert.equal(codes.has("coverage_quote_reused"), true); + assert.equal(codes.has("fact_coverage_quote_unsupported"), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage validator requires authority links for critical issues and legal-authority matrices", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-authority-")); + try { + await writeCompleteFixture(workspace); + const root = join(workspace, STATE_ROOT); + const issues = JSON.parse(await readFile(join(root, "issues.json"), "utf8")) as { issues: Array> }; + issues.issues[0]!.authorityIds = []; + issues.issues[0]!.authorityNotRequiredReason = "Authority support omitted."; + await writeJson(join(root, "issues.json"), issues); + await writeJson(join(root, "authorities.json"), { schemaVersion: 1, authorities: [] }); + + const matrices = JSON.parse(await readFile(join(root, "matrices.json"), "utf8")) as { matrices: Array> }; + const authorityMatrix = matrices.matrices.find((matrix) => matrix.id === "legal-authority")!; + authorityMatrix.status = "complete"; + authorityMatrix.entries = [{ + id: "M-AUTH-001", + summary: "Authority support for the closing condition.", + factIds: ["F-001"], + riskSignals: [], + issueIds: ["I-001"], + authorityIds: [], + }]; + delete authorityMatrix.notApplicableReason; + await writeJson(join(root, "matrices.json"), matrices); + + const coverage = JSON.parse(await readFile(join(root, "coverage.json"), "utf8")) as { authorities: unknown[] }; + coverage.authorities = []; + await writeJson(join(root, "coverage.json"), coverage); + + const validation = await runCli(workspace, "validate", "--write-proof"); + assert.equal(validation.exitCode, 2); + const result = JSON.parse(validation.stdout) as { errors: Array<{ code: string }> }; + const codes = new Set(result.errors.map((error) => error.code)); + assert.equal(codes.has("critical_issue_authority_missing"), true); + assert.equal(codes.has("legal_authority_links_missing"), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage validator accepts null for an optional threshold assessment", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-null-threshold-")); + try { + await writeCompleteFixture(workspace); + const factsPath = join(workspace, STATE_ROOT, "facts.json"); + const facts = JSON.parse(await readFile(factsPath, "utf8")) as { facts: Array> }; + facts.facts[0]!.thresholdAssessment = null; + await writeJson(factsPath, facts); + + const validation = await runCli(workspace, "validate", "--write-proof"); + assert.equal(validation.exitCode, 0, validation.stderr); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage validator uses locators without quotes for binary deliverables", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-binary-")); + try { + await writeCompleteFixture(workspace); + const root = join(workspace, STATE_ROOT); + const binaryPath = join(workspace, "deliverables", "opinion.docx"); + const binary = Buffer.from("synthetic-binary-legal-opinion"); + await writeFile(binaryPath, binary); + + const config = JSON.parse(await readFile(join(root, "config.json"), "utf8")) as { deliverables: Array> }; + config.deliverables[0]!.path = "deliverables/opinion.docx"; + await writeJson(join(root, "config.json"), config); + const coverage = JSON.parse(await readFile(join(root, "coverage.json"), "utf8")) as { + deliverables: Array>; + facts: Array>; + issues: Array>; + authorities: Array>; + }; + coverage.deliverables = [{ path: "deliverables/opinion.docx", sha256: sha256(binary) }]; + for (const row of [...coverage.facts, ...coverage.issues, ...coverage.authorities]) { + row.deliverablePath = "deliverables/opinion.docx"; + delete row.quote; + } + await writeJson(join(root, "coverage.json"), coverage); + + const validation = await runCli(workspace, "validate", "--write-proof"); + assert.equal(validation.exitCode, 0, validation.stderr); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage validator rejects empty-fact shortcuts and material facts outside matrices", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-empty-facts-")); + try { + await writeCompleteFixture(workspace); + const root = join(workspace, STATE_ROOT); + const matrices = JSON.parse(await readFile(join(root, "matrices.json"), "utf8")) as { matrices: Array> }; + for (const matrix of matrices.matrices) { + matrix.status = "not-applicable"; + matrix.entries = []; + matrix.notApplicableReason = "No responsive facts for this synthetic matrix."; + } + await writeJson(join(root, "matrices.json"), matrices); + + const orphaned = await runCli(workspace, "validate", "--write-proof"); + assert.equal(orphaned.exitCode, 2); + const orphanedResult = JSON.parse(orphaned.stdout) as { errors: Array<{ code: string }> }; + assert.equal(orphanedResult.errors.some((error) => error.code === "material_fact_matrix_orphaned"), true); + + const sources = JSON.parse(await readFile(join(root, "sources.json"), "utf8")) as { sources: Array> }; + sources.sources[0]!.factIds = []; + sources.sources[0]!.noMaterialFactsReason = "The reviewed synthetic source is genuinely non-responsive."; + await writeJson(join(root, "sources.json"), sources); + await writeJson(join(root, "facts.json"), { schemaVersion: 1, facts: [] }); + await writeJson(join(root, "issues.json"), { schemaVersion: 1, issues: [] }); + await writeJson(join(root, "authorities.json"), { schemaVersion: 1, authorities: [] }); + const coverage = JSON.parse(await readFile(join(root, "coverage.json"), "utf8")) as Record; + coverage.facts = []; + coverage.issues = []; + coverage.authorities = []; + await writeJson(join(root, "coverage.json"), coverage); + + const blocked = await runCli(workspace, "validate", "--write-proof"); + assert.equal(blocked.exitCode, 2); + const blockedResult = JSON.parse(blocked.stdout) as { errors: Array<{ code: string }> }; + assert.equal(blockedResult.errors.some((error) => error.code === "material_facts_missing"), true); + + const config = JSON.parse(await readFile(join(root, "config.json"), "utf8")) as Record; + config.allowNoMaterialFacts = true; + await writeJson(join(root, "config.json"), config); + const explicitNoFacts = await runCli(workspace, "validate", "--write-proof"); + assert.equal(explicitNoFacts.exitCode, 0, explicitNoFacts.stderr); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage hook activates only legal work and injects one observable milestone", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-hook-")); + const ordinaryWorkspace = await mkdtemp(join(tmpdir(), "pilotdeck-nonlegal-hook-")); + try { + const legalSubmit = await runHook({ + hookEventName: "UserPromptSubmit", + sessionId: "legal-session", + transcriptPath: "", + cwd: workspace, + prompt: "Please conduct legal due diligence and issue a legal opinion.", + internal: false, + }); + assert.equal(legalSubmit.hookSpecificOutput.dynamicContext?.length, 1); + assert.equal(legalSubmit.hookSpecificOutput.artifactContracts?.[0]?.path, `${STATE_ROOT}/completion-proof.json`); + + const preModel = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "legal-session", + transcriptPath: "", + cwd: workspace, + }); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /milestone \(configuration\)/u); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /fix validator code jurisdiction_missing now/u); + assert.equal(preModel.hookSpecificOutput.modelRequestPatch?.metadata?.legalCoverageActive, true); + + await rm(join(workspace, STATE_ROOT, "sessions"), { recursive: true, force: true }); + await writeFile(join(workspace, STATE_ROOT, "completion-proof.json"), "{\"forged\":true}\n"); + const blockedStop = await runHook({ + hookEventName: "Stop", + sessionId: "legal-session", + transcriptPath: "", + cwd: workspace, + }); + assert.equal(blockedStop.continue, false); + await assert.rejects(stat(join(workspace, STATE_ROOT, "completion-proof.json")), { code: "ENOENT" }); + + const ordinarySubmit = await runHook({ + hookEventName: "UserPromptSubmit", + sessionId: "ordinary-session", + transcriptPath: "", + cwd: ordinaryWorkspace, + prompt: "Summarize the weekly engineering notes.", + internal: false, + }); + assert.equal(ordinarySubmit.hookSpecificOutput.artifactContracts, undefined); + assert.equal(ordinarySubmit.hookSpecificOutput.dynamicContext, undefined); + } finally { + await rm(workspace, { recursive: true, force: true }); + await rm(ordinaryWorkspace, { recursive: true, force: true }); + } +}); + +test("legal coverage hook groups repeated validator errors into one bounded milestone", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-grouped-")); + try { + await writeCompleteFixture(workspace); + const matricesPath = join(workspace, STATE_ROOT, "matrices.json"); + const matrices = JSON.parse(await readFile(matricesPath, "utf8")) as { matrices: Array> }; + matrices.matrices[0]!.status = "pending"; + matrices.matrices[1]!.status = "pending"; + await writeJson(matricesPath, matrices); + + const preModel = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "grouped-session", + transcriptPath: "", + cwd: workspace, + }); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /fix validator code matrix_pending now/u); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /occurs 2 times/u); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /Fix all occurrences in one bounded edit/u); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal product plugin loads one skill and contains no benchmark-specific controls", async () => { + const plugin = await loadPluginFromPath(PLUGIN_ROOT, "project"); + assert.equal(plugin.name, "legal-coverage"); + assert.equal(plugin.skills?.length, 1); + assert.equal(plugin.skills?.[0]?.name, "legal-coverage:conduct-legal-due-diligence"); + assert.equal(plugin.hooksConfig?.PreModelRequest?.length, 1); + + const files = await collectFiles(PLUGIN_ROOT); + const productionText = (await Promise.all(files.map((path) => readFile(path, "utf8")))).join("\n"); + for (const forbidden of ["legalBenchmarkCase", "case-input", "qingci", "rubric", "judge-response", "checkpoint_id"]) { + assert.doesNotMatch(productionText, new RegExp(forbidden, "iu")); + } +}); + +export async function writeCompleteFixture(workspace: string): Promise { + await mkdir(join(workspace, "source-room"), { recursive: true }); + await mkdir(join(workspace, "deliverables"), { recursive: true }); + await writeFile(join(workspace, "source-room", "record.txt"), "Synthetic company record.\n"); + const opinion = [ + "# Legal Opinion", + "Synthetic entity registered capital of 120 currency units is material to the transaction.", + "The threshold breach requires a closing condition.", + "Applicable law supports the stated closing condition.", + "", + ].join("\n"); + await writeFile(join(workspace, "deliverables", "opinion.md"), opinion); + + const init = await runCli( + workspace, + "init", + "--input", "source-room", + "--deliverable", "opinion=deliverables/opinion.md", + "--jurisdiction", "Synthetic jurisdiction", + "--basis-date", "Synthetic review date", + ); + assert.equal(init.exitCode, 0, init.stderr); + const root = join(workspace, STATE_ROOT); + await writeJson(join(root, "sources.json"), { + schemaVersion: 1, + sources: [{ + id: "S-001", + path: "source-room/record.txt", + status: "reviewed", + extractionMethod: "plain-text inspection", + evidenceClass: "official-record", + factIds: ["F-001"], + unresolvedItems: [], + }], + }); + await writeJson(join(root, "facts.json"), { + schemaVersion: 1, + facts: [{ + id: "F-001", + subject: "Synthetic entity", + predicate: "registered capital", + value: 120, + unit: "currency units", + dateOrPeriod: "Synthetic review date", + sourceRefs: [{ sourceId: "S-001", locator: "line 1" }], + evidenceClass: "official-record", + verificationStatus: "verified", + conflictStatus: "none", + material: true, + critical: true, + thresholdAssessment: { operator: "gt", actual: 120, threshold: 100, unit: "currency units", breached: true }, + }], + }); + await writeJson(join(root, "matrices.json"), { + schemaVersion: 1, + matrices: [ + { + id: "equity-capital-timeline", + status: "complete", + entries: [{ + id: "M-001", + summary: "Capital exceeds the configured analytical threshold.", + factIds: ["F-001"], + riskSignals: ["threshold_breach"], + issueIds: ["I-001"], + }], + }, + ...[ + "holding-platform-special-rights", + "governance-personnel-timeline", + "contract-key-terms", + "debt-collateral-liquidity", + "employment-ip-timeline", + "legal-authority", + ].map((id) => ({ id, status: "not-applicable", entries: [], notApplicableReason: "No responsive synthetic facts in the supplied source." })), + ], + }); + await writeJson(join(root, "issues.json"), { + schemaVersion: 1, + issues: [{ + id: "I-001", + ruleId: "threshold-breach", + status: "open", + severity: "high", + critical: true, + factIds: ["F-001"], + authorityIds: ["A-001"], + analysis: "The normalized amount is above the analytical threshold.", + conclusion: "The transaction should not close before confirmation.", + recommendations: ["Use a documented condition precedent."], + }], + }); + await writeJson(join(root, "authorities.json"), { + schemaVersion: 1, + authorities: [{ + id: "A-001", + name: "Synthetic transactions act", + article: "Article 1", + effectiveVersion: "Current synthetic version", + effectiveDate: "Synthetic effective date", + verificationStatus: "verified", + sourceLocator: "Synthetic official source", + supportedIssueIds: ["I-001"], + supportedConclusion: "A closing condition may address the identified risk.", + }], + }); + await writeJson(join(root, "coverage.json"), { + schemaVersion: 1, + deliverables: [{ path: "deliverables/opinion.md", sha256: sha256(opinion) }], + sources: [], + facts: [{ + factId: "F-001", + status: "covered", + deliverablePath: "deliverables/opinion.md", + section: "Legal Opinion", + locator: "paragraph 1", + claim: "The capital fact is material.", + quote: "Synthetic entity registered capital of 120 currency units is material to the transaction.", + }], + issues: [{ + issueId: "I-001", + status: "covered", + deliverablePath: "deliverables/opinion.md", + section: "Legal Opinion", + locator: "paragraph 2", + claim: "The breach requires a closing condition.", + quote: "The threshold breach requires a closing condition.", + }], + authorities: [{ + authorityId: "A-001", + status: "covered", + deliverablePath: "deliverables/opinion.md", + section: "Legal Opinion", + locator: "paragraph 3", + claim: "The authority supports the control.", + quote: "Applicable law supports the stated closing condition.", + }], + }); +} + +async function runCli(workspace: string, ...args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const result = await execFile(process.execPath, [CLI, ...args, "--workspace", workspace], { encoding: "utf8" }); + return { ...result, exitCode: 0 }; + } catch (error) { + const failed = error as Error & { stdout?: string; stderr?: string; code?: number | string }; + return { + stdout: failed.stdout ?? "", + stderr: failed.stderr ?? failed.message, + exitCode: typeof failed.code === "number" ? failed.code : 1, + }; + } +} + +async function runHook(input: Record): Promise<{ + continue?: boolean; + hookSpecificOutput: { + additionalContext?: string; + dynamicContext?: unknown[]; + artifactContracts?: Array<{ path: string }>; + modelRequestPatch?: { metadata?: Record }; + }; +}> { + const stdout = await new Promise((resolvePromise, reject) => { + const child = spawn(process.execPath, [HOOK], { stdio: ["pipe", "pipe", "pipe"] }); + let output = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { output += chunk; }); + child.stderr.on("data", (chunk: string) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolvePromise(output); + else reject(new Error(stderr || `Hook exited with code ${code}.`)); + }); + child.stdin.end(JSON.stringify(input)); + }); + return JSON.parse(stdout) as never; +} + +async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function collectFiles(directory: string): Promise { + const output: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) output.push(...await collectFiles(path)); + else output.push(path); + } + return output; +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} From 181a01ebeef72e9ec0a018c1dc2430702a35e04f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Fri, 24 Jul 2026 00:35:05 +0800 Subject: [PATCH 06/14] fix(legal): close coverage validator gaps --- .../legal/plugins/legal-coverage/hook.mjs | 188 ++++++---- .../scripts/lib/legal-coverage.mjs | 266 +++++++++++++- .../conduct-legal-due-diligence/SKILL.md | 2 +- .../references/data-contracts.txt | 10 +- .../references/issue-rules.txt | 4 +- .../legal-coverage-plugin-runtime.spec.ts | 78 +++- tests/products/legal-coverage.spec.ts | 341 +++++++++++++++++- 7 files changed, 788 insertions(+), 101 deletions(-) diff --git a/products/legal/plugins/legal-coverage/hook.mjs b/products/legal/plugins/legal-coverage/hook.mjs index 32d71840..e672e6f2 100644 --- a/products/legal/plugins/legal-coverage/hook.mjs +++ b/products/legal/plugins/legal-coverage/hook.mjs @@ -3,107 +3,159 @@ import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { PROOF_PATH, + STATE_DIRECTORY, activationMatches, ensureWorkspace, milestoneFor, + resolveSafeWorkspacePath, validateWorkspace, } from "./scripts/lib/legal-coverage.mjs"; -let body = ""; -for await (const chunk of process.stdin) body += chunk; -const input = JSON.parse(body); -const output = { hookSpecificOutput: { hookEventName: input.hookEventName } }; -const sessionFile = sessionStatePath(input.cwd, input.sessionId); const cliPath = fileURLToPath(new URL("./scripts/legal-coverage.mjs", import.meta.url)); +let hookEventName = "Unknown"; -if (input.hookEventName === "UserPromptSubmit" && input.internal !== true) { - const configured = await hasConfiguredWorkspace(input.cwd); - const active = configured || activationMatches(String(input.prompt ?? "")); - if (active) { - await ensureWorkspace(input.cwd); - await writeSessionState(sessionFile, { active: true }); - output.hookSpecificOutput.dynamicContext = [{ - id: "legal-coverage-activation", - priority: "critical", - ttlMs: 60 * 60 * 1000, - content: [ - "Legal coverage controls are active for this task.", - "Load and apply the project skill legal-coverage:conduct-legal-due-diligence before substantive analysis.", - "Keep legal facts, issue rules, authorities, and coverage mappings under .pilotdeck/work/legal-coverage.", - "Use one main-agent writer for canonical ledgers and deliverables; delegated workers may write only disjoint evidence fragments.", - "The completion proof is generated only by the bundled validator and is required before completion.", - ].join("\n"), - }]; - output.hookSpecificOutput.artifactContracts = [{ - id: "legal-coverage-completion-proof", - path: PROOF_PATH, - required: true, - expectedExtensions: [".json"], - validatorIds: ["core:file-exists"], - domainId: "legal-due-diligence", - }]; +try { + let body = ""; + for await (const chunk of process.stdin) body += chunk; + const input = JSON.parse(body); + hookEventName = typeof input?.hookEventName === "string" ? input.hookEventName : hookEventName; + const output = { hookSpecificOutput: { hookEventName } }; + const sessionPath = sessionStatePath(input.sessionId); + + if (input.hookEventName === "UserPromptSubmit" && input.internal !== true) { + const configured = await hasConfiguredWorkspace(input.cwd); + const active = configured || activationMatches(String(input.prompt ?? "")); + if (active) { + await ensureWorkspace(input.cwd); + await writeSessionState(input.cwd, sessionPath, { active: true }); + output.hookSpecificOutput.dynamicContext = [{ + id: "legal-coverage-activation", + priority: "critical", + ttlMs: 60 * 60 * 1000, + content: [ + "Legal coverage controls are active for this task.", + "Load and apply the project skill legal-coverage:conduct-legal-due-diligence before substantive analysis.", + "Keep legal facts, issue rules, authorities, and coverage mappings under .pilotdeck/work/legal-coverage.", + "Use one main-agent writer for canonical ledgers and deliverables; delegated workers may write only disjoint evidence fragments.", + "The completion proof is generated only by the bundled validator and is required before completion.", + ].join("\n"), + }]; + output.hookSpecificOutput.artifactContracts = [{ + id: "legal-coverage-completion-proof", + path: PROOF_PATH, + required: true, + expectedExtensions: [".json"], + validatorIds: ["core:file-exists"], + domainId: "legal-due-diligence", + }]; + } } -} -const active = await readSessionState(sessionFile) - || await pathExists(`${input.cwd}/.pilotdeck/work/legal-coverage/config.json`) - || await pathExists(`${input.cwd}/${PROOF_PATH}`); -if (active && input.hookEventName === "PreModelRequest") { - const result = await validateWorkspace({ workspaceRoot: input.cwd, writeProof: true }); - output.hookSpecificOutput.additionalContext = milestoneFor(result, cliPath); - output.hookSpecificOutput.modelRequestPatch = { - metadata: { - legalCoverageActive: true, - legalCoverageState: result.passed ? "validated" : result.errors[0]?.phase ?? "incomplete", - }, - }; -} + const active = await readSessionState(input.cwd, sessionPath) + || await pathExists(input.cwd, `${STATE_DIRECTORY}/config.json`) + || await pathExists(input.cwd, PROOF_PATH); + if (active && input.hookEventName === "PreModelRequest") { + const result = await validateWorkspace({ workspaceRoot: input.cwd, writeProof: true }); + output.hookSpecificOutput.additionalContext = milestoneFor(result, cliPath); + output.hookSpecificOutput.modelRequestPatch = { + metadata: { + legalCoverageActive: true, + legalCoverageState: result.passed ? "validated" : result.errors[0]?.phase ?? "incomplete", + }, + }; + } -if (active && input.hookEventName === "Stop") { - const result = await validateWorkspace({ workspaceRoot: input.cwd, writeProof: true }); - if (!result.passed) { - output.continue = false; - output.stopReason = "legal_coverage_incomplete"; - output.reason = milestoneFor(result, cliPath); + if (active && input.hookEventName === "Stop") { + const result = await validateWorkspace({ workspaceRoot: input.cwd, writeProof: true }); + if (!result.passed) { + output.continue = false; + output.stopReason = "legal_coverage_incomplete"; + output.reason = milestoneFor(result, cliPath); + } } -} -if (input.hookEventName === "SessionEnd") await rm(sessionFile, { force: true }); -console.log(JSON.stringify(output)); + if (input.hookEventName === "SessionEnd") await removeSessionState(input.cwd, sessionPath); + console.log(JSON.stringify(output)); +} catch (error) { + const code = errorCode(error); + const reason = [ + `Legal coverage ${hookEventName} hook failed closed because validator or state I/O did not complete`, + code ? ` (${code}).` : ".", + " Completion is blocked until the legal coverage state and validator can be read and written successfully.", + ].join(""); + console.log(JSON.stringify({ + continue: false, + stopReason: "legal_coverage_validation_error", + reason, + hookSpecificOutput: { hookEventName }, + })); + console.error(reason); + process.exitCode = 2; +} async function hasConfiguredWorkspace(workspaceRoot) { try { - const config = JSON.parse(await readFile(`${workspaceRoot}/.pilotdeck/work/legal-coverage/config.json`, "utf8")); - return config?.enabled === true; - } catch { - return false; + const configPath = await resolveSafeWorkspacePath(workspaceRoot, `${STATE_DIRECTORY}/config.json`, { allowMissing: true }); + const value = await readFile(configPath, "utf8"); + try { + const config = JSON.parse(value); + return config && typeof config === "object" && !Array.isArray(config) + ? config.enabled === true + : true; + } catch (error) { + if (error instanceof SyntaxError) return true; + throw error; + } + } catch (error) { + if (isMissing(error)) return false; + throw error; } } -async function writeSessionState(path, value) { +async function writeSessionState(workspaceRoot, candidate, value) { + const path = await resolveSafeWorkspacePath(workspaceRoot, candidate, { allowMissing: true }); await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(value)}\n`, { mode: 0o600 }); + const checkedPath = await resolveSafeWorkspacePath(workspaceRoot, candidate, { allowMissing: true }); + await writeFile(checkedPath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); } -async function readSessionState(path) { +async function readSessionState(workspaceRoot, candidate) { try { + const path = await resolveSafeWorkspacePath(workspaceRoot, candidate, { allowMissing: true }); const value = JSON.parse(await readFile(path, "utf8")); return value?.active === true; - } catch { - return false; + } catch (error) { + if (isMissing(error)) return false; + throw error; } } -function sessionStatePath(workspaceRoot, sessionId) { +function sessionStatePath(sessionId) { const safe = String(sessionId ?? "unknown").normalize("NFKC").replace(/[^a-zA-Z0-9._-]+/gu, "-").slice(0, 96) || "unknown"; - return `${workspaceRoot}/.pilotdeck/work/legal-coverage/sessions/${safe}.json`; + return `${STATE_DIRECTORY}/sessions/${safe}.json`; } -async function pathExists(path) { +async function pathExists(workspaceRoot, candidate) { try { + const path = await resolveSafeWorkspacePath(workspaceRoot, candidate, { allowMissing: true }); await stat(path); return true; - } catch { - return false; + } catch (error) { + if (isMissing(error)) return false; + throw error; } } + +async function removeSessionState(workspaceRoot, candidate) { + const path = await resolveSafeWorkspacePath(workspaceRoot, candidate, { allowMissing: true }); + await rm(path, { force: true }); +} + +function isMissing(error) { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +function errorCode(error) { + if (!(error instanceof Error) || !("code" in error) || typeof error.code !== "string") return undefined; + return /^[A-Z0-9_]{1,32}$/u.test(error.code) ? error.code : undefined; +} 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 b12ed570..f1cec2c4 100644 --- a/products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs +++ b/products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; -import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path"; -export const VALIDATOR_VERSION = "1.1.0"; +export const VALIDATOR_VERSION = "1.2.0"; export const STATE_DIRECTORY = ".pilotdeck/work/legal-coverage"; export const PROOF_PATH = `${STATE_DIRECTORY}/completion-proof.json`; @@ -56,8 +56,9 @@ const TEXT_EXTENSIONS = new Set([".md", ".txt", ".html", ".htm", ".csv"]); export async function ensureWorkspace(workspaceRoot) { const workspace = resolve(workspaceRoot); - const stateRoot = resolveWithinWorkspace(workspace, STATE_DIRECTORY); + const stateRoot = await resolveSafeWorkspacePath(workspace, STATE_DIRECTORY, { allowMissing: true }); await mkdir(stateRoot, { recursive: true }); + await resolveSafeWorkspacePath(workspace, STATE_DIRECTORY); const templates = { config: { schemaVersion: 1, @@ -80,6 +81,7 @@ export async function ensureWorkspace(workspaceRoot) { }; for (const [key, template] of Object.entries(templates)) { const filePath = resolve(stateRoot, STATE_FILES[key]); + await resolveSafeWorkspacePath(workspace, toWorkspacePath(workspace, filePath), { allowMissing: true }); if (!await pathExists(filePath)) await writeJsonAtomic(filePath, template); } return { workspace, stateRoot, paths: statePaths(workspace) }; @@ -93,13 +95,23 @@ export async function readWorkspaceState(workspaceRoot) { for (const [key, filePath] of Object.entries(paths)) { if (key === "proof") continue; try { + await resolveSafeWorkspacePath(workspace, toWorkspacePath(workspace, filePath)); state[key] = JSON.parse(await readFile(filePath, "utf8")); + if (!isRecord(state[key])) { + state[key] = undefined; + readErrors.push(issue( + phaseForStateKey(key), + "state_document_not_object", + `${relative(workspace, filePath)} must contain a JSON object at the top level.`, + relative(workspace, filePath), + )); + } } catch (error) { state[key] = undefined; readErrors.push(issue( phaseForStateKey(key), "state_file_invalid", - `${relative(workspace, filePath)} is missing or is not valid JSON: ${errorMessage(error)}`, + `${relative(workspace, filePath)} is missing, unsafe, or is not valid JSON: ${errorMessage(error)}`, relative(workspace, filePath), )); } @@ -121,16 +133,26 @@ export async function validateWorkspace(options) { factIds: new Set(), issueIds: new Set(), authorityIds: new Set(), + sources: new Map(), deliverables: new Map(), deliverableContents: new Map(), + proofPathSafe: true, }; + try { + await resolveSafeWorkspacePath(workspace, PROOF_PATH, { allowMissing: true }); + } catch (error) { + context.proofPathSafe = false; + add(context, "configuration", "proof_path_invalid", errorMessage(error), PROOF_PATH); + } + await validateConfig(context); await validateSources(context); validateFacts(context); validateMatrices(context); validateIssues(context); validateAuthorities(context); + validateRelationships(context); await validateCoverage(context); const stateHash = await computeStateHash(context); @@ -142,12 +164,15 @@ export async function validateWorkspace(options) { validatorVersion: VALIDATOR_VERSION, validatedAt: new Date().toISOString(), stateHash, + sources: [...context.sources.entries()] + .map(([path, value]) => ({ path, sha256: value.sha256, bytes: value.bytes })) + .sort((a, b) => a.path.localeCompare(b.path)), deliverables: [...context.deliverables.entries()] .map(([path, value]) => ({ path, sha256: value.sha256, bytes: value.bytes })) .sort((a, b) => a.path.localeCompare(b.path)), }; await writeJsonAtomic(loaded.paths.proof, proof); - } else { + } else if (context.proofPathSafe) { await rm(loaded.paths.proof, { force: true }); } } @@ -171,9 +196,9 @@ export async function validateWorkspace(options) { export function milestoneFor(result, cliPath) { if (result.passed) { return [ - "Legal coverage validation is complete and the completion proof matches the current deliverables.", + "Legal coverage validation is complete and the completion proof matches the reviewed sources, canonical ledgers, and current deliverables.", "Satisfy any other active domain skill, artifact contract, and deliverable QA before stopping.", - "If any bound ledger or deliverable changes, rerun the validator because the current proof will become stale.", + "If any bound source, ledger, or deliverable changes, re-inspect affected evidence and rerun the validator because the current proof will become stale.", ].join("\n"); } const first = result.errors[0]; @@ -225,6 +250,42 @@ export function resolveWithinWorkspace(workspaceRoot, candidate) { return resolved; } +export async function resolveSafeWorkspacePath(workspaceRoot, candidate, options = {}) { + const workspace = resolve(workspaceRoot); + const resolved = resolveWithinWorkspace(workspace, candidate); + const workspaceInfo = await lstat(workspace); + if (workspaceInfo.isSymbolicLink() || !workspaceInfo.isDirectory()) { + throw new Error(`Workspace root must be a real directory: ${workspace}`); + } + const canonicalWorkspace = await realpath(workspace); + const segments = relative(workspace, resolved).split(sep).filter(Boolean); + let current = workspace; + let exists = true; + for (const segment of segments) { + current = resolve(current, segment); + let info; + try { + info = await lstat(current); + } catch (error) { + if (options.allowMissing === true && error?.code === "ENOENT") { + exists = false; + break; + } + throw error; + } + if (info.isSymbolicLink()) { + throw new Error(`Symbolic links are not allowed in workspace paths: ${toWorkspacePath(workspace, current)}`); + } + } + if (exists) { + const canonical = await realpath(resolved); + if (canonical !== canonicalWorkspace && !canonical.startsWith(`${canonicalWorkspace}${sep}`)) { + throw new Error(`Path resolves outside the workspace: ${candidate}`); + } + } + return resolved; +} + async function validateConfig(context) { const config = context.state.config; if (!isRecord(config)) return; @@ -252,7 +313,7 @@ async function validateConfig(context) { ids.add(deliverable.id); paths.add(deliverable.path); try { - const filePath = resolveWithinWorkspace(context.workspace, deliverable.path); + const filePath = await resolveSafeWorkspacePath(context.workspace, deliverable.path); if (deliverable.required !== false) { const info = await stat(filePath).catch(() => undefined); if (!info?.isFile() || info.size === 0) { @@ -303,9 +364,20 @@ async function validateSources(context) { add(context, "sources", "unreadable_source_unresolved", `Unreadable source ${source.id} must record unresolvedItems.`, at); } try { - const sourcePath = resolveWithinWorkspace(context.workspace, source.path); + const sourcePath = await resolveSafeWorkspacePath(context.workspace, source.path); const info = await lstat(sourcePath).catch(() => undefined); - if (!info?.isFile()) add(context, "sources", "source_file_missing", `Inventoried source is missing or not a file: ${source.path}.`, source.path); + if (!info?.isFile()) { + add(context, "sources", "source_file_missing", `Inventoried source is missing or not a file: ${source.path}.`, source.path); + } else { + const data = await readFile(sourcePath); + const actual = { sha256: sha256(data), bytes: data.byteLength }; + context.sources.set(source.path, actual); + if (!/^[a-f0-9]{64}$/u.test(source.sha256 ?? "")) { + add(context, "sources", "source_hash_missing", `Source ${source.id} must bind the SHA-256 recorded when it was reviewed.`, at); + } else if (source.sha256 !== actual.sha256) { + add(context, "sources", "source_hash_stale", `Source ${source.id} changed after its ledger row was reviewed. Re-inspect it and update the dependent ledgers before recording a new hash.`, source.path); + } + } } catch (error) { add(context, "sources", "source_path_invalid", errorMessage(error), at); } @@ -320,7 +392,7 @@ async function validateSources(context) { continue; } try { - const root = resolveWithinWorkspace(context.workspace, inputRoot); + const root = await resolveSafeWorkspacePath(context.workspace, inputRoot); for (const path of await listSourceFiles(context.workspace, root)) discovered.add(path); } catch (error) { add(context, "sources", "input_root_unreadable", `Cannot inventory input root ${inputRoot}: ${errorMessage(error)}`, inputRoot); @@ -557,10 +629,6 @@ function validateAuthorities(context) { if (!isRecord(legalIssue)) continue; for (const authorityId of stringArray(legalIssue.authorityIds)) { if (!context.authorityIds.has(authorityId)) add(context, "authorities", "issue_authority_unknown", `Issue ${legalIssue.id} references unknown authority ${authorityId}.`, STATE_FILES.issues); - const authority = ledger.authorities.find((item) => item?.id === authorityId); - if (authority && !stringArray(authority.supportedIssueIds).includes(legalIssue.id)) { - add(context, "authorities", "authority_issue_backlink_missing", `Authority ${authorityId} must backlink issue ${legalIssue.id}.`, STATE_FILES.authorities); - } } } const matrices = Array.isArray(context.state.matrices?.matrices) ? context.state.matrices.matrices : []; @@ -574,6 +642,92 @@ function validateAuthorities(context) { } } +function validateRelationships(context) { + const sources = recordMap(context.state.sources?.sources); + const facts = recordMap(context.state.facts?.facts); + const issues = recordMap(context.state.issues?.issues); + const authorities = recordMap(context.state.authorities?.authorities); + + for (const source of sources.values()) { + for (const factId of stringArray(source.factIds)) { + const fact = facts.get(factId); + if (fact && !factSourceIds(fact).includes(source.id)) { + add(context, "facts", "source_fact_backlink_missing", `Source ${source.id} lists fact ${factId}, but that fact does not reference the source.`, STATE_FILES.facts); + } + } + } + for (const fact of facts.values()) { + for (const sourceId of factSourceIds(fact)) { + const source = sources.get(sourceId); + if (source && !stringArray(source.factIds).includes(fact.id)) { + add(context, "sources", "fact_source_backlink_missing", `Fact ${fact.id} references source ${sourceId}, but that source does not list the fact.`, STATE_FILES.sources); + } + } + } + + for (const legalIssue of issues.values()) { + for (const authorityId of stringArray(legalIssue.authorityIds)) { + const authority = authorities.get(authorityId); + if (authority && !stringArray(authority.supportedIssueIds).includes(legalIssue.id)) { + add(context, "authorities", "authority_issue_backlink_missing", `Authority ${authorityId} must backlink issue ${legalIssue.id}.`, STATE_FILES.authorities); + } + } + } + for (const authority of authorities.values()) { + for (const issueId of stringArray(authority.supportedIssueIds)) { + const legalIssue = issues.get(issueId); + if (legalIssue && !stringArray(legalIssue.authorityIds).includes(authority.id)) { + add(context, "authorities", "issue_authority_backlink_missing", `Authority ${authority.id} lists issue ${issueId}, but that issue does not reference the authority.`, STATE_FILES.issues); + } + } + } + + const matrices = Array.isArray(context.state.matrices?.matrices) ? context.state.matrices.matrices : []; + for (const matrix of matrices) { + if (!isRecord(matrix)) continue; + for (const entry of Array.isArray(matrix.entries) ? matrix.entries : []) { + if (!isRecord(entry)) continue; + const entryFactIds = stringArray(entry.factIds); + const entryIssueIds = stringArray(entry.issueIds); + const entryAuthorityIds = stringArray(entry.authorityIds); + for (const issueId of entryIssueIds) { + const legalIssue = issues.get(issueId); + if (legalIssue && !stringArray(legalIssue.factIds).some((factId) => entryFactIds.includes(factId))) { + add(context, "issues", "matrix_issue_fact_mismatch", `Matrix entry ${entry.id} and linked issue ${issueId} must reference at least one common fact.`, STATE_FILES.matrices); + } + } + for (const signal of stringArray(entry.riskSignals)) { + const ruleId = Object.keys(ISSUE_RULES).find((key) => ISSUE_RULES[key] === signal); + const matchingIssue = entryIssueIds + .map((issueId) => issues.get(issueId)) + .find((legalIssue) => legalIssue?.ruleId === ruleId && entryFactIds.every((factId) => stringArray(legalIssue.factIds).includes(factId))); + if (!matchingIssue) { + add(context, "issues", "risk_signal_fact_mismatch", `Risk signal ${signal} on ${entry.id} requires a linked ${ruleId} issue covering all entry facts.`, STATE_FILES.matrices); + } + } + if (matrix.id !== "legal-authority") continue; + for (const authorityId of entryAuthorityIds) { + const authority = authorities.get(authorityId); + if (authority && !entryIssueIds.some((issueId) => stringArray(authority.supportedIssueIds).includes(issueId))) { + add(context, "authorities", "matrix_authority_issue_mismatch", `Legal-authority matrix entry ${entry.id} links authority ${authorityId} without a supported issue from the same entry.`, STATE_FILES.matrices); + } + } + for (const issueId of entryIssueIds) { + const supported = entryAuthorityIds.some((authorityId) => { + const authority = authorities.get(authorityId); + const legalIssue = issues.get(issueId); + return authority && legalIssue + && stringArray(authority.supportedIssueIds).includes(issueId) + && stringArray(legalIssue.authorityIds).includes(authorityId); + }); + if (!supported) { + add(context, "authorities", "matrix_issue_authority_mismatch", `Legal-authority matrix entry ${entry.id} issue ${issueId} requires a mutually linked authority from the same entry.`, STATE_FILES.matrices); + } + } + } + } +} + async function validateCoverage(context) { const manifest = context.state.coverage; if (!isRecord(manifest)) return; @@ -613,12 +767,18 @@ async function validateCoverage(context) { if (fact.conflictStatus === "unresolved" && coverage?.status !== "unresolved") { add(context, "coverage", "conflict_not_disclosed", `Unresolved fact ${fact.id} must be marked unresolved in final coverage.`, STATE_FILES.coverage); } + if (fact.verificationStatus !== "verified" && coverage?.status !== "unresolved") { + add(context, "coverage", "unverified_fact_not_disclosed", `Material or critical fact ${fact.id} is not fully verified and must be marked unresolved in final coverage.`, STATE_FILES.coverage); + } } const issues = Array.isArray(context.state.issues?.issues) ? context.state.issues.issues : []; for (const legalIssue of issues) { if (!isRecord(legalIssue)) continue; const coverage = issueCoverage.get(legalIssue.id); if (!coverage) add(context, "coverage", "issue_orphaned", `Legal issue ${legalIssue.id} is not mapped to a final deliverable.`, STATE_FILES.coverage); + if (coverage && context.deliverableContents.has(coverage.deliverablePath) && !issueCoverageQuoteSupports(legalIssue, coverage.quote)) { + add(context, "coverage", "issue_coverage_quote_unsupported", `Coverage quote for issue ${legalIssue.id} must share specific reasoning or control language with its analysis, conclusion, or recommendations.`, STATE_FILES.coverage); + } if (legalIssue.status === "unresolved" && coverage?.status !== "unresolved") { add(context, "coverage", "unresolved_issue_not_disclosed", `Unresolved issue ${legalIssue.id} must be marked unresolved in final coverage.`, STATE_FILES.coverage); } @@ -628,6 +788,9 @@ async function validateCoverage(context) { if (!isRecord(authority) || authority.verificationStatus === "not-applicable") continue; const coverage = authorityCoverage.get(authority.id); if (!coverage) add(context, "coverage", "authority_orphaned", `Authority ${authority.id} is not mapped to a final deliverable.`, STATE_FILES.coverage); + if (coverage && context.deliverableContents.has(coverage.deliverablePath) && !authorityCoverageQuoteSupports(authority, coverage.quote)) { + add(context, "coverage", "authority_coverage_quote_unsupported", `Coverage quote for authority ${authority.id} must identify the authority and article and support its stated conclusion.`, STATE_FILES.coverage); + } if (authority.verificationStatus === "pending-verification" && coverage?.status !== "unresolved") { add(context, "coverage", "pending_authority_not_disclosed", `Pending authority ${authority.id} must be marked unresolved in final coverage.`, STATE_FILES.coverage); } @@ -699,6 +862,58 @@ function factCoverageQuoteSupports(fact, quote) { return details.some((value) => normalizedQuote.includes(value)); } +function issueCoverageQuoteSupports(legalIssue, quote) { + return semanticOverlapCount( + quote, + [legalIssue.analysis, legalIssue.conclusion, ...stringArray(legalIssue.recommendations)], + ) >= 2; +} + +function authorityCoverageQuoteSupports(authority, quote) { + if (!nonEmpty(quote) || !nonEmpty(authority.name) || !nonEmpty(authority.supportedConclusion)) return false; + if (authority.verificationStatus === "verified" && !citationIdentitySupports(quote, authority.name, authority.article)) return false; + return semanticOverlapCount(quote, [authority.supportedConclusion]) >= 2; +} + +function citationIdentitySupports(quote, name, article) { + if (!nonEmpty(name) || !nonEmpty(article)) return false; + const normalizedName = normalizeEvidenceText(name); + const normalizedArticle = normalizeEvidenceText(article); + return String(quote) + .split(/[;;。\n]+/u) + .map(normalizeEvidenceText) + .some((segment) => segment.includes(normalizedName) && segment.includes(normalizedArticle)); +} + +function semanticOverlapCount(quote, values) { + if (!nonEmpty(quote)) return 0; + const quoteAnchors = semanticAnchors(quote); + const valueAnchors = new Set(values.filter(nonEmpty).flatMap(semanticAnchors)); + let count = 0; + for (const anchor of quoteAnchors) if (valueAnchors.has(anchor)) count += 1; + return count; +} + +function semanticAnchors(value) { + const text = normalize(value); + const anchors = new Set(); + for (const token of text.match(/[\p{Script=Latin}\p{N}]{3,}/gu) ?? []) { + if (!SEMANTIC_STOP_WORDS.has(token)) anchors.add(token); + } + for (const sequence of text.match(/[\p{Script=Han}]{2,}/gu) ?? []) { + for (let index = 0; index < sequence.length - 1; index += 1) { + const pair = sequence.slice(index, index + 2); + if (!SEMANTIC_STOP_WORDS.has(pair)) anchors.add(pair); + } + } + return [...anchors]; +} + +const SEMANTIC_STOP_WORDS = new Set([ + "and", "are", "for", "from", "that", "the", "this", "with", + "公司", "法律", "问题", "交易", "风险", "要求", "存在", "相关", +]); + function scalarValues(value) { if (Array.isArray(value)) return value.flatMap(scalarValues); if (isRecord(value)) return Object.values(value).flatMap(scalarValues); @@ -737,6 +952,7 @@ function detectTimelineCollisions(facts) { } async function listSourceFiles(workspace, root) { + await resolveSafeWorkspacePath(workspace, toWorkspacePath(workspace, root)); const rootInfo = await lstat(root); if (rootInfo.isSymbolicLink()) throw new Error("Input roots may not be symbolic links."); if (rootInfo.isFile()) return [toWorkspacePath(workspace, root)]; @@ -759,7 +975,10 @@ async function computeStateHash(context) { const deliverables = [...context.deliverables.entries()] .map(([path, value]) => ({ path, sha256: value.sha256, bytes: value.bytes })) .sort((a, b) => a.path.localeCompare(b.path)); - return sha256(stableStringify({ validatorVersion: VALIDATOR_VERSION, state, deliverables })); + const sources = [...context.sources.entries()] + .map(([path, value]) => ({ path, sha256: value.sha256, bytes: value.bytes })) + .sort((a, b) => a.path.localeCompare(b.path)); + return sha256(stableStringify({ validatorVersion: VALIDATOR_VERSION, state, sources, deliverables })); } async function writeJsonAtomic(filePath, value) { @@ -794,6 +1013,21 @@ function issueCoversFact(issues, factId) { return (issues ?? []).some((item) => stringArray(item.factIds).includes(factId)); } +function recordMap(rows) { + const output = new Map(); + for (const row of Array.isArray(rows) ? rows : []) { + if (isRecord(row) && nonEmpty(row.id)) output.set(row.id, row); + } + return output; +} + +function factSourceIds(fact) { + return (Array.isArray(fact.sourceRefs) ? fact.sourceRefs : []) + .filter(isRecord) + .map((reference) => reference.sourceId) + .filter(nonEmpty); +} + function toWorkspacePath(workspace, filePath) { return relative(workspace, filePath).split(sep).join("/"); } diff --git a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md index f0f4e9c8..9006f047 100644 --- a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md +++ b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md @@ -25,7 +25,7 @@ Create the legal analysis; let the bundled validator enforce structure and cover ## Build Evidence Before Conclusions -1. Inventory every file under every configured input root in `sources.json`. Use one stable source ID per file. +1. Inventory every file under every configured input root in `sources.json`. Use one stable source ID per file and record the lowercase SHA-256 of the exact bytes inspected. If a source changes, re-inspect it and update every dependent ledger before recording the new hash. 2. Inspect every machine-readable source completely, including all spreadsheet sheets and presentation slides. Mark a source `unreadable` only after deterministic extraction or inspection fails; record the unresolved items. 3. Record atomic facts in `facts.json`. Preserve the subject, predicate, value, unit, date or period, source locator, evidence class, verification state, conflict state, and materiality. Do not merge conflicting statements into one fact. 4. Set `material: true` only when the fact changes a legal conclusion, risk severity, transaction control, or unresolved disclosure. Set `critical: true` only when it may block or materially restructure the transaction. Do not default every extracted fact to material or critical. diff --git a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/data-contracts.txt b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/data-contracts.txt index b4eeacdb..157afbdd 100644 --- a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/data-contracts.txt +++ b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/data-contracts.txt @@ -17,11 +17,13 @@ config.json sources.json - sources: one row per physical file under inputRoots -- row: { id, path, status, extractionMethod, evidenceClass, factIds, noMaterialFactsReason, unresolvedItems } +- row: { id, path, sha256, status, extractionMethod, evidenceClass, factIds, noMaterialFactsReason, unresolvedItems } - status: reviewed | unreadable | pending - evidenceClass: official-record | executed-contract | company-disclosure | financial-record | third-party-record | interview | image-or-scan | other -- A reviewed row needs extractionMethod, evidenceClass, and either factIds or noMaterialFactsReason. +- sha256 is the lowercase SHA-256 of the exact source bytes at review time. If the source changes, re-inspect it and update every dependent ledger before recording the new hash. +- A reviewed row needs sha256, extractionMethod, evidenceClass, and either factIds or noMaterialFactsReason. - An unreadable row needs unresolvedItems. Pending rows block completion. +- sources.factIds and facts.sourceRefs are reciprocal: each link must appear on both rows. facts.json - facts: atomic fact rows @@ -48,6 +50,7 @@ issues.json - status: open | mitigated | unresolved - recommendations is a non-empty array of legal or transaction controls. - Every critical issue requires authorityIds. authorityNotRequiredReason cannot waive authority support for a critical issue. +- issues.authorityIds and authorities.supportedIssueIds are reciprocal: each link must appear on both rows. authorities.json - authorities: { id, name, article, effectiveVersion, effectiveDate, verificationStatus, sourceLocator, pendingReason, supportedIssueIds, supportedConclusion } @@ -64,4 +67,7 @@ coverage.json - Every unreadable source or source with unresolvedItems must have an unresolved source coverage row. - quote is required and must occur exactly in text deliverables. Quotes must be distinct within each coverage group; one generic sentence cannot cover multiple records. - A material-fact quote must contain the fact subject and either its predicate, value, or date/period. +- A material or critical fact whose verificationStatus is partially-verified or unverified must use coverage status unresolved. +- An issue quote must share specific reasoning or control language with the issue analysis, conclusion, or recommendations. A unique but unrelated sentence does not establish coverage. +- A verified-authority quote must identify the authority and article in the same citation segment and share specific language with supportedConclusion. - Binary deliverables still require a precise locator and claim. diff --git a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/issue-rules.txt b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/issue-rules.txt index 9fb54ae2..721e3d77 100644 --- a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/issue-rules.txt +++ b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/issue-rules.txt @@ -23,6 +23,8 @@ Rule IDs and matrix riskSignals - source-contradiction -> source_contradiction Use for every unresolved contradiction between source records. The validator requires this issue for facts whose conflictStatus is unresolved. -For every riskSignal on a matrix entry, link an issue with the matching ruleId. Link all facts needed to explain the relationship, not only the fact that contains a number. State the legal significance and a concrete conclusion; a restatement of source text is not legal reasoning. +For every riskSignal on a matrix entry, link an issue with the matching ruleId. That issue must cover every fact on the matrix entry. Every other linked issue must share at least one fact with the entry. Link all facts needed to explain the relationship, not only the fact that contains a number. State the legal significance and a concrete conclusion; a restatement of source text is not legal reasoning. + +In the legal-authority matrix, every linked authority must support an issue on the same entry, and every linked issue must have a mutually linked authority on that entry. An authority cannot be borrowed from an unrelated issue merely to fill authorityIds. Use the hyphenated value on the left only in issues.json ruleId. Use the underscored value on the right only in matrices.json riskSignals. Every critical issue must link at least one authority; a free-text waiver is not accepted. diff --git a/tests/agent/legal-coverage-plugin-runtime.spec.ts b/tests/agent/legal-coverage-plugin-runtime.spec.ts index dbfe25e9..7cc5f6a8 100644 --- a/tests/agent/legal-coverage-plugin-runtime.spec.ts +++ b/tests/agent/legal-coverage-plugin-runtime.spec.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { createLocalGateway } from "../../src/cli/createLocalGateway.js"; @@ -43,7 +43,7 @@ test("real gateway drives legal plugin milestones through bounded artifact corre } const agentRequests = requests.filter((request) => !messageText(request.messages).includes("Summarize the conversation so far")); - assert.equal(agentRequests.length, 2); + assert.equal(agentRequests.length, 2, JSON.stringify(events)); assert.match(messageText(agentRequests[0]?.messages ?? []), /Legal coverage controls are active/u); assert.match(messageText(agentRequests[0]?.messages ?? []), /completion-proof\.json/u); assert.match(messageText(agentRequests[0]?.messages ?? []), /Legal coverage milestone/u); @@ -61,13 +61,76 @@ test("real gateway drives legal plugin milestones through bounded artifact corre } }); -function fakeModelRuntime(requests: CanonicalModelRequest[], projectRoot: string): ModelRuntime { +test("real gateway blocks completion when the legal Stop hook cannot read session state", async () => { + const root = await mkdtemp(join(tmpdir(), "pilotdeck-legal-stop-failure-")); + const projectRoot = join(root, "project"); + const pilotHome = join(root, "home"); + const installedPlugin = join(projectRoot, ".pilotdeck", "plugins", "legal-coverage"); + const requests: CanonicalModelRequest[] = []; + await mkdir(projectRoot, { recursive: true }); + await mkdir(pilotHome, { recursive: true }); + await cp(PLUGIN_ROOT, installedPlugin, { recursive: true }); + await writeFile(join(pilotHome, "pilotdeck.yaml"), TEST_CONFIG); + + const runtime = createLocalGateway({ + projectRoot, + fallbackProjectRoot: projectRoot, + pilotHome, + env: { ...process.env, PILOT_HOME: pilotHome }, + __testModelFactory: () => fakeModelRuntime(requests, projectRoot, { corruptSessionStateAfterProof: true }), + }); + try { + const events = []; + for await (const event of runtime.gateway.submitTurn({ + sessionKey: "legal-stop-failure-session", + channelKey: "test", + projectKey: projectRoot, + message: "Conduct legal due diligence and produce a legal opinion.", + canPrompt: false, + })) { + events.push(event); + } + + const agentRequests = requests.filter((request) => !isCompactionRequest(request)); + assert.equal(agentRequests.length, 2, JSON.stringify(events)); + assert.equal(events.some((event) => event.type === "error" + && /Legal coverage Stop hook failed closed/u.test(event.message)), true); + assert.equal(events.some((event) => event.type === "turn_completed" && event.finishReason === "tool_error"), true); + assert.equal(events.some((event) => event.type === "turn_completed" && event.finishReason === "completed"), false); + } finally { + await runtime.dispose(); + await rm(root, { recursive: true, force: true }); + } +}); + +function fakeModelRuntime( + requests: CanonicalModelRequest[], + projectRoot: string, + options: { corruptSessionStateAfterProof?: boolean } = {}, +): ModelRuntime { return { async *stream(request) { requests.push(request); - if (requests.length === 1) await writeMinimalValidState(projectRoot); + const isCompaction = isCompactionRequest(request); + const agentRequestCount = requests.filter((candidate) => !isCompactionRequest(candidate)).length; + if (!isCompaction && agentRequestCount === 1) await writeMinimalValidState(projectRoot); + if (!isCompaction && agentRequestCount === 2 && options.corruptSessionStateAfterProof) { + const proofPath = join(projectRoot, STATE_ROOT, "completion-proof.json"); + assert.match((await readFile(proofPath, "utf8")), /"stateHash"/u); + const sessionsRoot = join(projectRoot, STATE_ROOT, "sessions"); + const sessionFiles = await readdir(sessionsRoot); + assert.equal(sessionFiles.length, 1); + const sessionPath = join(sessionsRoot, sessionFiles[0]!); + await rm(sessionPath); + await mkdir(sessionPath); + } yield { type: "message_start", role: "assistant" }; - yield { type: "text_delta", text: requests.length === 1 ? "Initial legal completion." : "Validated legal completion." }; + yield { + type: "text_delta", + text: isCompaction + ? "Synthetic compact summary." + : agentRequestCount === 1 ? "Initial legal completion." : "Validated legal completion.", + }; yield { type: "usage", usage: { inputTokens: 20, outputTokens: 5, totalTokens: 25 } }; yield { type: "message_end", finishReason: "stop" }; }, @@ -104,6 +167,7 @@ async function writeMinimalValidState(workspace: string): Promise { sources: [{ id: "S-001", path: "source-room/record.txt", + sha256: sha256("Synthetic source with no material legal facts.\n"), status: "reviewed", extractionMethod: "plain-text inspection", evidenceClass: "official-record", @@ -144,6 +208,10 @@ function messageText(messages: readonly CanonicalMessage[]): string { .join("\n"); } +function isCompactionRequest(request: CanonicalModelRequest): boolean { + return messageText(request.messages).includes("Summarize the conversation so far"); +} + async function writeJson(path: string, value: unknown): Promise { await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); } diff --git a/tests/products/legal-coverage.spec.ts b/tests/products/legal-coverage.spec.ts index e72ddd6b..0be2e3cb 100644 --- a/tests/products/legal-coverage.spec.ts +++ b/tests/products/legal-coverage.spec.ts @@ -2,15 +2,17 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { execFile as execFileCallback, spawn } from "node:child_process"; -import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { loadPluginFromPath } from "../../src/extension/plugins/loading/PluginLoader.js"; const execFile = promisify(execFileCallback); const PLUGIN_ROOT = resolve("products/legal/plugins/legal-coverage"); const CLI = join(PLUGIN_ROOT, "scripts", "legal-coverage.mjs"); +const VALIDATOR_LIB = join(PLUGIN_ROOT, "scripts", "lib", "legal-coverage.mjs"); const HOOK = join(PLUGIN_ROOT, "hook.mjs"); const STATE_ROOT = join(".pilotdeck", "work", "legal-coverage"); @@ -25,8 +27,17 @@ test("legal coverage validator creates a current proof and removes it when the d assert.deepEqual(result.counts, { sources: 1, facts: 1, issues: 1, authorities: 1, deliverables: 1 }); const proofPath = join(workspace, STATE_ROOT, "completion-proof.json"); - const proof = JSON.parse(await readFile(proofPath, "utf8")) as { stateHash: string; deliverables: Array<{ sha256: string }> }; + const proof = JSON.parse(await readFile(proofPath, "utf8")) as { + stateHash: string; + sources: Array<{ path: string; sha256: string; bytes: number }>; + deliverables: Array<{ sha256: string }>; + }; assert.match(proof.stateHash, /^[a-f0-9]{64}$/u); + assert.deepEqual(proof.sources, [{ + path: "source-room/record.txt", + sha256: sha256("Synthetic company record.\n"), + bytes: Buffer.byteLength("Synthetic company record.\n"), + }]); assert.match(proof.deliverables[0]?.sha256 ?? "", /^[a-f0-9]{64}$/u); await writeFile(join(workspace, "deliverables", "opinion.md"), "# Changed after coverage\n"); @@ -169,6 +180,244 @@ test("legal coverage validator requires authority links for critical issues and } }); +test("legal coverage validator rejects non-object canonical JSON documents", async () => { + const stateFiles = ["config", "sources", "facts", "matrices", "issues", "authorities", "coverage"]; + const nonObjects = [null, [], "not an object", 42]; + for (const [index, stateFile] of stateFiles.entries()) { + const workspace = await mkdtemp(join(tmpdir(), `pilotdeck-legal-coverage-non-object-${stateFile}-`)); + try { + await writeCompleteFixture(workspace); + await writeFile(join(workspace, STATE_ROOT, `${stateFile}.json`), `${JSON.stringify(nonObjects[index % nonObjects.length])}\n`); + const validation = await runCli(workspace, "validate", "--write-proof"); + assert.equal(validation.exitCode, 2, `${stateFile}: ${validation.stderr}`); + const result = JSON.parse(validation.stdout) as { passed: boolean; errors: Array<{ code: string; path?: string }> }; + assert.equal(result.passed, false); + assert.equal(result.errors.some((error) => error.code === "state_document_not_object" && error.path?.endsWith(`${stateFile}.json`)), true); + await assert.rejects(stat(join(workspace, STATE_ROOT, "completion-proof.json")), { code: "ENOENT" }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + } +}); + +test("legal coverage validator binds reviewed source bytes to the ledger and state hash", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-source-hash-")); + try { + await writeCompleteFixture(workspace); + const current = await runCli(workspace, "validate", "--write-proof"); + assert.equal(current.exitCode, 0, current.stderr); + const currentResult = JSON.parse(current.stdout) as { stateHash: string }; + + await writeFile(join(workspace, "source-room", "record.txt"), "Changed source bytes after legal review.\n"); + const stale = await runCli(workspace, "validate", "--write-proof"); + assert.equal(stale.exitCode, 2); + const staleResult = JSON.parse(stale.stdout) as { stateHash: string; errors: Array<{ code: string }> }; + assert.notEqual(staleResult.stateHash, currentResult.stateHash); + assert.equal(staleResult.errors.some((error) => error.code === "source_hash_stale"), true); + await assert.rejects(stat(join(workspace, STATE_ROOT, "completion-proof.json")), { code: "ENOENT" }); + + await writeFile(join(workspace, "source-room", "record.txt"), "Synthetic company record.\n"); + const sourcesPath = join(workspace, STATE_ROOT, "sources.json"); + const sources = JSON.parse(await readFile(sourcesPath, "utf8")) as { sources: Array> }; + delete sources.sources[0]!.sha256; + await writeJson(sourcesPath, sources); + const unbound = await runCli(workspace, "validate", "--write-proof"); + assert.equal(unbound.exitCode, 2); + const unboundResult = JSON.parse(unbound.stdout) as { errors: Array<{ code: string }> }; + assert.equal(unboundResult.errors.some((error) => error.code === "source_hash_missing"), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage validator rejects ancestor symlinks and a symlinked proof", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-symlink-")); + const outside = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-outside-")); + try { + await writeCompleteFixture(workspace); + await mkdir(join(outside, "source-room"), { recursive: true }); + await mkdir(join(outside, "deliverables"), { recursive: true }); + await writeFile(join(outside, "source-room", "record.txt"), "Synthetic company record.\n"); + await writeFile(join(outside, "deliverables", "opinion.md"), "External legal opinion.\n"); + await symlink(outside, join(workspace, "escape")); + + const root = join(workspace, STATE_ROOT); + const config = JSON.parse(await readFile(join(root, "config.json"), "utf8")) as { inputRoots: string[]; deliverables: Array> }; + config.inputRoots = ["escape/source-room"]; + config.deliverables[0]!.path = "escape/deliverables/opinion.md"; + await writeJson(join(root, "config.json"), config); + const sources = JSON.parse(await readFile(join(root, "sources.json"), "utf8")) as { sources: Array> }; + sources.sources[0]!.path = "escape/source-room/record.txt"; + await writeJson(join(root, "sources.json"), sources); + + const escaped = await runCli(workspace, "validate", "--write-proof"); + assert.equal(escaped.exitCode, 2); + const escapedResult = JSON.parse(escaped.stdout) as { errors: Array<{ code: string }> }; + assert.equal(escapedResult.errors.some((error) => error.code === "source_path_invalid"), true); + assert.equal(escapedResult.errors.some((error) => error.code === "input_root_unreadable"), true); + assert.equal(escapedResult.errors.some((error) => error.code === "deliverable_path_invalid"), true); + + config.inputRoots = ["source-room"]; + config.deliverables[0]!.path = "deliverables/opinion.md"; + sources.sources[0]!.path = "source-room/record.txt"; + await writeJson(join(root, "config.json"), config); + await writeJson(join(root, "sources.json"), sources); + const externalProof = join(outside, "completion-proof.json"); + await writeFile(externalProof, "external sentinel\n"); + await symlink(externalProof, join(root, "completion-proof.json")); + const proofSymlink = await runCli(workspace, "validate", "--write-proof"); + assert.equal(proofSymlink.exitCode, 2); + const proofResult = JSON.parse(proofSymlink.stdout) as { errors: Array<{ code: string }> }; + assert.equal(proofResult.errors.some((error) => error.code === "proof_path_invalid"), true); + assert.equal(await readFile(externalProof, "utf8"), "external sentinel\n"); + } finally { + await rm(workspace, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } +}); + +test("legal coverage validator does not read or write through a symlinked state ancestor", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-state-symlink-")); + const outside = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-state-outside-")); + try { + await writeCompleteFixture(workspace); + const stateRoot = join(workspace, STATE_ROOT); + const outsideState = join(outside, "state"); + await rename(stateRoot, outsideState); + await symlink(outsideState, stateRoot); + const externalProof = join(outsideState, "completion-proof.json"); + await writeFile(externalProof, "external state sentinel\n"); + + const result = await runValidatorDirect(workspace); + assert.equal(result.passed, false); + assert.equal(result.errors.some((error: { code: string }) => error.code === "state_file_invalid"), true); + assert.equal(result.errors.some((error: { code: string }) => error.code === "proof_path_invalid"), true); + assert.equal(await readFile(externalProof, "utf8"), "external state sentinel\n"); + } finally { + await rm(workspace, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } +}); + +test("legal coverage validator requires unresolved disclosure for unverified material facts", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-unverified-")); + try { + await writeCompleteFixture(workspace); + const factsPath = join(workspace, STATE_ROOT, "facts.json"); + const facts = JSON.parse(await readFile(factsPath, "utf8")) as { facts: Array> }; + facts.facts[0]!.verificationStatus = "partially-verified"; + await writeJson(factsPath, facts); + const validation = await runCli(workspace, "validate", "--write-proof"); + assert.equal(validation.exitCode, 2); + const result = JSON.parse(validation.stdout) as { errors: Array<{ code: string }> }; + assert.equal(result.errors.some((error) => error.code === "unverified_fact_not_disclosed"), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage validator rejects unique but irrelevant issue and authority quotes", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-semantic-quotes-")); + try { + await writeCompleteFixture(workspace); + const opinionPath = join(workspace, "deliverables", "opinion.md"); + const opinion = [ + "# Legal Opinion", + "Synthetic entity registered capital of 120 currency units is material to the transaction.", + "The office will archive a uniquely numbered blue folder tomorrow.", + "A separate cafeteria notice confirms the weekly menu schedule.", + "", + ].join("\n"); + await writeFile(opinionPath, opinion); + const coveragePath = join(workspace, STATE_ROOT, "coverage.json"); + const coverage = JSON.parse(await readFile(coveragePath, "utf8")) as { + deliverables: Array>; + issues: Array>; + authorities: Array>; + }; + coverage.deliverables[0]!.sha256 = sha256(opinion); + coverage.issues[0]!.quote = "The office will archive a uniquely numbered blue folder tomorrow."; + coverage.authorities[0]!.quote = "A separate cafeteria notice confirms the weekly menu schedule."; + await writeJson(coveragePath, coverage); + + const validation = await runCli(workspace, "validate", "--write-proof"); + assert.equal(validation.exitCode, 2); + const result = JSON.parse(validation.stdout) as { errors: Array<{ code: string }> }; + const codes = new Set(result.errors.map((error) => error.code)); + assert.equal(codes.has("issue_coverage_quote_unsupported"), true); + assert.equal(codes.has("authority_coverage_quote_unsupported"), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage validator enforces reciprocal and same-entry ledger relationships", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-relationships-")); + try { + await writeCompleteFixture(workspace); + const root = join(workspace, STATE_ROOT); + const sources = JSON.parse(await readFile(join(root, "sources.json"), "utf8")) as { sources: Array> }; + sources.sources[0]!.factIds = ["F-002"]; + await writeJson(join(root, "sources.json"), sources); + + const facts = JSON.parse(await readFile(join(root, "facts.json"), "utf8")) as { facts: Array> }; + facts.facts.push({ + ...facts.facts[0], + id: "F-002", + predicate: "employee count", + value: 42, + material: false, + critical: false, + sourceRefs: [], + thresholdAssessment: null, + }); + await writeJson(join(root, "facts.json"), facts); + + const authorities = JSON.parse(await readFile(join(root, "authorities.json"), "utf8")) as { authorities: Array> }; + authorities.authorities.push({ + id: "A-002", + name: "Unrelated synthetic act", + article: "Article 9", + effectiveVersion: "Current synthetic version", + effectiveDate: "Synthetic effective date", + verificationStatus: "verified", + sourceLocator: "Synthetic official source", + supportedIssueIds: ["I-001"], + supportedConclusion: "An unrelated filing rule applies.", + }); + await writeJson(join(root, "authorities.json"), authorities); + + const matrices = JSON.parse(await readFile(join(root, "matrices.json"), "utf8")) as { matrices: Array> }; + const riskMatrix = matrices.matrices.find((matrix) => matrix.id === "equity-capital-timeline")!; + (riskMatrix.entries as Array>)[0]!.factIds = ["F-001", "F-002"]; + const authorityMatrix = matrices.matrices.find((matrix) => matrix.id === "legal-authority")!; + authorityMatrix.status = "complete"; + authorityMatrix.entries = [{ + id: "M-AUTH-001", + summary: "An intentionally mismatched authority relationship.", + factIds: ["F-002"], + riskSignals: [], + issueIds: ["I-001"], + authorityIds: ["A-002"], + }]; + delete authorityMatrix.notApplicableReason; + await writeJson(join(root, "matrices.json"), matrices); + + const validation = await runCli(workspace, "validate", "--write-proof"); + assert.equal(validation.exitCode, 2); + const result = JSON.parse(validation.stdout) as { errors: Array<{ code: string }> }; + const codes = new Set(result.errors.map((error) => error.code)); + assert.equal(codes.has("fact_source_backlink_missing"), true); + assert.equal(codes.has("source_fact_backlink_missing"), true); + assert.equal(codes.has("issue_authority_backlink_missing"), true); + assert.equal(codes.has("matrix_issue_fact_mismatch"), true); + assert.equal(codes.has("risk_signal_fact_mismatch"), true); + assert.equal(codes.has("matrix_issue_authority_mismatch"), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + test("legal coverage validator accepts null for an optional threshold assessment", async () => { const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-null-threshold-")); try { @@ -339,6 +588,62 @@ test("legal coverage hook groups repeated validator errors into one bounded mile } }); +test("legal coverage hook activates a malformed configured workspace so the agent can repair it", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-malformed-config-")); + try { + const stateRoot = join(workspace, STATE_ROOT); + await mkdir(stateRoot, { recursive: true }); + await writeFile(join(stateRoot, "config.json"), "{ malformed\n"); + + const submit = await runHook({ + hookEventName: "UserPromptSubmit", + sessionId: "malformed-config-session", + transcriptPath: "", + cwd: workspace, + prompt: "Continue the configured workspace.", + internal: false, + }); + assert.equal(submit.hookSpecificOutput.dynamicContext?.length, 1); + + const preModel = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "malformed-config-session", + transcriptPath: "", + cwd: workspace, + }); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /state_file_invalid/u); + assert.equal(preModel.hookSpecificOutput.modelRequestPatch?.metadata?.legalCoverageState, "configuration"); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("legal coverage hook rejects a symlinked session-state ancestor without writing outside the workspace", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-session-symlink-")); + const outside = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-session-outside-")); + try { + const stateRoot = join(workspace, STATE_ROOT); + await mkdir(stateRoot, { recursive: true }); + await symlink(outside, join(stateRoot, "sessions")); + + const result = await runHookProcess({ + hookEventName: "UserPromptSubmit", + sessionId: "symlink-session", + transcriptPath: "", + cwd: workspace, + prompt: "Please conduct legal due diligence and issue a legal opinion.", + internal: false, + }); + assert.equal(result.exitCode, 2); + assert.match(result.stderr, /failed closed/u); + assert.equal(JSON.parse(result.stdout).continue, false); + await assert.rejects(stat(join(outside, "symlink-session.json")), { code: "ENOENT" }); + } finally { + await rm(workspace, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } +}); + test("legal product plugin loads one skill and contains no benchmark-specific controls", async () => { const plugin = await loadPluginFromPath(PLUGIN_ROOT, "project"); assert.equal(plugin.name, "legal-coverage"); @@ -361,7 +666,7 @@ export async function writeCompleteFixture(workspace: string): Promise { "# Legal Opinion", "Synthetic entity registered capital of 120 currency units is material to the transaction.", "The threshold breach requires a closing condition.", - "Applicable law supports the stated closing condition.", + "Synthetic transactions act Article 1 states that a closing condition may address the identified risk.", "", ].join("\n"); await writeFile(join(workspace, "deliverables", "opinion.md"), opinion); @@ -381,6 +686,7 @@ export async function writeCompleteFixture(workspace: string): Promise { sources: [{ id: "S-001", path: "source-room/record.txt", + sha256: sha256("Synthetic company record.\n"), status: "reviewed", extractionMethod: "plain-text inspection", evidenceClass: "official-record", @@ -488,7 +794,7 @@ export async function writeCompleteFixture(workspace: string): Promise { section: "Legal Opinion", locator: "paragraph 3", claim: "The authority supports the control.", - quote: "Applicable law supports the stated closing condition.", + quote: "Synthetic transactions act Article 1 states that a closing condition may address the identified risk.", }], }); } @@ -507,6 +813,17 @@ async function runCli(workspace: string, ...args: string[]): Promise<{ stdout: s } } +async function runValidatorDirect(workspace: string): Promise<{ passed: boolean; errors: Array<{ code: string }> }> { + const moduleUrl = pathToFileURL(VALIDATOR_LIB).href; + const script = [ + `import { validateWorkspace } from ${JSON.stringify(moduleUrl)};`, + "const result = await validateWorkspace({ workspaceRoot: process.argv[1], writeProof: true });", + "process.stdout.write(JSON.stringify(result));", + ].join("\n"); + const result = await execFile(process.execPath, ["--input-type=module", "--eval", script, workspace], { encoding: "utf8" }); + return JSON.parse(result.stdout) as { passed: boolean; errors: Array<{ code: string }> }; +} + async function runHook(input: Record): Promise<{ continue?: boolean; hookSpecificOutput: { @@ -516,7 +833,17 @@ async function runHook(input: Record): Promise<{ modelRequestPatch?: { metadata?: Record }; }; }> { - const stdout = await new Promise((resolvePromise, reject) => { + const result = await runHookProcess(input); + if (result.exitCode !== 0) throw new Error(result.stderr || `Hook exited with code ${result.exitCode}.`); + return JSON.parse(result.stdout) as never; +} + +async function runHookProcess(input: Record): Promise<{ + stdout: string; + stderr: string; + exitCode: number; +}> { + return new Promise((resolvePromise, reject) => { const child = spawn(process.execPath, [HOOK], { stdio: ["pipe", "pipe", "pipe"] }); let output = ""; let stderr = ""; @@ -526,12 +853,10 @@ async function runHook(input: Record): Promise<{ child.stderr.on("data", (chunk: string) => { stderr += chunk; }); child.on("error", reject); child.on("close", (code) => { - if (code === 0) resolvePromise(output); - else reject(new Error(stderr || `Hook exited with code ${code}.`)); + resolvePromise({ stdout: output, stderr, exitCode: code ?? 1 }); }); child.stdin.end(JSON.stringify(input)); }); - return JSON.parse(stdout) as never; } async function writeJson(path: string, value: unknown): Promise { From 6efa7541d9c9c9cc02b484340ad245959110b511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Fri, 24 Jul 2026 00:36:28 +0800 Subject: [PATCH 07/14] test(legal): record coverage v1.2 evidence --- .../automated-gates.txt | 28 ++++ .../code-review.txt | 133 ++++++++++++++++ .../evidence-index.txt | 20 +++ .../false-positive-rejection-summary.txt | 31 ++++ .../final-code-review-v2.txt | 62 ++++++++ .../final-code-review-v3.txt | 67 ++++++++ .../final-manual-qa-v2.txt | 95 ++++++++++++ .../final-manual-qa-v3.txt | 87 +++++++++++ .../full-suite-worker.txt | 60 ++++++++ .../generic-fix-decision.txt | 17 +++ .../hard-case-validator-summary.txt | 23 +++ .../hard-verifier-summary.txt | 31 ++++ .../harness-recovery-incidents.txt | 17 +++ .../implementation-boundary.txt | 24 +++ .../judge-connectivity.txt | 21 +++ .../manual-qa-review.txt | 144 ++++++++++++++++++ .../post-review-fix-gates.txt | 27 ++++ .../recovery-v12-summary.txt | 37 +++++ .../targeted-tests-worker.txt | 84 ++++++++++ .../targeted-tests.txt | 27 ++++ 20 files changed, 1035 insertions(+) create mode 100644 .omo/evidence/20260723-legal-coverage-v1/automated-gates.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/code-review.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/evidence-index.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/false-positive-rejection-summary.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/final-code-review-v2.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/final-code-review-v3.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/final-manual-qa-v2.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/final-manual-qa-v3.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/full-suite-worker.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/generic-fix-decision.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/hard-case-validator-summary.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/hard-verifier-summary.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/harness-recovery-incidents.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/implementation-boundary.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/judge-connectivity.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/manual-qa-review.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/post-review-fix-gates.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/recovery-v12-summary.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/targeted-tests-worker.txt create mode 100644 .omo/evidence/20260723-legal-coverage-v1/targeted-tests.txt diff --git a/.omo/evidence/20260723-legal-coverage-v1/automated-gates.txt b/.omo/evidence/20260723-legal-coverage-v1/automated-gates.txt new file mode 100644 index 00000000..c3e240dd --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/automated-gates.txt @@ -0,0 +1,28 @@ +WHAT WAS TESTED +Frozen commit: 81201cd3f00459ee5dafeb52b3a7612cb0442cc9 +Frozen tree: 411d051e +1. npx tsc -p tsconfig.json --noEmit +2. npm test +Surface: final committed branch, full TypeScript graph, production build, and repository regression suite. + +WHAT WAS OBSERVED +TypeScript: exit 0, 3.001 seconds, no diagnostics. +npm test: exit 0. +Tests: 204 +Pass: 204 +Fail: 0 +Cancelled: 0 +Skipped: 0 +Todo: 0 +TAP duration: 28077.761 ms + +The full-suite QA worker independently ran npm test before the freeze with the same file content: exit 0, 204 of 204 passed, 33.07 seconds wall time. Its sanitized report is full-suite-worker.txt. + +WHY IT IS ENOUGH +The frozen-tree gate rebuilds the executable dist tree and executes every repository test, including the legal validator and real local-gateway plugin flow. The independent worker run provides a second execution of the same full suite. + +WHAT WAS OMITTED +Raw full-suite output, environment dumps, credentials, legal source material, and generated legal content were omitted. + +CLEANUP +No tracked file changed during the gate. Normal ignored build output remains. No test process remains running. diff --git a/.omo/evidence/20260723-legal-coverage-v1/code-review.txt b/.omo/evidence/20260723-legal-coverage-v1/code-review.txt new file mode 100644 index 00000000..822795b5 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/code-review.txt @@ -0,0 +1,133 @@ +FINAL CODE REVIEW - LEGAL COVERAGE V1 + +Verdict: REJECT / CHANGES REQUIRED +Frozen commit: 81201cd3f00459ee5dafeb52b3a7612cb0442cc9 +Frozen tree: 411d051ede610638f94fa1fe6cc361ac7bc01a71 (requested short tree: 411d051e) +Base: 2b1b120c +Review date: 2026-07-23 + +FINDINGS + +[P0] Valid JSON with a non-object top level bypasses the entire validator and generates a completion proof. + +References: +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:128-137 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:228-230 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:274-276 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:337-340 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:391-394 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:454-457 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:524-527 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:577-580 + +Each phase silently returns when its parsed document is not a record. JSON parse therefore succeeds for null, arrays, strings, and numbers, but no schema error is added. `passed` is based only on the empty error array. An isolated probe wrote `null` to all seven canonical files; validation returned `passed:true`, `errors:0`, and wrote a 64-character completion-proof state hash. A single critical ledger can likewise be replaced by a non-object to skip that ledger's checks when the remaining references permit it. This is a complete fail-closed bypass. Require every canonical document to be a record before phase validation and add a blocking schema/type error otherwise. + +[P1] Stop-hook exceptions are classified as non-blocking, so filesystem or validator failures can permit completion. + +References: +- products/legal/plugins/legal-coverage/hook.mjs:48-69 +- src/extension/hooks/execution/CommandHookExecutor.ts:84-89 +- src/extension/hooks/execution/HookRuntime.ts:95-105 + +The hook has no top-level failure boundary. Any exception in `validateWorkspace`, proof removal, proof writing, or state access exits Node with code 1 and emits no blocking output. Core treats only exit code 2 as blocking; exit code 1 is a non-blocking hook error. An isolated probe first created a proof, corrupted config JSON, made the state directory non-writable, and dispatched Stop. Proof deletion threw a permission error; hook exit was 1 with zero stdout bytes, which Core classifies as non-blocking. Since artifact existence is checked before Stop and only with `core:file-exists`, a previously present proof can pass artifact validation before this failure. Catch unexpected failures and emit an explicit blocking result or exit 2, with a test proving Stop cannot complete on validator I/O failure. + +[P1] Source content is absent from validation and the state hash, so evidence can change without making the proof stale. + +References: +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:305-308 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:756-763 + +Source validation checks only that each inventoried path is a file. `computeStateHash` hashes parsed ledgers and deliverable hashes, but not source bytes, source metadata, or source digests. An isolated valid-workspace probe changed the sole reviewed source after proof creation; both validations passed and produced the identical stateHash. Facts, authorities, and the final opinion can therefore remain certified after their underlying evidence changes. Bind each inventoried source to a current digest in validation/proof state and reject stale ledger digests. + +[P1] Lexical path checks allow ancestor-symlink escape for sources, deliverables, state, and proof files. + +References: +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:57-60 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:216-225 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:254-264 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:305-308 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:739-752 + +`resolveWithinWorkspace` checks only the lexical resolved path. `listSourceFiles` rejects a symlink only when the final input root or a directly enumerated child is itself a symlink; it does not resolve and constrain ancestor components. Deliverables use `stat`/`readFile`, which follow symlinks, and the state directory is also created and written without a realpath boundary check. An isolated probe placed `workspace/escape` as a symlink to an external directory, configured `escape/source-room/record.txt` and `escape/deliverables/opinion.md`, and validation passed with zero errors. This contradicts the workspace-relative/project-local contract and can read or write outside the workspace. Constrain existing paths by realpath and reject symlinks for every state, source, deliverable, and proof path, including ancestors. + +[P1] Material facts marked unverified can be presented as covered rather than unresolved. + +References: +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:48-50 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:362-364 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:605-615 + +`unverified` and `partially-verified` are accepted fact verification states, but final coverage requires `unresolved` only for `conflictStatus === "unresolved"`. A material or critical fact with `verificationStatus:"unverified"`, `conflictStatus:"none"`, and a matching quote can therefore pass with coverage status `covered`. The completion contract says uncertainty must be preserved and incomplete facts must fail closed. Require non-verified material/critical facts to be disclosed as unresolved (or define and validate a documented exception). + +[P1] Issue and authority coverage quotes are checked for presence, not semantic support. + +References: +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:605-612 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:617-633 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:637-674 +- tests/products/legal-coverage.spec.ts:96-130 + +Only material-fact rows call `factCoverageQuoteSupports`. Issue and authority rows need merely a non-empty exact substring in the deliverable, and quote reuse is prevented only within each coverage group. Distinct unrelated filler sentences can therefore certify every issue and authority while their `claim`, legal conclusion, and supported conclusion are fabricated. The generic-quote test exercises reuse and fact semantics only; it does not adversarially test issue or authority semantics. Add domain-specific semantic linkage for issue and authority rows and tests with unique but irrelevant exact quotes. + +[P2] Cross-ledger references validate existence but not reciprocal or same-fact relationships. + +References: +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:368-388 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:432-450 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:509-518 +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs:549-573 + +The source/fact graph permits `source.factIds` and `fact.sourceRefs` to disagree as long as both IDs exist. A matrix risk signal only needs a linked issue with the matching rule; the issue need not cover the matrix entry's facts. Issue-to-authority references require an authority backlink, but authority-to-issue references do not require the issue backlink, and a legal-authority matrix entry only checks that its authority IDs exist, not that they support the entry's issue IDs. Unrelated facts and authorities can therefore satisfy structural checks. Enforce bidirectional source/fact and issue/authority links, and require matrix issue/authority relationships to cover the same entry facts/issues. + +TEST QUALITY AND RESIDUAL RISKS + +The focused suites are valuable and passed 12/12, including the real gateway path, but they do not cover top-level non-object JSON, source-content staleness, ancestor symlinks, Stop-time exceptions, unverified material facts, unique irrelevant issue/authority quotes, or reciprocal cross-ledger links. Those missing adversarial cases correspond directly to the findings above. + +Residual risks after those fixes include semantic abuse of `allowNoMaterialFacts` and free-text `notApplicableReason`, automatic detection covering only timeline collisions, threshold breaches, and unresolved source contradictions (not the other three documented cross-fact rules), binary deliverables relying on unverified free-text locators/claims, and race conditions when concurrent sessions validate and rewrite shared workspace proof state. + +No benchmark-specific token or case-specific control was found in the ten product files. The changed-file boundary is clean: no Core source file is changed by this commit. The product-only regression scans the plugin directory rather than the enclosing product README, but manual review of all added product files found no leakage. + +FILES REVIEWED + +- products/legal/README.md +- products/legal/plugins/legal-coverage/hook.mjs +- products/legal/plugins/legal-coverage/hooks/hooks.json +- products/legal/plugins/legal-coverage/plugin.json +- products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs +- products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs +- products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md +- products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/agents/openai.yaml +- products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/data-contracts.txt +- products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/references/issue-rules.txt +- tests/agent/legal-coverage-plugin-runtime.spec.ts +- tests/products/legal-coverage.spec.ts + +COMMANDS USED + +- git rev-parse HEAD +- git rev-parse HEAD^{tree} +- git status --short / git diff --name-only / git diff --cached --name-only +- git diff --name-status 2b1b120c..HEAD +- git diff --stat 2b1b120c..HEAD +- git diff --check 2b1b120c..HEAD +- git diff --find-renames --find-copies 2b1b120c..HEAD +- line-numbered reads of every added product and test file plus the relevant existing HookRuntime, CommandHookExecutor, lifecycle, artifact-validation, and artifact-store code +- node --check on hook.mjs, scripts/legal-coverage.mjs, and scripts/lib/legal-coverage.mjs +- dynamic ESM import of scripts/lib/legal-coverage.mjs (observed VALIDATOR_VERSION 1.1.0) +- node --import tsx --test --test-force-exit --test-timeout 60000 tests/products/legal-coverage.spec.ts tests/agent/legal-coverage-plugin-runtime.spec.ts +- isolated temporary-workspace probes for top-level-null schema handling, source-content staleness, ancestor-symlink escape, and Stop-hook exception classification + +VERIFICATION OBSERVED + +- Syntax checks: exit 0 for all three production MJS files. +- Import check: exit 0; module imported and reported validator version 1.1.0. +- Focused source tests: 12 tests, 12 passed, 0 failed/cancelled/skipped/todo; runner duration 1409.263 ms. +- No build or rebuild was run by this reviewer, and no concurrent build/test worker was present before the focused command. +- Probe: top-level-null => passed=true, errors=0, proof hash length=64. +- Probe: source mutation => beforePassed=true, afterPassed=true, sameStateHash=true. +- Probe: ancestor symlink => passed=true, errors=0. +- Probe: Stop proof-removal permission failure => hook exit=1, stdoutBytes=0, Core classification=non-blocking. + +NO-EDIT CLEANUP + +All probe workspaces were created under the system temporary directory and removed in `finally` blocks. No probe process remains. Product files, test files, index, branch, commit, and git state were not edited. Normal pre-existing untracked `.omo/evidence/20260723-legal-coverage-v1/` and `.omo/ulw-loop/` content was preserved. This reviewer created only this requested code-review.txt evidence file. diff --git a/.omo/evidence/20260723-legal-coverage-v1/evidence-index.txt b/.omo/evidence/20260723-legal-coverage-v1/evidence-index.txt new file mode 100644 index 00000000..b7bdc382 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/evidence-index.txt @@ -0,0 +1,20 @@ +CURRENT FINAL EVIDENCE +- post-review-fix-gates.txt: v1.2.0 targeted 22/22 and full 214/214 gates. +- hard-case-validator-summary.txt: final source-bound validator pass. +- hard-verifier-summary.txt: final independent verifier pass and provenance qualification. +- false-positive-rejection-summary.txt: strengthened negative rejection and stale-proof removal. +- recovery-v12-summary.txt: two real Agent recovery runs with dynamic prompt counts. +- implementation-boundary.txt: final legal-only boundary audit. +- generic-fix-decision.txt: findings fixed here versus the separate Core shell-safety residual. +- final-code-review-v3.txt: final independent closure review when complete. +- final-manual-qa-v3.txt: final independent evidence audit when complete. +- judge-connectivity.txt: current external Judge connectivity blocker. + +HISTORICAL / SUPERSEDED EVIDENCE +- automated-gates.txt, targeted-tests.txt, targeted-tests-worker.txt, and full-suite-worker.txt record the pre-v1.2.0 frozen tree (12/12 and 204/204). They are retained as audit history, not as the final gate. +- manual-qa-review.txt records the pre-v1.2.0 audit. final-manual-qa-v2.txt records the pre-hook-path-fix 21/213 audit. Both are superseded by final-manual-qa-v3.txt. +- code-review.txt is the findings report that triggered v1.2.0. final-code-review-v2.txt identified the remaining hook session-path gap. Both are superseded as a release verdict by final-code-review-v3.txt. +- harness-recovery-incidents.txt records earlier pre-v1.2.0 recovery history and the separate Core shell-safety incident. + +GLOBAL LIMITATION +No current Judge score exists while the private scoring endpoint is unreachable. Local product QA and the independent hard verifier passing do not substitute for that external gate. diff --git a/.omo/evidence/20260723-legal-coverage-v1/false-positive-rejection-summary.txt b/.omo/evidence/20260723-legal-coverage-v1/false-positive-rejection-summary.txt new file mode 100644 index 00000000..43e4bc53 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/false-positive-rejection-summary.txt @@ -0,0 +1,31 @@ +WHAT WAS TESTED +Surface: legal-coverage v1.2.0 with writeProof=true against the known difficult-case shortcut fixture. +Expected: fail closed, remove any stale completion proof, and report the strengthened semantic/integrity failures. + +WHAT WAS OBSERVED +Passed: false +Errors: 1359 +Warnings: 0 +Completion proof exists after validation: false +Selected rejection counts: +source_hash_missing: 62 +material_fact_matrix_orphaned: 278 +coverage_quote_reused: 393 +fact_coverage_quote_unsupported: 323 +unverified_fact_not_disclosed: 270 +critical_issue_authority_missing: 4 +legal_authority_links_missing: 2 +source_fact_backlink_missing: 8 +risk_signal_fact_mismatch: 5 +matrix_issue_fact_mismatch: 2 +matrix_issue_authority_mismatch: 1 +issue_coverage_quote_unsupported: 11 + +WHY IT IS ENOUGH +The shortcut is rejected by independent source-integrity, matrix-completeness, quote-reuse, semantic fact/issue coverage, uncertainty disclosure, authority, and reciprocal-link guards. Running with writeProof=true also proves that a stale proof is removed rather than left for an existence-only consumer. + +WHAT WAS OMITTED +The shortcut content, repeated quote, source text, case entities, legal conclusions, and proof bytes were omitted. + +CLEANUP +The negative fixture remains isolated. Its stale completion proof was intentionally removed by the validator. diff --git a/.omo/evidence/20260723-legal-coverage-v1/final-code-review-v2.txt b/.omo/evidence/20260723-legal-coverage-v1/final-code-review-v2.txt new file mode 100644 index 00000000..7be08cb8 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/final-code-review-v2.txt @@ -0,0 +1,62 @@ +FINAL CODE REVIEW V2 - LEGAL COVERAGE V1 + +Verdict: REJECT / CHANGES REQUIRED +Base: origin/codex/legal-runtime-e2e (2b1b120c) +Reviewed state: current working-tree diff on codex/legal-coverage-v1 +Review date: 2026-07-24 + +FINDINGS + +[P1] The Hook still follows a symlinked sessions directory and can write legal session state outside the workspace. + +References: +- products/legal/plugins/legal-coverage/hook.mjs:24-28 +- products/legal/plugins/legal-coverage/hook.mjs:52-54 +- products/legal/plugins/legal-coverage/hook.mjs:75 +- products/legal/plugins/legal-coverage/hook.mjs:94-139 + +The validator now constrains canonical state files, sources, deliverables, and the proof with resolveSafeWorkspacePath, but the Hook's activation and session-state paths do not use that boundary. sessionStatePath constructs a string, and readSessionState, writeSessionState, and SessionEnd call readFile, mkdir/writeFile, and rm directly. hasConfiguredWorkspace and the config/proof pathExists checks likewise operate before the validator's safe-path checks. + +An isolated synthetic probe created `.pilotdeck/work/legal-coverage/sessions` as a symlink to an external temporary directory, then dispatched UserPromptSubmit. The hook exited 0 and wrote `review-probe.json` with `active:true` into the external directory. Therefore the previous ancestor-symlink finding is only partially closed: validator state is constrained, while Hook-owned state still escapes the workspace. + +Make every Hook read/write/remove for config, proof, and session state reuse the same real-path constraint. For a missing sessions directory, validate the existing ancestors, create the directory, validate it again, then resolve the target before writing. Add a Hook-level regression test that symlinks `sessions` outside the workspace and proves the external sentinel/directory is unchanged and the hook fails closed. Cover SessionEnd removal as well as UserPromptSubmit creation. + +This is the only blocker found in this review. + +SEVEN-FINDING CLOSURE AUDIT + +1. Top-level non-object canonical JSON: CLOSED. readWorkspaceState records state_document_not_object, clears that state value, and validation cannot pass. The focused suite covers all seven canonical files across null/array/string/number representatives. + +2. Stop-hook exceptions classified as non-blocking: CLOSED for the reviewed failure path. The top-level boundary emits an explicit blocking object and exits 2. The real gateway test proves a Stop-time session-state read failure finishes as tool_error and never completed. + +3. Source content absent from proof/state hash: CLOSED. Each inventoried file is hashed, its recorded digest is checked, current source bytes are included in stateHash, and proof metadata includes sorted source digests. Source mutation changes the hash, raises source_hash_stale, and removes the proof. + +4. Ancestor symlink escape: PARTIALLY CLOSED; see the P1 finding. Canonical validator paths, sources, deliverables, input roots, and proof are constrained. Hook-owned activation/session paths remain unconstrained. + +5. Unverified material facts presented as covered: CLOSED. Material or critical facts whose verificationStatus is not verified must have unresolved coverage. + +6. Issue/authority quotes checked only for presence: CLOSED to the intended deterministic-validator boundary. Issue quotes require semantic anchor overlap with reasoning/control text; verified-authority quotes require authority and article in one citation segment plus overlap with supportedConclusion. Unique unrelated quote tests fail. This remains a lexical support guard, not a substitute for legal semantic review. + +7. Non-reciprocal or unrelated cross-ledger references: CLOSED. Source/fact and issue/authority links are bidirectional; matrix issues must share facts, risk-signal issues must cover all entry facts, and legal-authority entries require mutually linked authorities and issues from the same entry. + +VERIFICATION + +- `git diff --check origin/codex/legal-runtime-e2e`: passed. +- Syntax checks for hook.mjs, scripts/legal-coverage.mjs, and scripts/lib/legal-coverage.mjs: passed. +- `npx tsc -p tsconfig.json --noEmit`: passed. +- Focused legal/runtime tests: 21 passed, 0 failed. +- `npm test`: passed with exit code 0, including build and the complete repository test suite. +- Isolated sessions-ancestor symlink probe: hook status 0, externalSessionWritten=true, stdoutBytes=895. + +Probe workspaces were synthetic, created under the system temporary directory, and removed. An accidentally waiting probe process from the first invocation was terminated and its temporary directories were removed. No real legal content was opened or reproduced. + +RESIDUAL RISKS AFTER THE BLOCKER IS FIXED + +- Semantic support uses bounded lexical anchors and cannot prove substantive legal correctness; this is acceptable only as a fail-closed structural/coverage guard backed by the legal Skill and human/domain review. +- Binary deliverables still rely on user-authored locator and claim fields because exact quote validation is unavailable. +- Concurrent filesystem replacement between path validation, read, hash, and atomic rename remains a local TOCTOU risk. Avoiding it fully would require descriptor-relative filesystem operations outside this legal-only change's current scope. +- allowNoMaterialFacts and free-text notApplicableReason remain judgment-bearing escape hatches; their use should remain auditable in difficult-case review. + +BOUNDARY AND NO-EDIT CONFIRMATION + +The reviewed diff remains limited to products/legal and the two focused legal test files. No benchmark-specific names, fixed case counts, Judge controls, or temporary-run paths were found in the reviewed implementation. This reviewer did not edit product code or tests and created only this requested report. diff --git a/.omo/evidence/20260723-legal-coverage-v1/final-code-review-v3.txt b/.omo/evidence/20260723-legal-coverage-v1/final-code-review-v3.txt new file mode 100644 index 00000000..6eac936d --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/final-code-review-v3.txt @@ -0,0 +1,67 @@ +FINAL CODE REVIEW V3 - LEGAL COVERAGE V1 + +Verdict: PASS +Base: origin/codex/legal-runtime-e2e (2b1b120c) +Reviewed state: current working-tree diff on codex/legal-coverage-v1 +Review date: 2026-07-24 + +FINDINGS + +No correctness, compatibility, recoverability, or data-consistency findings were found in the reviewed legal-only boundary. + +V2 BLOCKER CLOSURE + +The v2 Hook path-escape blocker is CLOSED. + +- Config reads call resolveSafeWorkspacePath before readFile. Missing config remains the only non-error absence case; unsafe paths propagate to the top-level fail-closed boundary. +- Config/proof activation checks call resolveSafeWorkspacePath before stat. +- Session paths are workspace-relative and retain bounded session-id sanitization. +- Session reads call resolveSafeWorkspacePath before readFile. +- Session writes validate existing ancestors, create the missing directory, validate the full path again, and only then call writeFile. +- SessionEnd removal calls resolveSafeWorkspacePath before rm. The normal SessionEnd flow also performs the safe session read before removal. +- Any unsafe path reaches the top-level hook boundary, emits continue:false with legal_coverage_validation_error, and exits 2. + +The committed symlinked-sessions test genuinely proves the write-side property: it dispatches the real Hook process with `sessions` linked outside, requires exit 2 and continue:false, and asserts the exact sanitized external target was not created. + +An independent synthetic sentinel probe extended that coverage without modifying tests: + +- UserPromptSubmit with a symlinked sessions ancestor: exit 2; existing external session sentinel unchanged. +- SessionEnd with a symlinked sessions ancestor: exit 2; external session sentinel not removed or modified. +- UserPromptSubmit with a symlinked config: exit 2; external config unchanged. +- PreModelRequest with a symlinked proof: exit 2; external proof unchanged. + +The resolveSafeWorkspacePath export introduces no new behavior. The function body remains the validator's reviewed implementation; exporting it allows the Hook to reuse the same constraint. The direct relative import has no circular dependency, syntax/import checks pass, and both focused and full suites pass. + +ORIGINAL SEVEN-FINDING CLOSURE AUDIT + +1. Top-level non-object canonical JSON: CLOSED. +2. Stop-hook exceptions classified as non-blocking: CLOSED. +3. Source content absent from proof/state hash: CLOSED. +4. Ancestor symlink escape for sources, deliverables, canonical state, proof, and Hook session state: CLOSED. +5. Unverified material facts presented as covered: CLOSED. +6. Issue/authority quotes checked only for presence: CLOSED to the documented deterministic lexical-support boundary. +7. Non-reciprocal or unrelated cross-ledger references: CLOSED. + +VERIFICATION + +- `git diff --check origin/codex/legal-runtime-e2e`: passed. +- Syntax checks for hook.mjs, scripts/legal-coverage.mjs, and scripts/lib/legal-coverage.mjs: passed. +- `npx tsc -p tsconfig.json --noEmit`: passed. +- Focused legal/runtime tests: 22 passed, 0 failed. +- `npm test`: 214 passed, 0 failed/cancelled/skipped/todo; exit 0. +- Independent config/proof/session sentinel probe: all four unsafe-path operations exited 2 and preserved external content. + +All probe workspaces were synthetic, created under the system temporary directory, and removed. No probe process remains. No real legal content was opened or reproduced. + +RESIDUAL RISKS + +- Semantic support remains a bounded lexical guard, not proof of substantive legal correctness; the legal Skill and domain review remain necessary. +- Binary deliverables rely on explicit locator and claim fields because exact text-quote validation is unavailable. +- Filesystem replacement between path validation and the subsequent operation remains a local TOCTOU risk. Eliminating it fully would require descriptor-relative filesystem operations beyond this legal-only implementation boundary. +- allowNoMaterialFacts and free-text notApplicableReason remain judgment-bearing fields that should stay visible in difficult-case review. + +These are previously identified design limitations, not regressions or blockers for this change. + +BOUNDARY AND NO-EDIT CONFIRMATION + +The implementation diff remains limited to products/legal and the two focused legal test files. No benchmark-specific names, fixed case counts, Judge controls, or temporary-run paths were found in the reviewed product implementation. This reviewer did not edit product code or tests and created only this requested v3 report. diff --git a/.omo/evidence/20260723-legal-coverage-v1/final-manual-qa-v2.txt b/.omo/evidence/20260723-legal-coverage-v1/final-manual-qa-v2.txt new file mode 100644 index 00000000..a464a30f --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/final-manual-qa-v2.txt @@ -0,0 +1,95 @@ +PILOTDECK LEGAL-COVERAGE V1.2.0 - FINAL MANUAL QA V2 +Date: 2026-07-24 +Mode: read-only evidence audit plus local reruns; no product, test, or case edits + +VERDICT + +LOCAL PRODUCT QA: PASS. +EXTERNAL JUDGE GATE: BLOCKED BY PRIVATE-NETWORK CONNECTIVITY; NO CURRENT SCORE EXISTS. + +The local verdict covers installation fidelity, the focused and repository-wide automated gates, +the strengthened product validator, the independent difficult-case verifier, fail-closed negative +behavior, dynamic request injection, and artifact provenance. It does not substitute for the +unreachable external Judge. + +OBSERVED RESULTS + +1. Final plugin installation fidelity + - Repository bundle files: 9. + - Installed difficult-case bundle files: 9. + - Recursive byte comparison: identical; diff exit 0. + - The installed real-run plugin therefore matches the final v1.2.0 working-tree implementation. + +2. Automated gates + - Focused legal-product and real-gateway suites: 21/21 passed; zero failures, skips, + cancellations, or todos. + - Full repository build and test suite: 213/213 passed; zero failures, skips, cancellations, + or todos. + - The full suite was independently rerun during this audit and completed successfully. + +3. Shipped v1.2.0 validator on the difficult case + - Passed: true. + - Errors/warnings: 0/0. + - Counts: sources=62, facts=76, issues=14, authorities=25, deliverables=1. + - A read-only status rerun reproduced the recorded state hash and counts. + - The current proof binds reviewed source bytes, canonical state, and the deliverable. + +4. Independent difficult-case verifier + - Passed: true; verifier errors: 0. + - Source ledger: 62/62, with no missing, duplicate, or uninspected machine-readable source. + - Presentation manifest: valid, 9 slides. + - Topic evidence: 8/8 present. + - Reverse coverage: present. + - Final integrity: valid. + - A fresh read-only verifier rerun reproduced these aggregate results. + +5. Known shortcut / false-positive fixture + - Passed: false. + - Errors/warnings: 1359/0. + - Completion proof after fail-closed validation: absent. + - Rejection spans independent source-digest, materiality/matrix, quote-reuse, semantic-support, + unresolved-verification, authority, and reciprocal-link guards. + - A read-only status rerun reproduced the 1359-error total and selected per-code counts. + +6. Two real Agent recovery runs with dynamic injection + - Contract-migration run: 49 model stream requests, patched 49/49; 48 Stop continuations; + 59 tool calls; validator reduced from 101 errors to 0 errors and 0 warnings. + - Integrity-finalization run: 15 model stream requests, patched 15/15; 14 Stop continuations; + 16 tool calls; completed with the opinion bytes unchanged and the independent verifier passed. + - The final integrity run's 15/15 result is directly corroborated by its retained aggregate and + request audit. The earlier 49/49 aggregate was captured at completion and retained in the + sanitized recovery summary; its per-request file was subsequently overwritten by the second + recovery run, so the report does not claim a second independent reconstruction of those 49 rows. + +PROVENANCE + +PilotDeck Agent sessions produced the substantive opinion, the v1.2.0 canonical-state migration, +the required opinion corrections, and the refreshed final-integrity manifest. One topic-evidence +compatibility artifact and the reverse-coverage compatibility artifact were originally derived by +QA from already validated canonical ledgers. Those two artifacts support legacy-verifier +compatibility and must not be represented as autonomous output from the primary Agent run. + +BOUNDARY AND RESIDUAL RISK + +- The post-review implementation remains in the legal Domain Plugin, its legal Skill/references, + and two focused test files. PilotDeck Core source was not changed. +- The previously observed empty shell redirection under bypass-permissions is a separate Core + tool-safety issue. It remains intentionally outside this legal-only change and requires its own + regression-tested Core PR. +- Current Judge connectivity evidence shows timeout before HTTP submission because this machine + has no route to the private scoring service. Therefore overall score, critical score, and Judge + error count cannot be claimed. This is an external availability blocker, not a semantic pass or + rejection. + +WHY THIS IS ENOUGH FOR THE LOCAL VERDICT + +The installed bytes were exercised through the real gateway, the focused adversarial tests cover +the repaired fail-closed paths, the full repository suite passed, the positive difficult case +passes both product and independent validators, and the known shortcut fails without leaving a +proof. The remaining unverified gate is exclusively the external Judge. + +WHAT WAS OMITTED + +Legal facts and conclusions, source names and paths, case entities, raw prompts and model output, +tool payloads, credentials, private model configuration, exact endpoint/address details, raw Judge +responses, and Judge payloads were not copied into this report. diff --git a/.omo/evidence/20260723-legal-coverage-v1/final-manual-qa-v3.txt b/.omo/evidence/20260723-legal-coverage-v1/final-manual-qa-v3.txt new file mode 100644 index 00000000..28106e12 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/final-manual-qa-v3.txt @@ -0,0 +1,87 @@ +PILOTDECK LEGAL-COVERAGE V1.2.0 - FINAL MANUAL QA V3 +Date: 2026-07-24 +Mode: incremental read-only audit plus local test reruns; no product, test, or case edits + +VERDICT + +LOCAL PRODUCT QA: PASS. +EXTERNAL JUDGE GATE: BLOCKED BY PRIVATE-NETWORK CONNECTIVITY; NO CURRENT SCORE EXISTS. + +This v3 audit supersedes the local gate counts in v2. It specifically verifies the final installed +plugin after the Hook session-path fix and the new symlinked-sessions regression. + +INCREMENTAL RESULTS + +1. Final installed plugin + - Repository bundle files: 9. + - Installed difficult-case bundle files: 9. + - Recursive byte comparison after the final installation sync: identical; diff exit 0. + - Hook syntax and validator-library syntax: passed. + - Validator version: 1.2.0. + +2. Hook session-state symlink regression + - The focused test creates the legal state root, replaces its sessions child with a symlink to + an external temporary directory, and dispatches an activating UserPromptSubmit event. + - Observed hook exit code: 2. + - Observed output: explicit fail-closed blocking result with continue=false. + - Observed external effect: no session-state file was created through the symlink. + - The Hook now resolves config, proof, and session-state candidates through the validator's + real-path constraint. Session creation checks the path before and after directory creation; + session reads and SessionEnd removal use the same constrained resolver. + - The targeted suite independently reran this regression and passed it. + +3. Final automated gates + - Focused legal-product and real-gateway suites: 22/22 passed. + - Full repository build and test suite: 214/214 passed. + - Both runs had zero failures, skips, cancellations, or todos. + - The full suite was independently rebuilt and rerun during this audit. + +4. Difficult-case positive state + - Shipped validator: passed=true; errors/warnings=0/0. + - Counts: sources=62, facts=76, issues=14, authorities=25, deliverables=1. + - Independent verifier: passed=true; errors=0. + - Source ledger: 62/62, with no missing, duplicate, or uninspected machine-readable source. + - Presentation manifest valid; 9 slides; topic evidence 8/8; reverse coverage present; final + integrity valid. + - Fresh read-only validator and verifier reruns reproduced these aggregates after the final + plugin installation. + +5. Known shortcut / false-positive fixture + - Validator result: passed=false; errors/warnings=1359/0. + - Completion proof: absent. + - Fresh read-only validation reproduced the total and the independent integrity, materiality, + matrix, semantic-support, uncertainty, authority, and reciprocal-link rejection classes. + +UNCHANGED PROVENANCE QUALIFICATION + +PilotDeck Agent sessions produced the substantive opinion, the v1.2.0 canonical-state migration, +the required opinion corrections, and the refreshed final-integrity manifest. One topic-evidence +compatibility artifact and the reverse-coverage compatibility artifact were originally derived by +QA from already validated canonical ledgers. Those two compatibility artifacts must not be +represented as autonomous output from the primary Agent run. + +BOUNDARY AND LIMITATIONS + +- The final fix remains inside the legal Domain Plugin and focused legal tests. PilotDeck Core + source was not changed. +- The new adversarial regression directly exercises the session write path. SessionEnd uses the + same constrained path helper and was inspected in this audit; there is not a separate symlinked + SessionEnd process assertion in the focused test. +- The generic empty-shell-redirection risk under bypass-permissions remains a separate Core issue + and is intentionally outside this legal-only change. +- The external Judge remains unreachable before HTTP submission. No overall score, critical score, + or Judge error count can be claimed. This is an external availability blocker, not a semantic + pass or rejection. + +WHY THIS IS ENOUGH FOR THE LOCAL VERDICT + +The exact final installed bytes pass the new adversarial Hook test, all focused real-gateway and +legal tests, and the full repository suite. The positive difficult case passes both independent +validation surfaces, while the known shortcut remains fail-closed without a proof. The only +uncompleted release gate is the external Judge. + +WHAT WAS OMITTED + +Legal facts and conclusions, source names and paths, case entities, raw prompts and model output, +tool payloads, credentials, private model configuration, endpoint/address details, raw Judge +responses, and Judge payloads were not copied into this report. diff --git a/.omo/evidence/20260723-legal-coverage-v1/full-suite-worker.txt b/.omo/evidence/20260723-legal-coverage-v1/full-suite-worker.txt new file mode 100644 index 00000000..9840b8e3 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/full-suite-worker.txt @@ -0,0 +1,60 @@ +PILOTDECK FULL-SUITE QA WORKER REPORT +Date: 2026-07-23 +Worktree: /Users/da/Documents/PilotDeck-worktrees/20260723-feat-legal-coverage-v1 + +WHAT WAS TESTED + +Command: npm test +Invocation count: exactly one +Surface: the repository production build followed by every compiled Node test and spec matched +by dist/tests/**/*.test.js and dist/tests/**/*.spec.js. +Intended proof: the complete discovered PilotDeck regression suite, including the legal-coverage +change, builds and passes as one gate. + +WHAT WAS OBSERVED + +Command exit code: 0 +Wall duration: 33.07 seconds +TAP runner duration: 28213.584458 ms +Tests: 204 +Suites: 0 +Pass: 204 +Fail: 0 +Cancelled: 0 +Skipped: 0 +Todo: 0 +Failed test names: none + +Post-run repository checks: + git status --short: + ?? .omo/evidence/20260723-legal-coverage-v1/ + ?? .omo/ulw-loop/ + ?? products/legal/ + ?? tests/agent/legal-coverage-plugin-runtime.spec.ts + ?? tests/products/ + git diff --name-only: no paths + git diff --cached --name-only: no paths + +The status output matched the pre-run untracked feature/evidence set. Both tracked and staged +name-only diffs were empty, proving this QA worker did not mutate tracked product or test source. + +WHY IT IS ENOUGH + +The package's npm test script performs the production build and then asks Node's test runner to +discover both compiled test and spec patterns. The runner discovered 204 tests and every one +passed. The zero exit code and zero fail, cancelled, skipped, and todo counts satisfy the full +regression gate, while the three Git checks establish that the run caused no tracked-source or +staged mutation. + +WHAT WAS OMITTED + +No real legal case workspace or legal source content was accessed. The raw command transcript, +environment data, credentials, tokens, auth headers, private endpoints, and generated legal +artifacts are not included. Only aggregate TAP results and repository cleanliness metadata are +recorded. + +CLEANUP + +The temporary raw test log under /tmp was removed after extracting the sanitized aggregate. +Normal ignored build output was left in the worktree. This evidence report is the only file added +by this worker. diff --git a/.omo/evidence/20260723-legal-coverage-v1/generic-fix-decision.txt b/.omo/evidence/20260723-legal-coverage-v1/generic-fix-decision.txt new file mode 100644 index 00000000..7e8c3797 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/generic-fix-decision.txt @@ -0,0 +1,17 @@ +DECISION +The final frozen-diff review found seven actionable defects inside the legal Domain Plugin. They were fixed in the legal plugin and focused tests; no PilotDeck Core source was changed. + +FIXED IN THIS PR +- Non-object canonical JSON can no longer bypass validation. +- Stop-hook validator/state I/O failures block completion with exit code 2. +- Reviewed sources bind review-time SHA-256 and current source bytes enter the state proof. +- State, source, deliverable, and proof paths reject workspace-internal symlink ancestors. +- Partially verified or unverified material facts require unresolved final disclosure. +- Issue and authority quotes require deterministic semantic support. +- Source/fact, issue/authority, and same-entry matrix relationships are reciprocal and relevant. + +BOUNDARY ANALYSIS +All fixes belong to the legal completion contract. The earlier empty shell redirection under bypassPermissions remains a generic Core tool-safety issue and is intentionally excluded from this legal-only PR. It requires a separate Core PR with its own failing regression test and permission-mode analysis. + +WHAT WAS OMITTED +No legal source text, model response, tool payload, case entity, private endpoint, token, or credential is included. diff --git a/.omo/evidence/20260723-legal-coverage-v1/hard-case-validator-summary.txt b/.omo/evidence/20260723-legal-coverage-v1/hard-case-validator-summary.txt new file mode 100644 index 00000000..770a943d --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/hard-case-validator-summary.txt @@ -0,0 +1,23 @@ +WHAT WAS TESTED +Command: legal-coverage v1.2.0 validate --workspace --write-proof. +Surface: the shipped validator and the plugin bytes installed into the real 62-file difficult-case workspace after a fresh PilotDeck Agent migrated the canonical state to the strengthened contract. +Expected: exit 0, passed=true, zero errors and warnings, and a current source-bound completion proof. + +WHAT WAS OBSERVED +Exit: 0 +Passed: true +Errors: 0 +Warnings: 0 +Counts: sources=62, facts=76, issues=14, authorities=25, deliverables=1 +State SHA-256: 35983599b74372c31c3b7bc2079029d281b1246de3b4210aab5601fb05a169b7 +Proof path: .pilotdeck/work/legal-coverage/completion-proof.json +Installed plugin recursive comparison: byte-identical to the final working-tree plugin. + +WHY IT IS ENOUGH +Validator v1.2.0 binds current source bytes, review-time source digests, canonical ledgers, and deliverable hashes. It also enforces fail-closed document types and paths, unresolved verification states, reciprocal ledger links, same-entry matrix relationships, and semantic issue/authority coverage. + +WHAT WAS OMITTED +The source-room path, source text, entity names, opinion content, coverage quotes, proof body, credentials, and raw model output were omitted. + +CLEANUP +The final write-proof run changed only the generated proof timestamp/bytes. No product or test source was modified. diff --git a/.omo/evidence/20260723-legal-coverage-v1/hard-verifier-summary.txt b/.omo/evidence/20260723-legal-coverage-v1/hard-verifier-summary.txt new file mode 100644 index 00000000..b36f9949 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/hard-verifier-summary.txt @@ -0,0 +1,31 @@ +WHAT WAS TESTED +Command: node verify-hard . +Surface: the independent legacy hard-case verifier after the PilotDeck Agent refreshed the final integrity manifest for the current opinion. +Expected: exit 0 and hardCaseQuality.passed=true. + +WHAT WAS OBSERVED +Exit: 0 +Passed: true +Source files: 62 +Ledger files: 62 +Missing sources: 0 +Duplicate ledger paths: 0 +Uninspected machine-readable sources: 0 +PPTX manifest valid: true +PPTX slides: 9 +Missing topic evidence: 0 of 8 +Reverse coverage review present: true +Final integrity valid: true +Verifier errors: 0 + +WHY IT IS ENOUGH +The verifier independently checks inventory completeness, machine-readable inspection, presentation coverage, eight-topic synthesis, reverse coverage, and SHA-256 integrity. The final integrity manifest was refreshed and verified by a real PilotDeck Agent recovery turn rather than by the QA worker. + +PROVENANCE QUALIFICATION +One remaining topic-evidence compatibility artifact and the reverse-coverage compatibility artifact were originally derived by QA from already validated canonical ledgers. They must not be represented as autonomous output from the primary PilotDeck run. The substantive opinion, v1.2.0 canonical state migration, and refreshed final integrity manifest were produced by PilotDeck Agent sessions. + +WHAT WAS OMITTED +All legal facts, source names, source paths, opinion content, compatibility artifact content, and raw model responses were omitted. + +CLEANUP +The verifier and Agent recovery process exited. No product or test source was modified. diff --git a/.omo/evidence/20260723-legal-coverage-v1/harness-recovery-incidents.txt b/.omo/evidence/20260723-legal-coverage-v1/harness-recovery-incidents.txt new file mode 100644 index 00000000..c7a7ac92 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/harness-recovery-incidents.txt @@ -0,0 +1,17 @@ +WHAT WAS TESTED +Surface: repeated real createLocalGateway recovery turns over the existing difficult-case workspace, with dynamic legal milestones active on every model request. + +WHAT WAS OBSERVED +Primary run: 72/72 model requests received the dynamic legal-coverage metadata patch; the run produced the substantive opinion and canonical ledgers but reached the turn cap. +First recovery: 72/72 requests were patched; validator errors fell from 99 to 10 but the reused session repeatedly compacted its multi-million-token history. +Fresh repair session: no compaction loop; 36/36 requests were patched; product validator reached zero errors and seven of eight legacy topic reports were produced. +Finalization attempt: the model issued an empty shell redirection that truncated the temporary verifier and modified temporary case config despite explicit instructions. The process was stopped immediately; the verifier was restored from a complete sibling copy; product validator state remained passed with zero errors. + +WHY IT MATTERS +Fresh-session recovery is materially more efficient than reusing a saturated session. The empty-redirection incident is a generic bash-tool safety issue under bypassPermissions, not a legal-plugin defect. Fixing it in this legal-only PR would violate the implementation boundary; it must be handled in a separate PilotDeck Core change with its own regression test. + +WHAT WAS OMITTED +Raw chat logs, tool payloads, legal facts, model responses, source text, tokens, credentials, and private endpoints were omitted. + +CLEANUP +All failed or interrupted recovery processes exited. The temporary verifier was restored and syntax-checked before the independent hard verification was rerun. diff --git a/.omo/evidence/20260723-legal-coverage-v1/implementation-boundary.txt b/.omo/evidence/20260723-legal-coverage-v1/implementation-boundary.txt new file mode 100644 index 00000000..8b45958a --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/implementation-boundary.txt @@ -0,0 +1,24 @@ +WHAT WAS TESTED +Surfaces: changed-path inventory against origin/codex/legal-runtime-e2e, product-only forbidden-token scan, plugin manifest/hook registry inspection, and diff review after the v1.2.0 fixes. +Expected: no Core source edit, no benchmark-specific entity or control in products/legal, and all legal behavior packaged as one legal plugin plus one legal skill. + +WHAT WAS OBSERVED +Changed src/ paths: 0 +Product files under products/legal: 10 +Focused test files: 2 +Post-review modified product files: 5 +Post-review modified test files: 2 +Product-only matches for hard-case title, entity names, output stem, benchmark marker, temporary run name, fixed source-count literal, and fixed slide-count literal: 0 +Plugin hooks: UserPromptSubmit, PreModelRequest, Stop, SessionEnd +Skill count: 1 (conduct-legal-due-diligence) +Validator version: 1.2.0 +Core source changes: none + +WHY IT IS ENOUGH +The generic Harness remains untouched. Activation, dynamic milestones, fail-closed stopping, legal workflow guidance, source/ledger/deliverable integrity, semantic coverage, and deterministic validation are owned by the legal product bundle. Focused negative-token tests prevent benchmark details from entering shipped product code. + +WHAT WAS OMITTED +Forbidden case tokens are summarized rather than reproduced. No source-room or opinion content was scanned into this evidence file. + +CLEANUP +Read-only audit; no runtime resources or temporary mutations were created. diff --git a/.omo/evidence/20260723-legal-coverage-v1/judge-connectivity.txt b/.omo/evidence/20260723-legal-coverage-v1/judge-connectivity.txt new file mode 100644 index 00000000..f79cbce0 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/judge-connectivity.txt @@ -0,0 +1,21 @@ +WHAT WAS TESTED +1. node /tmp/pilotdeck-legal-coverage-runner.mjs judge-hard +2. curl HTTPS connectivity probe to the configured Judge endpoint with a 15-second connect timeout. +3. Local DNS and route inspection for the resolved address. + +WHAT WAS OBSERVED +Judge attempt: no score returned; TCP connection timed out before HTTP or scoring. +curl: HTTP code 000, no TCP or TLS connection, connect timeout after 15.011 seconds. +DNS: the Judge hostname resolves to a private 10.32.x.x address. +Route: the machine has only the normal en0 default route and no private-network/VPN route to the Judge address. +Judge payload result: not submitted. +Judge errors: not applicable because the scoring service was never reached. + +WHY IT MATTERS +This is an external private-network availability blocker, not a semantic Judge failure. No current overall or critical score can be claimed until the endpoint is reachable. Product validator, independent hard verifier, targeted tests, and the full suite remain independently passed. + +WHAT WAS OMITTED +The exact private endpoint, resolved IP, payload, source text, opinion content, credentials, and any prior Judge response were omitted. + +CLEANUP +The failed Judge process and connectivity probe exited. No bound port, temporary process, or new raw Judge response remains. diff --git a/.omo/evidence/20260723-legal-coverage-v1/manual-qa-review.txt b/.omo/evidence/20260723-legal-coverage-v1/manual-qa-review.txt new file mode 100644 index 00000000..b9f49a1c --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/manual-qa-review.txt @@ -0,0 +1,144 @@ +PILOTDECK LEGAL-COVERAGE V1 - FINAL MANUAL QA REVIEW +Date: 2026-07-23 +Mode: read-only evidence audit plus approved validator reruns + +FINDINGS + +1. BLOCKER - No end-to-end Judge result exists. + The existing connectivity evidence shows that the configured Judge resolved to a private + address for which this machine had no route. The request did not reach HTTP and no payload was + submitted, so there is no current overall score, critical score, or semantic Judge verdict. + This is an external private-network availability blocker, not a product pass and not a Judge + rejection. + +2. PROVENANCE QUALIFICATION - The independent hard verifier is not wholly autonomous output. + PilotDeck autonomously produced the substantive deliverable and canonical legal-coverage state + that the shipped product validator accepted. The final independent verify-hard pass also + depends on three legacy compatibility artifacts derived by QA from that already validated + canonical state: the remaining topic-evidence artifact, the reverse-coverage artifact, and the + final integrity attestation. Those three artifacts prove compatibility with the legacy hard + verifier; they must not be represented as autonomous PilotDeck-agent output. + +3. RESIDUAL INTEGRATION RISK - The rejected negative fixture still contains a pre-existing + completion-proof file, while the current validator status is passed=false. The validator and + runtime paths that revalidate current state fail closed, but a hypothetical external consumer + that trusts proof-file existence without revalidation could accept stale evidence. No such + bypass was observed in the tested PilotDeck completion path. + +4. OUT-OF-SCOPE CORE RISK - A prior finalization attempt under bypassPermissions issued an empty + shell redirection outside the project and truncated a temporary verifier. The process was + stopped and the verifier restored before verification. This is a generic Core tool-safety + issue, not a legal-plugin defect, and remains appropriate for a separate Core change and + regression test. + +VERDICT + +LOCAL PRODUCT QA: PASS. +OVERALL BENCHMARK / RELEASE VERDICT: NOT ESTABLISHED - BLOCKED ON JUDGE CONNECTIVITY. + +No new legal-product defect was found in the frozen tree. Dynamic activation, fail-closed +revalidation, semantic coverage guards, product boundary isolation, the full test suite, plugin +installation fidelity, and cleanup are supported by the evidence and the sanitized reruns below. +The result must not be described as a complete benchmark pass until the Judge returns a valid +score. + +FROZEN IDENTITY AND INSTALLATION FIDELITY + +- Requested commit: 81201cd3f00459ee5dafeb52b3a7612cb0442cc9 - matched. +- Requested tree: 411d051ede610638f94fa1fe6cc361ac7bc01a71 - matched. +- Repository plugin bundle files: 9. +- Installed hard-case plugin bundle files: 9. +- Recursive byte comparison: identical; diff exit 0. + +APPROVED RERUNS + +1. Shipped product validator, hard case, with --write-proof + Exit: 0 + Passed: true + Errors: 0 + Warnings: 0 + Counts: sources=62, facts=76, issues=14, authorities=25, deliverables=1 + The generated completion proof was refreshed and its bytes changed, as expected for a new + write-proof capture. It was the only legal-coverage file modified by the rerun; canonical + ledgers, deliverable, verifier, product code, tests, and frozen git state were not modified. + +2. Independent verify-hard command + Exit: 0 + Passed: true + Source files: 62 + Ledger files: 62 + Missing sources: 0 + Duplicate ledger paths: 0 + Uninspected machine-readable sources: 0 + Presentation manifest valid: true + Presentation slides: 9 + Missing topic evidence: 0 + Reverse-coverage artifact present: true + Final integrity valid: true + Verifier errors: 0 + Provenance remains qualified by Finding 2. + +3. Current validator status, known false-positive fixture + Exit: 0 + Passed: false + Errors: 1000 + Warnings: 0 + Selected rejection counts: + coverage_quote_reused=393 + fact_coverage_quote_unsupported=323 + critical_issue_authority_missing=4 + legal_authority_links_missing=2 + The expected negative fixture remains rejected. The status command made no workspace mutation. + +EVIDENCE COVERAGE ASSESSMENT + +- Dynamic prompt activation: PROVED for the tested PilotDeck runtime path. The focused suite drove + the real local gateway and verified legal-only activation and milestone injection. Sanitized + recovery telemetry records model-request patches on 72/72 primary requests, 72/72 reused-session + requests, and 36/36 fresh-repair requests. + +- Fail-closed completion: PROVED for validator and runtime completion paths that revalidate state. + The hard case writes a proof only after a zero-error validation, while the known false-positive + fixture remains passed=false under current status with four directly relevant semantic rejection + classes. Focused tests also cover proof creation, invalidation, and Stop-hook behavior. See + Finding 3 for the stale-file integration caveat. + +- Semantic coverage: PROVED at product-validator level. The accepted difficult case has zero + errors and warnings, while the known shortcut remains rejected for reused coverage, unsupported + fact coverage, missing critical authority, and missing authority links. The independent verifier + separately confirms inventory, machine-readable inspection, presentation coverage, topic + coverage, reverse coverage, and integrity, subject to the compatibility-artifact qualification. + +- Boundary isolation: PROVED by the recorded inventory. There are zero Core source edits, no + hard-case-specific forbidden tokens in shipped product code, and the behavior is packaged in the + legal product plugin and skill. The generic shell-safety incident was correctly excluded from + this legal-only change. + +- Automated regression coverage: PROVED on the frozen tree. TypeScript exited 0 with no + diagnostics. Targeted production-build/runtime gates passed 12/12. The complete npm test gate + passed 204/204 with zero failures, cancellations, skips, or todos. An independent worker also + recorded a second 204/204 full-suite pass. + +- Cleanup: PROVED for this review. No relevant PilotDeck, local-gateway, or Judge process remained. + Raw rerun output was held only in temporary files and removed after aggregate extraction. The + frozen repository has no tracked or staged diff; only the pre-existing untracked evidence and + work-tracking directories remain. No source document, opinion content, coverage quote content, + private model log, raw Judge payload, credential, or private endpoint is reproduced here. + +EVIDENCE AUDIT + +All 11 pre-existing reviewer-readable reports in this evidence directory were inspected. Their +counts and conclusions are mutually consistent with the reruns. The reports adequately cover the +tested local product surfaces, but judge-connectivity.txt correctly records an unavailable external +gate rather than converting it into a pass. + +RESIDUAL RISKS + +- Judge scoring remains blocked until private-network access is restored and a valid response is + captured. +- Three compatibility artifacts have QA-derived provenance and reduce the evidentiary strength of + the independent verifier for autonomous-agent performance; they do not weaken the shipped + product validator's pass over canonical state. +- Proof consumers must revalidate current state rather than trust proof-file existence alone. +- The bypassPermissions shell-truncation behavior needs a separate Core safety fix and regression + test. diff --git a/.omo/evidence/20260723-legal-coverage-v1/post-review-fix-gates.txt b/.omo/evidence/20260723-legal-coverage-v1/post-review-fix-gates.txt new file mode 100644 index 00000000..25d25fcd --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/post-review-fix-gates.txt @@ -0,0 +1,27 @@ +WHAT WAS TESTED +Surface: the final legal-coverage v1.2.0 commit b21dc972b126152393c4dbecc46ddd26ccfcb20d (tree 02544a6470d29682d42a55832f65ffbcb423e9d9), after all actionable findings in code-review.txt and final-code-review-v2.txt were fixed. +1. Syntax checks for the hook, CLI, and validator library. +2. npx tsc -p tsconfig.json --noEmit. +3. npm run build. +4. Compiled legal product and real-gateway runtime suites. +5. npm test for the full repository. +6. git diff --check and changed-path boundary inspection. + +WHAT WAS OBSERVED +Syntax checks: exit 0. +TypeScript: exit 0, no diagnostics. +Build: exit 0. +Targeted tests: 22/22 passed; zero failures, cancellations, skips, or todos. +Full suite: 214/214 passed; zero failures, cancellations, skips, or todos; TAP duration 27386.833625 ms. +Diff check: exit 0. +Core source changes: 0. +The only tracked working-tree edits after the review are five legal product files and two focused test files. + +WHY IT IS ENOUGH +The focused suites exercise every new adversarial guard and the real createLocalGateway Stop path. The full suite rebuilds PilotDeck and covers all discovered repository tests. The path inventory proves the fixes remain inside the legal Domain Plugin and its tests. + +WHAT WAS OMITTED +Raw TAP output, environment data, credentials, legal source material, model responses, private endpoint details, and generated opinion content were omitted. + +CLEANUP +No tracked file was changed by the commands beyond the intentional seven-file fix set. Normal ignored build output remains. diff --git a/.omo/evidence/20260723-legal-coverage-v1/recovery-v12-summary.txt b/.omo/evidence/20260723-legal-coverage-v1/recovery-v12-summary.txt new file mode 100644 index 00000000..2b9dbda1 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/recovery-v12-summary.txt @@ -0,0 +1,37 @@ +WHAT WAS TESTED +Surface: two fresh real PilotDeck Agent recovery sessions over the existing difficult-case workspace with the installed legal-coverage v1.2.0 plugin and dynamic milestone injection. + +MIGRATION RUN +Purpose: migrate the old canonical state to source-bound and semantically linked v1.2.0 contracts without weakening the validator or rewriting the opinion from scratch. +Duration: 341962 ms. +Model stream requests: 49. +Requests with model patch: 49/49. +Stop-driven continuations: 48. +Tool calls: 59; three tool failures across both runs were surfaced and recovered. +Initial validator errors: 101. +Final validator errors/warnings: 0/0. +Legal state hash: 35983599b74372c31c3b7bc2079029d281b1246de3b4210aab5601fb05a169b7. +The legacy verifier then reported only a stale final-integrity hash because the opinion had changed. + +INTEGRITY RUN +Purpose: refresh the established legacy final-integrity manifest for the current opinion and run the independent verifier without changing canonical legal coverage state. +Duration: 66146 ms. +Model stream requests: 15. +Requests with model patch: 15/15. +Stop-driven continuations: 14. +Tool calls: 16. +Finish reason: completed. +Independent hard verifier: passed=true. +Opinion bytes: unchanged during this run. + +WHY IT IS ENOUGH +The first run demonstrates that state-driven dynamic prompts can guide a real Agent through a large contract migration in bounded error classes until the legal Stop gate permits completion. The second run independently restores legacy integrity and proves the resulting workspace passes both the shipped validator and the external hard verifier. + +PROVENANCE +The Agent produced the v1.2.0 canonical migration, opinion corrections, and refreshed final-integrity manifest. One remaining topic-evidence compatibility artifact and the reverse-coverage compatibility artifact were originally QA-derived from validated canonical ledgers and remain explicitly qualified. + +WHAT WAS OMITTED +Raw prompts, model output, tool payloads, legal content, source paths, credentials, private model configuration, and endpoint details were omitted. + +CLEANUP +Both Agent sessions exited. The temporary runner remains syntax-valid. No product or test source was changed by the runs. diff --git a/.omo/evidence/20260723-legal-coverage-v1/targeted-tests-worker.txt b/.omo/evidence/20260723-legal-coverage-v1/targeted-tests-worker.txt new file mode 100644 index 00000000..3e8c355f --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/targeted-tests-worker.txt @@ -0,0 +1,84 @@ +TARGETED LEGAL-COVERAGE QA WORKER REPORT +Date: 2026-07-23 +Worktree: /Users/da/Documents/PilotDeck-worktrees/20260723-feat-legal-coverage-v1 + +WHAT WAS TESTED + +1. Command: npx tsc -p tsconfig.json --noEmit + Surface: repository TypeScript project, no output emission. + Intended proof: the legal-coverage branch typechecks without generating build artifacts. + +2. Command: npm run build + Surface: repository production build, including the runtime check, configuration bootstrap, + edgeclaw-memory-core build, main TypeScript compilation, and built-in plugin asset copy. + Intended proof: the branch compiles into the runnable dist tree used by the targeted tests. + +3. Command: node --test --test-force-exit --test-timeout 60000 dist/tests/products/legal-coverage.spec.js dist/tests/agent/legal-coverage-plugin-runtime.spec.js + Surface: compiled legal-coverage validator and plugin-runtime suites. + Intended proof: validator semantics and the real createLocalGateway hook, milestone, and + bounded artifact-correction wiring behave as specified. + +WHAT WAS OBSERVED + +1. TypeScript no-emit gate + Exit code: 0 + Command duration: 2.927 seconds + Tests/pass/fail: not applicable (compiler gate) + Output: no diagnostics. + +2. Production build + Exit code: 0 + Command duration: 4.522 seconds + Tests/pass/fail: not applicable (build gate) + Output: runtime check, configuration bootstrap, dependency build, main compilation, and + plugin asset copy all completed successfully. + +3. Targeted compiled tests + Exit code: 0 + Command duration: 1.164 seconds (test runner reported 1223.222 ms internally) + Tests: 12 + Pass: 12 + Fail: 0 + Cancelled: 0 + Skipped: 0 + Todo: 0 + Suites: 0 (Node TAP summary; tests were discovered and executed directly) + Observed coverage included proof lifecycle and invalidation, conflict and disclosure + rejection, source inventory and timeline collision checks, quote and fact support checks, + authority-link requirements, optional threshold handling, binary deliverable locators, + empty-fact and matrix completeness rejection, legal-only hook activation, bounded grouping + of repeated validator errors, plugin loading constraints, and a real gateway run through + bounded artifact correction. + +Repository cleanliness after all commands: + git status --short matched the baseline untracked set: + ?? .omo/evidence/20260723-legal-coverage-v1/ + ?? .omo/ulw-loop/ + ?? products/legal/ + ?? tests/agent/legal-coverage-plugin-runtime.spec.ts + ?? tests/products/ + git diff --name-only returned no paths. + git diff --cached --name-only returned no paths. + Therefore no tracked source or test files changed during this QA run. + +WHY IT IS ENOUGH + +The no-emit compiler gate checks the full TypeScript graph, and the production build proves the +same branch can produce the compiled runtime and test artifacts. The focused test invocation then +executes the validator semantics and the actual local-gateway integration path requested for this +change, including hook activation, observable milestones, and bounded artifact correction. All +three gates exited successfully, all 12 targeted tests passed, and tracked-file cleanliness was +verified after execution. + +WHAT WAS OMITTED + +No real legal case workspace or legal source content was opened or included. No environment dump, +credentials, tokens, auth headers, private paths beyond the named worktree, raw generated legal +artifacts, or secret-bearing logs were captured. The routine Node experimental SQLite warning was +not reproduced because it is unrelated to the asserted behavior. + +CLEANUP + +No temporary QA workspace was created and no product or test source was edited. Build outputs were +left in the repository's normal ignored/generated locations. The only file created by this worker +is this sanitized evidence report. diff --git a/.omo/evidence/20260723-legal-coverage-v1/targeted-tests.txt b/.omo/evidence/20260723-legal-coverage-v1/targeted-tests.txt new file mode 100644 index 00000000..72a78db0 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/targeted-tests.txt @@ -0,0 +1,27 @@ +WHAT WAS TESTED +1. npx tsc -p tsconfig.json --noEmit +2. npm run build +3. node --test --test-force-exit --test-timeout 60000 dist/tests/products/legal-coverage.spec.js dist/tests/agent/legal-coverage-plugin-runtime.spec.js +Surface: the full TypeScript graph, production build, legal validator semantics, and real createLocalGateway plugin wiring. + +WHAT WAS OBSERVED +TypeScript: exit 0, 2.886 seconds, no diagnostics. +Build: exit 0, 4.098 seconds. +Targeted tests: exit 0, 1.144 seconds wall time. +Tests: 12 +Pass: 12 +Fail: 0 +Cancelled: 0 +Skipped: 0 +Todo: 0 + +The independent QA worker also ran the same gates successfully: TypeScript 2.927 seconds, build 4.522 seconds, targeted tests 12 of 12 passed. Its full sanitized report is targeted-tests-worker.txt. + +WHY IT IS ENOUGH +The tests cover proof creation and invalidation, source inventory, conflicts, timelines, materiality, threshold assessment, quote reuse and semantic support, critical authority links, binary deliverables, zero-fact shortcuts, matrix orphans, activation, grouped milestones, benchmark exclusion, and a real local-gateway bounded artifact-correction flow. + +WHAT WAS OMITTED +No real legal workspace, source content, opinion content, private configuration, tokens, or credentials were accessed by these automated gates. + +CLEANUP +No tracked source or test file changed during either QA run. Normal ignored dist output remains for subsequent full-suite execution. From d2328dca99687a42753bd20552914f052c772464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Sat, 25 Jul 2026 00:42:01 +0800 Subject: [PATCH 08/14] feat(legal): inject bounded adaptive work slices --- .../legal/plugins/legal-coverage/hook.mjs | 35 ++- .../plugins/legal-coverage/hooks/hooks.json | 10 + .../legal-coverage/scripts/legal-coverage.mjs | 16 +- .../scripts/lib/legal-coverage.mjs | 285 ++++++++++++++++++ .../legal-coverage-plugin-runtime.spec.ts | 5 +- tests/products/legal-coverage.spec.ts | 149 ++++++++- 6 files changed, 488 insertions(+), 12 deletions(-) diff --git a/products/legal/plugins/legal-coverage/hook.mjs b/products/legal/plugins/legal-coverage/hook.mjs index e672e6f2..c5e7f627 100644 --- a/products/legal/plugins/legal-coverage/hook.mjs +++ b/products/legal/plugins/legal-coverage/hook.mjs @@ -6,7 +6,10 @@ import { STATE_DIRECTORY, activationMatches, ensureWorkspace, + milestoneDigest, + milestoneEnvelopeFor, milestoneFor, + nextCoverageBatch, resolveSafeWorkspacePath, validateWorkspace, } from "./scripts/lib/legal-coverage.mjs"; @@ -51,12 +54,18 @@ try { } } - const active = await readSessionState(input.cwd, sessionPath) + const sessionState = await readSessionState(input.cwd, sessionPath); + const active = sessionState?.active === true || await pathExists(input.cwd, `${STATE_DIRECTORY}/config.json`) || await pathExists(input.cwd, PROOF_PATH); if (active && input.hookEventName === "PreModelRequest") { const result = await validateWorkspace({ workspaceRoot: input.cwd, writeProof: true }); - output.hookSpecificOutput.additionalContext = milestoneFor(result, cliPath); + const workItems = await dynamicWorkItems(input.cwd, result); + const digest = milestoneDigest(result, workItems); + if (sessionState?.lastMilestoneDigest !== digest) { + output.hookSpecificOutput.additionalContext = milestoneEnvelopeFor(result, cliPath, workItems); + await writeSessionState(input.cwd, sessionPath, { active: true, lastMilestoneDigest: digest }); + } output.hookSpecificOutput.modelRequestPatch = { metadata: { legalCoverageActive: true, @@ -65,6 +74,19 @@ try { }; } + if (active && input.hookEventName === "PostCompact") { + const result = await validateWorkspace({ workspaceRoot: input.cwd, writeProof: true }); + const workItems = await dynamicWorkItems(input.cwd, result); + const digest = milestoneDigest(result, workItems); + output.hookSpecificOutput.dynamicContext = [{ + id: `legal-coverage-post-compact-${digest.slice(0, 12)}`, + priority: "critical", + ttlMs: 60 * 60 * 1000, + content: milestoneEnvelopeFor(result, cliPath, workItems), + }]; + await writeSessionState(input.cwd, sessionPath, { active: true, lastMilestoneDigest: digest }); + } + if (active && input.hookEventName === "Stop") { const result = await validateWorkspace({ workspaceRoot: input.cwd, writeProof: true }); if (!result.passed) { @@ -112,6 +134,11 @@ async function hasConfiguredWorkspace(workspaceRoot) { } } +async function dynamicWorkItems(workspaceRoot, result) { + if (result.errors[0]?.phase !== "coverage") return undefined; + return nextCoverageBatch(workspaceRoot, { limit: 4, maxSerializedBytes: 2048 }); +} + async function writeSessionState(workspaceRoot, candidate, value) { const path = await resolveSafeWorkspacePath(workspaceRoot, candidate, { allowMissing: true }); await mkdir(dirname(path), { recursive: true }); @@ -123,9 +150,9 @@ async function readSessionState(workspaceRoot, candidate) { try { const path = await resolveSafeWorkspacePath(workspaceRoot, candidate, { allowMissing: true }); const value = JSON.parse(await readFile(path, "utf8")); - return value?.active === true; + return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; } catch (error) { - if (isMissing(error)) return false; + if (isMissing(error)) return undefined; throw error; } } diff --git a/products/legal/plugins/legal-coverage/hooks/hooks.json b/products/legal/plugins/legal-coverage/hooks/hooks.json index 8c04669c..a63fcf2e 100644 --- a/products/legal/plugins/legal-coverage/hooks/hooks.json +++ b/products/legal/plugins/legal-coverage/hooks/hooks.json @@ -19,6 +19,16 @@ ] } ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "node hook.mjs" + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs b/products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs index c43edd90..3b0250bd 100644 --- a/products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs +++ b/products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs @@ -3,6 +3,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { ensureWorkspace, + nextCoverageBatch, validateWorkspace, } from "./lib/legal-coverage.mjs"; @@ -31,13 +32,26 @@ if (command === "init") { await writeFile(initialized.paths.config, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); console.log(JSON.stringify({ initialized: true, workspaceRoot, stateDirectory: ".pilotdeck/work/legal-coverage" }, null, 2)); process.exitCode = 0; +} else if (command === "next-batch") { + if (readOption(args, "--phase") !== "coverage") { + console.error("next-batch currently requires --phase coverage"); + process.exitCode = 1; + } else { + await ensureWorkspace(workspaceRoot); + const result = await nextCoverageBatch(workspaceRoot, { + limit: readOption(args, "--limit"), + maxSerializedBytes: readOption(args, "--max-bytes"), + }); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = 0; + } } else if (command === "validate" || command === "status") { await ensureWorkspace(workspaceRoot); const result = await validateWorkspace({ workspaceRoot, writeProof: args.includes("--write-proof") }); console.log(JSON.stringify(result, null, 2)); process.exitCode = result.passed || command === "status" ? 0 : 2; } else { - console.error("Usage: legal-coverage.mjs [--workspace PATH] [--input PATH] [--deliverable ID=PATH] [--jurisdiction NAME] [--basis-date DATE] [--allow-no-material-facts] [--write-proof]"); + console.error("Usage: legal-coverage.mjs [--workspace PATH] [--phase coverage] [--limit 1..12] [--max-bytes 1024..24576] [--input PATH] [--deliverable ID=PATH] [--jurisdiction NAME] [--basis-date DATE] [--allow-no-material-facts] [--write-proof]"); process.exitCode = 1; } 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 f1cec2c4..ba07569c 100644 --- a/products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs +++ b/products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs @@ -119,6 +119,116 @@ export async function readWorkspaceState(workspaceRoot) { return { workspace, paths, state, readErrors }; } +export async function nextCoverageBatch(workspaceRoot, options = {}) { + const loaded = await readWorkspaceState(workspaceRoot); + const limit = boundedInteger(options.limit, 1, 12, 12); + const maxSerializedBytes = boundedInteger(options.maxSerializedBytes, 1024, 24576, 24576); + const coverage = isRecord(loaded.state.coverage) ? loaded.state.coverage : {}; + const groups = []; + + const deliverableRows = Array.isArray(coverage.deliverables) ? coverage.deliverables : []; + const deliverables = []; + for (const deliverable of Array.isArray(loaded.state.config?.deliverables) ? loaded.state.config.deliverables : []) { + if (!isRecord(deliverable) || deliverable.required === false || !nonEmpty(deliverable.path)) continue; + let actualSha256; + try { + const path = await resolveSafeWorkspacePath(loaded.workspace, deliverable.path); + actualSha256 = sha256(await readFile(path)); + } catch {} + const existingCoverage = deliverableRows.find((row) => row?.path === deliverable.path); + if (!actualSha256 || existingCoverage?.sha256 !== actualSha256) { + deliverables.push({ + id: deliverable.id, + path: deliverable.path, + actualSha256: actualSha256 ?? null, + existingCoverage: isRecord(existingCoverage) ? existingCoverage : null, + }); + } + } + groups.push({ group: "deliverables", items: deliverables }); + + const sourceRows = Array.isArray(coverage.sources) ? coverage.sources : []; + groups.push({ + group: "sources", + items: (Array.isArray(loaded.state.sources?.sources) ? loaded.state.sources.sources : []) + .filter((source) => isRecord(source) && nonEmpty(source.id) + && (source.status === "unreadable" || stringArray(source.unresolvedItems).length > 0)) + .map((source) => ({ + sourceId: source.id, + path: source.path, + status: source.status, + unresolvedItems: stringArray(source.unresolvedItems), + requiredStatus: "unresolved", + existingCoverage: coverageRowFor(sourceRows, "sourceId", source.id), + })) + .filter((item) => coverageRowNeedsRepair(item.existingCoverage, true)), + }); + + const factRows = Array.isArray(coverage.facts) ? coverage.facts : []; + groups.push({ + group: "facts", + items: (Array.isArray(loaded.state.facts?.facts) ? loaded.state.facts.facts : []) + .filter((fact) => isRecord(fact) && nonEmpty(fact.id) && (fact.material === true || fact.critical === true)) + .map((fact) => { + const unresolved = fact.conflictStatus === "unresolved" || fact.verificationStatus !== "verified"; + return { + factId: fact.id, + subject: fact.subject, + predicate: fact.predicate, + value: fact.value, + unit: fact.unit, + dateOrPeriod: fact.dateOrPeriod, + sourceRefs: fact.sourceRefs, + requiredStatus: unresolved ? "unresolved" : "covered", + existingCoverage: coverageRowFor(factRows, "factId", fact.id), + }; + }) + .filter((item) => coverageRowNeedsRepair(item.existingCoverage, item.requiredStatus === "unresolved")), + }); + + const issueRows = Array.isArray(coverage.issues) ? coverage.issues : []; + groups.push({ + group: "issues", + items: (Array.isArray(loaded.state.issues?.issues) ? loaded.state.issues.issues : []) + .filter((legalIssue) => isRecord(legalIssue) && nonEmpty(legalIssue.id)) + .map((legalIssue) => ({ + issueId: legalIssue.id, + status: legalIssue.status, + severity: legalIssue.severity, + analysis: legalIssue.analysis, + conclusion: legalIssue.conclusion, + recommendations: legalIssue.recommendations, + requiredStatus: legalIssue.status === "unresolved" ? "unresolved" : "covered", + existingCoverage: coverageRowFor(issueRows, "issueId", legalIssue.id), + })) + .filter((item) => coverageRowNeedsRepair(item.existingCoverage, item.requiredStatus === "unresolved")), + }); + + const authorityRows = Array.isArray(coverage.authorities) ? coverage.authorities : []; + groups.push({ + group: "authorities", + items: (Array.isArray(loaded.state.authorities?.authorities) ? loaded.state.authorities.authorities : []) + .filter((authority) => isRecord(authority) && nonEmpty(authority.id) && authority.verificationStatus !== "not-applicable") + .map((authority) => ({ + authorityId: authority.id, + name: authority.name, + article: authority.article, + verificationStatus: authority.verificationStatus, + supportedConclusion: authority.supportedConclusion, + requiredStatus: authority.verificationStatus === "pending-verification" ? "unresolved" : "covered", + existingCoverage: coverageRowFor(authorityRows, "authorityId", authority.id), + })) + .filter((item) => coverageRowNeedsRepair(item.existingCoverage, item.requiredStatus === "unresolved")), + }); + + const selected = groups.find((group) => group.items.length > 0); + if (selected) return packCoverageBatch(selected.group, selected.items, limit, maxSerializedBytes); + + const validation = await validateWorkspace({ workspaceRoot: loaded.workspace, writeProof: false }); + const errors = validation.errors.filter((error) => error.phase === "coverage"); + return packCoverageBatch("validation-errors", errors, limit, maxSerializedBytes); +} + export async function validateWorkspace(options) { const workspace = resolve(options.workspaceRoot); const loaded = await readWorkspaceState(workspace); @@ -220,6 +330,181 @@ export function milestoneFor(result, cliPath) { ].filter(Boolean).join("\n"); } +export function milestoneDigest(result, workItems) { + const first = result.errors[0]; + const repeatedErrorCount = result.errors.filter((error) => error.code === first?.code).length; + return sha256(JSON.stringify({ + milestone: milestoneName(result), + passed: result.passed, + phase: first?.phase ?? null, + code: first?.code ?? null, + repeatedErrorCount, + errorCount: result.errors.length, + counts: result.counts, + workItemsDigest: workItems ? sha256(JSON.stringify(workItems)) : null, + validatedStateHash: result.passed ? result.stateHash : null, + })); +} + +export function milestoneEnvelopeFor(result, cliPath, workItems) { + const first = result.errors[0]; + const sameCode = result.errors.filter((error) => error.code === first?.code); + const command = `node ${JSON.stringify(cliPath)} validate --workspace \"$PWD\" --write-proof`; + const initialize = `node ${JSON.stringify(cliPath)} init --workspace \"$PWD\" --input --deliverable = --jurisdiction --basis-date `; + const milestone = milestoneName(result); + const representativePaths = sameCode + .slice(0, 4) + .map((error) => error.path) + .filter((path) => typeof path === "string" && path.length > 0); + const envelope = { + milestone, + objective: result.passed + ? "Preserve the validated legal deliverable state while completing remaining task-specific QA." + : objectiveForPhase(first?.phase), + invariants: [ + "Do not invent missing legal facts or silently resolve disputed evidence.", + "Preserve source-to-fact-to-issue-to-deliverable links.", + "Do not create or edit completion-proof.json manually.", + ], + artifactPointers: result.passed + ? ["state://legal-coverage", `workspace://${PROOF_PATH}`] + : ["state://legal-coverage", `workspace://${STATE_DIRECTORY}/${stateFileForPhase(first?.phase)}`], + knownGaps: result.passed ? [] : [{ + phase: first?.phase ?? "configuration", + code: first?.code ?? "state_file_invalid", + occurrences: sameCode.length, + representativePaths, + }], + progress: result.counts, + ...(result.passed ? {} : { workBatch: workBatchFor(first?.phase) }), + ...(workItems ? { workItems } : {}), + nextAction: nextActionFor(result, sameCode.length, command, initialize), + completionSignal: result.passed ? "legal-coverage-validated" : "legal-coverage-blocked", + }; + return `\n${JSON.stringify(envelope, null, 2)}\n`; +} + +function milestoneName(result) { + if (result.passed) return "COMPLETE"; + switch (result.errors[0]?.phase) { + case "configuration": return "INIT"; + case "sources": return "SOURCE_REVIEW"; + case "facts": return "SOURCES_READY"; + case "matrices": + case "issues": + case "authorities": return "EVIDENCE_READY"; + case "coverage": return "VALIDATING"; + default: return "INIT"; + } +} + +function workBatchFor(phase) { + switch (phase) { + case "sources": return { scope: "one source batch", maxRecords: 12, maxSerializedBytes: 24576, validateAfterWrite: true }; + case "facts": return { scope: "one evidence fragment", maxRecords: 12, maxSerializedBytes: 24576, validateAfterWrite: true }; + case "matrices": return { scope: "one matrix", maxRecords: 12, maxSerializedBytes: 24576, validateAfterWrite: true }; + case "issues": return { scope: "one issue group", maxRecords: 8, maxSerializedBytes: 24576, validateAfterWrite: true }; + case "authorities": return { scope: "one authority group", maxRecords: 8, maxSerializedBytes: 24576, validateAfterWrite: true }; + case "coverage": return { scope: "one coverage group", maxRecords: 12, maxSerializedBytes: 24576, validateAfterWrite: true }; + default: return { scope: "one configuration repair", maxRecords: 1, maxSerializedBytes: 24576, validateAfterWrite: true }; + } +} + +function nextActionFor(result, occurrenceCount, command, initialize) { + if (result.passed) { + return "Run any remaining task-specific deliverable QA; rerun legal coverage validation after any bound artifact changes."; + } + const first = result.errors[0]; + if (first?.phase === "configuration") { + return `Initialize or repair configuration, then validate. Initializer: ${initialize}`; + } + const batch = workBatchFor(first?.phase); + if (first?.phase === "coverage") { + const inspect = command.replace(" validate ", " next-batch --phase coverage --limit 12 --max-bytes 24576 ") + .replace(" --write-proof", ""); + return `Use the injected workItems as the next deterministic coverage slice. If it contains only a pointer, inspect it with: ${inspect}. ` + + `Repair at most ${batch.maxRecords} records and ${batch.maxSerializedBytes} serialized bytes, then run: ${command}. ` + + "Repeat only after validation reports progress."; + } + return `Repair the next ${batch.scope} for ${first?.code ?? "state_file_invalid"} ` + + `(up to ${batch.maxRecords} records and ${batch.maxSerializedBytes} serialized bytes; ` + + `${occurrenceCount} occurrence(s) currently visible), then run: ${command}. ` + + "Repeat with the next bounded batch only after validation reports progress."; +} + +function objectiveForPhase(phase) { + switch (phase) { + case "sources": return "Complete source inventory and evidence review."; + case "facts": return "Complete sourced legal fact records without unsupported conclusions."; + case "matrices": return "Complete only the due-diligence projections required by this workflow."; + case "issues": return "Resolve or disclose material legal issues and contradictions."; + case "authorities": return "Verify and link controlling legal authorities."; + case "coverage": return "Reconcile reviewed records with the requested deliverables."; + default: return "Configure the legal coverage workspace for this task."; + } +} + +function stateFileForPhase(phase) { + switch (phase) { + case "sources": return "sources.json"; + case "facts": return "facts.json"; + case "matrices": return "matrices.json"; + case "issues": return "issues.json"; + case "authorities": return "authorities.json"; + case "coverage": return "coverage.json"; + default: return "config.json"; + } +} + +function coverageRowFor(rows, idField, id) { + const row = rows.find((candidate) => candidate?.[idField] === id); + return isRecord(row) ? row : null; +} + +function coverageRowNeedsRepair(row, requireUnresolved) { + if (!isRecord(row)) return true; + if (!COVERAGE_STATUSES.has(row.status) || (requireUnresolved && row.status !== "unresolved")) return true; + return !nonEmpty(row.deliverablePath) || !nonEmpty(row.section) || !nonEmpty(row.claim) || !nonEmpty(row.locator); +} + +function packCoverageBatch(group, candidates, limit, maxSerializedBytes) { + const items = []; + let serializedBytes = 0; + for (const candidate of candidates.slice(0, limit)) { + const candidateBytes = Buffer.byteLength(JSON.stringify(candidate)); + if (candidateBytes > maxSerializedBytes) { + if (items.length === 0) { + items.push({ + id: candidate.factId ?? candidate.issueId ?? candidate.authorityId ?? candidate.sourceId ?? candidate.id ?? null, + oversizedRecord: true, + serializedBytes: candidateBytes, + recordPointer: `state://legal-coverage/${group}`, + }); + } + break; + } + if (serializedBytes + candidateBytes > maxSerializedBytes) break; + items.push(candidate); + serializedBytes += candidateBytes; + } + return { + phase: "coverage", + group, + remaining: candidates.length, + returned: items.length, + hasMore: candidates.length > items.length, + limits: { maxRecords: limit, maxSerializedBytes }, + serializedBytes, + items, + }; +} + +function boundedInteger(value, minimum, maximum, fallback) { + const number = Number(value); + if (!Number.isInteger(number)) return fallback; + return Math.min(maximum, Math.max(minimum, number)); +} + export function activationMatches(prompt) { return /(?:法律.{0,8}(?:尽调|尽职调查|意见书|风险审查)|尽职调查.{0,8}(?:法律|意见)|legal\s+(?:due\s+diligence|opinion|risk\s+review)|transaction\s+legal\s+review)/iu.test(prompt); } diff --git a/tests/agent/legal-coverage-plugin-runtime.spec.ts b/tests/agent/legal-coverage-plugin-runtime.spec.ts index 7cc5f6a8..f1c20828 100644 --- a/tests/agent/legal-coverage-plugin-runtime.spec.ts +++ b/tests/agent/legal-coverage-plugin-runtime.spec.ts @@ -46,9 +46,10 @@ test("real gateway drives legal plugin milestones through bounded artifact corre assert.equal(agentRequests.length, 2, JSON.stringify(events)); assert.match(messageText(agentRequests[0]?.messages ?? []), /Legal coverage controls are active/u); assert.match(messageText(agentRequests[0]?.messages ?? []), /completion-proof\.json/u); - assert.match(messageText(agentRequests[0]?.messages ?? []), /Legal coverage milestone/u); + assert.match(messageText(agentRequests[0]?.messages ?? []), //u); + assert.match(messageText(agentRequests[0]?.messages ?? []), /"milestone": "INIT"/u); assert.match(messageText(agentRequests[1]?.messages ?? []), /Artifact validation failed/u); - assert.match(messageText(agentRequests[1]?.messages ?? []), /Legal coverage validation is complete/u); + assert.match(messageText(agentRequests[1]?.messages ?? []), /"milestone": "COMPLETE"/u); assert.equal(agentRequests[1]?.metadata?.legalCoverageState, "validated"); assert.equal(events.some((event) => event.type === "turn_completed" && event.finishReason === "completed"), true); diff --git a/tests/products/legal-coverage.spec.ts b/tests/products/legal-coverage.spec.ts index 0be2e3cb..86bec419 100644 --- a/tests/products/legal-coverage.spec.ts +++ b/tests/products/legal-coverage.spec.ts @@ -51,6 +51,67 @@ test("legal coverage validator creates a current proof and removes it when the d } }); +test("legal coverage next-batch exposes one bounded deterministic repair slice", async () => { + const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-batch-")); + try { + await writeCompleteFixture(workspace); + const coveragePath = join(workspace, STATE_ROOT, "coverage.json"); + const completeCoverage = JSON.parse(await readFile(coveragePath, "utf8")) as Record; + await writeJson(coveragePath, { + schemaVersion: 1, + deliverables: [], + sources: [], + facts: [], + issues: [], + authorities: [], + }); + + const deliverableBatch = await runCli(workspace, "next-batch", "--phase", "coverage", "--limit", "1"); + assert.equal(deliverableBatch.exitCode, 0, deliverableBatch.stderr); + const deliverable = JSON.parse(deliverableBatch.stdout) as { + group: string; + returned: number; + limits: { maxRecords: number; maxSerializedBytes: number }; + items: Array<{ path?: string; actualSha256?: string }>; + }; + assert.equal(deliverable.group, "deliverables"); + assert.equal(deliverable.returned, 1); + assert.equal(deliverable.limits.maxRecords, 1); + assert.equal(deliverable.limits.maxSerializedBytes, 24576); + assert.equal(deliverable.items[0]?.path, "deliverables/opinion.md"); + assert.match(deliverable.items[0]?.actualSha256 ?? "", /^[a-f0-9]{64}$/u); + const coverageMilestone = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "coverage-batch-session", + transcriptPath: "", + cwd: workspace, + }); + assert.match(coverageMilestone.hookSpecificOutput.additionalContext ?? "", /next-batch --phase coverage/u); + assert.match(coverageMilestone.hookSpecificOutput.additionalContext ?? "", /"group": "deliverables"/u); + assert.match(coverageMilestone.hookSpecificOutput.additionalContext ?? "", /"maxSerializedBytes": 2048/u); + assert.equal((coverageMilestone.hookSpecificOutput.additionalContext ?? "").length < 4096, true); + + const emptyCoverage = JSON.parse(await readFile(coveragePath, "utf8")) as Record; + emptyCoverage.deliverables = completeCoverage.deliverables; + await writeJson(coveragePath, emptyCoverage); + const factBatch = await runCli(workspace, "next-batch", "--phase", "coverage", "--limit", "12", "--max-bytes", "24576"); + assert.equal(factBatch.exitCode, 0, factBatch.stderr); + const facts = JSON.parse(factBatch.stdout) as { + group: string; + returned: number; + serializedBytes: number; + items: Array<{ factId?: string; requiredStatus?: string }>; + }; + assert.equal(facts.group, "facts"); + assert.equal(facts.returned, 1); + assert.equal(facts.items[0]?.factId, "F-001"); + assert.equal(facts.items[0]?.requiredStatus, "covered"); + assert.equal(facts.serializedBytes <= 24576, true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + test("legal coverage validator rejects orphaned conflicts and incomplete final disclosure", async () => { const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-conflict-")); try { @@ -533,10 +594,63 @@ test("legal coverage hook activates only legal work and injects one observable m transcriptPath: "", cwd: workspace, }); - assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /milestone \(configuration\)/u); - assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /fix validator code jurisdiction_missing now/u); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", //u); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /"milestone": "INIT"/u); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /"code": "jurisdiction_missing"/u); assert.equal(preModel.hookSpecificOutput.modelRequestPatch?.metadata?.legalCoverageActive, true); + const unchangedPreModel = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "legal-session", + transcriptPath: "", + cwd: workspace, + }); + assert.equal(unchangedPreModel.hookSpecificOutput.additionalContext, undefined); + assert.equal(unchangedPreModel.hookSpecificOutput.modelRequestPatch?.metadata?.legalCoverageActive, true); + + const configPath = join(workspace, STATE_ROOT, "config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")) as Record; + config.jurisdiction = "Synthetic jurisdiction"; + await writeJson(configPath, config); + const changedPreModel = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "legal-session", + transcriptPath: "", + cwd: workspace, + }); + assert.match(changedPreModel.hookSpecificOutput.additionalContext ?? "", /"code": "basis_date_missing"/u); + + const changedConfig = JSON.parse(await readFile(configPath, "utf8")) as Record; + changedConfig.basisDate = "Synthetic review date"; + changedConfig.inputRoots = ["source-room"]; + changedConfig.deliverables = [{ id: "opinion", path: "deliverables/opinion.md", required: true }]; + await mkdir(join(workspace, "source-room"), { recursive: true }); + await mkdir(join(workspace, "deliverables"), { recursive: true }); + await writeFile(join(workspace, "deliverables", "opinion.md"), "# Draft\n"); + await writeJson(configPath, changedConfig); + const sourceReview = await runHook({ + hookEventName: "PreModelRequest", + sessionId: "legal-session", + transcriptPath: "", + cwd: workspace, + }); + assert.match(sourceReview.hookSpecificOutput.additionalContext ?? "", /"milestone": "SOURCES_READY"/u); + assert.match(sourceReview.hookSpecificOutput.additionalContext ?? "", /"maxRecords": 12/u); + assert.match(sourceReview.hookSpecificOutput.additionalContext ?? "", /"maxSerializedBytes": 24576/u); + + const postCompact = await runHook({ + hookEventName: "PostCompact", + sessionId: "legal-session", + transcriptPath: "", + cwd: workspace, + }); + assert.equal(postCompact.hookSpecificOutput.dynamicContext?.length, 1); + const recoveredContext = (postCompact.hookSpecificOutput.dynamicContext?.[0] as { content?: string } | undefined)?.content ?? ""; + assert.match(recoveredContext, //u); + for (const forbidden of ["rubric", "judge-response", "checkpoint_id", "ground truth"]) { + assert.doesNotMatch(recoveredContext, new RegExp(forbidden, "iu")); + } + await rm(join(workspace, STATE_ROOT, "sessions"), { recursive: true, force: true }); await writeFile(join(workspace, STATE_ROOT, "completion-proof.json"), "{\"forged\":true}\n"); const blockedStop = await runHook({ @@ -580,14 +694,36 @@ test("legal coverage hook groups repeated validator errors into one bounded mile transcriptPath: "", cwd: workspace, }); - assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /fix validator code matrix_pending now/u); - assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /occurs 2 times/u); - assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /Fix all occurrences in one bounded edit/u); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /"code": "matrix_pending"/u); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /"occurrences": 2/u); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /"milestone": "EVIDENCE_READY"/u); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /one matrix/u); + assert.match(preModel.hookSpecificOutput.additionalContext ?? "", /up to 12 records/u); } finally { await rm(workspace, { recursive: true, force: true }); } }); +test("legal coverage milestone digest ignores opaque incomplete-state hash churn", async () => { + const { milestoneDigest } = await import(pathToFileURL(VALIDATOR_LIB).href) as { + milestoneDigest: (result: Record) => string; + }; + const incomplete = { + passed: false, + stateHash: "a".repeat(64), + errors: [{ phase: "facts", code: "material_facts_missing", path: "facts.json" }], + counts: { sources: 64, facts: 0, issues: 0, authorities: 0, deliverables: 1 }, + }; + assert.equal(milestoneDigest(incomplete), milestoneDigest({ + ...incomplete, + stateHash: "b".repeat(64), + })); + assert.notEqual(milestoneDigest(incomplete), milestoneDigest({ + ...incomplete, + counts: { ...incomplete.counts, facts: 12 }, + })); +}); + test("legal coverage hook activates a malformed configured workspace so the agent can repair it", async () => { const workspace = await mkdtemp(join(tmpdir(), "pilotdeck-legal-coverage-malformed-config-")); try { @@ -650,9 +786,12 @@ test("legal product plugin loads one skill and contains no benchmark-specific co assert.equal(plugin.skills?.length, 1); assert.equal(plugin.skills?.[0]?.name, "legal-coverage:conduct-legal-due-diligence"); assert.equal(plugin.hooksConfig?.PreModelRequest?.length, 1); + assert.equal(plugin.hooksConfig?.PostCompact?.length, 1); const files = await collectFiles(PLUGIN_ROOT); const productionText = (await Promise.all(files.map((path) => readFile(path, "utf8")))).join("\n"); + assert.match(productionText, /Do not install system packages, language packages, plugins, or binaries/u); + assert.match(productionText, /Treat every configured input root as read-only/u); for (const forbidden of ["legalBenchmarkCase", "case-input", "qingci", "rubric", "judge-response", "checkpoint_id"]) { assert.doesNotMatch(productionText, new RegExp(forbidden, "iu")); } From adfd557d9f8f0b612c820cc4cbbbd7cc45428539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Sat, 25 Jul 2026 00:42:11 +0800 Subject: [PATCH 09/14] docs(legal): define E2-Elite adaptive projection --- products/legal/README.md | 16 + .../e2-elite-adaptive-legal-projection.md | 483 ++++++++++++++++++ .../conduct-legal-due-diligence/SKILL.md | 19 +- 3 files changed, 511 insertions(+), 7 deletions(-) create mode 100644 products/legal/docs/e2-elite-adaptive-legal-projection.md diff --git a/products/legal/README.md b/products/legal/README.md index d32041fd..f6da5167 100644 --- a/products/legal/README.md +++ b/products/legal/README.md @@ -6,6 +6,11 @@ selection, deterministic validation, and completion proof. PilotDeck Core continues to own only generic hooks, context delivery, tools, and artifact correction. +The proposed delivery architecture is documented in +[E2-Elite: Adaptive Legal Projection](docs/e2-elite-adaptive-legal-projection.md). +It selects narrative, hybrid, schedule-heavy, or operational delivery before +deciding whether structured legal projections are useful. + ## Install Link the plugin into a project or PilotDeck home: @@ -41,6 +46,17 @@ node products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs validate \ --write-proof ``` +When validation reaches final coverage, inspect one deterministic repair slice +instead of loading every ledger into model context: + +```bash +node products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs next-batch \ + --workspace /path/to/project \ + --phase coverage \ + --limit 12 \ + --max-bytes 24576 +``` + Validation fails closed on incomplete source inventory, partial facts, orphaned risks, unverified citations that are not disclosed, stale deliverable hashes, or missing or generic final coverage. Canonical ledgers use a single main-agent diff --git a/products/legal/docs/e2-elite-adaptive-legal-projection.md b/products/legal/docs/e2-elite-adaptive-legal-projection.md new file mode 100644 index 00000000..1e4ad6c0 --- /dev/null +++ b/products/legal/docs/e2-elite-adaptive-legal-projection.md @@ -0,0 +1,483 @@ +# E2-Elite: Adaptive Legal Projection + +Status: accepted architecture decision with a bounded due-diligence vertical +slice, supported by a frozen-evidence experiment, a full-corpus applicability +review, and an isolated real-runtime pilot. + +## 1. Decision + +E2-Elite is the proposed legal delivery architecture for PilotDeck: + +> Selective structured projection + adaptive legal reasoning + bounded +> validation + observable mutation control. + +It is not a mandatory report template. It does not require every legal task to +produce tables or appendices. The legal plugin first classifies the task and +creates a small projection plan. Structured records and deterministic renderers +are used only where they preserve dense, repetitive, or exact facts better than +free-form prose. Narrative legal reasoning remains authored and reviewed as +narrative. + +The name distinguishes this design from the E2 experiment. E2 demonstrated the +semantic value of record-oriented projections, but its process was too costly +and did not enforce the requested write boundary. E2-Elite retains the useful +mechanism and replaces the experimental generation process. + +## 2. Evidence and problem statement + +The difficult legal-opinion experiment compared three artifacts over the same +321 checkpoints: + +| Variant | Overall | Critical | Interpretation | +|---|---:|---:|---| +| E0 frozen opinion | 185/321 | 100/141 | Narrative baseline | +| E1 raw ledger appendices | 174/321 | 94/141 | Atomic rows lost record context | +| E2 record-oriented appendices | 225/321 | 113/141 | Related facts were exposed together | + +Three paired E0/E2 rechecks covered every changed checkpoint. E2 accumulated +145 passes against E0's 16 across 180 judgments. It produced 43 stable E2-only +wins, including 15 critical wins, and no stable E0-only win. + +The result is strong evidence for record-oriented projection, but not for a +record-only workflow: + +- fact extraction improved from 155/267 to 198/267; +- legal reasoning fell from 18/33 to 15/33 in the single full run; +- legal citation remained 5/13; +- the Agent used 47 model requests and about 1.25 million tokens; +- it created an unauthorized scratch artifact; +- it incorrectly reported that only the target opinion changed. + +The production problem therefore has four parts: preserve record-level fact +coverage, preserve legal reasoning quality, bound the repair cost, and verify +actual mutations independently of the Agent's narrative. + +Sanitized experiment evidence is stored under +`.omo/evidence/20260723-legal-coverage-v1/`. Raw legal material, expected +answers, Judge prompts, and Judge responses remain outside Git. + +## 3. Design principles + +1. **Adapt before structuring.** Select a delivery mode from the task shape; + never assume that a table is useful. +2. **Project records, not cells.** Keep an entity, event, clause, calculation, + or issue together with its provenance, uncertainty, and legal significance. +3. **Reason in prose where prose carries the law.** A renderer may assemble a + chronology or schedule; it must not manufacture legal analysis. +4. **Expose only decision-useful structure.** Internal extraction state does not + automatically become a user-visible appendix. +5. **Inject state, not history.** Dynamic prompts carry current invariants, + compact pointers, gaps, and one next action instead of replaying raw evidence. +6. **Validate deterministically before asking a model.** Schema, provenance, + arithmetic, linkage, citation shape, hashes, and mutation boundaries are + machine checks. A model is reserved for bounded semantic review. +7. **Measure regressions, cost, and side effects.** A higher Judge score alone + is not a production pass. + +## 4. Ownership boundary + +| Layer | Owns | Must not own | +|---|---|---| +| PilotDeck Core | Generic lifecycle hooks, context envelopes, artifact contracts, write policy, before/after hashes, mutation manifests, retry budgets, completion enforcement | Legal concepts, legal importance rules, task-family prompts, benchmark IDs, case entities, expected answers, Judge endpoints | +| Legal plugin and Skills | Legal task profiling, claims, issues, authorities, provenance semantics, importance routing, projection plans, legal renderers, citation review, substantive reasoning review | Generic harness lifecycle or benchmark scoring policy | +| Benchmark harness | Corpus loading, rubric and Judge adapters, A/B comparisons, paired rechecks, cost and mutation measurements, release evidence | Production prompt injection or runtime completion decisions | + +The external Judge is evaluation-only. Production behavior must remain useful +and testable when no Judge service exists. + +Within the legal product bundle, shared legal infrastructure and task expertise +remain separate: + +- the legal plugin owns the claim envelope, projection-plan protocol, + provenance semantics, renderer registry, and legal validation interfaces; +- task-family Skills own contract-review, due-diligence, litigation, privacy, + calculation, or operational instructions and register only the claim kinds, + renderers, and hard gates they need; +- a Skill may select `narrative` and register no renderer at all; +- the current `legal-coverage` due-diligence workflow must not be widened into a + universal legal schema. Shared mechanics should be extracted only after two + task families prove the same abstraction. + +## 5. Delivery modes + +The legal plugin selects one mode before drafting. The mode is a planning hint, +not a permanent task taxonomy. + +| Mode | Default output | Typical tasks | Structured projection | +|---|---|---|---| +| `narrative` | Coherent prose or formal pleading | Pleadings, advocacy, short advice, legal content | Internal checklist or sparse support only | +| `hybrid` | Narrative analysis with selected schedules | Contract review, privacy assessment, investigation, opinion | Only dense sections with clear record semantics | +| `schedule-heavy` | Tables/calculations lead, prose interprets | Due diligence, disclosure lists, chronology, damages | Primary delivery mechanism | +| `operational` | State, manifest, action log, renamed/classified files | Matter intake, filing, document organization | Operational records, not legal appendices | + +Mode selection uses observable task properties: + +- the requested user-visible artifact; +- evidence count, formats, and repetition; +- whether exact quotation, location, amount, date, or status is required; +- whether relationships across records matter; +- whether legal persuasion or explanation dominates; +- whether the task primarily mutates files rather than drafts analysis. + +If confidence is low, choose the less structured mode and allow a later plan to +add one targeted projection. Do not default upward to `schedule-heavy`. + +## 6. Extensible legal representation + +The legal plugin owns an open claim envelope. `kind` and `fields` are extensible +because a closed hierarchy would force unrelated legal tasks into one schema. + +```ts +interface LegalClaim { + id: string + kind: string + subject?: EntityRef + fields: Record + sources: SourceSpan[] + confidence: "confirmed" | "supported" | "unclear" | "disputed" + materiality?: "critical" | "material" | "context" + legalSignificance?: string + unresolved?: string[] + relations?: ClaimRelation[] +} +``` + +Examples of `kind` include `contract_clause`, `corporate_record`, +`evidence_event`, `damages_item`, and `processing_activity`. These are plugin +vocabulary, not Core vocabulary. + +The envelope must preserve: + +- **record identity:** which real-world thing the fields describe; +- **co-location:** fields needed to interpret one another stay together; +- **provenance:** file and stable location or span; +- **epistemic status:** confirmed, inferred, disputed, missing, or not applicable; +- **relations:** contradictions, support, supersession, dependency, and grouping; +- **legal significance:** why the record matters to the requested decision. + +Fields that cannot be sourced stay explicitly unresolved. Renderers may never +turn absence into `not_present` unless the task's review protocol supports that +conclusion. + +## 7. Projection planning + +Before drafting, the legal plugin creates a compact `ProjectionPlan`: + +```ts +interface ProjectionPlan { + mode: "narrative" | "hybrid" | "schedule-heavy" | "operational" + rationale: string + projections: Array<{ + id: string + recordKinds: string[] + audience: "internal" | "user" + placement: "inline" | "appendix" | "artifact" | "state" + renderer?: string + purpose: string + omitWhenEmpty: boolean + }> + narrativeSections: string[] + hardGates: string[] +} +``` + +A projection is justified when at least one of these conditions holds: + +- records repeat across multiple entities, documents, dates, or issues; +- users need exact comparison, reconciliation, calculation, quotation, or status; +- several facts must be co-located to avoid a misleading conclusion; +- a deterministic completeness rule exists; +- the requested deliverable explicitly calls for a table, schedule, register, + chronology, or manifest. + +A projection is rejected or kept internal when it would fragment an argument, +duplicate a short narrative, reveal work product the user did not request, or +create false precision from incomplete evidence. + +## 8. Dynamic context injection + +Core provides a generic hook that delivers a bounded context envelope. The legal +plugin supplies domain content for that envelope at milestone transitions. + +```text +INIT + -> SOURCE_REVIEW + -> SOURCES_READY + -> EVIDENCE_READY + -> PROJECTION_PLANNED + -> DRAFT_READY + -> VALIDATING + -> COMPLETE +``` + +An injected envelope contains only: + +```json +{ + "milestone": "PROJECTION_PLANNED", + "objective": "Draft the selected deliverable", + "invariants": ["Do not invent missing fields", "Preserve source links"], + "artifactPointers": ["state://projection-plan", "state://open-issues"], + "knownGaps": ["Two material records remain disputed"], + "writeScope": ["deliverables/opinion.md"], + "nextAction": "Draft the narrative and the two approved projections", + "completionSignal": "draft-ready" +} +``` + +Injection rules: + +- inject on milestone change, failed hard gate, compaction recovery, or explicit + continuation, not on every model turn; +- use stable artifact pointers and compact summaries, not raw document excerpts; +- include one next action so the prompt does not become a second workflow engine; +- for a large canonical ledger, inject only the next deterministic work slice + (currently capped at four records and 2 KiB) rather than requiring the Agent + to rediscover the slice or load the full ledger; +- keep generic lifecycle fields in Core and legal invariants in the plugin; +- deduplicate by milestone, state version, and injection digest; +- cap the envelope by bytes and fail with a pointer rather than truncating a + legal invariant silently; +- deduplicate on the visible milestone, blocking gap, and aggregate progress; + an unrelated draft hash change must not replay the same envelope; +- make canonical ledger writes bounded transactions (one fragment or ledger + section, at most 12 records and 24 KiB of serialized new content), followed + by deterministic validation; +- never include rubric IDs, expected answers, Judge feedback, or benchmark-only + case facts. + +This is dynamic prompt injection as state delivery. It is not keyword-based +prompt accumulation. + +## 9. Composition and rendering + +Composition has three separate operations: + +1. **Legal drafting:** the Agent writes conclusions, legal reasoning, + qualifications, recommendations, and transitions. +2. **Projection rendering:** deterministic code renders approved record sets + into tables, schedules, calculations, timelines, registers, or manifests. +3. **Document composition:** named anchors place rendered sections inline or in + appendices without rewriting unrelated narrative. + +Renderers must be optional, idempotent, stable under record ordering, and able to +report omitted or unresolved values. They receive validated records rather than +raw source text. A renderer must not decide legal materiality or fill a missing +legal conclusion. + +For narrative mode, composition can consist only of Agent-authored prose. For +hybrid mode, projections should normally stay between one and five. A larger +number requires a plan rationale because the 17-appendix E2 artifact was useful +for coverage but poor as a default product shape. + +## 10. Bounded validation + +Validation is layered from cheapest and most deterministic to most semantic: + +1. schema and required-field validation; +2. source inventory and provenance resolution; +3. record linkage, deduplication, and unresolved-state validation; +4. arithmetic, dates, status vocabulary, quotations, anchors, and artifact hash + checks where applicable; +5. deliverable-specific structural gates; +6. one focused legal reasoning and citation review; +7. mutation-policy verification and completion proof. + +Failures produce typed gaps with artifact pointers. The repair prompt receives +only failed gates and their local context. It does not ask the Agent to rewrite +the entire deliverable. + +At most two targeted repair passes are allowed. After the second failure, the +run returns an explicit incomplete result with unresolved gates. It must not +loop until a model happens to satisfy an unstable evaluator. + +## 11. Mutation policy + +Core owns a domain-neutral write contract: + +```ts +interface MutationPolicy { + writableArtifacts: string[] + writableScratchPrefixes: string[] + readOnlyPrefixes: string[] + forbiddenPrefixes: string[] + denyUnmatchedWrites: boolean +} +``` + +Core snapshots relevant paths before execution and emits a mutation manifest +from observed filesystem state afterward. The manifest records created, +modified, deleted, and unchanged declared artifacts with hashes. The Agent's +self-report is informational only. + +Unexpected writes fail the completion gate. Scratch writes are allowed only +under declared prefixes and are either retained intentionally or cleaned by a +separate, explicit lifecycle action. Symlink escapes, path normalization, case +normalization where required, and rename detection must be covered by Core +tests. + +The legal plugin declares its target deliverables and work-state prefixes. It +does not implement a competing filesystem monitor. + +## 12. E2-Elite experiment protocol + +The first implementation experiment reuses the difficult case without giving +production code access to rubric expectations or Judge feedback. + +Acceptance targets: + +| Gate | Target | +|---|---:| +| Overall semantic score | at least 220/321 | +| Critical semantic score | at least 113/141 | +| Fact extraction | at least 195/267 | +| Legal reasoning | at least 18/33 | +| Legal citation | at least 5/13 | +| User-visible appendices | 3-5 unless the plan justifies more | +| Model tokens | no more than 150,000 | +| Unauthorized mutations | 0 | +| Stable paired regressions | 0 | + +Compare four frozen variants where practical: + +- current narrative baseline; +- selective projections without semantic verifier; +- selective projections plus one reasoning review; +- the previous E2 semantic artifact as an upper-reference, not a process model. + +Record score, critical score, dimension score, model requests, input/output +tokens, elapsed time, projection count, mutation manifest, and unresolved gates. +Any changed checkpoint receives three paired rechecks before being called a +stable improvement or regression. + +## 13. Cross-case validation method + +The initial review uses a stratified sample rather than tuning on the difficult +opinion or immediately rerunning the full corpus. For each case, inspect only: + +- user request and requested deliverable; +- checkpoint count, dimensions, and critical count; +- source and generated artifact counts and formats; +- repetition, exactness, relationship density, and narrative dependence. + +Do not inspect expected answers to design production prompts. Do not import +rubric language into legal schemas. The review asks whether E2-Elite's adaptive +decision is appropriate, not whether one projection can maximize every score. + +Classification: + +- `primary`: structured or operational records are central to the requested + deliverable; +- `optional`: selective records help, but narrative analysis remains central; +- `inappropriate`: structured projection should not be the main user-visible + workflow, though small internal records may still support drafting. + +## 14. Cross-case applicability results + +The source workbook contains 85 entries across the first six batches and the +7.14 retest column. Three labels repeat exactly, leaving 82 unique cases. The +inventory contains 4,685 checkpoints, including 2,135 marked critical. The +review classified every unique case from task metadata and requested artifact +shape, without reading expected answers or Judge feedback into the design: + +| Applicability | Cases | Meaning | +|---|---:|---| +| `primary` | 18 | Structured or operational records are central | +| `optional` | 46 | Selective internal or visible projections can help | +| `internal-support-only` | 13 | Narrative remains primary; structure is an internal consistency aid | +| `outside-legal-plugin` | 5 | Reuse generic harness capabilities or another task Skill | + +| Delivery mode | Cases | +|---|---:| +| `narrative` | 14 | +| `hybrid` | 48 | +| `schedule-heavy` | 14 | +| `operational` | 6 | + +These are architecture-applicability classifications, not semantic pass +results. They show that E2-Elite is broadly useful only when its mode selector +is allowed to choose sparse internal support or no legal projection at all. + +Eleven cases were selected across materially different task families. Counts are +case metadata only; no expected answer or Judge feedback informed the design. + +| Task family and observed shape | Mode | Useful projection | Where deterministic rendering harms quality | Required hard gates | Applicability | +|---|---|---|---|---|---| +| SaaS contract review: 84 checks, 29 critical, 4 mixed Markdown/PDF inputs | `hybrid` | Clause-source-risk-recommendation records for data, AI, renewal, and SLA | Rendering the whole memorandum as clause rows fragments prioritization and negotiation advice | Requested topics covered; quote locations resolve; risk and recommendation stay linked; legal basis reviewed | `optional` | +| Civil pleading: 50 checks, 27 critical, 21 PDFs | `narrative` | Internal party-fact-evidence-relief consistency map | Formulaic rendering weakens factual theory, persuasion, and procedural coherence; no default user-visible appendix | Parties, claims, facts, evidence, jurisdiction, requested relief, and calculations are mutually consistent | `inappropriate` as the main workflow | +| Multi-person statement comparison: 60 checks, 28 critical, 6 Markdown inputs; the request explicitly requires a comparison table and contradiction analysis | `hybrid` | Fact-point x statement records, evolution timeline, support/contradiction links, objective-evidence gaps | A table cannot decide credibility or replace careful explanation of ambiguity and alternative causes | Every material statement has provenance; inconsistency labels are neutral; objective corroboration and gaps are explicit | `primary` for comparison records | +| Personal-information impact assessment: 51 checks, 38 critical, 3 mixed inputs | `hybrid` | Processing activities, data lifecycle, affected rights, controls, residual-risk register, open technical questions | A generated risk register cannot replace necessity, proportionality, legal-basis, and residual-risk reasoning | Unknown technical details remain unresolved; processing-purpose-data-recipient links are complete; citations and residual risks reviewed | `optional` | +| Tort damages calculation: 54 checks, 37 critical; arithmetic and legal reasoning dominate | `schedule-heavy` | Claim-item formula, factual inputs, authority/version, evidence, responsibility allocation, subtotal and final amount | Narrative should explain contested assumptions and alternative scenarios; a single unexplained total creates false certainty | Units, dates, jurisdiction/version, formulas, dependencies, responsibility ratio, rounding, and evidence links recompute exactly | `primary` | +| Matter intake and file management: 85 checks, 19 critical, 21 PDFs; observed outputs include matter records, event logs, and a portfolio register | `operational` | File manifest, classification state, matter metadata, event/action log, unresolved filing queue | Legal appendices are the wrong abstraction; prose-only output also fails because actual file operations matter | No source mutation unless authorized; every file accounted for; naming collisions and duplicates handled; mutation manifest matches results | `primary` in operational mode | +| Short legal advice email: 49 checks, 24 critical, 1 document input | `narrative` | Small internal issue-rule-risk-action checklist | A visible schedule makes a short answer harder to read and can obscure conditional advice | Question answered directly; assumptions and missing contract terms disclosed; legal basis checked; next actions are practical | `inappropriate` as the main workflow | +| Tabular due diligence: 125 checks, 49 critical, 11 mixed inputs; one row per file with exact status, quotation, and location | `schedule-heavy` | Contract x review-field records with controlled status, verbatim quote, location, and aggregate counts | Free-form generated cell prose reduces comparability; narrative is useful only for cross-document exceptions and conclusions | Every file and field accounted for; status vocabulary valid; quotes and locations resolve; counts recompute from rows | `primary` | +| Case-law research report: 62 checks, 35 critical, 2 pleadings; fact, citation, reasoning, and structure are all material | `hybrid` | Issue-authority-holding-applicability records, search scope, negative-result log, citation provenance | A case list cannot replace comparison of legally material facts or explain why an authority applies | Research scope and cutoff disclosed; every citation resolves; holding is separated from application; contrary authority and research gaps remain visible | `optional` user projection, `primary` internal authority records | +| Document redaction: 33 checks, 11 critical, 1 Word input; structure compliance dominates and the output must remain Word | `operational` | Detection/redaction manifest, replacement policy, residual scan, before/after document metadata | A legal record appendix is irrelevant, and reconstructing the document as generated prose can destroy layout | Authorized categories only; every occurrence handled; residual scan passes; layout and file type preserved; mutation manifest names the output | `inappropriate` for legal projection; use generic document transformation plus a task Skill | +| Legal content planning: 13 checks, 5 critical, 9 text/tabular inputs; reasoning dominates but the request repeats platform, audience, purpose, conversion, and safety fields | `hybrid` | Task-specific content-idea records and a lightweight publishing plan | A legal claim model is the wrong semantic type; rigid row generation can flatten editorial judgment and platform-specific voice | Requested themes covered; platform/audience fit explained; legal and marketing-risk language reviewed; no unsupported legal claim | `inappropriate` for the legal plugin; use a content-planning Skill | + +The sample supports the adaptive design and rejects both a universal structured +format and a universal legal runtime. Projection is primary for due diligence, +evidence comparison, calculations, and operational records; optional for +contract, regulatory, and legal-research analysis; and usually inappropriate as +the main visible workflow for pleadings and short advice. Document redaction and +content planning confirm the ownership boundary: they may reuse Core lifecycle, +mutation, and artifact mechanisms, but they should not activate the legal claim +and projection protocol merely because their benchmark category is legal. + +The design therefore applies across the legal-analysis subset only because mode +selection may choose little or no user-visible projection. It intentionally does +not apply to every task in the broader legal benchmark. If `ProjectionPlan` were +required to produce appendices for every case, or if every task were forced to +create `LegalClaim` records, the design would fail this review. + +## 15. Implementation sequence + +Keep each stage independently measurable and do not begin with a general +semantic verifier. + +The current implementation is a useful due-diligence vertical slice, not yet +the complete architecture: + +| Current behavior | E2-Elite change | Boundary | +|---|---|---| +| `legal-coverage` creates seven fixed due-diligence matrices | Add a validated projection plan and task-owned registrations; keep the existing matrices as one due-diligence adapter | Legal plugin and task Skill only | +| The legal hook now emits compact, digest-deduplicated envelopes, recovers after compaction, and injects a 2-KiB deterministic coverage slice | Generalize the domain-neutral delivery primitive only after another task family proves the same contract | Current legal content stays in the plugin; any later generic delivery mechanism belongs in Core | +| The artifact contract requires `completion-proof.json` and checks file existence | Extend Core contracts with observed mutation policy and hash manifests; keep legal completion semantics in the plugin | Core mechanism, plugin declaration | +| Coverage discovery now has a read-only `next-batch` CLI and automatic four-record prompt slice | Extend bounded slices to other proven ledger phases; add explicit repair budgets and return unresolved gates after two targeted attempts | Legal slicing stays in the plugin; a generic retry budget belongs in Core | +| Activation recognizes due-diligence/opinion/risk-review requests | Let task Skills opt into the shared legal protocol; do not broaden one regex to every legal prompt | Task Skill registration | + +1. **Core mutation contract.** Add policy parsing, before/after snapshots, + mutation manifests, deny-unmatched enforcement, and adversarial path tests. +2. **Legal task profiler and `ProjectionPlan`.** Implement the four modes, + conservative fallback, plan validation, and task-family configuration in the + legal plugin. +3. **Open legal claim envelope.** Add provenance, uncertainty, relation, and + unresolved-field validation without imposing a closed claim taxonomy. +4. **Two high-value renderers.** Start with record schedules and calculations; + prove idempotence and lossless linkage. Do not build a renderer per case. +5. **Milestone injection.** Add versioned, digest-deduplicated state envelopes + through the generic Core hook, with byte limits and compaction recovery. +6. **Bounded validation.** Add deterministic gates first, then one focused legal + reasoning/citation review and no more than two targeted repairs. +7. **Difficult-case A/B.** Meet semantic, cost, appendix, and mutation gates. +8. **Stratified pilot.** Run one case from each mode and compare against the + unchanged baseline. +9. **Full-corpus decision.** Rerun the 80+ cases only after the four-mode pilot + shows no architectural mismatch and the benchmark noise policy is fixed. + +Each implementation step must add evidence that proves the layer boundary: +Core tests remain domain-neutral, legal tests contain no benchmark answers, and +benchmark adapters remain unreachable from production runtime. + +## 16. Non-goals + +E2-Elite does not: + +- turn all legal work into tables or typed appendices; +- move legal schemas, rules, or prompt content into PilotDeck Core; +- encode rubric checkpoints, expected answers, or known case entities; +- use an external Judge to steer production execution; +- treat deterministic rendering as legal reasoning; +- authorize unlimited verifier/repair loops; +- trust the Agent's description of which files changed; +- claim that the 82-case architecture review is a full-corpus semantic quality result. diff --git a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md index 9006f047..deb30193 100644 --- a/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md +++ b/products/legal/plugins/legal-coverage/skills/conduct-legal-due-diligence/SKILL.md @@ -21,17 +21,21 @@ Create the legal analysis; let the bundled validator enforce structure and cover 3. Use no more than four concurrent delegated workers. Give each worker a disjoint source batch or legal topic and an explicit output path under `.pilotdeck/work/legal-coverage/fragments/` (or a task-required evidence path). 4. Each fragment must list source path/ID, inspection method, locator-grounded atomic facts, evidence class, verification state, conflicts, unresolved items, and proposed materiality. The worker returns only the fragment path and a summary under 1,000 characters. 5. Delegated workers may inspect sources and write only their assigned evidence fragment. They must not edit canonical ledgers, the completion proof, or a final deliverable. -6. After workers return, the main agent reads fragments instead of replaying all raw source text and serially merges supported facts into canonical state. A failed worker is retried only for its missing batch; never restart completed batches. +6. After each worker batch returns, the main agent reads its fragments instead of replaying raw source text and immediately merges supported facts into canonical state. Do not launch a second extraction wave while completed fragments remain unmerged. +7. Treat every canonical write as a bounded transaction: merge at most 12 records and at most 24 KiB of serialized new content from one fragment or one ledger section, whichever limit comes first. Prefer a focused `edit_file` insertion after reading the current ledger; update reciprocal links and run validation before the next batch. Never emit or replace an entire large ledger in one tool call. +8. A failed worker is retried only for its missing batch; never restart completed batches. ## Build Evidence Before Conclusions 1. Inventory every file under every configured input root in `sources.json`. Use one stable source ID per file and record the lowercase SHA-256 of the exact bytes inspected. If a source changes, re-inspect it and update every dependent ledger before recording the new hash. 2. Inspect every machine-readable source completely, including all spreadsheet sheets and presentation slides. Mark a source `unreadable` only after deterministic extraction or inspection fails; record the unresolved items. -3. Record atomic facts in `facts.json`. Preserve the subject, predicate, value, unit, date or period, source locator, evidence class, verification state, conflict state, and materiality. Do not merge conflicting statements into one fact. -4. Set `material: true` only when the fact changes a legal conclusion, risk severity, transaction control, or unresolved disclosure. Set `critical: true` only when it may block or materially restructure the transaction. Do not default every extracted fact to material or critical. -5. Link each reviewed source to extracted fact IDs or give a specific `noMaterialFactsReason`. -6. Do not set `config.allowNoMaterialFacts` to true for a responsive diligence room. It exists only for a genuinely non-responsive source set after every file was reviewed. -7. Create the configured deliverable skeleton early and update it incrementally. Do not wait until research is complete to start the formal output. +3. Treat every configured input root as read-only. Put OCR text, extraction caches, conversion scripts, and other derived working files under `.pilotdeck/work/legal-coverage/`, never beside source documents. +4. Use only inspection tools already available in the runtime. Do not install system packages, language packages, plugins, or binaries during a legal task. If available deterministic fallbacks cannot read a file, mark it pending manual verification instead of mutating the host environment. +5. Record atomic facts in `facts.json`. Preserve the subject, predicate, value, unit, date or period, source locator, evidence class, verification state, conflict state, and materiality. Do not merge conflicting statements into one fact. +6. Set `material: true` only when the fact changes a legal conclusion, risk severity, transaction control, or unresolved disclosure. Set `critical: true` only when it may block or materially restructure the transaction. Do not default every extracted fact to material or critical. +7. Link each reviewed source to extracted fact IDs or give a specific `noMaterialFactsReason`. +8. Do not set `config.allowNoMaterialFacts` to true for a responsive diligence room. It exists only for a genuinely non-responsive source set after every file was reviewed. +9. Create the configured deliverable skeleton early and update it incrementally. Do not wait until research is complete to start the formal output. ## Complete Legal Analysis @@ -51,7 +55,8 @@ Create the legal analysis; let the bundled validator enforce structure and cover 4. For text deliverables, copy an exact supporting quote into each coverage row. Each row needs distinct supporting text; do not reuse a generic sentence across facts or issues. 5. A material-fact quote must contain the fact subject and either its predicate, value, or date/period. Add a concise evidence appendix when the main analysis would otherwise become unreadable. 6. Mark unresolved facts, issues, and pending authorities as `unresolved` in coverage. Never hide a conflict or verification gap. -7. Run the injected validator command with `--write-proof`. Fix the first reported blocking condition, rerun, and stop only when it passes. +7. During the coverage phase, use the injected `next-batch --phase coverage` command to read only the next bounded repair slice. Do not scan or rewrite all canonical ledgers to discover uncovered records. +8. Run the injected validator command with `--write-proof`. Fix the first reported blocking condition, rerun, and stop only when it passes. ## Completion Rule From 07e31e2a5c3f4c443ce5db70e3d86779d22e5fcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Sat, 25 Jul 2026 00:42:28 +0800 Subject: [PATCH 10/14] test(legal): record projection experiment evidence --- .../projection-experiment-summary.json | 86 +++++++++++++++++++ .../projection-experiment-summary.md | 86 +++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 .omo/evidence/20260723-legal-coverage-v1/projection-experiment-summary.json create mode 100644 .omo/evidence/20260723-legal-coverage-v1/projection-experiment-summary.md diff --git a/.omo/evidence/20260723-legal-coverage-v1/projection-experiment-summary.json b/.omo/evidence/20260723-legal-coverage-v1/projection-experiment-summary.json new file mode 100644 index 00000000..69241398 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/projection-experiment-summary.json @@ -0,0 +1,86 @@ +{ + "schemaVersion": 1, + "experiment": "frozen-evidence-projection-e1-e2", + "contaminationControls": { + "e1AgentRerun": false, + "e2FrozenEvidenceAgentTurn": true, + "sourceFilesReread": false, + "rubricUsedForGeneration": false, + "judgeFeedbackUsedForGeneration": false, + "rawPrivateArtifactsCommitted": false + }, + "e0": { + "passed": 185, + "total": 321, + "score": 0.5763239875389408, + "criticalPassed": 100, + "criticalTotal": 141 + }, + "e1": { + "passed": 174, + "total": 321, + "score": 0.5420560747663551, + "criticalPassed": 94, + "criticalTotal": 141 + }, + "singleRunTransitions": { + "stablePass": 169, + "stableFail": 131, + "improvement": 5, + "regression": 16 + }, + "pairedRecheck": { + "rounds": 3, + "checkpointCount": 21, + "e0AggregatePasses": 16, + "e1AggregatePasses": 15, + "e1BetterCheckpointCount": 1, + "tiedCheckpointCount": 18, + "e0BetterCheckpointCount": 2 + }, + "e2": { + "semanticCandidateAccepted": true, + "processAccepted": false, + "sourceFileCount": 0, + "preservesE0AsExactPrefix": true, + "appendedChars": 14453, + "passed": 225, + "total": 321, + "score": 0.7009345794392523, + "criticalPassed": 113, + "criticalTotal": 141, + "factExtractionPassed": 198, + "factExtractionTotal": 267, + "legalReasoningPassed": 15, + "legalReasoningTotal": 33, + "legalCitationPassed": 5, + "legalCitationTotal": 13, + "processViolations": [ + "created-one-extra-legal-work-scratch-file", + "self-reported-only-target-file-changed" + ], + "requestCount": 47, + "totalTokens": 1252901, + "pairedRecheck": { + "rounds": 3, + "checkpointCount": 60, + "e0AggregatePasses": 16, + "e2AggregatePasses": 145, + "e2BetterCheckpointCount": 47, + "tiedCheckpointCount": 9, + "e0BetterCheckpointCount": 4, + "stableE2OnlyWins": 43, + "stableE2OnlyCriticalWins": 15, + "stableE0OnlyWins": 0 + } + }, + "decision": { + "rawLedgerAppendicesSupported": false, + "recordOrientedProjectionSupported": true, + "generatedE2ProcessProductionReady": false, + "legalProjectionOwner": "legal-plugin-or-skill", + "genericMutationContractOwner": "pilotdeck-core", + "verifierLoopJustified": false, + "nextStep": "implement-typed-legal-records-deterministic-rendering-and-generic-mutation-enforcement" + } +} diff --git a/.omo/evidence/20260723-legal-coverage-v1/projection-experiment-summary.md b/.omo/evidence/20260723-legal-coverage-v1/projection-experiment-summary.md new file mode 100644 index 00000000..8cbcdcb2 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/projection-experiment-summary.md @@ -0,0 +1,86 @@ +# Frozen-evidence projection experiment + +## What was tested + +The difficult legal case was evaluated without rerunning the Agent and without rereading source documents. + +- E0: the frozen v1.2 opinion. +- E1: the byte-identical E0 body plus mechanically generated user-visible schedules from the frozen evidence ledger. +- Constant Judge settings: 321 checkpoints, batch size 10, 120,000-character limit, same case record, same captured final response. +- E1 generation did not use rubric expectations or Judge feedback. The rubric was used only after generation for evaluation. + +Raw legal artifacts, payloads, and Judge responses remain outside Git. This file contains only aggregate, sanitized evidence. + +## Observed results + +| Metric | E0 | E1 | Delta | +|---|---:|---:|---:| +| Overall pass | 185/321 (57.63%) | 174/321 (54.21%) | -11 | +| Critical pass | 100/141 (70.92%) | 94/141 (66.67%) | -6 | + +The single full-run transition table contained 5 apparent improvements, 16 apparent regressions, 169 stable passes, and 131 stable failures. + +Because E1 is E0 with append-only schedules, the 16 apparent regressions could not be treated as deterministic content loss. The 21 disputed checkpoints were therefore rejudged in three concurrent E0/E1 pairs: + +| Paired result | E0 | E1 | +|---|---:|---:| +| Round 1 | 6/21 | 6/21 | +| Round 2 | 5/21 | 5/21 | +| Round 3 | 5/21 | 4/21 | +| Aggregate | 16/63 | 15/63 | + +Across the 21 checkpoints, E1 was better on 1, tied on 18, and E0 was better on 2. One stable E1 improvement came from exposing a source filename that identified a document as an excerpt. One stable E1 regression came from splitting related record fields across separate rows, which caused the Judge to require an entity-specific paid-in statement instead of accepting a document-level statement. + +The Judge guard repeatedly downgraded a substantively supporting verdict to fail on one checkpoint even though its own reasoning said the requirement was satisfied. This is evaluation noise and should be fixed or adjudicated in rubric v2. + +## E2 record-oriented projection + +E2 used a real Agent turn over a copied workspace with zero source files. It could read only the frozen opinion and internal legal evidence. The generation prompt prohibited rubric/Judge input, required the E0 document to remain an exact byte prefix, and limited the change to record-oriented supporting schedules. + +The semantic artifact preserved E0 as an exact prefix and appended 14,453 characters. Its single full Judge run scored: + +| Metric | E0 | E2 | Delta | +|---|---:|---:|---:| +| Overall pass | 185/321 (57.63%) | 225/321 (70.09%) | +40 | +| Critical pass | 100/141 (70.92%) | 113/141 (80.14%) | +13 | + +The single-run transition table contained 50 improvements and 10 regressions. Fact extraction improved from 155/267 to 198/267. Legal citation remained 5/13. Legal reasoning fell from 18/33 to 15/33 in that single run, so record schedules do not replace substantive legal review. + +All 60 changed checkpoints were rejudged in three concurrent E0/E2 pairs: + +| Paired result | E0 | E2 | +|---|---:|---:| +| Round 1 | 5/60 | 48/60 | +| Round 2 | 6/60 | 48/60 | +| Round 3 | 5/60 | 49/60 | +| Aggregate | 16/180 | 145/180 | + +E2 was better on 47 checkpoints, tied on 9, and worse on 4. There were 43 stable E2-only wins (E0 0/3, E2 3/3), including 15 critical checkpoints. There were no stable E0-only wins (E0 3/3, E2 0/3). + +E2 did not pass the process gate: + +- The Agent created one scratch schedule under the legal work directory despite an explicit instruction that only the opinion could change. +- The Agent then incorrectly reported that the target opinion was the only modified file. +- The run used 47 model requests and approximately 1.25 million total tokens. + +The E2 score therefore proves semantic value, but the generated process is not a production implementation. + +## Why this is enough + +The experiment isolates raw ledger exposure from source reading, Agent generation, prompt injection, and Core lifecycle behavior. The paired three-round recheck prevents a single drifting semantic judgment from being mistaken for a causal effect. + +The evidence rejects the naive hypothesis that appending a raw fact ledger is a general solution. It strongly supports record-oriented, selective, co-located, lossless projection. Adding atomic rows without document semantics has near-zero value; aggregating related fields into legal records produces a large and repeatable fact-coverage gain. + +## Boundary decision + +- No legal schema, legal importance rule, or record renderer belongs in Core. +- No benchmark answer, rubric ID, case entity, or Judge integration should enter production runtime. +- The legal plugin/Skill should own typed legal records, legal-importance routing, compact schedule rendering, and the substantive reasoning review. +- Core may own a generic allowed-mutation contract and hash-based before/after enforcement because the E2 Agent's self-report was demonstrably unreliable. That abstraction should remain domain-neutral. +- The production design must replace the 1.25-million-token exploratory synthesis with incremental structured records and deterministic rendering. +- A verifier/gap loop is not yet justified. First implement the cheaper projection path and mutation guard, then A/B the verifier against that baseline. +- The external Judge remains an evaluation-only dependency. + +## Omitted + +Raw legal materials, opinion text, expected answers, per-checkpoint legal content, Judge payloads, raw responses, authentication data, and private temporary paths are intentionally omitted. From af50728fa12c578bf81a70e560fb1ffa8bb10cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Sat, 25 Jul 2026 00:42:39 +0800 Subject: [PATCH 11/14] test(legal): record runtime and corpus evidence --- .../adaptive-runtime-pilot.md | 122 ++++++++++++++++++ .../corpus-applicability-summary.md | 81 ++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 .omo/evidence/20260723-legal-coverage-v1/adaptive-runtime-pilot.md create mode 100644 .omo/evidence/20260723-legal-coverage-v1/corpus-applicability-summary.md diff --git a/.omo/evidence/20260723-legal-coverage-v1/adaptive-runtime-pilot.md b/.omo/evidence/20260723-legal-coverage-v1/adaptive-runtime-pilot.md new file mode 100644 index 00000000..2949aa91 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/adaptive-runtime-pilot.md @@ -0,0 +1,122 @@ +# E2-Elite adaptive runtime pilot + +## What was tested + +One difficult legal-opinion case was run through the real PilotDeck local +gateway with the legal plugin installed in an isolated PilotDeck home and an +isolated workspace. The run used the original task query and 62 copied source +files. No rubric, expected answer, Judge response, or case-specific answer was +available to the plugin or Agent. + +The evaluation-only mutation guard denied dependency installation, privilege +escalation, writes outside the isolated workspace, and, after the first-run +finding, writes under the copied source root. The user's downloaded source +files and real PilotDeck home were not modified. + +Three bounded observations were retained: + +1. an initial 72-turn end-to-end run using state-hash milestone deduplication; +2. a 12-turn continuation using observable-progress deduplication; +3. a 12-turn continuation after adding the deterministic coverage-batch CLI. + +A final model-free hook probe exercised automatic work-slice injection over the +resulting real legal state. + +## What was observed + +### Initial run + +- Duration: 3,241,710 ms (about 54 minutes). +- Finish reason: `tool_error` after the configured turn budget. +- Model requests reported by the runtime: 73; audited stream calls including + delegated activity: 206. +- Total token accounting: 7,876,826. +- Canonical state reached 64 source rows, 256 facts, 7 matrices, 13 issues, + 9 authorities, and 2 deliverables. +- Validation remained failed with 212 errors; first error: + `coverage/deliverable_hash_missing`. +- No completion proof was created. +- One attempted canonical write was about 84 KiB and required large-output + recovery. +- The Agent created two OCR text caches under the copied input root. This + increased the source inventory from the original 62 files to 64 rows. The + original Downloads corpus was unchanged, but the mutation is still a process + failure. + +The old digest replayed the envelope repeatedly when draft/state hashes changed +without a meaningful milestone change. Near the end of the run, every audited +stream request carried an envelope. + +### Observable-progress continuation + +- Duration: 266,435 ms; 12 model turns. +- Total token accounting: 413,578. +- Envelope delivery: 1 of 12 requests. +- Legal state and 212 validation errors were unchanged. + +This isolates a real cost improvement from semantic progress: suppressing +opaque hash churn removed repeated prompt delivery, but it did not tell the +Agent how to read a bounded slice of the large existing ledgers. + +### Coverage-batch continuation + +- Duration: 723,066 ms; 12 model turns. +- Total token accounting: 488,515. +- Envelope delivery: 2 of 12 requests. The second injection followed a genuine + blocking-gap change. +- `coverage.json` advanced from empty arrays to 2 deliverables, 10 unresolved + sources, 42 facts, 9 issues, and 7 authorities. +- Validation errors fell from 212 to 164; first error became + `coverage/unresolved_source_not_disclosed`. +- No completion proof was created. + +The Agent created and used its own coverage scripts instead of the new CLI and +wrote about 40 KiB in one step, so the model did not obey the new 12-record / +24-KiB transaction guidance. The run proves progress, not compliance or final +quality. + +### Automatic dynamic work-slice probe + +The final production hook was invoked directly against the 164-error real +state. It produced: + +- milestone: `VALIDATING`; +- total context size: 3,627 bytes; +- first gap: 19 occurrences of `unresolved_source_not_disclosed`; +- injected work group: `sources`; +- injected items: 4 of 19; +- work-item payload: 1,237 bytes, capped by 4 records / 2,048 bytes; +- benchmark-marker scan: no rubric, Judge-response, checkpoint ID, or ground + truth marker. + +## Why it is enough + +The initial run proves the workflow can inventory and project a large, +multi-format legal room while exposing real cost and mutation failures. The two +continuations isolate the effect of prompt deduplication and bounded work +discovery. The final probe proves the shipped hook can inject a compact, +current, domain-owned work slice without relying on model compliance to invoke +the CLI first. + +Together with the automated gateway tests, this is enough to accept the +bounded vertical slice and reject the previous hash-driven prompt replay. It is +not enough to claim that the difficult case or the 82-case corpus semantically +passes. + +## What was omitted + +Raw legal sources, extracted facts, opinion text, model messages, expected +answers, rubric content, Judge payloads, credentials, private configuration, +and raw tool logs are omitted. Only aggregate counts, failure codes, hashes, +and control-flow observations are recorded. + +## Remaining risk + +- The difficult case still has 164 deterministic coverage errors. +- The final automatic work-slice injection has deterministic and gateway test + coverage, but was not followed by another paid 12-turn model run. +- Model guidance alone did not enforce the batch-write limit; a generic Core + mutation/size contract remains a separate future capability. +- The original legal Skills needed for a faithful 82-case A/B rerun are absent + from the supplied archive. + diff --git a/.omo/evidence/20260723-legal-coverage-v1/corpus-applicability-summary.md b/.omo/evidence/20260723-legal-coverage-v1/corpus-applicability-summary.md new file mode 100644 index 00000000..7180bf38 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/corpus-applicability-summary.md @@ -0,0 +1,81 @@ +# Full-corpus E2-Elite applicability review + +## What was tested + +The review used the case-name workbook from the supplied ten-batch evaluation +archive and the available case metadata. The scope was the first six delivery +batches plus the 7.14 retest column, matching the user's "80+" corpus. + +Only task labels, requested artifact shape, source/generated-file metadata, +checkpoint counts, dimensions, and critical flags were used. Expected answers, +checkpoint descriptions, Judge reasoning, and generated legal content were not +used to design or alter production prompts. + +Each unique case was classified along two axes: + +- delivery mode: narrative, hybrid, schedule-heavy, or operational; +- E2-Elite applicability: primary, optional, internal-support-only, or + outside-legal-plugin. + +## What was observed + +- Spreadsheet entries: 85. +- Exact duplicate labels: 3. +- Unique cases: 82. +- Checkpoints represented by the case inventory: 4,685. +- Critical checkpoints: 2,135. + +Applicability: + +| Class | Cases | +|---|---:| +| primary | 18 | +| optional | 46 | +| internal-support-only | 13 | +| outside-legal-plugin | 5 | + +Delivery modes: + +| Mode | Cases | +|---|---:| +| narrative | 14 | +| hybrid | 48 | +| schedule-heavy | 14 | +| operational | 6 | + +The result supports adaptive selection and rejects a universal legal table or +seven-matrix workflow. Structured projection is most useful for due diligence, +evidence comparison, calculations, timelines, registers, and operational file +state. It is usually selective for contract, privacy, investigation, and legal +research work. Pleadings and short advice should remain narrative, with at most +small internal consistency records. File redaction and legal content planning +need generic artifact controls or separate task Skills, not the due-diligence +legal schema. + +## Why it is enough + +All 82 unique labels were included in the architecture-applicability census, +and eleven materially different task shapes received a deeper manual review in +`products/legal/docs/e2-elite-adaptive-legal-projection.md`. The full census +tests whether the architecture can select an appropriate mode without tuning +production behavior to expected answers. The stratified review tests the +failure boundary of each mode in more detail. + +This is enough to decide that E2-Elite should be adaptive and task-owned. It is +not enough to claim a semantic quality improvement across the corpus. + +## What was omitted + +Raw source documents, expected answers, rubric text, checkpoint descriptions, +Judge prompts and responses, generated legal documents, credentials, and +private endpoint details are intentionally omitted. The three duplicate labels +were retained in the source-entry count and removed only from the unique-case +classification; no additional cases were deleted to force the count to 80. + +## Remaining gate + +A full 82-case A/B rerun should wait until the original legal Skills are +available and one representative case from each delivery mode passes runtime, +mutation, cost, and semantic gates. The recovered `output/skills/*.json` files +are QA reports that point to unavailable Skill sources; they are not executable +Skill bodies and cannot support a faithful rerun. From f9c8812ab1bcfea4685fe76dc3e4b75667dc3ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Sat, 25 Jul 2026 00:42:54 +0800 Subject: [PATCH 12/14] test(legal): record E2-Elite release gates --- .../e2-elite-final-gates.txt | 34 +++++++++++++++++++ .../evidence-index.txt | 6 +++- .../implementation-boundary.txt | 4 +-- 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 .omo/evidence/20260723-legal-coverage-v1/e2-elite-final-gates.txt diff --git a/.omo/evidence/20260723-legal-coverage-v1/e2-elite-final-gates.txt b/.omo/evidence/20260723-legal-coverage-v1/e2-elite-final-gates.txt new file mode 100644 index 00000000..046c8a78 --- /dev/null +++ b/.omo/evidence/20260723-legal-coverage-v1/e2-elite-final-gates.txt @@ -0,0 +1,34 @@ +WHAT WAS TESTED +1. npx tsc -p tsconfig.json --noEmit +2. npm run build +3. node --test --test-force-exit --test-timeout 60000 dist/tests/products/legal-coverage.spec.js dist/tests/agent/legal-coverage-plugin-runtime.spec.js +4. npm test +5. node --check products/legal/plugins/legal-coverage/hook.mjs +6. node --check products/legal/plugins/legal-coverage/scripts/legal-coverage.mjs +7. node --check products/legal/plugins/legal-coverage/scripts/lib/legal-coverage.mjs +8. git diff --check + +WHAT WAS OBSERVED +TypeScript: exit 0, no diagnostics. +Build: exit 0. +Targeted legal and real-gateway tests: 24 passed, 0 failed. +Full repository tests: 216 passed, 0 failed, 0 skipped, 0 cancelled. +Syntax checks: exit 0. +Whitespace check: exit 0. + +The focused suite covers proof validity, provenance, relationship rules, +symlink fail-closed behavior, activation boundaries, milestone deduplication, +PostCompact recovery, deterministic next-batch output, the 24-KiB batch cap, +automatic 2-KiB work-item injection, benchmark-token exclusion, and real local +gateway behavior. + +WHY IT IS ENOUGH +The full suite exercises every repository test after the final production-code +change. Focused tests directly cover the new legal behavior and its interaction +with the real gateway. The separate real-case pilot records runtime, cost, +mutation, and incomplete-validation behavior that hermetic tests cannot prove. + +WHAT WAS OMITTED +Raw test output, environment dumps, credentials, private configuration, legal +source content, generated legal content, and model transcripts are omitted. + diff --git a/.omo/evidence/20260723-legal-coverage-v1/evidence-index.txt b/.omo/evidence/20260723-legal-coverage-v1/evidence-index.txt index b7bdc382..e6b1a3fa 100644 --- a/.omo/evidence/20260723-legal-coverage-v1/evidence-index.txt +++ b/.omo/evidence/20260723-legal-coverage-v1/evidence-index.txt @@ -1,4 +1,8 @@ CURRENT FINAL EVIDENCE +- e2-elite-final-gates.txt: final 24/24 focused and 216/216 full-suite gates. +- adaptive-runtime-pilot.md: three bounded real-case runs plus the final automatic work-slice probe. +- corpus-applicability-summary.md: 85 source entries, 82 unique cases, and adaptive applicability census. +- projection-experiment-summary.md/json: frozen E0/E1/E2 semantic projection experiment. - post-review-fix-gates.txt: v1.2.0 targeted 22/22 and full 214/214 gates. - hard-case-validator-summary.txt: final source-bound validator pass. - hard-verifier-summary.txt: final independent verifier pass and provenance qualification. @@ -17,4 +21,4 @@ HISTORICAL / SUPERSEDED EVIDENCE - harness-recovery-incidents.txt records earlier pre-v1.2.0 recovery history and the separate Core shell-safety incident. GLOBAL LIMITATION -No current Judge score exists while the private scoring endpoint is unreachable. Local product QA and the independent hard verifier passing do not substitute for that external gate. +No current Judge score exists while the private scoring endpoint is unreachable. The real E2-Elite run also remains deterministically incomplete with 164 coverage errors. Local product QA, runtime progress, and the independent hard verifier do not substitute for those external and semantic gates. diff --git a/.omo/evidence/20260723-legal-coverage-v1/implementation-boundary.txt b/.omo/evidence/20260723-legal-coverage-v1/implementation-boundary.txt index 8b45958a..fcc25fa5 100644 --- a/.omo/evidence/20260723-legal-coverage-v1/implementation-boundary.txt +++ b/.omo/evidence/20260723-legal-coverage-v1/implementation-boundary.txt @@ -9,13 +9,13 @@ Focused test files: 2 Post-review modified product files: 5 Post-review modified test files: 2 Product-only matches for hard-case title, entity names, output stem, benchmark marker, temporary run name, fixed source-count literal, and fixed slide-count literal: 0 -Plugin hooks: UserPromptSubmit, PreModelRequest, Stop, SessionEnd +Plugin hooks: UserPromptSubmit, PreModelRequest, PostCompact, Stop, SessionEnd Skill count: 1 (conduct-legal-due-diligence) Validator version: 1.2.0 Core source changes: none WHY IT IS ENOUGH -The generic Harness remains untouched. Activation, dynamic milestones, fail-closed stopping, legal workflow guidance, source/ledger/deliverable integrity, semantic coverage, and deterministic validation are owned by the legal product bundle. Focused negative-token tests prevent benchmark details from entering shipped product code. +The generic Harness remains untouched. Activation, dynamic milestones, compact coverage work slices, fail-closed stopping, legal workflow guidance, source/ledger/deliverable integrity, semantic coverage, and deterministic validation are owned by the legal product bundle. Focused negative-token tests prevent benchmark details from entering shipped product code. WHAT WAS OMITTED Forbidden case tokens are summarized rather than reproduced. No source-room or opinion content was scanned into this evidence file. From a24b96b5520ffbd65884dcba57b92d23bf3b273a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Sat, 25 Jul 2026 01:13:23 +0800 Subject: [PATCH 13/14] test(legal): record clean stacked QA --- .../qa-summary.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .omo/evidence/20260725-legal-coverage-clean-stack/qa-summary.md diff --git a/.omo/evidence/20260725-legal-coverage-clean-stack/qa-summary.md b/.omo/evidence/20260725-legal-coverage-clean-stack/qa-summary.md new file mode 100644 index 00000000..5eabd2b4 --- /dev/null +++ b/.omo/evidence/20260725-legal-coverage-clean-stack/qa-summary.md @@ -0,0 +1,39 @@ +# Clean stacked legal coverage QA + +## What was tested + +- Started from the updated `codex/legal-runtime-e2e` head after it merged + `origin/main`. +- Cherry-picked only the legal coverage commits from + `ca960406` through `8c8c506f`; the old `Merge origin/main` commit was not + copied into this branch. +- Installed dependencies with `pnpm install --frozen-lockfile` in the fresh + worktree. +- Ran `npm test`, which rebuilt the project and exercised the complete compiled + test suite. +- Ran `git diff --check` and verified the worktree is clean. + +## What was observed + +- Clean stacked branch: `codex/legal-coverage-v1-clean`. +- Base: updated `codex/legal-runtime-e2e` at `54b110b2`. +- Head after QA: `f9c8812ab1bcfea4685fe76dc3e4b75667dc3ebf`. +- Full compiled test suite: 191 passed, 0 failed. +- Build: passed. +- Whitespace check: passed. +- No untracked or modified files remain in the clean worktree. + +## Why it is enough + +The branch is directly descended from the updated stacked base, so GitHub can +compute the PR diff from a single base ancestor. The legal implementation and +its tests are identical in behavior to the previously verified candidate, but +the duplicated mainline merge is removed from the PR topology. + +This proves the replacement PR is locally ready. GitHub CodeQL and required +review gates remain separate remote checks after the branch is pushed. + +## What was omitted + +No credentials, API keys, legal source content, Judge payloads, raw model +traffic, or private configuration were captured. From d30b328018e539e3b60e8d6cb11fd03a2c290c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E8=BE=BE=E5=A5=87?= <> Date: Sat, 25 Jul 2026 01:19:47 +0800 Subject: [PATCH 14/14] test(legal): correct clean stack test count --- .../20260725-legal-coverage-clean-stack/qa-summary.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.omo/evidence/20260725-legal-coverage-clean-stack/qa-summary.md b/.omo/evidence/20260725-legal-coverage-clean-stack/qa-summary.md index 5eabd2b4..89573061 100644 --- a/.omo/evidence/20260725-legal-coverage-clean-stack/qa-summary.md +++ b/.omo/evidence/20260725-legal-coverage-clean-stack/qa-summary.md @@ -17,8 +17,9 @@ - Clean stacked branch: `codex/legal-coverage-v1-clean`. - Base: updated `codex/legal-runtime-e2e` at `54b110b2`. -- Head after QA: `f9c8812ab1bcfea4685fe76dc3e4b75667dc3ebf`. -- Full compiled test suite: 191 passed, 0 failed. +- Head after QA: `a24b96b5520ffbd65884dcba57b92d23bf3b273a`. +- Full compiled test suite (`npm test`): 216 passed, 0 failed, 0 skipped, + 0 cancelled; exit code 0 (29.2 seconds). - Build: passed. - Whitespace check: passed. - No untracked or modified files remain in the clean worktree.