doctrina-cli 0.13.0 → 0.14.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.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -2
  3. package/src/commands/adapter.js +205 -0
  4. package/src/commands/analyze.js +66 -19
  5. package/src/commands/change.js +100 -42
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +57 -2
  8. package/src/commands/completion.js +6 -0
  9. package/src/commands/constitution.js +8 -1
  10. package/src/commands/context.js +359 -53
  11. package/src/commands/contract.js +17 -24
  12. package/src/commands/coverage.js +16 -4
  13. package/src/commands/decision.js +172 -40
  14. package/src/commands/doctor.js +38 -7
  15. package/src/commands/handoff.js +8 -1
  16. package/src/commands/hooks.js +6 -0
  17. package/src/commands/index-rebuild.js +8 -1
  18. package/src/commands/init.js +139 -6
  19. package/src/commands/intake.js +11 -2
  20. package/src/commands/intent.js +8 -1
  21. package/src/commands/metrics.js +80 -7
  22. package/src/commands/next.js +14 -2
  23. package/src/commands/prime.js +8 -1
  24. package/src/commands/report.js +8 -1
  25. package/src/commands/review.js +8 -1
  26. package/src/commands/search.js +8 -1
  27. package/src/commands/show.js +8 -1
  28. package/src/commands/skill.js +16 -6
  29. package/src/commands/spec.js +13 -6
  30. package/src/commands/status.js +14 -2
  31. package/src/commands/templates.js +193 -59
  32. package/src/commands/trace.js +15 -3
  33. package/src/commands/upgrade.js +8 -1
  34. package/src/commands/validate.js +58 -2
  35. package/src/commands/verify.js +12 -2
  36. package/src/commands/watch.js +8 -1
  37. package/src/commands/why.js +10 -24
  38. package/src/commands/work.js +8 -1
  39. package/src/index.js +60 -6
  40. package/src/lib/adapters.js +140 -0
  41. package/src/lib/adr-guard.js +1 -0
  42. package/src/lib/args.js +10 -0
  43. package/src/lib/clarity.js +1 -0
  44. package/src/lib/colors.js +1 -0
  45. package/src/lib/commands.js +239 -26
  46. package/src/lib/criteria.js +1 -0
  47. package/src/lib/dates.js +1 -0
  48. package/src/lib/diff.js +1 -0
  49. package/src/lib/doc-model.js +287 -0
  50. package/src/lib/docs-impact.js +167 -0
  51. package/src/lib/ears.js +1 -0
  52. package/src/lib/exit-codes.js +103 -0
  53. package/src/lib/flag-catalog.js +106 -0
  54. package/src/lib/fs-ops.js +1 -0
  55. package/src/lib/gates.js +123 -0
  56. package/src/lib/git.js +97 -0
  57. package/src/lib/index-json.js +14 -6
  58. package/src/lib/json-out.js +83 -0
  59. package/src/lib/prompt.js +18 -1
  60. package/src/lib/scan.js +81 -33
  61. package/src/lib/spec-ops.js +94 -9
  62. package/src/lib/suggest.js +1 -0
  63. package/src/lib/templates.js +89 -0
  64. package/src/lib/usage.js +89 -0
  65. package/src/lib/version.js +1 -0
  66. package/templates/AGENTS.md.template +59 -53
