honestdocs 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/docs.mjs ADDED
@@ -0,0 +1,139 @@
1
+ // The document set: finding the files, reading them, and deriving the fields nobody
2
+ // should type.
3
+ //
4
+ // One rule runs through all of it: a field a person has to remember to update is a field
5
+ // that will be wrong. So `progress`, `files`, `superseded-by` and `updated` are derived —
6
+ // from the checklist in the body, from git, from the other document's `supersedes` — and
7
+ // overwritten on every `index`. Only `title`, `status`, `supersedes` and `related` are
8
+ // written by hand, because only a person knows them.
9
+
10
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
11
+ import { basename, dirname, join, relative } from "node:path";
12
+ import { parse, stringify } from "./frontmatter.mjs";
13
+ import { filesForAllDocs, isShallow, lastChangeDates, toplevel, uncommittedFiles } from "./git.mjs";
14
+
15
+ /** Files in the documents folder that are about the system, not documents in it. */
16
+ const NOT_DOCUMENTS = new Set(["INDEX.md", "README.md"]);
17
+ const SKIP_DIRS = new Set([".git", "node_modules", "assets"]);
18
+
19
+ const idOf = (file) => basename(file, ".md");
20
+ export const today = () => new Date().toISOString().slice(0, 10);
21
+
22
+ export function slugify(s) {
23
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
24
+ }
25
+
26
+ /** Every document file, sorted, as absolute paths. */
27
+ function listFiles(config) {
28
+ const out = [];
29
+ const skip = config.templatesDir;
30
+ const walk = (dir) => {
31
+ for (const entry of readdirSync(dir)) {
32
+ if (SKIP_DIRS.has(entry)) continue;
33
+ const p = join(dir, entry);
34
+ if (statSync(p).isDirectory()) {
35
+ if (p !== skip) walk(p);
36
+ } else if (entry.endsWith(".md") && !(dir === config.docsDir && NOT_DOCUMENTS.has(entry))) out.push(p);
37
+ }
38
+ };
39
+ if (existsSync(config.docsDir)) walk(config.docsDir);
40
+ return out.sort();
41
+ }
42
+
43
+ const relPath = (config, file) => relative(config.docsDir, file).split("\\").join("/");
44
+
45
+ /** The area a file sits in, from its folder; null at the top level. */
46
+ export function folderArea(config, file) {
47
+ const dir = dirname(relPath(config, file));
48
+ return dir === "." ? null : dir.split("/")[0];
49
+ }
50
+
51
+ /** Read every document: `{ file, rel, id, meta, body }`, meta `{}` when missing (`hasMeta` false). */
52
+ export function loadDocs(config) {
53
+ return listFiles(config).map((file) => {
54
+ const { meta, body } = parse(readFileSync(file, "utf8"));
55
+ return { file, rel: relPath(config, file), id: idOf(file), meta: meta ?? {}, hasMeta: !!meta, body };
56
+ });
57
+ }
58
+
59
+ export function writeDoc(doc) {
60
+ writeFileSync(doc.file, stringify(doc.meta) + doc.body);
61
+ }
62
+
63
+ export function findDoc(docs, id) {
64
+ return docs.find((d) => d.id === id) ?? null;
65
+ }
66
+
67
+ /** `done/total` over `- [x]` and `- [ ]` in the body; "" when there is no checklist. */
68
+ export function checklistProgress(body) {
69
+ const done = (body.match(/^\s*[-*] \[[xX]\]/gm) ?? []).length;
70
+ const open = (body.match(/^\s*[-*] \[ \]/gm) ?? []).length;
71
+ return done + open === 0 ? "" : `${done}/${done + open}`;
72
+ }
73
+
74
+ /** The unticked checklist lines, for `brief`. */
75
+ export function openItems(body) {
76
+ return [...body.matchAll(/^\s*[-*] \[ \]\s*(.+)$/gm)].map((m) => m[1].trim());
77
+ }
78
+
79
+ /**
80
+ * What git can tell about the set. `available` is false outside a repository and in a
81
+ * shallow clone, where every derived value would be wrong — better to derive nothing and
82
+ * say so than to write a wrong answer into a hundred files.
83
+ */
84
+ export function gitFacts(config) {
85
+ const docsTop = toplevel(config.docsDir);
86
+ const shallow = !!docsTop && (isShallow(config.docsDir) || config.codeRepos.some((r) => toplevel(r) && isShallow(r)));
87
+ if (!docsTop || shallow) {
88
+ return { available: false, reason: docsTop ? "shallow clone" : "not a git repository", dates: {}, files: {}, dirty: new Set() };
89
+ }
90
+ return {
91
+ available: true,
92
+ dates: lastChangeDates(config.docsDir),
93
+ files: filesForAllDocs(config),
94
+ dirty: uncommittedFiles(config.docsDir),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Compute the derived fields for every document, in memory. Returns the documents whose
100
+ * frontmatter changed, each with `before` and the new `meta`. Nothing is written here —
101
+ * `index` writes, `check` only compares.
102
+ */
103
+ export function derive(config, docs, facts = gitFacts(config)) {
104
+ const replacedBy = new Map();
105
+ for (const d of docs) for (const t of asList(d.meta.supersedes)) replacedBy.set(t, d.id);
106
+
107
+ const changed = [];
108
+ for (const d of docs) {
109
+ if (!d.hasMeta || !d.meta.id) continue;
110
+ const before = JSON.stringify(d.meta);
111
+ const meta = { ...d.meta };
112
+
113
+ const progress = checklistProgress(d.body);
114
+ if (progress || meta.progress) meta.progress = progress;
115
+
116
+ if (facts.available) {
117
+ const files = facts.files[d.id] ?? [];
118
+ if (files.length || asList(meta.files).length) meta.files = files;
119
+ const last = facts.dirty.has(d.rel) ? today() : facts.dates[d.rel];
120
+ if (last) meta.updated = last;
121
+ }
122
+
123
+ const by = replacedBy.get(d.id);
124
+ if (by) {
125
+ meta["superseded-by"] = by;
126
+ // A replaced document that still says it is in force is the single most expensive
127
+ // thing in a docs folder: someone builds from it.
128
+ if (meta.status !== "superseded" && meta.status !== "abandoned" && meta.status !== "retired") meta.status = "superseded";
129
+ } else if (asList(meta["superseded-by"]).length) {
130
+ // Nothing replaces it any more — the newer document dropped its `supersedes`.
131
+ meta["superseded-by"] = "";
132
+ }
133
+
134
+ if (JSON.stringify(meta) !== before) changed.push({ doc: d, before: JSON.parse(before), meta });
135
+ }
136
+ return changed;
137
+ }
138
+
139
+ export const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []);
@@ -0,0 +1,90 @@
1
+ // Frontmatter: a deliberately small YAML subset — `key: scalar` and `key: [a, b]`,
2
+ // nothing nested.
3
+ //
4
+ // Documents are read and rewritten by these scripts far more often than a full YAML
5
+ // parser would earn its keep, and a strict, flat shape is what lets `check` say something
6
+ // precise. Anything a real YAML parser would read differently is quoted on the way out.
7
+
8
+ const FM = /^?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
9
+
10
+ const unquote = (v) => {
11
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
12
+ try {
13
+ return JSON.parse(v);
14
+ } catch {
15
+ return v.slice(1, -1);
16
+ }
17
+ }
18
+ if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'");
19
+ return v;
20
+ };
21
+
22
+ /** Split `[a, "b, c", d]` into items, honouring quotes. */
23
+ function splitList(inner) {
24
+ const items = [];
25
+ let cur = "";
26
+ let quote = null;
27
+ for (const ch of inner) {
28
+ if (quote) {
29
+ cur += ch;
30
+ if (ch === quote) quote = null;
31
+ } else if (ch === '"' || ch === "'") {
32
+ cur += ch;
33
+ quote = ch;
34
+ } else if (ch === ",") {
35
+ items.push(cur);
36
+ cur = "";
37
+ } else cur += ch;
38
+ }
39
+ items.push(cur);
40
+ return items.map((s) => unquote(s.trim())).filter((s) => s !== "");
41
+ }
42
+
43
+ /** `{ meta, body }`; `meta` is null when the file has no frontmatter block at all. */
44
+ export function parse(raw) {
45
+ const m = FM.exec(raw);
46
+ if (!m) return { meta: null, body: raw.replace(/^/, "") };
47
+ const meta = {};
48
+ for (const line of m[1].split(/\r?\n/)) {
49
+ const kv = /^([A-Za-z][\w-]*):[ \t]*(.*)$/.exec(line);
50
+ if (!kv) continue;
51
+ const [, key, rest] = kv;
52
+ const v = rest.trim();
53
+ if (v === "") meta[key] = "";
54
+ else if (v.startsWith("[") && v.endsWith("]")) meta[key] = splitList(v.slice(1, -1));
55
+ else if (v === "true" || v === "false") meta[key] = v === "true";
56
+ else meta[key] = unquote(v);
57
+ }
58
+ return { meta, body: raw.slice(m[0].length) };
59
+ }
60
+
61
+ const KEY_ORDER = [
62
+ "id", "title", "area", "type", "status", "legacy",
63
+ "created", "updated", "completed", "progress",
64
+ "supersedes", "superseded-by", "related", "files",
65
+ ];
66
+
67
+ // A plain scalar that a YAML parser would read as something else — structure, a comment,
68
+ // a number, a boolean — or that carries leading/trailing space, gets quoted.
69
+ const NEEDS_QUOTES = /^[\s[\]{}>|*&!%@`#'",?:-]|[\s:]$|: | #|^(true|false|yes|no|null|~)$|^[\d.+-]+$/i;
70
+
71
+ const scalar = (v) => {
72
+ const s = String(v);
73
+ return NEEDS_QUOTES.test(s) && !/^\d{4}-\d{2}-\d{2}$/.test(s) ? JSON.stringify(s) : s;
74
+ };
75
+ const listItem = (v) => {
76
+ const s = String(v);
77
+ return /[,[\]"']|^\s|\s$/.test(s) ? JSON.stringify(s) : s;
78
+ };
79
+
80
+ export function stringify(meta) {
81
+ const keys = [...KEY_ORDER.filter((k) => k in meta), ...Object.keys(meta).filter((k) => !KEY_ORDER.includes(k))];
82
+ const lines = keys.map((k) => {
83
+ const v = meta[k];
84
+ if (Array.isArray(v)) return `${k}: [${v.map(listItem).join(", ")}]`;
85
+ if (v === undefined || v === null || v === "") return `${k}:`;
86
+ if (typeof v === "boolean" || typeof v === "number") return `${k}: ${v}`;
87
+ return `${k}: ${scalar(v)}`;
88
+ });
89
+ return `---\n${lines.join("\n")}\n---\n`;
90
+ }
package/src/git.mjs ADDED
@@ -0,0 +1,181 @@
1
+ // Everything honestdocs asks git. Each question is one `git log` over the whole history,
2
+ // parsed here, rather than one call per document — a project with a few hundred
3
+ // documents should not mean a few hundred processes.
4
+
5
+ import { execFileSync } from "node:child_process";
6
+ import { realpathSync } from "node:fs";
7
+ import { basename, join, relative } from "node:path";
8
+
9
+ const RS = "\x1e"; // starts a commit
10
+ const GS = "\x1d"; // ends its message
11
+
12
+ /** Run git; "" when it fails (not a repository, git missing). */
13
+ export function git(args, cwd) {
14
+ try {
15
+ return execFileSync("git", ["-c", "core.quotepath=off", ...args], {
16
+ cwd,
17
+ encoding: "utf8",
18
+ maxBuffer: 512 * 1024 * 1024,
19
+ stdio: ["ignore", "pipe", "ignore"],
20
+ });
21
+ } catch {
22
+ return "";
23
+ }
24
+ }
25
+
26
+ /**
27
+ * The canonical form of a path. Windows can spell one folder two ways — a short 8.3 name
28
+ * such as `RUNNER~1` from the environment, the long name from git — and `path.relative`
29
+ * between the two spellings walks out of the folder instead of into it.
30
+ */
31
+ export function real(p) {
32
+ try {
33
+ return realpathSync.native(p);
34
+ } catch {
35
+ return p;
36
+ }
37
+ }
38
+
39
+ export const toplevel = (dir) => {
40
+ const top = git(["rev-parse", "--show-toplevel"], dir).trim();
41
+ return top ? real(top) : null;
42
+ };
43
+
44
+ /** A shallow clone (CI's default) has no history to derive anything from. */
45
+ export const isShallow = (dir) => git(["rev-parse", "--is-shallow-repository"], dir).trim() === "true";
46
+
47
+ /**
48
+ * The date each file under `dir` last changed in content, as `{ "area/x.md": "YYYY-MM-DD" }`
49
+ * with paths relative to `dir`.
50
+ *
51
+ * A pure rename is not an edit: moving a folder of documents must not stamp all of them
52
+ * with the date of the move, which would erase their history in one commit. So history is
53
+ * read newest first, and a rename links the older path to the newer one — the edits made
54
+ * under the old name still count for the file as it is called now. A file whose only
55
+ * commit is a rename gets that commit's date.
56
+ */
57
+ export function lastChangeDates(dir) {
58
+ const out = git(["log", `--format=${RS}%ad`, "--date=short", "--name-status", "-M", "--relative", "--", "."], dir);
59
+ const dates = {};
60
+ const seen = {};
61
+ const current = new Map(); // a path as it was then → the path it is called now
62
+ const now = (p) => current.get(p) ?? p;
63
+ for (const chunk of out.split(RS)) {
64
+ const [dateLine, ...entries] = chunk.split(/\r?\n/);
65
+ const date = dateLine.trim();
66
+ for (const entry of entries) {
67
+ if (!entry) continue;
68
+ const [status, a, b] = entry.split("\t");
69
+ if (status.startsWith("R") || status.startsWith("C")) {
70
+ const target = now(b);
71
+ seen[target] ??= date;
72
+ if (status !== "R100" && status !== "C100") dates[target] ??= date;
73
+ if (status.startsWith("R")) current.set(a, target);
74
+ } else if (a) {
75
+ const target = now(a);
76
+ seen[target] ??= date;
77
+ if (status !== "D") dates[target] ??= date;
78
+ }
79
+ }
80
+ }
81
+ for (const [p, d] of Object.entries(seen)) dates[p] ??= d;
82
+ return dates;
83
+ }
84
+
85
+ /**
86
+ * Files under `dir` with changes git has not committed yet, relative to `dir`. Their
87
+ * `updated` is today: the edit is happening now, and it will be committed with today's
88
+ * date far more often than not.
89
+ */
90
+ export function uncommittedFiles(dir) {
91
+ const top = toplevel(dir);
92
+ if (!top) return new Set();
93
+ const out = git(["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."], dir);
94
+ const set = new Set();
95
+ const entries = out.split("\0");
96
+ for (let i = 0; i < entries.length; i++) {
97
+ const e = entries[i];
98
+ if (e.length < 4) continue;
99
+ // A rename is followed by its old path as a separate entry; skip that one.
100
+ if (e[0] === "R" || e[0] === "C") i++;
101
+ const rel = relative(real(dir), join(top, e.slice(3))).split("\\").join("/");
102
+ if (!rel.startsWith("..")) set.add(rel);
103
+ }
104
+ return set;
105
+ }
106
+
107
+ /**
108
+ * Which files the commits naming each document touched: `{ id: [file, …] }`, newest
109
+ * commit first.
110
+ *
111
+ * A commit names a document with a trailer line — `Doc: 2026-11-02-token-rotation` — and
112
+ * may name several, on several lines or comma-separated. The match is on the whole id:
113
+ * `Doc: 2026-11-02-auth` must not count as a commit for `2026-11-02-auth-tokens`.
114
+ *
115
+ * Paths are given as they are named now: a file renamed after the commit is listed under
116
+ * its new name. That is also what lets `exclude` — the documents folder, when it lives in
117
+ * the same repository as the code — still recognise documents committed before the folder
118
+ * was moved.
119
+ */
120
+ function filesByTrailer(repo, trailer, { exclude = null, prefix = "" } = {}) {
121
+ const out = git(["log", "-i", "-E", `--grep=^${trailer}:`, `--format=${RS}%B${GS}`, "--name-only"], repo);
122
+ if (!out) return {};
123
+ const now = renamesToNow(repo);
124
+ const line = new RegExp(`^${trailer}:[ \\t]*(.+)$`, "gim");
125
+ const result = {};
126
+ for (const chunk of out.split(RS)) {
127
+ const end = chunk.indexOf(GS);
128
+ if (end < 0) continue;
129
+ const message = chunk.slice(0, end);
130
+ const files = chunk.slice(end + 1).split(/\r?\n/).map((s) => now(s.trim())).filter(Boolean)
131
+ .filter((f) => !exclude || !(f === exclude || f.startsWith(exclude + "/")));
132
+ if (!files.length) continue;
133
+ const ids = new Set();
134
+ for (const m of message.matchAll(line)) {
135
+ for (const id of m[1].split(",")) if (id.trim()) ids.add(id.trim());
136
+ }
137
+ for (const id of ids) {
138
+ const set = (result[id] ??= new Set());
139
+ for (const f of files) set.add(prefix + f);
140
+ }
141
+ }
142
+ return Object.fromEntries(Object.entries(result).map(([k, v]) => [k, [...v]]));
143
+ }
144
+
145
+ /**
146
+ * A function from a path as some commit named it to the path it has today, following
147
+ * every rename in the history, newest first.
148
+ */
149
+ function renamesToNow(repo) {
150
+ const out = git(["log", "-M", "--diff-filter=R", "--name-status", "--format="], repo);
151
+ const map = new Map();
152
+ const now = (p) => map.get(p) ?? p;
153
+ for (const entry of out.split(/\r?\n/)) {
154
+ const [status, from, to] = entry.split("\t");
155
+ if (status?.startsWith("R") && from && to) map.set(from, now(to));
156
+ }
157
+ return now;
158
+ }
159
+
160
+ /**
161
+ * The trailer-derived files across every configured code repository. With more than one
162
+ * repository, each path is prefixed with its repository's folder name so `app/x.ts` in
163
+ * one is not confused with `app/x.ts` in another.
164
+ */
165
+ export function filesForAllDocs(config) {
166
+ const merged = {};
167
+ const multi = config.codeRepos.length > 1;
168
+ for (const repoDir of config.codeRepos) {
169
+ const top = toplevel(repoDir);
170
+ if (!top) continue;
171
+ const rel = relative(top, real(config.docsDir)).split("\\").join("/");
172
+ const exclude = rel && !rel.startsWith("..") ? rel : null;
173
+ const prefix = multi ? basename(top) + "/" : "";
174
+ const found = filesByTrailer(top, config.trailer, { exclude, prefix });
175
+ for (const [id, files] of Object.entries(found)) {
176
+ const set = new Set([...(merged[id] ?? []), ...files]);
177
+ merged[id] = [...set];
178
+ }
179
+ }
180
+ return merged;
181
+ }
package/src/render.mjs ADDED
@@ -0,0 +1,84 @@
1
+ // INDEX.md: two views of the same set — newest first, for "what has been happening",
2
+ // and by area, for "where is the document about X". Generated, never maintained.
3
+ //
4
+ // The output carries no generation timestamp, on purpose: the same documents must give
5
+ // byte-identical output, so `check` can tell a stale index from a fresh one.
6
+
7
+ import { LIFECYCLES } from "./config.mjs";
8
+
9
+ export const MARK = {
10
+ draft: "✏️",
11
+ planned: "📋",
12
+ "in-progress": "🔧",
13
+ done: "✅",
14
+ current: "📘",
15
+ superseded: "🗑️",
16
+ retired: "📦",
17
+ abandoned: "⛔",
18
+ };
19
+
20
+ const esc = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
21
+ const link = (d) => `[${esc(d.meta.title || d.id)}](${encodeURI(d.rel)})`;
22
+ const byUpdated = (a, b) =>
23
+ String(b.meta.updated ?? "").localeCompare(String(a.meta.updated ?? "")) || a.id.localeCompare(b.id);
24
+ const byCreated = (a, b) =>
25
+ String(b.meta.created ?? "").localeCompare(String(a.meta.created ?? "")) || a.id.localeCompare(b.id);
26
+
27
+ export function renderIndex(config, docs) {
28
+ const legend = new Map();
29
+ for (const lifecycle of Object.values(LIFECYCLES)) {
30
+ for (const [status, text] of Object.entries(lifecycle)) if (!legend.has(status)) legend.set(status, text);
31
+ }
32
+
33
+ const lines = [
34
+ `# ${config.title}`,
35
+ "",
36
+ "> Generated by `honestdocs index`. Do not edit — change the document's own frontmatter",
37
+ "> and run it again.",
38
+ ">",
39
+ "> **The code is the truth.** A document records what was decided at the time. Where the",
40
+ "> two disagree, believe the code, then come back and correct the document's `status`.",
41
+ "",
42
+ `${docs.length} document${docs.length === 1 ? "" : "s"}`,
43
+ "",
44
+ "| | Status | Meaning |",
45
+ "|---|---|---|",
46
+ ...Object.keys(MARK).map((s) => `| ${MARK[s]} | ${s} | ${esc(legend.get(s))} |`),
47
+ "",
48
+ ];
49
+
50
+ const recent = [...docs].sort(byUpdated).slice(0, config.recent);
51
+ if (recent.length) {
52
+ lines.push("## Recently changed", "", "| Updated | | Document | Area |", "|---|---|---|---|");
53
+ for (const d of recent) {
54
+ lines.push(`| ${esc(d.meta.updated)} | ${MARK[d.meta.status] ?? ""} | ${link(d)} | ${esc(d.meta.area)} |`);
55
+ }
56
+ lines.push("");
57
+ }
58
+
59
+ const areaNames = Object.keys(config.areas);
60
+ const present = [...new Set(docs.map((d) => d.meta.area).filter(Boolean))].sort(
61
+ (a, b) => (areaNames.indexOf(a) + 1 || 1e9) - (areaNames.indexOf(b) + 1 || 1e9) || a.localeCompare(b),
62
+ );
63
+ if (present.length) lines.push("## By area", "");
64
+ for (const area of present) {
65
+ lines.push(`### ${area}`, "");
66
+ if (config.areas[area]) lines.push(`_${esc(config.areas[area])}_`, "");
67
+ lines.push("| | Document | Type | Progress | Updated |", "|---|---|---|---|---|");
68
+ for (const d of docs.filter((x) => x.meta.area === area).sort(byCreated)) {
69
+ lines.push(
70
+ `| ${MARK[d.meta.status] ?? ""} | ${link(d)} | ${esc(d.meta.type)} | ${esc(d.meta.progress)} | ${esc(d.meta.updated)} |`,
71
+ );
72
+ }
73
+ lines.push("");
74
+ }
75
+
76
+ const loose = docs.filter((d) => !d.meta.area);
77
+ if (loose.length) {
78
+ lines.push("## Not filed yet", "", "| Document |", "|---|");
79
+ for (const d of loose.sort((a, b) => a.id.localeCompare(b.id))) lines.push(`| ${link(d)} |`);
80
+ lines.push("");
81
+ }
82
+
83
+ return lines.join("\n");
84
+ }
package/src/rules.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // Numbered rules inside `rule` documents.
2
+ //
3
+ // A rule is a list item that opens with its id in bold:
4
+ //
5
+ // - **R3** Every route checks the item it names, not just the caller.
6
+ // Test: `tests/routes/item-scope.test.ts`
7
+ //
8
+ // The id is letters then digits (R3, M12, SEC4). Lines indented under the item belong to
9
+ // it; a `Test:` or `Tests:` line names, in backticks, the files that prove it. A rule that
10
+ // can be a test should be one, and `check` fails when a named test does not exist — so
11
+ // "the test for this rule" cannot quietly become a file that was deleted last spring.
12
+
13
+ const RULE = /^(\s*)[-*]\s+\*\*([A-Z][A-Z0-9]*?\d+)\*\*[.:]?\s*(.*)$/;
14
+ const TEST = /^\s*Tests?:\s*(.+)$/i;
15
+ const FENCE = /^\s*(```|~~~)/;
16
+
17
+ export function parseRules(body) {
18
+ const rules = [];
19
+ let cur = null;
20
+ let inFence = false;
21
+ const lines = body.split(/\r?\n/);
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const line = lines[i];
24
+ if (FENCE.test(line)) {
25
+ inFence = !inFence;
26
+ cur = null;
27
+ continue;
28
+ }
29
+ if (inFence) continue;
30
+ const m = RULE.exec(line);
31
+ if (m) {
32
+ cur = { id: m[2], text: m[3].trim(), tests: [], line: i + 1, indent: m[1].length };
33
+ const inline = /^(.*?)\s+Tests?:\s*(`.+)$/i.exec(cur.text);
34
+ if (inline) {
35
+ cur.text = inline[1];
36
+ for (const p of inline[2].matchAll(/`([^`]+)`/g)) cur.tests.push(p[1].trim());
37
+ }
38
+ rules.push(cur);
39
+ continue;
40
+ }
41
+ if (!cur) continue;
42
+ const indent = /^(\s*)/.exec(line)[1].length;
43
+ if (line.trim() === "" || indent <= cur.indent) {
44
+ cur = null;
45
+ continue;
46
+ }
47
+ const t = TEST.exec(line);
48
+ if (t) for (const p of t[1].matchAll(/`([^`]+)`/g)) cur.tests.push(p[1].trim());
49
+ else cur.text += " " + line.trim();
50
+ }
51
+ return rules;
52
+ }
@@ -0,0 +1,26 @@
1
+ // Body templates for new documents. A project can override any of them by putting
2
+ // `<type>.md` in the folder its config names under `templates`; the rest fall back to the
3
+ // ones shipped here.
4
+
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
10
+ const BUILT_IN = join(PACKAGE_ROOT, "templates");
11
+
12
+ export function templateFor(config, type) {
13
+ for (const dir of [config.templatesDir, BUILT_IN]) {
14
+ if (!dir) continue;
15
+ const file = join(dir, `${type}.md`);
16
+ if (existsSync(file)) return readFileSync(file, "utf8").replace(/\r\n/g, "\n");
17
+ }
18
+ return "# {{title}}\n\n## Log\n";
19
+ }
20
+
21
+ export function fill(template, values) {
22
+ return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (m, k) => (k in values ? String(values[k]) : m));
23
+ }
24
+
25
+ /** A file shipped with the package (guides, the agents snippet, the skill). */
26
+ export const packageFile = (...parts) => readFileSync(join(PACKAGE_ROOT, ...parts), "utf8").replace(/\r\n/g, "\n");
@@ -0,0 +1,17 @@
1
+ # {{title}}
2
+
3
+ ## Context
4
+
5
+ _The forces at play: what is true, what is constrained, what is being asked for._
6
+
7
+ ## Decision
8
+
9
+ _The choice, stated so it can be followed: "We will …"._
10
+
11
+ ## Alternatives considered
12
+
13
+ _Each option that lost, and the reason it lost._
14
+
15
+ ## Consequences
16
+
17
+ _Both the good ones and the ones we are choosing to live with._
@@ -0,0 +1,28 @@
1
+ # {{title}}
2
+
3
+ ## Context
4
+
5
+ _What is the situation, and why now?_
6
+
7
+ ## Goals / Non-goals
8
+
9
+ ## Design
10
+
11
+ ## Alternatives considered
12
+
13
+ _What else was on the table, and the reason it lost. This is the section that stops the
14
+ same argument being had again in three months — by a person or by an AI that proposes the
15
+ rejected option as if it were new._
16
+
17
+ ## Checklist
18
+
19
+ - [ ] first step
20
+
21
+ ## Verification
22
+
23
+ _How someone tells this actually works: a command, a test, a thing to look at._
24
+
25
+ ## Log
26
+
27
+ _Dated notes as the work goes. Also the hand-over: what a new session — yours or someone
28
+ else's — needs to know to pick this up._
@@ -0,0 +1,31 @@
1
+ # {{title}}
2
+
3
+ ## Context
4
+
5
+ _What is the situation, and why now?_
6
+
7
+ ## Goals / Non-goals
8
+
9
+ ## Decision
10
+
11
+ _The approach, in a paragraph. Details belong in the checklist and the log._
12
+
13
+ ## Alternatives considered
14
+
15
+ _What else was on the table, and the reason it lost._
16
+
17
+ ## Checklist
18
+
19
+ _Ticked boxes drive `progress`. A box nobody can tick — dropped, deferred — gets a line in
20
+ the Log saying what happened to it._
21
+
22
+ - [ ] first step
23
+
24
+ ## Verification
25
+
26
+ _How someone tells this actually works: a command, a test, a thing to look at._
27
+
28
+ ## Log
29
+
30
+ _Dated notes as the work goes. Also the hand-over: what a new session needs to know to
31
+ pick this up._
@@ -0,0 +1,21 @@
1
+ # {{title}}
2
+
3
+ ## Timeline
4
+
5
+ _Times, in order, from the first sign to the all-clear._
6
+
7
+ ## Impact
8
+
9
+ _Who was affected, and how badly. Be specific; "it broke" teaches nobody anything._
10
+
11
+ ## Root cause
12
+
13
+ _With the evidence that proves it — not the first plausible story._
14
+
15
+ ## What we changed
16
+
17
+ ## What we deliberately did not change, and why
18
+
19
+ ## Checklist
20
+
21
+ - [ ] follow-up
@@ -0,0 +1,8 @@
1
+ # {{title}}
2
+
3
+ _Background that should stay true. When it goes out of date, fix it here, in place —
4
+ never write a second document that contradicts this one._
5
+
6
+ ## Log
7
+
8
+ _Dated one-liners: what changed in this document and why._