honestdocs 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +340 -2
- package/bin/honestdocs.mjs +55 -0
- package/guide/METHOD.md +93 -0
- package/guide/agents-snippet.md +22 -0
- package/guide/before-you-build.md +44 -0
- package/guide/docs-readme.md +29 -0
- package/guide/skill/SKILL.md +71 -0
- package/package.json +41 -4
- package/src/cli.mjs +67 -0
- package/src/commands/brief.mjs +78 -0
- package/src/commands/check.mjs +163 -0
- package/src/commands/done.mjs +53 -0
- package/src/commands/guide.mjs +29 -0
- package/src/commands/index.mjs +62 -0
- package/src/commands/init.mjs +166 -0
- package/src/commands/new.mjs +91 -0
- package/src/commands/promote.mjs +55 -0
- package/src/commands/rules.mjs +51 -0
- package/src/commands/search.mjs +88 -0
- package/src/commands/status.mjs +43 -0
- package/src/config.mjs +146 -0
- package/src/docs.mjs +139 -0
- package/src/frontmatter.mjs +90 -0
- package/src/git.mjs +181 -0
- package/src/render.mjs +84 -0
- package/src/rules.mjs +52 -0
- package/src/templates.mjs +26 -0
- package/templates/decision.md +17 -0
- package/templates/design.md +28 -0
- package/templates/plan.md +31 -0
- package/templates/postmortem.md +21 -0
- package/templates/reference.md +8 -0
- package/templates/research.md +20 -0
- package/templates/rule.md +20 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: honestdocs
|
|
3
|
+
description: Keeps this project's development documents (decisions, designs, plans, research, postmortems, standing rules in {{root}}/) true with the honestdocs CLI. Use when starting a session, before designing or building something non-trivial, when committing work that a document describes, when closing out work, or when asked why something is built the way it is.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Development documents with honestdocs
|
|
7
|
+
|
|
8
|
+
`{{root}}/` is the project's long-term memory. Every document is Markdown with a small
|
|
9
|
+
frontmatter block; `INDEX.md` is generated. **The code is the truth** — where a document
|
|
10
|
+
and the code disagree, believe the code and correct the document.
|
|
11
|
+
|
|
12
|
+
## When a session starts
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
honestdocs brief
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Work in progress with its next unticked items, what is planned, what changed lately,
|
|
19
|
+
and the rules in force. Read the in-progress document you are continuing — its Log is
|
|
20
|
+
the hand-over from the last session.
|
|
21
|
+
|
|
22
|
+
## Before building
|
|
23
|
+
|
|
24
|
+
1. **Has this been weighed before?** `honestdocs search <words>`; with
|
|
25
|
+
`--section alternatives` it shows only what was considered and rejected, with the
|
|
26
|
+
reason. Superseded and abandoned documents are included on purpose. Do not propose a
|
|
27
|
+
rejected option again without saying what has changed.
|
|
28
|
+
2. **Which rules apply?** `honestdocs rules` lists the numbered rules in force and the
|
|
29
|
+
tests that prove them. Go through the `before-you-build` checklist in `{{root}}/rules/`.
|
|
30
|
+
3. **Open a document** for anything non-trivial:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
honestdocs new <area> <slug> --type design --title "Token rotation"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Types: `design` (how it should work), `plan` (ordered steps, checklist), `research`
|
|
37
|
+
(a question and sourced findings), `decision` (one choice, MADR), `postmortem` (what
|
|
38
|
+
went wrong, with evidence), `reference` and `rule` (living documents, edited in place).
|
|
39
|
+
Fill *Alternatives considered* — it is the part people and agents need most later.
|
|
40
|
+
|
|
41
|
+
## While building
|
|
42
|
+
|
|
43
|
+
- End every commit message that does the work with a trailer line:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
{{trailer}}: 2026-11-02-token-rotation
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That is how `honestdocs index` learns which files the document touched. Several ids:
|
|
50
|
+
comma-separated or one line each.
|
|
51
|
+
- Tick checklist items (`- [x]`) as they land. Add dated notes to the Log.
|
|
52
|
+
- Never write `progress`, `files`, `superseded-by` or `updated` by hand. Write only
|
|
53
|
+
`title`, `status`, `supersedes`, `related`.
|
|
54
|
+
- Replacing a document: `supersedes: [<old id>]` in the **new** one, then
|
|
55
|
+
`honestdocs index` marks the old one.
|
|
56
|
+
- A rule changed: edit its rule document in place and add a Log line — never a second
|
|
57
|
+
document beside it.
|
|
58
|
+
|
|
59
|
+
## Finishing
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
honestdocs done <id> # refuses while checklist items are unticked
|
|
63
|
+
honestdocs done <id> --abandoned # dropped on purpose; the reason goes in the Log
|
|
64
|
+
honestdocs status <id> current # living documents: draft | current | retired
|
|
65
|
+
honestdocs index && honestdocs check
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`check` exits 1 on anything that makes the set lie. A change is not finished while a
|
|
69
|
+
document or rule it affects still describes the old behaviour.
|
|
70
|
+
|
|
71
|
+
Every query command takes `--json`. The method in full: `honestdocs guide`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "honestdocs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Development documents that stay true — decisions, designs, plans, research, postmortems and standing rules, kept honest for people and AI agents.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"documentation",
|
|
7
|
+
"adr",
|
|
8
|
+
"architecture-decision-records",
|
|
9
|
+
"design-docs",
|
|
10
|
+
"devdocs",
|
|
11
|
+
"git",
|
|
12
|
+
"ai-agents",
|
|
13
|
+
"agents-md",
|
|
14
|
+
"cli"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Glivo",
|
|
18
|
+
"homepage": "https://github.com/glivo-dev/honestdocs#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/glivo-dev/honestdocs.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": "https://github.com/glivo-dev/honestdocs/issues",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"honestdocs": "bin/honestdocs.mjs"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin/",
|
|
30
|
+
"src/",
|
|
31
|
+
"templates/",
|
|
32
|
+
"guide/"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --test",
|
|
39
|
+
"assets": "node scripts/build-assets.mjs",
|
|
40
|
+
"docs:index": "node bin/honestdocs.mjs index",
|
|
41
|
+
"docs:check": "node bin/honestdocs.mjs check"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Small command-line plumbing shared by every command: arguments, colour, and the
|
|
2
|
+
// convention that diagnostics go to stderr so stdout stays parseable when a caller —
|
|
3
|
+
// a script, CI, an AI agent — pipes it.
|
|
4
|
+
|
|
5
|
+
export class UsageError extends Error {}
|
|
6
|
+
export class Failure extends Error {}
|
|
7
|
+
|
|
8
|
+
const useColour = () =>
|
|
9
|
+
!process.env.NO_COLOR && (process.env.FORCE_COLOR || process.stderr.isTTY);
|
|
10
|
+
|
|
11
|
+
const paint = (code) => (s) => (useColour() ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
12
|
+
|
|
13
|
+
export const c = {
|
|
14
|
+
dim: paint(2),
|
|
15
|
+
red: paint(31),
|
|
16
|
+
green: paint(32),
|
|
17
|
+
yellow: paint(33),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const say = (msg = "") => console.error(msg);
|
|
21
|
+
export const ok = (msg) => console.error(c.green("✓ ") + msg);
|
|
22
|
+
export const warn = (msg) => console.error(c.yellow("! ") + msg);
|
|
23
|
+
|
|
24
|
+
/** Stop with a message. A UsageError exits 2, anything else 1. */
|
|
25
|
+
export function die(msg) {
|
|
26
|
+
throw new Failure(msg);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse `argv` against a spec of flags.
|
|
31
|
+
*
|
|
32
|
+
* parseArgs(argv, { title: "string", force: "boolean", "code-repo": "list" })
|
|
33
|
+
*
|
|
34
|
+
* Returns `{ _: [positional…], title, force, "code-repo": [...] }`. An unknown flag is a
|
|
35
|
+
* UsageError rather than something silently ignored: a mistyped `--stauts` that does
|
|
36
|
+
* nothing is worse than one that stops.
|
|
37
|
+
*/
|
|
38
|
+
export function parseArgs(argv, spec = {}) {
|
|
39
|
+
const out = { _: [] };
|
|
40
|
+
for (const [k, kind] of Object.entries(spec)) if (kind === "list") out[k] = [];
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
if (a === "--") {
|
|
44
|
+
out._.push(...argv.slice(i + 1));
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
if (!a.startsWith("--") || a === "-") {
|
|
48
|
+
out._.push(a);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
let [name, inline] = a.slice(2).split(/=(.*)/s, 2);
|
|
52
|
+
const kind = spec[name];
|
|
53
|
+
if (!kind) throw new UsageError(`unknown option --${name}`);
|
|
54
|
+
if (kind === "boolean") {
|
|
55
|
+
out[name] = inline === undefined ? true : !/^(false|0|no)$/i.test(inline);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const value = inline ?? argv[++i];
|
|
59
|
+
if (value === undefined) throw new UsageError(`--${name} needs a value`);
|
|
60
|
+
if (kind === "list") out[name].push(value);
|
|
61
|
+
else out[name] = value;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Print JSON on stdout — the one place a command writes data rather than talk. */
|
|
67
|
+
export const emitJson = (value) => console.log(JSON.stringify(value, null, 2));
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { emitJson, parseArgs } from "../cli.mjs";
|
|
2
|
+
import { loadConfig } from "../config.mjs";
|
|
3
|
+
import { loadDocs, openItems } from "../docs.mjs";
|
|
4
|
+
import { MARK } from "../render.mjs";
|
|
5
|
+
import { parseRules } from "../rules.mjs";
|
|
6
|
+
|
|
7
|
+
export const summary = "what is under way, in a page — run it when a session starts";
|
|
8
|
+
|
|
9
|
+
export const help = `honestdocs brief — ${summary}
|
|
10
|
+
|
|
11
|
+
honestdocs brief [--days <n>] [--json]
|
|
12
|
+
|
|
13
|
+
The work in progress with its next unticked items, what is planned, what changed
|
|
14
|
+
lately, and which rules are in force. Short on purpose: it is what a person back
|
|
15
|
+
from a week away, or an AI agent with an empty context, reads first.
|
|
16
|
+
|
|
17
|
+
--days <n> how far back "changed lately" reaches (default 14)
|
|
18
|
+
--json everything, unabridged, as JSON on stdout
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
const daysAgo = (n) => new Date(Date.now() - n * 864e5).toISOString().slice(0, 10);
|
|
22
|
+
|
|
23
|
+
function collectBrief(config, { days = 14 } = {}) {
|
|
24
|
+
const docs = loadDocs(config).filter((d) => d.hasMeta);
|
|
25
|
+
const pick = (d) => ({ id: d.id, path: `${config.root}/${d.rel}`, title: d.meta.title, type: d.meta.type, status: d.meta.status });
|
|
26
|
+
const since = daysAgo(days);
|
|
27
|
+
return {
|
|
28
|
+
title: config.title,
|
|
29
|
+
documents: docs.length,
|
|
30
|
+
inProgress: docs.filter((d) => d.meta.status === "in-progress")
|
|
31
|
+
.map((d) => ({ ...pick(d), progress: d.meta.progress || "", next: openItems(d.body) })),
|
|
32
|
+
planned: docs.filter((d) => d.meta.status === "planned").map(pick),
|
|
33
|
+
drafts: docs.filter((d) => d.meta.status === "draft" && String(d.meta.updated ?? "") >= since).map(pick),
|
|
34
|
+
changed: docs.filter((d) => String(d.meta.updated ?? "") >= since)
|
|
35
|
+
.sort((a, b) => String(b.meta.updated).localeCompare(String(a.meta.updated)))
|
|
36
|
+
.map((d) => ({ ...pick(d), updated: d.meta.updated })),
|
|
37
|
+
rules: docs.filter((d) => d.meta.type === "rule" && (d.meta.status === "current" || d.meta.status === "draft"))
|
|
38
|
+
.map((d) => ({ ...pick(d), rules: parseRules(d.body).map((r) => r.id) })),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const range = (ids) => (ids.length === 0 ? "no rules yet" : ids.length <= 3 ? ids.join(", ") : `${ids[0]}…${ids.at(-1)} (${ids.length})`);
|
|
43
|
+
|
|
44
|
+
export function run(argv) {
|
|
45
|
+
const args = parseArgs(argv, { days: "string", json: "boolean" });
|
|
46
|
+
const config = loadConfig();
|
|
47
|
+
const b = collectBrief(config, { days: Number(args.days ?? 14) || 14 });
|
|
48
|
+
if (args.json) return emitJson(b);
|
|
49
|
+
|
|
50
|
+
const out = [`${b.title} — ${b.documents} documents`, ""];
|
|
51
|
+
const line = (d, extra = "") => ` ${MARK[d.status] ?? " "} ${d.id} — ${d.title}${extra}`;
|
|
52
|
+
|
|
53
|
+
out.push(b.inProgress.length ? "In progress" : "In progress: nothing");
|
|
54
|
+
for (const d of b.inProgress.slice(0, 10)) {
|
|
55
|
+
out.push(line(d, d.progress ? ` (${d.progress})` : ""));
|
|
56
|
+
for (const item of d.next.slice(0, 3)) out.push(` - [ ] ${item}`);
|
|
57
|
+
if (d.next.length > 3) out.push(` … ${d.next.length - 3} more`);
|
|
58
|
+
}
|
|
59
|
+
if (b.inProgress.length > 10) out.push(` … ${b.inProgress.length - 10} more (--json for all)`);
|
|
60
|
+
|
|
61
|
+
if (b.planned.length) {
|
|
62
|
+
out.push("", "Planned — re-check against the code before building");
|
|
63
|
+
for (const d of b.planned.slice(0, 8)) out.push(line(d));
|
|
64
|
+
if (b.planned.length > 8) out.push(` … ${b.planned.length - 8} more`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const recent = b.changed.filter((d) => d.status !== "in-progress").slice(0, 8);
|
|
68
|
+
if (recent.length) {
|
|
69
|
+
out.push("", "Changed lately");
|
|
70
|
+
for (const d of recent) out.push(line(d, ` · ${d.updated}`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (b.rules.length) {
|
|
74
|
+
out.push("", "Rules in force — read the ones a change touches before building");
|
|
75
|
+
for (const d of b.rules) out.push(` ${d.path} — ${d.title}: ${range(d.rules)}`);
|
|
76
|
+
}
|
|
77
|
+
console.log(out.join("\n"));
|
|
78
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { c, emitJson, parseArgs, say } from "../cli.mjs";
|
|
4
|
+
import { lifecycleOf, loadConfig, statusesFor } from "../config.mjs";
|
|
5
|
+
import { asList, checklistProgress, derive, folderArea, gitFacts, loadDocs, writeDoc } from "../docs.mjs";
|
|
6
|
+
import { renderIndex } from "../render.mjs";
|
|
7
|
+
import { parseRules } from "../rules.mjs";
|
|
8
|
+
|
|
9
|
+
export const summary = "validate every document; exit 1 on any error";
|
|
10
|
+
|
|
11
|
+
export const help = `honestdocs check — ${summary}
|
|
12
|
+
|
|
13
|
+
honestdocs check [--fix] [--json]
|
|
14
|
+
|
|
15
|
+
Errors (exit 1) are things that make the set lie or break:
|
|
16
|
+
· frontmatter missing, or an id that does not match its filename
|
|
17
|
+
· an area, type or status outside the config — or a status the type's
|
|
18
|
+
lifecycle does not have
|
|
19
|
+
· a document in one area's folder that claims another area
|
|
20
|
+
· supersedes / related pointing at nothing
|
|
21
|
+
· a superseded document that still says it is in force
|
|
22
|
+
· a checklist whose progress was never recounted
|
|
23
|
+
· a rule whose named test file does not exist, or two rules with one id
|
|
24
|
+
· INDEX.md not regenerated since the frontmatter last changed
|
|
25
|
+
|
|
26
|
+
Warnings are for a person to look at: derived fields git would now answer
|
|
27
|
+
differently (run \`honestdocs index\`), a done document without a date, and so on.
|
|
28
|
+
|
|
29
|
+
--fix correct what is mechanical: the area, from the folder
|
|
30
|
+
--json { documents, errors, warnings, fixed } on stdout
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
function runCheck(config, { fix = false } = {}) {
|
|
34
|
+
const docs = loadDocs(config);
|
|
35
|
+
const errors = [];
|
|
36
|
+
const warnings = [];
|
|
37
|
+
const fixed = [];
|
|
38
|
+
const err = (d, msg) => errors.push({ file: d.rel, msg });
|
|
39
|
+
const warnAt = (d, msg) => warnings.push({ file: d.rel, msg });
|
|
40
|
+
|
|
41
|
+
const ids = new Map();
|
|
42
|
+
for (const d of docs) {
|
|
43
|
+
if (ids.has(d.id)) err(d, `duplicate id — also ${ids.get(d.id).rel}`);
|
|
44
|
+
else ids.set(d.id, d);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const d of docs) {
|
|
48
|
+
const m = d.meta;
|
|
49
|
+
if (!d.hasMeta) {
|
|
50
|
+
err(d, "no frontmatter — start documents with `honestdocs new`, or add the block by hand");
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!m.id) err(d, "missing `id`");
|
|
54
|
+
else if (m.id !== d.id) err(d, `id "${m.id}" does not match the filename "${d.id}" — the filename is the id`);
|
|
55
|
+
if (!m.title) err(d, "missing `title`");
|
|
56
|
+
|
|
57
|
+
const folder = folderArea(config, d.file);
|
|
58
|
+
if (!m.area) err(d, "missing `area`");
|
|
59
|
+
else if (!config.areas[m.area]) err(d, `unknown area "${m.area}" — add it to the config's areas, or move the file`);
|
|
60
|
+
if (folder && m.area && folder !== m.area) {
|
|
61
|
+
if (fix && config.areas[folder]) {
|
|
62
|
+
m.area = folder;
|
|
63
|
+
writeDoc(d);
|
|
64
|
+
fixed.push({ file: d.rel, msg: `area → ${folder}` });
|
|
65
|
+
} else err(d, `says area "${m.area}" but lives in ${folder}/ — the folder wins (check --fix)`);
|
|
66
|
+
}
|
|
67
|
+
if (!folder) warnAt(d, "at the top level — it belongs in an area folder");
|
|
68
|
+
|
|
69
|
+
if (!m.type) err(d, "missing `type`");
|
|
70
|
+
else if (!config.types[m.type]) err(d, `unknown type "${m.type}"`);
|
|
71
|
+
if (!m.status) err(d, "missing `status`");
|
|
72
|
+
else if (config.types[m.type]) {
|
|
73
|
+
const allowed = statusesFor(config, m.type);
|
|
74
|
+
if (!allowed[m.status]) {
|
|
75
|
+
err(d, `"${m.status}" is not a status for a ${m.type} (${lifecycleOf(config, m.type)} document) — expected one of: ${Object.keys(allowed).join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const target of asList(m.supersedes)) {
|
|
80
|
+
if (target === d.id) err(d, "supersedes itself");
|
|
81
|
+
else if (!ids.has(target)) err(d, `supersedes "${target}", which does not exist`);
|
|
82
|
+
}
|
|
83
|
+
for (const target of asList(m.related)) {
|
|
84
|
+
const isPath = target.includes("/") || target.endsWith(".md");
|
|
85
|
+
if (isPath) {
|
|
86
|
+
if (!existsSync(resolve(config.base, target))) err(d, `related "${target}" is not a file under ${config.base}`);
|
|
87
|
+
} else if (!ids.has(target)) err(d, `related "${target}", which does not exist`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (m.status === "superseded" && !m["superseded-by"]) {
|
|
91
|
+
warnAt(d, "superseded, but nothing says by what — set `supersedes` on the newer document");
|
|
92
|
+
}
|
|
93
|
+
if ((m.status === "done" || m.status === "abandoned") && !m.completed && m.legacy !== true) {
|
|
94
|
+
warnAt(d, `${m.status} but \`completed\` is empty — close documents with \`honestdocs done\``);
|
|
95
|
+
}
|
|
96
|
+
if (m.status === "done") {
|
|
97
|
+
const p = checklistProgress(d.body);
|
|
98
|
+
if (p && p.split("/")[0] !== p.split("/")[1]) warnAt(d, `done, but its checklist is at ${p} — say in the Log what happened to the rest`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Rules: unique ids across every rule document, and tests that exist.
|
|
103
|
+
const ruleIds = new Map();
|
|
104
|
+
const roots = [config.base, ...config.codeRepos];
|
|
105
|
+
for (const d of docs) {
|
|
106
|
+
if (d.meta.type !== "rule") continue;
|
|
107
|
+
const rules = parseRules(d.body);
|
|
108
|
+
const inForce = d.meta.status === "current" || d.meta.status === "draft";
|
|
109
|
+
if (!rules.length && inForce) warnAt(d, "a rule document with no rules — write them as `- **R1** …`");
|
|
110
|
+
for (const r of rules) {
|
|
111
|
+
const where = `${d.rel}:${r.line}`;
|
|
112
|
+
if (ruleIds.has(r.id)) errors.push({ file: where, msg: `rule ${r.id} is also defined at ${ruleIds.get(r.id)}` });
|
|
113
|
+
else ruleIds.set(r.id, where);
|
|
114
|
+
if (!inForce) continue;
|
|
115
|
+
for (const t of r.tests) {
|
|
116
|
+
if (!roots.some((root) => existsSync(resolve(root, t)))) {
|
|
117
|
+
errors.push({ file: where, msg: `rule ${r.id} names the test \`${t}\`, which does not exist` });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// What the derivation would change. Fields a person could have fixed before the commit
|
|
124
|
+
// are errors; fields that only a later commit can settle (git's answer moves with every
|
|
125
|
+
// commit that names the document) are warnings.
|
|
126
|
+
const facts = gitFacts(config);
|
|
127
|
+
for (const ch of derive(config, docs.filter((d) => d.hasMeta && d.meta.id === d.id), facts)) {
|
|
128
|
+
for (const k of new Set([...Object.keys(ch.before), ...Object.keys(ch.meta)])) {
|
|
129
|
+
if (JSON.stringify(ch.before[k]) === JSON.stringify(ch.meta[k])) continue;
|
|
130
|
+
const msg = `\`${k}\` is ${JSON.stringify(ch.before[k] ?? "")}, should be ${JSON.stringify(ch.meta[k])} — run \`honestdocs index\``;
|
|
131
|
+
if (k === "status") err(ch.doc, `replaced by ${ch.meta["superseded-by"]} but still says "${ch.before.status}" — run \`honestdocs index\``);
|
|
132
|
+
else if (k === "progress" || k === "superseded-by") err(ch.doc, msg);
|
|
133
|
+
else warnAt(ch.doc, msg);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The index must match the frontmatter as written — not as git would now derive it —
|
|
138
|
+
// so a commit can never make it stale on its own.
|
|
139
|
+
const indexFile = join(config.docsDir, "INDEX.md");
|
|
140
|
+
const expected = renderIndex(config, docs.filter((d) => d.hasMeta));
|
|
141
|
+
if (!existsSync(indexFile)) errors.push({ file: "INDEX.md", msg: "missing — run `honestdocs index`" });
|
|
142
|
+
else if (readFileSync(indexFile, "utf8") !== expected) {
|
|
143
|
+
errors.push({ file: "INDEX.md", msg: "stale: frontmatter changed since it was generated — run `honestdocs index`" });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { documents: docs.length, git: facts.available ? "ok" : facts.reason, errors, warnings, fixed };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function run(argv) {
|
|
150
|
+
const args = parseArgs(argv, { fix: "boolean", json: "boolean" });
|
|
151
|
+
const config = loadConfig();
|
|
152
|
+
const r = runCheck(config, { fix: args.fix });
|
|
153
|
+
if (args.json) emitJson(r);
|
|
154
|
+
else {
|
|
155
|
+
if (r.git !== "ok") say(c.yellow(`! git: ${r.git} — derived fields not compared`));
|
|
156
|
+
for (const f of r.fixed) say(c.green(" fixed ") + c.dim(`${f.file}: ${f.msg}`));
|
|
157
|
+
for (const w of r.warnings) say(c.yellow(" warn ") + `${w.file}: ${w.msg}`);
|
|
158
|
+
for (const e of r.errors) say(c.red(" error ") + `${e.file}: ${e.msg}`);
|
|
159
|
+
const tail = `${r.documents} document(s), ${r.errors.length} error(s), ${r.warnings.length} warning(s)`;
|
|
160
|
+
say(r.errors.length ? c.red(`\n✗ ${tail}`) : c.green(`\n✓ ${tail}`));
|
|
161
|
+
}
|
|
162
|
+
if (r.errors.length) process.exitCode = 1;
|
|
163
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { UsageError, c, die, ok, parseArgs, say } from "../cli.mjs";
|
|
2
|
+
import { lifecycleOf, loadConfig } from "../config.mjs";
|
|
3
|
+
import { checklistProgress, findDoc, loadDocs, today, writeDoc } from "../docs.mjs";
|
|
4
|
+
import { report, runIndex } from "./index.mjs";
|
|
5
|
+
|
|
6
|
+
export const summary = "close a work document as done (or abandoned)";
|
|
7
|
+
|
|
8
|
+
export const help = `honestdocs done — ${summary}
|
|
9
|
+
|
|
10
|
+
honestdocs done <id> [--abandoned] [--force]
|
|
11
|
+
|
|
12
|
+
Sets status and completed, then reruns the index so files and progress are final.
|
|
13
|
+
A checklist with unticked items is refused: either the items are done, or "done"
|
|
14
|
+
is not the word for what happened.
|
|
15
|
+
|
|
16
|
+
--abandoned the work was dropped on purpose; the reasoning is the record
|
|
17
|
+
--force close it anyway — after saying in the Log what became of the
|
|
18
|
+
unticked items
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
export function run(argv) {
|
|
22
|
+
const args = parseArgs(argv, { abandoned: "boolean", force: "boolean" });
|
|
23
|
+
const [id] = args._;
|
|
24
|
+
if (!id) throw new UsageError("need a document id");
|
|
25
|
+
const config = loadConfig();
|
|
26
|
+
const doc = findDoc(loadDocs(config), id);
|
|
27
|
+
if (!doc) die(`no document with the id "${id}"`);
|
|
28
|
+
if (!doc.hasMeta) die(`${doc.rel} has no frontmatter`);
|
|
29
|
+
|
|
30
|
+
if (lifecycleOf(config, doc.meta.type) !== "work") {
|
|
31
|
+
die(`${id} is a ${doc.meta.type} — a living document is never finished. ` +
|
|
32
|
+
"Keep it current, or retire it: `honestdocs status " + id + " retired`");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const status = args.abandoned ? "abandoned" : "done";
|
|
36
|
+
const progress = checklistProgress(doc.body);
|
|
37
|
+
if (status === "done" && progress && !args.force) {
|
|
38
|
+
const [d, total] = progress.split("/").map(Number);
|
|
39
|
+
if (d < total) {
|
|
40
|
+
say(c.yellow(`! ${id} is at ${progress}.`));
|
|
41
|
+
say(c.dim(" Tick the rest, or write in the Log what happened to them and pass --force."));
|
|
42
|
+
say(c.dim(" If the work was dropped rather than finished: --abandoned."));
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
doc.meta.status = status;
|
|
49
|
+
doc.meta.completed = today();
|
|
50
|
+
writeDoc(doc);
|
|
51
|
+
ok(`${id} → ${status} (${doc.meta.completed})`);
|
|
52
|
+
report(config, runIndex(config), false);
|
|
53
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { UsageError, parseArgs } from "../cli.mjs";
|
|
2
|
+
import { DEFAULT_ROOT, findConfig, loadConfig } from "../config.mjs";
|
|
3
|
+
import { fill, packageFile } from "../templates.mjs";
|
|
4
|
+
|
|
5
|
+
export const summary = "print the method, the agent instructions or the skill";
|
|
6
|
+
|
|
7
|
+
const TOPICS = {
|
|
8
|
+
method: ["METHOD.md"],
|
|
9
|
+
agents: ["agents-snippet.md"],
|
|
10
|
+
skill: ["skill", "SKILL.md"],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const help = `honestdocs guide — ${summary}
|
|
14
|
+
|
|
15
|
+
honestdocs guide [method|agents|skill]
|
|
16
|
+
|
|
17
|
+
method the rules of the method itself (default)
|
|
18
|
+
agents the section \`init --agents\` writes into AGENTS.md
|
|
19
|
+
skill the agent skill \`init --skill\` installs
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export function run(argv) {
|
|
23
|
+
const args = parseArgs(argv, {});
|
|
24
|
+
const topic = args._[0] ?? "method";
|
|
25
|
+
if (!TOPICS[topic]) throw new UsageError(`unknown topic "${topic}" — expected: ${Object.keys(TOPICS).join(", ")}`);
|
|
26
|
+
const config = findConfig() ? loadConfig() : null;
|
|
27
|
+
const values = { root: config?.root ?? DEFAULT_ROOT, trailer: config?.trailer ?? "Doc" };
|
|
28
|
+
console.log(fill(packageFile("guide", ...TOPICS[topic]), values).trimEnd());
|
|
29
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { c, emitJson, ok, parseArgs, say, warn } from "../cli.mjs";
|
|
4
|
+
import { loadConfig } from "../config.mjs";
|
|
5
|
+
import { derive, gitFacts, loadDocs, writeDoc } from "../docs.mjs";
|
|
6
|
+
import { renderIndex } from "../render.mjs";
|
|
7
|
+
|
|
8
|
+
export const summary = "recompute the derived fields and regenerate INDEX.md";
|
|
9
|
+
|
|
10
|
+
export const help = `honestdocs index — ${summary}
|
|
11
|
+
|
|
12
|
+
honestdocs index [--dry-run] [--json]
|
|
13
|
+
|
|
14
|
+
Writes into each document the fields nobody should type: progress (from its
|
|
15
|
+
checklist), files (from commits carrying the trailer), superseded-by (from the
|
|
16
|
+
newer document's supersedes), updated (from git). Then regenerates INDEX.md.
|
|
17
|
+
|
|
18
|
+
--dry-run report what would change, write nothing
|
|
19
|
+
--json the report as JSON on stdout
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
/** Derive, write, render. Shared by `done`, `status` and `init`, which end with an index. */
|
|
23
|
+
export function runIndex(config, { dryRun = false } = {}) {
|
|
24
|
+
const docs = loadDocs(config);
|
|
25
|
+
const facts = gitFacts(config);
|
|
26
|
+
const changed = derive(config, docs, facts);
|
|
27
|
+
for (const ch of changed) {
|
|
28
|
+
ch.doc.meta = ch.meta;
|
|
29
|
+
if (!dryRun) writeDoc(ch.doc);
|
|
30
|
+
}
|
|
31
|
+
const indexFile = join(config.docsDir, "INDEX.md");
|
|
32
|
+
const content = renderIndex(config, docs.filter((d) => d.hasMeta));
|
|
33
|
+
const indexChanged = !existsSync(indexFile) || readFileSync(indexFile, "utf8") !== content;
|
|
34
|
+
if (indexChanged && !dryRun) writeFileSync(indexFile, content);
|
|
35
|
+
return {
|
|
36
|
+
documents: docs.length,
|
|
37
|
+
git: facts.available ? "ok" : facts.reason,
|
|
38
|
+
changed: changed.map((ch) => ({
|
|
39
|
+
file: ch.doc.rel,
|
|
40
|
+
fields: Object.keys({ ...ch.before, ...ch.meta }).filter(
|
|
41
|
+
(k) => JSON.stringify(ch.before[k]) !== JSON.stringify(ch.meta[k]),
|
|
42
|
+
),
|
|
43
|
+
})),
|
|
44
|
+
index: indexChanged ? (dryRun ? "would change" : "written") : "unchanged",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function report(config, r, dryRun) {
|
|
49
|
+
if (r.git !== "ok") warn(`git: ${r.git} — files and updated were left as they are`);
|
|
50
|
+
if (dryRun) say(c.yellow(`dry run — ${r.changed.length} document(s) would change; INDEX.md ${r.index}`));
|
|
51
|
+
else ok(`${config.root}/INDEX.md — ${r.documents} document(s), index ${r.index}`);
|
|
52
|
+
for (const ch of r.changed.slice(0, 20)) say(c.dim(` ${ch.file}: ${ch.fields.join(", ")}`));
|
|
53
|
+
if (r.changed.length > 20) say(c.dim(` … and ${r.changed.length - 20} more`));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function run(argv) {
|
|
57
|
+
const args = parseArgs(argv, { "dry-run": "boolean", json: "boolean" });
|
|
58
|
+
const config = loadConfig();
|
|
59
|
+
const r = runIndex(config, { dryRun: args["dry-run"] });
|
|
60
|
+
if (args.json) emitJson(r);
|
|
61
|
+
else report(config, r, args["dry-run"]);
|
|
62
|
+
}
|