@@ -0,0 +1,167 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { isFile, read, walk } from "./fs-ops.js";
5
+ import { COMMAND_NAMES } from "./commands.js";
6
+ import { locateTemplatesDir } from "./templates.js";
7
+
8
+ // "Docs ship inside the change, never after it" (audit item D2).
9
+ //
10
+ // A docs phase scheduled AFTER the work never happens. So a change that
11
+ // alters a documented surface — a command, a flag, an exit code — must also
12
+ // carry the documentation for it, checked at close time and refusable with
13
+ // --force exactly like the archive verification gate.
14
+ //
15
+ // Both halves are deterministic: what the change says it touches is read
16
+ // from its own artifacts, and whether docs moved is read from git. No
17
+ // language understanding (ADR 0005).
18
+
19
+ const DOC_PATHS = [/^docs\//, /^README\.md$/, /^README\.pt\.md$/];
20
+
21
+ // Non-empty lines of the shipped change templates, so authored content can
22
+ // be told apart from scaffold. Computed once; falls back to an empty set
23
+ // when the templates cannot be located (unusual installs), which only makes
24
+ // the gate more eager, never wrong about a real signal.
25
+ // Ticking a scaffold checkbox rewrites "- [ ] ..." to "- [x] ...", which
26
+ // would otherwise stop the line matching its own template and make every
27
+ // completed change look authored. Compare with the box state normalised.
28
+ function normaliseScaffoldLine(line) {
29
+ return line.trim().replace(/^-\s*\[[ xX]\]/, "- [ ]");
30
+ }
31
+
32
+ let scaffoldCache = null;
33
+ function scaffoldLines() {
34
+ if (scaffoldCache) return scaffoldCache;
35
+ const lines = new Set();
36
+ try {
37
+ const changeTemplates = path.join(locateTemplatesDir(), "change");
38
+ for (const f of walk(changeTemplates)) {
39
+ for (const line of read(f).split(/\r?\n/)) {
40
+ const trimmed = normaliseScaffoldLine(line);
41
+ if (trimmed) lines.add(trimmed);
42
+ }
43
+ }
44
+ } catch { /* no templates dir — treat everything as authored */ }
45
+ scaffoldCache = lines;
46
+ return lines;
47
+ }
48
+
49
+ // Does this change alter something the documentation makes promises about?
50
+ // Read from the change's own artifacts — the deltas it will merge into
51
+ // specs, plus the proposal that states its shape. Returns the list of
52
+ // signals found (empty when the change touches no documented surface).
53
+ export function documentedSurfaceSignals(changeDir) {
54
+ const signals = [];
55
+ const known = new Set(COMMAND_NAMES);
56
+
57
+ // Read only what the AUTHOR wrote. The scaffold's own boilerplate names
58
+ // commands — the proposal's Verification checklist cites `doctrina verify`
59
+ // and `doctrina coverage` — so scanning the raw file made every change look
60
+ // like it touched a documented surface. A gate that fires on everything is
61
+ // a gate that gets ignored, so the template's lines are subtracted first.
62
+ const boilerplate = scaffoldLines();
63
+ const authored = (text) => text
64
+ .split(/\r?\n/)
65
+ .filter((line) => !boilerplate.has(normaliseScaffoldLine(line)))
66
+ .join("\n");
67
+
68
+ const sources = [];
69
+ const proposal = path.join(changeDir, "proposal.md");
70
+ if (isFile(proposal)) sources.push(authored(read(proposal)));
71
+ for (const p of walk(path.join(changeDir, "specs"))) {
72
+ if (p.endsWith("delta.md")) sources.push(authored(read(p)));
73
+ }
74
+ const text = sources.join("\n");
75
+ if (!text.trim()) return signals;
76
+
77
+ // Command references, from code context only, so prose about "the
78
+ // doctrina framework" is not a signal.
79
+ const chunks = [];
80
+ const outside = text.replace(/```[\s\S]*?```/g, (b) => { chunks.push(b); return "\n"; });
81
+ for (const m of outside.matchAll(/`[^`]+`/g)) chunks.push(m[0]);
82
+ const code = chunks.join("\n");
83
+
84
+ const commands = new Set();
85
+ for (const m of code.matchAll(/(?:npx\s+doctrina-cli|doctrina)\s+([a-z][a-z-]+)/g)) {
86
+ if (known.has(m[1])) commands.add(m[1]);
87
+ }
88
+ if (commands.size > 0) {
89
+ signals.push(`commands: ${[...commands].sort().join(", ")}`);
90
+ }
91
+
92
+ const flags = new Set();
93
+ for (const m of code.matchAll(/--([a-z][a-z0-9-]{2,})/g)) flags.add(`--${m[1]}`);
94
+ if (flags.size > 0) {
95
+ signals.push(`flags: ${[...flags].sort().slice(0, 8).join(", ")}`);
96
+ }
97
+
98
+ if (/\bexit\s+code|\bexits?\s+(?:with\s+)?[0-4]\b/i.test(text)) {
99
+ signals.push("exit codes");
100
+ }
101
+
102
+ return signals;
103
+ }
104
+
105
+ // Documentation paths touched by the work in flight. Union of the working
106
+ // tree (uncommitted, the common agent case) and this branch's commits
107
+ // against the default branch (the case where the agent already committed).
108
+ // Returns [] outside a git repository — where the gate cannot see, it does
109
+ // not accuse.
110
+ //
111
+ // KNOWN COARSENESS: this answers "did documentation move in this work?",
112
+ // not "did documentation move FOR THIS CHANGE?". Closing several changes
113
+ // from one uncommitted working tree, docs written for an earlier change
114
+ // satisfy the gate for a later one. The gate is deliberately permissive
115
+ // here — its job is to stop a change shipping with NO documentation at
116
+ // all, and attributing a hunk to a change would need guesswork the rest of
117
+ // the framework refuses to do (ADR 0005).
118
+ export function docsTouched(projectRoot) {
119
+ const git = (args) => {
120
+ const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
121
+ if (r.error || r.status !== 0) return [];
122
+ return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
123
+ };
124
+ const set = new Set();
125
+ for (const f of git(["diff", "--name-only", "HEAD"])) set.add(f);
126
+ for (const f of git(["ls-files", "--others", "--exclude-standard"])) set.add(f);
127
+ for (const base of ["main", "master"]) {
128
+ const mb = git(["merge-base", "HEAD", base]);
129
+ if (mb.length > 0) {
130
+ for (const f of git(["diff", "--name-only", `${mb[0]}..HEAD`])) set.add(f);
131
+ break;
132
+ }
133
+ }
134
+ return [...set]
135
+ .map((f) => f.replace(/\\/g, "/"))
136
+ .filter((f) => DOC_PATHS.some((re) => re.test(f)));
137
+ }
138
+
139
+ export function isGitRepo(projectRoot) {
140
+ const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
141
+ cwd: projectRoot, encoding: "utf8",
142
+ });
143
+ return !r.error && r.status === 0;
144
+ }
145
+
146
+ // The gate itself: { ok, signals, touched, reason }. `ok` is true when the
147
+ // change touches no documented surface, when documentation moved with it,
148
+ // or when git cannot answer.
149
+ export function checkDocsImpact(projectRoot, changeDir) {
150
+ const signals = documentedSurfaceSignals(changeDir);
151
+ if (signals.length === 0) {
152
+ return { ok: true, signals, touched: [], reason: "touches no documented surface" };
153
+ }
154
+ if (!isGitRepo(projectRoot)) {
155
+ return { ok: true, signals, touched: [], reason: "not a git repository — cannot tell what moved" };
156
+ }
157
+ const touched = docsTouched(projectRoot);
158
+ if (touched.length > 0) {
159
+ return { ok: true, signals, touched, reason: `documentation moved with it (${touched.length} file${touched.length === 1 ? "" : "s"})` };
160
+ }
161
+ return {
162
+ ok: false,
163
+ signals,
164
+ touched,
165
+ reason: "alters a documented surface but no docs/ or README change accompanies it",
166
+ };
167
+ }
package/src/lib/ears.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Structural EARS checks for capability specs. The clarify command catches
2
3
  // word-level smells; this catches grammar-shape problems per EARS section.
3
4
  // All findings are warnings: specs evolve, validation should not block.
@@ -0,0 +1,103 @@
1
+ // @ts-check
2
+ // The exit-code contract (audit item C7).
3
+ //
4
+ // Code 1 used to mean three different things, and an agent driving the
5
+ // loop could not tell them apart:
6
+ //
7
+ // "your change is not ready" -> iterate and retry
8
+ // "this project is not configured" -> run a setup command first
9
+ // "this machine cannot run this" -> stop
10
+ //
11
+ // All three returned 1. The first means keep working, the second means do
12
+ // something else first, the third means give up — and an autonomous loop
13
+ // that cannot distinguish them either spins on an unfixable failure or
14
+ // abandons a fixable one.
15
+ //
16
+ // This is the smallest module in the CLI and the one a machine consumer
17
+ // depends on most. Every command's exit path maps to a member here.
18
+
19
+ export const EXIT = Object.freeze({
20
+ /** The command did what it was asked. Warnings are allowed. */
21
+ OK: 0,
22
+ /** A gate failed: the WORK is not ready. Fix the work and retry. */
23
+ GATE: 1,
24
+ /** The INVOCATION is wrong: unknown command, missing/malformed argument.
25
+ * Do not retry unchanged. */
26
+ USAGE: 2,
27
+ /** A PRECONDITION is missing: the project is not set up for this yet.
28
+ * Run the named setup command, then retry. */
29
+ PRECONDITION: 3,
30
+ /** The ENVIRONMENT cannot run this: git absent, unreadable tree, and
31
+ * other conditions no amount of retrying fixes. Stop. */
32
+ ENVIRONMENT: 4,
33
+ });
34
+
35
+ // What each code means, for `--help`, the docs, and the tests that assert
36
+ // the mapping. Keyed by numeric code so a consumer can look one up.
37
+ export const EXIT_MEANINGS = Object.freeze({
38
+ [EXIT.OK]: {
39
+ name: "OK",
40
+ summary: "success (warnings allowed)",
41
+ agentAction: "continue",
42
+ },
43
+ [EXIT.GATE]: {
44
+ name: "GATE",
45
+ summary: "a gate failed — the work is not ready",
46
+ agentAction: "fix the work, then retry the same command",
47
+ },
48
+ [EXIT.USAGE]: {
49
+ name: "USAGE",
50
+ summary: "the invocation is wrong — unknown command or bad argument",
51
+ agentAction: "do not retry unchanged; correct the invocation",
52
+ },
53
+ [EXIT.PRECONDITION]: {
54
+ name: "PRECONDITION",
55
+ summary: "a precondition is missing — the project is not set up for this",
56
+ agentAction: "run the setup command named in the error, then retry",
57
+ },
58
+ [EXIT.ENVIRONMENT]: {
59
+ name: "ENVIRONMENT",
60
+ summary: "the environment cannot run this command",
61
+ agentAction: "stop; retrying will not help",
62
+ },
63
+ });
64
+
65
+ // The block `doctrina --help` prints, generated so help and contract
66
+ // cannot drift.
67
+ export function exitCodeHelp() {
68
+ return Object.entries(EXIT_MEANINGS)
69
+ .map(([code, m]) => ` ${code} ${m.summary}`)
70
+ .join("\n");
71
+ }
72
+
73
+ // A precondition failure, thrown by a command whose project/config is not
74
+ // ready. The entrypoint maps it to EXIT.PRECONDITION and prints the setup
75
+ // command, so every "you need to run X first" path reports the same code.
76
+ export class PreconditionError extends Error {
77
+ constructor(message, remedy) {
78
+ super(message);
79
+ this.name = "PreconditionError";
80
+ this.exitCode = EXIT.PRECONDITION;
81
+ this.remedy = remedy ?? null;
82
+ }
83
+ }
84
+
85
+ // An environment failure: the machine, not the project, is the problem.
86
+ export class EnvironmentError extends Error {
87
+ constructor(message, remedy) {
88
+ super(message);
89
+ this.name = "EnvironmentError";
90
+ this.exitCode = EXIT.ENVIRONMENT;
91
+ this.remedy = remedy ?? null;
92
+ }
93
+ }
94
+
95
+ // "Not a Doctrina project" is the single most common precondition, raised
96
+ // by nearly every command. One constructor so the message and the code
97
+ // stay identical everywhere.
98
+ export function notADoctrinaProject() {
99
+ return new PreconditionError(
100
+ "not a Doctrina project (no .doctrina/ in cwd)",
101
+ "doctrina init",
102
+ );
103
+ }
@@ -0,0 +1,106 @@
1
+ // @ts-check
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { COMMAND_NAMES } from "./commands.js";
6
+
7
+ // One catalog of "which flags does command X accept", with two consumers:
8
+ //
9
+ // - the static undeclared-flag test (C3), which asserts every flag a
10
+ // command READS is declared, so `parseArgs` never swallows a positional
11
+ // as an undeclared flag's value again; and
12
+ // - the docs accuracy gate (D1, `scripts/check-docs.js`), which asserts
13
+ // every flag the DOCS document against a command is declared.
14
+ //
15
+ // One invariant, two checks: source and prose are held to the same list, so
16
+ // a renamed flag cannot stay green in either place. Both read a command
17
+ // module's exported `flags` — the declaration lives WITH the command, not in
18
+ // the entrypoint, which is why an added command no longer requires editing
19
+ // `src/index.js` to make its flags parse (the structural half of C3).
20
+
21
+ const here = path.dirname(fileURLToPath(import.meta.url));
22
+ const commandsDir = path.join(here, "..", "commands");
23
+
24
+ // Flags the entrypoint owns for every command; a command never redeclares them.
25
+ export const GLOBAL_FLAGS = Object.freeze({
26
+ boolean: ["help", "h", "v", "debug"],
27
+ string: [],
28
+ });
29
+
30
+ // Command name -> module filename. Every command's module is `<name>.js`
31
+ // except where this map says otherwise.
32
+ const MODULE_OVERRIDES = { index: "index-rebuild.js" };
33
+
34
+ export function moduleFileFor(commandName) {
35
+ return MODULE_OVERRIDES[commandName] ?? `${commandName}.js`;
36
+ }
37
+
38
+ export function modulePathFor(commandName) {
39
+ return path.join(commandsDir, moduleFileFor(commandName));
40
+ }
41
+
42
+ // The flag spec a command module exports, normalised to
43
+ // { boolean: string[], string: string[] }. Returns null when the module
44
+ // declares none — which the C3 test treats as a failure and the docs gate
45
+ // treats as "not checkable yet", so the two can land independently.
46
+ export async function loadFlagSpec(commandName) {
47
+ const mod = await import(pathToUrl(modulePathFor(commandName)));
48
+ const spec = mod.flags;
49
+ if (!spec) return null;
50
+ return {
51
+ boolean: [...(spec.boolean ?? [])],
52
+ string: [...(spec.string ?? [])],
53
+ };
54
+ }
55
+
56
+ // Every flag name a command accepts (own + global), as a Set. null when the
57
+ // command declares no spec.
58
+ export async function declaredFlags(commandName) {
59
+ const spec = await loadFlagSpec(commandName);
60
+ if (!spec) return null;
61
+ return new Set([
62
+ ...spec.boolean, ...spec.string,
63
+ ...GLOBAL_FLAGS.boolean, ...GLOBAL_FLAGS.string,
64
+ ]);
65
+ }
66
+
67
+ export async function loadAllFlagSpecs() {
68
+ const out = new Map();
69
+ for (const name of COMMAND_NAMES) out.set(name, await loadFlagSpec(name));
70
+ return out;
71
+ }
72
+
73
+ // Flag names a source file READS, by scanning its call sites:
74
+ // flagBool(flags, "x") flagString(flags, "x") flags.has("x") flags.get("x")
75
+ // Deterministic text scan, not execution — it sees every branch, including
76
+ // the ones a given run never takes.
77
+ export function scanFlagUsage(sourceText) {
78
+ const used = new Set();
79
+ const patterns = [
80
+ /flagBool\(\s*flags\s*,\s*["']([^"']+)["']/g,
81
+ /flagString\(\s*flags\s*,\s*["']([^"']+)["']/g,
82
+ /flags\.has\(\s*["']([^"']+)["']/g,
83
+ /flags\.get\(\s*["']([^"']+)["']/g,
84
+ ];
85
+ for (const re of patterns) {
86
+ for (const m of sourceText.matchAll(re)) used.add(m[1]);
87
+ }
88
+ return used;
89
+ }
90
+
91
+ export function readCommandSource(commandName) {
92
+ return readFileSync(modulePathFor(commandName), "utf8");
93
+ }
94
+
95
+ // Flags a command's `help` string documents: every `--flag` token in it.
96
+ // The docs gate compares prose against this too, so help and reference
97
+ // cannot drift apart from the declaration.
98
+ export function scanHelpFlags(helpText) {
99
+ const out = new Set();
100
+ for (const m of String(helpText ?? "").matchAll(/--([a-z][a-z0-9-]*)/g)) out.add(m[1]);
101
+ return out;
102
+ }
103
+
104
+ function pathToUrl(p) {
105
+ return "file:///" + p.replace(/\\/g, "/");
106
+ }
package/src/lib/fs-ops.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, renameSync, unlinkSync, rmdirSync } from "node:fs";
2
3
  import path from "node:path";
3
4
 
@@ -0,0 +1,123 @@
1
+ // @ts-check
2
+ import { getSection } from "./doc-model.js";
3
+ import path from "node:path";
4
+ import { appendFileSync, writeFileSync } from "node:fs";
5
+ import { exists, isFile, mkdirp, read } from "./fs-ops.js";
6
+ import { today } from "./dates.js";
7
+ import { collectAnalysis } from "../commands/analyze.js";
8
+
9
+ // The gate-to-transition map (audit item C6).
10
+ //
11
+ // Each command used to decide its own preconditions. `change archive` and
12
+ // `close` checked theirs; `change apply` checked nothing. So `analyze`
13
+ // could exit 1 on a change and `change apply` would mutate it anyway and
14
+ // exit 0 — and the README flowchart tells agents to follow exactly that
15
+ // analyze → apply path. An agent could reach, through one path, a state
16
+ // another path forbids.
17
+ //
18
+ // The fix is not "add a check to apply" but "declare once which gates
19
+ // guard which transition, and have every driver consult the declaration".
20
+ // Which specific gate guards which transition matters less than the
21
+ // guarantee that the answer does not depend on how you got there.
22
+
23
+ // A gate is a named precondition returning human-readable blockers.
24
+ export const GATES = {
25
+ // Structural validity: the change's own files parse and point at real
26
+ // targets. The same checks `doctrina analyze` reports.
27
+ structure: {
28
+ label: "structure",
29
+ rerun: (id) => `doctrina analyze ${id}`,
30
+ blockers(projectRoot, changeDir) {
31
+ return collectAnalysis(projectRoot, changeDir)
32
+ .filter((r) => r.kind === "fail")
33
+ .map((r) => stripAnsi(r.line).replace(/^✗\s*/, "").trim());
34
+ },
35
+ },
36
+
37
+ // Verification: the work is claimed complete. Every checkbox in
38
+ // tasks.md (closing steps included) and in the proposal's
39
+ // "## Verification" section.
40
+ verification: {
41
+ label: "verification",
42
+ rerun: (id) => `doctrina change tick ${id}`,
43
+ blockers(projectRoot, changeDir) {
44
+ const out = [];
45
+ const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
46
+
47
+ const tasksPath = path.join(changeDir, "tasks.md");
48
+ if (isFile(tasksPath)) {
49
+ const n = countUnchecked(read(tasksPath));
50
+ if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
51
+ }
52
+ const proposalPath = path.join(changeDir, "proposal.md");
53
+ if (isFile(proposalPath)) {
54
+ const n = countUnchecked(getSection(read(proposalPath), "Verification"));
55
+ if (n > 0) out.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
56
+ }
57
+ return out;
58
+ },
59
+ },
60
+ };
61
+
62
+ // Which gates guard each lifecycle transition. The single declaration.
63
+ //
64
+ // `apply` requires structure: mutating specs from a change whose deltas do
65
+ // not parse is how a malformed delta reaches a spec file. This is the gate
66
+ // `analyze` reports and that `apply` used to ignore entirely (C6).
67
+ //
68
+ // `archive` requires verification, and deliberately NOT structure. The
69
+ // structure gate is a PRE-APPLY question — "is this safe to apply?" — and
70
+ // one of its checks is that an ADDED delta's target does not already hold
71
+ // real content. After a successful apply that target holds exactly the
72
+ // content the delta just wrote, so re-asking at archive time reports a
73
+ // conflict that is the proof the apply worked. A gate must be asked at the
74
+ // point its question is meaningful.
75
+ export const TRANSITIONS = {
76
+ apply: { label: "change apply", gates: ["structure"] },
77
+ archive: { label: "change archive", gates: ["verification"] },
78
+ };
79
+
80
+ // Evaluate a transition's gates. Returns { ok, blockers, gates } where
81
+ // blockers is a flat list of human-readable reasons.
82
+ export function checkTransition(projectRoot, changeDir, transition) {
83
+ const t = TRANSITIONS[transition];
84
+ if (!t) throw new Error(`unknown transition "${transition}"`);
85
+ const blockers = [];
86
+ for (const name of t.gates) {
87
+ for (const b of GATES[name].blockers(projectRoot, changeDir)) {
88
+ blockers.push({ gate: name, message: b });
89
+ }
90
+ }
91
+ return { ok: blockers.length === 0, blockers, gates: t.gates };
92
+ }
93
+
94
+ // Record a forced transition in the archive ledger, so a gap that was
95
+ // waved through is part of the visible history rather than nothing at all
96
+ // — the same posture `change archive --force` and `change abandon` take.
97
+ export function recordForcedGap(projectRoot, id, transition, blockers) {
98
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
99
+ const ledgerPath = path.join(archiveDir, "LEDGER.md");
100
+ // Create the ledger if this is the first entry. A forced transition can
101
+ // happen before any change has ever been archived, and a gap that goes
102
+ // unrecorded because the file did not exist yet is exactly the silence
103
+ // this is meant to break.
104
+ if (!exists(ledgerPath)) {
105
+ mkdirp(archiveDir);
106
+ writeFileSync(ledgerPath,
107
+ "# Change ledger\n\n" +
108
+ "One line per archived change, newest last. Appended by\n" +
109
+ "`doctrina change archive`; edit freely, the CLI only appends.\n\n");
110
+ }
111
+ const summary = blockers.map((b) => `${b.gate}: ${b.message}`).join("; ");
112
+ appendFileSync(
113
+ ledgerPath,
114
+ ` - ${today()} — ${id} — forced ${transition} past ${blockers.length} ` +
115
+ `blocker${blockers.length === 1 ? "" : "s"} (${summary})\n`,
116
+ );
117
+ return true;
118
+ }
119
+
120
+ function stripAnsi(s) {
121
+ // eslint-disable-next-line no-control-regex
122
+ return String(s).replace(/\[[0-9;]*m/g, "");
123
+ }
package/src/lib/git.js ADDED
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+ import { spawnSync } from "node:child_process";
3
+
4
+ // One place that knows how to ask git a question and how to interpret not
5
+ // getting an answer (audit item C8).
6
+ //
7
+ // Nine call sites each ran `spawnSync("git", ...)` and decided for
8
+ // themselves what a non-zero status meant. `metrics` decided it meant
9
+ // failure, so on a brand-new project — the most common state in which
10
+ // someone explores the CLI — it printed:
11
+ //
12
+ // error: git log failed: fatal: your current branch 'master' does not
13
+ // have any commits yet
14
+ //
15
+ // A repository with no commits is a valid state, not an error. So is a
16
+ // directory that is not a repository at all. What is genuinely an error
17
+ // is git being absent when a command cannot work without it — and that is
18
+ // an ENVIRONMENT condition (exit 4), not a gate failure.
19
+
20
+ export const GIT_STATE = Object.freeze({
21
+ /** git ran and answered. */
22
+ OK: "ok",
23
+ /** A repository with no commits yet — a valid first-run state. */
24
+ EMPTY: "empty",
25
+ /** Not a git repository. */
26
+ NOT_A_REPO: "not-a-repo",
27
+ /** git is not installed or not on PATH. */
28
+ ABSENT: "absent",
29
+ /** git ran and refused, for a reason this module does not classify. */
30
+ FAILED: "failed",
31
+ });
32
+
33
+ // Run a git command. Never throws. Returns
34
+ // { state, stdout, lines, stderr, code }.
35
+ export function git(cwd, args) {
36
+ const r = spawnSync("git", args, { cwd, encoding: "utf8" });
37
+ if (r.error) {
38
+ // ENOENT here means the binary is missing, not that the command failed.
39
+ return { state: GIT_STATE.ABSENT, stdout: "", lines: [], stderr: String(r.error.message ?? ""), code: null };
40
+ }
41
+ const stderr = String(r.stderr ?? "");
42
+ if (r.status === 0) {
43
+ const stdout = String(r.stdout ?? "");
44
+ return { state: GIT_STATE.OK, stdout, lines: splitLines(stdout), stderr, code: 0 };
45
+ }
46
+ // git's own wording for the benign cases. Matched on the message because
47
+ // git reports them all with a generic non-zero status. "Needed a single
48
+ // revision" is what `rev-parse --verify HEAD` says on a repo with no
49
+ // commits, which is how the empty-history probe first fooled itself.
50
+ if (/does not have any commits yet|unknown revision or path not in the working tree|bad default revision|Needed a single revision|ambiguous argument 'HEAD'/i.test(stderr)) {
51
+ return { state: GIT_STATE.EMPTY, stdout: "", lines: [], stderr, code: r.status };
52
+ }
53
+ if (/not a git repository/i.test(stderr)) {
54
+ return { state: GIT_STATE.NOT_A_REPO, stdout: "", lines: [], stderr, code: r.status };
55
+ }
56
+ // Anything else that exited non-zero is a failure, NOT a success with an
57
+ // empty answer. Reporting it as OK is how a caller reads "no results"
58
+ // from a command that actually refused.
59
+ return { state: GIT_STATE.FAILED, stdout: "", lines: [], stderr, code: r.status };
60
+ }
61
+
62
+ // Is this a git repository at all?
63
+ export function isRepo(cwd) {
64
+ return git(cwd, ["rev-parse", "--is-inside-work-tree"]).state === GIT_STATE.OK;
65
+ }
66
+
67
+ // Does it have at least one commit? False for a fresh `git init`.
68
+ export function hasCommits(cwd) {
69
+ return git(cwd, ["rev-parse", "--verify", "HEAD"]).state === GIT_STATE.OK;
70
+ }
71
+
72
+ // The state a command should report before trying to read history:
73
+ // { usable, state, reason } where `reason` is a sentence fit to print.
74
+ export function historyState(cwd) {
75
+ const probe = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
76
+ if (probe.state === GIT_STATE.ABSENT) {
77
+ return { usable: false, state: GIT_STATE.ABSENT, reason: "git is not installed or not on PATH" };
78
+ }
79
+ if (probe.state !== GIT_STATE.OK) {
80
+ return { usable: false, state: GIT_STATE.NOT_A_REPO, reason: "this is not a git repository" };
81
+ }
82
+ if (!hasCommits(cwd)) {
83
+ return { usable: false, state: GIT_STATE.EMPTY, reason: "this repository has no commits yet" };
84
+ }
85
+ return { usable: true, state: GIT_STATE.OK, reason: "" };
86
+ }
87
+
88
+ // Lines of output, trimmed and blank-filtered — what nearly every caller
89
+ // actually wanted from stdout.
90
+ export function gitLines(cwd, args) {
91
+ const r = git(cwd, args);
92
+ return r.state === GIT_STATE.OK ? r.lines : [];
93
+ }
94
+
95
+ function splitLines(s) {
96
+ return s.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
97
+ }
@@ -1,9 +1,22 @@
1
+ // @ts-check
1
2
  import path from "node:path";
2
3
  import { read, write, exists } from "./fs-ops.js";
3
4
  import { cliVersion } from "./version.js";
4
5
 
5
6
  export const SCHEMA_VERSION = "0.1.0";
6
7
 
8
+ // The artifact categories a well-formed index.json carries. ONE definition,
9
+ // consumed by `blank()` (what `init` writes) and by `templates check` /
10
+ // `templates update` (what a project is measured against).
11
+ //
12
+ // There used to be two: the shape `init` materialised from a template file
13
+ // and the shape `templates check` required. Nothing compared them, so when
14
+ // `contracts` was added to the expected shape and not to the template,
15
+ // every project was born needing a scaffold update (audit item C5).
16
+ export const ARTIFACT_CATEGORIES = Object.freeze([
17
+ "specs", "decisions", "changes", "changes_archive", "skills", "contracts",
18
+ ]);
19
+
7
20
  export function indexPath(projectRoot) {
8
21
  return path.join(projectRoot, ".doctrina", "index.json");
9
22
  }
@@ -46,12 +59,7 @@ export function blank(projectName, date) {
46
59
  version: "0.1.0",
47
60
  last_updated: date,
48
61
  },
49
- specs: [],
50
- decisions: [],
51
- changes: [],
52
- changes_archive: [],
53
- skills: [],
54
- contracts: [],
62
+ ...Object.fromEntries(ARTIFACT_CATEGORIES.map((cat) => [cat, []])),
55
63
  },
56
64
  };
57
65
  }