diff --git a/.devia/01_ARCHITECTURE.md b/.devia/01_ARCHITECTURE.md index 4ed9d5b..27b07db 100644 --- a/.devia/01_ARCHITECTURE.md +++ b/.devia/01_ARCHITECTURE.md @@ -11,7 +11,7 @@ src/cli.mjs argument parsing, command table, context (root, .devia, f ↓ src/commands/*.mjs one file per command, each exporting a default (ctx, name) => exit code ↓ -src/lib/*.mjs yaml · fs · git · rules · ui · vendor · version — no command logic here +src/lib/*.mjs yaml · markdown · fs · git · rules · ui · vendor · version — no command logic ↓ content rules/ · standard/ · checklists/ · templates/ (read, never imported) ``` @@ -52,6 +52,8 @@ Content is data. Code reads it; code never encodes what a rule says. |---|---|---| | No runtime dependencies | The tool that preaches `ARC-004` cannot carry a tree of its own | `.cursor/rules/devia.mdc`, `CONTRIBUTING.md` | | Own YAML subset parser | Frontmatter and impact maps only; devia writes the files it reads | `src/lib/yaml.mjs` header | +| Own Markdown subset renderer | Same reason, same bar: the memory templates use a known subset, and `ARC-004` rules out a library. A line outside the subset is shown as written, never dropped | `src/lib/markdown.mjs` header | +| `devia read` embeds its content | A reader that needs a server is a reader nobody opens: the page works by double-click, offline | `src/commands/read.mjs` | | Rule IDs are stable and never reused | Citations in old reports must keep resolving | `GOVERNANCE.md`, `rules/LIFECYCLE.md` | | Pinning the standard into `.devia/standard/` is opt-in | 391 copied files against 17 of memory buries what the folder is for, and every sync becomes a 391-file diff. `devia rules` reads the standard; `devia sync` pins it for offline agents and audits | `src/commands/init.mjs`, `.devia/11_GAPS.md` G8 | | The CLI version and the standard version move separately | A CLI fix must not force an adopter to re-pin the corpus | `src/lib/version.mjs` | diff --git a/.devia/02_SURFACES.md b/.devia/02_SURFACES.md index 498b068..ab7072a 100644 --- a/.devia/02_SURFACES.md +++ b/.devia/02_SURFACES.md @@ -12,6 +12,7 @@ | `devia check` | Readiness gates | `src/commands/check.mjs` | 1 on any P0 FAIL | | `devia doctor` | Adoption, drift, staleness | `src/commands/doctor.mjs` | 1 when there is no `.devia/` | | `devia rules` | Query the registry | `src/commands/rules.mjs` | 1 when `--id` is unknown | +| `devia read` | Render the memory as one self-contained page | `src/commands/read.mjs` | 1 without `.devia/` | | `devia sync` | Pin the standard, or refresh a pinned copy | `src/commands/sync.mjs` | 1 without `.devia/` | | `devia skills` | Install adapters and the skill pack, per repository or `--global` | `src/commands/skills.mjs` | 2 on a bad action | | `devia gap` / `devia debt` | Registry lines | `src/commands/registry.mjs` | 1 when the id is unknown | @@ -37,6 +38,7 @@ say where, or `--yes` to accept it. Nothing is written before that question is s | `init` | `.devia/` (memory, `devia.json`, `impact-map.yaml`, `standard/`) | | `init`, `skills install` | `AGENTS.md`, `CLAUDE.md`, `.cursor/rules/devia.mdc`, `.github/copilot-instructions.md`, `.windsurfrules` | | `skills install --skill` | `.cursor/skills/devia/SKILL.md`, `.claude/skills/devia/SKILL.md` | +| `read` | `.devia/reader.html` — a generated snapshot, gitignored, never the source | | `skills install --global` | Outside the repository, in each agent's own configuration: `~/.claude/skills/devia/`, `~/.codex/skills/devia/`, `~/.cursor/rules/devia.mdc`, `~/.gemini/GEMINI.md` when empty. Copilot and Windsurf report `SKIP` (`12_DEBT.md` D8) | `files` in `package.json` decides what npm ships. Adding a directory the CLI reads at runtime diff --git a/.devia/11_GAPS.md b/.devia/11_GAPS.md index 3c728cb..3a9fb60 100644 --- a/.devia/11_GAPS.md +++ b/.devia/11_GAPS.md @@ -23,3 +23,4 @@ Add one with `npx devia gap add "question"`. | G6 | Should devia install its contract at user level for every agent, or stay per-repository outside Claude Code? | devia is for every agent — user-level install built for Claude Code, Codex, Cursor and Gemini in 0.3.0 | | G1 | Should `devia check` grow ecosystem-specific gates (Python, Go, Rust) or stay deliberately generic? | Reframed: the failure was not the ecosystem but the assumption that the manifest sits at the repository root — fixed in 0.4.0. Ecosystem-specific gates remain out of scope | | G8 | Should devia pin a copy of the standard into every adopter repository by default? | No — opt-in via devia sync. Measured on a real repository: 391 pinned files against 17 of memory, tripling a 211-file project and turning every sync into a 391-file diff | +| G9 | Should devia render the memory itself, or leave reading to whatever the project already has? | devia renders it: `devia read` writes one self-contained page — no server, no network, no dependency (ARC-004) | diff --git a/.gitignore b/.gitignore index c1b7773..74306e7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ build/ .env.* !.env.example +# Généré par `devia read` — un instantané de la mémoire, pas la mémoire +.devia/reader.html + # Local secrets / audits *.pem *.key diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b1511..f2df014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 0.6.0 — 2026-09-09 + +The standard is unchanged: `VERSION` stays at 0.1.0. + +### `devia read` — the memory as one page + +A memory nobody reads is documentation with extra steps. `devia read` renders `.devia/` into a +single HTML file: a sidebar in reading order — the contract first, then the numbered files — and +links between memory files that jump inside the page instead of asking the filesystem for them. + +The page is **self-contained**: content embedded, no fetch, no CDN, no stylesheet to resolve. It +opens by double-click, offline. The reader that prompted this one needed a running local server, +because a page that fetches its own content hits CORS on `file://`. + +- `devia read` writes `.devia/reader.html`; `--out` puts it elsewhere +- It is a **snapshot**, never the source: regenerate it after changing the memory. Adopters + should gitignore it, as this repository now does +- `src/lib/markdown.mjs` renders the subset the memory templates actually use — headings, + tables, fenced code, lists, quotes, rules, and a few inline marks. Same reasoning as the YAML + parser: devia writes the files it reads, and `ARC-004` rules out a library. Content is escaped + before anything else, so memory text cannot inject markup, and a line outside the subset is + shown as written rather than dropped + +Recorded as G9. + +### Fixed + +- `devia --help` still described `sync` as refreshing a vendored standard, which 0.5.0 made + opt-in + ## 0.5.0 — 2026-09-09 The standard is unchanged: `VERSION` stays at 0.1.0. diff --git a/package.json b/package.json index 2b142f4..3b5cd8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schneiderjoseph/devia", - "version": "0.5.0", + "version": "0.6.0", "description": "One standard, one memory: engineering and design rules plus living project memory for AI coding agents", "type": "module", "license": "MIT", diff --git a/src/cli.mjs b/src/cli.mjs index 178fc66..b8e16a9 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -10,6 +10,7 @@ const COMMANDS = { check: () => import("./commands/check.mjs"), doctor: () => import("./commands/doctor.mjs"), rules: () => import("./commands/rules.mjs"), + read: () => import("./commands/read.mjs"), sync: () => import("./commands/sync.mjs"), skills: () => import("./commands/skills.mjs"), gap: () => import("./commands/registry.mjs"), @@ -24,7 +25,8 @@ ${color.bold("devia")} — one standard, one memory ${color.bold("devia check")} readiness gates — P0 failures exit non-zero ${color.bold("devia doctor")} adoption, drift and staleness diagnosis ${color.bold("devia rules")} list or show rules from the registry - ${color.bold("devia sync")} refresh the vendored standard after upgrading devia + ${color.bold("devia read")} render the memory as one self-contained page + ${color.bold("devia sync")} pin the standard under .devia/standard/, or refresh it ${color.bold("devia skills")} install the agent adapters (install --agent all) ${color.bold("devia gap")} add or close a line in 11_GAPS.md ${color.bold("devia debt")} add or close a line in 12_DEBT.md diff --git a/src/commands/read.mjs b/src/commands/read.mjs new file mode 100644 index 0000000..3a2d769 --- /dev/null +++ b/src/commands/read.mjs @@ -0,0 +1,161 @@ +import path from "node:path"; +import { exists, read, writeFile, walk } from "../lib/fs.mjs"; +import { renderMarkdown, escapeHtml } from "../lib/markdown.mjs"; +import { cliVersion } from "../lib/version.mjs"; +import { color, heading, status, line } from "../lib/ui.mjs"; + +/** + * The memory, as one page a human can read. + * + * Self-contained on purpose: the content is embedded, so the file opens by double-click with no + * server, no network and no CDN. A reader that needs a running server is a reader nobody opens. + */ + +/** Reading order: the contract first, then the numbered files, then whatever else is there. */ +function order(files) { + const rank = (f) => { + if (f === "AGENTS.md") return -2; + if (f === "README.md") return -1; + const n = f.match(/^(\d+)_/); + return n ? Number(n[1]) : 999; + }; + return files.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); +} + +function title(file) { + if (file === "AGENTS.md") return "AGENTS"; + const body = file.replace(/\.md$/, ""); + const m = body.match(/^(\d+)_(.*)$/); + return m ? `${m[1]} · ${m[2].replace(/_/g, " ")}` : body.replace(/_/g, " "); +} + +const STYLE = ` +:root{--ink:#121826;--ink-line:#222F45;--paper:#fff;--canvas:#EFEEEA;--line:#E7E7EA; +--muted:#6B7280;--muted-d:rgba(229,231,235,.62);--text-d:#E5E7EB;--accent:#FF5A5F; +--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace} +*{box-sizing:border-box} +body{margin:0;background:var(--canvas);color:var(--ink); +font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;display:flex;min-height:100vh} +nav{width:290px;flex:0 0 290px;background:var(--ink);color:var(--text-d);position:sticky;top:0; +height:100vh;overflow:auto;padding:22px 16px} +nav h1{font-size:13px;letter-spacing:.28em;text-transform:uppercase;font-family:var(--mono); +color:var(--muted-d);margin:0 0 4px} +nav .sub{font-size:12px;color:var(--muted-d);margin-bottom:18px;line-height:1.4} +nav a{display:block;padding:7px 10px;border-radius:8px;color:var(--text-d);text-decoration:none; +font-size:13px;margin-bottom:2px} +nav a:hover{background:rgba(255,255,255,.06)} +nav a.active{background:rgba(255,90,95,.16);outline:1px solid rgba(255,90,95,.25)} +main{flex:1;min-width:0;padding:44px 56px;background:var(--paper)} +article{max-width:820px;display:none} +article.active{display:block} +h1,h2,h3,h4{line-height:1.25;margin:1.8em 0 .6em} +h1{font-size:30px;margin-top:0}h2{font-size:22px}h3{font-size:17px}h4{font-size:15px} +h2{border-top:1px solid var(--line);padding-top:1.2em} +p{margin:.7em 0} +a{color:var(--accent)} +code{font-family:var(--mono);font-size:.88em;background:var(--canvas);padding:2px 5px;border-radius:4px} +pre{background:var(--ink);color:var(--text-d);padding:16px 18px;border-radius:10px;overflow:auto} +pre code{background:none;color:inherit;padding:0;font-size:12.5px;line-height:1.55} +table{border-collapse:collapse;width:100%;margin:1.1em 0;font-size:14px;display:block;overflow-x:auto} +th,td{border:1px solid var(--line);padding:8px 11px;text-align:left;vertical-align:top} +th{background:var(--canvas);font-weight:600} +blockquote{margin:1.1em 0;padding:2px 0 2px 16px;border-left:3px solid var(--accent);color:var(--muted)} +hr{border:0;border-top:1px solid var(--line);margin:2em 0} +ul,ol{padding-left:1.3em}li{margin:.3em 0} +footer{margin-top:48px;padding-top:16px;border-top:1px solid var(--line); +font-family:var(--mono);font-size:11px;color:var(--muted)} +@media(max-width:820px){body{display:block}nav{width:auto;height:auto;position:static}main{padding:28px 20px}} +`; + +const SCRIPT = ` +var links = [].slice.call(document.querySelectorAll('nav a')); +var docs = [].slice.call(document.querySelectorAll('article')); +function show(id) { + docs.forEach(function (d) { d.classList.toggle('active', d.id === id); }); + links.forEach(function (a) { a.classList.toggle('active', a.getAttribute('href') === '#' + id); }); + if (location.hash !== '#' + id) history.replaceState(null, '', '#' + id); + window.scrollTo(0, 0); +} +links.forEach(function (a) { + a.addEventListener('click', function (e) { e.preventDefault(); show(a.getAttribute('href').slice(1)); }); +}); +// A link between memory files jumps inside the page instead of asking the filesystem for it. +[].slice.call(document.querySelectorAll('article a')).forEach(function (a) { + var href = a.getAttribute('href') || ''; + if (!/^[^/:#]+\\.md(#.*)?$/.test(href)) return; + var id = 'doc-' + href.split('#')[0].replace(/\\.md$/, ''); + if (!document.getElementById(id)) return; + a.addEventListener('click', function (e) { e.preventDefault(); show(id); }); +}); +show((location.hash || '').slice(1) || docs[0].id); +`; + +export default async function readCommand(ctx) { + const { root, deviaDir, flags } = ctx; + + if (flags.help) { + line(` +${color.bold("devia read")} — the memory as one self-contained page + + --root repository to read + --out where to write (default: .devia/reader.html) + +The page embeds the memory: it opens by double-click, with no server and no network. +Regenerate it after changing the memory — it is a snapshot, never the source. +`.trim()); + return 0; + } + + if (!exists(deviaDir)) { + status("FAIL", "no .devia/", "run `devia init` first"); + return 1; + } + + const files = order(walk(deviaDir, { filter: (f) => f.endsWith(".md") && !f.includes(path.sep) })); + if (!files.length) { + status("FAIL", "no memory files to read", ".devia/ holds no markdown"); + return 1; + } + + const project = path.basename(root); + const nav = []; + const articles = []; + for (const file of files) { + const id = `doc-${file.replace(/\.md$/, "")}`; + nav.push(`${escapeHtml(title(file))}`); + articles.push( + `
\n${renderMarkdown(read(path.join(deviaDir, file)) || "")}\n` + + `
${escapeHtml(file)} · .devia/
` + ); + } + + const today = new Date().toISOString().slice(0, 10); + const html = ` + + +${escapeHtml(project)} — memory + + + +
+${articles.join("\n")} +
+ + +`; + + const out = flags.out ? path.resolve(String(flags.out)) : path.join(deviaDir, "reader.html"); + writeFile(out, html); + + heading(`devia read — ${project}`); + status("PASS", `${files.length} memory files rendered`, `${Math.round(html.length / 1024)} kB`); + status("PASS", "written", out); + line(""); + line(color.dim(" Open it directly — no server needed. Regenerate after changing the memory.")); + line(color.dim(` Generated ${today} by devia ${cliVersion()}.`)); + line(""); + return 0; +} diff --git a/src/lib/markdown.mjs b/src/lib/markdown.mjs new file mode 100644 index 0000000..4ad97f8 --- /dev/null +++ b/src/lib/markdown.mjs @@ -0,0 +1,179 @@ +/** + * A Markdown subset, rendered to HTML. + * + * The same reasoning as `yaml.mjs`: devia writes the files it reads. The memory templates use + * headings, tables, fenced code, lists, blockquotes, rules and a few inline marks — so that is + * what this renders. Anything outside the subset survives as escaped text rather than being + * silently swallowed, because a reader that drops a line is worse than one that shows it plainly. + * + * A dependency would be the easy answer here, and `ARC-004` says it is not available. + */ + +const ESCAPES = [ + ["&", "&"], + ["<", "<"], + [">", ">"], + ['"', """], +]; + +export function escapeHtml(text) { + let out = String(text); + for (const [from, to] of ESCAPES) out = out.split(from).join(to); + return out; +} + +/** + * Inline marks, applied to already-escaped text. Code spans are lifted out first so their + * contents are never re-parsed. The placeholder is NUL-delimited on purpose: prose contains + * " 12 ", and a placeholder prose can produce turns a number into a code span. + */ +const MARK = "\u0000"; + +function inline(text) { + const spans = []; + let out = text.replace(/`([^`]+)`/g, (_, code) => { + spans.push(code); + return `${MARK}${spans.length - 1}${MARK}`; + }); + + out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, href) => { + const safe = /^(https?:|mailto:|#|[\w./-])/.test(href) ? href : "#"; + return `${label}`; + }); + out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); + out = out.replace(/(^|[\s(])\*([^*\n]+)\*(?=[\s).,;:!?]|$)/g, "$1$2"); + + // Regex littérale avec des échappements \u0000 : aucun octet de contrôle dans la source. + return out.replace(/\u0000(\d+)\u0000/g, (_, i) => `${spans[Number(i)]}`); +} + +function isTableSeparator(line) { + return /^\|[\s:|-]+\|$/.test(line.trim()) && line.includes("-"); +} + +function cells(line) { + return line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((c) => inline(c.trim())); +} + +/** Render a Markdown subset to an HTML fragment. */ +export function renderMarkdown(source) { + const lines = escapeHtml(source).split(/\r?\n/); + const html = []; + let i = 0; + + const flushList = (tag, items) => { + html.push(`<${tag}>`); + for (const item of items) html.push(`
  • ${inline(item)}
  • `); + html.push(``); + }; + + while (i < lines.length) { + const line = lines[i]; + + if (!line.trim()) { + i++; + continue; + } + + // Fenced code: kept verbatim, never re-parsed. + const fence = line.match(/^```(\w*)\s*$/); + if (fence) { + const body = []; + i++; + while (i < lines.length && !/^```\s*$/.test(lines[i])) body.push(lines[i++]); + i++; + const lang = fence[1] ? ` data-lang="${fence[1]}"` : ""; + html.push(`${body.join("\n")}`); + continue; + } + + const heading = line.match(/^(#{1,4})\s+(.*)$/); + if (heading) { + const level = heading[1].length; + html.push(`${inline(heading[2].trim())}`); + i++; + continue; + } + + if (/^(-{3,}|\*{3,})\s*$/.test(line.trim())) { + html.push("
    "); + i++; + continue; + } + + // Table: a header row followed by a separator row. + if (line.trim().startsWith("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) { + const head = cells(line); + i += 2; + const body = []; + while (i < lines.length && lines[i].trim().startsWith("|")) body.push(cells(lines[i++])); + html.push(""); + for (const c of head) html.push(``); + html.push(""); + for (const row of body) { + html.push(""); + for (const c of row) html.push(``); + html.push(""); + } + html.push("
    ${c}
    ${c}
    "); + continue; + } + + // Les lignes sont déjà échappées : le chevron d'une citation y est ">", pas ">". + if (/^>\s?/.test(line)) { + const quote = []; + while (i < lines.length && /^>\s?/.test(lines[i])) { + quote.push(lines[i++].replace(/^>\s?/, "")); + } + html.push(`
    ${inline(quote.join(" "))}
    `); + continue; + } + + if (/^\s*[-*]\s+/.test(line)) { + const items = []; + while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { + let item = lines[i++].replace(/^\s*[-*]\s+/, ""); + // A wrapped bullet continues on the next indented, non-marker line. + while (i < lines.length && /^\s{2,}\S/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i])) { + item += " " + lines[i++].trim(); + } + items.push(item); + } + flushList("ul", items); + continue; + } + + if (/^\s*\d+\.\s+/.test(line)) { + const items = []; + while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { + let item = lines[i++].replace(/^\s*\d+\.\s+/, ""); + while (i < lines.length && /^\s{2,}\S/.test(lines[i]) && !/^\s*\d+\.\s+/.test(lines[i])) { + item += " " + lines[i++].trim(); + } + items.push(item); + } + flushList("ol", items); + continue; + } + + // Paragraph: consecutive plain lines, joined. + const para = []; + while ( + i < lines.length && + lines[i].trim() && + !/^(#{1,4}\s|```|>|\s*[-*]\s|\s*\d+\.\s|\|)/.test(lines[i]) + ) { + para.push(lines[i++].trim()); + } + if (para.length) html.push(`

    ${inline(para.join(" "))}

    `); + // A line the subset does not recognise is shown as written, never dropped. + else html.push(`

    ${inline(lines[i++].trim())}

    `); + } + + return html.join("\n"); +} diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 402cb49..ff8e593 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -303,6 +303,42 @@ test("skills install writes outside the project only behind --global", () => { } }); +// A reader that needs a running server is a reader nobody opens, so the page carries its own +// content: no fetch, no CDN, no stylesheet to resolve. +test("read renders the memory into one self-contained page", () => { + const dir = scratch(); + try { + devia(["init", "--root", dir], dir); + const out = devia(["read", "--root", dir], dir).out; + const page = fs.readFileSync(path.join(dir, ".devia", "reader.html"), "utf8"); + + assert.match(out, /memory files rendered/); + for (const external of ["fetch(", "http://", "https://", " in it.'); + assert.ok(!html.includes("