diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f889f3d..2b5d28e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,11 +22,8 @@ jobs: - name: Install run: npm ci - - name: Build - run: npm run build - - - name: Typecheck - run: npm run typecheck + - name: Test + run: npm test - name: Pack dry run run: npm run pack:check diff --git a/PRD.md b/PRD.md index 33e12d45..4c68cf89 100644 --- a/PRD.md +++ b/PRD.md @@ -31,6 +31,11 @@ LongTable의 차별점은 "더 많은 자동화"가 아니라 다음 세 가지 - `researcher profile aware governance` - `traceable research state` +제품 경계는 **control-plane-first hybrid**다. 외부 provider와 학술 서비스는 +검색·독해·추출·작성·렌더링 엔진으로 교체 가능해야 한다. LongTable은 +Research Brief, 인간 판단이 필요한 시점, 결정 ledger, 접근권·provenance, +claim-evidence 연결, visual contract, package verification을 소유한다. + ## Target Users ### Primary @@ -68,6 +73,30 @@ LongTable의 차별점은 "더 많은 자동화"가 아니라 다음 세 가지 ## Key Capabilities +### LongTable Research Runtime + +LongTable consolidates scholarly discovery, legal full-text recovery, evidence +extraction, venue-pattern analysis, and verified visual production into one +durable CLI workflow. Its stages, evidence gates, and evaluation/removal rule +are defined in +[`docs/LONGTABLE-RESEARCH-RUNTIME.md`](docs/LONGTABLE-RESEARCH-RUNTIME.md). + +The runtime optimizes for human-equivalent evidence decisions, not imitation of +human prose. Legitimate disagreement is preserved, and hard quality or access +failures cannot be traded for speed. + +The public product has exactly two provider skills: + +- `$longtable` produces a versioned Research Brief after at most three + clarifying questions. +- `$longtable-research` produces a Verified Research Package and consumes the + brief without repeating the interview. + +Research Assurance is internal infrastructure, not a third skill. Provider +agents perform bounded work; Assurance owns risk-lens selection, normalized +evidence and disagreement, and human records at scope, access/corpus, +claim-strength, visual-evidence, and external-action boundaries. + ### 1. Researcher Profile - quick setup + progressive calibration @@ -94,6 +123,8 @@ LongTable의 차별점은 "더 많은 자동화"가 아니라 다음 세 가지 - Claude adapter - Codex adapter - future web app adapter +- MCP `input_required`와 provider-native 질문 UI는 transport이며, + Researcher Checkpoint의 의미·내구성·재개 상태는 LongTable이 소유한다. ## Product Modes @@ -134,6 +165,9 @@ LongTable의 차별점은 "더 많은 자동화"가 아니라 다음 세 가지 - checkpoint 수용률과 override 필요성 감소 - state restoration 성공률 - 사용자 보고 신뢰도 상승 +- accepted artifact까지의 elapsed time과 active human time 감소 +- 반복 질문, interruption, context switch, rework, abandonment 감소 +- prospective E2E에서 marginal value가 없는 LongTable Research stage 제거 ## Immediate Product Decision diff --git a/README.md b/README.md index 65093af8..c44de3c5 100644 --- a/README.md +++ b/README.md @@ -1,279 +1,200 @@ # LongTable -LongTable is a researcher-centered workspace for long scholarly projects. It -helps a researcher keep research questions, construct boundaries, method -choices, evidence standards, authorship judgments, and open tensions traceable -across AI-assisted sessions. - -LongTable is not a chatbot replacement and not a prompt collection. It is a -small research workspace system that runs through a CLI, provider skills, -optional MCP state and checkpoint transport, and panel collaboration records. - -LongTable also exposes a narrow Codex hard-stop guard: unresolved Research -Specification blockers can block session stop until the researcher decides, -clears, or explicitly defers them. Product, setup, documentation, release, and -hook-engineering prompts are not hard-stop blockers by default. - -Codex, Claude Code, and other providers remain the execution environments. -LongTable provides the durable research state, generated provider skills, -Researcher Checkpoints, and command-line tools that keep important decisions -from disappearing into chat history. - -## Core Workflow - -Use one shell setup step and one provider-native research-start step. - -```bash -npm install -g @longtable/cli -longtable setup --provider codex -cd "" -codex -``` +**The research control plane for Codex and Claude.** -Inside the provider, start the project with: +AI agents can search, read, draft, and render. LongTable keeps the research +contract human-owned: material research commitments, evidence provenance, +lawful-access boundaries, visual contracts, and completion gates survive beyond +one provider UI or chat session. -```text -$longtable-start -``` +> **Transient UI. Durable research decisions.** +> +> A Researcher Checkpoint is persisted before LongTable opens a question +> surface. Modern MCP form elicitation returns `input_required` instead of +> holding a 60-second push request open. If a client cancels, disconnects, or +> falls back to another surface, the same pending `QuestionRecord` remains +> resumable; an accepted answer appends one linked `DecisionRecord`. -Run pressure interviews with the LongTable grilling surface: +![LongTable Research evidence workbench](assets/longtable-research-hero.png) -```text -$longtable-interview -``` +LongTable gives researchers two public skills: -Required routing: +- `$longtable` diagnoses the research problem, asks no more than three clarifying questions, and produces a versioned Research Brief. +- `$longtable-research` turns that brief into a lawful, traceable, human-verified research package. -- `$longtable-start` is the research-start surface. -- `$longtable-interview` is the grilling interview surface. -- It does not require a Research Specification before asking the next pressure - question. -- It frames each turn as `Tension:` followed by one `Pressure question:` and - waits for a direct answer. -- If only a First Research Shape exists, LongTable continues through - `$longtable-start` until a Research Specification is created or explicitly - kept open. +LongTable is a **control-plane-first hybrid**: -`longtable start` remains a shell fallback for scripts and automation. It is not -the main research-start experience. +- Provider agents and scholarly services remain replaceable engines for + discovery, reading, extraction, drafting, and rendering. +- LongTable owns when human judgment is required and how that judgment remains + linked to evidence and later work. +- Its lawful reference runtime supplies an end-to-end path to a Verified + Research Package without making one model or search provider canonical. -## What Gets Created +**Discovery and rendering engines are adapters. Research commitments are not.** -A LongTable workspace lives in the research project folder: +> `0.2.0-beta.1` is published on npm under the `next` tag. The former `scholar-research`, `panel`, and expanded skill surfaces remain compatibility routes for one beta release; they are not the product’s primary interface. -```text -/ - AGENTS.md - CURRENT.md - .longtable/ - project.json - current-session.json - state.json - sessions/ +## Install + +```sh +npm install -g @longtable/cli@next +longtable setup ``` -- `AGENTS.md`: provider runtime guidance for this research workspace -- `CURRENT.md`: human-readable current state, regenerated from LongTable state -- `.longtable/project.json`: stable project identity -- `.longtable/current-session.json`: current session cursor -- `.longtable/state.json`: layered memory, questions, decisions, interview - turns, evidence records, First Research Shape, and Research Specification -- `.longtable/sessions/`: session snapshots +`longtable setup` installs exactly two provider skills. It removes obsolete LongTable start, interview, panel, and per-role skill folders from the selected Codex or Claude skill directory. -## Start vs Interview +Start in Codex or Claude: -`$longtable-start` asks open, natural-language questions. It should not begin -with a questionnaire, reader/reviewer contribution framing, or fixed -theory/method/measurement categories. It asks one question at a time, records -turns when MCP/state tools are available, and creates the durable Research -Specification when the interview has enough material. +```text +$longtable Help me sharpen this research problem and decide what evidence I need. +``` -The First Research Shape is only a short handle for resuming early work. The -substantive artifact is the Research Specification. It should preserve scope, -construct ontology, theory framing, coding and measurement rules, method -options, evidence and access requirements, epistemic alignment, protected -decisions, open questions, next actions, and confidence. +When the Research Brief is ready, choose: -`$longtable-interview` is a relentless sharpening interview for a research plan, -argument, method, manuscript, evidence standard, or product decision. It inspects -`CURRENT.md`, `.longtable/`, supplied documents, and cited evidence first when -those can answer the question. Then it names `Tension:` and asks one -`Pressure question:`. +```text +Start LongTable Research +Keep shaping +Save and stop +``` -The interview continues only while the next question can produce a new decision, -sharper boundary, stronger evidence standard, or clearer open tension. It stops -when remaining questions repeat the same tension without producing a new -decision. `$longtable-interview` is the only packaged pressure-interview skill; -legacy `$critical-interview` skill folders are removed during skill install. +The execution skill consumes the existing brief without repeating the interview: -Multiple interviews append to the same workspace state. A later interview may -propose or apply a Research Specification patch, append a DecisionRecord, or -record an open tension. It should not silently overwrite conflicting research -commitments. +```text +$longtable-research Explore the target journal, collect the lawful corpus, +build the evidence ledger, and produce the verified tables and figures. +``` -## Question UI +## What “verified” means -LongTable owns the question semantics. Providers own presentation. +A run cannot report `completed` until it creates and reads back a Verified Research Package containing: -The durable lifecycle is: +- Research Brief in versioned JSON and human-readable Markdown +- target-journal topic and format profile with provenance and human acceptance +- search and full-text corpus manifests +- bounded extraction, evidence spans, claim ledger, synthesis, and disagreement records +- human-reviewed editable tables, figures, and diagrams with data and hashes +- Research Assurance records at the five material boundaries +- limitations, reproduction command, machine manifest, human README, and passing verification report -```text -Researcher Checkpoint -> QuestionRecord -> DecisionRecord +```sh +longtable research verify --cwd "" --run-id "" --json ``` -Supported transports: - -- MCP/native structured elicitation when the provider exposes it -- terminal selector when the CLI has interactive TTY input and output -- numbered/plain-text fallback everywhere else - -Tmux is not required for LongTable core behavior. If a future Codex terminal -popup borrows an OMX-style tmux renderer, it must be documented as an optional -Codex transport that requires an attached tmux session and falls back to the -standard LongTable question path. - -## Common Commands - -```bash -longtable setup --provider codex -longtable doctor -longtable status --cwd "" -longtable resume --cwd "" -longtable roles -longtable question --prompt "" -longtable decide --question --answer -longtable spec read --cwd "" -longtable search --query "" -longtable panel --prompt "review this measurement plan" --json -``` +Local edits, provider output, a reachable URL, and a polished figure are not treated as proof of completion. -Provider skill installation is explicit: +## Research Assurance -```bash -longtable codex install-skills -longtable claude install-skills -longtable mcp install --provider all -``` +Research Assurance is the successor to the old Panel concept. Codex and Claude already know how to call multiple agents; LongTable does not compete with that orchestration. -## Panel Collaboration +Instead, Assurance selects research-risk lenses, normalizes evidence and disagreement, and records the human decision at five boundaries: -Panel is LongTable's public collaboration surface. Use it when role disagreement -matters before a research commitment becomes project memory. +1. scope +2. access and corpus +3. evidence direction or claim strength +4. visual evidence +5. external action -```text -$longtable: run a panel review of this measurement plan, keep disagreement -visible, and ask me what decision should be recorded before revising it. +It interrupts only on a hard failure or unresolved material conflict. The canonical diagnostic CLI is `longtable assure`; `longtable panel` is a one-release compatibility alias. Existing v1 `PanelPlan` and `PanelResult` records remain readable. -lt panel: show theory, methods, measurement, reviewer, and voice disagreements -before I commit this argument. -``` +## Durable MCP checkpoints -Scriptable equivalents: +`@longtable/mcp` uses the MCP 2026 multi-round-trip `input_required` flow while +serving 2025 stdio clients through the SDK compatibility shim. -```bash -longtable panel --prompt "review this measurement plan" --role editor,measurement_auditor --json -longtable panel --visibility always_visible --prompt "keep unresolved disagreement visible" --json -longtable ask --prompt "lt debate: debate this study design before I commit" --json -``` +- The first tool call persists the checkpoint and returns immediately for a + modern client. +- The accepted response re-enters the same handler and resolves the same + question ID. +- Caller-supplied idempotency keys prevent duplicate questions and decisions. +- Transport attempts are append-only, so presentation, explicit refusal, and + eventual acceptance remain auditable. +- Decline, cancel, disconnect, and fallback do not silently become research + decisions. -Team-style requests route through panel. Explicit debate-language requests -write panel debate records under `.longtable/panel/`. LongTable team execution -is disabled for new work, and `.longtable/team/` is historical state only. +The legacy shim uses a 24-hour per-round watchdog by default, configurable with +`LONGTABLE_MCP_LEGACY_ELICITATION_TIMEOUT_MS`. A host may still cancel its own +UI or process; LongTable's guarantee is durable recovery of the checkpoint, not +control over every provider UI lifecycle. -## Journal-Grounded Reviewer and Venue Fit +## Lawful full-text access -`longtable-reviewer` is the compact surface for peer-review and editor-style -feedback. It keeps reviewer objections, Journal Editor fit judgment, Venue -Strategist tradeoffs, and scholar-research evidence in one evidence-backed -workflow instead of splitting journal feedback into a separate visible skill. +LongTable supports four declared routes: -If a target journal is named, LongTable should ground reviewer feedback in a -Journal Profile: aims/scope, author guidance, recent article patterns, and -article type expectations. If reference papers are available or recoverable, -LongTable should build a Reference Pattern Matrix covering decision structure, -paper flow, standardized terminology, Figure/Table conventions, and APA 7 -style expectations. +- public OA and repositories +- PDFs lawfully supplied by the researcher +- explicitly licensed text-and-data-mining access +- manual institutional-browser handoff -Useful command surfaces: +The manual route asks the researcher to download a lawful copy and provide a local directory. LongTable never requests or stores browser cookies, passwords, tokens, or institutional credentials. Paywall, WAF, robots, VPN, proxy, and login-control bypass are prohibited. Access failures remain typed outcomes such as `restricted_access`, `robots_or_terms_blocked`, `manual_handoff_required`, and `parse_failed`. -```bash -longtable review --role reviewer --prompt "Evaluate this manuscript positioning for the target journal." -longtable panel --role reviewer,measurement_auditor --prompt "Compare journal fit, reviewer risk, and measurement evidence." -longtable search --intent venue --query "" -longtable scholar-research scaffold --cwd "" --json -``` +## Professional figures and tables -Treat journal fit as a risk rubric, not an acceptance prediction. A useful -reviewer pass separates editor-facing contribution claim, reviewer objection, -venue-strategy tradeoff, evidence gap, revision action, and any Researcher -Checkpoint needed before changing venue positioning. +Research visuals are evidence artifacts, not image-generation prompts. -## Researcher Checkpoints +- A table is used for exact lookup, auditability, and dense comparison. +- A figure is used for patterns, distributions, uncertainty, and comparisons. +- A diagram is used for mechanisms, workflow, architecture, or performed-versus-proposed boundaries. +- Every visual begins with an analytical question, claim link, denominator or unit of analysis, evidence boundary, target-journal grammar, and reader test. +- Research visuals must have deterministic, editable sources and data/provenance hashes. +- Mechanical QA is followed by human domain, statistical, journal-fit, and comprehension review. -LongTable asks and waits when a research commitment is about to become settled: +Image generation may be used for clearly labeled brand or concept artwork such as the README banner above. It must not fabricate evidence-bearing research figures or tables. -- research question or scope -- theory frame or construct map -- measurement, coding, or extraction standard -- method design or analysis strategy -- evidence access or citation standard -- authorship, voice, submission, or public-sharing decision +## CLI automation -For low-risk, reversible, or presentational choices, LongTable should proceed -with explicit assumptions instead of interrupting. +The normal path is conversational, but every durable boundary is scriptable: -## Development +```sh +# Start from a question; LongTable writes a Research Brief automatically. +longtable research run \ + --query "How is AI adoption validated in workplace learning research?" \ + --target-journal "Human Resource Development Quarterly" \ + --cwd . -```bash -npm ci -npm run build -npm run test -npm run release:check -``` +# Or consume a Research Brief created by $longtable. +longtable research run --brief research-brief.json --cwd . -Useful smoke checks: +# Resume after a lawful manual PDF handoff. +longtable research resume \ + --pdf-dir ./lawful-pdfs \ + --pdf-access manual_legitimate_access \ + --cwd . -```bash -npm run smoke:setup -npm run smoke:checkpoints -npm run smoke:hooks -npm run smoke:research-spec-audit -npm run smoke:question-audit -npm run pack:check +# Record the human-reviewed target-journal evidence profile. +longtable research record-journal-profile \ + --profile target-journal-profile.json \ + --cwd . + +longtable research status --cwd . --json +longtable research verify --cwd . --json ``` -## Release +The previous `longtable scholar-research` command remains a deprecated alias for the beta compatibility window. -LongTable packages are version-aligned under `@longtable/*`. +## Productivity gate -Before publishing: +More workflow is not automatically better. A stage remains default only when prospective conversational crossover tests show that it helps researchers. -```bash -npm run release:check -git diff --check -``` +Promotion beyond beta requires at least three complete matched E2E pairs, zero hard-gate failures, non-worse time to an accepted package, and improvement in at least one human burden or trust measure. Repetitive questions, interruptions, context switching, rework, and abandonment are measured. Stages that add friction without marginal value are removed or made opt-in. -After publishing, verify the registry: +## Packages -```bash -npm view @longtable/cli version dist-tags --json -npm view @longtable/mcp version dist-tags --json -npm install -g @longtable/cli@ -longtable --help -``` +- `@longtable/cli` — setup, diagnostics, state, Assurance, and research CLI +- `@longtable/research` — canonical journal-to-artifact runtime +- `@longtable/core`, `@longtable/memory`, `@longtable/checkpoints` — provider-neutral contracts +- `@longtable/provider-codex`, `@longtable/provider-claude` — two-skill provider adapters +- `@longtable/scholar-research` — one-release compatibility wrapper -Actual npm publish and GitHub release creation require explicit release -authority. Local implementation and verification can be completed before that -external step. +See [the runtime contract](docs/LONGTABLE-RESEARCH-RUNTIME.md), [the command surface](docs/LONGTABLE-COMMAND-SURFACE.md), and [the specification](Spec.md). -## Codex Stop hard-stop +## Development -LongTable Codex hooks now expose a narrow hard-stop verdict. `Stop` blocks only -Research Specification-affecting pending questions or obligations, while -product/tooling/docs/release work remains non-blocking unless explicitly marked -as a hard-stop research decision. `PostToolUse` stays quiet for no-op Bash and no -longer hard-blocks unrelated nonzero Bash failures. +```sh +npm install +npm run build +npm test +npm run release:check +``` -Inspect the current verdict with `longtable codex hook-doctor --json` or -`longtable doctor --json`; both report hook coverage/trust, `stopWouldBlock`, -`activeBlockers`, stale pending-question counts, and next actions. +LongTable records research state under `.longtable/`. Historical run directories and v1 record schemas are preserved; the 0.2 beta adds canonical names and stronger completion gates without rewriting prior evidence. diff --git a/Spec.md b/Spec.md index bbf261b4..39510609 100644 --- a/Spec.md +++ b/Spec.md @@ -10,6 +10,8 @@ Repository framing and migration boundary are defined in `README.md`, `RELATIONS Active supporting docs are: +- `docs/LONGTABLE-RESEARCH-RUNTIME.md` +- `docs/LONGTABLE-RESEARCH-NAMING-MIGRATION.md` - `docs/ARCHITECTURE.md` - `docs/ACTIVE-DECISIONS.md` - `docs/CHECKPOINTING.md` @@ -34,11 +36,29 @@ Historical exploration and release notes may be moved under `docs/archive/`. 2. `.claude`와 `.codex`는 원본이 아니라 adapter surface다. 3. checkpoint는 agent prerequisite가 아니라 research commitment semantics에 묶는다. 4. researcher profile은 setup에서 시작하고 운영 중 보정한다. -5. Codex는 native AskUserQuestion에 의존하지 않고 numbered choice protocol로 처리한다. +5. Codex의 AskUserQuestion 및 MCP form elicitation은 provider transport로 + 사용할 수 있지만 제품 계약은 아니다. `.longtable`의 + `QuestionRecord -> DecisionRecord` lifecycle이 checkpoint 의미, + idempotency, transport history, 재개 상태를 소유하며 numbered choice는 + 호환 fallback으로 유지한다. 6. researcher-facing execution and supporting packages should use the `longtable` identifier consistently. 7. question semantics are provider-neutral, but provider adapters may use the strongest native question surface available. -8. Panel orchestration is a first-class LongTable interaction pattern, but it is not OMX-style team orchestration. -9. Panel discussion must be inspectable through structured role outputs and invocation records, not raw hidden reasoning logs. +8. Research Assurance is the public name for the former Panel harness; provider-native agents execute work while LongTable normalizes evidence, disagreement, and human decisions. +9. Existing `PanelPlan` and `PanelResult` v1 records remain compatibility schemas and must not be rewritten. +10. Scholar research uses one versioned `ResearchBundle`, append-only events, + and an idempotent `run`/`resume` CLI; provider models can only propose + schema-valid patches. +11. LongTable Research quality gates are non-compensatory, and workflow stages + may become default only after prospective conversational E2E evidence. +12. Provider installation exposes exactly `$longtable` and + `$longtable-research`; deprecated full/role/start/interview/panel surfaces + must not create additional installed skills. +13. A workflow may report `completed` only after a Verified Research Package + manifest and verification report pass durable readback. +14. MCP 2026 transports return `input_required` instead of holding a + server-to-client elicitation request open. Client cancellation or timeout + never becomes a research decision; the same pending checkpoint remains + resumable. ## Proposed Repository Shape @@ -101,6 +121,13 @@ Current code-level scaffolds now cover: - `ResearchState` - `CheckpointPolicy` - `DecisionRecord` +- `ResearchBundle` +- `ScholarResearchEvent` +- `EvidenceSpan` +- `CitationSlot` +- `ProviderTaskPacket` +- `VisualEvidenceContract` +- `AdjudicationRecord` - `ArtifactRecord` - `InteractionMode` - `InvocationIntent` diff --git a/assets/longtable-research-hero.png b/assets/longtable-research-hero.png new file mode 100644 index 00000000..a36936af Binary files /dev/null and b/assets/longtable-research-hero.png differ diff --git a/compat/npm-research-0.1.72/README.md b/compat/npm-research-0.1.72/README.md new file mode 100644 index 00000000..552bfe46 --- /dev/null +++ b/compat/npm-research-0.1.72/README.md @@ -0,0 +1,6 @@ +# @longtable/research 0.1.72 compatibility facade + +This stable-channel package re-exports `@longtable/scholar-research@0.1.72`. + +Use `@longtable/research@next` for the LongTable Research 0.2 beta. The facade +exists so npm's required `latest` tag does not point to a prerelease. diff --git a/compat/npm-research-0.1.72/index.d.ts b/compat/npm-research-0.1.72/index.d.ts new file mode 100644 index 00000000..04943693 --- /dev/null +++ b/compat/npm-research-0.1.72/index.d.ts @@ -0,0 +1 @@ +export * from "@longtable/scholar-research"; diff --git a/compat/npm-research-0.1.72/index.js b/compat/npm-research-0.1.72/index.js new file mode 100644 index 00000000..04943693 --- /dev/null +++ b/compat/npm-research-0.1.72/index.js @@ -0,0 +1 @@ +export * from "@longtable/scholar-research"; diff --git a/compat/npm-research-0.1.72/package.json b/compat/npm-research-0.1.72/package.json new file mode 100644 index 00000000..8f7c11c7 --- /dev/null +++ b/compat/npm-research-0.1.72/package.json @@ -0,0 +1,27 @@ +{ + "name": "@longtable/research", + "version": "0.1.72", + "description": "Stable compatibility facade for the pre-0.2 LongTable scholar research runtime", + "type": "module", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.js", + "default": "./index.js" + } + }, + "files": [ + "index.js", + "index.d.ts", + "README.md" + ], + "dependencies": { + "@longtable/scholar-research": "0.1.72" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT" +} diff --git a/docs/LONGTABLE-COMMAND-SURFACE.md b/docs/LONGTABLE-COMMAND-SURFACE.md index 231bebac..34e572d2 100644 --- a/docs/LONGTABLE-COMMAND-SURFACE.md +++ b/docs/LONGTABLE-COMMAND-SURFACE.md @@ -5,9 +5,9 @@ LongTable should expose a small researcher-facing workflow: 1. `longtable setup` -2. `$longtable-start` -3. `$longtable-interview` for grilling-style pressure interviews -4. natural in-session directives such as `lt explore:`, `lt review:`, and +2. `$longtable` for workspace, start/interview, governance, role, and panel routing +3. `$longtable-research` for the durable scholarly-evidence workflow +4. natural in-session directives through `$longtable`, such as `lt explore:`, `lt review:`, and `lt panel:` The CLI remains available for setup, diagnostics, scripted workspace creation, @@ -16,6 +16,43 @@ inside the provider runtime. ## Primary Surfaces +### `longtable research run|resume|validate-visual-contract` + +Recovery and audit subcommands include provider patch recording, independent +extraction and synthesis verification, human citation and synthesis review, +read-only Visual Evidence Contract validation, allowlisted rendering, final +human render review, Paper B replay, and evaluation observation/report +recording. `trial-create|start|finish|status` provides a resumable prospective +crossover runner. These commands are intentionally not the default +conversational journey. + +`register-visual-portfolio` freezes a manuscript-level set of Figure, Table, +and Diagram contracts. No visual-contract or final-render stage completes until +all declared items pass their respective human gates. + +Durable scholarly-research automation: + +- `run` creates a versioned `ResearchBundle` and append-only event log +- `resume` continues at the first non-terminal stage without repeating an + unchanged completed stage +- `status` reads the durable bundle, `explain` scopes state and events to one + stage, and `verify` independently reads hashes, provenance, storage + isolation, visual QA, portfolio completion, and handoff evidence +- the stage order and five allowed blocking event classes are defined in + `LONGTABLE-RESEARCH-RUNTIME.md` +- stage-level commands are diagnostic/recovery surfaces, not the normal journey +- legal access and strict evidence depth remain visible; metadata or abstract + records never become full-text verified +- collected public-OA HTTPS routes are acquired automatically unless + `--no-auto-oa` is set; redirects, network destination, byte limits, PDF + signature, hash, and parser result are validated without credentials +- prospective trials freeze question, journal, model/version, corpus cutoff, + permissions, and stage profiles before execution +- legal access, source-version provenance, and quality hard gates are protected + controls, never ablation candidates +- no default stage may be removed before three complete matched crossover pairs + with human effort and trust ratings + ### `longtable setup` Permission and runtime setup: @@ -28,7 +65,7 @@ Permission and runtime setup: `longtable init` remains a deprecated compatibility alias. -### `$longtable-start` +### `$longtable` start route Provider-native research start: @@ -44,7 +81,7 @@ Provider-native research start: - use structured option UI only for final specification confirmation, short-handle stop points, or true checkpoint boundaries -### `$longtable-interview` +### `$longtable` pressure-interview route LongTable grilling interview: @@ -70,7 +107,7 @@ Automation fallback: - can create a workspace from explicit flags - should not be presented as the primary research-start experience -### `longtable panel` +### `longtable assure` (Research Assurance) Structured multi-role review: @@ -94,23 +131,23 @@ Structured multi-role review: Examples: ```bash -longtable panel --prompt "review this methods section" --json -longtable panel --prompt "review this measurement plan" --role editor,measurement_auditor --json -longtable panel --provider codex --native-workers --wait 30000 --prompt "review this methods section" --json -longtable panel --provider codex --native-subagents --prompt "legacy native subagent request" --json -longtable panel --visibility always_visible --prompt "keep unresolved disagreement visible" --json +longtable assure --prompt "review this methods section" --json +longtable assure --prompt "review this measurement plan" --role editor,measurement_auditor --json +longtable assure --provider codex --native-workers --wait 30000 --prompt "review this methods section" --json +longtable assure --provider codex --native-subagents --prompt "legacy native subagent request" --json +longtable assure --visibility always_visible --prompt "keep unresolved disagreement visible" --json ``` When a native worker run reaches a terminal `completed` or `blocked` state -through `longtable panel --native-workers --wait`, `longtable panel status ---wait`, or `longtable panel resume --wait`, LongTable records the normalized +through `longtable assure --native-workers --wait`, `longtable assure status +--wait`, or `longtable assure resume --wait`, LongTable records the normalized `PanelResult` into workspace evidence without collapsing blocked role outputs into completion. When a provider or external worker returns a result file outside that lifecycle, record the structured result before asking LongTable for a handoff or Research Specification patch: ```bash -longtable panel record --invocation --result-file panel-result.json +longtable assure record --invocation --result-file panel-result.json ``` Native worker-produced files live inside each worker git worktree under @@ -119,7 +156,7 @@ summaries, claims, objections, open questions, and evidence references before aggregation. They must not contain hidden reasoning, raw tool traces, or tmux logs. -Team-style requests route through panel. Explicit debate-language requests route +Team-style requests route through Research Assurance. Explicit debate-language requests route to panel debate records under `.longtable/panel/`. `longtable team` is not a public command surface. Historical `.longtable/team/` records remain readable only as older workspace state. @@ -191,8 +228,12 @@ longtable question --prompt "" longtable decide --question --answer longtable spec read --cwd "" longtable search --query "" -longtable panel --prompt "" -longtable panel record --invocation --result-file panel-result.json +longtable research run --query "" --allow-partial \ + --pdf-dir "" --pdf-access manual_legitimate_access +longtable research resume --run-id "" \ + --pdf-dir "" --pdf-access private +longtable assure --prompt "" +longtable assure record --invocation --result-file panel-result.json longtable handoff --cwd "" longtable codex install-skills longtable claude install-skills diff --git a/docs/LONGTABLE-RESEARCH-NAMING-MIGRATION.md b/docs/LONGTABLE-RESEARCH-NAMING-MIGRATION.md new file mode 100644 index 00000000..f36a0933 --- /dev/null +++ b/docs/LONGTABLE-RESEARCH-NAMING-MIGRATION.md @@ -0,0 +1,64 @@ +# LongTable Research Naming Migration + +Status: adopted for `0.2.0-beta.1`. + +## Problem + +`scholar-research` is understandable but grammatically awkward: a scholar is a +person, while `scholarly research` is the established adjective phrase. The +name also makes the durable end-to-end pipeline sound like a search helper even +though it covers topic and venue discovery, evidence acquisition, extraction, +synthesis, visuals, verification, and handoff. + +## Recommendation + +Use **Research** as the public command concept: + +```text +longtable research run +longtable research resume +longtable research evaluate +``` + +Use **LongTable Research** as the reader-facing capability label. Let +`$longtable` route ordinary conversation and expose `$longtable-research` only +when an explicit skill invocation is useful. + +Use `@longtable/research` as the canonical package. + +## Why This Split + +- `research` is universal, short, and accurately describes the CLI workflow. +- the `longtable` namespace prevents collision with generic shell commands. +- `LongTable Research` covers discovery through verified production. +- `scholarly-research` remains available where academic specificity matters. +- the public name does not expose the implementation's internal role catalog. + +## Rejected Primary Names + +- `scholar-research`: grammatically awkward. +- `academic-research`: familiar but narrower than institutional and + practitioner evidence. +- `research-evidence`: sounds like an output rather than a workflow. +- `evidence-lab`: memorable but product-like and less self-explanatory. +- `research-studio` or `research-workbench`: broad, but weak on evidence + discipline and easily confused with the whole LongTable product. + +## Compatibility Migration + +The beta implements: + +1. add `longtable research` as canonical; +2. keep `longtable scholar-research` as a one-release deprecation alias; +3. generate Codex and Claude skill surfaces from one canonical spec; +4. install only `$longtable-research`; accept `$scholar-research` language as a + one-release compatibility route without leaving a third installed skill; +5. migrate docs and telemetry labels without rewriting historical run records; +6. preserve `schema`, bundle versions, and existing run paths; +7. remove aliases only after doctor reports no active old surfaces. + +Because npm requires a `latest` tag for a new package, the canonical package +publishes a `0.1.72` stable compatibility facade that re-exports the former +runtime. `next` points to `0.2.0-beta.1`; the beta is not the stable default. + +No rename should silently rewrite `.longtable/research-runs/` history. diff --git a/docs/LONGTABLE-RESEARCH-RUNTIME.md b/docs/LONGTABLE-RESEARCH-RUNTIME.md new file mode 100644 index 00000000..406efe9c --- /dev/null +++ b/docs/LONGTABLE-RESEARCH-RUNTIME.md @@ -0,0 +1,307 @@ +# LongTable Research Runtime + +## Decision + +LongTable Research is the consolidated journal-to-artifact workflow behind +`$longtable-research` and `longtable research`. The +researcher-facing skill surface is limited to `$longtable` and +`$longtable-research`; journal editor, venue strategist, methods critic, evidence +verifier, and visual auditor remain internal lenses. + +The canonical automation surface is: + +```text +longtable research run +longtable research resume +longtable research status --run-id +longtable research explain --run-id --stage +longtable research verify --run-id +``` + +`run` automatically attempts bounded public-OA acquisition for collected cards +that explicitly assert legal full-text availability and provide an HTTPS route. +Use `--no-auto-oa` to disable it. Automatic acquisition: + +- sends no credentials, cookies, or institution session state; +- validates every redirect and rejects credential-bearing, non-HTTPS, + non-standard-port, loopback, private, local, and reserved network targets; +- limits redirect count, duration, and bytes; +- requires a PDF signature before parsing; +- stores public bytes only in the global content-addressed cache; +- records requested/final URL, HTTP status, source route, hash, parser output, + and a typed acquisition event; +- continues after individual failures when the acquired corpus remains usable. + +No login, WAF, robots, proxy, VPN, or paywall bypass is attempted. If all +asserted OA routes fail, the run stops at the access/corpus checkpoint and +preserves the distinction between `restricted_access`, `download_failed`, and +`parse_failed`. + +`status` reads the durable bundle. `explain` returns one stage's state and +append-only events. `verify` performs independent file readback over event +continuity, completed-stage hashes, full-text byte hashes, derived provenance, +licensed/private storage isolation, strict filled citation slots, visual +contracts, editable-source hashes, mechanical QA, portfolio completeness, and +the final handoff. A paused run may pass its materialized checks with an +explicit incompleteness warning; only a completed run is required to prove the +entire stage set and handoff. + +Stage subcommands may exist for diagnosis and recovery, but are not the normal +user journey. + +Before implementation, a proposed or approved visual contract can be checked +without mutating the run: + +```text +longtable research validate-visual-contract --contract --json +``` + +The human decision is then recorded as a durable workflow event: + +```text +longtable research record-visual-review \ + --contract --review +``` + +For a manuscript that requires multiple visuals, freeze the complete contract +set before any individual approval: + +```text +longtable research register-visual-portfolio --plan +``` + +The version-1 portfolio plan declares one run, one portfolio ID, and at least +two unique Visual Evidence Contract IDs. Registering a different plan under the +same run is rejected. The `visual_contract` stage completes only after every +declared contract is human-approved. Each contract is then rendered with +`render-visual`; `verify` and `handoff` complete only after every declared +Figure, Table, or Diagram has an accepted four-lens and timed-reader review. +This prevents one accepted figure from standing in for manuscript-level visual +verification. + +Approval requires all four human checks to pass. Rejection keeps the workflow +at the same checkpoint with the rationale intact; approval advances only to the +renderer boundary. + +Approved contracts are implemented and reviewed through: + +```text +longtable research render-visual \ + --contract-id --request +longtable research record-render-review --review +``` + +The initial allowlist contains only `longtable-svg-v1`. It renders editable, +grayscale-safe SVG for exact-lookup tables, bar/line/point figures, and +performed-versus-proposed diagrams. Every output is bound to the exact data +snapshot, request, and editable-source hashes. The renderer also writes a +placement manifest and mechanical QA report. Mechanical success advances only +to `awaiting_human`; it never substitutes for domain, statistical, journal, or +timed-reader review. + +## Durable Workflow + +The versioned stage order is: + +```text +scope -> topic -> venue -> collect -> fulltext -> extract -> synthesize + -> visual_contract -> implement -> render -> verify -> handoff +``` + +Each run owns a `ResearchBundle`, an append-only event log, and a derived state +snapshot under `.longtable/research-runs//`. The CLI is the only +canonical mutator. A provider receives a bounded task packet and may return only +a schema-valid proposed patch. + +`run` and `resume` are idempotent. A completed stage is not repeated when its +input hash is unchanged. Changed input creates a new event and invalidates +dependent derived state rather than silently overwriting provenance. + +## Responsibility Boundary + +Deterministic CLI responsibilities: + +- network policy, concurrency, source rate limits, retries, and circuit breakers +- hashes, cache keys, version linkage, and deduplication +- PDF parsing, OCR routing, and stable locators +- schema validation, event append, state derivation, and checkpoint enforcement +- allowlisted rendering plus mechanical visual checks + +Model responsibilities: + +- query expansion and relevance judgment +- claim-evidence interpretation +- venue and journal-pattern synthesis +- visual evidence contract proposals +- bounded narrative synthesis + +Metadata, an abstract, or a discovered PDF URL never counts as full-text +verification. + +## Researcher Checkpoints + +Scholar research adds only five blocking event classes: + +1. Scope Contract or an amendment to it, including target-venue selection +2. access or corpus boundary +3. evidence that changes direction or claim strength +4. Visual Evidence Contract +5. external upload, publication, or submission + +Theory, method, measurement, and analysis changes are instances of class 3, +not additional checkpoint types. Routine records use adaptive stratified +sampling; final claims, final visuals, journal exemplars, conflicts, OCR or +low-confidence evidence, ambiguous versions, and access ambiguity receive +complete human review. + +## Strict Evidence Contract + +A citation slot is `filled` only when it includes the claim, an evidence span, a +stable locator, source version or content hash, access class, and the relation +between evidence and slot. Conflicting evidence remains represented. + +Synthesis is also structured rather than accepted as unconstrained prose. Each +claim has a stable ID, role, reading order, and one or more reviewed citation +slot IDs. A different provider verifies every proposed claim, then a human +accepts, rejects, or preserves disagreement before `synthesize` completes. + +A visual specimen also requires the page render, bounding box, caption, and +surrounding context. Unsupported final claims, wrong source/version/citation, +visual distortion, prohibited access, or mutation before required approval are +non-compensatory failures. + +## Visual Contract + +Target-journal examples are used to derive a `VisualGrammar`, not copied as +artwork. A human approves a declarative `VisualEvidenceContract`; an allowlisted +renderer then produces editable source, a data snapshot, environment/version +metadata, journal-sized exports, and a placement manifest. Word or LaTeX +placement happens only after approval. + +### Human-readable professional visual principles + +The Paper B replay establishes a reusable visual decision standard: + +1. **Necessity before polish.** Every main-text visual answers one analytical + question and maps to a manuscript claim. Audit completeness alone is a + supplement function. +2. **Scientific unit before glyph.** Independent reports, estimands, repeated + conditions, denominators, uncertainty, and validation scope remain visibly + distinct. Heterogeneous estimands are never joined into one range or rank. +3. **Performed and proposed are separate layers.** A methods diagram must not + present a normative claim-control model as a procedure that was performed. +4. **One main-text reading path.** A reader should recover the question, + denominator, key value or relation, and conclusion in 10–20 seconds. +5. **Direct labels over legend travel.** Redundant markers, icons, colors, and + keys are removed unless each encoding adds non-duplicated scientific + meaning. Grayscale and print accessibility are defaults. +6. **Honest scale and uncertainty.** Axes do not manufacture contrast; observed + ranges are not styled as confidence intervals; small denominators are + printed and never allowed to look stronger than large denominators. +7. **Evidence hierarchy before performance ranking.** Rows are organized by + relevance and validation scope, then stable identifiers such as + author–year—not by favorable outcome magnitude. +8. **Main text and supplement have different jobs.** The main text supports + interpretation; the supplement preserves every study, condition, interval, + identifier, and locator needed for audit. +9. **Placement is part of meaning.** The visual appears near its first + interpretive use, with a short lead-in stating the question, pattern to read, + and prohibited inference. +10. **Journal grammar is evidenced.** Each exemplar includes source/version, + access class, page render, bounding box, caption, and surrounding context. + The grammar is abstracted; artwork is not copied. +11. **Editable and reproducible delivery.** Final outputs preserve editable + vector or table source, exact data snapshot, renderer/version, journal-sized + export, and placement manifest. +12. **Human validation is a hard gate.** Domain meaning, visual/statistical + integrity, journal fit, and actual reader comprehension are separately + checked. A mechanically valid render is not a successful visual. + +An approved contract therefore fails validation unless it declares grayscale +and color-vision safety, a meaningful text alternative, a readable minimum font +size, and distinct plans for domain, statistical, journal, and reader review. +Journal exemplars also fail when their page, bounding box, observed pattern, or +source/version/access provenance is missing. + +Selection rule: + +- use a **table** for exact lookup and multi-field audit; +- use a **figure** for a defensible pattern or comparison; +- use a **diagram** for a process or conceptual relation, with performed and + proposed content visibly separated. + +If a visual does not outperform a short paragraph or compact table on accepted +artifact time, active human effort, comprehension, or error prevention, remove +it from the default workflow. + +## Evaluation And Removal Rule + +Evaluation has three layers: + +1. deterministic fixtures +2. replay, including task `019f56ab-4f4e-7f13-9bc0-8c8d15966e0d` +3. prospective conversational end-to-end trials + +Replay is evidence about reproducibility, not live access or implementation +coverage. Only prospective trials can promote or remove a default workflow. +Quality gates are non-compensatory. Efficiency is evaluated only after quality +passes, using time to accepted artifact, active human time, interruption count, +repeated questions, context switches, rework, abandonment, effort, and trust. +A stage that adds no marginal quality or safety value and harms these measures +must be simplified, made opt-in, or removed. + +The executable evaluation surfaces are: + +```text +longtable research replay-paper-b \ + --source-root --output-dir \ + --exemplar-manifest [--human-review ] +longtable research eval-record \ + --plan-id --observation +longtable research eval-report --plan-id +longtable research trial-create --config +longtable research trial-start \ + --plan-id --trial-id --condition-index <0|1> +longtable research trial-finish \ + --plan-id --trial-id --condition-index <0|1> --result +longtable research trial-status \ + --plan-id --trial-id +``` + +`trial-create` freezes the human-supplied question, target journal, provider, +model/version, corpus cutoff, permissions profile, and two stage profiles. +Condition order is deterministic from a hashed crossover seed. Every profile +automatically retains the legal-access boundary, source-version provenance, +and non-compensatory quality gates; those controls cannot enter the stage +removal analysis. `trial-start` emits a bounded task capsule, and +`trial-finish` converts the measured result into an immutable prospective +observation. Repeated create, start, and finish calls are idempotent. + +Promotion requires at least three complete workflow-on/workflow-off crossover +pairs with effort and trust ratings. Three unpaired observations are not +sufficient. A stage becomes a removal candidate only when its absence produces +no hard-gate loss and its presence adds time or human burden without effort or +trust benefit. + +The Paper B replay recomputes the frozen 148-report Figure 7 values, recovers +the prior human decisions, validates located target-journal exemplars, renders +an editable SVG, and runs mechanical QA. `replayMechanicsPassed` remains +separate from the final `qualityGatePassed`: the latter stays false until the +new render has an explicit human review. Replay never counts as prospective or +holdout evidence. + +The initial calibration suite contains twelve tasks: four pinpoint retrieval, +three open topic/venue discovery, three full-text claim/extraction, and two +visual/manuscript end-to-end tasks. Frozen conditions are repeated three times. +High-impact gold records are independently double-reviewed and adjudicated; +engineering, calibration, and locked holdout sets remain separate. + +## Delivery Sequence + +1. `ResearchBundle`, event log, state derivation, and resume +2. existing scholarly search adapters connected to `collect` +3. legal full-text acquisition, parse/OCR, and locator validation +4. strict extraction and bounded provider task packets +5. visual contract, deterministic rendering, and verification +6. Paper B replay and one prospective end-to-end trial +7. connector expansion only after the workflow survives the productivity gate diff --git a/docs/MCP.md b/docs/MCP.md index 0c4126bd..6a0bacf7 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -73,8 +73,15 @@ longtable mcp install --provider codex --checkpoint-ui strong --write That writes Codex's granular MCP elicitation approval alongside the LongTable MCP server block. Without that approval, `elicit_question` still creates the -same durable `QuestionRecord`, records the transport failure or fallback status, -and returns a numbered fallback. +same durable `QuestionRecord`. The provider can then call `render_question` or +use `longtable question --print` for the numbered fallback surface. + +The current server uses MCP 2026 multi-round-trip `input_required`. A modern +tool call returns after persisting the checkpoint; the client collects the form +response and retries the handler with `inputResponses`. The 2025 stdio +compatibility shim remains enabled with a 24-hour per-round watchdog, adjustable +through `LONGTABLE_MCP_LEGACY_ELICITATION_TIMEOUT_MS`. Client cancellation does +not clear or answer the checkpoint. Provider guidance should route checkpoint UI through MCP first when `elicit_question` is available. The CLI command `longtable question --print` is diff --git a/docs/MIGRATION-MAP.md b/docs/MIGRATION-MAP.md index 4dfb7fc9..b60f6df0 100644 --- a/docs/MIGRATION-MAP.md +++ b/docs/MIGRATION-MAP.md @@ -45,6 +45,10 @@ The following are preserved as design history under `docs/archive/` rather than ## Active Secondary Documents +- `docs/LONGTABLE-RESEARCH-RUNTIME.md` — canonical durable LongTable Research + workflow, evidence contract, visual contract, and productivity removal rule +- `docs/LONGTABLE-RESEARCH-NAMING-MIGRATION.md` — adopted public `research` + command and one-release compatibility migration - `docs/ONBOARDING.md` - `docs/RESEARCHER-PROFILE.md` - `docs/INTERACTION-MODES.md` diff --git a/docs/RESEARCHER-CHECKPOINTS.md b/docs/RESEARCHER-CHECKPOINTS.md index 2b148288..fc9abb8a 100644 --- a/docs/RESEARCHER-CHECKPOINTS.md +++ b/docs/RESEARCHER-CHECKPOINTS.md @@ -60,7 +60,10 @@ LongTable adopts that pattern but narrows it for research: Research risk detected -> Researcher Checkpoint generated -> QuestionRecord written - -> provider renders structured/native/numbered question + -> transport attempt appended + -> provider renders MCP input_required / structured / native / numbered question + -> client interruption leaves the same QuestionRecord pending + -> retry resumes the same question id -> researcher answers -> DecisionRecord appended -> linked invocation/panel/evidence updated @@ -92,6 +95,11 @@ them conservatively. The technical name may stay `QuestionRecord`. The product name should be Researcher Checkpoint. +`transportStatus` remains a compatibility projection of the latest transport +event. `transportAttempts` is the append-only audit trail. An explicit +`idempotencyKey` lets transport retries reuse one checkpoint; accepting the same +response again returns the linked decision rather than appending another. + ## Trigger Policy Ask proactively when one of these conditions is true: @@ -206,5 +214,8 @@ are true: - `other` is visible when `allowOther` is true - the reason for asking is visible - pending questions survive session restart +- transport retries reuse the same question and do not duplicate the decision +- decline, cancel, and fallback remain visible in transport history; a lost + client transport never clears the pending question - the answer becomes a decision record - panel/evidence outputs can link back to the decision diff --git a/docs/RUNTIME-BOUNDARY-AUDIT.md b/docs/RUNTIME-BOUNDARY-AUDIT.md index 683f3e17..2d519af9 100644 --- a/docs/RUNTIME-BOUNDARY-AUDIT.md +++ b/docs/RUNTIME-BOUNDARY-AUDIT.md @@ -36,13 +36,20 @@ force. Treat them as different enforcement layers. | Generated skills | Bias the agent toward LongTable behavior | Override the provider's wrapper logic | | Hook context | Inject short state or warning context | Reliably carry long deliberation or philosophy | | Hook deny/block | Stop selected tool actions or prompt flows | Replace human judgment or provider runtime policy | -| MCP elicitation | Create and render durable checkpoint questions | Guarantee the client accepts native UI | +| MCP `input_required` | Return a durable checkpoint for client-side form fulfillment and retry | Guarantee the client keeps its UI or process alive | | CLI/state gates | Persist `QuestionRecord -> DecisionRecord` and block LongTable commands | Block every possible non-LongTable action | | File-backed artifacts | Preserve panel/interview records for later review | Make the record self-interpreting without quality rules | Therefore, `.md` changes are necessary but not sufficient. Hard or near-hard behavior belongs in hooks, MCP, CLI gates, and state transitions. +Modern MCP elicitation is multi-round-trip: LongTable persists the +`QuestionRecord`, returns `input_required`, and processes the accepted response +when the client retries the tool handler. The server does not hold a 60-second +push request open. Legacy 2025 stdio clients use the SDK shim with a long, +configurable watchdog. In either era, transport failure leaves the research +decision pending and resumable rather than manufacturing an answer. + ## Must-Stop Research Commitments LongTable should ask and stop before acting when the next step would change or diff --git a/docs/release-notes-0.2.0-beta.1.md b/docs/release-notes-0.2.0-beta.1.md new file mode 100644 index 00000000..9244b359 --- /dev/null +++ b/docs/release-notes-0.2.0-beta.1.md @@ -0,0 +1,40 @@ +# LongTable 0.2.0-beta.1 + +LongTable 0.2 beta reduces the provider surface to two skills and introduces a +real completion boundary for journal-grounded research production. + +## Public surface + +- `$longtable` is the research diagnosis and Research Brief front door. +- `$longtable-research` is the journal-to-artifact execution skill. +- `longtable research` and `@longtable/research` are canonical. +- `longtable assure` names the former Panel harness as Research Assurance. +- Deprecated full, start, interview, panel, per-role, and scholar-research + routes remain compatibility inputs for one release but do not install extra + skills. + +## Verified Research Package + +A run cannot report `completed` without: + +- versioned Research Brief JSON and Markdown +- lawful access plan with typed outcomes +- human-accepted target-journal topic and format profile +- search, full-text, evidence, claim, and synthesis artifacts +- human-reviewed editable visual portfolio +- five-boundary Research Assurance record +- limitations, reproduction command, hashes, human README, and passing + verification report + +## Visual boundary + +Research tables, figures, and diagrams are deterministic, editable, and bound +to data and provenance. Image generation is permitted only for clearly labeled +brand or concept artwork and must not fabricate evidence-bearing visuals. + +## Beta promotion rule + +Promotion requires at least three complete matched prospective E2E pairs, zero +hard-gate failures, non-worse time to an accepted package, and improvement in at +least one burden or trust measure. Stages that add friction without marginal +value are removed or made opt-in. diff --git a/package-lock.json b/package-lock.json index 29184479..7702a376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,116 @@ { "name": "longtable-monorepo", - "version": "0.1.72", + "version": "0.2.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "longtable-monorepo", - "version": "0.1.72", + "version": "0.2.0-beta.1", + "license": "MIT", "workspaces": [ "packages/*" ], + "dependencies": { + "accepts": "^2.0.0", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", + "body-parser": "^2.2.2", + "bytes": "^3.1.2", + "call-bind-apply-helpers": "^1.0.2", + "call-bound": "^1.0.4", + "content-disposition": "^1.1.0", + "content-type": "^1.0.5", + "cookie": "^0.7.2", + "cookie-signature": "^1.2.2", + "cors": "^2.8.6", + "cross-spawn": "^7.0.6", + "debug": "^4.4.3", + "depd": "^2.0.0", + "dunder-proto": "^1.0.1", + "ee-first": "^1.1.1", + "encodeurl": "^2.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "eventsource": "^3.0.7", + "eventsource-parser": "^3.0.8", + "express": "^5.2.1", + "express-rate-limit": "^8.5.2", + "fast-deep-equal": "^3.1.3", + "fast-string-truncated-width": "^1.2.1", + "fast-string-width": "^1.1.0", + "fast-uri": "^3.1.2", + "fast-wrap-ansi": "^0.1.6", + "finalhandler": "^2.1.1", + "forwarded": "^0.2.0", + "fresh": "^2.0.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.3", + "hono": "^4.12.27", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "inherits": "^2.0.4", + "ip-address": "^10.2.0", + "ipaddr.js": "^1.9.1", + "is-promise": "^4.0.0", + "isexe": "^2.0.0", + "jose": "^6.2.2", + "json-schema-traverse": "^1.0.0", + "json-schema-typed": "^8.0.2", + "math-intrinsics": "^1.1.0", + "media-typer": "^1.1.0", + "merge-descriptors": "^2.0.0", + "mime-db": "^1.54.0", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "negotiator": "^1.0.0", + "object-assign": "^4.1.1", + "object-inspect": "^1.13.4", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "path-key": "^3.1.1", + "path-to-regexp": "^8.4.2", + "pkce-challenge": "^5.0.1", + "proxy-addr": "^2.0.7", + "qs": "^6.15.2", + "range-parser": "^1.2.1", + "raw-body": "^3.0.2", + "require-from-string": "^2.0.2", + "router": "^2.2.0", + "safer-buffer": "^2.1.2", + "send": "^1.2.1", + "serve-static": "^2.2.1", + "setprototypeof": "^1.2.0", + "shebang-command": "^2.0.0", + "shebang-regex": "^3.0.0", + "side-channel": "^1.1.0", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2", + "sisteransi": "^1.0.5", + "statuses": "^2.0.2", + "toidentifier": "^1.0.1", + "type-is": "^2.0.1", + "typescript": "^5.9.3", + "undici-types": "^6.21.0", + "unpipe": "^1.0.0", + "vary": "^1.1.2", + "which": "^2.0.2", + "wrappy": "^1.0.2", + "zod": "^4.3.6", + "zod-to-json-schema": "^3.25.2" + }, + "devDependencies": {}, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@clack/core": { @@ -36,18 +135,6 @@ "sisteransi": "^1.0.5" } }, - "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@longtable/checkpoints": { "resolved": "packages/longtable-checkpoints", "link": true @@ -76,6 +163,10 @@ "resolved": "packages/longtable-provider-codex", "link": true }, + "node_modules/@longtable/research": { + "resolved": "packages/longtable-research", + "link": true + }, "node_modules/@longtable/research-search": { "resolved": "packages/longtable-research-search", "link": true @@ -88,44 +179,48 @@ "resolved": "packages/longtable-setup", "link": true }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "dev": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", + "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "zod": "^4.2.0" }, "engines": { - "node": ">=18" + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } + "engines": { + "node": ">=20" } }, "node_modules/@types/node": { @@ -183,20 +278,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -206,6 +301,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -441,7 +549,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -520,9 +627,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -670,7 +777,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -1169,22 +1275,38 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { "version": "5.9.3", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1196,7 +1318,6 @@ }, "node_modules/undici-types": { "version": "6.21.0", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -1243,7 +1364,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -1259,17 +1379,17 @@ }, "packages/longtable": { "name": "@longtable/cli", - "version": "0.1.72", + "version": "0.2.0-beta.1", "license": "MIT", "dependencies": { "@clack/prompts": "^1.2.0", - "@longtable/checkpoints": "0.1.72", - "@longtable/core": "0.1.72", - "@longtable/memory": "0.1.72", - "@longtable/provider-claude": "0.1.72", - "@longtable/provider-codex": "0.1.72", - "@longtable/scholar-research": "0.1.72", - "@longtable/setup": "0.1.72" + "@longtable/checkpoints": "0.2.0-beta.1", + "@longtable/core": "0.2.0-beta.1", + "@longtable/memory": "0.2.0-beta.1", + "@longtable/provider-claude": "0.2.0-beta.1", + "@longtable/provider-codex": "0.2.0-beta.1", + "@longtable/research": "0.2.0-beta.1", + "@longtable/setup": "0.2.0-beta.1" }, "bin": { "longtable": "bin/longtable" @@ -1284,10 +1404,10 @@ }, "packages/longtable-checkpoints": { "name": "@longtable/checkpoints", - "version": "0.1.72", + "version": "0.2.0-beta.1", "license": "MIT", "dependencies": { - "@longtable/core": "0.1.72" + "@longtable/core": "0.2.0-beta.1" }, "devDependencies": { "typescript": "^5.6.0" @@ -1298,7 +1418,7 @@ }, "packages/longtable-core": { "name": "@longtable/core", - "version": "0.1.72", + "version": "0.2.0-beta.1", "license": "MIT", "devDependencies": { "typescript": "^5.6.0" @@ -1309,35 +1429,36 @@ }, "packages/longtable-mcp": { "name": "@longtable/mcp", - "version": "0.1.72", + "version": "0.2.0-beta.1", "license": "MIT", "dependencies": { - "@longtable/checkpoints": "0.1.72", - "@longtable/cli": "0.1.72", - "@longtable/core": "0.1.72", - "@longtable/provider-claude": "0.1.72", - "@longtable/provider-codex": "0.1.72", - "@longtable/setup": "0.1.72", - "@modelcontextprotocol/sdk": "^1.29.0", + "@longtable/checkpoints": "0.2.0-beta.1", + "@longtable/cli": "0.2.0-beta.1", + "@longtable/core": "0.2.0-beta.1", + "@longtable/provider-claude": "0.2.0-beta.1", + "@longtable/provider-codex": "0.2.0-beta.1", + "@longtable/setup": "0.2.0-beta.1", + "@modelcontextprotocol/server": "^2.0.0", "zod": "^4.0.0" }, "bin": { "longtable-state": "bin/longtable-state" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/node": "^22.10.1", "typescript": "^5.6.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "packages/longtable-memory": { "name": "@longtable/memory", - "version": "0.1.72", + "version": "0.2.0-beta.1", "license": "MIT", "dependencies": { - "@longtable/core": "0.1.72" + "@longtable/core": "0.2.0-beta.1" }, "devDependencies": { "typescript": "^5.6.0" @@ -1348,12 +1469,12 @@ }, "packages/longtable-provider-claude": { "name": "@longtable/provider-claude", - "version": "0.1.72", + "version": "0.2.0-beta.1", "license": "MIT", "dependencies": { - "@longtable/checkpoints": "0.1.72", - "@longtable/core": "0.1.72", - "@longtable/setup": "0.1.72" + "@longtable/checkpoints": "0.2.0-beta.1", + "@longtable/core": "0.2.0-beta.1", + "@longtable/setup": "0.2.0-beta.1" }, "devDependencies": { "typescript": "^5.6.0" @@ -1364,12 +1485,12 @@ }, "packages/longtable-provider-codex": { "name": "@longtable/provider-codex", - "version": "0.1.72", + "version": "0.2.0-beta.1", "license": "MIT", "dependencies": { - "@longtable/checkpoints": "0.1.72", - "@longtable/core": "0.1.72", - "@longtable/setup": "0.1.72" + "@longtable/checkpoints": "0.2.0-beta.1", + "@longtable/core": "0.2.0-beta.1", + "@longtable/setup": "0.2.0-beta.1" }, "devDependencies": { "@types/node": "^22.10.1", @@ -1379,12 +1500,24 @@ "node": ">=18.0.0" } }, + "packages/longtable-research": { + "name": "@longtable/research", + "version": "0.2.0-beta.1", + "license": "MIT", + "devDependencies": { + "@types/node": "^22.10.1", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "packages/longtable-research-search": { "name": "@longtable/research-search", - "version": "0.1.72", + "version": "0.2.0-beta.1", "license": "MIT", "dependencies": { - "@longtable/scholar-research": "0.1.72" + "@longtable/research": "0.2.0-beta.1" }, "devDependencies": { "typescript": "^5.6.0" @@ -1395,10 +1528,12 @@ }, "packages/longtable-scholar-research": { "name": "@longtable/scholar-research", - "version": "0.1.72", + "version": "0.2.0-beta.1", "license": "MIT", + "dependencies": { + "@longtable/research": "0.2.0-beta.1" + }, "devDependencies": { - "@types/node": "^22.10.1", "typescript": "^5.6.0" }, "engines": { @@ -1407,11 +1542,11 @@ }, "packages/longtable-setup": { "name": "@longtable/setup", - "version": "0.1.72", + "version": "0.2.0-beta.1", "license": "MIT", "dependencies": { - "@longtable/core": "0.1.72", - "@longtable/memory": "0.1.72" + "@longtable/core": "0.2.0-beta.1", + "@longtable/memory": "0.2.0-beta.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/package.json b/package.json index fdb27e69..79df48d7 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,17 @@ { "name": "longtable-monorepo", - "version": "0.1.72", + "version": "0.2.0-beta.1", "private": true, "description": "Monorepo for the LongTable product surface and provider-neutral runtime packages", "workspaces": [ "packages/*" ], "scripts": { - "build": "npm run build --workspace @longtable/core && npm run build --workspace @longtable/memory && npm run build --workspace @longtable/checkpoints && npm run build --workspace @longtable/setup && npm run build --workspace @longtable/scholar-research && npm run build --workspace @longtable/research-search && npm run build --workspace @longtable/provider-codex && npm run build --workspace @longtable/provider-claude && npm run build --workspace @longtable/cli && npm run build --workspace @longtable/mcp", - "typecheck": "npm run typecheck --workspace @longtable/core && npm run typecheck --workspace @longtable/memory && npm run typecheck --workspace @longtable/checkpoints && npm run typecheck --workspace @longtable/setup && npm run typecheck --workspace @longtable/scholar-research && npm run typecheck --workspace @longtable/research-search && npm run typecheck --workspace @longtable/provider-codex && npm run typecheck --workspace @longtable/provider-claude && npm run typecheck --workspace @longtable/cli && npm run typecheck --workspace @longtable/mcp", + "build": "npm run build --workspace @longtable/core && npm run build --workspace @longtable/memory && npm run build --workspace @longtable/checkpoints && npm run build --workspace @longtable/setup && npm run build --workspace @longtable/research && npm run build --workspace @longtable/scholar-research && npm run build --workspace @longtable/research-search && npm run build --workspace @longtable/provider-codex && npm run build --workspace @longtable/provider-claude && npm run build --workspace @longtable/cli && npm run build --workspace @longtable/mcp", + "typecheck": "npm run typecheck --workspace @longtable/core && npm run typecheck --workspace @longtable/memory && npm run typecheck --workspace @longtable/checkpoints && npm run typecheck --workspace @longtable/setup && npm run typecheck --workspace @longtable/research && npm run typecheck --workspace @longtable/scholar-research && npm run typecheck --workspace @longtable/research-search && npm run typecheck --workspace @longtable/provider-codex && npm run typecheck --workspace @longtable/provider-claude && npm run typecheck --workspace @longtable/cli && npm run typecheck --workspace @longtable/mcp", "smoke:setup": "node scripts/smoke-setup-flow.mjs", "smoke:checkpoints": "node scripts/smoke-checkpoint-routing.mjs", + "smoke:mcp-elicitation": "node scripts/smoke-mcp-elicitation.mjs", "smoke:hooks": "node scripts/smoke-codex-hooks.mjs", "smoke:panel-handoff": "node scripts/smoke-panel-handoff.mjs", "smoke:panel-workers": "node scripts/smoke-panel-workers.mjs", @@ -21,11 +22,117 @@ "smoke:grilling-interview": "node scripts/e2e-grilling-interview-flow.mjs", "smoke:search": "node scripts/smoke-search.mjs", "smoke:scholar-research": "node scripts/smoke-scholar-research.mjs", - "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run typecheck", + "test": "npm run build && npm run smoke:setup && npm run smoke:checkpoints && npm run smoke:mcp-elicitation && npm run smoke:hooks && npm run smoke:panel-handoff && npm run smoke:panel-workers && npm run smoke:research-spec-audit && npm run smoke:question-audit && npm run smoke:role-audit && npm run smoke:human-persona && npm run smoke:grilling-interview && npm run smoke:search && npm run smoke:scholar-research && npm run typecheck", "pack:check": "npm pack --workspaces --dry-run", "release:check": "npm run test && npm run pack:check" }, "engines": { - "node": ">=18.0.0" - } + "node": ">=20.0.0" + }, + "main": "index.js", + "directories": { + "doc": "docs" + }, + "dependencies": { + "accepts": "^2.0.0", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", + "body-parser": "^2.2.2", + "bytes": "^3.1.2", + "call-bind-apply-helpers": "^1.0.2", + "call-bound": "^1.0.4", + "content-disposition": "^1.1.0", + "content-type": "^1.0.5", + "cookie": "^0.7.2", + "cookie-signature": "^1.2.2", + "cors": "^2.8.6", + "cross-spawn": "^7.0.6", + "debug": "^4.4.3", + "depd": "^2.0.0", + "dunder-proto": "^1.0.1", + "ee-first": "^1.1.1", + "encodeurl": "^2.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "eventsource": "^3.0.7", + "eventsource-parser": "^3.0.8", + "express": "^5.2.1", + "express-rate-limit": "^8.5.2", + "fast-deep-equal": "^3.1.3", + "fast-string-truncated-width": "^1.2.1", + "fast-string-width": "^1.1.0", + "fast-uri": "^3.1.2", + "fast-wrap-ansi": "^0.1.6", + "finalhandler": "^2.1.1", + "forwarded": "^0.2.0", + "fresh": "^2.0.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.3", + "hono": "^4.12.27", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "inherits": "^2.0.4", + "ip-address": "^10.2.0", + "ipaddr.js": "^1.9.1", + "is-promise": "^4.0.0", + "isexe": "^2.0.0", + "jose": "^6.2.2", + "json-schema-traverse": "^1.0.0", + "json-schema-typed": "^8.0.2", + "math-intrinsics": "^1.1.0", + "media-typer": "^1.1.0", + "merge-descriptors": "^2.0.0", + "mime-db": "^1.54.0", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "negotiator": "^1.0.0", + "object-assign": "^4.1.1", + "object-inspect": "^1.13.4", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "path-key": "^3.1.1", + "path-to-regexp": "^8.4.2", + "pkce-challenge": "^5.0.1", + "proxy-addr": "^2.0.7", + "qs": "^6.15.2", + "range-parser": "^1.2.1", + "raw-body": "^3.0.2", + "require-from-string": "^2.0.2", + "router": "^2.2.0", + "safer-buffer": "^2.1.2", + "send": "^1.2.1", + "serve-static": "^2.2.1", + "setprototypeof": "^1.2.0", + "shebang-command": "^2.0.0", + "shebang-regex": "^3.0.0", + "side-channel": "^1.1.0", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2", + "sisteransi": "^1.0.5", + "statuses": "^2.0.2", + "toidentifier": "^1.0.1", + "type-is": "^2.0.1", + "typescript": "^5.9.3", + "undici-types": "^6.21.0", + "unpipe": "^1.0.0", + "vary": "^1.1.2", + "which": "^2.0.2", + "wrappy": "^1.0.2", + "zod": "^4.3.6", + "zod-to-json-schema": "^3.25.2" + }, + "devDependencies": {}, + "keywords": [], + "author": "", + "license": "MIT", + "type": "commonjs" } diff --git a/packages/longtable-checkpoints/package.json b/packages/longtable-checkpoints/package.json index a424552c..a03d8261 100644 --- a/packages/longtable-checkpoints/package.json +++ b/packages/longtable-checkpoints/package.json @@ -1,6 +1,6 @@ { "name": "@longtable/checkpoints", - "version": "0.1.72", + "version": "0.2.0-beta.1", "private": false, "description": "Checkpoint policy engine for LongTable", "type": "module", @@ -21,7 +21,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@longtable/core": "0.1.72" + "@longtable/core": "0.2.0-beta.1" }, "devDependencies": { "typescript": "^5.6.0" diff --git a/packages/longtable-core/package.json b/packages/longtable-core/package.json index 71119c04..1f379b01 100644 --- a/packages/longtable-core/package.json +++ b/packages/longtable-core/package.json @@ -1,6 +1,6 @@ { "name": "@longtable/core", - "version": "0.1.72", + "version": "0.2.0-beta.1", "private": false, "description": "Provider-neutral domain models and contracts for LongTable", "type": "module", diff --git a/packages/longtable-core/src/types.ts b/packages/longtable-core/src/types.ts index 8a3f37c0..0bad12b3 100644 --- a/packages/longtable-core/src/types.ts +++ b/packages/longtable-core/src/types.ts @@ -455,6 +455,8 @@ export interface DecisionRecord { rationale?: string; explicitStateUpdates?: Record; studyContractId?: string; + sourceQuestionId?: string; + idempotencyKey?: string; } export type EvidenceSourceKind = @@ -650,6 +652,7 @@ export interface QuestionAnswer { rationale?: string; provider?: ProviderKind; surface: QuestionSurface; + idempotencyKey?: string; } export type QuestionRecordStatus = "pending" | "answered" | "cleared" | "error"; @@ -659,6 +662,7 @@ export type QuestionTransportStatus = | "attempted" | "accepted" | "declined" + | "cancelled" | "unsupported" | "timeout" | "error" @@ -671,6 +675,21 @@ export interface QuestionTransportState { message?: string; } +export interface QuestionTransportAttempt { + id: string; + attemptId: string; + sequence: number; + surface: QuestionSurface; + status: QuestionTransportStatus; + recordedAt: string; + provider?: ProviderKind; + parentAttemptId?: string; + requestId?: string; + action?: "accept" | "decline" | "cancel"; + message?: string; + retryable?: boolean; +} + export interface QuestionRecord { id: string; createdAt: string; @@ -680,8 +699,10 @@ export interface QuestionRecord { hardStopScope?: HardStopScope; commitmentFamily?: QuestionCommitmentFamily; epistemicBasis?: QuestionEpistemicBasis; + idempotencyKey?: string; prompt: QuestionPrompt; transportStatus?: QuestionTransportState; + transportAttempts?: QuestionTransportAttempt[]; answer?: QuestionAnswer; error?: string; clearedReason?: string; diff --git a/packages/longtable-mcp/README.md b/packages/longtable-mcp/README.md index 47845a0a..280b756d 100644 --- a/packages/longtable-mcp/README.md +++ b/packages/longtable-mcp/README.md @@ -16,7 +16,7 @@ longtable-state Run: ```bash -npx -y @longtable/mcp@0.1.53 +npx -y @longtable/mcp@next ``` Self-test: @@ -31,8 +31,10 @@ Codex UI Researcher Checkpoints are opt-in from the CLI: longtable mcp install --provider codex --checkpoint-ui strong --write ``` -If MCP elicitation is unavailable or not approved, the server returns the same -pending `QuestionRecord` as a numbered fallback. +Modern MCP clients receive `input_required`; the server does not hold a +60-second elicitation request open. The 2025 compatibility shim uses a 24-hour +per-round watchdog by default. In either era, interruption leaves the same +pending `QuestionRecord` resumable instead of creating a replacement. Provider guidance should use interview tools for `$longtable-start`. `$longtable-interview` is post-start and should route back to diff --git a/packages/longtable-mcp/package.json b/packages/longtable-mcp/package.json index 174d75b5..2f1d3919 100644 --- a/packages/longtable-mcp/package.json +++ b/packages/longtable-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@longtable/mcp", - "version": "0.1.72", + "version": "0.2.0-beta.1", "private": false, "description": "LongTable MCP transport for workspace state and Researcher Checkpoints", "type": "module", @@ -26,16 +26,17 @@ "self-test": "node ./dist/server.js --self-test" }, "dependencies": { - "@longtable/checkpoints": "0.1.72", - "@longtable/cli": "0.1.72", - "@longtable/core": "0.1.72", - "@longtable/provider-claude": "0.1.72", - "@longtable/provider-codex": "0.1.72", - "@longtable/setup": "0.1.72", - "@modelcontextprotocol/sdk": "^1.29.0", + "@longtable/checkpoints": "0.2.0-beta.1", + "@longtable/cli": "0.2.0-beta.1", + "@longtable/core": "0.2.0-beta.1", + "@longtable/provider-claude": "0.2.0-beta.1", + "@longtable/provider-codex": "0.2.0-beta.1", + "@longtable/setup": "0.2.0-beta.1", + "@modelcontextprotocol/server": "^2.0.0", "zod": "^4.0.0" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/node": "^22.10.1", "typescript": "^5.6.0" }, @@ -56,6 +57,6 @@ "access": "public" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } } diff --git a/packages/longtable-mcp/src/server.ts b/packages/longtable-mcp/src/server.ts index aa95d372..f94ecf62 100644 --- a/packages/longtable-mcp/src/server.ts +++ b/packages/longtable-mcp/src/server.ts @@ -4,9 +4,17 @@ import { createRequire } from "node:module"; import { basename, resolve } from "node:path"; import { cwd, exit } from "node:process"; import { fileURLToPath } from "node:url"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import type { CallToolResult, ElicitRequestFormParams, ElicitResult } from "@modelcontextprotocol/sdk/types.js"; +import { + acceptedContent, + inputRequired, + inputResponse, + McpServer, + type CallToolResult, + type ElicitRequestFormParams, + type InputRequiredResult, + type InputResponses +} from "@modelcontextprotocol/server"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { z } from "zod"; import { classifyCheckpointTrigger } from "@longtable/checkpoints"; import { renderQuestionRecordInput } from "@longtable/provider-claude"; @@ -27,7 +35,6 @@ import { answerWorkspaceQuestion, applyResearchSpecificationAuditUpdate, applyResearchSpecificationPatch, - clearWorkspaceQuestion, createOrUpdateProjectWorkspace, createWorkspaceQuestion, diffResearchSpecifications, @@ -37,6 +44,7 @@ import { loadWorkspaceState, proposeResearchSpecificationPatch, readResearchSpecificationHistory, + recordWorkspaceQuestionTransport, syncCurrentWorkspaceView } from "@longtable/cli"; import { @@ -55,6 +63,14 @@ import { const SERVER_NAME = "longtable-state"; const require = createRequire(import.meta.url); const SERVER_VERSION = String((require("../package.json") as { version?: unknown }).version ?? "0.0.0"); +const DEFAULT_LEGACY_ELICITATION_ROUND_TIMEOUT_MS = 24 * 60 * 60 * 1000; + +function legacyElicitationRoundTimeoutMs(): number { + const configured = Number(process.env.LONGTABLE_MCP_LEGACY_ELICITATION_TIMEOUT_MS); + return Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_LEGACY_ELICITATION_ROUND_TIMEOUT_MS; +} const TOOL_NAMES = [ "read_project", @@ -220,6 +236,16 @@ type AcceptedQuestionAnswer = string | string[] | { otherText?: string; }; +const acceptedQuestionContentSchema = z.object({ + answer: z.union([ + z.string().min(1), + z.array(z.string().min(1)).min(1) + ]), + otherText: z.string().optional() +}); + +const QUESTION_RESPONSE_PREFIX = "longtable-question:"; + const questionOptionSchema = z.object({ value: z.string().min(1), label: z.string().min(1), @@ -1050,29 +1076,23 @@ async function markQuestionTransport( context: Awaited>, questionId: string, status: QuestionTransportStatus, - message?: string + message?: string, + options: { + attemptId?: string; + action?: "accept" | "decline" | "cancel"; + provider?: ProviderKind; + retryable?: boolean; + } = {} ): Promise { - const state = asInterviewState(await loadWorkspaceState(context)); - let updatedQuestion: QuestionRecord | null = null; - state.questionLog = (state.questionLog ?? []).map((record: QuestionRecord) => { - if (record.id !== questionId) { - return record; - } - updatedQuestion = { - ...record, - updatedAt: new Date().toISOString(), - transportStatus: { - surface: "mcp_elicitation", - status, - updatedAt: new Date().toISOString(), - ...(message ? { message } : {}) - } - }; - return updatedQuestion; + const result = await recordWorkspaceQuestionTransport({ + context, + questionId, + status, + surface: "mcp_elicitation", + ...(message ? { message } : {}), + ...options }); - await writeFile(context.stateFilePath, JSON.stringify(state, null, 2), "utf8"); - await syncCurrentWorkspaceView(context); - return updatedQuestion; + return result.question; } function buildElicitationParams(record: QuestionRecord): ElicitRequestFormParams { @@ -1135,28 +1155,91 @@ function buildElicitationParams(record: QuestionRecord): ElicitRequestFormParams }; } -function acceptedAnswer(result: ElicitResult): { answer: AcceptedQuestionAnswer } | null { - if (result.action !== "accept") { - return null; - } - const content = result.content as Record | undefined; - const answer = content?.answer ?? content?.answers ?? content?.selectedValues; - const otherText = typeof content?.otherText === "string" && content.otherText.trim().length > 0 +function acceptedAnswer(content: z.infer): { answer: AcceptedQuestionAnswer } { + const answer = content.answer; + const otherText = typeof content.otherText === "string" && content.otherText.trim().length > 0 ? content.otherText.trim() : undefined; - if (typeof answer !== "string" || answer.length === 0) { - if (Array.isArray(answer) && answer.every((entry) => typeof entry === "string" && entry.length > 0)) { - return { - answer: otherText ? { selectedValues: answer, otherText } : answer - }; - } - return null; + if (Array.isArray(answer)) { + return { + answer: otherText ? { selectedValues: answer, otherText } : answer + }; } return { answer: otherText ? { selectedValue: answer, otherText } : answer }; } +function questionResponseKey(questionId: string): string { + return `${QUESTION_RESPONSE_PREFIX}${questionId}`; +} + +function questionIdFromInputResponses(inputResponses: Record | undefined): string | undefined { + const matches = Object.keys(inputResponses ?? {}).filter((key) => key.startsWith(QUESTION_RESPONSE_PREFIX)); + if (matches.length > 1) { + throw new Error("LongTable received multiple checkpoint responses in one tool call."); + } + return matches[0]?.slice(QUESTION_RESPONSE_PREFIX.length); +} + +async function resumedQuestionFromInputResponses( + context: Awaited>, + inputResponses: Record | undefined, + expected: { + checkpointKey?: string; + idempotencyKey?: string; + prompt?: string; + } = {} +): Promise { + const questionId = questionIdFromInputResponses(inputResponses); + if (!questionId) { + return undefined; + } + const state = await loadWorkspaceState(context); + const question = (state.questionLog ?? []).find((record: QuestionRecord) => record.id === questionId); + if (!question) { + throw new Error(`MCP input response references unknown LongTable question ${questionId}.`); + } + if (expected.checkpointKey && question.prompt.checkpointKey !== expected.checkpointKey) { + throw new Error(`MCP input response does not match checkpoint ${expected.checkpointKey}.`); + } + if (expected.idempotencyKey && question.idempotencyKey !== expected.idempotencyKey) { + throw new Error("MCP input response does not match the checkpoint idempotency key."); + } + if (expected.prompt && !question.prompt.rationale.includes(`Original prompt: ${expected.prompt}`)) { + throw new Error("MCP input response does not match the checkpoint prompt."); + } + return question; +} + +function readQuestionInputResponse( + inputResponses: Record | undefined, + question: QuestionRecord +): + | { kind: "missing" } + | { kind: "refused"; action: "decline" | "cancel" } + | { kind: "accepted"; answer: AcceptedQuestionAnswer } { + const key = questionResponseKey(question.id); + const typedResponses = inputResponses as InputResponses | undefined; + const view = inputResponse(typedResponses, key); + if (view.kind === "elicit" && view.action !== "accept") { + return { kind: "refused", action: view.action }; + } + const content = acceptedContent(typedResponses, key, acceptedQuestionContentSchema); + if (!content) { + return { kind: "missing" }; + } + return { kind: "accepted", answer: acceptedAnswer(content).answer }; +} + +function requireQuestionInput(question: QuestionRecord): InputRequiredResult { + return inputRequired({ + inputRequests: { + [questionResponseKey(question.id)]: inputRequired.elicit(buildElicitationParams(question)) + } + }); +} + function firstAcceptedAnswerValue(answer: AcceptedQuestionAnswer): string { if (typeof answer === "string") { return answer; @@ -1408,17 +1491,6 @@ async function markAlreadyConfirmedResearchSpecification( }; } -function statusForElicitationError(error: unknown): QuestionTransportStatus { - const message = error instanceof Error ? error.message : String(error); - if (/timed?\s*out|timeout/i.test(message)) { - return "timeout"; - } - if (/unsupported|not supported|unavailable|does not support/i.test(message)) { - return "unsupported"; - } - return "error"; -} - async function readAllowedProjectFiles(context: Awaited>) { const current = existsSync(context.currentFilePath) ? await readFile(context.currentFilePath, "utf8") @@ -1442,7 +1514,12 @@ export function createLongTableMcpServer(): McpServer { }, { instructions: - "Use LongTable state tools to inspect .longtable workspaces, evaluate Researcher Checkpoints, write QuestionRecords, append DecisionRecords, and regenerate CURRENT.md. Treat .longtable as the source of truth." + "Use LongTable state tools to inspect .longtable workspaces, evaluate Researcher Checkpoints, write QuestionRecords, append DecisionRecords, and regenerate CURRENT.md. Treat .longtable as the source of truth.", + inputRequired: { + legacyShim: true, + maxRounds: 8, + roundTimeoutMs: legacyElicitationRoundTimeoutMs() + } } ); @@ -1923,7 +2000,7 @@ export function createLongTableMcpServer(): McpServer { fallbackOnly: z.boolean().default(false) }) }, - async ({ cwd: inputCwd, shape: inputShape, provider, fallbackOnly }) => { + async ({ cwd: inputCwd, shape: inputShape, provider, fallbackOnly }, ctx): Promise => { try { const context = await requireContext(inputCwd); const state = asInterviewState(await loadWorkspaceState(context)); @@ -1940,17 +2017,30 @@ export function createLongTableMcpServer(): McpServer { }); } const spec = buildFirstResearchShapeQuestion(shape); - const created = await createWorkspaceQuestion({ + const shapeIdempotencyKey = `first-research-shape:${shape.sourceHookId ?? shape.handle}`; + const resumedQuestion = await resumedQuestionFromInputResponses( context, - prompt: spec.prompt, - title: spec.title, - question: spec.question, - checkpointKey: spec.checkpointKey, - questionOptions: spec.options, - displayReason: spec.displayReason, - provider: provider as ProviderKind, - required: true - }); + ctx.mcpReq.inputResponses, + { + checkpointKey: spec.checkpointKey, + idempotencyKey: shapeIdempotencyKey, + prompt: spec.prompt + } + ); + const created = resumedQuestion + ? { question: resumedQuestion } + : await createWorkspaceQuestion({ + context, + prompt: spec.prompt, + title: spec.title, + question: spec.question, + checkpointKey: spec.checkpointKey, + questionOptions: spec.options, + displayReason: spec.displayReason, + provider: provider as ProviderKind, + required: true, + idempotencyKey: shapeIdempotencyKey + }); const createdState = ensureFirstResearchShapeObligation( asInterviewState(await loadWorkspaceState(context)), shape, @@ -1974,68 +2064,53 @@ export function createLongTableMcpServer(): McpServer { }); } - try { - await markQuestionTransport(context, created.question.id, "attempted"); - const elicited = await server.server.elicitInput(buildElicitationParams(created.question)); - const accepted = acceptedAnswer(elicited); - if (!accepted) { - const status = elicited.action === "decline" || elicited.action === "cancel" - ? "declined" - : "fallback_rendered"; - const marked = await markQuestionTransport(context, created.question.id, status, `MCP elicitation returned action: ${elicited.action}.`); - const cleared = status === "declined" - ? await clearWorkspaceQuestion({ - context, - questionId: created.question.id, - reason: `MCP elicitation returned action: ${elicited.action}; confirmation was deferred without a research-direction decision.` - }) - : undefined; - return textResult({ - question: cleared?.question ?? marked ?? created.question, - shape, - readiness: await currentResearchSpecificationReadiness(context), - elicitation: { attempted: true, action: elicited.action }, - fallback - }); - } - const decided = await answerWorkspaceQuestion({ - context, - questionId: created.question.id, - answer: accepted.answer, + const elicited = readQuestionInputResponse(ctx.mcpReq.inputResponses, created.question); + if (elicited.kind === "missing") { + await markQuestionTransport(context, created.question.id, "attempted", undefined, { provider: provider as ProviderKind, - surface: "mcp_elicitation" + retryable: true }); - const marked = await markQuestionTransport(context, created.question.id, "accepted"); - const confirmation = await markFirstResearchShapeConfirmation( + return requireQuestionInput(created.question); + } + if (elicited.kind === "refused") { + const status = elicited.action === "cancel" ? "cancelled" : "declined"; + const marked = await markQuestionTransport( context, - shape, - firstAcceptedAnswerValue(accepted.answer), created.question.id, - decided.decision.id + status, + `MCP elicitation returned action: ${elicited.action}. The checkpoint remains pending and resumable.`, + { action: elicited.action, provider: provider as ProviderKind, retryable: true } ); - return textResult({ - shape: confirmation.shape, - readiness: confirmation.readiness, - question: marked ? { ...decided.question, transportStatus: marked.transportStatus } : decided.question, - decision: decided.decision, - elicitation: { attempted: true, action: elicited.action } - }); - } catch (elicitationError) { - const status = statusForElicitationError(elicitationError); - const message = elicitationError instanceof Error ? elicitationError.message : String(elicitationError); - const marked = await markQuestionTransport(context, created.question.id, status, message); return textResult({ question: marked ?? created.question, shape, readiness: await currentResearchSpecificationReadiness(context), - elicitation: { - attempted: true, - supported: status !== "unsupported" ? undefined : false, - error: message - }, + elicitation: { attempted: true, action: elicited.action, resumable: true }, fallback }); } + const decided = await answerWorkspaceQuestion({ + context, + questionId: created.question.id, + answer: elicited.answer, + provider: provider as ProviderKind, + surface: "mcp_elicitation", + answerIdempotencyKey: `${created.question.id}:mcp-accept` + }); + const confirmation = await markFirstResearchShapeConfirmation( + context, + shape, + firstAcceptedAnswerValue(elicited.answer), + created.question.id, + decided.decision.id + ); + return textResult({ + shape: confirmation.shape, + readiness: confirmation.readiness, + question: decided.question, + decision: decided.decision, + elicitation: { attempted: true, action: "accept" } + }); } catch (error) { return errorResult(error instanceof Error ? error.message : String(error)); } @@ -2053,7 +2128,7 @@ export function createLongTableMcpServer(): McpServer { fallbackOnly: z.boolean().default(false) }) }, - async ({ cwd: inputCwd, specification: inputSpecification, provider, fallbackOnly }) => { + async ({ cwd: inputCwd, specification: inputSpecification, provider, fallbackOnly }, ctx): Promise => { try { const context = await requireContext(inputCwd); const state = asInterviewState(await loadWorkspaceState(context)); @@ -2074,17 +2149,30 @@ export function createLongTableMcpServer(): McpServer { }); } const spec = buildResearchSpecificationQuestion(specification); - const created = await createWorkspaceQuestion({ + const specificationIdempotencyKey = `research-specification:${specification.sourceHookId ?? specification.createdAt ?? specification.title}`; + const resumedQuestion = await resumedQuestionFromInputResponses( context, - prompt: spec.prompt, - title: spec.title, - question: spec.question, - checkpointKey: spec.checkpointKey, - questionOptions: spec.options, - displayReason: spec.displayReason, - provider: provider as ProviderKind, - required: true - }); + ctx.mcpReq.inputResponses, + { + checkpointKey: spec.checkpointKey, + idempotencyKey: specificationIdempotencyKey, + prompt: spec.prompt + } + ); + const created = resumedQuestion + ? { question: resumedQuestion } + : await createWorkspaceQuestion({ + context, + prompt: spec.prompt, + title: spec.title, + question: spec.question, + checkpointKey: spec.checkpointKey, + questionOptions: spec.options, + displayReason: spec.displayReason, + provider: provider as ProviderKind, + required: true, + idempotencyKey: specificationIdempotencyKey + }); const fallback = renderQuestionFallback(created.question, provider as ProviderKind); if (fallbackOnly) { const marked = await markQuestionTransport(context, created.question.id, "fallback_rendered", "MCP elicitation skipped by fallbackOnly."); @@ -2098,71 +2186,55 @@ export function createLongTableMcpServer(): McpServer { }); } - try { - await markQuestionTransport(context, created.question.id, "attempted"); - const elicited = await server.server.elicitInput(buildElicitationParams(created.question)); - const accepted = acceptedAnswer(elicited); - if (!accepted) { - const status = elicited.action === "decline" || elicited.action === "cancel" - ? "declined" - : "fallback_rendered"; - const marked = await markQuestionTransport(context, created.question.id, status, `MCP elicitation returned action: ${elicited.action}.`); - const cleared = status === "declined" - ? await clearWorkspaceQuestion({ - context, - questionId: created.question.id, - reason: `MCP elicitation returned action: ${elicited.action}; Research Specification confirmation was deferred.` - }) - : undefined; - return textResult({ - question: cleared?.question ?? marked ?? created.question, - specification, - preview: renderResearchSpecificationPreview(specification), - readiness: await currentResearchSpecificationReadiness(context, specification), - elicitation: { attempted: true, action: elicited.action }, - fallback - }); - } - const decided = await answerWorkspaceQuestion({ - context, - questionId: created.question.id, - answer: accepted.answer, + const elicited = readQuestionInputResponse(ctx.mcpReq.inputResponses, created.question); + if (elicited.kind === "missing") { + await markQuestionTransport(context, created.question.id, "attempted", undefined, { provider: provider as ProviderKind, - surface: "mcp_elicitation" + retryable: true }); - const marked = await markQuestionTransport(context, created.question.id, "accepted"); - const confirmation = await markResearchSpecificationConfirmation( + return requireQuestionInput(created.question); + } + if (elicited.kind === "refused") { + const status = elicited.action === "cancel" ? "cancelled" : "declined"; + const marked = await markQuestionTransport( context, - specification, - firstAcceptedAnswerValue(accepted.answer), created.question.id, - decided.decision.id + status, + `MCP elicitation returned action: ${elicited.action}. The checkpoint remains pending and resumable.`, + { action: elicited.action, provider: provider as ProviderKind, retryable: true } ); - return textResult({ - specification: confirmation.specification, - preview: renderResearchSpecificationPreview(confirmation.specification), - readiness: confirmation.readiness, - question: marked ? { ...decided.question, transportStatus: marked.transportStatus } : decided.question, - decision: decided.decision, - elicitation: { attempted: true, action: elicited.action } - }); - } catch (elicitationError) { - const status = statusForElicitationError(elicitationError); - const message = elicitationError instanceof Error ? elicitationError.message : String(elicitationError); - const marked = await markQuestionTransport(context, created.question.id, status, message); return textResult({ question: marked ?? created.question, specification, preview: renderResearchSpecificationPreview(specification), readiness: await currentResearchSpecificationReadiness(context, specification), - elicitation: { - attempted: true, - supported: status !== "unsupported" ? undefined : false, - error: message - }, + elicitation: { attempted: true, action: elicited.action, resumable: true }, fallback }); } + const decided = await answerWorkspaceQuestion({ + context, + questionId: created.question.id, + answer: elicited.answer, + provider: provider as ProviderKind, + surface: "mcp_elicitation", + answerIdempotencyKey: `${created.question.id}:mcp-accept` + }); + const confirmation = await markResearchSpecificationConfirmation( + context, + specification, + firstAcceptedAnswerValue(elicited.answer), + created.question.id, + decided.decision.id + ); + return textResult({ + specification: confirmation.specification, + preview: renderResearchSpecificationPreview(confirmation.specification), + readiness: confirmation.readiness, + question: decided.question, + decision: decided.decision, + elicitation: { attempted: true, action: "accept" } + }); } catch (error) { return errorResult(error instanceof Error ? error.message : String(error)); } @@ -2288,28 +2360,50 @@ export function createLongTableMcpServer(): McpServer { required: z.boolean().optional(), commitmentFamily: commitmentFamilySchema.optional(), epistemicBasis: epistemicBasisSchema.optional(), + idempotencyKey: z.string().min(1).optional().describe("Stable caller key for retrying the same checkpoint without creating a duplicate."), fallbackOnly: z.boolean().default(false).describe("Create and render the checkpoint without calling MCP elicitation.") }) }, - async ({ cwd: inputCwd, prompt, title, question, type, checkpointKey, options, allowOther, otherLabel, displayReason, provider, required, commitmentFamily, epistemicBasis, fallbackOnly }) => { + async ({ cwd: inputCwd, prompt, title, question, type, checkpointKey, options, allowOther, otherLabel, displayReason, provider, required, commitmentFamily, epistemicBasis, idempotencyKey, fallbackOnly }, ctx): Promise => { try { const context = await requireContext(inputCwd); - const created = await createWorkspaceQuestion({ + const resumedQuestion = await resumedQuestionFromInputResponses( context, - prompt, - title, - question, - type: type as QuestionPromptType | undefined, - checkpointKey, - questionOptions: options as QuestionOption[] | undefined, - allowOther, - otherLabel, - displayReason, - provider, - required, - commitmentFamily: commitmentFamily as QuestionCommitmentFamily | undefined, - epistemicBasis: epistemicBasis as QuestionEpistemicBasis | undefined - }); + ctx.mcpReq.inputResponses, + { checkpointKey, idempotencyKey, prompt } + ); + const created = resumedQuestion + ? { question: resumedQuestion } + : await createWorkspaceQuestion({ + context, + prompt, + title, + question, + type: type as QuestionPromptType | undefined, + checkpointKey, + questionOptions: options as QuestionOption[] | undefined, + allowOther, + otherLabel, + displayReason, + provider, + required, + commitmentFamily: commitmentFamily as QuestionCommitmentFamily | undefined, + epistemicBasis: epistemicBasis as QuestionEpistemicBasis | undefined, + idempotencyKey + }); + if (created.question.status === "answered" && created.question.decisionRecordId) { + const currentState = await loadWorkspaceState(context); + const decision = currentState.decisionLog.find( + (record) => record.id === created.question.decisionRecordId + ); + if (decision) { + return textResult({ + question: created.question, + decision, + elicitation: { attempted: false, reason: "idempotent_replay" } + }); + } + } const fallback = renderQuestionFallback(created.question, provider as ProviderKind); if (fallbackOnly) { const marked = await markQuestionTransport(context, created.question.id, "fallback_rendered", "MCP elicitation skipped by fallbackOnly."); @@ -2321,50 +2415,43 @@ export function createLongTableMcpServer(): McpServer { }); } - try { - await markQuestionTransport(context, created.question.id, "attempted"); - const elicited = await server.server.elicitInput(buildElicitationParams(created.question)); - const accepted = acceptedAnswer(elicited); - if (!accepted) { - const status = elicited.action === "decline" || elicited.action === "cancel" - ? "declined" - : "fallback_rendered"; - const marked = await markQuestionTransport(context, created.question.id, status, `MCP elicitation returned action: ${elicited.action}.`); - return textResult({ - question: marked ?? created.question, - elicitation: { attempted: true, action: elicited.action }, - fallback, - nextAction: `longtable decide --question ${created.question.id} --answer ` - }); - } - const decided = await answerWorkspaceQuestion({ - context, - questionId: created.question.id, - answer: accepted.answer, + const elicited = readQuestionInputResponse(ctx.mcpReq.inputResponses, created.question); + if (elicited.kind === "missing") { + await markQuestionTransport(context, created.question.id, "attempted", undefined, { provider: provider as ProviderKind, - surface: "mcp_elicitation" - }); - const marked = await markQuestionTransport(context, created.question.id, "accepted"); - return textResult({ - question: marked ? { ...decided.question, transportStatus: marked.transportStatus } : decided.question, - decision: decided.decision, - elicitation: { attempted: true, action: elicited.action } + retryable: true }); - } catch (elicitationError) { - const status = statusForElicitationError(elicitationError); - const message = elicitationError instanceof Error ? elicitationError.message : String(elicitationError); - const marked = await markQuestionTransport(context, created.question.id, status, message); + return requireQuestionInput(created.question); + } + if (elicited.kind === "refused") { + const status = elicited.action === "cancel" ? "cancelled" : "declined"; + const marked = await markQuestionTransport( + context, + created.question.id, + status, + `MCP elicitation returned action: ${elicited.action}. The checkpoint remains pending and resumable.`, + { action: elicited.action, provider: provider as ProviderKind, retryable: true } + ); return textResult({ question: marked ?? created.question, - elicitation: { - attempted: true, - supported: status !== "unsupported" ? undefined : false, - error: message - }, + elicitation: { attempted: true, action: elicited.action, resumable: true }, fallback, nextAction: `longtable decide --question ${created.question.id} --answer ` }); } + const decided = await answerWorkspaceQuestion({ + context, + questionId: created.question.id, + answer: elicited.answer, + provider: provider as ProviderKind, + surface: "mcp_elicitation", + answerIdempotencyKey: `${created.question.id}:mcp-accept` + }); + return textResult({ + question: decided.question, + decision: decided.decision, + elicitation: { attempted: true, action: "accept" } + }); } catch (error) { return errorResult(error instanceof Error ? error.message : String(error)); } @@ -2451,10 +2538,13 @@ export function createLongTableMcpServer(): McpServer { return server; } -export async function runStdioServer(): Promise { - const server = createLongTableMcpServer(); - const transport = new StdioServerTransport(); - await server.connect(transport); +export function runStdioServer(): void { + serveStdio(() => createLongTableMcpServer(), { + legacy: "serve", + onerror: (error) => { + console.error(`${SERVER_NAME} MCP transport error: ${error.message}`); + } + }); console.error(`${SERVER_NAME} MCP server running on stdio`); } @@ -2464,7 +2554,7 @@ export async function runLongTableMcpCli(argv = process.argv): Promise { return; } - await runStdioServer(); + runStdioServer(); } function isDirectRun(): boolean { diff --git a/packages/longtable-memory/package.json b/packages/longtable-memory/package.json index 6543a257..8665b64f 100644 --- a/packages/longtable-memory/package.json +++ b/packages/longtable-memory/package.json @@ -1,6 +1,6 @@ { "name": "@longtable/memory", - "version": "0.1.72", + "version": "0.2.0-beta.1", "private": false, "description": "Memory contracts for LongTable explicit state, inferred hypotheses, and open tensions", "type": "module", @@ -21,7 +21,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@longtable/core": "0.1.72" + "@longtable/core": "0.2.0-beta.1" }, "devDependencies": { "typescript": "^5.6.0" diff --git a/packages/longtable-provider-claude/package.json b/packages/longtable-provider-claude/package.json index ef9178aa..e447c172 100644 --- a/packages/longtable-provider-claude/package.json +++ b/packages/longtable-provider-claude/package.json @@ -1,6 +1,6 @@ { "name": "@longtable/provider-claude", - "version": "0.1.72", + "version": "0.2.0-beta.1", "private": false, "description": "Claude adapter surface for LongTable", "type": "module", @@ -21,9 +21,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@longtable/checkpoints": "0.1.72", - "@longtable/core": "0.1.72", - "@longtable/setup": "0.1.72" + "@longtable/checkpoints": "0.2.0-beta.1", + "@longtable/core": "0.2.0-beta.1", + "@longtable/setup": "0.2.0-beta.1" }, "devDependencies": { "typescript": "^5.6.0" diff --git a/packages/longtable-provider-claude/src/skills.ts b/packages/longtable-provider-claude/src/skills.ts index 0e21877f..a8147418 100644 --- a/packages/longtable-provider-claude/src/skills.ts +++ b/packages/longtable-provider-claude/src/skills.ts @@ -27,7 +27,20 @@ const COMPACT_ROLE_SKILL_NAMES: Record = { voice_keeper: "longtable-voice" }; -const DEPRECATED_CLAUDE_SKILL_NAMES = ["critical-interview"] as const; +const LEGACY_CLAUDE_SKILL_NAMES = [ + "critical-interview", + "scholar-research", + "longtable-start", + "longtable-interview", + "longtable-panel", + "longtable-explore", + "longtable-review", + "longtable-methods", + "longtable-measure", + "longtable-theory", + "longtable-reviewer", + "longtable-voice" +] as const; export function resolveClaudeSkillsDir(customDir?: string): string { return customDir ? resolve(customDir) : join(homedir(), ".claude", "skills"); @@ -63,7 +76,7 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): ClaudeSkill const specs = [ { name: "longtable", - description: "Use for LongTable research conversations, project memory, checkpointing, and role routing.", + description: "Use as the LongTable research front door for problem diagnosis, a bounded Research Brief, workspace memory, Research Assurance, and routing into LongTable Research.", triggers: ["longtable", "lt ", "research workspace", "research checkpoint"], body: [ "## Purpose", @@ -77,23 +90,22 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): ClaudeSkill "- `longtable: help me narrow this project`", "- `lt explore: ...`", "- `lt review: ...`", - "- `lt panel: ...`", - "- `longtable: run a panel review of this measurement plan, show the main disagreements, and ask me what decision should be recorded before you revise it.`", - "- `longtable: use editor, reviewer, methods, measurement, and voice perspectives as a panel to evaluate this manuscript section. Do not collapse disagreement too early.`", - "- `$longtable-methods`, `$longtable-measure`, `$longtable-theory`, `$longtable-reviewer`, or `$longtable-voice` when the researcher explicitly wants that shortcut.", - "- `$longtable-start` to create or continue the first research-start interview.", - "- `$longtable-interview` to run a LongTable grilling interview with one high-leverage pressure question at a time.", - "- `$scholar-research` for scholarly evidence recovery, citation-slot research, and legal full-text readiness.", + "- `lt assure: review this high-risk decision`", + "- `longtable: run Research Assurance on this measurement plan, preserve disagreement, and interrupt me only if a decision is genuinely blocking.`", + "- Natural-language requests for research start, diagnosis, review, or a high-risk assurance pass.", + "- `$longtable-research` for scholarly evidence recovery, citation-slot research, and legal full-text readiness.", "", "## Rules", "", "- Treat `.longtable/` state as the project source of truth when present.", - "- The compact visible shortcut set is panel, methods, measure, theory, reviewer, and voice. Other roles remain available through this router when the request calls for them.", + "- The installation exposes exactly `$longtable` and `$longtable-research`; separate start, interview, panel, and role skills are removed.", + "- Ask at most three clarifying questions before presenting a versioned Research Brief and the choices: start LongTable Research, keep shaping, or save and stop.", + "- Treat methods, measurement, theory, reviewer, editor, ethics, venue, and voice as internal Research Assurance lenses.", "- Prefer natural language over asking the researcher to run shell role commands.", "- For systematic review, meta-analysis, PDF collection, full-text extraction, institutionally licensed sources, or TDM work, ensure `longtable access setup` readiness exists or surface an ACCESS CHECKPOINT before continuing.", "- Access setup records capability status only. The researcher handles VPN/proxy/library/SSO login directly; LongTable must not store passwords, API keys, tokens, PDFs, or full text in setup state.", - "- For `$longtable-start`, use natural-language turns for the interview and reserve structured options for final Research Specification confirmation; First Research Shape is only a shorter handle/resume layer.", - "- For `$longtable-interview`, run the compact pressure loop: read state, name the unresolved `Tension:`, ask exactly one `Pressure question:`, then wait. Continue only while the next question can produce a new decision.", + "- For the start route, use natural-language turns and reserve structured options for final Research Specification confirmation; First Research Shape is only a short resume handle.", + "- For the pressure-interview route, read state, name the unresolved `Tension:`, ask exactly one `Pressure question:`, then wait.", "- Keep unrelated pending Researcher Checkpoints separate. Treat them as blocking only when the researcher is confirming, saving, or recording a research decision.", "- If a Researcher Checkpoint is needed, ask a short structured question with meaningful options and wait for the researcher.", "- If changing LongTable product language, README positioning, or checkpoint policy, ask a Meta-Decision Checkpoint first.", @@ -109,9 +121,11 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): ClaudeSkill "- Disclose consulted roles with `LongTable consulted: ...` when a role is foregrounded.", "- Label unsupported external claims as inference or estimate.", "", - "## Panel And Question Ordering", + "## Research Assurance And Question Ordering", "", - "- If the user asks for a panel, team-style review, debate, disagreement, or multiple perspectives, use the panel surface and expose a structured deliberation record instead of a one-line summary.", + "- Treat panel, team-style review, debate, disagreement, or multiple perspectives as compatibility language for Research Assurance.", + "- Run Assurance only at scope, access/corpus, evidence/claim-strength, visual-evidence, or external-action boundaries, and interrupt only for a hard failure or unresolved disagreement.", + "- Provider-native multi-agent execution is an adapter, not the LongTable product contract.", "- The structured deliberation record must include: roles consulted, each role's main claim or objection, the disagreement map, the decision options, the recommended option when defensible, and the exact researcher-facing question.", "- If the panel converges, explain what changed the disagreement; if it does not converge, preserve the unresolved conflict instead of collapsing it into one confident synthesis.", "- Ask and stop when missing context would decide a high-risk research commitment.", @@ -125,22 +139,24 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): ClaudeSkill ] }, { - name: "scholar-research", - description: "LongTable scholarly evidence recovery and citation-slot research using host-agent orchestration, legal OA/fulltext connectors, evidence ledgers, and Researcher Checkpoints.", - triggers: ["$scholar-research", "scholar research", "citation slot", "scholarly evidence", "PDF evidence"], + name: "longtable-research", + description: "Use LongTable Research for journal-grounded discovery, lawful full-text recovery, verified extraction and synthesis, professional research visuals, and a Verified Research Package.", + triggers: ["$longtable-research", "scholar research", "citation slot", "scholarly evidence", "PDF evidence"], body: [ - "Run a LongTable scholar-research session.", + "Run LongTable Research.", "", - "Treat `.longtable/` as the source of truth. Recover only legally accessible scholarly evidence. Do not bypass paywalls, authentication, robots.txt, WAFs, or access controls. Use host-agent orchestration for parallel research, but write journal, evidence ledger, claim ledger, fallback ledger, and citation-slot matrix into `.longtable/research-runs//`.", + "Treat `.longtable/` as the source of truth. Consume an existing Research Brief without repeating its questions. Recover only legally accessible scholarly evidence. Provider-native agents may execute bounded tasks; LongTable owns the versioned bundle, provenance, assurance, and human gates.", "", "## Required Flow", "", - "1. Start with a citation-slot matrix or DOI/title/URL seed batch; free-form literature search is post-MVP.", - "2. Run `longtable scholar-research doctor` before evidence recovery and surface any missing connector readiness.", - "3. Create or reuse a run scaffold with `longtable scholar-research scaffold --cwd --json`.", - "4. Search in this order: DOI/title seed, Crossref/OpenAlex/Semantic Scholar metadata, arXiv/SSRN/ERIC/PubMed/PMC/CORE/DOAJ/repository sweep, publisher landing page, legal PDF/full text, fallback ledger.", - "5. Mark citation slots `filled` only when full text produced an extracted quote/claim. Metadata, abstract, or fallback evidence is `provisional`, not filled.", - "6. Stop for a Researcher Checkpoint when access is restricted, when a strong claim has weak evidence, or when synthesis would change the research direction.", + "1. Run `longtable research doctor` before evidence recovery and surface missing connector readiness.", + "2. Use `longtable research run --query ` for the durable topic, venue, collection, full-text, extraction, visual, verification, and handoff workflow. Use `resume`; do not recreate completed stages.", + "3. Search in this order: DOI/title seed, Crossref/OpenAlex/Semantic Scholar metadata, arXiv/ERIC/PubMed/PMC/CORE/DOAJ/repository sweep, publisher landing page, and legal PDF/full text.", + "4. A local PDF corpus requires `--pdf-dir` and an explicit `--pdf-access public_oa|manual_legitimate_access|licensed_tdm|private`. Do not guess its access class.", + "5. When the run pauses at extract, read only the bounded packets under `provider-tasks/extract/`. Return a `longtable.provider-proposed-patch`; preserve exact quote, locator, hash, version, and access class.", + "6. Provider proposals may create only `provisional` slots. `filled` requires independent verification and human review under the strict evidence contract.", + "7. Preserve supports, qualifies, and contradicts relations. Do not collapse conflicts because two providers agree.", + "8. Stop for a Researcher Checkpoint when access is restricted, evidence changes direction or claim strength, a Visual Evidence Contract needs approval, or an external action is requested.", "", "## Safety Boundary", "", @@ -157,7 +173,14 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): ClaudeSkill "- `.longtable/research-runs//evidence-ledger.md`", "- `.longtable/research-runs//fallback-ledger.md`", "- `.longtable/research-runs//citation-slot-matrix.md`", - "- `.longtable/research-runs//sources/manifest.jsonl`" + "- `.longtable/research-runs//sources/manifest.jsonl`", + "- `.longtable/research-runs//research-bundle.json`", + "- `.longtable/research-runs//events.jsonl`", + "- `.longtable/research-runs//provider-tasks/extract/manifest.json`", + "- `.longtable/research-runs//research-package/README.md`", + "- `.longtable/research-runs//research-package/package-manifest.json`", + "", + "`$scholar-research` and `longtable scholar-research` are one-release compatibility aliases only." ] }, { @@ -378,15 +401,11 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): ClaudeSkill ] } ]; - return surface === "full" - ? specs - : specs.filter((spec) => - spec.name === "longtable" || - spec.name === "scholar-research" || - spec.name === "longtable-start" || - spec.name === "longtable-interview" || - spec.name === "longtable-panel" - ); + void surface; + return specs.filter((spec) => + spec.name === "longtable" || + spec.name === "longtable-research" + ); } function mustAskQuestionsForRole(role: RoleDefinition): string[] { @@ -438,7 +457,7 @@ function roleSkillSpec(role: RoleDefinition, surface: LongTableSkillSurface = "c "", "- Keep `longtable-reviewer` as the visible compact surface; use Journal Editor and Venue Strategist as internal lenses.", "- When a target journal, submission venue, reference paper set, or journal-ready claim is named, ground feedback in a `Journal Profile`: aims/scope, author guidance, recent article pattern, and article type expectations.", - "- Use `scholar-research` or `longtable search --intent venue` to build or refresh a `Reference Pattern Matrix` before making source-backed journal-fit claims.", + "- Use `longtable-research` or `longtable search --intent venue` to build or refresh a `Reference Pattern Matrix` before making source-backed journal-fit claims.", "- Read available reference papers and compare decision structure, paper flow, standardized terminology, Figure/Table conventions, and APA 7 style expectations.", "- Return reviewer feedback as: editor-facing contribution claim, reviewer objection, Venue Strategist tradeoff, evidence gap, revision action, and Researcher Checkpoint when the recommendation changes venue positioning." ] @@ -494,26 +513,26 @@ function roleSkillSpec(role: RoleDefinition, surface: LongTableSkillSurface = "c }; } -function compactRoles(roles: RoleDefinition[]): RoleDefinition[] { - return roles.filter((role) => Object.hasOwn(COMPACT_ROLE_SKILL_NAMES, role.key)); +function compactRoles(_roles: RoleDefinition[]): RoleDefinition[] { + return []; } -function allClaudeSkillSpecs(roles: RoleDefinition[]): ClaudeSkillSpec[] { - const byName = new Map(); - for (const spec of [...buildClaudeSkillSpecs(roles, "compact"), ...buildClaudeSkillSpecs(roles, "full")]) { - byName.set(spec.name, spec); - } - return [...byName.values()]; +function legacyClaudeSkillNames(roles: RoleDefinition[]): string[] { + return [...new Set([ + ...LEGACY_CLAUDE_SKILL_NAMES, + ...roles.flatMap((role) => [ + skillNameForRole(role, "compact"), + skillNameForRole(role, "full") + ]) + ])]; } export function buildClaudeSkillSpecs( roles: RoleDefinition[], surface: LongTableSkillSurface = "compact" ): ClaudeSkillSpec[] { - const roleSpecs = (surface === "compact" ? compactRoles(roles) : roles).map((role) => - roleSkillSpec(role, surface) - ); - return [...baseSkillSpecs(surface), ...roleSpecs]; + void roles; + return baseSkillSpecs(surface); } export async function installClaudeSkills( @@ -525,13 +544,7 @@ export async function installClaudeSkills( await mkdir(skillsDir, { recursive: true }); const specs = buildClaudeSkillSpecs(roles, surface); - const selectedNames = new Set(specs.map((spec) => spec.name)); - for (const spec of allClaudeSkillSpecs(roles)) { - if (!selectedNames.has(spec.name)) { - await rm(join(skillsDir, spec.name), { recursive: true, force: true }); - } - } - for (const skillName of DEPRECATED_CLAUDE_SKILL_NAMES) { + for (const skillName of legacyClaudeSkillNames(roles)) { await rm(join(skillsDir, skillName), { recursive: true, force: true }); } @@ -558,14 +571,14 @@ export async function removeClaudeSkills( const skillsDir = resolveClaudeSkillsDir(customDir); const removed: string[] = []; - for (const spec of allClaudeSkillSpecs(roles)) { + for (const spec of buildClaudeSkillSpecs(roles)) { const skillDir = join(skillsDir, spec.name); if (existsSync(skillDir)) { await rm(skillDir, { recursive: true, force: true }); removed.push(skillDir); } } - for (const skillName of DEPRECATED_CLAUDE_SKILL_NAMES) { + for (const skillName of legacyClaudeSkillNames(roles)) { const skillDir = join(skillsDir, skillName); if (existsSync(skillDir)) { await rm(skillDir, { recursive: true, force: true }); diff --git a/packages/longtable-provider-codex/package.json b/packages/longtable-provider-codex/package.json index f98f930a..8eb58fd5 100644 --- a/packages/longtable-provider-codex/package.json +++ b/packages/longtable-provider-codex/package.json @@ -1,6 +1,6 @@ { "name": "@longtable/provider-codex", - "version": "0.1.72", + "version": "0.2.0-beta.1", "private": false, "description": "Codex adapter surface for LongTable", "type": "module", @@ -15,9 +15,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@longtable/checkpoints": "0.1.72", - "@longtable/core": "0.1.72", - "@longtable/setup": "0.1.72" + "@longtable/checkpoints": "0.2.0-beta.1", + "@longtable/core": "0.2.0-beta.1", + "@longtable/setup": "0.2.0-beta.1" }, "devDependencies": { "@types/node": "^22.10.1", diff --git a/packages/longtable-provider-codex/src/skills.ts b/packages/longtable-provider-codex/src/skills.ts index 634dc1ac..53ba04f9 100644 --- a/packages/longtable-provider-codex/src/skills.ts +++ b/packages/longtable-provider-codex/src/skills.ts @@ -27,7 +27,20 @@ const COMPACT_ROLE_SKILL_NAMES: Record = { voice_keeper: "longtable-voice" }; -const DEPRECATED_CODEX_SKILL_NAMES = ["critical-interview"] as const; +const LEGACY_CODEX_SKILL_NAMES = [ + "critical-interview", + "scholar-research", + "longtable-start", + "longtable-interview", + "longtable-panel", + "longtable-explore", + "longtable-review", + "longtable-methods", + "longtable-measure", + "longtable-theory", + "longtable-reviewer", + "longtable-voice" +] as const; export function resolveCodexSkillsDir(customDir?: string): string { return customDir ? resolve(customDir) : join(homedir(), ".codex", "skills"); @@ -63,7 +76,7 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): CodexSkillS { name: "longtable", description: - "Use for LongTable research conversations, lt explore/review/panel requests, researcher checkpoints, and role routing.", + "Use as the LongTable research front door for problem diagnosis, a bounded Research Brief, workspace memory, Research Assurance, and routing into LongTable Research.", body: [ "## Purpose", "", @@ -76,38 +89,38 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): CodexSkillS "- `longtable: help me narrow this project`", "- `lt explore: ...`", "- `lt review: ...`", - "- `lt panel: ...`", + "- `lt assure: review this high-risk decision`", "- `use the LongTable methods critic on this design`", - "- `$longtable: run a panel review of this measurement plan, show the main disagreements, and ask me what decision should be recorded before you revise it.`", - "- `$longtable: use editor, reviewer, methods, measurement, and voice perspectives as a panel to evaluate this manuscript section. Do not collapse disagreement too early.`", - "- `$longtable-methods`, `$longtable-measure`, `$longtable-theory`, `$longtable-reviewer`, or `$longtable-voice` when the researcher explicitly wants that shortcut.", - "- `$longtable-start` to create or continue the first research-start interview.", - "- `$longtable-interview` to run a LongTable grilling interview with one high-leverage pressure question at a time.", - "- `$scholar-research` for scholarly evidence recovery, citation-slot research, and legal full-text readiness.", + "- `$longtable: run Research Assurance on this measurement plan, preserve disagreement, and interrupt me only if a decision is genuinely blocking.`", + "- Natural-language requests for research start, diagnosis, review, or a high-risk assurance pass.", + "- `$longtable-research` for scholarly evidence recovery, citation-slot research, and legal full-text readiness.", "", "## Routing Rules", "", "- `lt explore` keeps the problem open and asks clarifying or tension questions before recommending closure.", "- `lt review` starts with the strongest weakness, objection, or missing evidence.", - "- `lt panel` creates a visible multi-role review with synthesis, role opinions, conflict summary, and a decision prompt.", - "- `$longtable-start` runs the provider-native LongTable start interview. It replaces the old CLI start questionnaire as the primary research-start surface.", - "- `$longtable-interview` is the grilling interview surface. It can use Research Specification state when present, but it does not require one to ask the next pressure question.", - "- Natural references to methods, measurement, theory, reviewer, editor, ethics, venue, or voice should foreground the matching LongTable role.", - "- The compact visible shortcut set is panel, methods, measure, theory, reviewer, and voice. Other roles remain available through this router when the request calls for them.", + "- `lt assure` invokes Research Assurance. Provider-native agents may execute lenses; LongTable owns the evidence, disagreement, and checkpoint contract.", + "- A start or resume intent routes inside `$longtable` to the provider-native research-start interview.", + "- A grilling or pressure-interview intent routes inside `$longtable` to the one-question pressure loop.", + "- Natural references to methods, measurement, theory, reviewer, editor, ethics, venue, or voice select internal Research Assurance lenses rather than separate public skills.", + "- The installation exposes exactly `$longtable` and `$longtable-research`; the former full surface and separate role/panel/start/interview skills are removed.", + "- Ask at most three clarifying questions before presenting a versioned Research Brief and the choices: start LongTable Research, keep shaping, or save and stop.", + "- The Research Brief is the provider-neutral handoff. Do not ask LongTable Research to repeat fields already recorded there.", "- When research responsibility is about to shift, surface a Researcher Checkpoint before closure.", "- When changing LongTable product language, README positioning, or checkpoint policy, surface a Meta-Decision Checkpoint first.", "- Stop before acting when the request would change the research question/scope, theory frame, measurement/coding standard, method design, or analysis strategy.", "- For low-risk reversible work, proceed with explicit assumptions instead of interrupting.", "- For LongTable product, hook, setup, release, or documentation work, do not create research-state QuestionRecords.", "", - "## Panel Behavior", + "## Research Assurance", "", - "- If the user asks for a panel, disagreement, team-style review, debate, or multiple perspectives, route to the LongTable panel surface first.", - "- Treat `lt panel` as the primary in-Codex collaborative surface; keep deprecated team-command aliases hidden and disabled.", - "- Use LongTable's sequential panel fallback as the stable surface and make the fallback explicit in the technical record.", + "- Treat panel, disagreement, team-style review, debate, or multiple perspectives as compatibility language for Research Assurance.", + "- Run Assurance automatically only at scope, access/corpus, evidence/claim-strength, visual-evidence, or external-action boundaries.", + "- Keep Assurance quiet unless a non-compensatory failure or unresolved disagreement requires researcher judgment.", + "- Provider-native multi-agent execution is an adapter. Do not present agent spawning as the LongTable product.", "- Do not expose hidden reasoning, tool logs, or private chain-of-thought. Expose a structured deliberation record instead.", "- A structured deliberation record must include: roles consulted, each role's main claim or objection, the disagreement map, the decision options, the recommended option when defensible, and the exact researcher-facing question.", - "- If the panel converges, explain what changed the disagreement; if it does not converge, preserve the unresolved conflict instead of collapsing it into one confident synthesis.", + "- If the review converges, record what resolved the disagreement; otherwise preserve the unresolved conflict.", "", "## Question Ordering", "", @@ -125,8 +138,8 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): CodexSkillS "- Treat provider-native question UI as transport; LongTable state records are the source of truth.", "- For systematic review, meta-analysis, PDF collection, full-text extraction, institutionally licensed sources, or TDM work, ensure `longtable access setup` readiness exists or surface an ACCESS CHECKPOINT before continuing.", "- Access setup records capability status only. The researcher handles VPN/proxy/library/SSO login directly; LongTable must not store passwords, API keys, tokens, PDFs, or full text in setup state.", - "- For `$longtable-start`, use natural-language turns for the interview and reserve MCP option UI for the final research-facing Research Specification checkpoint; First Research Shape is only a shorter handle/resume layer.", - "- For `$longtable-interview`, run the compact pressure loop: read state, name the unresolved `Tension:`, ask exactly one `Pressure question:`, then wait. Continue only while the next question can produce a new decision.", + "- For the start route, use natural-language turns and reserve MCP option UI for the final Research Specification checkpoint; First Research Shape is only a short resume handle.", + "- For the pressure-interview route, read state, name the unresolved `Tension:`, ask exactly one `Pressure question:`, then wait.", "- Keep unrelated pending Researcher Checkpoints separate. Treat them as blocking only when the researcher is confirming, saving, or recording a research decision.", "- When the `mcp__longtable_state__.elicit_question` tool is available, use it first for researcher checkpoints so Codex can show the MCP elicitation UI and LongTable can record the answer as `mcp_elicitation`.", "- Use `longtable question --print --provider codex --prompt \"...\"` only as a fallback when the MCP tool is unavailable, unsupported, declined, canceled, or blocked by the client.", @@ -136,22 +149,24 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): CodexSkillS ] }, { - name: "scholar-research", + name: "longtable-research", description: - "LongTable scholarly evidence recovery and citation-slot research using host-agent orchestration, legal OA/fulltext connectors, evidence ledgers, and Researcher Checkpoints.", + "Use LongTable Research for journal-grounded discovery, lawful full-text recovery, verified extraction and synthesis, professional research visuals, and a Verified Research Package.", body: [ - "Run a LongTable scholar-research session.", + "Run LongTable Research.", "", - "Treat `.longtable/` as the source of truth. Recover only legally accessible scholarly evidence. Do not bypass paywalls, authentication, robots.txt, WAFs, or access controls. Use host-agent orchestration for parallel research, but write journal, evidence ledger, claim ledger, fallback ledger, and citation-slot matrix into `.longtable/research-runs//`.", + "Treat `.longtable/` as the source of truth. Consume an existing Research Brief without repeating its questions. Recover only legally accessible scholarly evidence. Provider-native agents may execute bounded tasks; LongTable owns the versioned bundle, provenance, assurance, and human gates.", "", "## Required Flow", "", - "1. Start with a citation-slot matrix or DOI/title/URL seed batch; free-form literature search is post-MVP.", - "2. Run `longtable scholar-research doctor` before evidence recovery and surface any missing connector readiness.", - "3. Create or reuse a run scaffold with `longtable scholar-research scaffold --cwd --json`.", - "4. Search in this order: DOI/title seed, Crossref/OpenAlex/Semantic Scholar metadata, arXiv/SSRN/ERIC/PubMed/PMC/CORE/DOAJ/repository sweep, publisher landing page, legal PDF/full text, fallback ledger.", - "5. Mark citation slots `filled` only when full text produced an extracted quote/claim. Metadata, abstract, or fallback evidence is `provisional`, not filled.", - "6. Stop for a Researcher Checkpoint when access is restricted, when a strong claim has weak evidence, or when synthesis would change the research direction.", + "1. Run `longtable research doctor` before evidence recovery and surface missing connector readiness.", + "2. Use `longtable research run --query ` for the durable topic, venue, collection, full-text, extraction, visual, verification, and handoff workflow. Use `resume`; do not recreate completed stages.", + "3. Search in this order: DOI/title seed, Crossref/OpenAlex/Semantic Scholar metadata, arXiv/ERIC/PubMed/PMC/CORE/DOAJ/repository sweep, publisher landing page, and legal PDF/full text.", + "4. A local PDF corpus requires `--pdf-dir` and an explicit `--pdf-access public_oa|manual_legitimate_access|licensed_tdm|private`. Do not guess its access class.", + "5. When the run pauses at extract, read only the bounded packets under `provider-tasks/extract/`. Return a `longtable.provider-proposed-patch`; preserve exact quote, locator, hash, version, and access class.", + "6. Provider proposals may create only `provisional` slots. `filled` requires independent verification and human review under the strict evidence contract.", + "7. Preserve supports, qualifies, and contradicts relations. Do not collapse conflicts because two providers agree.", + "8. Stop for a Researcher Checkpoint when access is restricted, evidence changes direction or claim strength, a Visual Evidence Contract needs approval, or an external action is requested.", "", "## Safety Boundary", "", @@ -168,7 +183,14 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): CodexSkillS "- `.longtable/research-runs//evidence-ledger.md`", "- `.longtable/research-runs//fallback-ledger.md`", "- `.longtable/research-runs//citation-slot-matrix.md`", - "- `.longtable/research-runs//sources/manifest.jsonl`" + "- `.longtable/research-runs//sources/manifest.jsonl`", + "- `.longtable/research-runs//research-bundle.json`", + "- `.longtable/research-runs//events.jsonl`", + "- `.longtable/research-runs//provider-tasks/extract/manifest.json`", + "- `.longtable/research-runs//research-package/README.md`", + "- `.longtable/research-runs//research-package/package-manifest.json`", + "", + "`$scholar-research` and `longtable scholar-research` are one-release compatibility aliases only." ] }, { @@ -424,7 +446,9 @@ function baseSkillSpecs(surface: LongTableSkillSurface = "compact"): CodexSkillS ] } ]; - return surface === "full" ? [...base, ...fullOnly] : base; + void surface; + void fullOnly; + return base.filter((spec) => spec.name === "longtable" || spec.name === "longtable-research"); } function mustAskQuestionsForRole(role: RoleDefinition): string[] { @@ -476,7 +500,7 @@ function roleSkillSpec(role: RoleDefinition, surface: LongTableSkillSurface = "c "", "- Keep `longtable-reviewer` as the visible compact surface; use Journal Editor and Venue Strategist as internal lenses.", "- When a target journal, submission venue, reference paper set, or journal-ready claim is named, ground feedback in a `Journal Profile`: aims/scope, author guidance, recent article pattern, and article type expectations.", - "- Use `scholar-research` or `longtable search --intent venue` to build or refresh a `Reference Pattern Matrix` before making source-backed journal-fit claims.", + "- Use `longtable-research` or `longtable search --intent venue` to build or refresh a `Reference Pattern Matrix` before making source-backed journal-fit claims.", "- Read available reference papers and compare decision structure, paper flow, standardized terminology, Figure/Table conventions, and APA 7 style expectations.", "- Return reviewer feedback as: editor-facing contribution claim, reviewer objection, Venue Strategist tradeoff, evidence gap, revision action, and Researcher Checkpoint when the recommendation changes venue positioning." ] @@ -531,26 +555,26 @@ function roleSkillSpec(role: RoleDefinition, surface: LongTableSkillSurface = "c }; } -function compactRoles(roles: RoleDefinition[]): RoleDefinition[] { - return roles.filter((role) => Object.hasOwn(COMPACT_ROLE_SKILL_NAMES, role.key)); +function compactRoles(_roles: RoleDefinition[]): RoleDefinition[] { + return []; } -function allCodexSkillSpecs(roles: RoleDefinition[]): CodexSkillSpec[] { - const byName = new Map(); - for (const spec of [...buildCodexSkillSpecs(roles, "compact"), ...buildCodexSkillSpecs(roles, "full")]) { - byName.set(spec.name, spec); - } - return [...byName.values()]; +function legacyCodexSkillNames(roles: RoleDefinition[]): string[] { + return [...new Set([ + ...LEGACY_CODEX_SKILL_NAMES, + ...roles.flatMap((role) => [ + skillNameForRole(role, "compact"), + skillNameForRole(role, "full") + ]) + ])]; } export function buildCodexSkillSpecs( roles: RoleDefinition[], surface: LongTableSkillSurface = "compact" ): CodexSkillSpec[] { - const roleSpecs = (surface === "compact" ? compactRoles(roles) : roles).map((role) => - roleSkillSpec(role, surface) - ); - return [...baseSkillSpecs(surface), ...roleSpecs]; + void roles; + return baseSkillSpecs(surface); } export async function installCodexSkills( @@ -562,13 +586,7 @@ export async function installCodexSkills( await mkdir(skillsDir, { recursive: true }); const specs = buildCodexSkillSpecs(roles, surface); - const selectedNames = new Set(specs.map((spec) => spec.name)); - for (const spec of allCodexSkillSpecs(roles)) { - if (!selectedNames.has(spec.name)) { - await rm(join(skillsDir, spec.name), { recursive: true, force: true }); - } - } - for (const skillName of DEPRECATED_CODEX_SKILL_NAMES) { + for (const skillName of legacyCodexSkillNames(roles)) { await rm(join(skillsDir, skillName), { recursive: true, force: true }); } @@ -595,14 +613,14 @@ export async function removeCodexSkills( const skillsDir = resolveCodexSkillsDir(customDir); const removed: string[] = []; - for (const spec of allCodexSkillSpecs(roles)) { + for (const spec of buildCodexSkillSpecs(roles)) { const skillDir = join(skillsDir, spec.name); if (existsSync(skillDir)) { await rm(skillDir, { recursive: true, force: true }); removed.push(skillDir); } } - for (const skillName of DEPRECATED_CODEX_SKILL_NAMES) { + for (const skillName of legacyCodexSkillNames(roles)) { const skillDir = join(skillsDir, skillName); if (existsSync(skillDir)) { await rm(skillDir, { recursive: true, force: true }); diff --git a/packages/longtable-research-search/README.md b/packages/longtable-research-search/README.md index 45d4de37..18d7afb2 100644 --- a/packages/longtable-research-search/README.md +++ b/packages/longtable-research-search/README.md @@ -1,4 +1,4 @@ # @longtable/research-search -Deprecated compatibility package. Use `@longtable/scholar-research` for new +Deprecated compatibility package. Use `@longtable/research` for new LongTable scholarly evidence and citation-slot research work. diff --git a/packages/longtable-research-search/package.json b/packages/longtable-research-search/package.json index 298f2605..9385363d 100644 --- a/packages/longtable-research-search/package.json +++ b/packages/longtable-research-search/package.json @@ -1,8 +1,8 @@ { "name": "@longtable/research-search", - "version": "0.1.72", + "version": "0.2.0-beta.1", "private": false, - "description": "Deprecated compatibility layer for @longtable/scholar-research", + "description": "Deprecated compatibility layer for @longtable/research", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -21,7 +21,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@longtable/scholar-research": "0.1.72" + "@longtable/research": "0.2.0-beta.1" }, "devDependencies": { "typescript": "^5.6.0" diff --git a/packages/longtable-research-search/src/index.ts b/packages/longtable-research-search/src/index.ts index 04943693..be6115c6 100644 --- a/packages/longtable-research-search/src/index.ts +++ b/packages/longtable-research-search/src/index.ts @@ -1 +1 @@ -export * from "@longtable/scholar-research"; +export * from "@longtable/research"; diff --git a/packages/longtable-research/README.md b/packages/longtable-research/README.md new file mode 100644 index 00000000..6bcc645a --- /dev/null +++ b/packages/longtable-research/README.md @@ -0,0 +1,22 @@ +# @longtable/research + +Canonical LongTable Research runtime for journal-to-artifact execution. + +It owns the versioned Research Brief handoff, lawful access plan, durable +ResearchBundle, search and full-text manifests, bounded provider packets, +independent verification, human evidence and visual gates, target-journal +profile, Research Assurance records, and Verified Research Package. + +The runtime never automates institutional login or bypasses paywalls, WAFs, +robots controls, VPN restrictions, or proxies. Research figures and tables are +deterministic, editable, and data/provenance-bound; image generation is limited +to clearly labeled brand or concept artwork. + +Use through: + +```sh +longtable research run --brief research-brief.json --cwd . +longtable research verify --cwd . --json +``` + +`@longtable/scholar-research` is a one-release compatibility wrapper. diff --git a/packages/longtable-research/package.json b/packages/longtable-research/package.json new file mode 100644 index 00000000..ed766113 --- /dev/null +++ b/packages/longtable-research/package.json @@ -0,0 +1,47 @@ +{ + "name": "@longtable/research", + "version": "0.2.0-beta.1", + "private": false, + "description": "LongTable Research: journal-grounded evidence, verified synthesis, and reproducible research packages", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "typescript": "^5.6.0" + }, + "keywords": [ + "longtable", + "research", + "scholarly", + "evidence", + "citation" + ], + "author": "Hosung You", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/HosungYou/LongTable.git" + }, + "homepage": "https://github.com/HosungYou/LongTable#readme", + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/longtable-research/src/access-plan.ts b/packages/longtable-research/src/access-plan.ts new file mode 100644 index 00000000..48ff3fb6 --- /dev/null +++ b/packages/longtable-research/src/access-plan.ts @@ -0,0 +1,115 @@ +export const LAWFUL_ACCESS_PLAN_VERSION = "1.0.0"; + +export type LawfulAccessRoute = + | "public_oa" + | "user_supplied_pdf" + | "licensed_tdm" + | "manual_institutional_browser"; + +export type LawfulAccessOutcome = + | "available" + | "not_found" + | "restricted_access" + | "robots_or_terms_blocked" + | "manual_handoff_required" + | "license_declaration_required" + | "download_failed" + | "parse_failed"; + +export interface LawfulAccessPlan { + schema: "longtable.lawful-access-plan"; + version: typeof LAWFUL_ACCESS_PLAN_VERSION; + planId: string; + createdAt: string; + routes: Array<{ + route: LawfulAccessRoute; + enabled: boolean; + authorizationBasis: string; + userAction?: string; + licenseDeclarationId?: string; + }>; + prohibitedMethods: string[]; + typedOutcomes: LawfulAccessOutcome[]; +} + +export function buildLawfulAccessPlan(input: { + runId: string; + allowPublicOa: boolean; + hasUserPdfDirectory: boolean; + pdfAccessClass?: string; +}): LawfulAccessPlan { + return { + schema: "longtable.lawful-access-plan", + version: LAWFUL_ACCESS_PLAN_VERSION, + planId: `access-${input.runId}`, + createdAt: new Date().toISOString(), + routes: [ + { + route: "public_oa", + enabled: input.allowPublicOa, + authorizationBasis: "Publicly accessible OA or repository route; credentials omitted." + }, + { + route: "user_supplied_pdf", + enabled: input.hasUserPdfDirectory, + authorizationBasis: input.pdfAccessClass + ? `User declared access class: ${input.pdfAccessClass}.` + : "Disabled until the user declares the lawful access class." + }, + { + route: "licensed_tdm", + enabled: input.pdfAccessClass === "licensed_tdm", + authorizationBasis: "Requires an explicit local license declaration; no credential material is recorded.", + licenseDeclarationId: input.pdfAccessClass === "licensed_tdm" ? "user-declared-licensed-tdm" : undefined + }, + { + route: "manual_institutional_browser", + enabled: true, + authorizationBasis: "The researcher uses their own authorized browser session.", + userAction: "Download the lawful copy manually, then supply its local directory and access class. Do not export cookies or credentials." + } + ], + prohibitedMethods: [ + "paywall bypass", + "WAF or robots bypass", + "VPN or proxy rotation to evade controls", + "automated institutional login", + "cookie, token, password, or credential capture" + ], + typedOutcomes: [ + "available", + "not_found", + "restricted_access", + "robots_or_terms_blocked", + "manual_handoff_required", + "license_declaration_required", + "download_failed", + "parse_failed" + ] + }; +} + +export function validateLawfulAccessPlan(value: unknown): LawfulAccessPlan { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Lawful access plan must be an object."); + } + const plan = value as Partial; + if (plan.schema !== "longtable.lawful-access-plan" || plan.version !== LAWFUL_ACCESS_PLAN_VERSION) { + throw new Error(`Lawful access plan must use version ${LAWFUL_ACCESS_PLAN_VERSION}.`); + } + if (!Array.isArray(plan.routes) || plan.routes.length !== 4) { + throw new Error("Lawful access plan must declare all four supported route classes."); + } + const serialized = JSON.stringify(plan).toLowerCase(); + for (const secretField of ["\"password\"", "\"cookie\"", "\"authorization\"", "\"token\"", "\"secret\""]) { + if (serialized.includes(secretField)) { + throw new Error("Lawful access plans may not store credentials, cookies, tokens, or secrets."); + } + } + if (!Array.isArray(plan.prohibitedMethods) || + !plan.prohibitedMethods.some((item) => /paywall/i.test(item)) || + !plan.prohibitedMethods.some((item) => /institutional login/i.test(item))) { + throw new Error("Lawful access plan must preserve non-bypass and non-login-automation boundaries."); + } + return plan as LawfulAccessPlan; +} diff --git a/packages/longtable-research/src/evaluation.ts b/packages/longtable-research/src/evaluation.ts new file mode 100644 index 00000000..ba48d765 --- /dev/null +++ b/packages/longtable-research/src/evaluation.ts @@ -0,0 +1,1030 @@ +import { createHash } from "node:crypto"; +import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export const SCHOLAR_RESEARCH_EVALUATION_TASKS = [ + { id: "pinpoint-1", family: "pinpoint_retrieval" }, + { id: "pinpoint-2", family: "pinpoint_retrieval" }, + { id: "pinpoint-3", family: "pinpoint_retrieval" }, + { id: "pinpoint-4", family: "pinpoint_retrieval" }, + { id: "discovery-1", family: "open_topic_venue" }, + { id: "discovery-2", family: "open_topic_venue" }, + { id: "discovery-3", family: "open_topic_venue" }, + { id: "fulltext-1", family: "fulltext_claim_extraction" }, + { id: "fulltext-2", family: "fulltext_claim_extraction" }, + { id: "fulltext-3", family: "fulltext_claim_extraction" }, + { id: "visual-e2e-1", family: "visual_manuscript_e2e" }, + { id: "visual-e2e-2", family: "visual_manuscript_e2e" } +] as const; + +export interface ScholarResearchEvaluationPlan { + schema: "longtable.scholar-research-evaluation-plan"; + version: 1; + createdAt: string; + planId: string; + layers: ["fixtures", "replay", "prospective"]; + repeatsPerFrozenCondition: 3; + taskSplit: { + engineering: string[]; + calibration: string[]; + lockedHoldout: string[]; + }; + tasks: typeof SCHOLAR_RESEARCH_EVALUATION_TASKS; + replayCases: Array<{ + threadId: string; + posture: "replay_only"; + criteria: string[]; + }>; + conditions: ["workflow_off", "workflow_on", "frontier_ceiling"]; + qualityHardGates: string[]; + productivityMetrics: string[]; + promotionRule: string; +} + +export const EVALUATION_QUALITY_GATES = [ + "unsupportedFinalClaim", + "wrongSourceVersionOrCitation", + "visualDistortion", + "prohibitedAccess", + "preapprovalMutation" +] as const; + +export const PROSPECTIVE_TRIAL_PROTECTED_STAGES = [ + "legal-access-boundary", + "source-version-provenance", + "quality-hard-gates" +] as const; + +export interface ScholarResearchEvaluationObservation { + schema: "longtable.scholar-research-evaluation-observation"; + version: 1; + observationId: string; + planId: string; + taskId: string; + layer: "fixture" | "replay" | "prospective"; + condition: "workflow_off" | "workflow_on" | "frontier_ceiling"; + crossoverPairId?: string; + provider: string; + model: string; + modelVersion: string; + corpusCutoff: string; + permissionsProfile: string; + stageProfile: string[]; + startedAt: string; + acceptedArtifactAt?: string; + completedAt: string; + quality: Record; + metrics: { + elapsedMs: number; + activeHumanMs: number; + interruptionCount: number; + repeatedQuestionCount: number; + contextSwitchCount: number; + reworkCount: number; + toolCallCount: number; + tokenCount?: number; + monetaryCost?: number; + abandoned: boolean; + researcherEffort?: number; + researcherTrust?: number; + }; + artifactRefs: string[]; + notes: string[]; +} + +export interface ScholarResearchEvaluationReport { + schema: "longtable.scholar-research-evaluation-report"; + version: 1; + planId: string; + createdAt: string; + observationCount: number; + prospectiveCount: number; + matchedProspectivePairs: number; + conditionSummary: Array<{ + condition: ScholarResearchEvaluationObservation["condition"]; + observations: number; + qualityPasses: number; + qualityPassRate: number; + medianElapsedMs?: number; + medianActiveHumanMs?: number; + medianInterruptions?: number; + medianEffort?: number; + medianTrust?: number; + }>; + ablations: Array<{ + stage: string; + matchedPairs: number; + verdict: "retain" | "simplify_or_opt_in" | "remove_candidate" | "insufficient_evidence"; + qualityLossWhenRemoved: number; + medianActiveHumanMsDeltaWhenEnabled?: number; + medianElapsedMsDeltaWhenEnabled?: number; + medianEffortDeltaWhenEnabled?: number; + medianTrustDeltaWhenEnabled?: number; + reason: string; + }>; + promotionEligible: boolean; + limitations: string[]; +} + +export interface ProspectiveTrialConfig { + schema: "longtable.prospective-trial-config"; + version: 1; + planId: string; + trialId: string; + taskId: string; + researchQuestion: string; + targetJournal: string; + provider: string; + model: string; + modelVersion: string; + corpusCutoff: string; + permissionsProfile: string; + crossoverSeed: string; + workflowOnStages: string[]; + workflowOffStages: string[]; +} + +export interface ProspectiveTrialProtocol { + schema: "longtable.prospective-trial-protocol"; + version: 1; + planId: string; + trialId: string; + taskId: string; + createdAt: string; + researchQuestion: string; + targetJournal: string; + provider: string; + model: string; + modelVersion: string; + corpusCutoff: string; + permissionsProfile: string; + crossoverSeedHash: string; + conditionOrder: Array<{ + index: number; + condition: "workflow_off" | "workflow_on"; + stageProfile: string[]; + crossoverPairId: string; + }>; +} + +export interface ProspectiveTrialSession { + schema: "longtable.prospective-trial-session"; + version: 1; + planId: string; + trialId: string; + sessionId: string; + conditionIndex: number; + condition: "workflow_off" | "workflow_on"; + stageProfile: string[]; + crossoverPairId: string; + startedAt: string; + status: "running" | "completed"; + completedAt?: string; + observationId?: string; + resultHash?: string; +} + +export interface ProspectiveTrialSessionResult { + schema: "longtable.prospective-trial-session-result"; + version: 1; + acceptedArtifactAt?: string; + activeHumanMs: number; + interruptionCount: number; + repeatedQuestionCount: number; + contextSwitchCount: number; + reworkCount: number; + toolCallCount: number; + tokenCount?: number; + monetaryCost?: number; + abandoned: boolean; + researcherEffort?: number; + researcherTrust?: number; + quality: ScholarResearchEvaluationObservation["quality"]; + artifactRefs: string[]; + notes: string[]; +} + +export interface ProspectiveTrialTaskCapsule { + schema: "longtable.prospective-trial-task-capsule"; + version: 1; + planId: string; + trialId: string; + sessionId: string; + conditionIndex: number; + condition: "workflow_off" | "workflow_on"; + taskId: string; + researchQuestion: string; + targetJournal: string; + provider: string; + model: string; + modelVersion: string; + corpusCutoff: string; + permissionsProfile: string; + stageProfile: string[]; + protectedStages: string[]; + instruction: string; +} + +function normalizePlanId(value: string): string { + return value.trim().replace(/[^\w.-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "") || "scholar-evaluation"; +} + +function normalizeTrialId(value: string): string { + return value.trim().replace(/[^\w.-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "") || "prospective-trial"; +} + +function hash(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function uniqueStages(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + +function prospectiveTrialDirectory(cwd: string, planId: string, trialId: string): string { + return join( + observationDirectory(cwd, planId), + "trials", + normalizeTrialId(trialId) + ); +} + +function validateProspectiveTrialConfig(config: ProspectiveTrialConfig): string[] { + const errors: string[] = []; + if (config.schema !== "longtable.prospective-trial-config" || config.version !== 1) { + errors.push("Unsupported prospective trial config schema or version."); + } + for (const [field, value] of [ + ["planId", config.planId], + ["trialId", config.trialId], + ["taskId", config.taskId], + ["researchQuestion", config.researchQuestion], + ["targetJournal", config.targetJournal], + ["provider", config.provider], + ["model", config.model], + ["modelVersion", config.modelVersion], + ["corpusCutoff", config.corpusCutoff], + ["permissionsProfile", config.permissionsProfile], + ["crossoverSeed", config.crossoverSeed] + ] as const) { + if (typeof value !== "string" || !value.trim()) errors.push(`${field} is required.`); + } + if (!Array.isArray(config.workflowOnStages) || uniqueStages(config.workflowOnStages).length === 0) { + errors.push("workflowOnStages must contain at least one stage."); + } + if (!Array.isArray(config.workflowOffStages) || uniqueStages(config.workflowOffStages).length === 0) { + errors.push("workflowOffStages must contain at least one stage."); + } + const on = uniqueStages(config.workflowOnStages).sort(); + const off = uniqueStages(config.workflowOffStages).sort(); + if (JSON.stringify(on) === JSON.stringify(off)) { + errors.push("workflowOnStages and workflowOffStages must differ."); + } + return errors; +} + +function validateProspectiveTrialSessionResult( + result: ProspectiveTrialSessionResult +): string[] { + const errors: string[] = []; + if (result.schema !== "longtable.prospective-trial-session-result" || result.version !== 1) { + errors.push("Unsupported prospective trial session result schema or version."); + } + for (const field of [ + "activeHumanMs", + "interruptionCount", + "repeatedQuestionCount", + "contextSwitchCount", + "reworkCount", + "toolCallCount" + ] as const) { + const value = result[field]; + if (!Number.isFinite(value) || value < 0) { + errors.push(`${field} must be finite and non-negative.`); + } + } + for (const field of ["tokenCount", "monetaryCost"] as const) { + const value = result[field]; + if (value !== undefined && (!Number.isFinite(value) || value < 0)) { + errors.push(`${field} must be finite and non-negative when supplied.`); + } + } + for (const field of ["researcherEffort", "researcherTrust"] as const) { + const value = result[field]; + if (value !== undefined && (!Number.isFinite(value) || value < 1 || value > 7)) { + errors.push(`${field} must be between 1 and 7 when supplied.`); + } + } + for (const gate of EVALUATION_QUALITY_GATES) { + if (typeof result.quality?.[gate] !== "boolean") { + errors.push(`Quality gate ${gate} must be a boolean failure flag.`); + } + } + if (!Array.isArray(result.artifactRefs) || result.artifactRefs.length === 0 || + result.artifactRefs.some((value) => typeof value !== "string" || !value.trim())) { + errors.push("artifactRefs must contain at least one non-empty reference."); + } + if (!Array.isArray(result.notes) || + result.notes.some((value) => typeof value !== "string")) { + errors.push("notes must be an array of strings."); + } + const passed = EVALUATION_QUALITY_GATES.every((gate) => result.quality?.[gate] === false); + if (result.abandoned && result.acceptedArtifactAt) { + errors.push("An abandoned session cannot record acceptedArtifactAt."); + } + if (!result.abandoned && passed && !result.acceptedArtifactAt) { + errors.push("A completed quality-passing session requires acceptedArtifactAt."); + } + if ((!passed || result.abandoned) && result.acceptedArtifactAt) { + errors.push("acceptedArtifactAt is allowed only for a non-abandoned quality-passing session."); + } + if (result.acceptedArtifactAt && !Number.isFinite(Date.parse(result.acceptedArtifactAt))) { + errors.push("acceptedArtifactAt must be a valid timestamp."); + } + return errors; +} + +export function buildScholarResearchEvaluationPlan(input: { + planId?: string; + createdAt?: string; + replayThreadId?: string; +} = {}): ScholarResearchEvaluationPlan { + const createdAt = input.createdAt ?? new Date().toISOString(); + const ids = SCHOLAR_RESEARCH_EVALUATION_TASKS.map((task) => task.id); + return { + schema: "longtable.scholar-research-evaluation-plan", + version: 1, + createdAt, + planId: normalizePlanId(input.planId ?? `evaluation-${createdAt.slice(0, 10)}`), + layers: ["fixtures", "replay", "prospective"], + repeatsPerFrozenCondition: 3, + taskSplit: { + engineering: ids.slice(0, 4), + calibration: ids.slice(4, 8), + lockedHoldout: ids.slice(8) + }, + tasks: SCHOLAR_RESEARCH_EVALUATION_TASKS, + replayCases: [{ + threadId: input.replayThreadId ?? "019f56ab-4f4e-7f13-9bc0-8c8d15966e0d", + posture: "replay_only", + criteria: [ + "separate performed procedure from proposed claim-control model", + "judge whether each figure or table is necessary for its research claim", + "measure expert recovery of question, denominator, key value, and conclusion within 10-20 seconds", + "detect main-text versus supplement redundancy", + "require editable vector output and journal-policy-compatible visual grammar" + ] + }], + conditions: ["workflow_off", "workflow_on", "frontier_ceiling"], + qualityHardGates: [ + "unsupported_final_claim", + "wrong_source_version_or_citation", + "visual_distortion", + "prohibited_access", + "preapproval_mutation" + ], + productivityMetrics: [ + "time_to_accepted_artifact", + "active_human_time", + "interruptions", + "repeated_questions", + "context_switches", + "rework", + "abandonment", + "researcher_effort", + "researcher_trust" + ], + promotionRule: "Only prospective conversational E2E evidence may promote, simplify, or remove a default stage; quality hard gates must pass before efficiency is compared." + }; +} + +export async function writeScholarResearchEvaluationPlan(input: { + cwd: string; + planId?: string; + replayThreadId?: string; +}): Promise<{ plan: ScholarResearchEvaluationPlan; path: string }> { + const plan = buildScholarResearchEvaluationPlan(input); + const directory = resolve(input.cwd, ".longtable", "evaluations", plan.planId); + const path = join(directory, "evaluation-plan.json"); + await mkdir(directory, { recursive: true }); + await writeFile(path, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); + return { plan, path }; +} + +function observationDirectory(cwd: string, planId: string): string { + return resolve(cwd, ".longtable", "evaluations", normalizePlanId(planId)); +} + +export async function createProspectiveTrialProtocol(input: { + cwd: string; + configPath: string; +}): Promise<{ + protocol: ProspectiveTrialProtocol; + path: string; +}> { + const config = JSON.parse( + await readFile(resolve(input.configPath), "utf8") + ) as ProspectiveTrialConfig; + const errors = validateProspectiveTrialConfig(config); + if (errors.length > 0) { + throw new Error(`Prospective trial config is invalid: ${errors.join(" ")}`); + } + const planDirectory = observationDirectory(input.cwd, config.planId); + const plan = JSON.parse( + await readFile(join(planDirectory, "evaluation-plan.json"), "utf8") + ) as ScholarResearchEvaluationPlan; + if (config.planId !== plan.planId) { + throw new Error("Prospective trial config planId does not match the evaluation plan."); + } + if (!plan.tasks.some((task) => task.id === config.taskId)) { + throw new Error(`Task ${config.taskId} is not declared in the evaluation plan.`); + } + + const seedHash = hash(`${config.crossoverSeed}\0${config.trialId}\0${config.taskId}`); + const firstCondition: "workflow_off" | "workflow_on" = + Number.parseInt(seedHash.slice(0, 2), 16) % 2 === 0 ? "workflow_off" : "workflow_on"; + const secondCondition = firstCondition === "workflow_off" ? "workflow_on" : "workflow_off"; + const protectedStages = [...PROSPECTIVE_TRIAL_PROTECTED_STAGES]; + const onStages = uniqueStages([...protectedStages, ...config.workflowOnStages]); + const offStages = uniqueStages([...protectedStages, ...config.workflowOffStages]); + const stages = { + workflow_on: onStages, + workflow_off: offStages + } as const; + const pairId = `${normalizePlanId(config.planId)}:${normalizeTrialId(config.trialId)}`; + const conditionOrder: ProspectiveTrialProtocol["conditionOrder"] = [ + { + index: 0, + condition: firstCondition, + stageProfile: stages[firstCondition], + crossoverPairId: pairId + }, + { + index: 1, + condition: secondCondition, + stageProfile: stages[secondCondition], + crossoverPairId: pairId + } + ]; + const expected = { + schema: "longtable.prospective-trial-protocol" as const, + version: 1 as const, + planId: plan.planId, + trialId: normalizeTrialId(config.trialId), + taskId: config.taskId.trim(), + researchQuestion: config.researchQuestion.trim(), + targetJournal: config.targetJournal.trim(), + provider: config.provider.trim(), + model: config.model.trim(), + modelVersion: config.modelVersion.trim(), + corpusCutoff: config.corpusCutoff.trim(), + permissionsProfile: config.permissionsProfile.trim(), + crossoverSeedHash: seedHash, + conditionOrder + }; + const directory = prospectiveTrialDirectory(input.cwd, config.planId, config.trialId); + const path = join(directory, "protocol.json"); + await mkdir(directory, { recursive: true }); + try { + const existing = JSON.parse(await readFile(path, "utf8")) as ProspectiveTrialProtocol; + const { createdAt: _existingCreatedAt, ...existingFrozen } = existing; + if (JSON.stringify(existingFrozen) !== JSON.stringify(expected)) { + throw new Error( + `Prospective trial ${expected.trialId} already exists with different frozen inputs.` + ); + } + return { protocol: existing, path }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const protocol: ProspectiveTrialProtocol = { + ...expected, + createdAt: new Date().toISOString() + }; + await writeFile(path, `${JSON.stringify(protocol, null, 2)}\n`, "utf8"); + return { protocol, path }; +} + +async function readProspectiveTrialProtocol(input: { + cwd: string; + planId: string; + trialId: string; +}): Promise<{ protocol: ProspectiveTrialProtocol; directory: string }> { + const directory = prospectiveTrialDirectory(input.cwd, input.planId, input.trialId); + const protocol = JSON.parse( + await readFile(join(directory, "protocol.json"), "utf8") + ) as ProspectiveTrialProtocol; + if (protocol.planId !== normalizePlanId(input.planId) || + protocol.trialId !== normalizeTrialId(input.trialId)) { + throw new Error("Prospective trial protocol identity does not match the requested trial."); + } + return { protocol, directory }; +} + +function prospectiveSessionPath(directory: string, conditionIndex: number): string { + return join(directory, "sessions", `session-${conditionIndex}.json`); +} + +export async function startProspectiveTrialSession(input: { + cwd: string; + planId: string; + trialId: string; + conditionIndex: number; +}): Promise<{ + session: ProspectiveTrialSession; + path: string; + taskCapsule: ProspectiveTrialTaskCapsule; + taskCapsulePath: string; +}> { + const { protocol, directory } = await readProspectiveTrialProtocol(input); + const assigned = protocol.conditionOrder.find((entry) => entry.index === input.conditionIndex); + if (!assigned) throw new Error("conditionIndex must be 0 or 1."); + const sessionsDirectory = join(directory, "sessions"); + const path = prospectiveSessionPath(directory, input.conditionIndex); + const taskCapsulePath = join( + sessionsDirectory, + `session-${input.conditionIndex}-task.json` + ); + await mkdir(sessionsDirectory, { recursive: true }); + try { + const existing = JSON.parse(await readFile(path, "utf8")) as ProspectiveTrialSession; + const taskCapsule = JSON.parse( + await readFile(taskCapsulePath, "utf8") + ) as ProspectiveTrialTaskCapsule; + return { session: existing, path, taskCapsule, taskCapsulePath }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const sessionId = `${protocol.trialId}-${input.conditionIndex}`; + const session: ProspectiveTrialSession = { + schema: "longtable.prospective-trial-session", + version: 1, + planId: protocol.planId, + trialId: protocol.trialId, + sessionId, + conditionIndex: input.conditionIndex, + condition: assigned.condition, + stageProfile: assigned.stageProfile, + crossoverPairId: assigned.crossoverPairId, + startedAt: new Date().toISOString(), + status: "running" + }; + const taskCapsule: ProspectiveTrialTaskCapsule = { + schema: "longtable.prospective-trial-task-capsule", + version: 1, + planId: protocol.planId, + trialId: protocol.trialId, + sessionId, + conditionIndex: input.conditionIndex, + condition: assigned.condition, + taskId: protocol.taskId, + researchQuestion: protocol.researchQuestion, + targetJournal: protocol.targetJournal, + provider: protocol.provider, + model: protocol.model, + modelVersion: protocol.modelVersion, + corpusCutoff: protocol.corpusCutoff, + permissionsProfile: protocol.permissionsProfile, + stageProfile: assigned.stageProfile, + protectedStages: [...PROSPECTIVE_TRIAL_PROTECTED_STAGES], + instruction: "Produce one accepted research artifact for the frozen question and journal. Record human burden and hard-gate failures; do not bypass access controls or mutate approved research scope." + }; + await writeFile(path, `${JSON.stringify(session, null, 2)}\n`, "utf8"); + await writeFile(taskCapsulePath, `${JSON.stringify(taskCapsule, null, 2)}\n`, "utf8"); + return { session, path, taskCapsule, taskCapsulePath }; +} + +export async function finishProspectiveTrialSession(input: { + cwd: string; + planId: string; + trialId: string; + conditionIndex: number; + resultPath: string; +}): Promise<{ + session: ProspectiveTrialSession; + path: string; + observation: ScholarResearchEvaluationObservation; + observationsPath: string; +}> { + const { protocol, directory } = await readProspectiveTrialProtocol(input); + const path = prospectiveSessionPath(directory, input.conditionIndex); + const session = JSON.parse(await readFile(path, "utf8")) as ProspectiveTrialSession; + const result = JSON.parse( + await readFile(resolve(input.resultPath), "utf8") + ) as ProspectiveTrialSessionResult; + const resultErrors = validateProspectiveTrialSessionResult(result); + if (resultErrors.length > 0) { + throw new Error(`Prospective trial result is invalid: ${resultErrors.join(" ")}`); + } + const resultHash = hash(JSON.stringify(result)); + const observationId = `prospective-${protocol.trialId}-${input.conditionIndex}`; + if (session.status === "completed") { + if (session.resultHash !== resultHash || session.observationId !== observationId) { + throw new Error(`Session ${session.sessionId} is already completed with a different result.`); + } + const observationsPath = join(observationDirectory(input.cwd, input.planId), "observations.jsonl"); + const observation = (await readFile(observationsPath, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as ScholarResearchEvaluationObservation) + .find((entry) => entry.observationId === observationId); + if (!observation) throw new Error(`Completed session ${session.sessionId} has no observation.`); + return { session, path, observation, observationsPath }; + } + const completedAt = new Date().toISOString(); + const elapsedMs = Date.parse(completedAt) - Date.parse(session.startedAt); + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { + throw new Error("Session startedAt is invalid or later than completion time."); + } + if (result.activeHumanMs > elapsedMs) { + throw new Error("activeHumanMs cannot exceed wall-clock session elapsed time."); + } + if (result.acceptedArtifactAt && + (Date.parse(result.acceptedArtifactAt) < Date.parse(session.startedAt) || + Date.parse(result.acceptedArtifactAt) > Date.parse(completedAt))) { + throw new Error("acceptedArtifactAt must fall within the session window."); + } + const observation: ScholarResearchEvaluationObservation = { + schema: "longtable.scholar-research-evaluation-observation", + version: 1, + observationId, + planId: protocol.planId, + taskId: protocol.taskId, + layer: "prospective", + condition: session.condition, + crossoverPairId: session.crossoverPairId, + provider: protocol.provider, + model: protocol.model, + modelVersion: protocol.modelVersion, + corpusCutoff: protocol.corpusCutoff, + permissionsProfile: protocol.permissionsProfile, + stageProfile: session.stageProfile, + startedAt: session.startedAt, + ...(result.acceptedArtifactAt ? { acceptedArtifactAt: result.acceptedArtifactAt } : {}), + completedAt, + quality: result.quality, + metrics: { + elapsedMs, + activeHumanMs: result.activeHumanMs, + interruptionCount: result.interruptionCount, + repeatedQuestionCount: result.repeatedQuestionCount, + contextSwitchCount: result.contextSwitchCount, + reworkCount: result.reworkCount, + toolCallCount: result.toolCallCount, + ...(result.tokenCount !== undefined ? { tokenCount: result.tokenCount } : {}), + ...(result.monetaryCost !== undefined ? { monetaryCost: result.monetaryCost } : {}), + abandoned: result.abandoned, + ...(result.researcherEffort !== undefined + ? { researcherEffort: result.researcherEffort } + : {}), + ...(result.researcherTrust !== undefined + ? { researcherTrust: result.researcherTrust } + : {}) + }, + artifactRefs: result.artifactRefs, + notes: result.notes + }; + const observationTempPath = join(directory, "sessions", `session-${input.conditionIndex}-observation.json`); + await writeFile(observationTempPath, `${JSON.stringify(observation, null, 2)}\n`, "utf8"); + const recorded = await recordScholarResearchEvaluationObservation({ + cwd: input.cwd, + planId: input.planId, + observationPath: observationTempPath + }); + const completedSession: ProspectiveTrialSession = { + ...session, + status: "completed", + completedAt, + observationId, + resultHash + }; + await writeFile(path, `${JSON.stringify(completedSession, null, 2)}\n`, "utf8"); + return { + session: completedSession, + path, + observation: recorded.observation, + observationsPath: recorded.path + }; +} + +export async function readProspectiveTrialStatus(input: { + cwd: string; + planId: string; + trialId: string; +}): Promise<{ + protocol: ProspectiveTrialProtocol; + sessions: Array; + nextConditionIndex: number | null; +}> { + const { protocol, directory } = await readProspectiveTrialProtocol(input); + const sessions: Array = []; + for (const condition of protocol.conditionOrder) { + try { + sessions.push(JSON.parse( + await readFile(prospectiveSessionPath(directory, condition.index), "utf8") + ) as ProspectiveTrialSession); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + sessions.push(null); + } + } + const running = sessions.find((session) => session?.status === "running"); + const unstarted = sessions.findIndex((session) => session === null); + return { + protocol, + sessions, + nextConditionIndex: running?.conditionIndex ?? (unstarted >= 0 ? unstarted : null) + }; +} + +function qualityPassed(observation: ScholarResearchEvaluationObservation): boolean { + return EVALUATION_QUALITY_GATES.every((gate) => observation.quality[gate] === false); +} + +function median(values: number[]): number | undefined { + if (values.length === 0) return undefined; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 + ? sorted[middle] + : (sorted[middle - 1] + sorted[middle]) / 2; +} + +export function validateScholarResearchEvaluationObservation( + observation: ScholarResearchEvaluationObservation +): string[] { + const errors: string[] = []; + if (observation.schema !== "longtable.scholar-research-evaluation-observation" || + observation.version !== 1) errors.push("Unsupported evaluation observation schema or version."); + for (const [field, value] of [ + ["observationId", observation.observationId], + ["planId", observation.planId], + ["taskId", observation.taskId], + ["provider", observation.provider], + ["model", observation.model], + ["modelVersion", observation.modelVersion], + ["corpusCutoff", observation.corpusCutoff], + ["permissionsProfile", observation.permissionsProfile], + ["startedAt", observation.startedAt], + ["completedAt", observation.completedAt] + ] as const) { + if (!value?.trim()) errors.push(`${field} is required.`); + } + if (!["fixture", "replay", "prospective"].includes(observation.layer)) { + errors.push("Invalid evaluation layer."); + } + if (!["workflow_off", "workflow_on", "frontier_ceiling"].includes(observation.condition)) { + errors.push("Invalid evaluation condition."); + } + if (!observation.stageProfile?.length) errors.push("At least one enabled-stage label is required."); + if (!observation.artifactRefs?.length) errors.push("At least one artifact reference is required."); + for (const gate of EVALUATION_QUALITY_GATES) { + if (typeof observation.quality?.[gate] !== "boolean") { + errors.push(`Quality gate ${gate} must be a boolean failure flag.`); + } + } + for (const field of [ + "elapsedMs", + "activeHumanMs", + "interruptionCount", + "repeatedQuestionCount", + "contextSwitchCount", + "reworkCount", + "toolCallCount" + ] as const) { + const value = observation.metrics?.[field]; + if (!Number.isFinite(value) || value < 0) errors.push(`Metric ${field} must be finite and non-negative.`); + } + for (const field of ["researcherEffort", "researcherTrust"] as const) { + const value = observation.metrics?.[field]; + if (value !== undefined && (!Number.isFinite(value) || value < 1 || value > 7)) { + errors.push(`${field} must be between 1 and 7 when supplied.`); + } + } + const startedAt = Date.parse(observation.startedAt); + const completedAt = Date.parse(observation.completedAt); + if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt)) { + errors.push("startedAt and completedAt must be valid timestamps."); + } else if (completedAt < startedAt) { + errors.push("completedAt cannot precede startedAt."); + } + if (observation.acceptedArtifactAt) { + const acceptedAt = Date.parse(observation.acceptedArtifactAt); + if (!Number.isFinite(acceptedAt)) { + errors.push("acceptedArtifactAt must be a valid timestamp."); + } else if (Number.isFinite(startedAt) && Number.isFinite(completedAt) && + (acceptedAt < startedAt || acceptedAt > completedAt)) { + errors.push("acceptedArtifactAt must fall within the observation window."); + } + } + if (!observation.metrics.abandoned && qualityPassed(observation) && !observation.acceptedArtifactAt) { + errors.push("A non-abandoned quality-passing observation requires acceptedArtifactAt."); + } + return errors; +} + +export async function recordScholarResearchEvaluationObservation(input: { + cwd: string; + planId: string; + observationPath: string; +}): Promise<{ observation: ScholarResearchEvaluationObservation; path: string }> { + const directory = observationDirectory(input.cwd, input.planId); + const planPath = join(directory, "evaluation-plan.json"); + const plan = JSON.parse(await readFile(planPath, "utf8")) as ScholarResearchEvaluationPlan; + const observation = JSON.parse( + await readFile(resolve(input.observationPath), "utf8") + ) as ScholarResearchEvaluationObservation; + if (observation.planId !== plan.planId) throw new Error("Observation planId does not match the evaluation plan."); + const errors = validateScholarResearchEvaluationObservation(observation); + if (errors.length > 0) throw new Error(`Evaluation observation is invalid: ${errors.join(" ")}`); + const observationsPath = join(directory, "observations.jsonl"); + let existing: ScholarResearchEvaluationObservation[] = []; + try { + existing = (await readFile(observationsPath, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as ScholarResearchEvaluationObservation); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const prior = existing.find((entry) => entry.observationId === observation.observationId); + if (prior) { + if (JSON.stringify(prior) !== JSON.stringify(observation)) { + throw new Error(`Observation ${observation.observationId} already exists with different content.`); + } + return { observation: prior, path: observationsPath }; + } + await appendFile(observationsPath, `${JSON.stringify(observation)}\n`, "utf8"); + return { observation, path: observationsPath }; +} + +function summarizeCondition( + condition: ScholarResearchEvaluationObservation["condition"], + observations: ScholarResearchEvaluationObservation[] +): ScholarResearchEvaluationReport["conditionSummary"][number] { + const selected = observations.filter((entry) => entry.condition === condition); + const passes = selected.filter(qualityPassed); + return { + condition, + observations: selected.length, + qualityPasses: passes.length, + qualityPassRate: selected.length > 0 ? passes.length / selected.length : 0, + ...(median(passes.map((entry) => entry.metrics.elapsedMs)) !== undefined + ? { medianElapsedMs: median(passes.map((entry) => entry.metrics.elapsedMs)) } + : {}), + ...(median(passes.map((entry) => entry.metrics.activeHumanMs)) !== undefined + ? { medianActiveHumanMs: median(passes.map((entry) => entry.metrics.activeHumanMs)) } + : {}), + ...(median(passes.map((entry) => entry.metrics.interruptionCount)) !== undefined + ? { medianInterruptions: median(passes.map((entry) => entry.metrics.interruptionCount)) } + : {}), + ...(median(passes.flatMap((entry) => entry.metrics.researcherEffort === undefined ? [] : [entry.metrics.researcherEffort])) !== undefined + ? { medianEffort: median(passes.flatMap((entry) => entry.metrics.researcherEffort === undefined ? [] : [entry.metrics.researcherEffort])) } + : {}), + ...(median(passes.flatMap((entry) => entry.metrics.researcherTrust === undefined ? [] : [entry.metrics.researcherTrust])) !== undefined + ? { medianTrust: median(passes.flatMap((entry) => entry.metrics.researcherTrust === undefined ? [] : [entry.metrics.researcherTrust])) } + : {}) + }; +} + +function buildAblations(observations: ScholarResearchEvaluationObservation[]): ScholarResearchEvaluationReport["ablations"] { + const prospective = observations.filter( + (entry) => entry.layer === "prospective" && entry.crossoverPairId + ); + const protectedStages = new Set(PROSPECTIVE_TRIAL_PROTECTED_STAGES); + const stages = [...new Set(prospective.flatMap((entry) => entry.stageProfile))] + .filter((stage) => !protectedStages.has(stage)) + .sort(); + return stages.map((stage) => { + const pairs = new Map(); + for (const observation of prospective) { + const id = observation.crossoverPairId!; + pairs.set(id, [...(pairs.get(id) ?? []), observation]); + } + const matched = [...pairs.values()].flatMap((entries) => { + const enabled = entries.find((entry) => entry.stageProfile.includes(stage)); + const disabled = entries.find((entry) => !entry.stageProfile.includes(stage)); + return enabled && disabled ? [{ enabled, disabled }] : []; + }); + const qualityLossWhenRemoved = matched.filter( + ({ enabled, disabled }) => qualityPassed(enabled) && !qualityPassed(disabled) + ).length; + const activeDeltas = matched + .filter(({ enabled, disabled }) => qualityPassed(enabled) && qualityPassed(disabled)) + .map(({ enabled, disabled }) => enabled.metrics.activeHumanMs - disabled.metrics.activeHumanMs); + const elapsedDeltas = matched + .filter(({ enabled, disabled }) => qualityPassed(enabled) && qualityPassed(disabled)) + .map(({ enabled, disabled }) => enabled.metrics.elapsedMs - disabled.metrics.elapsedMs); + const effortDeltas = matched.flatMap(({ enabled, disabled }) => + enabled.metrics.researcherEffort !== undefined && + disabled.metrics.researcherEffort !== undefined + ? [enabled.metrics.researcherEffort - disabled.metrics.researcherEffort] + : [] + ); + const trustDeltas = matched.flatMap(({ enabled, disabled }) => + enabled.metrics.researcherTrust !== undefined && + disabled.metrics.researcherTrust !== undefined + ? [enabled.metrics.researcherTrust - disabled.metrics.researcherTrust] + : [] + ); + const activeDelta = median(activeDeltas); + const elapsedDelta = median(elapsedDeltas); + const effortDelta = median(effortDeltas); + const trustDelta = median(trustDeltas); + let verdict: ScholarResearchEvaluationReport["ablations"][number]["verdict"] = "insufficient_evidence"; + let reason = "At least three matched prospective crossover pairs are required."; + if (matched.length >= 3) { + if (qualityLossWhenRemoved > 0) { + verdict = "retain"; + reason = "Removing the stage caused at least one non-compensatory quality failure."; + } else if ( + ((activeDelta ?? 0) > 0 || (elapsedDelta ?? 0) > 0) && + effortDelta !== undefined && effortDelta >= 0 && + trustDelta !== undefined && trustDelta <= 0 + ) { + verdict = "remove_candidate"; + reason = "The enabled stage added time or human burden without observed hard-gate or trust benefit in matched prospective pairs."; + } else { + verdict = "simplify_or_opt_in"; + reason = "No hard-gate loss was observed; keep only if its marginal interpretive value is confirmed by researchers."; + } + } + return { + stage, + matchedPairs: matched.length, + verdict, + qualityLossWhenRemoved, + ...(activeDelta !== undefined ? { medianActiveHumanMsDeltaWhenEnabled: activeDelta } : {}), + ...(elapsedDelta !== undefined ? { medianElapsedMsDeltaWhenEnabled: elapsedDelta } : {}), + ...(effortDelta !== undefined ? { medianEffortDeltaWhenEnabled: effortDelta } : {}), + ...(trustDelta !== undefined ? { medianTrustDeltaWhenEnabled: trustDelta } : {}), + reason + }; + }); +} + +export async function writeScholarResearchEvaluationReport(input: { + cwd: string; + planId: string; +}): Promise<{ report: ScholarResearchEvaluationReport; path: string }> { + const directory = observationDirectory(input.cwd, input.planId); + const observationsPath = join(directory, "observations.jsonl"); + let observations: ScholarResearchEvaluationObservation[] = []; + try { + observations = (await readFile(observationsPath, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as ScholarResearchEvaluationObservation); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const prospective = observations.filter((entry) => entry.layer === "prospective"); + const pairGroups = new Map(); + for (const observation of prospective.filter((entry) => entry.crossoverPairId)) { + const pairId = observation.crossoverPairId!; + pairGroups.set(pairId, [...(pairGroups.get(pairId) ?? []), observation]); + } + const matchedPairs = [...pairGroups.values()].filter((entries) => + entries.some((entry) => entry.condition === "workflow_off") && + entries.some((entry) => entry.condition === "workflow_on") + ); + const ratedMatchedPairs = matchedPairs.filter((entries) => + entries + .filter((entry) => entry.condition === "workflow_off" || entry.condition === "workflow_on") + .every((entry) => + entry.metrics.researcherEffort !== undefined && + entry.metrics.researcherTrust !== undefined + ) + ); + const report: ScholarResearchEvaluationReport = { + schema: "longtable.scholar-research-evaluation-report", + version: 1, + planId: normalizePlanId(input.planId), + createdAt: new Date().toISOString(), + observationCount: observations.length, + prospectiveCount: prospective.length, + matchedProspectivePairs: matchedPairs.length, + conditionSummary: (["workflow_off", "workflow_on", "frontier_ceiling"] as const) + .map((condition) => summarizeCondition(condition, observations)), + ablations: buildAblations(observations), + promotionEligible: matchedPairs.length >= 3 && + ratedMatchedPairs.length === matchedPairs.length, + limitations: [ + ...(matchedPairs.length < 3 + ? ["Fewer than three complete prospective crossover pairs are recorded."] + : []), + ...(ratedMatchedPairs.length < matchedPairs.length + ? ["Some matched prospective pairs lack researcher effort or trust ratings."] + : []), + ...(!prospective.some((entry) => entry.condition === "workflow_off") + ? ["No prospective workflow-off condition is recorded."] + : []), + ...(!prospective.some((entry) => entry.condition === "workflow_on") + ? ["No prospective workflow-on condition is recorded."] + : []) + ] + }; + const path = join(directory, "evaluation-report.json"); + await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + return { report, path }; +} diff --git a/packages/longtable-research/src/extraction.ts b/packages/longtable-research/src/extraction.ts new file mode 100644 index 00000000..2b67d5f6 --- /dev/null +++ b/packages/longtable-research/src/extraction.ts @@ -0,0 +1,357 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import type { FullTextManifestRecord, ParsedPdfPage } from "./fulltext.js"; +import type { + CitationSlot, + EvidenceSpan, + ProviderProposedPatch, + ProviderTaskPacket +} from "./workflow.js"; + +export interface ExtractionTaskPacket extends ProviderTaskPacket { + packetId: string; + sourceIds: string[]; + instructions: string[]; +} + +export interface PreparedExtractionTasks { + schema: "longtable.prepared-extraction-tasks"; + version: 1; + createdAt: string; + runId: string; + packets: Array<{ + packetId: string; + path: string; + sourceIds: string[]; + spanCount: number; + characterCount: number; + }>; +} + +export interface PreparedSynthesisTask { + packetId: string; + path: string; + filledSlotCount: number; + provisionalOrConflictCount: number; +} + +export interface SynthesisClaim { + id: string; + text: string; + citationSlotIds: string[]; + role: "finding" | "qualification" | "conflict" | "limitation"; +} + +export interface SynthesisProposal { + title: string; + claims: SynthesisClaim[]; + readingOrder: string[]; +} + +export interface ProviderSynthesisVerification { + schema: "longtable.provider-synthesis-verification"; + version: 1; + runId: string; + packetId: string; + provider: string; + independent: true; + proposalPatchHash: string; + decisions: Array<{ + claimId: string; + decision: "agree" | "disagree" | "uncertain"; + rationale: string; + }>; +} + +export interface HumanSynthesisReview { + schema: "longtable.human-synthesis-review"; + version: 1; + runId: string; + proposalPatchHash: string; + reviewer: string; + reviewedAt: string; + decisions: Array<{ + claimId: string; + decision: "accept" | "reject" | "preserve_disagreement"; + rationale: string; + }>; +} + +export interface AdjudicationRecord { + schema: "longtable.adjudication-record"; + version: 1; + id: string; + runId: string; + stage: "extract"; + inputBundleVersion: string; + inputBundleHash: string; + citationSlotId: string; + proposer: { + provider: string; + decision: "supports" | "qualifies" | "contradicts" | "insufficient"; + evidenceSpanIds: string[]; + }; + verifier: { + provider: string; + independent: true; + decision: "agree" | "disagree" | "uncertain"; + evidenceSpanIds: string[]; + rationale: string; + }; + errorClass: + | "none" + | "claim_evidence_relation" + | "insufficient_evidence" + | "source_or_version" + | "access_or_provenance"; + severity: "none" | "low" | "medium" | "high" | "critical"; + humanRequired: boolean; + createdAt: string; + replayFixtureRef: string; + humanDecision?: { + reviewer: string; + adjudicatorRole: "domain_researcher"; + decision: "accept" | "revise" | "reject" | "preserve_disagreement"; + decidedAt: string; + rationale: string; + }; +} + +export interface ProviderVerification { + schema: "longtable.provider-verification"; + version: 1; + runId: string; + packetId: string; + provider: string; + independent: true; + proposalPatchHash: string; + decisions: Array<{ + citationSlotId: string; + decision: "agree" | "disagree" | "uncertain"; + evidenceSpanIds: string[]; + rationale: string; + }>; +} + +export interface HumanCitationReview { + schema: "longtable.human-citation-review"; + version: 1; + runId: string; + reviewer: string; + reviewedAt: string; + decisions: Array<{ + citationSlotId: string; + decision: "accept" | "reject" | "preserve_disagreement"; + rationale: string; + }>; +} + +interface ParsedFullTextFile { + contentHash: string; + sourceVersion: string; + accessClass: string; + pages: ParsedPdfPage[]; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function cleanText(value: string): string { + return value.replace(/\u0000/g, "").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +function splitPage(page: ParsedPdfPage, record: FullTextManifestRecord, maxSpanCharacters: number): EvidenceSpan[] { + const text = cleanText(page.text); + if (!text) return []; + const spans: EvidenceSpan[] = []; + let start = 0; + while (start < text.length) { + let end = Math.min(text.length, start + maxSpanCharacters); + if (end < text.length) { + const paragraph = text.lastIndexOf("\n\n", end); + const sentence = text.lastIndexOf(". ", end); + const boundary = Math.max(paragraph, sentence); + if (boundary > start + Math.floor(maxSpanCharacters * 0.5)) end = boundary + (boundary === sentence ? 1 : 0); + } + const quote = text.slice(start, end).trim(); + if (quote) { + spans.push({ + spanId: `span-${record.contentHash.replace(/^sha256:/, "").slice(0, 12)}-p${page.page}-${start}-${end}`, + sourceId: record.sourceId, + quote, + locator: `${page.locator};chars:${start}-${end};extraction:${page.extraction}`, + contentHash: record.contentHash, + sourceVersion: record.sourceVersion, + accessClass: record.accessClass + }); + } + start = Math.max(end, start + 1); + } + return spans; +} + +export async function prepareExtractionTaskPackets(input: { + runId: string; + runDir: string; + query: string; + records: FullTextManifestRecord[]; + maxSpanCharacters?: number; + maxPacketCharacters?: number; +}): Promise { + const maxSpanCharacters = Math.max(500, Math.min(8_000, input.maxSpanCharacters ?? 3_500)); + const maxPacketCharacters = Math.max(maxSpanCharacters, Math.min(50_000, input.maxPacketCharacters ?? 20_000)); + const allSpans: EvidenceSpan[] = []; + for (const record of [...input.records].sort((a, b) => a.sourceId.localeCompare(b.sourceId))) { + const parsed = JSON.parse(await readFile(record.derivedTextPath, "utf8")) as ParsedFullTextFile; + if (parsed.contentHash !== record.contentHash || parsed.sourceVersion !== record.sourceVersion || + parsed.accessClass !== record.accessClass) { + throw new Error(`Parsed full text provenance mismatch for ${basename(record.derivedTextPath)}.`); + } + for (const page of parsed.pages.sort((a, b) => a.page - b.page)) { + allSpans.push(...splitPage(page, record, maxSpanCharacters)); + } + } + if (allSpans.length === 0) throw new Error("No non-empty full-text evidence spans were available for extraction."); + + const outputDirectory = join(input.runDir, "provider-tasks", "extract"); + await mkdir(outputDirectory, { recursive: true }); + const packetMetadata: PreparedExtractionTasks["packets"] = []; + let packetSpans: EvidenceSpan[] = []; + let packetCharacters = 0; + + async function flush(): Promise { + if (packetSpans.length === 0) return; + const packetNumber = packetMetadata.length + 1; + const packetId = `extract-${String(packetNumber).padStart(4, "0")}-${sha256(JSON.stringify(packetSpans)).slice(0, 12)}`; + const packet: ExtractionTaskPacket = { + schema: "longtable.provider-task-packet", + version: 1, + packetId, + runId: input.runId, + stage: "extract", + objective: `Propose claim-evidence relations relevant to: ${input.query}`, + boundedEvidence: packetSpans, + allowedPatchPaths: ["/citationSlots"], + maxOutputCharacters: 20_000, + sourceIds: [...new Set(packetSpans.map((span) => span.sourceId))], + instructions: [ + "Return only a longtable.provider-proposed-patch JSON object.", + "Create provisional citation slots only; never mark a provider-proposed slot filled.", + "Quote only supplied evidence spans and preserve their exact locator, content hash, source version, and access class.", + "Use supports, qualifies, or contradicts; preserve conflicting evidence instead of collapsing it.", + "Do not infer full-text support beyond the supplied spans." + ] + }; + const path = join(outputDirectory, `${packetId}.json`); + await writeFile(path, `${JSON.stringify(packet, null, 2)}\n`, "utf8"); + packetMetadata.push({ + packetId, + path, + sourceIds: packet.sourceIds, + spanCount: packetSpans.length, + characterCount: packetCharacters + }); + packetSpans = []; + packetCharacters = 0; + } + + for (const span of allSpans) { + if (packetSpans.length > 0 && packetCharacters + span.quote.length > maxPacketCharacters) await flush(); + packetSpans.push(span); + packetCharacters += span.quote.length; + } + await flush(); + const manifest: PreparedExtractionTasks = { + schema: "longtable.prepared-extraction-tasks", + version: 1, + createdAt: new Date().toISOString(), + runId: input.runId, + packets: packetMetadata + }; + await writeFile(join(outputDirectory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + return manifest; +} + +export function adjudicationNeedsHuman(record: AdjudicationRecord): boolean { + return record.verifier.decision !== "agree" || record.humanRequired; +} + +function collectSlotLike(value: unknown): CitationSlot[] { + if (Array.isArray(value)) return value.flatMap(collectSlotLike); + if (!value || typeof value !== "object") return []; + const record = value as Record; + if (typeof record.id === "string" && typeof record.claim === "string" && "evidence" in record) { + return [record as unknown as CitationSlot]; + } + return Object.values(record).flatMap(collectSlotLike); +} + +export function citationSlotsFromProviderPatch(patch: ProviderProposedPatch): CitationSlot[] { + const slots = patch.operations.flatMap((operation) => collectSlotLike(operation.value)); + const ids = new Set(); + for (const slot of slots) { + if (ids.has(slot.id)) throw new Error(`Duplicate citation slot id in provider patch: ${slot.id}`); + ids.add(slot.id); + } + return slots; +} + +export function synthesisProposalFromProviderPatch(patch: ProviderProposedPatch): SynthesisProposal { + const synthesisOperations = patch.operations.filter( + (operation) => operation.path === "/synthesis" + ); + if (synthesisOperations.length !== 1) { + throw new Error("A synthesis patch must contain exactly one /synthesis operation."); + } + return synthesisOperations[0].value as SynthesisProposal; +} + +export async function prepareSynthesisTaskPacket(input: { + runId: string; + runDir: string; + query: string; + citationSlots: CitationSlot[]; +}): Promise { + const boundedEvidence = input.citationSlots + .filter((slot) => Boolean(slot.evidence)) + .map((slot) => slot.evidence!); + if (boundedEvidence.length === 0) { + throw new Error("Synthesis requires at least one human-reviewed citation slot with evidence."); + } + const packetId = `synthesize-${sha256(JSON.stringify(input.citationSlots)).slice(0, 16)}`; + const packet: ExtractionTaskPacket = { + schema: "longtable.provider-task-packet", + version: 1, + packetId, + runId: input.runId, + stage: "synthesize", + objective: `Propose a bounded synthesis for: ${input.query}`, + boundedEvidence, + allowedPatchPaths: ["/synthesis"], + maxOutputCharacters: 30_000, + sourceIds: [...new Set(boundedEvidence.map((span) => span.sourceId))], + instructions: [ + "Return only a longtable.provider-proposed-patch JSON object.", + "Set /synthesis to {title, claims, readingOrder}; do not return free-standing narrative.", + "Every claim must contain id, text, role, and citationSlotIds supplied in citationSlots.", + "Treat provisional, rejected, and preserved-disagreement slots as limits, not supporting facts.", + "Preserve contradicting evidence and legitimate disagreement.", + "Do not introduce claims, sources, locators, or numbers absent from the packet." + ] + }; + const outputDirectory = join(input.runDir, "provider-tasks", "synthesize"); + await mkdir(outputDirectory, { recursive: true }); + const path = join(outputDirectory, `${packetId}.json`); + await writeFile(path, `${JSON.stringify({ + ...packet, + citationSlots: input.citationSlots + }, null, 2)}\n`, "utf8"); + return { + packetId, + path, + filledSlotCount: input.citationSlots.filter((slot) => slot.status === "filled").length, + provisionalOrConflictCount: input.citationSlots.filter((slot) => slot.status !== "filled").length + }; +} diff --git a/packages/longtable-research/src/fulltext.ts b/packages/longtable-research/src/fulltext.ts new file mode 100644 index 00000000..243429fd --- /dev/null +++ b/packages/longtable-research/src/fulltext.ts @@ -0,0 +1,642 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import { promisify } from "node:util"; +import { + appendFile, + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile +} from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { basename, extname, join, resolve } from "node:path"; +import type { EvidenceCard } from "./types.js"; + +const execFileAsync = promisify(execFile); + +export const FULLTEXT_ACCESS_CLASSES = [ + "public_oa", + "manual_legitimate_access", + "licensed_tdm", + "private" +] as const; + +export type FullTextAccessClass = typeof FULLTEXT_ACCESS_CLASSES[number]; + +export function parseFullTextAccessClass(value?: string | boolean): FullTextAccessClass | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + if (!FULLTEXT_ACCESS_CLASSES.includes(value as FullTextAccessClass)) { + throw new Error(`Unknown PDF access class: ${value}`); + } + return value as FullTextAccessClass; +} + +export interface ParsedPdfPage { + page: number; + text: string; + locator: string; + extraction: "text" | "ocr"; +} + +export interface ParsedPdfDocument { + pageCount: number; + pages: ParsedPdfPage[]; + ocrPages: number[]; + parser: { + pdfinfo: string; + pdftotext: string; + pdftoppm?: string; + tesseract?: string; + }; +} + +export interface FullTextManifestRecord { + schema: "longtable.fulltext-manifest-record"; + version: 1; + sourceId: string; + originalName: string; + originalPath: string; + storedPath: string; + derivedTextPath: string; + contentHash: string; + sourceVersion: string; + accessClass: FullTextAccessClass; + storageClass: "global_public_cache" | "project_isolated"; + byteLength: number; + pageCount: number; + ocrPages: number[]; + collectedAt: string; + acquisition?: { + route: "public_oa_url"; + sourceCardId: string; + sourceRoute: string; + requestedUrl: string; + resolvedUrl: string; + httpStatus: number; + contentType?: string; + }; +} + +export interface IngestPdfDirectoryInput { + pdfDirectory: string; + runDir: string; + accessClass: FullTextAccessClass; + globalCacheRoot?: string; + parser?: (path: string) => Promise; +} + +export type PublicOaAcquisitionFailureReason = + | "not_found" + | "no_full_text" + | "restricted_access" + | "robots_or_terms_blocked" + | "ambiguous_match" + | "download_failed" + | "parse_failed" + | "weak_evidence"; + +export interface PublicOaAcquisitionEvent { + schema: "longtable.public-oa-acquisition-event"; + version: 1; + sourceCardId: string; + sourceRoute: string; + requestedUrl?: string; + resolvedUrl?: string; + status: "acquired" | "failed"; + failureReason?: PublicOaAcquisitionFailureReason; + detail: string; + contentHash?: string; + manifestSourceId?: string; + occurredAt: string; +} + +export interface PublicOaFetchResponse { + ok: boolean; + status: number; + statusText: string; + url: string; + headers: { + get(name: string): string | null; + }; + arrayBuffer(): Promise; +} + +export type PublicOaFetch = ( + input: string, + init: { + method: "GET"; + redirect: "manual"; + credentials: "omit"; + headers: Record; + signal: AbortSignal; + } +) => Promise; + +export interface AcquirePublicOaFullTextInput { + cards: EvidenceCard[]; + runDir: string; + maxDocuments?: number; + maxBytesPerDocument?: number; + timeoutMs?: number; + globalCacheRoot?: string; + parser?: (path: string) => Promise; + fetch?: PublicOaFetch; + resolveHost?: (hostname: string) => Promise; +} + +function sha256(buffer: Buffer): string { + return createHash("sha256").update(buffer).digest("hex"); +} + +function toolPath(envName: string, fallback: string): string { + return process.env[envName]?.trim() || fallback; +} + +async function pageCount(path: string, pdfinfo: string): Promise { + const result = await execFileAsync(pdfinfo, [path], { maxBuffer: 4 * 1024 * 1024 }); + const match = /^Pages:\s+(\d+)\s*$/m.exec(result.stdout); + if (!match) throw new Error(`pdfinfo did not report a page count for ${basename(path)}.`); + return Number(match[1]); +} + +async function extractPageText(path: string, page: number, pdftotext: string): Promise { + const result = await execFileAsync( + pdftotext, + ["-f", String(page), "-l", String(page), "-layout", "-enc", "UTF-8", path, "-"], + { maxBuffer: 32 * 1024 * 1024 } + ); + return result.stdout.replace(/\u0000/g, "").trim(); +} + +async function ocrPage(path: string, page: number, pdftoppm: string, tesseract: string): Promise { + const temporary = await mkdtemp(join(tmpdir(), "longtable-ocr-")); + try { + const prefix = join(temporary, "page"); + await execFileAsync( + pdftoppm, + ["-f", String(page), "-l", String(page), "-r", "300", "-png", "-singlefile", path, prefix], + { maxBuffer: 8 * 1024 * 1024 } + ); + const image = `${prefix}.png`; + const result = await execFileAsync( + tesseract, + [image, "stdout", "-l", process.env.LONGTABLE_OCR_LANG?.trim() || "eng"], + { maxBuffer: 32 * 1024 * 1024 } + ); + return result.stdout.replace(/\u0000/g, "").trim(); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} + +export async function parsePdfWithLocalTools(path: string): Promise { + const pdfinfo = toolPath("LONGTABLE_PDFINFO_BIN", "pdfinfo"); + const pdftotext = toolPath("LONGTABLE_PDFTOTEXT_BIN", "pdftotext"); + const pdftoppm = toolPath("LONGTABLE_PDFTOPPM_BIN", "pdftoppm"); + const tesseract = toolPath("LONGTABLE_TESSERACT_BIN", "tesseract"); + const count = await pageCount(path, pdfinfo); + const pages: ParsedPdfPage[] = []; + const ocrPages: number[] = []; + for (let page = 1; page <= count; page += 1) { + let text = await extractPageText(path, page, pdftotext); + let extraction: ParsedPdfPage["extraction"] = "text"; + if (text.replace(/\s+/g, "").length < 20) { + text = await ocrPage(path, page, pdftoppm, tesseract); + extraction = "ocr"; + ocrPages.push(page); + } + pages.push({ page, text, locator: `page:${page}`, extraction }); + } + return { + pageCount: count, + pages, + ocrPages, + parser: { pdfinfo, pdftotext, pdftoppm, tesseract } + }; +} + +function globalPublicCacheRoot(input?: string): string { + return resolve(input ?? join(homedir(), ".longtable", "cache", "fulltext", "sha256")); +} + +function privateIpv4(address: string): boolean { + const parts = address.split(".").map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return true; + } + const [a, b] = parts; + return a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 0) || + (a === 192 && b === 2) || + (a === 192 && b === 168) || + (a === 198 && b === 51) || + (a === 198 && (b === 18 || b === 19)) || + (a === 203 && b === 0) || + a >= 224; +} + +function privateIp(address: string): boolean { + const normalized = address.toLowerCase().split("%")[0]; + if (isIP(normalized) === 4) return privateIpv4(normalized); + if (isIP(normalized) !== 6) return true; + if (normalized === "::" || normalized === "::1" || + normalized.startsWith("fc") || normalized.startsWith("fd") || + normalized.startsWith("fe8") || normalized.startsWith("fe9") || + normalized.startsWith("fea") || normalized.startsWith("feb") || + normalized.startsWith("2001:db8")) { + return true; + } + const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(normalized); + return mapped ? privateIpv4(mapped[1]) : false; +} + +async function defaultResolveHost(hostname: string): Promise { + if (isIP(hostname)) return [hostname]; + return (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address); +} + +async function assertSafePublicOaUrl( + raw: string, + resolveHost: (hostname: string) => Promise +): Promise { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error("The OA candidate URL is invalid."); + } + if (url.protocol !== "https:") throw new Error("Only HTTPS OA acquisition is allowed."); + if (url.username || url.password) throw new Error("Credential-bearing OA URLs are prohibited."); + if (url.port && url.port !== "443") throw new Error("Non-standard OA URL ports are prohibited."); + if (url.hostname.toLowerCase() === "localhost" || url.hostname.endsWith(".localhost")) { + throw new Error("Loopback OA URLs are prohibited."); + } + const addresses = await resolveHost(url.hostname); + if (addresses.length === 0 || addresses.some(privateIp)) { + throw new Error("OA URL resolution reached a private, local, or reserved network."); + } + return url; +} + +function redirectStatus(status: number): boolean { + return [301, 302, 303, 307, 308].includes(status); +} + +async function fetchPublicOaPdf(input: { + url: string; + maxBytes: number; + timeoutMs: number; + fetch: PublicOaFetch; + resolveHost: (hostname: string) => Promise; +}): Promise<{ + bytes: Buffer; + resolvedUrl: string; + status: number; + contentType?: string; +}> { + let current = await assertSafePublicOaUrl(input.url, input.resolveHost); + for (let redirects = 0; redirects <= 5; redirects += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), input.timeoutMs); + let response: PublicOaFetchResponse; + try { + response = await input.fetch(current.toString(), { + method: "GET", + redirect: "manual", + credentials: "omit", + headers: { + Accept: "application/pdf,application/octet-stream;q=0.8", + "User-Agent": "LongTable/0.1 scholarly-evidence-acquisition" + }, + signal: controller.signal + }); + } finally { + clearTimeout(timeout); + } + if (redirectStatus(response.status)) { + if (redirects === 5) throw new Error("OA acquisition exceeded five validated redirects."); + const location = response.headers.get("location"); + if (!location) throw new Error("OA redirect did not include a Location header."); + current = await assertSafePublicOaUrl( + new URL(location, current).toString(), + input.resolveHost + ); + continue; + } + if (response.status === 401 || response.status === 403 || response.status === 451) { + throw new Error(`OA route returned restricted access (${response.status}).`); + } + if (!response.ok) throw new Error(`OA download failed with HTTP ${response.status}.`); + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > input.maxBytes) { + throw new Error(`OA PDF exceeds the ${input.maxBytes}-byte acquisition limit.`); + } + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length === 0 || bytes.length > input.maxBytes) { + throw new Error(`OA response size is outside the 1-${input.maxBytes} byte limit.`); + } + if (bytes.subarray(0, Math.min(bytes.length, 1024)).indexOf("%PDF-") < 0) { + throw new Error("OA response did not contain a PDF signature."); + } + return { + bytes, + resolvedUrl: response.url || current.toString(), + status: response.status, + ...(response.headers.get("content-type") + ? { contentType: response.headers.get("content-type")! } + : {}) + }; + } + throw new Error("OA acquisition ended without a terminal response."); +} + +function oaFailureReason(error: unknown): PublicOaAcquisitionFailureReason { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + if (message.includes("restricted access") || message.includes("private, local") || + message.includes("credential-bearing") || message.includes("prohibited")) { + return "restricted_access"; + } + if (message.includes("pdf signature")) return "parse_failed"; + if (message.includes("abort") || message.includes("http") || message.includes("download") || + message.includes("redirect") || message.includes("size") || message.includes("limit")) { + return "download_failed"; + } + return "download_failed"; +} + +async function appendOaEvent(runDir: string, event: PublicOaAcquisitionEvent): Promise { + const path = join(runDir, "sources", "acquisition-events.jsonl"); + await mkdir(resolve(path, ".."), { recursive: true }); + await appendFile(path, `${JSON.stringify(event)}\n`, "utf8"); +} + +export async function acquirePublicOaFullText( + input: AcquirePublicOaFullTextInput +): Promise<{ + records: FullTextManifestRecord[]; + events: PublicOaAcquisitionEvent[]; + manifestPath: string; + eventsPath: string; +}> { + const maxDocuments = Math.max(1, Math.min(50, input.maxDocuments ?? 10)); + const maxBytes = Math.max(1_000_000, Math.min(200_000_000, input.maxBytesPerDocument ?? 50_000_000)); + const timeoutMs = Math.max(1_000, Math.min(120_000, input.timeoutMs ?? 30_000)); + const fetcher: PublicOaFetch = input.fetch ?? (async (url, init) => { + return await fetch(url, init) as unknown as PublicOaFetchResponse; + }); + const resolveHost = input.resolveHost ?? defaultResolveHost; + const parser = input.parser ?? parsePdfWithLocalTools; + const manifestPath = join(input.runDir, "sources", "manifest.jsonl"); + const eventsPath = join(input.runDir, "sources", "acquisition-events.jsonl"); + const knownHashes = await existingManifestHashes(manifestPath); + const records: FullTextManifestRecord[] = []; + const events: PublicOaAcquisitionEvent[] = []; + const candidates = input.cards + .filter((card) => card.legalFullTextAvailable) + .filter((card, index, cards) => + cards.findIndex((candidate) => + candidate.fullTextUrl === card.fullTextUrl && candidate.id === card.id + ) === index + ) + .slice(0, maxDocuments); + await mkdir(join(input.runDir, "sources"), { recursive: true }); + + for (const card of candidates) { + const occurredAt = new Date().toISOString(); + if (!card.fullTextUrl) { + const event: PublicOaAcquisitionEvent = { + schema: "longtable.public-oa-acquisition-event", + version: 1, + sourceCardId: card.id, + sourceRoute: card.sourceRoute, + status: "failed", + failureReason: "no_full_text", + detail: "The metadata source asserted OA availability but supplied no full-text URL.", + occurredAt + }; + events.push(event); + await appendOaEvent(input.runDir, event); + continue; + } + try { + const downloaded = await fetchPublicOaPdf({ + url: card.fullTextUrl, + maxBytes, + timeoutMs, + fetch: fetcher, + resolveHost + }); + const digest = sha256(downloaded.bytes); + const contentHash = `sha256:${digest}`; + if (knownHashes.has(contentHash)) { + const event: PublicOaAcquisitionEvent = { + schema: "longtable.public-oa-acquisition-event", + version: 1, + sourceCardId: card.id, + sourceRoute: card.sourceRoute, + requestedUrl: card.fullTextUrl, + resolvedUrl: downloaded.resolvedUrl, + status: "acquired", + detail: "The OA PDF was already present in this run by content hash.", + contentHash, + occurredAt + }; + events.push(event); + await appendOaEvent(input.runDir, event); + continue; + } + const storedPath = join(globalPublicCacheRoot(input.globalCacheRoot), digest.slice(0, 2), `${digest}.pdf`); + await mkdir(resolve(storedPath, ".."), { recursive: true }); + try { + await stat(storedPath); + } catch { + await writeFile(storedPath, downloaded.bytes); + } + let parsed: ParsedPdfDocument; + try { + parsed = await parser(storedPath); + } catch (error) { + const event: PublicOaAcquisitionEvent = { + schema: "longtable.public-oa-acquisition-event", + version: 1, + sourceCardId: card.id, + sourceRoute: card.sourceRoute, + requestedUrl: card.fullTextUrl, + resolvedUrl: downloaded.resolvedUrl, + status: "failed", + failureReason: "parse_failed", + detail: error instanceof Error ? error.message : String(error), + contentHash, + occurredAt + }; + events.push(event); + await appendOaEvent(input.runDir, event); + continue; + } + const derivedTextPath = join(input.runDir, "derived", "fulltext", `${digest}.json`); + await mkdir(resolve(derivedTextPath, ".."), { recursive: true }); + await writeFile(derivedTextPath, `${JSON.stringify({ + schema: "longtable.parsed-fulltext", + version: 1, + contentHash, + sourceVersion: contentHash, + accessClass: "public_oa", + ...parsed + }, null, 2)}\n`, "utf8"); + const record: FullTextManifestRecord = { + schema: "longtable.fulltext-manifest-record", + version: 1, + sourceId: card.id, + originalName: `${card.doi ?? card.id}.pdf`.replace(/[^A-Za-z0-9._-]+/g, "_"), + originalPath: card.fullTextUrl, + storedPath, + derivedTextPath, + contentHash, + sourceVersion: contentHash, + accessClass: "public_oa", + storageClass: "global_public_cache", + byteLength: downloaded.bytes.length, + pageCount: parsed.pageCount, + ocrPages: parsed.ocrPages, + collectedAt: occurredAt, + acquisition: { + route: "public_oa_url", + sourceCardId: card.id, + sourceRoute: card.sourceRoute, + requestedUrl: card.fullTextUrl, + resolvedUrl: downloaded.resolvedUrl, + httpStatus: downloaded.status, + ...(downloaded.contentType ? { contentType: downloaded.contentType } : {}) + } + }; + await appendFile(manifestPath, `${JSON.stringify(record)}\n`, "utf8"); + knownHashes.add(contentHash); + records.push(record); + const event: PublicOaAcquisitionEvent = { + schema: "longtable.public-oa-acquisition-event", + version: 1, + sourceCardId: card.id, + sourceRoute: card.sourceRoute, + requestedUrl: card.fullTextUrl, + resolvedUrl: downloaded.resolvedUrl, + status: "acquired", + detail: "The public OA PDF passed URL, network, size, signature, hash, and parser checks.", + contentHash, + manifestSourceId: record.sourceId, + occurredAt + }; + events.push(event); + await appendOaEvent(input.runDir, event); + } catch (error) { + const event: PublicOaAcquisitionEvent = { + schema: "longtable.public-oa-acquisition-event", + version: 1, + sourceCardId: card.id, + sourceRoute: card.sourceRoute, + requestedUrl: card.fullTextUrl, + status: "failed", + failureReason: oaFailureReason(error), + detail: error instanceof Error ? error.message : String(error), + occurredAt + }; + events.push(event); + await appendOaEvent(input.runDir, event); + } + } + return { records, events, manifestPath, eventsPath }; +} + +async function existingManifestHashes(path: string): Promise> { + return new Set((await readFullTextManifest(path)).map((record) => record.contentHash)); +} + +export async function readFullTextManifest(path: string): Promise { + try { + const lines = (await readFile(path, "utf8")).split("\n").filter(Boolean); + return lines.map((line) => { + try { + return JSON.parse(line) as FullTextManifestRecord; + } catch { + return undefined; + } + }).filter((record): record is FullTextManifestRecord => Boolean(record?.contentHash)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +export async function ingestPdfDirectory(input: IngestPdfDirectoryInput): Promise { + const directory = resolve(input.pdfDirectory); + const entries = (await readdir(directory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && extname(entry.name).toLowerCase() === ".pdf") + .sort((a, b) => a.name.localeCompare(b.name)); + if (entries.length === 0) throw new Error(`No PDF files were found in ${directory}.`); + + const manifestPath = join(input.runDir, "sources", "manifest.jsonl"); + const knownHashes = await existingManifestHashes(manifestPath); + const parser = input.parser ?? parsePdfWithLocalTools; + const records: FullTextManifestRecord[] = []; + await mkdir(join(input.runDir, "sources"), { recursive: true }); + + for (const entry of entries) { + const originalPath = join(directory, entry.name); + const bytes = await readFile(originalPath); + const hash = sha256(bytes); + if (knownHashes.has(`sha256:${hash}`)) continue; + const metadata = await stat(originalPath); + const publicCache = input.accessClass === "public_oa"; + const storedPath = publicCache + ? join(globalPublicCacheRoot(input.globalCacheRoot), hash.slice(0, 2), `${hash}.pdf`) + : join(input.runDir, "sources", "content", `${hash}.pdf`); + await mkdir(resolve(storedPath, ".."), { recursive: true }); + try { + await stat(storedPath); + } catch { + await copyFile(originalPath, storedPath); + } + const parsed = await parser(storedPath); + const derivedTextPath = join(input.runDir, "derived", "fulltext", `${hash}.json`); + await mkdir(resolve(derivedTextPath, ".."), { recursive: true }); + await writeFile(derivedTextPath, `${JSON.stringify({ + schema: "longtable.parsed-fulltext", + version: 1, + contentHash: `sha256:${hash}`, + sourceVersion: `sha256:${hash}`, + accessClass: input.accessClass, + ...parsed + }, null, 2)}\n`, "utf8"); + const record: FullTextManifestRecord = { + schema: "longtable.fulltext-manifest-record", + version: 1, + sourceId: `file:${hash}`, + originalName: entry.name, + originalPath, + storedPath, + derivedTextPath, + contentHash: `sha256:${hash}`, + sourceVersion: `sha256:${hash}`, + accessClass: input.accessClass, + storageClass: publicCache ? "global_public_cache" : "project_isolated", + byteLength: metadata.size, + pageCount: parsed.pageCount, + ocrPages: parsed.ocrPages, + collectedAt: new Date().toISOString() + }; + await appendFile(manifestPath, `${JSON.stringify(record)}\n`, "utf8"); + knownHashes.add(record.contentHash); + records.push(record); + } + return records; +} diff --git a/packages/longtable-research/src/index.ts b/packages/longtable-research/src/index.ts new file mode 100644 index 00000000..864024d5 --- /dev/null +++ b/packages/longtable-research/src/index.ts @@ -0,0 +1,17 @@ +export * from "./types.js"; +export * from "./query.js"; +export * from "./sources.js"; +export * from "./rank.js"; +export * from "./run.js"; +export * from "./publisher-access.js"; +export * from "./protocol.js"; +export * from "./workflow.js"; +export * from "./evaluation.js"; +export * from "./fulltext.js"; +export * from "./visual-contract.js"; +export * from "./extraction.js"; +export * from "./render.js"; +export * from "./replay.js"; +export * from "./research-brief.js"; +export * from "./journal-profile.js"; +export * from "./access-plan.js"; diff --git a/packages/longtable-research/src/journal-profile.ts b/packages/longtable-research/src/journal-profile.ts new file mode 100644 index 00000000..24a59efb --- /dev/null +++ b/packages/longtable-research/src/journal-profile.ts @@ -0,0 +1,78 @@ +export const TARGET_JOURNAL_PROFILE_VERSION = "1.0.0"; + +export interface TargetJournalProfile { + schema: "longtable.target-journal-profile"; + version: typeof TARGET_JOURNAL_PROFILE_VERSION; + profileId: string; + targetJournal: string; + createdAt: string; + evidenceSources: Array<{ + sourceId: string; + title: string; + locator: string; + contentHash: string; + accessClass: string; + }>; + topicPatterns: string[]; + formatPatterns: { + manuscriptStructure: string[]; + tables: string[]; + figures: string[]; + diagrams: string[]; + }; + humanReview: { + reviewer: string; + reviewedAt: string; + decision: "accept" | "reject"; + notes: string; + }; +} + +export function validateTargetJournalProfile(value: unknown): TargetJournalProfile { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Target-journal profile must be a JSON object."); + } + const profile = value as Partial; + if (profile.schema !== "longtable.target-journal-profile" || + profile.version !== TARGET_JOURNAL_PROFILE_VERSION) { + throw new Error(`Target-journal profile must use version ${TARGET_JOURNAL_PROFILE_VERSION}.`); + } + for (const [field, content] of [ + ["profileId", profile.profileId], + ["targetJournal", profile.targetJournal], + ["createdAt", profile.createdAt] + ] as const) { + if (typeof content !== "string" || !content.trim()) { + throw new Error(`Target-journal profile field ${field} is required.`); + } + } + if (!Array.isArray(profile.evidenceSources) || profile.evidenceSources.length === 0 || + profile.evidenceSources.some((source) => + !source.sourceId?.trim() || + !source.title?.trim() || + !source.locator?.trim() || + !source.contentHash?.startsWith("sha256:") || + !source.accessClass?.trim() + )) { + throw new Error("Target-journal profile requires at least one provenance-complete evidence source."); + } + if (!Array.isArray(profile.topicPatterns) || profile.topicPatterns.length === 0) { + throw new Error("Target-journal profile requires at least one topic pattern."); + } + const format = profile.formatPatterns; + if (!format || + !Array.isArray(format.manuscriptStructure) || + !Array.isArray(format.tables) || + !Array.isArray(format.figures) || + !Array.isArray(format.diagrams) || + format.manuscriptStructure.length + format.tables.length + format.figures.length + format.diagrams.length === 0) { + throw new Error("Target-journal profile requires observed manuscript or visual format patterns."); + } + if (!profile.humanReview || + !profile.humanReview.reviewer?.trim() || + !profile.humanReview.reviewedAt?.trim() || + profile.humanReview.decision !== "accept") { + throw new Error("Target-journal profile requires an accepted human review."); + } + return profile as TargetJournalProfile; +} diff --git a/packages/longtable-scholar-research/src/protocol.ts b/packages/longtable-research/src/protocol.ts similarity index 97% rename from packages/longtable-scholar-research/src/protocol.ts rename to packages/longtable-research/src/protocol.ts index 727cc764..323e9ef8 100644 --- a/packages/longtable-scholar-research/src/protocol.ts +++ b/packages/longtable-research/src/protocol.ts @@ -1,7 +1,9 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; -export const SCHOLAR_RESEARCH_SKILL_NAME = "scholar-research"; +export const LONGTABLE_RESEARCH_SKILL_NAME = "longtable-research"; +/** @deprecated Use LONGTABLE_RESEARCH_SKILL_NAME. */ +export const SCHOLAR_RESEARCH_SKILL_NAME = LONGTABLE_RESEARCH_SKILL_NAME; export const SCHOLAR_RESEARCH_FAILURE_REASONS = [ "not_found", diff --git a/packages/longtable-scholar-research/src/publisher-access.ts b/packages/longtable-research/src/publisher-access.ts similarity index 100% rename from packages/longtable-scholar-research/src/publisher-access.ts rename to packages/longtable-research/src/publisher-access.ts diff --git a/packages/longtable-scholar-research/src/query.ts b/packages/longtable-research/src/query.ts similarity index 100% rename from packages/longtable-scholar-research/src/query.ts rename to packages/longtable-research/src/query.ts diff --git a/packages/longtable-scholar-research/src/rank.ts b/packages/longtable-research/src/rank.ts similarity index 100% rename from packages/longtable-scholar-research/src/rank.ts rename to packages/longtable-research/src/rank.ts diff --git a/packages/longtable-research/src/render.ts b/packages/longtable-research/src/render.ts new file mode 100644 index 00000000..f2944ce3 --- /dev/null +++ b/packages/longtable-research/src/render.ts @@ -0,0 +1,528 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, join, resolve, sep } from "node:path"; +import type { VisualEvidenceContract } from "./visual-contract.js"; + +export const ALLOWLISTED_VISUAL_RENDERERS = ["longtable-svg-v1"] as const; +export type AllowlistedVisualRenderer = typeof ALLOWLISTED_VISUAL_RENDERERS[number]; + +export interface VisualRenderRequest { + schema: "longtable.visual-render-request"; + version: 1; + runId: string; + contractId: string; + renderer: AllowlistedVisualRenderer; + widthPx: number; + heightPx: number; + specification: + | { + kind: "table"; + columns: Array<{ field: string; label: string; align?: "left" | "right" }>; + maxRows?: number; + } + | { + kind: "figure"; + mark: "bar" | "line" | "point"; + orientation?: "vertical" | "horizontal"; + xField: string; + yField: string; + seriesField?: string; + denominatorField?: string; + directLabel?: "value" | "fraction_percent"; + xLabel: string; + yLabel: string; + } + | { + kind: "diagram"; + nodeIdField: string; + nodeLabelField: string; + nodeLayerField: string; + edgeSourceField: string; + edgeTargetField: string; + edgeLabelField?: string; + }; +} + +export interface VisualRenderManifest { + schema: "longtable.visual-render-manifest"; + version: 1; + runId: string; + contractId: string; + renderer: AllowlistedVisualRenderer; + rendererVersion: "1"; + renderedAt: string; + dataSnapshotPath: string; + dataSnapshotHash: string; + requestHash: string; + editableSourcePath: string; + editableSourceHash: string; + exportPaths: string[]; + placementManifestPath: string; + widthPx: number; + heightPx: number; +} + +export interface MechanicalVisualQa { + schema: "longtable.mechanical-visual-qa"; + version: 1; + runId: string; + contractId: string; + renderer: AllowlistedVisualRenderer; + checkedAt: string; + passed: boolean; + hardFailures: string[]; + warnings: string[]; + checks: { + outputExists: boolean; + deterministicHashPresent: boolean; + noActiveContent: boolean; + declaredDimensions: boolean; + minimumFontSize: boolean; + directLabelsPresent: boolean; + dataSnapshotBound: boolean; + }; +} + +type DataRow = Record; + +function sha256(value: string | Buffer): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +function xml(value: unknown): string { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function assertWithin(parent: string, child: string): void { + const root = resolve(parent); + const target = resolve(child); + if (target !== root && !target.startsWith(`${root}${sep}`)) { + throw new Error("Visual renderer output escaped its run directory."); + } +} + +function finiteNumber(value: unknown, field: string): number { + const number = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(number)) throw new Error(`Visual data field ${field} must be numeric.`); + return number; +} + +function textElement(x: number, y: number, value: unknown, options?: { + anchor?: "start" | "middle" | "end"; + size?: number; + weight?: number; + rotate?: number; +}): string { + const transform = options?.rotate ? ` transform="rotate(${options.rotate} ${x} ${y})"` : ""; + return `${xml(value)}`; +} + +function svgShell(input: { + width: number; + height: number; + title: string; + description: string; + body: string; +}): string { + return [ + ``, + `${xml(input.title)}`, + `${xml(input.description)}`, + ``, + ``, + input.body, + "", + "", + "" + ].join("\n"); +} + +function renderTable(contract: VisualEvidenceContract, request: VisualRenderRequest, rows: DataRow[]): string { + if (request.specification.kind !== "table") throw new Error("Table contract requires a table specification."); + const columns = request.specification.columns; + if (columns.length === 0) throw new Error("Table renderer requires at least one column."); + const maxRows = Math.max(1, Math.min(30, request.specification.maxRows ?? 15)); + const visibleRows = rows.slice(0, maxRows); + const margin = 24; + const captionHeight = 52; + const rowHeight = 25; + const columnWidth = (request.widthPx - margin * 2) / columns.length; + const body: string[] = [ + textElement(margin, 24, contract.analyticalQuestion, { size: 13, weight: 700 }), + textElement(margin, 43, `Denominator: ${contract.dataContract.denominator}`, { size: 9 }) + ]; + const top = captionHeight; + body.push(``); + columns.forEach((column, index) => { + const x = margin + index * columnWidth + (column.align === "right" ? columnWidth - 4 : 4); + body.push(textElement(x, top + 17, column.label, { + anchor: column.align === "right" ? "end" : "start", + size: 10, + weight: 700 + })); + }); + body.push(``); + visibleRows.forEach((row, rowIndex) => { + const y = top + rowHeight * (rowIndex + 1) + 17; + columns.forEach((column, columnIndex) => { + const x = margin + columnIndex * columnWidth + (column.align === "right" ? columnWidth - 4 : 4); + body.push(textElement(x, y, row[column.field], { + anchor: column.align === "right" ? "end" : "start", + size: 9 + })); + }); + body.push(``); + }); + if (rows.length > visibleRows.length) { + body.push(textElement(margin, request.heightPx - 10, `${rows.length - visibleRows.length} additional rows belong in the supplement.`, { size: 8 })); + } + return svgShell({ + width: request.widthPx, + height: request.heightPx, + title: contract.readerTakeaway, + description: contract.accessibility?.textAlternative ?? contract.readerTakeaway, + body: body.join("\n") + }); +} + +function renderFigure(contract: VisualEvidenceContract, request: VisualRenderRequest, rows: DataRow[]): string { + if (request.specification.kind !== "figure") throw new Error("Figure contract requires a figure specification."); + const spec = request.specification; + if (rows.length === 0) throw new Error("Figure renderer requires at least one data row."); + const margin = { top: 54, right: 24, bottom: 58, left: 68 }; + if (spec.orientation === "horizontal") { + if (spec.mark !== "bar") throw new Error("Horizontal figures currently require bar marks."); + const left = Math.min(300, Math.max(150, Math.floor(request.widthPx * 0.34))); + const right = 90; + const top = 70; + const bottom = 42; + const plotWidth = request.widthPx - left - right; + const plotHeight = request.heightPx - top - bottom; + const domain = contract.axis?.domain ?? [0, Math.max(...rows.map((row) => finiteNumber(row[spec.yField], spec.yField))) || 1]; + if (domain[0] !== 0) throw new Error("Horizontal bar figures require a zero baseline."); + const seriesValues = spec.seriesField + ? [...new Set(rows.map((row) => String(row[spec.seriesField!] ?? "")))] + : []; + const panelGap = seriesValues.length > 1 ? 22 : 0; + const rowStep = (plotHeight - panelGap * seriesValues.length) / rows.length; + if (rowStep < 18) throw new Error("Horizontal bar rows are too dense for the approved output size."); + const body: string[] = [ + textElement(20, 23, contract.analyticalQuestion, { size: 11, weight: 700 }), + textElement(20, 42, `Denominator: ${contract.dataContract.denominator}`, { size: 9 }), + ``, + `` + ]; + let priorSeries: string | undefined; + let encounteredSeries = 0; + rows.forEach((row, index) => { + const value = finiteNumber(row[spec.yField], spec.yField); + if (value < domain[0] || value > domain[1]) throw new Error("Figure data falls outside the approved axis domain."); + const series = spec.seriesField ? String(row[spec.seriesField] ?? "") : undefined; + const isNewSeries = Boolean(series && series !== priorSeries); + if (isNewSeries) encounteredSeries += 1; + const centerY = top + encounteredSeries * panelGap + (index + 0.5) * rowStep; + if (series && series !== priorSeries) { + if (priorSeries) { + const dividerY = centerY - rowStep / 2 - panelGap / 2; + body.push(``); + } + body.push(textElement(20, centerY - rowStep / 2 - 6, series, { size: 8, weight: 700 })); + priorSeries = series; + } + const width = (value - domain[0]) / (domain[1] - domain[0]) * plotWidth; + body.push(textElement(left - 8, centerY + 3, row[spec.xField], { anchor: "end", size: 8 })); + body.push(``); + let label = String(value); + if (spec.directLabel === "fraction_percent") { + if (!spec.denominatorField) throw new Error("fraction_percent labels require denominatorField."); + const denominator = finiteNumber(row[spec.denominatorField], spec.denominatorField); + label = `${value}/${denominator} (${(value / denominator * 100).toFixed(1)}%)`; + } + body.push(textElement(Math.min(left + width + 6, request.widthPx - right + 4), centerY + 3, label, { size: 8, weight: 700 })); + }); + for (let tick = 0; tick <= 4; tick += 1) { + const value = domain[0] + (domain[1] - domain[0]) * tick / 4; + const tickX = left + plotWidth * tick / 4; + body.push(``); + body.push(textElement(tickX, top + plotHeight + 15, Number(value.toPrecision(3)), { anchor: "middle", size: 8 })); + } + body.push(textElement(left + plotWidth / 2, request.heightPx - 8, spec.yLabel, { anchor: "middle", size: 9, weight: 700 })); + return svgShell({ + width: request.widthPx, + height: request.heightPx, + title: contract.readerTakeaway, + description: contract.accessibility?.textAlternative ?? contract.readerTakeaway, + body: body.join("\n") + }); + } + const plotWidth = request.widthPx - margin.left - margin.right; + const plotHeight = request.heightPx - margin.top - margin.bottom; + const yValues = rows.map((row) => finiteNumber(row[spec.yField], spec.yField)); + const domain = contract.axis?.domain ?? [0, Math.max(...yValues) * 1.08 || 1]; + if (spec.mark === "bar" && domain[0] !== 0) { + throw new Error("Bar figures require a zero baseline."); + } + if (yValues.some((value) => value < domain[0] || value > domain[1])) { + throw new Error("Figure data falls outside the approved axis domain."); + } + const xValues = rows.map((row) => String(row[spec.xField] ?? "")); + const x = (index: number): number => margin.left + ((index + 0.5) / rows.length) * plotWidth; + const y = (value: number): number => margin.top + plotHeight - + ((value - domain[0]) / (domain[1] - domain[0])) * plotHeight; + const body: string[] = [ + textElement(margin.left, 22, contract.analyticalQuestion, { size: 13, weight: 700 }), + textElement(margin.left, 40, `Denominator: ${contract.dataContract.denominator}`, { size: 9 }), + ``, + `` + ]; + for (let tick = 0; tick <= 4; tick += 1) { + const value = domain[0] + (domain[1] - domain[0]) * tick / 4; + const tickY = y(value); + body.push(``); + body.push(textElement(margin.left - 7, tickY + 3, Number(value.toPrecision(3)), { anchor: "end", size: 8 })); + } + if (spec.mark === "bar") { + const barWidth = Math.max(4, plotWidth / rows.length * 0.58); + rows.forEach((row, index) => { + const value = yValues[index]; + const top = y(value); + body.push(``); + body.push(textElement(x(index), top - 5, value, { anchor: "middle", size: 8, weight: 700 })); + }); + } else { + if (spec.mark === "line") { + const points = yValues.map((value, index) => `${x(index)},${y(value)}`).join(" "); + body.push(``); + } + rows.forEach((row, index) => { + const value = yValues[index]; + body.push(``); + body.push(textElement(x(index), y(value) - 7, value, { anchor: "middle", size: 8, weight: 700 })); + }); + } + xValues.forEach((value, index) => { + body.push(textElement(x(index), margin.top + plotHeight + 17, value, { anchor: "middle", size: 8 })); + }); + body.push(textElement(margin.left + plotWidth / 2, request.heightPx - 10, spec.xLabel, { anchor: "middle", size: 9, weight: 700 })); + body.push(textElement(14, margin.top + plotHeight / 2, spec.yLabel, { anchor: "middle", size: 9, weight: 700, rotate: -90 })); + return svgShell({ + width: request.widthPx, + height: request.heightPx, + title: contract.readerTakeaway, + description: contract.accessibility?.textAlternative ?? contract.readerTakeaway, + body: body.join("\n") + }); +} + +function renderDiagram(contract: VisualEvidenceContract, request: VisualRenderRequest, snapshot: unknown): string { + if (request.specification.kind !== "diagram") throw new Error("Diagram contract requires a diagram specification."); + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + throw new Error("Diagram snapshot must contain nodes and edges arrays."); + } + const record = snapshot as Record; + const nodes = Array.isArray(record.nodes) ? record.nodes as DataRow[] : []; + const edges = Array.isArray(record.edges) ? record.edges as DataRow[] : []; + if (nodes.length === 0) throw new Error("Diagram renderer requires at least one node."); + const spec = request.specification; + const performed = nodes.filter((node) => String(node[spec.nodeLayerField]) === "performed"); + const proposed = nodes.filter((node) => String(node[spec.nodeLayerField]) === "proposed"); + if (nodes.length !== performed.length + proposed.length) { + throw new Error("Every diagram node must be explicitly labeled performed or proposed."); + } + const positions = new Map(); + const body: string[] = [ + textElement(24, 24, contract.analyticalQuestion, { size: 13, weight: 700 }), + textElement(request.widthPx * 0.25, 48, "Performed", { anchor: "middle", size: 10, weight: 700 }), + textElement(request.widthPx * 0.75, 48, "Proposed", { anchor: "middle", size: 10, weight: 700 }), + `` + ]; + function place(layer: DataRow[], x: number): void { + layer.forEach((node, index) => { + const y = 80 + index * Math.max(54, (request.heightPx - 110) / Math.max(1, layer.length)); + const id = String(node[spec.nodeIdField]); + positions.set(id, { x, y }); + }); + } + place(performed, request.widthPx * 0.25); + place(proposed, request.widthPx * 0.75); + for (const edge of edges) { + const source = positions.get(String(edge[spec.edgeSourceField])); + const target = positions.get(String(edge[spec.edgeTargetField])); + if (!source || !target) throw new Error("Diagram edge references an unknown node."); + body.push(``); + if (spec.edgeLabelField && edge[spec.edgeLabelField]) { + body.push(textElement((source.x + target.x) / 2, (source.y + target.y) / 2 - 4, edge[spec.edgeLabelField], { anchor: "middle", size: 8 })); + } + } + body.unshift(``); + for (const node of nodes) { + const id = String(node[spec.nodeIdField]); + const position = positions.get(id)!; + body.push(``); + body.push(textElement(position.x, position.y + 4, node[spec.nodeLabelField], { anchor: "middle", size: 9, weight: 700 })); + } + return svgShell({ + width: request.widthPx, + height: request.heightPx, + title: contract.readerTakeaway, + description: contract.accessibility?.textAlternative ?? contract.readerTakeaway, + body: body.join("\n") + }); +} + +async function readSnapshot(path: string): Promise<{ raw: Buffer; parsed: unknown; rows: DataRow[] }> { + const raw = await readFile(path); + const parsed = JSON.parse(raw.toString("utf8")) as unknown; + const rows = Array.isArray(parsed) + ? parsed as DataRow[] + : parsed && typeof parsed === "object" && Array.isArray((parsed as Record).rows) + ? (parsed as { rows: DataRow[] }).rows + : []; + return { raw, parsed, rows }; +} + +export async function renderVisualArtifact(input: { + runDir: string; + contract: VisualEvidenceContract; + request: VisualRenderRequest; +}): Promise<{ manifest: VisualRenderManifest; manifestPath: string; qa: MechanicalVisualQa; qaPath: string }> { + if (input.contract.status !== "approved" || !input.contract.approval) { + throw new Error("Visual rendering requires a human-approved contract."); + } + if (input.request.schema !== "longtable.visual-render-request" || input.request.version !== 1 || + input.request.contractId !== input.contract.id || + input.request.renderer !== "longtable-svg-v1") { + throw new Error("Invalid or non-allowlisted visual render request."); + } + if (input.request.specification.kind !== input.contract.kind) { + throw new Error("Render request kind does not match the approved contract."); + } + if (input.contract.output.renderer !== input.request.renderer || + input.contract.output.rendererVersion !== "1" || + input.contract.output.editableSourceFormat !== "svg") { + throw new Error("Approved contract does not authorize longtable-svg-v1 editable SVG output."); + } + if (!Number.isInteger(input.request.widthPx) || !Number.isInteger(input.request.heightPx) || + input.request.widthPx < 320 || input.request.widthPx > 2400 || + input.request.heightPx < 220 || input.request.heightPx > 2400) { + throw new Error("Visual dimensions must be integer pixels within the allowlisted range."); + } + const snapshotPath = resolve(input.contract.dataContract.dataSnapshotPath); + const snapshot = await readSnapshot(snapshotPath); + const outputDirectory = join(resolve(input.runDir), "artifacts", "visuals", input.contract.id); + assertWithin(input.runDir, outputDirectory); + await mkdir(outputDirectory, { recursive: true }); + const svg = input.contract.kind === "table" + ? renderTable(input.contract, input.request, snapshot.rows) + : input.contract.kind === "figure" + ? renderFigure(input.contract, input.request, snapshot.rows) + : renderDiagram(input.contract, input.request, snapshot.parsed); + const editableSourcePath = join(outputDirectory, `${input.contract.id}.svg`); + await writeFile(editableSourcePath, svg, "utf8"); + const placementManifestPath = join(outputDirectory, "placement.json"); + await writeFile(placementManifestPath, `${JSON.stringify({ + schema: "longtable.visual-placement-manifest", + version: 1, + contractId: input.contract.id, + firstInterpretiveClaimId: input.contract.manuscriptClaimIds[0], + mainTextRole: input.contract.mainTextRole, + supplementRole: input.contract.supplementRole, + prohibitedInferences: input.contract.evidenceBoundary.prohibitedInferences, + leadIn: { + question: input.contract.analyticalQuestion, + pattern: input.contract.readerTakeaway, + prohibitedInference: input.contract.evidenceBoundary.prohibitedInferences[0] ?? "none" + } + }, null, 2)}\n`, "utf8"); + const manifest: VisualRenderManifest = { + schema: "longtable.visual-render-manifest", + version: 1, + runId: input.request.runId, + contractId: input.contract.id, + renderer: input.request.renderer, + rendererVersion: "1", + renderedAt: new Date().toISOString(), + dataSnapshotPath: snapshotPath, + dataSnapshotHash: sha256(snapshot.raw), + requestHash: sha256(JSON.stringify(input.request)), + editableSourcePath, + editableSourceHash: sha256(svg), + exportPaths: [editableSourcePath], + placementManifestPath, + widthPx: input.request.widthPx, + heightPx: input.request.heightPx + }; + const manifestPath = join(outputDirectory, "render-manifest.json"); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + const qa = await verifyRenderedVisual({ contract: input.contract, manifest }); + const qaPath = join(outputDirectory, "mechanical-qa.json"); + await writeFile(qaPath, `${JSON.stringify(qa, null, 2)}\n`, "utf8"); + return { manifest, manifestPath, qa, qaPath }; +} + +export async function verifyRenderedVisual(input: { + contract: VisualEvidenceContract; + manifest: VisualRenderManifest; +}): Promise { + const hardFailures: string[] = []; + const warnings: string[] = []; + let svg = ""; + try { + svg = await readFile(input.manifest.editableSourcePath, "utf8"); + } catch { + hardFailures.push("Editable visual output is missing or unreadable."); + } + const noActiveContent = !/ Number(match[1])); + const minimumFontSize = fontSizes.length > 0 && + Math.min(...fontSizes) >= (input.contract.accessibility?.minimumFontSizePt ?? 7); + if (!minimumFontSize) hardFailures.push("Rendered text falls below the approved minimum font size."); + const directLabelsPresent = !input.contract.directLabels || /; + corpus: { + reportRows: number; + expectedReportRows: 148; + exact: boolean; + }; + figure7: Array<{ + panel: string; + field: string; + label: string; + observed: number; + expected: number; + exact: boolean; + }>; + decisionRecovery: Record; + priorQaAssertionsPassed: boolean; + journalExemplarGate: { + supplied: boolean; + valid: boolean; + count: number; + hardFailures: string[]; + }; + visualContractValidation?: ReturnType; + render?: { + manifestPath: string; + manifest: VisualRenderManifest; + qaPath: string; + qa: MechanicalVisualQa; + }; + replayMechanicsPassed: boolean; + humanRenderGate: { + supplied: boolean; + valid: boolean; + reviewPath?: string; + failures: string[]; + }; + qualityGatePassed: boolean; + interpretation: string; +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (quoted) { + if (character === '"' && text[index + 1] === '"') { + field += '"'; + index += 1; + } else if (character === '"') { + quoted = false; + } else { + field += character; + } + } else if (character === '"') { + quoted = true; + } else if (character === ",") { + row.push(field); + field = ""; + } else if (character === "\n") { + row.push(field.replace(/\r$/, "")); + rows.push(row); + row = []; + field = ""; + } else { + field += character; + } + } + if (field || row.length > 0) { + row.push(field.replace(/\r$/, "")); + rows.push(row); + } + if (quoted) throw new Error("Unterminated quoted field in replay CSV."); + return rows.filter((entry) => entry.some((value) => value.length > 0)); +} + +function recordsFromCsv(text: string): Array> { + const rows = parseCsv(text); + const headers = rows.shift(); + if (!headers) return []; + return rows.map((values) => Object.fromEntries( + headers.map((header, index) => [header, values[index] ?? ""]) + )); +} + +function truthy(value: string): boolean { + return value.trim().toLowerCase() === "true"; +} + +async function artifact(role: string, path: string): Promise<{ role: string; path: string; bytes: number; sha256: string; content: Buffer }> { + const content = await readFile(path); + const metadata = await stat(path); + return { role, path, bytes: metadata.size, sha256: sha256(content), content }; +} + +function recoverDecisions(contract: string, debrief: string): Record { + const text = `${contract}\n${debrief}`; + return { + oneFigureOneQuestionOneSentence: /one figure,\s*one analytical question,\s*one sentence/i.test(text), + noPooling: /no pooled estimates|no mean,\s*median,\s*pooled estimate/i.test(text), + reportEstimandGrain: /strict report[–-]estimand row/i.test(text), + performedProposedBoundary: "not_applicable", + mainVersusSupplement: /main-reader layer[\s\S]*audit-completeness layer/i.test(text), + exactDenominator: /n\/148/i.test(text), + evidenceFirstOrdering: /evidence-first|never by performance/i.test(text), + observedRangeNotCi: /observed range[—-]not ci/i.test(text), + separateEstimands: /remain separate|distinct evidence bases/i.test(text), + timedReaderTest: /10[–-]20 second|ten-to-twenty-second/i.test(text) + }; +} + +function exemplarFailures(manifest: ReplayExemplarManifest | undefined): string[] { + if (!manifest) return ["No located target-journal visual exemplar manifest was supplied."]; + const failures: string[] = []; + if (manifest.schema !== "longtable.journal-visual-exemplar-manifest" || + manifest.version !== 1 || + manifest.journal !== "Educational Psychology Review") { + failures.push("Exemplar manifest schema, version, or journal is invalid."); + } + if (!Array.isArray(manifest.exemplars) || manifest.exemplars.length === 0) { + failures.push("At least one located target-journal exemplar is required."); + } + for (const exemplar of manifest.exemplars ?? []) { + if (!exemplar.sourceId || !exemplar.sourceVersion || !exemplar.accessClass || + !exemplar.pageRenderPath || !Number.isInteger(exemplar.page) || exemplar.page < 1 || + !exemplar.caption || !exemplar.surroundingContext || !exemplar.observedPattern || + !Number.isFinite(exemplar.boundingBox.x) || !Number.isFinite(exemplar.boundingBox.y) || + !Number.isFinite(exemplar.boundingBox.width) || exemplar.boundingBox.width <= 0 || + !Number.isFinite(exemplar.boundingBox.height) || exemplar.boundingBox.height <= 0) { + failures.push(`Exemplar ${exemplar.sourceId || "unknown"} lacks required provenance or location.`); + } + } + return failures; +} + +export async function runPaperBReplay(input: { + sourceRoot: string; + outputDirectory: string; + exemplarManifestPath?: string; + humanReviewPath?: string; +}): Promise<{ result: PaperBReplayResult; resultPath: string; snapshotPath: string }> { + const sourceRoot = resolve(input.sourceRoot); + const outputDirectory = resolve(input.outputDirectory); + await mkdir(outputDirectory, { recursive: true }); + const paths = Object.fromEntries( + Object.entries(RELATIVE).map(([key, relative]) => [key, join(sourceRoot, relative)]) + ) as Record; + const sourceArtifactsWithContent = await Promise.all([ + artifact("visual-contract", paths.contract), + artifact("human-reader-debrief", paths.debrief), + artifact("target-journal-contract", paths.journal), + artifact("prior-qa", paths.qa), + artifact("figure-7-report-evidence", paths.reportEvidence) + ]); + const byRole = new Map(sourceArtifactsWithContent.map((entry) => [entry.role, entry])); + const contractText = byRole.get("visual-contract")!.content.toString("utf8"); + const debriefText = byRole.get("human-reader-debrief")!.content.toString("utf8"); + const qa = JSON.parse(byRole.get("prior-qa")!.content.toString("utf8")) as { + assertions?: Record; + }; + const records = recordsFromCsv(byRole.get("figure-7-report-evidence")!.content.toString("utf8")); + const figure7 = FIGURE_7_EXPECTED.map((expected) => { + const observed = records.filter((record) => truthy(record[expected.field] ?? "")).length; + return { ...expected, observed, expected: expected.count, exact: observed === expected.count }; + }); + const snapshot = figure7.map((entry) => ({ + panel: entry.panel, + feature: entry.label, + reports: entry.observed, + denominator: records.length + })); + const snapshotPath = join(outputDirectory, "paper-b-figure-7-replay-data.json"); + await writeFile(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8"); + let exemplarManifest: ReplayExemplarManifest | undefined; + if (input.exemplarManifestPath) { + exemplarManifest = JSON.parse( + await readFile(resolve(input.exemplarManifestPath), "utf8") + ) as ReplayExemplarManifest; + } + const exemplarHardFailures = exemplarFailures(exemplarManifest); + const decisionRecovery = recoverDecisions(contractText, debriefText); + const priorQaAssertionsPassed = Boolean(qa.assertions) && + Object.values(qa.assertions ?? {}).every((value) => value === true); + let visualContractValidation: ReturnType | undefined; + let render: PaperBReplayResult["render"]; + let renderedContract: VisualEvidenceContract | undefined; + if (exemplarManifest && exemplarHardFailures.length === 0) { + const visualContract: VisualEvidenceContract = { + schema: "longtable.visual-evidence-contract", + version: 1, + id: "paper-b-epr-figure-7-replay", + kind: "figure", + status: "approved", + analyticalQuestion: "Which validation safeguards and source-mappable reporting categories are present across the 148-report corpus?", + readerTakeaway: "Coverage is uneven, and reporting counts indicate evidence presence rather than error prevalence.", + necessity: "Two ranked corpus-wide displays expose exact denominators without giving small stage cells equal visual weight.", + manuscriptClaimIds: ["paper-b-figure-7-main-claim"], + targetJournal: exemplarManifest.journal, + journalGrammar: { + journal: exemplarManifest.journal, + derivedFrom: exemplarManifest.exemplars, + pageOrColumnWidth: "full-width", + typography: ["Arial or Helvetica", "8–12 pt at final size"], + lineAndMarkerRules: ["grayscale-safe", "direct labels", "no color-only meaning"], + tableRules: [], + captionRules: ["caption outside artwork", "state evidence presence is not prevalence"], + colorPolicy: "grayscale", + notes: ["Replay recovers the approved Figure 7 decision; it does not constitute holdout evidence."] + }, + evidenceBoundary: { + performed: ["Frozen report-level coding and exact corpus counts"], + proposed: [], + prohibitedInferences: ["Error prevalence", "Event prevalence", "Causal effects"] + }, + dataContract: { + dataSnapshotPath: snapshotPath, + unitOfAnalysis: "included report", + denominator: "all 148 included reports", + estimandOrConstruct: "presence of coded safeguard or source-mappable reporting evidence", + uncertainty: "exact corpus count; no sampling interval", + independentUnitField: "study_id" + }, + readingOrder: ["question", "panel", "exact n/148 label", "prohibited prevalence inference"], + mainTextRole: "Corpus-wide evidence hierarchy and exact denominator", + supplementRole: "Stage-specific n/N and complete report-level evidence matrix", + directLabels: true, + axis: { domain: [0, 148] }, + output: { + editableSourceFormat: "svg", + renderer: "longtable-svg-v1", + rendererVersion: "1", + journalSizedExports: ["paper-b-epr-figure-7-replay.svg"], + placementManifestPath: "placement.json" + }, + readerTest: { + seconds: 20, + recover: ["question", "denominator", "key_value_or_relation", "conclusion"] + }, + accessibility: { + grayscaleSafe: true, + colorVisionSafe: true, + textAlternative: "Two groups of directly labeled bars show how many of 148 reports contain each safeguard or reporting category.", + minimumFontSizePt: 8 + }, + verificationPlan: { + domainMeaning: "Confirm counts mean evidence presence rather than event or error prevalence.", + statisticalIntegrity: "Recompute all 13 numerators from the frozen 148-report matrix.", + journalFit: "Check full-width size, typography, captions, and grayscale against located exemplars.", + readerComprehension: "Test recovery of question, 148-report denominator, uneven coverage, and prohibited prevalence inference within 20 seconds." + }, + approval: { + approvedBy: "Paper B replay of prior human decisions", + approvedAt: new Date().toISOString(), + decisionRecordId: `replay-${PAPER_B_REPLAY_THREAD_ID}` + } + }; + renderedContract = visualContract; + visualContractValidation = validateVisualEvidenceContract(visualContract); + if (visualContractValidation.valid) { + const rendered = await renderVisualArtifact({ + runDir: outputDirectory, + contract: visualContract, + request: { + schema: "longtable.visual-render-request", + version: 1, + runId: `replay-${PAPER_B_REPLAY_THREAD_ID}`, + contractId: visualContract.id, + renderer: "longtable-svg-v1", + widthPx: 920, + heightPx: 560, + specification: { + kind: "figure", + mark: "bar", + orientation: "horizontal", + xField: "feature", + yField: "reports", + seriesField: "panel", + denominatorField: "denominator", + directLabel: "fraction_percent", + xLabel: "Evidence category", + yLabel: "Reports with evidence" + } + } + }); + render = { + manifestPath: rendered.manifestPath, + manifest: rendered.manifest, + qaPath: rendered.qaPath, + qa: rendered.qa + }; + } + } + const sourceArtifacts = sourceArtifactsWithContent.map(({ content: _content, ...entry }) => entry); + const corpusExact = records.length === 148; + const decisionsExact = Object.values(decisionRecovery).every( + (value) => value === true || value === "not_applicable" + ); + const countsExact = figure7.every((entry) => entry.exact); + const replayMechanicsPassed = corpusExact && decisionsExact && countsExact && + priorQaAssertionsPassed && exemplarHardFailures.length === 0 && + visualContractValidation?.valid === true && render?.qa.passed === true; + const humanFailures: string[] = []; + let humanReview: HumanRenderedVisualReview | undefined; + if (input.humanReviewPath) { + humanReview = JSON.parse( + await readFile(resolve(input.humanReviewPath), "utf8") + ) as HumanRenderedVisualReview; + if (humanReview.schema !== "longtable.human-rendered-visual-review" || + humanReview.version !== 1 || + humanReview.runId !== `replay-${PAPER_B_REPLAY_THREAD_ID}` || + humanReview.contractId !== renderedContract?.id || + humanReview.decision !== "accept" || + !humanReview.reviewer?.trim() || !humanReview.reviewedAt || + !humanReview.rationale?.trim()) { + humanFailures.push("Human replay review schema, identity, decision, or rationale is invalid."); + } + const humanChecks = Object.values(humanReview.checks ?? {}); + if (humanChecks.length !== 4 || humanChecks.some((value) => value !== true)) { + humanFailures.push("All four human verification lenses must pass."); + } + const readerValues = humanReview.readerTest + ? [ + humanReview.readerTest.recoveredQuestion, + humanReview.readerTest.recoveredDenominator, + humanReview.readerTest.recoveredKeyValueOrRelation, + humanReview.readerTest.recoveredConclusion + ] + : []; + if (readerValues.length !== 4 || readerValues.some((value) => value !== true) || + !humanReview.readerTest || humanReview.readerTest.seconds <= 0 || + humanReview.readerTest.seconds > (renderedContract?.readerTest.seconds ?? 20)) { + humanFailures.push("The timed reader test did not recover all four required elements within the contract threshold."); + } + } else { + humanFailures.push("The newly rendered replay artifact has not received a human rendered-visual review."); + } + const humanRenderGate = { + supplied: Boolean(humanReview), + valid: Boolean(humanReview) && humanFailures.length === 0, + ...(input.humanReviewPath ? { reviewPath: resolve(input.humanReviewPath) } : {}), + failures: humanFailures + }; + const qualityGatePassed = replayMechanicsPassed && humanRenderGate.valid; + const result: PaperBReplayResult = { + schema: "longtable.paper-b-replay-result", + version: 1, + threadId: PAPER_B_REPLAY_THREAD_ID, + posture: "replay_only", + sourceRoot, + outputDirectory, + executedAt: new Date().toISOString(), + sourceArtifacts, + corpus: { reportRows: records.length, expectedReportRows: 148, exact: corpusExact }, + figure7, + decisionRecovery, + priorQaAssertionsPassed, + journalExemplarGate: { + supplied: Boolean(exemplarManifest), + valid: exemplarHardFailures.length === 0, + count: exemplarManifest?.exemplars.length ?? 0, + hardFailures: exemplarHardFailures + }, + ...(visualContractValidation ? { visualContractValidation } : {}), + ...(render ? { render } : {}), + replayMechanicsPassed, + humanRenderGate, + qualityGatePassed, + interpretation: qualityGatePassed + ? "The frozen case reproduced its approved Figure 7 decision and exact counts. This is replay evidence only, not a holdout or prospective productivity result." + : replayMechanicsPassed + ? "The frozen decisions, exact counts, journal exemplars, render, and mechanical QA reproduced, but the new rendered artifact still requires human review. This remains replay evidence only." + : "The replay recovered available decisions and counts but did not pass every non-compensatory mechanical gate; inspect the recorded failures." + }; + const resultPath = join(outputDirectory, "paper-b-replay-result.json"); + await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + return { result, resultPath, snapshotPath }; +} diff --git a/packages/longtable-research/src/research-brief.ts b/packages/longtable-research/src/research-brief.ts new file mode 100644 index 00000000..913399ab --- /dev/null +++ b/packages/longtable-research/src/research-brief.ts @@ -0,0 +1,184 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; + +export const RESEARCH_BRIEF_VERSION = "1.0.0"; + +export interface ResearchBrief { + schema: "longtable.research-brief"; + version: typeof RESEARCH_BRIEF_VERSION; + briefId: string; + createdAt: string; + updatedAt: string; + problem: string; + decision: string; + candidateResearchQuestion: string; + contextAndPopulation: string; + evidenceNeeds: string[]; + targetJournal: string | null; + accessConstraints: string[]; + deliverables: string[]; + successCriteria: string[]; + openTensions: string[]; + provenance: { + source: "longtable_interview" | "cli" | "imported"; + clarifyingQuestionCount: number; + }; +} + +export interface ResearchBriefSeed { + query: string; + targetJournal?: string; + field?: string; + source?: ResearchBrief["provenance"]["source"]; +} + +function idFrom(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16); +} + +export function buildResearchBrief(seed: ResearchBriefSeed): ResearchBrief { + const query = seed.query.trim(); + if (!query) throw new Error("A Research Brief requires a candidate research question."); + const timestamp = new Date().toISOString(); + const targetJournal = seed.targetJournal?.trim() || null; + const field = seed.field?.trim(); + const identity = { query, targetJournal, field: field ?? null }; + return { + schema: "longtable.research-brief", + version: RESEARCH_BRIEF_VERSION, + briefId: `brief-${idFrom(identity)}`, + createdAt: timestamp, + updatedAt: timestamp, + problem: query, + decision: "Produce a verified, journal-grounded research package for the stated problem.", + candidateResearchQuestion: query, + contextAndPopulation: field || "Not yet specified", + evidenceNeeds: [ + "Relevant peer-reviewed and repository evidence", + "Exact full-text evidence spans with locators", + "Target-journal topic and format patterns" + ], + targetJournal, + accessConstraints: [ + "Use only public OA, user-supplied lawful copies, or explicitly licensed TDM routes", + "Do not bypass paywalls, WAFs, robots controls, or institutional authentication" + ], + deliverables: [ + "Evidence and claim ledger", + "Journal-grounded synthesis", + "Human-reviewed editable tables, figures, or diagrams when warranted", + "Verified Research Package" + ], + successCriteria: [ + "Every material claim is traceable to bounded evidence", + "All hard Research Assurance gates pass", + "The package is reproducible from its manifest" + ], + openTensions: targetJournal ? [] : ["Target journal remains to be selected or verified."], + provenance: { + source: seed.source ?? "cli", + clarifyingQuestionCount: 0 + } + }; +} + +export function validateResearchBrief(value: unknown): ResearchBrief { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Research Brief must be a JSON object."); + } + const brief = value as Partial; + if (brief.schema !== "longtable.research-brief" || brief.version !== RESEARCH_BRIEF_VERSION) { + throw new Error(`Research Brief must use longtable.research-brief ${RESEARCH_BRIEF_VERSION}.`); + } + const requiredStrings: Array = [ + "briefId", + "createdAt", + "updatedAt", + "problem", + "decision", + "candidateResearchQuestion", + "contextAndPopulation" + ]; + for (const field of requiredStrings) { + if (typeof brief[field] !== "string" || !(brief[field] as string).trim()) { + throw new Error(`Research Brief field ${field} must be a non-empty string.`); + } + } + const requiredArrays: Array = [ + "evidenceNeeds", + "accessConstraints", + "deliverables", + "successCriteria", + "openTensions" + ]; + for (const field of requiredArrays) { + if (!Array.isArray(brief[field]) || !(brief[field] as unknown[]).every((entry) => typeof entry === "string")) { + throw new Error(`Research Brief field ${field} must be a string array.`); + } + } + if (!brief.provenance || + !["longtable_interview", "cli", "imported"].includes(String(brief.provenance.source)) || + !Number.isInteger(brief.provenance.clarifyingQuestionCount) || + brief.provenance.clarifyingQuestionCount < 0 || + brief.provenance.clarifyingQuestionCount > 3) { + throw new Error("Research Brief provenance must identify its source and no more than three clarifying questions."); + } + return brief as ResearchBrief; +} + +export async function readResearchBrief(path: string): Promise { + return validateResearchBrief(JSON.parse(await readFile(path, "utf8"))); +} + +export function renderResearchBriefMarkdown(brief: ResearchBrief): string { + const list = (items: string[]): string => items.length > 0 + ? items.map((item) => `- ${item}`).join("\n") + : "- None recorded"; + return [ + "# Research Brief", + "", + `Brief ID: ${brief.briefId}`, + `Version: ${brief.version}`, + "", + "## Problem", + "", + brief.problem, + "", + "## Decision", + "", + brief.decision, + "", + "## Candidate research question", + "", + brief.candidateResearchQuestion, + "", + "## Context and population", + "", + brief.contextAndPopulation, + "", + "## Evidence needs", + "", + list(brief.evidenceNeeds), + "", + "## Target journal", + "", + brief.targetJournal ?? "Not yet selected", + "", + "## Access constraints", + "", + list(brief.accessConstraints), + "", + "## Deliverables", + "", + list(brief.deliverables), + "", + "## Success criteria", + "", + list(brief.successCriteria), + "", + "## Open tensions", + "", + list(brief.openTensions), + "" + ].join("\n"); +} diff --git a/packages/longtable-research/src/run.ts b/packages/longtable-research/src/run.ts new file mode 100644 index 00000000..d7ec6df8 --- /dev/null +++ b/packages/longtable-research/src/run.ts @@ -0,0 +1,173 @@ +import { buildResearchSearchIntent } from "./query.js"; +import { dedupeAndRankCards } from "./rank.js"; +import { enrichCardsWithPublisherAccess } from "./publisher-access.js"; +import { + assessSearchSourceCapabilities, + runSourceSearch +} from "./sources.js"; +import type { + EvidenceRun, + EvidenceRunStatus, + RunResearchSearchInput, + SearchFetch, + SourceReport +} from "./types.js"; + +function runId(): string { + return `evidence_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +function now(): string { + return new Date().toISOString(); +} + +function defaultFetch(): SearchFetch { + if (typeof fetch !== "function") { + throw new Error("LongTable search requires a fetch-capable Node runtime."); + } + return fetch; +} + +async function withTimeout(work: Promise, timeoutMs: number, label: string): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +export async function runResearchSearch(input: RunResearchSearchInput): Promise { + const createdAt = now(); + const id = runId(); + const intent = buildResearchSearchIntent(input); + const env = input.env ?? process.env; + const capabilities = assessSearchSourceCapabilities(intent.requestedSources, env); + const skippedSources = capabilities.filter((capability) => !capability.enabled); + + if (skippedSources.length > 0 && input.allowPartial !== true) { + const updatedAt = now(); + return { + id, + createdAt, + updatedAt, + status: "blocked", + intent, + sourceReports: skippedSources.map((capability): SourceReport => ({ + source: capability.source, + status: "skipped", + count: 0, + elapsedMs: 0, + reason: capability.reason + })), + cards: [], + skippedSources, + warnings: skippedSources.map((capability) => capability.reason ?? `${capability.source} unavailable.`), + blockedReason: "One or more requested scholarly sources are unavailable. Confirm partial search or configure credentials." + }; + } + + const httpFetch = input.fetch ?? defaultFetch(); + const sourceConcurrency = Math.max(1, Math.min(8, input.sourceConcurrency ?? 3)); + const sourceRetries = Math.max(0, Math.min(3, input.sourceRetries ?? 1)); + const sourceTimeoutMs = Math.max(100, Math.min(120_000, input.sourceTimeoutMs ?? 20_000)); + const sourceReports: Array = new Array(capabilities.length); + const sourceCards: Array = new Array(capabilities.length); + const cards: EvidenceRun["cards"] = []; + let nextCapability = 0; + + async function runWorker(): Promise { + while (nextCapability < capabilities.length) { + const index = nextCapability++; + const capability = capabilities[index]; + if (!capability.enabled) { + sourceReports[index] = { + source: capability.source, + status: "skipped", + count: 0, + elapsedMs: 0, + reason: capability.reason + }; + sourceCards[index] = []; + continue; + } + + const started = Date.now(); + let lastError: unknown; + for (let attempt = 0; attempt <= sourceRetries; attempt += 1) { + try { + const result = await withTimeout(runSourceSearch({ + intent, + source: capability.source, + limit: intent.limit + }, { + fetch: httpFetch, + env + }), sourceTimeoutMs, capability.source); + sourceCards[index] = result.cards; + sourceReports[index] = { + source: capability.source, + status: "completed", + count: result.cards.length, + elapsedMs: Date.now() - started, + endpoint: result.endpoint + }; + lastError = undefined; + break; + } catch (error) { + lastError = error; + } + } + if (lastError) { + sourceCards[index] = []; + sourceReports[index] = { + source: capability.source, + status: "failed", + count: 0, + elapsedMs: Date.now() - started, + reason: lastError instanceof Error ? lastError.message : String(lastError) + }; + } + } + } + + await Promise.all(Array.from( + { length: Math.min(sourceConcurrency, capabilities.length) }, + () => runWorker() + )); + for (const entries of sourceCards) cards.push(...(entries ?? [])); + const completedReports = sourceReports.filter((report): report is SourceReport => Boolean(report)); + + const rankedCards = dedupeAndRankCards(cards, intent); + const finalCards = input.publisherAccess === true + ? await enrichCardsWithPublisherAccess({ + cards: rankedCards, + env, + fetch: httpFetch + }) + : rankedCards; + const hasFailure = completedReports.some((report) => report.status === "failed" || report.status === "skipped"); + const status: EvidenceRunStatus = hasFailure ? "partial" : "completed"; + + return { + id, + createdAt, + updatedAt: now(), + status, + intent, + sourceReports: completedReports, + cards: finalCards, + skippedSources, + warnings: [ + ...skippedSources.map((capability) => capability.reason ?? `${capability.source} unavailable.`), + ...completedReports + .filter((report) => report.status === "failed") + .map((report) => `${report.source} failed: ${report.reason ?? "unknown error"}`) + ] + }; +} diff --git a/packages/longtable-scholar-research/src/sources.ts b/packages/longtable-research/src/sources.ts similarity index 100% rename from packages/longtable-scholar-research/src/sources.ts rename to packages/longtable-research/src/sources.ts diff --git a/packages/longtable-scholar-research/src/types.ts b/packages/longtable-research/src/types.ts similarity index 98% rename from packages/longtable-scholar-research/src/types.ts rename to packages/longtable-research/src/types.ts index d9f79557..51ed7820 100644 --- a/packages/longtable-scholar-research/src/types.ts +++ b/packages/longtable-research/src/types.ts @@ -225,6 +225,9 @@ export interface RunResearchSearchInput extends BuildSearchIntentInput { fetch?: SearchFetch; allowPartial?: boolean; publisherAccess?: boolean; + sourceConcurrency?: number; + sourceRetries?: number; + sourceTimeoutMs?: number; } export interface CrossrefTdmLink { diff --git a/packages/longtable-research/src/visual-contract.ts b/packages/longtable-research/src/visual-contract.ts new file mode 100644 index 00000000..8f4b5bcd --- /dev/null +++ b/packages/longtable-research/src/visual-contract.ts @@ -0,0 +1,302 @@ +export const VISUAL_ARTIFACT_KINDS = ["table", "figure", "diagram"] as const; +export type VisualArtifactKind = typeof VISUAL_ARTIFACT_KINDS[number]; + +export interface JournalVisualExemplar { + sourceId: string; + sourceVersion: string; + accessClass: string; + pageRenderPath: string; + page: number; + boundingBox: { x: number; y: number; width: number; height: number }; + caption: string; + surroundingContext: string; + observedPattern: string; +} + +export interface VisualGrammar { + journal: string; + derivedFrom: JournalVisualExemplar[]; + pageOrColumnWidth: string; + typography: string[]; + lineAndMarkerRules: string[]; + tableRules: string[]; + captionRules: string[]; + colorPolicy: string; + notes: string[]; +} + +export interface VisualEvidenceContract { + schema: "longtable.visual-evidence-contract"; + version: 1; + id: string; + kind: VisualArtifactKind; + status: "proposed" | "approved" | "rejected"; + analyticalQuestion: string; + readerTakeaway: string; + necessity: string; + manuscriptClaimIds: string[]; + targetJournal: string; + journalGrammar: VisualGrammar; + evidenceBoundary: { + performed: string[]; + proposed: string[]; + prohibitedInferences: string[]; + }; + dataContract: { + dataSnapshotPath: string; + unitOfAnalysis: string; + denominator: string; + estimandOrConstruct: string; + uncertainty: string; + independentUnitField: string; + repeatedConditionField?: string; + }; + readingOrder: string[]; + mainTextRole: string; + supplementRole: string; + directLabels: boolean; + redundantEncodingJustification?: string; + axis?: { + domain: [number, number]; + transformation?: string; + truncationJustification?: string; + }; + output: { + editableSourceFormat: "svg" | "pdf" | "docx" | "xlsx" | "tex"; + renderer: string; + rendererVersion: string; + journalSizedExports: string[]; + placementManifestPath: string; + }; + readerTest: { + seconds: 10 | 15 | 20; + recover: ["question", "denominator", "key_value_or_relation", "conclusion"]; + }; + accessibility?: { + grayscaleSafe: boolean; + colorVisionSafe: boolean; + textAlternative: string; + minimumFontSizePt: number; + }; + verificationPlan?: { + domainMeaning: string; + statisticalIntegrity: string; + journalFit: string; + readerComprehension: string; + }; + approval?: { + approvedBy: string; + approvedAt: string; + decisionRecordId: string; + }; +} + +export interface VisualContractValidation { + valid: boolean; + hardFailures: string[]; + warnings: string[]; +} + +export interface HumanVisualContractReview { + schema: "longtable.human-visual-contract-review"; + version: 1; + runId: string; + reviewer: string; + reviewedAt: string; + decisionRecordId: string; + decision: "approve" | "reject"; + checks: { + domainMeaning: boolean; + statisticalIntegrity: boolean; + journalFit: boolean; + readerComprehension: boolean; + }; + rationale: string; +} + +export interface HumanRenderedVisualReview { + schema: "longtable.human-rendered-visual-review"; + version: 1; + runId: string; + contractId: string; + reviewer: string; + reviewedAt: string; + decision: "accept" | "reject"; + checks: { + domainMeaning: boolean; + statisticalIntegrity: boolean; + journalFit: boolean; + readerComprehension: boolean; + }; + readerTest: { + seconds: number; + recoveredQuestion: boolean; + recoveredDenominator: boolean; + recoveredKeyValueOrRelation: boolean; + recoveredConclusion: boolean; + }; + rationale: string; +} + +export interface VisualPortfolioPlan { + schema: "longtable.visual-portfolio-plan"; + version: 1; + runId: string; + portfolioId: string; + contractIds: string[]; + rationale: string; + registeredBy: string; + registeredAt: string; +} + +export function validateVisualPortfolioPlan(plan: VisualPortfolioPlan): string[] { + const errors: string[] = []; + if (plan.schema !== "longtable.visual-portfolio-plan" || plan.version !== 1) { + errors.push("Unsupported visual portfolio plan schema or version."); + } + for (const [field, value] of [ + ["runId", plan.runId], + ["portfolioId", plan.portfolioId], + ["rationale", plan.rationale], + ["registeredBy", plan.registeredBy], + ["registeredAt", plan.registeredAt] + ] as const) { + if (!value?.trim()) errors.push(`${field} is required.`); + } + if (!Number.isFinite(Date.parse(plan.registeredAt))) { + errors.push("registeredAt must be a valid timestamp."); + } + if (!Array.isArray(plan.contractIds) || plan.contractIds.length < 2 || + plan.contractIds.some((id) => typeof id !== "string" || !id.trim())) { + errors.push("A visual portfolio must declare at least two non-empty contract IDs."); + } else if (new Set(plan.contractIds).size !== plan.contractIds.length) { + errors.push("Visual portfolio contract IDs must be unique."); + } + return errors; +} + +export function recommendVisualArtifactKind(input: { + needsExactLookup: boolean; + needsPatternComparison: boolean; + needsProcessOrConceptualRelation: boolean; +}): VisualArtifactKind { + if (input.needsProcessOrConceptualRelation) return "diagram"; + if (input.needsPatternComparison) return "figure"; + return "table"; +} + +export function validateVisualEvidenceContract(contract: VisualEvidenceContract): VisualContractValidation { + const hardFailures: string[] = []; + const warnings: string[] = []; + const requiredText: Array<[string, string]> = [ + ["analyticalQuestion", contract.analyticalQuestion], + ["readerTakeaway", contract.readerTakeaway], + ["necessity", contract.necessity], + ["unitOfAnalysis", contract.dataContract.unitOfAnalysis], + ["denominator", contract.dataContract.denominator], + ["estimandOrConstruct", contract.dataContract.estimandOrConstruct], + ["uncertainty", contract.dataContract.uncertainty], + ["independentUnitField", contract.dataContract.independentUnitField] + ]; + for (const [field, value] of requiredText) { + if (!value.trim()) hardFailures.push(`${field} is required.`); + } + if (contract.manuscriptClaimIds.length === 0) { + hardFailures.push("A main-text visual must map to at least one manuscript claim."); + } + if (contract.journalGrammar.derivedFrom.length === 0) { + hardFailures.push("Journal visual grammar requires at least one located exemplar."); + } + if (contract.targetJournal.trim() !== contract.journalGrammar.journal.trim()) { + hardFailures.push("Target journal and journal visual grammar must identify the same venue."); + } + for (const exemplar of contract.journalGrammar.derivedFrom) { + if (!exemplar.sourceId || !exemplar.sourceVersion || !exemplar.accessClass || + !exemplar.pageRenderPath || !exemplar.caption || !exemplar.surroundingContext) { + hardFailures.push(`Journal exemplar ${exemplar.sourceId || "unknown"} lacks provenance or context.`); + } + if (!Number.isInteger(exemplar.page) || exemplar.page < 1) { + hardFailures.push(`Journal exemplar ${exemplar.sourceId || "unknown"} requires a positive page number.`); + } + const box = exemplar.boundingBox; + if (![box.x, box.y, box.width, box.height].every(Number.isFinite) || box.width <= 0 || box.height <= 0) { + hardFailures.push(`Journal exemplar ${exemplar.sourceId || "unknown"} requires a finite, positive bounding box.`); + } + if (!exemplar.observedPattern.trim()) { + hardFailures.push(`Journal exemplar ${exemplar.sourceId || "unknown"} requires an observed pattern.`); + } + } + if (contract.evidenceBoundary.performed.length > 0 && + contract.evidenceBoundary.proposed.length > 0 && + contract.kind === "diagram" && + contract.readingOrder.length < 2) { + hardFailures.push("A diagram mixing performed and proposed content must expose separate reading layers."); + } + if (!contract.directLabels && !contract.redundantEncodingJustification) { + warnings.push("Indirect legend lookup requires an explicit justification."); + } + const performed = new Set(contract.evidenceBoundary.performed.map((item) => item.trim().toLocaleLowerCase())); + if (contract.evidenceBoundary.proposed.some((item) => performed.has(item.trim().toLocaleLowerCase()))) { + hardFailures.push("Performed and proposed content must not contain the same unlabeled statement."); + } + if (contract.kind === "table" && contract.axis) { + hardFailures.push("A table must not declare a chart axis."); + } + if (contract.axis) { + const [minimum, maximum] = contract.axis.domain; + if (!(Number.isFinite(minimum) && Number.isFinite(maximum) && minimum < maximum)) { + hardFailures.push("Axis domain must be finite and increasing."); + } + if ((minimum !== 0 || contract.axis.transformation) && !contract.axis.truncationJustification) { + warnings.push("A transformed or non-zero-baseline axis needs a reader-facing justification."); + } + } + if (contract.status === "approved" && !contract.approval) { + hardFailures.push("Approved visual contracts require a human approval record."); + } + if (contract.status === "approved") { + if (!contract.accessibility?.grayscaleSafe || !contract.accessibility.colorVisionSafe || + !contract.accessibility.textAlternative.trim() || + !Number.isFinite(contract.accessibility.minimumFontSizePt) || + contract.accessibility.minimumFontSizePt < 7) { + hardFailures.push("Approved visual contracts require grayscale-safe, color-vision-safe, readable, text-described output."); + } + const verification = contract.verificationPlan; + if (!verification || [ + verification.domainMeaning, + verification.statisticalIntegrity, + verification.journalFit, + verification.readerComprehension + ].some((item) => !item.trim())) { + hardFailures.push("Approved visual contracts require separate domain, statistical, journal, and reader verification plans."); + } + } else { + if (!contract.accessibility) warnings.push("Add an accessibility contract before human approval."); + if (!contract.verificationPlan) warnings.push("Add four-lens human verification before approval."); + } + if (contract.output.journalSizedExports.length === 0) { + hardFailures.push("At least one journal-sized export is required."); + } + if (!contract.output.placementManifestPath) { + hardFailures.push("A placement manifest is required."); + } + if (!contract.dataContract.dataSnapshotPath.trim()) { + hardFailures.push("A versioned data snapshot path is required."); + } + if (!contract.output.renderer.trim() || !contract.output.rendererVersion.trim()) { + hardFailures.push("Renderer identity and version are required."); + } + if (!contract.mainTextRole.trim() || !contract.supplementRole.trim()) { + hardFailures.push("Main-text and supplement roles must both be explicit."); + } + if (contract.readingOrder.length === 0 || contract.readingOrder.some((step) => !step.trim())) { + hardFailures.push("A non-empty reader-facing reading order is required."); + } + if (contract.readerTest.recover.join("|") !== "question|denominator|key_value_or_relation|conclusion") { + hardFailures.push("Reader test must recover question, denominator, key value or relation, and conclusion."); + } + if (contract.readingOrder.length > 7 && contract.kind === "diagram") { + warnings.push("A main-text reader map with more than seven steps should be simplified or moved to the supplement."); + } + return { valid: hardFailures.length === 0, hardFailures, warnings }; +} diff --git a/packages/longtable-research/src/workflow.ts b/packages/longtable-research/src/workflow.ts new file mode 100644 index 00000000..435572f1 --- /dev/null +++ b/packages/longtable-research/src/workflow.ts @@ -0,0 +1,2556 @@ +import { createHash } from "node:crypto"; +import { appendFile, mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises"; +import { dirname, join, resolve, sep } from "node:path"; +import { runResearchSearch } from "./run.js"; +import { buildScholarResearchRunScaffold, writeScholarResearchRunScaffold } from "./protocol.js"; +import { + acquirePublicOaFullText, + ingestPdfDirectory, + readFullTextManifest, + type FullTextAccessClass, + type FullTextManifestRecord, + type ParsedPdfDocument, + type PublicOaAcquisitionEvent, + type PublicOaFetch +} from "./fulltext.js"; +import { + citationSlotsFromProviderPatch, + prepareExtractionTaskPackets, + prepareSynthesisTaskPacket, + synthesisProposalFromProviderPatch, + type AdjudicationRecord, + type HumanCitationReview, + type HumanSynthesisReview, + type ProviderVerification, + type ProviderSynthesisVerification, + type PreparedExtractionTasks, + type PreparedSynthesisTask, + type SynthesisClaim +} from "./extraction.js"; +import { + validateVisualEvidenceContract, + validateVisualPortfolioPlan, + type HumanRenderedVisualReview, + type HumanVisualContractReview, + type VisualEvidenceContract, + type VisualPortfolioPlan +} from "./visual-contract.js"; +import { + renderVisualArtifact, + type MechanicalVisualQa, + type VisualRenderManifest, + type VisualRenderRequest +} from "./render.js"; +import { + buildResearchBrief, + renderResearchBriefMarkdown, + validateResearchBrief, + type ResearchBrief +} from "./research-brief.js"; +import { + validateTargetJournalProfile, + type TargetJournalProfile +} from "./journal-profile.js"; +import { + buildLawfulAccessPlan, + validateLawfulAccessPlan, + type LawfulAccessPlan +} from "./access-plan.js"; +import type { EvidenceRun, RunResearchSearchInput, SearchFetch } from "./types.js"; + +export const SCHOLAR_RESEARCH_BUNDLE_VERSION = "1.0.0"; + +export const SCHOLAR_RESEARCH_STAGES = [ + "scope", + "topic", + "venue", + "collect", + "fulltext", + "extract", + "synthesize", + "visual_contract", + "implement", + "render", + "verify", + "handoff" +] as const; + +export type ScholarResearchStage = typeof SCHOLAR_RESEARCH_STAGES[number]; +export type ScholarResearchStageStatus = "pending" | "running" | "awaiting_provider" | "awaiting_human" | "completed" | "blocked"; +export type ScholarResearchWorkflowStatus = + | "running" + | "paused" + | "waiting_for_checkpoint" + | "completed" + | "failed"; + +export type ScholarResearchCheckpointClass = + | "scope_contract" + | "access_corpus_boundary" + | "evidence_direction_or_claim_strength" + | "visual_evidence_contract" + | "external_action"; + +export interface ScholarResearchStageState { + stage: ScholarResearchStage; + status: ScholarResearchStageStatus; + inputHash?: string; + startedAt?: string; + completedAt?: string; + outputRef?: string; + blockedReason?: string; + checkpointClass?: ScholarResearchCheckpointClass; +} + +export interface ResearchBundleInput { + query: string; + researchBrief: ResearchBrief; + targetJournal?: string; + field?: string; + must?: string; + exclude?: string; + sources?: string; + limit?: number; + allowPartial: boolean; + publisherAccess: boolean; + pdfDirectory?: string; + pdfAccessClass?: FullTextAccessClass; + autoAcquireOpenAccess: boolean; +} + +export interface ProductivityTelemetry { + startedAt: string; + updatedAt: string; + elapsedMs: number; + activeHumanMs: number; + interruptionCount: number; + repeatedQuestionCount: number; + contextSwitchCount: number; + reworkCount: number; + abandoned: boolean; + acceptedArtifactAt?: string; + researcherEffort?: number; + researcherTrust?: number; +} + +export interface ResearchBundle { + schema: "longtable.research-bundle"; + version: typeof SCHOLAR_RESEARCH_BUNDLE_VERSION; + runId: string; + runDir: string; + createdAt: string; + updatedAt: string; + status: ScholarResearchWorkflowStatus; + input: ResearchBundleInput; + stages: Record; + artifacts: { + researchBrief: { + jsonPath: string; + markdownPath: string; + contentHash: string; + }; + targetJournalProfile?: { + profileId: string; + path: string; + contentHash: string; + reviewedAt: string; + }; + lawfulAccessPlan: { + path: string; + contentHash: string; + plan: LawfulAccessPlan; + }; + verifiedPackage?: { + directory: string; + readmePath: string; + manifestPath: string; + assurancePath: string; + limitationsPath: string; + verificationPath: string; + }; + searchRun?: EvidenceRun; + searchRunPath?: string; + fullTextRecords?: FullTextManifestRecord[]; + publicOaAcquisition?: { + eventsPath: string; + attempted: number; + acquired: number; + failed: number; + events: PublicOaAcquisitionEvent[]; + }; + extractionTasks?: PreparedExtractionTasks; + providerPatches?: Array<{ + packetId: string; + stage: ScholarResearchStage; + provider: string; + patchHash: string; + path: string; + recordedAt: string; + }>; + providerVerifications?: Array<{ + packetId: string; + provider: string; + proposalPatchHash: string; + path: string; + recordedAt: string; + }>; + adjudications?: Array<{ citationSlotId: string; path: string }>; + citationSlotsPath?: string; + synthesisTask?: PreparedSynthesisTask; + synthesisVerifications?: Array<{ + packetId: string; + provider: string; + proposalPatchHash: string; + path: string; + recordedAt: string; + }>; + synthesisPath?: string; + visualContracts?: Array<{ + id: string; + status: "approved" | "rejected"; + path: string; + decisionRecordId: string; + reviewedAt: string; + }>; + visualPortfolio?: { + portfolioId: string; + contractIds: string[]; + path: string; + registeredAt: string; + }; + visualRenders?: Array<{ + contractId: string; + manifestPath: string; + qaPath: string; + editableSourcePath: string; + renderedAt: string; + }>; + finalVisualReviews?: Array<{ + contractId: string; + decision: "accept" | "reject"; + path: string; + reviewedAt: string; + }>; + }; + pendingCheckpoint?: { + class: ScholarResearchCheckpointClass; + stage: ScholarResearchStage; + reason: string; + }; + pauseReason?: string; + telemetry: ProductivityTelemetry; +} + +export interface ScholarResearchEvent { + schema: "longtable.scholar-research-event"; + version: 1; + sequence: number; + eventId: string; + occurredAt: string; + runId: string; + type: + | "run_created" + | "stage_started" + | "stage_completed" + | "stage_awaiting_provider" + | "stage_awaiting_human" + | "stage_blocked" + | "workflow_paused" + | "input_amended" + | "provider_patch_recorded" + | "provider_verification_recorded" + | "human_citation_review_recorded" + | "human_synthesis_review_recorded" + | "visual_portfolio_registered" + | "human_visual_review_recorded" + | "visual_render_recorded" + | "human_render_review_recorded" + | "target_journal_profile_recorded"; + stage?: ScholarResearchStage; + inputHash?: string; + checkpointClass?: ScholarResearchCheckpointClass; + reason?: string; + outputRef?: string; +} + +export interface EvidenceSpan { + spanId?: string; + sourceId: string; + quote: string; + locator: string; + contentHash: string; + sourceVersion: string; + accessClass: string; +} + +export interface CitationSlot { + id: string; + claim: string; + status: "filled" | "provisional" | "unfilled" | "blocked"; + relation?: "supports" | "qualifies" | "contradicts"; + evidence?: EvidenceSpan; +} + +export interface ProviderTaskPacket { + schema: "longtable.provider-task-packet"; + version: 1; + runId: string; + stage: ScholarResearchStage; + objective: string; + boundedEvidence: EvidenceSpan[]; + allowedPatchPaths: string[]; + maxOutputCharacters: number; +} + +export interface ProviderProposedPatchOperation { + op: "add" | "replace"; + path: string; + value: unknown; +} + +export interface ProviderProposedPatch { + schema: "longtable.provider-proposed-patch"; + version: 1; + runId: string; + stage: ScholarResearchStage; + operations: ProviderProposedPatchOperation[]; +} + +export interface RunScholarResearchWorkflowInput extends Omit< + ResearchBundleInput, + "query" | "researchBrief" | "allowPartial" | "publisherAccess" | "autoAcquireOpenAccess" +> { + cwd: string; + query?: string; + researchBrief?: ResearchBrief; + runId?: string; + allowPartial?: boolean; + publisherAccess?: boolean; + autoAcquireOpenAccess?: boolean; + env?: Record; + fetch?: SearchFetch; + fullTextParser?: (path: string) => Promise; + oaFetch?: PublicOaFetch; + oaResolveHost?: (hostname: string) => Promise; + globalPublicOaCacheRoot?: string; +} + +function now(): string { + return new Date().toISOString(); +} + +function stableHash(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function workflowPaths(runDir: string): { state: string; events: string; search: string } { + return { + state: join(runDir, "research-bundle.json"), + events: join(runDir, "events.jsonl"), + search: join(runDir, "artifacts", "search-run.json") + }; +} + +function initialStages(): Record { + return Object.fromEntries( + SCHOLAR_RESEARCH_STAGES.map((stage) => [stage, { stage, status: "pending" }]) + ) as Record; +} + +async function writeJsonAtomic(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.tmp-${process.pid}`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await rename(temporary, path); +} + +async function readEvents(path: string): Promise { + try { + const content = await readFile(path, "utf8"); + return content.split("\n").filter(Boolean).map((line) => JSON.parse(line) as ScholarResearchEvent); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +async function appendEvent(bundle: ResearchBundle, event: Omit): Promise { + const paths = workflowPaths(bundle.runDir); + const existing = await readEvents(paths.events); + const occurredAt = now(); + const complete: ScholarResearchEvent = { + schema: "longtable.scholar-research-event", + version: 1, + sequence: existing.length + 1, + eventId: `event-${String(existing.length + 1).padStart(6, "0")}`, + occurredAt, + runId: bundle.runId, + ...event + }; + await appendFile(paths.events, `${JSON.stringify(complete)}\n`, "utf8"); +} + +async function persistBundle(bundle: ResearchBundle): Promise { + const timestamp = now(); + bundle.updatedAt = timestamp; + bundle.telemetry.updatedAt = timestamp; + bundle.telemetry.elapsedMs = Math.max(0, Date.parse(timestamp) - Date.parse(bundle.telemetry.startedAt)); + await writeJsonAtomic(workflowPaths(bundle.runDir).state, bundle); +} + +async function beginStage(bundle: ResearchBundle, stage: ScholarResearchStage, input: unknown): Promise { + const inputHash = stableHash(input); + const current = bundle.stages[stage]; + if ((current.status === "completed" || current.status === "awaiting_provider" || current.status === "awaiting_human") && current.inputHash === inputHash) { + return undefined; + } + const startedAt = now(); + bundle.status = "running"; + bundle.stages[stage] = { stage, status: "running", inputHash, startedAt }; + await appendEvent(bundle, { type: "stage_started", stage, inputHash }); + await persistBundle(bundle); + return inputHash; +} + +async function awaitProviderStage( + bundle: ResearchBundle, + stage: ScholarResearchStage, + inputHash: string, + outputRef: string, + reason: string +): Promise { + bundle.stages[stage] = { + ...bundle.stages[stage], + stage, + status: "awaiting_provider", + inputHash, + outputRef + }; + await appendEvent(bundle, { + type: "stage_awaiting_provider", + stage, + inputHash, + outputRef, + reason + }); + await persistBundle(bundle); +} + +async function awaitHumanStage( + bundle: ResearchBundle, + stage: ScholarResearchStage, + inputHash: string, + outputRef: string, + reason: string +): Promise { + bundle.status = "paused"; + bundle.stages[stage] = { + ...bundle.stages[stage], + stage, + status: "awaiting_human", + inputHash, + outputRef + }; + await appendEvent(bundle, { + type: "stage_awaiting_human", + stage, + inputHash, + outputRef, + reason + }); + await persistBundle(bundle); +} + +async function completeStage(bundle: ResearchBundle, stage: ScholarResearchStage, inputHash: string, outputRef?: string): Promise { + bundle.stages[stage] = { + ...bundle.stages[stage], + stage, + status: "completed", + inputHash, + completedAt: now(), + ...(outputRef ? { outputRef } : {}) + }; + await appendEvent(bundle, { type: "stage_completed", stage, inputHash, ...(outputRef ? { outputRef } : {}) }); + await persistBundle(bundle); +} + +async function blockStage(bundle: ResearchBundle, stage: ScholarResearchStage, inputHash: string, checkpointClass: ScholarResearchCheckpointClass, reason: string): Promise { + bundle.status = "waiting_for_checkpoint"; + bundle.pendingCheckpoint = { class: checkpointClass, stage, reason }; + bundle.stages[stage] = { + ...bundle.stages[stage], + stage, + status: "blocked", + inputHash, + blockedReason: reason, + checkpointClass + }; + await appendEvent(bundle, { type: "stage_blocked", stage, inputHash, checkpointClass, reason }); + await persistBundle(bundle); +} + +async function pauseAtCapabilityBoundary(bundle: ResearchBundle, stage: ScholarResearchStage, reason: string): Promise { + bundle.status = "paused"; + bundle.pauseReason = reason; + if (bundle.pauseReason !== reason || (await readEvents(workflowPaths(bundle.runDir).events)).at(-1)?.reason !== reason) { + await appendEvent(bundle, { type: "workflow_paused", stage, reason }); + } + await persistBundle(bundle); +} + +export function citationSlotIsStrictlyFilled(slot: CitationSlot): boolean { + if (slot.status !== "filled" || !slot.claim.trim() || !slot.relation || !slot.evidence) return false; + const evidence = slot.evidence; + return Boolean( + evidence.sourceId.trim() && + evidence.quote.trim() && + evidence.locator.trim() && + evidence.contentHash.trim() && + evidence.sourceVersion.trim() && + evidence.accessClass.trim() + ); +} + +export function buildProviderTaskPacket(input: Omit): ProviderTaskPacket { + if (input.maxOutputCharacters < 1 || input.maxOutputCharacters > 100_000) { + throw new Error("Provider task packets require maxOutputCharacters between 1 and 100000."); + } + return { schema: "longtable.provider-task-packet", version: 1, ...input }; +} + +export function validateProviderProposedPatch( + packet: ProviderTaskPacket, + patch: ProviderProposedPatch +): { valid: boolean; errors: string[] } { + const errors: string[] = []; + function containsFilledStatus(value: unknown): boolean { + if (Array.isArray(value)) return value.some(containsFilledStatus); + if (!value || typeof value !== "object") return false; + const record = value as Record; + if (record.status === "filled") return true; + return Object.values(record).some(containsFilledStatus); + } + function citationCandidates(value: unknown): Array> { + if (Array.isArray(value)) return value.flatMap(citationCandidates); + if (!value || typeof value !== "object") return []; + const record = value as Record; + if ("claim" in record || "evidence" in record || "status" in record) return [record]; + return Object.values(record).flatMap(citationCandidates); + } + function validateSynthesis(value: unknown): void { + if (!value || typeof value !== "object" || Array.isArray(value)) { + errors.push("Synthesis value must be an object."); + return; + } + const proposal = value as Record; + if (typeof proposal.title !== "string" || !proposal.title.trim()) { + errors.push("Synthesis requires a non-empty title."); + } + if (!Array.isArray(proposal.claims) || proposal.claims.length === 0) { + errors.push("Synthesis requires at least one structured claim."); + return; + } + const suppliedSlots = new Map( + (((packet as ProviderTaskPacket & { citationSlots?: CitationSlot[] }).citationSlots) ?? []) + .map((slot) => [slot.id, slot]) + ); + const claimIds = new Set(); + for (const rawClaim of proposal.claims) { + if (!rawClaim || typeof rawClaim !== "object" || Array.isArray(rawClaim)) { + errors.push("Each synthesis claim must be an object."); + continue; + } + const claim = rawClaim as Record; + if (typeof claim.id !== "string" || !claim.id.trim() || claimIds.has(claim.id)) { + errors.push("Synthesis claims require unique non-empty ids."); + } else { + claimIds.add(claim.id); + } + if (typeof claim.text !== "string" || !claim.text.trim()) { + errors.push("Synthesis claims require non-empty text."); + } + if (!["finding", "qualification", "conflict", "limitation"].includes(String(claim.role))) { + errors.push("Synthesis claims require a valid role."); + } + if (!Array.isArray(claim.citationSlotIds) || claim.citationSlotIds.length === 0) { + errors.push("Every synthesis claim requires at least one citation slot."); + continue; + } + for (const slotId of claim.citationSlotIds) { + const slot = suppliedSlots.get(String(slotId)); + if (!slot) { + errors.push(`Synthesis claim references an unknown citation slot: ${String(slotId)}`); + } else if (claim.role === "finding" && slot.status !== "filled") { + errors.push(`Finding claims may cite only filled slots: ${String(slotId)}`); + } + } + } + const readingOrder = Array.isArray(proposal.readingOrder) + ? proposal.readingOrder.map(String) + : []; + if (readingOrder.length !== claimIds.size || + new Set(readingOrder).size !== claimIds.size || + readingOrder.some((id) => !claimIds.has(id))) { + errors.push("Synthesis readingOrder must list every claim id exactly once."); + } + } + if (patch.schema !== "longtable.provider-proposed-patch" || patch.version !== 1) { + errors.push("Unsupported provider patch schema or version."); + } + if (patch.runId !== packet.runId || patch.stage !== packet.stage) { + errors.push("Provider patch run or stage does not match its task packet."); + } + if (!Array.isArray(patch.operations) || patch.operations.length === 0) { + errors.push("Provider patch must contain at least one operation."); + } + if (patch.stage === "synthesize" && + (patch.operations.length !== 1 || patch.operations[0]?.path !== "/synthesis")) { + errors.push("Synthesis patches require exactly one /synthesis operation."); + } + for (const operation of patch.operations ?? []) { + if (operation.op !== "add" && operation.op !== "replace") { + errors.push(`Unsupported patch operation: ${String(operation.op)}`); + } + const allowed = packet.allowedPatchPaths.some( + (path) => operation.path === path || operation.path.startsWith(`${path}/`) + ); + if (!allowed) errors.push(`Patch path is outside the allowed boundary: ${operation.path}`); + if (patch.stage === "extract" && containsFilledStatus(operation.value)) { + errors.push("Provider extraction proposals cannot mark citation slots filled."); + } + if (patch.stage === "extract") { + for (const candidate of citationCandidates(operation.value)) { + if (candidate.status !== "provisional") { + errors.push("Provider extraction candidates must have provisional status."); + } + if (typeof candidate.claim !== "string" || !candidate.claim.trim()) { + errors.push("Provider extraction candidates require a non-empty claim."); + } + if (!["supports", "qualifies", "contradicts"].includes(String(candidate.relation))) { + errors.push("Provider extraction candidates require a valid evidence relation."); + } + const evidence = candidate.evidence as Partial | undefined; + const exactSpan = evidence && packet.boundedEvidence.some((span) => + span.sourceId === evidence.sourceId && + span.quote === evidence.quote && + span.locator === evidence.locator && + span.contentHash === evidence.contentHash && + span.sourceVersion === evidence.sourceVersion && + span.accessClass === evidence.accessClass + ); + if (!exactSpan) { + errors.push("Provider extraction evidence must exactly match a supplied bounded span."); + } + } + } + if (patch.stage === "synthesize") validateSynthesis(operation.value); + } + if (JSON.stringify(patch).length > packet.maxOutputCharacters) { + errors.push("Provider patch exceeds the packet output bound."); + } + return { valid: errors.length === 0, errors }; +} + +async function createBundle(input: RunScholarResearchWorkflowInput): Promise { + const researchBrief = input.researchBrief + ? validateResearchBrief(input.researchBrief) + : buildResearchBrief({ + query: input.query ?? "", + ...(input.targetJournal ? { targetJournal: input.targetJournal } : {}), + ...(input.field ? { field: input.field } : {}), + source: "cli" + }); + const query = input.query?.trim() || researchBrief.candidateResearchQuestion; + const targetJournal = input.targetJournal?.trim() || researchBrief.targetJournal || undefined; + const desiredInput: ResearchBundleInput = { + query, + researchBrief, + ...(targetJournal ? { targetJournal } : {}), + ...(input.field?.trim() ? { field: input.field.trim() } : {}), + ...(input.must?.trim() ? { must: input.must.trim() } : {}), + ...(input.exclude?.trim() ? { exclude: input.exclude.trim() } : {}), + ...(input.sources?.trim() ? { sources: input.sources.trim() } : {}), + ...(input.limit ? { limit: input.limit } : {}), + ...(input.pdfDirectory?.trim() ? { pdfDirectory: resolve(input.pdfDirectory.trim()) } : {}), + ...(input.pdfAccessClass ? { pdfAccessClass: input.pdfAccessClass } : {}), + allowPartial: input.allowPartial === true, + publisherAccess: input.publisherAccess === true, + autoAcquireOpenAccess: input.autoAcquireOpenAccess !== false + }; + if (input.runId) { + const candidate = buildScholarResearchRunScaffold({ cwd: input.cwd, runId: input.runId }); + try { + const existing = JSON.parse( + await readFile(workflowPaths(candidate.runDir).state, "utf8") + ) as ResearchBundle; + const comparableInput = input.researchBrief + ? desiredInput + : { ...desiredInput, researchBrief: existing.input.researchBrief }; + if (stableHash(existing.input) !== stableHash(comparableInput)) { + throw new Error(`Scholar research run ${existing.runId} already exists with different inputs.`); + } + return existing; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + const scaffold = await writeScholarResearchRunScaffold({ + cwd: input.cwd, + ...(input.runId ? { runId: input.runId } : {}) + }); + const createdAt = now(); + const briefJsonPath = join(scaffold.runDir, "artifacts", "research-brief.json"); + const briefMarkdownPath = join(scaffold.runDir, "artifacts", "RESEARCH_BRIEF.md"); + const lawfulAccessPlan = validateLawfulAccessPlan(buildLawfulAccessPlan({ + runId: scaffold.runId, + allowPublicOa: desiredInput.autoAcquireOpenAccess, + hasUserPdfDirectory: Boolean(desiredInput.pdfDirectory), + ...(desiredInput.pdfAccessClass ? { pdfAccessClass: desiredInput.pdfAccessClass } : {}) + })); + const lawfulAccessPlanPath = join(scaffold.runDir, "artifacts", "lawful-access-plan.json"); + await writeJsonAtomic(briefJsonPath, researchBrief); + await writeJsonAtomic(lawfulAccessPlanPath, lawfulAccessPlan); + await mkdir(dirname(briefMarkdownPath), { recursive: true }); + await writeFile(briefMarkdownPath, renderResearchBriefMarkdown(researchBrief), "utf8"); + const bundle: ResearchBundle = { + schema: "longtable.research-bundle", + version: SCHOLAR_RESEARCH_BUNDLE_VERSION, + runId: scaffold.runId, + runDir: scaffold.runDir, + createdAt, + updatedAt: createdAt, + status: "running", + input: desiredInput, + stages: initialStages(), + artifacts: { + researchBrief: { + jsonPath: briefJsonPath, + markdownPath: briefMarkdownPath, + contentHash: `sha256:${stableHash(researchBrief)}` + }, + lawfulAccessPlan: { + path: lawfulAccessPlanPath, + contentHash: `sha256:${stableHash(lawfulAccessPlan)}`, + plan: lawfulAccessPlan + } + }, + telemetry: { + startedAt: createdAt, + updatedAt: createdAt, + elapsedMs: 0, + activeHumanMs: 0, + interruptionCount: 0, + repeatedQuestionCount: 0, + contextSwitchCount: 0, + reworkCount: 0, + abandoned: false + } + }; + await appendEvent(bundle, { type: "run_created" }); + await persistBundle(bundle); + return bundle; +} + +export async function readResearchBundle(cwd: string, runId: string): Promise { + const scaffold = buildScholarResearchRunScaffold({ cwd, runId }); + return JSON.parse(await readFile(workflowPaths(scaffold.runDir).state, "utf8")) as ResearchBundle; +} + +export async function findLatestScholarResearchRunId(cwd: string): Promise { + const root = resolve(cwd, ".longtable", "research-runs"); + try { + const entries = await readdir(root, { withFileTypes: true }); + const candidates = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + try { + const bundle = JSON.parse(await readFile(join(root, entry.name, "research-bundle.json"), "utf8")) as ResearchBundle; + candidates.push({ runId: entry.name, updatedAt: bundle.updatedAt }); + } catch { + // Legacy scaffold-only runs are not resumable ResearchBundles. + } + } + return candidates.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]?.runId; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +async function advanceBundle(bundle: ResearchBundle, runtime: { + env?: Record; + fetch?: SearchFetch; + fullTextParser?: (path: string) => Promise; + oaFetch?: PublicOaFetch; + oaResolveHost?: (hostname: string) => Promise; + globalPublicOaCacheRoot?: string; +}): Promise { + let hash = await beginStage(bundle, "scope", { + researchBrief: bundle.input.researchBrief, + field: bundle.input.field + }); + if (hash) await completeStage(bundle, "scope", hash, bundle.artifacts.researchBrief.jsonPath); + + hash = await beginStage(bundle, "topic", { query: bundle.input.query, must: bundle.input.must, exclude: bundle.input.exclude }); + if (hash) await completeStage(bundle, "topic", hash); + + hash = await beginStage(bundle, "venue", { targetJournal: bundle.input.targetJournal ?? null, mode: bundle.input.targetJournal ? "selected" : "discovery" }); + if (hash) await completeStage(bundle, "venue", hash); + + const collectInput = { + query: bundle.input.query, + field: bundle.input.field, + must: bundle.input.must, + exclude: bundle.input.exclude, + sources: bundle.input.sources, + limit: bundle.input.limit, + allowPartial: bundle.input.allowPartial, + publisherAccess: bundle.input.publisherAccess + }; + hash = await beginStage(bundle, "collect", collectInput); + if (hash) { + const searchInput: RunResearchSearchInput = { + query: bundle.input.query, + ...(bundle.input.field ? { field: bundle.input.field } : {}), + ...(bundle.input.must ? { must: bundle.input.must } : {}), + ...(bundle.input.exclude ? { exclude: bundle.input.exclude } : {}), + ...(bundle.input.sources ? { sources: bundle.input.sources } : {}), + ...(bundle.input.limit ? { limit: bundle.input.limit } : {}), + allowPartial: bundle.input.allowPartial, + publisherAccess: bundle.input.publisherAccess, + source: "cli", + ...(runtime.env ? { env: runtime.env } : {}), + ...(runtime.fetch ? { fetch: runtime.fetch } : {}) + }; + const searchRun = await runResearchSearch(searchInput); + bundle.artifacts.searchRun = searchRun; + const searchPath = workflowPaths(bundle.runDir).search; + await writeJsonAtomic(searchPath, searchRun); + bundle.artifacts.searchRunPath = searchPath; + if (searchRun.status === "blocked") { + await blockStage( + bundle, + "collect", + hash, + "access_corpus_boundary", + searchRun.blockedReason ?? "The requested corpus cannot be collected under the current access boundary." + ); + return bundle; + } + await completeStage(bundle, "collect", hash, searchPath); + } + + const publicOaCandidates = (bundle.artifacts.searchRun?.cards ?? []).filter( + (card) => card.legalFullTextAvailable + ); + const autoAcquireOpenAccess = bundle.input.autoAcquireOpenAccess !== false; + if (!bundle.input.pdfDirectory && (!autoAcquireOpenAccess || publicOaCandidates.length === 0)) { + await pauseAtCapabilityBoundary( + bundle, + "fulltext", + autoAcquireOpenAccess + ? "No collected source exposed a public-OA full-text route. Supply a legitimate local PDF corpus with --pdf-dir and an explicit --pdf-access class." + : "Automatic public-OA acquisition is disabled. Supply a legitimate local PDF corpus with --pdf-dir and an explicit --pdf-access class." + ); + return bundle; + } + + hash = await beginStage(bundle, "fulltext", { + pdfDirectory: bundle.input.pdfDirectory ?? null, + pdfAccessClass: bundle.input.pdfAccessClass ?? null, + autoAcquireOpenAccess, + publicOaCandidates: publicOaCandidates.map((card) => ({ + id: card.id, + fullTextUrl: card.fullTextUrl ?? null, + sourceRoute: card.sourceRoute + })) + }); + if (hash) { + if (bundle.input.pdfDirectory && !bundle.input.pdfAccessClass) { + await blockStage( + bundle, + "fulltext", + hash, + "access_corpus_boundary", + "A PDF directory was supplied without an explicit access class." + ); + return bundle; + } + if (autoAcquireOpenAccess && publicOaCandidates.length > 0) { + const acquired = await acquirePublicOaFullText({ + cards: publicOaCandidates, + runDir: bundle.runDir, + ...(runtime.fullTextParser ? { parser: runtime.fullTextParser } : {}), + ...(runtime.oaFetch ? { fetch: runtime.oaFetch } : {}), + ...(runtime.oaResolveHost ? { resolveHost: runtime.oaResolveHost } : {}), + ...(runtime.globalPublicOaCacheRoot + ? { globalCacheRoot: runtime.globalPublicOaCacheRoot } + : {}) + }); + bundle.artifacts.publicOaAcquisition = { + eventsPath: acquired.eventsPath, + attempted: acquired.events.length, + acquired: acquired.events.filter((event) => event.status === "acquired").length, + failed: acquired.events.filter((event) => event.status === "failed").length, + events: acquired.events + }; + } + if (bundle.input.pdfDirectory && bundle.input.pdfAccessClass) { + await ingestPdfDirectory({ + pdfDirectory: bundle.input.pdfDirectory, + runDir: bundle.runDir, + accessClass: bundle.input.pdfAccessClass, + ...(runtime.fullTextParser ? { parser: runtime.fullTextParser } : {}) + }); + } + const manifestPath = join(bundle.runDir, "sources", "manifest.jsonl"); + const records = await readFullTextManifest(manifestPath); + if (records.length === 0) { + await blockStage( + bundle, + "fulltext", + hash, + "access_corpus_boundary", + `No full text was acquired from ${publicOaCandidates.length} public-OA candidate(s). Review typed acquisition failures or supply a legitimate local corpus.` + ); + return bundle; + } + bundle.artifacts.fullTextRecords = records; + await completeStage(bundle, "fulltext", hash, manifestPath); + } + + const fullTextRecords = bundle.artifacts.fullTextRecords ?? []; + hash = await beginStage(bundle, "extract", { + query: bundle.input.query, + records: fullTextRecords.map((record) => ({ + sourceId: record.sourceId, + contentHash: record.contentHash, + sourceVersion: record.sourceVersion, + accessClass: record.accessClass + })) + }); + if (hash) { + const tasks = await prepareExtractionTaskPackets({ + runId: bundle.runId, + runDir: bundle.runDir, + query: bundle.input.query, + records: fullTextRecords + }); + bundle.artifacts.extractionTasks = tasks; + const manifestPath = join(bundle.runDir, "provider-tasks", "extract", "manifest.json"); + await awaitProviderStage( + bundle, + "extract", + hash, + manifestPath, + "Bounded extraction packets are ready for a provider proposer and independent verifier." + ); + } + if (bundle.stages.extract.status !== "completed") { + await pauseAtCapabilityBoundary( + bundle, + "extract", + "Extraction packets are ready. Provider proposals must be schema-validated, independently verified, and human-reviewed before citation slots can be filled." + ); + return bundle; + } + + if (!bundle.artifacts.citationSlotsPath) { + throw new Error("Extract is complete but no human-reviewed citation slot artifact is registered."); + } + const citationSlotSet = JSON.parse( + await readFile(bundle.artifacts.citationSlotsPath, "utf8") + ) as { citationSlots: CitationSlot[] }; + hash = await beginStage(bundle, "synthesize", { + query: bundle.input.query, + slots: citationSlotSet.citationSlots.map((slot) => ({ + id: slot.id, + status: slot.status, + relation: slot.relation, + contentHash: slot.evidence?.contentHash, + locator: slot.evidence?.locator + })) + }); + if (hash) { + const task = await prepareSynthesisTaskPacket({ + runId: bundle.runId, + runDir: bundle.runDir, + query: bundle.input.query, + citationSlots: citationSlotSet.citationSlots + }); + bundle.artifacts.synthesisTask = task; + await awaitProviderStage( + bundle, + "synthesize", + hash, + task.path, + "A bounded synthesis packet is ready; unsupported narrative claims are prohibited." + ); + } + if (bundle.stages.synthesize.status !== "completed") { + await pauseAtCapabilityBoundary( + bundle, + "synthesize", + "Synthesis packet is ready for proposer, independent verifier, and human review of any direction or claim-strength change." + ); + return bundle; + } + + hash = await beginStage(bundle, "visual_contract", { + synthesisPath: bundle.artifacts.synthesisPath, + targetJournal: bundle.input.targetJournal ?? null + }); + if (hash) { + await blockStage( + bundle, + "visual_contract", + hash, + "visual_evidence_contract", + "A human-approved VisualEvidenceContract is required before implementation or rendering." + ); + return bundle; + } + + if (bundle.status === "completed") return bundle; + if (bundle.stages.implement.status === "pending") { + await pauseAtCapabilityBoundary( + bundle, + "implement", + "The approved visual contract is ready; supply an allowlisted render request to implement it." + ); + } else if (bundle.stages.verify.status === "awaiting_human") { + await pauseAtCapabilityBoundary( + bundle, + "verify", + "Mechanical QA passed; final human review of the rendered artifact remains required." + ); + } + return bundle; +} + +export async function runScholarResearchWorkflow(input: RunScholarResearchWorkflowInput): Promise { + if (!input.query?.trim() && !input.researchBrief) { + throw new Error("LongTable Research requires --query or --brief ."); + } + const bundle = await createBundle(input); + return advanceBundle(bundle, { + env: input.env, + fetch: input.fetch, + fullTextParser: input.fullTextParser, + oaFetch: input.oaFetch, + oaResolveHost: input.oaResolveHost, + globalPublicOaCacheRoot: input.globalPublicOaCacheRoot + }); +} + +export async function resumeScholarResearchWorkflow(input: { + cwd: string; + runId?: string; + env?: Record; + fetch?: SearchFetch; + fullTextParser?: (path: string) => Promise; + oaFetch?: PublicOaFetch; + oaResolveHost?: (hostname: string) => Promise; + globalPublicOaCacheRoot?: string; + pdfDirectory?: string; + pdfAccessClass?: FullTextAccessClass; +}): Promise { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No resumable scholar-research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + if (input.pdfDirectory || input.pdfAccessClass) { + bundle.input = { + ...bundle.input, + ...(input.pdfDirectory ? { pdfDirectory: resolve(input.pdfDirectory) } : {}), + ...(input.pdfAccessClass ? { pdfAccessClass: input.pdfAccessClass } : {}) + }; + bundle.pendingCheckpoint = undefined; + bundle.pauseReason = undefined; + if (bundle.stages.fulltext.status === "blocked") { + bundle.stages.fulltext = { stage: "fulltext", status: "pending" }; + } + bundle.status = "running"; + await appendEvent(bundle, { + type: "input_amended", + stage: "fulltext", + reason: "Full-text corpus/access input amended through resume." + }); + await persistBundle(bundle); + } + if (bundle.status === "waiting_for_checkpoint") return bundle; + return advanceBundle(bundle, { + env: input.env, + fetch: input.fetch, + fullTextParser: input.fullTextParser, + oaFetch: input.oaFetch, + oaResolveHost: input.oaResolveHost, + globalPublicOaCacheRoot: input.globalPublicOaCacheRoot + }); +} + +export interface ScholarResearchRunExplanation { + schema: "longtable.scholar-research-run-explanation"; + version: 1; + runId: string; + stage: ScholarResearchStage; + state: ScholarResearchStageState; + events: ScholarResearchEvent[]; + input: ResearchBundleInput; + pendingCheckpoint?: ResearchBundle["pendingCheckpoint"]; + pauseReason?: string; +} + +export async function explainScholarResearchStage(input: { + cwd: string; + runId?: string; + stage: ScholarResearchStage; +}): Promise { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No scholar-research run was found."); + if (!SCHOLAR_RESEARCH_STAGES.includes(input.stage)) { + throw new Error(`Unknown scholar-research stage: ${input.stage}`); + } + const bundle = await readResearchBundle(input.cwd, runId); + const events = (await readEvents(workflowPaths(bundle.runDir).events)) + .filter((event) => event.stage === input.stage); + return { + schema: "longtable.scholar-research-run-explanation", + version: 1, + runId: bundle.runId, + stage: input.stage, + state: bundle.stages[input.stage], + events, + input: bundle.input, + ...(bundle.pendingCheckpoint ? { pendingCheckpoint: bundle.pendingCheckpoint } : {}), + ...(bundle.pauseReason ? { pauseReason: bundle.pauseReason } : {}) + }; +} + +export interface ScholarResearchRunVerification { + schema: "longtable.scholar-research-run-verification"; + version: 1; + runId: string; + verifiedAt: string; + passed: boolean; + checks: Array<{ + id: string; + passed: boolean; + detail: string; + }>; + hardFailures: string[]; + warnings: string[]; +} + +export async function verifyScholarResearchRun(input: { + cwd: string; + runId?: string; +}): Promise { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No scholar-research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + const checks: ScholarResearchRunVerification["checks"] = []; + const hardFailures: string[] = []; + const warnings: string[] = []; + const check = (id: string, passed: boolean, detail: string): void => { + checks.push({ id, passed, detail }); + if (!passed) hardFailures.push(`${id}: ${detail}`); + }; + + check( + "bundle_schema", + bundle.schema === "longtable.research-bundle" && + bundle.version === SCHOLAR_RESEARCH_BUNDLE_VERSION, + "ResearchBundle schema and version must match the runtime." + ); + const events = await readEvents(workflowPaths(bundle.runDir).events); + check( + "event_sequence", + events.length > 0 && events.every((event, index) => + event.sequence === index + 1 && + event.eventId === `event-${String(index + 1).padStart(6, "0")}` && + event.runId === bundle.runId + ), + "Event sequence, IDs, and run identity must be contiguous." + ); + const completedStages = SCHOLAR_RESEARCH_STAGES.filter( + (stage) => bundle.stages[stage].status === "completed" + ); + check( + "completed_stage_hashes", + completedStages.every((stage) => Boolean(bundle.stages[stage].inputHash)), + "Every completed stage must retain its input hash." + ); + try { + const brief = validateResearchBrief( + JSON.parse(await readFile(bundle.artifacts.researchBrief.jsonPath, "utf8")) + ); + check( + "research_brief", + `sha256:${stableHash(brief)}` === bundle.artifacts.researchBrief.contentHash && + brief.briefId === bundle.input.researchBrief.briefId, + "The versioned Research Brief must match the bundle and its recorded hash." + ); + } catch (error) { + check("research_brief", false, error instanceof Error ? error.message : String(error)); + } + try { + const accessPlan = validateLawfulAccessPlan( + JSON.parse(await readFile(bundle.artifacts.lawfulAccessPlan.path, "utf8")) + ); + check( + "lawful_access_plan", + `sha256:${stableHash(accessPlan)}` === bundle.artifacts.lawfulAccessPlan.contentHash, + "Lawful access routes and typed outcomes must preserve non-bypass boundaries without secrets." + ); + } catch (error) { + check("lawful_access_plan", false, error instanceof Error ? error.message : String(error)); + } + if (bundle.artifacts.targetJournalProfile) { + try { + const profile = validateTargetJournalProfile( + JSON.parse(await readFile(bundle.artifacts.targetJournalProfile.path, "utf8")) + ); + check( + "target_journal_profile", + `sha256:${stableHash(profile)}` === bundle.artifacts.targetJournalProfile.contentHash, + "Target-journal topic and format evidence must pass schema, provenance, and human-review gates." + ); + } catch (error) { + check("target_journal_profile", false, error instanceof Error ? error.message : String(error)); + } + } else if (bundle.status === "completed") { + check("target_journal_profile", false, "A completed run requires a target-journal profile."); + } + + for (const record of bundle.artifacts.fullTextRecords ?? []) { + try { + const bytes = await readFile(record.storedPath); + check( + `fulltext_hash:${record.sourceId}`, + `sha256:${createHash("sha256").update(bytes).digest("hex")}` === record.contentHash, + "Stored full-text bytes must match the manifest content hash." + ); + const derived = JSON.parse(await readFile(record.derivedTextPath, "utf8")) as { + contentHash?: string; + sourceVersion?: string; + accessClass?: string; + pages?: unknown[]; + }; + check( + `fulltext_provenance:${record.sourceId}`, + derived.contentHash === record.contentHash && + derived.sourceVersion === record.sourceVersion && + derived.accessClass === record.accessClass && + Array.isArray(derived.pages) && + derived.pages.length === record.pageCount, + "Derived full text must preserve hash, version, access class, and page inventory." + ); + const stored = resolve(record.storedPath); + const runRoot = resolve(bundle.runDir); + check( + `storage_isolation:${record.sourceId}`, + record.accessClass === "public_oa" + ? record.storageClass === "global_public_cache" + : record.storageClass === "project_isolated" && + (stored === runRoot || stored.startsWith(`${runRoot}${sep}`)), + "Only public OA may use the global cache; all other content must remain inside the run." + ); + } catch (error) { + check( + `fulltext_readback:${record.sourceId}`, + false, + error instanceof Error ? error.message : String(error) + ); + } + } + + if (bundle.artifacts.citationSlotsPath) { + try { + const set = JSON.parse(await readFile(bundle.artifacts.citationSlotsPath, "utf8")) as { + citationSlots?: CitationSlot[]; + }; + check( + "filled_citation_slots", + Array.isArray(set.citationSlots) && + set.citationSlots + .filter((slot) => slot.status === "filled") + .every(citationSlotIsStrictlyFilled), + "Every filled citation slot must retain exact evidence and provenance." + ); + } catch (error) { + check("citation_slot_readback", false, error instanceof Error ? error.message : String(error)); + } + } + for (const adjudicationRef of bundle.artifacts.adjudications ?? []) { + try { + const adjudication = JSON.parse( + await readFile(adjudicationRef.path, "utf8") + ) as AdjudicationRecord; + check( + `adjudication:${adjudicationRef.citationSlotId}`, + adjudication.schema === "longtable.adjudication-record" && + adjudication.stage === "extract" && + adjudication.inputBundleVersion === bundle.version && + adjudication.inputBundleHash?.startsWith("sha256:") && + Boolean(adjudication.verifier.rationale?.trim()) && + Boolean(adjudication.errorClass) && + Boolean(adjudication.severity) && + Boolean(adjudication.createdAt) && + Boolean(adjudication.replayFixtureRef) && + (bundle.stages.extract.status !== "completed" || Boolean(adjudication.humanDecision)), + "Adjudication must preserve bundle identity, original/verifier decision, error class, severity, rationale, replay reference, and required human correction." + ); + } catch (error) { + check( + `adjudication_readback:${adjudicationRef.citationSlotId}`, + false, + error instanceof Error ? error.message : String(error) + ); + } + } + + for (const contractRecord of bundle.artifacts.visualContracts ?? []) { + try { + const contract = JSON.parse( + await readFile(contractRecord.path, "utf8") + ) as VisualEvidenceContract; + const validation = validateVisualEvidenceContract(contract); + check( + `visual_contract:${contractRecord.id}`, + validation.valid && contract.status === contractRecord.status, + validation.valid + ? "Visual contract status must match its bundle record." + : validation.hardFailures.join(" ") + ); + } catch (error) { + check( + `visual_contract_readback:${contractRecord.id}`, + false, + error instanceof Error ? error.message : String(error) + ); + } + } + + for (const renderRecord of bundle.artifacts.visualRenders ?? []) { + try { + const manifest = JSON.parse( + await readFile(renderRecord.manifestPath, "utf8") + ) as VisualRenderManifest; + const qa = JSON.parse(await readFile(renderRecord.qaPath, "utf8")) as MechanicalVisualQa; + const editable = await readFile(manifest.editableSourcePath); + check( + `visual_render:${renderRecord.contractId}`, + qa.passed && + manifest.contractId === renderRecord.contractId && + `sha256:${createHash("sha256").update(editable).digest("hex")}` === + manifest.editableSourceHash, + "Visual render must pass mechanical QA and preserve the editable-source hash." + ); + } catch (error) { + check( + `visual_render_readback:${renderRecord.contractId}`, + false, + error instanceof Error ? error.message : String(error) + ); + } + } + + const expectedVisuals = bundle.artifacts.visualPortfolio?.contractIds ?? []; + if (expectedVisuals.length > 0) { + const approved = new Set( + (bundle.artifacts.visualContracts ?? []) + .filter((entry) => entry.status === "approved") + .map((entry) => entry.id) + ); + const rendered = new Set((bundle.artifacts.visualRenders ?? []).map((entry) => entry.contractId)); + const accepted = new Set( + (bundle.artifacts.finalVisualReviews ?? []) + .filter((entry) => entry.decision === "accept") + .map((entry) => entry.contractId) + ); + check( + "visual_portfolio", + bundle.status !== "completed" || + expectedVisuals.every((id) => approved.has(id) && rendered.has(id) && accepted.has(id)), + "A completed portfolio run must approve, render, and accept every declared visual." + ); + } + + if (bundle.status === "completed") { + check( + "completed_stage_set", + SCHOLAR_RESEARCH_STAGES.every((stage) => bundle.stages[stage].status === "completed"), + "A completed workflow must complete every declared stage." + ); + const handoffPath = bundle.stages.handoff.outputRef; + if (!handoffPath) { + check("handoff_readback", false, "Completed workflow has no handoff path."); + } else { + try { + const handoff = JSON.parse(await readFile(handoffPath, "utf8")) as { + runId?: string; + externalActionPerformed?: boolean; + }; + check( + "handoff_readback", + handoff.runId === bundle.runId && handoff.externalActionPerformed === false, + "Handoff must match the run and must not claim an unperformed external action." + ); + } catch (error) { + check("handoff_readback", false, error instanceof Error ? error.message : String(error)); + } + } + const verifiedPackage = bundle.artifacts.verifiedPackage; + if (!verifiedPackage) { + check("verified_research_package", false, "Completed workflow has no Verified Research Package."); + } else { + try { + const manifest = JSON.parse(await readFile(verifiedPackage.manifestPath, "utf8")) as { + schema?: string; + version?: string; + runId?: string; + status?: string; + files?: Array<{ role?: string; path?: string; contentHash?: string }>; + }; + const files = manifest.files ?? []; + let hashesPass = files.length > 0; + for (const file of files) { + if (!file.path || !file.contentHash || await fileHash(file.path) !== file.contentHash) { + hashesPass = false; + break; + } + } + const requiredRoles = [ + "research_brief_json", + "target_journal_profile", + "lawful_access_plan", + "search_manifest", + "fulltext_manifest", + "citation_claim_ledger", + "synthesis", + "visual_portfolio", + "research_assurance", + "limitations", + "human_readme", + "verification" + ]; + check( + "verified_research_package", + manifest.schema === "longtable.verified-research-package" && + manifest.version === "1.0.0" && + manifest.runId === bundle.runId && + manifest.status === "verified" && + requiredRoles.every((role) => files.some((file) => file.role === role)) && + hashesPass, + "Verified Research Package manifest, required roles, and file hashes must pass readback." + ); + } catch (error) { + check("verified_research_package", false, error instanceof Error ? error.message : String(error)); + } + } + } else { + warnings.push(`Workflow status is ${bundle.status}; verification covers only materialized artifacts.`); + } + + return { + schema: "longtable.scholar-research-run-verification", + version: 1, + runId: bundle.runId, + verifiedAt: now(), + passed: hardFailures.length === 0, + checks, + hardFailures, + warnings + }; +} + +export async function recordScholarResearchProviderPatch(input: { + cwd: string; + runId?: string; + packetPath: string; + patchPath: string; + provider: string; +}): Promise<{ bundle: ResearchBundle; record: NonNullable[number] }> { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No scholar-research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + const packetPath = resolve(input.packetPath); + const knownExtractionPacket = bundle.artifacts.extractionTasks?.packets.some( + (packet) => resolve(packet.path) === packetPath + ) === true; + const knownSynthesisPacket = bundle.artifacts.synthesisTask + ? resolve(bundle.artifacts.synthesisTask.path) === packetPath + : false; + if (!knownExtractionPacket && !knownSynthesisPacket) { + throw new Error("The provider packet is not registered in this ResearchBundle."); + } + const packet = JSON.parse(await readFile(packetPath, "utf8")) as ProviderTaskPacket & { packetId: string }; + const patch = JSON.parse(await readFile(resolve(input.patchPath), "utf8")) as ProviderProposedPatch; + const validation = validateProviderProposedPatch(packet, patch); + if (!validation.valid) { + throw new Error(`Provider patch validation failed: ${validation.errors.join(" ")}`); + } + const provider = input.provider.trim(); + if (!provider) throw new Error("A provider label is required."); + const patchHash = stableHash(patch); + const recordedAt = now(); + const outputPath = join( + bundle.runDir, + "provider-results", + packet.stage, + `${packet.packetId}.${provider.replace(/[^\w.-]+/g, "-")}.${patchHash.slice(0, 12)}.json` + ); + const existing = bundle.artifacts.providerPatches?.find( + (record) => record.packetId === packet.packetId && + record.provider === provider && + record.patchHash === patchHash + ); + if (existing) return { bundle, record: existing }; + await writeJsonAtomic(outputPath, { + schema: "longtable.recorded-provider-patch", + version: 1, + packetId: packet.packetId, + provider, + recordedAt, + patchHash, + patch + }); + const record = { + packetId: packet.packetId, + stage: packet.stage, + provider, + patchHash, + path: outputPath, + recordedAt + }; + bundle.artifacts.providerPatches = [...(bundle.artifacts.providerPatches ?? []), record]; + await appendEvent(bundle, { + type: "provider_patch_recorded", + stage: packet.stage, + outputRef: outputPath, + reason: `Recorded schema-valid ${packet.stage} patch from ${provider}.` + }); + await persistBundle(bundle); + return { bundle, record }; +} + +export async function recordScholarResearchVerification(input: { + cwd: string; + runId?: string; + verificationPath: string; +}): Promise<{ bundle: ResearchBundle; verificationPath: string; adjudicationPaths: string[] }> { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No scholar-research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + const verification = JSON.parse( + await readFile(resolve(input.verificationPath), "utf8") + ) as ProviderVerification; + if (verification.schema !== "longtable.provider-verification" || verification.version !== 1 || + verification.runId !== bundle.runId || verification.independent !== true) { + throw new Error("Invalid provider verification schema, run, or independence declaration."); + } + const proposalRecord = bundle.artifacts.providerPatches?.find( + (record) => record.patchHash === verification.proposalPatchHash && + record.packetId === verification.packetId && + record.stage === "extract" + ); + if (!proposalRecord) throw new Error("The verification does not reference a recorded provider proposal."); + if (proposalRecord.provider === verification.provider) { + throw new Error("The independent verifier must differ from the proposer."); + } + const recordedProposal = JSON.parse(await readFile(proposalRecord.path, "utf8")) as { + patch: ProviderProposedPatch; + }; + const slots = citationSlotsFromProviderPatch(recordedProposal.patch); + const slotIds = new Set(slots.map((slot) => slot.id)); + const decisionIds = new Set(verification.decisions.map((decision) => decision.citationSlotId)); + if (decisionIds.size !== slotIds.size || [...slotIds].some((id) => !decisionIds.has(id))) { + throw new Error("The verifier must decide every proposed citation slot exactly once."); + } + for (const decision of verification.decisions) { + if (!["agree", "disagree", "uncertain"].includes(decision.decision) || !decision.rationale.trim()) { + throw new Error(`Invalid verifier decision for ${decision.citationSlotId}.`); + } + } + const verificationHash = stableHash(verification); + const existing = bundle.artifacts.providerVerifications?.find( + (record) => record.proposalPatchHash === proposalRecord.patchHash && + record.provider === verification.provider + ); + if (existing) { + return { + bundle, + verificationPath: existing.path, + adjudicationPaths: (bundle.artifacts.adjudications ?? []).map((record) => record.path) + }; + } + const recordedAt = now(); + const outputPath = join( + bundle.runDir, + "provider-results", + "verify", + `${verification.packetId}.${verification.provider.replace(/[^\w.-]+/g, "-")}.${verificationHash.slice(0, 12)}.json` + ); + await writeJsonAtomic(outputPath, verification); + const adjudicationPaths: string[] = []; + for (const slot of slots) { + const decision = verification.decisions.find((entry) => entry.citationSlotId === slot.id)!; + const adjudicationId = `adjudication-${stableHash({ + slot: slot.id, + proposal: proposalRecord.patchHash + }).slice(0, 16)}`; + const errorClass: AdjudicationRecord["errorClass"] = decision.decision === "agree" + ? "none" + : decision.decision === "disagree" + ? "claim_evidence_relation" + : "insufficient_evidence"; + const adjudication: AdjudicationRecord = { + schema: "longtable.adjudication-record", + version: 1, + id: adjudicationId, + runId: bundle.runId, + stage: "extract", + inputBundleVersion: bundle.version, + inputBundleHash: `sha256:${stableHash({ + runId: bundle.runId, + input: bundle.input, + extractInputHash: bundle.stages.extract.inputHash + })}`, + citationSlotId: slot.id, + proposer: { + provider: proposalRecord.provider, + decision: slot.relation ?? "insufficient", + evidenceSpanIds: slot.evidence?.spanId ? [slot.evidence.spanId] : [] + }, + verifier: { + provider: verification.provider, + independent: true, + decision: decision.decision, + evidenceSpanIds: decision.evidenceSpanIds, + rationale: decision.rationale + }, + errorClass, + severity: decision.decision === "agree" + ? "none" + : decision.decision === "disagree" + ? "high" + : "medium", + humanRequired: true, + createdAt: recordedAt, + replayFixtureRef: `adjudication:${bundle.runId}:${adjudicationId}` + }; + const path = join(bundle.runDir, "adjudications", `${adjudication.id}.json`); + await writeJsonAtomic(path, adjudication); + adjudicationPaths.push(path); + } + bundle.artifacts.providerVerifications = [ + ...(bundle.artifacts.providerVerifications ?? []), + { + packetId: verification.packetId, + provider: verification.provider, + proposalPatchHash: proposalRecord.patchHash, + path: outputPath, + recordedAt + } + ]; + bundle.artifacts.adjudications = [ + ...(bundle.artifacts.adjudications ?? []), + ...slots.map((slot, index) => ({ citationSlotId: slot.id, path: adjudicationPaths[index] })) + ]; + bundle.stages.extract = { ...bundle.stages.extract, status: "awaiting_human" }; + await appendEvent(bundle, { + type: "provider_verification_recorded", + stage: "extract", + outputRef: outputPath, + reason: "Independent verification recorded; human review remains required for final claims." + }); + await persistBundle(bundle); + return { bundle, verificationPath: outputPath, adjudicationPaths }; +} + +export async function recordScholarResearchHumanCitationReview(input: { + cwd: string; + runId?: string; + reviewPath: string; +}): Promise<{ bundle: ResearchBundle; citationSlots: CitationSlot[]; path: string }> { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No scholar-research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + const review = JSON.parse(await readFile(resolve(input.reviewPath), "utf8")) as HumanCitationReview; + if (review.schema !== "longtable.human-citation-review" || review.version !== 1 || + review.runId !== bundle.runId || !review.reviewer.trim() || !review.reviewedAt) { + throw new Error("Invalid human citation review."); + } + const proposedSlots: CitationSlot[] = []; + for (const record of bundle.artifacts.providerPatches ?? []) { + const recorded = JSON.parse(await readFile(record.path, "utf8")) as { patch: ProviderProposedPatch }; + proposedSlots.push(...citationSlotsFromProviderPatch(recorded.patch)); + } + const uniqueSlots = new Map(proposedSlots.map((slot) => [slot.id, slot])); + const decisions = new Map(review.decisions.map((decision) => [decision.citationSlotId, decision])); + if (decisions.size !== uniqueSlots.size || [...uniqueSlots.keys()].some((id) => !decisions.has(id))) { + throw new Error("Human review must decide every unique proposed citation slot exactly once."); + } + const citationSlots: CitationSlot[] = []; + for (const [id, slot] of uniqueSlots) { + const decision = decisions.get(id)!; + if (!decision.rationale.trim()) throw new Error(`Human review rationale is required for ${id}.`); + const reviewed: CitationSlot = { + ...slot, + status: decision.decision === "accept" + ? "filled" + : decision.decision === "reject" + ? "unfilled" + : "provisional" + }; + if (reviewed.status === "filled" && !citationSlotIsStrictlyFilled(reviewed)) { + throw new Error(`Accepted citation slot ${id} does not satisfy the strict evidence contract.`); + } + citationSlots.push(reviewed); + const adjudicationRef = bundle.artifacts.adjudications?.find((entry) => entry.citationSlotId === id); + if (adjudicationRef) { + const adjudication = JSON.parse(await readFile(adjudicationRef.path, "utf8")) as AdjudicationRecord; + adjudication.humanDecision = { + reviewer: review.reviewer, + adjudicatorRole: "domain_researcher", + decision: decision.decision === "accept" + ? "accept" + : decision.decision === "reject" + ? "reject" + : "preserve_disagreement", + decidedAt: review.reviewedAt, + rationale: decision.rationale + }; + await writeJsonAtomic(adjudicationRef.path, adjudication); + } + } + const outputPath = join(bundle.runDir, "artifacts", "citation-slots.json"); + await writeJsonAtomic(outputPath, { + schema: "longtable.citation-slot-set", + version: 1, + runId: bundle.runId, + reviewedBy: review.reviewer, + reviewedAt: review.reviewedAt, + citationSlots + }); + bundle.artifacts.citationSlotsPath = outputPath; + await appendEvent(bundle, { + type: "human_citation_review_recorded", + stage: "extract", + outputRef: outputPath, + reason: "Human citation review recorded; only accepted strict slots are filled." + }); + const inputHash = bundle.stages.extract.inputHash; + if (!inputHash) throw new Error("Extract stage has no input hash."); + await completeStage(bundle, "extract", inputHash, outputPath); + return { bundle, citationSlots, path: outputPath }; +} + +export async function recordScholarResearchSynthesisVerification(input: { + cwd: string; + runId?: string; + verificationPath: string; +}): Promise<{ bundle: ResearchBundle; path: string }> { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No scholar-research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + const verification = JSON.parse( + await readFile(resolve(input.verificationPath), "utf8") + ) as ProviderSynthesisVerification; + if (verification.schema !== "longtable.provider-synthesis-verification" || + verification.version !== 1 || verification.runId !== bundle.runId || + verification.independent !== true) { + throw new Error("Invalid synthesis verification schema, run, or independence declaration."); + } + const proposalRecord = bundle.artifacts.providerPatches?.find( + (record) => record.stage === "synthesize" && + record.packetId === verification.packetId && + record.patchHash === verification.proposalPatchHash + ); + if (!proposalRecord) throw new Error("Synthesis verification does not reference a recorded synthesis proposal."); + if (proposalRecord.provider === verification.provider) { + throw new Error("The synthesis verifier must differ from the proposer."); + } + const recorded = JSON.parse(await readFile(proposalRecord.path, "utf8")) as { + patch: ProviderProposedPatch; + }; + const proposal = synthesisProposalFromProviderPatch(recorded.patch); + const claimIds = new Set(proposal.claims.map((claim) => claim.id)); + const decisionIds = new Set(verification.decisions.map((decision) => decision.claimId)); + if (decisionIds.size !== claimIds.size || [...claimIds].some((id) => !decisionIds.has(id))) { + throw new Error("The synthesis verifier must decide every claim exactly once."); + } + for (const decision of verification.decisions) { + if (!["agree", "disagree", "uncertain"].includes(decision.decision) || !decision.rationale.trim()) { + throw new Error(`Invalid synthesis verifier decision for ${decision.claimId}.`); + } + } + const existing = bundle.artifacts.synthesisVerifications?.find( + (entry) => entry.proposalPatchHash === proposalRecord.patchHash && + entry.provider === verification.provider + ); + if (existing) return { bundle, path: existing.path }; + const recordedAt = now(); + const verificationHash = stableHash(verification); + const outputPath = join( + bundle.runDir, + "provider-results", + "verify-synthesis", + `${verification.packetId}.${verification.provider.replace(/[^\w.-]+/g, "-")}.${verificationHash.slice(0, 12)}.json` + ); + await writeJsonAtomic(outputPath, verification); + bundle.artifacts.synthesisVerifications = [ + ...(bundle.artifacts.synthesisVerifications ?? []), + { + packetId: verification.packetId, + provider: verification.provider, + proposalPatchHash: proposalRecord.patchHash, + path: outputPath, + recordedAt + } + ]; + bundle.stages.synthesize = { ...bundle.stages.synthesize, status: "awaiting_human" }; + await appendEvent(bundle, { + type: "provider_verification_recorded", + stage: "synthesize", + outputRef: outputPath, + reason: "Independent synthesis verification recorded; human claim review remains required." + }); + await persistBundle(bundle); + return { bundle, path: outputPath }; +} + +export async function recordScholarResearchHumanSynthesisReview(input: { + cwd: string; + runId?: string; + reviewPath: string; +}): Promise<{ bundle: ResearchBundle; claims: Array; path: string }> { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No scholar-research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + const review = JSON.parse( + await readFile(resolve(input.reviewPath), "utf8") + ) as HumanSynthesisReview; + if (review.schema !== "longtable.human-synthesis-review" || review.version !== 1 || + review.runId !== bundle.runId || !review.reviewer.trim() || !review.reviewedAt) { + throw new Error("Invalid human synthesis review."); + } + const proposalRecord = bundle.artifacts.providerPatches?.find( + (record) => record.stage === "synthesize" && record.patchHash === review.proposalPatchHash + ); + if (!proposalRecord) throw new Error("Human synthesis review does not reference a recorded proposal."); + const verification = bundle.artifacts.synthesisVerifications?.find( + (record) => record.proposalPatchHash === proposalRecord.patchHash + ); + if (!verification) throw new Error("Human synthesis review requires an independent provider verification."); + const recorded = JSON.parse(await readFile(proposalRecord.path, "utf8")) as { + patch: ProviderProposedPatch; + }; + const proposal = synthesisProposalFromProviderPatch(recorded.patch); + const claimIds = new Set(proposal.claims.map((claim) => claim.id)); + const decisions = new Map(review.decisions.map((decision) => [decision.claimId, decision])); + if (decisions.size !== claimIds.size || [...claimIds].some((id) => !decisions.has(id))) { + throw new Error("Human synthesis review must decide every claim exactly once."); + } + const claims = proposal.claims.map((claim) => { + const decision = decisions.get(claim.id)!; + if (!decision.rationale.trim()) throw new Error(`Human synthesis rationale is required for ${claim.id}.`); + return { + ...claim, + status: decision.decision === "accept" + ? "accepted" + : decision.decision === "reject" + ? "rejected" + : "preserved_disagreement", + humanRationale: decision.rationale + }; + }); + const outputPath = join(bundle.runDir, "artifacts", "synthesis.json"); + await writeJsonAtomic(outputPath, { + schema: "longtable.human-reviewed-synthesis", + version: 1, + runId: bundle.runId, + title: proposal.title, + readingOrder: proposal.readingOrder, + reviewer: review.reviewer, + reviewedAt: review.reviewedAt, + claims + }); + bundle.artifacts.synthesisPath = outputPath; + await appendEvent(bundle, { + type: "human_synthesis_review_recorded", + stage: "synthesize", + outputRef: outputPath, + reason: "Human synthesis review recorded; rejected and disputed claims remain explicit." + }); + const inputHash = bundle.stages.synthesize.inputHash; + if (!inputHash) throw new Error("Synthesize stage has no input hash."); + await completeStage(bundle, "synthesize", inputHash, outputPath); + return { bundle, claims, path: outputPath }; +} + +export async function registerScholarResearchVisualPortfolio(input: { + cwd: string; + runId?: string; + planPath: string; +}): Promise<{ bundle: ResearchBundle; plan: VisualPortfolioPlan; path: string }> { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No scholar-research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + if (bundle.stages.visual_contract.status !== "blocked" && + bundle.stages.visual_contract.status !== "awaiting_human") { + throw new Error("A visual portfolio must be registered at the Visual Evidence Contract checkpoint."); + } + const plan = JSON.parse( + await readFile(resolve(input.planPath), "utf8") + ) as VisualPortfolioPlan; + const errors = validateVisualPortfolioPlan(plan); + if (errors.length > 0) throw new Error(`Visual portfolio plan is invalid: ${errors.join(" ")}`); + if (plan.runId !== bundle.runId) throw new Error("Visual portfolio run does not match the ResearchBundle."); + const outputPath = join(bundle.runDir, "artifacts", "visual-portfolio-plan.json"); + if (bundle.artifacts.visualPortfolio) { + const existing = JSON.parse( + await readFile(bundle.artifacts.visualPortfolio.path, "utf8") + ) as VisualPortfolioPlan; + if (JSON.stringify(existing) !== JSON.stringify(plan)) { + throw new Error("A different visual portfolio is already frozen for this run."); + } + return { bundle, plan: existing, path: bundle.artifacts.visualPortfolio.path }; + } + await writeJsonAtomic(outputPath, plan); + bundle.artifacts.visualPortfolio = { + portfolioId: plan.portfolioId, + contractIds: plan.contractIds, + path: outputPath, + registeredAt: plan.registeredAt + }; + await appendEvent(bundle, { + type: "visual_portfolio_registered", + stage: "visual_contract", + outputRef: outputPath, + reason: `Visual portfolio ${plan.portfolioId} froze ${plan.contractIds.length} contract IDs.` + }); + await persistBundle(bundle); + return { bundle, plan, path: outputPath }; +} + +export async function recordScholarResearchHumanVisualReview(input: { + cwd: string; + runId?: string; + contractPath: string; + reviewPath: string; +}): Promise<{ bundle: ResearchBundle; contract: VisualEvidenceContract; path: string }> { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No scholar-research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + if (bundle.stages.visual_contract.status !== "blocked" && + bundle.stages.visual_contract.status !== "awaiting_human") { + throw new Error("The workflow is not awaiting a Visual Evidence Contract decision."); + } + const proposed = JSON.parse( + await readFile(resolve(input.contractPath), "utf8") + ) as VisualEvidenceContract; + const review = JSON.parse( + await readFile(resolve(input.reviewPath), "utf8") + ) as HumanVisualContractReview; + if (review.schema !== "longtable.human-visual-contract-review" || + review.version !== 1 || review.runId !== bundle.runId || + !review.reviewer.trim() || !review.reviewedAt || + !review.decisionRecordId.trim() || !review.rationale.trim()) { + throw new Error("Invalid human visual contract review."); + } + if (proposed.schema !== "longtable.visual-evidence-contract" || proposed.version !== 1) { + throw new Error("Invalid Visual Evidence Contract schema."); + } + const portfolio = bundle.artifacts.visualPortfolio; + if (portfolio && !portfolio.contractIds.includes(proposed.id)) { + throw new Error(`Visual contract ${proposed.id} is not declared in the frozen portfolio.`); + } + if (proposed.status !== "proposed") { + throw new Error("Human visual review requires a proposed contract."); + } + if (bundle.input.targetJournal && proposed.targetJournal !== bundle.input.targetJournal) { + throw new Error("Visual contract target journal does not match the ResearchBundle."); + } + const decisionChecks = Object.values(review.checks); + if (decisionChecks.length !== 4 || decisionChecks.some((value) => typeof value !== "boolean")) { + throw new Error("Visual review requires four explicit boolean checks."); + } + if (review.decision === "approve" && decisionChecks.some((value) => value !== true)) { + throw new Error("A visual contract cannot be approved while any human verification lens fails."); + } + if (review.decision !== "approve" && review.decision !== "reject") { + throw new Error("Visual review decision must be approve or reject."); + } + const finalStatus: "approved" | "rejected" = review.decision === "approve" ? "approved" : "rejected"; + const contract: VisualEvidenceContract = review.decision === "approve" + ? { + ...proposed, + status: "approved", + approval: { + approvedBy: review.reviewer, + approvedAt: review.reviewedAt, + decisionRecordId: review.decisionRecordId + } + } + : { ...proposed, status: "rejected", approval: undefined }; + const validation = validateVisualEvidenceContract(contract); + if (!validation.valid) { + throw new Error(`Visual Evidence Contract validation failed: ${validation.hardFailures.join(" ")}`); + } + const outputPath = join( + bundle.runDir, + "artifacts", + "visual-contracts", + `${contract.id}.${contract.status}.json` + ); + await writeJsonAtomic(outputPath, { + ...contract, + humanReview: review, + validation + }); + bundle.artifacts.visualContracts = [ + ...(bundle.artifacts.visualContracts ?? []).filter((entry) => entry.id !== contract.id), + { + id: contract.id, + status: finalStatus, + path: outputPath, + decisionRecordId: review.decisionRecordId, + reviewedAt: review.reviewedAt + } + ]; + await appendEvent(bundle, { + type: "human_visual_review_recorded", + stage: "visual_contract", + outputRef: outputPath, + reason: `Human Visual Evidence Contract decision recorded: ${contract.status}.` + }); + if (finalStatus === "approved") { + const expectedContractIds = portfolio?.contractIds ?? [contract.id]; + const approvedIds = new Set( + (bundle.artifacts.visualContracts ?? []) + .filter((entry) => entry.status === "approved") + .map((entry) => entry.id) + ); + const remaining = expectedContractIds.filter((id) => !approvedIds.has(id)); + if (remaining.length === 0) { + bundle.pendingCheckpoint = undefined; + bundle.pauseReason = undefined; + bundle.status = "running"; + const inputHash = bundle.stages.visual_contract.inputHash; + if (!inputHash) throw new Error("Visual contract stage has no input hash."); + await completeStage( + bundle, + "visual_contract", + inputHash, + portfolio?.path ?? outputPath + ); + } else { + bundle.status = "waiting_for_checkpoint"; + bundle.pendingCheckpoint = { + class: "visual_evidence_contract", + stage: "visual_contract", + reason: `The visual portfolio still requires human decisions for: ${remaining.join(", ")}.` + }; + bundle.stages.visual_contract = { + ...bundle.stages.visual_contract, + status: "blocked", + checkpointClass: "visual_evidence_contract", + blockedReason: bundle.pendingCheckpoint.reason + }; + await persistBundle(bundle); + } + } else { + bundle.status = "waiting_for_checkpoint"; + bundle.pendingCheckpoint = { + class: "visual_evidence_contract", + stage: "visual_contract", + reason: "The proposed Visual Evidence Contract was rejected and must be revised." + }; + bundle.stages.visual_contract = { + ...bundle.stages.visual_contract, + status: "blocked", + blockedReason: bundle.pendingCheckpoint.reason, + checkpointClass: "visual_evidence_contract" + }; + await persistBundle(bundle); + } + return { bundle, contract, path: outputPath }; +} + +export async function recordScholarResearchVisualRender(input: { + cwd: string; + runId?: string; + contractId: string; + requestPath: string; +}): Promise<{ + bundle: ResearchBundle; + manifest: VisualRenderManifest; + manifestPath: string; + qa: MechanicalVisualQa; + qaPath: string; +}> { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No scholar-research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + if (bundle.stages.visual_contract.status !== "completed") { + throw new Error("Visual rendering requires a completed Visual Evidence Contract stage."); + } + const contractRecord = bundle.artifacts.visualContracts?.find( + (record) => record.id === input.contractId && record.status === "approved" + ); + if (!contractRecord) throw new Error("No approved visual contract is registered for this contract ID."); + const contract = JSON.parse(await readFile(contractRecord.path, "utf8")) as VisualEvidenceContract; + const request = JSON.parse(await readFile(resolve(input.requestPath), "utf8")) as VisualRenderRequest; + if (request.runId !== bundle.runId) throw new Error("Visual render request run does not match the ResearchBundle."); + const existing = bundle.artifacts.visualRenders?.find( + (record) => record.contractId === contract.id + ); + if (existing) { + const manifest = JSON.parse(await readFile(existing.manifestPath, "utf8")) as VisualRenderManifest; + const qa = JSON.parse(await readFile(existing.qaPath, "utf8")) as MechanicalVisualQa; + const requestHash = `sha256:${stableHash(request)}`; + const snapshotHash = `sha256:${createHash("sha256").update( + await readFile(resolve(contract.dataContract.dataSnapshotPath)) + ).digest("hex")}`; + if (manifest.requestHash !== requestHash || manifest.dataSnapshotHash !== snapshotHash) { + throw new Error("A render already exists for this contract with different request or data inputs."); + } + return { + bundle, + manifest, + manifestPath: existing.manifestPath, + qa, + qaPath: existing.qaPath + }; + } + const implementHash = await beginStage(bundle, "implement", { + contractId: contract.id, + contractPath: contractRecord.path, + request + }); + if (!implementHash) throw new Error("Implement stage was already resolved without a registered render."); + const rendered = await renderVisualArtifact({ + runDir: bundle.runDir, + contract, + request + }); + if (!rendered.qa.passed) { + await blockStage( + bundle, + "render", + stableHash(rendered.manifest), + "visual_evidence_contract", + `Mechanical visual QA failed: ${rendered.qa.hardFailures.join(" ")}` + ); + return { bundle, ...rendered }; + } + bundle.artifacts.visualRenders = [ + ...(bundle.artifacts.visualRenders ?? []), + { + contractId: contract.id, + manifestPath: rendered.manifestPath, + qaPath: rendered.qaPath, + editableSourcePath: rendered.manifest.editableSourcePath, + renderedAt: rendered.manifest.renderedAt + } + ]; + await appendEvent(bundle, { + type: "visual_render_recorded", + stage: "render", + outputRef: rendered.manifestPath, + reason: "Allowlisted editable SVG render and mechanical QA were recorded." + }); + await completeStage(bundle, "implement", implementHash, rendered.manifest.editableSourcePath); + const renderHash = await beginStage(bundle, "render", { + contractId: contract.id, + requestHash: rendered.manifest.requestHash, + dataSnapshotHash: rendered.manifest.dataSnapshotHash + }); + if (!renderHash) throw new Error("Render stage was already resolved without completion."); + await completeStage(bundle, "render", renderHash, rendered.manifestPath); + const verifyHash = await beginStage(bundle, "verify", { + contractId: contract.id, + manifestPath: rendered.manifestPath, + mechanicalQaPath: rendered.qaPath + }); + if (!verifyHash) throw new Error("Verify stage was already resolved without human review."); + await awaitHumanStage( + bundle, + "verify", + verifyHash, + rendered.qaPath, + "Mechanical QA passed; final domain, statistical, journal-fit, and timed reader checks require a human." + ); + return { bundle, ...rendered }; +} + +export async function recordTargetJournalProfile(input: { + cwd: string; + runId?: string; + profile: TargetJournalProfile; +}): Promise<{ bundle: ResearchBundle; profile: TargetJournalProfile; path: string }> { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No LongTable Research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + const profile = validateTargetJournalProfile(input.profile); + if (bundle.input.targetJournal && + bundle.input.targetJournal.toLocaleLowerCase() !== profile.targetJournal.toLocaleLowerCase()) { + throw new Error("Target-journal profile does not match the Research Brief."); + } + const path = join(bundle.runDir, "artifacts", "target-journal-profile.json"); + await writeJsonAtomic(path, profile); + bundle.artifacts.targetJournalProfile = { + profileId: profile.profileId, + path, + contentHash: `sha256:${stableHash(profile)}`, + reviewedAt: profile.humanReview.reviewedAt + }; + await appendEvent(bundle, { + type: "target_journal_profile_recorded", + stage: "venue", + outputRef: path, + reason: "Human-accepted target-journal topic and format evidence recorded." + }); + const expectedVisuals = bundle.artifacts.visualPortfolio?.contractIds ?? []; + const acceptedVisuals = new Set( + (bundle.artifacts.finalVisualReviews ?? []) + .filter((entry) => entry.decision === "accept") + .map((entry) => entry.contractId) + ); + if (expectedVisuals.length > 0 && + expectedVisuals.every((contractId) => acceptedVisuals.has(contractId)) && + bundle.stages.verify.status === "awaiting_human" && + bundle.stages.verify.inputHash) { + bundle.pendingCheckpoint = undefined; + bundle.pauseReason = undefined; + await completeStage( + bundle, + "verify", + bundle.stages.verify.inputHash, + bundle.artifacts.finalVisualReviews?.at(-1)?.path + ); + await finalizeVerifiedResearchPackage(bundle); + return { bundle, profile, path }; + } + await persistBundle(bundle); + return { bundle, profile, path }; +} + +async function fileHash(path: string): Promise { + return `sha256:${createHash("sha256").update(await readFile(path)).digest("hex")}`; +} + +async function writeVerifiedResearchPackage(bundle: ResearchBundle, handoffPath: string): Promise> { + if (!bundle.artifacts.targetJournalProfile) { + throw new Error("Verified Research Package requires a human-accepted target-journal profile."); + } + const directory = join(bundle.runDir, "verified-research-package"); + await mkdir(directory, { recursive: true }); + const assurancePath = join(directory, "research-assurance.json"); + const limitationsPath = join(directory, "LIMITATIONS.md"); + const readmePath = join(directory, "README.md"); + const manifestPath = join(directory, "manifest.json"); + const verificationPath = join(directory, "verification.json"); + const assurance = { + schema: "longtable.research-assurance-record", + version: 1, + runId: bundle.runId, + createdAt: now(), + boundaries: [ + { + boundary: "scope", + status: "passed", + evidenceRefs: [bundle.artifacts.researchBrief.jsonPath] + }, + { + boundary: "access_corpus", + status: "passed", + evidenceRefs: [ + bundle.artifacts.lawfulAccessPlan.path, + join(bundle.runDir, "sources", "manifest.jsonl") + ] + }, + { + boundary: "evidence_claim_strength", + status: "passed", + evidenceRefs: [bundle.artifacts.citationSlotsPath, bundle.artifacts.synthesisPath].filter(Boolean) + }, + { + boundary: "visual_evidence", + status: "passed", + evidenceRefs: [ + bundle.artifacts.visualPortfolio?.path, + ...(bundle.artifacts.visualRenders ?? []).map((entry) => entry.manifestPath), + ...(bundle.artifacts.finalVisualReviews ?? []).map((entry) => entry.path) + ].filter(Boolean) + }, + { + boundary: "external_action", + status: "passed", + evidenceRefs: [handoffPath], + decision: "No external submission, upload, or publication was performed." + } + ] + }; + await writeJsonAtomic(assurancePath, assurance); + await writeFile(limitationsPath, [ + "# Limitations", + "", + "- Corpus coverage is bounded by the lawful access routes recorded in the run manifest.", + "- Provider-generated extraction and synthesis remain bounded proposals until their recorded verification and human gates pass.", + "- Journal patterns describe the reviewed exemplar set; they are not a guarantee of editorial acceptance.", + "- `externalActionPerformed` is false. Submission, upload, and publication remain separate human-authorized actions.", + "" + ].join("\n"), "utf8"); + await writeFile(readmePath, [ + "# Verified Research Package", + "", + `Run: ${bundle.runId}`, + `Research question: ${bundle.input.query}`, + `Target journal: ${bundle.input.targetJournal ?? "journal discovery"}`, + "", + "This package is complete only when `verification.json` reports `passed: true`.", + "The manifest points to the versioned Research Brief, journal profile, corpus, evidence ledger, synthesis, editable visuals, human reviews, assurance record, and limitations.", + "", + "## Reproduce", + "", + "```sh", + `longtable research verify --cwd ${JSON.stringify(resolve(bundle.runDir, "..", "..", ".."))} --run-id ${JSON.stringify(bundle.runId)} --json`, + "```", + "", + "No external submission or publication is included.", + "" + ].join("\n"), "utf8"); + + const candidates: Array<{ role: string; path: string | undefined }> = [ + { role: "research_brief_json", path: bundle.artifacts.researchBrief.jsonPath }, + { role: "research_brief_markdown", path: bundle.artifacts.researchBrief.markdownPath }, + { role: "target_journal_profile", path: bundle.artifacts.targetJournalProfile.path }, + { role: "lawful_access_plan", path: bundle.artifacts.lawfulAccessPlan.path }, + { role: "search_manifest", path: bundle.artifacts.searchRunPath }, + { role: "fulltext_manifest", path: join(bundle.runDir, "sources", "manifest.jsonl") }, + { role: "citation_claim_ledger", path: bundle.artifacts.citationSlotsPath }, + { role: "synthesis", path: bundle.artifacts.synthesisPath }, + { role: "visual_portfolio", path: bundle.artifacts.visualPortfolio?.path }, + ...((bundle.artifacts.visualContracts ?? []).map((entry) => ({ role: `visual_contract:${entry.id}`, path: entry.path }))), + ...((bundle.artifacts.visualRenders ?? []).flatMap((entry) => [ + { role: `visual_render_manifest:${entry.contractId}`, path: entry.manifestPath }, + { role: `visual_editable_source:${entry.contractId}`, path: entry.editableSourcePath } + ])), + ...((bundle.artifacts.finalVisualReviews ?? []).map((entry) => ({ role: `visual_human_review:${entry.contractId}`, path: entry.path }))), + { role: "research_assurance", path: assurancePath }, + { role: "limitations", path: limitationsPath }, + { role: "handoff", path: handoffPath }, + { role: "human_readme", path: readmePath } + ]; + const missing = candidates.filter((candidate) => !candidate.path).map((candidate) => candidate.role); + if (missing.length > 0) { + throw new Error(`Verified Research Package is missing required artifacts: ${missing.join(", ")}.`); + } + const files = []; + for (const candidate of candidates) { + files.push({ + role: candidate.role, + path: candidate.path as string, + contentHash: await fileHash(candidate.path as string) + }); + } + await writeJsonAtomic(manifestPath, { + schema: "longtable.verified-research-package", + version: "1.0.0", + runId: bundle.runId, + createdAt: now(), + status: "awaiting_verification", + reproduceCommand: `longtable research verify --cwd ${resolve(bundle.runDir, "..", "..", "..")} --run-id ${bundle.runId} --json`, + files + }); + return { + directory, + readmePath, + manifestPath, + assurancePath, + limitationsPath, + verificationPath + }; +} + +async function finalizeVerifiedResearchPackage(bundle: ResearchBundle): Promise { + const handoffHash = await beginStage(bundle, "handoff", { + synthesisPath: bundle.artifacts.synthesisPath, + visualRenders: bundle.artifacts.visualRenders, + finalVisualReviews: bundle.artifacts.finalVisualReviews, + targetJournalProfile: bundle.artifacts.targetJournalProfile + }); + if (!handoffHash) throw new Error("Handoff stage was already resolved without workflow completion."); + const handoffPath = join(bundle.runDir, "artifacts", "handoff.json"); + await writeJsonAtomic(handoffPath, { + schema: "longtable.research-handoff", + version: 1, + runId: bundle.runId, + createdAt: now(), + researchBrief: bundle.artifacts.researchBrief, + targetJournalProfile: bundle.artifacts.targetJournalProfile, + synthesisPath: bundle.artifacts.synthesisPath, + citationSlotsPath: bundle.artifacts.citationSlotsPath, + visualContracts: bundle.artifacts.visualContracts, + visualPortfolio: bundle.artifacts.visualPortfolio, + visualRenders: bundle.artifacts.visualRenders, + finalVisualReviews: bundle.artifacts.finalVisualReviews, + externalActionPerformed: false + }); + await completeStage(bundle, "handoff", handoffHash, handoffPath); + bundle.artifacts.verifiedPackage = await writeVerifiedResearchPackage(bundle, handoffPath); + bundle.status = "running"; + await persistBundle(bundle); + const verification = await verifyScholarResearchRun({ cwd: resolve(bundle.runDir, "..", "..", ".."), runId: bundle.runId }); + await writeJsonAtomic(bundle.artifacts.verifiedPackage.verificationPath, verification); + if (!verification.passed) { + bundle.status = "failed"; + await persistBundle(bundle); + throw new Error(`Verified Research Package preflight failed: ${verification.hardFailures.join(" ")}`); + } + const manifest = JSON.parse(await readFile(bundle.artifacts.verifiedPackage.manifestPath, "utf8")) as { + status: string; + files: Array<{ role: string; path: string; contentHash: string }>; + }; + manifest.status = "verified"; + manifest.files.push({ + role: "verification", + path: bundle.artifacts.verifiedPackage.verificationPath, + contentHash: await fileHash(bundle.artifacts.verifiedPackage.verificationPath) + }); + await writeJsonAtomic(bundle.artifacts.verifiedPackage.manifestPath, manifest); + bundle.status = "completed"; + await persistBundle(bundle); +} + +export async function recordScholarResearchHumanRenderedVisualReview(input: { + cwd: string; + runId?: string; + reviewPath: string; +}): Promise<{ bundle: ResearchBundle; review: HumanRenderedVisualReview; path: string }> { + const runId = input.runId ?? await findLatestScholarResearchRunId(input.cwd); + if (!runId) throw new Error("No scholar-research run was found."); + const bundle = await readResearchBundle(input.cwd, runId); + if (bundle.stages.verify.status !== "awaiting_human") { + throw new Error("The workflow is not awaiting final rendered-visual review."); + } + const review = JSON.parse( + await readFile(resolve(input.reviewPath), "utf8") + ) as HumanRenderedVisualReview; + if (review.schema !== "longtable.human-rendered-visual-review" || + review.version !== 1 || review.runId !== bundle.runId || + !review.contractId.trim() || !review.reviewer.trim() || + !review.reviewedAt || !review.rationale.trim()) { + throw new Error("Invalid human rendered-visual review."); + } + const renderRecord = bundle.artifacts.visualRenders?.find( + (record) => record.contractId === review.contractId + ); + if (!renderRecord) throw new Error("Human review does not reference a registered visual render."); + const qa = JSON.parse(await readFile(renderRecord.qaPath, "utf8")) as MechanicalVisualQa; + if (!qa.passed) throw new Error("A render that failed mechanical QA cannot receive final human acceptance."); + const checks = Object.values(review.checks); + const recovery = [ + review.readerTest.recoveredQuestion, + review.readerTest.recoveredDenominator, + review.readerTest.recoveredKeyValueOrRelation, + review.readerTest.recoveredConclusion + ]; + if (checks.length !== 4 || [...checks, ...recovery].some((value) => typeof value !== "boolean")) { + throw new Error("Final visual review requires four verification checks and four reader-recovery results."); + } + const contractRecord = bundle.artifacts.visualContracts?.find( + (record) => record.id === review.contractId && record.status === "approved" + ); + if (!contractRecord) throw new Error("Approved visual contract is missing."); + const contract = JSON.parse(await readFile(contractRecord.path, "utf8")) as VisualEvidenceContract; + if (review.readerTest.seconds > contract.readerTest.seconds || review.readerTest.seconds <= 0) { + throw new Error("Reader test exceeded the approved recovery-time threshold."); + } + if (review.decision === "accept" && + ([...checks, ...recovery].some((value) => value !== true))) { + throw new Error("Final visual acceptance requires every human and reader check to pass."); + } + if (review.decision !== "accept" && review.decision !== "reject") { + throw new Error("Rendered visual decision must be accept or reject."); + } + const outputPath = join( + bundle.runDir, + "artifacts", + "visuals", + review.contractId, + "human-review.json" + ); + await writeJsonAtomic(outputPath, review); + bundle.artifacts.finalVisualReviews = [ + ...(bundle.artifacts.finalVisualReviews ?? []).filter( + (entry) => entry.contractId !== review.contractId + ), + { + contractId: review.contractId, + decision: review.decision, + path: outputPath, + reviewedAt: review.reviewedAt + } + ]; + await appendEvent(bundle, { + type: "human_render_review_recorded", + stage: "verify", + outputRef: outputPath, + reason: `Final rendered visual decision recorded: ${review.decision}.` + }); + if (review.decision === "accept") { + const expectedContractIds = bundle.artifacts.visualPortfolio?.contractIds ?? [review.contractId]; + const acceptedIds = new Set( + (bundle.artifacts.finalVisualReviews ?? []) + .filter((entry) => entry.decision === "accept") + .map((entry) => entry.contractId) + ); + const remaining = expectedContractIds.filter((id) => !acceptedIds.has(id)); + if (remaining.length > 0) { + bundle.status = "paused"; + bundle.pauseReason = `Final rendered-visual reviews remain for: ${remaining.join(", ")}.`; + bundle.stages.verify = { + ...bundle.stages.verify, + status: "awaiting_human", + blockedReason: undefined + }; + await persistBundle(bundle); + return { bundle, review, path: outputPath }; + } + const inputHash = bundle.stages.verify.inputHash; + if (!inputHash) throw new Error("Verify stage has no input hash."); + if (!bundle.artifacts.targetJournalProfile) { + bundle.status = "waiting_for_checkpoint"; + bundle.pendingCheckpoint = { + class: "evidence_direction_or_claim_strength", + stage: "venue", + reason: "A human-accepted target-journal topic and format profile is required before package completion." + }; + bundle.pauseReason = bundle.pendingCheckpoint.reason; + await persistBundle(bundle); + return { bundle, review, path: outputPath }; + } + bundle.status = "running"; + bundle.pauseReason = undefined; + await completeStage(bundle, "verify", inputHash, outputPath); + await finalizeVerifiedResearchPackage(bundle); + } else { + bundle.status = "waiting_for_checkpoint"; + bundle.pendingCheckpoint = { + class: "visual_evidence_contract", + stage: "verify", + reason: "The rendered visual was rejected and requires a revised contract or render request." + }; + bundle.stages.verify = { + ...bundle.stages.verify, + status: "blocked", + checkpointClass: "visual_evidence_contract", + blockedReason: bundle.pendingCheckpoint.reason + }; + await persistBundle(bundle); + } + return { bundle, review, path: outputPath }; +} diff --git a/packages/longtable-research/tsconfig.json b/packages/longtable-research/tsconfig.json new file mode 100644 index 00000000..4d104d4b --- /dev/null +++ b/packages/longtable-research/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/longtable-scholar-research/README.md b/packages/longtable-scholar-research/README.md index 367a6cd9..24e531a9 100644 --- a/packages/longtable-scholar-research/README.md +++ b/packages/longtable-scholar-research/README.md @@ -1,18 +1,13 @@ # @longtable/scholar-research -LongTable scholarly evidence recovery and citation-slot research support. +Deprecated compatibility package for `@longtable/research`. -This package exposes the legal scholarly search adapters, connector readiness, -smoke fixtures, and `.longtable/research-runs//` scaffold used by the -`scholar-research` skill. +Use the canonical package and CLI surface for new work: -## Journal and Venue Evidence +```bash +npm install @longtable/research@next +longtable research run --query "" +``` -`scholar-research` can support editor and venue-fit work by scaffolding -evidence ledgers and search runs for a target journal profile. Journal fit -should be grounded in source-backed aims/scope, author guidance, recent article -patterns, and article type expectations. Metadata-only or unsourced venue -claims should stay provisional. - -This package does not bypass paywalls, authentication, robots.txt, WAFs, or -access controls. +This alias is retained for the 0.2 beta migration window. Existing +`.longtable/research-runs/` records and versioned schemas are not renamed. diff --git a/packages/longtable-scholar-research/package.json b/packages/longtable-scholar-research/package.json index 0bd1182e..2a31fdd7 100644 --- a/packages/longtable-scholar-research/package.json +++ b/packages/longtable-scholar-research/package.json @@ -1,8 +1,8 @@ { "name": "@longtable/scholar-research", - "version": "0.1.72", + "version": "0.2.0-beta.1", "private": false, - "description": "LongTable scholarly evidence recovery, citation-slot research, and legal full-text readiness", + "description": "Deprecated compatibility package for @longtable/research", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -20,16 +20,16 @@ "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" }, + "dependencies": { + "@longtable/research": "0.2.0-beta.1" + }, "devDependencies": { - "@types/node": "^22.10.1", "typescript": "^5.6.0" }, "keywords": [ "longtable", "research", - "scholarly", - "evidence", - "citation" + "compatibility" ], "author": "Hosung You", "license": "MIT", diff --git a/packages/longtable-scholar-research/src/index.ts b/packages/longtable-scholar-research/src/index.ts index 2d0deede..58f846d4 100644 --- a/packages/longtable-scholar-research/src/index.ts +++ b/packages/longtable-scholar-research/src/index.ts @@ -1,7 +1,5 @@ -export * from "./types.js"; -export * from "./query.js"; -export * from "./sources.js"; -export * from "./rank.js"; -export * from "./run.js"; -export * from "./publisher-access.js"; -export * from "./protocol.js"; +/** + * @deprecated Install and import `@longtable/research`. + * This compatibility package is retained for the 0.2 beta migration window. + */ +export * from "@longtable/research"; diff --git a/packages/longtable-scholar-research/src/run.ts b/packages/longtable-scholar-research/src/run.ts deleted file mode 100644 index 17beefae..00000000 --- a/packages/longtable-scholar-research/src/run.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { buildResearchSearchIntent } from "./query.js"; -import { dedupeAndRankCards } from "./rank.js"; -import { enrichCardsWithPublisherAccess } from "./publisher-access.js"; -import { - assessSearchSourceCapabilities, - runSourceSearch -} from "./sources.js"; -import type { - EvidenceRun, - EvidenceRunStatus, - RunResearchSearchInput, - SearchFetch, - SourceReport -} from "./types.js"; - -function runId(): string { - return `evidence_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; -} - -function now(): string { - return new Date().toISOString(); -} - -function defaultFetch(): SearchFetch { - if (typeof fetch !== "function") { - throw new Error("LongTable search requires a fetch-capable Node runtime."); - } - return fetch; -} - -export async function runResearchSearch(input: RunResearchSearchInput): Promise { - const createdAt = now(); - const id = runId(); - const intent = buildResearchSearchIntent(input); - const env = input.env ?? process.env; - const capabilities = assessSearchSourceCapabilities(intent.requestedSources, env); - const skippedSources = capabilities.filter((capability) => !capability.enabled); - - if (skippedSources.length > 0 && input.allowPartial !== true) { - const updatedAt = now(); - return { - id, - createdAt, - updatedAt, - status: "blocked", - intent, - sourceReports: skippedSources.map((capability): SourceReport => ({ - source: capability.source, - status: "skipped", - count: 0, - elapsedMs: 0, - reason: capability.reason - })), - cards: [], - skippedSources, - warnings: skippedSources.map((capability) => capability.reason ?? `${capability.source} unavailable.`), - blockedReason: "One or more requested scholarly sources are unavailable. Confirm partial search or configure credentials." - }; - } - - const httpFetch = input.fetch ?? defaultFetch(); - const sourceReports: SourceReport[] = []; - const cards = []; - - for (const capability of capabilities) { - if (!capability.enabled) { - sourceReports.push({ - source: capability.source, - status: "skipped", - count: 0, - elapsedMs: 0, - reason: capability.reason - }); - continue; - } - - const started = Date.now(); - try { - const result = await runSourceSearch({ - intent, - source: capability.source, - limit: intent.limit - }, { - fetch: httpFetch, - env - }); - cards.push(...result.cards); - sourceReports.push({ - source: capability.source, - status: "completed", - count: result.cards.length, - elapsedMs: Date.now() - started, - endpoint: result.endpoint - }); - } catch (error) { - sourceReports.push({ - source: capability.source, - status: "failed", - count: 0, - elapsedMs: Date.now() - started, - reason: error instanceof Error ? error.message : String(error) - }); - } - } - - const rankedCards = dedupeAndRankCards(cards, intent); - const finalCards = input.publisherAccess === true - ? await enrichCardsWithPublisherAccess({ - cards: rankedCards, - env, - fetch: httpFetch - }) - : rankedCards; - const hasFailure = sourceReports.some((report) => report.status === "failed" || report.status === "skipped"); - const status: EvidenceRunStatus = hasFailure ? "partial" : "completed"; - - return { - id, - createdAt, - updatedAt: now(), - status, - intent, - sourceReports, - cards: finalCards, - skippedSources, - warnings: [ - ...skippedSources.map((capability) => capability.reason ?? `${capability.source} unavailable.`), - ...sourceReports - .filter((report) => report.status === "failed") - .map((report) => `${report.source} failed: ${report.reason ?? "unknown error"}`) - ] - }; -} diff --git a/packages/longtable-setup/README.md b/packages/longtable-setup/README.md index 4ebbe654..8248bc5f 100644 --- a/packages/longtable-setup/README.md +++ b/packages/longtable-setup/README.md @@ -32,11 +32,13 @@ Global setup should answer: - where LongTable may install runtime support - which provider surfaces are approved - how strongly LongTable may interrupt research decisions -- whether setup should show the provider-native `$longtable-start` launch steps +- whether setup should show the provider-native `$longtable` interview launch steps -Project and session intake belongs to `$longtable-start` inside Codex or -Claude Code, not library-level setup helpers. `longtable start` is a fallback -for scripts and automation, not the primary research-start experience. +Project and session intake belongs to the `$longtable` router inside Codex or +Claude Code, not library-level setup helpers. The former `$longtable-start` +flow remains available only on the explicit full compatibility surface. +`longtable start` is a fallback for scripts and automation, not the primary +research-start experience. ## Included Outputs diff --git a/packages/longtable-setup/package.json b/packages/longtable-setup/package.json index c1d1007f..a24a956a 100644 --- a/packages/longtable-setup/package.json +++ b/packages/longtable-setup/package.json @@ -1,6 +1,6 @@ { "name": "@longtable/setup", - "version": "0.1.72", + "version": "0.2.0-beta.1", "private": false, "description": "Researcher onboarding and setup flows for LongTable", "type": "module", @@ -23,8 +23,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@longtable/core": "0.1.72", - "@longtable/memory": "0.1.72" + "@longtable/core": "0.2.0-beta.1", + "@longtable/memory": "0.2.0-beta.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/packages/longtable/README.md b/packages/longtable/README.md index 0cb1c95d..0645ff25 100644 --- a/packages/longtable/README.md +++ b/packages/longtable/README.md @@ -1,154 +1,45 @@ # @longtable/cli -Researcher-facing CLI for LongTable. - -LongTable keeps scholarly project state in `.longtable/` and exposes generated -provider skills for Codex and Claude Code. The CLI installs setup, state, -checkpoint, search, panel, and diagnostic tooling. It does not replace the -provider. +The researcher-facing LongTable CLI for Codex and Claude. ## Install -```bash -npm install -g @longtable/cli -``` - -## Primary Flow - -```bash -longtable setup --provider codex -cd "" -codex -``` - -Then invoke: - -```text -$longtable-start -``` - -`$longtable-start` creates or resumes the workspace, asks open research-start -questions, and stores a Research Specification when there is enough material. - -Use the LongTable grilling surface for pressure interviews: - -```text -$longtable-interview -``` - -`$longtable-interview` asks one relentless sharpening question at a time. It -frames each turn as `Tension:` followed by one `Pressure question:` and waits -for a direct answer. It can use Research Specification state when present, but -it does not require one before asking the next pressure question. - -It continues only while the next question can produce a new decision, sharper -boundary, stronger evidence standard, or clearer open tension, and stops when -remaining questions repeat the same tension without producing a new decision. -`$longtable-interview` is the only packaged pressure-interview skill; legacy -`$critical-interview` skill folders are removed during skill install. - -## Workspace Artifacts - -```text -/ - AGENTS.md - CURRENT.md - .longtable/ - project.json - current-session.json - state.json - sessions/ -``` - -- `CURRENT.md`: human-readable current project state -- `.longtable/state.json`: durable memory, questions, decisions, interview - turns, evidence records, First Research Shape, and Research Specification -- `QuestionRecord -> DecisionRecord`: the durable checkpoint lifecycle - -Provider UI is transport. LongTable supports MCP/native structured elicitation, -interactive TTY selector surfaces, and numbered/plain-text fallback. Tmux is not -required for LongTable core behavior. - -## Commands - -```bash -longtable setup --provider codex -longtable doctor -longtable status --cwd "" -longtable resume --cwd "" -longtable roles -longtable question --prompt "" -longtable decide --question --answer -longtable spec read --cwd "" -longtable search --query "" -longtable panel --prompt "review this measurement plan" --json +```sh +npm install -g @longtable/cli@next +longtable setup ``` -`longtable start` remains available for scripted workspace creation with -`--no-interview --json`, but it is not the primary research-start surface. +Setup installs exactly two skills: -## Panel Orchestration +- `$longtable` — bounded research diagnosis, Research Brief, workspace state, and Research Assurance routing +- `$longtable-research` — journal discovery, lawful evidence acquisition, extraction, synthesis, editable visuals, and Verified Research Package -Panel orchestration is for moments where disagreement matters: methods risk, -measurement validity, theory fit, literature positioning, and claims that need -challenge before they become project memory. +Deprecated `--surface full` is accepted for compatibility but still installs only these two skills and prunes obsolete LongTable start, interview, panel, and role folders. -The CLI creates a provider-neutral `PanelPlan` and returns a planned -`PanelResult`. When native subagents are unavailable, LongTable uses a stable -sequential fallback prompt. That keeps the same research semantics available in -Codex and Claude Code without making either provider's native question or agent -tool the source of truth. +## Canonical commands -```bash -longtable panel --prompt "Review this measurement plan." --role editor,measurement_auditor --json -longtable panel --visibility always_visible --prompt "Keep unresolved disagreement visible." --json -longtable ask --prompt "lt debate: Review this design before I commit it." --json +```sh +longtable status --cwd . +longtable assure --prompt "Review the unresolved evidence risk." --json +longtable research run --brief research-brief.json --cwd . +longtable research resume --pdf-dir ./lawful-pdfs --pdf-access manual_legitimate_access --cwd . +longtable research record-journal-profile --profile target-journal-profile.json --cwd . +longtable research verify --cwd . --json ``` -Team-style requests route through panel. Explicit debate-language requests write -panel debate records under `.longtable/panel/`; LongTable team execution is -disabled for new work. +`longtable panel` and `longtable scholar-research` remain one-release aliases. Historical `PanelPlan`, `PanelResult`, and `.longtable/research-runs/` records are not rewritten. -## Journal-Grounded Reviewer +## Completion boundary -`longtable-reviewer` and `--role reviewer` are the compact surface for -peer-review, editor-style, and venue-fit feedback. The reviewer role combines -Journal Editor and Venue Strategist lenses with scholar-research evidence -instead of requiring a separate visible editor skill. +`completed` requires a read-back Verified Research Package with the Research Brief, human-accepted target-journal profile, lawful access plan, corpus and evidence manifests, synthesis, human-reviewed editable visuals, Research Assurance records, limitations, reproduction command, hashes, and passing verification report. -If a target journal is named, journal-fit claims require a Journal Profile: -aims/scope, author guidance, recent article patterns, and article type -expectations. Reference papers should be compared through a Reference Pattern -Matrix covering decision structure, paper flow, standardized terminology, -Figure/Table conventions, and APA 7 style expectations. +## Safety -```bash -longtable review --role reviewer --prompt "Evaluate this manuscript positioning." -longtable panel --role reviewer,measurement_auditor --prompt "Compare journal fit and reviewer risk." -longtable search --intent venue --query "" -``` - -Treat the result as a fit-risk rubric, not an acceptance prediction. +LongTable supports public OA, lawful user-supplied PDFs, licensed TDM, and manual institutional-browser handoff. It does not automate institutional login or store credentials, cookies, or tokens, and it does not bypass paywalls, WAFs, robots controls, VPN restrictions, or proxies. ## Development -```bash +```sh npm run build --workspace @longtable/cli npm run typecheck --workspace @longtable/cli ``` - -## Codex hard-stop diagnostics - -Codex `Stop` blocks only active LongTable hard-stop blockers: unresolved -Research Specification question, scope, construct, method, evidence, or protected -decision commitments. Use: - -```bash -longtable codex hook-doctor --json -longtable codex status --json -longtable doctor --json -``` - -to inspect hook coverage/trust plus `stopWouldBlock`, `activeBlockers`, stale -pending-question counts, and next actions. Tmux remains an optional terminal -transport; LongTable state and hooks own the behavior. diff --git a/packages/longtable/package.json b/packages/longtable/package.json index e01d9c40..b590cbcb 100644 --- a/packages/longtable/package.json +++ b/packages/longtable/package.json @@ -1,6 +1,6 @@ { "name": "@longtable/cli", - "version": "0.1.72", + "version": "0.2.0-beta.1", "private": false, "description": "Researcher-facing LongTable CLI", "type": "module", @@ -29,13 +29,13 @@ }, "dependencies": { "@clack/prompts": "^1.2.0", - "@longtable/checkpoints": "0.1.72", - "@longtable/core": "0.1.72", - "@longtable/memory": "0.1.72", - "@longtable/provider-claude": "0.1.72", - "@longtable/provider-codex": "0.1.72", - "@longtable/scholar-research": "0.1.72", - "@longtable/setup": "0.1.72" + "@longtable/checkpoints": "0.2.0-beta.1", + "@longtable/core": "0.2.0-beta.1", + "@longtable/memory": "0.2.0-beta.1", + "@longtable/provider-claude": "0.2.0-beta.1", + "@longtable/provider-codex": "0.2.0-beta.1", + "@longtable/research": "0.2.0-beta.1", + "@longtable/setup": "0.2.0-beta.1" }, "devDependencies": { "@types/node": "^22.10.1", diff --git a/packages/longtable/src/cli.ts b/packages/longtable/src/cli.ts index 02bab300..bc8f9e47 100644 --- a/packages/longtable/src/cli.ts +++ b/packages/longtable/src/cli.ts @@ -33,16 +33,46 @@ import { buildResearchSearchIntent, buildScholarResearchSmokeFixture, parsePublisherTarget, + parseFullTextAccessClass, probePublisherAccess, publisherConfigs, runResearchSearch, + runScholarResearchWorkflow, + readResearchBundle, + findLatestScholarResearchRunId, + explainScholarResearchStage, + verifyScholarResearchRun, + recordScholarResearchProviderPatch, + recordScholarResearchVerification, + recordScholarResearchHumanCitationReview, + recordScholarResearchHumanSynthesisReview, + recordScholarResearchHumanVisualReview, + registerScholarResearchVisualPortfolio, + recordScholarResearchHumanRenderedVisualReview, + recordScholarResearchVisualRender, + runPaperBReplay, + recordScholarResearchSynthesisVerification, + recordTargetJournalProfile, + readResearchBrief, + resumeScholarResearchWorkflow, SEARCH_SOURCES, + validateVisualEvidenceContract, writeScholarResearchRunScaffold, + writeScholarResearchEvaluationPlan, + recordScholarResearchEvaluationObservation, + writeScholarResearchEvaluationReport, + createProspectiveTrialProtocol, + startProspectiveTrialSession, + finishProspectiveTrialSession, + readProspectiveTrialStatus, summarizeConfiguredPublisherAccess, type EvidenceRun, type PublisherAccessRecord, type SearchSource, - type SearchSourceCapability + type SearchSourceCapability, + type VisualEvidenceContract, + type TargetJournalProfile, + type ScholarResearchStage } from "./search/index.js"; import { buildProviderChoices, @@ -377,7 +407,7 @@ function renderInterviewLaunchSteps(provider: ProviderKind): string { "Next:", "1. cd \"\"", `2. run \`${command}\``, - "3. invoke `$longtable-start`", + "3. invoke `$longtable` and ask to start or resume the research interview", "", "The start interview will create or resume `.longtable/`, may store a short First Research Shape handle, and uses option UI for the final Research Specification confirmation." ]); @@ -393,7 +423,7 @@ function usage(): string { return [ "Usage:", " Run `longtable ...` in your terminal, not inside the Codex chat box.", - " LongTable research starts inside Codex or Claude with `$longtable-start` after setup.", + " LongTable research starts inside Codex or Claude through the `$longtable` router after setup.", "", " longtable setup [--provider codex|claude] [--install-scope user|project|none] [--surfaces cli_only|skills|skills_mcp|skills_mcp_sentinel] [--intervention advisory|balanced|strong] [--checkpoint-ui off|interactive|strong] [--workspace create|later] [--project-dir ] [--json] [--dir ] [--skills-dir ] [--runtime-path ] [--setup-path ]", " longtable init [deprecated alias for setup; full legacy flags still supported for automation]", @@ -412,20 +442,44 @@ function usage(): string { " longtable access doctor [--doi ] [--publisher auto|elsevier|springer_nature|wiley|taylor_francis|all] [--json]", " longtable access probe --doi [--publisher auto|elsevier|springer_nature|wiley|taylor_francis] [--json]", " longtable search --query [--intent literature|theory|measurement|citation|metadata|venue] [--field ] [--source all|crossref,arxiv,openalex,semantic_scholar,pubmed,eric,doaj] [--must ] [--exclude ] [--limit ] [--allow-partial] [--publisher-access] [--record] [--cwd ] [--json]", - " longtable scholar-research doctor [--json]", - " longtable scholar-research scaffold [--cwd ] [--run-id ] [--json]", - " longtable scholar-research smoke-fixture [--json]", + " longtable research run (--query | --brief ) [--target-journal ] [--field ] [--source ] [--must ] [--exclude ] [--limit ] [--allow-partial] [--publisher-access] [--no-auto-oa] [--pdf-dir ] [--pdf-access public_oa|manual_legitimate_access|licensed_tdm|private] [--cwd ] [--run-id ] [--json]", + " longtable research resume [--cwd ] [--run-id ] [--pdf-dir ] [--pdf-access public_oa|manual_legitimate_access|licensed_tdm|private] [--json]", + " longtable research status [--cwd ] [--run-id ] [--json]", + " longtable research explain --stage [--cwd ] [--run-id ] [--json]", + " longtable research verify [--cwd ] [--run-id ] [--json]", + " longtable research eval-scaffold [--cwd ] [--plan-id ] [--replay-thread ] [--json]", + " longtable research eval-record --plan-id --observation [--cwd ] [--json]", + " longtable research eval-report --plan-id [--cwd ] [--json]", + " longtable research trial-create --config [--cwd ] [--json]", + " longtable research trial-start --plan-id --trial-id --condition-index <0|1> [--cwd ] [--json]", + " longtable research trial-finish --plan-id --trial-id --condition-index <0|1> --result [--cwd ] [--json]", + " longtable research trial-status --plan-id --trial-id [--cwd ] [--json]", + " longtable research record-patch --packet --patch --provider [--cwd ] [--run-id ] [--json]", + " longtable research record-verification --verification [--cwd ] [--run-id ] [--json]", + " longtable research record-human-review --review [--cwd ] [--run-id ] [--json]", + " longtable research record-synthesis-verification --verification [--cwd ] [--run-id ] [--json]", + " longtable research record-synthesis-review --review [--cwd ] [--run-id ] [--json]", + " longtable research record-journal-profile --profile [--cwd ] [--run-id ] [--json]", + " longtable research register-visual-portfolio --plan [--cwd ] [--run-id ] [--json]", + " longtable research record-visual-review --contract --review [--cwd ] [--run-id ] [--json]", + " longtable research render-visual --contract-id --request [--cwd ] [--run-id ] [--json]", + " longtable research record-render-review --review [--cwd ] [--run-id ] [--json]", + " longtable research replay-paper-b --source-root --output-dir [--exemplar-manifest ] [--human-review ] [--json]", + " longtable research validate-visual-contract --contract [--json]", + " longtable research doctor [--json]", + " longtable research scaffold [--cwd ] [--run-id ] [--json]", + " longtable research smoke-fixture [--json]", " longtable sentinel --prompt [--cwd ] [--json] [--record]", " longtable ask [--prompt ] [--print] [--json] [--setup ] [--cwd ]", " longtable clarify --prompt [--provider codex|claude] [--required|--advisory] [--print] [--cwd ] [--json] [--force]", " longtable question (--prompt | --question ) [--title ] [--text ] [--provider codex|claude] [--required|--advisory] [--surface tmux_popup|terminal_selector|numbered] [--print] [--cwd ] [--json]", " longtable clear-question --question --reason [--cwd ] [--json]", - " longtable panel [--prompt ] [--role ] [--mode review|critique|draft|commit] [--visibility synthesis_only|show_on_conflict|always_visible] [--provider codex|claude] [--native-workers|--native-subagents] [--wait [ms]] [--print] [--json] [--setup ] [--cwd ]", - " longtable panel status --run [--wait [ms]] [--cwd ] [--json]", - " longtable panel stop --run [--cwd ] [--json]", - " longtable panel shutdown --run [--cwd ] [--json]", - " longtable panel resume --run [--wait [ms]] [--cwd ] [--json]", - " longtable panel record [--invocation ] --result-file [--surface sequential_fallback|native_subagents|native_workers] [--cwd ] [--json]", + " longtable assure [--prompt ] [--role ] [--mode review|critique|draft|commit] [--visibility synthesis_only|show_on_conflict|always_visible] [--provider codex|claude] [--native-workers|--native-subagents] [--wait [ms]] [--print] [--json] [--setup ] [--cwd ]", + " longtable assure status --run [--wait [ms]] [--cwd ] [--json]", + " longtable assure stop --run [--cwd ] [--json]", + " longtable assure shutdown --run [--cwd ] [--json]", + " longtable assure resume --run [--wait [ms]] [--cwd ] [--json]", + " longtable assure record [--invocation ] --result-file [--surface sequential_fallback|native_subagents|native_workers] [--cwd ] [--json]", " longtable handoff [--cwd ] [--output ] [--print] [--json]", " longtable decide [--question ] --answer [--rationale ] [--provider codex|claude] [--surface tmux_popup|mcp_elicitation|terminal_selector|numbered] [--cwd ] [--json]", " longtable explore|review|critique|draft|commit|submit [--prompt ] [--role ] [--panel] [--show-conflicts] [--show-deliberation] [--print] [--json] [--stage ] [--setup ] [--cwd ]", @@ -447,7 +501,7 @@ function usage(): string { "Examples:", " longtable setup --provider codex", " cd \"\" && codex", - " $longtable-start", + " $longtable", " longtable start --no-interview --path ~/Research/My-Project --name \"AI Adoption Meta-Analysis\" --goal \"Narrow the review question\"", " longtable doctor", " longtable roles", @@ -465,7 +519,7 @@ function parseArgs(argv: string[]): ParsedArgs { const modeCommand = command && VALID_MODES.has(command as InteractionMode); const directCommand = - command && ["init", "setup", "start", "resume", "doctor", "status", "audit", "roles", "show", "install", "mcp", "codex", "claude", "ask", "clarify", "question", "clear-question", "prune-questions", "panel", "handoff", "decide", "sentinel", "access", "search", "scholar-research", "spec"].includes(command); + command && ["init", "setup", "start", "resume", "doctor", "status", "audit", "roles", "show", "install", "mcp", "codex", "claude", "ask", "clarify", "question", "clear-question", "prune-questions", "assure", "panel", "handoff", "decide", "sentinel", "access", "search", "research", "scholar-research", "spec"].includes(command); let startIndex = 1; if (modeCommand) { @@ -473,7 +527,7 @@ function parseArgs(argv: string[]): ParsedArgs { startIndex = 1; } else if (command === "codex" || command === "claude" || command === "mcp") { startIndex = 2; - } else if ((command === "access" || command === "search" || command === "scholar-research" || command === "spec" || command === "panel") && maybeSubcommand && !maybeSubcommand.startsWith("--")) { + } else if ((command === "access" || command === "search" || command === "research" || command === "scholar-research" || command === "spec" || command === "assure" || command === "panel") && maybeSubcommand && !maybeSubcommand.startsWith("--")) { subcommand = maybeSubcommand; startIndex = 2; } else if (command === "audit" && maybeSubcommand && !maybeSubcommand.startsWith("--")) { @@ -886,12 +940,12 @@ function buildPermissionSetupChoices(): { { id: "create", label: "Show interview launch steps", - description: "Why: research should start inside the provider. What you get: setup finishes with Codex/Claude + $longtable-start steps. Tradeoff: workspace creation waits for the in-provider interview." + description: "Why: research should start inside the provider. What you get: setup finishes with Codex/Claude + $longtable router steps. Tradeoff: workspace creation waits for the in-provider interview." }, { id: "later", label: "No, prepare runtime only", - description: "Why: keeps setup short. What you get: runtime support without project state. Tradeoff: no durable research memory until `$longtable-start` creates or resumes a workspace." + description: "Why: keeps setup short. What you get: runtime support without project state. Tradeoff: no durable research memory until `$longtable` creates or resumes a workspace." } ] }; @@ -1068,7 +1122,7 @@ async function runSetup(args: Record): Promise { interventionPosture: effectiveIntervention, checkpointUiMode: checkpointUi, workspaceCreationPreference: workspacePreference, - officialStartSurface: "$longtable-start", + officialStartSurface: "$longtable", setupPosture: "permission_first", teamMode: "panel" }; @@ -1123,9 +1177,9 @@ async function runSetup(args: Record): Promise { mcpInstall, workspacePreference, nextStep: { - surface: "$longtable-start", + surface: "$longtable", command: provider === "codex" ? "codex" : "claude", - description: "Open the provider in the research folder and invoke `$longtable-start`." + description: "Open the provider in the research folder and invoke `$longtable` to start or resume the research interview." } }, null, 2)); return; @@ -1159,7 +1213,7 @@ async function runSetup(args: Record): Promise { console.log(renderInterviewLaunchSteps(provider)); if (workspacePreference === "create") { console.log(""); - console.log("Workspace launch requested. Open the provider in your research folder and run `$longtable-start`; the interview will create `.longtable/` there."); + console.log("Workspace launch requested. Open the provider in your research folder and invoke `$longtable` to start the interview; it will create `.longtable/` there."); } } @@ -2768,6 +2822,7 @@ function runRoleAudit(): RoleAuditResult { "longtable-panel", "longtable-explore", "longtable-review", + "longtable-research", "scholar-research" ]); const roles: RoleAuditEntry[] = [ @@ -3272,10 +3327,10 @@ function panelWorkerNextCommands(context: LongTableProjectContext, runId: string } { const cwdFlag = `--cwd "${context.project.projectPath}"`; return { - status: `longtable panel status ${cwdFlag} --run ${runId}`, - stop: `longtable panel stop ${cwdFlag} --run ${runId}`, - shutdown: `longtable panel shutdown ${cwdFlag} --run ${runId}`, - resume: `longtable panel resume ${cwdFlag} --run ${runId}` + status: `longtable assure status ${cwdFlag} --run ${runId}`, + stop: `longtable assure stop ${cwdFlag} --run ${runId}`, + shutdown: `longtable assure shutdown ${cwdFlag} --run ${runId}`, + resume: `longtable assure resume ${cwdFlag} --run ${runId}` }; } @@ -3814,6 +3869,17 @@ function parseLimit(value: string | boolean | undefined): number | undefined { return parsed; } +function parseConditionIndex(value: string | boolean | undefined): number { + if (typeof value !== "string") { + throw new Error("condition-index must be 0 or 1."); + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || (parsed !== 0 && parsed !== 1)) { + throw new Error("condition-index must be 0 or 1."); + } + return parsed; +} + async function confirmPartialSearch(skippedSources: SearchSourceCapability[]): Promise { if (!input.isTTY || !output.isTTY) { return false; @@ -4372,13 +4438,520 @@ async function runScholarResearch( args: Record ): Promise { const json = args.json === true; + if (subcommand === "run") { + const query = typeof args.query === "string" ? args.query : ""; + const researchBrief = typeof args.brief === "string" + ? await readResearchBrief(resolve(args.brief)) + : undefined; + const bundle = await runScholarResearchWorkflow({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + query, + ...(researchBrief ? { researchBrief } : {}), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + ...(typeof args["target-journal"] === "string" ? { targetJournal: args["target-journal"] } : {}), + ...(typeof args.field === "string" ? { field: args.field } : {}), + ...(typeof args.must === "string" ? { must: args.must } : {}), + ...(typeof args.exclude === "string" ? { exclude: args.exclude } : {}), + ...(typeof args.source === "string" ? { sources: args.source } : {}), + ...(parseLimit(args.limit) ? { limit: parseLimit(args.limit) } : {}), + ...(typeof args["pdf-dir"] === "string" ? { pdfDirectory: args["pdf-dir"] } : {}), + ...(parseFullTextAccessClass(args["pdf-access"]) ? { pdfAccessClass: parseFullTextAccessClass(args["pdf-access"]) } : {}), + allowPartial: args["allow-partial"] === true, + publisherAccess: args["publisher-access"] === true, + autoAcquireOpenAccess: args["no-auto-oa"] !== true, + env + }); + if (json) { + console.log(JSON.stringify(bundle, null, 2)); + return; + } + console.log("LongTable Research run"); + console.log(`- run: ${bundle.runId}`); + console.log(`- status: ${bundle.status}`); + console.log(`- next: ${bundle.pendingCheckpoint?.stage ?? Object.values(bundle.stages).find((stage) => stage.status !== "completed")?.stage ?? "complete"}`); + console.log(`- bundle: ${join(bundle.runDir, "research-bundle.json")}`); + return; + } + + if (subcommand === "resume") { + const bundle = await resumeScholarResearchWorkflow({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + ...(typeof args["pdf-dir"] === "string" ? { pdfDirectory: args["pdf-dir"] } : {}), + ...(parseFullTextAccessClass(args["pdf-access"]) ? { pdfAccessClass: parseFullTextAccessClass(args["pdf-access"]) } : {}), + env + }); + if (json) { + console.log(JSON.stringify(bundle, null, 2)); + return; + } + console.log("LongTable Research resume"); + console.log(`- run: ${bundle.runId}`); + console.log(`- status: ${bundle.status}`); + console.log(`- next: ${bundle.pendingCheckpoint?.stage ?? Object.values(bundle.stages).find((stage) => stage.status !== "completed")?.stage ?? "complete"}`); + return; + } + + if (subcommand === "status") { + const projectCwd = typeof args.cwd === "string" ? args.cwd : cwd(); + const runId = typeof args["run-id"] === "string" + ? args["run-id"] + : await findLatestScholarResearchRunId(projectCwd); + if (!runId) throw new Error("No scholar-research run was found."); + const bundle = await readResearchBundle(projectCwd, runId); + if (json) { + console.log(JSON.stringify(bundle, null, 2)); + return; + } + console.log("LongTable Research status"); + console.log(`- run: ${bundle.runId}`); + console.log(`- status: ${bundle.status}`); + console.log(`- updated: ${bundle.updatedAt}`); + for (const stage of Object.values(bundle.stages)) { + console.log(`- ${stage.stage}: ${stage.status}`); + } + return; + } + + if (subcommand === "explain") { + if (typeof args.stage !== "string") { + throw new Error("explain requires --stage ."); + } + const explanation = await explainScholarResearchStage({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + stage: args.stage as ScholarResearchStage + }); + if (json) { + console.log(JSON.stringify(explanation, null, 2)); + return; + } + console.log("LongTable Research stage explanation"); + console.log(`- run: ${explanation.runId}`); + console.log(`- stage: ${explanation.stage}`); + console.log(`- status: ${explanation.state.status}`); + console.log(`- events: ${explanation.events.length}`); + if (explanation.pendingCheckpoint) { + console.log(`- checkpoint: ${explanation.pendingCheckpoint.reason}`); + } + if (explanation.pauseReason) console.log(`- pause: ${explanation.pauseReason}`); + return; + } + + if (subcommand === "verify") { + const verification = await verifyScholarResearchRun({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}) + }); + if (json) { + console.log(JSON.stringify(verification, null, 2)); + return; + } + console.log("LongTable Research run verification"); + console.log(`- run: ${verification.runId}`); + console.log(`- passed: ${verification.passed ? "yes" : "no"}`); + console.log(`- checks: ${verification.checks.length}`); + console.log(`- hard failures: ${verification.hardFailures.length}`); + for (const failure of verification.hardFailures) console.log(` - ${failure}`); + return; + } + + if (subcommand === "eval-scaffold") { + const result = await writeScholarResearchEvaluationPlan({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["plan-id"] === "string" ? { planId: args["plan-id"] } : {}), + ...(typeof args["replay-thread"] === "string" ? { replayThreadId: args["replay-thread"] } : {}) + }); + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log("LongTable Research evaluation scaffold"); + console.log(`- plan: ${result.plan.planId}`); + console.log(`- path: ${result.path}`); + return; + } + + if (subcommand === "eval-record") { + if (typeof args["plan-id"] !== "string" || typeof args.observation !== "string") { + throw new Error("eval-record requires --plan-id --observation ."); + } + const recorded = await recordScholarResearchEvaluationObservation({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + planId: args["plan-id"], + observationPath: args.observation + }); + if (json) { + console.log(JSON.stringify(recorded, null, 2)); + return; + } + console.log("LongTable Research evaluation observation recorded"); + console.log(`- observation: ${recorded.observation.observationId}`); + console.log(`- path: ${recorded.path}`); + return; + } + + if (subcommand === "eval-report") { + if (typeof args["plan-id"] !== "string") { + throw new Error("eval-report requires --plan-id ."); + } + const report = await writeScholarResearchEvaluationReport({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + planId: args["plan-id"] + }); + if (json) { + console.log(JSON.stringify(report, null, 2)); + return; + } + console.log("LongTable Research evaluation report"); + console.log(`- observations: ${report.report.observationCount}`); + console.log(`- prospective: ${report.report.prospectiveCount}`); + console.log(`- matched crossover pairs: ${report.report.matchedProspectivePairs}`); + console.log(`- promotion eligible: ${report.report.promotionEligible ? "yes" : "no"}`); + console.log(`- path: ${report.path}`); + return; + } + + if (subcommand === "trial-create") { + if (typeof args.config !== "string") { + throw new Error("trial-create requires --config ."); + } + const result = await createProspectiveTrialProtocol({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + configPath: args.config + }); + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log("LongTable Research prospective trial created"); + console.log(`- trial: ${result.protocol.trialId}`); + console.log(`- first condition: ${result.protocol.conditionOrder[0].condition}`); + console.log(`- path: ${result.path}`); + return; + } + + if (subcommand === "trial-start") { + if (typeof args["plan-id"] !== "string" || typeof args["trial-id"] !== "string") { + throw new Error("trial-start requires --plan-id --trial-id --condition-index <0|1>."); + } + const result = await startProspectiveTrialSession({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + planId: args["plan-id"], + trialId: args["trial-id"], + conditionIndex: parseConditionIndex(args["condition-index"]) + }); + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log("LongTable Research prospective trial session started"); + console.log(`- session: ${result.session.sessionId}`); + console.log(`- condition: ${result.session.condition}`); + console.log(`- task capsule: ${result.taskCapsulePath}`); + return; + } + + if (subcommand === "trial-finish") { + if (typeof args["plan-id"] !== "string" || + typeof args["trial-id"] !== "string" || + typeof args.result !== "string") { + throw new Error("trial-finish requires --plan-id --trial-id --condition-index <0|1> --result ."); + } + const result = await finishProspectiveTrialSession({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + planId: args["plan-id"], + trialId: args["trial-id"], + conditionIndex: parseConditionIndex(args["condition-index"]), + resultPath: args.result + }); + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log("LongTable Research prospective trial session completed"); + console.log(`- session: ${result.session.sessionId}`); + console.log(`- observation: ${result.observation.observationId}`); + console.log(`- ledger: ${result.observationsPath}`); + return; + } + + if (subcommand === "trial-status") { + if (typeof args["plan-id"] !== "string" || typeof args["trial-id"] !== "string") { + throw new Error("trial-status requires --plan-id --trial-id ."); + } + const result = await readProspectiveTrialStatus({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + planId: args["plan-id"], + trialId: args["trial-id"] + }); + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log("LongTable Research prospective trial status"); + console.log(`- trial: ${result.protocol.trialId}`); + console.log(`- sessions completed: ${result.sessions.filter((session) => session?.status === "completed").length}/2`); + console.log(`- next condition index: ${result.nextConditionIndex ?? "complete"}`); + return; + } + + if (subcommand === "record-patch") { + if (typeof args.packet !== "string" || typeof args.patch !== "string" || typeof args.provider !== "string") { + throw new Error("record-patch requires --packet --patch --provider ."); + } + const result = await recordScholarResearchProviderPatch({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + packetPath: args.packet, + patchPath: args.patch, + provider: args.provider + }); + if (json) { + console.log(JSON.stringify(result.record, null, 2)); + return; + } + console.log("LongTable Research provider patch recorded"); + console.log(`- packet: ${result.record.packetId}`); + console.log(`- provider: ${result.record.provider}`); + console.log(`- path: ${result.record.path}`); + return; + } + + if (subcommand === "record-verification") { + if (typeof args.verification !== "string") { + throw new Error("record-verification requires --verification ."); + } + const result = await recordScholarResearchVerification({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + verificationPath: args.verification + }); + if (json) { + console.log(JSON.stringify({ + verificationPath: result.verificationPath, + adjudicationPaths: result.adjudicationPaths + }, null, 2)); + return; + } + console.log("LongTable Research independent verification recorded"); + console.log(`- verification: ${result.verificationPath}`); + console.log(`- adjudications: ${result.adjudicationPaths.length}`); + return; + } + + if (subcommand === "record-human-review") { + if (typeof args.review !== "string") { + throw new Error("record-human-review requires --review ."); + } + const result = await recordScholarResearchHumanCitationReview({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + reviewPath: args.review + }); + if (json) { + console.log(JSON.stringify({ path: result.path, citationSlots: result.citationSlots }, null, 2)); + return; + } + console.log("LongTable Research human citation review recorded"); + console.log(`- citation slots: ${result.citationSlots.length}`); + console.log(`- path: ${result.path}`); + return; + } + + if (subcommand === "record-synthesis-verification") { + if (typeof args.verification !== "string") { + throw new Error("record-synthesis-verification requires --verification ."); + } + const result = await recordScholarResearchSynthesisVerification({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + verificationPath: args.verification + }); + if (json) { + console.log(JSON.stringify({ path: result.path }, null, 2)); + return; + } + console.log("LongTable Research synthesis verification recorded"); + console.log(`- path: ${result.path}`); + return; + } + + if (subcommand === "record-synthesis-review") { + if (typeof args.review !== "string") { + throw new Error("record-synthesis-review requires --review ."); + } + const result = await recordScholarResearchHumanSynthesisReview({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + reviewPath: args.review + }); + if (json) { + console.log(JSON.stringify({ path: result.path, claims: result.claims }, null, 2)); + return; + } + console.log("LongTable Research human synthesis review recorded"); + console.log(`- claims: ${result.claims.length}`); + console.log(`- path: ${result.path}`); + return; + } + + if (subcommand === "record-journal-profile") { + if (typeof args.profile !== "string") { + throw new Error("record-journal-profile requires --profile ."); + } + const profile = JSON.parse(await readFile(resolve(args.profile), "utf8")) as TargetJournalProfile; + const result = await recordTargetJournalProfile({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + profile + }); + if (json) { + console.log(JSON.stringify({ path: result.path, bundle: result.bundle }, null, 2)); + return; + } + console.log("LongTable Research target-journal profile recorded"); + console.log(`- journal: ${result.profile.targetJournal}`); + console.log(`- path: ${result.path}`); + return; + } + + if (subcommand === "register-visual-portfolio") { + if (typeof args.plan !== "string") { + throw new Error("register-visual-portfolio requires --plan ."); + } + const result = await registerScholarResearchVisualPortfolio({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + planPath: args.plan + }); + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log("LongTable Research visual portfolio registered"); + console.log(`- portfolio: ${result.plan.portfolioId}`); + console.log(`- contracts: ${result.plan.contractIds.length}`); + console.log(`- path: ${result.path}`); + return; + } + + if (subcommand === "validate-visual-contract") { + if (typeof args.contract !== "string") { + throw new Error("validate-visual-contract requires --contract ."); + } + const contractPath = resolve(args.contract); + const contract = JSON.parse(await readFile(contractPath, "utf8")) as VisualEvidenceContract; + const result = validateVisualEvidenceContract(contract); + if (json) { + console.log(JSON.stringify({ contractPath, ...result }, null, 2)); + return; + } + console.log("LongTable Research visual contract validation"); + console.log(`- contract: ${contractPath}`); + console.log(`- valid: ${result.valid ? "yes" : "no"}`); + for (const failure of result.hardFailures) console.log(`- hard failure: ${failure}`); + for (const warning of result.warnings) console.log(`- warning: ${warning}`); + if (!result.valid) process.exitCode = 1; + return; + } + + if (subcommand === "record-visual-review") { + if (typeof args.contract !== "string" || typeof args.review !== "string") { + throw new Error("record-visual-review requires --contract --review ."); + } + const result = await recordScholarResearchHumanVisualReview({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + contractPath: args.contract, + reviewPath: args.review + }); + if (json) { + console.log(JSON.stringify({ path: result.path, contract: result.contract }, null, 2)); + return; + } + console.log("LongTable Research human visual review recorded"); + console.log(`- decision: ${result.contract.status}`); + console.log(`- path: ${result.path}`); + return; + } + + if (subcommand === "render-visual") { + if (typeof args["contract-id"] !== "string" || typeof args.request !== "string") { + throw new Error("render-visual requires --contract-id --request ."); + } + const result = await recordScholarResearchVisualRender({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + contractId: args["contract-id"], + requestPath: args.request + }); + if (json) { + console.log(JSON.stringify({ + manifestPath: result.manifestPath, + qaPath: result.qaPath, + qa: result.qa + }, null, 2)); + return; + } + console.log("LongTable Research visual rendered"); + console.log(`- editable source: ${result.manifest.editableSourcePath}`); + console.log(`- mechanical QA: ${result.qa.passed ? "passed" : "failed"}`); + console.log(`- manifest: ${result.manifestPath}`); + return; + } + + if (subcommand === "record-render-review") { + if (typeof args.review !== "string") { + throw new Error("record-render-review requires --review ."); + } + const result = await recordScholarResearchHumanRenderedVisualReview({ + cwd: typeof args.cwd === "string" ? args.cwd : cwd(), + ...(typeof args["run-id"] === "string" ? { runId: args["run-id"] } : {}), + reviewPath: args.review + }); + if (json) { + console.log(JSON.stringify({ path: result.path, review: result.review }, null, 2)); + return; + } + console.log("LongTable Research rendered visual review recorded"); + console.log(`- decision: ${result.review.decision}`); + console.log(`- path: ${result.path}`); + return; + } + + if (subcommand === "replay-paper-b") { + if (typeof args["source-root"] !== "string" || typeof args["output-dir"] !== "string") { + throw new Error("replay-paper-b requires --source-root --output-dir ."); + } + const replay = await runPaperBReplay({ + sourceRoot: args["source-root"], + outputDirectory: args["output-dir"], + ...(typeof args["exemplar-manifest"] === "string" + ? { exemplarManifestPath: args["exemplar-manifest"] } + : {}), + ...(typeof args["human-review"] === "string" + ? { humanReviewPath: args["human-review"] } + : {}) + }); + if (json) { + console.log(JSON.stringify(replay, null, 2)); + return; + } + console.log("LongTable Research Paper B replay"); + console.log(`- quality gate: ${replay.result.qualityGatePassed ? "passed" : "failed"}`); + console.log(`- posture: ${replay.result.posture}`); + console.log(`- result: ${replay.resultPath}`); + return; + } + if (subcommand === "doctor" || subcommand === "status" || !subcommand) { const readiness = assessScholarResearchReadiness(env); if (json) { console.log(JSON.stringify(readiness, null, 2)); return; } - console.log("LongTable scholar-research doctor"); + console.log("LongTable Research doctor"); for (const connector of readiness.connectors) { const missing = connector.missingEnv.length > 0 ? ` (missing ${connector.missingEnv.join(", ")})` : ""; console.log(`- ${connector.name}: ${connector.status}${missing}`); @@ -4398,7 +4971,7 @@ async function runScholarResearch( console.log(JSON.stringify(scaffold, null, 2)); return; } - console.log("LongTable scholar-research scaffold"); + console.log("LongTable Research scaffold"); console.log(`- run: ${scaffold.runId}`); console.log(`- dir: ${scaffold.runDir}`); return; @@ -4410,14 +4983,14 @@ async function runScholarResearch( console.log(JSON.stringify({ fixture }, null, 2)); return; } - console.log("LongTable scholar-research smoke fixture"); + console.log("LongTable Research smoke fixture"); for (const item of fixture) { console.log(`- ${item.id}: ${item.category} - ${item.label}`); } return; } - throw new Error(`Unknown scholar-research subcommand: ${subcommand}`); + throw new Error(`Unknown LongTable Research subcommand: ${subcommand}`); } async function requireWorkspaceContext(args: Record): Promise { @@ -5350,7 +5923,7 @@ async function runStart(args: Record): Promise { "1. longtable setup --provider codex", "2. cd \"\"", "3. codex", - "4. $longtable-start", + "4. $longtable", "", "For automation, pass `--no-interview --json` with `--name`, `--path`, and `--goal`." ])); @@ -5471,7 +6044,7 @@ async function runCodexSubcommand( const installed = await installCodexSkills(roles, customDir, skillSurface); console.log(`Installed ${installed.length} LongTable Codex skills in ${resolveCodexSkillsDir(customDir)} (${skillSurface} surface)`); console.log("Use them inside Codex with natural-language triggers such as `lt explore: ...` or `lt panel: ...`."); - console.log("Use `$longtable` as the general router; compact installs expose `$longtable-panel` plus the most common role shortcuts."); + console.log("Use `$longtable` as the research front door; installs expose only `$longtable` and `$longtable-research`."); for (const skill of installed) { console.log(`- ${skill.name}`); } @@ -5744,7 +6317,10 @@ async function main(): Promise { return; } - if (command === "scholar-research") { + if (command === "research" || command === "scholar-research") { + if (command === "scholar-research" && values.json !== true) { + console.warn("Deprecated: use `longtable research`; `longtable scholar-research` is retained for one compatibility release."); + } await runScholarResearch(subcommand, values); return; } @@ -5779,7 +6355,10 @@ async function main(): Promise { return; } - if (command === "panel") { + if (command === "assure" || command === "panel") { + if (command === "panel" && values.json !== true) { + console.warn("Deprecated: use `longtable assure`; `longtable panel` is retained for one compatibility release."); + } if (subcommand === "record") { await runPanelRecordCommand(values); return; diff --git a/packages/longtable/src/panel.ts b/packages/longtable/src/panel.ts index 2bb53b93..f951f877 100644 --- a/packages/longtable/src/panel.ts +++ b/packages/longtable/src/panel.ts @@ -764,3 +764,17 @@ export function renderPanelSummary(plan: PanelPlan): string { export function listDefaultPanelRoles(): CanonicalPersona[] { return PERSONA_DEFINITIONS.filter((persona) => persona.defaultPanelMember).map((persona) => persona.key); } + +/** + * Research Assurance is the public name for the former Panel harness. The + * underlying v1 PanelPlan/PanelResult schemas remain unchanged for replay and + * compatibility during the 0.2 beta. + */ +export type ResearchAssurancePlan = PanelPlan; +export type ResearchAssuranceResult = PanelResult; +export type ResearchAssuranceFallback = PanelFallback; +export type BuildResearchAssurancePlanOptions = BuildPanelPlanOptions; +export const buildResearchAssurancePlan = buildPanelPlan; +export const buildResearchAssuranceFallback = buildPanelFallback; +export const renderResearchAssuranceSummary = renderPanelSummary; +export const listDefaultResearchAssuranceRoles = listDefaultPanelRoles; diff --git a/packages/longtable/src/persona-router.ts b/packages/longtable/src/persona-router.ts index 02c9c750..1893aa99 100644 --- a/packages/longtable/src/persona-router.ts +++ b/packages/longtable/src/persona-router.ts @@ -194,7 +194,7 @@ export function renderRoleGuidance(roles: readonly CanonicalPersona[]): string | `- ${definition.label}: ${definition.shortDescription}`, " Journal-grounded reviewer workflow:", " - Build or request a Journal Profile before target-journal fit claims.", - " - Use scholar-research or venue search to form a Reference Pattern Matrix.", + " - Use longtable-research or venue search to form a Reference Pattern Matrix.", " - Compare reference papers for decision structure, paper flow, standardized terminology, Figure/Table conventions, and APA 7 style.", " - Include the Venue Strategist tradeoff in the reviewer feedback." ].join("\n"); diff --git a/packages/longtable/src/project-session.ts b/packages/longtable/src/project-session.ts index f5f86fc2..993aa3b8 100644 --- a/packages/longtable/src/project-session.ts +++ b/packages/longtable/src/project-session.ts @@ -28,6 +28,8 @@ import type { QuestionSurface, QuestionPromptType, QuestionRecord, + QuestionTransportAttempt, + QuestionTransportStatus, ResearchSpecificationChange, ResearchSpecificationPatch, ResearchSpecificationPatchSource, @@ -3713,11 +3715,27 @@ export async function createWorkspaceQuestion(options: { hardStopScope?: HardStopScope; commitmentFamily?: QuestionCommitmentFamily; epistemicBasis?: QuestionEpistemicBasis; + idempotencyKey?: string; }): Promise<{ question: QuestionRecord; state: ResearchState; + created: boolean; + replayed: boolean; }> { const state = await loadResearchState(options.context.stateFilePath); + if (options.idempotencyKey) { + const existing = (state.questionLog ?? []).find( + (record) => record.idempotencyKey === options.idempotencyKey + ); + if (existing) { + return { + question: existing, + state, + created: false, + replayed: true + }; + } + } const trigger = classifyCheckpointTrigger(options.prompt, { unresolvedTensions: state.openTensions ?? [], studyContract: state.studyContract @@ -3749,6 +3767,7 @@ export async function createWorkspaceQuestion(options: { updatedAt: createdAt, status: "pending", ...metadata, + ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}), ...(typeof options.hardStop === "boolean" ? { hardStop: options.hardStop } : {}), ...(options.hardStopScope ? { hardStopScope: options.hardStopScope } : {}), prompt: { @@ -3775,7 +3794,81 @@ export async function createWorkspaceQuestion(options: { await writeFile(options.context.stateFilePath, JSON.stringify(withObligation, null, 2), "utf8"); await syncCurrentWorkspaceView(options.context); - return { question, state: withObligation }; + return { question, state: withObligation, created: true, replayed: false }; +} + +export async function recordWorkspaceQuestionTransport(options: { + context: LongTableProjectContext; + questionId: string; + status: QuestionTransportStatus; + surface?: QuestionSurface; + provider?: ProviderKind; + attemptId?: string; + parentAttemptId?: string; + requestId?: string; + action?: "accept" | "decline" | "cancel"; + message?: string; + retryable?: boolean; +}): Promise<{ + question: QuestionRecord; + state: ResearchState; +}> { + const state = await loadResearchState(options.context.stateFilePath); + const question = (state.questionLog ?? []).find((record) => record.id === options.questionId); + if (!question) { + throw new Error(`No LongTable question found for ${options.questionId}.`); + } + const recordedAt = nowIso(); + const surface = options.surface ?? "mcp_elicitation"; + const previous = question.transportAttempts?.at(-1); + const attemptId = options.attemptId + ?? (previous?.status === "attempted" ? previous.attemptId : createId("question_transport_attempt")); + const isExactReplay = Boolean( + previous + && previous.attemptId === attemptId + && previous.surface === surface + && previous.status === options.status + && previous.message === options.message + ); + const transportAttempts = isExactReplay + ? (question.transportAttempts ?? []) + : [ + ...(question.transportAttempts ?? []), + { + id: createId("question_transport_event"), + attemptId, + sequence: (question.transportAttempts?.length ?? 0) + 1, + surface, + status: options.status, + recordedAt, + ...(options.provider ? { provider: options.provider } : {}), + ...(options.parentAttemptId ? { parentAttemptId: options.parentAttemptId } : {}), + ...(options.requestId ? { requestId: options.requestId } : {}), + ...(options.action ? { action: options.action } : {}), + ...(options.message ? { message: options.message } : {}), + ...(typeof options.retryable === "boolean" ? { retryable: options.retryable } : {}) + } satisfies QuestionTransportAttempt + ]; + const updatedQuestion: QuestionRecord = { + ...question, + updatedAt: recordedAt, + transportAttempts, + transportStatus: { + surface, + status: options.status, + updatedAt: recordedAt, + ...(options.message ? { message: options.message } : {}) + } + }; + const updated: ResearchState = { + ...state, + questionLog: (state.questionLog ?? []).map((record) => + record.id === question.id ? updatedQuestion : record + ) + }; + await writeFile(options.context.stateFilePath, JSON.stringify(updated, null, 2), "utf8"); + await syncCurrentWorkspaceView(options.context); + return { question: updatedQuestion, state: updated }; } function updateInvocationWithDecision( @@ -4055,12 +4148,34 @@ export async function answerWorkspaceQuestion(options: { rationale?: string; provider?: "codex" | "claude"; surface?: QuestionSurface; + answerIdempotencyKey?: string; }): Promise<{ question: QuestionRecord; decision: DecisionRecord; state: ResearchState; }> { const state = await loadResearchState(options.context.stateFilePath); + const existingQuestion = options.questionId + ? (state.questionLog ?? []).find((record) => record.id === options.questionId) + : undefined; + if (existingQuestion?.status === "answered" && existingQuestion.answer && existingQuestion.decisionRecordId) { + const normalizedReplay = normalizeQuestionAnswerSelection(existingQuestion, options.answer); + const sameAnswer = JSON.stringify(normalizedReplay.selectedValues) === JSON.stringify(existingQuestion.answer.selectedValues) + && (normalizedReplay.otherText ?? "") === (existingQuestion.answer.otherText ?? ""); + const sameKey = !options.answerIdempotencyKey + || options.answerIdempotencyKey === existingQuestion.answer.idempotencyKey; + const existingDecision = state.decisionLog.find( + (record) => record.id === existingQuestion.decisionRecordId + ); + if (sameAnswer && sameKey && existingDecision) { + return { + question: existingQuestion, + decision: existingDecision, + state + }; + } + throw new Error(`LongTable question ${existingQuestion.id} was already answered with a different response.`); + } const question = findQuestionForDecision(state, options.questionId); if (!question) { throw new Error(options.questionId ? `No pending LongTable question found for ${options.questionId}.` : "No pending LongTable question was found."); @@ -4077,7 +4192,8 @@ export async function answerWorkspaceQuestion(options: { ...(normalized.otherText ? { otherText: normalized.otherText } : {}), ...(rationale ? { rationale } : {}), ...(options.provider ? { provider: options.provider } : {}), - surface: options.surface ?? (options.provider === "claude" ? "native_structured" : "numbered") + surface: options.surface ?? (options.provider === "claude" ? "native_structured" : "numbered"), + ...(options.answerIdempotencyKey ? { idempotencyKey: options.answerIdempotencyKey } : {}) }; const timestamp = nowIso(); @@ -4092,15 +4208,30 @@ export async function answerWorkspaceQuestion(options: { ...(question.epistemicBasis ? { epistemicBasis: question.epistemicBasis } : {}), selectedOption: answer.selectedValues[0], selectedOptions: answer.selectedValues, - ...(rationale ? { rationale } : {}) + ...(rationale ? { rationale } : {}), + sourceQuestionId: question.id, + ...(options.answerIdempotencyKey ? { idempotencyKey: options.answerIdempotencyKey } : {}) }; + const acceptedAttemptId = question.transportAttempts?.at(-1)?.attemptId + ?? createId("question_transport_attempt"); + const acceptedAttempt: QuestionTransportAttempt = { + id: createId("question_transport_event"), + attemptId: acceptedAttemptId, + sequence: (question.transportAttempts?.length ?? 0) + 1, + surface: answer.surface, + status: "accepted", + recordedAt: timestamp, + ...(options.provider ? { provider: options.provider } : {}), + action: "accept" + }; const answeredQuestion: QuestionRecord = { ...question, updatedAt: timestamp, status: "answered", answer, decisionRecordId: decision.id, + transportAttempts: [...(question.transportAttempts ?? []), acceptedAttempt], transportStatus: { surface: answer.surface, status: "accepted", diff --git a/packages/longtable/src/prompt-aliases.ts b/packages/longtable/src/prompt-aliases.ts index 4170b303..5d90aa0e 100644 --- a/packages/longtable/src/prompt-aliases.ts +++ b/packages/longtable/src/prompt-aliases.ts @@ -111,7 +111,7 @@ function promptSpec() { "Prioritize likely objections, missing evidence, weak claims, journal-grounded fit risks, and points needing clarification.", "Journal-grounded reviewer workflow:", "- Build or request a Journal Profile before target-journal fit claims.", - "- Use scholar-research or venue search to form a Reference Pattern Matrix.", + "- Use longtable-research or venue search to form a Reference Pattern Matrix.", "- Compare reference papers for decision structure, paper flow, standardized terminology, Figure/Table conventions, and APA 7 style.", "- Include the Venue Strategist tradeoff in the reviewer feedback.", "Disclose that the reviewer role was consulted.", diff --git a/packages/longtable/src/search/index.ts b/packages/longtable/src/search/index.ts index 04943693..be6115c6 100644 --- a/packages/longtable/src/search/index.ts +++ b/packages/longtable/src/search/index.ts @@ -1 +1 @@ -export * from "@longtable/scholar-research"; +export * from "@longtable/research"; diff --git a/schemas/decision-record.schema.yaml b/schemas/decision-record.schema.yaml index 93842096..be3ddfd1 100644 --- a/schemas/decision-record.schema.yaml +++ b/schemas/decision-record.schema.yaml @@ -39,11 +39,23 @@ properties: summary: type: string minLength: 1 + commitmentFamily: + type: string + epistemicBasis: + type: string selectedOption: type: string + selectedOptions: + type: array + items: + type: string rationale: type: string explicitStateUpdates: type: object studyContractId: type: string + sourceQuestionId: + type: string + idempotencyKey: + type: string diff --git a/schemas/state.schema.yaml b/schemas/state.schema.yaml index 7e0bfc04..36616b11 100644 --- a/schemas/state.schema.yaml +++ b/schemas/state.schema.yaml @@ -17,6 +17,14 @@ properties: workingState: type: object description: Mutable task cursor for the current research session. + hooks: + type: array + items: + type: object + firstResearchShape: + type: object + researchSpecification: + type: object inferredHypotheses: type: array items: @@ -52,6 +60,18 @@ properties: type: array items: $ref: "./decision-record.schema.yaml" + invocationLog: + type: array + items: + type: object + questionLog: + type: array + items: + type: object + questionObligations: + type: array + items: + type: object artifactRecords: type: array items: diff --git a/scripts/e2e-grilling-interview-flow.mjs b/scripts/e2e-grilling-interview-flow.mjs index e23f8626..47be444a 100644 --- a/scripts/e2e-grilling-interview-flow.mjs +++ b/scripts/e2e-grilling-interview-flow.mjs @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; @@ -22,33 +22,22 @@ function runCli(args) { }); } -runCli(["codex", "install-skills", "--dir", skillsDir]); +runCli(["codex", "install-skills", "--surface", "full", "--dir", skillsDir]); -const interviewSkill = readFileSync(join(skillsDir, "longtable-interview", "SKILL.md"), "utf8"); const routerSkill = readFileSync(join(skillsDir, "longtable", "SKILL.md"), "utf8"); +const installedSkills = readdirSync(skillsDir).sort(); -assert(interviewSkill.includes("LongTable grilling interview"), "interview skill should expose the grilling loop"); -assert(interviewSkill.includes("Tension:"), "interview skill should require the tension frame"); -assert(interviewSkill.includes("Pressure question:"), "interview skill should require a pressure-question frame"); -assert(interviewSkill.includes("Ask exactly one question per turn"), "interview skill should stay compact"); -assert(interviewSkill.includes("Wait for the researcher's direct answer"), "interview skill should require a direct-answer loop"); -assert(!interviewSkill.includes("No option menus"), "interview skill should avoid prohibition-first wording"); -assert(!interviewSkill.includes("approval prompts"), "interview skill should avoid prohibition-first wording"); -assert(!interviewSkill.includes("accept, revise, or reject"), "interview skill should not ask for accept/revise/reject choices"); -assert(!interviewSkill.includes("accept/revise/reject"), "interview skill should not ask for accept/revise/reject choices"); -assert(!interviewSkill.includes("Recommended answer"), "interview skill should not use a recommended-answer frame"); -assert(!interviewSkill.includes("recommended answer"), "interview skill should not use a recommended-answer frame"); assert( - interviewSkill.includes("remaining questions repeat the same tension without producing a new decision"), - "interview skill should preserve the grilling stop rule" + JSON.stringify(installedSkills) === JSON.stringify(["longtable", "longtable-research"]), + "even the deprecated full surface should install exactly the two public skills" ); -assert(!interviewSkill.includes("option-first"), "interview skill should not keep option-first mode"); -assert(!interviewSkill.includes("ordinary follow-up"), "interview skill should not keep ordinary follow-up mode"); -assert(!interviewSkill.includes("route to `$longtable-start` immediately"), "interview skill should not route itself away"); -assert(!interviewSkill.includes("grill-me"), "interview skill should not advertise grill-me requests"); -assert(routerSkill.includes("LongTable grilling interview"), "router should describe longtable-interview as grilling"); +assert(!existsSync(join(skillsDir, "longtable-interview")), "legacy interview skill should be pruned"); +assert(routerSkill.includes("grilling or pressure-interview intent"), "router should describe the internal pressure-interview route"); assert(routerSkill.includes("Pressure question:"), "router should preserve pressure-question behavior"); -assert(routerSkill.includes("compact pressure loop"), "router should advertise the compact pressure loop"); +assert(routerSkill.includes("pressure-interview route"), "router should advertise the pressure-interview route"); +assert(routerSkill.includes("at most three clarifying questions"), "router should bound first-use questioning"); +assert(routerSkill.includes("versioned Research Brief"), "router should produce the durable handoff"); +assert(routerSkill.includes("start LongTable Research, keep shaping, or save and stop"), "router should expose the three first-use choices"); assert(!routerSkill.includes("approval prompts"), "router should avoid prohibition-first wording"); assert(!routerSkill.includes("accept, revise, or reject"), "router should not advertise accept/revise/reject choices"); assert(!routerSkill.includes("accept/revise/reject"), "router should not advertise accept/revise/reject choices"); @@ -60,11 +49,13 @@ assert(!routerSkill.includes("grill-me"), "router should not route grill-me requ console.log(JSON.stringify({ skillsDir, - interviewSurface: "longtable-interview", - removedSurface: "critical-interview", + installedSkills, + interviewSurface: "longtable internal pressure-interview route", + removedSurfaces: ["critical-interview", "longtable-interview"], observed: { grillingLoop: true, pressureQuestion: true, + boundedResearchBrief: true, oldOrdinaryModeRemoved: true } }, null, 2)); diff --git a/scripts/smoke-mcp-elicitation.mjs b/scripts/smoke-mcp-elicitation.mjs new file mode 100644 index 00000000..f33b2b27 --- /dev/null +++ b/scripts/smoke-mcp-elicitation.mjs @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +const delayMs = Number(process.env.LONGTABLE_MCP_TEST_DELAY_MS ?? 0); +const workspace = await mkdtemp(join(tmpdir(), "longtable-mcp-elicitation-")); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const serverEntry = join(repoRoot, "packages", "longtable-mcp", "dist", "server.js"); +const setupFixture = join( + repoRoot, + "packages", + "longtable-setup", + "examples", + "codex-setup-output.json" +); +let action = "accept"; +let remainingDelayMs = delayMs; + +async function delay() { + if (remainingDelayMs > 0) { + const currentDelayMs = remainingDelayMs; + remainingDelayMs = 0; + await new Promise((resolve) => setTimeout(resolve, currentDelayMs)); + } +} + +async function openConnection() { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [serverEntry], + cwd: repoRoot, + stderr: "pipe" + }); + const client = new Client( + { name: "longtable-smoke-client", version: "1.0.0" }, + { + capabilities: { + elicitation: { + form: {} + } + }, + versionNegotiation: { + mode: { pin: "2026-07-28" } + }, + inputRequired: { + autoFulfill: true, + maxRounds: 4 + } + } + ); + client.setRequestHandler("elicitation/create", async () => { + await delay(); + if (action === "accept") { + return { + action: "accept", + content: { + answer: "proceed" + } + }; + } + return { action }; + }); + await client.connect(transport); + return { + client, + async close() { + await client.close(); + } + }; +} + +async function state() { + return JSON.parse(await readFile(join(workspace, ".longtable", "state.json"), "utf8")); +} + +const acceptedArgs = { + cwd: workspace, + prompt: "Choose whether this durable checkpoint should proceed.", + title: "Durable checkpoint", + question: "Proceed with the recorded research commitment?", + checkpointKey: "mcp_2026_durable_checkpoint", + options: [ + { value: "proceed", label: "Proceed", recommended: true }, + { value: "revise", label: "Revise" } + ], + provider: "codex", + required: true, + idempotencyKey: "smoke:accepted" +}; + +let connection = await openConnection(); +try { + const workspaceResult = await connection.client.callTool({ + name: "create_workspace", + arguments: { + cwd: workspace, + projectName: "MCP elicitation smoke", + seedGoal: "Verify durable Researcher Checkpoints.", + setupPath: setupFixture + } + }); + if (workspaceResult.isError) { + throw new Error(`MCP workspace setup failed: ${JSON.stringify(workspaceResult)}`); + } + + const accepted = await connection.client.callTool({ + name: "elicit_question", + arguments: acceptedArgs + }); + if (accepted.isError) { + throw new Error(`MCP accept failed: ${JSON.stringify({ + result: accepted, + state: await state() + })}`); + } + + const acceptedState = await state(); + assert.equal(acceptedState.questionLog.length, 1); + assert.equal(acceptedState.decisionLog.length, 1); + assert.equal(acceptedState.questionLog[0].status, "answered"); + assert.deepEqual( + acceptedState.questionLog[0].transportAttempts.map((event) => event.status), + ["attempted", "accepted"] + ); + + await connection.client.callTool({ + name: "elicit_question", + arguments: acceptedArgs + }); + const replayState = await state(); + assert.equal(replayState.questionLog.length, 1); + assert.equal(replayState.decisionLog.length, 1); + + action = "decline"; + await connection.client.callTool({ + name: "elicit_question", + arguments: { + ...acceptedArgs, + prompt: "Decline this checkpoint without losing it.", + checkpointKey: "mcp_2026_declined_checkpoint", + idempotencyKey: "smoke:declined" + } + }); + const declinedState = await state(); + const declined = declinedState.questionLog.find( + (question) => question.idempotencyKey === "smoke:declined" + ); + assert.equal(declined.status, "pending"); + assert.equal(declined.transportStatus.status, "declined"); + assert.deepEqual( + declined.transportAttempts.map((event) => event.status), + ["attempted", "declined"] + ); + + action = "cancel"; + await connection.client.callTool({ + name: "elicit_question", + arguments: { + ...acceptedArgs, + prompt: "Cancel this transport without losing the checkpoint.", + checkpointKey: "mcp_2026_cancelled_checkpoint", + idempotencyKey: "smoke:cancelled" + } + }); + const cancelledState = await state(); + const cancelled = cancelledState.questionLog.find( + (question) => question.idempotencyKey === "smoke:cancelled" + ); + assert.equal(cancelled.status, "pending"); + assert.equal(cancelled.transportStatus.status, "cancelled"); + assert.deepEqual( + cancelled.transportAttempts.map((event) => event.status), + ["attempted", "cancelled"] + ); + + action = "accept"; + await connection.client.callTool({ + name: "elicit_question", + arguments: { + ...acceptedArgs, + prompt: "Persist this checkpoint before restarting the MCP server.", + checkpointKey: "mcp_2026_restart_checkpoint", + idempotencyKey: "smoke:restart", + fallbackOnly: true + } + }); +} finally { + await connection.close(); +} + +connection = await openConnection(); +try { + await connection.client.callTool({ + name: "elicit_question", + arguments: { + ...acceptedArgs, + prompt: "Persist this checkpoint before restarting the MCP server.", + checkpointKey: "mcp_2026_restart_checkpoint", + idempotencyKey: "smoke:restart" + } + }); + const resumedState = await state(); + const restartedQuestions = resumedState.questionLog.filter( + (question) => question.idempotencyKey === "smoke:restart" + ); + assert.equal(restartedQuestions.length, 1); + assert.equal(restartedQuestions[0].status, "answered"); + assert.deepEqual( + restartedQuestions[0].transportAttempts.map((event) => event.status), + ["fallback_rendered", "attempted", "accepted"] + ); +} finally { + await connection.close(); + await rm(workspace, { recursive: true, force: true }); +} + +console.log(JSON.stringify({ + ok: true, + protocol: "2026-07-28", + delayMs, + contracts: [ + "accepted", + "idempotent replay", + "declined remains pending", + "cancelled remains pending", + "fresh-process resume" + ] +}, null, 2)); diff --git a/scripts/smoke-panel-handoff.mjs b/scripts/smoke-panel-handoff.mjs index 58ba3119..82a636d2 100644 --- a/scripts/smoke-panel-handoff.mjs +++ b/scripts/smoke-panel-handoff.mjs @@ -77,25 +77,25 @@ runCli([ "--json" ]); -runCli(["codex", "install-skills", "--surface", "compact", "--dir", codexSkillsDir]); -const codexPanelSkillPath = join(codexSkillsDir, "longtable-panel", "SKILL.md"); -if (!existsSync(codexPanelSkillPath)) { - throw new Error("compact Codex skills should include longtable-panel."); +runCli(["codex", "install-skills", "--surface", "full", "--dir", codexSkillsDir]); +const codexPanelSkillPath = join(codexSkillsDir, "longtable", "SKILL.md"); +if (!existsSync(codexPanelSkillPath) || existsSync(join(codexSkillsDir, "longtable-panel"))) { + throw new Error("Codex should route Research Assurance through the single longtable skill."); } const codexPanelSkill = readFileSync(codexPanelSkillPath, "utf8"); -assertIncludes(codexPanelSkill, "--native-workers", "Codex panel skill native worker wording"); -assertIncludes(codexPanelSkill, "Sequential fallback", "Codex panel skill fallback wording"); -assertIncludes(codexPanelSkill, "PanelResult", "Codex panel skill normalized result wording"); +assertIncludes(codexPanelSkill, "Research Assurance", "Codex router Assurance wording"); +assertIncludes(codexPanelSkill, "Provider-native multi-agent execution", "Codex router native-agent wording"); +assertIncludes(codexPanelSkill, "structured deliberation record", "Codex router normalized record wording"); -runCli(["claude", "install-skills", "--surface", "compact", "--dir", claudeSkillsDir]); -const claudePanelSkillPath = join(claudeSkillsDir, "longtable-panel", "SKILL.md"); -if (!existsSync(claudePanelSkillPath)) { - throw new Error("compact Claude skills should include longtable-panel."); +runCli(["claude", "install-skills", "--surface", "full", "--dir", claudeSkillsDir]); +const claudePanelSkillPath = join(claudeSkillsDir, "longtable", "SKILL.md"); +if (!existsSync(claudePanelSkillPath) || existsSync(join(claudeSkillsDir, "longtable-panel"))) { + throw new Error("Claude should route Research Assurance through the single longtable skill."); } const claudePanelSkill = readFileSync(claudePanelSkillPath, "utf8"); -assertIncludes(claudePanelSkill, "--native-workers", "Claude panel skill native worker wording"); -assertIncludes(claudePanelSkill, "Sequential fallback", "Claude panel skill fallback wording"); -assertIncludes(claudePanelSkill, "PanelResult", "Claude panel skill normalized result wording"); +assertIncludes(claudePanelSkill, "Research Assurance", "Claude router Assurance wording"); +assertIncludes(claudePanelSkill, "Provider-native multi-agent execution", "Claude router native-agent wording"); +assertIncludes(claudePanelSkill, "structured deliberation record", "Claude router normalized record wording"); const panel = JSON.parse(runCli([ "panel", diff --git a/scripts/smoke-panel-workers.mjs b/scripts/smoke-panel-workers.mjs index 761eca5a..406c0d82 100644 --- a/scripts/smoke-panel-workers.mjs +++ b/scripts/smoke-panel-workers.mjs @@ -193,9 +193,9 @@ function assertThrowsSync(fn, expectedMessage, label) { } const help = runCli(["--help"]); -assertIncludes(help, "longtable panel status --run", "help includes panel status"); -assertIncludes(help, "longtable panel stop --run", "help includes panel stop"); -assertIncludes(help, "longtable panel resume --run", "help includes panel resume"); +assertIncludes(help, "longtable assure status --run", "help includes assurance status"); +assertIncludes(help, "longtable assure stop --run", "help includes assurance stop"); +assertIncludes(help, "longtable assure resume --run", "help includes assurance resume"); assertIncludes(help, "--wait [ms]", "help includes bounded wait option"); assert(!help.includes("longtable team"), "help must not advertise public longtable team"); diff --git a/scripts/smoke-role-audit.mjs b/scripts/smoke-role-audit.mjs index ee5df754..c51464ae 100644 --- a/scripts/smoke-role-audit.mjs +++ b/scripts/smoke-role-audit.mjs @@ -21,7 +21,7 @@ const audit = JSON.parse(execFileSync("node", [cli, "audit", "roles", "--json"], })); assert(audit.passed === true, "role audit should pass"); -assert(audit.totals.roleCount >= 8, "role audit should cover generated role skills"); +assert(audit.totals.roleCount === 0, "provider installs should not expose standalone role skills"); for (const role of audit.roles) { assert(role.missingSections.length === 0, `${role.provider}:${role.name} should include all required sections`); @@ -74,12 +74,6 @@ const reviewerPrompt = execFileSync("node", [ const roles = personas.listRoleDefinitions(); const codexCompactNames = codex.buildCodexSkillSpecs(roles, "compact").map((skill) => skill.name); const claudeCompactNames = claude.buildClaudeSkillSpecs(roles, "compact").map((skill) => skill.name); -const codexEditor = codex.buildCodexSkillSpecs(roles, "full").find((skill) => skill.name === "longtable-editor"); -const claudeEditor = claude.buildClaudeSkillSpecs(roles, "full").find((skill) => skill.name === "longtable-editor"); -const codexReviewer = codex.buildCodexSkillSpecs(roles, "compact").find((skill) => skill.name === "longtable-reviewer"); -const claudeReviewer = claude.buildClaudeSkillSpecs(roles, "compact").find((skill) => skill.name === "longtable-reviewer"); -const journalFitBoundary = - "If a target journal is named, do not claim journal fit from role intuition alone."; const journalGroundedReviewerMarkers = [ "Journal-grounded reviewer workflow", "Journal Profile", @@ -89,16 +83,22 @@ const journalGroundedReviewerMarkers = [ "Figure/Table", "APA 7", "Venue Strategist", - "scholar-research" + "longtable-research" ]; -assert(codexEditor?.body.join("\n").includes(journalFitBoundary), "Codex editor skill should require journal evidence before fit claims"); -assert(claudeEditor?.body.join("\n").includes(journalFitBoundary), "Claude editor skill should require journal evidence before fit claims"); assert(!codexCompactNames.includes("longtable-editor"), "Codex compact surface should not split editor into a separate visible skill"); assert(!claudeCompactNames.includes("longtable-editor"), "Claude compact surface should not split editor into a separate visible skill"); +assert(JSON.stringify(codexCompactNames.sort()) === JSON.stringify(["longtable", "longtable-research"]), "Codex should expose only two skills"); +assert(JSON.stringify(claudeCompactNames.sort()) === JSON.stringify(["longtable", "longtable-research"]), "Claude should expose only two skills"); +assert( + JSON.stringify(codex.buildCodexSkillSpecs(roles, "full").map((skill) => skill.name).sort()) === JSON.stringify(["longtable", "longtable-research"]), + "Deprecated Codex full surface should not regenerate role skills" +); +assert( + JSON.stringify(claude.buildClaudeSkillSpecs(roles, "full").map((skill) => skill.name).sort()) === JSON.stringify(["longtable", "longtable-research"]), + "Deprecated Claude full surface should not regenerate role skills" +); for (const marker of journalGroundedReviewerMarkers) { - assert(codexReviewer?.body.join("\n").includes(marker), `Codex reviewer skill should include ${marker}`); - assert(claudeReviewer?.body.join("\n").includes(marker), `Claude reviewer skill should include ${marker}`); assert(reviewerPrompt.includes(marker), `reviewer prompt should include ${marker}`); assert(reviewerPromptAlias.includes(marker), `reviewer prompt alias should include ${marker}`); } diff --git a/scripts/smoke-scholar-research.mjs b/scripts/smoke-scholar-research.mjs index fa171231..ffda8293 100644 --- a/scripts/smoke-scholar-research.mjs +++ b/scripts/smoke-scholar-research.mjs @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -18,14 +18,15 @@ function assert(condition, label) { } } -const scholar = await import(join(repoRoot, "packages", "longtable-scholar-research", "dist", "index.js")); +const scholar = await import(join(repoRoot, "packages", "longtable-research", "dist", "index.js")); const compatibility = await import(join(repoRoot, "packages", "longtable-research-search", "dist", "index.js")); const codex = await import(join(repoRoot, "packages", "longtable-provider-codex", "dist", "index.js")); const claude = await import(join(repoRoot, "packages", "longtable-provider-claude", "dist", "index.js")); const personas = await import(join(repoRoot, "packages", "longtable", "dist", "personas.js")); assertEqual(compatibility.SCHOLAR_RESEARCH_SKILL_NAME, scholar.SCHOLAR_RESEARCH_SKILL_NAME, "compatibility package re-export"); -assertEqual(scholar.SCHOLAR_RESEARCH_SKILL_NAME, "scholar-research", "canonical skill name"); +assertEqual(scholar.LONGTABLE_RESEARCH_SKILL_NAME, "longtable-research", "canonical skill name"); +assertEqual(scholar.SCHOLAR_RESEARCH_SKILL_NAME, "longtable-research", "compatibility skill constant"); assert(scholar.SCHOLAR_RESEARCH_FAILURE_REASONS.includes("restricted_access"), "restricted access failure reason"); assert(scholar.SCHOLAR_RESEARCH_FAILURE_REASONS.includes("robots_or_terms_blocked"), "robots/terms failure reason"); assert(scholar.SCHOLAR_RESEARCH_SLOT_STATUSES.includes("filled"), "filled slot status"); @@ -49,6 +50,978 @@ assertEqual(fixture.filter((item) => item.category === "oa_pdf").length, 3, "OA assertEqual(fixture.filter((item) => item.category === "restricted_fallback").length, 2, "restricted fallback fixture count"); assertEqual(fixture.filter((item) => item.category === "korean_institutional_report").length, 1, "Korean report fixture count"); +assertEqual(scholar.citationSlotIsStrictlyFilled({ + id: "slot-1", + claim: "A claim", + status: "filled", + relation: "supports", + evidence: { + sourceId: "doi:10.1000/example", + quote: "Evidence text", + locator: "page=2,paragraph=3", + contentHash: "sha256:fixture", + sourceVersion: "version-of-record", + accessClass: "open_access" + } +}), true, "strict filled citation slot"); +assertEqual(scholar.citationSlotIsStrictlyFilled({ + id: "slot-2", + claim: "A claim", + status: "filled" +}), false, "filled label alone is insufficient"); + +const providerPacket = scholar.buildProviderTaskPacket({ + runId: "packet-run", + stage: "extract", + objective: "Propose claim-evidence links", + boundedEvidence: [{ + spanId: "span-1", + sourceId: "doi:10.1000/packet", + quote: "Packet evidence", + locator: "page:1;chars:0-15", + contentHash: "sha256:packet", + sourceVersion: "version-of-record", + accessClass: "public_oa" + }], + allowedPatchPaths: ["/citationSlots"], + maxOutputCharacters: 2000 +}); +assertEqual(scholar.validateProviderProposedPatch(providerPacket, { + schema: "longtable.provider-proposed-patch", + version: 1, + runId: "packet-run", + stage: "extract", + operations: [{ + op: "add", + path: "/citationSlots/0", + value: { + id: "slot-1", + claim: "Packet claim", + status: "provisional", + relation: "supports", + evidence: providerPacket.boundedEvidence[0] + } + }] +}).valid, true, "bounded provider patch"); +assertEqual(scholar.validateProviderProposedPatch(providerPacket, { + schema: "longtable.provider-proposed-patch", + version: 1, + runId: "packet-run", + stage: "extract", + operations: [{ op: "replace", path: "/input/query", value: "mutated" }] +}).valid, false, "provider cannot mutate outside allowed paths"); +assertEqual(scholar.validateProviderProposedPatch(providerPacket, { + schema: "longtable.provider-proposed-patch", + version: 1, + runId: "packet-run", + stage: "extract", + operations: [{ op: "add", path: "/citationSlots/0", value: { status: "filled" } }] +}).valid, false, "provider cannot self-promote citation slots to filled"); + +const evaluationPlan = scholar.buildScholarResearchEvaluationPlan(); +assertEqual(evaluationPlan.tasks.length, 12, "evaluation task count"); +assertEqual(evaluationPlan.repeatsPerFrozenCondition, 3, "frozen condition repeat count"); +assertEqual(evaluationPlan.replayCases[0].posture, "replay_only", "Paper B thread is replay-only"); + +const visualContract = { + schema: "longtable.visual-evidence-contract", + version: 1, + id: "visual-1", + kind: "figure", + status: "proposed", + analyticalQuestion: "How does validation scope change the evidence pattern?", + readerTakeaway: "Scope is visible before outcome magnitude.", + necessity: "A pattern comparison is faster than exact row lookup.", + manuscriptClaimIds: ["claim-1"], + targetJournal: "Fixture Journal", + journalGrammar: { + journal: "Fixture Journal", + derivedFrom: [{ + sourceId: "doi:10.1000/visual", + sourceVersion: "version-of-record", + accessClass: "public_oa", + pageRenderPath: "page-4.png", + page: 4, + boundingBox: { x: 10, y: 10, width: 100, height: 80 }, + caption: "Fixture caption", + surroundingContext: "Fixture results context", + observedPattern: "Direct labels and grayscale marks" + }], + pageOrColumnWidth: "single-column", + typography: ["sans-serif"], + lineAndMarkerRules: ["grayscale"], + tableRules: [], + captionRules: ["title outside artwork"], + colorPolicy: "grayscale", + notes: [] + }, + evidenceBoundary: { performed: ["audit"], proposed: [], prohibitedInferences: ["causality"] }, + dataContract: { + dataSnapshotPath: "data.json", + unitOfAnalysis: "report-estimand", + denominator: "all eligible reports", + estimandOrConstruct: "validation coverage", + uncertainty: "exact counts", + independentUnitField: "report_id" + }, + readingOrder: ["question", "pattern", "denominator", "conclusion"], + mainTextRole: "Pattern interpretation", + supplementRole: "Complete rows and locators", + directLabels: true, + output: { + editableSourceFormat: "svg", + renderer: "fixture", + rendererVersion: "1", + journalSizedExports: ["figure.svg"], + placementManifestPath: "placement.json" + }, + readerTest: { + seconds: 20, + recover: ["question", "denominator", "key_value_or_relation", "conclusion"] + } +}; +assertEqual(scholar.validateVisualEvidenceContract(visualContract).valid, true, "visual evidence contract"); +const approvedVisualContract = { + ...visualContract, + status: "approved", + accessibility: { + grayscaleSafe: true, + colorVisionSafe: true, + textAlternative: "A compact description of the evidenced pattern and its denominator.", + minimumFontSizePt: 8 + }, + verificationPlan: { + domainMeaning: "A domain reviewer checks the interpretation against the cited claims.", + statisticalIntegrity: "A methods reviewer checks units, denominators, axes, and uncertainty.", + journalFit: "An editor checks the located exemplars and journal-sized export.", + readerComprehension: "A blinded reader performs the 15-second recovery test." + }, + approval: { + approvedBy: "human-fixture", + approvedAt: "2026-07-27T00:00:00.000Z", + decisionRecordId: "decision-visual-1" + } +}; +assertEqual(scholar.validateVisualEvidenceContract(approvedVisualContract).valid, true, "approved visual requires four-lens verification"); +const inaccessibleApprovedVisual = { + ...approvedVisualContract, + accessibility: { ...approvedVisualContract.accessibility, grayscaleSafe: false } +}; +assert( + scholar.validateVisualEvidenceContract(inaccessibleApprovedVisual).hardFailures.some((failure) => failure.includes("grayscale-safe")), + "approved visual rejects inaccessible output" +); +assertEqual(scholar.recommendVisualArtifactKind({ + needsExactLookup: false, + needsPatternComparison: true, + needsProcessOrConceptualRelation: false +}), "figure", "visual kind recommendation"); + +const workflowCwd = mkdtempSync(join(tmpdir(), "longtable-scholar-workflow-")); +try { + const fixtureFetch = async () => ({ + ok: true, + status: 200, + statusText: "OK", + text: async () => "", + json: async () => ({ + message: { + items: [{ + DOI: "10.1000/workflow", + title: ["Workflow evidence"], + author: [{ given: "Ada", family: "Lovelace" }], + published: { "date-parts": [[2025]] }, + "container-title": ["Fixture Journal"], + abstract: "This fixture evaluates a durable scholarly research workflow." + }] + } + }) + }); + const bundle = await scholar.runScholarResearchWorkflow({ + cwd: workflowCwd, + runId: "workflow-run", + query: "durable scholarly research workflow", + sources: "crossref", + allowPartial: true, + fetch: fixtureFetch, + env: {} + }); + assertEqual(bundle.status, "paused", "workflow pauses honestly at unimplemented fulltext boundary"); + assertEqual(bundle.stages.collect.status, "completed", "collect stage completed"); + assertEqual(bundle.stages.fulltext.status, "pending", "fulltext remains pending"); + assertEqual(bundle.artifacts.searchRun.cards.length, 1, "search result stored in bundle"); + const eventPath = join(bundle.runDir, "events.jsonl"); + const eventCount = readFileSync(eventPath, "utf8").trim().split("\n").length; + const resumed = await scholar.resumeScholarResearchWorkflow({ + cwd: workflowCwd, + runId: bundle.runId, + fetch: fixtureFetch, + env: {} + }); + assertEqual(resumed.status, "paused", "resume preserves capability boundary"); + assertEqual(readFileSync(eventPath, "utf8").trim().split("\n").length, eventCount, "resume is idempotent"); + await scholar.runScholarResearchWorkflow({ + cwd: workflowCwd, + runId: bundle.runId, + query: "durable scholarly research workflow", + sources: "crossref", + allowPartial: true, + fetch: fixtureFetch, + env: {} + }); + assertEqual(readFileSync(eventPath, "utf8").trim().split("\n").length, eventCount, "run with the same id and input is idempotent"); + + const oaRequestInits = []; + const oaBytes = Buffer.from("%PDF-1.7\nfixture OA bytes\n%%EOF", "utf8"); + const oaSearchFetch = async () => ({ + ok: true, + status: 200, + statusText: "OK", + text: async () => "", + json: async () => ({ + results: [{ + id: "https://openalex.org/W123", + display_name: "Open-access workflow evidence", + publication_year: 2026, + primary_location: { + pdf_url: "https://oa.example.test/article.pdf", + source: { display_name: "Fixture OA Journal" } + }, + open_access: { is_oa: true }, + authorships: [], + cited_by_count: 0 + }] + }) + }); + const oaBundle = await scholar.runScholarResearchWorkflow({ + cwd: workflowCwd, + runId: "oa-auto-run", + query: "open access workflow evidence", + sources: "openalex", + allowPartial: true, + fetch: oaSearchFetch, + oaFetch: async (url, init) => { + oaRequestInits.push({ url, init }); + return { + ok: true, + status: 200, + statusText: "OK", + url, + headers: { + get: (name) => { + if (name.toLowerCase() === "content-type") return "application/pdf"; + if (name.toLowerCase() === "content-length") return String(oaBytes.length); + return null; + } + }, + arrayBuffer: async () => oaBytes.buffer.slice( + oaBytes.byteOffset, + oaBytes.byteOffset + oaBytes.byteLength + ) + }; + }, + oaResolveHost: async () => ["93.184.216.34"], + globalPublicOaCacheRoot: join(workflowCwd, "oa-cache"), + fullTextParser: async () => ({ + pageCount: 1, + pages: [{ + page: 1, + text: "Open-access full-text evidence was acquired through a validated public route.", + locator: "page:1", + extraction: "text" + }], + ocrPages: [], + parser: { pdfinfo: "fixture", pdftotext: "fixture" } + }), + env: { OPENALEX_API_KEY: "fixture-openalex" } + }); + assertEqual(oaBundle.stages.fulltext.status, "completed", "automatic public OA acquisition completes fulltext"); + assertEqual(oaBundle.artifacts.fullTextRecords.length, 1, "automatic OA record stored"); + assertEqual(oaBundle.artifacts.fullTextRecords[0].accessClass, "public_oa", "automatic OA access class"); + assertEqual(oaBundle.artifacts.publicOaAcquisition.acquired, 1, "automatic OA acquisition telemetry"); + assertEqual(oaRequestInits[0].init.redirect, "manual", "OA redirects require validation"); + assertEqual(oaRequestInits[0].init.credentials, "omit", "OA acquisition omits credentials"); + let unsafeOaFetchCalled = false; + const unsafeOaBundle = await scholar.runScholarResearchWorkflow({ + cwd: workflowCwd, + runId: "oa-unsafe-run", + query: "unsafe OA route", + sources: "openalex", + allowPartial: true, + fetch: async () => ({ + ok: true, + status: 200, + statusText: "OK", + text: async () => "", + json: async () => ({ + results: [{ + id: "https://openalex.org/W124", + display_name: "Unsafe OA route", + publication_year: 2026, + primary_location: { + pdf_url: "https://127.0.0.1/article.pdf", + source: { display_name: "Unsafe Fixture" } + }, + open_access: { is_oa: true }, + authorships: [] + }] + }) + }), + oaFetch: async () => { + unsafeOaFetchCalled = true; + throw new Error("unsafe fetch should not execute"); + }, + fullTextParser: async () => { + throw new Error("unsafe parser should not execute"); + }, + env: { OPENALEX_API_KEY: "fixture-openalex" } + }); + assertEqual(unsafeOaFetchCalled, false, "private OA target is rejected before fetch"); + assertEqual(unsafeOaBundle.status, "waiting_for_checkpoint", "unsafe-only OA corpus blocks at access boundary"); + assertEqual( + unsafeOaBundle.artifacts.publicOaAcquisition.events[0].failureReason, + "restricted_access", + "unsafe OA target receives typed failure" + ); + + const pdfDirectory = join(workflowCwd, "pdf-input"); + mkdirSync(pdfDirectory, { recursive: true }); + writeFileSync(join(pdfDirectory, "fixture.pdf"), "%PDF fixture bytes", "utf8"); + const fullTextBundle = await scholar.runScholarResearchWorkflow({ + cwd: workflowCwd, + runId: "fulltext-run", + query: "durable scholarly research workflow", + sources: "crossref", + allowPartial: true, + pdfDirectory, + pdfAccessClass: "manual_legitimate_access", + fetch: fixtureFetch, + env: {}, + fullTextParser: async () => ({ + pageCount: 2, + pages: [ + { page: 1, text: "First page evidence", locator: "page:1", extraction: "text" }, + { page: 2, text: "Second page evidence", locator: "page:2", extraction: "ocr" } + ], + ocrPages: [2], + parser: { pdfinfo: "fixture", pdftotext: "fixture" } + }) + }); + assertEqual(fullTextBundle.stages.fulltext.status, "completed", "fulltext stage completed"); + assertEqual(fullTextBundle.stages.extract.status, "awaiting_provider", "extract packets await provider"); + assertEqual(fullTextBundle.artifacts.fullTextRecords[0].storageClass, "project_isolated", "manual PDF isolation"); + assert(fullTextBundle.artifacts.fullTextRecords[0].contentHash.startsWith("sha256:"), "content-addressed PDF hash"); + assert(existsSync(fullTextBundle.artifacts.fullTextRecords[0].derivedTextPath), "parsed fulltext artifact exists"); + assert(fullTextBundle.artifacts.extractionTasks.packets.length > 0, "bounded extraction packets created"); + const extractionPacket = JSON.parse(readFileSync(fullTextBundle.artifacts.extractionTasks.packets[0].path, "utf8")); + assertEqual(extractionPacket.stage, "extract", "extraction packet stage"); + assert(extractionPacket.boundedEvidence.every((span) => span.locator.includes("page:")), "extraction spans preserve page locators"); + assert(extractionPacket.instructions.some((line) => line.includes("never mark")), "packet prohibits provider-filled slots"); + const patchPath = join(workflowCwd, "provider-patch.json"); + writeFileSync(patchPath, JSON.stringify({ + schema: "longtable.provider-proposed-patch", + version: 1, + runId: fullTextBundle.runId, + stage: "extract", + operations: [{ + op: "add", + path: "/citationSlots/0", + value: { + id: "workflow-slot-1", + claim: "The fixture contains page evidence.", + status: "provisional", + relation: "supports", + evidence: extractionPacket.boundedEvidence[0] + } + }] + }), "utf8"); + const recordedPatch = await scholar.recordScholarResearchProviderPatch({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + packetPath: fullTextBundle.artifacts.extractionTasks.packets[0].path, + patchPath, + provider: "codex-fixture" + }); + assert(existsSync(recordedPatch.record.path), "validated provider patch persisted"); + const recordedAgain = await scholar.recordScholarResearchProviderPatch({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + packetPath: fullTextBundle.artifacts.extractionTasks.packets[0].path, + patchPath, + provider: "codex-fixture" + }); + assertEqual(recordedAgain.bundle.artifacts.providerPatches.length, 1, "provider patch recording is idempotent"); + const verificationPath = join(workflowCwd, "provider-verification.json"); + writeFileSync(verificationPath, JSON.stringify({ + schema: "longtable.provider-verification", + version: 1, + runId: fullTextBundle.runId, + packetId: recordedPatch.record.packetId, + provider: "claude-fixture", + independent: true, + proposalPatchHash: recordedPatch.record.patchHash, + decisions: [{ + citationSlotId: "workflow-slot-1", + decision: "agree", + evidenceSpanIds: [extractionPacket.boundedEvidence[0].spanId], + rationale: "The exact supplied span supports the bounded claim." + }] + }), "utf8"); + const verificationRecord = await scholar.recordScholarResearchVerification({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + verificationPath + }); + assertEqual(verificationRecord.bundle.stages.extract.status, "awaiting_human", "provider agreement does not replace human review"); + assertEqual(verificationRecord.adjudicationPaths.length, 1, "adjudication record created"); + const adjudicationBeforeHuman = JSON.parse( + readFileSync(verificationRecord.adjudicationPaths[0], "utf8") + ); + assertEqual(adjudicationBeforeHuman.stage, "extract", "adjudication records its semantic stage"); + assertEqual(adjudicationBeforeHuman.errorClass, "none", "agreement has explicit no-error class"); + assert(adjudicationBeforeHuman.inputBundleHash.startsWith("sha256:"), "adjudication binds the input bundle"); + assert(adjudicationBeforeHuman.replayFixtureRef.includes(adjudicationBeforeHuman.id), "adjudication is replay-addressable"); + + const humanReviewPath = join(workflowCwd, "human-review.json"); + writeFileSync(humanReviewPath, JSON.stringify({ + schema: "longtable.human-citation-review", + version: 1, + runId: fullTextBundle.runId, + reviewer: "human-fixture", + reviewedAt: "2026-07-27T00:00:00.000Z", + decisions: [{ + citationSlotId: "workflow-slot-1", + decision: "accept", + rationale: "The quote, locator, version, and claim relation were manually checked." + }] + }), "utf8"); + const humanReview = await scholar.recordScholarResearchHumanCitationReview({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + reviewPath: humanReviewPath + }); + assertEqual(humanReview.bundle.stages.extract.status, "completed", "human review completes extract"); + assertEqual(humanReview.citationSlots[0].status, "filled", "human-accepted strict slot becomes filled"); + assertEqual(scholar.citationSlotIsStrictlyFilled(humanReview.citationSlots[0]), true, "human accepted slot remains strict"); + const adjudicationAfterHuman = JSON.parse( + readFileSync(verificationRecord.adjudicationPaths[0], "utf8") + ); + assertEqual(adjudicationAfterHuman.humanDecision.adjudicatorRole, "domain_researcher", "adjudication records role-specific human authority"); + const synthesisReady = await scholar.resumeScholarResearchWorkflow({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + fetch: fixtureFetch, + env: {} + }); + assertEqual(synthesisReady.stages.synthesize.status, "awaiting_provider", "human-reviewed slots feed synthesis"); + assert(existsSync(synthesisReady.artifacts.synthesisTask.path), "bounded synthesis packet exists"); + const synthesisPacket = JSON.parse(readFileSync(synthesisReady.artifacts.synthesisTask.path, "utf8")); + assertEqual(synthesisPacket.citationSlots[0].status, "filled", "synthesis receives reviewed slot state"); + const synthesisPatchPath = join(workflowCwd, "synthesis-patch.json"); + writeFileSync(synthesisPatchPath, JSON.stringify({ + schema: "longtable.provider-proposed-patch", + version: 1, + runId: fullTextBundle.runId, + stage: "synthesize", + operations: [{ + op: "add", + path: "/synthesis", + value: { + title: "Fixture synthesis", + claims: [{ + id: "synthesis-claim-1", + text: "The fixture contains located page evidence.", + citationSlotIds: ["workflow-slot-1"], + role: "finding" + }], + readingOrder: ["synthesis-claim-1"] + } + }] + }), "utf8"); + const synthesisPatch = await scholar.recordScholarResearchProviderPatch({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + packetPath: synthesisReady.artifacts.synthesisTask.path, + patchPath: synthesisPatchPath, + provider: "claude-synthesis-fixture" + }); + assertEqual(synthesisPatch.record.stage, "synthesize", "synthesis proposal is stage-scoped"); + const synthesisVerificationPath = join(workflowCwd, "synthesis-verification.json"); + writeFileSync(synthesisVerificationPath, JSON.stringify({ + schema: "longtable.provider-synthesis-verification", + version: 1, + runId: fullTextBundle.runId, + packetId: synthesisPatch.record.packetId, + provider: "codex-synthesis-fixture", + independent: true, + proposalPatchHash: synthesisPatch.record.patchHash, + decisions: [{ + claimId: "synthesis-claim-1", + decision: "agree", + rationale: "The claim is bounded to the accepted citation slot." + }] + }), "utf8"); + const synthesisVerification = await scholar.recordScholarResearchSynthesisVerification({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + verificationPath: synthesisVerificationPath + }); + assertEqual(synthesisVerification.bundle.stages.synthesize.status, "awaiting_human", "synthesis verification preserves human gate"); + const synthesisReviewPath = join(workflowCwd, "synthesis-review.json"); + writeFileSync(synthesisReviewPath, JSON.stringify({ + schema: "longtable.human-synthesis-review", + version: 1, + runId: fullTextBundle.runId, + proposalPatchHash: synthesisPatch.record.patchHash, + reviewer: "human-fixture", + reviewedAt: "2026-07-27T00:05:00.000Z", + decisions: [{ + claimId: "synthesis-claim-1", + decision: "accept", + rationale: "The wording and direction match the reviewed evidence." + }] + }), "utf8"); + const synthesisReview = await scholar.recordScholarResearchHumanSynthesisReview({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + reviewPath: synthesisReviewPath + }); + assertEqual(synthesisReview.bundle.stages.synthesize.status, "completed", "human synthesis review completes synthesis"); + const visualBoundary = await scholar.resumeScholarResearchWorkflow({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + fetch: fixtureFetch, + env: {} + }); + assertEqual(visualBoundary.stages.visual_contract.status, "blocked", "visual implementation is blocked before human contract approval"); + assertEqual(visualBoundary.pendingCheckpoint.class, "visual_evidence_contract", "visual checkpoint class"); + const workflowVisualDataPath = join(workflowCwd, "visual-data.json"); + writeFileSync(workflowVisualDataPath, JSON.stringify([ + { scope: "Internal", coverage: 2 }, + { scope: "External", coverage: 5 } + ]), "utf8"); + const tableDataPath = join(workflowCwd, "table-data.json"); + writeFileSync(tableDataPath, JSON.stringify([ + { study: "A", scope: "Internal", count: 2 }, + { study: "B", scope: "External", count: 5 } + ]), "utf8"); + const diagramDataPath = join(workflowCwd, "diagram-data.json"); + writeFileSync(diagramDataPath, JSON.stringify({ + nodes: [ + { id: "extract", label: "Data extraction", layer: "performed" }, + { id: "audit", label: "Evidence audit", layer: "performed" }, + { id: "control", label: "Claim-control model", layer: "proposed" } + ], + edges: [ + { source: "extract", target: "audit", label: "performed" }, + { source: "audit", target: "control", label: "informs" } + ] + }), "utf8"); + const workflowVisualContractPath = join(workflowCwd, "visual-contract.json"); + const proposedFigureContract = { + ...approvedVisualContract, + status: "proposed", + approval: undefined, + dataContract: { + ...approvedVisualContract.dataContract, + dataSnapshotPath: workflowVisualDataPath + }, + output: { + ...approvedVisualContract.output, + renderer: "longtable-svg-v1", + rendererVersion: "1", + editableSourceFormat: "svg" + } + }; + writeFileSync(workflowVisualContractPath, JSON.stringify(proposedFigureContract), "utf8"); + const { axis: _unusedTableAxis, ...tableContractWithoutAxis } = proposedFigureContract; + const proposedTableContract = { + ...tableContractWithoutAxis, + id: "visual-table-fixture", + kind: "table", + analyticalQuestion: "Which study and scope values require exact lookup?", + readerTakeaway: "Exact values remain visible by study and validation scope.", + necessity: "A compact table supports precise row lookup.", + dataContract: { + ...proposedFigureContract.dataContract, + dataSnapshotPath: tableDataPath, + independentUnitField: "study" + } + }; + const tableContractPath = join(workflowCwd, "visual-table-contract.json"); + writeFileSync(tableContractPath, JSON.stringify(proposedTableContract), "utf8"); + const proposedDiagramContract = { + ...proposedFigureContract, + id: "visual-diagram-fixture", + kind: "diagram", + analyticalQuestion: "Which workflow layers were performed and which remain proposed?", + readerTakeaway: "Performed evidence work is visually separated from the proposed claim-control model.", + necessity: "A diagram is required to distinguish process and conceptual layers.", + evidenceBoundary: { + performed: ["Data extraction", "Evidence audit"], + proposed: ["Claim-control model"], + prohibitedInferences: ["The proposed model was empirically tested"] + }, + dataContract: { + ...proposedFigureContract.dataContract, + dataSnapshotPath: diagramDataPath, + unitOfAnalysis: "workflow node", + denominator: "all declared workflow nodes", + estimandOrConstruct: "performed versus proposed layer", + independentUnitField: "id" + }, + readingOrder: ["performed layer", "transition", "proposed layer", "boundary"] + }; + const diagramContractPath = join(workflowCwd, "visual-diagram-contract.json"); + writeFileSync(diagramContractPath, JSON.stringify(proposedDiagramContract), "utf8"); + const portfolioPlanPath = join(workflowCwd, "visual-portfolio-plan.json"); + writeFileSync(portfolioPlanPath, JSON.stringify({ + schema: "longtable.visual-portfolio-plan", + version: 1, + runId: fullTextBundle.runId, + portfolioId: "fixture-portfolio", + contractIds: [ + proposedFigureContract.id, + proposedTableContract.id, + proposedDiagramContract.id + ], + rationale: "The manuscript requires a pattern figure, exact-lookup table, and performed/proposed diagram.", + registeredBy: "human-visual-fixture", + registeredAt: "2026-07-27T00:09:00.000Z" + }), "utf8"); + const portfolio = JSON.parse(execFileSync("node", [ + cli, + "scholar-research", + "register-visual-portfolio", + "--cwd", workflowCwd, + "--run-id", fullTextBundle.runId, + "--plan", portfolioPlanPath, + "--json" + ], { + cwd: repoRoot, + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "" + } + })); + assertEqual(portfolio.plan.contractIds.length, 3, "visual portfolio freezes three contract IDs"); + const workflowVisualReviewPath = join(workflowCwd, "visual-review.json"); + writeFileSync(workflowVisualReviewPath, JSON.stringify({ + schema: "longtable.human-visual-contract-review", + version: 1, + runId: fullTextBundle.runId, + reviewer: "human-visual-fixture", + reviewedAt: "2026-07-27T00:10:00.000Z", + decisionRecordId: "decision-visual-workflow", + decision: "approve", + checks: { + domainMeaning: true, + statisticalIntegrity: true, + journalFit: true, + readerComprehension: true + }, + rationale: "All four review lenses and the 15-second recovery test passed." + }), "utf8"); + const visualReview = await scholar.recordScholarResearchHumanVisualReview({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + contractPath: workflowVisualContractPath, + reviewPath: workflowVisualReviewPath + }); + assertEqual(visualReview.contract.status, "approved", "human review approves valid visual contract"); + assertEqual(visualReview.bundle.stages.visual_contract.status, "blocked", "one portfolio approval does not complete checkpoint"); + const tableVisualReviewPath = join(workflowCwd, "visual-table-review.json"); + writeFileSync(tableVisualReviewPath, JSON.stringify({ + ...JSON.parse(readFileSync(workflowVisualReviewPath, "utf8")), + reviewedAt: "2026-07-27T00:11:00.000Z", + decisionRecordId: "decision-visual-table" + }), "utf8"); + const tableVisualReview = await scholar.recordScholarResearchHumanVisualReview({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + contractPath: tableContractPath, + reviewPath: tableVisualReviewPath + }); + assertEqual(tableVisualReview.bundle.stages.visual_contract.status, "blocked", "two portfolio approvals remain incomplete"); + const diagramVisualReviewPath = join(workflowCwd, "visual-diagram-review.json"); + writeFileSync(diagramVisualReviewPath, JSON.stringify({ + ...JSON.parse(readFileSync(workflowVisualReviewPath, "utf8")), + reviewedAt: "2026-07-27T00:12:00.000Z", + decisionRecordId: "decision-visual-diagram" + }), "utf8"); + const diagramVisualReview = await scholar.recordScholarResearchHumanVisualReview({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + contractPath: diagramContractPath, + reviewPath: diagramVisualReviewPath + }); + assertEqual(diagramVisualReview.bundle.stages.visual_contract.status, "completed", "all portfolio approvals complete checkpoint"); + const rendererBoundary = await scholar.resumeScholarResearchWorkflow({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + fetch: fixtureFetch, + env: {} + }); + assertEqual(rendererBoundary.stages.visual_contract.status, "completed", "visual approval survives resume"); + assertEqual(rendererBoundary.pauseReason.includes("allowlisted render"), true, "workflow pauses honestly before renderer implementation"); + const visualRenderRequestPath = join(workflowCwd, "visual-render-request.json"); + writeFileSync(visualRenderRequestPath, JSON.stringify({ + schema: "longtable.visual-render-request", + version: 1, + runId: fullTextBundle.runId, + contractId: visualReview.contract.id, + renderer: "longtable-svg-v1", + widthPx: 540, + heightPx: 360, + specification: { + kind: "figure", + mark: "bar", + xField: "scope", + yField: "coverage", + xLabel: "Validation scope", + yLabel: "Evidence count" + } + }), "utf8"); + const renderedVisual = await scholar.recordScholarResearchVisualRender({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + contractId: visualReview.contract.id, + requestPath: visualRenderRequestPath + }); + assertEqual(renderedVisual.qa.passed, true, "allowlisted render passes mechanical QA"); + assert(existsSync(renderedVisual.manifest.editableSourcePath), "editable SVG output exists"); + assertEqual(renderedVisual.bundle.stages.verify.status, "awaiting_human", "mechanical QA does not replace human render review"); + const tableRenderRequestPath = join(workflowCwd, "table-render-request.json"); + writeFileSync(tableRenderRequestPath, JSON.stringify({ + schema: "longtable.visual-render-request", + version: 1, + runId: fullTextBundle.runId, + contractId: tableVisualReview.contract.id, + renderer: "longtable-svg-v1", + widthPx: 540, + heightPx: 300, + specification: { + kind: "table", + columns: [ + { field: "study", label: "Study" }, + { field: "scope", label: "Validation scope" }, + { field: "count", label: "Count", align: "right" } + ] + } + }), "utf8"); + const tableRender = await scholar.recordScholarResearchVisualRender({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + contractId: tableVisualReview.contract.id, + requestPath: tableRenderRequestPath + }); + assertEqual(tableRender.qa.passed, true, "editable table rendering passes mechanical QA"); + const diagramRenderRequestPath = join(workflowCwd, "diagram-render-request.json"); + writeFileSync(diagramRenderRequestPath, JSON.stringify({ + schema: "longtable.visual-render-request", + version: 1, + runId: fullTextBundle.runId, + contractId: diagramVisualReview.contract.id, + renderer: "longtable-svg-v1", + widthPx: 640, + heightPx: 360, + specification: { + kind: "diagram", + nodeIdField: "id", + nodeLabelField: "label", + nodeLayerField: "layer", + edgeSourceField: "source", + edgeTargetField: "target", + edgeLabelField: "label" + } + }), "utf8"); + const diagramRender = await scholar.recordScholarResearchVisualRender({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + contractId: diagramVisualReview.contract.id, + requestPath: diagramRenderRequestPath + }); + assertEqual(diagramRender.qa.passed, true, "performed/proposed diagram rendering passes mechanical QA"); + const journalProfilePath = join(workflowCwd, "target-journal-profile.json"); + writeFileSync(journalProfilePath, JSON.stringify({ + schema: "longtable.target-journal-profile", + version: "1.0.0", + profileId: "fixture-journal-profile", + targetJournal: "Fixture Journal", + createdAt: "2026-07-27T00:14:00.000Z", + evidenceSources: [{ + sourceId: "doi:10.1000/visual", + title: "Fixture journal exemplar", + locator: "page:4", + contentHash: "sha256:fixture-journal-evidence", + accessClass: "public_oa" + }], + topicPatterns: ["Evidence-bound validation studies"], + formatPatterns: { + manuscriptStructure: ["Question, method, evidence, limitation"], + tables: ["Exact lookup values with denominators"], + figures: ["Direct labels and grayscale marks"], + diagrams: ["Performed and proposed layers are visibly distinct"] + }, + humanReview: { + reviewer: "human-journal-fixture", + reviewedAt: "2026-07-27T00:14:30.000Z", + decision: "accept", + notes: "Observed patterns match the exemplar set." + } + }), "utf8"); + await scholar.recordTargetJournalProfile({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + profile: JSON.parse(readFileSync(journalProfilePath, "utf8")) + }); + const renderedVisualReviewPath = join(workflowCwd, "rendered-visual-review.json"); + writeFileSync(renderedVisualReviewPath, JSON.stringify({ + schema: "longtable.human-rendered-visual-review", + version: 1, + runId: fullTextBundle.runId, + contractId: visualReview.contract.id, + reviewer: "human-reader-fixture", + reviewedAt: "2026-07-27T00:15:00.000Z", + decision: "accept", + checks: { + domainMeaning: true, + statisticalIntegrity: true, + journalFit: true, + readerComprehension: true + }, + readerTest: { + seconds: 15, + recoveredQuestion: true, + recoveredDenominator: true, + recoveredKeyValueOrRelation: true, + recoveredConclusion: true + }, + rationale: "The rendered figure preserved meaning and passed the timed recovery test." + }), "utf8"); + const renderedVisualReview = await scholar.recordScholarResearchHumanRenderedVisualReview({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + reviewPath: renderedVisualReviewPath + }); + assertEqual(renderedVisualReview.bundle.stages.verify.status, "awaiting_human", "one portfolio render review does not complete verification"); + const tableRenderedReviewPath = join(workflowCwd, "rendered-table-review.json"); + writeFileSync(tableRenderedReviewPath, JSON.stringify({ + ...JSON.parse(readFileSync(renderedVisualReviewPath, "utf8")), + contractId: tableVisualReview.contract.id, + reviewedAt: "2026-07-27T00:16:00.000Z", + rationale: "The exact-lookup table preserved every reviewed row and value." + }), "utf8"); + const tableRenderedReview = await scholar.recordScholarResearchHumanRenderedVisualReview({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + reviewPath: tableRenderedReviewPath + }); + assertEqual(tableRenderedReview.bundle.stages.verify.status, "awaiting_human", "two portfolio render reviews remain incomplete"); + const diagramRenderedReviewPath = join(workflowCwd, "rendered-diagram-review.json"); + writeFileSync(diagramRenderedReviewPath, JSON.stringify({ + ...JSON.parse(readFileSync(renderedVisualReviewPath, "utf8")), + contractId: diagramVisualReview.contract.id, + reviewedAt: "2026-07-27T00:17:00.000Z", + rationale: "The diagram clearly separated performed and proposed layers." + }), "utf8"); + const diagramRenderedReview = await scholar.recordScholarResearchHumanRenderedVisualReview({ + cwd: workflowCwd, + runId: fullTextBundle.runId, + reviewPath: diagramRenderedReviewPath + }); + assertEqual(diagramRenderedReview.bundle.stages.verify.status, "completed", "all portfolio render reviews complete verification"); + assertEqual(diagramRenderedReview.bundle.stages.handoff.status, "completed", "verified portfolio writes handoff"); + assertEqual(diagramRenderedReview.bundle.status, "completed", "portfolio fixture workflow completes"); + assert(existsSync(diagramRenderedReview.bundle.artifacts.verifiedPackage.manifestPath), "verified package manifest exists"); + assert(existsSync(diagramRenderedReview.bundle.artifacts.verifiedPackage.verificationPath), "verified package verification exists"); + const runVerification = await scholar.verifyScholarResearchRun({ + cwd: workflowCwd, + runId: fullTextBundle.runId + }); + assertEqual(runVerification.passed, true, "completed portfolio run passes durable readback verification"); + const cliStatus = JSON.parse(execFileSync("node", [ + cli, + "scholar-research", + "status", + "--cwd", workflowCwd, + "--run-id", fullTextBundle.runId, + "--json" + ], { + cwd: repoRoot, + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "" + } + })); + assertEqual(cliStatus.status, "completed", "CLI status reads the durable bundle"); + const cliExplanation = JSON.parse(execFileSync("node", [ + cli, + "scholar-research", + "explain", + "--cwd", workflowCwd, + "--run-id", fullTextBundle.runId, + "--stage", "verify", + "--json" + ], { + cwd: repoRoot, + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "" + } + })); + assertEqual(cliExplanation.state.status, "completed", "CLI explain reads stage state and events"); + const cliVerification = JSON.parse(execFileSync("node", [ + cli, + "scholar-research", + "verify", + "--cwd", workflowCwd, + "--run-id", fullTextBundle.runId, + "--json" + ], { + cwd: repoRoot, + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "" + } + })); + assertEqual(cliVerification.passed, true, "CLI verify independently reads durable artifacts"); + + const blockedBundle = await scholar.runScholarResearchWorkflow({ + cwd: workflowCwd, + runId: "access-blocked-run", + query: "durable scholarly research workflow", + sources: "crossref", + allowPartial: true, + pdfDirectory, + fetch: fixtureFetch, + env: {} + }); + assertEqual(blockedBundle.status, "waiting_for_checkpoint", "missing PDF access class blocks"); + assertEqual(blockedBundle.pendingCheckpoint.class, "access_corpus_boundary", "access checkpoint class"); + const accessResumed = await scholar.resumeScholarResearchWorkflow({ + cwd: workflowCwd, + runId: blockedBundle.runId, + pdfAccessClass: "private", + fetch: fixtureFetch, + env: {}, + fullTextParser: async () => ({ + pageCount: 1, + pages: [{ page: 1, text: "Private corpus evidence", locator: "page:1", extraction: "text" }], + ocrPages: [], + parser: { pdfinfo: "fixture", pdftotext: "fixture" } + }) + }); + assertEqual(accessResumed.stages.fulltext.status, "completed", "access amendment resumes fulltext"); + assertEqual(accessResumed.artifacts.fullTextRecords[0].accessClass, "private", "amended access class recorded"); +} finally { + rmSync(workflowCwd, { recursive: true, force: true }); +} + const cwd = mkdtempSync(join(tmpdir(), "longtable-scholar-smoke-")); try { const scaffold = JSON.parse(execFileSync("node", [ @@ -70,6 +1043,271 @@ try { assert(existsSync(join(cwd, ".longtable", "research-runs", "smoke-run", "journal.md")), "journal scaffold exists"); assert(existsSync(join(cwd, ".longtable", "research-runs", "smoke-run", "evidence-ledger.md")), "evidence ledger scaffold exists"); assert(existsSync(join(cwd, ".longtable", "research-runs", "smoke-run", "citation-slot-matrix.md")), "citation slot scaffold exists"); + const evaluation = await scholar.writeScholarResearchEvaluationPlan({ + cwd, + planId: "smoke-evaluation" + }); + const observationPath = join(cwd, "evaluation-observation.json"); + writeFileSync(observationPath, JSON.stringify({ + schema: "longtable.scholar-research-evaluation-observation", + version: 1, + observationId: "prospective-smoke-1", + planId: evaluation.plan.planId, + taskId: "visual-e2e-1", + layer: "prospective", + condition: "workflow_on", + crossoverPairId: "pair-1", + provider: "codex", + model: "fixture", + modelVersion: "1", + corpusCutoff: "2026-07-27", + permissionsProfile: "fixture-local-only", + stageProfile: ["extract-verifier", "visual-contract", "human-render-review"], + startedAt: "2026-07-27T00:00:00.000Z", + acceptedArtifactAt: "2026-07-27T00:09:00.000Z", + completedAt: "2026-07-27T00:10:00.000Z", + quality: { + unsupportedFinalClaim: false, + wrongSourceVersionOrCitation: false, + visualDistortion: false, + prohibitedAccess: false, + preapprovalMutation: false + }, + metrics: { + elapsedMs: 600000, + activeHumanMs: 120000, + interruptionCount: 1, + repeatedQuestionCount: 0, + contextSwitchCount: 1, + reworkCount: 0, + toolCallCount: 12, + abandoned: false, + researcherEffort: 3, + researcherTrust: 6 + }, + artifactRefs: ["fixture-artifact.svg"], + notes: ["prospective fixture only"] + }), "utf8"); + const recordedObservation = await scholar.recordScholarResearchEvaluationObservation({ + cwd, + planId: evaluation.plan.planId, + observationPath + }); + assert(existsSync(recordedObservation.path), "evaluation observation ledger exists"); + const recordedObservationAgain = await scholar.recordScholarResearchEvaluationObservation({ + cwd, + planId: evaluation.plan.planId, + observationPath + }); + assertEqual(recordedObservationAgain.path, recordedObservation.path, "evaluation observation recording is idempotent"); + const trialConfigPath = join(cwd, "prospective-trial-config.json"); + writeFileSync(trialConfigPath, JSON.stringify({ + schema: "longtable.prospective-trial-config", + version: 1, + planId: evaluation.plan.planId, + trialId: "smoke-crossover", + taskId: "visual-e2e-2", + researchQuestion: "Which visual contract best preserves report-level evidence?", + targetJournal: "Educational Psychology Review", + provider: "codex", + model: "fixture", + modelVersion: "1", + corpusCutoff: "2026-07-27", + permissionsProfile: "fixture-local-only", + crossoverSeed: "smoke-seed", + workflowOnStages: ["visual-contract", "human-render-review"], + workflowOffStages: ["human-render-review"] + }), "utf8"); + const trial = await scholar.createProspectiveTrialProtocol({ + cwd, + configPath: trialConfigPath + }); + assertEqual(trial.protocol.conditionOrder.length, 2, "trial has two crossover conditions"); + assert( + trial.protocol.conditionOrder.every((condition) => + scholar.PROSPECTIVE_TRIAL_PROTECTED_STAGES.every((stage) => + condition.stageProfile.includes(stage) + ) + ), + "protected stages are injected into both conditions" + ); + assert( + trial.protocol.conditionOrder[0].condition !== trial.protocol.conditionOrder[1].condition, + "trial condition order contains workflow on and off" + ); + const trialAgain = await scholar.createProspectiveTrialProtocol({ + cwd, + configPath: trialConfigPath + }); + assertEqual(trialAgain.protocol.createdAt, trial.protocol.createdAt, "trial creation is idempotent"); + const session = await scholar.startProspectiveTrialSession({ + cwd, + planId: evaluation.plan.planId, + trialId: trial.protocol.trialId, + conditionIndex: 0 + }); + const sessionAgain = await scholar.startProspectiveTrialSession({ + cwd, + planId: evaluation.plan.planId, + trialId: trial.protocol.trialId, + conditionIndex: 0 + }); + assertEqual(sessionAgain.session.startedAt, session.session.startedAt, "trial start is idempotent"); + assertEqual( + session.taskCapsule.researchQuestion, + trial.protocol.researchQuestion, + "task capsule freezes the research question" + ); + const trialResultPath = join(cwd, "prospective-trial-result.json"); + writeFileSync(trialResultPath, JSON.stringify({ + schema: "longtable.prospective-trial-session-result", + version: 1, + acceptedArtifactAt: session.session.startedAt, + activeHumanMs: 0, + interruptionCount: 0, + repeatedQuestionCount: 0, + contextSwitchCount: 0, + reworkCount: 0, + toolCallCount: 1, + abandoned: false, + researcherEffort: 2, + researcherTrust: 6, + quality: { + unsupportedFinalClaim: false, + wrongSourceVersionOrCitation: false, + visualDistortion: false, + prohibitedAccess: false, + preapprovalMutation: false + }, + artifactRefs: ["fixture-crossover-artifact.svg"], + notes: ["prospective trial fixture"] + }), "utf8"); + const finished = await scholar.finishProspectiveTrialSession({ + cwd, + planId: evaluation.plan.planId, + trialId: trial.protocol.trialId, + conditionIndex: 0, + resultPath: trialResultPath + }); + assertEqual(finished.session.status, "completed", "trial finish completes the session"); + const finishedAgain = await scholar.finishProspectiveTrialSession({ + cwd, + planId: evaluation.plan.planId, + trialId: trial.protocol.trialId, + conditionIndex: 0, + resultPath: trialResultPath + }); + assertEqual( + finishedAgain.observation.observationId, + finished.observation.observationId, + "trial finish is idempotent" + ); + const trialStatus = await scholar.readProspectiveTrialStatus({ + cwd, + planId: evaluation.plan.planId, + trialId: trial.protocol.trialId + }); + assertEqual(trialStatus.nextConditionIndex, 1, "trial status advances to the second condition"); + const cliTrial = JSON.parse(execFileSync("node", [ + cli, + "scholar-research", + "trial-create", + "--cwd", cwd, + "--config", trialConfigPath, + "--json" + ], { + cwd: repoRoot, + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "" + } + })); + assertEqual(cliTrial.protocol.createdAt, trial.protocol.createdAt, "CLI trial-create is idempotent"); + const cliSession = JSON.parse(execFileSync("node", [ + cli, + "scholar-research", + "trial-start", + "--cwd", cwd, + "--plan-id", evaluation.plan.planId, + "--trial-id", trial.protocol.trialId, + "--condition-index", "1", + "--json" + ], { + cwd: repoRoot, + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "" + } + })); + const cliTrialResultPath = join(cwd, "prospective-trial-result-cli.json"); + writeFileSync(cliTrialResultPath, JSON.stringify({ + schema: "longtable.prospective-trial-session-result", + version: 1, + acceptedArtifactAt: cliSession.session.startedAt, + activeHumanMs: 0, + interruptionCount: 0, + repeatedQuestionCount: 0, + contextSwitchCount: 0, + reworkCount: 0, + toolCallCount: 1, + abandoned: false, + researcherEffort: 2, + researcherTrust: 6, + quality: { + unsupportedFinalClaim: false, + wrongSourceVersionOrCitation: false, + visualDistortion: false, + prohibitedAccess: false, + preapprovalMutation: false + }, + artifactRefs: ["fixture-crossover-artifact-cli.svg"], + notes: ["prospective trial CLI fixture"] + }), "utf8"); + const cliFinished = JSON.parse(execFileSync("node", [ + cli, + "scholar-research", + "trial-finish", + "--cwd", cwd, + "--plan-id", evaluation.plan.planId, + "--trial-id", trial.protocol.trialId, + "--condition-index", "1", + "--result", cliTrialResultPath, + "--json" + ], { + cwd: repoRoot, + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "" + } + })); + assertEqual(cliFinished.session.status, "completed", "CLI trial-finish completes the session"); + const cliStatus = JSON.parse(execFileSync("node", [ + cli, + "scholar-research", + "trial-status", + "--cwd", cwd, + "--plan-id", evaluation.plan.planId, + "--trial-id", trial.protocol.trialId, + "--json" + ], { + cwd: repoRoot, + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? "" + } + })); + assertEqual(cliStatus.nextConditionIndex, null, "CLI trial-status reports a completed pair"); + const evaluationReport = await scholar.writeScholarResearchEvaluationReport({ + cwd, + planId: evaluation.plan.planId + }); + assertEqual(evaluationReport.report.prospectiveCount, 3, "prospective observation count"); + assertEqual(evaluationReport.report.matchedProspectivePairs, 1, "complete crossover pair count"); + assertEqual(evaluationReport.report.promotionEligible, false, "one crossover pair cannot promote workflow"); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -95,13 +1333,16 @@ assert(!doctor.scholarResearch.connectors.some((connector) => connector.name === const roles = personas.listRoleDefinitions(); const codexSkillNames = codex.buildCodexSkillSpecs(roles, "compact").map((skill) => skill.name); -assert(codexSkillNames.includes("scholar-research"), "Codex skill bundle includes scholar-research"); +assert(codexSkillNames.includes("longtable-research"), "Codex skill bundle includes longtable-research"); assert(!codexSkillNames.includes("critical-interview"), "Codex skill bundle should not include critical-interview"); -const codexScholar = codex.buildCodexSkillSpecs(roles, "compact").find((skill) => skill.name === "scholar-research"); -assert(codexScholar.body.join("\n").includes("Do not bypass paywalls"), "Codex scholar-research states safety boundary"); +const codexScholar = codex.buildCodexSkillSpecs(roles, "compact").find((skill) => skill.name === "longtable-research"); +assert(codexScholar.body.join("\n").includes("Do not bypass paywalls"), "Codex longtable-research states safety boundary"); const claudeSkillNames = claude.buildClaudeSkillSpecs(roles, "compact").map((skill) => skill.name); -assert(claudeSkillNames.includes("scholar-research"), "Claude skill bundle includes scholar-research"); +assert(claudeSkillNames.includes("longtable-research"), "Claude skill bundle includes longtable-research"); assert(!claudeSkillNames.includes("critical-interview"), "Claude skill bundle should not include critical-interview"); +const claudeScholar = claude.buildClaudeSkillSpecs(roles, "compact").find((skill) => skill.name === "longtable-research"); +assertEqual(claudeScholar.body.join("\n"), codexScholar.body.join("\n"), "Codex and Claude longtable-research semantics match"); +assert(codexScholar.body.join("\n").includes("provider-tasks/extract/manifest.json"), "provider skill explains bounded extraction packets"); console.log("scholar research smoke passed"); diff --git a/scripts/smoke-setup-flow.mjs b/scripts/smoke-setup-flow.mjs index 6c3ca3f9..450bad67 100644 --- a/scripts/smoke-setup-flow.mjs +++ b/scripts/smoke-setup-flow.mjs @@ -56,8 +56,8 @@ if (setupJson.setup.initialState.explicitState.installScope !== "none") { if (setupJson.setup.initialState.explicitState.runtimeSurfaces !== "cli_only") { throw new Error("Setup did not record runtime surface."); } -if (setupJson.setup.initialState.explicitState.officialStartSurface !== "$longtable-start") { - throw new Error("Setup should point researchers to $longtable-start."); +if (setupJson.setup.initialState.explicitState.officialStartSurface !== "$longtable") { + throw new Error("Setup should point researchers to the consolidated $longtable router."); } if (["t", "muxMode"].join("") in setupJson.setup.initialState.explicitState) { throw new Error("Setup should not record a console-specific mode."); @@ -140,8 +140,8 @@ const movedStart = runCli([ ], { stdio: ["ignore", "pipe", "pipe"] }); -if (!movedStart.includes("$longtable-start")) { - throw new Error("Interactive longtable start should direct researchers to $longtable-start."); +if (!movedStart.includes("$longtable")) { + throw new Error("Interactive longtable start should direct researchers to the $longtable router."); } const skillsDir = join(tmp, "codex-skills"); @@ -149,72 +149,31 @@ const staleCriticalInterviewDir = join(skillsDir, "critical-interview"); mkdirSync(staleCriticalInterviewDir, { recursive: true }); writeFileSync(join(staleCriticalInterviewDir, "SKILL.md"), "stale critical-interview alias", "utf8"); const installOutput = runCli(["codex", "install-skills", "--dir", skillsDir]); -if (!installOutput.includes("longtable-start")) { - throw new Error("Codex skill install should include longtable-start."); +if (!installOutput.includes("longtable") || !installOutput.includes("longtable-research")) { + throw new Error("Compact Codex skill install should include the two consolidated surfaces."); } -if (!installOutput.includes("longtable-interview")) { - throw new Error("Codex skill install should include longtable-interview."); -} -if (!installOutput.includes("longtable-methods") || !installOutput.includes("longtable-measure")) { - throw new Error("Compact Codex skill install should include short role shortcuts."); -} -if (!installOutput.includes("longtable-panel")) { - throw new Error("Compact Codex skill install should include the primary panel shortcut."); -} -if (installOutput.includes("longtable-methods-critic")) { - throw new Error("Compact Codex skill install should not expose full role shortcuts by default."); -} -const startSkill = readFileSync(join(skillsDir, "longtable-start", "SKILL.md"), "utf8"); -const interviewSkill = readFileSync(join(skillsDir, "longtable-interview", "SKILL.md"), "utf8"); -if (!startSkill.includes("First Research Shape")) { - throw new Error("longtable-start skill should document First Research Shape."); -} -if (!startSkill.includes("Research Specification") || !startSkill.includes("confirm_research_specification")) { - throw new Error("longtable-start skill should document Research Specification confirmation."); -} -if (!startSkill.includes("must not be treated as the default endpoint")) { - throw new Error("longtable-start skill should demote First Research Shape from the default endpoint."); -} -if (!startSkill.includes("Do not begin with reader/reviewer")) { - throw new Error("longtable-start skill should forbid early reader/reviewer prompts."); -} -if (!startSkill.includes("Closure Readiness") || !startSkill.includes("never stop merely because a fixed number of turns has passed")) { - throw new Error("longtable-start skill should document content-based closure readiness."); -} -if (!startSkill.includes("append_interview_turn") || !startSkill.includes("readyToSummarize")) { - throw new Error("longtable-start skill should document durable turn recording and readiness signals."); -} -if (!startSkill.includes("summarize_research_specification")) { - throw new Error("longtable-start skill should store the Research Specification through MCP."); -} -if (!startSkill.includes("one main uncertainty") || !startSkill.includes("mini-questionnaire")) { - throw new Error("longtable-start skill should softly document one-question-at-a-time behavior."); -} -if (interviewSkill.includes("option-first") || interviewSkill.includes("ordinary follow-up")) { - throw new Error("longtable-interview skill should not keep the old ordinary option-first mode."); +for (const removed of ["longtable-start", "longtable-interview", "longtable-panel", "longtable-methods", "longtable-measure"]) { + if (installOutput.includes(removed) || existsSync(join(skillsDir, removed))) { + throw new Error(`Compact Codex skill install should not expose ${removed}.`); + } } -if (interviewSkill.includes("route to `$longtable-start` immediately")) { - throw new Error("longtable-interview skill should no longer route grilling interviews away from itself."); +const compactRouter = readFileSync(join(skillsDir, "longtable", "SKILL.md"), "utf8"); +if (!compactRouter.includes("exposes exactly `$longtable` and `$longtable-research`")) { + throw new Error("Compact LongTable router should document the consolidated surface."); } -if ( - !interviewSkill.includes("LongTable grilling interview") || - !interviewSkill.includes("Tension:") || - !interviewSkill.includes("Pressure question:") || - !interviewSkill.includes("Ask exactly one question per turn") || - !interviewSkill.includes("Wait for the researcher's direct answer") -) { - throw new Error("longtable-interview skill should be a simple pressure-question grilling loop."); +const fullSkillsDir = join(tmp, "codex-skills-full"); +const fullInstallOutput = runCli(["codex", "install-skills", "--surface", "full", "--dir", fullSkillsDir]); +if (!fullInstallOutput.includes("longtable-research")) { + throw new Error("Deprecated full install option should resolve to the two-skill surface."); } -if ( - interviewSkill.includes("recommended answer") || - interviewSkill.includes("accept, revise, or reject") || - interviewSkill.includes("No option menus") || - interviewSkill.includes("approval prompts") -) { - throw new Error("longtable-interview skill should not use the old recommended-answer approval frame."); +for (const removed of ["scholar-research", "longtable-start", "longtable-interview", "longtable-panel", "longtable-methods-critic"]) { + if (existsSync(join(fullSkillsDir, removed))) { + throw new Error(`Full compatibility option must prune legacy skill ${removed}.`); + } } -if (!interviewSkill.includes("remaining questions repeat the same tension without producing a new decision")) { - throw new Error("longtable-interview skill should preserve the grilling stop rule."); +const fullRouter = readFileSync(join(fullSkillsDir, "longtable", "SKILL.md"), "utf8"); +if (!fullRouter.includes("Ask at most three clarifying questions") || !fullRouter.includes("Research Brief")) { + throw new Error("LongTable router should enforce the bounded Research Brief contract."); } if (installOutput.includes("critical-interview")) { throw new Error("Codex skill install should not include critical-interview."); @@ -227,8 +186,12 @@ const staleClaudeCriticalInterviewDir = join(claudeSkillsDir, "critical-intervie mkdirSync(staleClaudeCriticalInterviewDir, { recursive: true }); writeFileSync(join(staleClaudeCriticalInterviewDir, "SKILL.md"), "stale critical-interview alias", "utf8"); const claudeInstallOutput = runCli(["claude", "install-skills", "--dir", claudeSkillsDir]); -if (!claudeInstallOutput.includes("longtable-interview")) { - throw new Error("Claude skill install should include longtable-interview."); +if (!claudeInstallOutput.includes("longtable") || !claudeInstallOutput.includes("longtable-research")) { + throw new Error("Compact Claude skill install should include the two consolidated surfaces."); +} +if (claudeInstallOutput.includes("longtable-interview") || + existsSync(join(claudeSkillsDir, "longtable-interview"))) { + throw new Error("Compact Claude skill install should not expose longtable-interview."); } if (claudeInstallOutput.includes("critical-interview")) { throw new Error("Claude skill install should not include critical-interview."); @@ -253,10 +216,4 @@ if (!codexMcpContent.includes("[mcp_servers.longtable-state.tools.summarize_rese if (!codexMcpContent.includes("[mcp_servers.longtable-state.tools.confirm_research_specification]")) { throw new Error("MCP install snippet should approve confirm_research_specification."); } -const fullSkillsDir = join(tmp, "codex-skills-full"); -const fullInstallOutput = runCli(["codex", "install-skills", "--surface", "full", "--dir", fullSkillsDir]); -if (!fullInstallOutput.includes("longtable-methods-critic") || !fullInstallOutput.includes("longtable-panel")) { - throw new Error("Full Codex skill install should preserve the full skill surface."); -} - console.log("setup/start smoke passed");