doctrina-cli 0.12.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.
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +76 -19
- package/src/commands/change.js +341 -44
- package/src/commands/clarify.js +40 -19
- package/src/commands/close.js +119 -11
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +359 -53
- package/src/commands/contract.js +17 -24
- package/src/commands/coverage.js +16 -4
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +38 -7
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +152 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +14 -2
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +16 -6
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +277 -40
- package/src/commands/trace.js +15 -3
- package/src/commands/upgrade.js +23 -12
- package/src/commands/validate.js +93 -3
- package/src/commands/verify.js +12 -2
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +69 -7
- package/src/index.js +60 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +47 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +314 -3
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +228 -3
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/.gitkeep +0 -0
- package/templates/AGENTS.md.template +71 -75
- package/templates/change/spec-delta.md.template +13 -7
package/src/lib/gates.js
ADDED
|
@@ -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
|
+
}
|
package/src/lib/index-json.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { EXIT } from "./exit-codes.js";
|
|
4
|
+
|
|
5
|
+
// `--json` on every command, with a versioned envelope (audit item M7).
|
|
6
|
+
//
|
|
7
|
+
// Five of thirty-five commands spoke JSON. Everything else emitted
|
|
8
|
+
// ANSI-coloured prose, while the primary consumer is a machine. Paired with
|
|
9
|
+
// the exit-code contract (ADR 0018), the two form the machine interface:
|
|
10
|
+
// structured output plus a meaningful status. Without both, an autonomous
|
|
11
|
+
// loop is parsing English.
|
|
12
|
+
//
|
|
13
|
+
// Two levels of support, and the difference is stated rather than hidden:
|
|
14
|
+
//
|
|
15
|
+
// STRUCTURED — the command builds a payload describing its result. These
|
|
16
|
+
// are the read and report commands, where a result has real shape.
|
|
17
|
+
// ENVELOPE — the command's human output is captured and returned inside
|
|
18
|
+
// a versioned envelope alongside `ok` and `exitCode`. Branch on those;
|
|
19
|
+
// the lines are there for completeness, not for parsing.
|
|
20
|
+
//
|
|
21
|
+
// A command with nothing better to say is still machine-consumable, which
|
|
22
|
+
// is what makes "--json on every command" a claim rather than an intention.
|
|
23
|
+
|
|
24
|
+
export const JSON_SCHEMA_VERSION = "1.0.0";
|
|
25
|
+
|
|
26
|
+
export function wantsJson(flags) {
|
|
27
|
+
return flags?.get?.("json") === true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Print a structured payload. `command` is the invocation, `data` whatever
|
|
31
|
+
// that command has to say.
|
|
32
|
+
export function emitJson(command, data, { ok = true, exitCode = EXIT.OK } = {}) {
|
|
33
|
+
process.stdout.write(JSON.stringify({
|
|
34
|
+
$schema_version: JSON_SCHEMA_VERSION,
|
|
35
|
+
command,
|
|
36
|
+
ok,
|
|
37
|
+
exit_code: exitCode,
|
|
38
|
+
...data,
|
|
39
|
+
}, null, 2) + "\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Run `fn` with console output captured, so a command that has no
|
|
43
|
+
// structured payload can still answer in JSON. Returns { code, stdout,
|
|
44
|
+
// stderr } with the lines it would have printed.
|
|
45
|
+
export async function captureOutput(fn) {
|
|
46
|
+
const stdout = [];
|
|
47
|
+
const stderr = [];
|
|
48
|
+
const origLog = console.log;
|
|
49
|
+
const origError = console.error;
|
|
50
|
+
const origWarn = console.warn;
|
|
51
|
+
// Some commands write to the stream directly rather than through console
|
|
52
|
+
// — `completion` emits a shell script meant for redirection. Capturing
|
|
53
|
+
// only console.* let that output escape the envelope and corrupt the
|
|
54
|
+
// JSON on stdout, so the raw stream is captured too.
|
|
55
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
56
|
+
const pushLines = (target, chunk) => {
|
|
57
|
+
for (const line of String(chunk).replace(/\n$/, "").split("\n")) target.push(line);
|
|
58
|
+
};
|
|
59
|
+
console.log = (...args) => stdout.push(args.map(String).join(" "));
|
|
60
|
+
console.error = (...args) => stderr.push(args.map(String).join(" "));
|
|
61
|
+
console.warn = (...args) => stderr.push(args.map(String).join(" "));
|
|
62
|
+
process.stdout.write = (chunk, ...rest) => {
|
|
63
|
+
pushLines(stdout, chunk);
|
|
64
|
+
const cb = rest.find((r) => typeof r === "function");
|
|
65
|
+
if (cb) cb();
|
|
66
|
+
return true;
|
|
67
|
+
};
|
|
68
|
+
try {
|
|
69
|
+
const code = await fn();
|
|
70
|
+
return { code: code ?? EXIT.OK, stdout, stderr };
|
|
71
|
+
} finally {
|
|
72
|
+
console.log = origLog;
|
|
73
|
+
console.error = origError;
|
|
74
|
+
console.warn = origWarn;
|
|
75
|
+
process.stdout.write = origWrite;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Strip ANSI so captured lines are usable as data even when colour was on.
|
|
80
|
+
export function stripAnsi(s) {
|
|
81
|
+
// eslint-disable-next-line no-control-regex
|
|
82
|
+
return String(s).replace(/\[[0-9;]*m/g, "");
|
|
83
|
+
}
|
package/src/lib/prompt.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import readline from "node:readline";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
|
|
5
|
+
// Is there a real person on the other end? Every prompt must ask this
|
|
6
|
+
// first. `init` checked it before the ADAPTER prompt but not before the
|
|
7
|
+
// project-description prompt, so a non-interactive caller that forgot
|
|
8
|
+
// --non-interactive got a project with a blank description and no warning
|
|
9
|
+
// (audit item C9).
|
|
10
|
+
export function isInteractive() {
|
|
11
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
12
|
+
}
|
|
2
13
|
|
|
3
14
|
export async function ask(question, { defaultValue = "" } = {}) {
|
|
4
15
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -11,7 +22,13 @@ export async function ask(question, { defaultValue = "" } = {}) {
|
|
|
11
22
|
}
|
|
12
23
|
}
|
|
13
24
|
|
|
14
|
-
|
|
25
|
+
// Ask for consent. Off a terminal there is nobody to ask, so the answer is
|
|
26
|
+
// `whenNonInteractive` — which callers set to false for destructive
|
|
27
|
+
// operations: silence is not consent.
|
|
28
|
+
export async function confirm(question, { defaultYes = false, whenNonInteractive = null } = {}) {
|
|
29
|
+
if (!isInteractive()) {
|
|
30
|
+
return whenNonInteractive === null ? defaultYes : whenNonInteractive;
|
|
31
|
+
}
|
|
15
32
|
const suffix = defaultYes ? "Y/n" : "y/N";
|
|
16
33
|
const ans = await ask(`${question} (${suffix})`);
|
|
17
34
|
if (!ans) return defaultYes;
|
package/src/lib/scan.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getHeader, getSection } from "./doc-model.js";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import { readdirSync } from "node:fs";
|
|
3
5
|
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
@@ -5,29 +7,18 @@ import { today } from "./dates.js";
|
|
|
5
7
|
import { parseFrontmatter } from "../commands/skill.js";
|
|
6
8
|
import { parseOperation, parseCapabilityFromDelta } from "../commands/change.js";
|
|
7
9
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// agent hand-authoring an ADR with the bare spec form, or vice versa)
|
|
13
|
-
// still parses instead of silently falling back to a default value.
|
|
14
|
-
const HEADER_PREFIX = "(?:-\\s+)?";
|
|
15
|
-
|
|
16
|
-
// Parse a spec-style header line: **Name:** value (dash optional).
|
|
10
|
+
// Header reading lives in ONE place now (lib/doc-model.js, audit item M3).
|
|
11
|
+
// These two names survive because dozens of call sites use them and the
|
|
12
|
+
// distinction they once encoded — spec style vs list style — is now the
|
|
13
|
+
// document model's job, not the caller's.
|
|
17
14
|
export function specHeader(text, name) {
|
|
18
|
-
|
|
19
|
-
return m ? m[1].trim() : null;
|
|
15
|
+
return getHeader(text, name);
|
|
20
16
|
}
|
|
21
17
|
|
|
22
|
-
// Parse an ADR/proposal-style header line: - **Name:** value (dash optional).
|
|
23
18
|
export function listHeader(text, name) {
|
|
24
|
-
|
|
25
|
-
return m ? m[1].trim() : null;
|
|
19
|
+
return getHeader(text, name);
|
|
26
20
|
}
|
|
27
21
|
|
|
28
|
-
// The machine-readable "**Depends on:** defects, decisions" spec header: the
|
|
29
|
-
// sibling capabilities this spec builds on. Returns [] when absent, "n/a", or
|
|
30
|
-
// empty. One parser so index, why, context, and review agree.
|
|
31
22
|
export function parseDependsOn(text) {
|
|
32
23
|
const raw = specHeader(text, "Depends on");
|
|
33
24
|
if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
|
|
@@ -39,6 +30,72 @@ function dirEntries(dir) {
|
|
|
39
30
|
return readdirSync(dir).filter((e) => !e.startsWith(".")).sort();
|
|
40
31
|
}
|
|
41
32
|
|
|
33
|
+
|
|
34
|
+
// The capabilities an ADR governs, from its optional "Scope:" header
|
|
35
|
+
// (M4). Returns [] when absent, "n/a", or "—" — an unscoped ADR is global
|
|
36
|
+
// and belongs in every pack, which is what keeps this backward compatible.
|
|
37
|
+
export function parseAdrScope(text) {
|
|
38
|
+
const raw = listHeader(text, "Scope");
|
|
39
|
+
if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
|
|
40
|
+
return raw.match(/[a-z][a-z0-9][a-z0-9-]*/g) ?? [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// One sentence describing what an ADR decided, taken from its "## Decision"
|
|
44
|
+
// section. This is what a budget-constrained pack falls back to before it
|
|
45
|
+
// drops an ADR entirely: a title and a sentence still carry the decision,
|
|
46
|
+
// where an omission carries nothing.
|
|
47
|
+
export function adrSummary(text) {
|
|
48
|
+
const body = getSection(text, "Decision");
|
|
49
|
+
if (!body) return null;
|
|
50
|
+
for (const line of body.split("\n")) {
|
|
51
|
+
const s = line.trim();
|
|
52
|
+
if (!s || s.startsWith("<!--") || s.startsWith("#") || s.startsWith("|")) continue;
|
|
53
|
+
// Take the first sentence, capped so the index stays scannable.
|
|
54
|
+
const plain = s.replace(/^[-*\d.]+\s*/, "").replace(/\*\*/g, "");
|
|
55
|
+
const sentence = (plain.match(/^.*?[.!?](?=\s|$)/) ?? [plain])[0].trim();
|
|
56
|
+
if (sentence.length < 12) continue;
|
|
57
|
+
return sentence.length > 220 ? sentence.slice(0, 217).trimEnd() + "..." : sentence;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The index entry for ONE decision, derived from its file. This is the
|
|
63
|
+
// single definition of a decision record: `deriveIndex` builds the whole
|
|
64
|
+
// list from it, and `doctrina decision new` registers a new ADR through it
|
|
65
|
+
// rather than assembling a look-alike by hand. Two constructors for one
|
|
66
|
+
// record shape is how a field added to the deriver (M4's `summary`) turns
|
|
67
|
+
// every freshly created ADR into index drift the moment it is written.
|
|
68
|
+
export function decisionEntry(text, basename, prev, date) {
|
|
69
|
+
const id = basename.match(/^(\d{4})-/)?.[1] ?? prev?.id ?? "0000";
|
|
70
|
+
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
71
|
+
const entry = {
|
|
72
|
+
id,
|
|
73
|
+
path: `.doctrina/decisions/${basename}`,
|
|
74
|
+
title: titleMatch ? titleMatch[1].trim() : prev?.title ?? basename,
|
|
75
|
+
status: listHeader(text, "Status") ?? prev?.status ?? "proposed",
|
|
76
|
+
date: listHeader(text, "Date") ?? prev?.date ?? date,
|
|
77
|
+
};
|
|
78
|
+
const supersedes = listHeader(text, "Supersedes");
|
|
79
|
+
if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
|
|
80
|
+
const supersededBy = listHeader(text, "Superseded by");
|
|
81
|
+
if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
|
|
82
|
+
const landed = listHeader(text, "Landed");
|
|
83
|
+
if (landed && landed !== "—") entry.landed = landed;
|
|
84
|
+
// Scope (M4): the capabilities this decision governs. ADRs are immutable
|
|
85
|
+
// and never retire, so without a scope every accepted ADR loads into every
|
|
86
|
+
// context pack forever — the pack grows O(project age) with no decay. An
|
|
87
|
+
// ADR with no Scope: is GLOBAL by definition, the backward-compatible
|
|
88
|
+
// default.
|
|
89
|
+
const scope = parseAdrScope(text);
|
|
90
|
+
if (scope.length > 0) entry.scope = scope;
|
|
91
|
+
// Summary: one sentence from the Decision section, so a pack over budget
|
|
92
|
+
// can degrade an ADR to title + summary instead of dropping it outright.
|
|
93
|
+
const summary = adrSummary(text);
|
|
94
|
+
if (summary) entry.summary = summary;
|
|
95
|
+
return entry;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
42
99
|
// Regenerate the index object from the artifacts on disk. The files are
|
|
43
100
|
// the source of truth; fields with no on-disk source (project name,
|
|
44
101
|
// framework_version, product metadata) are carried over from `current`.
|
|
@@ -52,6 +109,12 @@ export function deriveIndex(projectRoot, current) {
|
|
|
52
109
|
project: current?.project ?? path.basename(projectRoot),
|
|
53
110
|
framework_version: current?.framework_version ?? "0.0.0",
|
|
54
111
|
last_updated: current?.last_updated ?? date,
|
|
112
|
+
// Project settings, carried over verbatim. `deriveIndex` rebuilds the
|
|
113
|
+
// artifact graph FROM DISK, so anything it does not explicitly carry is
|
|
114
|
+
// silently erased on the next `index rebuild` — config has no on-disk
|
|
115
|
+
// source to be rederived from, which is exactly why it must be listed
|
|
116
|
+
// here alongside project and framework_version. Absent means defaults.
|
|
117
|
+
...(current?.config !== undefined ? { config: current.config } : {}),
|
|
55
118
|
artifacts: {
|
|
56
119
|
product: cur.product ?? {
|
|
57
120
|
path: ".doctrina/product.md",
|
|
@@ -112,23 +175,8 @@ export function deriveIndex(projectRoot, current) {
|
|
|
112
175
|
const base = path.basename(f);
|
|
113
176
|
const m = base.match(/^(\d{4})-.*\.md$/);
|
|
114
177
|
if (!m) continue;
|
|
115
|
-
const text = read(f);
|
|
116
178
|
const prev = (cur.decisions ?? []).find((d) => d.id === m[1]);
|
|
117
|
-
|
|
118
|
-
const entry = {
|
|
119
|
-
id: m[1],
|
|
120
|
-
path: `.doctrina/decisions/${base}`,
|
|
121
|
-
title: titleMatch ? titleMatch[1].trim() : prev?.title ?? base,
|
|
122
|
-
status: listHeader(text, "Status") ?? prev?.status ?? "proposed",
|
|
123
|
-
date: listHeader(text, "Date") ?? prev?.date ?? date,
|
|
124
|
-
};
|
|
125
|
-
const supersedes = listHeader(text, "Supersedes");
|
|
126
|
-
if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
|
|
127
|
-
const supersededBy = listHeader(text, "Superseded by");
|
|
128
|
-
if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
|
|
129
|
-
const landed = listHeader(text, "Landed");
|
|
130
|
-
if (landed && landed !== "—") entry.landed = landed;
|
|
131
|
-
out.artifacts.decisions.push(entry);
|
|
179
|
+
out.artifacts.decisions.push(decisionEntry(read(f), base, prev, date));
|
|
132
180
|
}
|
|
133
181
|
|
|
134
182
|
// Open changes — every directory except archive/.
|