generated from coulomb/repo-seed
Compare commits
8 Commits
a883a90568
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e2f35ca5e | |||
| ed64f0c57d | |||
| c2c86c7c7c | |||
| 75e85a1e36 | |||
| 697bc90780 | |||
| a9e3b76a38 | |||
| 78085e1eb3 | |||
| 62ba1d1140 |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
## Kaizen Agents
|
||||||
|
|
||||||
|
Specialized agent personas available on demand via the state-hub MCP.
|
||||||
|
|
||||||
|
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
|
||||||
|
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
|
||||||
|
|
||||||
|
Common agents:
|
||||||
|
|
||||||
|
| Agent | Category | When to use |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
|
||||||
|
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
|
||||||
|
| `test-maintenance` | testing | Diagnose and fix failing tests |
|
||||||
|
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
|
||||||
|
| `keepaTodofile` | process | Maintain TODO.md during work |
|
||||||
|
| `project-management` | process | Track status, determine next steps |
|
||||||
|
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
|
||||||
|
|
||||||
|
All 17 agents: call `list_kaizen_agents()` for the full list.
|
||||||
8
.claude/rules/architecture.md
Normal file
8
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## Architecture
|
||||||
|
|
||||||
|
<!-- TODO: Describe the key design decisions and component structure.
|
||||||
|
Key modules, data flows, external integrations, state machines, etc. -->
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||||
50
.claude/rules/credential-routing.md
Normal file
50
.claude/rules/credential-routing.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Credential and access routing
|
||||||
|
|
||||||
|
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||||
|
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||||
|
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||||
|
|
||||||
|
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||||
|
other credential need belongs to another subsystem. **Do not** message
|
||||||
|
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||||
|
|
||||||
|
### Lookup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
warden route find "<describe your need>" --json
|
||||||
|
warden route show <catalog-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||||
|
|
||||||
|
| Agent runtime | How to orient |
|
||||||
|
| --- | --- |
|
||||||
|
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=citation-engine` is for coordination, not secret vending |
|
||||||
|
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||||
|
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||||
|
|
||||||
|
### Quick routing table
|
||||||
|
|
||||||
|
| I need… | Owner | ops-warden executes? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||||
|
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||||
|
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||||
|
| Authorization decision | flex-auth | No — route only |
|
||||||
|
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||||
|
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||||
|
|
||||||
|
### Anti-patterns (do not do these)
|
||||||
|
|
||||||
|
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||||
|
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||||
|
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||||
|
|
||||||
|
### Other capabilities (reuse-surface)
|
||||||
|
|
||||||
|
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||||
|
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||||
|
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||||
|
get wrong.
|
||||||
|
|
||||||
|
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
## First Session Protocol
|
||||||
|
|
||||||
|
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
|
||||||
|
The project is registered but work has not yet been structured.
|
||||||
|
|
||||||
|
**Step 1 — Read, don't write**
|
||||||
|
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
|
||||||
|
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
|
||||||
|
- Scan repo root: README, directory structure, existing code or docs
|
||||||
|
|
||||||
|
**Step 2 — Survey in-progress work**
|
||||||
|
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
|
||||||
|
|
||||||
|
**Step 3 — Propose workstreams to Bernd**
|
||||||
|
Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to a
|
||||||
|
roadmap phase. **Wait for approval before creating.**
|
||||||
|
|
||||||
|
**Step 4 — Create workplan file first, then DB record (ADR-001)**
|
||||||
|
```
|
||||||
|
workplans/CENG-WP-NNNN-<slug>.md ← write this first
|
||||||
|
```
|
||||||
|
Then register in the hub:
|
||||||
|
```
|
||||||
|
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
|
||||||
|
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5 — Record the setup**
|
||||||
|
```
|
||||||
|
add_progress_event(
|
||||||
|
summary="First session: structured infotech into N workstreams, M tasks",
|
||||||
|
event_type="milestone",
|
||||||
|
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
|
||||||
|
detail={"workstreams": [...], "tasks_created": M}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Delete or archive this file once past first session -->
|
||||||
8
.claude/rules/repo-boundary.md
Normal file
8
.claude/rules/repo-boundary.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## Repo boundary
|
||||||
|
|
||||||
|
This repo owns **citation-engine** only. It does not own:
|
||||||
|
|
||||||
|
<!-- TODO: List what belongs in adjacent repos, e.g.:
|
||||||
|
- SSH key management → railiance-infra/
|
||||||
|
- State hub code → state-hub/
|
||||||
|
-->
|
||||||
5
.claude/rules/repo-identity.md
Normal file
5
.claude/rules/repo-identity.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
**Purpose:** Core domain model, API contracts, persistence interfaces, citation rendering. Leaf node — depends on no other subsystem. INTENT-only during umbrella-first MVP.
|
||||||
|
|
||||||
|
**Domain:** infotech
|
||||||
|
**Repo slug:** citation-engine
|
||||||
|
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a
|
||||||
85
.claude/rules/session-protocol.md
Normal file
85
.claude/rules/session-protocol.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
## Session Protocol
|
||||||
|
|
||||||
|
Dev Hub (State Hub API): http://127.0.0.1:8000
|
||||||
|
MCP server name in `~/.claude.json`: `dev-hub`
|
||||||
|
|
||||||
|
**Step 1 — Orient**
|
||||||
|
|
||||||
|
Read the offline-safe brief first — it works without a live hub connection:
|
||||||
|
```bash
|
||||||
|
cat .custodian-brief.md
|
||||||
|
```
|
||||||
|
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
|
||||||
|
```
|
||||||
|
get_domain_summary("infotech")
|
||||||
|
```
|
||||||
|
If MCP tools are unavailable in the current agent session, use the REST API:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
If the hub is offline: `cd ~/state-hub && make api`
|
||||||
|
|
||||||
|
**Step 2 — Check inbox**
|
||||||
|
With MCP tools:
|
||||||
|
```
|
||||||
|
get_messages(to_agent="citation-engine", unread_only=True)
|
||||||
|
```
|
||||||
|
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
|
||||||
|
requests before proceeding.
|
||||||
|
|
||||||
|
Without MCP tools:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/messages/?to_agent=citation-engine&unread_only=true" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||||
|
-H "Content-Type: application/json" -d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3 — Scan workplans**
|
||||||
|
```bash
|
||||||
|
ls workplans/
|
||||||
|
```
|
||||||
|
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||||
|
`wait`/`todo`/`progress` tasks.
|
||||||
|
|
||||||
|
**Step 4 — Present brief**
|
||||||
|
|
||||||
|
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
|
||||||
|
2. **Pending tasks** from `workplans/` + any `[repo:citation-engine]` hub tasks
|
||||||
|
3. **Goal guidance** — if `goal_guidance` in summary:
|
||||||
|
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
|
||||||
|
- `alignment_warnings`: flag if active work is not aligned with current goal
|
||||||
|
4. **Suggested next action** — highest-priority open item
|
||||||
|
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
|
||||||
|
|
||||||
|
If no workstreams: follow First Session Protocol (`first-session.md`).
|
||||||
|
|
||||||
|
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
|
||||||
|
|
||||||
|
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
|
||||||
|
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
|
||||||
|
|
||||||
|
**Session close:**
|
||||||
|
With MCP tools:
|
||||||
|
```
|
||||||
|
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
|
||||||
|
```
|
||||||
|
Without MCP tools:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
|
||||||
|
```
|
||||||
|
If workplan files were modified, ensure the local copy is up to date first:
|
||||||
|
```bash
|
||||||
|
git -C <repo_path> pull --ff-only
|
||||||
|
cd ~/state-hub && make fix-consistency REPO=citation-engine
|
||||||
|
```
|
||||||
|
For repos where implementation runs on a remote machine (e.g. CoulombCore),
|
||||||
|
use the combined target which pulls before fixing:
|
||||||
|
```bash
|
||||||
|
cd ~/state-hub && make fix-consistency-remote REPO=citation-engine
|
||||||
|
```
|
||||||
|
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
|
||||||
|
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
|
||||||
|
until you pull — intentional to prevent clobbering remote progress.
|
||||||
19
.claude/rules/stack-and-commands.md
Normal file
19
.claude/rules/stack-and-commands.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Stack
|
||||||
|
|
||||||
|
<!-- TODO: Fill in language, frameworks, and key dependencies -->
|
||||||
|
- **Language:**
|
||||||
|
- **Key deps:**
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TODO: Fill in the standard commands for this repo
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
|
||||||
|
# Lint / type check
|
||||||
|
|
||||||
|
# Build / package (if applicable)
|
||||||
|
```
|
||||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
|
File location: `workplans/CENG-WP-NNNN-<slug>.md`
|
||||||
|
ID prefix: `CENG-WP-`
|
||||||
|
|
||||||
|
Work items originate as files in this repo **before** being registered in the hub.
|
||||||
|
|
||||||
|
Canonical workplan/workstream frontmatter statuses are:
|
||||||
|
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
|
||||||
|
Use `proposed` for a newly drafted plan, `ready` after review against current
|
||||||
|
repo state, and `finished` when implementation is complete. `stalled` and
|
||||||
|
`needs_review` are derived health labels, not stored statuses.
|
||||||
|
|
||||||
|
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||||
|
prefix: `YYMMDD-CENG-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||||
|
unchanged; the prefix is only for quick visual reference.
|
||||||
|
|
||||||
|
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
|
||||||
|
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
|
||||||
|
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
|
||||||
|
directly. Promote anything requiring analysis, design, approval, dependencies, or
|
||||||
|
multiple planned phases into a normal workplan.
|
||||||
|
|
||||||
|
Ecosystem todos from other agents arrive as `[repo:citation-engine]` hub tasks —
|
||||||
|
visible at session start. Pick one up by creating the workplan file, then registering
|
||||||
|
the workstream.
|
||||||
|
|
||||||
|
Task blocks use this shape:
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CENG-WP-NNNN-T01
|
||||||
|
status: wait | todo | progress | done | cancel
|
||||||
|
priority: high | medium | low
|
||||||
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression is `todo` → `progress` → `done`; use `wait` for waiting or
|
||||||
|
blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||||
# Custodian Brief — citation-engine
|
# Custodian Brief — citation-engine
|
||||||
|
|
||||||
**Domain:** citation_evidence
|
**Domain:** infotech
|
||||||
**Last synced:** 2026-06-21 23:28 UTC
|
**Last synced:** 2026-06-22 18:27 UTC
|
||||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||||
|
|
||||||
## Active Workstreams
|
## Active Workstreams
|
||||||
|
|
||||||
### INTENT placeholder — await extraction from citation-evidence
|
*(none — repo may need first-session setup)*
|
||||||
Progress: 0/1 done | workstream_id: `ccd217a2-572d-435d-9a29-e0a55b6a4311`
|
|
||||||
|
|
||||||
**Open tasks:**
|
|
||||||
- ! Extraction gate `755e36b7`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
## MCP Orientation (when available)
|
## MCP Orientation (when available)
|
||||||
|
|
||||||
If the state-hub MCP server is reachable, call:
|
If the state-hub MCP server is reachable, call:
|
||||||
`get_domain_summary("citation_evidence")`
|
`get_domain_summary("infotech")`
|
||||||
This provides richer cross-domain context.
|
This provides richer cross-domain context.
|
||||||
If the MCP call fails, use this file as your orientation source.
|
If the MCP call fails, use this file as your orientation source.
|
||||||
|
|||||||
175
.gitignore
vendored
175
.gitignore
vendored
@@ -1,176 +1,7 @@
|
|||||||
# ---> Python
|
node_modules/
|
||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
dist/
|
||||||
downloads/
|
coverage/
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
*.pot
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
*.log
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
|
||||||
.pybuilder/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
||||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
||||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
||||||
# install all needed dependencies.
|
|
||||||
#Pipfile.lock
|
|
||||||
|
|
||||||
# UV
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
#uv.lock
|
|
||||||
|
|
||||||
# poetry
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
||||||
#poetry.lock
|
|
||||||
|
|
||||||
# pdm
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
||||||
#pdm.lock
|
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
||||||
# in version control.
|
|
||||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
||||||
.pdm.toml
|
|
||||||
.pdm-python
|
|
||||||
.pdm-build/
|
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
||||||
__pypackages__/
|
|
||||||
|
|
||||||
# Celery stuff
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
|
|
||||||
# SageMath parsed files
|
|
||||||
*.sage.py
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env
|
.env
|
||||||
.venv
|
.venv
|
||||||
env/
|
tsconfig.tsbuildinfo
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env.bak/
|
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
# Spyder project settings
|
|
||||||
.spyderproject
|
|
||||||
.spyproject
|
|
||||||
|
|
||||||
# Rope project settings
|
|
||||||
.ropeproject
|
|
||||||
|
|
||||||
# mkdocs documentation
|
|
||||||
/site
|
|
||||||
|
|
||||||
# mypy
|
|
||||||
.mypy_cache/
|
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
|
||||||
.pyre/
|
|
||||||
|
|
||||||
# pytype static type analyzer
|
|
||||||
.pytype/
|
|
||||||
|
|
||||||
# Cython debug symbols
|
|
||||||
cython_debug/
|
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
|
|
||||||
# Ruff stuff:
|
|
||||||
.ruff_cache/
|
|
||||||
|
|
||||||
# PyPI configuration file
|
|
||||||
.pypirc
|
|
||||||
|
|
||||||
19
.repo-classification.yaml
Normal file
19
.repo-classification.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
repo_classification:
|
||||||
|
standard: Repo Classification Standard
|
||||||
|
version: '1.0'
|
||||||
|
classified_at: '2026-06-22'
|
||||||
|
classified_by: agent
|
||||||
|
category: project
|
||||||
|
domain: infotech
|
||||||
|
secondary_domains: []
|
||||||
|
capability_tags:
|
||||||
|
- citations
|
||||||
|
- evidence
|
||||||
|
- knowledge
|
||||||
|
business_stake:
|
||||||
|
- technology
|
||||||
|
- product
|
||||||
|
- operations
|
||||||
|
business_mechanics:
|
||||||
|
- coordination
|
||||||
|
- operation
|
||||||
219
AGENTS.md
Normal file
219
AGENTS.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# citation-engine — Agent Instructions
|
||||||
|
|
||||||
|
## Repo Identity
|
||||||
|
|
||||||
|
**Purpose:** Core domain model, API contracts, persistence interfaces, citation rendering. Leaf node — depends on no other subsystem. INTENT-only during umbrella-first MVP.
|
||||||
|
|
||||||
|
**Domain:** infotech
|
||||||
|
**Repo slug:** citation-engine
|
||||||
|
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
|
||||||
|
**Workplan prefix:** `CENG-WP-`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Hub Integration
|
||||||
|
|
||||||
|
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
|
||||||
|
there is no MCP server for Codex agents.
|
||||||
|
|
||||||
|
| Context | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| Local workstation | `http://127.0.0.1:8000` |
|
||||||
|
| Remote via tunnel | `http://127.0.0.1:18000` |
|
||||||
|
|
||||||
|
### Orient at session start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Offline brief — works without hub connection
|
||||||
|
cat .custodian-brief.md
|
||||||
|
|
||||||
|
# Active workstreams for this domain
|
||||||
|
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
|
||||||
|
# Check inbox
|
||||||
|
curl -s "http://127.0.0.1:8000/messages/?to_agent=citation-engine&unread_only=true" \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Mark a message read:
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
|
||||||
|
-H "Content-Type: application/json" -d '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log progress (required at session close)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8000/progress/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"summary": "what was done",
|
||||||
|
"event_type": "note",
|
||||||
|
"author": "codex",
|
||||||
|
"workstream_id": "<uuid>",
|
||||||
|
"task_id": "<uuid>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit `workstream_id` / `task_id` when not applicable.
|
||||||
|
|
||||||
|
### Update task status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status": "progress"}'
|
||||||
|
# values: wait | todo | progress | done | cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flag a task for human review
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"needs_human": true, "intervention_note": "reason"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Protocol
|
||||||
|
|
||||||
|
**Start:**
|
||||||
|
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||||
|
2. Check inbox: `GET /messages/?to_agent=citation-engine&unread_only=true`; mark read
|
||||||
|
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||||
|
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
|
||||||
|
|
||||||
|
**During work:**
|
||||||
|
- Update task statuses in workplan files as tasks progress
|
||||||
|
- Record significant decisions via `POST /decisions/`
|
||||||
|
|
||||||
|
**Close:**
|
||||||
|
1. Update workplan file task statuses to reflect progress
|
||||||
|
2. Log: `POST /progress/` with a summary of what changed
|
||||||
|
3. Note for the custodian operator: after workplan file changes, run from
|
||||||
|
`~/state-hub`:
|
||||||
|
```bash
|
||||||
|
make fix-consistency REPO=citation-engine
|
||||||
|
```
|
||||||
|
This syncs task status from files into the hub DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credential and access routing
|
||||||
|
|
||||||
|
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
|
||||||
|
for inference. Run this check **before** requesting secrets, API keys, SSH access,
|
||||||
|
login tokens, or database passwords — in any repo, not only `ops-warden`.
|
||||||
|
|
||||||
|
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
|
||||||
|
other credential need belongs to another subsystem. **Do not** message
|
||||||
|
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
|
||||||
|
|
||||||
|
### Lookup (do this first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
warden route find "<describe your need>" --json
|
||||||
|
warden route show <catalog-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
|
||||||
|
|
||||||
|
| Agent runtime | How to orient |
|
||||||
|
| --- | --- |
|
||||||
|
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=citation-engine` is for coordination, not secret vending |
|
||||||
|
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
|
||||||
|
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
|
||||||
|
|
||||||
|
### Quick routing table
|
||||||
|
|
||||||
|
| I need… | Owner | ops-warden executes? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
|
||||||
|
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
|
||||||
|
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
|
||||||
|
| Authorization decision | flex-auth | No — route only |
|
||||||
|
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
|
||||||
|
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
|
||||||
|
|
||||||
|
### Anti-patterns (do not do these)
|
||||||
|
|
||||||
|
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
|
||||||
|
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
|
||||||
|
- Pasting secrets into Git, State Hub, workplans, logs, or chat
|
||||||
|
|
||||||
|
### Other capabilities (reuse-surface)
|
||||||
|
|
||||||
|
Non-credential capabilities are usually discovered through **reuse-surface** federation
|
||||||
|
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
|
||||||
|
every repo's agent instructions because it is high-frequency, high-risk, and easy to
|
||||||
|
get wrong.
|
||||||
|
|
||||||
|
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||||
|
|
||||||
|
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||||
|
<!-- Append repo-specific agent instructions below this marker.
|
||||||
|
The state-hub template sync preserves content after this line. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workplan Convention (ADR-001)
|
||||||
|
|
||||||
|
Work items originate as files in this repo — not in the hub. The hub is a
|
||||||
|
read/cache/index layer that rebuilds from files.
|
||||||
|
|
||||||
|
**File location:** `workplans/CITATION-WP-NNNN-<slug>.md`
|
||||||
|
|
||||||
|
**Archived location:** finished workplans may move to
|
||||||
|
`workplans/archived/YYMMDD-CITATION-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
||||||
|
the completion/archive date; the frontmatter `id` does not change.
|
||||||
|
|
||||||
|
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
|
||||||
|
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
|
||||||
|
this only for low-risk work completed directly; create a normal workplan for
|
||||||
|
anything needing analysis, design, approval, dependencies, or multiple phases.
|
||||||
|
|
||||||
|
**Frontmatter:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
id: CITATION-WP-NNNN
|
||||||
|
type: workplan
|
||||||
|
title: "..."
|
||||||
|
domain: infotech
|
||||||
|
repo: citation-engine
|
||||||
|
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||||
|
owner: codex
|
||||||
|
topic_slug: ...
|
||||||
|
created: "YYYY-MM-DD"
|
||||||
|
updated: "YYYY-MM-DD"
|
||||||
|
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `proposed` for a new draft, `ready` after review against current repo
|
||||||
|
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||||
|
derived health labels, not frontmatter statuses.
|
||||||
|
|
||||||
|
**Task block format** (one per `##` section):
|
||||||
|
|
||||||
|
```
|
||||||
|
## Task Title
|
||||||
|
|
||||||
|
` ` `task
|
||||||
|
id: CITATION-WP-NNNN-T01
|
||||||
|
status: wait | todo | progress | done | cancel
|
||||||
|
priority: high | medium | low
|
||||||
|
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
|
||||||
|
` ` `
|
||||||
|
|
||||||
|
Task description text.
|
||||||
|
```
|
||||||
|
|
||||||
|
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
|
||||||
|
|
||||||
|
To create a new workplan:
|
||||||
|
1. Write the file following the format above
|
||||||
|
2. Notify the custodian operator to run `make fix-consistency REPO=citation-engine`
|
||||||
|
(or send a message to the hub agent via `POST /messages/`)
|
||||||
12
CLAUDE.md
Normal file
12
CLAUDE.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# citation-engine — Claude Code Instructions
|
||||||
|
|
||||||
|
@SCOPE.md
|
||||||
|
@.claude/rules/repo-identity.md
|
||||||
|
@.claude/rules/session-protocol.md
|
||||||
|
@.claude/rules/first-session.md
|
||||||
|
@.claude/rules/workplan-convention.md
|
||||||
|
@.claude/rules/stack-and-commands.md
|
||||||
|
@.claude/rules/architecture.md
|
||||||
|
@.claude/rules/repo-boundary.md
|
||||||
|
@.claude/rules/credential-routing.md
|
||||||
|
@.claude/rules/agents.md
|
||||||
13
INTENT.md
13
INTENT.md
@@ -318,15 +318,14 @@ This repository should be:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MVP Coordination — Code Lives Upstream
|
## MVP Coordination — Extraction Complete
|
||||||
|
|
||||||
During the umbrella-first MVP phase (decided 2026-05-24), **the source code
|
The umbrella-first MVP (CE-WP-0001..0008) stabilized the engine API. Code now
|
||||||
for this subsystem does not live in this repository yet**. It lives in the
|
lives in this repository under `src/shared/` and `src/engine/`, extracted from
|
||||||
umbrella repo at `citation-evidence/src/engine/` and `citation-evidence/src/shared/`.
|
`citation-evidence` per `workplans/CENG-WP-0001-intent-placeholder.md`.
|
||||||
|
|
||||||
This INTENT.md documents the *intended* responsibilities and boundaries.
|
The umbrella consumes this package via `link:../citation-engine` per `CE-WP-0009`.
|
||||||
When the engine's interfaces have stabilized through actual MVP use, the
|
This repository is the canonical home going forward.
|
||||||
corresponding code extracts into this repository.
|
|
||||||
|
|
||||||
**Shared contracts** (vocabulary, state enums, relation types, selector
|
**Shared contracts** (vocabulary, state enums, relation types, selector
|
||||||
taxonomy, event types, viewer adapter, canonical text normalization, allowed
|
taxonomy, event types, viewer adapter, canonical text normalization, allowed
|
||||||
|
|||||||
121
README.md
121
README.md
@@ -1,19 +1,112 @@
|
|||||||
# citation-engine
|
# citation-engine
|
||||||
|
|
||||||
Domain model and tooling for citations — the shared vocabulary
|
Core domain model and engine services for the citation-evidence ecosystem.
|
||||||
(`Document`, `Selector`, `Annotation`, `EvidenceItem`, `EvidenceLink`,
|
|
||||||
`EvidenceSet`, state enums, branded IDs, canonical text normalization)
|
|
||||||
together with the services and repositories that act on it.
|
|
||||||
|
|
||||||
## MVP status: INTENT only
|
This package provides the shared vocabulary (`Document`, `Selector`, `Annotation`,
|
||||||
|
`EvidenceItem`, `EvidenceLink`, `EvidenceSet`, state enums, branded IDs,
|
||||||
|
canonical text normalization) together with in-memory repositories, orchestration
|
||||||
|
services, the event bus, and citation card renderers.
|
||||||
|
|
||||||
During the citation-evidence MVP, code lives upstream in
|
Published as **`@citation-evidence/engine`**.
|
||||||
[`citation-evidence`](../citation-evidence/) under `src/shared/` and
|
|
||||||
`src/engine/`. This repo currently holds `INTENT.md` describing what will
|
|
||||||
move here once the engine API stabilises. Until extraction, contract
|
|
||||||
changes belong in
|
|
||||||
[`citation-evidence/wiki/SharedContracts.md`](../citation-evidence/wiki/SharedContracts.md),
|
|
||||||
not here.
|
|
||||||
|
|
||||||
The dependency map (`citation-evidence/wiki/DependencyMap.md`) marks this
|
## Layout
|
||||||
package as a **leaf node**: nothing internal depends on anything else.
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
shared/ # types, enums, pure helpers
|
||||||
|
engine/ # services, repositories, rendering, persistence
|
||||||
|
wiki/
|
||||||
|
SharedContracts.md # conformance copy (authoritative: citation-evidence)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
### Local development (sibling checkout)
|
||||||
|
|
||||||
|
Repos are expected as siblings:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/citation-evidence/
|
||||||
|
~/citation-engine/
|
||||||
|
~/evidence-anchor/
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
In the consumer's `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@citation-evidence/engine": "link:../citation-engine"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `pnpm install`. TypeScript path aliases in consumers typically map
|
||||||
|
`@shared/*` and `@engine/*` to `../citation-engine/src/shared/*` and
|
||||||
|
`../citation-engine/src/engine/*` (see `CE-WP-0009` in citation-evidence).
|
||||||
|
|
||||||
|
### Registry install (when published)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @citation-evidence/engine
|
||||||
|
```
|
||||||
|
|
||||||
|
Registry host TBD. See [Publishing checklist](#publishing-checklist) below.
|
||||||
|
|
||||||
|
### evidence-anchor (placeholder)
|
||||||
|
|
||||||
|
When `evidence-anchor` extracts, it will depend on engine types only:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@citation-evidence/engine": "link:../citation-engine"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Import shared types via `@shared/*` path aliases or `@citation-evidence/engine/shared`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm test
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contract sync
|
||||||
|
|
||||||
|
See [`wiki/README.md`](wiki/README.md). Changes to shared contracts land in
|
||||||
|
`citation-evidence/wiki/SharedContracts.md` first, then sync here.
|
||||||
|
|
||||||
|
## Extraction status
|
||||||
|
|
||||||
|
Code lives in `src/shared/` and `src/engine/`, extracted from the umbrella
|
||||||
|
MVP per `workplans/CENG-WP-0001-intent-placeholder.md`. The umbrella consumes
|
||||||
|
this package via `link:` per `CE-WP-0009`.
|
||||||
|
|
||||||
|
## Publishing checklist
|
||||||
|
|
||||||
|
Use when a package registry is configured (ADR-0002: independent repos + publish).
|
||||||
|
|
||||||
|
- [ ] Set `private: false` (or scoped private per org policy)
|
||||||
|
- [ ] Confirm `files` in `package.json` includes `src/`, `wiki/`, `README.md`, `LICENSE`
|
||||||
|
- [ ] Decide: TypeScript sources via `exports` (current) vs `tsc` emit to `dist/`
|
||||||
|
- [ ] Bump semver: patch for fixes, minor for additive contracts, major for breaks
|
||||||
|
- [ ] Run `pnpm test && pnpm typecheck && pnpm lint` before publish
|
||||||
|
- [ ] Update consumers from `link:` to registry version
|
||||||
|
- [ ] Document registry URL and auth in org runbook (not in this repo)
|
||||||
|
|
||||||
|
No publish CI step is configured yet — credentials and registry choice are
|
||||||
|
out of scope until an external consumer requires it.
|
||||||
|
|
||||||
|
## Related repos
|
||||||
|
|
||||||
|
- [`citation-evidence`](../citation-evidence/) — umbrella reference workspace
|
||||||
|
- [`evidence-anchor`](../evidence-anchor/) — selector creation and resolution
|
||||||
|
- [`evidence-source`](../evidence-source/) — document ingestion
|
||||||
|
- [`citation-work`](../citation-work/) — review workspace UX
|
||||||
|
- [`evidence-binder`](../evidence-binder/) — evidence-to-field linking
|
||||||
137
SCOPE.md
Normal file
137
SCOPE.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# SCOPE
|
||||||
|
|
||||||
|
> This file helps you quickly understand what this repository is about,
|
||||||
|
> when it is relevant, and when it is not.
|
||||||
|
> It is intentionally lightweight and may be incomplete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-liner
|
||||||
|
|
||||||
|
<!-- Describe the purpose of this repository in one precise sentence. -->
|
||||||
|
<!-- Example: "Provides a lightweight event router for Kubernetes-native systems." -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Idea
|
||||||
|
|
||||||
|
<!-- What is the main capability or idea behind this repository? -->
|
||||||
|
<!-- What problem does it try to solve? -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## In Scope
|
||||||
|
|
||||||
|
<!-- What this repository is responsible for. -->
|
||||||
|
<!-- Be explicit and concrete. -->
|
||||||
|
|
||||||
|
-
|
||||||
|
-
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
<!-- What this repository deliberately does NOT do. -->
|
||||||
|
<!-- This is often more important than "In Scope". -->
|
||||||
|
|
||||||
|
-
|
||||||
|
-
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relevant When
|
||||||
|
|
||||||
|
<!-- When should someone consider using or exploring this repository? -->
|
||||||
|
|
||||||
|
-
|
||||||
|
-
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not Relevant When
|
||||||
|
|
||||||
|
<!-- When should someone ignore this repository? -->
|
||||||
|
|
||||||
|
-
|
||||||
|
-
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
<!-- Rough indication of maturity. No strict format required. -->
|
||||||
|
|
||||||
|
- Status: <!-- e.g. concept / experimental / active / stable / deprecated -->
|
||||||
|
- Implementation: <!-- e.g. idea / partial / substantial / complete -->
|
||||||
|
- Stability: <!-- e.g. unstable / evolving / stable -->
|
||||||
|
- Usage: <!-- e.g. none / personal / internal / production -->
|
||||||
|
|
||||||
|
<!-- Add any notes that help set expectations. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Fits
|
||||||
|
|
||||||
|
<!-- Where does this repository sit in the bigger picture? -->
|
||||||
|
|
||||||
|
- Upstream dependencies:
|
||||||
|
- Downstream consumers:
|
||||||
|
- Often used with:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
<!-- Terms that are important to understand this repo. -->
|
||||||
|
<!-- Especially useful if naming differs from other repos. -->
|
||||||
|
|
||||||
|
- Preferred terms:
|
||||||
|
- Also known as:
|
||||||
|
- Potentially confusing terms:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related / Overlapping Repositories
|
||||||
|
|
||||||
|
<!-- List repositories that have similar or adjacent responsibilities. -->
|
||||||
|
<!-- Helps detect duplication and navigate the ecosystem. -->
|
||||||
|
|
||||||
|
- <repo-name> — <!-- how it relates -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Oriented
|
||||||
|
|
||||||
|
<!-- If someone decides to look deeper, where should they start? -->
|
||||||
|
|
||||||
|
- Start with:
|
||||||
|
- Key files / directories:
|
||||||
|
- Entry points:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provided Capabilities
|
||||||
|
|
||||||
|
<!-- What can this repo's domain provide to other domains on request? -->
|
||||||
|
<!-- Each capability block is parsed by the state-hub capability catalog ingest. -->
|
||||||
|
<!-- Remove the examples and add your own, or leave empty if none. -->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
```capability
|
||||||
|
type: infrastructure
|
||||||
|
title: Example capability title
|
||||||
|
description: What this capability provides, in one or two sentences.
|
||||||
|
keywords: [keyword1, keyword2, keyword3]
|
||||||
|
```
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
<!-- Anything else worth knowing. Keep it short. -->
|
||||||
48
eslint.config.js
Normal file
48
eslint.config.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// ESLint flat config — enforces shared/engine dependency boundary.
|
||||||
|
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import boundaries from "eslint-plugin-boundaries";
|
||||||
|
import importPlugin from "eslint-plugin-import";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ["dist/", "node_modules/", "coverage/", "**/*.d.ts"],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
globals: { ...globals.node },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
boundaries,
|
||||||
|
import: importPlugin,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
"import/resolver": {
|
||||||
|
typescript: { project: "./tsconfig.json" },
|
||||||
|
},
|
||||||
|
"boundaries/elements": [
|
||||||
|
{ type: "shared", pattern: "src/shared/**" },
|
||||||
|
{ type: "engine", pattern: "src/engine/**" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"boundaries/element-types": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
default: "disallow",
|
||||||
|
rules: [
|
||||||
|
{ from: "shared", allow: [] },
|
||||||
|
{ from: "engine", allow: ["shared"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
39
package.json
Normal file
39
package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@citation-evidence/engine",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Core domain model and engine services for the citation-evidence ecosystem.",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@9.15.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.10.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"wiki",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": "./src/engine/index.ts",
|
||||||
|
"./shared": "./src/shared/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc -b --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"eslint": "^9.7.0",
|
||||||
|
"eslint-import-resolver-typescript": "^3.6.3",
|
||||||
|
"eslint-plugin-boundaries": "^4.2.2",
|
||||||
|
"eslint-plugin-import": "^2.30.0",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"typescript-eslint": "^8.0.0",
|
||||||
|
"vitest": "^2.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
3476
pnpm-lock.yaml
generated
Normal file
3476
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
119
registry/capabilities/capability.infotech.citation-engine.md
Normal file
119
registry/capabilities/capability.infotech.citation-engine.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
id: capability.infotech.citation-engine
|
||||||
|
name: Citation Evidence Domain Engine
|
||||||
|
summary: Core domain model and engine services for the citation-evidence ecosystem — shared vocabulary,
|
||||||
|
in-memory repositories, orchestration services, event bus, and citation card renderers.
|
||||||
|
owner: citation-engine
|
||||||
|
status: draft
|
||||||
|
domain: infotech
|
||||||
|
tags:
|
||||||
|
- citation
|
||||||
|
- evidence
|
||||||
|
- domain-model
|
||||||
|
maturity:
|
||||||
|
discovery:
|
||||||
|
current: D3
|
||||||
|
target: D5
|
||||||
|
confidence: medium
|
||||||
|
rationale: README documents the domain vocabulary (Document, Selector, Annotation, EvidenceItem/Link/Set)
|
||||||
|
and points to wiki/SharedContracts.md as the authoritative conformance copy shared with citation-evidence.
|
||||||
|
availability:
|
||||||
|
current: A1
|
||||||
|
target: A3
|
||||||
|
confidence: medium
|
||||||
|
rationale: Published as `@citation-evidence/engine`, consumed today only as a local sibling-checkout
|
||||||
|
package link; no registry publish yet.
|
||||||
|
external_evidence:
|
||||||
|
completeness:
|
||||||
|
level: C1
|
||||||
|
confidence: low
|
||||||
|
basis: scope_vs_intent_and_consumer_expectations
|
||||||
|
satisfied_expectations:
|
||||||
|
- shared vocabulary and branded IDs
|
||||||
|
- in-memory repositories and orchestration services
|
||||||
|
- citation card renderers
|
||||||
|
broken_expectations: []
|
||||||
|
out_of_scope_expectations: []
|
||||||
|
reliability:
|
||||||
|
level: R0
|
||||||
|
confidence: low
|
||||||
|
basis: consumer_quality_signals
|
||||||
|
known_reliability_risks:
|
||||||
|
- only consumed by sibling citation-evidence today; no independent external consumer
|
||||||
|
discovery:
|
||||||
|
intent: Provide the shared domain model and engine services underlying the citation-evidence workspace,
|
||||||
|
decoupled from any single UI shell.
|
||||||
|
includes:
|
||||||
|
- Document/Selector/Annotation/EvidenceItem/EvidenceLink/EvidenceSet types
|
||||||
|
- in-memory repositories, event bus, orchestration services
|
||||||
|
- citation card rendering
|
||||||
|
excludes:
|
||||||
|
- the evidence-workspace UI shell (owned by citation-evidence)
|
||||||
|
assumptions: []
|
||||||
|
use_cases: []
|
||||||
|
research_memos: []
|
||||||
|
availability:
|
||||||
|
current_level: A1
|
||||||
|
target_level: A3
|
||||||
|
current_artifacts:
|
||||||
|
- '`@citation-evidence/engine` npm package (local link, not published)'
|
||||||
|
target_artifacts: []
|
||||||
|
consumption_modes:
|
||||||
|
- library import (workspace link)
|
||||||
|
relations:
|
||||||
|
depends_on: []
|
||||||
|
supports: []
|
||||||
|
related_to: []
|
||||||
|
evidence:
|
||||||
|
documentation:
|
||||||
|
- README.md
|
||||||
|
- wiki/SharedContracts.md
|
||||||
|
tests:
|
||||||
|
- vitest.config.ts
|
||||||
|
consumer_feedback: []
|
||||||
|
bug_reports: []
|
||||||
|
incidents: []
|
||||||
|
consumer_guidance:
|
||||||
|
recommended_for:
|
||||||
|
- citation-evidence and future citation-domain consumers within the same monorepo/workspace
|
||||||
|
not_recommended_for:
|
||||||
|
- consumers outside the citation-evidence workspace (package not published to a registry)
|
||||||
|
known_limitations:
|
||||||
|
- not yet published; only consumable via sibling-checkout workspace link
|
||||||
|
promotion_history: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Citation Evidence Domain Engine
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`citation-engine` is the extracted domain model and engine layer for the citation-evidence ecosystem, published (workspace-local) as `@citation-evidence/engine` and consumed by the citation-evidence umbrella app.
|
||||||
|
|
||||||
|
## Assessment notes
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
README documents the domain vocabulary (Document, Selector, Annotation, EvidenceItem/Link/Set) and points to wiki/SharedContracts.md as the authoritative conformance copy shared with citation-evidence.
|
||||||
|
|
||||||
|
### Availability
|
||||||
|
|
||||||
|
Published as `@citation-evidence/engine`, consumed today only as a local sibling-checkout package link; no registry publish yet.
|
||||||
|
|
||||||
|
### Completeness
|
||||||
|
|
||||||
|
First-pass honest assessment from the REUSE-WP-0017 coverage campaign
|
||||||
|
(reuse-surface). No external consumer feedback exists yet; levels reflect
|
||||||
|
scope-vs-intent documentation quality, not internal code quality.
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
|
||||||
|
No production consumer telemetry exists yet; reliability level is
|
||||||
|
intentionally conservative pending REUSE-WP-0019 reuse-telemetry evidence.
|
||||||
|
|
||||||
|
## Promotion checklist
|
||||||
|
|
||||||
|
- [x] ID follows `capability.<domain>.<name>` pattern
|
||||||
|
- [x] Maturity enums match `specs/CapabilityMaturityStandard.md`
|
||||||
|
- [x] `external_evidence` is populated separately from `maturity`
|
||||||
|
- [ ] Relations reference valid capability IDs (none yet)
|
||||||
|
- [x] Index entry added in `registry/indexes/capabilities.yaml`
|
||||||
@@ -1,4 +1,19 @@
|
|||||||
version: 1
|
version: 1
|
||||||
updated: '2026-06-16'
|
updated: '2026-07-06'
|
||||||
domain: helix_forge
|
domain: helix_forge
|
||||||
capabilities: []
|
capabilities:
|
||||||
|
- id: capability.infotech.citation-engine
|
||||||
|
name: Citation Evidence Domain Engine
|
||||||
|
summary: Core domain model and engine services for the citation-evidence ecosystem — shared vocabulary,
|
||||||
|
in-memory repositories, orchestration services, event bus, and citation card renderers.
|
||||||
|
vector: D3 / A1 / C1 / R0
|
||||||
|
domain: infotech
|
||||||
|
status: draft
|
||||||
|
owner: citation-engine
|
||||||
|
path: registry/capabilities/capability.infotech.citation-engine.md
|
||||||
|
tags:
|
||||||
|
- citation
|
||||||
|
- evidence
|
||||||
|
- domain-model
|
||||||
|
consumption_modes:
|
||||||
|
- library import (workspace link)
|
||||||
|
|||||||
7
src/engine/README.md
Normal file
7
src/engine/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# `src/engine/` — services, repositories, event bus
|
||||||
|
|
||||||
|
Future home: `citation-engine` (the services half).
|
||||||
|
Owns: repositories for `Document`/`Annotation`/`EvidenceItem`/`EvidenceLink`,
|
||||||
|
ID generation orchestration, the event bus, and pure orchestration services.
|
||||||
|
|
||||||
|
May import from: `shared/` only (`wiki/DependencyMap.md` §4).
|
||||||
168
src/engine/engine.test.ts
Normal file
168
src/engine/engine.test.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||||
|
import type { DocumentId, RepresentationId } from "@shared/ids";
|
||||||
|
import type { Selector } from "@shared/selector";
|
||||||
|
import { createEngine, type Engine, type EngineEvent } from "./index";
|
||||||
|
|
||||||
|
function fakeDocAndRep(): { document: Document; representation: DocumentRepresentation } {
|
||||||
|
const docId = "doc_fake" as DocumentId;
|
||||||
|
const repId = "rep_fake" as RepresentationId;
|
||||||
|
return {
|
||||||
|
document: {
|
||||||
|
id: docId,
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
representation: {
|
||||||
|
id: repId,
|
||||||
|
documentId: docId,
|
||||||
|
representationType: "pdf-text",
|
||||||
|
contentHash: "h",
|
||||||
|
canonicalText: "The quick brown fox.",
|
||||||
|
pageMap: [{ page: 1, width: 100, height: 100 }],
|
||||||
|
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 20, pageLength: 20 }],
|
||||||
|
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Engine integration", () => {
|
||||||
|
let engine: Engine;
|
||||||
|
let events: EngineEvent[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
engine = createEngine();
|
||||||
|
events = [];
|
||||||
|
engine.bus.onAny((e) => events.push(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("documentService.register stores both and emits DocumentImported + DocumentRepresentationGenerated", () => {
|
||||||
|
const { document, representation } = fakeDocAndRep();
|
||||||
|
const result = engine.documents.register({ document, representation });
|
||||||
|
expect(result.document).toBe(document);
|
||||||
|
expect(result.representation).toBe(representation);
|
||||||
|
expect(engine.documents.get(document.id)).toBe(document);
|
||||||
|
expect(engine.documents.getRepresentation(representation.id)).toBe(representation);
|
||||||
|
expect(events.map((e) => e.type)).toEqual(["DocumentImported", "DocumentRepresentationGenerated"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annotationService.create stamps an ID + normalize version + timestamps, then emits AnnotationCreated", () => {
|
||||||
|
const { document, representation } = fakeDocAndRep();
|
||||||
|
engine.documents.register({ document, representation });
|
||||||
|
const selectors: Selector[] = [{ type: "TextQuoteSelector", exact: "brown fox" }];
|
||||||
|
const ann = engine.annotations.create({
|
||||||
|
documentId: document.id,
|
||||||
|
representationId: representation.id,
|
||||||
|
selectors,
|
||||||
|
quote: "brown fox",
|
||||||
|
note: "a quick mark",
|
||||||
|
});
|
||||||
|
expect(ann.id).toMatch(/^ann_/);
|
||||||
|
expect(ann.normalizeVersion).toBeGreaterThan(0);
|
||||||
|
expect(ann.createdAt).toBe(ann.updatedAt);
|
||||||
|
expect(engine.annotations.get(ann.id)).toBe(ann);
|
||||||
|
const created = events.find((e) => e.type === "AnnotationCreated");
|
||||||
|
expect(created?.type).toBe("AnnotationCreated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setResolutionStatus emits AnnotationResolved for resolved/ambiguous and AnnotationResolutionFailed for unresolved/stale", () => {
|
||||||
|
const { document, representation } = fakeDocAndRep();
|
||||||
|
engine.documents.register({ document, representation });
|
||||||
|
const ann = engine.annotations.create({
|
||||||
|
documentId: document.id,
|
||||||
|
representationId: representation.id,
|
||||||
|
selectors: [{ type: "TextQuoteSelector", exact: "x" }],
|
||||||
|
});
|
||||||
|
events.length = 0;
|
||||||
|
engine.annotations.setResolutionStatus(ann.id, "resolved", { confidence: 0.95 });
|
||||||
|
expect(events.map((e) => e.type)).toEqual(["AnnotationResolved"]);
|
||||||
|
engine.annotations.setResolutionStatus(ann.id, "unresolved", { confidence: 0, reason: "no quote match" });
|
||||||
|
expect(events.map((e) => e.type)).toEqual(["AnnotationResolved", "AnnotationResolutionFailed"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evidenceService.create requires at least one annotation and emits EvidenceItemCreated", () => {
|
||||||
|
const { document, representation } = fakeDocAndRep();
|
||||||
|
engine.documents.register({ document, representation });
|
||||||
|
const ann = engine.annotations.create({
|
||||||
|
documentId: document.id,
|
||||||
|
representationId: representation.id,
|
||||||
|
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
|
||||||
|
});
|
||||||
|
expect(() => engine.evidence.create({ annotationIds: [] })).toThrow();
|
||||||
|
const item = engine.evidence.create({
|
||||||
|
annotationIds: [ann.id],
|
||||||
|
commentary: "good quote",
|
||||||
|
});
|
||||||
|
expect(item.status).toBe("candidate");
|
||||||
|
expect(item.annotationIds).toEqual([ann.id]);
|
||||||
|
expect(events.find((e) => e.type === "EvidenceItemCreated")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setStatus emits EvidenceItemUpdated only on real change and carries previousStatus", () => {
|
||||||
|
const { document, representation } = fakeDocAndRep();
|
||||||
|
engine.documents.register({ document, representation });
|
||||||
|
const ann = engine.annotations.create({
|
||||||
|
documentId: document.id,
|
||||||
|
representationId: representation.id,
|
||||||
|
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
|
||||||
|
});
|
||||||
|
const item = engine.evidence.create({ annotationIds: [ann.id] });
|
||||||
|
events.length = 0;
|
||||||
|
const same = engine.evidence.setStatus(item.id, "candidate");
|
||||||
|
expect(same).toBe(item);
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
engine.evidence.setStatus(item.id, "confirmed");
|
||||||
|
const updated = events.find((e) => e.type === "EvidenceItemUpdated");
|
||||||
|
expect(updated).toBeDefined();
|
||||||
|
if (updated?.type === "EvidenceItemUpdated") {
|
||||||
|
expect(updated.previousStatus).toBe("candidate");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listByDocument scopes evidence items to a single document via annotation lookup", () => {
|
||||||
|
const a = fakeDocAndRep();
|
||||||
|
engine.documents.register(a);
|
||||||
|
const annA = engine.annotations.create({
|
||||||
|
documentId: a.document.id,
|
||||||
|
representationId: a.representation.id,
|
||||||
|
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
|
||||||
|
});
|
||||||
|
engine.evidence.create({ annotationIds: [annA.id], commentary: "a" });
|
||||||
|
|
||||||
|
// Second, distinct document.
|
||||||
|
const otherDocId = "doc_other" as DocumentId;
|
||||||
|
const otherRepId = "rep_other" as RepresentationId;
|
||||||
|
engine.documents.register({
|
||||||
|
document: { ...a.document, id: otherDocId },
|
||||||
|
representation: { ...a.representation, id: otherRepId, documentId: otherDocId },
|
||||||
|
});
|
||||||
|
const annB = engine.annotations.create({
|
||||||
|
documentId: otherDocId,
|
||||||
|
representationId: otherRepId,
|
||||||
|
selectors: [{ type: "TextQuoteSelector", exact: "z" }],
|
||||||
|
});
|
||||||
|
engine.evidence.create({ annotationIds: [annB.id], commentary: "b" });
|
||||||
|
|
||||||
|
expect(engine.evidence.listByDocument(a.document.id)).toHaveLength(1);
|
||||||
|
expect(engine.evidence.listByDocument(otherDocId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activate emits EvidenceItemActivated without mutating the item", () => {
|
||||||
|
const { document, representation } = fakeDocAndRep();
|
||||||
|
engine.documents.register({ document, representation });
|
||||||
|
const ann = engine.annotations.create({
|
||||||
|
documentId: document.id,
|
||||||
|
representationId: representation.id,
|
||||||
|
selectors: [{ type: "TextQuoteSelector", exact: "x" }],
|
||||||
|
});
|
||||||
|
const item = engine.evidence.create({ annotationIds: [ann.id] });
|
||||||
|
events.length = 0;
|
||||||
|
engine.evidence.activate(item.id, "sidebar");
|
||||||
|
const activated = events.find((e) => e.type === "EvidenceItemActivated");
|
||||||
|
expect(activated).toBeDefined();
|
||||||
|
if (activated?.type === "EvidenceItemActivated") {
|
||||||
|
expect(activated.source).toBe("sidebar");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
64
src/engine/events/bus.test.ts
Normal file
64
src/engine/events/bus.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { DocumentId } from "@shared/ids";
|
||||||
|
import { createEventBus } from "./bus";
|
||||||
|
|
||||||
|
const docId = "doc_test" as DocumentId;
|
||||||
|
const minimalDoc = {
|
||||||
|
id: docId,
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("EventBus", () => {
|
||||||
|
it("delivers typed events to the registered listener", () => {
|
||||||
|
const bus = createEventBus();
|
||||||
|
const spy = vi.fn();
|
||||||
|
bus.on("DocumentImported", spy);
|
||||||
|
const result = bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
|
||||||
|
expect(spy).toHaveBeenCalledOnce();
|
||||||
|
expect(spy.mock.calls[0]![0]).toMatchObject({ type: "DocumentImported", documentId: docId });
|
||||||
|
expect(result.listenerCount).toBe(1);
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not deliver an event to listeners of a different type", () => {
|
||||||
|
const bus = createEventBus();
|
||||||
|
const spy = vi.fn();
|
||||||
|
bus.on("AnnotationCreated", spy);
|
||||||
|
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers every event to onAny listeners", () => {
|
||||||
|
const bus = createEventBus();
|
||||||
|
const spy = vi.fn();
|
||||||
|
bus.onAny(spy);
|
||||||
|
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
|
||||||
|
bus.emit({ type: "EvidenceItemActivated", evidenceItemId: "ev_x" as never });
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an unsubscribe function from on()", () => {
|
||||||
|
const bus = createEventBus();
|
||||||
|
const spy = vi.fn();
|
||||||
|
const off = bus.on("DocumentImported", spy);
|
||||||
|
off();
|
||||||
|
bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures listener errors and still calls subsequent listeners", () => {
|
||||||
|
const bus = createEventBus();
|
||||||
|
const boom = new Error("listener exploded");
|
||||||
|
const a = vi.fn(() => { throw boom; });
|
||||||
|
const b = vi.fn();
|
||||||
|
bus.on("DocumentImported", a);
|
||||||
|
bus.on("DocumentImported", b);
|
||||||
|
const result = bus.emit({ type: "DocumentImported", documentId: docId, document: minimalDoc });
|
||||||
|
expect(a).toHaveBeenCalledOnce();
|
||||||
|
expect(b).toHaveBeenCalledOnce();
|
||||||
|
expect(result.errors).toEqual([boom]);
|
||||||
|
expect(result.listenerCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
79
src/engine/events/bus.ts
Normal file
79
src/engine/events/bus.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Synchronous in-process event bus.
|
||||||
|
*
|
||||||
|
* Listeners fire in registration order on the calling stack; `emit` returns
|
||||||
|
* after every listener has run. A listener throwing does not stop later
|
||||||
|
* listeners — its error surfaces through the returned `errors` array so
|
||||||
|
* callers can decide whether to log, rethrow, or ignore.
|
||||||
|
*
|
||||||
|
* MVP-sufficient. ADR-0005 (persistence) will decide whether to upgrade to
|
||||||
|
* an async/queued bus when storage becomes durable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EngineEvent, EngineEventOf, EngineEventType } from "./types";
|
||||||
|
|
||||||
|
export type EngineEventListener<T extends EngineEventType = EngineEventType> = (
|
||||||
|
event: EngineEventOf<T>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export type AnyEngineEventListener = (event: EngineEvent) => void;
|
||||||
|
|
||||||
|
export interface EmitResult {
|
||||||
|
readonly listenerCount: number;
|
||||||
|
readonly errors: readonly unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventBus {
|
||||||
|
on<T extends EngineEventType>(type: T, listener: EngineEventListener<T>): () => void;
|
||||||
|
onAny(listener: AnyEngineEventListener): () => void;
|
||||||
|
emit<T extends EngineEventType>(event: EngineEventOf<T>): EmitResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEventBus(): EventBus {
|
||||||
|
const typedListeners = new Map<EngineEventType, Set<EngineEventListener>>();
|
||||||
|
const anyListeners = new Set<AnyEngineEventListener>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
on(type, listener) {
|
||||||
|
let set = typedListeners.get(type);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
typedListeners.set(type, set);
|
||||||
|
}
|
||||||
|
set.add(listener as unknown as EngineEventListener);
|
||||||
|
return () => {
|
||||||
|
set!.delete(listener as unknown as EngineEventListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onAny(listener) {
|
||||||
|
anyListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
anyListeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
emit(event) {
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
let count = 0;
|
||||||
|
const typedSet = typedListeners.get(event.type);
|
||||||
|
if (typedSet) {
|
||||||
|
for (const l of typedSet) {
|
||||||
|
count++;
|
||||||
|
try {
|
||||||
|
(l as AnyEngineEventListener)(event);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const l of anyListeners) {
|
||||||
|
count++;
|
||||||
|
try {
|
||||||
|
l(event);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { listenerCount: count, errors };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
8
src/engine/events/index.ts
Normal file
8
src/engine/events/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export {
|
||||||
|
createEventBus,
|
||||||
|
type EventBus,
|
||||||
|
type EngineEventListener,
|
||||||
|
type AnyEngineEventListener,
|
||||||
|
type EmitResult,
|
||||||
|
} from "./bus";
|
||||||
159
src/engine/events/types.ts
Normal file
159
src/engine/events/types.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Engine event vocabulary.
|
||||||
|
*
|
||||||
|
* Implements `wiki/SharedContracts.md` §4 (closed event list). Each event
|
||||||
|
* carries the *minimum* identifying payload needed by downstream listeners;
|
||||||
|
* services hand back the full domain object to the caller separately.
|
||||||
|
*
|
||||||
|
* Adding an event requires updating SharedContracts.md first.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Annotation, AnnotationResolutionStatus } from "@shared/annotation";
|
||||||
|
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||||
|
import type { EvidenceItem, EvidenceItemStatus } from "@shared/evidence";
|
||||||
|
import type {
|
||||||
|
EvidenceLink,
|
||||||
|
EvidenceTarget,
|
||||||
|
} from "@shared/evidence-link";
|
||||||
|
import type {
|
||||||
|
AnnotationId,
|
||||||
|
DocumentId,
|
||||||
|
EvidenceItemId,
|
||||||
|
EvidenceLinkId,
|
||||||
|
RepresentationId,
|
||||||
|
SessionId,
|
||||||
|
} from "@shared/ids";
|
||||||
|
import type { Session } from "@shared/session";
|
||||||
|
|
||||||
|
export interface DocumentImportedEvent {
|
||||||
|
readonly type: "DocumentImported";
|
||||||
|
readonly documentId: DocumentId;
|
||||||
|
readonly document: Document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentRepresentationGeneratedEvent {
|
||||||
|
readonly type: "DocumentRepresentationGenerated";
|
||||||
|
readonly documentId: DocumentId;
|
||||||
|
readonly representationId: RepresentationId;
|
||||||
|
readonly representation: DocumentRepresentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentRemovedEvent {
|
||||||
|
readonly type: "DocumentRemoved";
|
||||||
|
readonly documentId: DocumentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationCreatedEvent {
|
||||||
|
readonly type: "AnnotationCreated";
|
||||||
|
readonly annotationId: AnnotationId;
|
||||||
|
readonly annotation: Annotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationResolvedEvent {
|
||||||
|
readonly type: "AnnotationResolved";
|
||||||
|
readonly annotationId: AnnotationId;
|
||||||
|
readonly status: AnnotationResolutionStatus;
|
||||||
|
readonly confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationResolutionFailedEvent {
|
||||||
|
readonly type: "AnnotationResolutionFailed";
|
||||||
|
readonly annotationId: AnnotationId;
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationUpdatedEvent {
|
||||||
|
readonly type: "AnnotationUpdated";
|
||||||
|
readonly annotationId: AnnotationId;
|
||||||
|
readonly annotation: Annotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceItemCreatedEvent {
|
||||||
|
readonly type: "EvidenceItemCreated";
|
||||||
|
readonly evidenceItemId: EvidenceItemId;
|
||||||
|
readonly evidenceItem: EvidenceItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceItemUpdatedEvent {
|
||||||
|
readonly type: "EvidenceItemUpdated";
|
||||||
|
readonly evidenceItemId: EvidenceItemId;
|
||||||
|
readonly evidenceItem: EvidenceItem;
|
||||||
|
readonly previousStatus: EvidenceItemStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceItemActivatedEvent {
|
||||||
|
readonly type: "EvidenceItemActivated";
|
||||||
|
readonly evidenceItemId: EvidenceItemId;
|
||||||
|
readonly source?: "sidebar" | "form-field" | "citation-card";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceLinkCreatedEvent {
|
||||||
|
readonly type: "EvidenceLinkCreated";
|
||||||
|
readonly linkId: EvidenceLinkId;
|
||||||
|
readonly link: EvidenceLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceLinkUpdatedEvent {
|
||||||
|
readonly type: "EvidenceLinkUpdated";
|
||||||
|
readonly linkId: EvidenceLinkId;
|
||||||
|
readonly link: EvidenceLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceLinkRemovedEvent {
|
||||||
|
readonly type: "EvidenceLinkRemoved";
|
||||||
|
readonly linkId: EvidenceLinkId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormFieldActivatedEvent {
|
||||||
|
readonly type: "FormFieldActivated";
|
||||||
|
readonly target: EvidenceTarget;
|
||||||
|
readonly previousTarget?: EvidenceTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionCreatedEvent {
|
||||||
|
readonly type: "SessionCreated";
|
||||||
|
readonly sessionId: SessionId;
|
||||||
|
readonly session: Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRenamedEvent {
|
||||||
|
readonly type: "SessionRenamed";
|
||||||
|
readonly sessionId: SessionId;
|
||||||
|
readonly session: Session;
|
||||||
|
readonly previousName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionDeletedEvent {
|
||||||
|
readonly type: "SessionDeleted";
|
||||||
|
readonly sessionId: SessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionActivatedEvent {
|
||||||
|
readonly type: "SessionActivated";
|
||||||
|
readonly sessionId: SessionId | null;
|
||||||
|
readonly previousSessionId: SessionId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EngineEvent =
|
||||||
|
| DocumentImportedEvent
|
||||||
|
| DocumentRepresentationGeneratedEvent
|
||||||
|
| DocumentRemovedEvent
|
||||||
|
| AnnotationCreatedEvent
|
||||||
|
| AnnotationUpdatedEvent
|
||||||
|
| AnnotationResolvedEvent
|
||||||
|
| AnnotationResolutionFailedEvent
|
||||||
|
| EvidenceItemCreatedEvent
|
||||||
|
| EvidenceItemUpdatedEvent
|
||||||
|
| EvidenceItemActivatedEvent
|
||||||
|
| EvidenceLinkCreatedEvent
|
||||||
|
| EvidenceLinkUpdatedEvent
|
||||||
|
| EvidenceLinkRemovedEvent
|
||||||
|
| FormFieldActivatedEvent
|
||||||
|
| SessionCreatedEvent
|
||||||
|
| SessionRenamedEvent
|
||||||
|
| SessionDeletedEvent
|
||||||
|
| SessionActivatedEvent;
|
||||||
|
|
||||||
|
export type EngineEventType = EngineEvent["type"];
|
||||||
|
|
||||||
|
export type EngineEventOf<T extends EngineEventType> = Extract<EngineEvent, { type: T }>;
|
||||||
62
src/engine/index.ts
Normal file
62
src/engine/index.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Engine composition root.
|
||||||
|
*
|
||||||
|
* `createEngine()` wires in-memory repos to the services and shares a single
|
||||||
|
* event bus. The app layer holds the returned `Engine` instance and passes
|
||||||
|
* its services into the UI.
|
||||||
|
*
|
||||||
|
* Swapping the repository implementation later (ADR-0005) is a matter of
|
||||||
|
* replacing `createInMemoryRepos()` here. The service signatures don't
|
||||||
|
* change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createEventBus, type EventBus } from "./events";
|
||||||
|
import {
|
||||||
|
createInMemoryRepos,
|
||||||
|
type InMemoryRepos,
|
||||||
|
} from "./repos";
|
||||||
|
import {
|
||||||
|
createAnnotationService,
|
||||||
|
createDocumentService,
|
||||||
|
createEvidenceService,
|
||||||
|
type AnnotationService,
|
||||||
|
type DocumentService,
|
||||||
|
type EvidenceService,
|
||||||
|
} from "./services";
|
||||||
|
|
||||||
|
export * from "./events";
|
||||||
|
export * from "./repos";
|
||||||
|
export * from "./services";
|
||||||
|
export * from "./rendering";
|
||||||
|
export {
|
||||||
|
SNAPSHOT_VERSION,
|
||||||
|
attachPersister,
|
||||||
|
captureSnapshot,
|
||||||
|
documentIdsIn,
|
||||||
|
restoreFromStorage,
|
||||||
|
restoreSnapshot,
|
||||||
|
sanitizeDocumentForPersistence,
|
||||||
|
type EngineSnapshot,
|
||||||
|
type PersisterOptions,
|
||||||
|
} from "./persistence";
|
||||||
|
|
||||||
|
export interface Engine {
|
||||||
|
readonly bus: EventBus;
|
||||||
|
readonly repos: InMemoryRepos;
|
||||||
|
readonly documents: DocumentService;
|
||||||
|
readonly annotations: AnnotationService;
|
||||||
|
readonly evidence: EvidenceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEngine(): Engine {
|
||||||
|
const bus = createEventBus();
|
||||||
|
const repos = createInMemoryRepos();
|
||||||
|
const documents = createDocumentService(repos.documents, repos.representations, bus);
|
||||||
|
const annotations = createAnnotationService(repos.annotations, bus);
|
||||||
|
const evidence = createEvidenceService(
|
||||||
|
repos.evidenceItems,
|
||||||
|
(id) => repos.annotations.get(id),
|
||||||
|
bus,
|
||||||
|
);
|
||||||
|
return { bus, repos, documents, annotations, evidence };
|
||||||
|
}
|
||||||
209
src/engine/persistence.test.ts
Normal file
209
src/engine/persistence.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||||
|
import type { DocumentId, RepresentationId } from "@shared/ids";
|
||||||
|
import {
|
||||||
|
attachPersister,
|
||||||
|
captureSnapshot,
|
||||||
|
createEngine,
|
||||||
|
restoreFromStorage,
|
||||||
|
restoreSnapshot,
|
||||||
|
sanitizeDocumentForPersistence,
|
||||||
|
type Engine,
|
||||||
|
type EngineEvent,
|
||||||
|
type EngineSnapshot,
|
||||||
|
} from "./index";
|
||||||
|
|
||||||
|
function fakeDocAndRep(suffix: string): {
|
||||||
|
document: Document;
|
||||||
|
representation: DocumentRepresentation;
|
||||||
|
} {
|
||||||
|
const docId = `doc_${suffix}` as DocumentId;
|
||||||
|
const repId = `rep_${suffix}` as RepresentationId;
|
||||||
|
return {
|
||||||
|
document: {
|
||||||
|
id: docId,
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
title: `Doc ${suffix}`,
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
representation: {
|
||||||
|
id: repId,
|
||||||
|
documentId: docId,
|
||||||
|
representationType: "pdf-text",
|
||||||
|
contentHash: `hash-${suffix}`,
|
||||||
|
canonicalText: "The quick brown fox.",
|
||||||
|
pageMap: [{ page: 1, width: 100, height: 100 }],
|
||||||
|
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 20, pageLength: 20 }],
|
||||||
|
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (k) => map.get(k) ?? null,
|
||||||
|
setItem: (k, v) => void map.set(k, v),
|
||||||
|
removeItem: (k) => void map.delete(k),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed(engine: Engine, suffix: string) {
|
||||||
|
const { document, representation } = fakeDocAndRep(suffix);
|
||||||
|
engine.documents.register({ document, representation });
|
||||||
|
const ann = engine.annotations.create({
|
||||||
|
documentId: document.id,
|
||||||
|
representationId: representation.id,
|
||||||
|
selectors: [{ type: "TextQuoteSelector", exact: "brown fox" }],
|
||||||
|
quote: "brown fox",
|
||||||
|
});
|
||||||
|
const item = engine.evidence.create({
|
||||||
|
annotationIds: [ann.id],
|
||||||
|
commentary: `commentary-${suffix}`,
|
||||||
|
});
|
||||||
|
return { document, representation, ann, item };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("captureSnapshot + restoreSnapshot", () => {
|
||||||
|
it("round-trips documents, representations, annotations and evidence items", () => {
|
||||||
|
const src = createEngine();
|
||||||
|
const a = seed(src, "a");
|
||||||
|
const b = seed(src, "b");
|
||||||
|
const snap = captureSnapshot(src);
|
||||||
|
expect(snap.documents).toHaveLength(2);
|
||||||
|
expect(snap.representations).toHaveLength(2);
|
||||||
|
expect(snap.annotations).toHaveLength(2);
|
||||||
|
expect(snap.evidenceItems).toHaveLength(2);
|
||||||
|
|
||||||
|
const dst = createEngine();
|
||||||
|
restoreSnapshot(dst, snap);
|
||||||
|
expect(dst.documents.get(a.document.id)?.title).toBe("Doc a");
|
||||||
|
expect(dst.documents.get(b.document.id)?.title).toBe("Doc b");
|
||||||
|
expect(dst.annotations.get(a.ann.id)?.quote).toBe("brown fox");
|
||||||
|
expect(dst.evidence.get(a.item.id)?.commentary).toBe("commentary-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restoreSnapshot does NOT emit *Created events (events would loop the persister)", () => {
|
||||||
|
const src = createEngine();
|
||||||
|
seed(src, "x");
|
||||||
|
const snap = captureSnapshot(src);
|
||||||
|
|
||||||
|
const dst = createEngine();
|
||||||
|
const seen: EngineEvent["type"][] = [];
|
||||||
|
dst.bus.onAny((e) => seen.push(e.type));
|
||||||
|
restoreSnapshot(dst, snap);
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a snapshot with a mismatching version", () => {
|
||||||
|
const dst = createEngine();
|
||||||
|
expect(() =>
|
||||||
|
restoreSnapshot(dst, {
|
||||||
|
version: 999,
|
||||||
|
documents: [],
|
||||||
|
representations: [],
|
||||||
|
annotations: [],
|
||||||
|
evidenceItems: [],
|
||||||
|
} as EngineSnapshot),
|
||||||
|
).toThrow(/version/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("attachPersister", () => {
|
||||||
|
let storage: ReturnType<typeof memoryStorage>;
|
||||||
|
let engine: Engine;
|
||||||
|
const KEY = "ce-test-snap";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = memoryStorage();
|
||||||
|
engine = createEngine();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a snapshot to storage on every mutating event", () => {
|
||||||
|
const off = attachPersister(engine, { key: KEY, storage });
|
||||||
|
expect(storage.getItem(KEY)).toBeNull();
|
||||||
|
seed(engine, "z");
|
||||||
|
const raw = storage.getItem(KEY);
|
||||||
|
expect(raw).not.toBeNull();
|
||||||
|
const snap = JSON.parse(raw!) as EngineSnapshot;
|
||||||
|
expect(snap.documents).toHaveLength(1);
|
||||||
|
expect(snap.evidenceItems).toHaveLength(1);
|
||||||
|
off();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops writing after the unsubscribe is called", () => {
|
||||||
|
const off = attachPersister(engine, { key: KEY, storage });
|
||||||
|
seed(engine, "q");
|
||||||
|
const after = storage.getItem(KEY);
|
||||||
|
off();
|
||||||
|
seed(engine, "r");
|
||||||
|
expect(storage.getItem(KEY)).toBe(after);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a JSON.stringify failure without throwing into the caller", () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const failing = { ...memoryStorage(), setItem: () => { throw new Error("boom"); } };
|
||||||
|
attachPersister(engine, { key: KEY, storage: failing });
|
||||||
|
expect(() => seed(engine, "k")).not.toThrow();
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("restoreFromStorage", () => {
|
||||||
|
it("returns {restored: false} when the key is empty", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const engine = createEngine();
|
||||||
|
const result = restoreFromStorage(engine, { key: "missing", storage });
|
||||||
|
expect(result.restored).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hydrates the engine when storage holds a valid snapshot", () => {
|
||||||
|
const src = createEngine();
|
||||||
|
seed(src, "rs");
|
||||||
|
const storage = memoryStorage();
|
||||||
|
storage.setItem("snap", JSON.stringify(captureSnapshot(src)));
|
||||||
|
|
||||||
|
const dst = createEngine();
|
||||||
|
const result = restoreFromStorage(dst, { key: "snap", storage });
|
||||||
|
expect(result.restored).toBe(true);
|
||||||
|
expect(dst.documents.list()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips blob: URIs from persisted documents", () => {
|
||||||
|
const engine = createEngine();
|
||||||
|
const docId = "doc_blob" as DocumentId;
|
||||||
|
engine.documents.register({
|
||||||
|
document: {
|
||||||
|
id: docId,
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
title: "upload.pdf",
|
||||||
|
uri: "blob:http://localhost/dead",
|
||||||
|
createdAt: "2026-06-07T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-07T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
representation: fakeDocAndRep("blob").representation,
|
||||||
|
});
|
||||||
|
const snap = captureSnapshot(engine);
|
||||||
|
expect(snap.documents[0]?.uri).toBeUndefined();
|
||||||
|
expect(sanitizeDocumentForPersistence({
|
||||||
|
id: docId,
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
uri: "blob:x",
|
||||||
|
createdAt: "x",
|
||||||
|
updatedAt: "x",
|
||||||
|
}).uri).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores malformed JSON without throwing", () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const storage = memoryStorage();
|
||||||
|
storage.setItem("snap", "not-json");
|
||||||
|
const engine = createEngine();
|
||||||
|
const result = restoreFromStorage(engine, { key: "snap", storage });
|
||||||
|
expect(result.restored).toBe(false);
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
146
src/engine/persistence.ts
Normal file
146
src/engine/persistence.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Engine snapshot + restore.
|
||||||
|
*
|
||||||
|
* MVP "persistence" — capture the engine's in-memory state into a JSON blob
|
||||||
|
* and restore it later. Used by the SPA to survive page reloads via
|
||||||
|
* `localStorage` until ADR-0005 lands a real store.
|
||||||
|
*
|
||||||
|
* Restore deliberately bypasses the service layer: it writes directly to
|
||||||
|
* the repos so no `*Created` events fire. Without that, restoring would
|
||||||
|
* trigger the persister to re-write the same snapshot — and if the user
|
||||||
|
* has another tab open, it would also broadcast spurious "this annotation
|
||||||
|
* just appeared" events to UI listeners.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Annotation } from "@shared/annotation";
|
||||||
|
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||||
|
import type { EvidenceItem } from "@shared/evidence";
|
||||||
|
import type { DocumentId } from "@shared/ids";
|
||||||
|
|
||||||
|
import type { Engine } from "./index";
|
||||||
|
|
||||||
|
export const SNAPSHOT_VERSION = 1;
|
||||||
|
|
||||||
|
export interface EngineSnapshot {
|
||||||
|
readonly version: number;
|
||||||
|
readonly documents: readonly Document[];
|
||||||
|
readonly representations: readonly DocumentRepresentation[];
|
||||||
|
readonly annotations: readonly Annotation[];
|
||||||
|
readonly evidenceItems: readonly EvidenceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip ephemeral blob URLs — they cannot survive reload without bytes. */
|
||||||
|
export function sanitizeDocumentForPersistence(document: Document): Document {
|
||||||
|
if (!document.uri || !document.uri.startsWith("blob:")) return document;
|
||||||
|
const { uri: _blobUri, ...rest } = document;
|
||||||
|
void _blobUri;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureSnapshot(engine: Engine): EngineSnapshot {
|
||||||
|
const documents = engine.documents.list().map(sanitizeDocumentForPersistence);
|
||||||
|
// Gather representations per known document.
|
||||||
|
const representations: DocumentRepresentation[] = [];
|
||||||
|
const annotations: Annotation[] = [];
|
||||||
|
const evidenceItems: EvidenceItem[] = [];
|
||||||
|
const seenItemIds = new Set<string>();
|
||||||
|
for (const doc of documents) {
|
||||||
|
representations.push(...engine.documents.listRepresentations(doc.id));
|
||||||
|
annotations.push(...engine.annotations.listByDocument(doc.id));
|
||||||
|
for (const item of engine.evidence.listByDocument(doc.id)) {
|
||||||
|
// listByDocument keys off annotation lookup; an item that shares
|
||||||
|
// annotations across two documents would surface twice. De-dupe.
|
||||||
|
if (!seenItemIds.has(item.id)) {
|
||||||
|
seenItemIds.add(item.id);
|
||||||
|
evidenceItems.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: SNAPSHOT_VERSION,
|
||||||
|
documents: [...documents],
|
||||||
|
representations,
|
||||||
|
annotations,
|
||||||
|
evidenceItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreSnapshot(engine: Engine, snapshot: EngineSnapshot): void {
|
||||||
|
if (snapshot.version !== SNAPSHOT_VERSION) {
|
||||||
|
throw new Error(
|
||||||
|
`restoreSnapshot: snapshot version ${snapshot.version} does not match current ${SNAPSHOT_VERSION}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const d of snapshot.documents) engine.repos.documents.create(d);
|
||||||
|
for (const r of snapshot.representations) engine.repos.representations.create(r);
|
||||||
|
for (const a of snapshot.annotations) engine.repos.annotations.create(a);
|
||||||
|
for (const i of snapshot.evidenceItems) engine.repos.evidenceItems.create(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersisterOptions {
|
||||||
|
/** Storage key. */
|
||||||
|
readonly key: string;
|
||||||
|
/** Storage shim — defaults to globalThis.localStorage. */
|
||||||
|
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to engine events and write a fresh snapshot on every mutation.
|
||||||
|
* Returns the unsubscribe function.
|
||||||
|
*
|
||||||
|
* Initial snapshot is NOT written — call `captureSnapshot` + `storage.setItem`
|
||||||
|
* yourself if you want a baseline.
|
||||||
|
*/
|
||||||
|
export function attachPersister(engine: Engine, options: PersisterOptions): () => void {
|
||||||
|
const storage = options.storage ?? globalThis.localStorage;
|
||||||
|
const write = () => {
|
||||||
|
const snap = captureSnapshot(engine);
|
||||||
|
try {
|
||||||
|
storage.setItem(options.key, JSON.stringify(snap));
|
||||||
|
} catch (err) {
|
||||||
|
// localStorage quota / serialization errors shouldn't crash the app.
|
||||||
|
// Surface to the console; ADR-0005 owns the durable fix.
|
||||||
|
console.warn("attachPersister: write failed", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const offs = [
|
||||||
|
engine.bus.on("DocumentImported", write),
|
||||||
|
engine.bus.on("DocumentRepresentationGenerated", write),
|
||||||
|
engine.bus.on("AnnotationCreated", write),
|
||||||
|
engine.bus.on("AnnotationResolved", write),
|
||||||
|
engine.bus.on("AnnotationResolutionFailed", write),
|
||||||
|
engine.bus.on("EvidenceItemCreated", write),
|
||||||
|
engine.bus.on("EvidenceItemUpdated", write),
|
||||||
|
];
|
||||||
|
return () => {
|
||||||
|
for (const off of offs) off();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RestoreFromStorageOptions = PersisterOptions;
|
||||||
|
|
||||||
|
export function restoreFromStorage(
|
||||||
|
engine: Engine,
|
||||||
|
options: RestoreFromStorageOptions,
|
||||||
|
): { readonly restored: boolean; readonly snapshot?: EngineSnapshot } {
|
||||||
|
const storage = options.storage ?? globalThis.localStorage;
|
||||||
|
const raw = storage.getItem(options.key);
|
||||||
|
if (!raw) return { restored: false };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as EngineSnapshot;
|
||||||
|
if (typeof parsed !== "object" || parsed === null) return { restored: false };
|
||||||
|
restoreSnapshot(engine, parsed);
|
||||||
|
return { restored: true, snapshot: parsed };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("restoreFromStorage: parse failed, ignoring stored snapshot", err);
|
||||||
|
return { restored: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow helper: get the set of document ids restored from a snapshot.
|
||||||
|
* Useful for the SPA's "show me what was open last time" logic.
|
||||||
|
*/
|
||||||
|
export function documentIdsIn(snapshot: EngineSnapshot): readonly DocumentId[] {
|
||||||
|
return snapshot.documents.map((d) => d.id);
|
||||||
|
}
|
||||||
172
src/engine/rendering/html.test.ts
Normal file
172
src/engine/rendering/html.test.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* HTML citation card renderer tests (CE-WP-0004-T03).
|
||||||
|
*
|
||||||
|
* Snapshots lock the output format defined in
|
||||||
|
* `docs/decisions/ADR-0007-citation-card-format.md`. Class names are
|
||||||
|
* part of the public contract — renaming any of them requires updating
|
||||||
|
* both this test and the ADR.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Annotation } from "@shared/annotation";
|
||||||
|
import type { Document } from "@shared/document";
|
||||||
|
import type { EvidenceItem } from "@shared/evidence";
|
||||||
|
import type {
|
||||||
|
AnnotationId,
|
||||||
|
DocumentId,
|
||||||
|
EvidenceItemId,
|
||||||
|
RepresentationId,
|
||||||
|
} from "@shared/ids";
|
||||||
|
|
||||||
|
import { renderCitationCardHtml } from "./html";
|
||||||
|
|
||||||
|
const DOC_ID = "doc_2024-order" as DocumentId;
|
||||||
|
const REP_ID = "rep_2024-order" as RepresentationId;
|
||||||
|
const ANN_ID = "ann_para3" as AnnotationId;
|
||||||
|
const EV_ID = "ev_para3" as EvidenceItemId;
|
||||||
|
|
||||||
|
function makeDoc(overrides: Partial<Document> = {}): Document {
|
||||||
|
return {
|
||||||
|
id: DOC_ID,
|
||||||
|
title: "Order from 14 Mar 2024",
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAnn(overrides: Partial<Annotation> = {}): Annotation {
|
||||||
|
return {
|
||||||
|
id: ANN_ID,
|
||||||
|
documentId: DOC_ID,
|
||||||
|
representationId: REP_ID,
|
||||||
|
selectors: [],
|
||||||
|
quote: "Die Frist endet am 31. März 2024.",
|
||||||
|
normalizeVersion: 1,
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEv(overrides: Partial<EvidenceItem> = {}): EvidenceItem {
|
||||||
|
return {
|
||||||
|
id: EV_ID,
|
||||||
|
annotationIds: [ANN_ID],
|
||||||
|
status: "candidate",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renderCitationCardHtml()", () => {
|
||||||
|
it("renders the full aside with quote, attribution, and commentary", () => {
|
||||||
|
const out = renderCitationCardHtml({
|
||||||
|
evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }),
|
||||||
|
document: makeDoc(),
|
||||||
|
annotation: makeAnn(),
|
||||||
|
});
|
||||||
|
expect(out).toMatchInlineSnapshot(`
|
||||||
|
"<aside class="citation-card">
|
||||||
|
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
|
||||||
|
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&annotation=ann_para3">Open source</a></p>
|
||||||
|
<div class="citation-card__commentary">Deadline clause for the buyer.</div>
|
||||||
|
</aside>
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the commentary div when none is set", () => {
|
||||||
|
const out = renderCitationCardHtml({
|
||||||
|
evidenceItem: makeEv(),
|
||||||
|
document: makeDoc(),
|
||||||
|
annotation: makeAnn(),
|
||||||
|
});
|
||||||
|
expect(out).toMatchInlineSnapshot(`
|
||||||
|
"<aside class="citation-card">
|
||||||
|
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
|
||||||
|
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&annotation=ann_para3">Open source</a></p>
|
||||||
|
</aside>
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts \\n inside the quote to <br> for rich-text paste", () => {
|
||||||
|
const out = renderCitationCardHtml({
|
||||||
|
evidenceItem: makeEv(),
|
||||||
|
document: makeDoc(),
|
||||||
|
annotation: makeAnn({ quote: "Line one.\nLine two." }),
|
||||||
|
});
|
||||||
|
expect(out).toContain(
|
||||||
|
'<blockquote class="citation-card__quote">Line one.<br>Line two.</blockquote>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HTML-escapes &, <, >, \", and ' in user-supplied text", () => {
|
||||||
|
const out = renderCitationCardHtml({
|
||||||
|
evidenceItem: makeEv({
|
||||||
|
commentary: `Notes: <script>alert("xss")</script> & 'untrusted'`,
|
||||||
|
}),
|
||||||
|
document: makeDoc({ title: `Order "draft" & more` }),
|
||||||
|
annotation: makeAnn({ quote: "<<value>> & 'inner'" }),
|
||||||
|
});
|
||||||
|
// Quote escaping
|
||||||
|
expect(out).toContain(
|
||||||
|
"<blockquote class=\"citation-card__quote\"><<value>> & 'inner'</blockquote>",
|
||||||
|
);
|
||||||
|
// Source label escaping
|
||||||
|
expect(out).toContain(
|
||||||
|
"<cite class=\"citation-card__source\">Order "draft" & more</cite>",
|
||||||
|
);
|
||||||
|
// Commentary escaping — and especially: no raw <script>
|
||||||
|
expect(out).not.toContain("<script>");
|
||||||
|
expect(out).toContain(
|
||||||
|
"<div class=\"citation-card__commentary\">Notes: <script>alert("xss")</script> & 'untrusted'</div>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to metadata.filename when document.title is missing", () => {
|
||||||
|
const out = renderCitationCardHtml({
|
||||||
|
evidenceItem: makeEv(),
|
||||||
|
document: {
|
||||||
|
id: DOC_ID,
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
metadata: { filename: "order-2024.pdf" },
|
||||||
|
},
|
||||||
|
annotation: makeAnn(),
|
||||||
|
});
|
||||||
|
expect(out).toContain(
|
||||||
|
'<cite class="citation-card__source">order-2024.pdf</cite>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the · separator and link when openContextUrlOverride is empty", () => {
|
||||||
|
const out = renderCitationCardHtml({
|
||||||
|
evidenceItem: makeEv(),
|
||||||
|
document: makeDoc(),
|
||||||
|
annotation: makeAnn(),
|
||||||
|
openContextUrlOverride: "",
|
||||||
|
});
|
||||||
|
expect(out).toMatchInlineSnapshot(`
|
||||||
|
"<aside class="citation-card">
|
||||||
|
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
|
||||||
|
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite></p>
|
||||||
|
</aside>
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not emit inline style attributes (host page controls CSS)", () => {
|
||||||
|
const out = renderCitationCardHtml({
|
||||||
|
evidenceItem: makeEv({ commentary: "Some commentary." }),
|
||||||
|
document: makeDoc(),
|
||||||
|
annotation: makeAnn(),
|
||||||
|
});
|
||||||
|
expect(out).not.toMatch(/style=/);
|
||||||
|
});
|
||||||
|
});
|
||||||
92
src/engine/rendering/html.ts
Normal file
92
src/engine/rendering/html.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* HTML citation card renderer.
|
||||||
|
*
|
||||||
|
* Implements CE-WP-0004-T03. Output format and rules are locked in
|
||||||
|
* `docs/decisions/ADR-0007-citation-card-format.md`.
|
||||||
|
*
|
||||||
|
* The output is a single `<aside class="citation-card">` element with no
|
||||||
|
* inline styles — host pages provide the CSS. Commentary is treated as
|
||||||
|
* plain text; no Markdown or raw HTML passthrough.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Annotation } from "@shared/annotation";
|
||||||
|
import { resolveSourceLabel } from "@shared/citation-card-source";
|
||||||
|
import type { Document } from "@shared/document";
|
||||||
|
import type { EvidenceItem } from "@shared/evidence";
|
||||||
|
import { openContextUrl } from "@shared/open-context-url";
|
||||||
|
|
||||||
|
export interface RenderCitationCardHtmlInput {
|
||||||
|
readonly evidenceItem: EvidenceItem;
|
||||||
|
readonly document: Document;
|
||||||
|
readonly annotation: Annotation;
|
||||||
|
/**
|
||||||
|
* Override the deep-link URL — same semantics as
|
||||||
|
* `renderCitationCardMarkdown` (host pages with a non-default mount
|
||||||
|
* prefix). Defaults to `openContextUrl({…})`.
|
||||||
|
*/
|
||||||
|
readonly openContextUrlOverride?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTML_ENTITIES: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value.replace(/[&<>"']/g, (c) => HTML_ENTITIES[c]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert source line breaks to `<br>` inside the blockquote so a
|
||||||
|
* multi-line quote stays multi-line in rich-text paste targets. Each
|
||||||
|
* line is HTML-escaped first.
|
||||||
|
*/
|
||||||
|
function escapeQuoteWithLineBreaks(quote: string): string {
|
||||||
|
return quote.split("\n").map(escapeHtml).join("<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCitationCardHtml({
|
||||||
|
evidenceItem,
|
||||||
|
document,
|
||||||
|
annotation,
|
||||||
|
openContextUrlOverride,
|
||||||
|
}: RenderCitationCardHtmlInput): string {
|
||||||
|
const quote = annotation.quote ?? "";
|
||||||
|
const sourceLabel = resolveSourceLabel(document);
|
||||||
|
|
||||||
|
const url =
|
||||||
|
openContextUrlOverride !== undefined
|
||||||
|
? openContextUrlOverride
|
||||||
|
: openContextUrl({
|
||||||
|
documentId: document.id,
|
||||||
|
annotationId: annotation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const quoteHtml = escapeQuoteWithLineBreaks(quote);
|
||||||
|
const sourceHtml = escapeHtml(sourceLabel);
|
||||||
|
|
||||||
|
const linkSegment =
|
||||||
|
url.length > 0
|
||||||
|
? ` · <a class="citation-card__link" href="${escapeHtml(url)}">Open source</a>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const attributionHtml = `<cite class="citation-card__source">${sourceHtml}</cite>${linkSegment}`;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'<aside class="citation-card">',
|
||||||
|
` <blockquote class="citation-card__quote">${quoteHtml}</blockquote>`,
|
||||||
|
` <p class="citation-card__attribution">${attributionHtml}</p>`,
|
||||||
|
];
|
||||||
|
if (evidenceItem.commentary) {
|
||||||
|
const commentaryHtml = escapeHtml(evidenceItem.commentary);
|
||||||
|
lines.push(
|
||||||
|
` <div class="citation-card__commentary">${commentaryHtml}</div>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push("</aside>");
|
||||||
|
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
|
}
|
||||||
8
src/engine/rendering/index.ts
Normal file
8
src/engine/rendering/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
renderCitationCardMarkdown,
|
||||||
|
type RenderCitationCardMarkdownInput,
|
||||||
|
} from "./markdown";
|
||||||
|
export {
|
||||||
|
renderCitationCardHtml,
|
||||||
|
type RenderCitationCardHtmlInput,
|
||||||
|
} from "./html";
|
||||||
194
src/engine/rendering/markdown.test.ts
Normal file
194
src/engine/rendering/markdown.test.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Markdown citation card renderer tests (CE-WP-0004-T02).
|
||||||
|
*
|
||||||
|
* Snapshots lock the output format defined in
|
||||||
|
* `docs/decisions/ADR-0007-citation-card-format.md`. If a snapshot
|
||||||
|
* intentionally changes, update ADR-0007 in the same commit so the
|
||||||
|
* written contract and the runtime stay in sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Annotation } from "@shared/annotation";
|
||||||
|
import type { Document } from "@shared/document";
|
||||||
|
import type { EvidenceItem } from "@shared/evidence";
|
||||||
|
import type {
|
||||||
|
AnnotationId,
|
||||||
|
DocumentId,
|
||||||
|
EvidenceItemId,
|
||||||
|
RepresentationId,
|
||||||
|
} from "@shared/ids";
|
||||||
|
|
||||||
|
import { renderCitationCardMarkdown } from "./markdown";
|
||||||
|
|
||||||
|
const DOC_ID = "doc_2024-order" as DocumentId;
|
||||||
|
const REP_ID = "rep_2024-order" as RepresentationId;
|
||||||
|
const ANN_ID = "ann_para3" as AnnotationId;
|
||||||
|
const EV_ID = "ev_para3" as EvidenceItemId;
|
||||||
|
|
||||||
|
function makeDoc(overrides: Partial<Document> = {}): Document {
|
||||||
|
return {
|
||||||
|
id: DOC_ID,
|
||||||
|
title: "Order from 14 Mar 2024",
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAnn(overrides: Partial<Annotation> = {}): Annotation {
|
||||||
|
return {
|
||||||
|
id: ANN_ID,
|
||||||
|
documentId: DOC_ID,
|
||||||
|
representationId: REP_ID,
|
||||||
|
selectors: [],
|
||||||
|
quote: "Die Frist endet am 31. März 2024.",
|
||||||
|
normalizeVersion: 1,
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEv(overrides: Partial<EvidenceItem> = {}): EvidenceItem {
|
||||||
|
return {
|
||||||
|
id: EV_ID,
|
||||||
|
annotationIds: [ANN_ID],
|
||||||
|
status: "candidate",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renderCitationCardMarkdown()", () => {
|
||||||
|
it("renders quote + attribution + commentary", () => {
|
||||||
|
const out = renderCitationCardMarkdown({
|
||||||
|
evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }),
|
||||||
|
document: makeDoc(),
|
||||||
|
annotation: makeAnn(),
|
||||||
|
});
|
||||||
|
expect(out).toMatchInlineSnapshot(`
|
||||||
|
"> Die Frist endet am 31. März 2024.
|
||||||
|
|
||||||
|
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
||||||
|
|
||||||
|
Deadline clause for the buyer.
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the commentary paragraph when none is set", () => {
|
||||||
|
const out = renderCitationCardMarkdown({
|
||||||
|
evidenceItem: makeEv(),
|
||||||
|
document: makeDoc(),
|
||||||
|
annotation: makeAnn(),
|
||||||
|
});
|
||||||
|
expect(out).toMatchInlineSnapshot(`
|
||||||
|
"> Die Frist endet am 31. März 2024.
|
||||||
|
|
||||||
|
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves multi-line quotes by prefixing each line with '> '", () => {
|
||||||
|
const out = renderCitationCardMarkdown({
|
||||||
|
evidenceItem: makeEv(),
|
||||||
|
document: makeDoc(),
|
||||||
|
annotation: makeAnn({ quote: "Line one.\nLine two.\nLine three." }),
|
||||||
|
});
|
||||||
|
expect(out).toMatchInlineSnapshot(`
|
||||||
|
"> Line one.
|
||||||
|
> Line two.
|
||||||
|
> Line three.
|
||||||
|
|
||||||
|
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to metadata.filename when document.title is missing", () => {
|
||||||
|
const out = renderCitationCardMarkdown({
|
||||||
|
evidenceItem: makeEv(),
|
||||||
|
document: {
|
||||||
|
id: DOC_ID,
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
metadata: { filename: "order-2024.pdf" },
|
||||||
|
},
|
||||||
|
annotation: makeAnn(),
|
||||||
|
});
|
||||||
|
expect(out).toMatchInlineSnapshot(`
|
||||||
|
"> Die Frist endet am 31. März 2024.
|
||||||
|
|
||||||
|
— *order-2024.pdf* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes * and _ in the source label to keep italics intact", () => {
|
||||||
|
const out = renderCitationCardMarkdown({
|
||||||
|
evidenceItem: makeEv(),
|
||||||
|
document: makeDoc({ title: "Order *2024_v2*" }),
|
||||||
|
annotation: makeAnn(),
|
||||||
|
});
|
||||||
|
expect(out).toMatchInlineSnapshot(`
|
||||||
|
"> Die Frist endet am 31. März 2024.
|
||||||
|
|
||||||
|
— *Order \\*2024\\_v2\\** · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the open-context link when override is empty", () => {
|
||||||
|
const out = renderCitationCardMarkdown({
|
||||||
|
evidenceItem: makeEv(),
|
||||||
|
document: makeDoc(),
|
||||||
|
annotation: makeAnn(),
|
||||||
|
openContextUrlOverride: "",
|
||||||
|
});
|
||||||
|
expect(out).toMatchInlineSnapshot(`
|
||||||
|
"> Die Frist endet am 31. März 2024.
|
||||||
|
|
||||||
|
— *Order from 14 Mar 2024*
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours an openContextUrlOverride for mounted-prefix hosts", () => {
|
||||||
|
const out = renderCitationCardMarkdown({
|
||||||
|
evidenceItem: makeEv(),
|
||||||
|
document: makeDoc(),
|
||||||
|
annotation: makeAnn(),
|
||||||
|
openContextUrlOverride: "https://citations.example.test/v?d=foo&a=bar",
|
||||||
|
});
|
||||||
|
expect(out).toContain(
|
||||||
|
"[Open source](https://citations.example.test/v?d=foo&a=bar)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an empty blockquote line when the annotation has no quote", () => {
|
||||||
|
const out = renderCitationCardMarkdown({
|
||||||
|
evidenceItem: makeEv(),
|
||||||
|
document: makeDoc(),
|
||||||
|
annotation: {
|
||||||
|
id: ANN_ID,
|
||||||
|
documentId: DOC_ID,
|
||||||
|
representationId: REP_ID,
|
||||||
|
selectors: [],
|
||||||
|
normalizeVersion: 1,
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(out).toMatchInlineSnapshot(`
|
||||||
|
">
|
||||||
|
|
||||||
|
— *Order from 14 Mar 2024* · [Open source](/viewer?document=doc_2024-order&annotation=ann_para3)
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
src/engine/rendering/markdown.ts
Normal file
68
src/engine/rendering/markdown.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Markdown citation card renderer.
|
||||||
|
*
|
||||||
|
* Implements CE-WP-0004-T02. Output format and rules are locked in
|
||||||
|
* `docs/decisions/ADR-0007-citation-card-format.md`.
|
||||||
|
*
|
||||||
|
* Inputs are passed by value (not looked up from a service) so this
|
||||||
|
* renderer can be unit-tested without an engine and reused by future
|
||||||
|
* batch-export jobs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Annotation } from "@shared/annotation";
|
||||||
|
import { resolveSourceLabel } from "@shared/citation-card-source";
|
||||||
|
import type { Document } from "@shared/document";
|
||||||
|
import type { EvidenceItem } from "@shared/evidence";
|
||||||
|
import { openContextUrl } from "@shared/open-context-url";
|
||||||
|
|
||||||
|
export interface RenderCitationCardMarkdownInput {
|
||||||
|
readonly evidenceItem: EvidenceItem;
|
||||||
|
readonly document: Document;
|
||||||
|
readonly annotation: Annotation;
|
||||||
|
/**
|
||||||
|
* Override the deep-link URL — useful for hosts that mount the viewer
|
||||||
|
* under a non-default prefix. Defaults to `openContextUrl({…})`.
|
||||||
|
*/
|
||||||
|
readonly openContextUrlOverride?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape `*` and `_` inside the source label so it doesn't accidentally
|
||||||
|
* break out of the surrounding `*…*` italic span. No other escaping is
|
||||||
|
* performed — citations should reproduce the source text verbatim.
|
||||||
|
*/
|
||||||
|
function escapeSourceLabel(label: string): string {
|
||||||
|
return label.replace(/[*_]/g, (m) => `\\${m}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCitationCardMarkdown({
|
||||||
|
evidenceItem,
|
||||||
|
document,
|
||||||
|
annotation,
|
||||||
|
openContextUrlOverride,
|
||||||
|
}: RenderCitationCardMarkdownInput): string {
|
||||||
|
const quote = annotation.quote ?? "";
|
||||||
|
const sourceLabel = resolveSourceLabel(document);
|
||||||
|
|
||||||
|
const url =
|
||||||
|
openContextUrlOverride !== undefined
|
||||||
|
? openContextUrlOverride
|
||||||
|
: openContextUrl({
|
||||||
|
documentId: document.id,
|
||||||
|
annotationId: annotation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const quoteLines = quote.length === 0 ? [""] : quote.split("\n");
|
||||||
|
// Trailing whitespace on a blockquote line can be misread as a hard
|
||||||
|
// line break (`<br>`) by some Markdown engines; trimEnd keeps an
|
||||||
|
// empty line as bare `>`.
|
||||||
|
const quoteBlock = quoteLines.map((line) => `> ${line}`.trimEnd()).join("\n");
|
||||||
|
|
||||||
|
const attributionLink = url.length > 0 ? ` · [Open source](${url})` : "";
|
||||||
|
const attribution = `— *${escapeSourceLabel(sourceLabel)}*${attributionLink}`;
|
||||||
|
|
||||||
|
const sections = [quoteBlock, attribution];
|
||||||
|
if (evidenceItem.commentary) sections.push(evidenceItem.commentary);
|
||||||
|
|
||||||
|
return sections.join("\n\n") + "\n";
|
||||||
|
}
|
||||||
47
src/engine/repos/in-memory-sessions.ts
Normal file
47
src/engine/repos/in-memory-sessions.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* In-memory `Map`-backed `SessionRepository`.
|
||||||
|
*
|
||||||
|
* Sister to `in-memory.ts` but for `Session` objects. The session repo
|
||||||
|
* lives *outside* the per-session `Engine` (one repo for the whole app;
|
||||||
|
* each session's engine snapshot is keyed by `session.id`). Keeping it
|
||||||
|
* in the same `engine/repos` directory keeps the storage layer
|
||||||
|
* conventions together so the eventual ADR-0005 swap touches one
|
||||||
|
* neighbourhood.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SessionId } from "@shared/ids";
|
||||||
|
import type { Session } from "@shared/session";
|
||||||
|
|
||||||
|
export interface SessionRepository {
|
||||||
|
create(session: Session): Session;
|
||||||
|
get(id: SessionId): Session | null;
|
||||||
|
list(): readonly Session[];
|
||||||
|
update(session: Session): Session;
|
||||||
|
delete(id: SessionId): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInMemorySessionRepository(): SessionRepository {
|
||||||
|
const sessions = new Map<SessionId, Session>();
|
||||||
|
return {
|
||||||
|
create(session) {
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return sessions.get(id) ?? null;
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
return [...sessions.values()];
|
||||||
|
},
|
||||||
|
update(session) {
|
||||||
|
if (!sessions.has(session.id)) {
|
||||||
|
throw new Error(`SessionRepository.update: unknown id ${session.id}`);
|
||||||
|
}
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return sessions.delete(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
166
src/engine/repos/in-memory.ts
Normal file
166
src/engine/repos/in-memory.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* In-memory `Map`-backed repositories.
|
||||||
|
*
|
||||||
|
* Implements the MVP storage layer. The repository interfaces match the
|
||||||
|
* shape that ADR-0005's eventual persistence implementation will satisfy,
|
||||||
|
* so swapping `createInMemoryRepos()` for a SQLite/Postgres factory later
|
||||||
|
* is a localised change.
|
||||||
|
*
|
||||||
|
* All mutating methods return the *stored* object so callers can pick up
|
||||||
|
* server-assigned fields (none in MVP, but the contract anticipates it).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Annotation } from "@shared/annotation";
|
||||||
|
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||||
|
import type { EvidenceItem } from "@shared/evidence";
|
||||||
|
import type {
|
||||||
|
AnnotationId,
|
||||||
|
DocumentId,
|
||||||
|
EvidenceItemId,
|
||||||
|
RepresentationId,
|
||||||
|
} from "@shared/ids";
|
||||||
|
|
||||||
|
export interface DocumentRepository {
|
||||||
|
create(document: Document): Document;
|
||||||
|
get(id: DocumentId): Document | null;
|
||||||
|
list(): readonly Document[];
|
||||||
|
update(document: Document): Document;
|
||||||
|
delete(id: DocumentId): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepresentationRepository {
|
||||||
|
create(representation: DocumentRepresentation): DocumentRepresentation;
|
||||||
|
get(id: RepresentationId): DocumentRepresentation | null;
|
||||||
|
listByDocument(documentId: DocumentId): readonly DocumentRepresentation[];
|
||||||
|
deleteByDocument(documentId: DocumentId): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationRepository {
|
||||||
|
create(annotation: Annotation): Annotation;
|
||||||
|
get(id: AnnotationId): Annotation | null;
|
||||||
|
listByDocument(documentId: DocumentId): readonly Annotation[];
|
||||||
|
update(annotation: Annotation): Annotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceItemRepository {
|
||||||
|
create(item: EvidenceItem): EvidenceItem;
|
||||||
|
get(id: EvidenceItemId): EvidenceItem | null;
|
||||||
|
listByDocument(
|
||||||
|
documentId: DocumentId,
|
||||||
|
annotationLookup: (id: AnnotationId) => Annotation | null,
|
||||||
|
): readonly EvidenceItem[];
|
||||||
|
update(item: EvidenceItem): EvidenceItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InMemoryRepos {
|
||||||
|
readonly documents: DocumentRepository;
|
||||||
|
readonly representations: RepresentationRepository;
|
||||||
|
readonly annotations: AnnotationRepository;
|
||||||
|
readonly evidenceItems: EvidenceItemRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInMemoryRepos(): InMemoryRepos {
|
||||||
|
const documents = new Map<DocumentId, Document>();
|
||||||
|
const representations = new Map<RepresentationId, DocumentRepresentation>();
|
||||||
|
const annotations = new Map<AnnotationId, Annotation>();
|
||||||
|
const evidenceItems = new Map<EvidenceItemId, EvidenceItem>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
documents: {
|
||||||
|
create(document) {
|
||||||
|
documents.set(document.id, document);
|
||||||
|
return document;
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return documents.get(id) ?? null;
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
return [...documents.values()];
|
||||||
|
},
|
||||||
|
update(document) {
|
||||||
|
if (!documents.has(document.id)) {
|
||||||
|
throw new Error(`DocumentRepository.update: unknown id ${document.id}`);
|
||||||
|
}
|
||||||
|
documents.set(document.id, document);
|
||||||
|
return document;
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return documents.delete(id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
representations: {
|
||||||
|
create(representation) {
|
||||||
|
representations.set(representation.id, representation);
|
||||||
|
return representation;
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return representations.get(id) ?? null;
|
||||||
|
},
|
||||||
|
listByDocument(documentId) {
|
||||||
|
const out: DocumentRepresentation[] = [];
|
||||||
|
for (const rep of representations.values()) {
|
||||||
|
if (rep.documentId === documentId) out.push(rep);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
deleteByDocument(documentId) {
|
||||||
|
let removed = 0;
|
||||||
|
for (const [id, rep] of representations) {
|
||||||
|
if (rep.documentId === documentId) {
|
||||||
|
representations.delete(id);
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
create(annotation) {
|
||||||
|
annotations.set(annotation.id, annotation);
|
||||||
|
return annotation;
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return annotations.get(id) ?? null;
|
||||||
|
},
|
||||||
|
listByDocument(documentId) {
|
||||||
|
const out: Annotation[] = [];
|
||||||
|
for (const ann of annotations.values()) {
|
||||||
|
if (ann.documentId === documentId) out.push(ann);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
update(annotation) {
|
||||||
|
if (!annotations.has(annotation.id)) {
|
||||||
|
throw new Error(`AnnotationRepository.update: unknown id ${annotation.id}`);
|
||||||
|
}
|
||||||
|
annotations.set(annotation.id, annotation);
|
||||||
|
return annotation;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
evidenceItems: {
|
||||||
|
create(item) {
|
||||||
|
evidenceItems.set(item.id, item);
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return evidenceItems.get(id) ?? null;
|
||||||
|
},
|
||||||
|
listByDocument(documentId, annotationLookup) {
|
||||||
|
const out: EvidenceItem[] = [];
|
||||||
|
for (const item of evidenceItems.values()) {
|
||||||
|
if (item.annotationIds.some((aid) => annotationLookup(aid)?.documentId === documentId)) {
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
update(item) {
|
||||||
|
if (!evidenceItems.has(item.id)) {
|
||||||
|
throw new Error(`EvidenceItemRepository.update: unknown id ${item.id}`);
|
||||||
|
}
|
||||||
|
evidenceItems.set(item.id, item);
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/engine/repos/index.ts
Normal file
12
src/engine/repos/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
createInMemoryRepos,
|
||||||
|
type InMemoryRepos,
|
||||||
|
type DocumentRepository,
|
||||||
|
type RepresentationRepository,
|
||||||
|
type AnnotationRepository,
|
||||||
|
type EvidenceItemRepository,
|
||||||
|
} from "./in-memory";
|
||||||
|
export {
|
||||||
|
createInMemorySessionRepository,
|
||||||
|
type SessionRepository,
|
||||||
|
} from "./in-memory-sessions";
|
||||||
129
src/engine/services/annotations.ts
Normal file
129
src/engine/services/annotations.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Annotation service — creates technical marks on document ranges and
|
||||||
|
* emits `AnnotationCreated`. Resolution-status updates emit
|
||||||
|
* `AnnotationResolved` / `AnnotationResolutionFailed`.
|
||||||
|
*
|
||||||
|
* Annotation creation is the engine's response to a user action in the
|
||||||
|
* viewer (T07). The viewer adapter has already turned the selection into
|
||||||
|
* `Selector[]`; this service stamps an ID, normalize-version, timestamps,
|
||||||
|
* persists, and broadcasts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Annotation,
|
||||||
|
AnnotationResolutionStatus,
|
||||||
|
} from "@shared/annotation";
|
||||||
|
import type { DocumentId, RepresentationId, AnnotationId } from "@shared/ids";
|
||||||
|
import type { Selector } from "@shared/selector";
|
||||||
|
import { newId } from "@shared/ids";
|
||||||
|
import { NORMALIZE_VERSION } from "@shared/text/normalize";
|
||||||
|
|
||||||
|
import type { EventBus } from "../events";
|
||||||
|
import type { AnnotationRepository } from "../repos";
|
||||||
|
|
||||||
|
export interface CreateAnnotationInput {
|
||||||
|
readonly documentId: DocumentId;
|
||||||
|
readonly representationId?: RepresentationId;
|
||||||
|
readonly selectors: readonly Selector[];
|
||||||
|
readonly quote?: string;
|
||||||
|
readonly note?: string;
|
||||||
|
readonly createdBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationService {
|
||||||
|
create(input: CreateAnnotationInput): Annotation;
|
||||||
|
get(id: AnnotationId): Annotation | null;
|
||||||
|
listByDocument(documentId: DocumentId): readonly Annotation[];
|
||||||
|
setResolutionStatus(
|
||||||
|
id: AnnotationId,
|
||||||
|
status: AnnotationResolutionStatus,
|
||||||
|
opts: { readonly confidence: number; readonly reason?: string },
|
||||||
|
): Annotation;
|
||||||
|
/**
|
||||||
|
* Edit the human-facing `quote` text on an annotation without touching
|
||||||
|
* the underlying selectors. Selectors stay the source of truth for
|
||||||
|
* locating the passage; the quote is the user's editable display copy.
|
||||||
|
*/
|
||||||
|
updateQuote(id: AnnotationId, quote: string): Annotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAnnotationService(
|
||||||
|
annotations: AnnotationRepository,
|
||||||
|
bus: EventBus,
|
||||||
|
now: () => string = () => new Date().toISOString(),
|
||||||
|
): AnnotationService {
|
||||||
|
return {
|
||||||
|
create(input) {
|
||||||
|
const ts = now();
|
||||||
|
const annotation: Annotation = {
|
||||||
|
id: newId("annotation"),
|
||||||
|
documentId: input.documentId,
|
||||||
|
...(input.representationId !== undefined ? { representationId: input.representationId } : {}),
|
||||||
|
selectors: input.selectors,
|
||||||
|
...(input.quote !== undefined ? { quote: input.quote } : {}),
|
||||||
|
...(input.note !== undefined ? { note: input.note } : {}),
|
||||||
|
normalizeVersion: NORMALIZE_VERSION,
|
||||||
|
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
|
||||||
|
createdAt: ts,
|
||||||
|
updatedAt: ts,
|
||||||
|
};
|
||||||
|
const stored = annotations.create(annotation);
|
||||||
|
bus.emit({ type: "AnnotationCreated", annotationId: stored.id, annotation: stored });
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return annotations.get(id);
|
||||||
|
},
|
||||||
|
listByDocument(documentId) {
|
||||||
|
return annotations.listByDocument(documentId);
|
||||||
|
},
|
||||||
|
setResolutionStatus(id, status, opts) {
|
||||||
|
const existing = annotations.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`AnnotationService.setResolutionStatus: unknown id ${id}`);
|
||||||
|
}
|
||||||
|
const updated: Annotation = {
|
||||||
|
...existing,
|
||||||
|
resolutionStatus: status,
|
||||||
|
updatedAt: now(),
|
||||||
|
};
|
||||||
|
const stored = annotations.update(updated);
|
||||||
|
if (status === "unresolved" || status === "stale") {
|
||||||
|
bus.emit({
|
||||||
|
type: "AnnotationResolutionFailed",
|
||||||
|
annotationId: stored.id,
|
||||||
|
reason: opts.reason ?? status,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
bus.emit({
|
||||||
|
type: "AnnotationResolved",
|
||||||
|
annotationId: stored.id,
|
||||||
|
status,
|
||||||
|
confidence: opts.confidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
updateQuote(id, quote) {
|
||||||
|
const existing = annotations.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`AnnotationService.updateQuote: unknown id ${id}`);
|
||||||
|
}
|
||||||
|
const trimmed = quote.length === 0 ? undefined : quote;
|
||||||
|
const updated: Annotation = {
|
||||||
|
...existing,
|
||||||
|
// exactOptionalPropertyTypes: drop `quote` when empty rather
|
||||||
|
// than setting it to undefined.
|
||||||
|
...(trimmed !== undefined ? { quote: trimmed } : {}),
|
||||||
|
updatedAt: now(),
|
||||||
|
};
|
||||||
|
if (trimmed === undefined && "quote" in updated) {
|
||||||
|
// Remove the field outright when clearing.
|
||||||
|
delete (updated as { quote?: string }).quote;
|
||||||
|
}
|
||||||
|
const stored = annotations.update(updated);
|
||||||
|
bus.emit({ type: "AnnotationUpdated", annotationId: stored.id, annotation: stored });
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
74
src/engine/services/documents.ts
Normal file
74
src/engine/services/documents.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Document service — registers ingested documents and emits the §4 events.
|
||||||
|
*
|
||||||
|
* The ingest pipeline (`src/source/pdf/ingest.ts`) is a pure function over
|
||||||
|
* bytes — it does not touch the engine. The app composition root calls
|
||||||
|
* `ingestPdf` then hands the result to `documentService.register()`, which
|
||||||
|
* is where the engine takes over: persist into the repos, emit
|
||||||
|
* `DocumentImported` + `DocumentRepresentationGenerated`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||||
|
import type { DocumentId, RepresentationId } from "@shared/ids";
|
||||||
|
|
||||||
|
import type { EventBus } from "../events";
|
||||||
|
import type { DocumentRepository, RepresentationRepository } from "../repos";
|
||||||
|
|
||||||
|
export interface DocumentService {
|
||||||
|
register(input: {
|
||||||
|
readonly document: Document;
|
||||||
|
readonly representation: DocumentRepresentation;
|
||||||
|
}): { readonly document: Document; readonly representation: DocumentRepresentation };
|
||||||
|
get(id: DocumentId): Document | null;
|
||||||
|
list(): readonly Document[];
|
||||||
|
getRepresentation(id: RepresentationId): DocumentRepresentation | null;
|
||||||
|
listRepresentations(documentId: DocumentId): readonly DocumentRepresentation[];
|
||||||
|
remove(id: DocumentId): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDocumentService(
|
||||||
|
documents: DocumentRepository,
|
||||||
|
representations: RepresentationRepository,
|
||||||
|
bus: EventBus,
|
||||||
|
): DocumentService {
|
||||||
|
return {
|
||||||
|
register({ document, representation }) {
|
||||||
|
const storedDocument = documents.create(document);
|
||||||
|
const storedRepresentation = representations.create(representation);
|
||||||
|
bus.emit({
|
||||||
|
type: "DocumentImported",
|
||||||
|
documentId: storedDocument.id,
|
||||||
|
document: storedDocument,
|
||||||
|
});
|
||||||
|
bus.emit({
|
||||||
|
type: "DocumentRepresentationGenerated",
|
||||||
|
documentId: storedDocument.id,
|
||||||
|
representationId: storedRepresentation.id,
|
||||||
|
representation: storedRepresentation,
|
||||||
|
});
|
||||||
|
return { document: storedDocument, representation: storedRepresentation };
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return documents.get(id);
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
return documents.list();
|
||||||
|
},
|
||||||
|
getRepresentation(id) {
|
||||||
|
return representations.get(id);
|
||||||
|
},
|
||||||
|
listRepresentations(documentId) {
|
||||||
|
return representations.listByDocument(documentId);
|
||||||
|
},
|
||||||
|
remove(id) {
|
||||||
|
const existing = documents.get(id);
|
||||||
|
if (!existing) return false;
|
||||||
|
representations.deleteByDocument(id);
|
||||||
|
const removed = documents.delete(id);
|
||||||
|
if (removed) {
|
||||||
|
bus.emit({ type: "DocumentRemoved", documentId: id });
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
142
src/engine/services/evidence.ts
Normal file
142
src/engine/services/evidence.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Evidence service — creates EvidenceItems on top of annotations and
|
||||||
|
* tracks their lifecycle. Emits §4 events: `EvidenceItemCreated`,
|
||||||
|
* `EvidenceItemUpdated`, `EvidenceItemActivated`.
|
||||||
|
*
|
||||||
|
* MVP item shape per `wiki/SharedContracts.md` §2.2: status starts at
|
||||||
|
* `candidate`, may transition to `confirmed | rejected | needs-check`.
|
||||||
|
* Item-level relation/strength (supports/contradicts/...) lives on the
|
||||||
|
* link, not the item — that's CE-WP-0003.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Annotation } from "@shared/annotation";
|
||||||
|
import type {
|
||||||
|
EvidenceItem,
|
||||||
|
EvidenceItemStatus,
|
||||||
|
} from "@shared/evidence";
|
||||||
|
import type {
|
||||||
|
AnnotationId,
|
||||||
|
DocumentId,
|
||||||
|
EvidenceItemId,
|
||||||
|
} from "@shared/ids";
|
||||||
|
import { newId } from "@shared/ids";
|
||||||
|
|
||||||
|
import type { EventBus, EvidenceItemActivatedEvent } from "../events";
|
||||||
|
import type { EvidenceItemRepository } from "../repos";
|
||||||
|
|
||||||
|
export interface CreateEvidenceItemInput {
|
||||||
|
readonly annotationIds: readonly AnnotationId[];
|
||||||
|
readonly title?: string;
|
||||||
|
readonly commentary?: string;
|
||||||
|
readonly status?: EvidenceItemStatus;
|
||||||
|
readonly confidence?: number;
|
||||||
|
readonly tags?: readonly string[];
|
||||||
|
readonly createdBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceService {
|
||||||
|
create(input: CreateEvidenceItemInput): EvidenceItem;
|
||||||
|
get(id: EvidenceItemId): EvidenceItem | null;
|
||||||
|
listByDocument(documentId: DocumentId): readonly EvidenceItem[];
|
||||||
|
setStatus(id: EvidenceItemId, status: EvidenceItemStatus): EvidenceItem;
|
||||||
|
updateCommentary(id: EvidenceItemId, commentary: string): EvidenceItem;
|
||||||
|
activate(
|
||||||
|
id: EvidenceItemId,
|
||||||
|
source?: EvidenceItemActivatedEvent["source"],
|
||||||
|
): EvidenceItem;
|
||||||
|
/**
|
||||||
|
* Reverse lookup: find the evidence item that owns a given annotation.
|
||||||
|
* Used by the viewer's click-on-highlight handler so a click on the
|
||||||
|
* passage activates the right sidebar row.
|
||||||
|
*/
|
||||||
|
findByAnnotationId(
|
||||||
|
documentId: DocumentId,
|
||||||
|
annotationId: AnnotationId,
|
||||||
|
): EvidenceItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEvidenceService(
|
||||||
|
items: EvidenceItemRepository,
|
||||||
|
annotationLookup: (id: AnnotationId) => Annotation | null,
|
||||||
|
bus: EventBus,
|
||||||
|
now: () => string = () => new Date().toISOString(),
|
||||||
|
): EvidenceService {
|
||||||
|
return {
|
||||||
|
create(input) {
|
||||||
|
if (input.annotationIds.length === 0) {
|
||||||
|
throw new Error("EvidenceService.create: at least one annotationId is required");
|
||||||
|
}
|
||||||
|
const ts = now();
|
||||||
|
const item: EvidenceItem = {
|
||||||
|
id: newId("evidence"),
|
||||||
|
annotationIds: input.annotationIds,
|
||||||
|
...(input.title !== undefined ? { title: input.title } : {}),
|
||||||
|
...(input.commentary !== undefined ? { commentary: input.commentary } : {}),
|
||||||
|
status: input.status ?? "candidate",
|
||||||
|
...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
|
||||||
|
...(input.tags !== undefined ? { tags: input.tags } : {}),
|
||||||
|
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
|
||||||
|
createdAt: ts,
|
||||||
|
updatedAt: ts,
|
||||||
|
};
|
||||||
|
const stored = items.create(item);
|
||||||
|
bus.emit({ type: "EvidenceItemCreated", evidenceItemId: stored.id, evidenceItem: stored });
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return items.get(id);
|
||||||
|
},
|
||||||
|
listByDocument(documentId) {
|
||||||
|
return items.listByDocument(documentId, annotationLookup);
|
||||||
|
},
|
||||||
|
setStatus(id, status) {
|
||||||
|
const existing = items.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`EvidenceService.setStatus: unknown id ${id}`);
|
||||||
|
}
|
||||||
|
if (existing.status === status) return existing;
|
||||||
|
const updated: EvidenceItem = { ...existing, status, updatedAt: now() };
|
||||||
|
const stored = items.update(updated);
|
||||||
|
bus.emit({
|
||||||
|
type: "EvidenceItemUpdated",
|
||||||
|
evidenceItemId: stored.id,
|
||||||
|
evidenceItem: stored,
|
||||||
|
previousStatus: existing.status,
|
||||||
|
});
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
updateCommentary(id, commentary) {
|
||||||
|
const existing = items.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`EvidenceService.updateCommentary: unknown id ${id}`);
|
||||||
|
}
|
||||||
|
const updated: EvidenceItem = { ...existing, commentary, updatedAt: now() };
|
||||||
|
const stored = items.update(updated);
|
||||||
|
bus.emit({
|
||||||
|
type: "EvidenceItemUpdated",
|
||||||
|
evidenceItemId: stored.id,
|
||||||
|
evidenceItem: stored,
|
||||||
|
previousStatus: existing.status,
|
||||||
|
});
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
activate(id, source) {
|
||||||
|
const existing = items.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`EvidenceService.activate: unknown id ${id}`);
|
||||||
|
}
|
||||||
|
bus.emit({
|
||||||
|
type: "EvidenceItemActivated",
|
||||||
|
evidenceItemId: existing.id,
|
||||||
|
...(source !== undefined ? { source } : {}),
|
||||||
|
});
|
||||||
|
return existing;
|
||||||
|
},
|
||||||
|
findByAnnotationId(documentId, annotationId) {
|
||||||
|
for (const item of items.listByDocument(documentId, annotationLookup)) {
|
||||||
|
if (item.annotationIds.includes(annotationId)) return item;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
29
src/engine/services/index.ts
Normal file
29
src/engine/services/index.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export {
|
||||||
|
createDocumentService,
|
||||||
|
type DocumentService,
|
||||||
|
} from "./documents";
|
||||||
|
export {
|
||||||
|
createAnnotationService,
|
||||||
|
type AnnotationService,
|
||||||
|
type CreateAnnotationInput,
|
||||||
|
} from "./annotations";
|
||||||
|
export {
|
||||||
|
createEvidenceService,
|
||||||
|
type EvidenceService,
|
||||||
|
type CreateEvidenceItemInput,
|
||||||
|
} from "./evidence";
|
||||||
|
export {
|
||||||
|
ACTIVE_SESSION_KEY,
|
||||||
|
attachSessionPersister,
|
||||||
|
clearAllSessionData,
|
||||||
|
computeNextDefaultName,
|
||||||
|
createSessionService,
|
||||||
|
DuplicateSessionNameError,
|
||||||
|
engineSnapshotKey,
|
||||||
|
restoreSessionsFromStorage,
|
||||||
|
SESSIONS_INDEX_KEY,
|
||||||
|
type CreateSessionInput,
|
||||||
|
type RestoreSessionsResult,
|
||||||
|
type SessionPersisterOptions,
|
||||||
|
type SessionService,
|
||||||
|
} from "./sessions";
|
||||||
250
src/engine/services/sessions.test.ts
Normal file
250
src/engine/services/sessions.test.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { SessionId } from "@shared/ids";
|
||||||
|
import type { Session } from "@shared/session";
|
||||||
|
|
||||||
|
import { createEventBus, type EventBus, type EngineEvent } from "../events";
|
||||||
|
import {
|
||||||
|
createInMemorySessionRepository,
|
||||||
|
type SessionRepository,
|
||||||
|
} from "../repos";
|
||||||
|
import {
|
||||||
|
ACTIVE_SESSION_KEY,
|
||||||
|
attachSessionPersister,
|
||||||
|
computeNextDefaultName,
|
||||||
|
createSessionService,
|
||||||
|
DuplicateSessionNameError,
|
||||||
|
engineSnapshotKey,
|
||||||
|
restoreSessionsFromStorage,
|
||||||
|
SESSIONS_INDEX_KEY,
|
||||||
|
type SessionService,
|
||||||
|
} from "./sessions";
|
||||||
|
|
||||||
|
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (k) => map.get(k) ?? null,
|
||||||
|
setItem: (k, v) => void map.set(k, v),
|
||||||
|
removeItem: (k) => void map.delete(k),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshService(): {
|
||||||
|
service: SessionService;
|
||||||
|
repo: SessionRepository;
|
||||||
|
bus: EventBus;
|
||||||
|
events: EngineEvent[];
|
||||||
|
} {
|
||||||
|
const repo = createInMemorySessionRepository();
|
||||||
|
const bus = createEventBus();
|
||||||
|
const events: EngineEvent[] = [];
|
||||||
|
bus.onAny((e) => events.push(e));
|
||||||
|
const service = createSessionService(repo, bus);
|
||||||
|
return { service, repo, bus, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("engineSnapshotKey", () => {
|
||||||
|
it("formats as citation-evidence:session:<id>:engine-snapshot:v1", () => {
|
||||||
|
expect(engineSnapshotKey("sess_abc" as SessionId)).toBe(
|
||||||
|
"citation-evidence:session:sess_abc:engine-snapshot:v1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeNextDefaultName", () => {
|
||||||
|
const now = new Date(2026, 4, 27); // 2026-05-27 → "260527"
|
||||||
|
|
||||||
|
it("returns 000 for the first session of the day", () => {
|
||||||
|
expect(computeNextDefaultName([], now)).toBe("260527-session-000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts only sessions whose name matches today's prefix", () => {
|
||||||
|
const existing = [
|
||||||
|
{ name: "260527-session-000" } as Session,
|
||||||
|
{ name: "260527-session-001" } as Session,
|
||||||
|
{ name: "Random other name" } as Session,
|
||||||
|
{ name: "260526-session-007" } as Session, // yesterday — ignored
|
||||||
|
];
|
||||||
|
expect(computeNextDefaultName(existing, now)).toBe("260527-session-002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments past the highest, not the count", () => {
|
||||||
|
const existing = [
|
||||||
|
{ name: "260527-session-005" } as Session,
|
||||||
|
{ name: "260527-session-002" } as Session,
|
||||||
|
];
|
||||||
|
expect(computeNextDefaultName(existing, now)).toBe("260527-session-006");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores names with the wrong shape and accepts trimmed matches", () => {
|
||||||
|
const existing = [
|
||||||
|
{ name: " 260527-session-003 " } as Session, // whitespace OK
|
||||||
|
{ name: "260527-session-12" } as Session, // wrong digit count
|
||||||
|
{ name: "260527-sess-005" } as Session, // wrong infix
|
||||||
|
];
|
||||||
|
expect(computeNextDefaultName(existing, now)).toBe("260527-session-004");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SessionService.nextDefaultName", () => {
|
||||||
|
it("delegates to computeNextDefaultName against the current repo", () => {
|
||||||
|
const s = freshService();
|
||||||
|
const now = new Date(2026, 4, 27);
|
||||||
|
s.service.create({ name: "260527-session-000" });
|
||||||
|
s.service.create({ name: "260527-session-001" });
|
||||||
|
expect(s.service.nextDefaultName(now)).toBe("260527-session-002");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SessionService — lifecycle", () => {
|
||||||
|
let s: ReturnType<typeof freshService>;
|
||||||
|
beforeEach(() => {
|
||||||
|
s = freshService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a session and emits SessionCreated", () => {
|
||||||
|
const created = s.service.create("Lease 2024");
|
||||||
|
expect(created.name).toBe("Lease 2024");
|
||||||
|
expect(created.id).toMatch(/^sess_/);
|
||||||
|
expect(s.events).toHaveLength(1);
|
||||||
|
expect(s.events[0]!.type).toBe("SessionCreated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace in names", () => {
|
||||||
|
const created = s.service.create(" Trimmed ");
|
||||||
|
expect(created.name).toBe("Trimmed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty names", () => {
|
||||||
|
expect(() => s.service.create(" ")).toThrow(/must not be empty/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects case-insensitive duplicates", () => {
|
||||||
|
s.service.create("Demo");
|
||||||
|
expect(() => s.service.create("demo")).toThrow(DuplicateSessionNameError);
|
||||||
|
expect(() => s.service.create(" Demo ")).toThrow(DuplicateSessionNameError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rename emits SessionRenamed with previousName", () => {
|
||||||
|
const created = s.service.create("Old");
|
||||||
|
s.events.length = 0;
|
||||||
|
const renamed = s.service.rename(created.id, "New");
|
||||||
|
expect(renamed.name).toBe("New");
|
||||||
|
expect(s.events).toHaveLength(1);
|
||||||
|
const evt = s.events[0]!;
|
||||||
|
expect(evt.type).toBe("SessionRenamed");
|
||||||
|
if (evt.type === "SessionRenamed") {
|
||||||
|
expect(evt.previousName).toBe("Old");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rename rejects a duplicate (but allows renaming to your own current name)", () => {
|
||||||
|
const a = s.service.create("Alpha");
|
||||||
|
s.service.create("Beta");
|
||||||
|
expect(() => s.service.rename(a.id, "Beta")).toThrow(DuplicateSessionNameError);
|
||||||
|
// self-rename is fine
|
||||||
|
const same = s.service.rename(a.id, "Alpha");
|
||||||
|
expect(same.name).toBe("Alpha");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete emits SessionDeleted and clears active if it was the active one", () => {
|
||||||
|
const created = s.service.create("To Delete");
|
||||||
|
s.service.setActive(created.id);
|
||||||
|
s.events.length = 0;
|
||||||
|
const ok = s.service.delete(created.id);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
const types = s.events.map((e) => e.type);
|
||||||
|
expect(types).toContain("SessionActivated"); // active cleared first
|
||||||
|
expect(types).toContain("SessionDeleted");
|
||||||
|
expect(s.service.getActive()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete on an unknown id is a no-op (returns false, no events)", () => {
|
||||||
|
const ok = s.service.delete("sess_missing" as SessionId);
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(s.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setActive on an unknown id throws", () => {
|
||||||
|
expect(() => s.service.setActive("sess_nope" as SessionId)).toThrow(/unknown session/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setActive emits SessionActivated with previousSessionId", () => {
|
||||||
|
const a = s.service.create("A");
|
||||||
|
const b = s.service.create("B");
|
||||||
|
s.events.length = 0;
|
||||||
|
s.service.setActive(a.id);
|
||||||
|
s.service.setActive(b.id);
|
||||||
|
const activated = s.events.filter((e) => e.type === "SessionActivated");
|
||||||
|
expect(activated).toHaveLength(2);
|
||||||
|
if (activated[1]!.type === "SessionActivated") {
|
||||||
|
expect(activated[1]!.previousSessionId).toBe(a.id);
|
||||||
|
expect(activated[1]!.sessionId).toBe(b.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setActive to the same id is a no-op (no event)", () => {
|
||||||
|
const a = s.service.create("A");
|
||||||
|
s.service.setActive(a.id);
|
||||||
|
s.events.length = 0;
|
||||||
|
s.service.setActive(a.id);
|
||||||
|
expect(s.events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setActive stamps lastOpenedAt", () => {
|
||||||
|
const a = s.service.create("A");
|
||||||
|
expect(a.lastOpenedAt).toBeUndefined();
|
||||||
|
s.service.setActive(a.id);
|
||||||
|
const reread = s.service.get(a.id);
|
||||||
|
expect(reread?.lastOpenedAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("attachSessionPersister + restoreSessionsFromStorage", () => {
|
||||||
|
it("round-trips a session index through storage", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const src = freshService();
|
||||||
|
attachSessionPersister(src.service, src.bus, { storage });
|
||||||
|
|
||||||
|
const a = src.service.create("Alpha");
|
||||||
|
const b = src.service.create("Beta");
|
||||||
|
src.service.setActive(b.id);
|
||||||
|
|
||||||
|
// Read-back into a fresh service.
|
||||||
|
const dst = freshService();
|
||||||
|
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
|
||||||
|
expect(result.restored).toBe(true);
|
||||||
|
expect(result.sessions.map((s: Session) => s.name).sort()).toEqual(["Alpha", "Beta"]);
|
||||||
|
expect(result.activeSessionId).toBe(b.id);
|
||||||
|
expect(dst.service.getActive()).toBe(b.id);
|
||||||
|
// a is still in the repo too
|
||||||
|
expect(dst.service.get(a.id)?.name).toBe("Alpha");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns {restored:false} when storage is empty", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const dst = freshService();
|
||||||
|
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
|
||||||
|
expect(result.restored).toBe(false);
|
||||||
|
expect(result.sessions).toHaveLength(0);
|
||||||
|
expect(result.activeSessionId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete clears both the index entry and the per-session snapshot key", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const src = freshService();
|
||||||
|
attachSessionPersister(src.service, src.bus, { storage });
|
||||||
|
const created = src.service.create("Doomed");
|
||||||
|
// Pretend an engine snapshot was written by the per-session persister.
|
||||||
|
storage.setItem(engineSnapshotKey(created.id), "{}");
|
||||||
|
|
||||||
|
src.service.delete(created.id);
|
||||||
|
|
||||||
|
expect(storage.getItem(engineSnapshotKey(created.id))).toBeNull();
|
||||||
|
const raw = storage.getItem(SESSIONS_INDEX_KEY);
|
||||||
|
expect(raw).not.toBeNull();
|
||||||
|
const parsed = JSON.parse(raw!);
|
||||||
|
expect(parsed.sessions).toHaveLength(0);
|
||||||
|
expect(storage.getItem(ACTIVE_SESSION_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
362
src/engine/services/sessions.ts
Normal file
362
src/engine/services/sessions.ts
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
/**
|
||||||
|
* SessionService — lifecycle for `Session` records.
|
||||||
|
*
|
||||||
|
* Lives *above* the per-session `Engine` (the engine itself is recreated
|
||||||
|
* each time the active session changes). The service owns its own
|
||||||
|
* `EventBus` instance — independent of any engine bus — but uses the
|
||||||
|
* same `EngineEvent` shape so consumers can subscribe with the standard
|
||||||
|
* `bus.on("SessionCreated", …)` pattern.
|
||||||
|
*
|
||||||
|
* Per-session engine snapshot persistence is handled by attaching the
|
||||||
|
* existing `attachPersister(engine, { key: engineSnapshotKey(sessionId) })`
|
||||||
|
* inside the app's `EngineProvider`. The helpers in this file own the
|
||||||
|
* *cross-session* storage: the session index + the active-session
|
||||||
|
* pointer.
|
||||||
|
*
|
||||||
|
* Naming rules:
|
||||||
|
* - Names are trimmed on input.
|
||||||
|
* - Case-insensitive uniqueness — two sessions cannot coexist with
|
||||||
|
* names that differ only in case ("Demo" vs "demo"). This avoids
|
||||||
|
* surprising the ZIP-merge path in T07, which uses `session.name`
|
||||||
|
* to find an existing target.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { newId } from "@shared/ids";
|
||||||
|
import type { SessionId } from "@shared/ids";
|
||||||
|
import type { Session } from "@shared/session";
|
||||||
|
|
||||||
|
import type { EventBus } from "../events";
|
||||||
|
import type { SessionRepository } from "../repos";
|
||||||
|
|
||||||
|
const SESSIONS_INDEX_KEY = "citation-evidence:sessions:v1";
|
||||||
|
const ACTIVE_SESSION_KEY = "citation-evidence:active-session-id:v1";
|
||||||
|
|
||||||
|
export { SESSIONS_INDEX_KEY, ACTIVE_SESSION_KEY };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the engine-snapshot storage key for a given session.
|
||||||
|
*
|
||||||
|
* Format: `citation-evidence:session:<sessionId>:engine-snapshot:v1`.
|
||||||
|
* The `v1` tail leaves room for a future migration that changes the
|
||||||
|
* snapshot shape without sweeping the legacy keys.
|
||||||
|
*/
|
||||||
|
export function engineSnapshotKey(sessionId: SessionId): string {
|
||||||
|
return `citation-evidence:session:${sessionId}:engine-snapshot:v1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DuplicateSessionNameError extends Error {
|
||||||
|
constructor(name: string) {
|
||||||
|
super(`Session with name "${name}" already exists`);
|
||||||
|
this.name = "DuplicateSessionNameError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionInput {
|
||||||
|
readonly name: string;
|
||||||
|
/** Override the generated id — primarily for tests and importers. */
|
||||||
|
readonly id?: SessionId;
|
||||||
|
readonly now?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionService {
|
||||||
|
create(input: string | CreateSessionInput): Session;
|
||||||
|
rename(id: SessionId, name: string): Session;
|
||||||
|
delete(id: SessionId): boolean;
|
||||||
|
list(): readonly Session[];
|
||||||
|
get(id: SessionId): Session | null;
|
||||||
|
setActive(id: SessionId | null): void;
|
||||||
|
getActive(): SessionId | null;
|
||||||
|
/** Record an "I just opened this" timestamp on the session. */
|
||||||
|
touch(id: SessionId): Session | null;
|
||||||
|
/**
|
||||||
|
* Suggest a default session name when the user hasn't typed one.
|
||||||
|
* Returns `YYMMDD-session-NNN` where `NNN` is the next free counter
|
||||||
|
* for today (starting at `000`). Pure: does not mutate the repo.
|
||||||
|
*/
|
||||||
|
nextDefaultName(now?: Date): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowIso(now?: string): string {
|
||||||
|
return now ?? new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalisedName(name: string): { display: string; key: string } {
|
||||||
|
const display = name.trim();
|
||||||
|
return { display, key: display.toLocaleLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionService(
|
||||||
|
repo: SessionRepository,
|
||||||
|
bus: EventBus,
|
||||||
|
): SessionService {
|
||||||
|
let activeId: SessionId | null = null;
|
||||||
|
|
||||||
|
function assertUniqueName(name: string, exceptId?: SessionId) {
|
||||||
|
const { key } = normalisedName(name);
|
||||||
|
for (const existing of repo.list()) {
|
||||||
|
if (exceptId && existing.id === exceptId) continue;
|
||||||
|
if (existing.name.trim().toLocaleLowerCase() === key) {
|
||||||
|
throw new DuplicateSessionNameError(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
create(input) {
|
||||||
|
const { name, id, now } =
|
||||||
|
typeof input === "string" ? { name: input, id: undefined, now: undefined } : input;
|
||||||
|
const { display } = normalisedName(name);
|
||||||
|
if (display.length === 0) {
|
||||||
|
throw new Error("SessionService.create: name must not be empty");
|
||||||
|
}
|
||||||
|
assertUniqueName(display);
|
||||||
|
const ts = nowIso(now);
|
||||||
|
const session: Session = {
|
||||||
|
id: id ?? newId("session"),
|
||||||
|
name: display,
|
||||||
|
createdAt: ts,
|
||||||
|
updatedAt: ts,
|
||||||
|
};
|
||||||
|
const stored = repo.create(session);
|
||||||
|
bus.emit({ type: "SessionCreated", sessionId: stored.id, session: stored });
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
rename(id, name) {
|
||||||
|
const existing = repo.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error(`SessionService.rename: unknown session ${id}`);
|
||||||
|
}
|
||||||
|
const { display } = normalisedName(name);
|
||||||
|
if (display.length === 0) {
|
||||||
|
throw new Error("SessionService.rename: name must not be empty");
|
||||||
|
}
|
||||||
|
assertUniqueName(display, id);
|
||||||
|
const previousName = existing.name;
|
||||||
|
const next: Session = {
|
||||||
|
...existing,
|
||||||
|
name: display,
|
||||||
|
updatedAt: nowIso(),
|
||||||
|
};
|
||||||
|
const stored = repo.update(next);
|
||||||
|
bus.emit({
|
||||||
|
type: "SessionRenamed",
|
||||||
|
sessionId: stored.id,
|
||||||
|
session: stored,
|
||||||
|
previousName,
|
||||||
|
});
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
const removed = repo.delete(id);
|
||||||
|
if (removed) {
|
||||||
|
if (activeId === id) {
|
||||||
|
const previousSessionId = activeId;
|
||||||
|
activeId = null;
|
||||||
|
bus.emit({
|
||||||
|
type: "SessionActivated",
|
||||||
|
sessionId: null,
|
||||||
|
previousSessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bus.emit({ type: "SessionDeleted", sessionId: id });
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
return repo.list();
|
||||||
|
},
|
||||||
|
get(id) {
|
||||||
|
return repo.get(id);
|
||||||
|
},
|
||||||
|
setActive(id) {
|
||||||
|
if (id !== null && !repo.get(id)) {
|
||||||
|
throw new Error(`SessionService.setActive: unknown session ${id}`);
|
||||||
|
}
|
||||||
|
if (id === activeId) return;
|
||||||
|
const previousSessionId = activeId;
|
||||||
|
activeId = id;
|
||||||
|
if (id) {
|
||||||
|
const existing = repo.get(id);
|
||||||
|
if (existing) {
|
||||||
|
repo.update({ ...existing, lastOpenedAt: nowIso() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bus.emit({
|
||||||
|
type: "SessionActivated",
|
||||||
|
sessionId: id,
|
||||||
|
previousSessionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getActive() {
|
||||||
|
return activeId;
|
||||||
|
},
|
||||||
|
touch(id) {
|
||||||
|
const existing = repo.get(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
return repo.update({ ...existing, lastOpenedAt: nowIso() });
|
||||||
|
},
|
||||||
|
nextDefaultName(now = new Date()) {
|
||||||
|
return computeNextDefaultName(repo.list(), now);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper exported for tests + callers that want to preview the
|
||||||
|
* name without going through the service instance. Format:
|
||||||
|
* `YYMMDD-session-NNN`. `NNN` increments only against today's
|
||||||
|
* existing sessions; tomorrow's counter starts fresh at `000`.
|
||||||
|
*/
|
||||||
|
export function computeNextDefaultName(
|
||||||
|
existing: readonly Session[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
const yy = String(now.getFullYear() % 100).padStart(2, "0");
|
||||||
|
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(now.getDate()).padStart(2, "0");
|
||||||
|
const prefix = `${yy}${mm}${dd}-session-`;
|
||||||
|
const re = new RegExp(`^${prefix}(\\d{3})$`);
|
||||||
|
let max = -1;
|
||||||
|
for (const s of existing) {
|
||||||
|
const m = re.exec(s.name.trim());
|
||||||
|
if (m) {
|
||||||
|
const n = parseInt(m[1]!, 10);
|
||||||
|
if (n > max) max = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = String(max + 1).padStart(3, "0");
|
||||||
|
return `${prefix}${next}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cross-session persistence (the session index + active-session pointer).
|
||||||
|
// Per-session engine snapshots are handled by `attachPersister` against
|
||||||
|
// `engineSnapshotKey(sessionId)`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SessionPersisterOptions {
|
||||||
|
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionsFile {
|
||||||
|
readonly version: 1;
|
||||||
|
readonly sessions: readonly Session[];
|
||||||
|
readonly activeSessionId: SessionId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachSessionPersister(
|
||||||
|
service: SessionService,
|
||||||
|
bus: EventBus,
|
||||||
|
options: SessionPersisterOptions = {},
|
||||||
|
): () => void {
|
||||||
|
const storage = options.storage ?? globalThis.localStorage;
|
||||||
|
const writeIndex = () => {
|
||||||
|
const file: SessionsFile = {
|
||||||
|
version: 1,
|
||||||
|
sessions: service.list(),
|
||||||
|
activeSessionId: service.getActive(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
storage.setItem(SESSIONS_INDEX_KEY, JSON.stringify(file));
|
||||||
|
if (file.activeSessionId) {
|
||||||
|
storage.setItem(ACTIVE_SESSION_KEY, file.activeSessionId);
|
||||||
|
} else {
|
||||||
|
storage.removeItem(ACTIVE_SESSION_KEY);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("attachSessionPersister: write failed", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const writeIndexAndCleanup = () => {
|
||||||
|
writeIndex();
|
||||||
|
};
|
||||||
|
const writeOnDelete = (sessionId: SessionId) => {
|
||||||
|
writeIndex();
|
||||||
|
try {
|
||||||
|
storage.removeItem(engineSnapshotKey(sessionId));
|
||||||
|
// Also drop the per-session active-document-id key — otherwise
|
||||||
|
// it gets orphaned and accumulates in localStorage forever.
|
||||||
|
storage.removeItem(`citation-evidence:session:${sessionId}:active-document-id:v1`);
|
||||||
|
storage.removeItem(`citation-evidence:session:${sessionId}:capture-state:v1`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("attachSessionPersister: snapshot cleanup failed", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const offs = [
|
||||||
|
bus.on("SessionCreated", writeIndexAndCleanup),
|
||||||
|
bus.on("SessionRenamed", writeIndexAndCleanup),
|
||||||
|
bus.on("SessionActivated", writeIndexAndCleanup),
|
||||||
|
bus.on("SessionDeleted", (e) => writeOnDelete(e.sessionId)),
|
||||||
|
];
|
||||||
|
return () => {
|
||||||
|
for (const off of offs) off();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreSessionsResult {
|
||||||
|
readonly restored: boolean;
|
||||||
|
readonly sessions: readonly Session[];
|
||||||
|
readonly activeSessionId: SessionId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate the session repo from storage *without* firing events. Mirrors
|
||||||
|
* `restoreSnapshot`'s "direct repo write" pattern so the persister
|
||||||
|
* (which is attached after restore) doesn't immediately re-write what
|
||||||
|
* we just read.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Wipe every `citation-evidence:*` key from storage. Intended for the
|
||||||
|
* "Reset all data" UX affordance — gives the user a clean slate without
|
||||||
|
* having to dig into devtools. Returns the number of keys removed.
|
||||||
|
*
|
||||||
|
* The implementation enumerates the storage's keys (via `Storage.length`
|
||||||
|
* + `Storage.key(i)`) because there is no namespaced `clear()` API.
|
||||||
|
*/
|
||||||
|
export function clearAllSessionData(
|
||||||
|
storage: Pick<Storage, "getItem" | "setItem" | "removeItem" | "key" | "length"> = globalThis.localStorage,
|
||||||
|
): number {
|
||||||
|
const toRemove: string[] = [];
|
||||||
|
for (let i = 0; i < storage.length; i++) {
|
||||||
|
const k = storage.key(i);
|
||||||
|
if (k && k.startsWith("citation-evidence:")) toRemove.push(k);
|
||||||
|
}
|
||||||
|
for (const k of toRemove) storage.removeItem(k);
|
||||||
|
return toRemove.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreSessionsFromStorage(
|
||||||
|
repo: SessionRepository,
|
||||||
|
service: SessionService,
|
||||||
|
options: SessionPersisterOptions = {},
|
||||||
|
): RestoreSessionsResult {
|
||||||
|
const storage = options.storage ?? globalThis.localStorage;
|
||||||
|
const raw = storage.getItem(SESSIONS_INDEX_KEY);
|
||||||
|
if (!raw) return { restored: false, sessions: [], activeSessionId: null };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<SessionsFile>;
|
||||||
|
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.sessions)) {
|
||||||
|
return { restored: false, sessions: [], activeSessionId: null };
|
||||||
|
}
|
||||||
|
for (const s of parsed.sessions) repo.create(s);
|
||||||
|
const activeRaw =
|
||||||
|
typeof parsed.activeSessionId === "string" ? parsed.activeSessionId : null;
|
||||||
|
const fallbackActiveRaw = storage.getItem(ACTIVE_SESSION_KEY);
|
||||||
|
const candidate = (activeRaw ?? fallbackActiveRaw) as SessionId | null;
|
||||||
|
let activeSessionId: SessionId | null = null;
|
||||||
|
if (candidate && repo.get(candidate)) {
|
||||||
|
activeSessionId = candidate;
|
||||||
|
// Use service.setActive to keep the in-memory activeId aligned —
|
||||||
|
// suppress the resulting event so we don't bounce the persister.
|
||||||
|
// The bus listener attached *after* restore is what does the
|
||||||
|
// persistence, so emitting here is harmless either way; but
|
||||||
|
// skipping it keeps restore strictly side-effect-free from the
|
||||||
|
// listener's point of view.
|
||||||
|
service.setActive(activeSessionId);
|
||||||
|
}
|
||||||
|
return { restored: true, sessions: repo.list(), activeSessionId };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("restoreSessionsFromStorage: parse failed", err);
|
||||||
|
return { restored: false, sessions: [], activeSessionId: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/engine/session-snapshot.test.ts
Normal file
98
src/engine/session-snapshot.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Per-session engine snapshot round-trip.
|
||||||
|
*
|
||||||
|
* The workplan (CE-WP-0005-T01) requires that two sessions persisted
|
||||||
|
* under the per-session key scheme can each be restored independently
|
||||||
|
* — proving the storage layout actually partitions data by session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||||
|
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
|
||||||
|
|
||||||
|
import {
|
||||||
|
attachPersister,
|
||||||
|
createEngine,
|
||||||
|
engineSnapshotKey,
|
||||||
|
restoreFromStorage,
|
||||||
|
type Engine,
|
||||||
|
type EngineSnapshot,
|
||||||
|
} from "./index";
|
||||||
|
|
||||||
|
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
getItem: (k) => map.get(k) ?? null,
|
||||||
|
setItem: (k, v) => void map.set(k, v),
|
||||||
|
removeItem: (k) => void map.delete(k),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedDoc(engine: Engine, label: string): { id: DocumentId } {
|
||||||
|
const id = `doc_${label}` as DocumentId;
|
||||||
|
const repId = `rep_${label}` as RepresentationId;
|
||||||
|
const document: Document = {
|
||||||
|
id,
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
title: `Doc ${label}`,
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
const representation: DocumentRepresentation = {
|
||||||
|
id: repId,
|
||||||
|
documentId: id,
|
||||||
|
representationType: "pdf-text",
|
||||||
|
contentHash: `hash-${label}`,
|
||||||
|
canonicalText: `text for ${label}`,
|
||||||
|
pageMap: [{ page: 1, width: 100, height: 100 }],
|
||||||
|
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 12, pageLength: 12 }],
|
||||||
|
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
engine.documents.register({ document, representation });
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("per-session engine snapshot round-trip", () => {
|
||||||
|
it("keeps two sessions' snapshots isolated under per-session storage keys", () => {
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const sessA = "sess_aaa" as SessionId;
|
||||||
|
const sessB = "sess_bbb" as SessionId;
|
||||||
|
|
||||||
|
// Author session A.
|
||||||
|
const engineA = createEngine();
|
||||||
|
const offA = attachPersister(engineA, { key: engineSnapshotKey(sessA), storage });
|
||||||
|
const a1 = seedDoc(engineA, "a1");
|
||||||
|
const a2 = seedDoc(engineA, "a2");
|
||||||
|
offA();
|
||||||
|
|
||||||
|
// Author session B with completely different documents.
|
||||||
|
const engineB = createEngine();
|
||||||
|
const offB = attachPersister(engineB, { key: engineSnapshotKey(sessB), storage });
|
||||||
|
const b1 = seedDoc(engineB, "b1");
|
||||||
|
offB();
|
||||||
|
|
||||||
|
// Restore each into its own fresh engine; assert isolation.
|
||||||
|
const restoredA = createEngine();
|
||||||
|
const resA = restoreFromStorage(restoredA, { key: engineSnapshotKey(sessA), storage });
|
||||||
|
expect(resA.restored).toBe(true);
|
||||||
|
const aIds = restoredA.documents.list().map((d) => d.id).sort();
|
||||||
|
expect(aIds).toEqual([a1.id, a2.id].sort());
|
||||||
|
|
||||||
|
const restoredB = createEngine();
|
||||||
|
const resB = restoreFromStorage(restoredB, { key: engineSnapshotKey(sessB), storage });
|
||||||
|
expect(resB.restored).toBe(true);
|
||||||
|
const bIds = restoredB.documents.list().map((d) => d.id);
|
||||||
|
expect(bIds).toEqual([b1.id]);
|
||||||
|
|
||||||
|
// Sanity: each snapshot key really does hold a distinct snapshot.
|
||||||
|
const rawA = storage.getItem(engineSnapshotKey(sessA));
|
||||||
|
const rawB = storage.getItem(engineSnapshotKey(sessB));
|
||||||
|
expect(rawA).not.toBeNull();
|
||||||
|
expect(rawB).not.toBeNull();
|
||||||
|
const snapA = JSON.parse(rawA!) as EngineSnapshot;
|
||||||
|
const snapB = JSON.parse(rawB!) as EngineSnapshot;
|
||||||
|
expect(snapA.documents).toHaveLength(2);
|
||||||
|
expect(snapB.documents).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
8
src/shared/README.md
Normal file
8
src/shared/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# `src/shared/` — vocabulary, types, pure helpers
|
||||||
|
|
||||||
|
Future home: `citation-engine` (the shared types and contracts half of it).
|
||||||
|
Owns: `Document`, `Selector`, `Annotation`, `EvidenceItem`, `EvidenceLink`,
|
||||||
|
`EvidenceSet`, state enums, branded IDs, canonical text normalization.
|
||||||
|
|
||||||
|
May import from: nothing internal. Leaf node of the dependency graph
|
||||||
|
(`wiki/DependencyMap.md` §4).
|
||||||
45
src/shared/annotation.ts
Normal file
45
src/shared/annotation.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* The Annotation type.
|
||||||
|
*
|
||||||
|
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.1 (resolutionStatus)
|
||||||
|
* and `wiki/ArchitectureOverview.md` §4.4. Annotations are the *technical*
|
||||||
|
* mark on a document range — meaning and commentary live on EvidenceItem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AnnotationId, DocumentId, RepresentationId } from "./ids";
|
||||||
|
import type { Selector } from "./selector";
|
||||||
|
|
||||||
|
/** Closed enum per `wiki/SharedContracts.md` §2.1. */
|
||||||
|
export type AnnotationResolutionStatus =
|
||||||
|
| "resolved"
|
||||||
|
| "ambiguous"
|
||||||
|
| "unresolved"
|
||||||
|
| "stale";
|
||||||
|
|
||||||
|
export interface Annotation {
|
||||||
|
readonly id: AnnotationId;
|
||||||
|
readonly documentId: DocumentId;
|
||||||
|
readonly representationId?: RepresentationId;
|
||||||
|
/**
|
||||||
|
* All available selectors for this passage, in order of expected
|
||||||
|
* resolution confidence. Per the §3 redundancy rule, the system stores
|
||||||
|
* every selector kind it could derive at capture time.
|
||||||
|
*/
|
||||||
|
readonly selectors: readonly Selector[];
|
||||||
|
/** Verbatim canonical text at capture time. */
|
||||||
|
readonly quote?: string;
|
||||||
|
/** Short human note attached to the technical mark. */
|
||||||
|
readonly note?: string;
|
||||||
|
/**
|
||||||
|
* Version of `normalize()` that was active when these selectors were
|
||||||
|
* stored. Recorded so future normalization changes can be detected as a
|
||||||
|
* migration concern. See `src/shared/text/normalize.ts`.
|
||||||
|
*/
|
||||||
|
readonly normalizeVersion: number;
|
||||||
|
readonly resolutionStatus?: AnnotationResolutionStatus;
|
||||||
|
readonly createdBy?: string;
|
||||||
|
/** ISO-8601 timestamp. */
|
||||||
|
readonly createdAt: string;
|
||||||
|
/** ISO-8601 timestamp. */
|
||||||
|
readonly updatedAt: string;
|
||||||
|
}
|
||||||
75
src/shared/citation-card-source.test.ts
Normal file
75
src/shared/citation-card-source.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for `resolveSourceLabel()`. Locks the precedence order
|
||||||
|
* described in workplan CE-WP-0004-T02:
|
||||||
|
* title → metadata.filename → uri → id.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { resolveSourceLabel } from "./citation-card-source";
|
||||||
|
import type { Document } from "./document";
|
||||||
|
import type { DocumentId } from "./ids";
|
||||||
|
|
||||||
|
function makeDoc(partial: Partial<Document>): Document {
|
||||||
|
return {
|
||||||
|
id: "doc_test" as DocumentId,
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveSourceLabel()", () => {
|
||||||
|
it("prefers document.title when set", () => {
|
||||||
|
expect(
|
||||||
|
resolveSourceLabel(
|
||||||
|
makeDoc({
|
||||||
|
title: "Order from 2024",
|
||||||
|
uri: "https://example.test/o.pdf",
|
||||||
|
metadata: { filename: "fallback.pdf" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("Order from 2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to metadata.filename when title is missing", () => {
|
||||||
|
expect(
|
||||||
|
resolveSourceLabel(
|
||||||
|
makeDoc({
|
||||||
|
uri: "https://example.test/o.pdf",
|
||||||
|
metadata: { filename: "fallback.pdf" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("fallback.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to uri when title and filename are missing", () => {
|
||||||
|
expect(
|
||||||
|
resolveSourceLabel(makeDoc({ uri: "https://example.test/o.pdf" })),
|
||||||
|
).toBe("https://example.test/o.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to id when everything else is missing", () => {
|
||||||
|
expect(resolveSourceLabel(makeDoc({}))).toBe("doc_test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats whitespace-only strings as empty", () => {
|
||||||
|
expect(
|
||||||
|
resolveSourceLabel(
|
||||||
|
makeDoc({ title: " ", uri: "https://example.test/o.pdf" }),
|
||||||
|
),
|
||||||
|
).toBe("https://example.test/o.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-string metadata.filename", () => {
|
||||||
|
expect(
|
||||||
|
resolveSourceLabel(
|
||||||
|
makeDoc({
|
||||||
|
metadata: { filename: 42 },
|
||||||
|
uri: "https://example.test/o.pdf",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe("https://example.test/o.pdf");
|
||||||
|
});
|
||||||
|
});
|
||||||
26
src/shared/citation-card-source.ts
Normal file
26
src/shared/citation-card-source.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Source-label resolution for citation cards.
|
||||||
|
*
|
||||||
|
* Per workplan CE-WP-0004-T02: the label rendered after the quote is
|
||||||
|
* document.title → document.metadata.filename → document.uri → document.id
|
||||||
|
* — the first non-empty value wins. Renderers must call this helper so
|
||||||
|
* the two output formats (Markdown, HTML) stay consistent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Document } from "./document";
|
||||||
|
|
||||||
|
function nonEmptyString(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSourceLabel(document: Document): string {
|
||||||
|
const title = nonEmptyString(document.title);
|
||||||
|
if (title) return title;
|
||||||
|
const filename = nonEmptyString(document.metadata?.["filename"]);
|
||||||
|
if (filename) return filename;
|
||||||
|
const uri = nonEmptyString(document.uri);
|
||||||
|
if (uri) return uri;
|
||||||
|
return document.id;
|
||||||
|
}
|
||||||
35
src/shared/citation-card.ts
Normal file
35
src/shared/citation-card.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* CitationCard — a presentable rendering of an EvidenceItem.
|
||||||
|
*
|
||||||
|
* Implements `wiki/ArchitectureOverview.md` §4.7. Stored or transient,
|
||||||
|
* always derived from an `EvidenceItem` + its `Document` + the first
|
||||||
|
* referenced `Annotation`. The renderers in `engine/rendering/` produce
|
||||||
|
* the string forms (`format: "markdown" | "html"`); the
|
||||||
|
* `"web-component"` format is reserved for a later workplan (see §14.2).
|
||||||
|
*
|
||||||
|
* `id` and `evidenceItemId` use the branded ID types from `./ids` so they
|
||||||
|
* cannot be swapped at compile time, even though `ArchitectureOverview.md`
|
||||||
|
* §4.7 shows them as bare `string`s. The brand is invisible at runtime.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CitationCardId, EvidenceItemId } from "./ids";
|
||||||
|
|
||||||
|
export type CitationCardFormat = "html" | "markdown" | "web-component";
|
||||||
|
|
||||||
|
export interface CitationCard {
|
||||||
|
readonly id: CitationCardId;
|
||||||
|
readonly evidenceItemId: EvidenceItemId;
|
||||||
|
/** Verbatim quote rendered into the card body. */
|
||||||
|
readonly quote: string;
|
||||||
|
/**
|
||||||
|
* Human-readable source label. Resolution order is
|
||||||
|
* `document.title` → `filename from metadata` → `document.uri` →
|
||||||
|
* the document id. Renderers should call `resolveSourceLabel()` from
|
||||||
|
* `./citation-card-source.ts` rather than computing this inline.
|
||||||
|
*/
|
||||||
|
readonly sourceLabel: string;
|
||||||
|
readonly commentary?: string;
|
||||||
|
/** Deep link to reopen the source context — see `./open-context-url.ts`. */
|
||||||
|
readonly openContextUrl?: string;
|
||||||
|
readonly format: CitationCardFormat;
|
||||||
|
}
|
||||||
91
src/shared/document.ts
Normal file
91
src/shared/document.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Document and DocumentRepresentation types.
|
||||||
|
*
|
||||||
|
* Implements `wiki/SharedContracts.md` §1 (vocabulary) and
|
||||||
|
* `wiki/ArchitectureOverview.md` §4.1, §4.2. Pure data — no behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DocumentId, RepresentationId } from "./ids";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kind of normalized view derived from a source document.
|
||||||
|
*
|
||||||
|
* MVP recognises only `pdf-text`; the other variants are reserved for the
|
||||||
|
* HTML/Markdown/OCR adapters that arrive after CE-WP-0002.
|
||||||
|
*/
|
||||||
|
export type RepresentationType =
|
||||||
|
| "pdf-text"
|
||||||
|
| "html-dom"
|
||||||
|
| "markdown-rendered"
|
||||||
|
| "plain-text"
|
||||||
|
| "ocr-text";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-level geometry. One entry per physical PDF page.
|
||||||
|
* Coordinates are PDF user-space points (1/72 inch).
|
||||||
|
*/
|
||||||
|
export interface PageInfo {
|
||||||
|
/** 1-indexed physical page number. */
|
||||||
|
readonly page: number;
|
||||||
|
/** Page width in user-space points. */
|
||||||
|
readonly width: number;
|
||||||
|
/** Page height in user-space points. */
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageMap = readonly PageInfo[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps canonical-text offset ranges to physical pages.
|
||||||
|
*
|
||||||
|
* Entries are sorted by `globalStart`, are non-overlapping, and together
|
||||||
|
* cover `[0, canonicalText.length)`. `pageLength` equals
|
||||||
|
* `globalEnd - globalStart` and is also the length of the page-local text
|
||||||
|
* (used by `PdfPageTextSelector`).
|
||||||
|
*/
|
||||||
|
export interface PageOffsetRange {
|
||||||
|
readonly page: number;
|
||||||
|
/** Inclusive canonical-text offset where this page begins. */
|
||||||
|
readonly globalStart: number;
|
||||||
|
/** Exclusive canonical-text offset where this page ends. */
|
||||||
|
readonly globalEnd: number;
|
||||||
|
/** Length of the page's text in canonical-text characters. */
|
||||||
|
readonly pageLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OffsetMap = readonly PageOffsetRange[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved for `StructuralSelector` (heading/section/AST path).
|
||||||
|
* Not implementable in MVP — type is `never` to enforce that at compile time.
|
||||||
|
*/
|
||||||
|
export type StructureMap = never;
|
||||||
|
|
||||||
|
/** A source document known to the system. */
|
||||||
|
export interface Document {
|
||||||
|
readonly id: DocumentId;
|
||||||
|
readonly title?: string;
|
||||||
|
readonly uri?: string;
|
||||||
|
readonly mediaType: string;
|
||||||
|
readonly fingerprint?: string;
|
||||||
|
readonly version?: string;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly updatedAt: string;
|
||||||
|
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A normalized, addressable view of a `Document`. */
|
||||||
|
export interface DocumentRepresentation {
|
||||||
|
readonly id: RepresentationId;
|
||||||
|
readonly documentId: DocumentId;
|
||||||
|
readonly representationType: RepresentationType;
|
||||||
|
/** Hash of the canonical text — stable identifier for the representation. */
|
||||||
|
readonly contentHash: string;
|
||||||
|
/** Canonical text after `normalize()` is applied. */
|
||||||
|
readonly canonicalText?: string;
|
||||||
|
readonly pageMap?: PageMap;
|
||||||
|
readonly structureMap?: StructureMap;
|
||||||
|
readonly offsetMap?: OffsetMap;
|
||||||
|
/** ISO-8601 timestamp. */
|
||||||
|
readonly generatedAt: string;
|
||||||
|
}
|
||||||
62
src/shared/evidence-link.test.ts
Normal file
62
src/shared/evidence-link.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Conformance test: the runtime enum lists in `evidence-link.ts` must
|
||||||
|
* match the lists in `wiki/SharedContracts.md` §2.4 and §2.5 exactly.
|
||||||
|
*
|
||||||
|
* If you intentionally change an enum, update both the doc and the
|
||||||
|
* runtime list together — this test will tell you which one you forgot.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
EVIDENCE_LINK_STATUS_VALUES,
|
||||||
|
EVIDENCE_RELATION_VALUES,
|
||||||
|
} from "./evidence-link";
|
||||||
|
|
||||||
|
const HERE = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
const CONTRACTS_PATH = resolve(HERE, "../../wiki/SharedContracts.md");
|
||||||
|
|
||||||
|
function extractFencedListAfterHeading(markdown: string, heading: string): string[] {
|
||||||
|
const headingIndex = markdown.indexOf(heading);
|
||||||
|
if (headingIndex === -1) {
|
||||||
|
throw new Error(`Could not find heading "${heading}" in SharedContracts.md`);
|
||||||
|
}
|
||||||
|
const after = markdown.slice(headingIndex + heading.length);
|
||||||
|
const fenceOpen = after.indexOf("```");
|
||||||
|
if (fenceOpen === -1) throw new Error(`No fenced block after "${heading}"`);
|
||||||
|
const bodyStart = after.indexOf("\n", fenceOpen) + 1;
|
||||||
|
const fenceClose = after.indexOf("```", bodyStart);
|
||||||
|
if (fenceClose === -1) throw new Error(`Unterminated fenced block after "${heading}"`);
|
||||||
|
const body = after.slice(bodyStart, fenceClose);
|
||||||
|
return body
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
// Strip trailing " — explanatory note" if present (none in §2.4/§2.5 today,
|
||||||
|
// but §2.1/§2.2 use that style — being defensive keeps the helper reusable).
|
||||||
|
.map((line) => line.split(/\s+[—-]\s+/)[0]!.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EvidenceLink enum conformance with SharedContracts.md", () => {
|
||||||
|
const markdown = readFileSync(CONTRACTS_PATH, "utf8");
|
||||||
|
|
||||||
|
it("§2.4 EvidenceLink.status matches EVIDENCE_LINK_STATUS_VALUES", () => {
|
||||||
|
const docValues = extractFencedListAfterHeading(
|
||||||
|
markdown,
|
||||||
|
"### 2.4 `EvidenceLink.status` (per target)",
|
||||||
|
);
|
||||||
|
expect(docValues).toEqual([...EVIDENCE_LINK_STATUS_VALUES]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("§2.5 EvidenceLink.relation matches EVIDENCE_RELATION_VALUES", () => {
|
||||||
|
const docValues = extractFencedListAfterHeading(
|
||||||
|
markdown,
|
||||||
|
"### 2.5 `EvidenceLink.relation`",
|
||||||
|
);
|
||||||
|
expect(docValues).toEqual([...EVIDENCE_RELATION_VALUES]);
|
||||||
|
});
|
||||||
|
});
|
||||||
107
src/shared/evidence-link.ts
Normal file
107
src/shared/evidence-link.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* EvidenceLink + EvidenceTarget shapes.
|
||||||
|
*
|
||||||
|
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.4
|
||||||
|
* (EvidenceLink.status) and §2.5 (EvidenceLink.relation), and
|
||||||
|
* `wiki/ArchitectureOverview.md` §4 (target-type catalogue).
|
||||||
|
*
|
||||||
|
* An EvidenceLink ties exactly one EvidenceItem to one structured target
|
||||||
|
* (e.g. a form field). Multiple links per item are allowed when the same
|
||||||
|
* evidence supports several targets. Multiple links per target are allowed
|
||||||
|
* when several pieces of evidence apply to the same field — the
|
||||||
|
* EvidenceSet captures that ordered group.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EvidenceItemId, EvidenceLinkId } from "./ids";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closed enum per `wiki/SharedContracts.md` §2.4.
|
||||||
|
*
|
||||||
|
* `no-evidence` is a *derived* state — computed when a target has zero
|
||||||
|
* links — and is therefore NOT stored on a link itself. The stored values
|
||||||
|
* are the five members of `EvidenceLinkStoredStatus`.
|
||||||
|
*/
|
||||||
|
export type EvidenceLinkStatus =
|
||||||
|
| "no-evidence"
|
||||||
|
| "candidate"
|
||||||
|
| "confirmed"
|
||||||
|
| "conflicting"
|
||||||
|
| "insufficient"
|
||||||
|
| "verified";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of `EvidenceLinkStatus` that may appear on a stored link
|
||||||
|
* record. `no-evidence` is excluded because it is derived from the
|
||||||
|
* absence of links on a target, not stored.
|
||||||
|
*/
|
||||||
|
export type EvidenceLinkStoredStatus = Exclude<EvidenceLinkStatus, "no-evidence">;
|
||||||
|
|
||||||
|
/** Closed enum per `wiki/SharedContracts.md` §2.5. */
|
||||||
|
export type EvidenceRelation =
|
||||||
|
| "supports"
|
||||||
|
| "contradicts"
|
||||||
|
| "explains"
|
||||||
|
| "qualifies"
|
||||||
|
| "source-for"
|
||||||
|
| "context-for";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known target-type catalogue per `wiki/ArchitectureOverview.md` §4
|
||||||
|
* (`EvidenceTargetType`). The MVP only exercises `"form-field"`; the
|
||||||
|
* others are reserved so future workplans can extend without renaming.
|
||||||
|
*/
|
||||||
|
export type EvidenceTargetType =
|
||||||
|
| "form-field"
|
||||||
|
| "claim"
|
||||||
|
| "requirement"
|
||||||
|
| "decision"
|
||||||
|
| "document-section";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic shape of an evidence target. `targetId` is opaque to the engine
|
||||||
|
* — the host subsystem (form renderer, claims index, …) owns the
|
||||||
|
* namespace for its `targetType`.
|
||||||
|
*/
|
||||||
|
export interface EvidenceTarget {
|
||||||
|
readonly targetType: EvidenceTargetType;
|
||||||
|
readonly targetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceLink {
|
||||||
|
readonly id: EvidenceLinkId;
|
||||||
|
readonly evidenceItemId: EvidenceItemId;
|
||||||
|
readonly targetType: EvidenceTargetType;
|
||||||
|
readonly targetId: string;
|
||||||
|
readonly relation: EvidenceRelation;
|
||||||
|
readonly status: EvidenceLinkStoredStatus;
|
||||||
|
/** Optional 0..1 confidence assigned by user or auto-process. */
|
||||||
|
readonly confidence?: number;
|
||||||
|
readonly createdBy?: string;
|
||||||
|
/** ISO-8601 timestamp. */
|
||||||
|
readonly createdAt: string;
|
||||||
|
/** ISO-8601 timestamp. */
|
||||||
|
readonly updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The canonical lists, exported for use by enum-conformance tests
|
||||||
|
* (see `evidence-link.test.ts`) and for any UI code that needs to
|
||||||
|
* enumerate options. Order matches `wiki/SharedContracts.md`.
|
||||||
|
*/
|
||||||
|
export const EVIDENCE_LINK_STATUS_VALUES: readonly EvidenceLinkStatus[] = [
|
||||||
|
"no-evidence",
|
||||||
|
"candidate",
|
||||||
|
"confirmed",
|
||||||
|
"conflicting",
|
||||||
|
"insufficient",
|
||||||
|
"verified",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EVIDENCE_RELATION_VALUES: readonly EvidenceRelation[] = [
|
||||||
|
"supports",
|
||||||
|
"contradicts",
|
||||||
|
"explains",
|
||||||
|
"qualifies",
|
||||||
|
"source-for",
|
||||||
|
"context-for",
|
||||||
|
];
|
||||||
36
src/shared/evidence-set.ts
Normal file
36
src/shared/evidence-set.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* EvidenceSet — an ordered group of evidence items pointed at a target.
|
||||||
|
*
|
||||||
|
* Implements `wiki/SharedContracts.md` §1 (vocabulary) and
|
||||||
|
* `wiki/ArchitectureOverview.md` §4.6.
|
||||||
|
*
|
||||||
|
* The set itself is target-shaped: it carries the `(targetType, targetId)`
|
||||||
|
* pair so the binder can answer "give me the EvidenceSet for this form
|
||||||
|
* field" in one call. `activeEvidenceItemId` is the membership of the
|
||||||
|
* set that the UI is currently focused on; cycling Tab/Shift-Tab through
|
||||||
|
* the field's chips updates it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EvidenceItemId, EvidenceSetId } from "./ids";
|
||||||
|
import type { EvidenceTargetType } from "./evidence-link";
|
||||||
|
|
||||||
|
export interface EvidenceSet {
|
||||||
|
readonly id: EvidenceSetId;
|
||||||
|
readonly label?: string;
|
||||||
|
/**
|
||||||
|
* Optional target binding. Form-field sets always carry these; ad-hoc
|
||||||
|
* topical sets may leave them undefined.
|
||||||
|
*/
|
||||||
|
readonly targetType?: EvidenceTargetType;
|
||||||
|
readonly targetId?: string;
|
||||||
|
/**
|
||||||
|
* Membership in display order. The binder is free to reorder, but
|
||||||
|
* persistence preserves this order so cycling is deterministic.
|
||||||
|
*/
|
||||||
|
readonly evidenceItemIds: readonly EvidenceItemId[];
|
||||||
|
/**
|
||||||
|
* The currently active member, or undefined if the set is empty or
|
||||||
|
* no member is yet focused.
|
||||||
|
*/
|
||||||
|
readonly activeEvidenceItemId?: EvidenceItemId;
|
||||||
|
}
|
||||||
37
src/shared/evidence.ts
Normal file
37
src/shared/evidence.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* EvidenceItem type.
|
||||||
|
*
|
||||||
|
* Implements `wiki/SharedContracts.md` §1 (vocabulary), §2.2 (status enum)
|
||||||
|
* and `wiki/ArchitectureOverview.md` §4.5. An EvidenceItem is the *meaning*
|
||||||
|
* layer on top of one or more technical Annotations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AnnotationId, EvidenceItemId } from "./ids";
|
||||||
|
|
||||||
|
/** Closed enum per `wiki/SharedContracts.md` §2.2. */
|
||||||
|
export type EvidenceItemStatus =
|
||||||
|
| "candidate"
|
||||||
|
| "confirmed"
|
||||||
|
| "rejected"
|
||||||
|
| "needs-check";
|
||||||
|
|
||||||
|
export interface EvidenceItem {
|
||||||
|
readonly id: EvidenceItemId;
|
||||||
|
/**
|
||||||
|
* One or more annotations that together constitute the evidence.
|
||||||
|
* Multiple annotations are used when a piece of evidence spans
|
||||||
|
* discontiguous passages.
|
||||||
|
*/
|
||||||
|
readonly annotationIds: readonly AnnotationId[];
|
||||||
|
readonly title?: string;
|
||||||
|
readonly commentary?: string;
|
||||||
|
readonly status: EvidenceItemStatus;
|
||||||
|
/** Optional 0..1 confidence assigned by user or auto-process. */
|
||||||
|
readonly confidence?: number;
|
||||||
|
readonly tags?: readonly string[];
|
||||||
|
readonly createdBy?: string;
|
||||||
|
/** ISO-8601 timestamp. */
|
||||||
|
readonly createdAt: string;
|
||||||
|
/** ISO-8601 timestamp. */
|
||||||
|
readonly updatedAt: string;
|
||||||
|
}
|
||||||
21
src/shared/ids.test.ts
Normal file
21
src/shared/ids.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { newId } from "./ids";
|
||||||
|
|
||||||
|
describe("newId", () => {
|
||||||
|
it("returns ids with the expected prefix for each kind", () => {
|
||||||
|
expect(newId("document")).toMatch(/^doc_[0-9a-f-]{36}$/);
|
||||||
|
expect(newId("representation")).toMatch(/^rep_[0-9a-f-]{36}$/);
|
||||||
|
expect(newId("annotation")).toMatch(/^ann_[0-9a-f-]{36}$/);
|
||||||
|
expect(newId("evidence")).toMatch(/^ev_[0-9a-f-]{36}$/);
|
||||||
|
expect(newId("evidence-set")).toMatch(/^evset_[0-9a-f-]{36}$/);
|
||||||
|
expect(newId("evidence-link")).toMatch(/^evlink_[0-9a-f-]{36}$/);
|
||||||
|
expect(newId("citation-card")).toMatch(/^card_[0-9a-f-]{36}$/);
|
||||||
|
expect(newId("citation-recovery")).toMatch(/^crec_[0-9a-f-]{36}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a unique id on every call", () => {
|
||||||
|
const a = newId("annotation");
|
||||||
|
const b = newId("annotation");
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
src/shared/ids.ts
Normal file
58
src/shared/ids.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Branded ID types and the `newId(kind)` factory.
|
||||||
|
*
|
||||||
|
* Implements the identifier portion of `wiki/SharedContracts.md` §1 and
|
||||||
|
* `wiki/ArchitectureOverview.md` §3.2. Each branded type is structurally a
|
||||||
|
* `string` but nominally distinct, so passing an `AnnotationId` where a
|
||||||
|
* `DocumentId` is required is a compile-time error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare const __brand: unique symbol;
|
||||||
|
|
||||||
|
type Brand<K, T extends string> = K & { readonly [__brand]: T };
|
||||||
|
|
||||||
|
export type DocumentId = Brand<string, "DocumentId">;
|
||||||
|
export type RepresentationId = Brand<string, "RepresentationId">;
|
||||||
|
export type AnnotationId = Brand<string, "AnnotationId">;
|
||||||
|
export type EvidenceItemId = Brand<string, "EvidenceItemId">;
|
||||||
|
export type EvidenceSetId = Brand<string, "EvidenceSetId">;
|
||||||
|
export type EvidenceLinkId = Brand<string, "EvidenceLinkId">;
|
||||||
|
export type CitationCardId = Brand<string, "CitationCardId">;
|
||||||
|
export type CitationRecoveryAttemptId = Brand<string, "CitationRecoveryAttemptId">;
|
||||||
|
export type SessionId = Brand<string, "SessionId">;
|
||||||
|
|
||||||
|
export type IdKindMap = {
|
||||||
|
document: DocumentId;
|
||||||
|
representation: RepresentationId;
|
||||||
|
annotation: AnnotationId;
|
||||||
|
evidence: EvidenceItemId;
|
||||||
|
"evidence-set": EvidenceSetId;
|
||||||
|
"evidence-link": EvidenceLinkId;
|
||||||
|
"citation-card": CitationCardId;
|
||||||
|
"citation-recovery": CitationRecoveryAttemptId;
|
||||||
|
session: SessionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdKind = keyof IdKindMap;
|
||||||
|
|
||||||
|
const PREFIXES: Record<IdKind, string> = {
|
||||||
|
document: "doc",
|
||||||
|
representation: "rep",
|
||||||
|
annotation: "ann",
|
||||||
|
evidence: "ev",
|
||||||
|
"evidence-set": "evset",
|
||||||
|
"evidence-link": "evlink",
|
||||||
|
"citation-card": "card",
|
||||||
|
"citation-recovery": "crec",
|
||||||
|
session: "sess",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a new branded identifier of the requested kind.
|
||||||
|
*
|
||||||
|
* IDs use the shape `<prefix>_<uuid>` so they are human-recognizable when
|
||||||
|
* they show up in logs, URLs, or stored JSON.
|
||||||
|
*/
|
||||||
|
export function newId<K extends IdKind>(kind: K): IdKindMap[K] {
|
||||||
|
return `${PREFIXES[kind]}_${crypto.randomUUID()}` as IdKindMap[K];
|
||||||
|
}
|
||||||
13
src/shared/index.ts
Normal file
13
src/shared/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export * from "./ids";
|
||||||
|
export * from "./document";
|
||||||
|
export * from "./selector";
|
||||||
|
export * from "./annotation";
|
||||||
|
export * from "./evidence";
|
||||||
|
export * from "./evidence-link";
|
||||||
|
export * from "./evidence-set";
|
||||||
|
export * from "./citation-card";
|
||||||
|
export * from "./citation-card-source";
|
||||||
|
export * from "./open-context-url";
|
||||||
|
export * from "./session";
|
||||||
|
export * from "./session-archive";
|
||||||
|
export { normalize, NORMALIZE_VERSION } from "./text/normalize";
|
||||||
32
src/shared/open-context-url.test.ts
Normal file
32
src/shared/open-context-url.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for `openContextUrl()`. Locks the shape from
|
||||||
|
* `wiki/ArchitectureOverview.md` §14.3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { AnnotationId, DocumentId } from "./ids";
|
||||||
|
import { openContextUrl } from "./open-context-url";
|
||||||
|
|
||||||
|
const DOC = "doc_abc-123" as DocumentId;
|
||||||
|
const ANN = "ann_def-456" as AnnotationId;
|
||||||
|
|
||||||
|
describe("openContextUrl()", () => {
|
||||||
|
it("produces the canonical /viewer?document=…&annotation=… shape", () => {
|
||||||
|
expect(openContextUrl({ documentId: DOC, annotationId: ANN })).toBe(
|
||||||
|
"/viewer?document=doc_abc-123&annotation=ann_def-456",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("percent-encodes reserved characters in ids", () => {
|
||||||
|
const ugly = "doc with spaces&=#" as DocumentId;
|
||||||
|
const url = openContextUrl({ documentId: ugly, annotationId: ANN });
|
||||||
|
expect(url).toBe(
|
||||||
|
"/viewer?document=doc%20with%20spaces%26%3D%23&annotation=ann_def-456",
|
||||||
|
);
|
||||||
|
// round-trip
|
||||||
|
const params = new URL(url, "https://example.test").searchParams;
|
||||||
|
expect(params.get("document")).toBe("doc with spaces&=#");
|
||||||
|
expect(params.get("annotation")).toBe("ann_def-456");
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/shared/open-context-url.ts
Normal file
36
src/shared/open-context-url.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Open-context URL — the canonical deep link that lets a citation card
|
||||||
|
* reopen the source context that backs it.
|
||||||
|
*
|
||||||
|
* Implements `wiki/ArchitectureOverview.md` §14.3. The URL shape is:
|
||||||
|
*
|
||||||
|
* /viewer?document=<documentId>&annotation=<annotationId>
|
||||||
|
*
|
||||||
|
* Both ids are mandatory. The annotation alone is insufficient because
|
||||||
|
* the router needs the document id to mount the right viewer adapter
|
||||||
|
* before resolving the annotation's selectors.
|
||||||
|
*
|
||||||
|
* This convention is stable across persistence modes — when the
|
||||||
|
* in-memory engine is replaced by a real backend (ADR-0005), the same
|
||||||
|
* URL shape is expected to still resolve.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AnnotationId, DocumentId } from "./ids";
|
||||||
|
|
||||||
|
export interface OpenContextUrlInput {
|
||||||
|
readonly documentId: DocumentId;
|
||||||
|
readonly annotationId: AnnotationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the deep link for a given (document, annotation) pair.
|
||||||
|
*
|
||||||
|
* Query-string values are percent-encoded via `encodeURIComponent` so that
|
||||||
|
* any future id scheme containing reserved characters (`&`, `=`, `#`) still
|
||||||
|
* round-trips.
|
||||||
|
*/
|
||||||
|
export function openContextUrl(input: OpenContextUrlInput): string {
|
||||||
|
const docPart = encodeURIComponent(input.documentId);
|
||||||
|
const annPart = encodeURIComponent(input.annotationId);
|
||||||
|
return `/viewer?document=${docPart}&annotation=${annPart}`;
|
||||||
|
}
|
||||||
79
src/shared/selector.ts
Normal file
79
src/shared/selector.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* The Selector discriminated union.
|
||||||
|
*
|
||||||
|
* Implements `wiki/SharedContracts.md` §3. Each selector kind has a unique
|
||||||
|
* `type` discriminator and locates a passage inside one
|
||||||
|
* `DocumentRepresentation`.
|
||||||
|
*
|
||||||
|
* The MVP implements the four PDF-relevant variants
|
||||||
|
* (`TextQuoteSelector`, `TextPositionSelector`, `PdfRectSelector`,
|
||||||
|
* `PdfPageTextSelector`). The other three kinds (DOM, structural, fragment)
|
||||||
|
* are reserved as `never`-typed stubs so adding them later is a localised
|
||||||
|
* change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Exact quote with optional surrounding context (W3C-aligned). */
|
||||||
|
export interface TextQuoteSelector {
|
||||||
|
readonly type: "TextQuoteSelector";
|
||||||
|
/** The verbatim quoted passage from the canonical text. */
|
||||||
|
readonly exact: string;
|
||||||
|
/** Up to ~32 chars of canonical text immediately before `exact`. */
|
||||||
|
readonly prefix?: string;
|
||||||
|
/** Up to ~32 chars of canonical text immediately after `exact`. */
|
||||||
|
readonly suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical-text character offsets (inclusive start, exclusive end). */
|
||||||
|
export interface TextPositionSelector {
|
||||||
|
readonly type: "TextPositionSelector";
|
||||||
|
readonly start: number;
|
||||||
|
readonly end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A rectangle on a PDF page, in page-relative normalized coordinates (0..1). */
|
||||||
|
export interface NormalizedRect {
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One or more rectangles on a single PDF page. */
|
||||||
|
export interface PdfRectSelector {
|
||||||
|
readonly type: "PdfRectSelector";
|
||||||
|
/** 1-indexed physical page number. */
|
||||||
|
readonly page: number;
|
||||||
|
readonly rects: readonly NormalizedRect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page-local text offsets, for a single PDF page. */
|
||||||
|
export interface PdfPageTextSelector {
|
||||||
|
readonly type: "PdfPageTextSelector";
|
||||||
|
readonly page: number;
|
||||||
|
readonly start: number;
|
||||||
|
readonly end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reserved for HTML/Markdown viewer adapters. Not implementable in MVP. */
|
||||||
|
export type DomRangeSelector = never;
|
||||||
|
|
||||||
|
/** Reserved for heading/section/AST-path locators. Not implementable in MVP. */
|
||||||
|
export type StructuralSelector = never;
|
||||||
|
|
||||||
|
/** Reserved for exported deep-link fragments. Not implementable in MVP. */
|
||||||
|
export type FragmentSelector = never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The closed union of all selector kinds. The `never` members keep the union
|
||||||
|
* exhaustive so future selector additions are a single edit.
|
||||||
|
*/
|
||||||
|
export type Selector =
|
||||||
|
| TextQuoteSelector
|
||||||
|
| TextPositionSelector
|
||||||
|
| PdfRectSelector
|
||||||
|
| PdfPageTextSelector
|
||||||
|
| DomRangeSelector
|
||||||
|
| StructuralSelector
|
||||||
|
| FragmentSelector;
|
||||||
|
|
||||||
|
export type SelectorType = Selector["type"];
|
||||||
88
src/shared/session-archive.test.ts
Normal file
88
src/shared/session-archive.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { DocumentId, SessionId } from "./ids";
|
||||||
|
import {
|
||||||
|
parseSessionArchiveManifest,
|
||||||
|
SESSION_ARCHIVE_SCHEMA_VERSION,
|
||||||
|
SessionArchiveParseError,
|
||||||
|
type SessionArchiveManifest,
|
||||||
|
} from "./session-archive";
|
||||||
|
|
||||||
|
function validManifest(): SessionArchiveManifest {
|
||||||
|
return {
|
||||||
|
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
|
||||||
|
exportedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
session: {
|
||||||
|
id: "sess_abc" as SessionId,
|
||||||
|
name: "Demo",
|
||||||
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
engine: {
|
||||||
|
version: 1,
|
||||||
|
documents: [],
|
||||||
|
representations: [],
|
||||||
|
annotations: [],
|
||||||
|
evidenceItems: [],
|
||||||
|
},
|
||||||
|
documentBindings: [
|
||||||
|
{
|
||||||
|
documentId: "doc_abc" as DocumentId,
|
||||||
|
filename: "demo.pdf",
|
||||||
|
fingerprint: "abc123",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseSessionArchiveManifest", () => {
|
||||||
|
it("round-trips a valid manifest through JSON.stringify + parse", () => {
|
||||||
|
const m = validManifest();
|
||||||
|
const round = parseSessionArchiveManifest(JSON.parse(JSON.stringify(m)));
|
||||||
|
expect(round).toEqual(m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unsupported schemaVersion", () => {
|
||||||
|
const m = { ...validManifest(), schemaVersion: 999 as unknown };
|
||||||
|
expect(() => parseSessionArchiveManifest(m)).toThrow(SessionArchiveParseError);
|
||||||
|
expect(() => parseSessionArchiveManifest(m)).toThrow(/unsupported schemaVersion/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing required top-level field", () => {
|
||||||
|
const m = validManifest();
|
||||||
|
const broken = { ...m, exportedAt: undefined as unknown };
|
||||||
|
expect(() => parseSessionArchiveManifest(broken)).toThrow(/exportedAt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed session sub-object", () => {
|
||||||
|
const m = validManifest();
|
||||||
|
const broken = { ...m, session: { ...m.session, id: 12345 as unknown } };
|
||||||
|
expect(() => parseSessionArchiveManifest(broken)).toThrow(/session\.id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed engine snapshot", () => {
|
||||||
|
const m = validManifest();
|
||||||
|
const broken = { ...m, engine: { ...m.engine, version: "1" as unknown } };
|
||||||
|
expect(() => parseSessionArchiveManifest(broken)).toThrow(/engine\.version/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-array documentBindings", () => {
|
||||||
|
const m = validManifest();
|
||||||
|
const broken = { ...m, documentBindings: "nope" as unknown };
|
||||||
|
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed documentBindings entry", () => {
|
||||||
|
const m = validManifest();
|
||||||
|
const broken = {
|
||||||
|
...m,
|
||||||
|
documentBindings: [{ documentId: "doc_x", fingerprint: "abc" }] as unknown[],
|
||||||
|
};
|
||||||
|
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings\[0\]\.filename/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-object root", () => {
|
||||||
|
expect(() => parseSessionArchiveManifest("oops")).toThrow(/manifest/);
|
||||||
|
expect(() => parseSessionArchiveManifest(null)).toThrow(/manifest/);
|
||||||
|
});
|
||||||
|
});
|
||||||
150
src/shared/session-archive.ts
Normal file
150
src/shared/session-archive.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* SessionArchiveManifest — JSON contract for `manifest.json` inside a
|
||||||
|
* session ZIP archive.
|
||||||
|
*
|
||||||
|
* Schema version 1; see `docs/decisions/ADR-0008-session-archive-format.md`
|
||||||
|
* for the authoritative spec. This module is the executable contract:
|
||||||
|
* `parseSessionArchiveManifest` validates an `unknown` JSON value and
|
||||||
|
* narrows it to `SessionArchiveManifest` or throws with a useful
|
||||||
|
* message.
|
||||||
|
*
|
||||||
|
* The `engine` field re-uses the in-memory `EngineSnapshot` shape so
|
||||||
|
* the in-memory ↔ archive round-trip stays a one-way conversion.
|
||||||
|
* Only minimal structural validation runs here; the engine helpers
|
||||||
|
* (`restoreSnapshot`) handle deeper validation when actually
|
||||||
|
* hydrating an engine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DocumentId, SessionId } from "./ids";
|
||||||
|
|
||||||
|
export const SESSION_ARCHIVE_SCHEMA_VERSION = 1 as const;
|
||||||
|
|
||||||
|
export interface SessionArchiveSessionRecord {
|
||||||
|
readonly id: SessionId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionArchiveDocumentBinding {
|
||||||
|
readonly documentId: DocumentId;
|
||||||
|
readonly filename: string;
|
||||||
|
readonly fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror of `EngineSnapshot` — kept loose here (record of unknown) to
|
||||||
|
* avoid the cross-package dependency back into `@engine`. The engine
|
||||||
|
* persistence layer owns the authoritative shape.
|
||||||
|
*/
|
||||||
|
export interface SessionArchiveEngineSnapshot {
|
||||||
|
readonly version: number;
|
||||||
|
readonly documents: readonly unknown[];
|
||||||
|
readonly representations: readonly unknown[];
|
||||||
|
readonly annotations: readonly unknown[];
|
||||||
|
readonly evidenceItems: readonly unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionArchiveManifest {
|
||||||
|
readonly schemaVersion: typeof SESSION_ARCHIVE_SCHEMA_VERSION;
|
||||||
|
readonly exportedAt: string;
|
||||||
|
readonly session: SessionArchiveSessionRecord;
|
||||||
|
readonly engine: SessionArchiveEngineSnapshot;
|
||||||
|
readonly documentBindings: readonly SessionArchiveDocumentBinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionArchiveParseError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`SessionArchiveManifest parse failed: ${message}`);
|
||||||
|
this.name = "SessionArchiveParseError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertString(value: unknown, field: string): string {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new SessionArchiveParseError(`field "${field}" must be a string`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertObject(value: unknown, field: string): Record<string, unknown> {
|
||||||
|
if (!isObject(value)) {
|
||||||
|
throw new SessionArchiveParseError(`field "${field}" must be an object`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertArray(value: unknown, field: string): readonly unknown[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new SessionArchiveParseError(`field "${field}" must be an array`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSessionRecord(raw: unknown): SessionArchiveSessionRecord {
|
||||||
|
const obj = assertObject(raw, "session");
|
||||||
|
return {
|
||||||
|
id: assertString(obj.id, "session.id") as SessionId,
|
||||||
|
name: assertString(obj.name, "session.name"),
|
||||||
|
createdAt: assertString(obj.createdAt, "session.createdAt"),
|
||||||
|
updatedAt: assertString(obj.updatedAt, "session.updatedAt"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDocumentBinding(
|
||||||
|
raw: unknown,
|
||||||
|
index: number,
|
||||||
|
): SessionArchiveDocumentBinding {
|
||||||
|
const obj = assertObject(raw, `documentBindings[${index}]`);
|
||||||
|
return {
|
||||||
|
documentId: assertString(obj.documentId, `documentBindings[${index}].documentId`) as DocumentId,
|
||||||
|
filename: assertString(obj.filename, `documentBindings[${index}].filename`),
|
||||||
|
fingerprint: assertString(obj.fingerprint, `documentBindings[${index}].fingerprint`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEngineSnapshot(raw: unknown): SessionArchiveEngineSnapshot {
|
||||||
|
const obj = assertObject(raw, "engine");
|
||||||
|
const version = obj.version;
|
||||||
|
if (typeof version !== "number") {
|
||||||
|
throw new SessionArchiveParseError(`field "engine.version" must be a number`);
|
||||||
|
}
|
||||||
|
const documents = assertArray(obj.documents, "engine.documents");
|
||||||
|
const representations = assertArray(obj.representations, "engine.representations");
|
||||||
|
const annotations = assertArray(obj.annotations, "engine.annotations");
|
||||||
|
const evidenceItems = assertArray(obj.evidenceItems, "engine.evidenceItems");
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
documents,
|
||||||
|
representations,
|
||||||
|
annotations,
|
||||||
|
evidenceItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSessionArchiveManifest(raw: unknown): SessionArchiveManifest {
|
||||||
|
const obj = assertObject(raw, "manifest");
|
||||||
|
const schemaVersion = obj.schemaVersion;
|
||||||
|
if (schemaVersion !== SESSION_ARCHIVE_SCHEMA_VERSION) {
|
||||||
|
throw new SessionArchiveParseError(
|
||||||
|
`unsupported schemaVersion ${String(schemaVersion)} — expected ${SESSION_ARCHIVE_SCHEMA_VERSION}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const exportedAt = assertString(obj.exportedAt, "exportedAt");
|
||||||
|
const session = parseSessionRecord(obj.session);
|
||||||
|
const engine = parseEngineSnapshot(obj.engine);
|
||||||
|
const documentBindings = assertArray(obj.documentBindings, "documentBindings").map(
|
||||||
|
(entry, i) => parseDocumentBinding(entry, i),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
|
||||||
|
exportedAt,
|
||||||
|
session,
|
||||||
|
engine,
|
||||||
|
documentBindings,
|
||||||
|
};
|
||||||
|
}
|
||||||
26
src/shared/session.ts
Normal file
26
src/shared/session.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* `Session` — a user-named workspace that owns one engine snapshot.
|
||||||
|
*
|
||||||
|
* Sessions partition the demo app: each one holds its own documents,
|
||||||
|
* annotations, evidence items, and links. Membership is implicit — a
|
||||||
|
* session "owns" whatever lives in its engine snapshot. The session
|
||||||
|
* record itself only carries the human metadata (name, timestamps) and
|
||||||
|
* the branded id used as a key in `localStorage` and the ZIP archive
|
||||||
|
* (see ADR-0008).
|
||||||
|
*
|
||||||
|
* The id is opaque (`sess_<uuid>` per `ids.ts`). The name is the human
|
||||||
|
* label; uniqueness is enforced by the `SessionService` on create and
|
||||||
|
* rename. Names are *trimmed* before storage so a leading/trailing
|
||||||
|
* space does not let two sessions coexist with effectively the same
|
||||||
|
* label.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SessionId } from "./ids";
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
readonly id: SessionId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly updatedAt: string;
|
||||||
|
readonly lastOpenedAt?: string;
|
||||||
|
}
|
||||||
56
src/shared/text/normalize.test.ts
Normal file
56
src/shared/text/normalize.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { NORMALIZE_VERSION, normalize } from "./normalize.js";
|
||||||
|
|
||||||
|
describe("normalize (NORMALIZE_VERSION=1)", () => {
|
||||||
|
it("returns the version constant alongside the text", () => {
|
||||||
|
const out = normalize("hello");
|
||||||
|
expect(out.version).toBe(NORMALIZE_VERSION);
|
||||||
|
expect(out.text).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies Unicode NFC composition", () => {
|
||||||
|
// "é" decomposed (e + combining acute) vs precomposed.
|
||||||
|
const decomposed = "café";
|
||||||
|
const precomposed = "café";
|
||||||
|
expect(normalize(decomposed).text).toBe(precomposed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes CRLF and CR line endings to LF", () => {
|
||||||
|
expect(normalize("a\r\nb\rc").text).toBe("a\nb\nc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses horizontal whitespace runs to a single space", () => {
|
||||||
|
expect(normalize("a b\t\tc d").text).toBe("a b c d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves paragraph boundaries but collapses 3+ blank lines to one", () => {
|
||||||
|
const input = "para one\n\n\n\npara two\n\npara three";
|
||||||
|
expect(normalize(input).text).toBe("para one\n\npara two\n\npara three");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips soft hyphens (German line-broken word reassembly)", () => {
|
||||||
|
// German "Donaudampfschiff" line-broken with soft hyphens.
|
||||||
|
expect(normalize("Donaudampfschiff").text).toBe(
|
||||||
|
"Donaudampfschiff",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips soft hyphens that span a newline ('word-\\nfragment' → 'wordfragment')", () => {
|
||||||
|
expect(normalize("word\nfragment").text).toBe("wordfragment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mangle ligatures (preserves the round-trip)", () => {
|
||||||
|
// The ligature "fi" (U+FB01) is left as-is — NFC does NOT decompose it.
|
||||||
|
// Test documents that current behavior so a future change is intentional.
|
||||||
|
expect(normalize("efficient").text).toBe("efficient");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a mixed-whitespace paragraph realistically", () => {
|
||||||
|
const input = " First line \r\n Second line.\r\n\r\n\r\nNext para. ";
|
||||||
|
expect(normalize(input).text).toBe("First line\nSecond line.\n\nNext para.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty string for whitespace-only input", () => {
|
||||||
|
expect(normalize(" \n\n \t ").text).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/shared/text/normalize.ts
Normal file
49
src/shared/text/normalize.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Canonical text normalization for selectors and stored quotes.
|
||||||
|
// Contract: wiki/SharedContracts.md §6.
|
||||||
|
//
|
||||||
|
// IMPORTANT: NORMALIZE_VERSION is stored on every Annotation. Bumping it is a
|
||||||
|
// migration event — old selectors must be re-resolved against re-normalized
|
||||||
|
// text before the new version becomes the default.
|
||||||
|
|
||||||
|
export const NORMALIZE_VERSION = 1;
|
||||||
|
|
||||||
|
// Soft hyphen (U+00AD), optionally followed by a single \n so that a PDF-
|
||||||
|
// extracted "word\nfragment" reassembles to "wordfragment" rather than
|
||||||
|
// leaving a stray line break in the middle of a hyphenated word.
|
||||||
|
const SOFT_HYPHEN_AT_BREAK = /\n?/g;
|
||||||
|
|
||||||
|
// Horizontal whitespace = any \s except \n and \r. The double-negation
|
||||||
|
// [^\S\r\n] is the idiomatic regex: \S is "not whitespace", so
|
||||||
|
// "not (not-whitespace or line-ending)" = "whitespace that is not a newline".
|
||||||
|
// Covers space, tab, NBSP, narrow NBSP, em-space, all Zs general-category.
|
||||||
|
const HORIZONTAL_WHITESPACE_RUN = /[^\S\r\n]+/g;
|
||||||
|
|
||||||
|
// 3+ newlines collapse to exactly two (one paragraph boundary).
|
||||||
|
const PARAGRAPH_RUN = /\n{3,}/g;
|
||||||
|
|
||||||
|
export function normalize(input: string): { text: string; version: number } {
|
||||||
|
// 1. Unicode NFC.
|
||||||
|
let text = input.normalize("NFC");
|
||||||
|
|
||||||
|
// 2. Normalize line endings: CRLF and CR -> LF.
|
||||||
|
text = text.replace(/\r\n?/g, "\n");
|
||||||
|
|
||||||
|
// 4. Strip soft hyphens (U+00AD) — including the line break that follows
|
||||||
|
// one — so PDF line-broken hyphenations reassemble. Done before
|
||||||
|
// horizontal collapse so no stray space remains.
|
||||||
|
text = text.replace(SOFT_HYPHEN_AT_BREAK, "");
|
||||||
|
|
||||||
|
// 3. Collapse horizontal whitespace runs to a single space.
|
||||||
|
text = text.replace(HORIZONTAL_WHITESPACE_RUN, " ");
|
||||||
|
|
||||||
|
// 5. Preserve paragraph boundaries (\n\n); collapse 3+ blank lines to 2.
|
||||||
|
text = text.replace(PARAGRAPH_RUN, "\n\n");
|
||||||
|
|
||||||
|
// Trim line-edge whitespace left over after horizontal collapse.
|
||||||
|
text = text.replace(/ +\n/g, "\n").replace(/\n +/g, "\n");
|
||||||
|
|
||||||
|
// Trim leading/trailing whitespace from the whole document.
|
||||||
|
text = text.trim();
|
||||||
|
|
||||||
|
return { text, version: NORMALIZE_VERSION };
|
||||||
|
}
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["src/shared/*"],
|
||||||
|
"@engine/*": ["src/engine/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "vitest.config.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
18
vitest.config.ts
Normal file
18
vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@shared": resolve(__dirname, "src/shared"),
|
||||||
|
"@engine": resolve(__dirname, "src/engine"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
globals: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
22
wiki/README.md
Normal file
22
wiki/README.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Wiki — citation-engine
|
||||||
|
|
||||||
|
## SharedContracts sync
|
||||||
|
|
||||||
|
`SharedContracts.md` in this directory is a **conformance copy** for tests
|
||||||
|
(e.g. `evidence-link.test.ts` enum parity checks). It is not authoritative.
|
||||||
|
|
||||||
|
**Authoritative source:** `citation-evidence/wiki/SharedContracts.md`
|
||||||
|
|
||||||
|
### When contracts change
|
||||||
|
|
||||||
|
1. Land the change in `citation-evidence/wiki/SharedContracts.md` (and any
|
||||||
|
runtime types in `citation-engine/src/shared/` if enums or shapes change).
|
||||||
|
2. Copy/sync to `citation-engine/wiki/SharedContracts.md`.
|
||||||
|
3. Run `pnpm test` in **both** repos.
|
||||||
|
4. Bump `@citation-evidence/engine` version if the change is breaking for
|
||||||
|
consumers.
|
||||||
|
|
||||||
|
### Test reference path
|
||||||
|
|
||||||
|
`src/shared/evidence-link.test.ts` reads `wiki/SharedContracts.md` relative
|
||||||
|
to `src/shared/` (`../../wiki/SharedContracts.md`).
|
||||||
306
wiki/SharedContracts.md
Normal file
306
wiki/SharedContracts.md
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
# Shared Contracts — citation-evidence
|
||||||
|
|
||||||
|
This document is the **single source of truth** for everything that more than one
|
||||||
|
subsystem in the citation-evidence ecosystem must agree on:
|
||||||
|
|
||||||
|
- the **vocabulary** (entity names and what they mean),
|
||||||
|
- the **canonical state enums** for entities that flow across repo boundaries,
|
||||||
|
- the **relation type** vocabulary,
|
||||||
|
- the **selector type** taxonomy,
|
||||||
|
- the **event type** vocabulary,
|
||||||
|
- the **ownership rules** for shared types versus shared behavior.
|
||||||
|
|
||||||
|
The five sister repos (`citation-engine`, `evidence-anchor`, `evidence-source`,
|
||||||
|
`citation-work`, `evidence-binder`) defer to this document. When their
|
||||||
|
`INTENT.md` files refer to "shared contracts", they mean this file.
|
||||||
|
|
||||||
|
During the umbrella-first MVP phase, the **TypeScript implementations** of
|
||||||
|
these contracts live in `citation-evidence/src/shared/` and are imported by
|
||||||
|
the per-subsystem code under `citation-evidence/src/{engine,anchor,source,work,binder}/`.
|
||||||
|
When a subsystem extracts to its own repo, it takes its slice of the shared
|
||||||
|
types with it — but this document remains the canonical vocabulary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Vocabulary
|
||||||
|
|
||||||
|
These nine entities are the vocabulary every subsystem uses.
|
||||||
|
|
||||||
|
| Entity | One-line definition | Owner (post-extraction) |
|
||||||
|
|---------------------------|----------------------------------------------------------------------------------------------------|-------------------------|
|
||||||
|
| `Document` | An identified source object: PDF, Markdown, HTML, scan, etc. | `citation-engine` |
|
||||||
|
| `DocumentRepresentation` | A normalized, addressable view of a document (canonical text, page map, structure). | `citation-engine` |
|
||||||
|
| `Selector` | A technical locator for a passage inside a representation. | `citation-engine` (types) / `evidence-anchor` (behavior) |
|
||||||
|
| `Annotation` | A technical mark on a document range, expressed as one or more selectors plus quote text. | `citation-engine` |
|
||||||
|
| `EvidenceItem` | A meaningful evidence object built from one or more annotations, with commentary and status. | `citation-engine` |
|
||||||
|
| `EvidenceSet` | An ordered group of evidence items associated with a target or topic. | `citation-engine` (type) / `evidence-binder` (behavior) |
|
||||||
|
| `EvidenceLink` | A relation between an `EvidenceItem` and a structured target (form field, claim, requirement, …). | `citation-engine` (type) / `evidence-binder` (behavior) |
|
||||||
|
| `CitationCard` | A renderable, exportable presentation of an evidence item. | `citation-engine` |
|
||||||
|
| `CitationRecoveryAttempt` | A traceable attempt to locate a cited passage from an external clue. | `citation-engine` (type) / `evidence-source` (behavior) |
|
||||||
|
|
||||||
|
**Ownership rule:** *types and interfaces flow downward from `citation-engine`;
|
||||||
|
behavior flows upward into the specialised repos*. Where the table shows a
|
||||||
|
split, the engine repo holds the data shape and the other repo holds the
|
||||||
|
algorithms and lifecycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Canonical state enums
|
||||||
|
|
||||||
|
These enums are the authoritative values. Subsystems must not invent local
|
||||||
|
variants without updating this document first.
|
||||||
|
|
||||||
|
### 2.1 `Annotation.resolutionStatus`
|
||||||
|
|
||||||
|
```
|
||||||
|
resolved — selectors located the passage with high confidence
|
||||||
|
ambiguous — multiple plausible candidates found
|
||||||
|
unresolved — no plausible candidate found
|
||||||
|
stale — representation has changed since selectors were stored
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 `EvidenceItem.status`
|
||||||
|
|
||||||
|
```
|
||||||
|
candidate — captured but not yet vetted
|
||||||
|
confirmed — verified by a user as useful evidence
|
||||||
|
rejected — explicitly discarded
|
||||||
|
needs-check — flagged for review
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** earlier subsystem drafts introduced `strong-support`, `weak-support`,
|
||||||
|
> and `contradicts` on the item. Those concepts now live on the **link**, not
|
||||||
|
> the item — see §2.4.
|
||||||
|
|
||||||
|
### 2.3 `Document.reviewStatus` (when used by `citation-work`)
|
||||||
|
|
||||||
|
```
|
||||||
|
unreviewed
|
||||||
|
in-review
|
||||||
|
relevant
|
||||||
|
rejected
|
||||||
|
needs-follow-up
|
||||||
|
cited
|
||||||
|
verified
|
||||||
|
```
|
||||||
|
|
||||||
|
`citation-work` may treat any of these as the active state; the canonical
|
||||||
|
storage lives on the Document record in `citation-engine`.
|
||||||
|
|
||||||
|
### 2.4 `EvidenceLink.status` (per target)
|
||||||
|
|
||||||
|
```
|
||||||
|
no-evidence
|
||||||
|
candidate
|
||||||
|
confirmed
|
||||||
|
conflicting
|
||||||
|
insufficient
|
||||||
|
verified
|
||||||
|
```
|
||||||
|
|
||||||
|
`no-evidence` is a *derived* state computed when a target has zero links;
|
||||||
|
it is not stored on a link itself.
|
||||||
|
|
||||||
|
### 2.5 `EvidenceLink.relation`
|
||||||
|
|
||||||
|
```
|
||||||
|
supports
|
||||||
|
contradicts
|
||||||
|
explains
|
||||||
|
qualifies
|
||||||
|
source-for
|
||||||
|
context-for
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the closed vocabulary for the MVP. Adding a relation requires updating
|
||||||
|
this document and the `EvidenceLink` schema together.
|
||||||
|
|
||||||
|
### 2.6 `CitationRecoveryAttempt.state`
|
||||||
|
|
||||||
|
```
|
||||||
|
created
|
||||||
|
source-found-fulltext
|
||||||
|
source-found-preview-only
|
||||||
|
source-found-metadata-only
|
||||||
|
source-not-found
|
||||||
|
quote-found
|
||||||
|
quote-not-found
|
||||||
|
candidate-passages-found
|
||||||
|
manual-confirmation-needed
|
||||||
|
confirmed
|
||||||
|
annotation-created
|
||||||
|
failed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Selector taxonomy
|
||||||
|
|
||||||
|
A `Selector` is a discriminated union of:
|
||||||
|
|
||||||
|
```
|
||||||
|
TextQuoteSelector exact quote + prefix/suffix context
|
||||||
|
TextPositionSelector canonical text start/end offsets
|
||||||
|
PdfRectSelector page number + normalized page rectangles
|
||||||
|
PdfPageTextSelector page number + page-local text offsets
|
||||||
|
DomRangeSelector DOM path + range offsets (HTML/Markdown)
|
||||||
|
StructuralSelector heading/section/AST path
|
||||||
|
FragmentSelector exported fragment / deep link (export-only)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Selector redundancy rule:** when an annotation is created, the system stores
|
||||||
|
*all selector types that are available* for that document representation, not
|
||||||
|
just one. Resolution tries them in order of expected confidence and stops at
|
||||||
|
the first high-confidence match.
|
||||||
|
|
||||||
|
W3C Web Annotation mapping uses these same concepts but as JSON-LD; the mapping
|
||||||
|
is documented separately (see ADR-0003 — pending).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Event vocabulary
|
||||||
|
|
||||||
|
Events are the primary integration mechanism between subsystems. The closed
|
||||||
|
event vocabulary for the MVP is:
|
||||||
|
|
||||||
|
```
|
||||||
|
DocumentImported
|
||||||
|
DocumentRepresentationGenerated
|
||||||
|
DocumentRemoved
|
||||||
|
AnnotationCreated
|
||||||
|
AnnotationResolved
|
||||||
|
AnnotationResolutionFailed
|
||||||
|
EvidenceItemCreated
|
||||||
|
EvidenceItemUpdated
|
||||||
|
EvidenceLinkCreated
|
||||||
|
EvidenceLinkUpdated
|
||||||
|
EvidenceItemActivated
|
||||||
|
FormFieldActivated
|
||||||
|
CitationCardRendered
|
||||||
|
CitationRecoveryStarted
|
||||||
|
CitationRecoveryCandidateFound
|
||||||
|
CitationRecoveryConfirmed
|
||||||
|
SessionCreated
|
||||||
|
SessionRenamed
|
||||||
|
SessionDeleted
|
||||||
|
SessionActivated
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Session*` events live on the cross-session session bus (the
|
||||||
|
SessionService's own EventBus instance — see CE-WP-0005). The remaining
|
||||||
|
events live on the per-session engine bus and are scoped to whatever
|
||||||
|
session is currently active.
|
||||||
|
|
||||||
|
Subsystems must emit these events through a shared event bus owned by
|
||||||
|
`citation-engine`. Subsystems may listen to any event but must not invent
|
||||||
|
event types without updating this document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Viewer adapter contract
|
||||||
|
|
||||||
|
Viewer adapters are the bridge between a document format and the rest of the
|
||||||
|
system. They are **owned by `evidence-anchor`** as far as the contract goes;
|
||||||
|
concrete adapters may live in either `evidence-anchor` or `evidence-source`
|
||||||
|
depending on whether the heavy lifting is selector logic or document
|
||||||
|
representation logic.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface DocumentViewerAdapter {
|
||||||
|
mediaTypes: string[];
|
||||||
|
load(document: Document, representation?: DocumentRepresentation): Promise<void>;
|
||||||
|
getCurrentSelection(): Promise<SelectionCapture | null>;
|
||||||
|
createSelectorsFromSelection(selection: SelectionCapture): Promise<Selector[]>;
|
||||||
|
resolveSelectors(selectors: Selector[]): Promise<AnchorResolution>;
|
||||||
|
scrollToResolvedTarget(target: ResolvedAnchorTarget, opts?: { center?: boolean; behavior?: "auto"|"smooth" }): Promise<void>;
|
||||||
|
renderHighlight(target: ResolvedAnchorTarget, opts?: HighlightRenderOptions): Promise<void>;
|
||||||
|
getHighlightClientRects(annotationId: string): Promise<DOMRect[]>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
MVP delivers a single `PDFViewerAdapter`. HTML and Markdown adapters are
|
||||||
|
deferred.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Canonical text normalization
|
||||||
|
|
||||||
|
All text-based selectors and quote matching depend on a deterministic
|
||||||
|
normalization function. The MVP normalization is:
|
||||||
|
|
||||||
|
1. Unicode NFC normalization.
|
||||||
|
2. Replace all line-ending sequences with `\n`.
|
||||||
|
3. Collapse runs of horizontal whitespace into a single space.
|
||||||
|
4. Strip soft hyphens (U+00AD).
|
||||||
|
5. Preserve paragraph boundaries (double `\n`).
|
||||||
|
|
||||||
|
**This function is versioned.** Stored selectors record the normalization
|
||||||
|
version they were created against. Changing the function later requires either
|
||||||
|
backwards-compatible behavior or a re-anchoring migration.
|
||||||
|
|
||||||
|
The reference implementation lives in `citation-evidence/src/shared/text/normalize.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Visual guide rect registry
|
||||||
|
|
||||||
|
The visual-guide overlay (form field → evidence card → source highlight)
|
||||||
|
requires DOM rects from three independently-rendered subsystems. The contract
|
||||||
|
is a **rect registry** owned by `evidence-binder`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface RectRegistry {
|
||||||
|
register(kind: "field" | "evidence-card" | "highlight", id: string, getRect: () => DOMRect | null): () => void;
|
||||||
|
getRect(kind: "field" | "evidence-card" | "highlight", id: string): DOMRect | null;
|
||||||
|
subscribe(listener: (event: RectRegistryEvent) => void): () => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each renderer (form, evidence sidebar, viewer adapter) registers a
|
||||||
|
`getRect` callback. The overlay queries on-demand and re-renders on scroll,
|
||||||
|
resize, focus, and active-evidence change.
|
||||||
|
|
||||||
|
This contract MUST be defined and stable before any of the three renderers
|
||||||
|
hardens, or the overlay becomes the system's coupling bottleneck.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Ownership rules (the short version)
|
||||||
|
|
||||||
|
1. **Types and interfaces** flow downward from `citation-engine`.
|
||||||
|
2. **Behavior and algorithms** live in the specialised repos.
|
||||||
|
3. Where a concept appears in both a type and a behavior context (e.g.
|
||||||
|
`Selector`, `EvidenceLink`, `EvidenceSet`, `CitationRecoveryAttempt`),
|
||||||
|
the engine owns the shape and the specialised repo owns the lifecycle.
|
||||||
|
4. **The shared event bus is engine-owned**; subsystems publish and subscribe
|
||||||
|
but do not extend the event vocabulary unilaterally.
|
||||||
|
5. **No new enum values, relation types, event types, or selector kinds**
|
||||||
|
land in code without first appearing in this document.
|
||||||
|
6. During umbrella-first MVP: rules 1-5 are aspirational. We will tolerate
|
||||||
|
small violations in `citation-evidence/src/` and reconcile during extraction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Change process
|
||||||
|
|
||||||
|
Changes to this document are change to the contract.
|
||||||
|
|
||||||
|
- Small additions (a new enum value, a new event type) can be made in a single
|
||||||
|
PR that updates this doc + the type definitions + at least one consumer.
|
||||||
|
- Breaking changes (renaming an entity, removing a state, changing an
|
||||||
|
ownership split) require a short ADR in `docs/decisions/` and a heads-up
|
||||||
|
progress event on the state-hub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Pending ADRs that will affect this document
|
||||||
|
|
||||||
|
These are listed in `docs/decisions/` once written. Until then the document
|
||||||
|
reflects the current best understanding from the architecture overview.
|
||||||
|
|
||||||
|
- **ADR-0001** — Umbrella-first MVP strategy (decided 2026-05-24, this session).
|
||||||
|
- **ADR-0002** — Monorepo vs polyrepo packaging (pending).
|
||||||
|
- **ADR-0003** — W3C Web Annotation: lossy mapping vs round-trip guarantee (pending).
|
||||||
|
- **ADR-0004** — PDF viewer library choice: `react-pdf-highlighter-plus` vs PDF.js direct (pending).
|
||||||
|
- **ADR-0005** — Persistence: local-first SQLite vs Postgres from day one (pending).
|
||||||
|
- **ADR-0006** — Selector ownership split (types in engine, algorithms in anchor) (pending — implied here).
|
||||||
@@ -1,31 +1,105 @@
|
|||||||
---
|
---
|
||||||
id: CENG-WP-0001
|
id: CENG-WP-0001
|
||||||
type: workplan
|
type: workplan
|
||||||
title: "INTENT placeholder — await extraction from citation-evidence"
|
title: "Extract engine from citation-evidence umbrella"
|
||||||
domain: citation_evidence
|
domain: infotech
|
||||||
repo: citation-engine
|
repo: citation-engine
|
||||||
status: active
|
status: done
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: citation_evidence_mvp
|
topic_slug: citation_evidence_mvp
|
||||||
|
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
|
||||||
created: "2026-06-21"
|
created: "2026-06-21"
|
||||||
updated: "2026-06-21"
|
updated: "2026-06-22"
|
||||||
state_hub_workstream_id: "ccd217a2-572d-435d-9a29-e0a55b6a4311"
|
state_hub_workstream_id: "ccd217a2-572d-435d-9a29-e0a55b6a4311"
|
||||||
---
|
---
|
||||||
|
|
||||||
# CENG-WP-0001 — INTENT Placeholder
|
# CENG-WP-0001 — Extract Engine from Umbrella
|
||||||
|
|
||||||
Umbrella-first MVP: engine code lives in `citation-evidence/src/engine/` and
|
Extract `citation-evidence/src/shared/` and `citation-evidence/src/engine/` into
|
||||||
`citation-evidence/src/shared/`. This file satisfies ADR-001 workplan structure
|
this repository as a standalone TypeScript package (`@citation-evidence/engine`).
|
||||||
until extraction per `INTENT.md`.
|
|
||||||
|
|
||||||
## Extraction gate
|
Prerequisite: umbrella MVP complete (CE-WP-0001..0008).
|
||||||
|
|
||||||
|
## Dependency order
|
||||||
|
|
||||||
|
```
|
||||||
|
T01 (toolchain scaffold)
|
||||||
|
└─ T02 (copy shared/ + engine/ from umbrella)
|
||||||
|
└─ T03 (conformance reference: wiki/SharedContracts.md)
|
||||||
|
└─ T04 (test + typecheck + lint green)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T01 — Toolchain scaffold
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CENG-WP-0001-T01
|
id: CENG-WP-0001-T01
|
||||||
status: wait
|
status: done
|
||||||
priority: low
|
priority: critical
|
||||||
state_hub_task_id: "755e36b7-983d-48e8-af94-fd074db7f745"
|
state_hub_task_id: "755e36b7-983d-48e8-af94-fd074db7f745"
|
||||||
```
|
```
|
||||||
|
|
||||||
Blocked on citation-evidence MVP stabilization and engine API contract freeze.
|
Deliverables:
|
||||||
See `citation-evidence/wiki/DependencyMap.md`.
|
- `package.json` with `test`, `lint`, `typecheck` scripts
|
||||||
|
- `tsconfig.json` with `@shared/*` and `@engine/*` path aliases
|
||||||
|
- `vitest.config.ts`, `eslint.config.js`, `.nvmrc`
|
||||||
|
- Node-oriented `.gitignore`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T02 — Extract shared/ and engine/
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CENG-WP-0001-T02
|
||||||
|
status: done
|
||||||
|
priority: critical
|
||||||
|
depends_on: [T01]
|
||||||
|
state_hub_task_id: "01c0295a-d524-48cc-848c-d55eefbedbe2"
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the umbrella partitions verbatim:
|
||||||
|
- `src/shared/` — domain types, enums, normalization
|
||||||
|
- `src/engine/` — services, repos, events, rendering, persistence
|
||||||
|
|
||||||
|
No import path changes required; aliases match the umbrella layout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T03 — SharedContracts conformance reference
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CENG-WP-0001-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
depends_on: [T02]
|
||||||
|
state_hub_task_id: "aee0fcae-2a26-47a7-be0b-b2940f4c7767"
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `wiki/SharedContracts.md` from the umbrella so enum conformance tests
|
||||||
|
(`evidence-link.test.ts`) keep a local reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T04 — Verification
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CENG-WP-0001-T04
|
||||||
|
status: done
|
||||||
|
priority: critical
|
||||||
|
depends_on: [T02, T03]
|
||||||
|
state_hub_task_id: "46f76120-e5b3-40ef-b5e3-599db1c4b82b"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `pnpm test` — 89 tests pass
|
||||||
|
- `pnpm typecheck` — clean
|
||||||
|
- `pnpm lint` — clean (shared/engine boundary enforced)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
- `CE-WP-0009` — wire `citation-evidence` to consume this package via `link:` and
|
||||||
|
delete in-repo copies (`citation-evidence/workplans/CE-WP-0009-engine-workspace-wireup.md`).
|
||||||
|
- `CENG-WP-0002` — ADR-0002, publish readiness, contract sync
|
||||||
|
(`workplans/CENG-WP-0002-package-distribution.md`).
|
||||||
161
workplans/CENG-WP-0002-package-distribution.md
Normal file
161
workplans/CENG-WP-0002-package-distribution.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
id: CENG-WP-0002
|
||||||
|
type: workplan
|
||||||
|
title: "Package distribution — ADR-0002, publish readiness, contract sync"
|
||||||
|
domain: infotech
|
||||||
|
repo: citation-engine
|
||||||
|
status: done
|
||||||
|
owner: codex
|
||||||
|
topic_slug: citation_evidence_mvp
|
||||||
|
created: "2026-06-22"
|
||||||
|
updated: "2026-06-22"
|
||||||
|
planning_order: 2
|
||||||
|
planning_priority: high
|
||||||
|
depends_on_workplan: CENG-WP-0001
|
||||||
|
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
|
||||||
|
spec_refs:
|
||||||
|
- ../citation-evidence/docs/decisions/ADR-0002-monorepo-vs-polyrepo.md
|
||||||
|
- ../citation-evidence/wiki/SharedContracts.md
|
||||||
|
- ../citation-evidence/wiki/DependencyMap.md
|
||||||
|
state_hub_workstream_id: "c1141068-d969-4367-bd6f-d159efb8332d"
|
||||||
|
---
|
||||||
|
|
||||||
|
# CENG-WP-0002 — Package Distribution
|
||||||
|
|
||||||
|
Make `@citation-evidence/engine` consumable beyond a sibling `link:` checkout:
|
||||||
|
resolve the monorepo-vs-polyrepo decision, document the install story for sister
|
||||||
|
repos, and prepare optional registry publish.
|
||||||
|
|
||||||
|
Prerequisite: `CENG-WP-0001` complete. `CE-WP-0009` (umbrella wireup) can
|
||||||
|
proceed in parallel using `link:`; publish tasks here depend on T01.
|
||||||
|
|
||||||
|
## Dependency order
|
||||||
|
|
||||||
|
```
|
||||||
|
T01 (resolve ADR-0002)
|
||||||
|
└─ T02 (consumer install docs + export surface)
|
||||||
|
└─ T03 (SharedContracts sync runbook)
|
||||||
|
└─ T04 (publish readiness — optional until registry chosen)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T01 — Resolve ADR-0002
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CENG-WP-0002-T01
|
||||||
|
status: done
|
||||||
|
priority: critical
|
||||||
|
depends_on: []
|
||||||
|
state_hub_task_id: "ed44ca36-d834-4fe7-8a02-82e5a921b95d"
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill in `citation-evidence/docs/decisions/ADR-0002-monorepo-vs-polyrepo.md`:
|
||||||
|
|
||||||
|
- **Decision** — pick A, B, or C for the citation-evidence ecosystem.
|
||||||
|
- **Recommended default** (if no strong preference): **B — six independent repos
|
||||||
|
with published packages**, using `link:../citation-engine` during local dev
|
||||||
|
until a registry is configured.
|
||||||
|
- **Consequences** — how sister repos install engine, how versions bump, how
|
||||||
|
`SharedContracts.md` stays authoritative.
|
||||||
|
|
||||||
|
Stop and ask the repo owner if the choice is genuinely ambiguous.
|
||||||
|
|
||||||
|
**Acceptance:** ADR status moves from `proposed` to `accepted`; decision and
|
||||||
|
consequences sections are filled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T02 — Consumer install docs and export surface
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CENG-WP-0002-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
depends_on: [T01]
|
||||||
|
state_hub_task_id: "5726bfff-e3ff-4942-a5d8-3a69107695e7"
|
||||||
|
```
|
||||||
|
|
||||||
|
Document in `citation-engine/README.md`:
|
||||||
|
|
||||||
|
- **Local dev** — `link:../citation-engine` (sibling checkout layout).
|
||||||
|
- **Post-publish** — `pnpm add @citation-evidence/engine` (registry TBD).
|
||||||
|
|
||||||
|
Review and stabilize `package.json` `exports`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
".": "./src/engine/index.ts",
|
||||||
|
"./shared": "./src/shared/index.ts"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm TypeScript consumers can resolve both entry points without deep imports
|
||||||
|
into `src/`. Add `types` conditions or subpath exports only if a consumer
|
||||||
|
repo hits resolution errors during `CE-WP-0009`.
|
||||||
|
|
||||||
|
**Acceptance:** README has copy-paste install snippets for umbrella and one
|
||||||
|
sister repo placeholder (`evidence-anchor`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T03 — SharedContracts sync runbook
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CENG-WP-0002-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
depends_on: [T01]
|
||||||
|
state_hub_task_id: "deb05231-751a-4b3b-812a-62ad3016724a"
|
||||||
|
```
|
||||||
|
|
||||||
|
`citation-evidence/wiki/SharedContracts.md` remains the **authoritative**
|
||||||
|
contract. `citation-engine/wiki/SharedContracts.md` is a conformance copy for
|
||||||
|
enum tests (`evidence-link.test.ts`).
|
||||||
|
|
||||||
|
Write a short runbook (in `citation-engine/README.md` or `wiki/README.md`):
|
||||||
|
|
||||||
|
1. Contract change lands in umbrella `wiki/SharedContracts.md`.
|
||||||
|
2. Copy/sync to `citation-engine/wiki/SharedContracts.md`.
|
||||||
|
3. Update runtime enums/types in `src/shared/` if needed.
|
||||||
|
4. Run `pnpm test` in both repos.
|
||||||
|
|
||||||
|
**Acceptance:** runbook exists; `evidence-link.test.ts` path to local
|
||||||
|
`wiki/SharedContracts.md` is documented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T04 — Publish readiness (deferred until registry exists)
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CENG-WP-0002-T04
|
||||||
|
status: done
|
||||||
|
priority: low
|
||||||
|
depends_on: [T01, T02]
|
||||||
|
state_hub_task_id: "9087896a-7779-42e0-8844-aaa8de1016e9"
|
||||||
|
```
|
||||||
|
|
||||||
|
Prepare for first registry publish when a host is chosen (npm, GitHub Packages,
|
||||||
|
private Verdaccio, etc.):
|
||||||
|
|
||||||
|
- Decide `private: false` vs scoped private package.
|
||||||
|
- Add `files` whitelist (`src/`, `wiki/`, `README.md`, `LICENSE`).
|
||||||
|
- Optional: `tsc` emit to `dist/` if consumers cannot consume TypeScript
|
||||||
|
sources directly (decide based on ADR-0002 choice and first consumer).
|
||||||
|
- Versioning policy: semver from `0.1.0`; breaking contract changes bump minor
|
||||||
|
or major per `SharedContracts.md` § change process.
|
||||||
|
- CI publish step stub (document only — no registry credentials in repo).
|
||||||
|
|
||||||
|
**Acceptance:** a "Publishing checklist" section in README; package is
|
||||||
|
structurally publishable even if no publish has occurred yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relationship to other workplans
|
||||||
|
|
||||||
|
| Workplan | Role |
|
||||||
|
|----------|------|
|
||||||
|
| `CENG-WP-0001` | Extraction — **done** |
|
||||||
|
| `CE-WP-0009` | Umbrella consumes engine via `link:` — **done** |
|
||||||
|
| `CENG-WP-0002` | Distribution strategy and publish prep — this file |
|
||||||
|
| Future `EANCH-WP-0002`, etc. | Sister-repo extractions depend on engine install story from T02 |
|
||||||
Reference in New Issue
Block a user