diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 88102ef..0000000 --- a/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -.git -.github -.rainbond -__pycache__ -*.pyc -tests diff --git a/.gitignore b/.gitignore index 481d614..0fd933b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,15 @@ __pycache__/ *.py[codz] *$py.class +# Helm: the library is LOCAL and PERSONAL — never commit artifacts. This repo is +# public; artifacts (which may be internal/confidential) stay on the local disk +# only. catalog.json is derived (rebuild with `helm index`). +library/* +!library/.gitkeep + +# Personal report outputs dropped at the repo root — never commit to this public repo +/*.html + # Helm-local design and generated artifact working files assets/drafts/ /*-current-state-*.html diff --git a/AGENTS.md b/AGENTS.md index b5f9fce..63ce48d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,32 @@ -# Helm artifact handoff - -Start by reading [`docs/CODEX-MEMORY.md`](docs/CODEX-MEMORY.md). If the current agent platform offers repository-scoped persistent memory, inject its **Memory payload** at that scope; otherwise reread the checked-in file at the start of each task. Do not put tokens, credentials, private artifacts, or machine addresses into project memory. - -For any AI-generated HTML that is meant to be retained, read [`AI-GUIDE.md`](AI-GUIDE.md), [`docs/REPORT-DESIGN-STANDARD.md`](docs/REPORT-DESIGN-STANDARD.md), and the full [`docs/HTML-DOCUMENT-SPEC.md`](docs/HTML-DOCUMENT-SPEC.md) before writing it. Produce one complete, self-contained `HDOC/1.0` file; do not submit Markdown fragments, partial drafts, or executable HTML. The report must be answer-first: make the route from question or decision to evidence, interpretation, and next action or boundary visible rather than producing a landing-page-style document. - -For a substantial report, do not imitate the visual style from prose alone. Run `scripts/helm-report list`, select the closest Profile, scaffold the report with `scripts/helm-report new`, and bind each important relationship to a registered component. Replace all specimen content, remove every `placeholder` marker, and make `scripts/helm-report check output.html` pass before submission. The rendered vocabulary is in [`authoring/component-gallery.html`](authoring/component-gallery.html). - -For a local clone, run this once before the first handoff: - -```bash -scripts/helm-agent-bootstrap --agent-name "your-agent-name" -``` - -Then submit the final file exactly once after local validation: - -```bash -scripts/helm-submit output.html --source "your-agent-name" -``` - -Run the submission from the target Codex/project root. Helm records that workspace as the artifact's catalog project when the source manifest does not already declare `project`; if needed, pass `--project-id` and `--project-name` explicitly. The command only hands the artifact revision to the owner's inbox. It does not grant direct access to the browser library. Keep the same manifest ID when revising the same logical artifact; Helm preserves distinct bytes as immutable revisions and asks the owner before advancing the current version. Use a new ID for a different document or explicit fork. On `422`, correct the HDOC contract violation before resubmitting. +# Helm artifact handoff (for agents) + +You are producing a durable HTML report for a person to read and keep. The +target is one **self-contained `HDOC/1.1` file** in Helm's cool-grey design +system, dropped into a local folder. + +1. Read [`skill/SKILL.md`](skill/SKILL.md) for the workflow, and + [`skill/design-system.md`](skill/design-system.md) before writing any HTML. + Look at [`skill/components.html`](skill/components.html) for the rendered + component vocabulary. Do not imitate the visual style from prose alone. +2. Inventory the claims first: label each material relationship (comparison, + sequence, hierarchy, magnitude, composition, uncertainty, claim strength, + action) and its evidence state, then bind each to the smallest component. +3. Scaffold and fill: + + ```bash + bin/helm new "Decision-relevant title" --type report --source your-agent-name + # edit $HELM_LIBRARY//index.html — replace EVERY specimen, + # remove every `placeholder`, delete unused components. + bin/helm check $HELM_LIBRARY//index.html # must pass + bin/helm index + ``` + +Set `$HELM_LIBRARY` (or run `bin/helm init` once) so any agent knows the target +folder. No `bin/helm`? Copy `skill/template.html` and edit the inline +`application/helm+json` manifest by hand. + +Produce one complete, answer-first document — not Markdown, not a fragment, not a +partial draft. Keep the manifest `id` stable when revising the same logical +artifact; use a new id for a different document. Do not add a dependency on a +host app, external script, remote asset, or network service. Do not reintroduce +sharing, publishing, or a server — the library is local and personal by design. diff --git a/AI-GUIDE.md b/AI-GUIDE.md index b1ede37..0a709eb 100644 --- a/AI-GUIDE.md +++ b/AI-GUIDE.md @@ -1,56 +1,41 @@ -# HTML generation guide for agents and other projects +# HTML generation guide for agents -For Codex-style agents, begin with [`docs/CODEX-MEMORY.md`](docs/CODEX-MEMORY.md). It contains the repository-scoped context to inject when the host supports project memory, plus the deterministic handoff workflow when it does not. +Everything an agent needs is in [`skill/`](skill/). This file is the short +pointer; the skill is the standard. -Before generating any HTML intended for this repository, read [`docs/HTML-DOCUMENT-SPEC.md`](docs/HTML-DOCUMENT-SPEC.md) in full and comply with `HDOC/1.0`. Then read [`docs/REPORT-DESIGN-STANDARD.md`](docs/REPORT-DESIGN-STANDARD.md). Use [`scripts/helm-report`](scripts/helm-report) to select a report Profile and compose registered visual components instead of recreating a report from prose instructions. +- [`skill/SKILL.md`](skill/SKILL.md) — the authoring workflow, step by step. +- [`skill/design-system.md`](skill/design-system.md) — the cool-grey engineering + identity: tokens, typography, three content widths, and the component grammar. +- [`skill/template.html`](skill/template.html) — the standalone scaffold with the + full embedded CSS and an inline `HDOC/1.1` manifest. +- [`skill/components.html`](skill/components.html) — rendered specimens of every + component. -The output must be a complete standalone `.html` document — not Markdown, a component, or a code fragment. Include the Helm manifest, the duplicate `helm:*` metadata, semantic `
`, embedded essential styles, and factual provenance. Put the current Codex/project workspace in the optional `manifest.project` object (`id` + recognizable `name`) so Helm can group the artifact at project level before type and tags. Use the templates in [`templates/`](templates/) as working examples. +## The contract in one screen -Design direction: calm, evidence-forward report. The artifact should optimize for reading and later recovery, not landing-page conversion. Lead with the reader's question and the short answer; make the path through evidence, interpretation, and the next action or boundary explicit. Before writing HTML, inventory the material claims and label each important relationship: comparison, sequence, hierarchy, magnitude, composition, uncertainty, claim strength, comparable cases, or action/checkpoint. Bind every relationship to the smallest registered component and declare that Claim–component mapping in `manifest.presentation.claims`. Use inline SVG or semantic HTML/CSS, not a runtime chart library or decorative image; each component needs a named claim, evidence state, source/method, scope boundary, and text/table fallback. Keep sources, data dates, assumptions, and confidence near the claims they qualify. Do not add dependencies on a host application, external scripts, authentication, or untrusted executable code. +The output is a complete standalone `.html` document — not Markdown, a component, +or a fragment. It must contain: -## Agent authoring workflow +- an inline manifest `` with at + least `id`, `title`, `type`; +- a semantic `
`; +- embedded essential CSS — no external script, stylesheet, font, or remote image + (embed images as `data:` URIs); +- provenance for factual claims kept next to the claim. -List the available Profiles and components: +Design direction: a calm, evidence-forward report, optimized for reading and +later recovery — not landing-page conversion. Lead with the reader's question and +the short answer; make the path through evidence, interpretation, and the next +action or boundary explicit. Inventory the material claims, label each important +relationship, and bind it to the smallest registered component. Every component +needs a named claim, an evidence state, a source/method, a scope boundary, and a +text/table fallback. -```bash -scripts/helm-report list -``` - -Start from the closest Profile. Profiles supply a useful default composition; `--components` may replace it when the claim inventory requires another grammar. Use `deep-dive` when a long, chaptered report benefits from presentation-like pacing: it adds a sticky chapter index and CSS-only scroll entry while keeping every component readable without animation. - -```bash -scripts/helm-report new \ - --profile benchmark \ - --title "Decision-relevant title" \ - --id stable-artifact-id \ - --project-id project-workspace \ - --project-name "Project workspace" \ - --output output.html -``` - -Replace every outlined specimen with real content, update its `data-evidence-state`, `data-source`, and `data-scope`, and remove the `placeholder` class. Do not preserve a component whose underlying relationship is absent. Do not leave a material relationship prose-only merely because the Profile omitted it. Then run: +Validate before you finish: ```bash -scripts/helm-report check output.html +bin/helm check output.html # or $HELM_LIBRARY//index.html ``` -The check must pass before `helm-submit`. The rendered component catalog is [`authoring/component-gallery.html`](authoring/component-gallery.html). - -## Report preflight - -Before returning the file, verify that a reader can see the following without interaction: - -1. The document type, title, date/status, and decision-relevant purpose. -2. The short answer, recommendation, or current finding before long background. -3. Evidence that supports the conclusion, distinguished from interpretation. -4. The action, owner/checkpoint, caveat, or open question that follows from the evidence. -5. Provenance for factual claims: source, date, method, assumption, or confidence as appropriate. -6. Every material relationship has the smallest useful visual treatment — comparison, evidence route, flow, boundary diagram, metric, or range — or an explicit reason it does not need one. - -The authoring CLI is the default starting point for substantial artifacts. The files in `templates/` remain compact examples and compatibility starters; use `research`, `benchmark`, `architecture`, `decision`, `handoff`, or `deep-dive` Profiles when the result needs deliberate visual evidence. - -## Optional automatic handoff to Helm - -For a fresh local clone, run [`scripts/helm-agent-bootstrap`](scripts/helm-agent-bootstrap) once. Then write the final `.html` file first and submit that exact file once with [`scripts/helm-submit`](scripts/helm-submit). Run the command from the target project root so the Bridge can attach that workspace to legacy files that do not yet declare `manifest.project`; use `--project-id` and `--project-name` when the working directory is not the target project. Do not send partial drafts. A successful Bridge response means the artifact is ready in the owner's **Agent inbox** for explicit review and import; it does not mean the browser library was changed. - -Never expose the token in a generated artifact, repository, task log, or prompt. Reuse the manifest ID only for a revision of the same logical artifact; Helm appends distinct bytes as an immutable Revision and never overwrites the prior source. Use a new ID for a different document or explicit fork. On `422`, fix the reported HDOC contract issue and resubmit the same intended artifact. +Then place the file in the library and run `bin/helm index`. See +[`AGENTS.md`](AGENTS.md) for the handoff steps. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65b1431..a9c7085 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,30 +5,30 @@ Thank you for improving Helm. The project is small by design, so a focused chang ## Before you start 1. Read [`README.md`](README.md) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). -2. For retained HTML or agent workflows, read [`AGENTS.md`](AGENTS.md), [`AI-GUIDE.md`](AI-GUIDE.md), and the full [`docs/HTML-DOCUMENT-SPEC.md`](docs/HTML-DOCUMENT-SPEC.md). -3. Keep the original HTML immutable. Catalog metadata is an overlay; it is never a silent source-file rewrite. +2. For the artifact contract and agent workflow, read [`AGENTS.md`](AGENTS.md), [`AI-GUIDE.md`](AI-GUIDE.md), [`docs/HDOC-SPEC.md`](docs/HDOC-SPEC.md), and the authoring standard in [`skill/`](skill/). +3. Keep artifacts local and personal. The library is the filesystem; this repo never carries generated artifacts. ## Local verification ```bash -python3 -m unittest discover -s tests -p 'test_*.py' -python3 helm_share_server.py --host 127.0.0.1 --port 4173 +python3 -m unittest discover -s tests -p 'test_*.py' # CLI + HDOC contract +bin/helm check # validate one artifact +bin/helm serve --port 4180 # http://127.0.0.1:4180/gallery/ ``` -Open the local app and, when a change affects HTML validity or repair behavior, visit `/tests/contract-smoke.html` and `/tests/repair-smoke.html`. Both should show `passed`. - ## Pull-request expectations - Explain the user-visible outcome and the preserved invariant. - Keep unrelated formatting and generated artifacts out of the change. - Add or update a focused regression test for behavioral changes. -- Do not commit Bridge tokens, browser IndexedDB data, private artifacts, machine addresses, or generated local reports. -- Do not add remote scripts, runtime CDNs, or executable HTML to a retained artifact without a documented and reviewed reason. +- **Never commit a generated artifact.** Library files and root-level report HTML are gitignored precisely because they may contain internal or confidential content and this repo is public. +- Do not add remote scripts, runtime CDNs, external fonts, or executable HTML to an artifact — `helm check` enforces self-containment. +- Preserve the local-first, zero-dependency guarantee. No framework build, server database, or cloud dependency. ## Design bar -Helm is a calm, evidence-forward research workbench, not a generic file manager or marketing page. Visuals must clarify a real comparison, flow, boundary, or uncertainty; they must not be decoration. See [`docs/REPORT-DESIGN-STANDARD.md`](docs/REPORT-DESIGN-STANDARD.md). +Helm is a calm, evidence-forward research workbench, not a generic file manager or marketing page. Visuals must clarify a real comparison, flow, boundary, or uncertainty; they must not be decoration. See [`skill/design-system.md`](skill/design-system.md). ## Reporting a security issue -Do not include credentials or private artifacts in a public issue. For a vulnerability that could expose a local library, Bridge token, or shared document, use GitHub's private vulnerability-reporting channel when it is available; otherwise contact the repository owner privately before publishing technical details. +Do not include credentials or private artifacts in a public issue. For a vulnerability that could expose a local library or a private document, use GitHub's private vulnerability-reporting channel when it is available; otherwise contact the repository owner privately before publishing technical details. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 1f00b51..0000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM docker.1ms.run/library/python:3.12-alpine - -WORKDIR /app -COPY . . - -ENV HELM_SHARE_HOST=0.0.0.0 \ - HELM_SHARE_PORT=8080 \ - PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -EXPOSE 8080 -CMD ["python3", "helm_share_server.py"] diff --git a/Procfile b/Procfile deleted file mode 100644 index 781f7d2..0000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: python3 helm_share_server.py --host 0.0.0.0 --port $PORT diff --git a/README.md b/README.md index 141bb76..b4f818a 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,79 @@ # Helm -[![Verify](https://github.com/waple0820/Helm/actions/workflows/verify.yml/badge.svg)](https://github.com/waple0820/Helm/actions/workflows/verify.yml) -[![License: MIT](https://img.shields.io/badge/license-MIT-1f6feb.svg)](LICENSE) +

A local-first artifact library for HTML that AI agents produce — for one person to read and keep.

-

- Helm logo -

+Helm does two things well: -

A local-first artifact repository for work produced by AI agents.

+- **Ease of use** — any coding agent (Claude Code, Codex, anything) hands off a + finished report by *writing one file*. No token, no daemon, no review inbox. +- **Professionalism** — a built-in authoring skill produces self-contained + `HDOC/1.1` HTML in a distinct cool-grey engineering design system, so the + output actually looks and reads like a report. -Helm keeps finished HTML reports, briefs, dashboards, and research as durable project artifacts. It preserves the original file, makes its contract inspectable, and gives people—not agents—the final say over what enters a personal library or gets shared. +There is no sharing, publishing, cloud, or build step. The library is a folder. +A static gallery reads it back. -![Helm library showing project navigation, agent-authored reports, document inspection, and intranet sharing](assets/helm-library.jpg) +## The whole model -## Why Helm +```text +agent ──writes──▶ $HELM_LIBRARY//index.html (standalone HDOC/1.1) + │ + helm index ──▶ catalog.json + │ + gallery/ ──▶ static browse (python http, no logic) +``` -- **Originals remain evidence.** Helm indexes, exports, backs up, and shares the exact HTML bytes; it never silently rewrites the source artifact. -- **Agents have an authoring system.** `HDOC/1.0`, report Profiles, reusable visual components, and a preflight linter turn evidence into portable reports instead of prose with a borrowed skin. -- **The owner keeps control.** Agent output arrives in a reviewable inbox. Browser storage, imports, and intranet publication remain deliberate human actions. -- **Artifacts have history.** Immutable revisions, Draft / Reviewed / Published state, visual comparison, Fork lineage, and a stable Channel address keep change legible without rewriting evidence. +- **The library is the filesystem.** One folder per artifact, one `index.html` + inside. The original bytes are the record. +- **Publishing = writing a file + reindexing.** Nothing gates it. +- **Every artifact is portable.** Self-contained HTML, embedded CSS, inline + manifest, light/dark, no dependency on Helm to be readable. ## Start in one minute -The machine running Helm-aware agents must have outbound Internet access so it can clone or update the repository and let agents reach the external sources used in their reports. The Helm library itself has no third-party runtime dependency and can continue serving retained local artifacts after the repository has been cloned. - ```bash git clone https://github.com/waple0820/Helm.git -cd html-displayer - -# Browser library + optional read-only intranet sharing service. -python3 helm_share_server.py --host 127.0.0.1 --port 4173 +cd Helm +bin/helm init # set the library path + install the skill +bin/helm serve # http://127.0.0.1:4173/gallery/ ``` -Open [http://127.0.0.1:4173](http://127.0.0.1:4173). The library is stored in that browser's IndexedDB; no account, cloud sync, build step, or external font is required. - -## How an artifact moves - -```text -Agent / project - └─ complete HDOC/1.0 HTML - └─ Helm Bridge inbox ── owner review ──> browser library (IndexedDB) - │ │ - └─ exact original bytes ├─ HARC backup / explicit folder sync - └─ reviewed Channel + immutable Revisions -``` - -The important boundary is deliberate: submitting to the Bridge is a handoff, not permission to modify the browser library. - ## Hand an artifact to Helm -For the machine that owns the library, bootstrap the loopback Bridge once: +From any agent, once the analysis is done: ```bash -scripts/helm-agent-bootstrap --agent-name codex +bin/helm new "Decision-relevant title" --type decision --source claude-code +# → edit $HELM_LIBRARY//index.html: replace every specimen +bin/helm check $HELM_LIBRARY//index.html # HDOC contract must pass +bin/helm index # rebuild catalog.json ``` -From the project that produced a final report, brief, reference note, or dashboard: +The authoring standard the agent follows lives in [`skill/`](skill/): +[`SKILL.md`](skill/SKILL.md) (workflow), [`design-system.md`](skill/design-system.md) +(the cool-grey identity + component grammar), and +[`components.html`](skill/components.html) (rendered vocabulary). -```bash -/path/to/html-displayer/scripts/helm-submit output.html --source "your-agent-name" -``` +## CLI -The final artifact appears in **Agent inbox** for review and explicit import. Read [`AGENTS.md`](AGENTS.md) first when authoring with an agent; [`AI-GUIDE.md`](AI-GUIDE.md) contains the concise generation entry point. - -For substantial reports, scaffold the visual grammar before writing the final HTML: - -```bash -scripts/helm-report list -scripts/helm-report new --profile benchmark --title "Report title" --id report-id --output output.html -# Replace the outlined specimens with real evidence and remove placeholder markers. -scripts/helm-report check output.html -``` - -Open the rendered [component studio](authoring/component-gallery.html) to inspect the registered KPI, evidence, comparison, magnitude, uncertainty, flow, hierarchy, trace, case, and roadmap components. - -## Repository guide - -| Path | Purpose | +| Command | Does | | --- | --- | -| [`index.html`](index.html), [`app.js`](app.js), [`channel-store.js`](channel-store.js), [`styles.css`](styles.css) | Static browser library, Artifact / Revision store, safe reader, and interface. | -| [`validator.js`](validator.js) | Browser-side `HDOC/1.0` inspection and portability warnings. | -| [`helm_bridge.py`](helm_bridge.py) | Loopback-only agent ingress and immutable inbox records. | -| [`helm_share_server.py`](helm_share_server.py) | Static app plus owner-only publication of immutable read-only share links. | -| [`docs/`](docs/) | Format contract, architecture, report design, storage, Bridge, and sharing references. | -| [`templates/`](templates/) | Compact standalone compatibility starters. | -| [`authoring/component-gallery.html`](authoring/component-gallery.html) | Rendered catalog of the reusable visual grammar. | -| [`scripts/helm-report`](scripts/helm-report) | Agent report Profiles, component scaffolding, Gallery generation, and visual-contract checks. | -| [`scripts/`](scripts/) | Clone bootstrap, report authoring, and final agent handoff commands. | -| [`tests/`](tests/) | Contract, Bridge, share-store, and template regression checks. | +| `helm init` | set the library path, install the skill into detected agents | +| `helm new ` | scaffold a standalone HDOC artifact into the library | +| `helm index` | rescan the library, rebuild `catalog.json` | +| `helm serve` | static HTTP for the gallery + library | +| `helm check <file>` | validate the HDOC/1.1 contract | -Read [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the data boundaries and [`docs/HTML-DOCUMENT-SPEC.md`](docs/HTML-DOCUMENT-SPEC.md) for the interoperable artifact contract. +Python standard library only. No dependencies. -## Develop and verify +## Layout -Helm has no package-install step. Run the same checks used by CI: - -```bash -python3 -m unittest discover -s tests -p 'test_*.py' -python3 -m http.server 4183 --bind 127.0.0.1 +```text +bin/helm the CLI (one file) +skill/ authoring skill: SKILL.md, design-system.md, template.html, components.html +gallery/ static browser (reads library/catalog.json) +library/ the library — one folder per artifact + catalog.json +docs/ ARCHITECTURE.md, HDOC-SPEC.md ``` -Open the browser smoke pages at `http://127.0.0.1:4183/tests/contract-smoke.html`, `channel-store-smoke.html`, and `repair-smoke.html`. The production share server deliberately blocks `/tests`; use this isolated static development port for browser checks. - -For a reviewed remote intranet installation, use [`scripts/deploy-remote`](scripts/deploy-remote). It deploys only the committed tree, tests it before activation, keeps runtime shares outside the release, and rolls back a failed health check. See [`docs/INTRANET-SHARING.md`](docs/INTRANET-SHARING.md). - -## Scope and boundaries - -Helm is intentionally a single-person, local-first archive. It does not provide cloud sync, multi-user collaboration, a WYSIWYG editor, background filesystem watching, RAG chat, OCR, or a mobile app. Those belong only after the original-file, recovery, and handoff contracts prove durable. - -## Documentation - -- [`AGENTS.md`](AGENTS.md) — instructions for coding agents entering the repository. -- [`AI-GUIDE.md`](AI-GUIDE.md) — concise HTML generation and handoff guide. -- [`docs/HTML-DOCUMENT-SPEC.md`](docs/HTML-DOCUMENT-SPEC.md) — `HDOC/1.0` interchange contract. -- [`docs/REPORT-DESIGN-STANDARD.md`](docs/REPORT-DESIGN-STANDARD.md) — answer-first report and visual-evidence standard. -- [`docs/AGENT-BRIDGE.md`](docs/AGENT-BRIDGE.md) — Bridge API, security model, and conflict semantics. -- [`docs/LOCAL-ARCHIVE-LAYOUT.md`](docs/LOCAL-ARCHIVE-LAYOUT.md) — portable `HARC/1.0` archive format. -- [`docs/INTRANET-SHARING.md`](docs/INTRANET-SHARING.md) — owner-controlled immutable sharing model. -- [`docs/CHANNELS.md`](docs/CHANNELS.md) — Artifact, Revision, publication, comparison, and Fork semantics. - -## Contributing and security - -Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md), preserve the immutable-original and no-secret boundaries, and run the verification command before opening a pull request. Helm is released under the [MIT License](LICENSE). +MIT. Local-first, personal, no cloud. diff --git a/app.js b/app.js deleted file mode 100644 index 76d316f..0000000 --- a/app.js +++ /dev/null @@ -1,1570 +0,0 @@ -const SCHEMA = 'HDOC/1.0'; -const DOCUMENT_TYPES = new Set(['report', 'brief', 'reference', 'dashboard', 'note']); -const PROJECT_ID_PATTERN = /^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$/; -const UNASSIGNED_PROJECT = Object.freeze({ id: 'unassigned', name: 'Needs project' }); -const MAX_IMPORT_BYTES = 5 * 1024 * 1024; -const MAX_SEARCH_TEXT = 250000; -const AGENT_BRIDGE_URL = 'http://127.0.0.1:4175'; -const CHANNEL_API_URL = '/api/channels'; -const PUBLIC_CHANNEL_API_URL = '/api/public/channels'; -const BUILTIN_CONTENT_VERSION = 3; -const MANAGED_WELCOME_REVISION_IDS = new Set([ - // Official v1 Welcome shipped by Helm. User-edited copies have a different - // content-addressed Revision ID and are never replaced by this migration. - 'sha256:b64dad9e6fe8ed89a2738621bbecf5c2c1686460b6b49a2a789c1abce51303af' -]); -const channelRepository = globalThis.HelmChannelStore?.defaultRepository; - -const templates = [ - { id: 'research-report', title: 'Research dossier', type: 'report', tags: ['research', 'evidence'], summary: 'Question → answer → evidence → recommendation.', accent: '#e7e6df' }, - { id: 'decision-brief', title: 'Decision brief', type: 'brief', tags: ['decision', 'active'], summary: 'Decision → comparison → recommendation → checkpoint.', accent: '#d6e5f0' }, - { id: 'reference-note', title: 'Reference note', type: 'reference', tags: ['reference', 'notes'], summary: 'Pattern → application → caveats → primary sources.', accent: '#f0ddcf' } -]; - -const seedDocuments = [ - { id: 'welcome-to-helm', title: 'Welcome to Helm', type: 'reference', tags: ['start-here', 'library'], summary: 'Your local-first HTML library. Import an artifact or begin with a template.', source: 'Helm', project: { id: 'helm', name: 'Helm' }, createdAt: '2026-07-13T09:00:00.000Z', updatedAt: '2026-07-13T09:00:00.000Z' }, - { id: 'document-contract', title: 'The Helm document contract', type: 'reference', tags: ['standard', 'HDOC/1.0'], summary: 'The small, portable agreement that lets HTML artifacts carry their own context.', source: 'Helm', project: { id: 'helm', name: 'Helm' }, createdAt: '2026-07-13T09:02:00.000Z', updatedAt: '2026-07-13T09:02:00.000Z' } -]; - -let documents = []; -let selectedId = null; -let activeFilter = 'all'; -let activeProject = 'all'; -let toastTimer; -let templateToCreate = templates[0]; -let editingId = null; -let pendingDuplicateImport = null; -let archiveFolderHandle = null; -let folderReplaceArmed = false; -let agentInboxDocuments = []; -let selectedAgentInboxIds = new Set(); -let appearanceMode = 'system'; -let readerArtifactId = null; -let readerLoadToken = 0; -let readerSlowTimer = null; -const lineageSources = new Map(); - -const APPEARANCE_MODES = new Set(['light', 'dark', 'system']); - -const $ = (selector) => document.querySelector(selector); -const $$ = (selector) => [...document.querySelectorAll(selector)]; -const esc = (value = '') => String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); -const safeText = (value, fallback = '') => typeof value === 'string' ? value.trim() : fallback; -const slug = (value) => safeText(value).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'artifact'; -const dateLabel = (value) => { - const date = new Date(value); - return Number.isNaN(date.valueOf()) ? 'Unknown date' : new Intl.DateTimeFormat('en', { month: 'short', day: '2-digit', year: 'numeric' }).format(date); -}; -const byteLabel = (value = 0) => value < 1024 ? `${value || 0} B` : `${(value / 1024).toFixed(1)} KB`; -const isTimestamp = (value) => typeof value === 'string' && !Number.isNaN(Date.parse(value)); -const normaliseTimestamp = (value, fallback) => isTimestamp(value) ? new Date(value).toISOString() : fallback; -const normaliseTags = (value) => (Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : []).filter((tag) => typeof tag === 'string').map((tag) => tag.trim()).filter(Boolean).slice(0, 8); - -function normaliseProject(value, fallback = UNASSIGNED_PROJECT) { - const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; - const name = safeText(raw.name || raw.label || (typeof value === 'string' ? value : '')).slice(0, 100); - const candidateId = safeText(raw.id, name ? slug(name) : ''); - if (!name && !candidateId) return { ...fallback }; - return { - id: PROJECT_ID_PATTERN.test(candidateId) ? candidateId : slug(candidateId || name), - name: name || candidateId - }; -} - -function projectFor(artifact) { - return normaliseProject(artifact?.project); -} - -function inferredLegacyProject(input, manifest) { - const identity = safeText(input?.sourceDocumentId, safeText(manifest?.id, safeText(input?.id))).toLowerCase(); - if (identity.startsWith('texas-gto-lab-')) return { id: 'texas-gto-lab', name: 'Texas GTO Lab' }; - if (identity === 'codex-memory-activation-20260713' || identity === 'codex-helm-workflow-reference-2026-07-13') return { id: 'helm', name: 'Helm' }; - if (identity.startsWith('gpt55-lexbench-')) return { id: 'lexbrowserenv', name: 'LexBrowserEnv' }; - if (input?.id === 'welcome-to-helm' || input?.id === 'document-contract' || safeText(input?.source) === 'Helm') return { id: 'helm', name: 'Helm' }; - return UNASSIGNED_PROJECT; -} - -function knownProjects() { - const projects = new Map(); - documents.forEach((artifact) => { - const project = projectFor(artifact); - projects.set(project.id, project); - }); - return [...projects.values()].sort((left, right) => { - if (left.id === UNASSIGNED_PROJECT.id) return 1; - if (right.id === UNASSIGNED_PROJECT.id) return -1; - return left.name.localeCompare(right.name); - }); -} - -async function getAll(options = {}) { - if (!channelRepository) throw new Error('Helm Channels repository is unavailable.'); - const records = await channelRepository.listDocuments(options); - return Promise.all(records.filter(Boolean).map(async (record) => ({ ...record, revisions: await channelRepository.listRevisions(record.id) }))); -} - -async function fetchPublicChannelDocuments(localDocuments = []) { - const response = await fetch(PUBLIC_CHANNEL_API_URL, { headers: { Accept: 'application/json' } }); - if (!response.ok) return []; - const payload = await response.json().catch(() => null); - if (!Array.isArray(payload?.artifacts)) return []; - const localIds = new Set(localDocuments.map((artifact) => artifact.id)); - const publicRecords = await Promise.all(payload.artifacts.filter((record) => record && !localIds.has(record.id)).map(async (record) => { - try { - const sourceResponse = await fetch(record.revision_path, { headers: { Accept: 'text/html' } }); - if (!sourceResponse.ok) return null; - const html = await sourceResponse.text(); - const revisionId = `sha256:${record.sha256}`; - const publishedAt = normaliseTimestamp(record.published_at, new Date().toISOString()); - const updatedAt = normaliseTimestamp(record.updated_at, publishedAt); - const share = { - artifactId: record.id, - stableUrl: record.stable_url, - revisionUrl: record.revision_url, - sha256: record.sha256, - publishedAt - }; - return enrichArtifact({ - id: record.id, - title: record.title, - type: record.type, - summary: record.summary, - tags: record.tags, - project: record.project, - source: 'Published Helm Channel', - updatedAt, - html, - remotePublication: true, - status: 'published', - currentRevisionId: revisionId, - publishedRevisionId: revisionId, - catalogUpdatedAt: updatedAt, - revisions: [{ - id: revisionId, - artifactId: record.id, - html, - contentHash: record.sha256, - createdAt: publishedAt, - authoredAt: publishedAt, - author: 'Published Helm Channel', - share - }] - }, { preserveId: true, takenIds: new Set(localIds) }); - } catch (error) { - console.warn(`Published Channel ${record.id || 'unknown'} could not be loaded.`, error); - return null; - } - })); - return publicRecords.filter(Boolean); -} - -async function loadLineageSource(id, seen = new Set()) { - if (!id || seen.has(id) || documents.some((artifact) => artifact.id === id) || lineageSources.has(id)) return; - seen.add(id); - const document = await channelRepository.getDocument(id); - if (!document) return; - const source = { ...document, revisions: await channelRepository.listRevisions(id) }; - lineageSources.set(id, source); - if (source.forkedFrom) await loadLineageSource(source.forkedFrom.artifactId, seen); -} - -async function loadRequiredLineageSources() { - for (const artifact of documents) { - if (artifact.forkedFrom) await loadLineageSource(artifact.forkedFrom.artifactId); - } -} - -async function saveDocument(artifact, options = {}) { - const existing = await channelRepository.getArtifact(artifact.id); - const result = await channelRepository.createOrRevise(artifact, { - artifactId: artifact.id, - expectedCurrentRevisionId: existing?.currentRevisionId, - updateCatalog: Boolean(existing) && options.updateCatalog !== false - }); - return { ...result.document, revisions: await channelRepository.listRevisions(artifact.id) }; -} - -async function removeDocument(id, { hard = true } = {}) { - return channelRepository.deleteArtifact(id, { hard }); -} - -async function getSetting(key) { - return channelRepository.getSetting(key); -} - -async function setSetting(key, value) { - return channelRepository.setSetting(key, value); -} - -function setAppearance(mode, persist = false) { - appearanceMode = APPEARANCE_MODES.has(mode) ? mode : 'system'; - const systemPrefersDark = globalThis.matchMedia?.('(prefers-color-scheme: dark)')?.matches; - document.documentElement.dataset.theme = appearanceMode === 'system' ? (systemPrefersDark ? 'dark' : 'light') : appearanceMode; - document.documentElement.dataset.appearance = appearanceMode; - $$('[data-theme-mode]').forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.themeMode === appearanceMode))); - if (persist) void setSetting('appearanceMode', appearanceMode); -} - -const appearanceMedia = globalThis.matchMedia?.('(prefers-color-scheme: dark)'); -appearanceMedia?.addEventListener?.('change', () => { if (appearanceMode === 'system') setAppearance('system'); }); - -function manifestFor(artifact) { - const manifest = { - schema_version: SCHEMA, - id: artifact.id, - title: artifact.title, - type: artifact.type, - tags: artifact.tags, - summary: artifact.summary, - created_at: artifact.createdAt, - updated_at: artifact.updatedAt, - provenance: { author: 'Personal archive', sources: [] } - }; - const project = projectFor(artifact); - if (project.id !== UNASSIGNED_PROJECT.id) manifest.project = project; - return manifest; -} - -function visualModule(artifact) { - const type = artifact.type; - if (artifact.id === 'welcome-to-helm') { - const nodes = [['01', 'Guide', 'read the contract'], ['02', 'Create', 'final HDOC/1.0'], ['03', 'Retain', 'review in Helm']]; - const node = ([index, label, detail], x) => `<g transform="translate(${x} 20)"><rect width="198" height="110" rx="4"></rect><text class="visual-number" x="16" y="25">${index}</text><text class="visual-label" x="16" y="58">${label}</text><text class="visual-detail" x="16" y="82">${detail}</text></g>`; - return `<figure class="visual-figure" aria-labelledby="visual-title-welcome"><div class="visual-heading"><div><p class="visual-kicker">Artifact route</p><h2 id="visual-title-welcome">From an agent task to a durable document</h2></div><p>HELM / ONBOARDING</p></div><svg viewBox="0 0 700 150" role="img" aria-label="Read the Helm guide, create a final HDOC artifact, then retain and review it in Helm"><defs><marker id="arrow-welcome" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0,0 L0,6 L6,3 z"></path></marker></defs><path class="visual-link" d="M204 75 H244" marker-end="url(#arrow-welcome)"></path><path class="visual-link" d="M438 75 H478" marker-end="url(#arrow-welcome)"></path>${nodes.map((item, index) => node(item, index * 250)).join('')}</svg><figcaption>Agents follow the repository contract; owners inspect the retained Revision, mark it reviewed, and explicitly advance its Channel when it is ready to share.</figcaption><ol class="visual-fallback"><li><b>Guide:</b> read the repository contract.</li><li><b>Create:</b> deliver one final HDOC/1.0 file.</li><li><b>Retain:</b> inspect, review, and publish in Helm.</li></ol></figure>`; - } - const copy = type === 'brief' - ? { kind: 'Decision map', title: 'Make the chosen option and the reason for it visible', nodes: [['A', 'Option', 'upside'], ['B', 'Recommended', 'evidence'], ['C', 'Alternative', 'trade-off']], note: 'Replace the option names and labels with the actual criteria, evidence date, and reversal condition.' } - : type === 'reference' - ? { kind: 'Pattern route', title: 'Show when a reusable pattern turns an input into an outcome', nodes: [['01', 'Trigger', 'precondition'], ['02', 'Pattern', 'minimum action'], ['03', 'Outcome', 'observable result']], note: 'Replace this route with the real precondition, smallest dependable action, and observable result.' } - : type === 'dashboard' - ? { kind: 'State lens', title: 'Expose the measure, change, and boundary before interpretation', nodes: [['01', 'Measure', 'unit / range'], ['02', 'Change', 'period'], ['03', 'Boundary', 'method']], note: 'Replace these labels with measured values, period, and the condition that limits interpretation.' } - : type === 'note' - ? { kind: 'Handoff route', title: 'Make delivery, proof, and the remaining owner visible', nodes: [['01', 'Delivered', 'artifact'], ['02', 'Verified', 'evidence'], ['03', 'Review', 'owner']], note: 'Replace the stages with the delivered result, the proof, and the only remaining human decision.' } - : { kind: 'Evidence route', title: 'Show how dated evidence earns the current answer', nodes: [['01', 'Observation', 'dated source'], ['02', 'Interpretation', 'confidence'], ['03', 'Action', 'boundary']], note: 'Replace every node with the actual observation, interpretation, and action; keep source, date, and confidence beside the claim.' }; - const [first, second, third] = copy.nodes; - const node = ([index, label, detail], x) => `<g transform="translate(${x} 20)"><rect width="198" height="110" rx="4"></rect><text class="visual-number" x="16" y="25">${index}</text><text class="visual-label" x="16" y="58">${label}</text><text class="visual-detail" x="16" y="82">${detail}</text></g>`; - return `<figure class="visual-figure" aria-labelledby="visual-title-${esc(type)}"><div class="visual-heading"><div><p class="visual-kicker">${copy.kind}</p><h2 id="visual-title-${esc(type)}">${copy.title}</h2></div><p>Template visual · replace before handoff</p></div><svg viewBox="0 0 700 150" role="img" aria-label="${esc(copy.title)}"><defs><marker id="arrow-${esc(type)}" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0,0 L0,6 L6,3 z"></path></marker></defs><path class="visual-link" d="M204 75 H244" marker-end="url(#arrow-${esc(type)})"></path><path class="visual-link" d="M438 75 H478" marker-end="url(#arrow-${esc(type)})"></path>${node(first, 0)}${node(second, 250)}${node(third, 500)}</svg><figcaption>${copy.note}</figcaption><ol class="visual-fallback"><li><b>${first[1]}:</b> ${first[2]}.</li><li><b>${second[1]}:</b> ${second[2]}.</li><li><b>${third[1]}:</b> ${third[2]}.</li></ol></figure>`; -} - -function articleHtml(artifact, sections = []) { - const manifest = JSON.stringify(manifestFor(artifact), null, 2).replace(/<\//g, '<\\/'); - const readerPath = artifact.type === 'report' ? 'Question → answer → evidence → action' : artifact.type === 'brief' ? 'Decision → options → recommendation → checkpoint' : artifact.type === 'reference' ? 'Pattern → application → caveats → sources' : artifact.type === 'dashboard' ? 'Current state → measures → change → data notes' : 'Finding → evidence → open question → next step'; - const body = sections.map(([heading, text], index) => { - const role = /evidence|options|comparison|sources|method/i.test(heading) ? 'evidence' : /recommendation|action|decision|next|boundary|notes/i.test(heading) ? 'action' : 'context'; - return `<section class="report-section ${role}"><div class="section-index">${String(index + 1).padStart(2, '0')}</div><div><h2>${esc(heading)}</h2><p>${esc(text)}</p></div></section>`; - }).join(''); - const styles = `:root{color-scheme:light;--paper:#f7f6f1;--ink:#1b232c;--muted:#65717a;--line:#d7dad5;--panel:#ecefe9;--accent:#c9543b}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{width:min(980px,calc(100% - 48px));margin:0 auto;padding:34px 0 96px}.topline{display:flex;justify-content:space-between;gap:20px;padding-bottom:18px;border-bottom:1px solid var(--line);color:var(--muted);font:11px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase}.hero{padding:64px 0 38px;border-bottom:1px solid var(--line)}.eyebrow,.section-index{margin:0 0 13px;color:var(--muted);font:11px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.1em;text-transform:uppercase}.eyebrow{color:var(--accent)}h1,h2{font-family:Georgia,"Times New Roman",serif;font-weight:500;letter-spacing:-.04em}h1{max-width:760px;margin:0;font-size:clamp(42px,7vw,72px);line-height:.98}h2{margin:0;font-size:29px;line-height:1.1}.summary{max-width:700px;margin:22px 0 0;color:#46535d;font-size:20px;line-height:1.55}.meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:23px}.meta span{padding:5px 7px;border:1px solid var(--line);color:var(--muted);font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.visual-figure{margin:0;padding:30px 0;border-bottom:1px solid var(--line)}.visual-heading{display:flex;justify-content:space-between;gap:28px;align-items:start;margin-bottom:18px}.visual-heading h2{max-width:580px;font-size:24px}.visual-heading>p,.visual-kicker{margin:0;color:var(--muted);font:10px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase}.visual-kicker{margin-bottom:7px;color:var(--accent)}.visual-figure svg{display:block;width:100%;height:auto;border:1px solid var(--line);background:#fbfbf8}.visual-figure rect{fill:var(--paper);stroke:#9eaaa8}.visual-figure .visual-link{fill:none;stroke:var(--accent);stroke-width:2}.visual-figure marker path{fill:var(--accent)}.visual-number{fill:var(--accent);font:11px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em}.visual-label{fill:var(--ink);font:600 17px ui-sans-serif,system-ui,sans-serif}.visual-detail{fill:var(--muted);font:12px ui-monospace,SFMono-Regular,Menlo,monospace}.visual-figure figcaption{max-width:700px;margin:12px 0 0;color:#536168;font-size:13px;line-height:1.55}.visual-fallback{display:none}.reader-path{display:grid;grid-template-columns:160px minmax(0,1fr);gap:28px;margin:30px 0 0;padding:22px 0;border-bottom:1px solid var(--line)}.reader-path p{margin:0;font-family:Georgia,"Times New Roman",serif;font-size:22px;line-height:1.32;letter-spacing:-.02em}.reader-path .section-index{color:var(--accent)}.report-section{display:grid;grid-template-columns:160px minmax(0,1fr);gap:28px;padding:46px 0;border-bottom:1px solid var(--line)}.report-section>div:last-child{max-width:700px}.report-section p{margin:15px 0 0;color:#46535d;font-size:16px;line-height:1.72}.report-section.evidence{background:linear-gradient(90deg,transparent 0,transparent 160px,var(--panel) 160px,var(--panel) 100%);padding-left:18px;padding-right:24px}.report-section.action>div:last-child{padding-left:20px;border-left:3px solid var(--accent)}.source-note{margin:34px 0 0;color:var(--muted);font-size:13px;line-height:1.6}@media(max-width:700px){main{width:min(100% - 32px,980px)}.topline,.visual-heading,.reader-path,.report-section{display:block}.topline span:last-child{display:none}.visual-heading>p{margin-top:10px}.visual-figure svg{display:none}.visual-fallback{display:grid;gap:7px;margin:14px 0 0;padding-left:20px;color:#46535d;font-size:14px;line-height:1.55}.reader-path .section-index,.report-section .section-index{margin-bottom:12px}.report-section.evidence{margin-left:-16px;margin-right:-16px;padding-left:16px;background:var(--panel)}}`; - const sourceNote = artifact.id === 'welcome-to-helm' - ? 'Helm keeps original HTML immutable by Revision. Catalog metadata may evolve independently; publication always remains an explicit owner action.' - : 'Before handoff, replace the template visual with actual evidence and add sources, dates, assumptions, and confidence wherever they qualify a factual claim.'; - return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="helm:title" content="${esc(artifact.title)}"><meta name="helm:type" content="${esc(artifact.type)}"><meta name="helm:summary" content="${esc(artifact.summary)}"><meta name="helm:tags" content="${esc(artifact.tags.join(', '))}"><title>${esc(artifact.title)}
${esc(artifact.type)} · HDOC/1.0Updated ${esc(artifact.updatedAt.slice(0, 10))}

Evidence original · ${esc(artifact.type)}

${esc(artifact.title)}

${esc(artifact.summary || 'A personal HTML artifact.')}

${artifact.tags.map((tag) => `${esc(tag)}`).join('')}
${visualModule(artifact)}${body}

${esc(sourceNote)}

`; -} - -function seedHtml(artifact) { - const sections = artifact.id === 'welcome-to-helm' - ? [['What belongs here', 'Retain finished HTML outputs that should remain findable and reviewable: reports, project briefs, notes, dashboards, and research. Helm preserves the exact source as an immutable Revision instead of flattening it into app-only content.'], ['Agent handoff', 'Give an agent the repository guide, keep the same manifest ID when revising one logical artifact, and submit the final HDOC/1.0 file once. The owner accepts the incoming Revision before it becomes current.'], ['Review and publish', 'Open the artifact in the safe reader, inspect its contract health, mark the current Revision reviewed, then publish only when its stable Channel should advance. Every published Revision retains a separate immutable address.']] - : [['Why a contract', 'HTML is a superb final format for AI-assisted work, but a loose file often loses its purpose and provenance. A tiny manifest makes it searchable, auditable and portable.'], ['Use it elsewhere', 'Give another project the Helm document contract before asking it to generate HTML. The result can be imported here without losing its record.']]; - return articleHtml(artifact, sections); -} - -function inspectHtml(html) { - try { - if (globalThis.HelmValidator?.validate) return globalThis.HelmValidator.validate(html); - } catch (error) { - console.warn('Helm contract validation failed.', error); - } - const parsed = new DOMParser().parseFromString(html, 'text/html'); - let manifest = null; - try { manifest = JSON.parse(parsed.querySelector('script[data-helm-manifest]')?.textContent || 'null'); } catch { /* fallback remains readable */ } - const content = parsed.querySelector('main[data-document-root], main, article, body'); - return { valid: false, score: 0, manifest, extractedText: safeText(content?.textContent).replace(/\s+/g, ' '), issues: [{ severity: 'warning', code: 'validator-unavailable', message: 'Contract inspection was unavailable in this browser.' }] }; -} - -function derivedRevisionData(html, identity = {}) { - const inspection = inspectHtml(html); - const validatorVersion = Number(globalThis.HelmValidator?.VALIDATOR_VERSION); - const issues = Array.isArray(inspection.issues) ? [...inspection.issues] : []; - const sourceDocumentId = safeText(identity.sourceDocumentId, safeText(inspection.manifest?.id)); - if (identity.identityState === 'catalog-copy' && sourceDocumentId && !issues.some((issue) => issue.code === 'catalog-copy-identity')) { - issues.unshift({ severity: 'warning', code: 'catalog-copy-identity', message: `Source manifest ID “${sourceDocumentId}” already exists here. This is an explicit catalog copy with library ID “${safeText(identity.id)}”; its original HTML was not renamed.` }); - } - return { - contentText: safeText(inspection.extractedText).slice(0, MAX_SEARCH_TEXT), - validation: { - valid: Boolean(inspection.valid), - hasManifest: Boolean(inspection.manifest), - score: Number.isFinite(inspection.score) ? inspection.score : 0, - issues - }, - ...(Number.isInteger(validatorVersion) && validatorVersion > 0 ? { derivedVersion: validatorVersion } : {}) - }; -} - -function isManagedWelcome(artifact) { - return artifact?.id === 'welcome-to-helm' && MANAGED_WELCOME_REVISION_IDS.has(artifact.currentRevisionId || `sha256:${artifact.contentHash}`); -} - -async function refreshStoredValidation(records) { - const validatorVersion = Number(globalThis.HelmValidator?.VALIDATOR_VERSION); - if (typeof globalThis.HelmValidator?.validate !== 'function' || !Number.isInteger(validatorVersion) || validatorVersion < 1) { - console.error('Helm validator is unavailable; stored Revision health was left unchanged.'); - return getAll(); - } - for (const artifact of records) { - for (const revision of artifact.revisions || []) { - if (revision.derivedVersion === validatorVersion) continue; - const derived = derivedRevisionData(revision.html, artifact); - await channelRepository.updateRevisionDerivedData(artifact.id, revision.id, derived); - } - } - return getAll(); -} - -function preferredId(value, title) { - return typeof value === 'string' && /^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$/.test(value) ? value : slug(title); -} - -function uniqueId(preferred, title, takenIds) { - const base = preferredId(preferred, title); - if (!takenIds.has(base)) return base; - let attempt = 2; - while (takenIds.has(`${base}-${attempt}`)) attempt += 1; - return `${base}-${attempt}`; -} - -function enrichArtifact(record, options = {}) { - const input = record && typeof record === 'object' ? record : {}; - if (typeof input.html !== 'string') throw new TypeError('An artifact must contain its original HTML source.'); - const now = new Date().toISOString(); - const inspection = inspectHtml(input.html); - const parsed = new DOMParser().parseFromString(input.html, 'text/html'); - const meta = (name) => safeText(parsed.querySelector(`meta[name="${name}"]`)?.content); - const manifest = inspection.manifest && typeof inspection.manifest === 'object' && !Array.isArray(inspection.manifest) ? inspection.manifest : {}; - const title = safeText(input.title, safeText(manifest.title, meta('helm:title') || safeText(parsed.querySelector('title')?.textContent, options.fallbackName || 'Untitled artifact'))).slice(0, 100); - const typeCandidate = safeText(input.type, safeText(manifest.type, meta('helm:type') || 'reference')).toLowerCase(); - const type = DOCUMENT_TYPES.has(typeCandidate) ? typeCandidate : 'reference'; - const tags = normaliseTags(input.tags?.length ? input.tags : manifest.tags?.length ? manifest.tags : meta('helm:tags')); - const summary = safeText(input.summary, safeText(manifest.summary, meta('helm:summary') || safeText(parsed.querySelector('main p, article p, p')?.textContent, 'Imported HTML artifact.'))).slice(0, 240); - const source = safeText(input.source, safeText(manifest.provenance?.author, options.fallbackName || 'Imported file')); - const createdAt = normaliseTimestamp(input.createdAt || manifest.created_at, now); - const updatedAt = normaliseTimestamp(input.updatedAt || manifest.updated_at, createdAt); - const takenIds = options.takenIds || new Set(); - const sourceDocumentId = safeText(input.sourceDocumentId, safeText(manifest.id)); - const hasDuplicateSourceIdentity = !options.preserveId && Boolean(sourceDocumentId) && Boolean(options.existingSourceIds?.has(sourceDocumentId)); - const id = options.preserveId && safeText(input.id) ? safeText(input.id) : uniqueId(input.id || sourceDocumentId, title, takenIds); - takenIds.add(id); - const identityState = input.identityState === 'catalog-copy' || hasDuplicateSourceIdentity ? 'catalog-copy' : sourceDocumentId ? 'aligned' : 'unmanaged'; - const inferredProject = inferredLegacyProject(input, manifest); - const project = normaliseProject(input.project || manifest.project, inferredProject); - const core = new Set(['id', 'title', 'type', 'tags', 'summary', 'source', 'project', 'createdAt', 'updatedAt', 'html', 'contentText', 'validation', 'sourceDocumentId', 'identityState']); - const extensions = Object.fromEntries(Object.entries(input).filter(([key]) => !core.has(key))); - const derived = derivedRevisionData(input.html, { id, sourceDocumentId, identityState }); - return { ...extensions, id, sourceDocumentId: sourceDocumentId || null, identityState, title, type, tags, summary, source, project, createdAt, updatedAt, html: input.html, ...derived }; -} - -async function initialise() { - try { - if (!channelRepository) throw new Error('Helm Channels repository is unavailable.'); - await channelRepository.open(); - setAppearance(await getSetting('appearanceMode')); - let stored = await getAll({ includeArchived: true }); - stored = await refreshStoredValidation(stored); - const initialized = await getSetting('libraryInitialized'); - if (!initialized && !stored.length) { - const seeded = seedDocuments.map((artifact) => ({ ...artifact, html: seedHtml(artifact) })); - documents = await Promise.all(seeded.map((artifact) => saveDocument(enrichArtifact(artifact, { takenIds: new Set() })))); - } else { - documents = stored; - } - const builtinVersion = Number(await getSetting('builtinContentVersion') || 0); - const welcome = documents.find((artifact) => artifact.id === 'welcome-to-helm'); - if (builtinVersion < BUILTIN_CONTENT_VERSION && isManagedWelcome(welcome)) { - const definition = seedDocuments.find((artifact) => artifact.id === 'welcome-to-helm'); - const upgraded = enrichArtifact({ ...definition, html: seedHtml(definition) }, { preserveId: true, takenIds: new Set(documents.map((artifact) => artifact.id)) }); - const saved = await saveDocument(upgraded, { updateCatalog: false }); - documents = documents.map((artifact) => artifact.id === saved.id ? saved : artifact); - } - await setSetting('builtinContentVersion', BUILTIN_CONTENT_VERSION); - const publicDocuments = await fetchPublicChannelDocuments(documents).catch((error) => { - console.warn('The public Channel catalog is unavailable.', error); - return []; - }); - documents.push(...publicDocuments); - await loadRequiredLineageSources(); - await setSetting('libraryInitialized', true); - archiveFolderHandle = await getSetting('archiveFolderHandle'); - selectedId = null; - render(); - void refreshAgentInboxBadge(); - } catch (error) { - console.error(error); - showToast('This browser could not open its local library.'); - } -} - -function filteredDocuments() { - const search = $('#searchInput').value.trim().toLowerCase(); - const sort = $('#sortSelect').value; - const result = documents.filter((artifact) => { - const matchesFilter = activeFilter === 'all' || artifact.type === activeFilter; - const matchesProject = activeProject === 'all' || projectFor(artifact).id === activeProject; - const project = projectFor(artifact); - const corpus = [artifact.title, artifact.summary, artifact.type, project.id, project.name, ...(artifact.tags || []), artifact.contentText || ''].join(' ').toLowerCase(); - return matchesFilter && matchesProject && (!search || corpus.includes(search)); - }); - return result.sort((left, right) => sort === 'title' ? left.title.localeCompare(right.title) : new Date(right[sort === 'created' ? 'createdAt' : 'catalogUpdatedAt'] || right.updatedAt) - new Date(left[sort === 'created' ? 'createdAt' : 'catalogUpdatedAt'] || left.updatedAt)); -} - -function renderProjects() { - const projects = knownProjects(); - if (activeProject !== 'all' && !projects.some((project) => project.id === activeProject)) activeProject = 'all'; - $('#projectSuggestions').innerHTML = projects.filter((project) => project.id !== UNASSIGNED_PROJECT.id).map((project) => ``).join(''); - const assignedProjects = projects.filter((project) => project.id !== UNASSIGNED_PROJECT.id); - const unassignedProject = projects.find((project) => project.id === UNASSIGNED_PROJECT.id); - $('#projects').innerHTML = [ - ``, - ...assignedProjects.map((project) => ``) - ].join(''); - $('#projectReviewLabel').hidden = !unassignedProject; - $('#projectReview').hidden = !unassignedProject; - $('#projectReview').innerHTML = unassignedProject ? `` : ''; - $$('.project-collection').forEach((button) => button.addEventListener('click', () => { - activeProject = button.dataset.project; - activeFilter = 'all'; - $('#searchInput').value = ''; - $('#typeFilter').value = 'all'; - showView('library'); - render(); - })); -} - -function renderLibrary() { - const filtered = filteredDocuments(); - const query = $('#searchInput').value.trim(); - const activeType = activeFilter === 'all' ? 'All artifacts' : `${activeFilter[0].toUpperCase()}${activeFilter.slice(1)}s`; - const selectedProject = activeProject === 'all' ? null : knownProjects().find((project) => project.id === activeProject); - const lenses = [selectedProject ? `Project: ${selectedProject.name}` : null, activeFilter !== 'all' ? activeType : null, query ? `Search: “${query.length > 22 ? `${query.slice(0, 22)}…` : query}”` : null].filter(Boolean); - const filterState = lenses.join(' · ') || activeType; - const hasActiveLens = Boolean(query) || activeFilter !== 'all' || activeProject !== 'all'; - const readyCount = documents.filter((artifact) => artifact.validation?.valid).length; - const projectCount = new Set(documents.map((artifact) => projectFor(artifact).id).filter((id) => id !== UNASSIGNED_PROJECT.id)).size; - $('#documentCount').textContent = String(documents.length).padStart(2, '0'); - $('#libraryCount').textContent = String(documents.length).padStart(2, '0'); - $('#libraryVisibleCount').textContent = String(filtered.length).padStart(2, '0'); - $('#libraryFilterState').textContent = filterState; - $('#clearLibraryFilters').hidden = !hasActiveLens; - $('#contractReadyCount').textContent = String(readyCount).padStart(2, '0'); - $('#librarySourceCount').textContent = String(projectCount).padStart(2, '0'); - $('#documentGrid').innerHTML = filtered.map((artifact, index) => { - const project = projectFor(artifact); - const state = workflowState(artifact); - const conciseState = stableShare(artifact) ? 'Published' : state === 'reviewed' ? 'Reviewed' : 'Draft'; - return `
${conciseState.toUpperCase()}

${esc(project.name)}

${esc(artifact.title)}

${esc(artifact.summary || 'No summary provided.')}

${esc(artifact.type)}Updated ${dateLabel(artifact.catalogUpdatedAt || artifact.updatedAt)}
`; - }).join(''); - $('#emptyState').hidden = Boolean(filtered.length); - $$('.document-card').forEach((card) => { - card.addEventListener('click', (event) => { if (!event.target.closest('[data-open]')) selectDocument(card.dataset.id); }); - card.addEventListener('keydown', (event) => { if (event.key === 'Enter') selectDocument(card.dataset.id); }); - }); - $$('[data-open]').forEach((button) => button.addEventListener('click', (event) => { event.stopPropagation(); openReader(button.dataset.open); })); -} - -function templatePreview(type) { - const labels = type === 'brief' ? ['option', 'choice', 'check'] : type === 'reference' ? ['trigger', 'pattern', 'outcome'] : ['source', 'claim', 'action']; - return ``; -} - -function renderTemplates() { - $('#templateGrid').innerHTML = templates.map((template, index) => `
${String(index + 1).padStart(2, '0')} / HDOC STARTER${esc(template.type.toUpperCase())}
${esc(template.type)} / visual route${templatePreview(template.type)}

${template.title}

${template.summary}

`).join(''); - $$('[data-template]').forEach((button) => button.addEventListener('click', () => openCreateDialog(templates.find((template) => template.id === button.dataset.template)))); -} - -function revisionsFor(artifact) { - return [...(artifact?.revisions || [])].sort((left, right) => new Date(left.createdAt) - new Date(right.createdAt)); -} - -function revisionNumber(artifact, revisionId = artifact?.currentRevisionId) { - const index = revisionsFor(artifact).findIndex((revision) => revision.id === revisionId); - return index < 0 ? 1 : index + 1; -} - -function workflowState(artifact) { - return artifact?.status === 'in-review' ? 'reviewed' : artifact?.status === 'published' ? 'published' : 'draft'; -} - -function workflowLabel(artifact) { - return workflowState(artifact).replace(/^./, (letter) => letter.toUpperCase()); -} - -function publishedRevision(artifact) { - return revisionsFor(artifact).find((revision) => revision.id === artifact?.publishedRevisionId) || null; -} - -function publicationShare(artifact) { - const share = publishedRevision(artifact)?.share; - return share && typeof share === 'object' ? share : null; -} - -function stableShare(artifact) { - const share = publicationShare(artifact); - return share && typeof share.stableUrl === 'string' ? share : null; -} - -function legacyShareRecord(artifact) { - const share = publicationShare(artifact); - return share?.kind === 'legacy' ? share : null; -} - -function activeLegacyShare(artifact) { - const share = legacyShareRecord(artifact); - return share && !share.revokedAt && typeof share.legacyUrl === 'string' ? share : null; -} - -function hasChannelIdentity(artifact) { - const manifestId = artifact?.validation?.manifest?.id || inspectHtml(artifact?.html || '').manifest?.id; - return typeof manifestId === 'string' && manifestId === artifact?.id; -} - -function revisionLabel(artifact, revisionId = artifact?.currentRevisionId) { - return `Revision ${String(revisionNumber(artifact, revisionId)).padStart(2, '0')}`; -} - -function renderInspector() { - const artifact = documents.find((item) => item.id === selectedId); - $('.app-shell').classList.toggle('without-inspector', !artifact || !$('#libraryView').classList.contains('active-view')); - $('#inspectorEmpty').hidden = Boolean(artifact); - $('#inspectorContent').hidden = !artifact; - if (!artifact) return; - const health = artifact.validation || inspectHtml(artifact.html); - const errors = health.issues.filter((issue) => issue.severity === 'error'); - const warnings = health.issues.filter((issue) => issue.severity === 'warning'); - $('#selectedType').textContent = artifact.type.toUpperCase(); - const state = workflowState(artifact); - const readOnlyChannel = Boolean(artifact.remotePublication); - $('#selectedWorkflowStatus').dataset.status = state; - $('#selectedWorkflowStatus').textContent = state.toUpperCase(); - $('#selectedRevisionLabel').textContent = revisionLabel(artifact); - const share = stableShare(artifact); - const legacyShare = activeLegacyShare(artifact); - const legacyRecord = legacyShareRecord(artifact); - const revisionShare = publicationShare(artifact); - const publishedNumber = artifact.publishedRevisionId ? revisionNumber(artifact, artifact.publishedRevisionId) : null; - const ahead = Boolean(artifact.publishedRevisionId && artifact.publishedRevisionId !== artifact.currentRevisionId); - $('#selectedPublishedState').textContent = legacyShare - ? `Legacy immutable share for Revision ${String(publishedNumber || 1).padStart(2, '0')} is still public.` - : legacyRecord?.revokedAt - ? `Legacy share for Revision ${String(publishedNumber || 1).padStart(2, '0')} was retired.` - : publishedNumber ? (!share ? `Revision ${String(publishedNumber).padStart(2, '0')} was last published; stable address revoked.` : ahead ? `Current draft is ahead of published revision ${String(publishedNumber).padStart(2, '0')}.` : `Published revision ${String(publishedNumber).padStart(2, '0')} is current.`) : 'Not published'; - $('#selectedPublishedState').classList.toggle('is-behind', ahead); - $('#reviewButton').hidden = state === 'published' || readOnlyChannel; - $('#reviewButton').disabled = state === 'reviewed'; - $('#reviewButton').textContent = state === 'draft' ? 'Mark reviewed' : 'Reviewed · ready to publish'; - $('#selectedTitle').textContent = artifact.title; - $('#selectedSummary').textContent = artifact.summary || 'No summary provided.'; - $('#selectedTags').innerHTML = artifact.tags.map((tag) => `${esc(tag)}`).join(''); - $('#selectedUpdated').textContent = dateLabel(artifact.catalogUpdatedAt || artifact.updatedAt); - $('#selectedSource').textContent = artifact.source || 'Imported file'; - $('#selectedProject').textContent = projectFor(artifact).name; - const identity = $('#selectedIdentity'); - identity.textContent = artifact.forkedFrom ? `Fork: ${artifact.id}` : artifact.identityState === 'catalog-copy' ? `Copy: ${artifact.id}` : artifact.sourceDocumentId ? `Aligned: ${artifact.id}` : `Library: ${artifact.id}`; - identity.title = `Library ID: ${artifact.id}${artifact.sourceDocumentId ? ` · source manifest ID: ${artifact.sourceDocumentId}` : ''}`; - $('#selectedFormat').textContent = health.hasManifest || health.manifest ? SCHEMA : 'Plain HTML'; - $('#selectedSize').textContent = byteLabel(new Blob([artifact.html]).size); - $('#manifestDot').className = `quality-dot ${health.valid ? 'good' : ''}`; - $('#manifestStatus').textContent = health.valid ? 'HDOC/1.0 passed' : errors.length ? `${errors.length} contract error${errors.length === 1 ? '' : 's'}` : `${warnings.length} portability warning${warnings.length === 1 ? '' : 's'}`; - $('#manifestHint').textContent = health.valid ? 'Portable metadata and structure are present.' : 'The original source is unchanged; review before sharing.'; - $('#healthScore').textContent = `${health.score ?? 0}`; - $('#healthHint').textContent = health.valid ? (warnings.length ? `Contract passed · ${warnings.length} catalog or portability warning${warnings.length === 1 ? '' : 's'}.` : 'No contract errors detected.') : `${errors.length} error${errors.length === 1 ? '' : 's'} · ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`; - $('#healthIssues').innerHTML = health.issues.slice(0, 3).map((issue) => `
  • ${esc(issue.message)}
  • `).join(''); - $('#repairButton').hidden = health.valid || readOnlyChannel; - const identityReady = hasChannelIdentity(artifact); - $('#shareButton').disabled = !health.valid || !identityReady || state === 'draft' || Boolean(legacyShare); - $('#shareButton').title = legacyShare ? 'Retire the legacy one-shot share before publishing this Artifact as a Channel.' : !identityReady ? 'A Channel requires the logical Artifact ID to match the embedded HDOC manifest ID.' : state === 'draft' ? 'Mark this Revision reviewed before publishing.' : !health.valid ? 'Repair the HDOC contract before publishing.' : ''; - $('#shareButton').textContent = legacyShare ? 'Retire legacy link first' : state === 'draft' ? 'Review before publishing' : state === 'published' && share ? 'Copy stable link' : artifact.publishedRevisionId ? 'Publish current revision' : 'Publish stable link'; - const visibleShare = share?.stableUrl || legacyShare?.legacyUrl || ''; - $('#shareRecord').hidden = !visibleShare; - $('#shareRecordLabel').textContent = legacyShare ? 'LEGACY IMMUTABLE SHARE' : 'STABLE ADDRESS'; - $('#revokeShareButton').hidden = readOnlyChannel || (!share && !legacyShare); - $('#revokeShareButton').textContent = legacyShare ? 'Retire legacy link' : 'Revoke stable address'; - $('#selectedShare').textContent = visibleShare; - if (visibleShare) $('#selectedShare').href = visibleShare; - else $('#selectedShare').removeAttribute('href'); - $('#selectedRevisionShare').textContent = legacyShare ? 'This pre-Channel one-shot file remains public until the owner explicitly retires it.' : revisionShare?.revisionUrl ? `Immutable snapshot: ${revisionShare.revisionUrl}` : 'Published revisions remain available at immutable addresses.'; - $('#editMetadataButton').hidden = readOnlyChannel; - $('#deleteButton').hidden = readOnlyChannel; - $('#forkButton').hidden = readOnlyChannel; - $('#lineageRecord').hidden = !artifact.forkedFrom; - $('#selectedLineage').textContent = artifact.forkedFrom ? `${artifact.forkedFrom.artifactId} · ${artifact.forkedFrom.revisionId.slice(0, 18)}…` : ''; - const forkNeedsRevision = Boolean(artifact.forkedFrom && !identityReady); - $('#forkHandoff').hidden = !forkNeedsRevision; - $('#forkArtifactId').textContent = forkNeedsRevision ? artifact.id : ''; -} - -function render() { renderProjects(); renderLibrary(); renderTemplates(); renderInspector(); $('#archiveDocumentCount').textContent = String(documents.filter((artifact) => !artifact.remotePublication).length).padStart(2, '0'); renderFolderStatus(); } -function selectDocument(id) { selectedId = id; renderLibrary(); renderInspector(); } - -function showView(view) { - $$('.view').forEach((element) => element.classList.toggle('active-view', element.id === `${view}View`)); - $$('.nav-item').forEach((element) => element.classList.toggle('active', element.dataset.view === view)); - $('#viewTitle').textContent = view === 'library' ? 'Library' : view === 'templates' ? 'Templates' : 'Document contract'; - $('.app-shell').classList.toggle('without-inspector', view !== 'library' || !selectedDocument()); -} - -function showToast(message) { - const toast = $('#toast'); - toast.textContent = message; - toast.classList.add('show'); - clearTimeout(toastTimer); - toastTimer = setTimeout(() => toast.classList.remove('show'), 2600); -} - -function pendingAgentInboxDocuments() { - const knownHashes = new Set(documents.flatMap((artifact) => (artifact.revisions || []).map((revision) => revision.contentHash))); - return agentInboxDocuments.filter((artifact) => artifact && typeof artifact.id === 'string' && typeof artifact.html === 'string' && !knownHashes.has(artifact.sha256)); -} - -function updateAgentInboxBadge() { - const badge = $('#agentInboxCount'); - if (!badge) return; - const count = pendingAgentInboxDocuments().length; - badge.hidden = count === 0; - badge.textContent = String(count); -} - -async function fetchAgentBridgeDocuments() { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3500); - try { - const response = await fetch(`${AGENT_BRIDGE_URL}/v1/artifacts`, { headers: { Accept: 'application/json' }, signal: controller.signal }); - if (!response.ok) throw new Error(`Bridge returned ${response.status}`); - const payload = await response.json(); - if (!Array.isArray(payload.documents)) throw new Error('Bridge response did not include documents.'); - return payload.documents; - } finally { - clearTimeout(timeout); - } -} - -function renderAgentInbox({ online = true, error = null } = {}) { - const dot = $('#agentInboxDot'); - const status = $('#agentInboxStatus'); - const hint = $('#agentInboxHint'); - const list = $('#agentInboxList'); - const importButton = $('#agentInboxImportButton'); - const refreshButton = $('#agentInboxRefreshButton'); - if (!dot || !status || !hint || !list || !importButton || !refreshButton) return; - refreshButton.disabled = false; - dot.classList.toggle('connected', online); - if (!online) { - status.textContent = 'Local Bridge unavailable'; - hint.textContent = error ? 'Start Helm Bridge, then refresh. The browser library was not changed.' : 'Bridge listens at 127.0.0.1:4175.'; - list.innerHTML = '
  • No Agent connectionHelm does not expose the browser library to a network service. The Bridge is a separate loopback process.
  • '; - importButton.disabled = true; - importButton.textContent = 'Import selected artifacts'; - return; - } - const pending = pendingAgentInboxDocuments(); - const pendingIds = new Set(pending.map((artifact) => artifact.sha256 || artifact.id)); - selectedAgentInboxIds = new Set([...selectedAgentInboxIds].filter((id) => pendingIds.has(id))); - const selectedCount = pending.filter((artifact) => selectedAgentInboxIds.has(artifact.sha256 || artifact.id)).length; - const existing = agentInboxDocuments.length - pending.length; - status.textContent = pending.length ? `${pending.length} artifact${pending.length === 1 ? '' : 's'} ready for review` : 'Inbox is up to date'; - hint.textContent = existing ? `${existing} already present in this browser and will never be overwritten.` : 'Select the artifacts to import; Bridge never writes the browser library directly.'; - list.innerHTML = pending.length - ? pending.map((artifact) => `
  • `).join('') - : '
  • No new artifactsWhen an Agent submits a valid HDOC document, it will appear here for your explicit import.
  • '; - list.querySelectorAll('[data-agent-inbox-id]').forEach((input) => input.addEventListener('change', () => { - const id = input.dataset.agentInboxId; - if (input.checked) selectedAgentInboxIds.add(id); - else selectedAgentInboxIds.delete(id); - renderAgentInbox(); - })); - importButton.disabled = selectedCount === 0; - importButton.textContent = selectedCount ? `Import selected (${selectedCount})` : 'Select artifacts to import'; -} - -async function refreshAgentInbox({ forDialog = false } = {}) { - const refreshButton = $('#agentInboxRefreshButton'); - if (forDialog) { - $('#agentInboxStatus').textContent = 'Checking local Bridge…'; - $('#agentInboxHint').textContent = 'Reading only the Bridge inbox; your browser library is unchanged.'; - refreshButton.disabled = true; - } - try { - agentInboxDocuments = await fetchAgentBridgeDocuments(); - updateAgentInboxBadge(); - if (forDialog) renderAgentInbox(); - return true; - } catch (error) { - console.info('Helm Bridge is not available.', error); - agentInboxDocuments = []; - updateAgentInboxBadge(); - if (forDialog) renderAgentInbox({ online: false, error }); - return false; - } finally { - if (refreshButton && !forDialog) refreshButton.disabled = false; - } -} - -async function refreshAgentInboxBadge() { - await refreshAgentInbox(); -} - -function openAgentInbox() { - $('#agentInboxDialog').showModal(); - void refreshAgentInbox({ forDialog: true }); -} - -async function importAgentInbox() { - const pending = pendingAgentInboxDocuments().filter((artifact) => selectedAgentInboxIds.has(artifact.sha256 || artifact.id)); - if (!pending.length) { showToast('Select one or more Agent artifacts first.'); return; } - const accepted = []; - const importedHeads = new Map(); - let rejected = 0; - for (const remote of pending) { - try { - const storedArtifact = await channelRepository.getArtifact(remote.id); - const storedDocument = storedArtifact ? await channelRepository.getDocument(remote.id) : null; - const existing = importedHeads.get(remote.id) || documents.find((item) => item.id === remote.id) || lineageSources.get(remote.id) || (storedDocument ? { ...storedDocument, revisions: await channelRepository.listRevisions(remote.id) } : null); - const incoming = enrichArtifact({ - id: existing?.id || remote.id, - sourceDocumentId: remote.source_document_id || remote.id, - title: remote.title, - type: remote.type, - tags: remote.tags, - summary: remote.summary, - source: `Agent · ${safeText(remote.source, 'unnamed-agent')}`, - project: remote.project, - createdAt: remote.created_at, - updatedAt: remote.updated_at, - html: remote.html, - bridge: { source: remote.source, receivedAt: remote.received_at, sha256: remote.sha256 } - }, { takenIds: new Set(), preserveId: true }); - const result = await channelRepository.createOrRevise(incoming, { - artifactId: incoming.id, - expectedCurrentRevisionId: existing?.currentRevisionId, - updateCatalog: false - }); - const stored = { ...result.document, revisions: await channelRepository.listRevisions(incoming.id) }; - accepted.push(stored); - importedHeads.set(remote.id, stored); - } catch (error) { - rejected += 1; - console.warn('A Bridge artifact could not be indexed.', error); - } - } - $('#agentInboxDialog').close(); - pending.forEach((artifact) => selectedAgentInboxIds.delete(artifact.sha256 || artifact.id)); - const acceptedById = new Map(accepted.map((saved) => [saved.id, saved])); - const latestAccepted = [...acceptedById.values()]; - latestAccepted.forEach((saved) => lineageSources.delete(saved.id)); - documents = documents.filter((item) => !acceptedById.has(item.id)); - documents.push(...latestAccepted); - if (latestAccepted.length) selectedId = latestAccepted.at(-1).id; - render(); - showToast(`${accepted.length} Agent revision${accepted.length === 1 ? '' : 's'} accepted${rejected ? ` · ${rejected} rejected` : ''}.`); - await refreshAgentInbox(); -} - -async function renderFolderStatus() { - const status = $('#folderStatus'); - if (!status) return; - const helper = globalThis.HelmFolderSync; - const dot = $('#folderStatusDot'); - const hint = $('#folderStatusHint'); - const choose = $('#chooseFolderButton'); - const sync = $('#syncFolderButton'); - const recover = $('#recoverFolderButton'); - dot.classList.remove('connected'); - sync.disabled = true; - recover.disabled = false; - if (!helper?.isSupported?.()) { - status.textContent = 'Browser library only'; - hint.textContent = 'Portable archive export works everywhere; folder sync needs a Chromium browser with File System Access.'; - choose.disabled = true; - recover.disabled = true; - return; - } - choose.disabled = false; - if (!archiveFolderHandle) { - status.textContent = 'No folder connected'; - hint.textContent = 'Connect a folder, then choose Sync now. Helm never watches or writes in the background.'; - return; - } - const permission = await helper.verifyPermission(archiveFolderHandle, { mode: 'readwrite', request: false }); - if (!permission.ok) { - status.textContent = 'Folder permission needed'; - hint.textContent = 'Use Connect folder to renew access, then explicitly sync or recover.'; - return; - } - dot.classList.add('connected'); - status.textContent = 'Folder connected'; - const lastSync = await getSetting('lastFolderSyncAt'); - hint.textContent = lastSync ? `Last explicit sync: ${dateLabel(lastSync)}. Sync only runs when you press the button.` : 'Ready for an explicit sync. Existing Helm files require a second confirmation before replacement.'; - sync.disabled = false; -} - -async function chooseArchiveFolder() { - const helper = globalThis.HelmFolderSync; - if (!helper?.chooseDirectory) { showToast('Folder sync is unavailable in this browser.'); return; } - try { - const selection = await helper.chooseDirectory({ mode: 'readwrite', id: 'helm-archive' }); - if (selection.status === 'cancelled') return; - if (!selection.ok) throw new Error(selection.failures?.[0]?.message || 'Could not connect that folder.'); - archiveFolderHandle = selection.handle; - folderReplaceArmed = false; - $('#syncFolderButton').textContent = 'Sync now'; - await setSetting('archiveFolderHandle', archiveFolderHandle); - await renderFolderStatus(); - showToast('Folder connected. Nothing has been written yet.'); - } catch (error) { - console.error(error); - showToast('Helm could not retain access to that folder.'); - } -} - -async function syncArchiveFolder() { - const helper = globalThis.HelmFolderSync; - if (!helper?.writeArchive || !archiveFolderHandle) { showToast('Connect a local folder before syncing.'); return; } - const button = $('#syncFolderButton'); - button.disabled = true; - try { - const result = await helper.writeArchive(archiveFolderHandle, archiveRecords(), { - conflictPolicy: folderReplaceArmed ? 'replace' : 'error', - requestPermission: true - }); - if (result.status === 'conflict' && result.requiresExplicitReplace) { - folderReplaceArmed = true; - button.textContent = 'Confirm replace'; - showToast('A Helm archive already exists there. Click Confirm replace to update Helm-owned files.'); - return; - } - if (!result.ok) throw new Error(result.failures?.[0]?.message || `Folder sync stopped: ${result.status}.`); - folderReplaceArmed = false; - button.textContent = 'Sync now'; - await setSetting('lastFolderSyncAt', new Date().toISOString()); - showToast(`${archiveRecords().length} artifacts synced to the selected folder.`); - } catch (error) { - console.error(error); - showToast('Folder sync did not finish. Existing folder data was left available for recovery.'); - } finally { - await renderFolderStatus(); - } -} - -async function recoverArchiveFolder() { - const helper = globalThis.HelmFolderSync; - if (!helper?.recoverArchive) { showToast('Folder recovery is unavailable in this browser.'); return; } - if (!archiveFolderHandle) { - await chooseArchiveFolder(); - if (!archiveFolderHandle) return; - } - try { - const recovery = await helper.recoverArchive(archiveFolderHandle, { requestPermission: true }); - if (!recovery.ok || !recovery.complete) throw new Error(recovery.failures?.[0]?.message || 'The folder archive is incomplete. No browser records were changed.'); - const existingIds = new Set(documents.map((artifact) => artifact.id)); - const takenIds = new Set(existingIds); - const knownSourceIds = sourceIdentitySet(); - const accepted = []; - const duplicates = []; - let sameIdSkipped = 0; - for (const record of recovery.documents) { - if (existingIds.has(record.id)) { sameIdSkipped += 1; continue; } - const artifact = enrichArtifact(record, { takenIds, preserveId: true }); - const sourceConflict = Boolean(artifact.sourceDocumentId && knownSourceIds.has(artifact.sourceDocumentId)); - if (artifact.sourceDocumentId) knownSourceIds.add(artifact.sourceDocumentId); - if (sourceConflict) { - artifact.identityState = 'catalog-copy'; - artifact.validation.issues.unshift({ severity: 'warning', code: 'catalog-copy-identity', message: `Source manifest ID “${artifact.sourceDocumentId}” already exists here. This is an explicit catalog copy with library ID “${artifact.id}”; its original HTML was not renamed.` }); - duplicates.push(artifact); - } else accepted.push(artifact); - } - if (duplicates.length) showDuplicateImportResolution(accepted, duplicates, { label: 'recovered artifact', skipped: sameIdSkipped }); - else await persistImportedArtifacts(accepted, { label: 'recovered artifact', skipped: sameIdSkipped }); - } catch (error) { - console.error(error); - showToast('Folder recovery could not complete; the browser library was not changed.'); - } finally { - await renderFolderStatus(); - } -} - -function openCreateDialog(template) { - templateToCreate = template; - $('#createTitle').textContent = template.title; - $('#newTitle').value = ''; - $('#newSummary').value = template.summary; - $('#newTags').value = template.tags.join(', '); - $('#createDialog').showModal(); - setTimeout(() => $('#newTitle').focus(), 30); -} - -async function createFromTemplate() { - const title = $('#newTitle').value.trim(); - if (!title) return; - const now = new Date().toISOString(); - const base = { id: `${slug(title)}-${Date.now().toString(36)}`, title, type: templateToCreate.type, tags: $('#newTags').value.split(',').map((tag) => tag.trim()).filter(Boolean), summary: $('#newSummary').value.trim(), source: 'Helm template', project: activeProject === 'all' ? { id: 'helm', name: 'Helm' } : knownProjects().find((project) => project.id === activeProject) || UNASSIGNED_PROJECT, createdAt: now, updatedAt: now }; - const sections = templateToCreate.id === 'research-report' - ? [['Question and scope', 'State the exact question, audience, decision window, and what this artifact deliberately leaves out.'], ['Short answer', 'Write the decision-relevant conclusion in one or two sentences before adding background.'], ['Evidence ledger', 'Add dated sources, observations, counter-evidence, and the confidence each item earns.'], ['Interpretation and next action', 'Explain what the evidence means, the recommendation it supports, and the condition that would trigger a revisit.'], ['Sources and method', 'Record primary sources, dates, definitions, assumptions, and collection limits.']] - : templateToCreate.id === 'decision-brief' - ? [['Decision and deadline', 'Describe the call that needs to be made, the owner, and the non-negotiable constraints.'], ['Recommendation', 'State the selected path in one direct sentence before explaining the alternatives.'], ['Options and comparison', 'Compare realistic alternatives on the same benefits, costs, risks, and evidence.'], ['Action and checkpoint', 'Name the next action, accountable owner, and date or condition for review.'], ['Risk and reversal condition', 'Record the assumption, counter-signal, or new evidence that would reopen the decision.']] - : [['Pattern in one line', 'Explain the reusable idea in plain language before adding implementation detail.'], ['When to use it', 'State the preconditions, expected benefit, and the case where a simpler alternative is better.'], ['Smallest reliable workflow', 'Describe the fewest dependable steps and the observable result that confirms success.'], ['Caveats and sources', 'Record version sensitivity, constraints, primary sources, and links worth recovering later.']]; - const artifact = await saveDocument(enrichArtifact({ ...base, html: articleHtml(base, sections) }, { takenIds: new Set(documents.map((item) => item.id)) })); - documents.push(artifact); - selectedId = artifact.id; - $('#createDialog').close(); - showView('library'); - render(); - showToast('New HDOC artifact added to your local library.'); - openReader(artifact.id); -} - -function sourceIdentitySet(records = documents) { - return new Set(records.map((artifact) => artifact.sourceDocumentId).filter(Boolean)); -} - -function parseHtmlDocument(html, filename, takenIds) { - const candidate = enrichArtifact({ html, source: filename }, { fallbackName: filename.replace(/\.html?$/i, ''), takenIds: new Set() }); - const existing = documents.find((artifact) => artifact.id === candidate.sourceDocumentId) || lineageSources.get(candidate.sourceDocumentId); - if (existing) return { ...candidate, id: existing.id, identityState: existing.identityState }; - candidate.id = uniqueId(candidate.id, candidate.title, takenIds); - takenIds.add(candidate.id); - return candidate; -} - -async function persistImportedArtifacts(artifacts, { label = 'artifact', skipped = 0 } = {}) { - if (!artifacts.length) { - if (skipped) showToast(`${skipped} duplicate artifact${skipped === 1 ? '' : 's'} skipped.`); - return; - } - const outcomes = []; - for (const artifact of restoreOrder(artifacts)) { - try { outcomes.push({ status: 'fulfilled', value: await restoreImportedArtifact(artifact) }); } - catch (reason) { outcomes.push({ status: 'rejected', reason }); } - } - const savedById = new Map(outcomes.filter((outcome) => outcome.status === 'fulfilled').map((outcome) => [outcome.value.id, outcome.value])); - const saved = [...savedById.values()]; - const failed = artifacts.length - saved.length; - if (saved.length) { - saved.forEach((record) => lineageSources.delete(record.id)); - documents = documents.filter((item) => !saved.some((record) => record.id === item.id)); - documents.push(...saved); - selectedId = saved.at(-1).id; - showView('library'); - render(); - } - if (failed) console.error('Some imported artifacts could not be saved.', outcomes.filter((outcome) => outcome.status === 'rejected')); - showToast(`${saved.length} ${label}${saved.length === 1 ? '' : 's'} added${skipped ? ` · ${skipped} skipped` : ''}${failed ? ` · ${failed} could not be saved` : ''}.`); -} - -function restoreOrder(artifacts) { - const pending = [...artifacts]; - const incomingIds = new Set(pending.map((artifact) => artifact.id)); - const resolved = new Set(documents.map((artifact) => artifact.id)); - const ordered = []; - while (pending.length) { - const index = pending.findIndex((artifact) => { - const firstParent = Array.isArray(artifact.revisions) ? artifact.revisions[0]?.parent?.artifactId : null; - const dependency = artifact.forkedFrom?.artifactId || firstParent; - return !dependency || !incomingIds.has(dependency) || resolved.has(dependency); - }); - const [next] = pending.splice(index < 0 ? 0 : index, 1); - ordered.push(next); - resolved.add(next.id); - } - return ordered; -} - -async function restoreImportedArtifact(artifact) { - const history = Array.isArray(artifact.revisions) ? artifact.revisions.filter((revision) => revision && typeof revision.html === 'string') : []; - if (!history.length || await channelRepository.getArtifact(artifact.id)) return saveDocument(artifact); - const catalog = Object.fromEntries(Object.entries(artifact).filter(([key]) => key !== 'revisions' && key !== 'html' && key !== 'validation' && key !== 'contentText' && key !== 'share')); - let expectedCurrentRevisionId; - for (const revision of history) { - const parent = revision.parent && (await channelRepository.getRevision(revision.parent.artifactId, revision.parent.revisionId)) ? revision.parent : undefined; - const derived = typeof globalThis.HelmValidator?.validate === 'function' - ? derivedRevisionData(revision.html, artifact) - : { contentText: revision.contentText || '', validation: revision.validation || null, ...(revision.derivedVersion ? { derivedVersion: revision.derivedVersion } : {}) }; - const result = await channelRepository.createOrRevise({ - ...catalog, - html: revision.html, - ...derived, - authoredAt: revision.authoredAt, - author: revision.author, - updatedAt: revision.authoredAt || revision.createdAt || artifact.updatedAt - }, { - artifactId: artifact.id, - expectedCurrentRevisionId, - ...(parent ? { parent } : {}), - ...(expectedCurrentRevisionId === undefined && artifact.forkedFrom ? { forkedFrom: artifact.forkedFrom } : {}), - revisionShare: revision.share || null, - updateCatalog: expectedCurrentRevisionId !== undefined - }); - expectedCurrentRevisionId = result.revision.id; - } - if (artifact.publishedRevisionId && await channelRepository.getRevision(artifact.id, artifact.publishedRevisionId)) { - await channelRepository.setStatus(artifact.id, 'published', { revisionId: artifact.publishedRevisionId }); - } - if (artifact.currentRevisionId && await channelRepository.getRevision(artifact.id, artifact.currentRevisionId)) { - await channelRepository.setCurrentRevision(artifact.id, artifact.currentRevisionId); - } - if (artifact.status !== 'published' && ['draft', 'in-review', 'archived'].includes(artifact.status)) await channelRepository.setStatus(artifact.id, artifact.status); - return refreshImportedArtifact(artifact.id); -} - -async function refreshImportedArtifact(id) { - const document = await channelRepository.getDocument(id); - return { ...document, revisions: await channelRepository.listRevisions(id) }; -} - -function showDuplicateImportResolution(accepted, duplicates, { label = 'artifact', skipped = 0, afterPersist = null } = {}) { - pendingDuplicateImport = { accepted, duplicates, label, skipped, afterPersist }; - $('#duplicateImportList').innerHTML = duplicates.map((artifact) => `
  • ${esc(artifact.title)}Source manifest ID: ${esc(artifact.sourceDocumentId || 'unknown')} · proposed library ID: ${esc(artifact.id)}
  • `).join(''); - $('#duplicateImportDialog').showModal(); -} - -async function resolveDuplicateImports(keepCopies) { - const pending = pendingDuplicateImport; - pendingDuplicateImport = null; - if (!pending) return; - if ($('#duplicateImportDialog').open) $('#duplicateImportDialog').close(); - await persistImportedArtifacts(keepCopies ? [...pending.accepted, ...pending.duplicates] : pending.accepted, { label: pending.label, skipped: pending.skipped + (keepCopies ? 0 : pending.duplicates.length) }); - if (pending.afterPersist) await pending.afterPersist(); -} - -async function importFiles(files) { - const htmlFiles = [...files].filter((file) => /\.html?$/i.test(file.name) || file.type === 'text/html'); - if (!htmlFiles.length) { showToast('Choose one or more .html files.'); return; } - const takenIds = new Set(documents.map((artifact) => artifact.id)); - const imported = []; - const rejected = []; - for (const file of htmlFiles) { - if (file.size > MAX_IMPORT_BYTES) { rejected.push(`${file.name} is larger than 5 MB`); continue; } - try { - const artifact = parseHtmlDocument(await file.text(), file.name, takenIds); - const archived = artifact.sourceDocumentId ? await channelRepository.getArtifact(artifact.sourceDocumentId) : null; - if (archived) artifact.id = archived.id; - imported.push(artifact); - } catch (error) { - rejected.push(`${file.name} could not be indexed`); - console.warn(error); - } - } - if (imported.length) await persistImportedArtifacts(imported, { skipped: rejected.length }); - else showToast(rejected[0] || 'No readable HTML artifacts were added.'); -} - -function selectedDocument() { return documents.find((artifact) => artifact.id === selectedId); } - -function safeReaderSource(html) { - const csp = ``; - if (/]*>/i.test(html)) return html.replace(/]*>/i, (head) => `${head}${csp}`); - return `${csp}${html}`; -} - -function readerDocument() { - return documents.find((artifact) => artifact.id === readerArtifactId) || selectedDocument(); -} - -function resetReaderFrame() { - readerLoadToken += 1; - clearTimeout(readerSlowTimer); - readerSlowTimer = null; - const dialog = $('#readerDialog'); - const frame = $('#readerFrame'); - frame.onload = null; - frame.onerror = null; - frame.removeAttribute('srcdoc'); - frame.src = 'about:blank'; - frame.removeAttribute('aria-hidden'); - dialog.classList.remove('is-loading', 'has-error'); - dialog.removeAttribute('aria-busy'); - $('#readerLoading').hidden = true; - readerArtifactId = null; -} - -function openReader(id = selectedId) { - const artifact = documents.find((item) => item.id === id); - if (!artifact) return; - const dialog = $('#readerDialog'); - const frame = $('#readerFrame'); - const loading = $('#readerLoading'); - const token = ++readerLoadToken; - readerArtifactId = artifact.id; - clearTimeout(readerSlowTimer); - $('#readerTitle').textContent = artifact.title; - $('#forkArtifactButton').hidden = Boolean(artifact.remotePublication); - $('#readerRevisionState').dataset.status = workflowState(artifact); - $('#readerRevisionState').textContent = `v${revisionNumber(artifact)} · ${workflowLabel(artifact)}`; - $('#readerShare').disabled = Boolean(activeLegacyShare(artifact)) || workflowState(artifact) === 'draft' || !hasChannelIdentity(artifact) || !(artifact.validation || inspectHtml(artifact.html)).valid; - $('#readerShare').title = activeLegacyShare(artifact) ? 'Retire the legacy one-shot share before publishing a Channel.' : workflowState(artifact) === 'draft' ? 'Mark this Revision reviewed before publishing.' : !hasChannelIdentity(artifact) ? 'Create a Revision whose manifest ID matches this Artifact before publishing.' : ''; - $('#readerLoadingTitle').textContent = 'Opening document'; - $('#readerLoadingHint').textContent = 'Preparing preview…'; - loading.hidden = false; - dialog.classList.remove('has-error'); - dialog.classList.add('is-loading'); - dialog.setAttribute('aria-busy', 'true'); - frame.setAttribute('aria-hidden', 'true'); - if (!dialog.open) dialog.showModal(); - - requestAnimationFrame(() => requestAnimationFrame(() => { - if (token !== readerLoadToken || !dialog.open) return; - const source = safeReaderSource(artifact.html); - frame.onload = () => { - if (token !== readerLoadToken) return; - clearTimeout(readerSlowTimer); - readerSlowTimer = null; - dialog.classList.remove('is-loading'); - dialog.removeAttribute('aria-busy'); - frame.removeAttribute('aria-hidden'); - loading.hidden = true; - }; - frame.onerror = () => { - if (token !== readerLoadToken) return; - clearTimeout(readerSlowTimer); - dialog.classList.add('has-error'); - $('#readerLoadingTitle').textContent = 'Preview unavailable'; - $('#readerLoadingHint').textContent = 'The original HTML is still available to export.'; - }; - frame.removeAttribute('src'); - frame.srcdoc = source; - readerSlowTimer = setTimeout(() => { - if (token === readerLoadToken && dialog.classList.contains('is-loading')) { - $('#readerLoadingHint').textContent = 'Still rendering…'; - } - }, 1800); - })); -} - -function downloadDocument(artifact = selectedDocument()) { - if (!artifact) return; - const url = URL.createObjectURL(new Blob([artifact.html], { type: 'text/html;charset=utf-8' })); - const anchor = Object.assign(window.document.createElement('a'), { href: url, download: `${slug(artifact.title)}.html` }); - anchor.click(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - showToast('Original HTML exported.'); -} - -async function copyText(value) { - try { - await navigator.clipboard.writeText(value); - return true; - } catch { - const input = Object.assign(document.createElement('textarea'), { value }); - input.style.position = 'fixed'; - input.style.opacity = '0'; - document.body.append(input); - input.select(); - const copied = document.execCommand('copy'); - input.remove(); - return copied; - } -} - -async function refreshArtifact(id) { - const document = await channelRepository.getDocument(id); - if (!document) return null; - const refreshed = { ...document, revisions: await channelRepository.listRevisions(id) }; - documents = documents.map((item) => item.id === id ? refreshed : item); - return refreshed; -} - -async function markReviewed() { - const artifact = selectedDocument(); - if (!artifact || workflowState(artifact) !== 'draft') return; - await channelRepository.setStatus(artifact.id, 'in-review', { revisionId: artifact.currentRevisionId }); - await refreshArtifact(artifact.id); - render(); - showToast('Current revision marked reviewed.'); -} - -async function publishDocument(artifact = selectedDocument()) { - if (!artifact) return; - if (activeLegacyShare(artifact)) { showToast('Retire the legacy one-shot link before publishing a stable Channel.'); return; } - if (!hasChannelIdentity(artifact)) { showToast('Create a new HDOC revision whose manifest ID matches this Artifact before publishing it as a Channel.'); return; } - if (workflowState(artifact) === 'draft') { showToast('Mark the current revision reviewed before publishing.'); return; } - const health = artifact.validation || inspectHtml(artifact.html); - if (!health.valid) { showToast('Repair the HDOC contract before publishing.'); return; } - const existingShare = stableShare(artifact); - if (artifact.status === 'published' && artifact.publishedRevisionId === artifact.currentRevisionId && existingShare) { - const copied = await copyText(existingShare.stableUrl); - showToast(copied ? 'Stable address copied.' : 'Stable address is shown in the inspector.'); - return; - } - const buttons = $$('[data-share-action]'); - buttons.forEach((button) => { button.disabled = true; }); - try { - const baseRevision = stableShare(artifact) ? artifact.publishedRevisionId?.replace(/^sha256:/, '') : undefined; - const response = await fetch(`${CHANNEL_API_URL}/publish`, { - method: 'POST', - headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ html: artifact.html, ...(baseRevision ? { base_revision_sha256: baseRevision } : {}) }) - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok || !payload.stable_url) throw new Error(payload.errors?.[0] || payload.message || (response.status === 409 ? 'The published Channel changed; refresh before publishing again.' : `Share service returned ${response.status}.`)); - await channelRepository.setRevisionShare(artifact.id, artifact.currentRevisionId, { artifactId: payload.artifact?.id || artifact.id, stableUrl: payload.stable_url, revisionUrl: payload.revision_url, sha256: payload.sha256, publishedAt: new Date().toISOString() }); - await channelRepository.setStatus(artifact.id, 'published', { revisionId: artifact.currentRevisionId }); - const refreshed = await refreshArtifact(artifact.id); - if (readerArtifactId === artifact.id) { - $('#readerRevisionState').dataset.status = workflowState(refreshed); - $('#readerRevisionState').textContent = `v${revisionNumber(refreshed)} · ${workflowLabel(refreshed)}`; - } - render(); - const copied = await copyText(payload.stable_url); - showToast(copied ? 'Stable Channel address copied.' : 'Published; the stable address is shown in the inspector.'); - } catch (error) { - console.error(error); - showToast('This page could not be published to the intranet.'); - } finally { - buttons.forEach((button) => { button.disabled = false; }); - } -} - -async function revokePublication() { - const artifact = selectedDocument(); - const share = stableShare(artifact); - const legacyShare = activeLegacyShare(artifact); - if (!artifact || (!share && !legacyShare)) return; - try { - if (legacyShare) { - if (!legacyShare.legacyPath || !legacyShare.sha256) throw new Error('Legacy share metadata lacks the exact path and digest required for safe retirement.'); - const response = await fetch('/api/share/revoke', { - method: 'POST', - headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: legacyShare.legacyPath, sha256: legacyShare.sha256 }) - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(payload.message || `Share service returned ${response.status}.`); - await channelRepository.revokePublication(artifact.id, artifact.publishedRevisionId, { ...legacyShare, revokedAt: payload.revoked_at || new Date().toISOString() }); - await refreshArtifact(artifact.id); - render(); - showToast('Legacy one-shot link retired. The library Revision remains unchanged.'); - return; - } - const base = artifact.publishedRevisionId.replace(/^sha256:/, ''); - const response = await fetch(`${CHANNEL_API_URL}/artifacts/${encodeURIComponent(share.artifactId || artifact.id)}/revoke`, { - method: 'POST', - headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ base_revision_sha256: base }) - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(payload.message || `Share service returned ${response.status}.`); - await channelRepository.revokePublication(artifact.id, artifact.publishedRevisionId, { ...share, stableUrl: null, revokedAt: new Date().toISOString() }); - await refreshArtifact(artifact.id); - render(); - showToast('Stable address revoked. Immutable revision links remain available.'); - } catch (error) { - console.error(error); - showToast('The stable address could not be revoked.'); - } -} - -function historyArtifact() { - const id = $('#historyDialog').dataset.artifactId; - return documents.find((artifact) => artifact.id === id) || lineageSources.get(id) || selectedDocument(); -} - -function revisionOptionLabel(artifact, revision) { - const flags = []; - if (revision.id === artifact.currentRevisionId) flags.push('current'); - if (revision.id === artifact.publishedRevisionId) flags.push('published'); - return `${revisionLabel(artifact, revision.id)}${flags.length ? ` · ${flags.join(', ')}` : ''}`; -} - -function renderVisualDiff() { - const artifact = historyArtifact(); - if (!artifact) return; - const revisions = revisionsFor(artifact); - const before = revisions.find((revision) => revision.id === $('#compareFrom').value) || revisions[0]; - const after = revisions.find((revision) => revision.id === $('#compareTo').value) || revisions.at(-1); - if (!before || !after) return; - $('#diffBeforeLabel').textContent = revisionOptionLabel(artifact, before); - $('#diffAfterLabel').textContent = revisionOptionLabel(artifact, after); - $('#diffBefore').srcdoc = safeReaderSource(before.html); - $('#diffAfter').srcdoc = safeReaderSource(after.html); - $$('#revisionTimeline li').forEach((item) => item.classList.toggle('is-selected', item.dataset.revisionId === after.id)); -} - -function openHistoryDialog(artifact = selectedDocument(), selectedRevisionId = null) { - if (!artifact) return; - const revisions = revisionsFor(artifact); - const share = stableShare(artifact); - const legacyShare = activeLegacyShare(artifact); - const dialog = $('#historyDialog'); - dialog.dataset.artifactId = artifact.id; - dialog.dataset.selectedRevisionId = selectedRevisionId || artifact.currentRevisionId; - $('#historyArtifactIdentity').textContent = `${artifact.id} · ${workflowLabel(artifact).toUpperCase()}`; - $('#historyArtifactTitle').textContent = artifact.title; - const visibleShare = share?.stableUrl || legacyShare?.legacyUrl || ''; - $('#historyAddressLabel').textContent = legacyShare ? 'LEGACY IMMUTABLE SHARE' : 'STABLE ADDRESS'; - $('#historyStableLink').textContent = visibleShare || (legacyShareRecord(artifact)?.revokedAt ? 'Legacy share retired' : 'Not published'); - if (visibleShare) $('#historyStableLink').href = visibleShare; - else $('#historyStableLink').removeAttribute('href'); - $('#historyStableLink').removeAttribute('aria-disabled'); - if (!visibleShare) $('#historyStableLink').setAttribute('aria-disabled', 'true'); - $('#historyRevisionCount').textContent = String(revisions.length).padStart(2, '0'); - $('#revisionTimeline').innerHTML = revisions.slice().reverse().map((revision) => { - const current = revision.id === artifact.currentRevisionId; - const published = revision.id === artifact.publishedRevisionId; - return `
  • `; - }).join(''); - const options = revisions.map((revision) => ``).join(''); - $('#compareFrom').innerHTML = options; - $('#compareTo').innerHTML = options; - const selectedIndex = Math.max(0, revisions.findIndex((revision) => revision.id === dialog.dataset.selectedRevisionId)); - $('#compareTo').value = revisions[selectedIndex]?.id || artifact.currentRevisionId; - $('#compareFrom').value = revisions[Math.max(0, selectedIndex - 1)]?.id || $('#compareTo').value; - const isSingleRevision = revisions.length === 1; - $('#compareWorkspace').classList.toggle('is-single-revision', isSingleRevision); - $('#compareEyebrow').textContent = isSingleRevision ? 'REVISION PREVIEW' : 'VISUAL COMPARE'; - $('#compareHeading').textContent = isSingleRevision ? 'First retained Revision' : 'Rendered revision diff'; - $('#compareNote').textContent = isSingleRevision ? 'There is no earlier Revision to compare yet. This original remains sandboxed and unchanged.' : 'Both revisions render at the same viewport. The original HTML remains sandboxed and unchanged.'; - $$('#revisionTimeline [data-compare-revision]').forEach((button) => button.addEventListener('click', () => { - dialog.dataset.selectedRevisionId = button.dataset.compareRevision; - $('#compareTo').value = button.dataset.compareRevision; - const index = revisions.findIndex((revision) => revision.id === button.dataset.compareRevision); - $('#compareFrom').value = revisions[Math.max(0, index - 1)]?.id || button.dataset.compareRevision; - renderVisualDiff(); - })); - renderVisualDiff(); - if (!dialog.open) dialog.showModal(); -} - -function closeHistoryDialog() { - $('#diffBefore').removeAttribute('srcdoc'); - $('#diffAfter').removeAttribute('srcdoc'); - $('#diffBefore').src = 'about:blank'; - $('#diffAfter').src = 'about:blank'; - $('#historyDialog').removeAttribute('data-artifact-id'); - $('#historyDialog').removeAttribute('data-selected-revision-id'); -} - -async function forkArtifact() { - const source = historyArtifact() || selectedDocument(); - if (!source) return; - const revisionId = $('#historyDialog').open ? ($('#historyDialog').dataset.selectedRevisionId || $('#compareTo').value) : source.currentRevisionId; - const now = new Date().toISOString(); - const newId = `${source.id}-fork-${Date.now().toString(36)}`; - const result = await channelRepository.fork(source.id, { - id: newId, - title: `${source.title} — fork`, - source: 'Helm fork', - identityState: 'fork', - project: projectFor(source), - createdAt: now, - updatedAt: now - }, { artifactId: newId, revisionId }); - const fork = { ...result.document, revisions: await channelRepository.listRevisions(newId) }; - documents.push(fork); - selectedId = fork.id; - if ($('#historyDialog').open) $('#historyDialog').close(); - render(); - showToast(`Forked ${revisionLabel(source, revisionId)}. Copy the agent handoff to create its first aligned Revision.`); -} - -async function copyForkHandoff() { - const artifact = selectedDocument(); - if (!artifact?.forkedFrom || hasChannelIdentity(artifact)) return; - const prompt = `Continue the Helm fork "${artifact.title}" as Artifact ID "${artifact.id}". Read AI-GUIDE.md, docs/REPORT-DESIGN-STANDARD.md, and docs/HTML-DOCUMENT-SPEC.md. Use the forked Revision as source evidence, produce one finished self-contained HDOC/1.0 HTML file, and set the embedded manifest id exactly to "${artifact.id}". Keep the same ID for later revisions of this logical artifact.`; - const copied = await copyText(prompt); - showToast(copied ? 'Agent handoff copied.' : 'Could not copy the agent handoff.'); -} - -async function openLineage() { - const artifact = selectedDocument(); - if (!artifact?.forkedFrom) return; - await loadLineageSource(artifact.forkedFrom.artifactId); - const source = documents.find((item) => item.id === artifact.forkedFrom.artifactId) || lineageSources.get(artifact.forkedFrom.artifactId); - if (!source) { showToast('The source artifact is not present in this library.'); return; } - openHistoryDialog(source, artifact.forkedFrom.revisionId); -} - -function archiveRecords() { - const records = new Map([...lineageSources.values(), ...documents.filter((artifact) => !artifact.remotePublication)].map((artifact) => [artifact.id, artifact])); - return [...records.values()].map(({ contentText, validation, ...record }) => record); -} - -async function exportArchive() { - try { - const backup = globalThis.HelmArchiveBackup; - if (!backup) throw new Error('Archive tools are unavailable.'); - const records = archiveRecords(); - if (backup.hasFileSystemAccess()) await backup.saveWithFileSystemAccess(records); - else backup.downloadArchive(records); - $('#archiveDialog').close(); - showToast(`${records.length} artifacts exported as a portable archive.`); - } catch (error) { - if (error.name !== 'AbortError') { console.error(error); showToast('Archive export could not be completed.'); } - } -} - -async function importArchiveFile(file) { - try { - const backup = globalThis.HelmArchiveBackup; - if (!backup) throw new Error('Archive tools are unavailable.'); - const archive = await backup.readArchiveFile(file); - const plan = backup.prepareImport(archive, archiveRecords()); - const takenIds = new Set(documents.map((artifact) => artifact.id)); - const knownSourceIds = sourceIdentitySet(); - const accepted = []; - const duplicates = []; - for (const artifact of plan.acceptedDocuments) { - const enriched = enrichArtifact(artifact, { takenIds, preserveId: true }); - const sourceConflict = Boolean(enriched.sourceDocumentId && knownSourceIds.has(enriched.sourceDocumentId)); - if (enriched.sourceDocumentId) knownSourceIds.add(enriched.sourceDocumentId); - if (sourceConflict) { - enriched.identityState = 'catalog-copy'; - enriched.validation.issues.unshift({ severity: 'warning', code: 'catalog-copy-identity', message: `Source manifest ID “${enriched.sourceDocumentId}” already exists here. This is an explicit catalog copy with library ID “${enriched.id}”; its original HTML was not renamed.` }); - duplicates.push(enriched); - } else accepted.push(enriched); - } - $('#archiveDialog').close(); - if (duplicates.length) showDuplicateImportResolution(accepted, duplicates, { label: 'archive artifact', skipped: plan.skippedCount }); - else await persistImportedArtifacts(accepted, { label: 'archive artifact', skipped: plan.skippedCount }); - } catch (error) { - console.error(error); - showToast('That file is not a valid Helm archive.'); - } -} - -function openMetadataDialog() { - const artifact = selectedDocument(); - if (!artifact) return; - editingId = artifact.id; - $('#catalogTitle').value = artifact.title; - $('#catalogType').value = artifact.type; - $('#catalogSummary').value = artifact.summary; - $('#catalogTags').value = artifact.tags.join(', '); - $('#catalogSource').value = artifact.source; - $('#catalogProject').value = projectFor(artifact).id === UNASSIGNED_PROJECT.id ? '' : projectFor(artifact).name; - $('#metadataDialog').showModal(); -} - -async function saveCatalogMetadata() { - const artifact = documents.find((item) => item.id === editingId); - const title = $('#catalogTitle').value.trim(); - if (!artifact || !title) return; - const type = $('#catalogType').value; - const patch = { - title: title.slice(0, 100), - type: DOCUMENT_TYPES.has(type) ? type : artifact.type, - summary: $('#catalogSummary').value.trim().slice(0, 240), - tags: normaliseTags($('#catalogTags').value), - source: $('#catalogSource').value.trim().slice(0, 120) || 'Personal archive', - project: normaliseProject($('#catalogProject').value) - }; - await channelRepository.updateCatalog(artifact.id, patch); - await refreshArtifact(artifact.id); - selectedId = artifact.id; - editingId = null; - $('#metadataDialog').close(); - render(); - showToast('Catalog metadata saved. Original HTML is unchanged.'); -} - -async function repairSelected() { - const artifact = selectedDocument(); - const repair = globalThis.HelmRepair; - if (!artifact || !repair?.createCompliantCopy) return; - try { - const result = repair.createCompliantCopy(artifact, { existingIds: new Set(documents.map((item) => item.id)) }); - const repaired = enrichArtifact(result.record, { takenIds: new Set(documents.map((item) => item.id)), preserveId: true }); - if (!repaired.validation.valid) throw new Error('The generated compliant copy did not pass validation.'); - const stored = await saveDocument(repaired); - documents.push(stored); - selectedId = stored.id; - render(); - showToast('A new compliant copy was created. The original is unchanged.'); - } catch (error) { - console.error(error); - showToast('A compliant copy could not be created.'); - } -} - -async function deleteSelected() { - const artifact = selectedDocument(); - if (!artifact) return; - const hasForks = documents.some((item) => item.forkedFrom?.artifactId === artifact.id); - const lastFolderSync = await getSetting('lastFolderSyncAt'); - const backupHint = lastFolderSync ? ` A folder sync was recorded on ${dateLabel(lastFolderSync)}; export or sync again if this change should be recoverable.` : ' No completed folder sync is recorded for this browser library.'; - if (!confirm(`Remove “${artifact.title}” from this browser? The original file will not be touched.${backupHint}`)) return; - await removeDocument(artifact.id, { hard: !hasForks }); - if (hasForks) lineageSources.set(artifact.id, artifact); - documents = documents.filter((item) => item.id !== artifact.id); - selectedId = null; - render(); - showToast(hasForks ? 'Artifact archived so existing Fork lineage remains verifiable.' : 'Artifact removed from this browser.'); -} - -function wireEvents() { - $$('[data-theme-mode]').forEach((button) => button.addEventListener('click', () => setAppearance(button.dataset.themeMode, true))); - $('#importButton').addEventListener('click', () => $('#fileInput').click()); - $('#agentInboxButton').addEventListener('click', openAgentInbox); - $('#agentInboxRefreshButton').addEventListener('click', () => { void refreshAgentInbox({ forDialog: true }); }); - $('#agentInboxImportButton').addEventListener('click', importAgentInbox); - $$('[data-close-agent-inbox]').forEach((button) => button.addEventListener('click', () => $('#agentInboxDialog').close())); - $('[data-empty-import]').addEventListener('click', () => $('#fileInput').click()); - $('#fileInput').addEventListener('change', async (event) => { await importFiles(event.target.files); event.target.value = ''; }); - $('#archiveButton').addEventListener('click', async () => { $('#archiveDocumentCount').textContent = String(archiveRecords().length).padStart(2, '0'); await renderFolderStatus(); $('#archiveDialog').showModal(); }); - $('#archiveExportButton').addEventListener('click', exportArchive); - $('#archiveImportButton').addEventListener('click', () => $('#archiveInput').click()); - $('#archiveInput').addEventListener('change', async (event) => { if (event.target.files[0]) await importArchiveFile(event.target.files[0]); event.target.value = ''; }); - $('[data-close-archive]').addEventListener('click', () => $('#archiveDialog').close()); - $('#chooseFolderButton').addEventListener('click', chooseArchiveFolder); - $('#syncFolderButton').addEventListener('click', syncArchiveFolder); - $('#recoverFolderButton').addEventListener('click', recoverArchiveFolder); - $('#templateButton').addEventListener('click', () => openCreateDialog(templates[0])); - $('#readContract').addEventListener('click', () => showView('contract')); - $$('.nav-item').forEach((button) => button.addEventListener('click', () => showView(button.dataset.view))); - $('#typeFilter').addEventListener('change', (event) => { activeFilter = event.target.value; renderLibrary(); }); - $('#clearLibraryFilters').addEventListener('click', () => { activeFilter = 'all'; activeProject = 'all'; $('#searchInput').value = ''; $('#typeFilter').value = 'all'; render(); }); - $('#searchInput').addEventListener('input', renderLibrary); - $('#sortSelect').addEventListener('change', renderLibrary); - $$('[data-close-create]').forEach((button) => button.addEventListener('click', () => $('#createDialog').close())); - $('#createForm').addEventListener('submit', (event) => { - event.preventDefault(); - createFromTemplate(); - }); - $('#previewButton').addEventListener('click', () => openReader()); - $('#exportButton').addEventListener('click', () => downloadDocument()); - $('#shareButton').addEventListener('click', () => publishDocument()); - $('#revokeShareButton').addEventListener('click', revokePublication); - $('#readerShare').addEventListener('click', () => publishDocument(readerDocument())); - $('#reviewButton').addEventListener('click', markReviewed); - $('#historyButton').addEventListener('click', () => openHistoryDialog()); - $('#readerHistory').addEventListener('click', () => openHistoryDialog(readerDocument())); - $('#forkButton').addEventListener('click', forkArtifact); - $('#forkArtifactButton').addEventListener('click', forkArtifact); - $('#copyForkHandoffButton').addEventListener('click', copyForkHandoff); - $('#openLineageButton').addEventListener('click', openLineage); - $('#compareFrom').addEventListener('change', renderVisualDiff); - $('#compareTo').addEventListener('change', () => { $('#historyDialog').dataset.selectedRevisionId = $('#compareTo').value; renderVisualDiff(); }); - $('#closeHistory').addEventListener('click', () => $('#historyDialog').close()); - $('#historyDialog').addEventListener('close', closeHistoryDialog); - $('#readerExport').addEventListener('click', () => downloadDocument(readerDocument())); - $('#closeReader').addEventListener('click', () => $('#readerDialog').close()); - $('#readerDialog').addEventListener('close', resetReaderFrame); - $('#editMetadataButton').addEventListener('click', openMetadataDialog); - $$('[data-close-metadata]').forEach((button) => button.addEventListener('click', () => { editingId = null; $('#metadataDialog').close(); })); - $('#metadataForm').addEventListener('submit', (event) => { - event.preventDefault(); - saveCatalogMetadata(); - }); - $('#repairButton').addEventListener('click', repairSelected); - $('#duplicateImportForm').addEventListener('submit', (event) => { event.preventDefault(); resolveDuplicateImports(event.submitter?.id === 'keepDuplicateImports'); }); - $('#duplicateImportDialog').addEventListener('cancel', (event) => { event.preventDefault(); resolveDuplicateImports(false); }); - $('#deleteButton').addEventListener('click', deleteSelected); - $('#closeInspector').addEventListener('click', () => { selectedId = null; renderLibrary(); renderInspector(); }); - $('#openShortcuts').addEventListener('click', () => $('#shortcutsDialog').showModal()); - $('[data-close-shortcuts]').addEventListener('click', () => $('#shortcutsDialog').close()); - document.addEventListener('keydown', (event) => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); $('#searchInput').focus(); } - if (!event.metaKey && !event.ctrlKey && !event.altKey && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') { - if (event.key.toLowerCase() === 'n') openCreateDialog(templates[0]); - if (event.key.toLowerCase() === 'i') $('#fileInput').click(); - } - }); -} - -wireEvents(); -initialise(); diff --git a/archive-backup.js b/archive-backup.js deleted file mode 100644 index 920ef1d..0000000 --- a/archive-backup.js +++ /dev/null @@ -1,351 +0,0 @@ -/* - * Helm portable archive helper. - * - * This file deliberately owns no storage. It turns document records into a - * durable JSON archive and plans a safe, non-destructive import. The app that - * calls it remains responsible for presenting conflicts and persisting only - * `acceptedDocuments`. - */ -(function attachHelmArchiveBackup(global) { - 'use strict'; - - const FORMAT = 'helm-archive'; - const SCHEMA_VERSION = 'HARC/1.0'; - const RECORD_KEYS = new Set(['id', 'title', 'type', 'tags', 'summary', 'source', 'project', 'createdAt', 'updatedAt', 'html', 'extensions']); - const RESERVED_EXTENSION_KEYS = new Set(['__proto__', 'prototype', 'constructor']); - - function isPlainObject(value) { - // `Object.getPrototypeOf(value) === Object.prototype` would reject valid - // records handed over from an iframe or another JavaScript realm. - return Boolean(value) && Object.prototype.toString.call(value) === '[object Object]'; - } - - function isIsoTimestamp(value) { - if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) return false; - return !Number.isNaN(Date.parse(value)); - } - - function cloneJson(value, path) { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError(`${path} must contain only JSON values.`); - return value; - } - if (Array.isArray(value)) return value.map((item, index) => cloneJson(item, `${path}[${index}]`)); - if (!isPlainObject(value)) throw new TypeError(`${path} must contain only JSON values.`); - const copy = {}; - for (const [key, item] of Object.entries(value)) { - if (RESERVED_EXTENSION_KEYS.has(key)) throw new TypeError(`${path}.${key} is not an allowed extension key.`); - copy[key] = cloneJson(item, `${path}.${key}`); - } - return copy; - } - - function issue(path, message) { - return { path, message }; - } - - function invalid(errors, path, message) { - errors.push(issue(path, message)); - } - - function normaliseExtensions(value, path, errors) { - if (value === undefined) return undefined; - if (!isPlainObject(value)) { - invalid(errors, path, 'must be a JSON object when present.'); - return undefined; - } - try { - const extensions = cloneJson(value, path); - Object.keys(extensions).forEach((key) => { - if (RECORD_KEYS.has(key)) invalid(errors, `${path}.${key}`, 'uses a reserved document field name.'); - }); - return extensions; - } catch (error) { - invalid(errors, path, error.message); - return undefined; - } - } - - function documentToArchiveRecord(record, index) { - const path = `documents[${index}]`; - if (!isPlainObject(record)) throw new TypeError(`${path} must be a document record.`); - if (typeof record.id !== 'string' || !record.id.trim()) throw new TypeError(`${path}.id must be a non-empty string.`); - if (typeof record.title !== 'string' || !record.title.trim()) throw new TypeError(`${path}.title must be a non-empty string.`); - if (typeof record.type !== 'string' || !record.type.trim()) throw new TypeError(`${path}.type must be a non-empty string.`); - if (!Array.isArray(record.tags) || record.tags.some((tag) => typeof tag !== 'string')) throw new TypeError(`${path}.tags must be an array of strings.`); - if (record.summary !== undefined && record.summary !== null && typeof record.summary !== 'string') throw new TypeError(`${path}.summary must be a string or null.`); - if (record.source !== undefined && record.source !== null && typeof record.source !== 'string') throw new TypeError(`${path}.source must be a string or null.`); - if (record.project !== undefined && (!isPlainObject(record.project) || typeof record.project.id !== 'string' || !record.project.id.trim() || typeof record.project.name !== 'string' || !record.project.name.trim())) throw new TypeError(`${path}.project must contain non-empty id and name strings.`); - if (!isIsoTimestamp(record.createdAt)) throw new TypeError(`${path}.createdAt must be an ISO 8601 timestamp.`); - if (!isIsoTimestamp(record.updatedAt)) throw new TypeError(`${path}.updatedAt must be an ISO 8601 timestamp.`); - if (typeof record.html !== 'string') throw new TypeError(`${path}.html must contain the original HTML source as a string.`); - - const extensions = {}; - for (const [key, value] of Object.entries(record)) { - if (!RECORD_KEYS.has(key)) extensions[key] = cloneJson(value, `${path}.${key}`); - } - if (record.extensions !== undefined) { - if (!isPlainObject(record.extensions)) throw new TypeError(`${path}.extensions must be a JSON object when present.`); - for (const [key, value] of Object.entries(record.extensions)) { - if (RESERVED_EXTENSION_KEYS.has(key) || RECORD_KEYS.has(key)) throw new TypeError(`${path}.extensions.${key} is a reserved field name.`); - extensions[key] = cloneJson(value, `${path}.extensions.${key}`); - } - } - - const metadata = { - title: record.title, - type: record.type, - tags: [...record.tags], - summary: record.summary ?? null, - source: record.source ?? null, - ...(record.project ? { project: cloneJson(record.project, `${path}.project`) } : {}), - created_at: record.createdAt, - updated_at: record.updatedAt - }; - if (Object.keys(extensions).length) metadata.extensions = extensions; - return { id: record.id, metadata, html: record.html }; - } - - function createArchive(records, options = {}) { - if (!Array.isArray(records)) throw new TypeError('createArchive expects an array of document records.'); - const exportedAt = options.exportedAt || new Date().toISOString(); - if (!isIsoTimestamp(exportedAt)) throw new TypeError('exportedAt must be an ISO 8601 timestamp.'); - const documents = records.map(documentToArchiveRecord); - const ids = new Set(); - documents.forEach((document, index) => { - if (ids.has(document.id)) throw new TypeError(`documents[${index}].id duplicates another record in this archive.`); - ids.add(document.id); - }); - return { - format: FORMAT, - schema_version: SCHEMA_VERSION, - exported_at: exportedAt, - document_count: documents.length, - documents - }; - } - - function validateArchive(payload) { - const errors = []; - if (!isPlainObject(payload)) { - return { valid: false, errors: [issue('$', 'Archive must be a JSON object.')] }; - } - if (payload.format !== FORMAT) invalid(errors, 'format', `must equal "${FORMAT}".`); - if (payload.schema_version !== SCHEMA_VERSION) invalid(errors, 'schema_version', `must equal "${SCHEMA_VERSION}".`); - if (!isIsoTimestamp(payload.exported_at)) invalid(errors, 'exported_at', 'must be an ISO 8601 timestamp.'); - if (!Number.isInteger(payload.document_count) || payload.document_count < 0) invalid(errors, 'document_count', 'must be a non-negative integer.'); - if (!Array.isArray(payload.documents)) invalid(errors, 'documents', 'must be an array.'); - if (Array.isArray(payload.documents) && payload.document_count !== payload.documents.length) invalid(errors, 'document_count', 'must match documents.length.'); - if (errors.length) return { valid: false, errors }; - - const documents = []; - const ids = new Set(); - payload.documents.forEach((document, index) => { - const path = `documents[${index}]`; - if (!isPlainObject(document)) { - invalid(errors, path, 'must be an object.'); - return; - } - if (typeof document.id !== 'string' || !document.id.trim()) invalid(errors, `${path}.id`, 'must be a non-empty string.'); - if (ids.has(document.id)) invalid(errors, `${path}.id`, 'duplicates another document id in this archive.'); - ids.add(document.id); - if (typeof document.html !== 'string') invalid(errors, `${path}.html`, 'must contain the original HTML source as a string.'); - if (!isPlainObject(document.metadata)) { - invalid(errors, `${path}.metadata`, 'must be an object.'); - return; - } - const metadata = document.metadata; - if (typeof metadata.title !== 'string' || !metadata.title.trim()) invalid(errors, `${path}.metadata.title`, 'must be a non-empty string.'); - if (typeof metadata.type !== 'string' || !metadata.type.trim()) invalid(errors, `${path}.metadata.type`, 'must be a non-empty string.'); - if (!Array.isArray(metadata.tags) || metadata.tags.some((tag) => typeof tag !== 'string')) invalid(errors, `${path}.metadata.tags`, 'must be an array of strings.'); - if (metadata.summary !== null && typeof metadata.summary !== 'string') invalid(errors, `${path}.metadata.summary`, 'must be a string or null.'); - if (metadata.source !== null && typeof metadata.source !== 'string') invalid(errors, `${path}.metadata.source`, 'must be a string or null.'); - if (metadata.project !== undefined && (!isPlainObject(metadata.project) || typeof metadata.project.id !== 'string' || !metadata.project.id.trim() || typeof metadata.project.name !== 'string' || !metadata.project.name.trim())) invalid(errors, `${path}.metadata.project`, 'must contain non-empty id and name strings when present.'); - if (!isIsoTimestamp(metadata.created_at)) invalid(errors, `${path}.metadata.created_at`, 'must be an ISO 8601 timestamp.'); - if (!isIsoTimestamp(metadata.updated_at)) invalid(errors, `${path}.metadata.updated_at`, 'must be an ISO 8601 timestamp.'); - const extensions = normaliseExtensions(metadata.extensions, `${path}.metadata.extensions`, errors); - - if (errors.some((entry) => entry.path === path || entry.path.startsWith(`${path}.`))) return; - const normalisedMetadata = { - title: metadata.title, - type: metadata.type, - tags: [...metadata.tags], - summary: metadata.summary, - source: metadata.source, - ...(metadata.project ? { project: cloneJson(metadata.project, `${path}.metadata.project`) } : {}), - created_at: metadata.created_at, - updated_at: metadata.updated_at - }; - if (extensions && Object.keys(extensions).length) normalisedMetadata.extensions = extensions; - documents.push({ id: document.id, metadata: normalisedMetadata, html: document.html }); - }); - - if (errors.length) return { valid: false, errors }; - return { - valid: true, - errors: [], - archive: { - format: FORMAT, - schema_version: SCHEMA_VERSION, - exported_at: payload.exported_at, - document_count: documents.length, - documents - } - }; - } - - function parseArchive(input) { - let payload = input; - if (typeof input === 'string') { - try { - payload = JSON.parse(input); - } catch (error) { - const parseError = new TypeError(`Archive is not valid JSON: ${error.message}`); - parseError.errors = [issue('$', 'Archive is not valid JSON.')]; - throw parseError; - } - } - const result = validateArchive(payload); - if (result.valid) return result.archive; - const error = new TypeError(`Invalid Helm archive: ${result.errors.map((entry) => `${entry.path} ${entry.message}`).join(' ')}`); - error.errors = result.errors; - throw error; - } - - function serialiseArchive(archive, space = 2) { - return JSON.stringify(parseArchive(archive), null, space); - } - - function archiveDocumentToRecord(document) { - const metadata = document.metadata; - const record = { - id: document.id, - title: metadata.title, - type: metadata.type, - tags: [...metadata.tags], - summary: metadata.summary ?? '', - source: metadata.source ?? '', - ...(metadata.project ? { project: cloneJson(metadata.project, 'metadata.project') } : {}), - createdAt: metadata.created_at, - updatedAt: metadata.updated_at, - html: document.html - }; - if (metadata.extensions) { - for (const [key, value] of Object.entries(metadata.extensions)) { - if (!RECORD_KEYS.has(key) && !RESERVED_EXTENSION_KEYS.has(key)) record[key] = cloneJson(value, `metadata.extensions.${key}`); - } - } - return record; - } - - function coerceArchive(recordsOrArchive, options) { - return Array.isArray(recordsOrArchive) ? createArchive(recordsOrArchive, options) : parseArchive(recordsOrArchive); - } - - function prepareImport(payload, existingDocuments = []) { - const archive = parseArchive(payload); - if (!Array.isArray(existingDocuments)) throw new TypeError('existingDocuments must be an array of current document records.'); - const existingById = new Map(); - existingDocuments.forEach((document, index) => { - if (document && typeof document.id === 'string' && document.id) existingById.set(document.id, { document, index }); - }); - const acceptedDocuments = []; - const conflicts = []; - archive.documents.forEach((document) => { - const existing = existingById.get(document.id); - if (existing) { - conflicts.push({ - id: document.id, - reason: 'existing-id', - incoming: archiveDocumentToRecord(document), - existing: existing.document, - existingIndex: existing.index - }); - } else { - acceptedDocuments.push(archiveDocumentToRecord(document)); - } - }); - return { - valid: true, - archive, - acceptedDocuments, - conflicts, - skippedCount: conflicts.length, - importedCount: acceptedDocuments.length - }; - } - - function archiveFilename(exportedAt) { - return `helm-archive-${exportedAt.slice(0, 10)}.json`; - } - - function downloadArchive(recordsOrArchive, options = {}) { - if (!global.document || !global.URL || typeof global.URL.createObjectURL !== 'function') throw new Error('Downloading an archive requires a browser document.'); - const archive = coerceArchive(recordsOrArchive, options); - const filename = options.filename || archiveFilename(archive.exported_at); - const blob = new Blob([serialiseArchive(archive)], { type: 'application/json;charset=utf-8' }); - const url = global.URL.createObjectURL(blob); - const anchor = global.document.createElement('a'); - anchor.href = url; - anchor.download = filename; - anchor.hidden = true; - global.document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - global.setTimeout(() => global.URL.revokeObjectURL(url), 0); - return { archive, filename }; - } - - async function readArchiveFile(file) { - if (!file || typeof file.text !== 'function') throw new TypeError('readArchiveFile expects a File or Blob with a text() method.'); - return parseArchive(await file.text()); - } - - function hasFileSystemAccess() { - return typeof global.showSaveFilePicker === 'function' && typeof global.showOpenFilePicker === 'function'; - } - - async function saveWithFileSystemAccess(recordsOrArchive, options = {}) { - if (typeof global.showSaveFilePicker !== 'function') throw new Error('The File System Access API is unavailable in this browser. Use downloadArchive instead.'); - const archive = coerceArchive(recordsOrArchive, options); - const filename = options.filename || archiveFilename(archive.exported_at); - const handle = options.handle || await global.showSaveFilePicker({ - suggestedName: filename, - types: [{ description: 'Helm archive', accept: { 'application/json': ['.json'] } }] - }); - const writable = await handle.createWritable(); - await writable.write(serialiseArchive(archive)); - await writable.close(); - return { archive, handle, filename }; - } - - async function openWithFileSystemAccess(options = {}) { - if (typeof global.showOpenFilePicker !== 'function') throw new Error('The File System Access API is unavailable in this browser. Use a file input with readArchiveFile instead.'); - const handle = options.handle || (await global.showOpenFilePicker({ - multiple: false, - types: [{ description: 'Helm archive', accept: { 'application/json': ['.json'] } }] - }))[0]; - if (!handle) throw new Error('No archive file was selected.'); - const archive = await readArchiveFile(await handle.getFile()); - const plan = prepareImport(archive, options.existingDocuments || []); - return { ...plan, handle }; - } - - global.HelmArchiveBackup = Object.freeze({ - FORMAT, - SCHEMA_VERSION, - createArchive, - validateArchive, - parseArchive, - serialiseArchive, - archiveDocumentToRecord, - prepareImport, - importArchive: prepareImport, - downloadArchive, - readArchiveFile, - hasFileSystemAccess, - saveWithFileSystemAccess, - openWithFileSystemAccess - }); -}(window)); diff --git a/authoring/component-gallery.html b/authoring/component-gallery.html deleted file mode 100644 index d697b7b..0000000 --- a/authoring/component-gallery.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - Helm component studio - - - - -
    -
    component-gallery · HDOC/1.0Updated 2026-07-15
    -
    -

    Evidence original · component-gallery

    Helm component studio

    A rendered catalog of the visual components agents can compose into portable Helm reports.

    -
    Profile
    component-gallery
    Components
    10 visual modules
    Mode
    chapter-reveal
    Evidence
    Rendered specimens
    -
    -

    Short answer

    Components are useful only when agents can select, compose, and validate them as part of the authoring workflow.

    - -
    01 / COMPONENTS

    A visual grammar agents can actually call.

    Each specimen includes a claim, evidence state, scope, source note, and accessible fallback.

    01 / 10

    KPI strip · headline measures

    The first screen exposes the measures that change the decision.

    VERIFIEDIllustrative specimen; values explain component behavior
    Coverage
    82%
    41 / 50 evaluated cases
    p50 latency
    1.8s
    single controlled environment
    Failure rate
    6%
    3 / 50 cases
    Confidence
    Med.
    one environment; repeat needed

    Helm component specimen / 2026-07-14. Illustrative specimen; values explain component behavior.

    -
    02 / 10

    Evidence ledger · claims are not equal

    The conclusion is only as strong as its visible evidence state.

    VERIFIEDIllustrative specimen; values explain component behavior
    Claim, evidence state, best source, and boundary
    IDClaimStateBest evidenceBoundary
    C02.1Replace with a verified factual claim.VerifiedSource + dateVersion / environment
    C02.2Replace with a clearly labelled interpretation.InterpretedC02.1 + reasoningNot source wording
    C02.3Replace with a proposed next move.ProposedObserved gapNot yet verified
    Helm component specimen / 2026-07-14. Illustrative specimen; values explain component behavior.
    -
    03 / 10

    Comparison matrix · same criteria

    The selected option wins on the criterion that matters, not on presentation.

    VERIFIEDIllustrative specimen; values explain component behavior
    Alternatives evaluated on common criteria
    OptionDecisive upsideCost / riskEvidenceRevisit if
    Recommended optionReplace with measurable benefitKnown trade-offSource / testReversal condition
    AlternativeReal upsideWhy it is not selectedSource / assumptionTrigger
    Helm component specimen / 2026-07-14. Illustrative specimen; values explain component behavior.
    -
    04 / 10

    Ranked bars · magnitude

    One option leads on the measured outcome, within this environment.

    VERIFIEDIllustrative specimen; values explain component behavior
    Helm component specimen / 2026-07-14. Illustrative specimen; values explain component behavior.
    -
    05 / 10

    Range · uncertainty

    The tail changes the conclusion that the average would suggest.

    VERIFIEDIllustrative specimen; values explain component behavior
    Helm component specimen / 2026-07-14. Illustrative specimen; values explain component behavior.
    -
    06 / 10

    Sequence · verb-labelled handoff

    The result becomes durable only after evidence passes an explicit review boundary.

    VERIFIEDIllustrative specimen; values explain component behavior
    1. 01 / COLLECTQuestionDefine reader and boundary.
    2. 02 / VERIFYEvidenceSeparate fact from inference.
    3. 03 / EXPLAINComponentMake the relation visible.
    4. 04 / RETAINArtifactValidate and submit once.
    Helm component specimen / 2026-07-14. Illustrative specimen; values explain component behavior.
    -
    07 / 10

    Hierarchy · ownership and scope

    Ownership stays legible when storage, projection, and excluded scope are separated.

    VERIFIEDIllustrative specimen; values explain component behavior
    Helm component specimen / 2026-07-14. Illustrative specimen; values explain component behavior.
    -
    08 / 10

    Trace · part-to-whole

    Most of the total comes from two stages, so optimization should start there.

    VERIFIEDIllustrative specimen; values explain component behavior
    Helm component specimen / 2026-07-14. Illustrative specimen; values explain component behavior.
    -
    09 / 10

    Case ledger · comparable examples

    The pattern holds in common cases but fails at one explicit boundary.

    VERIFIEDIllustrative specimen; values explain component behavior
    CASE 01PASS

    Common path

    Evidence, observed outcome, and why it matters.

    CASE 02PASS

    Different context

    Same structure, directly comparable labels.

    CASE 03BOUNDARY

    Failure case

    The condition that breaks the generalization.

    Helm component specimen / 2026-07-14. Illustrative specimen; values explain component behavior.
    -
    10 / 10

    Roadmap · evidence to action

    The next move is small, owned, measurable, and reversible.

    VERIFIEDIllustrative specimen; values explain component behavior
    NOW
    Replace with the smallest useful actionWhy this follows from the evidence.
    OwnerPerson or team
    CheckpointMetric / date / condition
    NEXT
    Follow only if the checkpoint passesDo not imply certainty before evidence exists.
    EvidenceRequired proof
    Revisit ifReversal condition
    Helm component specimen / 2026-07-14. Illustrative specimen; values explain component behavior.
    -

    Sources and method

    Make the evidence boundary recoverable.

    1. This gallery is generated by scripts/helm-report and contains illustrative component data.
    2. No runtime CDN, executable JavaScript, or remote visual dependency is required.
    -
    - - \ No newline at end of file diff --git a/bin/helm b/bin/helm new file mode 100755 index 0000000..a50c78e --- /dev/null +++ b/bin/helm @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Helm — local-first artifact library for AI agents. + +One tiny CLI, Python standard library only, zero runtime dependency. + + helm init set the library path + install the authoring skill + helm new scaffold a standalone HDOC artifact into the library + helm index rescan the library and rebuild catalog.json + helm serve serve the gallery + library over static HTTP + helm check <file> validate a file against the HDOC contract + +The library is just a folder. Each artifact is one self-contained HTML file +at <library>/<slug>/index.html carrying an inline Helm manifest. Nothing +here talks to a database, a daemon, or the network. +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import http.server +import json +import os +import re +import shutil +import socketserver +import sys +import unicodedata +from pathlib import Path + +SCHEMA = "HDOC/1.1" +REPO_ROOT = Path(__file__).resolve().parent.parent +CONFIG_PATH = Path.home() / ".helm" / "config.json" +MANIFEST_RE = re.compile( + r'<script[^>]+type=["\']application/helm\+json["\'][^>]*>(.*?)</script>', + re.DOTALL | re.IGNORECASE, +) +DOC_TYPES = { + "report", "brief", "reference", "dashboard", + "decision", "research", "benchmark", "note", +} + +C = { + "dim": "\033[2m", "b": "\033[1m", "cy": "\033[36m", + "gr": "\033[32m", "yl": "\033[33m", "rd": "\033[31m", "x": "\033[0m", +} +if not sys.stdout.isatty(): + C = {k: "" for k in C} + + +def say(msg: str) -> None: + print(msg) + + +def die(msg: str) -> None: + print(f"{C['rd']}helm: {msg}{C['x']}", file=sys.stderr) + raise SystemExit(1) + + +def today() -> str: + return _dt.date.today().isoformat() + + +# --------------------------------------------------------------------------- config + +def load_config() -> dict: + if CONFIG_PATH.exists(): + try: + return json.loads(CONFIG_PATH.read_text("utf-8")) + except json.JSONDecodeError: + die(f"corrupt config at {CONFIG_PATH}") + return {} + + +def save_config(cfg: dict) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text(json.dumps(cfg, indent=2) + "\n", "utf-8") + + +def library_dir(explicit: str | None = None) -> Path: + if explicit: + return Path(explicit).expanduser().resolve() + env = os.environ.get("HELM_LIBRARY") + if env: + return Path(env).expanduser().resolve() + cfg = load_config() + if cfg.get("library"): + return Path(cfg["library"]).expanduser().resolve() + return (REPO_ROOT / "library").resolve() + + +# --------------------------------------------------------------------------- slug + +def slugify(text: str) -> str: + text = unicodedata.normalize("NFKD", text) + ascii_text = text.encode("ascii", "ignore").decode("ascii").lower() + ascii_text = re.sub(r"[^a-z0-9]+", "-", ascii_text).strip("-") + if not ascii_text: + # non-latin title: keep a stable hash-free fallback from code points + ascii_text = "artifact-" + "-".join(str(ord(c)) for c in text[:4] if c.strip()) + return ascii_text[:60] or "artifact" + + +# --------------------------------------------------------------------------- manifest + +def read_manifest(html: str) -> dict | None: + m = MANIFEST_RE.search(html) + if not m: + return None + try: + return json.loads(m.group(1)) + except json.JSONDecodeError: + return None + + +# --------------------------------------------------------------------------- commands + +def cmd_init(args) -> None: + lib = library_dir(args.library) + lib.mkdir(parents=True, exist_ok=True) + cfg = load_config() + cfg["library"] = str(lib) + save_config(cfg) + say(f"{C['gr']}library{C['x']} {lib}") + say(f"{C['gr']}config{C['x']} {CONFIG_PATH}") + + # install the authoring skill into detected agents + skill_src = REPO_ROOT / "skill" + installed = [] + claude_skills = Path.home() / ".claude" / "skills" + if (Path.home() / ".claude").exists(): + dest = claude_skills / "helm-report" + claude_skills.mkdir(parents=True, exist_ok=True) + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(skill_src, dest) + installed.append(f"Claude Code {dest}") + + if installed: + say("") + say(f"{C['b']}skill installed{C['x']}") + for line in installed: + say(f" {line}") + + say("") + say(f"{C['b']}next{C['x']}") + say(f" export HELM_LIBRARY={lib} {C['dim']}# so any agent knows the target{C['x']}") + say(f" bin/helm new \"My first report\" {C['dim']}# scaffold an artifact{C['x']}") + say(f" bin/helm serve {C['dim']}# browse the gallery{C['x']}") + say("") + say(f"{C['dim']}Codex / other agents: point them at skill/SKILL.md and set HELM_LIBRARY.{C['x']}") + + +def cmd_new(args) -> None: + lib = library_dir(args.library) + lib.mkdir(parents=True, exist_ok=True) + template = REPO_ROOT / "skill" / "template.html" + if not template.exists(): + die("skill/template.html missing") + + title = args.title + slug = args.id or slugify(title) + doc_type = args.type + if doc_type not in DOC_TYPES: + die(f"unknown type {doc_type!r}; expected one of {', '.join(sorted(DOC_TYPES))}") + + art_dir = lib / slug + dest = art_dir / "index.html" + if dest.exists() and not args.force: + die(f"{dest} exists; pass --force to overwrite") + art_dir.mkdir(parents=True, exist_ok=True) + + html = template.read_text("utf-8") + manifest = { + "schema": SCHEMA, + "id": slug, + "title": title, + "type": doc_type, + "summary": args.summary or "", + "tags": [t for t in (args.tags or "").split(",") if t.strip()], + "source": args.source or os.environ.get("HELM_AGENT", "agent"), + "created": today(), + "updated": today(), + } + manifest_json = json.dumps(manifest, ensure_ascii=False, indent=2) + + html = _replace_between(html, "helm+json", manifest_json) + html = html.replace("{{TITLE}}", _escape(title)) + html = html.replace("{{SUMMARY}}", _escape(manifest["summary"])) + html = html.replace("{{TYPE}}", doc_type.upper()) + html = html.replace("{{DATE}}", today()) + dest.write_text(html, "utf-8") + + say(f"{C['gr']}created{C['x']} {dest}") + say(f"{C['dim']}edit it, replace every specimen, then:{C['x']}") + say(" bin/helm check " + str(dest)) + say(" bin/helm index") + + +def _escape(s: str) -> str: + return (s.replace("&", "&").replace("<", "<").replace(">", ">")) + + +def _replace_between(html: str, marker: str, payload: str) -> str: + """Replace the body of the <script type=...marker...> block, keep the tags.""" + pat = re.compile( + r'(<script[^>]+type=["\']application/' + re.escape(marker) + + r'["\'][^>]*>)(.*?)(</script>)', + re.DOTALL | re.IGNORECASE, + ) + if not pat.search(html): + return html + return pat.sub(lambda m: m.group(1) + "\n" + payload + "\n" + m.group(3), html, count=1) + + +def cmd_index(args) -> None: + lib = library_dir(args.library) + if not lib.exists(): + die(f"library not found: {lib} (run: helm init)") + + entries = [] + skipped = [] + for html_file in sorted(lib.glob("*/index.html")): + html = html_file.read_text("utf-8", errors="replace") + manifest = read_manifest(html) + slug = html_file.parent.name + if not manifest: + skipped.append(slug) + continue + stat = html_file.stat() + entries.append({ + "id": manifest.get("id", slug), + "slug": slug, + "title": manifest.get("title", slug), + "type": manifest.get("type", "note"), + "summary": manifest.get("summary", ""), + "tags": manifest.get("tags", []), + "source": manifest.get("source", ""), + "created": manifest.get("created", ""), + "updated": manifest.get("updated", ""), + "path": f"{slug}/index.html", + "bytes": stat.st_size, + }) + + entries.sort(key=lambda e: (e.get("updated") or "", e.get("created") or ""), reverse=True) + catalog = { + "schema": SCHEMA, + "generated": today(), + "count": len(entries), + "artifacts": entries, + } + out = lib / "catalog.json" + out.write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", "utf-8") + + say(f"{C['gr']}indexed{C['x']} {len(entries)} artifact(s) -> {out}") + if skipped: + say(f"{C['yl']}skipped{C['x']} {len(skipped)} without a manifest: {', '.join(skipped)}") + + +def cmd_check(args) -> None: + path = Path(args.file).expanduser() + if not path.exists(): + die(f"no such file: {path}") + html = path.read_text("utf-8", errors="replace") + problems, warnings = _contract_problems(html) + + for w in warnings: + say(f"{C['yl']}warn{C['x']} {w}") + if problems: + for p in problems: + say(f"{C['rd']}fail{C['x']} {p}") + raise SystemExit(1) + say(f"{C['gr']}ok{C['x']} {path} passes the HDOC/{SCHEMA.split('/')[1]} contract") + + +def _contract_problems(html: str) -> tuple[list[str], list[str]]: + problems, warnings = [], [] + manifest = read_manifest(html) + if manifest is None: + problems.append('missing inline manifest <script type="application/helm+json">') + else: + for key in ("id", "title", "type"): + if not manifest.get(key): + problems.append(f"manifest missing required field: {key}") + t = manifest.get("type") + if t and t not in DOC_TYPES: + problems.append(f"manifest.type {t!r} not in {sorted(DOC_TYPES)}") + + low = html.lower() + if "<main" not in low: + problems.append("no semantic <main> document root") + if "placeholder" in low or "{{title}}" in low: + problems.append("unreplaced specimen/placeholder content remains") + + # standalone: no external runtime dependency + for pat, label in ( + (r'<script[^>]+src=', "external <script src=> (must inline)"), + (r'<link[^>]+rel=["\']stylesheet["\']', "external stylesheet <link> (must inline)"), + (r'@import\s', "CSS @import (must inline)"), + ): + if re.search(pat, html, re.IGNORECASE): + problems.append(f"not self-contained: {label}") + if re.search(r'src=["\']https?://', html, re.IGNORECASE): + warnings.append("remote asset URL found; embed as data: URI for durability") + + return problems, warnings + + +def cmd_serve(args) -> None: + lib = library_dir(args.library) + # serve repo root so /gallery and the library are both reachable + root = REPO_ROOT + # expose the library at a stable path regardless of where it lives + link = root / "library" + external = lib.resolve() != link.resolve() + if external and not link.exists(): + try: + link.symlink_to(lib) + except OSError: + die(f"library is at {lib}; symlink it to {link} or run serve from there") + + port = args.port + handler = _quiet_handler(root) + + class Server(socketserver.TCPServer): + allow_reuse_address = True + + try: + httpd = Server(("127.0.0.1", port), handler) + except OSError as exc: + die(f"cannot bind 127.0.0.1:{port} ({exc}); try --port <other>") + with httpd: + say(f"{C['gr']}serving{C['x']} http://127.0.0.1:{port}/gallery/") + say(f"{C['dim']}library {lib}{C['x']}") + say(f"{C['dim']}Ctrl-C to stop{C['x']}") + try: + httpd.serve_forever() + except KeyboardInterrupt: + say("\nstopped") + + +def _quiet_handler(root: Path): + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=str(root), **kw) + + def log_message(self, fmt, *a): # quiet + pass + return Handler + + +# --------------------------------------------------------------------------- entry + +def main(argv=None) -> int: + p = argparse.ArgumentParser(prog="helm", description=__doc__.splitlines()[0]) + sub = p.add_subparsers(dest="cmd", required=True) + + sp = sub.add_parser("init", help="set library path + install the authoring skill") + sp.add_argument("--library", help="library directory (default: ./library)") + sp.set_defaults(fn=cmd_init) + + sp = sub.add_parser("new", help="scaffold a standalone HDOC artifact") + sp.add_argument("title") + sp.add_argument("--id", help="stable slug (default: from title)") + sp.add_argument("--type", default="report") + sp.add_argument("--summary", default="") + sp.add_argument("--tags", default="", help="comma-separated") + sp.add_argument("--source", default="", help="agent name") + sp.add_argument("--library") + sp.add_argument("--force", action="store_true") + sp.set_defaults(fn=cmd_new) + + sp = sub.add_parser("index", help="rebuild catalog.json") + sp.add_argument("--library") + sp.set_defaults(fn=cmd_index) + + sp = sub.add_parser("check", help="validate the HDOC contract") + sp.add_argument("file") + sp.set_defaults(fn=cmd_check) + + sp = sub.add_parser("serve", help="static HTTP for gallery + library") + sp.add_argument("--port", type=int, default=4173) + sp.add_argument("--library") + sp.set_defaults(fn=cmd_serve) + + args = p.parse_args(argv) + args.fn(args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/channel-store.js b/channel-store.js deleted file mode 100644 index bcabfa9..0000000 --- a/channel-store.js +++ /dev/null @@ -1,679 +0,0 @@ -/* - * Helm Channels repository. - * - * Logical artifacts and immutable HTML revisions are stored separately. The - * legacy `documents` store is retained as a read-only migration source. - */ -(function attachHelmChannelStore(global) { - 'use strict'; - - const DEFAULT_DB_NAME = 'helm-html-archive'; - const DB_VERSION = 4; - const LEGACY_STORE = 'documents'; - const ARTIFACT_STORE = 'artifacts'; - const REVISION_STORE = 'revisions'; - const SETTINGS_STORE = 'settings'; - const MIGRATION_KEY = 'channelsMigrationV1'; - const LEGACY_SHARE_NORMALISATION_KEY = 'legacyShareNormalisationV1'; - const STATUSES = new Set(['draft', 'in-review', 'published', 'archived']); - const CATALOG_FIELDS = new Set(['title', 'type', 'tags', 'summary', 'source', 'project']); - const ARTIFACT_FIELDS = new Set([ - 'id', 'title', 'type', 'tags', 'summary', 'source', 'project', 'createdAt', - 'updatedAt', 'catalogUpdatedAt', 'status', 'currentRevisionId', - 'publishedRevisionId', 'forkedFrom', 'sourceDocumentId', 'identityState', - 'extensions' - ]); - const REVISION_INPUT_FIELDS = new Set([ - ...ARTIFACT_FIELDS, 'html', 'contentText', 'validation', 'share', 'revisionId', - 'contentHash', 'parent', 'authoredAt', 'author', 'derivedVersion' - ]); - - class ChannelStoreError extends Error { - constructor(code, message, details = {}) { - super(message); - this.name = 'ChannelStoreError'; - this.code = code; - Object.assign(this, details); - } - } - - function requestResult(request) { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - } - - function transactionDone(transaction) { - return new Promise((resolve, reject) => { - transaction.oncomplete = () => resolve(); - transaction.onabort = () => reject(transaction.error || new Error('IndexedDB transaction aborted.')); - transaction.onerror = () => { /* onabort carries the final error */ }; - }); - } - - function isPlainObject(value) { - return Boolean(value) && Object.prototype.toString.call(value) === '[object Object]'; - } - - function cloneJson(value, path = 'value') { - if (value === undefined) return undefined; - if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError(`${path} must contain only JSON values.`); - return value; - } - if (Array.isArray(value)) return value.map((entry, index) => cloneJson(entry, `${path}[${index}]`)); - if (!isPlainObject(value)) throw new TypeError(`${path} must contain only JSON values.`); - const copy = {}; - for (const [key, entry] of Object.entries(value)) { - if (key === '__proto__' || key === 'prototype' || key === 'constructor') throw new TypeError(`${path}.${key} is not allowed.`); - copy[key] = cloneJson(entry, `${path}.${key}`); - } - return copy; - } - - function safeString(value, fallback = '') { - return typeof value === 'string' && value.trim() ? value.trim() : fallback; - } - - function timestamp(value, fallback) { - return typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? new Date(value).toISOString() : fallback; - } - - function normaliseProject(value) { - if (!isPlainObject(value)) return { id: 'unassigned', name: 'Needs project' }; - return { - id: safeString(value.id, 'unassigned'), - name: safeString(value.name, safeString(value.id, 'Needs project')) - }; - } - - function normaliseTags(value) { - return (Array.isArray(value) ? value : []).filter((tag) => typeof tag === 'string').map((tag) => tag.trim()).filter(Boolean); - } - - function normaliseMigratedShare(value) { - if (!isPlainObject(value)) return null; - if (value.kind === 'legacy') return cloneJson(value, 'share'); - if (safeString(value.stableUrl)) return cloneJson(value, 'share'); - const legacyUrl = safeString(value.legacyUrl, safeString(value.url)); - let legacyPath = safeString(value.legacyPath, safeString(value.path)); - if (!legacyPath && legacyUrl) { - try { legacyPath = new URL(legacyUrl, 'http://helm.local').pathname; } - catch (_error) { /* Leave malformed historical metadata visible but non-actionable. */ } - } - if (!legacyUrl && !legacyPath) return cloneJson(value, 'share'); - return { - kind: 'legacy', - legacyUrl: legacyUrl || legacyPath, - legacyPath: legacyPath || null, - sha256: safeString(value.sha256) || null, - publishedAt: timestamp(value.publishedAt, null), - revokedAt: timestamp(value.revokedAt, null) - }; - } - - function extraFields(input, knownFields) { - const extensions = isPlainObject(input.extensions) ? cloneJson(input.extensions, 'extensions') : {}; - for (const [key, value] of Object.entries(input)) { - if (!knownFields.has(key) && value !== undefined) extensions[key] = cloneJson(value, key); - } - return Object.keys(extensions).length ? extensions : undefined; - } - - async function sha256(html) { - if (!global.crypto?.subtle || typeof global.TextEncoder !== 'function') { - throw new ChannelStoreError('webcrypto-unavailable', 'Helm Channels requires WebCrypto SHA-256 support.'); - } - const digest = await global.crypto.subtle.digest('SHA-256', new TextEncoder().encode(html)); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); - } - - function revisionIdForHash(hash) { - return `sha256:${hash}`; - } - - function normaliseParent(value) { - if (value === null || value === undefined) return null; - if (!isPlainObject(value) || !safeString(value.artifactId) || !safeString(value.revisionId)) { - throw new TypeError('A revision parent must contain artifactId and revisionId.'); - } - return { artifactId: value.artifactId.trim(), revisionId: value.revisionId.trim() }; - } - - function artifactFromInput(input, options = {}) { - const now = options.now || new Date().toISOString(); - const id = safeString(options.id || input.id); - if (!id) throw new TypeError('An artifact id is required.'); - const createdAt = timestamp(input.createdAt, now); - const status = STATUSES.has(options.status || input.status) ? (options.status || input.status) : 'draft'; - return { - id, - title: safeString(input.title, 'Untitled artifact'), - type: safeString(input.type, 'reference'), - tags: normaliseTags(input.tags), - summary: typeof input.summary === 'string' ? input.summary : '', - source: typeof input.source === 'string' ? input.source : '', - project: normaliseProject(input.project), - createdAt, - updatedAt: timestamp(input.updatedAt, createdAt), - catalogUpdatedAt: timestamp(input.catalogUpdatedAt, timestamp(input.updatedAt, createdAt)), - status, - currentRevisionId: options.revisionId, - publishedRevisionId: options.publishedRevisionId || null, - forkedFrom: options.forkedFrom ? normaliseParent(options.forkedFrom) : null, - sourceDocumentId: safeString(input.sourceDocumentId) || null, - identityState: safeString(input.identityState, input.sourceDocumentId ? 'aligned' : 'unmanaged'), - ...(extraFields(input, REVISION_INPUT_FIELDS) ? { extensions: extraFields(input, REVISION_INPUT_FIELDS) } : {}) - }; - } - - function revisionFromInput(artifactId, input, hash, options = {}) { - const now = options.now || new Date().toISOString(); - const id = revisionIdForHash(hash); - return { - artifactId, - id, - contentHash: hash, - parent: normaliseParent(options.parent), - createdAt: now, - authoredAt: timestamp(input.authoredAt || input.updatedAt, timestamp(input.createdAt, now)), - author: safeString(input.author, safeString(input.source)), - html: input.html, - contentText: typeof input.contentText === 'string' ? input.contentText : '', - validation: input.validation === undefined ? null : cloneJson(input.validation, 'validation'), - derivedVersion: Number.isInteger(input.derivedVersion) && input.derivedVersion > 0 ? input.derivedVersion : null, - sourceManifestId: safeString(input.sourceDocumentId) || null, - share: options.share === undefined || options.share === null ? null : cloneJson(options.share, 'share') - }; - } - - function projectArtifact(artifact, revision) { - if (!artifact || !revision) return null; - return { - ...cloneJson(artifact, 'artifact'), - html: revision.html, - contentText: revision.contentText || '', - validation: revision.validation, - derivedVersion: revision.derivedVersion || null, - share: revision.share, - revisionId: revision.id, - contentHash: revision.contentHash, - revisionCreatedAt: revision.createdAt, - parentRevision: revision.parent - }; - } - - function createIndexes(db) { - let artifacts; - if (!db.objectStoreNames.contains(ARTIFACT_STORE)) artifacts = db.createObjectStore(ARTIFACT_STORE, { keyPath: 'id' }); - else artifacts = null; - if (artifacts) { - artifacts.createIndex('by_project', 'project.id', { unique: false }); - artifacts.createIndex('by_status', 'status', { unique: false }); - artifacts.createIndex('by_updated_at', 'catalogUpdatedAt', { unique: false }); - } - let revisions; - if (!db.objectStoreNames.contains(REVISION_STORE)) revisions = db.createObjectStore(REVISION_STORE, { keyPath: ['artifactId', 'id'] }); - else revisions = null; - if (revisions) { - revisions.createIndex('by_artifact', 'artifactId', { unique: false }); - revisions.createIndex('by_content_hash', 'contentHash', { unique: false }); - revisions.createIndex('by_created_at', 'createdAt', { unique: false }); - } - if (!db.objectStoreNames.contains(SETTINGS_STORE)) db.createObjectStore(SETTINGS_STORE, { keyPath: 'key' }); - } - - function openDatabase(dbName) { - return new Promise((resolve, reject) => { - const request = global.indexedDB.open(dbName, DB_VERSION); - request.onupgradeneeded = () => createIndexes(request.result); - request.onsuccess = () => { - request.result.onversionchange = () => request.result.close(); - resolve(request.result); - }; - request.onblocked = () => reject(new ChannelStoreError('database-blocked', 'Close other Helm tabs so the Channels database can be upgraded.')); - request.onerror = () => reject(request.error); - }); - } - - function makeRepository(options = {}) { - const dbName = options.dbName || DEFAULT_DB_NAME; - let databasePromise = null; - const database = () => (databasePromise ||= openDatabase(dbName)); - - async function getAll(storeName) { - const db = await database(); - return requestResult(db.transaction(storeName, 'readonly').objectStore(storeName).getAll()); - } - - async function getArtifact(id) { - const db = await database(); - return requestResult(db.transaction(ARTIFACT_STORE, 'readonly').objectStore(ARTIFACT_STORE).get(id)); - } - - async function getRevision(artifactId, revisionId) { - const db = await database(); - return requestResult(db.transaction(REVISION_STORE, 'readonly').objectStore(REVISION_STORE).get([artifactId, revisionId])); - } - - async function getSetting(key) { - const db = await database(); - const entry = await requestResult(db.transaction(SETTINGS_STORE, 'readonly').objectStore(SETTINGS_STORE).get(key)); - return entry?.value; - } - - async function setSetting(key, value) { - const db = await database(); - const tx = db.transaction(SETTINGS_STORE, 'readwrite'); - tx.objectStore(SETTINGS_STORE).put({ key, value }); - await transactionDone(tx); - return value; - } - - async function migrateLegacyDocuments() { - const db = await database(); - const marker = await requestResult(db.transaction(SETTINGS_STORE, 'readonly').objectStore(SETTINGS_STORE).get(MIGRATION_KEY)); - if (marker?.value?.complete) return marker.value; - if (!db.objectStoreNames.contains(LEGACY_STORE)) { - const value = { complete: true, migratedCount: 0, completedAt: new Date().toISOString() }; - const tx = db.transaction(SETTINGS_STORE, 'readwrite'); - tx.objectStore(SETTINGS_STORE).put({ key: MIGRATION_KEY, value }); - await transactionDone(tx); - return value; - } - - const legacy = await requestResult(db.transaction(LEGACY_STORE, 'readonly').objectStore(LEGACY_STORE).getAll()); - const prepared = []; - const invalidLegacyRecords = []; - for (const [index, document] of legacy.entries()) { - if (!document || typeof document.html !== 'string' || !safeString(document.id)) { - invalidLegacyRecords.push(safeString(document?.id, `record-${index + 1}`)); - continue; - } - const hash = await sha256(document.html); - const revisionId = revisionIdForHash(hash); - const publishedRevisionId = document.share ? revisionId : null; - const artifact = artifactFromInput(document, { - id: document.id, - revisionId, - status: document.share ? 'published' : 'draft', - publishedRevisionId - }); - const revision = revisionFromInput(document.id, document, hash, { parent: null, now: timestamp(document.updatedAt, new Date().toISOString()), share: normaliseMigratedShare(document.share) }); - prepared.push({ artifact, revision }); - } - if (invalidLegacyRecords.length) { - throw new ChannelStoreError('migration-invalid-records', `Legacy migration stopped before hiding ${invalidLegacyRecords.length} invalid record(s).`, { records: invalidLegacyRecords }); - } - - const existingArtifacts = new Map((await getAll(ARTIFACT_STORE)).map((entry) => [entry.id, entry])); - const existingRevisions = new Map((await getAll(REVISION_STORE)).map((entry) => [`${entry.artifactId}\u0000${entry.id}`, entry])); - for (const { artifact, revision } of prepared) { - const oldRevision = existingRevisions.get(`${revision.artifactId}\u0000${revision.id}`); - if (oldRevision && oldRevision.html !== revision.html) throw new ChannelStoreError('revision-integrity', `Revision ${revision.id} has conflicting bytes.`); - const oldArtifact = existingArtifacts.get(artifact.id); - if (oldArtifact && !oldRevision && oldArtifact.currentRevisionId === revision.id) { - throw new ChannelStoreError('migration-incomplete', `Artifact ${artifact.id} points to a missing revision.`); - } - } - - const completedAt = new Date().toISOString(); - const value = { complete: true, migratedCount: prepared.length, completedAt }; - const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE, SETTINGS_STORE], 'readwrite'); - const artifacts = tx.objectStore(ARTIFACT_STORE); - const revisions = tx.objectStore(REVISION_STORE); - for (const { artifact, revision } of prepared) { - if (!existingRevisions.has(`${revision.artifactId}\u0000${revision.id}`)) revisions.add(revision); - if (!existingArtifacts.has(artifact.id)) artifacts.add(artifact); - } - tx.objectStore(SETTINGS_STORE).put({ key: MIGRATION_KEY, value }); - await transactionDone(tx); - return value; - } - - async function normaliseLegacyShares() { - const db = await database(); - const marker = await requestResult(db.transaction(SETTINGS_STORE, 'readonly').objectStore(SETTINGS_STORE).get(LEGACY_SHARE_NORMALISATION_KEY)); - if (marker?.value?.complete) return marker.value; - const revisions = await getAll(REVISION_STORE); - const updates = revisions - .map((revision) => ({ revision, share: normaliseMigratedShare(revision.share) })) - .filter(({ revision, share }) => JSON.stringify(revision.share) !== JSON.stringify(share)); - const completedAt = new Date().toISOString(); - const value = { complete: true, normalisedCount: updates.length, completedAt }; - const tx = db.transaction([REVISION_STORE, SETTINGS_STORE], 'readwrite'); - const revisionStore = tx.objectStore(REVISION_STORE); - for (const { revision, share } of updates) revisionStore.put({ ...revision, share }); - tx.objectStore(SETTINGS_STORE).put({ key: LEGACY_SHARE_NORMALISATION_KEY, value }); - await transactionDone(tx); - return value; - } - - async function open() { - await database(); - await migrateLegacyDocuments(); - await normaliseLegacyShares(); - return repository; - } - - async function listArtifacts(options = {}) { - await open(); - const artifacts = await getAll(ARTIFACT_STORE); - return artifacts.filter((artifact) => options.includeArchived === true || artifact.status !== 'archived'); - } - - async function listRevisions(artifactId) { - await open(); - const db = await database(); - const tx = db.transaction(REVISION_STORE, 'readonly'); - const records = await requestResult(tx.objectStore(REVISION_STORE).index('by_artifact').getAll(artifactId)); - return records.sort((left, right) => new Date(left.createdAt) - new Date(right.createdAt)); - } - - async function getDocument(id, revisionId) { - await open(); - const artifact = await getArtifact(id); - if (!artifact) return null; - const revision = await getRevision(id, revisionId || artifact.currentRevisionId); - return projectArtifact(artifact, revision); - } - - async function listDocuments(options = {}) { - const artifacts = await listArtifacts(options); - return Promise.all(artifacts.map((artifact) => getDocument(artifact.id))); - } - - async function createOrRevise(input, options = {}) { - await open(); - if (!isPlainObject(input) || typeof input.html !== 'string') throw new TypeError('createOrRevise requires an HTML document record.'); - const artifactId = safeString(options.artifactId || input.id); - if (!artifactId) throw new TypeError('createOrRevise requires an artifact id.'); - const hash = await sha256(input.html); - const revisionId = revisionIdForHash(hash); - const db = await database(); - const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE], 'readwrite'); - const artifacts = tx.objectStore(ARTIFACT_STORE); - const revisions = tx.objectStore(REVISION_STORE); - const existingArtifact = await requestResult(artifacts.get(artifactId)); - const existingRevision = await requestResult(revisions.get([artifactId, revisionId])); - - if (existingRevision) { - if (existingRevision.html !== input.html) { - tx.abort(); - throw new ChannelStoreError('revision-integrity', `Revision ${revisionId} has conflicting bytes.`); - } - if (!existingArtifact) { - tx.abort(); - throw new ChannelStoreError('orphan-revision', `Revision ${artifactId}/${revisionId} has no logical artifact.`); - } - const currentRevision = existingArtifact.currentRevisionId === revisionId - ? existingRevision - : await requestResult(revisions.get([artifactId, existingArtifact.currentRevisionId])); - if (!currentRevision) { - tx.abort(); - throw new ChannelStoreError('missing-revision', `Artifact ${artifactId} points to a missing current revision.`); - } - await transactionDone(tx); - return { created: false, revised: false, idempotent: true, artifact: existingArtifact, revision: existingRevision, document: projectArtifact(existingArtifact, currentRevision) }; - } - - if (existingArtifact && options.expectedCurrentRevisionId !== undefined && existingArtifact.currentRevisionId !== options.expectedCurrentRevisionId) { - tx.abort(); - throw new ChannelStoreError('head-conflict', 'The artifact changed after this revision was prepared.', { expected: options.expectedCurrentRevisionId, actual: existingArtifact.currentRevisionId }); - } - - const parent = options.parent !== undefined - ? normaliseParent(options.parent) - : existingArtifact ? { artifactId, revisionId: existingArtifact.currentRevisionId } : null; - if (parent) { - const parentRevision = await requestResult(revisions.get([parent.artifactId, parent.revisionId])); - if (!parentRevision) { - tx.abort(); - throw new ChannelStoreError('missing-parent', `Parent revision ${parent.artifactId}/${parent.revisionId} does not exist.`); - } - } - const revisionShare = options.revisionShare !== undefined - ? options.revisionShare - : (!existingArtifact && !options.forkedFrom ? input.share : null); - const revision = revisionFromInput(artifactId, input, hash, { parent, share: revisionShare }); - revisions.add(revision); - let artifact; - if (existingArtifact) { - artifact = { - ...existingArtifact, - currentRevisionId: revisionId, - status: existingArtifact.publishedRevisionId === revisionId ? 'published' : 'draft', - updatedAt: timestamp(input.updatedAt, existingArtifact.updatedAt), - ...(options.updateCatalog === true ? { - title: safeString(input.title, existingArtifact.title), - type: safeString(input.type, existingArtifact.type), - tags: input.tags ? normaliseTags(input.tags) : existingArtifact.tags, - summary: typeof input.summary === 'string' ? input.summary : existingArtifact.summary, - source: typeof input.source === 'string' ? input.source : existingArtifact.source, - project: input.project ? normaliseProject(input.project) : existingArtifact.project, - catalogUpdatedAt: new Date().toISOString() - } : {}) - }; - artifacts.put(artifact); - } else { - artifact = artifactFromInput(input, { id: artifactId, revisionId, status: options.status, forkedFrom: options.forkedFrom || parent }); - artifacts.add(artifact); - } - await transactionDone(tx); - return { created: !existingArtifact, revised: Boolean(existingArtifact), idempotent: false, artifact, revision, document: projectArtifact(artifact, revision) }; - } - - async function updateCatalog(id, patch) { - await open(); - if (!isPlainObject(patch)) throw new TypeError('Catalog patch must be an object.'); - const forbidden = Object.keys(patch).filter((key) => !CATALOG_FIELDS.has(key)); - if (forbidden.length) throw new ChannelStoreError('invalid-catalog-patch', `Catalog fields cannot update: ${forbidden.join(', ')}.`); - const db = await database(); - const tx = db.transaction(ARTIFACT_STORE, 'readwrite'); - const store = tx.objectStore(ARTIFACT_STORE); - const artifact = await requestResult(store.get(id)); - if (!artifact) { tx.abort(); throw new ChannelStoreError('not-found', `Artifact ${id} does not exist.`); } - const updated = { ...artifact, catalogUpdatedAt: new Date().toISOString() }; - if (patch.title !== undefined) updated.title = safeString(patch.title, artifact.title); - if (patch.type !== undefined) updated.type = safeString(patch.type, artifact.type); - if (patch.tags !== undefined) updated.tags = normaliseTags(patch.tags); - if (patch.summary !== undefined) updated.summary = typeof patch.summary === 'string' ? patch.summary : artifact.summary; - if (patch.source !== undefined) updated.source = typeof patch.source === 'string' ? patch.source : artifact.source; - if (patch.project !== undefined) updated.project = normaliseProject(patch.project); - store.put(updated); - await transactionDone(tx); - return getDocument(id); - } - - async function updateRevisionDerivedData(artifactId, revisionId, patch) { - await open(); - if (!isPlainObject(patch)) throw new TypeError('Revision derived-data patch must be an object.'); - const allowed = new Set(['contentText', 'validation', 'derivedVersion']); - const forbidden = Object.keys(patch).filter((key) => !allowed.has(key)); - if (forbidden.length) throw new ChannelStoreError('invalid-revision-derived-patch', `Revision derived fields cannot update: ${forbidden.join(', ')}.`); - const db = await database(); - const tx = db.transaction(REVISION_STORE, 'readwrite'); - const store = tx.objectStore(REVISION_STORE); - const revision = await requestResult(store.get([artifactId, revisionId])); - if (!revision) { tx.abort(); throw new ChannelStoreError('missing-revision', `Revision ${artifactId}/${revisionId} does not exist.`); } - const updated = { - ...revision, - ...(patch.contentText !== undefined ? { contentText: typeof patch.contentText === 'string' ? patch.contentText : '' } : {}), - ...(patch.validation !== undefined ? { validation: patch.validation === null ? null : cloneJson(patch.validation, 'validation') } : {}), - ...(patch.derivedVersion !== undefined ? { derivedVersion: Number.isInteger(patch.derivedVersion) && patch.derivedVersion > 0 ? patch.derivedVersion : null } : {}) - }; - store.put(updated); - await transactionDone(tx); - return updated; - } - - async function setStatus(id, status, options = {}) { - await open(); - if (!STATUSES.has(status)) throw new TypeError(`Unknown artifact status: ${status}.`); - const db = await database(); - const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE], 'readwrite'); - const artifacts = tx.objectStore(ARTIFACT_STORE); - const artifact = await requestResult(artifacts.get(id)); - if (!artifact) { tx.abort(); throw new ChannelStoreError('not-found', `Artifact ${id} does not exist.`); } - let publishedRevisionId = artifact.publishedRevisionId || null; - if (status === 'published') { - publishedRevisionId = options.revisionId || artifact.currentRevisionId; - const revision = await requestResult(tx.objectStore(REVISION_STORE).get([id, publishedRevisionId])); - if (!revision) { tx.abort(); throw new ChannelStoreError('missing-revision', `Revision ${publishedRevisionId} does not exist.`); } - } - const updated = { ...artifact, status, publishedRevisionId, catalogUpdatedAt: new Date().toISOString() }; - artifacts.put(updated); - await transactionDone(tx); - return getDocument(id); - } - - async function setRevisionShare(artifactId, revisionId, share) { - await open(); - if (share !== null && !isPlainObject(share)) throw new TypeError('Revision share metadata must be an object or null.'); - const db = await database(); - const tx = db.transaction(REVISION_STORE, 'readwrite'); - const revisions = tx.objectStore(REVISION_STORE); - const revision = await requestResult(revisions.get([artifactId, revisionId])); - if (!revision) { tx.abort(); throw new ChannelStoreError('missing-revision', `Revision ${revisionId} does not exist.`); } - const immutable = { - html: revision.html, - contentHash: revision.contentHash, - parent: cloneJson(revision.parent, 'parent') - }; - const updated = { ...revision, share: share === null ? null : cloneJson(share, 'share') }; - // Publication metadata is mutable, but the evidence original and lineage - // are deliberately copied from the stored record without accepting input. - updated.html = immutable.html; - updated.contentHash = immutable.contentHash; - updated.parent = immutable.parent; - revisions.put(updated); - await transactionDone(tx); - return updated; - } - - async function revokePublication(artifactId, revisionId, share) { - await open(); - if (!isPlainObject(share)) throw new TypeError('Revoked publication metadata must be an object.'); - const db = await database(); - const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE], 'readwrite'); - const artifacts = tx.objectStore(ARTIFACT_STORE); - const revisions = tx.objectStore(REVISION_STORE); - const artifact = await requestResult(artifacts.get(artifactId)); - const revision = await requestResult(revisions.get([artifactId, revisionId])); - if (!artifact || !revision) { tx.abort(); throw new ChannelStoreError('not-found', 'The published Artifact or Revision no longer exists.'); } - revisions.put({ ...revision, share: cloneJson(share, 'share') }); - const status = artifact.currentRevisionId === revisionId ? 'in-review' : artifact.status; - artifacts.put({ ...artifact, status, catalogUpdatedAt: new Date().toISOString() }); - await transactionDone(tx); - return getDocument(artifactId); - } - - async function setCurrentRevision(artifactId, revisionId) { - await open(); - const db = await database(); - const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE], 'readwrite'); - const artifacts = tx.objectStore(ARTIFACT_STORE); - const artifact = await requestResult(artifacts.get(artifactId)); - if (!artifact) { tx.abort(); throw new ChannelStoreError('not-found', `Artifact ${artifactId} does not exist.`); } - const revision = await requestResult(tx.objectStore(REVISION_STORE).get([artifactId, revisionId])); - if (!revision) { tx.abort(); throw new ChannelStoreError('missing-revision', `Revision ${revisionId} does not exist.`); } - const updated = { - ...artifact, - currentRevisionId: revisionId, - status: revisionId === artifact.publishedRevisionId ? artifact.status : 'draft', - catalogUpdatedAt: new Date().toISOString() - }; - artifacts.put(updated); - await transactionDone(tx); - return projectArtifact(updated, revision); - } - - async function fork(sourceArtifactId, input, options = {}) { - await open(); - const source = await getArtifact(sourceArtifactId); - if (!source) throw new ChannelStoreError('not-found', `Artifact ${sourceArtifactId} does not exist.`); - const sourceRevisionId = options.revisionId || source.currentRevisionId; - const sourceRevision = await getRevision(sourceArtifactId, sourceRevisionId); - if (!sourceRevision) throw new ChannelStoreError('missing-revision', `Revision ${sourceRevisionId} does not exist.`); - const forkInput = { ...projectArtifact(source, sourceRevision), ...(input || {}), html: input?.html ?? sourceRevision.html }; - const newId = safeString(options.artifactId || input?.id); - if (!newId || newId === sourceArtifactId) throw new TypeError('A fork requires a distinct artifact id.'); - if (await getArtifact(newId)) throw new ChannelStoreError('artifact-exists', `Artifact ${newId} already exists.`); - const parent = { artifactId: sourceArtifactId, revisionId: sourceRevisionId }; - return createOrRevise({ ...forkInput, id: newId }, { artifactId: newId, parent, forkedFrom: parent, status: options.status || 'draft' }); - } - - async function deleteArtifact(id, options = {}) { - await open(); - if (options.hard !== true) return setStatus(id, 'archived'); - const db = await database(); - const tx = db.transaction([ARTIFACT_STORE, REVISION_STORE], 'readwrite'); - const artifacts = tx.objectStore(ARTIFACT_STORE); - if (!await requestResult(artifacts.get(id))) { tx.abort(); throw new ChannelStoreError('not-found', `Artifact ${id} does not exist.`); } - artifacts.delete(id); - const index = tx.objectStore(REVISION_STORE).index('by_artifact'); - await new Promise((resolve, reject) => { - const cursor = index.openKeyCursor(global.IDBKeyRange.only(id)); - cursor.onerror = () => reject(cursor.error); - cursor.onsuccess = () => { - const result = cursor.result; - if (!result) { resolve(); return; } - tx.objectStore(REVISION_STORE).delete(result.primaryKey); - result.continue(); - }; - }); - await transactionDone(tx); - return { deleted: true, id }; - } - - async function close() { - if (!databasePromise) return; - const db = await databasePromise; - db.close(); - databasePromise = null; - } - - const repository = Object.freeze({ - open, - close, - migrateLegacyDocuments, - listArtifacts, - listRevisions, - getArtifact, - getRevision, - getSetting, - setSetting, - getDocument, - listDocuments, - createOrRevise, - updateCatalog, - updateRevisionDerivedData, - setStatus, - setRevisionShare, - revokePublication, - setCurrentRevision, - fork, - deleteArtifact, - projectArtifact - }); - return repository; - } - - global.HelmChannelStore = Object.freeze({ - DB_VERSION, - LEGACY_STORE, - ARTIFACT_STORE, - REVISION_STORE, - SETTINGS_STORE, - MIGRATION_KEY, - STATUSES, - ChannelStoreError, - sha256, - revisionIdForHash, - projectArtifact, - create: makeRepository, - defaultRepository: makeRepository() - }); -}(typeof window !== 'undefined' ? window : globalThis)); diff --git a/docs/AGENT-BRIDGE.md b/docs/AGENT-BRIDGE.md deleted file mode 100644 index cee987e..0000000 --- a/docs/AGENT-BRIDGE.md +++ /dev/null @@ -1,72 +0,0 @@ -# Helm Bridge — agent ingress - -`HDOC/1.0` describes the portable file. Helm Bridge is the optional local ingress service that lets an AI agent hand a completed Artifact revision to a person's Helm library without giving the agent access to that browser library. - -## Boundary - -```text -Agent writes HDOC HTML -> Helm Bridge inbox -> person reviews -> explicit browser import -> IndexedDB library -``` - -The Bridge stores the submitted original under `~/.helm-bridge/` in owner-only directories. It does **not** open, change, or read the browser's IndexedDB. The browser app only reads the inbox over loopback and imports on a human click. The Bridge listens on `127.0.0.1:4175` by default; it is not a public API. - -## Start on the person’s machine - -From a fresh clone, use the bootstrap so both the Bridge and the protected Agent configuration are ready: - -```bash -scripts/helm-agent-bootstrap --agent-name codex -``` - -`python3 helm_bridge.py` remains the lower-level service command when you deliberately want to manage its process yourself. - -On its first start, the service creates a random write token at `~/.helm-bridge/token` with owner-only permissions. Do not paste that token into a prompt, commit it, or put it in generated HTML. The service prints the token *path*, never the token itself. - -The read-only endpoints require no token: - -- `GET /v1/health` — liveness and inbox count. -- `GET /v1/contract` — the authoritative `HDOC/1.0` document contract. -- `GET /v1/artifacts` — current inbox records and their original HTML. - -`POST /v1/artifacts` requires `Authorization: Bearer $HELM_BRIDGE_TOKEN` and accepts raw UTF-8 HTML or `{ "html": "…", "source": "…", "project": { "id": "…", "name": "…" } }` JSON. A declared `manifest.project` takes precedence; otherwise the supplied client sends the current working project's stable ID and name as catalog-only metadata. - -## What the Bridge guarantees - -- Rejects unsafe or invalid submissions: executable scripts, inline handlers, missing HDOC metadata, invalid timestamps, duplicate roots or headings, and files larger than 5 MB. -- Preserves the accepted UTF-8 bytes exactly; it does not format, repair, or mutate submitted HTML. -- Treats the manifest ID as the logical Artifact identity. Resending exact bytes is idempotent; different bytes under that ID are appended as another immutable Revision. The Bridge never overwrites an earlier source or advances the browser's current Revision by itself. -- Warns about remote resources, but does not rewrite them. The browser reader keeps its separate no-network sandbox. - -## Agent integration - -An agent must first read [`docs/CODEX-MEMORY.md`](CODEX-MEMORY.md), [`AI-GUIDE.md`](../AI-GUIDE.md), [`REPORT-DESIGN-STANDARD.md`](REPORT-DESIGN-STANDARD.md), and the full [`HTML-DOCUMENT-SPEC.md`](HTML-DOCUMENT-SPEC.md). This repository's [`AGENTS.md`](../AGENTS.md) makes the handoff rule discoverable to compatible coding agents. Once bootstrap has placed the protected runtime configuration at the default path, the final delivery step is deterministic: - -```bash -/path/to/html-displayer/scripts/helm-submit output.html --source "research-agent" -``` - -[`templates/agent-handoff.html`](../templates/agent-handoff.html) is a valid, evidence-forward starting point for a result the agent wants a person to review. - -Run the command from the artifact's project root. The client derives its fallback project identity from that directory; use `--project-id stable-id --project-name "Project name"` if that is not possible. The wrapper loads the process environment from `~/.config/helm-agent/helm-bridge.env`. A direct client invocation needs only: - -```bash -HELM_BRIDGE_ENDPOINT=http://127.0.0.1:4175 -HELM_BRIDGE_TOKEN=owner-provided-secret -HELM_AGENT_NAME=research-agent -``` - -The supplied client treats `422` as a contract failure. A revised version of the same Artifact keeps its manifest ID; a different document or intentional fork receives a new one. No client may overwrite an earlier Revision or silently advance the owner's current version. - -## Remote agents - -When an agent lives on a server, keep the Bridge on the person’s machine and use an SSH reverse tunnel that binds only on the server loopback interface: - -```bash -ssh -N -R 127.0.0.1:4175:127.0.0.1:4175 agent-server -``` - -Then configure that server's agent runtime with `HELM_BRIDGE_ENDPOINT=http://127.0.0.1:4175` plus its token. This does not expose the Bridge on a public address. The server operator should place the token in a protected runtime environment file, not in an agent prompt or repository. - -`scripts/helm-agent-bootstrap` is intentionally for the machine that owns the Bridge and its token. On a remote Agent host, use `scripts/helm-agent-bootstrap --check` only after the tunnel owner has supplied the protected runtime configuration. - -To receive the artifact, open **Agent inbox** in Helm and choose **Import new artifacts**. The import is explicit; same browser IDs are skipped, while source-ID conflicts use the existing catalog-copy decision UI. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 47b7aaa..ae05236 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,51 +1,53 @@ # Helm architecture -Helm is deliberately split into small, inspectable planes. A document can remain useful even when any optional plane is not running. +Helm is a static browser app plus one Python-stdlib CLI. Nothing runs a +database, a daemon, or the network. A document stays useful even with no tooling +present. ## System map ```text - final standalone HDOC/1.0 HTML - │ - ▼ - agent/project ── POST ──> Loopback Bridge ── review ──> Browser library - immutable revisions IndexedDB - │ │ - └── immutable inbox ├── HARC export - ├── explicit folder sync - └── owner-selected Channel - │ - ▼ - stable address + immutable revision +agent / project + └─ complete HDOC/1.1 HTML + └─ write $HELM_LIBRARY/<slug>/index.html (exact bytes, the record) + └─ helm index ─▶ catalog.json + └─ gallery/ (static) ─▶ browse ``` -## Four planes - -| Plane | Primary code | Responsibility | Trust boundary | -| --- | --- | --- | --- | -| Browser library | `index.html`, `app.js`, `channel-store.js`, `validator.js`, `archive-backup.js`, `folder-sync.js`, `repair.js` | Artifact / Revision history, workflow state, visual compare, catalog, safe reading, recovery, and explicit import. | The browser owns the personal library. | -| Artifact contract | `docs/HTML-DOCUMENT-SPEC.md`, `AI-GUIDE.md`, `templates/` | Portable `HDOC/1.0` HTML with evidence, provenance, and visual reading structure. | Artifact authors must not rely on Helm to make a document intelligible. | -| Agent handoff | `helm_bridge.py`, `scripts/helm-agent-bootstrap`, `scripts/helm-submit` | Authenticated loopback ingress; exact-byte inbox storage; idempotency and revision semantics. | Agents can submit, never import into browser storage. | -| Intranet sharing | `helm_share_server.py`, `docs/INTRANET-SHARING.md` | Explicit publication to a stable Channel plus immutable content-addressed Revision addresses. | Visitors can read a selected shared file, never enumerate or alter the library. | - -## Data invariants - -1. **Original HTML is immutable.** A browser catalog overlay may improve title, project, tag, or source metadata without rewriting the stored file. -2. **Identity is stable.** The manifest ID identifies the logical Artifact; changed bytes append an immutable Revision. A new ID means a different Artifact or explicit Fork. -3. **The document is portable.** A finished artifact is a standalone `HDOC/1.0` file with a semantic root, embedded essential CSS, manifest, and provenance. -4. **Import is owner-controlled.** The Bridge and an intranet share server cannot mutate IndexedDB. -5. **Publication is explicit.** Sharing produces a content-addressed read-only copy and may atomically advance one stable Channel address outside the browser library and Git checkout. - -## Repository layout - -The root remains intentionally small because the product is a static browser application plus Python standard-library services. Browser modules stay adjacent to the entry page; reusable standards and operational contracts live under `docs/`; user-facing starters live under `templates/`; and behavior is covered by `tests/`. - -Do not introduce a framework build pipeline, server database, or cloud dependency merely to reorganize files. A new dependency needs to preserve Helm's local-first and durable-original guarantees. - -## Reading order for contributors - -1. [`README.md`](../README.md) — product promise and local start. -2. [`HTML-DOCUMENT-SPEC.md`](HTML-DOCUMENT-SPEC.md) — artifact interchange contract. -3. [`AGENT-BRIDGE.md`](AGENT-BRIDGE.md) or [`INTRANET-SHARING.md`](INTRANET-SHARING.md) — the relevant server boundary. -4. `app.js` or the corresponding Python service — implementation. -5. The matching test in `tests/` — observable behavior and regression guard. +## Three parts + +| Part | Code | Responsibility | +| --- | --- | --- | +| Authoring | `skill/` | The `HDOC/1.1` contract, the cool-grey design system, the scaffold and rendered component vocabulary agents author against. | +| Library + CLI | `bin/helm` | Scaffold, validate, index. The library is a folder; `catalog.json` is derived, never authoritative. | +| Gallery | `gallery/` | A static page that reads `catalog.json` and links to originals. No server logic. | + +## Invariants + +1. **The filesystem is the library.** One folder per artifact, one `index.html`. + The original bytes are the record; `catalog.json` is a derived index and can + be rebuilt at any time with `helm index`. +2. **Artifacts are portable.** Each is a standalone `HDOC/1.1` file with a + semantic root, embedded CSS, an inline manifest, and provenance. It needs + nothing from Helm to be readable. +3. **Publishing is writing a file.** No inbox, no review gate, no publish state. + The library is local and personal. +4. **No new runtime dependency.** Anything added must preserve the local-first, + zero-dependency, durable-original guarantees. No framework build, server DB, + or cloud. + +## What was removed (and why) + +The former sharing / publishing / intranet plane, the loopback Bridge inbox, the +IndexedDB browser library, folder-sync, archive-backup, and the Draft/Reviewed/ +Published state machine were all removed. For a single-person, local library they +were ceremony without payoff: the filesystem is the library, and a person's own +files need no import gate. + +## Reading order + +1. [`README.md`](../README.md) — the whole model. +2. [`skill/SKILL.md`](../skill/SKILL.md) — authoring workflow. +3. [`skill/design-system.md`](../skill/design-system.md) — the visual standard. +4. [`HDOC-SPEC.md`](HDOC-SPEC.md) — the file contract `helm check` enforces. +5. `bin/helm` — the implementation. diff --git a/docs/CHANNELS.md b/docs/CHANNELS.md deleted file mode 100644 index de98e67..0000000 --- a/docs/CHANNELS.md +++ /dev/null @@ -1,37 +0,0 @@ -# Helm Channels - -Channels is Helm's first revision model. It separates the thing a reader follows from the immutable HTML evidence that produced each version. - -## Model - -- **Artifact** — stable logical identity, catalog metadata, project, workflow status, current Revision, published Revision, and optional Fork origin. -- **Revision** — immutable HTML bytes addressed by SHA-256, with authoring time, validation evidence, parent Revision, and optional publication metadata. -- **Channel** — the Artifact's stable read-only intranet address. Publishing advances it with compare-and-swap; it never rewrites an immutable Revision address. - -The browser database stores Artifacts and Revisions in separate IndexedDB stores. Upgrading from the legacy document store is automatic and non-destructive. - -## Workflow - -```text -Draft revision -> Reviewed -> Published stable address - | | - +-> new revision ---------+-> explicit publish advances Channel - +-> Fork creates a new Artifact with recorded origin -``` - -A new Revision after publication returns the Artifact to Draft while leaving the last published Revision identifiable. Catalog-only edits do not create Revisions. A Fork begins with the exact source bytes for provenance; before it can own a separate Channel, its author must create a new valid HDOC Revision whose embedded manifest ID matches the Fork Artifact ID. Helm blocks publication while those identities differ. - -## Addresses - -- `/a/<artifact-id>` is the stable Channel address. -- `/r/<sha256>.html` is an immutable Revision address. - -`POST /api/channels/publish` accepts the exact HDOC HTML plus an optional `base_revision_sha256`. A stale base returns `409` instead of silently replacing a newer publication. `POST /api/channels/artifacts/<id>/revoke` removes the stable address; immutable Revision files remain evidence originals. - -## Backup compatibility - -`HARC/1.0` remains readable. Helm carries Channel fields and the Revision graph through `metadata.extensions`; a clean-library recovery recreates immutable Revisions, their shares, current head, published head, status, and Fork origin. Existing-ID imports remain non-destructive and require an explicit product decision. - -## Phase-one boundary - -This phase is single-owner and local-first. It does not add collaborative editing, remote identity, automatic sync, merge semantics, or access-control lists. Those need a separate trust and conflict model. diff --git a/docs/CODEX-MEMORY.md b/docs/CODEX-MEMORY.md deleted file mode 100644 index ba0b06e..0000000 --- a/docs/CODEX-MEMORY.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -kind: codex-project-memory -schema_version: 1 -scope: repository ---- - -# Helm project memory seed - -This is the small, durable context a coding agent should load when it first enters a clone of Helm. It is deliberately repository-scoped: it describes this product and must not overwrite a person's unrelated global preferences or secrets. - -## Agent installation rule - -1. Read this file, [`AI-GUIDE.md`](../AI-GUIDE.md), the full [`HTML-DOCUMENT-SPEC.md`](HTML-DOCUMENT-SPEC.md), and [`REPORT-DESIGN-STANDARD.md`](REPORT-DESIGN-STANDARD.md) before creating a retained HTML artifact. -2. If the agent platform supports persistent **project** memory, store the payload below as one note scoped to this repository. If it does not, keep this file as the source of truth and reread it at the start of each task. -3. Never store a Bridge token, credentials, private artifact content, or machine-specific address in that memory note. - -## Memory payload - -> Helm is a local-first personal archive for durable AI-authored HTML, not a generic file explorer. Treat every retained HTML output as an evidence original with stable identity, title, provenance, readable semantic structure, and future retrieval value. The authoritative interchange contract is `HDOC/1.0` in `docs/HTML-DOCUMENT-SPEC.md`; follow `AI-GUIDE.md` rather than inventing a parallel format. For a substantial report, use `scripts/helm-report`: inventory the material claims and their relationships, select the closest Profile, bind them to registered components through `manifest.presentation.claims` and matching `data-helm-*` attributes, replace all specimen content, remove every `placeholder` marker, and pass `scripts/helm-report check` before handoff. Helm's first organization level is the Codex/project workspace: write the optional `manifest.project` as `{ "id": "stable-workspace-id", "name": "Workspace name" }`, using the project identity rather than a tag or local machine path. Use `docs/REPORT-DESIGN-STANDARD.md` as the editorial bar: lead with the question and short answer and make the route from evidence to interpretation to decision or next action visible. Every material comparison, sequence, hierarchy, magnitude, change, composition, uncertainty, claim-strength distinction, comparable case set, or action dependency should receive the smallest useful registered component or an explicit reason it remains prose. A visual must carry a named conclusion, direct labels and boundaries, evidence state, scope/source or method note, and a nearby text or table fallback; use inline SVG or semantic HTML/CSS, never decorative chart wallpaper or a remote runtime dependency. Keep sources, dates, assumptions, and confidence beside the claims they qualify. A completed artifact must be one standalone UTF-8 HTML file with embedded essential CSS, one `<main data-document-root>`, one `h1`, duplicate `helm:*` metadata, and exactly one valid `data-helm-manifest`. Do not send Markdown fragments, partial drafts, executable scripts, inline event handlers, secrets, or external application dependencies. If the artifact is intended for Helm, validate it locally, then submit the exact final file once from the target project root with `scripts/helm-submit output.html --source "agent-name"`; the Bridge uses that workspace as a catalog fallback if the source has no project declaration. Helm Bridge validates and preserves the source but only puts it in the owner's inbox; a person explicitly imports it into browser storage. Publishing is a separate, explicit owner action: a validated artifact may be copied byte-for-byte to immutable, content-addressed storage. The hosted homepage may enumerate currently published Channels through the minimal read-only public catalog, but it must never expose drafts, archived browser records, owner metadata, credentials, or mutation access. Keep a stable manifest ID for revisions of the same logical Artifact: exact retries are idempotent, different bytes append an immutable Revision, and a new ID is reserved for a different document or explicit Fork. The visual and writing style is calm, evidence-forward, provenance-aware, answer-first, and optimized for later reading rather than landing-page polish. - -## Clone-to-handoff workflow - -Run this once on the machine that owns the browser library: - -```bash -scripts/helm-agent-bootstrap --agent-name codex -``` - -The command starts a loopback-only Bridge if needed and writes the local, owner-only runtime configuration used by `scripts/helm-submit`. It does not contact a cloud service or expose a public port. - -For each artifact intended for the archive: - -```bash -scripts/helm-submit output.html --source "codex" -``` - -Then open Helm and review **Agent inbox**. Submission is a handoff, not a hidden browser-library write. - -## What this can and cannot guarantee - -`AGENTS.md` makes this workflow visible to compatible coding agents when they open the clone. This document gives every session a deterministic context-refresh path. A repository cannot force every vendor's Codex installation to write into its provider-managed global long-term memory; the provider must expose that capability. The repository therefore remains correct even without it: the checked-in memory seed plus `AGENTS.md` are the durable, reviewable source of truth. diff --git a/docs/FOLDER-SYNC-API.md b/docs/FOLDER-SYNC-API.md deleted file mode 100644 index c9d06f8..0000000 --- a/docs/FOLDER-SYNC-API.md +++ /dev/null @@ -1,77 +0,0 @@ -# Helm explicit archive-folder API - -`folder-sync.js` is an optional, dependency-free bridge to Chromium's File System Access API. It does not keep a background watcher and does not access IndexedDB. Include it after the rest of Helm's browser scripts: - -```html -<script src="folder-sync.js"></script> -``` - -It exposes `window.HelmFolderSync`. - -## Archive layout - -The directory selected by the user has exactly these Helm-owned paths: - -```text -Selected folder/ - helm-archive.json - artifacts/ - <safe-id>.html -``` - -Each artifact file is the document record's original `html` string, written without parsing, normalising, or regenerating it. `<safe-id>` is produced by `HelmFolderSync.safeArtifactId(id)`: a readable, portable slug plus a deterministic identifier suffix. Do not derive filenames yourself; the top-level index is authoritative. - -`helm-archive.json` is the `HARC/1.0` **folder-index** profile. It keeps the standard archive envelope and metadata, but stores `artifact_path` instead of duplicating the HTML in JSON: - -```json -{ - "format": "helm-archive", - "schema_version": "HARC/1.0", - "archive_profile": "folder-index", - "artifact_directory": "artifacts", - "exported_at": "2026-07-13T12:00:00.000Z", - "document_count": 1, - "documents": [{ - "id": "decision-brief-q3", - "artifact_path": "artifacts/decision-brief-q3--e70ed5d5.html", - "metadata": { - "title": "Q3 decision brief", - "type": "brief", - "tags": ["decision"], - "summary": "The decision context.", - "source": "Planning agent", - "created_at": "2026-07-01T08:00:00.000Z", - "updated_at": "2026-07-13T11:30:00.000Z" - } - }] -} -``` - -## API - -- `isSupported()` / `hasFileSystemAccess()` — whether `showDirectoryPicker` exists. -- `chooseDirectory({ mode = 'readwrite', id?, startIn? })` — opens the browser picker. Returns `{ ok, status, handle?, permission?, failures? }`; a user cancellation has `status: 'cancelled'`. -- `verifyPermission(handle, { mode = 'read', request = false })` — checks a retained directory handle. Set `request: true` only inside an explicit user action if the UI wants to request access again. -- `writeArchive(handle, documents, { conflictPolicy = 'error', exportedAt?, requestPermission = false })` — writes `artifacts/<safe-id>.html` for every document and then writes the index last. Document records use Helm's normal fields (`id`, `title`, `type`, `tags`, `summary`, `source`, `createdAt`, `updatedAt`, `html`); extra JSON-only fields are preserved as `metadata.extensions`. -- `readArchive(handle, { requestPermission = false })` / `recoverArchive(...)` — validates the index, loads every referenced original HTML file, and returns recoverable Helm records. -- `buildFolderIndex(documents, { exportedAt? })`, `validateFolderIndex(value)`, `parseFolderIndex(value)`, `safeArtifactId(id)`, and `artifactPathForId(id)` are available for previews or tests. - -Every operation returns a structured result with `ok`, `status`, `failures`, and (where applicable) `conflicts`. `readArchive` returns `documents` only from files it successfully loads and marks an incomplete recovery with `complete: false`. - -## No implicit replacement - -`writeArchive` defaults to `conflictPolicy: 'error'`. If either an artifact file or `helm-archive.json` already exists, it makes **no writes** and returns `status: 'conflict'`, `requiresExplicitReplace: true`, and the exact paths in `conflicts`. - -Only a deliberate Sync now action may opt in to replacement: - -```js -const selection = await HelmFolderSync.chooseDirectory({ id: 'helm-archive' }); -if (!selection.ok) return; - -const write = await HelmFolderSync.writeArchive(selection.handle, documents, { - conflictPolicy: 'replace', - requestPermission: true -}); -``` - -Writing HTML files happens before the index, so an existing index remains available if a later artifact write fails. The File System Access API has no multi-file atomic transaction; a `partial-write` result therefore always includes the exact completed paths and failures for the UI to show. diff --git a/docs/HDOC-SPEC.md b/docs/HDOC-SPEC.md new file mode 100644 index 0000000..11a308d --- /dev/null +++ b/docs/HDOC-SPEC.md @@ -0,0 +1,64 @@ +# HDOC/1.1 — the artifact contract + +An HDOC artifact is one self-contained HTML file that carries both a +human-readable report and a machine-readable manifest. `bin/helm check` enforces +the hard rules below. + +## Required + +1. **Inline manifest.** Exactly one + `<script type="application/helm+json">…</script>` containing valid JSON with + at least: + + ```json + { + "schema": "HDOC/1.1", + "id": "stable-slug", + "title": "Decision-relevant title", + "type": "report" + } + ``` + + `type` ∈ `report`, `brief`, `reference`, `dashboard`, `decision`, `research`, + `benchmark`, `note`. Optional: `summary`, `tags` (array), `source`, `created`, + `updated` (ISO date). The manifest must match the visible document. + +2. **Semantic root.** A `<main>` element (`<main data-document-root>` by + convention) wrapping the document body. + +3. **Self-contained.** No external `<script src>`, no external stylesheet + `<link rel="stylesheet">`, no CSS `@import`. Embed all CSS inline; embed + images as `data:` URIs. A remote `src="http…"` is flagged as a durability + warning. + +4. **No leftover specimens.** No `placeholder` class and no unreplaced + `{{TITLE}}`-style tokens remain. + +## Identity and revisions + +- The manifest `id` identifies the logical artifact. Reuse it to revise the same + document (overwrite the file); use a new id for a different artifact. +- The library stores one file per artifact at `<slug>/index.html`. There is no + automatic revision history — this is a personal library; use Git if you want + history. + +## The index + +`helm index` scans `<library>/*/index.html`, reads each manifest, and writes +`<library>/catalog.json`: + +```json +{ + "schema": "HDOC/1.1", + "generated": "2026-07-22", + "count": 1, + "artifacts": [ + { "id": "…", "slug": "…", "title": "…", "type": "…", "summary": "…", + "tags": [], "source": "…", "created": "…", "updated": "…", + "path": "slug/index.html", "bytes": 14604 } + ] +} +``` + +Files without a manifest are skipped and reported. `catalog.json` is derived and +disposable — rebuild it any time. diff --git a/docs/HTML-DOCUMENT-SPEC.md b/docs/HTML-DOCUMENT-SPEC.md deleted file mode 100644 index fe29b9b..0000000 --- a/docs/HTML-DOCUMENT-SPEC.md +++ /dev/null @@ -1,132 +0,0 @@ -# Helm HTML Document Contract - -**Specification:** `HDOC/1.0` -**Status:** stable local-first format -**Purpose:** make AI-authored HTML a portable personal knowledge artifact, not an anonymous page. - -Any project, agent, or person that generates an HTML artifact for the Helm library must follow this document. The result must work as a standalone `.html` file and remain intelligible when copied outside Helm. Helm organizes its local catalog first by **project workspace**, then by document type and tags; declare the workspace when it is known. - -## The contract - -1. Deliver one complete HTML document — beginning with `<!doctype html>` — with all essential styling inline or embedded in the file. -2. Put the document's meaningful content inside one `<main data-document-root>` element. -3. Include a valid JSON manifest in `<script type="application/json" data-helm-manifest>`. -4. Use semantic HTML: one `h1`, ordered headings, real lists and tables, descriptive links, and no text encoded only in images. -5. State sources, data dates, assumptions, and confidence wherever factual claims would otherwise become untraceable. -6. Follow [`REPORT-DESIGN-STANDARD.md`](REPORT-DESIGN-STANDARD.md): use a calm, evidence-forward, answer-first report system with generous whitespace, useful hierarchy, quiet neutral surfaces, restrained accent color, and data clarity over dashboard decoration. When a comparison, sequence, hierarchy, magnitude, change, composition, or uncertainty is material, include one or more meaningful visual evidence modules selected from the visual grammar. A reader must be able to find the purpose or short answer, the supporting evidence, and the resulting action or boundary without relying on interaction. -7. Do not depend on a host app for fonts, scripts, navigation, APIs, authentication, or core content. Remote images and fonts may be used only as progressive enhancement; a document must still be meaningful without them. Relative file references such as `../stage2/report.html` or `./chart.png` are non-portable because sibling files do not travel with one standalone artifact. Embed essential resources and use absolute URLs for external destinations. -8. Treat user-supplied or third-party HTML as untrusted. Helm previews it in a sandbox, and authors should avoid scripts unless there is a clear, documented reason. - -## Report presentation standard - -`HDOC/1.0` keeps a file portable; the Report Design Standard keeps it useful when someone reads it later. This is a semantic requirement rather than a rigid page layout. - -- Make the reader's path visible: **question or decision → short answer → evidence → interpretation → action, boundary, or open question → sources/method**. A concise note may compress this sequence; a report may deepen it. -- Use the declared `type` to choose the shape: a report answers a question, a brief compares a decision, a reference explains reuse, a dashboard explains the current state and its measures, and a note preserves a finding or handoff. -- Use a comparison table for alternatives, an evidence route or ledger for claim strength, a timeline or flow for sequence, a diagram for hierarchy, and a labeled chart for magnitude or change. Give every figure a conclusion-led caption, labels/units or boundaries, an evidence state, source or method note, and an adjacent text/table fallback. Do not use visual components as page decoration. -- Put source, data date, method, assumption, or confidence close to a consequential claim. Images and charts must not be the only carrier of essential information. -- Do not hide a conclusion, evidence, or caveat behind an interaction. Interactions may clarify or filter; the standalone file must remain readable when printed, exported, or viewed narrowly. The optional `deep-dive` Profile may use the CSS-only `chapter-reveal` presentation mode, but motion must be progressive enhancement with static reduced-motion, mobile, unsupported-browser, and print behavior. - -See [`REPORT-DESIGN-STANDARD.md`](REPORT-DESIGN-STANDARD.md) for type-specific outlines, component guidance, and the delivery self-review. - -## Required manifest - -```html -<script type="application/json" data-helm-manifest> -{ - "schema_version": "HDOC/1.0", - "id": "short-stable-document-id", - "title": "A clear document title", - "type": "report", - "tags": ["research", "active"], - "summary": "One sentence explaining the artifact's decision-relevant value.", - "created_at": "2026-07-13T00:00:00Z", - "updated_at": "2026-07-13T00:00:00Z", - "project": { - "id": "short-project-workspace-id", - "name": "Recognizable project workspace name" - }, - "provenance": { - "author": "person, project, or agent name", - "sources": [ - { "label": "Source title", "url": "https://example.com", "accessed_at": "2026-07-13" } - ] - } -} -</script> -``` - -### Field rules - -| Field | Rule | -| --- | --- | -| `schema_version` | Exactly `HDOC/1.0`. | -| `id` | Stable, lowercase identifier. Do not use a random ID when revising an existing artifact. | -| `title` | Human-readable; 100 characters or fewer. | -| `type` | One of `report`, `brief`, `reference`, `dashboard`, or `note`. | -| `tags` | 0–8 concise lowercase tags. | -| `summary` | A decisive 240-character-or-fewer description, not a generic subtitle. | -| timestamps | ISO 8601 in UTC. Update `updated_at` on meaningful revisions. | -| `project` | Optional but strongly recommended for agent output. `{ "id": "stable-workspace-id", "name": "Workspace name" }`; use the Codex/project workspace, not a topic tag or a personal machine path. The Bridge can attach the current submitter workspace as catalog metadata when the original file has no declaration. | -| `provenance.sources` | Include sources used for claims. Use `[]` if the artifact is original writing without external sources. | - -## Required HTML metadata - -These duplicate the core manifest fields so a simple file indexer can inspect the document without parsing JSON: - -```html -<meta name="helm:title" content="A clear document title"> -<meta name="helm:type" content="report"> -<meta name="helm:summary" content="One decisive sentence."> -<meta name="helm:tags" content="research, active"> -``` - -## Links and embedded resources - -- Fragment links such as `#evidence`, absolute web/source URLs, and purpose-specific schemes such as `mailto:` and `tel:` are valid navigation targets. -- A relative `href` points into the author's original folder layout, which Helm does not import. Replace it with an absolute URL, or preserve the referenced evidence inside the artifact. -- Essential images, audio, video, fonts, and CSS must be embedded, normally with inline markup/CSS or a `data:` URL. A relative `src` or CSS `url(...)` violates the standalone portability expectation and is reported by validation. -- A remote media or CSS resource is a progressive enhancement, not part of the retained evidence original. Helm reports it as a portability warning, so a document that relies on it does not receive a 100-point validation score. - -## Reference skeleton - -```html -<!doctype html> -<html lang="en"> - <head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <meta name="helm:title" content="Title"> - <meta name="helm:type" content="brief"> - <meta name="helm:summary" content="One decisive sentence."> - <meta name="helm:tags" content="decision, active"> - <title>Title - - - - -
    -
    -

    BRIEF · 2026-07-13

    -

    Title

    -

    Summary.

    -
    -

    Context

    -

    Evidence

    -

    Recommendation

    -
    - - -``` - -## Generator instruction - -Give this exact instruction to another project or AI before it produces an artifact: - -> Generate a complete, standalone HTML document compliant with `docs/HTML-DOCUMENT-SPEC.md` (HDOC/1.0) and `docs/REPORT-DESIGN-STANDARD.md`. Start with `scripts/helm-report`, select the closest Profile, and inventory material relationships before writing the report. Return only the final `.html` source. Preserve the Helm manifest, semantic `
    `, evidence/provenance, and embedded essential CSS. Declare `manifest.project` with the stable Codex/project workspace ID and its human-readable name. Bind each material relationship to a registered visual component through `manifest.presentation.claims` and matching `data-helm-*` attributes. Establish the question and short answer early, make the route from evidence to interpretation to next action visible, and keep source, evidence state, scope, units, and fallback beside the claim they qualify. Run `scripts/helm-report check` before handoff. - -## Non-goals in v1 - -- This is not an execution environment for arbitrary HTML or JavaScript. -- It does not replace a multi-user document system or cloud sync. -- It does not prescribe a single visual style; it prescribes legibility, portability, metadata, and evidence. diff --git a/docs/INTRANET-SHARING.md b/docs/INTRANET-SHARING.md deleted file mode 100644 index 0f38d31..0000000 --- a/docs/INTRANET-SHARING.md +++ /dev/null @@ -1,102 +0,0 @@ -# Helm Channels and intranet sharing - -Helm keeps each owner's browser library private. Publishing copies only an explicitly selected, validated HDOC document into the share service. The hosted Helm homepage lists published Channels as a read-only public library; network visitors cannot see drafts, archived browser records, owner metadata, or change a Channel. - -The browser publishes only when the logical Artifact ID equals the embedded HDOC manifest ID. Catalog copies and newly created Forks therefore cannot accidentally advance the source Artifact's Channel; a Fork first needs an explicitly authored HDOC Revision with its own matching identity. - -## Two kinds of links - -Helm Channels separates a logical Artifact from its immutable Revisions: - -- `GET /a/` is the stable Artifact address. It serves the current published Revision with `Cache-Control: no-store`, so an update appears at the same address. -- `GET /r/.html` is one exact, immutable Revision. It is content-addressed and receives a one-year immutable cache policy. - -Every update appends a new byte-for-byte Revision and atomically advances the stable Artifact pointer. Existing Revision files are never overwritten. The service records the pointer and publication state in `channels.json`, while exact sources live under `revisions/` in the configured share directory. - -Revoking a Channel makes its stable `/a/` address return `410 Gone`. It does not delete immutable Revision addresses that have already been distributed. This is a publication boundary, not a promise that previously shared bytes can be recalled. - -## Owner workflow and API - -Run the static app and share API together: - -```bash -python3 helm_share_server.py \ - --host 0.0.0.0 \ - --port 4173 \ - --public-base-url http://INTRANET_HOST:4173 -``` - -Channel mutations are accepted through an owner loopback connection by default. A remote deployment may set a strong `HELM_OWNER_TOKEN`; command-line management then sends it as `Authorization: Bearer `. Browser requests must also use an allowed Origin, while command-line clients may omit Origin. Never embed the token in HTML or commit it to the repository. - -Create a Channel: - -```http -POST /api/channels/publish -Content-Type: application/json - -{"html":"..."} -``` - -The response contains both `stable_url` and `revision_url`. To publish changed bytes, send the Revision currently observed by the editor: - -```http -POST /api/channels/publish -Content-Type: application/json - -{"html":"...", "base_revision_sha256":""} -``` - -If another writer has advanced the Artifact, the service returns `409 revision_conflict` and the current digest instead of silently replacing it. Exact retries of the current published bytes are idempotent. - -Revoke the stable address with the same compare-and-swap boundary: - -```http -POST /api/channels/artifacts//revoke -Content-Type: application/json - -{"base_revision_sha256":""} -``` - -Owner-only `GET /api/channels` and `GET /api/channels/artifacts/` expose publication records for management UI. They are not an intranet directory. - -`GET /api/public/channels` exposes only the minimal metadata and stable/immutable URLs for currently published Channels. Revoked Channels and internal revision history are omitted. The hosted homepage uses this endpoint to render the public library without copying records into a visitor's IndexedDB. - -## Legacy one-shot shares - -Existing one-shot sharing remains fully compatible: - -- `POST /api/share` publishes one validated document through loopback. -- `GET /share/--.html` remains an immutable public address. -- Existing flat share files are not renamed or redirected during the Channels migration. Helm keeps their original URL visible as a **Legacy immutable share** instead of misreporting it as a Channel. -- The owner may explicitly retire one exact legacy URL with `POST /api/share/revoke`. The request must include both its `/share/...` path and full SHA-256 digest; Helm verifies the path, filename digest prefix, and stored bytes before deleting that one file. - -```http -POST /api/share/revoke -Content-Type: application/json - -{"path":"/share/--.html", "sha256":""} -``` - -After this explicit action the legacy URL returns `404 Not Found`. Unlike a Channel revoke, there is no retained content-addressed Revision behind a legacy one-shot share. - -The legacy endpoint never advances a Channel because it has no base Revision for conflict detection. Publish through `/api/channels/publish` when one stable address should evolve. - -## Security boundary - -- Public HTML receives a sandboxed CSP, `no-referrer`, and `nosniff` headers. -- The default share directory is `~/.helm-shares`; Channel source files and catalog metadata are owner-only. Legacy flat files retain their original compatibility permissions. -- Restrict the listening port to the intended private network at the host firewall. -- Never publish secrets, credentials, private source material, or machine-specific access data. -- Deleting a browser catalog entry does not delete an already published URL. - -## Repeatable remote deployment - -Deploy only a reviewed, committed tree. Runtime shares live outside the application directory and survive an atomic upgrade: - -```bash -scripts/deploy-remote \ - --host USER@INTRANET_HOST \ - --public-base-url http://INTRANET_HOST:4173 -``` - -The command archives `HEAD`, runs the full test suite in a staging directory on the target, swaps the application directory, restarts the configured tmux service, and rolls back if the Channel health check fails. It never copies browser data, Bridge tokens, or `~/.helm-shares`. diff --git a/docs/LOCAL-ARCHIVE-LAYOUT.md b/docs/LOCAL-ARCHIVE-LAYOUT.md deleted file mode 100644 index 8f5358d..0000000 --- a/docs/LOCAL-ARCHIVE-LAYOUT.md +++ /dev/null @@ -1,62 +0,0 @@ -# Helm portable archive format - -`archive-backup.js` exports and imports a portable JSON file for moving a Helm library between browser profiles or keeping an independent backup. It is deliberately separate from the browser database: an importer returns a plan, and the calling application decides what to persist. - -## `HARC/1.0` - -```json -{ - "format": "helm-archive", - "schema_version": "HARC/1.0", - "exported_at": "2026-07-13T12:00:00.000Z", - "document_count": 1, - "documents": [ - { - "id": "decision-brief-q3", - "metadata": { - "title": "Q3 decision brief", - "type": "brief", - "tags": ["decision", "active"], - "summary": "The context and recommendation for the Q3 decision.", - "source": "Planning agent", - "project": { "id": "planning", "name": "Planning workspace" }, - "created_at": "2026-07-01T08:00:00.000Z", - "updated_at": "2026-07-13T11:30:00.000Z" - }, - "html": "..." - } - ] -} -``` - -The original HTML is stored verbatim in `html`; its embedded HDOC manifest remains part of that source. Archive metadata mirrors Helm's **catalog record** using snake-case timestamp keys. All timestamps are required ISO 8601 values and are copied without changing their original value. - -Helm deliberately keeps a catalog overlay separate from the immutable HTML source. A local title, type, tags, summary, source label, project workspace, or `catalogUpdatedAt` may therefore differ from the original HDOC manifest after the user organizes the library. `metadata.project` is a first-class optional catalog field; other overlay facts are preserved through `metadata.extensions`. None are written back into `html` during export or recovery. - -`metadata.extensions` is optional. It preserves JSON-only document fields that are not part of Helm's core record — including `sourceDocumentId`, `identityState`, `catalogUpdatedAt`, workflow state, Fork provenance, and the immutable Revision graph. Extension field names cannot replace core fields or use prototype-sensitive names. This keeps `HARC/1.0` backwards compatible while allowing a clean-library recovery to recreate Channel history. - -## Safe import contract - -`HelmArchiveBackup.prepareImport(archiveOrJson, existingDocuments)` validates the entire payload before returning anything. It returns only documents whose `id` does not already exist. Same-ID records are returned as `conflicts`; they are never renamed, merged, or overwritten automatically. - -```js -const plan = HelmArchiveBackup.prepareImport(jsonText, currentDocuments); -await Promise.all(plan.acceptedDocuments.map(saveDocument)); -// Render plan.conflicts for an explicit user decision. -``` - -Do not persist `acceptedDocuments` until the caller has shown the result. Helm restores Revision history for an accepted Artifact. Existing-ID records remain conflicts: `HARC/1.0` makes no implicit overwrite, merge, or head-advance decision. - -## Browser helpers - -```js -const archive = HelmArchiveBackup.createArchive(selectedDocuments); -HelmArchiveBackup.downloadArchive(archive); - -// Optional Chromium File System Access API; downloads remain the fallback. -if (HelmArchiveBackup.hasFileSystemAccess()) { - await HelmArchiveBackup.saveWithFileSystemAccess(selectedDocuments); -} -``` - -`readArchiveFile(file)` accepts a file-input `File`, validates it, and returns the archive. `openWithFileSystemAccess({ existingDocuments })` does the same through the optional picker and returns a safe import plan. diff --git a/docs/REPORT-DESIGN-STANDARD.md b/docs/REPORT-DESIGN-STANDARD.md deleted file mode 100644 index 9acfee3..0000000 --- a/docs/REPORT-DESIGN-STANDARD.md +++ /dev/null @@ -1,145 +0,0 @@ -# Helm Report Design Standard - -## Why this exists - -Helm preserves finished thinking, not just files. A retained HTML document should let a reader answer three questions without reopening the task that produced it: - -1. What is the answer or decision? -2. What evidence earns that answer? -3. What should happen next, and what remains uncertain? - -This is an editorial and information-design standard, not a prescribed visual theme. It takes the useful parts of a strong report studio — thesis-led reading, visible evidence, deliberate visual grammar, and calm density — and makes them portable for any standalone `HDOC/1.0` file. - -## The reader path - -Every Helm artifact needs a visible reading path. The first screen should establish the artifact's **kind**, **title**, **short answer or purpose**, and enough context to judge its freshness and scope. Do not make a reader scroll through background before learning why the document matters. - -For a report or brief, use this sequence unless the subject gives a better reason not to: - -1. **Question / decision** — what is being investigated or decided, for whom, and why now. -2. **Short answer** — the conclusion, recommendation, or current state in plain language. -3. **Evidence** — observations, data, sources, alternatives, or examples that materially support the answer. -4. **Interpretation** — what the evidence means; include trade-offs or counter-evidence where they change the conclusion. -5. **Action and boundary** — next step, owner or checkpoint when relevant; state assumptions, unknowns, and confidence. -6. **Method and sources** — dates, source links, collection method, and definitions needed to revisit the work. - -Do not add ceremony for its own sake. A short note may compress the sequence; a deep dossier may repeat it in chapters. What must remain is answer-first orientation and a visible route back to evidence. - -## Choose the right report shape - -Use the document type to select a reader path, not merely a label. - -| Type | The reader should get | Typical shape | -| --- | --- | --- | -| `report` | An answer to a question and the evidence behind it | Question → answer → evidence → interpretation → recommendation → sources | -| `brief` | A decision and the real trade-offs | Decision → options/comparison → recommendation → owner/checkpoint → risks | -| `reference` | A durable, reusable explanation | Pattern → when to use → how to apply → caveats → primary sources | -| `dashboard` | The current state plus how to read it | Headline findings → time range/definitions → measures → changes → data notes | -| `note` | A recoverable finding or handoff | Finding → context → supporting evidence → open question / next step | - -## Visual evidence, not visual decoration - -Visual treatment must reveal the relationship in the material. A strong Helm report is often read first through its figures: the reader should be able to locate the conclusion, the comparison, the boundary, or the change before reading every paragraph. This does **not** mean adding generic illustrations or turning every section into a card. - -For a report, brief, dashboard, or operational handoff, inventory every material comparison, sequence, hierarchy, magnitude, composition, uncertainty, claim-strength distinction, case comparison, or action dependency before writing HTML. Place the smallest useful visual module near each claim it clarifies. A short report may need one; a benchmark dossier may need a sequence of complementary modules. Do not impose an arbitrary report-wide maximum that leaves important relationships prose-only. - -| Reader needs to see | Use | -| --- | --- | -| Alternatives and trade-offs | Comparison table or matrix | -| Sequence, stages, or ownership | Timeline or flow | -| Relative magnitude or change | Small chart with units, range, and date | -| Hierarchy or system boundaries | Diagram or nested outline | -| Source quality and uncertainty | Evidence ledger with source, claim, date, and confidence | - -Never use a chart, image, large number, or diagram as decoration. A factual visual needs a nearby title that says what it proves, readable labels, a unit or denominator and time range where relevant, and a source or method note. Keep the same information available as text or a table so the document remains understandable without the figure. - -## The visual evidence contract - -Each visual module must make one real relationship easier to understand. Before drawing it, write the sentence it needs to establish. Then use this compact contract: - -1. **Claim title** — name the conclusion the reader should see, not the chart type. “Parallel recall reduced total time in this environment” is stronger than “Latency chart”. -2. **Relationship and labels** — show the compared entities, ordering, units, baseline, arrows, or boundaries directly in the visual. A reader should not need a color legend to decode the central relationship. -3. **Evidence state** — distinguish measured fact, verified implementation evidence, interpretation, proposal, and illustrative placeholder. Do not make a mock number or generated image look observed. -4. **Scope note** — place source, date, collection method, confidence, and limiting condition in the figure caption or immediately below it. -5. **Text fallback** — use a semantic `
    ` with a `figcaption`, an adjacent table/list, or both. Inline SVG needs a title or an accessible text equivalent; no visual may be the only carrier of a consequential claim. - -Use inline SVG or semantic HTML/CSS for diagrams and simple charts. They keep the artifact self-contained, printable, searchable, and legible at narrow widths. Do not require a runtime CDN, a canvas-only rendering, a remote image, or interaction to learn the core result. - -Keep evidence navigation portable as well as visual. Do not link to sibling files with relative paths such as `../stage2/report.html`: Helm retains one HTML evidence original, not the source directory tree. Use an absolute, durable source URL, or bring the relevant finding and provenance into the artifact. Embed essential visual resources rather than referencing relative image, font, media, or stylesheet files. - -## Visual grammar library - -Use these patterns consistently rather than inventing a new decorative shape for every report: - -| Relationship | Preferred visual | What must be explicit | -| --- | --- | --- | -| Decision and trade-off | Comparison matrix or highlighted option table | Common criteria, selected option, reason, reversal condition | -| Evidence to conclusion | Evidence route or claim ledger | Observation vs inference, confidence, source/date | -| Steps, ownership, or handoff | Flow or timeline | Verb-labelled transitions, owner, checkpoint or failure path | -| Hierarchy, scope, or architecture | Boundary diagram or nested tree | Containment/ownership, interfaces, excluded scope | -| Magnitude or before/after change | Bar, slope, or small multiple | Unit, denominator, baseline, time window, measurement condition | -| Composition or allocation | Stacked bar or labelled partition | Whole being partitioned and whether parts truly sum to it | -| Uncertainty or range | Interval/range plot with a plain-language readout | Meaning of interval, sample/run boundary, confidence or percentile | -| Headline measures | KPI strip | Unit, denominator, direction, time/environment, and why the measure matters | -| Comparable examples | Case ledger | Same fields, outcome state, evidence, and explicit failure boundary | -| Part-to-whole execution | Trace decomposition | A real total, non-overlapping parts, units, and measurement or illustrative state | -| Action and checkpoint | Roadmap rows | Evidence, owner, next action, metric/checkpoint, and reversal condition | - -## Agent component contract - -Helm components are an authoring API, not visual inspiration. Substantial agent-authored reports should start with `scripts/helm-report`, declare `manifest.presentation.profile`, and bind each planned relationship to a rendered component through `manifest.presentation.claims` and matching DOM attributes: - -```html -
    - ... -
    -``` - -The registered vocabulary is intentionally small: KPI strip, evidence ledger, comparison matrix, ranked bars, range, sequence, hierarchy, trace, case ledger, and roadmap. Extend it only when none of these grammars can explain the relationship without distortion. `scripts/helm-report check` verifies the declared Claim–component coverage and rejects unfinished placeholders before handoff. - -Presentation pacing is separate from evidence grammar. The `deep-dive` Profile wraps existing components in the `chapter-reveal` mode: viewport-sized chapters, a sticky chapter index, and a restrained CSS view-timeline entry effect. Motion never counts as evidence, never replaces a component, and never hides a conclusion or caveat. The baseline is ordinary semantic HTML; narrow screens, printing, browsers without scroll-driven animation, and `prefers-reduced-motion` all receive the complete static report. Do not add mandatory scroll snapping, executable reveal scripts, or canvas-only information. - -Use a real screenshot only when the interface itself is evidence. Annotate version/date and sensitive-data treatment. Use generated imagery only for atmosphere or a clearly labelled conceptual metaphor — never as fabricated operational, product, or measurement evidence. - -## Page and component language - -- Use one strong title, a compact overline for type/date/status, and a direct summary. The title tells the subject; the summary tells the reader why it matters. -- Create hierarchy with typographic contrast, whitespace, rules, and grouping before adding cards, gradients, or shadows. -- Keep the reading column comfortable. Use a wider canvas only for comparison tables, diagrams, or evidence ledgers that require it. -- Use quiet neutral surfaces and one restrained accent to direct attention. Reserve loud color for a meaningful state such as risk, decision, or change. -- Put supporting metadata close to the claim it qualifies: source, data date, confidence, definition, or assumption. Do not hide essential context behind a hover or an interaction. -- Use cards only when they separate peers the reader may compare or scan. A page made of unrelated cards has no narrative. -- Interactions may filter, reveal method detail, or switch a view; they must not be required to understand the document's core conclusion or evidence. -- Prefer semantic sections, ordered headings, real tables and lists. A visual treatment should survive printing, export, reader mode, and a narrow viewport. - -## Required content checks - -Before delivering a document, ask: - -1. Can a reader state the conclusion or purpose after the first screen? -2. Can they distinguish evidence from interpretation and recommendation? -3. Does every consequential factual claim have a source, date, assumption, or an explicit "unknown" label? -4. Does each table, chart, diagram, or metric answer a specific question that prose alone would answer less clearly? -5. Where the material contains a material comparison, sequence, hierarchy, magnitude, or uncertainty, is there at least one meaningful visual evidence module close to that claim? -6. Does each visual show its labels, units or boundaries, evidence state, and source/method without relying on a hover or interaction? -7. Is the next decision, action, or open question explicit rather than implied? -8. Would the page still read as a report if its visual polish were removed? - -If any answer is no, fix the document structure before refining its skin. - -## Minimum implementation rules - -This standard supplements, but does not replace, [`HTML-DOCUMENT-SPEC.md`](HTML-DOCUMENT-SPEC.md). Continue to deliver one self-contained `HDOC/1.0` file with a manifest, duplicate `helm:*` metadata, one semantic `
    `, embedded essential CSS, and provenance. - -Start a substantial artifact from the closest `scripts/helm-report` Profile rather than recreating the information architecture. The files in [`templates/`](../templates/) remain compact compatibility starters; the authoring kit is the canonical source for composable visual components and pre-handoff checks. - -## Generator instruction - -Give this instruction to an agent in addition to the HDOC contract: - -> Design this as a Helm evidence report, not a landing page. Lead with the question and short answer; make the reader's path from evidence to interpretation to next action explicit. Where the material has a comparison, sequence, hierarchy, change, composition, or uncertainty, choose one to three visual evidence modules from the Helm visual grammar and make each carry a named conclusion. Use inline SVG or semantic HTML/CSS; include labels, units or boundaries, evidence state, source/method notes, and a text fallback. Put sources, dates, assumptions, and confidence beside the claims they qualify. Keep essential content visible without interaction, use calm reading-first typography and restrained color, and return one complete standalone HDOC/1.0 HTML document. diff --git a/examples/codex-memory-activation.html b/examples/codex-memory-activation.html deleted file mode 100644 index b8d303c..0000000 --- a/examples/codex-memory-activation.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - Helm 的 Codex 接入:可复核上下文,不是强制全局记忆 - - - - -
    -
    Reference · agent workflowReviewed 2026-07-13
    -
    -

    Codex / repository memory / controlled handoff

    可复核上下文,不是强制全局记忆。

    Helm 能让任何进入仓库的 agent 获得一致、可审计的写作与交接规则;它不能替供应商的产品强制写入全局长期记忆,也不应绕过人对浏览器库的控制。

    -
    适用对象
    在 Helm clone 内生成需保留 HTML 的 agent。
    关键产物
    项目记忆、HDOC 合同、报告标准、模板与 Bridge inbox。
    所有权
    agent 交接文件;人明确导入浏览器库。
    -
    - -

    模式一句话

    把必须遵守的上下文放在仓库中、让 agent 每次可重新读取;把文档交接做成可验证的本地 inbox;把最终进入个人库的决定保留给人。

    - -

    01 / guarantee

    这个接入真正保证什么

    它保证的是可复现的工作流,而不是“某个模型已经永久记住了 Helm”。任何兼容 agent 都能从同一套 checked-in 文件恢复规则;平台若支持项目级持久记忆,可以加速,但不是正确性的唯一来源。

    仓库可保证

    生成前能读到 HDOC/1.0、报告标准、模板与无密钥规则;交接前可做本地验证;Bridge 保留提交的原始字节。

    仓库不能保证

    不能强制供应商更新其全局长期记忆,不能直接写入浏览器 IndexedDB,也不能让 agent 静默替人导入文件。

    - -

    02 / sequence

    每次生成保留文档的最小路径

    这张表把责任与可观察证据分开。只要其中任一步缺失,就不应把“已进入 Helm 库”当成完成。

    步骤agent 动作留下的证据不代表什么
    加载上下文读取 docs/CODEX-MEMORY.md、生成指南、报告标准与 HDOC 合同。文档结构、元数据与写作顺序遵守同一规则。供应商全局记忆一定被更新。
    生成与验证从最接近的模板开始,输出一个完整自包含的 HTML。单一根节点、单一 h1、manifest、来源与无可执行内容。内容的事实正确性自动得到保证。
    交接通过 Helm Bridge 提交最终字节一次。Bridge 接受的稳定 ID 与原始 HTML。浏览器库已经改变。
    导入在 Helm 的 Agent inbox 选择并导入。浏览器本地库出现新的 artifact 记录。源文件或既有 artifact 被静默覆盖。
    - -

    03 / handoff

    交接边界:文件移动,不是权限升级

    Bridge 是交接缓冲区。它让 agent 交付完整文件,却不让 agent 获得浏览器库、云同步或任何凭证的访问权。

    01Agent

    写出并验证 HDOC 文档。

    02Bridge inbox

    检查合同并保存原始字节。

    03Human review

    在当前库旁查看待导入项。

    04Browser library

    明确导入,永不静默覆盖。

    - -

    04 / guardrails

    不能省略的边界

    Keep this true

    不要把 token、凭证、私有 HTML 或机器地址写入项目记忆、报告或模板。不同字节不能复用既有 stable ID;发生冲突时,新建真正新的文档身份。报告必须呈现来源、日期、假设与置信度,而不是只交付漂亮的页面。

    - - -
    - - diff --git a/examples/helm-authoring-kit-evaluation.html b/examples/helm-authoring-kit-evaluation.html deleted file mode 100644 index 9da6751..0000000 --- a/examples/helm-authoring-kit-evaluation.html +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - Helm authoring kit: first implementation review - - - - -
    -
    research · HDOC/1.0Updated 2026-07-14
    -
    -

    Evidence original · research

    Helm authoring kit: first implementation review

    Helm now turns report design guidance into selectable components, report profiles, and a visual contract that agents must pass before handoff.

    -
    Profile
    research review
    Components
    5 visual modules
    Evidence
    Repository + test run
    -
    -

    Short answer

    The first implementation closes the main usability gap: agents now receive a composable visual vocabulary and a failing quality gate, not merely a description of good taste.

    - -
    01 / FINDINGS

    The system now connects editorial judgment to executable authoring.

    This review covers the checked-in CLI, component catalog, browser validator, documentation, and automated test run on 2026-07-14. It does not yet measure quality across many independent agents.

    KPI strip · implementation surface

    The first release is broad enough to change the default agent workflow.

    VERIFIEDCurrent repository snapshot
    Components
    10
    registered visual grammars
    Profiles
    5
    research through handoff
    Tests
    24
    all passing in one run
    Runtime CDN
    0
    standalone HTML/CSS output

    Repository implementation and test suite, 2026-07-14. Counts describe this revision, not future compatibility.

    -

    Comparison matrix · authoring model

    The new path removes the three places where agents previously defaulted to prose.

    VERIFIEDRepository behavior comparison
    Previous guidance compared with the composable authoring kit
    CriterionPrevious pathCurrent pathWhy it matters
    Component selectionRead prose and improviseChoose from 10 registered grammarsLower authoring effort
    Starting structureOne fixed visual per full-page templateFive Profiles with component overrideContent drives composition
    Quality gateHDOC validity onlyClaim mapping, state, scope, source, fallback, placeholdersIncomplete visuals fail before handoff
    Direct inspection of the previous templates and the new authoring CLI and validator on 2026-07-14.
    -

    Sequence · agent authoring

    Agents must now choose the relationship before they receive the component.

    VERIFIEDCanonical authoring path
    1. 01 / INVENTORYClaimsName comparisons, ranges, flows, and boundaries.
    2. 02 / SELECTProfileStart with the closest report composition.
    3. 03 / BINDComponentsMap every material relation to a Claim ID.
    4. 04 / PROVECheckReplace specimens and pass visual preflight.
    The CLI creates the mapping; the Agent fills evidence; the linter rejects unfinished handoff.
    -

    Hierarchy · responsibility

    The design standard remains the constitution; the kit supplies vocabulary, defaults, and enforcement.

    INTERPRETEDResponsibility model
    This separation prevents a visual theme from replacing editorial judgment.
    -

    Roadmap · next proof

    The next milestone is repeated agent output quality, not a larger component count.

    PROPOSEDRequires multi-agent evidence
    NOW
    Generate three real reports in different projectsBenchmark, architecture, and decision content should each select different grammars.
    OwnerCodex sessions using Helm
    CheckpointNo prose-only material relationship
    NEXT
    Promote proven variants into the registryAdd a component only when existing grammars distort a recurring relation.
    EvidenceRendered reports + lint results
    Revisit ifAgents still bypass components
    The proposal follows from the implementation gap; broad Agent effectiveness remains unmeasured.
    -

    Sources and method

    This review is reproducible from the repository.

    1. Inspected AI-GUIDE.md, docs/REPORT-DESIGN-STANDARD.md, the four compatibility templates, scripts/helm-report, and validator.js on 2026-07-14.
    2. Ran the Python unit suite, JavaScript syntax checks, Python compilation, HDOC validation, and visual-contract validation. The report does not claim cross-agent quality until multiple independent projects use the workflow.
    -
    - - diff --git a/folder-sync.js b/folder-sync.js deleted file mode 100644 index 1a8d76d..0000000 --- a/folder-sync.js +++ /dev/null @@ -1,456 +0,0 @@ -/* - * Helm explicit archive-folder helper. - * - * This module is intentionally storage-agnostic: it never opens IndexedDB and - * it never watches a directory. A caller chooses a directory in a user gesture, - * retains that handle if desired, then invokes writeArchive or recoverArchive - * from an explicit Sync now / Recover action. - */ -(function attachHelmFolderSync(global) { - 'use strict'; - - const FORMAT = 'helm-archive'; - const SCHEMA_VERSION = 'HARC/1.0'; - const ARCHIVE_PROFILE = 'folder-index'; - const INDEX_FILENAME = 'helm-archive.json'; - const ARTIFACT_DIRECTORY = 'artifacts'; - const CORE_FIELDS = new Set(['id', 'title', 'type', 'tags', 'summary', 'source', 'createdAt', 'updatedAt', 'html', 'extensions']); - const RESERVED_KEYS = new Set(['__proto__', 'prototype', 'constructor']); - - function isPlainObject(value) { - return Boolean(value) && Object.prototype.toString.call(value) === '[object Object]'; - } - - function isIsoTimestamp(value) { - return typeof value === 'string' - && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value) - && !Number.isNaN(Date.parse(value)); - } - - function cloneJson(value, path) { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError(`${path} must contain only JSON values.`); - return value; - } - if (Array.isArray(value)) return value.map((entry, index) => cloneJson(entry, `${path}[${index}]`)); - if (!isPlainObject(value)) throw new TypeError(`${path} must contain only JSON values.`); - const copy = {}; - for (const [key, entry] of Object.entries(value)) { - if (RESERVED_KEYS.has(key)) throw new TypeError(`${path}.${key} is not an allowed extension key.`); - copy[key] = cloneJson(entry, `${path}.${key}`); - } - return copy; - } - - function failure(path, error) { - return { - path, - name: error?.name || 'Error', - message: error?.message || String(error) - }; - } - - function result(status, details = {}) { - return { ok: status === 'written' || status === 'recovered', status, failures: [], conflicts: [], ...details }; - } - - function hasFileSystemAccess() { - return typeof global.showDirectoryPicker === 'function'; - } - - function isDirectoryHandle(handle) { - return Boolean(handle) - && (handle.kind === undefined || handle.kind === 'directory') - && typeof handle.getFileHandle === 'function' - && typeof handle.getDirectoryHandle === 'function'; - } - - function checkMode(mode) { - if (mode === undefined) return 'read'; - if (mode !== 'read' && mode !== 'readwrite') throw new TypeError('Permission mode must be "read" or "readwrite".'); - return mode; - } - - async function verifyPermission(handle, options = {}) { - const mode = checkMode(options.mode); - if (!isDirectoryHandle(handle)) return { ok: false, state: 'invalid-handle', mode, requested: false }; - if (typeof handle.queryPermission !== 'function') { - return { ok: false, state: 'unsupported', mode, requested: false, message: 'This directory handle cannot report its permission state.' }; - } - try { - let state = await handle.queryPermission({ mode }); - let requested = false; - if (state !== 'granted' && options.request === true) { - if (typeof handle.requestPermission !== 'function') { - return { ok: false, state: 'unsupported', mode, requested: false, message: 'This directory handle cannot request permission.' }; - } - state = await handle.requestPermission({ mode }); - requested = true; - } - return { ok: state === 'granted', state, mode, requested }; - } catch (error) { - return { ok: false, state: 'error', mode, requested: false, error: failure('directory', error) }; - } - } - - async function chooseDirectory(options = {}) { - if (!hasFileSystemAccess()) return result('unsupported', { message: 'The File System Access API is unavailable in this browser.' }); - const mode = checkMode(options.mode === undefined ? 'readwrite' : options.mode); - const pickerOptions = { mode }; - if (typeof options.id === 'string' && options.id) pickerOptions.id = options.id; - if (options.startIn !== undefined) pickerOptions.startIn = options.startIn; - try { - const handle = await global.showDirectoryPicker(pickerOptions); - const permission = await verifyPermission(handle, { mode, request: false }); - return result('selected', { ok: true, handle, permission }); - } catch (error) { - if (error?.name === 'AbortError') return result('cancelled', { message: 'No directory was selected.' }); - return result('picker-failed', { failures: [failure('directory', error)] }); - } - } - - // A deterministic, filename-safe label. The hash prevents normalisation and - // case-folding collisions; buildFolderIndex rejects the improbable hash clash. - function fingerprint(value) { - let hash = 0x811c9dc5; - for (let index = 0; index < value.length; index += 1) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, '0'); - } - - function safeArtifactId(id) { - if (typeof id !== 'string' || !id.trim()) throw new TypeError('An artifact id must be a non-empty string.'); - const slug = id.trim().normalize('NFKD').replace(/[\u0300-\u036f]/g, '') - .replace(/[^A-Za-z0-9._-]+/g, '-') - .replace(/[-_.]{2,}/g, '-') - .replace(/^[-_.]+|[-_.]+$/g, '') - .slice(0, 72) || 'artifact'; - return `${slug}--${fingerprint(id)}`; - } - - function artifactPathForId(id) { - return `${ARTIFACT_DIRECTORY}/${safeArtifactId(id)}.html`; - } - - function normaliseExtensions(document, path) { - const extensions = {}; - for (const [key, value] of Object.entries(document)) { - if (!CORE_FIELDS.has(key)) extensions[key] = cloneJson(value, `${path}.${key}`); - } - if (document.extensions !== undefined) { - if (!isPlainObject(document.extensions)) throw new TypeError(`${path}.extensions must be a JSON object when present.`); - for (const [key, value] of Object.entries(document.extensions)) { - if (RESERVED_KEYS.has(key) || CORE_FIELDS.has(key)) throw new TypeError(`${path}.extensions.${key} is a reserved document field.`); - extensions[key] = cloneJson(value, `${path}.extensions.${key}`); - } - } - return Object.keys(extensions).length ? extensions : undefined; - } - - function normaliseDocument(document, index) { - const path = `documents[${index}]`; - if (!isPlainObject(document)) throw new TypeError(`${path} must be a document object.`); - if (typeof document.id !== 'string' || !document.id.trim()) throw new TypeError(`${path}.id must be a non-empty string.`); - if (typeof document.title !== 'string' || !document.title.trim()) throw new TypeError(`${path}.title must be a non-empty string.`); - if (typeof document.type !== 'string' || !document.type.trim()) throw new TypeError(`${path}.type must be a non-empty string.`); - if (!Array.isArray(document.tags) || document.tags.some((tag) => typeof tag !== 'string')) throw new TypeError(`${path}.tags must be an array of strings.`); - if (document.summary !== undefined && document.summary !== null && typeof document.summary !== 'string') throw new TypeError(`${path}.summary must be a string or null.`); - if (document.source !== undefined && document.source !== null && typeof document.source !== 'string') throw new TypeError(`${path}.source must be a string or null.`); - if (!isIsoTimestamp(document.createdAt)) throw new TypeError(`${path}.createdAt must be an ISO 8601 timestamp.`); - if (!isIsoTimestamp(document.updatedAt)) throw new TypeError(`${path}.updatedAt must be an ISO 8601 timestamp.`); - if (typeof document.html !== 'string') throw new TypeError(`${path}.html must contain the original HTML source as a string.`); - const metadata = { - title: document.title, - type: document.type, - tags: [...document.tags], - summary: document.summary ?? null, - source: document.source ?? null, - created_at: document.createdAt, - updated_at: document.updatedAt - }; - const extensions = normaliseExtensions(document, path); - if (extensions) metadata.extensions = extensions; - return { id: document.id, metadata, html: document.html, artifact_path: artifactPathForId(document.id) }; - } - - function buildFolderIndex(documents, options = {}) { - if (!Array.isArray(documents)) throw new TypeError('buildFolderIndex expects an array of document records.'); - const exportedAt = options.exportedAt || new Date().toISOString(); - if (!isIsoTimestamp(exportedAt)) throw new TypeError('exportedAt must be an ISO 8601 timestamp.'); - const ids = new Set(); - const paths = new Set(); - const records = documents.map(normaliseDocument).map((document, index) => { - if (ids.has(document.id)) throw new TypeError(`documents[${index}].id duplicates another document id.`); - if (paths.has(document.artifact_path)) throw new TypeError(`documents[${index}].id maps to an artifact filename already used by another id.`); - ids.add(document.id); - paths.add(document.artifact_path); - return { id: document.id, metadata: document.metadata, artifact_path: document.artifact_path }; - }); - return { - format: FORMAT, - schema_version: SCHEMA_VERSION, - archive_profile: ARCHIVE_PROFILE, - artifact_directory: ARTIFACT_DIRECTORY, - exported_at: exportedAt, - document_count: records.length, - documents: records - }; - } - - function validateArtifactPath(path) { - return typeof path === 'string' - && /^artifacts\/[A-Za-z0-9][A-Za-z0-9._-]*\.html$/.test(path) - && !path.includes('..'); - } - - function validateFolderIndex(payload) { - const errors = []; - const add = (path, message) => errors.push({ path, message }); - if (!isPlainObject(payload)) return { valid: false, errors: [{ path: '$', message: 'Folder index must be a JSON object.' }] }; - if (payload.format !== FORMAT) add('format', `must equal "${FORMAT}".`); - if (payload.schema_version !== SCHEMA_VERSION) add('schema_version', `must equal "${SCHEMA_VERSION}".`); - if (payload.archive_profile !== ARCHIVE_PROFILE) add('archive_profile', `must equal "${ARCHIVE_PROFILE}".`); - if (payload.artifact_directory !== ARTIFACT_DIRECTORY) add('artifact_directory', `must equal "${ARTIFACT_DIRECTORY}".`); - if (!isIsoTimestamp(payload.exported_at)) add('exported_at', 'must be an ISO 8601 timestamp.'); - if (!Number.isInteger(payload.document_count) || payload.document_count < 0) add('document_count', 'must be a non-negative integer.'); - if (!Array.isArray(payload.documents)) add('documents', 'must be an array.'); - if (Array.isArray(payload.documents) && payload.document_count !== payload.documents.length) add('document_count', 'must match documents.length.'); - if (errors.length) return { valid: false, errors }; - - const ids = new Set(); - const paths = new Set(); - const documents = []; - payload.documents.forEach((entry, index) => { - const path = `documents[${index}]`; - if (!isPlainObject(entry)) { add(path, 'must be an object.'); return; } - if (typeof entry.id !== 'string' || !entry.id.trim()) add(`${path}.id`, 'must be a non-empty string.'); - if (ids.has(entry.id)) add(`${path}.id`, 'duplicates another document id.'); - ids.add(entry.id); - if (!validateArtifactPath(entry.artifact_path)) add(`${path}.artifact_path`, 'must be a safe artifacts/.html path.'); - if (typeof entry.id === 'string' && entry.id.trim() && entry.artifact_path !== artifactPathForId(entry.id)) { - add(`${path}.artifact_path`, 'must be the deterministic safe path for its document id.'); - } - if (paths.has(entry.artifact_path)) add(`${path}.artifact_path`, 'duplicates another artifact path.'); - paths.add(entry.artifact_path); - const metadata = entry.metadata; - if (!isPlainObject(metadata)) { add(`${path}.metadata`, 'must be an object.'); return; } - if (typeof metadata.title !== 'string' || !metadata.title.trim()) add(`${path}.metadata.title`, 'must be a non-empty string.'); - if (typeof metadata.type !== 'string' || !metadata.type.trim()) add(`${path}.metadata.type`, 'must be a non-empty string.'); - if (!Array.isArray(metadata.tags) || metadata.tags.some((tag) => typeof tag !== 'string')) add(`${path}.metadata.tags`, 'must be an array of strings.'); - if (metadata.summary !== null && typeof metadata.summary !== 'string') add(`${path}.metadata.summary`, 'must be a string or null.'); - if (metadata.source !== null && typeof metadata.source !== 'string') add(`${path}.metadata.source`, 'must be a string or null.'); - if (!isIsoTimestamp(metadata.created_at)) add(`${path}.metadata.created_at`, 'must be an ISO 8601 timestamp.'); - if (!isIsoTimestamp(metadata.updated_at)) add(`${path}.metadata.updated_at`, 'must be an ISO 8601 timestamp.'); - let extensions; - try { - if (metadata.extensions !== undefined) { - if (!isPlainObject(metadata.extensions)) throw new TypeError('must be a JSON object when present.'); - extensions = cloneJson(metadata.extensions, `${path}.metadata.extensions`); - } - } catch (error) { add(`${path}.metadata.extensions`, error.message); } - documents.push({ - id: entry.id, - artifact_path: entry.artifact_path, - metadata: { - title: metadata.title, - type: metadata.type, - tags: Array.isArray(metadata.tags) ? [...metadata.tags] : [], - summary: metadata.summary, - source: metadata.source, - created_at: metadata.created_at, - updated_at: metadata.updated_at, - ...(extensions && Object.keys(extensions).length ? { extensions } : {}) - } - }); - }); - if (errors.length) return { valid: false, errors }; - return { - valid: true, - errors: [], - index: { - format: FORMAT, - schema_version: SCHEMA_VERSION, - archive_profile: ARCHIVE_PROFILE, - artifact_directory: ARTIFACT_DIRECTORY, - exported_at: payload.exported_at, - document_count: documents.length, - documents - } - }; - } - - function parseFolderIndex(input) { - let value = input; - if (typeof input === 'string') value = JSON.parse(input); - const checked = validateFolderIndex(value); - if (checked.valid) return checked.index; - const error = new TypeError(`Invalid Helm folder index: ${checked.errors.map((entry) => `${entry.path} ${entry.message}`).join(' ')}`); - error.errors = checked.errors; - throw error; - } - - async function getFileIfPresent(directoryHandle, name) { - try { - return { present: true, handle: await directoryHandle.getFileHandle(name, { create: false }) }; - } catch (error) { - if (error?.name === 'NotFoundError') return { present: false, handle: null }; - throw error; - } - } - - async function getDirectoryIfPresent(directoryHandle, name) { - try { - return { present: true, handle: await directoryHandle.getDirectoryHandle(name, { create: false }) }; - } catch (error) { - if (error?.name === 'NotFoundError') return { present: false, handle: null }; - throw error; - } - } - - async function writeText(fileHandle, text) { - const writable = await fileHandle.createWritable(); - try { - await writable.write(text); - await writable.close(); - } catch (error) { - try { await writable.abort?.(); } catch { /* Preserve the original error. */ } - throw error; - } - } - - function permissionFailure(permission) { - const status = permission.state === 'denied' ? 'permission-denied' : 'permission-required'; - return result(status, { permission, failures: permission.error ? [permission.error] : [] }); - } - - async function writeArchive(directoryHandle, documents, options = {}) { - const conflictPolicy = options.conflictPolicy === undefined ? 'error' : options.conflictPolicy; - if (conflictPolicy !== 'error' && conflictPolicy !== 'replace') { - return result('invalid-options', { failures: [{ path: 'conflictPolicy', name: 'TypeError', message: 'conflictPolicy must be "error" or "replace".' }] }); - } - if (!isDirectoryHandle(directoryHandle)) return result('invalid-handle', { failures: [{ path: 'directory', name: 'TypeError', message: 'writeArchive requires a directory handle.' }] }); - let folderIndex; - let normalisedDocuments; - try { - folderIndex = buildFolderIndex(documents, options); - normalisedDocuments = documents.map(normaliseDocument); - } catch (error) { - return result('invalid-documents', { failures: [failure('documents', error)] }); - } - const permission = await verifyPermission(directoryHandle, { mode: 'readwrite', request: options.requestPermission === true }); - if (!permission.ok) return permissionFailure(permission); - - let artifacts; - try { - artifacts = await getDirectoryIfPresent(directoryHandle, ARTIFACT_DIRECTORY); - const conflicts = []; - if (artifacts.present) { - for (const document of normalisedDocuments) { - const name = document.artifact_path.slice(`${ARTIFACT_DIRECTORY}/`.length); - if ((await getFileIfPresent(artifacts.handle, name)).present) conflicts.push({ path: document.artifact_path, reason: 'existing-artifact', id: document.id }); - } - } - if ((await getFileIfPresent(directoryHandle, INDEX_FILENAME)).present) conflicts.push({ path: INDEX_FILENAME, reason: 'existing-index' }); - if (conflicts.length && conflictPolicy !== 'replace') { - return result('conflict', { index: folderIndex, conflicts, requiresExplicitReplace: true, permission }); - } - const artifactDirectory = artifacts.present ? artifacts.handle : await directoryHandle.getDirectoryHandle(ARTIFACT_DIRECTORY, { create: true }); - const writtenFiles = []; - const replacedPaths = conflicts.map((entry) => entry.path); - const failures = []; - for (const document of normalisedDocuments) { - const name = document.artifact_path.slice(`${ARTIFACT_DIRECTORY}/`.length); - try { - await writeText(await artifactDirectory.getFileHandle(name, { create: true }), document.html); - writtenFiles.push(document.artifact_path); - } catch (error) { - failures.push(failure(document.artifact_path, error)); - break; - } - } - if (failures.length) return result('partial-write', { index: folderIndex, writtenFiles, replacedPaths, failures, conflicts: [], permission }); - try { - await writeText(await directoryHandle.getFileHandle(INDEX_FILENAME, { create: true }), JSON.stringify(folderIndex, null, 2)); - writtenFiles.push(INDEX_FILENAME); - } catch (error) { - failures.push(failure(INDEX_FILENAME, error)); - return result('partial-write', { index: folderIndex, writtenFiles, replacedPaths, failures, conflicts: [], permission }); - } - return result('written', { index: folderIndex, writtenFiles, replacedPaths, conflicts: [], permission }); - } catch (error) { - return result('write-failed', { index: folderIndex, failures: [failure('directory', error)], permission }); - } - } - - function recordFromFolderEntry(entry, html) { - const metadata = entry.metadata; - const record = { - id: entry.id, - title: metadata.title, - type: metadata.type, - tags: [...metadata.tags], - summary: metadata.summary ?? '', - source: metadata.source ?? '', - createdAt: metadata.created_at, - updatedAt: metadata.updated_at, - html - }; - if (metadata.extensions) Object.assign(record, cloneJson(metadata.extensions, `metadata.extensions for ${entry.id}`)); - return record; - } - - async function readArchive(directoryHandle, options = {}) { - if (!isDirectoryHandle(directoryHandle)) return result('invalid-handle', { failures: [{ path: 'directory', name: 'TypeError', message: 'readArchive requires a directory handle.' }] }); - const permission = await verifyPermission(directoryHandle, { mode: 'read', request: options.requestPermission === true }); - if (!permission.ok) return permissionFailure(permission); - let index; - try { - const indexHandle = await directoryHandle.getFileHandle(INDEX_FILENAME, { create: false }); - index = parseFolderIndex(await (await indexHandle.getFile()).text()); - } catch (error) { - return result(error?.name === 'NotFoundError' ? 'missing-index' : 'invalid-index', { failures: [failure(INDEX_FILENAME, error)], permission }); - } - let artifactDirectory; - try { - artifactDirectory = await directoryHandle.getDirectoryHandle(ARTIFACT_DIRECTORY, { create: false }); - } catch (error) { - return result('incomplete', { index, documents: [], failures: [failure(ARTIFACT_DIRECTORY, error)], permission, complete: false }); - } - const documents = []; - const failures = []; - for (const entry of index.documents) { - const name = entry.artifact_path.slice(`${ARTIFACT_DIRECTORY}/`.length); - try { - const html = await (await (await artifactDirectory.getFileHandle(name, { create: false })).getFile()).text(); - documents.push(recordFromFolderEntry(entry, html)); - } catch (error) { - failures.push(failure(entry.artifact_path, error)); - } - } - if (failures.length) return result('incomplete', { index, documents, failures, permission, complete: false }); - return result('recovered', { index, documents, permission, complete: true }); - } - - global.HelmFolderSync = Object.freeze({ - FORMAT, - SCHEMA_VERSION, - ARCHIVE_PROFILE, - INDEX_FILENAME, - ARTIFACT_DIRECTORY, - isSupported: hasFileSystemAccess, - hasFileSystemAccess, - chooseDirectory, - verifyPermission, - safeArtifactId, - artifactPathForId, - buildFolderIndex, - validateFolderIndex, - parseFolderIndex, - writeArchive, - readArchive, - recoverArchive: readArchive - }); -}(typeof window !== 'undefined' ? window : globalThis)); diff --git a/gallery/index.html b/gallery/index.html new file mode 100644 index 0000000..4967553 --- /dev/null +++ b/gallery/index.html @@ -0,0 +1,199 @@ + + + + + +Helm · Library + + + + + +
    +
    + HELMLIBRARY +
    + + +
    +
    +
    + +
    +
    + Local-first · agent-authored artifacts +

    Library

    +

    Agent 产出的独立 HDOC 文件,躺在本机文件夹里。点开即原件,无 server 逻辑,无云。

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + + + + diff --git a/helm_bridge.py b/helm_bridge.py deleted file mode 100644 index 3c1afbe..0000000 --- a/helm_bridge.py +++ /dev/null @@ -1,534 +0,0 @@ -#!/usr/bin/env python3 -"""Helm Bridge: a loopback-only ingress API for AI-authored HDOC HTML. - -The browser app deliberately owns no server-side state. This companion process -gives other agents one safe way to hand documents to that local library: -validate the document, preserve its exact UTF-8 source, and retain every -revision submitted for the same stable HDOC artifact ID. -""" - -from __future__ import annotations - -import argparse -import hashlib -import ipaddress -import json -import os -import re -import secrets -import tempfile -import threading -from datetime import datetime, timezone -from html.parser import HTMLParser -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from typing import Any -from urllib.parse import unquote, urlparse - - -API_VERSION = "HBRIDGE/1.0" -HDOC_VERSION = "HDOC/1.0" -MAX_DOCUMENT_BYTES = 5 * 1024 * 1024 -DOCUMENT_TYPES = {"report", "brief", "reference", "dashboard", "note"} -DEFAULT_CORS_ORIGINS: set[str] = set() -ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$") - - -class ContractError(ValueError): - """The submitted document is not a safe, valid HDOC artifact.""" - - def __init__(self, errors: list[str], warnings: list[str] | None = None): - super().__init__("; ".join(errors)) - self.errors = errors - self.warnings = warnings or [] - - -class HDOCParser(HTMLParser): - def __init__(self) -> None: - super().__init__(convert_charrefs=True) - self.meta: dict[str, str] = {} - self.manifest_scripts: list[str] = [] - self._manifest_parts: list[str] | None = None - self.document_roots = 0 - self.h1_count = 0 - self.unsafe_scripts = 0 - self.event_handlers: list[str] = [] - self.external_dependencies: list[str] = [] - self.relative_dependencies: list[str] = [] - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - attributes = {name.lower(): value or "" for name, value in attrs} - tag = tag.lower() - if tag == "meta" and attributes.get("name", "").lower().startswith("helm:"): - self.meta[attributes["name"].lower()] = attributes.get("content", "").strip() - if tag == "main" and "data-document-root" in attributes: - self.document_roots += 1 - if tag == "h1": - self.h1_count += 1 - if tag == "script": - if "data-helm-manifest" in attributes: - self._manifest_parts = [] - else: - self.unsafe_scripts += 1 - for name, value in attributes.items(): - if name.startswith("on"): - self.event_handlers.append(name) - if name not in {"src", "href"} or not value.strip(): - continue - reference = value.strip() - if re.match(r"^(?:https?:)?//", reference, re.IGNORECASE): - # An ordinary absolute anchor is provenance/navigation, not a - # runtime dependency. Remote src and non-anchor href values are. - if name == "src" or tag != "a": - self.external_dependencies.append(reference) - elif not re.match(r"^(?:#|data:|blob:|mailto:|tel:|[a-z][a-z0-9+.-]*:)", reference, re.IGNORECASE): - self.relative_dependencies.append(reference) - - def handle_data(self, data: str) -> None: - if self._manifest_parts is not None: - self._manifest_parts.append(data) - - def handle_endtag(self, tag: str) -> None: - if tag.lower() == "script" and self._manifest_parts is not None: - self.manifest_scripts.append("".join(self._manifest_parts)) - self._manifest_parts = None - - -def parse_timestamp(value: Any, field: str, errors: list[str]) -> None: - if not isinstance(value, str): - errors.append(f"{field} must be an ISO 8601 timestamp.") - return - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - errors.append(f"{field} must be an ISO 8601 timestamp.") - return - if parsed.tzinfo is None: - errors.append(f"{field} must include a timezone.") - - -def validate_hdoc(html: str) -> tuple[dict[str, Any], list[str]]: - errors: list[str] = [] - warnings: list[str] = [] - if not re.match(r"^\s*.") - parser = HDOCParser() - try: - parser.feed(html) - parser.close() - except Exception as error: # HTMLParser normally recovers, but keep ingress deterministic. - errors.append(f"HTML could not be parsed: {error}.") - - if parser.document_roots != 1: - errors.append("The document must contain exactly one
    .") - if parser.h1_count != 1: - errors.append("The document must contain exactly one h1.") - if parser.unsafe_scripts: - errors.append("Executable script tags are not accepted by Helm Bridge.") - if parser.event_handlers: - errors.append("Inline event handlers are not accepted by Helm Bridge.") - if len(parser.manifest_scripts) != 1: - errors.append("The document must contain exactly one data-helm-manifest script.") - manifest: dict[str, Any] = {} - else: - try: - parsed_manifest = json.loads(parser.manifest_scripts[0]) - manifest = parsed_manifest if isinstance(parsed_manifest, dict) else {} - if not manifest: - errors.append("The Helm manifest must be a JSON object.") - except json.JSONDecodeError: - manifest = {} - errors.append("The Helm manifest is not valid JSON.") - - if manifest.get("schema_version") != HDOC_VERSION: - errors.append(f"manifest.schema_version must equal {HDOC_VERSION}.") - document_id = manifest.get("id") - if not isinstance(document_id, str) or not ID_PATTERN.fullmatch(document_id) or len(document_id) > 100: - errors.append("manifest.id must be a stable lowercase ID (letters, digits, hyphens; max 100).") - title = manifest.get("title") - if not isinstance(title, str) or not title.strip() or len(title) > 100: - errors.append("manifest.title must be a non-empty string of at most 100 characters.") - document_type = manifest.get("type") - if document_type not in DOCUMENT_TYPES: - errors.append("manifest.type must be report, brief, reference, dashboard, or note.") - tags = manifest.get("tags") - if not isinstance(tags, list) or len(tags) > 8 or any(not isinstance(tag, str) or not tag.strip() for tag in tags): - errors.append("manifest.tags must contain at most eight non-empty strings.") - summary = manifest.get("summary") - if not isinstance(summary, str) or len(summary) > 240: - errors.append("manifest.summary must be a string of at most 240 characters.") - project = manifest.get("project") - if project is not None: - if not isinstance(project, dict): - errors.append("manifest.project must be an object when present.") - else: - project_id = project.get("id") - project_name = project.get("name") - if not isinstance(project_id, str) or not ID_PATTERN.fullmatch(project_id) or len(project_id) > 100: - errors.append("manifest.project.id must be a stable lowercase ID (letters, digits, hyphens; max 100).") - if not isinstance(project_name, str) or not project_name.strip() or len(project_name) > 100: - errors.append("manifest.project.name must be a non-empty string of at most 100 characters.") - parse_timestamp(manifest.get("created_at"), "manifest.created_at", errors) - parse_timestamp(manifest.get("updated_at"), "manifest.updated_at", errors) - provenance = manifest.get("provenance") - if not isinstance(provenance, dict) or not isinstance(provenance.get("author"), str) or not isinstance(provenance.get("sources"), list): - errors.append("manifest.provenance must include author and sources.") - - expected_meta = { - "helm:title": title, - "helm:type": document_type, - "helm:summary": summary, - "helm:tags": ", ".join(tags) if isinstance(tags, list) else None, - } - for name, expected in expected_meta.items(): - actual = parser.meta.get(name) - if not isinstance(expected, str) or actual != expected: - errors.append(f"{name} must exactly match the manifest.") - if parser.external_dependencies: - warnings.append("The artifact references remote resources; it should remain meaningful without them.") - if parser.relative_dependencies: - warnings.append("The artifact references relative files or links that will not travel with a standalone HTML document; use absolute links or embed essential resources.") - if errors: - raise ContractError(errors, warnings) - return manifest, warnings - - -def safe_filename(document_id: str, digest: str) -> str: - readable = re.sub(r"[^A-Za-z0-9._-]+", "-", document_id).strip(".-") or "artifact" - return f"{readable[:72]}--{digest[:12]}.html" - - -def atomic_write(path: Path, payload: bytes, mode: int | None = None) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - try: - with os.fdopen(descriptor, "wb") as handle: - handle.write(payload) - handle.flush() - os.fsync(handle.fileno()) - if mode is not None: - os.chmod(temporary_name, mode) - os.replace(temporary_name, path) - finally: - try: - os.unlink(temporary_name) - except FileNotFoundError: - pass - - -class BridgeCatalog: - def __init__(self, data_dir: Path): - self.data_dir = data_dir.expanduser().resolve() - self.artifact_dir = self.data_dir / "artifacts" - self.catalog_path = self.data_dir / "catalog.json" - self.token_path = self.data_dir / "token" - self.lock = threading.RLock() - self.data_dir.mkdir(parents=True, exist_ok=True, mode=0o700) - self.artifact_dir.mkdir(parents=True, exist_ok=True, mode=0o700) - # Bridge records can include private source documents. Restrict the - # default local store even if an earlier run created it under a broad umask. - os.chmod(self.data_dir, 0o700) - os.chmod(self.artifact_dir, 0o700) - self.documents = self._load_catalog() - - def _load_catalog(self) -> dict[str, dict[str, Any]]: - if not self.catalog_path.exists(): - return {} - try: - payload = json.loads(self.catalog_path.read_text(encoding="utf-8")) - documents = payload.get("documents", {}) - if isinstance(documents, dict): - migrated: dict[str, dict[str, Any]] = {} - for key, value in documents.items(): - if not isinstance(value, dict): - continue - digest = value.get("sha256") - document_id = value.get("id") or key - revision_key = f"{document_id}:{digest}" if isinstance(digest, str) else key - migrated[revision_key] = value - return migrated - except (OSError, json.JSONDecodeError): - pass - raise RuntimeError(f"Helm Bridge catalog is unreadable: {self.catalog_path}") - - def _save_catalog(self) -> None: - payload = { - "format": "helm-bridge-catalog", - "schema_version": API_VERSION, - "updated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), - "document_count": len(self.documents), - "documents": self.documents, - } - atomic_write(self.catalog_path, json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")) - - def ensure_token(self) -> str: - from_environment = os.environ.get("HELM_BRIDGE_TOKEN", "").strip() - if from_environment: - return from_environment - if self.token_path.exists(): - token = self.token_path.read_text(encoding="utf-8").strip() - if token: - return token - token = secrets.token_urlsafe(32) - atomic_write(self.token_path, f"{token}\n".encode("utf-8"), mode=0o600) - return token - - def ingest(self, html_bytes: bytes, source: str, submitted_project: dict[str, Any] | None = None) -> tuple[str, dict[str, Any]]: - if len(html_bytes) > MAX_DOCUMENT_BYTES: - raise ContractError([f"Document exceeds the {MAX_DOCUMENT_BYTES // (1024 * 1024)} MB ingress limit."]) - try: - html = html_bytes.decode("utf-8") - except UnicodeDecodeError as error: - raise ContractError([f"Document must be UTF-8 HTML: {error}."]) from error - manifest, warnings = validate_hdoc(html) - document_id = manifest["id"] - declared_project = manifest.get("project") if isinstance(manifest.get("project"), dict) else None - project = declared_project or self._catalog_project(submitted_project) - digest = hashlib.sha256(html_bytes).hexdigest() - with self.lock: - revision_key = f"{document_id}:{digest}" - existing = self.documents.get(revision_key) - if existing: - if (self.data_dir / existing["artifact_path"]).read_bytes() != html_bytes: - raise RuntimeError("Digest-addressed Bridge storage is inconsistent.") - return "idempotent", {**existing, "warnings": warnings} - prior_revisions = [record for record in self.documents.values() if record.get("id") == document_id] - relative_path = Path("artifacts") / f"{document_id}--{digest}.html" - received_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - record = { - "id": document_id, - "source_document_id": document_id, - "title": manifest["title"], - "type": manifest["type"], - "tags": manifest["tags"], - "summary": manifest["summary"], - "created_at": manifest["created_at"], - "updated_at": manifest["updated_at"], - "provenance": manifest["provenance"], - **({"project": project} if project else {}), - "source": source[:120] or "unnamed-agent", - "received_at": received_at, - "sha256": digest, - "revision_id": f"sha256:{digest}", - "revision_number": len(prior_revisions) + 1, - "artifact_path": relative_path.as_posix(), - "warnings": warnings, - } - atomic_write(self.data_dir / relative_path, html_bytes) - self.documents[revision_key] = record - self._save_catalog() - return ("revision" if prior_revisions else "created"), record - - @staticmethod - def _catalog_project(value: dict[str, Any] | None) -> dict[str, str] | None: - if not isinstance(value, dict): - return None - project_id = value.get("id") - project_name = value.get("name") - if not isinstance(project_id, str) or not ID_PATTERN.fullmatch(project_id) or len(project_id) > 100: - return None - if not isinstance(project_name, str) or not project_name.strip() or len(project_name) > 100: - return None - return {"id": project_id, "name": project_name.strip()} - - def list_documents(self) -> list[dict[str, Any]]: - with self.lock: - records = [] - for revision_key in sorted(self.documents, key=lambda key: self.documents[key].get("received_at", "")): - records.append(self.read_document(revision_key)) - return records - - def read_document(self, document_id: str) -> dict[str, Any]: - with self.lock: - record = self.documents.get(document_id) - if not record: - matches = [value for value in self.documents.values() if value.get("id") == document_id] - record = max(matches, key=lambda value: value.get("received_at", ""), default=None) - if not record: - raise KeyError(document_id) - try: - html = (self.data_dir / record["artifact_path"]).read_text(encoding="utf-8") - except OSError as error: - raise RuntimeError(f"Stored HTML for {document_id!r} is unavailable.") from error - return {**record, "html": html} - - -def is_allowed_browser_origin(origin: str, explicit_origins: set[str] | None = None) -> bool: - """Accept an explicitly configured origin or a syntactically exact loopback origin. - - Helm's UI is commonly served on an ephemeral development port. Restricting - by loopback host preserves that workflow without reflecting arbitrary web - origins into this owner-local API. - """ - if origin in (explicit_origins or set()): - return True - try: - parsed = urlparse(origin) - # Accessing port deliberately rejects malformed values such as :abc. - parsed.port - except ValueError: - return False - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - return False - if parsed.username or parsed.password or parsed.path or parsed.params or parsed.query or parsed.fragment: - return False - hostname = (parsed.hostname or "").rstrip(".").lower() - if hostname == "localhost": - return True - try: - return ipaddress.ip_address(hostname).is_loopback - except ValueError: - return False - - -class BridgeRequestHandler(BaseHTTPRequestHandler): - server: "BridgeHTTPServer" - protocol_version = "HTTP/1.1" - - def log_message(self, format: str, *args: Any) -> None: - # Avoid putting document titles or credentials in terminal logs. - print(f"Helm Bridge {self.address_string()} {self.command} {self.path} {args[-2] if len(args) >= 2 else ''}") - - def _origin_allowed(self) -> str | None: - origin = self.headers.get("Origin") - return origin if origin and is_allowed_browser_origin(origin, self.server.cors_origins) else None - - def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None: - encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(encoded))) - self.send_header("Cache-Control", "no-store") - origin = self._origin_allowed() - if origin: - self.send_header("Access-Control-Allow-Origin", origin) - self.send_header("Vary", "Origin") - self.end_headers() - self.wfile.write(encoded) - - def _send_text(self, status: HTTPStatus, payload: str, content_type: str = "text/plain; charset=utf-8") -> None: - encoded = payload.encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", content_type) - self.send_header("Content-Length", str(len(encoded))) - self.send_header("Cache-Control", "no-store") - origin = self._origin_allowed() - if origin: - self.send_header("Access-Control-Allow-Origin", origin) - self.send_header("Vary", "Origin") - self.end_headers() - self.wfile.write(encoded) - - def _authorized(self) -> bool: - authorization = self.headers.get("Authorization", "") - return authorization.startswith("Bearer ") and secrets.compare_digest(authorization[7:].strip(), self.server.token) - - def do_OPTIONS(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API - self.send_response(HTTPStatus.NO_CONTENT) - origin = self._origin_allowed() - if origin: - self.send_header("Access-Control-Allow-Origin", origin) - self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Helm-Source, X-Helm-Project-Id, X-Helm-Project-Name") - self.send_header("Vary", "Origin") - self.send_header("Content-Length", "0") - self.end_headers() - - def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API - path = urlparse(self.path).path - try: - if path == "/v1/health": - self._send_json(HTTPStatus.OK, {"ok": True, "api_version": API_VERSION, "document_count": len(self.server.catalog.documents)}) - return - if path == "/v1/contract": - self._send_text(HTTPStatus.OK, self.server.spec_path.read_text(encoding="utf-8"), "text/markdown; charset=utf-8") - return - if path == "/v1/artifacts": - documents = self.server.catalog.list_documents() - self._send_json(HTTPStatus.OK, {"api_version": API_VERSION, "document_count": len(documents), "documents": documents}) - return - if path.startswith("/v1/artifacts/"): - document = self.server.catalog.read_document(unquote(path.removeprefix("/v1/artifacts/"))) - self._send_json(HTTPStatus.OK, document) - return - self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"}) - except KeyError: - self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"}) - except (OSError, RuntimeError) as error: - self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "storage_error", "message": str(error)}) - - def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API - if urlparse(self.path).path != "/v1/artifacts": - self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"}) - return - if not self._authorized(): - self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"}) - return - try: - content_length = int(self.headers.get("Content-Length", "-1")) - except ValueError: - content_length = -1 - if content_length < 0 or content_length > MAX_DOCUMENT_BYTES: - self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "payload_too_large", "max_bytes": MAX_DOCUMENT_BYTES}) - return - payload = self.rfile.read(content_length) - source = self.headers.get("X-Helm-Source", "") - submitted_project: dict[str, Any] | None = None - project_id = self.headers.get("X-Helm-Project-Id", "").strip() - project_name = self.headers.get("X-Helm-Project-Name", "").strip() - if project_id or project_name: - submitted_project = {"id": project_id, "name": project_name} - if self.headers.get("Content-Type", "").split(";", 1)[0].lower() == "application/json": - try: - envelope = json.loads(payload.decode("utf-8")) - payload = envelope["html"].encode("utf-8") - source = source or str(envelope.get("source", "")) - submitted_project = submitted_project or envelope.get("project") - except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError): - self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_json_envelope", "message": "JSON requests require an html string."}) - return - try: - status, record = self.server.catalog.ingest(payload, source, submitted_project) - response_status = HTTPStatus.CREATED if status in {"created", "revision"} else HTTPStatus.OK - self._send_json(response_status, {"status": status, "artifact": record}) - except ContractError as error: - self._send_json(HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "invalid_hdoc", "errors": error.errors, "warnings": error.warnings}) - except (OSError, RuntimeError) as error: - self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "storage_error", "message": str(error)}) - - -class BridgeHTTPServer(ThreadingHTTPServer): - def __init__(self, address: tuple[str, int], catalog: BridgeCatalog, token: str, spec_path: Path, cors_origins: set[str]): - super().__init__(address, BridgeRequestHandler) - self.daemon_threads = True - self.catalog = catalog - self.token = token - self.spec_path = spec_path - self.cors_origins = cors_origins - - -def main() -> None: - parser = argparse.ArgumentParser(description="Run the local Helm Bridge ingestion API.") - parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: loopback only).") - parser.add_argument("--port", type=int, default=4175, help="Bind port (default: 4175).") - parser.add_argument("--data-dir", type=Path, default=Path(os.environ.get("HELM_BRIDGE_DATA_DIR", Path.home() / ".helm-bridge"))) - parser.add_argument("--spec", type=Path, default=Path(__file__).resolve().parent / "docs" / "HTML-DOCUMENT-SPEC.md") - parser.add_argument("--cors-origin", action="append", dest="cors_origins", help="Allowed browser origin; may be repeated.") - args = parser.parse_args() - catalog = BridgeCatalog(args.data_dir) - token = catalog.ensure_token() - cors_origins = set(args.cors_origins or DEFAULT_CORS_ORIGINS) - server = BridgeHTTPServer((args.host, args.port), catalog, token, args.spec.resolve(), cors_origins) - print(f"Helm Bridge {API_VERSION} listening on http://{args.host}:{args.port}") - print(f"Catalog: {catalog.data_dir}; token file: {catalog.token_path}") - try: - server.serve_forever() - except KeyboardInterrupt: - pass - finally: - server.server_close() - - -if __name__ == "__main__": - main() diff --git a/helm_share_server.py b/helm_share_server.py deleted file mode 100644 index 4f1a57f..0000000 --- a/helm_share_server.py +++ /dev/null @@ -1,555 +0,0 @@ -#!/usr/bin/env python3 -"""Serve Helm and publish immutable intranet shares and versioned Channels.""" - -from __future__ import annotations - -import argparse -import hashlib -import ipaddress -import json -import os -import re -import secrets -import threading -from datetime import datetime, timezone -from http import HTTPStatus -from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from typing import Any -from urllib.parse import quote, unquote, urlparse - -from helm_bridge import ContractError, MAX_DOCUMENT_BYTES, atomic_write, safe_filename, validate_hdoc - - -API_VERSION = "HSHARE/1.0" -CHANNEL_API_VERSION = "HCHANNEL/1.0" -MAX_REQUEST_BYTES = MAX_DOCUMENT_BYTES + 64 * 1024 -ARTIFACT_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$") -REVISION_FILENAME_PATTERN = re.compile(r"^([0-9a-f]{64})\.html$") -LEGACY_SHARE_FILENAME_PATTERN = re.compile(r"^.+--([0-9a-f]{12})\.html$") - - -def utc_now() -> str: - return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - - -class ChannelConflictError(RuntimeError): - def __init__(self, artifact_id: str, current_revision: str | None): - super().__init__(f"Artifact {artifact_id!r} has advanced since the requested base revision.") - self.artifact_id = artifact_id - self.current_revision = current_revision - - -class ChannelNotFoundError(LookupError): - pass - - -class ShareStore: - """Keep legacy flat shares and the append-only Helm Channels catalog.""" - - def __init__(self, root: Path): - self.root = root.expanduser().resolve() - self.root.mkdir(parents=True, exist_ok=True, mode=0o755) - self.revision_dir = self.root / "revisions" - self.revision_dir.mkdir(parents=True, exist_ok=True, mode=0o700) - os.chmod(self.revision_dir, 0o700) - self.channel_catalog_path = self.root / "channels.json" - self.lock = threading.RLock() - self.channel_catalog = self._load_channel_catalog() - - @staticmethod - def _validate_source(html_bytes: bytes) -> tuple[dict[str, Any], list[str]]: - if len(html_bytes) > MAX_DOCUMENT_BYTES: - raise ContractError([f"Document exceeds the {MAX_DOCUMENT_BYTES // (1024 * 1024)} MB share limit."]) - try: - html = html_bytes.decode("utf-8") - except UnicodeDecodeError as error: - raise ContractError([f"Document must be UTF-8 HTML: {error}."]) from error - return validate_hdoc(html) - - def _load_channel_catalog(self) -> dict[str, Any]: - if not self.channel_catalog_path.exists(): - return {"schema_version": CHANNEL_API_VERSION, "artifacts": {}, "revisions": {}} - try: - payload = json.loads(self.channel_catalog_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise RuntimeError(f"Helm Channels catalog is unreadable: {self.channel_catalog_path}") from error - if ( - not isinstance(payload, dict) - or payload.get("schema_version") != CHANNEL_API_VERSION - or not isinstance(payload.get("artifacts"), dict) - or not isinstance(payload.get("revisions"), dict) - ): - raise RuntimeError(f"Helm Channels catalog is invalid: {self.channel_catalog_path}") - return payload - - def _save_channel_catalog(self) -> None: - self.channel_catalog["updated_at"] = utc_now() - atomic_write( - self.channel_catalog_path, - json.dumps(self.channel_catalog, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8"), - mode=0o600, - ) - - def publish(self, html_bytes: bytes) -> dict[str, Any]: - """Legacy one-shot publication. Its API and flat-file layout stay stable.""" - manifest, warnings = self._validate_source(html_bytes) - digest = hashlib.sha256(html_bytes).hexdigest() - filename = safe_filename(manifest["id"], digest) - path = self.root / filename - if path.exists(): - if path.read_bytes() != html_bytes: - raise RuntimeError("Digest-addressed share storage is inconsistent.") - state = "idempotent" - else: - atomic_write(path, html_bytes, mode=0o644) - state = "created" - return { - "state": state, - "id": manifest["id"], - "title": manifest["title"], - "sha256": digest, - "filename": filename, - "warnings": warnings, - } - - def resolve(self, filename: str) -> Path | None: - """Resolve an existing legacy /share URL.""" - decoded = unquote(filename) - if not decoded or decoded != Path(decoded).name or not decoded.endswith(".html"): - return None - candidate = (self.root / decoded).resolve() - if candidate.parent != self.root or not candidate.is_file(): - return None - return candidate - - def revoke_legacy(self, public_path: str, expected_sha256: str) -> dict[str, Any]: - """Remove one exact legacy flat share without accepting arbitrary root files.""" - parsed = urlparse(public_path) - if parsed.query or parsed.fragment or not parsed.path.startswith("/share/"): - raise ChannelNotFoundError(public_path) - filename = unquote(parsed.path.removeprefix("/share/")) - match = LEGACY_SHARE_FILENAME_PATTERN.fullmatch(filename) - if not match or not re.fullmatch(r"[0-9a-f]{64}", expected_sha256 or ""): - raise ChannelNotFoundError(public_path) - if match.group(1) != expected_sha256[:12]: - raise ChannelConflictError(filename, None) - with self.lock: - candidate = self.resolve(filename) - if not candidate: - raise ChannelNotFoundError(public_path) - actual_sha256 = hashlib.sha256(candidate.read_bytes()).hexdigest() - if actual_sha256 != expected_sha256: - raise ChannelConflictError(filename, actual_sha256) - candidate.unlink() - return { - "state": "revoked", - "path": f"/share/{quote(filename)}", - "sha256": expected_sha256, - "revoked_at": utc_now(), - } - - def publish_channel(self, html_bytes: bytes, base_revision_sha256: str | None = None) -> dict[str, Any]: - manifest, warnings = self._validate_source(html_bytes) - artifact_id = manifest["id"] - digest = hashlib.sha256(html_bytes).hexdigest() - now = utc_now() - with self.lock: - artifact = self.channel_catalog["artifacts"].get(artifact_id) - current = artifact.get("current_revision") if artifact else None - if artifact and digest == current and artifact.get("status") == "published": - return {"state": "idempotent", "artifact": dict(artifact), "sha256": digest, "warnings": warnings} - if artifact and base_revision_sha256 != current: - raise ChannelConflictError(artifact_id, current) - if not artifact and base_revision_sha256 is not None: - raise ChannelConflictError(artifact_id, None) - - revision_path = self.revision_dir / f"{digest}.html" - revision = self.channel_catalog["revisions"].get(digest) - if revision: - if revision.get("artifact_id") != artifact_id or not revision_path.is_file() or revision_path.read_bytes() != html_bytes: - raise RuntimeError("Revision-addressed Channel storage is inconsistent.") - else: - atomic_write(revision_path, html_bytes, mode=0o600) - self.channel_catalog["revisions"][digest] = { - "artifact_id": artifact_id, - "sha256": digest, - "path": f"revisions/{digest}.html", - "published_at": now, - "manifest": manifest, - } - - revisions = list(artifact.get("revisions", [])) if artifact else [] - if digest not in revisions: - revisions.append(digest) - first_published_at = artifact.get("published_at", now) if artifact else now - was_revoked = bool(artifact and artifact.get("status") == "revoked") - record = { - "id": artifact_id, - "title": manifest["title"], - "type": manifest["type"], - "summary": manifest["summary"], - "tags": manifest["tags"], - **({"project": manifest["project"]} if isinstance(manifest.get("project"), dict) else {}), - "status": "published", - "current_revision": digest, - "published_at": first_published_at, - "updated_at": now, - "revoked_at": None, - "revisions": revisions, - } - self.channel_catalog["artifacts"][artifact_id] = record - self._save_channel_catalog() - state = "created" if artifact is None else "republished" if was_revoked else "updated" - return {"state": state, "artifact": dict(record), "sha256": digest, "warnings": warnings} - - def revoke_channel(self, artifact_id: str, base_revision_sha256: str | None) -> dict[str, Any]: - if not ARTIFACT_ID_PATTERN.fullmatch(artifact_id): - raise ChannelNotFoundError(artifact_id) - with self.lock: - artifact = self.channel_catalog["artifacts"].get(artifact_id) - if not artifact: - raise ChannelNotFoundError(artifact_id) - current = artifact.get("current_revision") - if base_revision_sha256 != current: - raise ChannelConflictError(artifact_id, current) - if artifact.get("status") == "revoked": - return {"state": "idempotent", "artifact": dict(artifact)} - now = utc_now() - artifact = {**artifact, "status": "revoked", "revoked_at": now, "updated_at": now} - self.channel_catalog["artifacts"][artifact_id] = artifact - self._save_channel_catalog() - return {"state": "revoked", "artifact": dict(artifact)} - - def artifact(self, artifact_id: str) -> dict[str, Any] | None: - decoded = unquote(artifact_id) - if not ARTIFACT_ID_PATTERN.fullmatch(decoded): - return None - with self.lock: - record = self.channel_catalog["artifacts"].get(decoded) - return dict(record) if record else None - - def artifacts(self) -> list[dict[str, Any]]: - with self.lock: - return [dict(record) for record in self.channel_catalog["artifacts"].values()] - - def public_artifacts(self, public_base_url: str) -> list[dict[str, Any]]: - """Return the minimal read-only catalog needed by the public Helm library.""" - base = public_base_url.rstrip("/") - with self.lock: - records = [] - for artifact in self.channel_catalog["artifacts"].values(): - if artifact.get("status") != "published": - continue - artifact_id = artifact["id"] - digest = artifact["current_revision"] - stable_path = f"/a/{quote(artifact_id)}" - revision_path = f"/r/{digest}.html" - records.append({ - "id": artifact_id, - "title": artifact["title"], - "type": artifact["type"], - "summary": artifact["summary"], - "tags": list(artifact.get("tags", [])), - **({"project": dict(artifact["project"])} if isinstance(artifact.get("project"), dict) else {}), - "sha256": digest, - "published_at": artifact["published_at"], - "updated_at": artifact["updated_at"], - "stable_path": stable_path, - "stable_url": f"{base}{stable_path}", - "revision_path": revision_path, - "revision_url": f"{base}{revision_path}", - }) - return sorted(records, key=lambda record: record["updated_at"], reverse=True) - - def resolve_artifact(self, artifact_id: str) -> tuple[str, Path] | None: - record = self.artifact(artifact_id) - if not record: - return None - if record.get("status") == "revoked": - return "revoked", self.revision_dir / f"{record['current_revision']}.html" - path = self.revision_dir / f"{record['current_revision']}.html" - if not path.is_file(): - raise RuntimeError("The current Channel revision is missing.") - return "published", path - - def resolve_revision(self, filename: str) -> Path | None: - decoded = unquote(filename) - match = REVISION_FILENAME_PATTERN.fullmatch(decoded) - if not match: - return None - digest = match.group(1) - with self.lock: - if digest not in self.channel_catalog["revisions"]: - return None - path = self.revision_dir / decoded - return path if path.is_file() else None - - -class ShareRequestHandler(SimpleHTTPRequestHandler): - server: "ShareHTTPServer" - protocol_version = "HTTP/1.1" - server_version = "HelmShare/1.1" - sys_version = "" - - def __init__(self, *args: Any, **kwargs: Any): - super().__init__(*args, directory=str(kwargs.pop("directory")), **kwargs) - - def log_message(self, format: str, *args: Any) -> None: - print(f"Helm Share {self.address_string()} {self.command} {self.path} {args[-2] if len(args) >= 2 else ''}") - - def _send_json(self, status: HTTPStatus, payload: dict[str, Any], head: bool = False) -> None: - encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(encoded))) - self.send_header("Cache-Control", "no-store") - if self.close_connection: - self.send_header("Connection", "close") - self.end_headers() - if not head: - self.wfile.write(encoded) - - def _loopback_writer(self) -> bool: - try: - return ipaddress.ip_address(self.client_address[0]).is_loopback - except ValueError: - return False - - def _owner_request(self) -> bool: - authorization = self.headers.get("Authorization", "") - bearer = authorization.removeprefix("Bearer ") if authorization.startswith("Bearer ") else "" - token_writer = bool( - self.server.owner_token - and bearer - and secrets.compare_digest(bearer, self.server.owner_token) - ) - if not self._loopback_writer() and not token_writer: - self.close_connection = True - self._send_json(HTTPStatus.FORBIDDEN, {"error": "read_only_network", "message": "Channel management requires the owner's loopback connection or deployment token."}) - return False - origin = self.headers.get("Origin") - if origin and origin not in self.server.allowed_origins: - self.close_connection = True - self._send_json(HTTPStatus.FORBIDDEN, {"error": "origin_forbidden"}) - return False - return True - - def _read_json(self) -> Any | None: - content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() - if content_type != "application/json": - self.close_connection = True - self._send_json(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, {"error": "content_type_required", "expected": "application/json"}) - return None - try: - content_length = int(self.headers.get("Content-Length", "-1")) - except ValueError: - content_length = -1 - if content_length < 0 or content_length > MAX_REQUEST_BYTES: - self.close_connection = True - self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "payload_too_large", "max_bytes": MAX_REQUEST_BYTES}) - return None - try: - return json.loads(self.rfile.read(content_length)) - except json.JSONDecodeError: - self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_json"}) - return None - - def _blocked_site_path(self, path: str) -> bool: - decoded = path - for _ in range(4): - expanded = unquote(decoded) - if expanded == decoded: - break - decoded = expanded - candidate = Path(self.translate_path(decoded)).resolve() - try: - relative = candidate.relative_to(self.server.site_root) - except ValueError: - return True - parts = relative.parts - if any(part.startswith(".") for part in parts) or (parts and parts[0] in {"scripts", "tests"}): - return True - try: - candidate.relative_to(self.server.store.root) - return True - except ValueError: - pass - return candidate.suffix.lower() in {".py", ".pyc", ".sh"} - - def _send_html(self, path: Path, immutable: bool, head: bool = False) -> None: - payload = b"" if head else path.read_bytes() - length = path.stat().st_size - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(length)) - self.send_header("Cache-Control", "public, max-age=31536000, immutable" if immutable else "no-store") - self.send_header("Content-Security-Policy", "sandbox allow-popups; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:") - self.send_header("Referrer-Policy", "no-referrer") - self.send_header("X-Content-Type-Options", "nosniff") - self.end_headers() - if not head: - self.wfile.write(payload) - - def _serve_read(self, head: bool = False) -> bool: - path = urlparse(self.path).path - if path.startswith("/share/"): - shared = self.server.store.resolve(path.removeprefix("/share/")) - if not shared: - self.send_error(HTTPStatus.NOT_FOUND) - else: - self._send_html(shared, immutable=True, head=head) - return True - if path.startswith("/a/"): - resolved = self.server.store.resolve_artifact(path.removeprefix("/a/")) - if not resolved: - self.send_error(HTTPStatus.NOT_FOUND) - elif resolved[0] == "revoked": - self.send_error(HTTPStatus.GONE, "This Helm Channel has been revoked.") - else: - self._send_html(resolved[1], immutable=False, head=head) - return True - if path.startswith("/r/"): - revision = self.server.store.resolve_revision(path.removeprefix("/r/")) - if not revision: - self.send_error(HTTPStatus.NOT_FOUND) - else: - self._send_html(revision, immutable=True, head=head) - return True - return False - - def do_GET(self) -> None: # noqa: N802 - path = urlparse(self.path).path - if path == "/api/share/health": - self._send_json(HTTPStatus.OK, {"ok": True, "api_version": API_VERSION, "channel_api_version": CHANNEL_API_VERSION, "public_base_url": self.server.public_base_url}) - return - if path == "/api/public/channels": - self._send_json(HTTPStatus.OK, {"ok": True, "artifacts": self.server.store.public_artifacts(self.server.public_base_url)}) - return - if path == "/api/channels": - if self._owner_request(): - self._send_json(HTTPStatus.OK, {"ok": True, "artifacts": self.server.store.artifacts()}) - return - if path.startswith("/api/channels/artifacts/"): - if not self._owner_request(): - return - artifact = self.server.store.artifact(path.removeprefix("/api/channels/artifacts/")) - self._send_json(HTTPStatus.OK, {"ok": True, "artifact": artifact}) if artifact else self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"}) - return - if self._serve_read(): - return - if self._blocked_site_path(path): - self.send_error(HTTPStatus.NOT_FOUND) - return - super().do_GET() - - def do_HEAD(self) -> None: # noqa: N802 - path = urlparse(self.path).path - if self._serve_read(head=True): - return - if self._blocked_site_path(path): - self.send_error(HTTPStatus.NOT_FOUND) - return - super().do_HEAD() - - def do_POST(self) -> None: # noqa: N802 - path = urlparse(self.path).path - legacy = path == "/api/share" - legacy_revoke = path == "/api/share/revoke" - channel_publish = path == "/api/channels/publish" - revoke_match = re.fullmatch(r"/api/channels/artifacts/([^/]+)/revoke", path) - if not legacy and not legacy_revoke and not channel_publish and not revoke_match: - self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"}) - return - if not self._owner_request(): - return - payload = self._read_json() - if payload is None: - return - try: - if legacy_revoke: - public_path = payload.get("path") if isinstance(payload, dict) else None - expected_sha256 = payload.get("sha256") if isinstance(payload, dict) else None - if not isinstance(public_path, str) or not isinstance(expected_sha256, str): - raise ContractError(["Request JSON must contain the legacy share path and SHA-256 digest."]) - result = self.server.store.revoke_legacy(public_path, expected_sha256) - self._send_json(HTTPStatus.OK, {**result, "ok": True}) - return - if revoke_match: - base = payload.get("base_revision_sha256") if isinstance(payload, dict) else None - result = self.server.store.revoke_channel(unquote(revoke_match.group(1)), base) - self._send_json(HTTPStatus.OK, {**result, "ok": True}) - return - html = payload.get("html") if isinstance(payload, dict) else None - if not isinstance(html, str): - raise ContractError(["Request JSON must contain an HTML string."]) - if legacy: - result = self.server.store.publish(html.encode("utf-8")) - public_path = f"/share/{quote(result['filename'])}" - self._send_json(HTTPStatus.CREATED if result["state"] == "created" else HTTPStatus.OK, {**result, "ok": True, "path": public_path, "url": f"{self.server.public_base_url}{public_path}"}) - return - result = self.server.store.publish_channel(html.encode("utf-8"), payload.get("base_revision_sha256")) - artifact_id = result["artifact"]["id"] - stable_path = f"/a/{quote(artifact_id)}" - revision_path = f"/r/{result['sha256']}.html" - self._send_json( - HTTPStatus.CREATED if result["state"] == "created" else HTTPStatus.OK, - {**result, "ok": True, "stable_path": stable_path, "stable_url": f"{self.server.public_base_url}{stable_path}", "revision_path": revision_path, "revision_url": f"{self.server.public_base_url}{revision_path}"}, - ) - except ChannelConflictError as error: - self._send_json(HTTPStatus.CONFLICT, {"error": "revision_conflict", "artifact_id": error.artifact_id, "current_revision_sha256": error.current_revision}) - except ChannelNotFoundError: - self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"}) - except ContractError as error: - self._send_json(HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "contract_invalid", "errors": error.errors, "warnings": error.warnings}) - except (OSError, RuntimeError) as error: - self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "storage_error", "message": str(error)}) - - -class ShareHTTPServer(ThreadingHTTPServer): - daemon_threads = True - - def __init__(self, address: tuple[str, int], site_root: Path, store: ShareStore, public_base_url: str, owner_token: str | None = None): - self.site_root = site_root.resolve() - self.store = store - self.public_base_url = public_base_url.rstrip("/") - self.owner_token = owner_token or None - port = address[1] - public_origin = urlparse(self.public_base_url) - self.allowed_origins = { - f"http://127.0.0.1:{port}", - f"http://localhost:{port}", - f"{public_origin.scheme}://{public_origin.netloc}", - } - super().__init__(address, lambda *args, **kwargs: ShareRequestHandler(*args, directory=self.site_root, **kwargs)) - actual_port = self.server_address[1] - if port == 0: - self.allowed_origins = {f"http://127.0.0.1:{actual_port}", f"http://localhost:{actual_port}"} - - -def main() -> None: - parser = argparse.ArgumentParser(description="Serve Helm with immutable intranet shares and versioned Channels.") - parser.add_argument("--host", default=os.environ.get("HELM_SHARE_HOST", "127.0.0.1")) - parser.add_argument("--port", type=int, default=int(os.environ.get("HELM_SHARE_PORT", "4173"))) - parser.add_argument("--site-root", type=Path, default=Path(__file__).resolve().parent) - parser.add_argument("--share-dir", type=Path, default=Path(os.environ.get("HELM_SHARE_DIR", "~/.helm-shares"))) - parser.add_argument("--public-base-url", default=os.environ.get("HELM_PUBLIC_BASE_URL", "http://127.0.0.1:4173")) - args = parser.parse_args() - server = ShareHTTPServer( - (args.host, args.port), - args.site_root, - ShareStore(args.share_dir), - args.public_base_url, - owner_token=os.environ.get("HELM_OWNER_TOKEN"), - ) - print(f"Helm Share serving {args.site_root.resolve()} at {args.host}:{args.port}; public links use {server.public_base_url}") - try: - server.serve_forever() - except KeyboardInterrupt: - pass - finally: - server.server_close() - - -if __name__ == "__main__": - main() diff --git a/index.html b/index.html deleted file mode 100644 index 59fdd24..0000000 --- a/index.html +++ /dev/null @@ -1,279 +0,0 @@ - - - - - - - - Helm — HTML Archive - - - - - -
    - - -
    -
    -
    PERSONAL REPOSITORYLibrary
    -
    - -
    - More -
    -
    - - - -
    - - - -
    -
    - - - -
    -
    - -
    -
    -
    -

    PERSONAL HTML LIBRARY

    -

    Documents, not loose files.

    -

    A curated home for the HTML worth keeping.

    - -
    - -
    - -
    - 00 shown All artifacts -
    - - - -
    -
    - -
    - -
    - -
    -

    AGENT AUTHORING

    Compose the evidence, not just the page.

    Profiles choose a proven report shape; components make each important relationship visible.

    -
    -

    HELM AUTHORING KIT

    Question → relationship → component → evidence contract.

    Agents can scaffold research, benchmark, architecture, decision, handoff, and presentation-paced deep-dive reports with reusable visual components, then fail the handoff if claims, scope, sources, fallbacks, or placeholders are incomplete.

    -
    scripts/helm-report new --profile deep-dive --output report.htmlOpen scroll component studio
    -
    -
    -
    - -
    -

    HELM DOCUMENT CONTRACT

    Readable by people. Legible to machines.

    A portable file contract plus an answer-first standard for durable AI-authored reports.

    -
    -
    SPECIFICATIONHDOC/1.0

    The file is the unit of exchange. It must render independently and state enough context for another person, tool, or project to understand it.

    -
    -

    01

    Self-contained

    Deliver a single .html file. Inline essential styles. Prefer local or data-URL assets; never rely on an app shell to make the document meaningful.

    -

    02

    Declared metadata

    Add title, document type, tags, timestamps, source and a concise summary through standard meta tags plus the Helm manifest.

    -

    03

    Answer before detail

    Show the question or decision, short answer, evidence and next action in a visible reading path. Do not make readers reconstruct the point from background.

    -

    04

    Evidence stays inspectable

    Put sources, assumptions, data dates and confidence beside claims. Keep a semantic root, real headings and tables so the report survives beyond its visual skin.

    -

    05

    Visuals explain

    Use a labelled figure for a real comparison, sequence, hierarchy, change or uncertainty. Give it a conclusion, evidence state, scope note and text fallback.

    -

    06

    Publish a Channel

    Review the current Revision before publishing. A stable Channel address advances only by an explicit publish, while every published Revision keeps its own immutable, content-addressed URL. Forks start a new identity and preserve their source lineage.

    -
    -

    REQUIRED MANIFEST SHAPE

    The full, copyable contract, visual report standard, and intranet sharing boundary are versioned in this repository.

    Open full specification →
    Open report design standard →
    Open sharing boundary →
    <script type="application/json" data-helm-manifest>
    -{
    -  "schema_version": "HDOC/1.0",
    -  "id": "brief-2026-07-13",
    -  "title": "Clear document title",
    -  "type": "brief",
    -  "tags": ["research", "active"],
    -  "summary": "One decisive sentence.",
    -  "created_at": "2026-07-13T00:00:00Z",
    -  "updated_at": "2026-07-13T00:00:00Z",
    -  "project": { "id": "project-workspace", "name": "Project workspace" },
    -  "provenance": { "author": "…", "sources": [] }
    -}
    -</script>
    -
    -
    -
    - - -
    - - -
    - - - - - -
    -
    - - -
    - - - - - - - - - - -
    -
    - - -
    - - -
      - -
      -
      - - -
      - - -
      Checking local Bridge…Bridge listens at 127.0.0.1:4175.
      -
        - -
        -
        - - -
        READERv1 · Draft
        -
        - - -
        -
        - - -
        -
        -

        ARTIFACT HISTORY

        Artifact title

        -
        STABLE ADDRESSNot published
        - -
        -
        - -
        -
        -

        VISUAL COMPARE

        Rendered revision diff
        -
        - -
        -
        -

        Both revisions render at the same viewport. The original HTML remains sandboxed and unchanged.

        -
        -
        Previous revision
        -
        Current revision
        -
        -
        -
        -
        -
        - - -
        - -

        Export every immutable revision, its lineage, and catalog metadata into one portable archive file. Importing never overwrites an artifact already present in this library.

        -
        00
        ARTIFACTS READYHTML source + metadata + timestamps
        -
        Browser library onlyConnect a local folder for explicit sync and recovery.
        - -
        -
        - -

        ⌘ KFocus search

        NNew from template

        IImport HTML

        EscClose dialogs

        -
        - - - - - - - - diff --git a/library/.gitkeep b/library/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/rainbond.app.json b/rainbond.app.json deleted file mode 100644 index 40368a6..0000000 --- a/rainbond.app.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "schema_version": 1, - "project": { - "team_name": "default", - "region_name": "rainbond", - "app_name": "Helm" - }, - "components": [ - { - "name": "helm-public", - "role": "service", - "code_from": "git", - "git_url": "https://ghfast.top/https://github.com/waple0820/Helm.git", - "code_version": "main", - "subdirectories": "", - "port": 8080, - "port_alias": "HTTP" - } - ] -} diff --git a/repair.js b/repair.js deleted file mode 100644 index 841cc48..0000000 --- a/repair.js +++ /dev/null @@ -1,401 +0,0 @@ -/* - * Helm compliant-copy repair helper. - * - * This module is intentionally pure: it reads one HTML record, creates a new - * HDOC/1.0 record, and returns it. It never talks to IndexedDB, localStorage, - * the network, or the DOM outside the DOMParser used to read the source. - */ -(function attachHelmRepair(root, factory) { - const api = factory(root); - if (root) root.HelmRepair = api; - if (typeof module === 'object' && module.exports) module.exports = api; -}(typeof globalThis !== 'undefined' ? globalThis : this, function createHelmRepair(root) { - 'use strict'; - - const SCHEMA_VERSION = 'HDOC/1.0'; - const DOCUMENT_TYPES = new Set(['report', 'brief', 'reference', 'dashboard', 'note']); - const DROP_ELEMENTS = new Set([ - 'script', 'style', 'noscript', 'template', 'svg', 'canvas', 'iframe', - 'object', 'embed', 'form', 'button', 'input', 'select', 'textarea', - 'details', 'dialog', 'audio', 'video', 'source', 'track' - ]); - const INLINE_ELEMENTS = new Set(['em', 'strong', 'b', 'i', 'code', 'kbd', 'mark', 'small', 'sub', 'sup', 'span']); - const MAX_CONTENT_CHARS = 120000; - - function cleanText(value) { - return String(value || '').replace(/\s+/g, ' ').trim(); - } - - function escapeHtml(value) { - return String(value || '').replace(/[&<>"']/g, (character) => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' - }[character])); - } - - function scriptJson(value) { - return JSON.stringify(value, null, 2) - .replace(//g, '\\u003e') - .replace(/&/g, '\\u0026'); - } - - function slugify(value, fallback) { - const slug = String(value || '') - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 54); - return slug || fallback; - } - - function timestamp(value) { - const candidate = value instanceof Date ? value : new Date(value || Date.now()); - return Number.isNaN(candidate.valueOf()) ? new Date().toISOString() : candidate.toISOString(); - } - - function textFromMeta(document, name) { - return cleanText(document.querySelector(`meta[name="${name}"]`)?.getAttribute('content')); - } - - function parseManifest(document) { - const node = document.querySelector('script[type="application/json"][data-helm-manifest], script[data-helm-manifest]'); - if (!node) return {}; - try { - const parsed = JSON.parse(node.textContent || ''); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } - } - - function readableContainer(document) { - return document.querySelector('main[data-document-root], main, article, [role="main"], body'); - } - - function firstReadableTitle(container) { - return cleanText(container?.querySelector('h1, h2, h3, h4, h5, h6')?.textContent); - } - - function pickTitle(record, manifest, document, container, options) { - const candidate = options.title || record.title || manifest.title || textFromMeta(document, 'helm:title') - || cleanText(document.querySelector('title')?.textContent) || firstReadableTitle(container) || 'Recovered HTML artifact'; - return cleanText(candidate).slice(0, 100) || 'Recovered HTML artifact'; - } - - function pickType(record, manifest, document, options) { - const candidate = cleanText(options.type || record.type || manifest.type || textFromMeta(document, 'helm:type')).toLowerCase(); - return DOCUMENT_TYPES.has(candidate) ? candidate : 'reference'; - } - - function normaliseTags(value) { - const values = Array.isArray(value) ? value : String(value || '').split(','); - const tags = []; - const seen = new Set(); - values.forEach((valueItem) => { - const tag = slugify(cleanText(valueItem), '').slice(0, 40); - if (tag && !seen.has(tag)) { - seen.add(tag); - tags.push(tag); - } - }); - return tags.slice(0, 8); - } - - function pickTags(record, manifest, document, options) { - const supplied = options.tags ?? record.tags ?? manifest.tags ?? textFromMeta(document, 'helm:tags'); - const tags = normaliseTags(supplied); - return tags.length ? tags : ['repaired']; - } - - function pickSummary(record, manifest, document, container, title, sourceLabel, options) { - const firstParagraph = cleanText(container?.querySelector('p')?.textContent); - const candidate = options.summary || record.summary || manifest.summary || textFromMeta(document, 'helm:summary') || firstParagraph - || `A compliant copy of ${title} recovered from ${sourceLabel}.`; - return cleanText(candidate).slice(0, 240) || `A compliant copy of ${title}.`; - } - - function pickProject(record, manifest) { - const candidate = record.project || manifest.project; - if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return null; - const id = cleanText(candidate.id); - const name = cleanText(candidate.name); - return /^[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?$/.test(id) && name ? { id: id.slice(0, 100), name: name.slice(0, 100) } : null; - } - - function collectedIds(existingIds) { - if (existingIds instanceof Set) return new Set(existingIds); - if (Array.isArray(existingIds)) return new Set(existingIds); - return new Set(); - } - - function randomAscii() { - if (root?.crypto?.getRandomValues) { - const bytes = new Uint32Array(2); - root.crypto.getRandomValues(bytes); - return `${bytes[0].toString(36)}${bytes[1].toString(36)}`; - } - return `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; - } - - function uniqueId(record, title, options) { - const taken = collectedIds(options.existingIds); - if (typeof record.id === 'string') taken.add(record.id); - const requested = slugify(options.id || options.idPrefix || title, 'artifact'); - const base = `${requested}-compliant-copy`.slice(0, 72).replace(/-+$/g, ''); - let id = `${base}-${Date.now().toString(36)}-${randomAscii().slice(0, 12)}`.replace(/-+/g, '-'); - let attempt = 2; - while (taken.has(id)) { - id = `${base}-${Date.now().toString(36)}-${randomAscii().slice(0, 12)}-${attempt}`.replace(/-+/g, '-'); - attempt += 1; - } - return id; - } - - function safeHref(value) { - const href = String(value || '').trim(); - return /^(?:https?:|mailto:|tel:|#)/i.test(href) ? href : ''; - } - - function inlineHtml(node) { - if (!node) return ''; - if (node.nodeType === 3) return escapeHtml(node.nodeValue); - if (node.nodeType !== 1 || DROP_ELEMENTS.has(node.tagName.toLowerCase())) return ''; - const tag = node.tagName.toLowerCase(); - const children = [...node.childNodes].map(inlineHtml).join(''); - if (tag === 'br') return '
        '; - if (tag === 'a') { - const href = safeHref(node.getAttribute('href')); - return href && cleanText(node.textContent) ? `${children}` : children; - } - if (INLINE_ELEMENTS.has(tag)) { - const safeTag = tag === 'b' ? 'strong' : tag === 'i' ? 'em' : tag; - return `<${safeTag}>${children}`; - } - return children; - } - - function listHtml(list) { - const tag = list.tagName.toLowerCase() === 'ol' ? 'ol' : 'ul'; - const items = [...list.children] - .filter((child) => child.tagName?.toLowerCase() === 'li') - .map((item) => { - const inline = [...item.childNodes] - .filter((child) => !(child.nodeType === 1 && ['ul', 'ol'].includes(child.tagName.toLowerCase()))) - .map(inlineHtml).join(''); - const nested = [...item.children] - .filter((child) => ['ul', 'ol'].includes(child.tagName.toLowerCase())) - .map(listHtml).join(''); - return `
      • ${inline || escapeHtml(cleanText(item.textContent))}${nested}
      • `; - }).join(''); - return items ? `<${tag}>${items}` : ''; - } - - function tableHtml(table) { - const caption = cleanText(table.querySelector(':scope > caption')?.textContent); - const rows = [...table.querySelectorAll('tr')].filter((row) => !row.closest('table') || row.closest('table') === table); - const serialisedRows = rows.map((row, rowIndex) => { - const cells = [...row.children].filter((cell) => ['td', 'th'].includes(cell.tagName.toLowerCase())); - if (!cells.length) return ''; - return cells.map((cell) => { - const header = rowIndex === 0 || cell.tagName.toLowerCase() === 'th'; - const tag = header ? 'th' : 'td'; - const span = ['colspan', 'rowspan'].map((attribute) => { - const value = Number(cell.getAttribute(attribute)); - return Number.isInteger(value) && value > 1 ? ` ${attribute}="${value}"` : ''; - }).join(''); - return `<${tag}${span}>${inlineHtml(cell) || escapeHtml(cleanText(cell.textContent))}`; - }).join(''); - }).filter(Boolean); - if (!serialisedRows.length) return ''; - const header = `${serialisedRows[0]}`; - const body = serialisedRows.length > 1 ? `${serialisedRows.slice(1).map((row) => `${row}`).join('')}` : ''; - return `${caption ? `` : ''}${header}${body}
        ${escapeHtml(caption)}
        `; - } - - function definitionListHtml(list) { - const items = [...list.children].filter((node) => ['dt', 'dd'].includes(node.tagName?.toLowerCase())).map((node) => { - const tag = node.tagName.toLowerCase(); - return `<${tag}>${inlineHtml(node) || escapeHtml(cleanText(node.textContent))}`; - }).join(''); - return items ? `
        ${items}
        ` : ''; - } - - function extractContent(container) { - const fragments = []; - let lastHeading = 1; - let characterCount = 0; - - function append(fragment) { - if (!fragment || characterCount >= MAX_CONTENT_CHARS) return; - characterCount += cleanText(fragment).length; - fragments.push(fragment); - } - - function walk(node) { - if (!node || characterCount >= MAX_CONTENT_CHARS) return; - if (node.nodeType === 3) { - const text = cleanText(node.nodeValue); - if (text) append(`

        ${escapeHtml(text)}

        `); - return; - } - if (node.nodeType !== 1) return; - const tag = node.tagName.toLowerCase(); - if (DROP_ELEMENTS.has(tag)) return; - if (/^h[1-6]$/.test(tag)) { - const text = cleanText(node.textContent); - if (!text) return; - const originalLevel = Number(tag.slice(1)); - const level = Math.min(6, Math.max(2, Math.min(originalLevel, lastHeading + 1))); - lastHeading = level; - append(`${escapeHtml(text)}`); - return; - } - if (tag === 'p') { - const content = inlineHtml(node); - if (cleanText(node.textContent)) append(`

        ${content || escapeHtml(cleanText(node.textContent))}

        `); - return; - } - if (tag === 'blockquote') { - const content = inlineHtml(node); - if (cleanText(node.textContent)) append(`

        ${content || escapeHtml(cleanText(node.textContent))}

        `); - return; - } - if (tag === 'pre') { - const text = node.textContent || ''; - if (cleanText(text)) append(`
        ${escapeHtml(text)}
        `); - return; - } - if (tag === 'ul' || tag === 'ol') { - append(listHtml(node)); - return; - } - if (tag === 'table') { - append(tableHtml(node)); - return; - } - if (tag === 'dl') { - append(definitionListHtml(node)); - return; - } - if (tag === 'img') { - const alt = cleanText(node.getAttribute('alt')); - if (alt) append(`

        Image description: ${escapeHtml(alt)}

        `); - return; - } - [...node.childNodes].forEach(walk); - } - - [...(container?.childNodes || [])].forEach(walk); - const recovered = fragments.join(''); - if (cleanText(recovered)) return recovered; - const fallback = cleanText(container?.textContent).slice(0, MAX_CONTENT_CHARS); - return `

        ${escapeHtml(fallback || 'No readable source content could be recovered from this artifact.')}

        `; - } - - function cleanLang(value) { - const lang = cleanText(value); - return /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i.test(lang) ? lang : 'en'; - } - - function buildHtml({ id, title, type, tags, summary, createdAt, sourceId, sourceLabel, lang, manifest, content }) { - const styles = 'body{margin:0;background:#f4f3ef;color:#17202a;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.65}main{max-width:780px;margin:0 auto;padding:72px 28px 96px}.eyebrow{margin:0 0 20px;color:#697681;font:600 11px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.12em}.rule{width:44px;height:2px;background:#e85b40;margin:0 0 28px}h1,h2,h3,h4,h5,h6{color:#18202a;font-family:Georgia,"Times New Roman",serif;font-weight:500;letter-spacing:-.03em;line-height:1.15}h1{max-width:700px;margin:0;font-size:clamp(2.45rem,7vw,4.35rem)}h2{margin:2.7rem 0 .65rem;font-size:1.7rem}h3{margin:2rem 0 .5rem;font-size:1.25rem}p,li,dd,td,th{font-size:1rem}p{max-width:700px;color:#44515b}a{color:#1c5f7a}ul,ol{padding-left:1.4rem}li+li{margin-top:.35rem}blockquote{margin:1.5rem 0;padding:.2rem 0 .2rem 1.1rem;border-left:2px solid #e85b40;color:#47545d}pre{overflow:auto;padding:1rem;background:#e9e9e4;border:1px solid #d6d8d2;border-radius:4px}table{width:100%;border-collapse:collapse;margin:1.3rem 0}caption{text-align:left;margin-bottom:.45rem;color:#5f6a72}th,td{padding:.6rem;text-align:left;vertical-align:top;border-bottom:1px solid #d6d8d2}th{color:#26333c;background:#eaebe6}.summary{max-width:680px;font-size:1.08rem}.tags{display:flex;flex-wrap:wrap;gap:.45rem;margin:1.35rem 0 2.4rem}.tags span{border:1px solid #d6d8d2;border-radius:999px;padding:.2rem .55rem;color:#56636b;font:600 10px ui-monospace,SFMono-Regular,Menlo,monospace}.provenance{margin-top:3rem;padding-top:1.2rem;border-top:1px solid #d6d8d2}.provenance p{font-size:.9rem}.image-description{color:#5a6972;font-style:italic}'; - const tagHtml = tags.map((tag) => `${escapeHtml(tag)}`).join(''); - return `${escapeHtml(title)}

        ${escapeHtml(type.toUpperCase())} · COMPLIANT COPY · ${escapeHtml(createdAt.slice(0, 10))}

        ${escapeHtml(title)}

        ${escapeHtml(summary)}

        ${tagHtml}
        ${content}

        Provenance

        This is a non-destructive HDOC/1.0 compliant copy derived from source artifact ${escapeHtml(sourceLabel)}${sourceId ? ` (ID: ${escapeHtml(sourceId)})` : ''}. The original source was not altered.

        `; - } - - /** - * Create a new, sanitised HDOC/1.0 record from an existing HTML record. - * - * Options: title, type, tags, summary, author, lang, id, idPrefix, - * existingIds (Set or array), and now (Date or timestamp) are supported. - * This function is deliberately non-persistent; callers decide whether to - * save the returned record. - */ - function createCompliantCopy(record, options = {}) { - if (!record || typeof record !== 'object' || typeof record.html !== 'string') { - throw new TypeError('HelmRepair.createCompliantCopy requires a record with its original HTML in record.html.'); - } - const Parser = options.DOMParser || root?.DOMParser; - if (typeof Parser !== 'function') { - throw new Error('HelmRepair.createCompliantCopy requires a DOMParser implementation.'); - } - - const sourceDocument = new Parser().parseFromString(record.html, 'text/html'); - const sourceManifest = parseManifest(sourceDocument); - const sourceContainer = readableContainer(sourceDocument); - const sourceId = cleanText(record.sourceDocumentId || sourceManifest.id || record.id); - const sourceLabel = cleanText(record.title || sourceManifest.title || textFromMeta(sourceDocument, 'helm:title') - || cleanText(sourceDocument.querySelector('title')?.textContent) || sourceId || 'unnamed source artifact'); - const title = pickTitle(record, sourceManifest, sourceDocument, sourceContainer, options); - const type = pickType(record, sourceManifest, sourceDocument, options); - const tags = pickTags(record, sourceManifest, sourceDocument, options); - const createdAt = timestamp(options.now); - const id = uniqueId(record, title, options); - const summary = pickSummary(record, sourceManifest, sourceDocument, sourceContainer, title, sourceLabel, options); - const project = pickProject(record, sourceManifest); - const lang = cleanLang(options.lang || sourceDocument.documentElement?.getAttribute('lang')); - const manifest = { - schema_version: SCHEMA_VERSION, - id, - title, - type, - tags, - summary, - created_at: createdAt, - updated_at: createdAt, - ...(project ? { project } : {}), - provenance: { - author: cleanText(options.author) || 'Helm compliant-copy repair', - sources: [], - derived_from: { - kind: 'local-artifact', - id: sourceId || null, - title: sourceLabel, - repaired_at: createdAt - } - } - }; - const content = extractContent(sourceContainer); - const html = buildHtml({ id, title, type, tags, summary, createdAt, sourceId, sourceLabel, lang, manifest, content }); - let validation = null; - let validationStatus = 'unavailable'; - if (root?.HelmValidator?.validate) { - validation = root.HelmValidator.validate(html, { DOMParser: Parser }); - validationStatus = 'validated'; - } - const contentText = cleanText(new Parser().parseFromString(html, 'text/html').querySelector('main[data-document-root]')?.textContent); - const outputRecord = { - id, - title, - type, - tags: [...tags], - summary, - source: `Helm repair from ${sourceLabel}`, - ...(project ? { project } : {}), - createdAt, - updatedAt: createdAt, - derivedFrom: { id: sourceId || null, title: sourceLabel }, - html, - contentText, - validation - }; - const report = { - operation: 'compliant-copy-repair', - source: { id: sourceId || null, title: sourceLabel }, - output: { id, title, schemaVersion: SCHEMA_VERSION }, - validationStatus, - valid: validation ? validation.valid : null, - issues: validation?.issues || [], - limitations: [ - 'The helper preserves readable text and basic structure, not the source layout, scripts, interactive behavior, media, or external assets.', - 'Complex nested layouts, charts, and image-only meaning may need manual reconstruction after repair.', - 'No storage is written. Saving the returned record remains the caller’s responsibility.' - ] - }; - return { record: outputRecord, report }; - } - - return Object.freeze({ SCHEMA_VERSION, createCompliantCopy }); -})); diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 539dce1..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Helm Share uses only the Python standard library. This marker selects the -# Python CNB runtime on source-based deployment platforms. diff --git a/scripts/deploy-remote b/scripts/deploy-remote deleted file mode 100755 index 194e176..0000000 --- a/scripts/deploy-remote +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env bash -# Atomically deploy the committed Helm tree to a remote single-user host. -set -euo pipefail - -remote_host="" -remote_dir="~/apps/html-displayer" -public_base_url="" -port="4173" -tmux_session="helm-html-archive" - -usage() { - cat <<'EOF' -Usage: scripts/deploy-remote --host USER@HOST --public-base-url URL [options] - -Options: - --remote-dir PATH Remote application directory (default: ~/apps/html-displayer) - --port PORT Share server port (default: 4173) - --tmux-session NAME tmux session name (default: helm-html-archive) - -The deploy contains only the committed Git tree. Runtime shares remain in -~/.helm-shares and are never copied or replaced. -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --host) remote_host="${2:-}"; shift 2 ;; - --remote-dir) remote_dir="${2:-}"; shift 2 ;; - --public-base-url) public_base_url="${2:-}"; shift 2 ;; - --port) port="${2:-}"; shift 2 ;; - --tmux-session) tmux_session="${2:-}"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac -done - -[[ -n "$remote_host" ]] || { echo "--host is required" >&2; exit 2; } -[[ -n "$public_base_url" ]] || { echo "--public-base-url is required" >&2; exit 2; } -[[ "$port" =~ ^[0-9]+$ ]] && (( port > 0 && port < 65536 )) || { echo "--port must be between 1 and 65535" >&2; exit 2; } -[[ "$tmux_session" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "--tmux-session contains unsupported characters" >&2; exit 2; } - -repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$repo_dir" -git diff --quiet && git diff --cached --quiet || { - echo "Refusing to deploy uncommitted tracked changes. Commit and review first." >&2 - exit 1 -} - -revision="$(git rev-parse --short=12 HEAD)" -archive="$(mktemp "${TMPDIR:-/tmp}/helm-deploy.XXXXXX.tar")" -remote_archive="/tmp/helm-deploy-${revision}-$$.tar" -trap 'rm -f "$archive"' EXIT -git archive --format=tar HEAD >"$archive" -scp -q "$archive" "$remote_host:$remote_archive" - -quote_remote() { printf '%q' "$1"; } -remote_command="bash -s -- $(quote_remote "$remote_dir") $(quote_remote "$remote_archive") $(quote_remote "$port") $(quote_remote "$tmux_session") $(quote_remote "$public_base_url") $(quote_remote "$revision")" - -ssh "$remote_host" "$remote_command" <<'REMOTE' -set -euo pipefail -remote_dir="$1" -archive="$2" -port="$3" -session="$4" -public_url="$5" -revision="$6" - -remote_dir="${remote_dir/#\~/$HOME}" -staging="${remote_dir}.next-${revision}" -previous="${remote_dir}.previous" -share_dir="$HOME/.helm-shares" - -cleanup() { rm -f "$archive"; } -trap cleanup EXIT -rm -rf "$staging" -mkdir -p "$staging" -tar -xf "$archive" -C "$staging" -( - cd "$staging" - python3 -m unittest discover -s tests -p 'test_*.py' -) - -start_server() { - local app_dir="$1" - local launch - printf -v launch 'cd %q && exec env HELM_PUBLIC_BASE_URL=%q HELM_SHARE_DIR=%q python3 helm_share_server.py --host 0.0.0.0 --port %q' \ - "$app_dir" "$public_url" "$share_dir" "$port" - tmux kill-session -t "$session" 2>/dev/null || true - tmux new-session -d -s "$session" "$launch" -} - -rollback_activation() { - # This function is called only after the previous application directory may - # have moved. Keep rollback best-effort even when the original activation - # failure occurred under `set -e`. - set +e - tmux kill-session -t "$session" 2>/dev/null - rm -rf "$remote_dir" - if [[ -e "$previous" || -L "$previous" ]]; then - mv "$previous" "$remote_dir" - start_server "$remote_dir" - rollback_status=$? - else - rollback_status=0 - fi - set -e - return "$rollback_status" -} - -rm -rf "$previous" -if [[ -e "$remote_dir" || -L "$remote_dir" ]]; then - mv "$remote_dir" "$previous" -fi -if ! mv "$staging" "$remote_dir"; then - echo "Helm directory activation failed; rolling back." >&2 - rollback_activation || echo "Rollback also failed; manual recovery is required at $remote_dir." >&2 - exit 1 -fi -if ! start_server "$remote_dir"; then - echo "Helm service start failed; rolling back." >&2 - rollback_activation || echo "Rollback also failed; manual recovery is required at $remote_dir." >&2 - exit 1 -fi - -healthy=false -for _ in $(seq 1 40); do - if python3 - "$port" <<'PY' -import json -import sys -from urllib.request import urlopen - -try: - with urlopen(f"http://127.0.0.1:{sys.argv[1]}/api/share/health", timeout=1) as response: - payload = json.load(response) - raise SystemExit(0 if payload.get("channel_api_version") == "HCHANNEL/1.0" else 1) -except Exception: - raise SystemExit(1) -PY - then - healthy=true - break - fi - sleep 0.25 -done - -if [[ "$healthy" != true ]]; then - echo "New Helm service failed its health check; rolling back." >&2 - rollback_activation || echo "Rollback also failed; manual recovery is required at $remote_dir." >&2 - exit 1 -fi - -echo "Helm ${revision} is healthy at ${public_url}." -REMOTE diff --git a/scripts/helm-agent-bootstrap b/scripts/helm-agent-bootstrap deleted file mode 100755 index 932dad5..0000000 --- a/scripts/helm-agent-bootstrap +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env bash -# Prepare one local machine for Helm-aware agents without exposing a network API. -set -euo pipefail - -agent_name="${HELM_AGENT_NAME:-codex}" -check_only=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --agent-name) - [[ $# -ge 2 ]] || { echo "--agent-name needs a value" >&2; exit 2; } - agent_name="$2" - shift 2 - ;; - --check) - check_only=true - shift - ;; - -h|--help) - printf '%s\n' 'Usage: scripts/helm-agent-bootstrap [--agent-name NAME] [--check]' - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - exit 2 - ;; - esac -done - -repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -data_dir="${HELM_BRIDGE_DATA_DIR:-$HOME/.helm-bridge}" -config_path="${HELM_AGENT_CONFIG:-$HOME/.config/helm-agent/helm-bridge.env}" -bridge_url="http://127.0.0.1:4175" - -# A remote Agent may have a protected configuration that points at a dedicated -# reverse-tunnel port. Check that configured endpoint without exposing its token. -if "$check_only" && [[ -r "$config_path" ]]; then - set -a - . "$config_path" - set +a - bridge_url="${HELM_BRIDGE_ENDPOINT:-$bridge_url}" -fi - -bridge_healthy() { - python3 - "$bridge_url/v1/health" <<'PY' -import json -import sys -from urllib.request import urlopen - -try: - with urlopen(sys.argv[1], timeout=1) as response: - payload = json.loads(response.read().decode("utf-8")) - raise SystemExit(0 if payload.get("ok") is True and payload.get("api_version") == "HBRIDGE/1.0" else 1) -except Exception: - raise SystemExit(1) -PY -} - -if ! bridge_healthy; then - if "$check_only"; then - echo "Helm Bridge is not healthy at $bridge_url" >&2 - exit 1 - fi - mkdir -p "$data_dir" - log_path="$data_dir/bridge.log" - nohup env HELM_BRIDGE_DATA_DIR="$data_dir" python3 "$repo_dir/helm_bridge.py" --host 127.0.0.1 --port 4175 >>"$log_path" 2>&1 & - bridge_pid=$! - for _ in {1..25}; do - if bridge_healthy; then break; fi - sleep 0.2 - done - if ! bridge_healthy; then - kill "$bridge_pid" 2>/dev/null || true - echo "Helm Bridge did not start; inspect $log_path" >&2 - exit 1 - fi -fi - -if "$check_only"; then - [[ -r "$config_path" ]] || { echo "Helm Agent configuration is unavailable: $config_path" >&2; exit 1; } - echo "Helm Agent bootstrap: ready" - exit 0 -fi - -token_path="$data_dir/token" -if [[ ! -r "$token_path" ]]; then - echo "Helm Bridge is healthy but its local token file is unavailable: $token_path. Remote agents need a protected runtime configuration supplied by their tunnel owner." >&2 - exit 1 -fi - -python3 - "$config_path" "$token_path" "$agent_name" <<'PY' -from __future__ import annotations - -import os -import shlex -import sys -import tempfile -from pathlib import Path - -config_path = Path(sys.argv[1]).expanduser() -token = Path(sys.argv[2]).expanduser().read_text(encoding="utf-8").strip() -agent_name = sys.argv[3] -if not token: - raise SystemExit("Helm Bridge token file is empty.") -config_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) -os.chmod(config_path.parent, 0o700) -payload = "\n".join(( - "export HELM_BRIDGE_ENDPOINT=http://127.0.0.1:4175", - f"export HELM_BRIDGE_TOKEN={shlex.quote(token)}", - f"export HELM_AGENT_NAME={shlex.quote(agent_name)}", - "", -)) -descriptor, temporary_name = tempfile.mkstemp(prefix=".helm-bridge.", dir=config_path.parent) -try: - os.fchmod(descriptor, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - handle.write(payload) - handle.flush() - os.fsync(handle.fileno()) - os.replace(temporary_name, config_path) - os.chmod(config_path, 0o600) -finally: - try: - os.unlink(temporary_name) - except FileNotFoundError: - pass -PY - -echo "Helm Agent bootstrap: Bridge ready on 127.0.0.1:4175; protected runtime configuration is ready." diff --git a/scripts/helm-report b/scripts/helm-report deleted file mode 100755 index f3ffc03..0000000 --- a/scripts/helm-report +++ /dev/null @@ -1,382 +0,0 @@ -#!/usr/bin/env python3 -"""Helm report authoring kit. - -Scaffold self-contained HDOC reports from reusable visual components, show the -available visual grammar, and fail fast when a generated report has not -completed its evidence contract. -""" - -from __future__ import annotations - -import argparse -import html -import json -import sys -from datetime import datetime, timezone -from html.parser import HTMLParser -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) - -from helm_bridge import ContractError, validate_hdoc # noqa: E402 - - -COMPONENTS = { - "kpi-strip": { - "relationship": "headline measures", - "description": "A compact set of decision-relevant measures with denominator and direction.", - }, - "evidence-ledger": { - "relationship": "claim strength", - "description": "Separates verified facts, measurements, interpretations, and proposals.", - }, - "comparison-matrix": { - "relationship": "alternatives and trade-offs", - "description": "Compares real peers against the same criteria and makes the selected path explicit.", - }, - "ranked-bars": { - "relationship": "relative magnitude", - "description": "Ranks comparable values on one honest baseline with direct labels.", - }, - "range": { - "relationship": "uncertainty or tail behavior", - "description": "Shows intervals or percentiles when an average would hide the decision boundary.", - }, - "sequence": { - "relationship": "ordered flow or handoff", - "description": "Shows verb-labelled stages, owners, checkpoints, and failure paths.", - }, - "hierarchy": { - "relationship": "ownership and scope", - "description": "Makes containment, responsibility, interfaces, and excluded scope visible.", - }, - "trace": { - "relationship": "part-to-whole decomposition", - "description": "Explains where a total comes from without disguising illustrative values as measurements.", - }, - "case-ledger": { - "relationship": "comparable examples", - "description": "Keeps cases scannable while preserving evidence, outcome, and boundary.", - }, - "roadmap": { - "relationship": "action and checkpoint", - "description": "Connects the next move to its evidence, owner, metric, and revisit condition.", - }, -} - -PROFILES = { - "research": ["evidence-ledger", "comparison-matrix", "sequence"], - "benchmark": ["kpi-strip", "ranked-bars", "range", "trace", "case-ledger"], - "architecture": ["hierarchy", "sequence", "comparison-matrix", "roadmap"], - "decision": ["comparison-matrix", "evidence-ledger", "roadmap"], - "handoff": ["sequence", "evidence-ledger", "roadmap"], - "deep-dive": ["kpi-strip", "hierarchy", "comparison-matrix", "trace", "evidence-ledger", "roadmap"], -} - -PROFILE_TYPES = { - "research": "report", - "benchmark": "report", - "architecture": "reference", - "decision": "brief", - "handoff": "note", - "deep-dive": "report", -} - -PRESENTATION_MODES = { - "standard": "Continuous evidence report with compact chapter spacing.", - "chapter-reveal": "Viewport-paced chapters with a sticky index and CSS-only scroll entry; static fallback is always visible.", -} - -STYLES = r""" -:root{color-scheme:light;--paper:#f1efe8;--raised:#fbfaf5;--ink:#191a18;--soft:#686861;--line:#d6d1c4;--accent:#b64d43;--accent-soft:#ead8d0;--cool:#476b75;--positive:#4f745e;--warning:#9b6f33;--display:Georgia,"Noto Serif SC","Songti SC",serif;--ui:Inter,"Noto Sans SC","PingFang SC",system-ui,sans-serif;--mono:"SFMono-Regular",Consolas,monospace;--reading:46rem;--content:76rem;--stage:92rem} -*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;color:var(--ink);background:var(--paper);font-family:var(--ui);line-height:1.65}main{padding:0 24px 96px}.stage{width:min(var(--stage),100%);margin-inline:auto}.content{width:min(var(--content),100%);margin-inline:auto}.reading{width:min(var(--reading),100%);margin-inline:auto}.topline{display:flex;justify-content:space-between;gap:20px;padding:22px 0;border-bottom:1px solid var(--line);color:var(--soft);font:11px var(--mono);letter-spacing:.12em;text-transform:uppercase}.hero{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:56px;align-items:end;padding:92px 0 58px}.eyebrow,.component-label,.state,.meta-label{margin:0;color:var(--accent);font:11px var(--mono);letter-spacing:.12em;text-transform:uppercase}.hero h1{max-width:880px;margin:14px 0 20px;font:500 clamp(48px,8vw,108px)/.94 var(--display);letter-spacing:-.055em}.hero .dek{max-width:780px;margin:0;color:#454943;font:21px/1.55 var(--display)}.hero-facts{margin:0;border-top:1px solid var(--line)}.hero-facts div{display:grid;grid-template-columns:82px 1fr;gap:16px;padding:13px 0;border-bottom:1px solid var(--line)}.hero-facts dt{color:var(--soft);font:10px var(--mono);text-transform:uppercase}.hero-facts dd{margin:0;font-size:13px}.thesis{margin-bottom:42px;padding:28px 30px;border-left:4px solid var(--accent);background:var(--raised)}.thesis p:last-child{max-width:900px;margin:8px 0 0;font:500 clamp(24px,3.3vw,43px)/1.18 var(--display);letter-spacing:-.025em}.reader-path{display:grid;grid-template-columns:180px 1fr;gap:28px;padding:24px 0;border-block:1px solid var(--line)}.reader-path p{margin:0}.reader-path p:last-child{font:24px/1.35 var(--display)}.chapter{padding:76px 0 34px}.chapter-head{display:grid;grid-template-columns:150px minmax(0,1fr);gap:30px;margin-bottom:28px}.chapter-index{color:var(--accent);font:11px var(--mono);letter-spacing:.1em}.chapter-head h2,.component-head h2{margin:0;font:500 clamp(32px,4vw,54px)/1.08 var(--display);letter-spacing:-.035em}.chapter-head p{max-width:680px;margin:14px 0 0;color:var(--soft)}[data-helm-component]{margin:0 0 28px;padding:26px;border:1px solid var(--line);background:var(--raised)}.component-head{display:flex;justify-content:space-between;gap:28px;align-items:flex-start;margin-bottom:22px}.component-head h2{max-width:760px;margin-top:7px;font-size:clamp(25px,3vw,38px)}.component-meta{flex:0 0 190px;text-align:right;color:var(--soft);font:10px/1.55 var(--mono)}.component-meta b{display:block;color:var(--ink);font-weight:500}.component-note,figcaption{max-width:820px;margin:16px 0 0;color:var(--soft);font-size:13px}.kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);border-block:1px solid var(--line)}.kpi-grid div{padding:23px 18px;border-right:1px solid var(--line)}.kpi-grid div:last-child{border-right:0}.kpi-grid dt{color:var(--soft);font:10px var(--mono);text-transform:uppercase}.kpi-grid dd{margin:7px 0 0;font:500 38px/1 var(--display);font-variant-numeric:tabular-nums}.kpi-grid small{display:block;margin-top:8px;color:var(--soft);font-size:11px}.table-wrap{overflow-x:auto;border:1px solid var(--line)}table{width:100%;border-collapse:collapse;font-size:13px}caption{padding:13px 15px;text-align:left;color:var(--soft);font:10px var(--mono);letter-spacing:.08em;text-transform:uppercase}th,td{padding:13px 15px;border-top:1px solid var(--line);text-align:left;vertical-align:top}th{color:var(--soft);font:10px var(--mono);letter-spacing:.06em;text-transform:uppercase}td strong{font-weight:650}.pill{display:inline-block;padding:3px 6px;border:1px solid currentColor;color:var(--cool);font:9px var(--mono);text-transform:uppercase}.pill.verified{color:var(--positive)}.pill.proposed{color:var(--warning)}.selected-row{background:color-mix(in srgb,var(--accent-soft) 64%,transparent)}.bars{display:grid;gap:15px}.bar-row{display:grid;grid-template-columns:minmax(130px,210px) minmax(160px,1fr) 72px;gap:14px;align-items:center}.bar-label{font-size:13px}.bar-track{height:18px;background:var(--paper);border-left:1px solid var(--ink)}.bar-fill{display:block;width:var(--value);height:100%;background:var(--cool)}.bar-row:first-child .bar-fill{background:var(--accent)}.bar-value{text-align:right;font:12px var(--mono);font-variant-numeric:tabular-nums}.range-grid{display:grid;gap:18px}.range-row{display:grid;grid-template-columns:120px 1fr 105px;gap:16px;align-items:center}.range-track{position:relative;height:22px;background:linear-gradient(var(--line),var(--line)) center/100% 1px no-repeat}.range-span{position:absolute;top:5px;left:var(--start);width:var(--width);height:12px;background:var(--accent-soft);border-inline:2px solid var(--accent)}.range-dot{position:absolute;top:7px;left:var(--point);width:8px;height:8px;border-radius:50%;background:var(--accent);transform:translateX(-50%)}.range-value{text-align:right;font:11px var(--mono)}.sequence{display:grid;grid-template-columns:repeat(var(--steps,4),1fr);gap:30px;list-style:none;margin:0;padding:0}.sequence li{position:relative;min-height:130px;padding:18px;border:1px solid var(--line);background:var(--paper)}.sequence li:not(:last-child)::after{position:absolute;top:50%;right:-24px;content:"→";color:var(--accent);font:20px var(--mono);transform:translateY(-50%)}.sequence span{color:var(--accent);font:10px var(--mono)}.sequence strong{display:block;margin:15px 0 5px;font:500 20px var(--display)}.sequence small{color:var(--soft)}.tree{display:grid;grid-template-columns:1fr 1.2fr;gap:28px}.tree-root{display:grid;place-items:center;min-height:190px;padding:22px;border:2px solid var(--accent);font:500 26px var(--display);text-align:center}.tree-branches{display:grid;gap:12px}.tree-branches div{padding:17px;border:1px solid var(--line);background:var(--paper)}.tree-branches strong{display:block}.tree-branches small{color:var(--soft)}.trace{display:flex;min-height:82px;border:1px solid var(--line)}.trace span{display:grid;align-content:center;width:var(--value);min-width:90px;padding:12px;border-right:1px solid var(--paper);color:#fff;background:var(--cool)}.trace span:nth-child(2){background:var(--accent)}.trace span:nth-child(3){background:var(--positive)}.trace span:nth-child(4){background:var(--warning)}.trace b,.trace small{display:block}.trace small{font:10px var(--mono);opacity:.82}.case-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.case{padding:18px;border:1px solid var(--line);background:var(--paper)}.case header{display:flex;justify-content:space-between;gap:12px;color:var(--soft);font:10px var(--mono);text-transform:uppercase}.case h3{margin:24px 0 8px;font:500 22px/1.15 var(--display)}.case p{margin:0;color:var(--soft);font-size:13px}.roadmap{display:grid;gap:0;border-top:1px solid var(--line)}.roadmap-row{display:grid;grid-template-columns:70px 1.3fr 1fr 1fr;gap:18px;padding:20px 0;border-bottom:1px solid var(--line)}.roadmap-row>span{color:var(--accent);font:11px var(--mono)}.roadmap-row b{display:block;margin-bottom:4px}.roadmap-row small{color:var(--soft)}.sources{margin-top:72px;padding-top:28px;border-top:1px solid var(--line)}.sources h2{font:500 30px var(--display)}.sources li{margin-bottom:8px;color:var(--soft)}.placeholder{outline:2px dashed var(--warning);outline-offset:4px}@media(max-width:900px){.hero{grid-template-columns:1fr}.hero-facts{max-width:520px}.kpi-grid{grid-template-columns:repeat(2,1fr)}.case-grid{grid-template-columns:1fr 1fr}.sequence{grid-template-columns:1fr 1fr}.sequence li::after{display:none}.roadmap-row{grid-template-columns:55px 1fr}.roadmap-row>div:nth-last-child(-n+2){grid-column:2}}@media(max-width:620px){main{padding-inline:16px}.topline span:last-child{display:none}.hero{padding:58px 0 42px}.hero h1{font-size:48px}.hero .dek{font-size:18px}.reader-path,.chapter-head,.component-head{display:block}.chapter-index,.component-meta{margin-bottom:12px;text-align:left}.chapter{padding-top:54px}[data-helm-component]{padding:18px;margin-inline:-8px}.kpi-grid,.case-grid,.sequence,.tree{grid-template-columns:1fr}.kpi-grid div{border-right:0;border-bottom:1px solid var(--line)}.bar-row,.range-row{grid-template-columns:1fr}.bar-value,.range-value{text-align:left}.trace{display:grid}.trace span{width:100%;min-width:0}.roadmap-row{grid-template-columns:42px 1fr}.table-wrap{margin-inline:-18px}.table-wrap table{min-width:660px}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}@media print{body{background:#fff}[data-helm-component]{break-inside:avoid}.placeholder{outline:none}} -.chapter-deck{display:grid;grid-template-columns:150px minmax(0,1fr);gap:30px}.chapter-rail{position:sticky;top:24px;align-self:start;display:grid;gap:8px;padding:18px 0;border-block:1px solid var(--line)}.chapter-rail strong{margin-bottom:5px;color:var(--soft);font:10px var(--mono);letter-spacing:.1em;text-transform:uppercase}.chapter-rail a{color:var(--soft);font:10px/1.35 var(--mono);text-decoration:none}.chapter-rail a:hover,.chapter-rail a:focus-visible{color:var(--accent)}.chapter-slides{min-width:0}.chapter-slide{min-height:min(84svh,820px);display:grid;grid-template-columns:58px minmax(0,1fr);gap:18px;align-items:center;border-top:1px solid var(--line)}.chapter-slide:first-child{border-top:0}.chapter-marker{align-self:start;margin-top:44px;color:var(--accent);font:10px var(--mono);letter-spacing:.08em;writing-mode:vertical-rl}.chapter-slide>[data-helm-component]{width:100%;margin:34px 0}@keyframes helm-chapter-in{from{opacity:.55;transform:translateY(16px)}to{opacity:1;transform:none}}@supports (animation-timeline:view()){.chapter-slide .chapter-marker,.chapter-slide>[data-helm-component]{animation:helm-chapter-in linear both;animation-timeline:view(block);animation-range:entry 0% entry 72%}}@media(max-width:900px){.chapter-deck{grid-template-columns:1fr}.chapter-rail{position:static;grid-template-columns:repeat(3,minmax(0,1fr));border-top:0}.chapter-rail strong{grid-column:1/-1}.chapter-slide{min-height:70svh;grid-template-columns:42px minmax(0,1fr)}}@media(max-width:620px){.chapter-rail{display:none}.chapter-slide{min-height:auto;display:block;padding:38px 0}.chapter-marker{margin:0 0 10px;writing-mode:horizontal-tb}.chapter-slide>[data-helm-component]{margin:0}.chapter-slide .chapter-marker,.chapter-slide>[data-helm-component]{animation:none!important;transform:none!important;opacity:1!important}}@media(prefers-reduced-motion:reduce){.chapter-slide .chapter-marker,.chapter-slide>[data-helm-component]{animation:none!important;transform:none!important;opacity:1!important}}@media print{.chapter-deck,.chapter-slide{display:block}.chapter-rail{display:none}.chapter-slide{min-height:auto;border-top:0;break-inside:auto}.chapter-marker{margin:12px 0;writing-mode:horizontal-tb}.chapter-slide>[data-helm-component]{margin:0 0 28px;animation:none!important;transform:none!important;opacity:1!important}} -""" - - -def esc(value: str) -> str: - return html.escape(value, quote=True) - - -def component_markup(name: str, claim_id: str, gallery: bool = False) -> str: - state = "illustrative" if not gallery else "verified" - source = "Replace with source and date" if not gallery else "Helm component specimen / 2026-07-14" - scope = "Replace with measurement boundary" if not gallery else "Illustrative specimen; values explain component behavior" - placeholder = "" if gallery else " placeholder" - attrs = f'data-helm-component="{name}" data-helm-claim="{claim_id}" data-evidence-state="{state}" data-source="{esc(source)}" data-scope="{esc(scope)}"' - meta = f'
        {state.upper()}{esc(scope)}
        ' - head = lambda label, title: f'

        {label}

        {title}

        {meta}
        ' - note = f'
        {esc(source)}. {esc(scope)}.
        ' - - if name == "kpi-strip": - return f'''
        {head("KPI strip · headline measures", f'The first screen exposes the measures that change the decision.')}
        Coverage
        82%
        41 / 50 evaluated cases
        p50 latency
        1.8s
        single controlled environment
        Failure rate
        6%
        3 / 50 cases
        Confidence
        Med.
        one environment; repeat needed

        {esc(source)}. {esc(scope)}.

        ''' - if name == "evidence-ledger": - return f'''
        {head("Evidence ledger · claims are not equal", "The conclusion is only as strong as its visible evidence state.")}
        Claim, evidence state, best source, and boundary
        IDClaimStateBest evidenceBoundary
        {claim_id}.1Replace with a verified factual claim.VerifiedSource + dateVersion / environment
        {claim_id}.2Replace with a clearly labelled interpretation.Interpreted{claim_id}.1 + reasoningNot source wording
        {claim_id}.3Replace with a proposed next move.ProposedObserved gapNot yet verified
        {note}
        ''' - if name == "comparison-matrix": - return f'''
        {head("Comparison matrix · same criteria", "The selected option wins on the criterion that matters, not on presentation.")}
        Alternatives evaluated on common criteria
        OptionDecisive upsideCost / riskEvidenceRevisit if
        Recommended optionReplace with measurable benefitKnown trade-offSource / testReversal condition
        AlternativeReal upsideWhy it is not selectedSource / assumptionTrigger
        {note}
        ''' - if name == "ranked-bars": - return f'''
        {head("Ranked bars · magnitude", "One option leads on the measured outcome, within this environment.")}{note}
        ''' - if name == "range": - return f'''
        {head("Range · uncertainty", "The tail changes the conclusion that the average would suggest.")}{note}
        ''' - if name == "sequence": - return f'''
        {head("Sequence · verb-labelled handoff", "The result becomes durable only after evidence passes an explicit review boundary.")}
        1. 01 / COLLECTQuestionDefine reader and boundary.
        2. 02 / VERIFYEvidenceSeparate fact from inference.
        3. 03 / EXPLAINComponentMake the relation visible.
        4. 04 / RETAINArtifactValidate and submit once.
        {note}
        ''' - if name == "hierarchy": - return f'''
        {head("Hierarchy · ownership and scope", "Ownership stays legible when storage, projection, and excluded scope are separated.")}{note}
        ''' - if name == "trace": - return f'''
        {head("Trace · part-to-whole", "Most of the total comes from two stages, so optimization should start there.")}{note}
        ''' - if name == "case-ledger": - return f'''
        {head("Case ledger · comparable examples", "The pattern holds in common cases but fails at one explicit boundary.")}
        CASE 01PASS

        Common path

        Evidence, observed outcome, and why it matters.

        CASE 02PASS

        Different context

        Same structure, directly comparable labels.

        CASE 03BOUNDARY

        Failure case

        The condition that breaks the generalization.

        {note}
        ''' - if name == "roadmap": - return f'''
        {head("Roadmap · evidence to action", "The next move is small, owned, measurable, and reversible.")}
        NOW
        Replace with the smallest useful actionWhy this follows from the evidence.
        OwnerPerson or team
        CheckpointMetric / date / condition
        NEXT
        Follow only if the checkpoint passesDo not imply certainty before evidence exists.
        EvidenceRequired proof
        Revisit ifReversal condition
        {note}
        ''' - raise KeyError(name) - - -def build_document(args: argparse.Namespace, *, gallery: bool = False) -> str: - names = list(COMPONENTS) if gallery else (args.components.split(",") if args.components else PROFILES[args.profile]) - names = [name.strip() for name in names if name.strip()] - unknown = [name for name in names if name not in COMPONENTS] - if unknown: - raise SystemExit(f"Unknown components: {', '.join(unknown)}") - now = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - title = args.title or ("Helm component studio" if gallery else f"{args.profile.title()} evidence report") - summary = args.summary or ("A rendered catalog of the visual components agents can compose into portable Helm reports." if gallery else "Replace with the decision-relevant answer this artifact preserves.") - document_id = args.id or ("helm-component-studio" if gallery else f"replace-{args.profile}-report-id") - profile = "component-gallery" if gallery else args.profile - presentation_mode = "chapter-reveal" if gallery or args.profile == "deep-dive" else "standard" - claims = [{"id": f"C{index:02d}", "relationship": COMPONENTS[name]["relationship"], "component": name} for index, name in enumerate(names, 1)] - manifest = { - "schema_version": "HDOC/1.0", - "id": document_id, - "title": title, - "type": "reference" if gallery else PROFILE_TYPES[args.profile], - "tags": ["design-system", "components"] if gallery else [args.profile, "evidence"], - "summary": summary, - "created_at": now, - "updated_at": now, - "project": {"id": args.project_id, "name": args.project_name}, - "provenance": {"author": args.author, "sources": []}, - "presentation": {"profile": profile, "mode": presentation_mode, "claims": claims}, - } - rendered_modules = [component_markup(name, claim["id"], gallery=gallery) for name, claim in zip(names, claims)] - if presentation_mode == "chapter-reveal": - rail = "".join( - f'{claim["id"]} · {esc(name.replace("-", " "))}' - for name, claim in zip(names, claims) - ) - slides = "\n".join( - f'
        {index:02d} / {len(names):02d}{module}
        ' - for index, (name, claim, module) in enumerate(zip(names, claims, rendered_modules), 1) - ) - modules = f'
        {slides}
        ' - else: - modules = "\n".join(rendered_modules) - placeholder_class = "" if gallery else " placeholder" - return f''' - - - - - - - - - {esc(title)} - - - - -
        -
        {esc(profile)} · HDOC/1.0Updated {now[:10]}
        -
        -

        Evidence original · {esc(profile)}

        {esc(title)}

        {esc(summary)}

        -
        Profile
        {esc(profile)}
        Components
        {len(names)} visual modules
        Mode
        {esc(presentation_mode)}
        Evidence
        {'Rendered specimens' if gallery else 'Replace before handoff'}
        -
        -

        Short answer

        {'Components are useful only when agents can select, compose, and validate them as part of the authoring workflow.' if gallery else 'Replace this sentence with the bounded conclusion a reader should retain.'}

        - -
        01 / COMPONENTS

        {'A visual grammar agents can actually call.' if gallery else 'Evidence modules selected from the claim relationships.'}

        {'Each specimen includes a claim, evidence state, scope, source note, and accessible fallback.' if gallery else 'Replace every placeholder with real content. Remove a module when the underlying relationship does not exist; add one when an important relationship remains prose-only.'}

        {modules}
        -

        Sources and method

        Make the evidence boundary recoverable.

        1. {'This gallery is generated by scripts/helm-report and contains illustrative component data.' if gallery else 'Replace with primary source, URL, access date, and the claim it supports.'}
        2. {'No runtime CDN, executable JavaScript, or remote visual dependency is required.' if gallery else 'State collection method, assumptions, exclusions, and confidence.'}
        -
        - -''' - - -class VisualParser(HTMLParser): - def __init__(self) -> None: - super().__init__(convert_charrefs=True) - self.manifest_parts: list[str] | None = None - self.manifest_text = "" - self.components: list[dict[str, object]] = [] - self.component_stack: list[dict[str, object]] = [] - self.placeholders = 0 - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - data = {key.lower(): value or "" for key, value in attrs} - if tag == "script" and "data-helm-manifest" in data: - self.manifest_parts = [] - if "data-helm-component" in data: - record: dict[str, object] = { - "attrs": data, - "tag": tag, - "heading": False, - "caption": False, - "graphic": False, - "fallback": False, - } - self.components.append(record) - self.component_stack.append(record) - if self.component_stack: - current = self.component_stack[-1] - if tag in {"h2", "h3"}: - current["heading"] = True - if tag == "figcaption": - current["caption"] = True - if tag in {"svg", "img", "canvas"} or data.get("role") == "img": - current["graphic"] = True - if tag in {"table", "ol", "ul"} or "data-helm-fallback" in data: - current["fallback"] = True - if "placeholder" in data.get("class", "").split() or "data-placeholder" in data: - self.placeholders += 1 - - def handle_data(self, data: str) -> None: - if self.manifest_parts is not None: - self.manifest_parts.append(data) - - def handle_endtag(self, tag: str) -> None: - if tag == "script" and self.manifest_parts is not None: - self.manifest_text = "".join(self.manifest_parts) - self.manifest_parts = None - if self.component_stack and tag == self.component_stack[-1]["tag"]: - self.component_stack.pop() - - -def check_report(path: Path) -> int: - source = path.read_text(encoding="utf-8") - errors: list[str] = [] - warnings: list[str] = [] - try: - manifest, bridge_warnings = validate_hdoc(source) - warnings.extend(bridge_warnings) - except ContractError as exc: - manifest = {} - errors.extend(exc.errors) - warnings.extend(exc.warnings) - parser = VisualParser() - parser.feed(source) - try: - presentation = json.loads(parser.manifest_text).get("presentation", {}) - except (json.JSONDecodeError, AttributeError): - presentation = {} - claims = presentation.get("claims", []) if isinstance(presentation, dict) else [] - declared = {claim.get("id"): claim.get("component") for claim in claims if isinstance(claim, dict)} - actual: dict[str, str] = {} - for index, record in enumerate(parser.components, 1): - component = record["attrs"] - assert isinstance(component, dict) - name = component.get("data-helm-component", "") - claim = component.get("data-helm-claim", "") - state = component.get("data-evidence-state", "") - if name not in COMPONENTS: - errors.append(f"component {index}: unknown data-helm-component {name!r}") - if not claim: - errors.append(f"component {name or index}: missing data-helm-claim") - elif claim in actual: - errors.append(f"claim {claim}: used by more than one component") - else: - actual[claim] = name - if state not in {"measured", "verified", "interpreted", "proposed", "illustrative"}: - errors.append(f"component {name or index}: invalid or missing data-evidence-state") - if state in {"measured", "verified"} and not component.get("data-source"): - errors.append(f"component {name or index}: {state} evidence requires data-source") - if not component.get("data-scope"): - errors.append(f"component {name or index}: missing data-scope boundary") - if not record["heading"]: - errors.append(f"component {name or index}: missing a conclusion-led h2 or h3") - if record["tag"] == "figure" and not record["caption"]: - errors.append(f"component {name or index}: figure requires a figcaption") - if record["graphic"] and not record["fallback"]: - errors.append(f"component {name or index}: graphic requires a table or list fallback") - for claim, component in declared.items(): - if actual.get(claim) != component: - errors.append(f"claim {claim}: declared {component!r}, rendered {actual.get(claim)!r}") - for claim in actual: - if claim not in declared: - warnings.append(f"claim {claim}: rendered but not declared in manifest.presentation.claims") - if manifest.get("type") in {"report", "brief", "dashboard"} and not parser.components: - errors.append("decision-facing documents require at least one data-helm-component visual module") - if parser.placeholders: - errors.append(f"{parser.placeholders} placeholder block(s) remain; replace their content and remove the placeholder class") - print(f"Helm report check: {path}") - for message in errors: - print(f"ERROR {message}") - for message in warnings: - print(f"WARNING {message}") - if errors: - print(f"FAIL {len(errors)} error(s), {len(warnings)} warning(s)") - return 1 - print(f"PASS {len(parser.components)} component(s), {len(declared)} declared claim(s), {len(warnings)} warning(s)") - return 0 - - -def parser() -> argparse.ArgumentParser: - root = argparse.ArgumentParser(prog="helm-report", description="Compose and validate Helm evidence reports.") - commands = root.add_subparsers(dest="command", required=True) - listing = commands.add_parser("list", help="List reusable components and profiles.") - listing.add_argument("--json", action="store_true") - new = commands.add_parser("new", help="Create a self-contained HDOC report scaffold.") - new.add_argument("--profile", choices=sorted(PROFILES), default="research") - new.add_argument("--components", help="Comma-separated component override.") - new.add_argument("--title") - new.add_argument("--summary") - new.add_argument("--id") - new.add_argument("--project-id", default="helm") - new.add_argument("--project-name", default="Helm") - new.add_argument("--author", default="codex") - new.add_argument("--output", required=True, type=Path) - gallery = commands.add_parser("gallery", help="Render every component as a standalone gallery.") - gallery.add_argument("--title") - gallery.add_argument("--summary") - gallery.add_argument("--id") - gallery.add_argument("--project-id", default="helm") - gallery.add_argument("--project-name", default="Helm") - gallery.add_argument("--author", default="Helm") - gallery.add_argument("--output", required=True, type=Path) - check = commands.add_parser("check", help="Validate HDOC plus the Agent visual-component contract.") - check.add_argument("path", type=Path) - return root - - -def main() -> int: - args = parser().parse_args() - if args.command == "list": - payload = {"profiles": PROFILES, "presentation_modes": PRESENTATION_MODES, "components": COMPONENTS} - if args.json: - print(json.dumps(payload, ensure_ascii=False, indent=2)) - else: - print("PROFILES") - for name, components in PROFILES.items(): - print(f" {name:12} {' → '.join(components)}") - print("\nPRESENTATION MODES") - for name, description in PRESENTATION_MODES.items(): - print(f" {name:18} {description}") - print("\nCOMPONENTS") - for name, item in COMPONENTS.items(): - print(f" {name:18} {item['relationship']}: {item['description']}") - return 0 - if args.command == "check": - return check_report(args.path) - gallery = args.command == "gallery" - if gallery: - args.profile = "research" - args.components = None - source = build_document(args, gallery=gallery) - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(source, encoding="utf-8") - print(f"Wrote {args.output} ({len(source.encode('utf-8'))} bytes)") - if not gallery: - print(f"Next: replace every outlined placeholder, remove its placeholder class, then run scripts/helm-report check {args.output}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/helm-submit b/scripts/helm-submit deleted file mode 100755 index d867ff8..0000000 --- a/scripts/helm-submit +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -# Load the protected per-agent runtime configuration, then submit one final HDOC. -set -euo pipefail - -config="${HELM_AGENT_CONFIG:-$HOME/.config/helm-agent/helm-bridge.env}" -if [[ ! -r "$config" ]]; then - echo "Helm Agent configuration is unavailable: $config" >&2 - exit 2 -fi - -set -a -. "$config" -set +a - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -exec python3 "$script_dir/helm_submit.py" "$@" diff --git a/scripts/helm_submit.py b/scripts/helm_submit.py deleted file mode 100644 index a58466d..0000000 --- a/scripts/helm_submit.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -"""Submit a completed HDOC HTML artifact to a reachable Helm Bridge. - -Agents should call this once they have written and locally validated their final -HTML file. The Bridge is intentionally responsible for final contract and -identity checks; this client never tries to repair or rewrite the source. -""" - -from __future__ import annotations - -import argparse -import json -import os -import re -import sys -from pathlib import Path -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - - -def project_id(value: str) -> str: - normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") - return normalized[:100] or "workspace" - - -def main() -> int: - parser = argparse.ArgumentParser(description="Submit a completed HDOC HTML file to Helm Bridge.") - parser.add_argument("html_file", type=Path, help="Completed UTF-8 .html artifact") - parser.add_argument("--source", default=os.environ.get("HELM_AGENT_NAME", "unnamed-agent"), help="Short provenance label for this agent") - parser.add_argument("--project-id", default=os.environ.get("HELM_PROJECT_ID"), help="Stable workspace identifier (defaults to the current folder)") - parser.add_argument("--project-name", default=os.environ.get("HELM_PROJECT_NAME"), help="Human-readable workspace name (defaults to the current folder)") - parser.add_argument("--endpoint", default=os.environ.get("HELM_BRIDGE_ENDPOINT", "http://127.0.0.1:4175"), help="Helm Bridge base URL") - args = parser.parse_args() - - workspace_name = (args.project_name or Path.cwd().name).strip()[:100] - workspace_id = project_id((args.project_id or workspace_name).strip()) - - token = os.environ.get("HELM_BRIDGE_TOKEN", "").strip() - if not token: - print("HELM_BRIDGE_TOKEN is not configured; artifact was not sent.", file=sys.stderr) - return 2 - try: - payload = args.html_file.read_bytes() - except OSError as error: - print(f"Could not read {args.html_file}: {error}", file=sys.stderr) - return 2 - - request = Request( - f"{args.endpoint.rstrip('/')}/v1/artifacts", - data=payload, - method="POST", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "text/html; charset=utf-8", - "X-Helm-Source": args.source[:120], - "X-Helm-Project-Id": workspace_id, - "X-Helm-Project-Name": workspace_name, - }, - ) - try: - with urlopen(request, timeout=15) as response: - body = json.loads(response.read().decode("utf-8")) - artifact = body.get("artifact", {}) - project = artifact.get("project") or {} - project_label = project.get("name") if isinstance(project, dict) else None - print(f"Helm Bridge: {body.get('status', 'accepted')} · {artifact.get('id', 'unknown-id')}{f' · {project_label}' if project_label else ''}") - return 0 - except HTTPError as error: - try: - body = json.loads(error.read().decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - body = {"error": "unexpected_response"} - if error.code == 409: - print(f"Helm Bridge: identity conflict for {body.get('id', 'artifact')}; source was not replaced.", file=sys.stderr) - elif error.code == 422: - print("Helm Bridge rejected the HDOC artifact:", file=sys.stderr) - for message in body.get("errors", []): - print(f"- {message}", file=sys.stderr) - else: - print(f"Helm Bridge request failed ({error.code}): {body.get('error', 'unknown error')}", file=sys.stderr) - return 1 - except URLError as error: - print(f"Helm Bridge is unreachable: {error.reason}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..f7fd165 --- /dev/null +++ b/skill/SKILL.md @@ -0,0 +1,107 @@ +--- +name: helm-report +description: > + Author a professional, self-contained HDOC/1.1 HTML artifact (report, brief, + reference, dashboard, decision, research, benchmark) in Helm's cool-grey + engineering design system, then drop it into the local Helm library. Use + whenever a coding agent (Claude Code, Codex, or any other) is asked to produce + a durable HTML report/brief/dashboard for a person to read and keep — NOT for + throwaway answers or Markdown fragments. +--- + +# Helm report authoring + +Helm turns an agent's finished analysis into one **standalone HTML file** that a +person reads and keeps. The library is a plain folder; publishing this artifact +means writing one file and rebuilding an index. There is no server to call, no +token, no review inbox. + +## What "done" looks like + +- One complete `.html` file — no external script, stylesheet, font, or image. +- An inline `HDOC/1.1` manifest that matches the visible document. +- Answer-first: the reader sees the bounded conclusion before the background. +- Every material relationship rendered with the smallest useful component, in + the cool-grey identity defined in `design-system.md`. +- `bin/helm check` passes; the file lives at `$HELM_LIBRARY//index.html`. + +Read [`design-system.md`](design-system.md) before writing HTML, and open +[`components.html`](components.html) to see the rendered component vocabulary. +Never imitate the visual style from prose alone. + +## Workflow + +### 1 — Inventory the claims first, not the layout + +Before any HTML, list the material claims the report must carry. For each, label +the relationship it expresses and its evidence state: + +| Relationship | Component | Evidence state | +| --- | --- | --- | +| the bounded conclusion | Thesis hero | — | +| headline metrics | Metastrip | verified / interpretation | +| claims are not equal | Evidence ledger | verified / interpretation / proposal | +| magnitude / comparison | Bar figure | measured with unit + baseline | +| the interface *is* the evidence | Spec block | verified | +| changes vs open questions | Boundary columns | mixed | + +Facts, measurements, your interpretation, and unlanded proposals must stay +visually and tonally distinct. Do not write an inferred claim as a source fact. + +### 2 — Scaffold + +```bash +bin/helm new "Decision-relevant title" \ + --type decision \ + --id stable-slug \ + --summary "one-line answer" \ + --tags "topic,area" \ + --source your-agent-name +``` + +This copies `template.html` into `$HELM_LIBRARY//index.html` with the +manifest filled. (No `bin/helm`? Copy `skill/template.html` yourself and edit the +`application/helm+json` block by hand.) + +### 3 — Fill, bind, prune + +- Replace **every** specimen. Remove each `placeholder` class as you go. +- Bind each inventoried relationship to the smallest component. Set each row's + evidence state honestly. +- **Delete any component whose relationship is absent** — an empty chart or an + unused ledger is worse than none. +- **Do not leave a real relationship prose-only** just because the template + omitted it. Add the component from `components.html`. +- Keep sources, dates, units, sample bounds, and confidence next to the claim + they qualify. A number without its denominator and conditions is a liability. + +### 4 — Check, place, index + +```bash +bin/helm check $HELM_LIBRARY//index.html # must pass +bin/helm index # rebuild catalog.json +``` + +`check` fails on: missing/invalid manifest, no `
        `, leftover placeholders, +or any external dependency. Fix the contract, do not weaken it. + +## Report preflight + +A reader must see, without interacting: + +1. Document type, title, date, and decision-relevant purpose. +2. The short answer or current finding, before long background. +3. Evidence that supports the conclusion, distinguished from interpretation. +4. The action, checkpoint, caveat, or open question that follows. +5. Provenance for factual claims: source, date, method, or confidence. +6. Every material relationship given the smallest useful visual treatment — or + an explicit reason it needs none. + +## Boundaries + +- No dependency on a host app, external script, auth, or remote asset. Embed + images as `data:` URIs. +- Reuse the manifest `id` only to revise the same logical artifact; use a new id + for a different document. +- Do not reintroduce sharing, publishing, or a server. The library is local and + personal by design. diff --git a/skill/components.html b/skill/components.html new file mode 100644 index 0000000..4e72937 --- /dev/null +++ b/skill/components.html @@ -0,0 +1,174 @@ + + + + + +Helm · Component gallery + + + + +
        HELM · COMPONENT GALLERY +
        + +
        +
        + Cool-grey engineering · rendered vocabulary +

        Component gallery

        +

        每个组件承载一个关系。选能表达该断言的最小组件;关系不存在就删掉组件。规范见 design-system.md

        +
        + +
        +
        01

        Thesis hero

        +
        关系有边界的结论用于每篇开场避免无边界口号
        +
        + SECTION · KICKER +

        库就是文件夹,不是上线系统。

        +

        一句展开:读者的问题、你的短答案、适用边界。

        +
        +
        + +
        +
        02

        Metastrip

        +
        关系关键指标一眼用于3–5 个数字撑起答案避免把所有数字堆这
        +
        +
        +
        删除代码~230 KB
        +
        运行时依赖0
        +
        上传步骤1
        +
        serverstatic
        +
        +
        +
        + +
        +
        03

        Evidence ledger

        +
        关系判断不等价用于事实/解释/提议并存避免无状态卡片阵
        +
        + + + + + + + +
        ID判断状态证据边界
        C01已被证据支持已验证源+方法适用范围
        C02架构解释解释推导非原话
        C03下一步提议缺口未验证
        +
        +
        + +
        +
        04

        Bar figure

        +
        关系量级/比较用于同刻度单一比较避免无单位无基线
        +
        +

        核心代码体积

        KB · 越低越好
        +
        +
        现在
        ~305
        +
        收敛后
        ~72
        +
        +

        观察:约 −76%。该仓库快照静态估算,非运行时基准。

        +
        +
        + +
        +
        05

        Spec block

        +
        关系字段名即契约用于接口/命令本身是证据避免装饰性代码
        +
        +
        +
        agent handoff · shellzero-auth
        +
        # 每篇:产出 → 写库 → 重建索引
        +write $HELM_LIBRARY/slug/index.html
        +helm index
        +
        +
        +
        + +
        +
        06

        Boundary columns

        +
        关系改变 vs 未做用于分开承诺与现实避免每句一卡
        +
        +
        +
        Promise · 已成立

        做到:用机制解释,不是口号。

        +
        Reality · 待落地

        没做:未知项拉回实现层。

        +
        Next · 行动

        下一步:owner / checkpoint。

        +
        +
        +
        + +
        +
        + + diff --git a/skill/design-system.md b/skill/design-system.md new file mode 100644 index 0000000..cf7c8a9 --- /dev/null +++ b/skill/design-system.md @@ -0,0 +1,104 @@ +# Helm design system — cool-grey engineering + +A living standard for Helm artifacts. It is not a skin you drop over prose; it is +an editorial method that turns analysis into something **credible, readable, and +recoverable**. The visual identity is deliberately distinct: a cool neutral +ground, a single cyan signal, sans body with monospace metadata, strict column +rules — an instrument panel, not a landing page. + +> Core rule: visual form must earn its place by making one important relationship +> legible faster than plain text would. If a chart, table, or card only repeats a +> paragraph, delete it. + +## Four principles + +1. **Conclusion first.** The hero states a bounded claim; the body then unfolds + evidence, mechanism, and counter-cases. Avoid using "deep dive" as a + substitute for an actual answer. +2. **Layered evidence.** Fact, measurement, interpretation, and proposal must be + distinguishable in tone and in colour. Never render an inferred claim as a + source fact. +3. **Visualize relationships only.** Reach for a graphic only when hierarchy, + flow, comparison, magnitude, composition, or uncertainty needs to be *seen*. +4. **Progressive enhancement.** The full narrative survives with no JavaScript, + in dark mode, with reduced motion, and at phone width. + +Selection formula: **reader's question → relationship type → evidence form → +smallest sufficient component.** + +## Tokens + +Semantic tokens are the fixed skeleton; a theme may vary surface and accent, never +the meaning. Light and dark are both first-class — every artifact ships both. + +| Token | Light | Dark | Role | +| --- | --- | --- | --- | +| `--paper` | `#f7f8f9` | `#0f1115` | page ground | +| `--raised` | `#ffffff` | `#16191e` | cards, tables, figures | +| `--ink` | `#14171a` | `#e7e9ec` | primary text | +| `--muted` | `#61666d` | `#9198a0` | secondary text | +| `--faint` | `#8b9098` | `#6b727a` | mono labels, metadata | +| `--line` / `--line-strong` | `#e3e5e8` / `#cfd2d6` | `#24272d` / `#31353c` | hairlines, borders | +| `--accent` / `--accent-ink` | `#0e8ba8` / `#0b6f86` | `#28b6d4` / `#5cc9e2` | the single cyan signal | +| `--ok` / `--warn` | `#1f8a5b` / `#b5751a` | `#3fbf85` / `#d6a04a` | verified / caution states | + +One accent only. If a section wants a new colour, it wants a new relationship +instead — express that with a component, not a hue. + +## Typography + +- **Sans body, mono metadata. No serif.** (This is the deliberate break from a + warm editorial-serif look.) Body: `Inter, system-ui, "PingFang SC", …`. + Metadata / labels / metrics / code: `ui-monospace, "SF Mono", "JetBrains Mono"`. +- Mono labels are uppercase with `.08–.14em` tracking. They name sections + (`00 / THESIS`), carry units, and read metric values. +- Heading weight, not colour, sets hierarchy. Default hero weight ~680; keep + contrast high. Never set Chinese text in all-caps. + +## Three content widths + +| Width | Token | Use | +| --- | --- | --- | +| Reading · 44rem | `--read` | continuous prose, the argument | +| Content · 72rem | `--content` | tables, figures, mixed layout | +| Stage · 90rem | `--stage` | the top bar and full-bleed chrome | + +Do not set every block to the same width. Prose stays narrow; evidence widens. + +## Rhythm + +- A faint horizontal grid (`--grid`, 2.5rem) runs behind the page — the + instrument-panel texture. It drops on mobile. +- Sections open with a mono marker: a short cyan tick, the number, the kind. +- Hairline rules (`--line`) separate sections and table rows; borders are 1px, + never heavy. Whitespace does the separating work. + +## Component vocabulary + +Each component binds one relationship. See `components.html` for rendered +specimens. Bind the smallest one that carries the claim; delete any whose +relationship is absent. + +| Component | Relationship | Use when | Avoid | +| --- | --- | --- | --- | +| **Thesis hero** | the bounded conclusion | every artifact opens with one | a slogan with no boundary | +| **Metastrip** | headline metrics at a glance | 3–5 numbers frame the answer | dumping every number here | +| **Evidence ledger** | claims are not equal | verified / interpretation / proposal coexist | a card grid with no state | +| **Bar figure** | magnitude / comparison | one comparison on a shared scale | no unit, no baseline, truncated axis | +| **Spec block** | the field/param names *are* the contract | an interface or command is itself evidence | decorative code | +| **Boundary columns** | changes vs open questions | separating promise from reality | one card per sentence | + +Charts: prefer inline SVG or semantic HTML/CSS over any runtime chart library. +Every figure carries a one-line, readable conclusion and keeps object, unit, +sample bound, and measurement condition attached to the number. + +## Evidence states + +| State | Class | Meaning | +| --- | --- | --- | +| verified | `.state.verified` | supported by a cited source + method | +| interpretation | `.state.interp` | your reading; explicitly not a source quote | +| proposal | `.state.proposal` | not yet landed / not yet verified | + +Use them anywhere a claim's strength matters — the ledger, inline, a metastrip +cell — so a reader never mistakes an inference for a measurement. diff --git a/skill/template.html b/skill/template.html new file mode 100644 index 0000000..612802e --- /dev/null +++ b/skill/template.html @@ -0,0 +1,252 @@ + + + + + +{{TITLE}} + + + + + + +
        +
        + HELMARTIFACT +
        + TYPE·{{TYPE}} + {{DATE}} + +
        +
        +
        + +
        + +
        +
        +
        00Thesis
        +
        + SPECIMEN · replace kicker +

        这里写有边界的核心结论,不是标题党。

        +

        一句展开:读者的问题是什么,你的短答案是什么,适用到哪里。删掉每个 placeholder class。

        +
        +
        +
        关键指标value
        +
        对比value
        +
        样本value
        +
        置信value
        +
        +
        +
        + + +
        +
        +
        01Evidence · 证据账本
        +

        哪些是事实,哪些是解释

        +

        事实、测量、解释、提议在语气和视觉上区分开。

        + + + + + + + +
        ID判断状态最佳证据边界
        C01已被证据支持的断言已验证源 + 方法适用范围
        C02你的架构解释解释推导依据非源文原话
        C03尚未落地的下一步提议缺口未验证
        +
        +
        + + +
        +
        +
        02Data · 量级
        +

        图形只承担一个关系

        +

        统一刻度、单位、基线。删掉与段落重复的图。

        +
        +

        图标题

        单位 · 方向
        +
        +
        基线
        100
        +
        方案
        40
        +
        +

        观察:一句结论。保留对象、单位、样本边界、测量条件。

        +
        +
        +
        + + +
        +
        +
        03Protocol · 契约
        +

        实现细节本身是证据时才用

        +
        +
        label · langnote
        +
        # specimen — replace with real interface / commands
        +field = "value"
        +
        +
        +
        + + +
        +
        +
        04Boundary · 边界与下一步
        +

        它改变什么 · 尚未做什么

        +
        +
        Promise · 已成立

        做到的事:用机制解释,不是口号。

        +
        Reality · 待落地

        没做的事:把未知项拉回实现层。

        +
        Next · 行动

        下一步:owner / checkpoint / 开放问题。

        +
        +
        +
        +
        + +
        +
        + HELM · HDOC/1.1 + SOURCE · agent + RENDER · 无运行时 · 明暗自适应 +
        +
        + + + + diff --git a/styles.css b/styles.css deleted file mode 100644 index bbdaa30..0000000 --- a/styles.css +++ /dev/null @@ -1,286 +0,0 @@ -:root { --ink:#11151a; --muted:#6f767d; --paper:#f3f2ef; --card:#fcfcfb; --line:#dedfdd; --accent:#ed5a3c; --lime:#c4e878; --navy:#202936; --mono:"DM Mono", monospace; --sans:"Manrope", sans-serif; --serif:"Newsreader", Georgia, serif; } -* { box-sizing:border-box; } [hidden] { display:none !important; } body { margin:0; min-width:1120px; color:var(--ink); background:var(--paper); font-family:var(--sans); font-size:13px; } button,input,textarea,select { font:inherit; } button { cursor:pointer; } .app-shell { display:grid; grid-template-columns:232px minmax(640px,1fr) 286px; min-height:100vh; }.app-shell.without-inspector { grid-template-columns:232px minmax(660px,1fr); }.app-shell.without-inspector .inspector { display:none; } -.sidebar { display:flex; min-width:0; flex-direction:column; overflow:hidden; padding:26px 18px 18px; color:#d9e1e8; background:var(--navy); } .brand { display:flex; gap:10px; align-items:flex-start; color:#fff; text-decoration:none; font-size:18px; font-weight:800; letter-spacing:.17em; } .brand small { display:block; margin-top:3px; color:#9da8b4; font-family:var(--mono); font-size:8px; font-weight:500; letter-spacing:.22em; } .brand-icon { display:block; box-sizing:border-box; width:32px; height:32px; flex:0 0 32px; padding:3px; border-radius:8px; background:#f4f3ef; object-fit:contain; } -.navigation { display:grid; gap:4px; margin:48px 0 36px; }.nav-item { display:grid; grid-template-columns:22px 1fr auto; align-items:center; width:100%; padding:11px 10px; border:0; border-radius:5px; color:#adb8c3; background:transparent; text-align:left; font-weight:600; }.nav-item:hover,.nav-item.active { color:#fff; background:rgba(255,255,255,.08); }.nav-item b { color:#a8b5c0; font-family:var(--mono); font-size:10px; font-weight:500; }.nav-glyph { color:var(--lime); font-size:16px; }.side-label { margin:0 10px 10px; color:#758394; font-family:var(--mono); font-size:9px; letter-spacing:.13em; }.collections { display:grid; gap:3px; }.collection { display:flex; align-items:center; gap:8px; width:100%; padding:8px 10px; border:0; color:#b1bcc7; background:transparent; text-align:left; }.collection:hover { color:#fff; }.collection i { width:7px; height:7px; border-radius:50%; background:var(--collection-color); }.collection b { margin-left:auto; color:#718092; font-family:var(--mono); font-size:10px; font-weight:400; }.sidebar-bottom { margin-top:auto; }.local-status { display:flex; gap:9px; align-items:center; padding:13px 10px; border-top:1px solid #384352; }.local-status>span { width:7px; height:7px; border-radius:50%; background:var(--lime); box-shadow:0 0 0 4px rgba(196,232,120,.1); }.local-status b { display:block; font-family:var(--mono); font-size:9px; letter-spacing:.1em; }.local-status small { display:block; margin-top:2px; color:#8190a0; font-size:10px; }.text-button { display:flex; justify-content:space-between; width:100%; padding:10px; border:0; color:#8795a5; background:transparent; font-size:11px; text-align:left; }.text-button span { font-family:var(--mono); } -.workspace { overflow:hidden; padding-bottom:64px; }.topbar { display:flex; justify-content:space-between; align-items:center; height:82px; padding:0 38px; border-bottom:1px solid var(--line); background:rgba(252,252,251,.54); }.crumb { display:flex; gap:10px; align-items:center; color:#7c8287; font-family:var(--mono); font-size:10px; letter-spacing:.08em; }.crumb i { width:3px; height:3px; border-radius:50%; background:#b9bcb9; }.crumb b { color:#242b31; font-weight:500; }.top-actions { display:flex; gap:8px; align-items:center; }.search-box { display:flex; align-items:center; width:228px; height:35px; padding:0 8px; border:1px solid var(--line); border-radius:4px; color:#788087; background:#fff; }.search-box>span { margin-right:6px; font-family:var(--serif); font-size:24px; line-height:0; transform:rotate(-15deg); }.search-box input { width:100%; border:0; outline:0; color:var(--ink); background:transparent; font-size:11px; }.search-box input::placeholder { color:#a1a6aa; }.search-box kbd { padding:2px 4px; border:1px solid #e1e1df; border-radius:3px; color:#969c9f; background:#f8f8f7; font-family:var(--mono); font-size:8px; white-space:nowrap; }.button { display:inline-flex; align-items:center; justify-content:center; gap:7px; min-height:35px; padding:0 12px; border:1px solid transparent; border-radius:4px; font-size:11px; font-weight:700; }.button-primary { color:#fff; background:var(--ink); }.button-primary:hover { background:#313940; }.button-quiet { border-color:var(--line); color:#3c454d; background:#fff; }.button-quiet:hover { border-color:#aab0b2; } -.view { display:none; padding:52px 38px 0; }.view.active-view { display:block; }.view-heading { display:flex; justify-content:space-between; gap:30px; align-items:flex-end; max-width:1040px; margin-bottom:35px; }.view-heading.compact { margin-bottom:40px; }.eyebrow { margin:0 0 10px; color:#78808a; font-family:var(--mono); font-size:9px; font-weight:500; letter-spacing:.13em; }.view h1 { max-width:670px; margin:0; font-family:var(--serif); font-size:43px; font-weight:500; letter-spacing:-.035em; line-height:1; }.lede { max-width:515px; margin:13px 0 0; color:#697177; font-size:13px; line-height:1.65; }.storage-stat { display:flex; gap:10px; align-items:flex-end; padding-bottom:4px; }.storage-stat span { color:var(--accent); font-family:var(--serif); font-size:48px; line-height:.8; }.storage-stat small { color:#717a7f; font-family:var(--mono); font-size:8px; line-height:1.45; letter-spacing:.09em; } -.protocol-card { display:grid; grid-template-columns:48px minmax(230px,1fr) auto; gap:17px; align-items:center; max-width:1040px; min-height:131px; margin-bottom:39px; padding:22px 24px; border:1px solid #26313e; background:var(--navy); color:#eaf1f5; }.protocol-index { align-self:stretch; padding-top:2px; color:var(--lime); font-family:var(--mono); font-size:10px; }.protocol-copy .eyebrow { margin-bottom:4px; color:#a0adba; }.protocol-copy h2 { margin:0; font-family:var(--serif); font-size:25px; font-weight:500; letter-spacing:-.02em; }.protocol-copy p:last-child { max-width:600px; margin:6px 0 0; color:#b7c1c9; font-size:11px; line-height:1.55; }.outline-button { display:flex; align-items:center; gap:18px; padding:9px 0 9px 17px; border:0; border-left:1px solid #53606d; color:#fff; background:transparent; font-size:11px; font-weight:700; }.outline-button span { color:var(--lime); font-size:16px; }.library-toolbar { display:flex; justify-content:space-between; max-width:1040px; padding-bottom:16px; border-bottom:1px solid var(--line); }.filter-tabs { display:flex; gap:5px; }.filter { padding:4px 9px; border:0; border-radius:3px; color:#7a8186; background:transparent; font-family:var(--mono); font-size:10px; }.filter:hover,.filter.active { color:#11161a; background:#e1e2df; }.sort-select { color:#858b90; font-family:var(--mono); font-size:9px; letter-spacing:.04em; }.sort-select select { margin-left:5px; border:0; outline:0; color:#3c4449; background:transparent; font-size:10px; } -.document-grid { display:grid; grid-template-columns:repeat(2,minmax(230px,1fr)); gap:12px; max-width:1040px; padding-top:17px; }.document-card { position:relative; min-height:195px; padding:19px 19px 16px; border:1px solid var(--line); background:var(--card); transition:transform .15s,border-color .15s,box-shadow .15s; }.document-card:hover,.document-card.selected { z-index:1; border-color:#899198; box-shadow:0 8px 19px rgba(34,44,51,.08); transform:translateY(-2px); }.card-top { display:flex; justify-content:space-between; align-items:center; }.type-pill { display:inline-block; padding:4px 6px; border:1px solid #d9dbd8; border-radius:2px; color:#626a70; background:#f7f7f5; font-family:var(--mono); font-size:8px; letter-spacing:.07em; }.contract-pill { border-color:#c7dc9a; color:#4c6720; background:#f5f9ed; }.card-action { display:grid; place-items:center; width:25px; height:25px; border:0; border-radius:50%; color:#747d84; background:transparent; font-size:19px; }.card-action:hover { color:#111; background:#e9e9e7; }.document-card h2 { margin:24px 0 7px; font-family:var(--serif); font-size:23px; font-weight:500; letter-spacing:-.025em; line-height:1.08; }.document-card .summary { display:-webkit-box; overflow:hidden; max-width:94%; margin:0; color:#6b7378; font-size:11px; line-height:1.5; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.card-bottom { position:absolute; right:18px; bottom:15px; left:19px; display:flex; align-items:center; justify-content:space-between; }.mini-tags { display:flex; gap:4px; overflow:hidden; max-width:75%; }.mini-tag { overflow:hidden; max-width:100px; padding:3px 5px; border-radius:2px; color:#5b666c; background:#eceeeb; font-family:var(--mono); font-size:8px; text-overflow:ellipsis; white-space:nowrap; }.card-date { color:#92999d; font-family:var(--mono); font-size:8px; }.empty-state { max-width:1040px; padding:65px 20px; text-align:center; }.empty-mark { color:var(--accent); font-family:var(--serif); font-size:45px; }.empty-state h2 { margin:8px 0; font-family:var(--serif); font-size:28px; font-weight:500; }.empty-state p { color:var(--muted); }.empty-state .button { margin-top:10px; } -.template-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:15px; max-width:1040px; }.template-card { display:flex; flex-direction:column; min-height:310px; padding:20px; border:1px solid var(--line); background:#fcfcfb; }.template-card:nth-child(2) .template-cover { background:#d6e5f0; }.template-card:nth-child(3) .template-cover { background:#f0ddcf; }.template-cover { display:flex; flex:1; align-items:flex-end; min-height:156px; padding:15px; background:#e7e6df; }.cover-lines { width:75%; }.cover-lines i { display:block; height:4px; margin-top:7px; background:rgba(24,32,39,.75); }.cover-lines i:nth-child(2) { width:67%; }.cover-lines i:nth-child(3) { width:83%; }.template-card h2 { margin:18px 0 6px; font-family:var(--serif); font-size:25px; font-weight:500; letter-spacing:-.025em; }.template-card p { margin:0; color:var(--muted); font-size:11px; line-height:1.6; }.template-card button { align-self:flex-start; margin-top:15px; } -.authoring-kit { display:grid; grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr); gap:38px; align-items:end; max-width:1040px; margin:0 0 24px; padding:26px 28px; color:#edf2f4; background:linear-gradient(120deg,#202936,#2b3944); box-shadow:var(--shadow); }.authoring-kit h2 { max-width:660px; margin:7px 0 10px; font-family:var(--serif); font-size:29px; font-weight:500; letter-spacing:-.025em; }.authoring-kit p:last-child { max-width:690px; margin:0; color:#b9c4ca; font-size:11px; line-height:1.65; }.authoring-actions { display:grid; gap:13px; }.authoring-actions code { display:block; padding:12px; overflow-wrap:anywhere; border:1px solid rgba(219,233,238,.18); color:#cfe0e6; background:rgba(0,0,0,.12); font:9px/1.6 var(--mono); }.authoring-actions .button { justify-content:space-between; color:#172127; background:var(--lime); text-decoration:none; } -.contract-sheet { max-width:1040px; border:1px solid var(--line); background:#fcfcfb; }.contract-intro { display:grid; grid-template-columns:155px 1fr; gap:0 28px; padding:29px 31px 27px; border-bottom:1px solid var(--line); }.contract-intro span { color:var(--muted); font-family:var(--mono); font-size:9px; letter-spacing:.1em; }.contract-intro b { font-family:var(--serif); font-size:27px; font-weight:500; }.contract-intro p { grid-column:2; max-width:580px; margin:6px 0 0; color:#616a70; font-size:12px; line-height:1.65; }.contract-grid { display:grid; grid-template-columns:repeat(2,1fr); }.contract-grid section { min-height:184px; padding:24px 31px; border-bottom:1px solid var(--line); }.contract-grid section:nth-child(odd) { border-right:1px solid var(--line); }.rule-number { margin:0 0 18px; color:var(--accent); font-family:var(--mono); font-size:10px; }.contract-grid h2 { margin:0 0 6px; font-family:var(--serif); font-size:24px; font-weight:500; }.contract-grid p:last-child { margin:0; color:#697177; font-size:11px; line-height:1.65; }.contract-grid code { color:#323f4e; font-family:var(--mono); font-size:10px; }.manifest-example { display:grid; grid-template-columns:.9fr 1.1fr; background:#f3f5f3; }.manifest-example>div { padding:29px 31px; }.manifest-example>div p:nth-child(2) { color:#626c71; font-size:11px; line-height:1.65; }.manifest-example a { color:#263d50; font-family:var(--mono); font-size:10px; }.manifest-example pre { overflow:auto; margin:0; padding:24px; color:#dce5ea; background:#202936; font-family:var(--mono); font-size:9px; line-height:1.55; } -.inspector { height:100vh; padding:82px 20px 20px; overflow-y:auto; border-left:1px solid var(--line); background:#f9f9f7; }.inspector-empty { display:grid; place-items:center; height:calc(100vh - 102px); color:#9da3a5; text-align:center; }.inspector-empty span { font-family:var(--serif); font-size:45px; }.inspector-empty p { margin-top:10px; font-size:11px; line-height:1.6; }.inspector-top { display:flex; justify-content:space-between; }.inspector-top button,.modal-close { width:25px; height:25px; border:0; color:#727b80; background:transparent; font-size:20px; }.inspector-content h2 { margin:18px 0 7px; font-family:var(--serif); font-size:30px; font-weight:500; letter-spacing:-.03em; line-height:1.03; }.inspector-summary { margin:0 0 16px; color:#687177; font-size:11px; line-height:1.6; }.tag-list { display:flex; flex-wrap:wrap; gap:4px; padding-bottom:20px; }.tag-list span { padding:4px 6px; border:1px solid #dbddda; border-radius:2px; color:#657077; font-family:var(--mono); font-size:8px; }.metadata { margin:0; border-top:1px solid var(--line); }.metadata>div { display:flex; justify-content:space-between; padding:11px 0; border-bottom:1px solid var(--line); }.metadata dt { color:#899095; font-family:var(--mono); font-size:8px; letter-spacing:.08em; }.metadata dd { max-width:156px; margin:0; overflow:hidden; color:#414a50; font-size:10px; text-align:right; text-overflow:ellipsis; white-space:nowrap; }.inspector-actions { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin:18px 0 6px; }.inspector-actions .button { padding:0 6px; }.share-button { width:100%; margin:0 0 8px; }.share-record { margin:0 0 8px; padding:10px; border:1px solid #cdd9d2; background:#eef4ef; }.share-record span { display:block; margin-bottom:4px; color:#607168; font:8px var(--mono); letter-spacing:.08em; }.share-record a { display:block; overflow:hidden; color:#315d4b; font:9px var(--mono); text-overflow:ellipsis; white-space:nowrap; }.catalog-button { width:100%; margin:0 0 18px; }.inspector-divider { height:1px; background:var(--line); }.quality-row { display:flex; gap:9px; align-items:flex-start; margin:16px 0; }.quality-dot { display:block; width:7px; height:7px; margin-top:4px; border-radius:50%; background:#c5cacf; }.quality-dot.good { background:#81a748; }.quality-row b,.quality-row small,.health-summary b,.health-summary small { display:block; }.quality-row b,.health-summary b { color:#515a60; font-size:10px; }.quality-row small,.health-summary small { margin-top:3px; color:#8b9296; font-size:9px; line-height:1.4; }.health-summary { display:flex; gap:10px; align-items:center; margin:0 0 10px; padding:10px; border:1px solid #dde0dc; background:#f5f6f3; }.health-summary strong { color:#405763; font-family:var(--serif); font-size:28px; font-weight:500; line-height:1; }.health-issues { display:grid; gap:5px; margin:0 0 12px; padding:0; list-style:none; }.health-issues li { display:flex; gap:6px; align-items:flex-start; color:#7b6654; font-size:9px; line-height:1.45; }.health-issues li::before { content:'·'; color:var(--accent); font-size:15px; line-height:8px; }.health-issues li.issue-error { color:#a0564b; }.repair-button { width:100%; margin:0 0 17px; }.danger-button { padding:0; border:0; color:#ad634e; background:transparent; font-size:10px; text-decoration:underline; text-underline-offset:3px; } -.modal,.shortcuts-modal { width:min(510px,calc(100vw - 32px)); padding:0; border:1px solid #cdd0ce; border-radius:5px; color:var(--ink); background:#fcfcfb; box-shadow:0 18px 60px rgba(24,31,37,.25); }.modal::backdrop,.shortcuts-modal::backdrop { background:rgba(17,22,27,.5); backdrop-filter:blur(3px); }.reader-modal::backdrop { background:rgba(17,22,27,.46); }.modal form,.archive-dialog-inner { padding:25px; }.modal-heading { display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:22px; }.modal-heading h2 { margin:0; font-family:var(--serif); font-size:27px; font-weight:500; }.modal label { display:block; margin-top:14px; color:#687177; font-family:var(--mono); font-size:9px; letter-spacing:.08em; }.modal input,.modal textarea { display:block; width:100%; margin-top:6px; padding:10px; border:1px solid var(--line); border-radius:3px; outline:none; color:var(--ink); background:#fff; font-family:var(--sans); font-size:12px; letter-spacing:0; }.modal input:focus,.modal textarea:focus { border-color:#6f7d88; }.modal textarea { height:76px; resize:vertical; }.modal-actions { display:flex; justify-content:flex-end; gap:7px; margin-top:21px; }.archive-copy { margin:0; color:#667076; font-size:11px; line-height:1.65; }.archive-summary { display:flex; gap:11px; align-items:center; margin-top:20px; padding:13px; border:1px solid #d9ddd8; background:#f5f7f3; }.archive-summary>span { color:var(--accent); font-family:var(--serif); font-size:35px; line-height:1; }.archive-summary b,.archive-summary small { display:block; }.archive-summary b { color:#4a5358; font-family:var(--mono); font-size:9px; letter-spacing:.07em; }.archive-summary small { margin-top:4px; color:#7e878c; font-size:9px; }.folder-sync { margin-top:10px; padding:13px; border:1px solid #d9ddd8; background:#f8f8f6; }.folder-sync-copy { display:flex; gap:9px; align-items:flex-start; }.folder-status-dot { display:block; width:7px; height:7px; margin-top:4px; border-radius:50%; background:#b6bdc2; }.folder-status-dot.connected { background:#81a748; }.folder-sync b,.folder-sync small { display:block; }.folder-sync b { color:#4a5358; font-family:var(--mono); font-size:9px; letter-spacing:.07em; }.folder-sync small { margin-top:4px; color:#7e878c; font-size:9px; line-height:1.45; }.folder-sync-actions { display:flex; flex-wrap:wrap; gap:6px; align-items:center; margin-top:12px; }.folder-sync-actions .button { min-height:31px; }.folder-recover { width:auto; margin-left:auto; padding:7px 0; color:#5e6c72; text-decoration:underline; text-underline-offset:3px; }.reader-modal { width:calc(100vw - 70px); height:calc(100vh - 50px); padding:0; border:0; border-radius:4px; overflow:hidden; background:#fff; box-shadow:0 20px 80px rgba(0,0,0,.35); }.reader-toolbar { display:flex; justify-content:space-between; align-items:center; height:51px; padding:0 15px 0 20px; border-bottom:1px solid var(--line); background:#fafaf8; }.reader-toolbar .eyebrow { display:inline; margin:0 9px 0 0; font-size:8px; }.reader-toolbar b { font-family:var(--serif); font-size:17px; font-weight:500; }.reader-toolbar>div:last-child { display:flex; gap:9px; align-items:center; }.reader-close { display:grid; place-items:center; width:27px; height:27px; border:0; border-radius:50%; color:#596269; background:#e9eae8; font-size:19px; }.reader-stage { position:relative; height:calc(100% - 51px); background:#fff; }.reader-modal iframe { display:block; width:100%; height:100%; border:0; opacity:1; transition:opacity .12s ease; }.reader-modal.is-loading iframe { opacity:0; }.reader-loading { position:absolute; inset:0; z-index:1; display:flex; align-items:center; justify-content:center; gap:12px; color:#3d474e; background:#f6f7f4; opacity:0; pointer-events:none; transition:opacity .12s ease; }.reader-modal.is-loading .reader-loading { opacity:1; }.reader-loading>span:last-child { display:grid; gap:3px; }.reader-loading b { font-size:12px; }.reader-loading small { color:#7b8489; font-size:10px; }.reader-spinner { width:18px; height:18px; flex:0 0 18px; border:2px solid #d0d5d2; border-top-color:var(--accent); border-radius:50%; animation:reader-spin .75s linear infinite; }@keyframes reader-spin { to { transform:rotate(360deg); } }.shortcuts-modal>div { padding:23px 26px; }.shortcuts-modal p { display:flex; align-items:center; gap:15px; margin:10px 0; color:#596167; font-size:11px; }.shortcuts-modal kbd { width:55px; padding:5px; border:1px solid #d9ddda; border-radius:3px; color:#232a2f; background:#f5f6f3; font-family:var(--mono); text-align:center; }.toast { position:fixed; bottom:23px; left:50%; z-index:10; padding:10px 15px; border:1px solid #40505d; border-radius:4px; color:#eef5f3; background:#202936; box-shadow:0 6px 25px rgba(0,0,0,.15); font-size:11px; opacity:0; pointer-events:none; transform:translate(-50%,14px); transition:.22s; }.toast.show { opacity:1; transform:translate(-50%,0); } -.modal input,.modal textarea,.modal select { display:block; width:100%; margin-top:6px; padding:10px; border:1px solid var(--line); border-radius:3px; outline:none; color:var(--ink); background:#fff; font-family:var(--sans); font-size:12px; letter-spacing:0; } -.modal input:focus,.modal textarea:focus,.modal select:focus { border-color:#6f7d88; } -.modal-copy { margin:-5px 0 14px; color:#687177; font-size:11px; line-height:1.6; } -.catalog-button { width:100%; margin:0 0 18px; } -.conflict-list { display:grid; gap:7px; max-height:180px; margin:0; padding:0; overflow:auto; list-style:none; } -.conflict-list li { padding:9px; border:1px solid #d9ddd8; background:#f7f8f5; } -.conflict-list b,.conflict-list small { display:block; } -.conflict-list b { color:#354048; font-size:11px; } -.conflict-list small { margin-top:3px; color:#748087; font-family:var(--mono); font-size:8px; line-height:1.45; } -.folder-recover:disabled { color:#a3a8aa; cursor:not-allowed; text-decoration:none; } -.agent-inbox-button b { display:inline-grid; min-width:15px; height:15px; margin-left:2px; place-items:center; border-radius:999px; color:#fff; background:#be4c36; font:9px var(--mono); } -.agent-inbox-inner { padding:23px 26px; } -.agent-inbox-status { display:flex; align-items:center; gap:9px; margin:2px 0 15px; padding:10px; border:1px solid #d9ddd8; background:#f7f8f5; } -.agent-inbox-status>div { min-width:0; } -.agent-inbox-status b,.agent-inbox-status small { display:block; } -.agent-inbox-status b { color:#354048; font-size:11px; } -.agent-inbox-status small { margin-top:3px; color:#748087; font-size:9px; line-height:1.45; } -.agent-inbox-list { margin-bottom:18px; } -.agent-inbox-choice { display:flex; gap:9px; align-items:flex-start; margin:0; color:inherit; font-family:var(--sans); font-size:inherit; letter-spacing:0; text-transform:none; cursor:pointer; } -.agent-inbox-choice input { width:15px; height:15px; margin:2px 0 0; accent-color:var(--accent); } -.agent-inbox-choice span { min-width:0; } -.project-review-label { margin-top:18px; } -.project-collection { min-width:0; align-items:center; overflow:hidden; } -.project-collection>i,.project-collection>b { flex:0 0 auto; } -.project-collection span { min-width:0; flex:1 1 auto; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.collection.is-active { color:#fff; background:rgba(255,255,255,.09); box-shadow:inset 2px 0 0 var(--collection-color); } -.card-project { margin:12px 0 0; overflow:hidden; color:#7b878c; font:8px var(--mono); letter-spacing:.09em; text-overflow:ellipsis; text-transform:uppercase; white-space:nowrap; } -.document-card .card-project + h2 { margin-top:5px; } -.inspector-badges { display:flex; gap:5px; align-items:center; } -.workflow-badge { display:inline-flex; min-height:20px; align-items:center; padding:0 6px; border:1px solid #b9c8ce; border-radius:2px; color:#49616d; background:#edf3f5; font:8px var(--mono); letter-spacing:.08em; } -.workflow-badge[data-status="reviewed"],.reader-revision-state[data-status="reviewed"] { border-color:#d7c28f; color:#735b25; background:#f7f0df; } -.workflow-badge[data-status="published"],.reader-revision-state[data-status="published"] { border-color:#bdd0b3; color:#426238; background:#edf4e9; } -.revision-state { margin:0 0 14px; padding:11px; border:1px solid #d9ddda; background:#f5f7f4; } -.revision-state-heading { display:flex; justify-content:space-between; gap:8px; align-items:center; } -.revision-state-heading span,.lineage-record>span { color:#849096; font:8px var(--mono); letter-spacing:.08em; } -.revision-state-heading strong { color:#3d4950; font:10px var(--mono); font-weight:500; } -.revision-state>p { margin:7px 0 10px; color:#67747a; font-size:9px; line-height:1.45; } -.revision-state>p.is-behind { padding-left:7px; border-left:2px solid var(--accent); color:#915642; } -.revision-state-actions { display:flex; justify-content:space-between; gap:8px; padding-top:8px; border-top:1px solid #dde1dd; } -.revision-state-actions .text-button,.lineage-record .text-button { width:auto; padding:0; color:#52636b; text-align:left; } -.stable-address small { display:block; margin-top:6px; color:#718078; font-size:8px; line-height:1.45; } -.revoke-channel-button { width:100%; margin:-2px 0 9px; padding:4px 0; color:#9a594b; text-align:left; } -.lineage-record { margin:0 0 8px; padding:10px; border:1px solid #d8deda; background:#f7f8f5; } -.lineage-record p { margin:6px 0 8px; color:#667178; font-size:9px; line-height:1.45; } -.lineage-record a { color:#315d4b; } -.fork-handoff { margin:0 0 8px; padding:10px; border:1px solid #d8d3c4; background:#faf7ee; } -.fork-handoff>span { color:#8b744d; font:8px var(--mono); letter-spacing:.08em; } -.fork-handoff p { margin:6px 0 9px; color:#6d6659; font-size:9px; line-height:1.5; } -.fork-handoff code { color:#5b482a; font:8px var(--mono); overflow-wrap:anywhere; } -.fork-handoff .button { width:100%; min-height:31px; } -.button:disabled { border-color:#d9dcda; color:#92999c; background:#eef0ed; box-shadow:none; cursor:not-allowed; opacity:.82; transform:none; } -.fork-button { width:100%; margin:0 0 8px; } -.reader-identity { display:flex; min-width:0; align-items:center; } -.reader-identity b { max-width:min(38vw,520px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.reader-revision-state { flex:0 0 auto; margin-left:9px; padding:3px 6px; border:1px solid #b9c8ce; border-radius:2px; color:#49616d; background:#edf3f5; font:8px var(--mono); letter-spacing:.05em; text-transform:uppercase; } -.artifact-workspace-modal { width:calc(100vw - 56px); height:calc(100vh - 42px); max-width:1480px; padding:0; overflow:hidden; border:0; border-radius:5px; color:var(--ink); background:#f9faf8; box-shadow:0 20px 80px rgba(0,0,0,.36); } -.artifact-workspace-modal::backdrop { background:rgba(17,22,27,.52); backdrop-filter:blur(3px); } -.artifact-workspace-shell { display:grid; grid-template-rows:67px minmax(0,1fr); height:100%; } -.artifact-workspace-header { display:grid; grid-template-columns:minmax(220px,1fr) minmax(220px,390px) auto; gap:24px; align-items:center; padding:0 18px 0 23px; border-bottom:1px solid var(--line); background:#fcfcfa; } -.artifact-workspace-header .eyebrow { margin:0 0 3px; font-size:8px; } -.artifact-workspace-header h2 { max-width:580px; margin:0; overflow:hidden; font-family:var(--serif); font-size:21px; font-weight:500; text-overflow:ellipsis; white-space:nowrap; } -.artifact-workspace-address { min-width:0; padding-left:17px; border-left:1px solid var(--line); } -.artifact-workspace-address span,.revision-rail-heading span { display:block; color:#899296; font:8px var(--mono); letter-spacing:.08em; } -.artifact-workspace-address a { display:block; margin-top:4px; overflow:hidden; color:#3c6655; font:9px var(--mono); text-overflow:ellipsis; white-space:nowrap; } -.artifact-workspace-address a[aria-disabled="true"] { color:#8c9496; cursor:default; text-decoration:none; } -.artifact-workspace { display:grid; grid-template-columns:245px minmax(0,1fr); min-height:0; } -.revision-rail { display:flex; min-height:0; flex-direction:column; padding:20px 16px 16px; border-right:1px solid var(--line); background:#f1f3f0; } -.revision-rail-heading { display:flex; justify-content:space-between; align-items:center; padding:0 3px 12px; border-bottom:1px solid #d7dcd7; } -.revision-rail-heading strong { color:#49575e; font:19px var(--serif); font-weight:500; } -.revision-timeline { min-height:0; flex:1; margin:0; padding:13px 0; overflow-y:auto; list-style:none; } -.revision-timeline:empty::before { display:block; padding:12px 9px; color:#899296; content:"Revision history will appear here."; font-size:9px; line-height:1.5; } -.revision-timeline li { position:relative; margin-left:9px; padding:0 0 18px 18px; border-left:1px solid #cbd2ce; } -.revision-timeline li::before { position:absolute; top:3px; left:-4px; width:7px; height:7px; border-radius:50%; background:#98a7ad; content:""; } -.revision-timeline li.is-current::before { background:var(--accent); box-shadow:0 0 0 3px rgba(203,84,58,.12); } -.revision-timeline li.is-selected button { color:var(--ink); } -.revision-timeline li.is-selected b { color:var(--accent); } -.revision-timeline button { width:100%; padding:0; border:0; color:#5e696e; background:transparent; text-align:left; } -.revision-timeline b,.revision-timeline small { display:block; } -.revision-timeline b { color:#334149; font:10px var(--mono); font-weight:500; } -.revision-timeline small { margin-top:4px; color:#7d898e; font-size:8px; line-height:1.4; } -.revision-rail>.button { width:100%; } -.compare-workspace { min-width:0; padding:19px 21px 21px; overflow:hidden; } -.compare-toolbar { display:flex; justify-content:space-between; gap:22px; align-items:flex-end; } -.compare-toolbar .eyebrow { margin:0 0 5px; font-size:8px; } -.compare-toolbar strong { color:#354149; font:18px var(--serif); font-weight:500; } -.compare-selectors { display:flex; gap:8px; align-items:flex-end; } -.compare-selectors>span { padding-bottom:7px; color:var(--accent); } -.compare-selectors label { display:grid; gap:4px; color:#879095; font:7px var(--mono); letter-spacing:.08em; } -.compare-selectors select { min-width:116px; height:28px; padding:0 24px 0 8px; border:1px solid #d2d7d3; border-radius:3px; color:#435159; background:#fff; font:9px var(--mono); } -.compare-note { margin:10px 0 12px; color:#7c878c; font-size:9px; line-height:1.45; } -.visual-diff { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; height:calc(100% - 79px); min-height:300px; } -.visual-diff section { display:grid; grid-template-rows:32px minmax(0,1fr); min-width:0; overflow:hidden; border:1px solid #d5dad6; background:#fff; } -.visual-diff section>header { display:flex; align-items:center; padding:0 10px; border-bottom:1px solid #dde1de; color:#657178; background:#f4f6f3; font:8px var(--mono); letter-spacing:.05em; text-transform:uppercase; } -.visual-diff iframe { width:100%; height:100%; border:0; background:#fff; } -.compare-workspace.is-single-revision .compare-selectors { display:none; } -.compare-workspace.is-single-revision .visual-diff { grid-template-columns:minmax(0,1fr); } -.compare-workspace.is-single-revision .visual-diff section:first-child { display:none; } -@media (max-width:1250px) { .app-shell { grid-template-columns:206px minmax(570px,1fr) 252px; }.sidebar { padding-left:14px; padding-right:14px; }.topbar,.view { padding-left:27px; padding-right:27px; }.top-actions .button-quiet { display:none; }.protocol-card { grid-template-columns:34px 1fr; }.outline-button { grid-column:2; justify-self:start; padding-left:0; border-left:0; }.template-grid { grid-template-columns:repeat(2,1fr); }.template-card:last-child { grid-column:span 2; }.inspector { padding-left:16px; padding-right:16px; } } - -/* A gallery-style workbench layer: semantic controls, visible state, and calm document previews. */ -:root { --surface:#fcfcfb; --surface-raised:#ffffff; --surface-muted:#f7f8f5; --shadow:0 15px 35px rgba(27,36,41,.07); } -body { min-width:0; } -button,input,textarea,select { transition:border-color .16s ease,background-color .16s ease,color .16s ease,box-shadow .16s ease,transform .16s ease; } -button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible { outline:2px solid #789ebe; outline-offset:3px; } -.top-actions { gap:7px; } -.appearance-control { display:inline-flex; align-items:center; padding:2px; border:1px solid var(--line); border-radius:5px; background:var(--surface-raised); } -.appearance-control button { min-height:29px; padding:0 7px; border:0; border-radius:3px; color:#778087; background:transparent; font:8px var(--mono); letter-spacing:.03em; } -.appearance-control button:hover { color:var(--ink); background:#eef0ed; } -.appearance-control button[aria-pressed="true"] { color:#162029; background:#dfe8e5; box-shadow:inset 0 0 0 1px rgba(38,57,68,.08); } -.protocol-card { grid-template-columns:34px minmax(250px,1fr) 250px; min-height:158px; padding:21px 24px; background:linear-gradient(120deg,#202936 0%,#263340 100%); box-shadow:var(--shadow); } -.protocol-copy .eyebrow { display:flex; gap:11px; align-items:center; } -.quiet-status { display:inline-flex; align-items:center; gap:5px; color:#b4c7d4; font-size:8px; letter-spacing:.08em; } -.quiet-status i { width:5px; height:5px; border-radius:50%; background:var(--lime); box-shadow:0 0 0 3px rgba(196,232,120,.1); } -.protocol-rail { display:flex; flex-direction:column; align-items:stretch; justify-content:space-between; min-height:113px; } -.protocol-stats { display:grid; grid-template-columns:1fr 1fr; border-bottom:1px solid rgba(198,214,224,.18); } -.protocol-stats>div { padding:0 0 11px; } -.protocol-stats>div+div { padding-left:13px; border-left:1px solid rgba(198,214,224,.18); } -.protocol-stats strong,.protocol-stats span { display:block; } -.protocol-stats strong { color:#f6f8f5; font-family:var(--serif); font-size:28px; font-weight:500; letter-spacing:-.04em; line-height:.9; } -.protocol-stats span { margin-top:5px; color:#aebdc8; font:8px var(--mono); letter-spacing:.08em; text-transform:uppercase; } -.protocol-rail .outline-button { justify-content:space-between; border-left:0; padding:9px 0 0; } -.library-toolbar { align-items:flex-end; gap:20px; padding:0 0 17px; } -.filter-zone { min-width:0; } -.toolbar-overline { display:flex; gap:10px; align-items:center; margin-bottom:9px; color:#8b9397; font:8px var(--mono); letter-spacing:.1em; text-transform:uppercase; } -.toolbar-overline>span:last-child { color:#788187; letter-spacing:.03em; text-transform:none; } -.toolbar-overline strong { color:#303a40; font-weight:500; } -.toolbar-overline i { color:#8d989e; font-style:normal; } -.toolbar-actions { display:flex; gap:13px; align-items:center; padding-bottom:1px; } -.clear-filter-button { padding:4px 0; border:0; color:#637078; background:transparent; font:9px var(--mono); text-decoration:underline; text-decoration-color:#b3b9b8; text-underline-offset:3px; } -.clear-filter-button:hover { color:#14212a; } -.clear-filter-button span { color:var(--accent); font-size:12px; vertical-align:-1px; } -.filter-tabs { gap:3px; flex-wrap:wrap; } -.filter { padding:5px 8px; border:1px solid transparent; } -.filter:hover,.filter.active { border-color:#d9ddda; background:#eef0ed; } -.document-grid { gap:15px; padding-top:20px; } -.document-card { min-height:258px; overflow:hidden; padding:16px 17px 15px; border-color:#d9dcd9; background:linear-gradient(145deg,var(--surface-raised),var(--surface)); box-shadow:0 1px 0 rgba(28,37,42,.02); } -.document-card:hover,.document-card.selected { border-color:#7c8d94; box-shadow:0 14px 30px rgba(30,44,51,.1); transform:translateY(-3px); } -.card-stage { position:relative; height:56px; margin:13px 0 0; overflow:hidden; border:1px solid rgba(45,61,68,.11); background:#e6e8e3; } -.document-card[data-type="report"] .card-stage { background:#dce7ec; } -.document-card[data-type="brief"] .card-stage { background:#e9dfc8; } -.document-card[data-type="reference"] .card-stage { background:#e7e8df; } -.document-card[data-type="dashboard"] .card-stage { background:#d9e6dc; } -.document-card[data-type="note"] .card-stage { background:#eee0d8; } -.card-stage::after { position:absolute; inset:0; content:""; background:repeating-linear-gradient(90deg,rgba(27,41,48,.07) 0 1px,transparent 1px 34px); } -.card-stage-kicker { position:absolute; top:9px; left:10px; z-index:1; color:#52636b; font:8px var(--mono); letter-spacing:.09em; text-transform:uppercase; } -.card-stage-mark { position:absolute; top:7px; right:10px; z-index:1; display:grid; width:17px; height:17px; place-items:center; border-radius:50%; color:#2d4c58; background:rgba(255,255,255,.55); font-size:10px; } -.card-stage-lines { position:absolute; right:10px; bottom:10px; left:10px; z-index:1; } -.card-stage-lines i { display:block; width:67%; height:3px; margin-top:4px; background:rgba(27,42,48,.64); } -.card-stage-lines i:nth-child(2) { width:43%; opacity:.72; }.card-stage-lines i:nth-child(3) { width:81%; opacity:.42; } -.document-card h2 { margin:15px 0 6px; font-size:24px; } -.document-card .summary { max-width:97%; } -.card-bottom { right:17px; bottom:14px; left:17px; } -.type-pill { background:var(--surface-muted); } -.template-grid { gap:16px; } -.template-card { min-height:368px; padding:17px; overflow:hidden; border-color:#d9dcd9; background:linear-gradient(145deg,var(--surface-raised),var(--surface)); box-shadow:0 1px 0 rgba(28,37,42,.02); transition:transform .16s ease,box-shadow .16s ease,border-color .16s ease; } -.template-card:hover { border-color:#89969b; box-shadow:var(--shadow); transform:translateY(-3px); } -.template-card-meta { display:flex; justify-content:space-between; align-items:center; margin-bottom:11px; color:#79848a; font:8px var(--mono); letter-spacing:.09em; } -.template-card-meta b { padding:3px 5px; border:1px solid rgba(38,52,60,.14); color:#516068; font-weight:500; } -.template-card:nth-child(2) .template-cover,.template-card:nth-child(3) .template-cover,.template-cover { min-height:176px; padding:11px; background:transparent; } -.template-sheet { position:relative; display:flex; flex:1; flex-direction:column; justify-content:flex-end; padding:14px; overflow:hidden; border:1px solid rgba(33,47,55,.16); background:var(--template-accent); box-shadow:inset 0 0 0 6px rgba(255,255,255,.16); } -.template-sheet>span { position:absolute; top:12px; left:13px; color:rgba(31,45,52,.72); font:8px var(--mono); letter-spacing:.07em; text-transform:uppercase; } -.template-visual { position:relative; display:grid; grid-template-columns:repeat(3,1fr); gap:8px; align-items:end; min-height:86px; margin:25px 2px 0; }.template-visual::before { position:absolute; top:42px; right:12%; left:12%; height:1px; content:""; background:rgba(36,52,60,.42); }.template-visual span { position:relative; z-index:1; display:grid; gap:7px; min-height:53px; padding:8px; border:1px solid rgba(35,50,57,.32); color:#3e4e55; background:rgba(255,255,255,.42); font:8px var(--mono); letter-spacing:.04em; text-transform:uppercase; }.template-visual span::after { position:absolute; top:23px; right:-8px; width:7px; height:7px; border-top:1px solid rgba(36,52,60,.48); border-right:1px solid rgba(36,52,60,.48); content:""; transform:rotate(45deg); }.template-visual span:last-child::after { display:none; }.template-visual b { color:#465c64; font-size:8px; font-weight:500; }.template-visual-brief span:nth-child(2) { border:2px solid #407a8a; background:rgba(223,238,240,.82); }.template-visual-reference span:nth-child(2) { border-color:#a96a34; background:rgba(250,241,230,.72); }.template-sheet-rule { width:39px; height:2px; margin:13px 0 0; background:rgba(29,45,51,.72); } -.template-card h2 { margin:18px 0 6px; } -.template-footer { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:auto; padding-top:16px; } -.template-footer .mini-tags { max-width:52%; }.template-footer .button { flex:0 0 auto; margin:0; }.template-footer .button span { color:var(--accent); font-size:14px; } -.empty-state { margin-top:20px; padding:57px 20px 62px; border:1px dashed #cbd0cb; background:rgba(252,252,251,.45); } -.empty-state-code { display:block; color:#859096; font:8px var(--mono); letter-spacing:.1em; } -.empty-mark { display:block; margin:9px 0 -1px; } - -html[data-theme="dark"] { color-scheme:dark; --ink:#e8edef; --muted:#a4afb5; --paper:#121719; --card:#1b2225; --line:#344044; --accent:#ff795b; --lime:#cde887; --navy:#171e27; --surface:#1b2225; --surface-raised:#20292c; --surface-muted:#293337; --shadow:0 16px 36px rgba(0,0,0,.23); } -html[data-theme="dark"] .topbar,html[data-theme="dark"] .inspector { background:#182023; border-color:var(--line); } -html[data-theme="dark"] .search-box,html[data-theme="dark"] .button-quiet,html[data-theme="dark"] .appearance-control,html[data-theme="dark"] .modal,html[data-theme="dark"] .shortcuts-modal,html[data-theme="dark"] .reader-toolbar { border-color:var(--line); color:#dce3e5; background:#20292c; } -html[data-theme="dark"] .search-box input,html[data-theme="dark"] .modal input,html[data-theme="dark"] .modal textarea,html[data-theme="dark"] .modal select { color:#edf1f2; background:#182023; } -html[data-theme="dark"] .appearance-control button { color:#aab5ba; } html[data-theme="dark"] .appearance-control button:hover { color:#eef3f4; background:#2c373b; } html[data-theme="dark"] .appearance-control button[aria-pressed="true"] { color:#edf3ed; background:#365049; } -html[data-theme="dark"] .crumb b,html[data-theme="dark"] .toolbar-overline strong,html[data-theme="dark"] .filter:hover,html[data-theme="dark"] .filter.active,html[data-theme="dark"] .sort-select select { color:#edf1f2; } -html[data-theme="dark"] .filter:hover,html[data-theme="dark"] .filter.active,html[data-theme="dark"] .type-pill,html[data-theme="dark"] .mini-tag,html[data-theme="dark"] .health-summary,html[data-theme="dark"] .archive-summary,html[data-theme="dark"] .folder-sync,html[data-theme="dark"] .agent-inbox-status,html[data-theme="dark"] .conflict-list li { border-color:#3b474a; background:#273135; } -html[data-theme="dark"] .document-card .summary,html[data-theme="dark"] .lede,html[data-theme="dark"] .template-card p,html[data-theme="dark"] .contract-grid p:last-child,html[data-theme="dark"] .contract-intro p,html[data-theme="dark"] .manifest-example>div p:nth-child(2),html[data-theme="dark"] .inspector-summary,html[data-theme="dark"] .modal-copy,html[data-theme="dark"] .archive-copy { color:#aab5b9; } -html[data-theme="dark"] .template-sheet { box-shadow:inset 0 0 0 6px rgba(255,255,255,.08); } html[data-theme="dark"] .manifest-example { background:#20292c; } html[data-theme="dark"] .manifest-example pre { background:#11171c; } -html[data-theme="dark"] .card-stage { border-color:rgba(219,232,235,.12); filter:saturate(.76) brightness(.78); } html[data-theme="dark"] .clear-filter-button { color:#aab6ba; } -html[data-theme="dark"] .reader-loading { color:#dce3e5; background:#182023; } html[data-theme="dark"] .reader-loading small { color:#98a5aa; } -html[data-theme="dark"] .revision-state,html[data-theme="dark"] .lineage-record,html[data-theme="dark"] .revision-rail { border-color:#3b474a; background:#20292c; } -html[data-theme="dark"] .fork-handoff { border-color:#5e543d; background:#29261f; } -html[data-theme="dark"] .fork-handoff p { color:#b8ad98; } -html[data-theme="dark"] .fork-handoff code,html[data-theme="dark"] .fork-handoff>span { color:#dcc28d; } -html[data-theme="dark"] .revision-state-heading strong,html[data-theme="dark"] .compare-toolbar strong,html[data-theme="dark"] .artifact-workspace-header h2,html[data-theme="dark"] .revision-timeline b { color:#e1e8e9; } -html[data-theme="dark"] .revision-state>p,html[data-theme="dark"] .lineage-record p,html[data-theme="dark"] .compare-note,html[data-theme="dark"] .revision-timeline small { color:#9eaaae; } -html[data-theme="dark"] .revision-state-actions,html[data-theme="dark"] .revision-rail-heading { border-color:#3b474a; } -html[data-theme="dark"] .workflow-badge,html[data-theme="dark"] .reader-revision-state { border-color:#49606a; color:#bdd0d7; background:#293a41; } -html[data-theme="dark"] .workflow-badge[data-status="reviewed"],html[data-theme="dark"] .reader-revision-state[data-status="reviewed"] { border-color:#705f39; color:#e1ca91; background:#3b3425; } -html[data-theme="dark"] .workflow-badge[data-status="published"],html[data-theme="dark"] .reader-revision-state[data-status="published"] { border-color:#486346; color:#bfd6b8; background:#293c2c; } -html[data-theme="dark"] .artifact-workspace-modal,html[data-theme="dark"] .artifact-workspace-header { color:#e8edef; border-color:#344044; background:#182023; } -html[data-theme="dark"] .compare-workspace { background:#161d20; } -html[data-theme="dark"] .compare-selectors select { border-color:#3b474a; color:#e1e8e9; background:#20292c; } -html[data-theme="dark"] .visual-diff section { border-color:#3b474a; background:#20292c; } -html[data-theme="dark"] .visual-diff section>header { border-color:#3b474a; color:#a8b4b8; background:#273135; } -.reader-modal.has-error .reader-spinner { border-color:#d7cbc8; border-top-color:#a95340; animation:none; } - -@media (max-width:1250px) { .appearance-control { display:none; } .protocol-card { grid-template-columns:34px 1fr; } .protocol-rail { grid-column:2; display:grid; grid-template-columns:1fr auto; align-items:end; min-height:auto; gap:15px; } .protocol-stats { max-width:236px; } .protocol-rail .outline-button { border-left:1px solid #53606d; padding:9px 0 9px 17px; } } -@media (min-width:1071px) and (max-width:1250px) { .app-shell.without-inspector { grid-template-columns:206px minmax(570px,1fr); } } -@media (max-width:1070px) { .app-shell { grid-template-columns:206px minmax(0,1fr); } .inspector { display:none; } .topbar,.view { padding-right:27px; } } -@media (max-width:900px) { .artifact-workspace { grid-template-columns:196px minmax(0,1fr); } .artifact-workspace-header { grid-template-columns:minmax(190px,1fr) minmax(180px,300px) auto; gap:15px; } .compare-workspace { padding-left:14px; padding-right:14px; } } -@media (max-width:760px) { .app-shell { display:block; } .sidebar { display:grid; grid-template-columns:auto 1fr; gap:13px; min-height:0; padding:14px 17px; } .brand { align-self:center; font-size:15px; } .brand-icon { width:28px; height:28px; flex-basis:28px; } .navigation { display:flex; justify-content:flex-end; gap:2px; margin:0; } .nav-item { display:flex; width:auto; padding:8px; font-size:0; } .nav-glyph { font-size:17px; } .nav-item b,.side-label,.collections,.sidebar-bottom { display:none; } .topbar { flex-wrap:wrap; gap:11px; height:auto; min-height:62px; padding:13px 18px; } .top-actions { width:100%; } .search-box { flex:1; width:auto; } .top-actions .button-quiet { display:none; } .view { padding:34px 18px 0; } .view-heading { display:block; margin-bottom:28px; } .view h1 { font-size:38px; } .storage-stat { margin-top:25px; } .protocol-card { grid-template-columns:1fr; gap:15px; padding:19px; } .protocol-index { display:none; } .protocol-rail { grid-column:auto; grid-template-columns:1fr; gap:14px; } .protocol-stats { max-width:none; } .protocol-rail .outline-button { justify-content:space-between; padding:9px 0 0; border-top:1px solid #53606d; border-left:0; } .library-toolbar { display:block; } .toolbar-actions { justify-content:space-between; margin-top:15px; } .filter-tabs { flex-wrap:nowrap; padding-bottom:2px; overflow-x:auto; } .filter { flex:0 0 auto; } .document-grid,.template-grid { grid-template-columns:1fr; } .template-card:last-child { grid-column:auto; } .contract-intro,.manifest-example { grid-template-columns:1fr; } .contract-intro p { grid-column:auto; } .contract-grid { grid-template-columns:1fr; } .contract-grid section:nth-child(odd) { border-right:0; } .reader-modal { width:calc(100vw - 20px); height:calc(100vh - 20px); } .reader-identity .eyebrow,.reader-toolbar #readerExport { display:none; } .reader-identity b { max-width:34vw; } .reader-revision-state { margin-left:6px; } .artifact-workspace-modal { width:calc(100vw - 16px); height:calc(100vh - 16px); } .artifact-workspace-shell { grid-template-rows:58px minmax(0,1fr); } .artifact-workspace-header { grid-template-columns:minmax(0,1fr) auto; padding-left:15px; } .artifact-workspace-header h2 { font-size:18px; } .artifact-workspace-address { display:none; } .artifact-workspace { grid-template-columns:1fr; grid-template-rows:162px minmax(0,1fr); } .revision-rail { padding:12px 13px; border-right:0; border-bottom:1px solid var(--line); } .revision-rail-heading { padding-bottom:7px; } .revision-timeline { display:flex; gap:9px; padding:8px 0; overflow-x:auto; } .revision-timeline li { min-width:142px; margin:0; padding:6px 9px; border:1px solid #cbd2ce; } .revision-timeline li::before { display:none; } .revision-rail>.button { align-self:flex-end; width:auto; min-height:29px; } .compare-workspace { padding:12px; overflow:auto; } .compare-toolbar { align-items:flex-start; } .compare-toolbar>div:first-child { display:none; } .compare-selectors { width:100%; justify-content:flex-end; } .compare-selectors label { flex:1; } .compare-selectors select { width:100%; min-width:0; } .compare-note { margin-top:8px; } .visual-diff { grid-template-columns:repeat(2,minmax(390px,1fr)); height:calc(100% - 62px); overflow-x:auto; } } - -/* Curated library: capabilities stay available without competing with the collection. */ -.topbar { height:72px; } -.top-actions { position:relative; } -.action-menu { position:relative; } -.action-menu>summary { display:flex; align-items:center; gap:6px; min-height:35px; padding:0 11px; border:1px solid var(--line); border-radius:4px; color:#505a60; background:var(--surface-raised); font-size:10px; font-weight:700; list-style:none; cursor:pointer; } -.action-menu>summary::-webkit-details-marker { display:none; } -.action-menu[open]>summary,.action-menu>summary:hover { border-color:#aab0b2; color:var(--ink); } -.action-menu-panel { position:absolute; top:42px; right:0; z-index:20; display:grid; gap:4px; width:196px; padding:8px; border:1px solid var(--line); border-radius:5px; background:var(--surface-raised); box-shadow:0 18px 42px rgba(25,34,39,.14); } -.action-menu-panel .appearance-control { display:grid; grid-template-columns:repeat(3,1fr); margin-bottom:4px; } -.action-menu-item { display:grid; grid-template-columns:20px 1fr auto; align-items:center; min-height:35px; padding:0 8px; border:0; border-radius:3px; color:#4a555b; background:transparent; text-align:left; font-size:10px; } -.action-menu-item:hover { color:var(--ink); background:#f0f1ee; } -.action-menu-item b { display:grid; min-width:17px; height:17px; place-items:center; border-radius:9px; color:#fff; background:var(--accent); font:8px var(--mono); } -#libraryView .view-heading { align-items:end; width:100%; max-width:none; margin:66px 0 56px; } -#libraryView .view-heading h1 { max-width:850px; font-size:55px; } -#libraryView .lede { margin-top:15px; font-size:14px; } -.contract-link { display:flex; gap:9px; align-items:center; margin-top:27px; padding:0; border:0; color:#68757c; background:transparent; font-size:10px; text-decoration:underline; text-decoration-color:#c5c9c7; text-underline-offset:4px; } -.contract-link span { color:var(--accent); font-size:14px; text-decoration:none; } -.library-overview { display:grid; grid-template-columns:repeat(3,minmax(112px,1fr)); min-width:420px; border-top:1px solid var(--line); border-bottom:1px solid var(--line); } -.library-overview>div { display:flex; gap:12px; align-items:flex-end; min-height:94px; padding:18px 20px; } -.library-overview>div+div { border-left:1px solid var(--line); } -.library-overview strong { color:var(--ink); font-family:var(--serif); font-size:43px; font-weight:500; letter-spacing:-.05em; line-height:.8; } -.library-overview>div:first-child strong { color:var(--accent); } -.library-overview span { color:#899195; font:8px/1.45 var(--mono); letter-spacing:.08em; text-transform:uppercase; } -.library-toolbar { align-items:center; width:100%; max-width:none; padding:11px 0; border-top:1px solid var(--line); } -.library-result { color:#899195; font:9px var(--mono); letter-spacing:.04em; } -.library-result strong { color:#313b40; font-weight:600; } -.library-result i { margin-left:7px; color:#9ba1a3; font-style:normal; } -.sort-select { display:flex; align-items:center; gap:7px; } -.sort-select select { min-height:30px; padding:0 23px 0 8px; border:1px solid transparent; border-radius:3px; } -.sort-select select:hover,.sort-select select:focus { border-color:var(--line); background:var(--surface-raised); } -.document-grid { grid-template-columns:repeat(2,minmax(260px,1fr)); gap:0; width:100%; max-width:none; padding-top:24px; border-top:0; } -.document-card { min-height:200px; padding:22px 23px 52px; border-color:var(--line); background:transparent; box-shadow:none; } -.document-card:nth-child(even) { border-left:0; } -.document-card:nth-child(n+3) { border-top:0; } -.document-card:hover,.document-card.selected { z-index:1; border-color:#9aa3a5; background:var(--surface-raised); box-shadow:0 12px 32px rgba(31,41,46,.07); transform:none; } -.document-card .card-project { margin:20px 0 0; color:#8b9396; text-transform:none; letter-spacing:.04em; } -.document-card .card-project+h2 { margin:7px 0 8px; font-size:26px; } -.document-card .summary { max-width:92%; -webkit-line-clamp:2; } -.document-card .card-bottom { right:23px; bottom:18px; left:23px; color:#92999c; font:8px var(--mono); text-transform:uppercase; } -.document-card .card-action { opacity:.45; } -.document-card:hover .card-action { opacity:1; } -.app-shell:not(.without-inspector) .document-grid { grid-template-columns:1fr; } -.app-shell:not(.without-inspector) .document-card { border-left:1px solid var(--line); } -.app-shell:not(.without-inspector) .document-card+.document-card { border-top:0; } -.inspector { padding-top:72px; } -.inspector-content { padding-top:14px; } -.inspector-content h2 { margin-top:22px; } -.inspector-summary { margin-bottom:20px; } -.revision-state { margin-bottom:14px; } -.inspector-actions { margin-top:14px; } -.inspector-disclosure { border-top:1px solid var(--line); } -.inspector-disclosure:last-child { border-bottom:1px solid var(--line); } -.inspector-disclosure>summary { display:flex; justify-content:space-between; align-items:center; padding:14px 0; color:#667178; font:9px var(--mono); letter-spacing:.05em; list-style:none; cursor:pointer; } -.inspector-disclosure>summary::-webkit-details-marker { display:none; } -.inspector-disclosure[open]>summary span { transform:rotate(45deg); } -.inspector-disclosure .tag-list { padding:0 0 12px; } -.inspector-disclosure .metadata { border-top:0; } -.inspector-disclosure .fork-button,.inspector-disclosure .catalog-button { width:100%; margin-bottom:7px; } -.inspector-disclosure .quality-row { border-top:1px solid var(--line); padding-top:14px; } -.inspector-disclosure .danger-button { margin:3px 0 16px; } -html[data-theme="dark"] .action-menu-panel,html[data-theme="dark"] .action-menu>summary { background:#20282b; border-color:var(--line); } -html[data-theme="dark"] .action-menu-item:hover { background:#2b3538; } -html[data-theme="dark"] .library-overview strong,html[data-theme="dark"] .library-result strong { color:#dbe1e3; } -html[data-theme="dark"] .library-overview>div:first-child strong { color:#ef7259; } -@media (min-width:1700px) { .app-shell.without-inspector .document-grid { grid-template-columns:repeat(3,minmax(260px,1fr)); } .app-shell.without-inspector .document-card:nth-child(3n+1) { border-left:1px solid var(--line); } .app-shell.without-inspector .document-card:nth-child(3n+2),.app-shell.without-inspector .document-card:nth-child(3n+3) { border-left:0; } .app-shell.without-inspector .document-card:nth-child(-n+3) { border-top:1px solid var(--line); } .app-shell.without-inspector .document-card:nth-child(n+4) { border-top:0; } } -@media (min-width:1500px) { .app-shell:not(.without-inspector) .document-grid { grid-template-columns:repeat(2,minmax(260px,1fr)); } .app-shell:not(.without-inspector) .document-card:nth-child(even) { border-left:0; } .app-shell:not(.without-inspector) .document-card:nth-child(n+3) { border-top:0; } } -@media (max-width:1499px) { .app-shell:not(.without-inspector) #libraryView .view-heading { display:block; } .app-shell:not(.without-inspector) .library-overview { width:100%; min-width:0; margin-top:32px; } } -@media (max-width:1100px) { .library-overview { min-width:330px; } .library-overview>div { gap:7px; padding:15px 12px; } .library-overview strong { font-size:35px; } } -@media (max-width:760px) { #libraryView .view-heading { display:block; margin:34px 0 38px; } #libraryView .view-heading h1 { font-size:42px; } .library-overview { width:100%; min-width:0; margin-top:36px; } .library-overview>div { min-height:78px; } .action-menu>summary { font-size:0; } .action-menu>summary span { font-size:12px; } .library-toolbar { display:flex; align-items:flex-start; } .toolbar-actions { flex-wrap:wrap; justify-content:flex-end; margin-top:0; } .library-result i { display:none; } .document-card:nth-child(even),.document-card:nth-child(n+3) { border-left:1px solid var(--line); border-top:0; } } -@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto !important; transition-duration:.01ms !important; animation-duration:.01ms !important; } } diff --git a/templates/agent-handoff.html b/templates/agent-handoff.html deleted file mode 100644 index 2052288..0000000 --- a/templates/agent-handoff.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - Agent handoff record - - - - -
        -
        Agent handoff · HDOC/1.0Updated YYYY-MM-DD
        -

        Result / evidence / review

        Agent handoff record

        Use this document to give a reviewer the answer and the proof without making them reconstruct the task from a transcript.

        -

        Result in one line

        State the completed outcome, decision, or finding directly. Do not begin with process narration or a list of tool calls.

        -

        Review route

        Let a reviewer see delivery, proof, and ownership in one pass.

        Replace before handoff

        01Deliveredartifact / change02 / VERIFIEDProoftest / source03Reviewremaining owner
        Use this route to separate the delivered result from the proof and the remaining owner. Do not mark a stage verified until the cited evidence exists.
        1. Delivered: artifact or change.
        2. Proof: test or source.
        3. Review: remaining owner.
        -

        01 / completed

        What changed or was delivered

        Describe only the relevant scope. Link to the artifact, file, environment, or decision boundary a reviewer needs to inspect.

        -

        02 / evidence

        Why this can be trusted

        Use an evidence ledger for material proof. Distinguish verified observations from reasoning, and name the confidence or limitation.

        EvidenceWhat it supportsWhen / sourceConfidence
        Test, source, or observationThe specific result this proves.Date, environment, or source link.Verified
        -

        03 / review boundary

        Open question or next action

        Needs a human owner

        List only the decision, risk, missing access, or follow-up that cannot be completed by the current artifact. If nothing is open, say so plainly.

        -

        Sources and method

        Record source links, access dates, test conditions, and assumptions needed to reproduce or challenge the result. Keep this aligned with the manifest provenance.

        -
        - - diff --git a/templates/decision-brief.html b/templates/decision-brief.html deleted file mode 100644 index ce5f2c5..0000000 --- a/templates/decision-brief.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - Decision brief title - - - - -
        -
        Decision brief · HDOC/1.0Decision date YYYY-MM-DD
        -

        Decision / options / recommendation

        Decision brief title

        State the decision and why it needs a call now. This is a reader's orientation, not a vague project introduction.

        -

        Recommendation

        State the recommended path in one direct sentence. Lead with the call, then use the rest of the brief to show why it earns confidence.

        -

        Decision map

        Make the selected option and its reason visible.

        Replace before handoff

        AOptionreal upsideB / SELECTEDRecommendedevidence + trade-offCAlternativereversal condition
        Use the same decision criteria in the real option comparison. The selected path needs an evidence note and a condition that would reopen the choice.
        1. Option: real upside.
        2. Recommended: evidence and trade-off.
        3. Alternative: reversal condition.
        -

        01 / context

        What is being decided

        Capture only the facts, constraints, and deadline needed to judge the choice.

        Name the decision maker, the scope, and the non-negotiable constraints. Exclude narrative that does not change the available options.

        -

        02 / comparison

        Options and trade-offs

        A matrix makes the decision legible when alternatives must be compared on the same criteria.

        OptionUpsideCost / riskEvidence
        The decisive benefit and who receives it.The cost or risk that remains after mitigation.Source, test, or observation with a date.
        AlternativeThe real upside that keeps it in consideration.Why it is not the current recommendation.Source, test, or assumption to verify.
        -

        03 / execution

        Action and checkpoint

        Turn the recommendation into an observable next step rather than an implied conclusion.

        Owner

        Name the person or team responsible for the next move.

        Next action

        State the smallest action that advances the decision.

        Checkpoint

        Name the date or condition that triggers review.

        -

        04 / boundary

        Risk, assumption, or reversal

        Record what could invalidate the recommendation so the reader knows when to reopen the brief.

        Revisit if

        State the critical assumption, counter-signal, or missing evidence. Link to the source or method note when a factual claim needs provenance.

        -

        Sources and method

        List the material sources, access dates, and definitions that informed the comparison. Update the manifest provenance when this becomes a real artifact.

        -
        - - diff --git a/templates/reference-note.html b/templates/reference-note.html deleted file mode 100644 index 636b571..0000000 --- a/templates/reference-note.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - Reference note title - - - - -
        -
        Reference note · HDOC/1.0Reviewed YYYY-MM-DD
        -

        Pattern / application / caveats

        Reference note title

        Name the reusable thing. This line says what it unlocks and for whom, so a future reader can decide whether to keep reading.

        -

        Pattern in one line

        Explain the core idea in plain language before adding technical detail, implementation choices, or exceptions.

        -

        Pattern route

        Show how the pattern turns a trigger into an observable result.

        Replace before handoff

        01Triggerprecondition02Patternminimum action03Outcomeobservable result
        Use a route only where the causal sequence is the reusable knowledge. Label the precondition, the smallest dependable action, and the observable result in the real artifact.
        1. Trigger: precondition.
        2. Pattern: minimum action.
        3. Outcome: observable result.
        -

        01 / use

        When this applies

        Use peer cards only when they answer a scanning question. Here they distinguish the conditions for using and avoiding the pattern.

        Use it when

        State the precondition, expected benefit, and the person who benefits.

        Do not use it when

        State the constraint, failure mode, or simpler alternative.

        -

        02 / workflow

        Smallest reliable workflow

        1. Start with the prerequisite, input, or decision trigger.
        2. Describe the minimum dependable action; use commands only when they are genuinely required.
        3. Name the observable result that tells a reader the pattern worked.
        -

        03 / boundary

        Caveat and version boundary

        Important limitation

        Record version sensitivity, risks, or an assumption that changes the recommendation. Do not bury a consequential exception after a long implementation guide.

        -

        Primary sources

        1. Add source title, URL, access date, and the fact it supports.
        2. Update the manifest provenance sources before handing off the completed artifact.
        -
        - - diff --git a/templates/research-dossier.html b/templates/research-dossier.html deleted file mode 100644 index 4f9c9c5..0000000 --- a/templates/research-dossier.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - Research dossier title - - - - -
        -
        Research dossier · HDOC/1.0Updated 2026-07-13
        -
        -

        Question / evidence / recommendation

        Research dossier title

        Name the question in the title. Use this line to establish why the answer matters now — not to repeat the title.

        - -
        - -

        Short answer

        State the decision-relevant conclusion in one or two direct sentences. A reader should understand the current answer before they reach the background or evidence.

        - -
        -

        Visual evidence route

        Show how dated evidence earns the current answer.

        Replace before handoff

        - 01Observationdated source02Interpretationconfidence03Actionboundary -
        Use this route only when it reduces the work of following a claim back to evidence. Replace each label with the real observation, interpretation, action, source/date, confidence, and reversal boundary.
        -
        1. Observation: dated source.
        2. Interpretation: confidence and limitation.
        3. Action: next step and reversal boundary.
        -
        - -
        -

        01 / scope

        Question and boundary

        Frame the exact question, audience, decision window, and exclusions that make the research legible.

        -

        Explain what was investigated and what would change the answer. Keep only context that changes the reader's interpretation of the evidence.

        -
        - -
        -

        02 / evidence

        Evidence ledger

        Use a table when the reader needs to compare claims and source quality. Record dates and uncertainty next to the observation.

        -
        Source / dateObservationWhat it supportsConfidence
        Source title
        YYYY-MM-DD
        The material fact, quoted or carefully paraphrased.The part of the answer this evidence can support.High
        Counter-evidence
        YYYY-MM-DD
        What weakens, qualifies, or limits the initial conclusion.The condition under which the recommendation changes.Medium
        -
        - -
        -

        03 / interpretation

        What the evidence means

        Separate the observations from the reasoning that turns them into a conclusion.

        -
        Signal

        The finding that most changes the decision.

        Trade-off

        The cost, risk, or downside that must remain visible.

        Unknown

        The missing evidence that would materially revise the answer.

        -
        - -
        -

        04 / action

        Recommendation and boundary

        Make the useful next move explicit and record the condition that would trigger a revisit.

        -

        Recommended next action

        State the action, owner or checkpoint, and the assumption it depends on. Do not let the recommendation appear as an unstated implication of the evidence.

        -
        - -

        Sources and method

        1. Add the primary sources, access dates, and any material definitions.
        2. State collection method, assumptions, and limitations that the reader cannot infer from the evidence ledger.
        3. Update the manifest's provenance sources when this placeholder becomes a real artifact.
        -
        - - diff --git a/tests/channel-store-smoke.html b/tests/channel-store-smoke.html deleted file mode 100644 index 17a31f2..0000000 --- a/tests/channel-store-smoke.html +++ /dev/null @@ -1,138 +0,0 @@ - - - - - Helm Channels store smoke test - - - -

        Helm Channels store smoke test

        -
        Running…
        - - - - diff --git a/tests/contract-smoke.html b/tests/contract-smoke.html deleted file mode 100644 index c0370d5..0000000 --- a/tests/contract-smoke.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - Helm contract smoke test - Running contract smoke test… - - - diff --git a/tests/repair-smoke.html b/tests/repair-smoke.html deleted file mode 100644 index 93f8b7d..0000000 --- a/tests/repair-smoke.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - Helm repair smoke test - Running repair smoke test… - - - - diff --git a/tests/test_authoring.py b/tests/test_authoring.py deleted file mode 100644 index 838f160..0000000 --- a/tests/test_authoring.py +++ /dev/null @@ -1,98 +0,0 @@ -import subprocess -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -COMMAND = ROOT / "scripts" / "helm-report" - - -class AuthoringKitTests(unittest.TestCase): - def run_command(self, *args: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [str(COMMAND), *args], - cwd=ROOT, - check=False, - capture_output=True, - text=True, - ) - - def test_component_gallery_passes_visual_contract(self): - result = self.run_command("check", "authoring/component-gallery.html") - self.assertEqual(0, result.returncode, result.stdout + result.stderr) - self.assertIn("10 component(s)", result.stdout) - source = (ROOT / "authoring" / "component-gallery.html").read_text(encoding="utf-8") - self.assertIn('data-helm-layout="chapter-deck"', source) - self.assertIn('"mode": "chapter-reveal"', source) - self.assertNotIn("" + ) + m = helm.read_manifest(html) + self.assertEqual(m["id"], "x") + + def test_missing_manifest(self): + self.assertIsNone(helm.read_manifest("")) + + +class ContractTest(unittest.TestCase): + def test_template_scaffold_fails_on_placeholders(self): + html = (REPO / "skill" / "template.html").read_text("utf-8") + problems, _ = helm._contract_problems(html) + self.assertTrue(any("placeholder" in p for p in problems)) + + def test_external_script_flagged(self): + html = ( + '' + '
        ' + ) + problems, _ = helm._contract_problems(html) + self.assertTrue(any("self-contained" in p for p in problems)) + + def test_clean_document_passes(self): + html = ( + '' + "

        Real

        " + ) + problems, _ = helm._contract_problems(html) + self.assertEqual(problems, []) + + +class NewIndexTest(unittest.TestCase): + def test_new_then_index_roundtrip(self): + with tempfile.TemporaryDirectory() as d: + lib = Path(d) + + class Args: + title = "Round Trip" + id = None + type = "decision" + summary = "hi" + tags = "a,b" + source = "unit" + library = str(lib) + force = True + + helm.cmd_new(Args()) + art = lib / "round-trip" / "index.html" + self.assertTrue(art.exists()) + m = helm.read_manifest(art.read_text("utf-8")) + self.assertEqual(m["type"], "decision") + self.assertEqual(m["tags"], ["a", "b"]) + + class IdxArgs: + library = str(lib) + + helm.cmd_index(IdxArgs()) + catalog = json.loads((lib / "catalog.json").read_text("utf-8")) + self.assertEqual(catalog["count"], 1) + self.assertEqual(catalog["artifacts"][0]["id"], "round-trip") + + +class SeedArtifactTest(unittest.TestCase): + def test_shipped_sample_passes_contract(self): + art = REPO / "library" / "helm-convergence" / "index.html" + if not art.exists(): + self.skipTest("no seeded artifact") + problems, _ = helm._contract_problems(art.read_text("utf-8")) + self.assertEqual(problems, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_helm_bridge.py b/tests/test_helm_bridge.py deleted file mode 100644 index 4e134b3..0000000 --- a/tests/test_helm_bridge.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -import sys -import tempfile -import threading -import unittest -from stat import S_IMODE -from pathlib import Path -from urllib.request import Request, urlopen - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from helm_bridge import BridgeCatalog, BridgeHTTPServer, ContractError, is_allowed_browser_origin, validate_hdoc - - -def document(document_id="agent-report", title="Agent report", body="Evidence", project=None): - project_fragment = f',"project":{project}' if project is not None else "" - return f'''{title}

        {title}

        {body}

        ''' - - -class HelmBridgeTests(unittest.TestCase): - def test_validates_and_rejects_executable_html(self): - manifest, warnings = validate_hdoc(document()) - self.assertEqual(manifest["id"], "agent-report") - self.assertEqual(warnings, []) - with self.assertRaises(ContractError): - validate_hdoc(document().replace("", "")) - - def test_portability_warnings_cover_relative_and_remote_dependencies(self): - relative = document(body='Stage twoChart') - _, warnings = validate_hdoc(relative) - self.assertTrue(any("relative files or links" in warning for warning in warnings)) - - remote_media = document(body='Chart') - _, warnings = validate_hdoc(remote_media) - self.assertTrue(any("remote resources" in warning for warning in warnings)) - - portable_links = document(body='FindingOwnerDot') - _, warnings = validate_hdoc(portable_links) - self.assertEqual(warnings, []) - - def test_cors_accepts_loopback_on_any_port_but_not_arbitrary_origins(self): - for origin in ( - "http://127.0.0.1:4173", - "http://127.0.0.1:4182", - "http://localhost:9000", - "https://[::1]:4443", - ): - with self.subTest(origin=origin): - self.assertTrue(is_allowed_browser_origin(origin)) - for origin in ( - "https://evil.example", - "http://localhost.evil.example:4173", - "http://user@localhost:4173", - "http://localhost:4173/path", - "null", - ): - with self.subTest(origin=origin): - self.assertFalse(is_allowed_browser_origin(origin)) - self.assertTrue(is_allowed_browser_origin("https://helm.example", {"https://helm.example"})) - - def test_http_cors_reflects_only_allowed_origin(self): - with tempfile.TemporaryDirectory() as directory: - catalog = BridgeCatalog(Path(directory)) - spec_path = Path(directory) / "spec.md" - spec_path.write_text("contract", encoding="utf-8") - server = BridgeHTTPServer(("127.0.0.1", 0), catalog, "token", spec_path, set()) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - endpoint = f"http://127.0.0.1:{server.server_port}/v1/health" - try: - with urlopen(Request(endpoint, headers={"Origin": "http://localhost:4182"})) as response: - self.assertEqual(response.headers.get("Access-Control-Allow-Origin"), "http://localhost:4182") - with urlopen(Request(endpoint, headers={"Origin": "https://evil.example"})) as response: - self.assertIsNone(response.headers.get("Access-Control-Allow-Origin")) - finally: - server.shutdown() - server.server_close() - thread.join(timeout=2) - - def test_idempotency_revision_history_and_exact_originals(self): - source = document(body="Exact original text.").encode("utf-8") - with tempfile.TemporaryDirectory() as directory: - catalog = BridgeCatalog(Path(directory)) - catalog.ensure_token() - self.assertEqual(S_IMODE(catalog.data_dir.stat().st_mode), 0o700) - self.assertEqual(S_IMODE(catalog.artifact_dir.stat().st_mode), 0o700) - self.assertEqual(S_IMODE(catalog.token_path.stat().st_mode), 0o600) - created, record = catalog.ingest(source, "test-agent") - repeated, same_record = catalog.ingest(source, "test-agent") - self.assertEqual(created, "created") - self.assertEqual(repeated, "idempotent") - self.assertEqual(record["sha256"], same_record["sha256"]) - self.assertEqual(catalog.read_document("agent-report")["html"].encode("utf-8"), source) - revised_source = document(body="Different source.").encode("utf-8") - revised, revised_record = catalog.ingest(revised_source, "test-agent") - self.assertEqual(revised, "revision") - self.assertEqual(revised_record["revision_number"], 2) - revisions = catalog.list_documents() - self.assertEqual(len(revisions), 2) - self.assertEqual({entry["html"].encode("utf-8") for entry in revisions}, {source, revised_source}) - - def test_project_can_be_declared_or_attached_by_the_submitter(self): - declared = '{"id":"texas-gto-lab","name":"Texas GTO Lab"}' - manifest, _ = validate_hdoc(document(project=declared)) - self.assertEqual(manifest["project"]["id"], "texas-gto-lab") - with tempfile.TemporaryDirectory() as directory: - catalog = BridgeCatalog(Path(directory)) - _, record = catalog.ingest(document().encode("utf-8"), "test-agent", {"id": "html-displayer", "name": "Helm"}) - self.assertEqual(record["project"], {"id": "html-displayer", "name": "Helm"}) - with self.assertRaises(ContractError): - validate_hdoc(document(project='{"id":"Not stable","name":"Broken"}')) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_helm_share_server.py b/tests/test_helm_share_server.py deleted file mode 100644 index 3a972b3..0000000 --- a/tests/test_helm_share_server.py +++ /dev/null @@ -1,264 +0,0 @@ -import http.client -import hashlib -import json -import tempfile -import threading -import unittest -from pathlib import Path -from stat import S_IMODE -from unittest.mock import patch - -from helm_bridge import ContractError -from helm_share_server import ChannelConflictError, ChannelNotFoundError, ShareHTTPServer, ShareRequestHandler, ShareStore - - -def hdoc(title="Shared report", summary="A report shared on the intranet."): - return f''' - - - -

        {title}

        {summary}

        '''.encode() - - -class ShareStoreTests(unittest.TestCase): - def test_legacy_publish_is_content_addressed_and_idempotent(self): - with tempfile.TemporaryDirectory() as directory: - store = ShareStore(Path(directory)) - first = store.publish(hdoc()) - retry = store.publish(hdoc()) - revised = store.publish(hdoc(summary="A revised, separately addressed report.")) - self.assertEqual(first["filename"], retry["filename"]) - self.assertEqual("created", first["state"]) - self.assertEqual("idempotent", retry["state"]) - self.assertNotEqual(first["filename"], revised["filename"]) - self.assertEqual(hdoc(), store.resolve(first["filename"]).read_bytes()) - - def test_legacy_revoke_requires_the_exact_path_and_digest(self): - with tempfile.TemporaryDirectory() as directory: - store = ShareStore(Path(directory)) - published = store.publish(hdoc()) - public_path = f'/share/{published["filename"]}' - with self.assertRaises(ChannelConflictError): - store.revoke_legacy(public_path, "0" * 64) - self.assertIsNotNone(store.resolve(published["filename"])) - revoked = store.revoke_legacy(public_path, published["sha256"]) - self.assertEqual("revoked", revoked["state"]) - self.assertIsNone(store.resolve(published["filename"])) - with self.assertRaises(ChannelNotFoundError): - store.revoke_legacy("/share/channels.json", published["sha256"]) - - def test_channel_publish_updates_pointer_without_mutating_revisions(self): - with tempfile.TemporaryDirectory() as directory: - store = ShareStore(Path(directory)) - first = store.publish_channel(hdoc()) - retry = store.publish_channel(hdoc()) - revised_source = hdoc(summary="A revised Channel report.") - revised = store.publish_channel(revised_source, first["sha256"]) - - self.assertEqual("created", first["state"]) - self.assertEqual("idempotent", retry["state"]) - self.assertEqual("updated", revised["state"]) - self.assertNotEqual(first["sha256"], revised["sha256"]) - self.assertEqual(hdoc(), store.resolve_revision(f'{first["sha256"]}.html').read_bytes()) - state, current = store.resolve_artifact("shared-report") - self.assertEqual("published", state) - self.assertEqual(revised_source, current.read_bytes()) - self.assertEqual(0o600, S_IMODE(current.stat().st_mode)) - self.assertEqual(0o600, S_IMODE(store.channel_catalog_path.stat().st_mode)) - - def test_channel_update_requires_the_current_base_revision(self): - with tempfile.TemporaryDirectory() as directory: - store = ShareStore(Path(directory)) - first = store.publish_channel(hdoc()) - with self.assertRaises(ChannelConflictError) as missing: - store.publish_channel(hdoc(summary="No base revision.")) - self.assertEqual(first["sha256"], missing.exception.current_revision) - with self.assertRaises(ChannelConflictError): - store.publish_channel(hdoc(summary="Stale base revision."), "0" * 64) - - def test_revoke_only_removes_the_stable_pointer_and_can_republish(self): - with tempfile.TemporaryDirectory() as directory: - store = ShareStore(Path(directory)) - first = store.publish_channel(hdoc()) - revoked = store.revoke_channel("shared-report", first["sha256"]) - repeated = store.revoke_channel("shared-report", first["sha256"]) - self.assertEqual("revoked", revoked["state"]) - self.assertEqual("idempotent", repeated["state"]) - self.assertEqual("revoked", store.resolve_artifact("shared-report")[0]) - self.assertEqual(hdoc(), store.resolve_revision(f'{first["sha256"]}.html').read_bytes()) - republished = store.publish_channel(hdoc(), first["sha256"]) - self.assertEqual("republished", republished["state"]) - self.assertEqual("published", store.resolve_artifact("shared-report")[0]) - - def test_channel_catalog_survives_restart_and_rejects_corruption(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - first = ShareStore(root).publish_channel(hdoc()) - reopened = ShareStore(root) - self.assertEqual(first["sha256"], reopened.artifact("shared-report")["current_revision"]) - reopened.channel_catalog_path.write_text("not json", encoding="utf-8") - with self.assertRaises(RuntimeError): - ShareStore(root) - - def test_invalid_document_is_rejected(self): - with tempfile.TemporaryDirectory() as directory: - with self.assertRaises(ContractError): - ShareStore(Path(directory)).publish_channel(b"

        not HDOC

        ") - - -class ShareServerTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - root = Path(self.temporary.name) - self.root = root - self.server = ShareHTTPServer(("127.0.0.1", 0), root, ShareStore(root / "shares"), "http://example.test") - self.port = self.server.server_address[1] - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - self.thread.start() - - def tearDown(self): - self.server.shutdown() - self.server.server_close() - self.thread.join(timeout=2) - self.temporary.cleanup() - - def request(self, method, path, payload=None, headers=None): - body = json.dumps(payload).encode() if payload is not None else None - request_headers = dict(headers or {}) - if payload is not None and "Content-Type" not in request_headers: - request_headers["Content-Type"] = "application/json" - connection = http.client.HTTPConnection("127.0.0.1", self.port, timeout=3) - connection.request(method, path, body=body, headers=request_headers) - response = connection.getresponse() - result = response.status, dict(response.getheaders()), response.read() - connection.close() - return result - - def publish_channel(self, source=None, base=None): - payload = {"html": (source or hdoc()).decode()} - if base is not None: - payload["base_revision_sha256"] = base - status, headers, body = self.request("POST", "/api/channels/publish", payload) - return status, headers, json.loads(body) - - def test_channel_http_lifecycle_and_cache_boundaries(self): - status, _, created = self.publish_channel() - self.assertEqual(201, status) - self.assertEqual("http://example.test/a/shared-report", created["stable_url"]) - digest = created["sha256"] - - stable_status, stable_headers, stable_body = self.request("GET", "/a/shared-report") - revision_status, revision_headers, revision_body = self.request("GET", f"/r/{digest}.html") - self.assertEqual((200, hdoc()), (stable_status, stable_body)) - self.assertEqual((200, hdoc()), (revision_status, revision_body)) - self.assertEqual("no-store", stable_headers["Cache-Control"]) - self.assertIn("immutable", revision_headers["Cache-Control"]) - self.assertIn("sandbox", stable_headers["Content-Security-Policy"]) - - revised_source = hdoc(summary="The newest stable Channel revision.") - update_status, _, updated = self.publish_channel(revised_source, digest) - self.assertEqual(200, update_status) - self.assertEqual("updated", updated["state"]) - self.assertEqual(hdoc(), self.request("GET", f"/r/{digest}.html")[2]) - self.assertEqual(revised_source, self.request("GET", "/a/shared-report")[2]) - - revoke_status, _, revoke_body = self.request("POST", "/api/channels/artifacts/shared-report/revoke", {"base_revision_sha256": updated["sha256"]}) - self.assertEqual((200, "revoked"), (revoke_status, revoke_body and json.loads(revoke_body)["state"])) - self.assertEqual(410, self.request("GET", "/a/shared-report")[0]) - self.assertEqual(200, self.request("GET", f'/r/{updated["sha256"]}.html')[0]) - - def test_head_matches_get_headers_without_a_body(self): - _, _, created = self.publish_channel() - status, headers, body = self.request("HEAD", f'/r/{created["sha256"]}.html') - self.assertEqual(200, status) - self.assertEqual(str(len(hdoc())), headers["Content-Length"]) - self.assertEqual(b"", body) - - def test_mutations_require_safe_origin_and_json(self): - evil = {"Origin": "https://evil.example", "Content-Type": "application/json"} - status, _, body = self.request("POST", "/api/channels/publish", {"html": hdoc().decode()}, evil) - self.assertEqual((403, "origin_forbidden"), (status, json.loads(body)["error"])) - - allowed = {"Origin": f"http://127.0.0.1:{self.port}", "Content-Type": "text/plain"} - status, _, body = self.request("POST", "/api/channels/publish", {"html": hdoc().decode()}, allowed) - self.assertEqual((415, "content_type_required"), (status, json.loads(body)["error"])) - - def test_deployment_token_allows_non_loopback_owner_requests(self): - self.server.owner_token = "deployment-secret" - headers = {"Authorization": "Bearer deployment-secret", "Content-Type": "application/json"} - with patch.object(ShareRequestHandler, "_loopback_writer", return_value=False): - status, _, body = self.request("POST", "/api/channels/publish", {"html": hdoc().decode()}, headers) - self.assertEqual((201, "created"), (status, json.loads(body)["state"])) - - with patch.object(ShareRequestHandler, "_loopback_writer", return_value=False): - status, _, body = self.request("GET", "/api/channels", headers=headers) - self.assertEqual((200, 1), (status, len(json.loads(body)["artifacts"]))) - - with patch.object(ShareRequestHandler, "_loopback_writer", return_value=False): - status, _, body = self.request("GET", "/api/channels") - self.assertEqual((403, "read_only_network"), (status, json.loads(body)["error"])) - - def test_stale_update_returns_current_revision(self): - _, _, created = self.publish_channel() - status, _, body = self.publish_channel(hdoc(summary="A conflicting update."), "0" * 64) - self.assertEqual(409, status) - self.assertEqual(created["sha256"], body["current_revision_sha256"]) - - def test_legacy_share_api_and_url_remain_compatible(self): - status, _, published_body = self.request("POST", "/api/share", {"html": hdoc().decode()}) - published = json.loads(published_body) - self.assertEqual(201, status) - self.assertTrue(published["path"].startswith("/share/shared-report--")) - get_status, headers, body = self.request("GET", published["path"]) - self.assertEqual((200, hdoc()), (get_status, body)) - self.assertIn("immutable", headers["Cache-Control"]) - - wrong_status, _, _ = self.request("POST", "/api/share/revoke", {"path": published["path"], "sha256": "0" * 64}) - self.assertEqual(409, wrong_status) - self.assertEqual(200, self.request("GET", published["path"])[0]) - revoke_status, _, revoke_body = self.request("POST", "/api/share/revoke", {"path": published["path"], "sha256": published["sha256"]}) - self.assertEqual((200, "revoked"), (revoke_status, json.loads(revoke_body)["state"])) - self.assertEqual(404, self.request("GET", published["path"])[0]) - - def test_legacy_revoke_rejects_arbitrary_share_root_files(self): - channel_file = self.server.store.channel_catalog_path - channel_file.write_text("protected", encoding="utf-8") - status, _, _ = self.request("POST", "/api/share/revoke", {"path": "/share/channels.json", "sha256": hashlib.sha256(b"protected").hexdigest()}) - self.assertEqual(404, status) - self.assertTrue(channel_file.exists()) - - def test_owner_catalog_and_path_validation(self): - (self.root / ".git").mkdir() - (self.root / ".git" / "config").write_text("private", encoding="utf-8") - (self.root / "scripts").mkdir() - (self.root / "scripts" / "x.txt").write_text("private", encoding="utf-8") - self.publish_channel() - status, _, body = self.request("GET", "/api/channels") - self.assertEqual(200, status) - self.assertEqual("shared-report", json.loads(body)["artifacts"][0]["id"]) - self.assertEqual(404, self.request("GET", "/a/%2e%2e%2fsecret")[0]) - self.assertEqual(404, self.request("GET", "/r/not-a-revision.html")[0]) - self.assertEqual(404, self.request("GET", "/%2egit/config")[0]) - self.assertEqual(404, self.request("GET", "/scripts%2fx.txt")[0]) - self.assertEqual(404, self.request("GET", "/shares/channels.json")[0]) - self.assertEqual(404, self.request("GET", "/shares/revisions/")[0]) - - def test_public_catalog_lists_only_live_channels_without_owner_auth(self): - _, _, created = self.publish_channel() - status, _, body = self.request("GET", "/api/public/channels") - payload = json.loads(body) - self.assertEqual(200, status) - self.assertEqual(1, len(payload["artifacts"])) - record = payload["artifacts"][0] - self.assertEqual("shared-report", record["id"]) - self.assertEqual("http://example.test/a/shared-report", record["stable_url"]) - self.assertEqual(created["sha256"], record["sha256"]) - self.assertNotIn("revisions", record) - - self.request("POST", "/api/channels/artifacts/shared-report/revoke", {"base_revision_sha256": created["sha256"]}) - status, _, body = self.request("GET", "/api/public/channels") - self.assertEqual((200, []), (status, json.loads(body)["artifacts"])) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_templates.py b/tests/test_templates.py deleted file mode 100644 index 649a3d3..0000000 --- a/tests/test_templates.py +++ /dev/null @@ -1,36 +0,0 @@ -import sys -import unittest -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from helm_bridge import validate_hdoc - - -ROOT = Path(__file__).resolve().parents[1] -TEMPLATES = ( - "research-dossier.html", - "decision-brief.html", - "reference-note.html", - "agent-handoff.html", -) - - -class TemplateContractTests(unittest.TestCase): - def test_shipped_templates_are_valid_hdoc_documents(self): - for name in TEMPLATES: - with self.subTest(template=name): - html = (ROOT / "templates" / name).read_text(encoding="utf-8") - manifest, _ = validate_hdoc(html) - self.assertEqual(manifest["schema_version"], "HDOC/1.0") - self.assertTrue(manifest["id"]) - self.assertTrue(manifest["project"]["id"]) - self.assertTrue(manifest["presentation"]["profile"]) - self.assertTrue(manifest["presentation"]["claims"]) - self.assertIn("
        ", html) - self.assertEqual(html.lower().count(" node.remove()); - return normalizedText(clone.textContent); - } - - function metaValues(document, name) { - return [...document.querySelectorAll('meta')] - .filter((node) => (node.getAttribute('name') || '').toLowerCase() === name) - .map((node) => (node.getAttribute('content') || '').trim()); - } - - function validateManifest(manifest, issues) { - if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return; - - if (manifest.schema_version !== SCHEMA_VERSION) { - issue(issues, 'manifest-schema-version', 'error', `Set manifest.schema_version to exactly \`${SCHEMA_VERSION}\`.`, 'manifest.schema_version'); - } - - if (typeof manifest.id !== 'string' || !ID_PATTERN.test(manifest.id)) { - issue(issues, 'manifest-id', 'error', 'Set manifest.id to a stable lowercase identifier using letters, numbers, and hyphens.', 'manifest.id'); - } - - if (typeof manifest.title !== 'string' || !manifest.title.trim()) { - issue(issues, 'manifest-title', 'error', 'Add a non-empty manifest.title that a person can recognize.', 'manifest.title'); - } else if (manifest.title.length > 100) { - issue(issues, 'manifest-title-length', 'error', 'Shorten manifest.title to 100 characters or fewer.', 'manifest.title'); - } - - if (!DOCUMENT_TYPES.has(manifest.type)) { - issue(issues, 'manifest-type', 'error', 'Set manifest.type to report, brief, reference, dashboard, or note.', 'manifest.type'); - } - - if (!Array.isArray(manifest.tags)) { - issue(issues, 'manifest-tags', 'error', 'Set manifest.tags to an array of zero to eight concise lowercase tags.', 'manifest.tags'); - } else { - if (manifest.tags.length > 8) { - issue(issues, 'manifest-tags-count', 'error', 'Keep manifest.tags to at most eight tags.', 'manifest.tags'); - } - const seenTags = new Set(); - manifest.tags.forEach((tag, index) => { - const location = `manifest.tags[${index}]`; - if (typeof tag !== 'string' || !tag.trim() || tag.length > 40) { - issue(issues, 'manifest-tag-format', 'error', 'Use a non-empty tag of 40 characters or fewer.', location); - return; - } - if (tag !== tag.toLowerCase()) { - issue(issues, 'manifest-tag-case', 'warning', 'Use lowercase tags so filtering remains consistent.', location); - } - const key = tag.trim().toLowerCase(); - if (seenTags.has(key)) issue(issues, 'manifest-tag-duplicate', 'warning', `Remove duplicate tag \`${tag}\`.`, location); - seenTags.add(key); - }); - } - - if (typeof manifest.summary !== 'string' || !manifest.summary.trim()) { - issue(issues, 'manifest-summary', 'error', 'Add a concise, decision-relevant manifest.summary.', 'manifest.summary'); - } else if (manifest.summary.length > 240) { - issue(issues, 'manifest-summary-length', 'error', 'Shorten manifest.summary to 240 characters or fewer.', 'manifest.summary'); - } - - if (manifest.project !== undefined) { - if (!manifest.project || typeof manifest.project !== 'object' || Array.isArray(manifest.project)) { - issue(issues, 'manifest-project', 'error', 'When present, manifest.project must contain a stable id and a human-readable name.', 'manifest.project'); - } else { - if (typeof manifest.project.id !== 'string' || !ID_PATTERN.test(manifest.project.id) || manifest.project.id.length > 100) { - issue(issues, 'manifest-project-id', 'error', 'Set manifest.project.id to a stable lowercase identifier using letters, numbers, and hyphens.', 'manifest.project.id'); - } - if (typeof manifest.project.name !== 'string' || !manifest.project.name.trim() || manifest.project.name.length > 100) { - issue(issues, 'manifest-project-name', 'error', 'Set manifest.project.name to a recognizable workspace name of 100 characters or fewer.', 'manifest.project.name'); - } - } - } - - ['created_at', 'updated_at'].forEach((field) => { - if (!isUtcTimestamp(manifest[field])) { - issue(issues, `manifest-${field}`, 'error', `Set manifest.${field} to an ISO 8601 timestamp in UTC, for example 2026-07-13T00:00:00Z.`, `manifest.${field}`); - } - }); - if (isUtcTimestamp(manifest.created_at) && isUtcTimestamp(manifest.updated_at) - && Date.parse(manifest.updated_at) < Date.parse(manifest.created_at)) { - issue(issues, 'manifest-timestamp-order', 'error', 'Set manifest.updated_at to the same time as or later than manifest.created_at.', 'manifest.updated_at'); - } - - if (!manifest.provenance || typeof manifest.provenance !== 'object' || Array.isArray(manifest.provenance)) { - issue(issues, 'manifest-provenance', 'error', 'Add manifest.provenance with an author and a sources array.', 'manifest.provenance'); - return; - } - if (typeof manifest.provenance.author !== 'string' || !manifest.provenance.author.trim()) { - issue(issues, 'manifest-provenance-author', 'error', 'Name the person, project, or agent in manifest.provenance.author.', 'manifest.provenance.author'); - } - if (!Array.isArray(manifest.provenance.sources)) { - issue(issues, 'manifest-provenance-sources', 'error', 'Set manifest.provenance.sources to an array; use [] for original writing without external sources.', 'manifest.provenance.sources'); - return; - } - manifest.provenance.sources.forEach((source, index) => { - const location = `manifest.provenance.sources[${index}]`; - if (!source || typeof source !== 'object' || Array.isArray(source)) { - issue(issues, 'manifest-source-format', 'error', 'Use a source object with label, url, and accessed_at fields.', location); - return; - } - if (typeof source.label !== 'string' || !source.label.trim()) { - issue(issues, 'manifest-source-label', 'warning', 'Add a human-readable label for this source.', `${location}.label`); - } - if (typeof source.url !== 'string' || !/^https?:\/\//i.test(source.url.trim())) { - issue(issues, 'manifest-source-url', 'warning', 'Add an absolute http(s) URL for this source.', `${location}.url`); - } - if (!isDate(source.accessed_at)) { - issue(issues, 'manifest-source-accessed-at', 'warning', 'Add the source access date as YYYY-MM-DD.', `${location}.accessed_at`); - } - }); - } - - function validateMetadata(document, manifest, issues) { - const expected = { - 'helm:title': manifest && manifest.title, - 'helm:type': manifest && manifest.type, - 'helm:summary': manifest && manifest.summary, - 'helm:tags': manifest && Array.isArray(manifest.tags) ? manifest.tags.join(', ') : undefined - }; - - Object.entries(expected).forEach(([name, expectedValue]) => { - const values = metaValues(document, name); - if (!values.length) { - issue(issues, 'metadata-missing', 'error', `Add \`\` so simple indexers can read the artifact.`, `meta[name="${name}"]`); - return; - } - if (values.length > 1) { - issue(issues, 'metadata-duplicate', 'warning', `Keep only one \`${name}\` meta element to avoid ambiguous indexing.`, `meta[name="${name}"]`); - } - const tagMetadataMatches = name !== 'helm:tags' || !Array.isArray(manifest?.tags) - || values[0].split(',').map((tag) => tag.trim()).filter(Boolean).join('\u0000') === manifest.tags.join('\u0000'); - if (expectedValue !== undefined && (name === 'helm:tags' ? !tagMetadataMatches : values[0] !== expectedValue)) { - issue(issues, 'metadata-mismatch', 'error', `Make \`${name}\` match the corresponding manifest value.`, `meta[name="${name}"]`); - } - }); - - const title = normalizedText(document.querySelector('title')?.textContent); - if (!title) { - issue(issues, 'document-title', 'warning', 'Add a so the exported artifact has a useful browser and file label.', 'title'); - } else if (manifest?.title && title !== manifest.title) { - issue(issues, 'document-title-mismatch', 'warning', 'Make <title> match manifest.title to keep export labels consistent.', 'title'); - } - } - - function validateStructure(document, issues) { - if (!document.doctype || document.doctype.name.toLowerCase() !== 'html') { - issue(issues, 'doctype', 'error', 'Begin the artifact with <!doctype html>.', 'doctype'); - } - const html = document.documentElement; - if (!html?.getAttribute('lang')) { - issue(issues, 'document-language', 'warning', 'Set <html lang="…"> to the document language for accessibility.', 'html'); - } - if (!document.querySelector('meta[charset]')) { - issue(issues, 'document-charset', 'warning', 'Declare UTF-8 with <meta charset="utf-8">.', 'head'); - } - - const roots = [...document.querySelectorAll('[data-document-root]')]; - if (!roots.length) { - issue(issues, 'document-root-missing', 'error', 'Put the meaningful content inside one <main data-document-root> element.', 'main[data-document-root]'); - return null; - } - if (roots.length > 1) { - issue(issues, 'document-root-multiple', 'error', 'Keep exactly one data-document-root element.', '[data-document-root]'); - } - const rootElement = roots[0]; - if (rootElement.tagName !== 'MAIN') { - issue(issues, 'document-root-element', 'error', 'Use <main data-document-root>, not another element, as the document root.', '[data-document-root]'); - } - - const text = visibleText(rootElement); - if (!text) { - issue(issues, 'document-root-empty', 'error', 'Add meaningful text content inside the document root.', 'main[data-document-root]'); - } - - const headings = [...rootElement.querySelectorAll('h1, h2, h3, h4, h5, h6')]; - const h1s = headings.filter((heading) => heading.tagName === 'H1'); - if (h1s.length !== 1) { - issue(issues, 'heading-h1-count', 'error', h1s.length ? 'Keep exactly one <h1> inside the document root.' : 'Add one descriptive <h1> inside the document root.', 'main[data-document-root]'); - } - if (headings.length && headings[0].tagName !== 'H1') { - issue(issues, 'heading-order-start', 'warning', 'Start the heading outline with the document <h1>.', 'main[data-document-root]'); - } - let previousLevel = 1; - headings.forEach((heading) => { - const level = Number(heading.tagName.slice(1)); - if (level > previousLevel + 1) { - issue(issues, 'heading-order-skip', 'warning', `Do not skip from h${previousLevel} to h${level}; use an intervening heading level.`, 'main[data-document-root]'); - } - previousLevel = level; - }); - - const headingsOutsideRoot = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6')] - .filter((heading) => !rootElement.contains(heading)); - if (headingsOutsideRoot.length) { - issue(issues, 'heading-outside-root', 'warning', 'Move meaningful headings into <main data-document-root> so readers and indexers see one coherent artifact.', 'main[data-document-root]'); - } - - rootElement.querySelectorAll('a[href]').forEach((anchor) => { - const href = (anchor.getAttribute('href') || '').trim(); - const label = normalizedText(anchor.textContent || anchor.getAttribute('aria-label') || anchor.getAttribute('title')); - if (!label) issue(issues, 'link-label-missing', 'warning', 'Give each link descriptive text, aria-label, or title.', 'a[href]'); - if (GENERIC_LINK_TEXT.has(label.toLowerCase())) { - issue(issues, 'link-label-generic', 'warning', `Replace generic link text \`${label}\` with text that names its destination.`, 'a[href]'); - } - if (anchor.getAttribute('target') === '_blank' && !/\bnoopener\b/i.test(anchor.getAttribute('rel') || '')) { - issue(issues, 'link-noopener', 'warning', 'Add rel="noopener" to target="_blank" links.', 'a[href]'); - } - if (isLikelyRelativeUrl(href)) { - issue(issues, 'link-resource-local', 'warning', `Replace the relative link \`${href}\` with an absolute URL or preserve the referenced content in this artifact; sibling files are not carried with a standalone HTML document.`, 'a[href]'); - } - }); - - rootElement.querySelectorAll('img').forEach((image) => { - if (!image.hasAttribute('alt')) { - issue(issues, 'image-alt-missing', 'warning', 'Add alt text, or alt="" for a decorative image.', 'img'); - } - }); - if (text.length < 40 && rootElement.querySelector('img, svg, canvas')) { - issue(issues, 'content-mostly-visual', 'warning', 'Add enough text to preserve the artifact’s meaning when images do not load.', 'main[data-document-root]'); - } - rootElement.querySelectorAll('table').forEach((table) => { - if (!table.querySelector('th')) { - issue(issues, 'table-header-missing', 'warning', 'Use <th> cells for table headers so the table remains understandable to assistive technology.', 'table'); - } - }); - return rootElement; - } - - function validatePortabilityAndRisk(document, issues) { - document.querySelectorAll('link[rel~="stylesheet"][href]').forEach((link) => { - issue(issues, 'stylesheet-dependency', 'warning', 'Embed essential CSS in a <style> element; linked stylesheets make a standalone artifact depend on another file or host.', 'link[rel~="stylesheet"]'); - }); - - document.querySelectorAll('style').forEach((style) => { - const css = style.textContent || ''; - if (/@import\s+(?:url\()?\s*["']?(?:https?:)?\/\//i.test(css)) { - issue(issues, 'css-import-remote', 'warning', 'Remove remote CSS @import rules; they are not available in an offline artifact.', 'style'); - } - if (/@import\s+/i.test(css) && !/@import\s+(?:url\()?\s*["']?(?:https?:)?\/\//i.test(css)) { - issue(issues, 'css-import-local', 'warning', 'Inline imported CSS instead of depending on another local file.', 'style'); - } - const urlPattern = /url\(\s*(['"]?)([^'"\)]+)\1\s*\)/gi; - let match; - while ((match = urlPattern.exec(css))) { - const url = match[2].trim(); - if (isLikelyRelativeUrl(url)) { - issue(issues, 'css-resource-local', 'warning', `Inline the CSS resource \`${url}\` or use a data URL so the artifact remains portable.`, 'style'); - } else if (isRemoteUrl(url)) { - issue(issues, 'css-resource-remote', 'warning', `Remote CSS resource \`${url}\` is only safe as a progressive enhancement; keep the document usable without it.`, 'style'); - } - } - }); - - document.querySelectorAll('img[src], audio[src], video[src], source[src], track[src]').forEach((node) => { - const src = (node.getAttribute('src') || '').trim(); - if (isLikelyRelativeUrl(src)) { - issue(issues, 'media-resource-local', 'warning', `Inline the ${node.tagName.toLowerCase()} resource \`${src}\` or make it non-essential; relative files are not part of a standalone HTML artifact.`, node.tagName.toLowerCase()); - } else if (isRemoteUrl(src)) { - issue(issues, 'media-resource-remote', 'warning', `Remote ${node.tagName.toLowerCase()} resource \`${src}\` should remain a progressive enhancement.`, node.tagName.toLowerCase()); - } - }); - - document.querySelectorAll('script').forEach((script) => { - const isManifest = script.hasAttribute('data-helm-manifest'); - const type = (script.getAttribute('type') || '').trim().toLowerCase(); - const executable = !type || type === 'module' || /(?:java|ecma)script/.test(type); - if (!isManifest && executable) { - const source = (script.getAttribute('src') || '').trim(); - issue(issues, source ? 'script-external' : 'script-inline', 'warning', source - ? `Remove or clearly justify executable script dependency \`${source}\`; Helm treats artifacts as untrusted documents.` - : 'Remove or clearly justify inline executable JavaScript; Helm documents should remain readable without code execution.', 'script'); - } - }); - - document.querySelectorAll('*').forEach((node) => { - [...node.attributes].forEach((attribute) => { - if (/^on/i.test(attribute.name)) { - issue(issues, 'event-handler', 'error', `Remove the ${attribute.name} event handler from <${node.tagName.toLowerCase()}>; documents should not execute embedded actions.`, node.tagName.toLowerCase()); - } - if (['href', 'src', 'action', 'data'].includes(attribute.name.toLowerCase()) && /^\s*javascript:/i.test(attribute.value)) { - issue(issues, 'javascript-url', 'error', `Remove the javascript: URL from <${node.tagName.toLowerCase()}>.`, node.tagName.toLowerCase()); - } - }); - }); - - const hasMetaRefresh = [...document.querySelectorAll('meta[http-equiv]')] - .some((meta) => (meta.getAttribute('http-equiv') || '').toLowerCase() === 'refresh'); - if (hasMetaRefresh) { - issue(issues, 'meta-refresh', 'warning', 'Remove automatic meta refresh redirects from a portable document.', 'meta[http-equiv="refresh"]'); - } - if (document.querySelector('base[href]')) { - issue(issues, 'base-url', 'warning', 'Remove <base>; it changes how relative links resolve after export.', 'base'); - } - document.querySelectorAll('iframe, object, embed').forEach((node) => { - issue(issues, 'embedded-content', 'warning', `Avoid <${node.tagName.toLowerCase()}> dependencies; preserve essential content directly in the artifact.`, node.tagName.toLowerCase()); - }); - document.querySelectorAll('form').forEach(() => { - issue(issues, 'form-interaction', 'warning', 'Avoid forms that depend on a host service; preserve the information as document content instead.', 'form'); - }); - } - - function validateVisualEvidence(document, rootElement, manifest, issues) { - if (!rootElement) return; - const components = [...rootElement.querySelectorAll('[data-helm-component]')]; - const decisionFacing = ['report', 'brief', 'dashboard'].includes(manifest?.type); - if (decisionFacing && !components.length && !rootElement.querySelector('figure, table')) { - issue(issues, 'visual-evidence-missing', 'warning', 'Add a meaningful visual component for the report’s material comparison, sequence, hierarchy, magnitude, or uncertainty—or state why prose is sufficient.', 'main[data-document-root]'); - } - - const renderedClaims = new Map(); - components.forEach((component, index) => { - const name = (component.getAttribute('data-helm-component') || '').trim(); - const claim = (component.getAttribute('data-helm-claim') || '').trim(); - const state = (component.getAttribute('data-evidence-state') || '').trim(); - const label = name || `visual component ${index + 1}`; - if (!VISUAL_COMPONENTS.has(name)) { - issue(issues, 'visual-component-unknown', 'warning', `Use a registered Helm component name instead of "${name || 'missing'}", or document the extension.`, '[data-helm-component]'); - } - if (!claim) { - issue(issues, 'visual-claim-missing', 'warning', `Bind ${label} to a claim with data-helm-claim.`, '[data-helm-component]'); - } else if (renderedClaims.has(claim)) { - issue(issues, 'visual-claim-duplicate', 'warning', `Claim "${claim}" is implemented by more than one component; keep one primary visual or declare separate claims.`, '[data-helm-component]'); - } else { - renderedClaims.set(claim, name); - } - if (!EVIDENCE_STATES.has(state)) { - issue(issues, 'visual-evidence-state', 'warning', `Set ${label} data-evidence-state to measured, verified, interpreted, proposed, or illustrative.`, '[data-helm-component]'); - } - if (!component.getAttribute('data-scope')?.trim()) { - issue(issues, 'visual-scope-missing', 'warning', `Add data-scope to ${label} so its environment, denominator, or interpretation boundary stays visible.`, '[data-helm-component]'); - } - if (['measured', 'verified'].includes(state) && !component.getAttribute('data-source')?.trim()) { - issue(issues, 'visual-source-missing', 'warning', `Add data-source to ${label}; ${state} evidence must identify its source or method.`, '[data-helm-component]'); - } - if (component.matches('figure') && !component.querySelector('figcaption')) { - issue(issues, 'visual-caption-missing', 'warning', `Add a figcaption to ${label} with source, method, and boundary.`, '[data-helm-component]'); - } - const hasGraphic = Boolean(component.querySelector('svg, img, canvas, [role="img"]')); - if (hasGraphic && !component.querySelector('[data-helm-fallback], table, ol, ul')) { - issue(issues, 'visual-fallback-missing', 'warning', `Add a text, list, or table fallback to ${label}.`, '[data-helm-component]'); - } - if (component.classList.contains('placeholder') || component.hasAttribute('data-placeholder')) { - issue(issues, 'visual-placeholder', 'error', `Replace the placeholder content in ${label} and remove its placeholder marker before handoff.`, '[data-helm-component]'); - } - }); - - const claims = manifest?.presentation?.claims; - if (claims !== undefined && !Array.isArray(claims)) { - issue(issues, 'presentation-claims-format', 'warning', 'Set manifest.presentation.claims to an array of claim/component mappings.', 'manifest.presentation.claims'); - return; - } - (claims || []).forEach((entry, index) => { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { - issue(issues, 'presentation-claim-format', 'warning', 'Use an object with id, relationship, and component for each presentation claim.', `manifest.presentation.claims[${index}]`); - return; - } - const actual = renderedClaims.get(entry.id); - if (!actual) { - issue(issues, 'presentation-claim-unrendered', 'error', `Render the declared claim "${entry.id || index + 1}" with its selected component.`, `manifest.presentation.claims[${index}]`); - } else if (actual !== entry.component) { - issue(issues, 'presentation-component-mismatch', 'error', `Claim "${entry.id}" declares "${entry.component}" but renders "${actual}".`, `manifest.presentation.claims[${index}]`); - } - if (typeof entry.relationship !== 'string' || !entry.relationship.trim()) { - issue(issues, 'presentation-relationship-missing', 'warning', `Name the relationship claim "${entry.id || index + 1}" needs the component to explain.`, `manifest.presentation.claims[${index}].relationship`); - } - }); - } - - /** - * Validate an HTML source string against the HDOC/1.0 document contract. - * - * `options.DOMParser` lets Node-based callers supply a DOM implementation. - */ - function validate(htmlSource, options = {}) { - const issues = []; - if (typeof htmlSource !== 'string') { - issue(issues, 'input-not-string', 'error', 'Pass the complete HTML source as a string.', 'input'); - return { valid: false, score: 0, issues, manifest: null, extractedText: '' }; - } - - const Parser = options.DOMParser || root?.DOMParser; - if (typeof Parser !== 'function') { - issue(issues, 'domparser-unavailable', 'error', 'A DOMParser implementation is required to validate HTML. In Node, pass { DOMParser } from a DOM environment.', 'environment'); - return { valid: false, score: 0, issues, manifest: null, extractedText: '' }; - } - - let document; - try { - document = new Parser().parseFromString(htmlSource, 'text/html'); - } catch (error) { - issue(issues, 'html-parse-failed', 'error', `HTML could not be parsed: ${error.message || 'unknown parser error'}.`, 'input'); - return { valid: false, score: 0, issues, manifest: null, extractedText: '' }; - } - - let manifest = null; - const manifestNodes = [...document.querySelectorAll('[data-helm-manifest]')]; - if (!manifestNodes.length) { - issue(issues, 'manifest-missing', 'error', 'Add one <script type="application/json" data-helm-manifest> block.', '[data-helm-manifest]'); - } else { - if (manifestNodes.length > 1) { - issue(issues, 'manifest-multiple', 'error', 'Keep exactly one Helm manifest block.', '[data-helm-manifest]'); - } - const manifestNode = manifestNodes[0]; - if (manifestNode.tagName !== 'SCRIPT') { - issue(issues, 'manifest-element', 'error', 'Put the Helm manifest in a <script> element.', '[data-helm-manifest]'); - } - if ((manifestNode.getAttribute('type') || '').trim().toLowerCase() !== 'application/json') { - issue(issues, 'manifest-content-type', 'error', 'Set the Helm manifest script type to application/json.', '[data-helm-manifest]'); - } - try { - manifest = JSON.parse(manifestNode.textContent || ''); - if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { - issue(issues, 'manifest-json-object', 'error', 'Use a JSON object for the Helm manifest.', '[data-helm-manifest]'); - manifest = null; - } - } catch (error) { - issue(issues, 'manifest-json-invalid', 'error', `Fix the Helm manifest JSON: ${error.message || 'invalid JSON'}.`, '[data-helm-manifest]'); - } - } - - validateManifest(manifest, issues); - validateMetadata(document, manifest, issues); - const rootElement = validateStructure(document, issues); - validatePortabilityAndRisk(document, issues); - validateVisualEvidence(document, rootElement, manifest, issues); - - const score = Math.max(0, 100 - issues.reduce((total, item) => total + SEVERITY_WEIGHT[item.severity], 0)); - const severityOrder = { error: 0, warning: 1, info: 2 }; - issues.sort((left, right) => severityOrder[left.severity] - severityOrder[right.severity] || left.code.localeCompare(right.code)); - return { - valid: !issues.some((item) => item.severity === 'error'), - score, - issues, - manifest, - extractedText: visibleText(rootElement || document.body) - }; - } - - return Object.freeze({ SCHEMA_VERSION, VALIDATOR_VERSION, validate }); -}));