doctrina-cli 0.15.1 → 0.16.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 +1 -1
- package/src/commands/adapter.js +24 -1
- package/src/commands/analyze.js +8 -212
- package/src/commands/change.js +143 -217
- package/src/commands/ci.js +182 -0
- package/src/commands/clarify.js +58 -21
- package/src/commands/close.js +291 -42
- package/src/commands/constitution.js +14 -91
- package/src/commands/context.js +278 -107
- package/src/commands/contract.js +95 -75
- package/src/commands/coverage.js +58 -273
- package/src/commands/decision.js +54 -7
- package/src/commands/doctor.js +292 -129
- package/src/commands/handoff.js +10 -57
- package/src/commands/hooks.js +11 -2
- package/src/commands/index-rebuild.js +9 -48
- package/src/commands/init.js +70 -13
- package/src/commands/intake.js +30 -92
- package/src/commands/metrics.js +53 -84
- package/src/commands/next.js +105 -147
- package/src/commands/prime.js +24 -91
- package/src/commands/report.js +50 -108
- package/src/commands/review.js +68 -36
- package/src/commands/search.js +18 -4
- package/src/commands/show.js +24 -9
- package/src/commands/skill.js +16 -25
- package/src/commands/spec.js +26 -5
- package/src/commands/status.js +51 -172
- package/src/commands/templates.js +31 -138
- package/src/commands/trace.js +36 -71
- package/src/commands/triage.js +4 -82
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +60 -138
- package/src/commands/why.js +2 -2
- package/src/commands/work.js +234 -263
- package/src/index.js +106 -7
- package/src/lib/actions.js +484 -0
- package/src/lib/adapters.js +0 -11
- package/src/lib/adr-guard.js +19 -0
- package/src/lib/agent-changelog.js +133 -0
- package/src/lib/analysis.js +264 -0
- package/src/lib/change-ops.js +131 -0
- package/src/lib/commands.js +56 -4
- package/src/lib/config.js +206 -0
- package/src/lib/constitution-model.js +102 -0
- package/src/lib/coverage-model.js +392 -0
- package/src/lib/criteria.js +23 -0
- package/src/lib/doc-model.js +326 -0
- package/src/lib/docs-impact.js +196 -27
- package/src/lib/flag-catalog.js +0 -16
- package/src/lib/fs-ops.js +58 -1
- package/src/lib/gates.js +181 -23
- package/src/lib/git.js +132 -0
- package/src/lib/index-json.js +2 -19
- package/src/lib/intake-model.js +86 -0
- package/src/lib/json-out.js +100 -2
- package/src/lib/ledger.js +192 -0
- package/src/lib/lexicon.js +191 -0
- package/src/lib/metrics-model.js +159 -0
- package/src/lib/names.js +42 -0
- package/src/lib/playbook.js +97 -0
- package/src/lib/project.js +57 -0
- package/src/lib/reproducibility.js +137 -0
- package/src/lib/runtime.js +191 -5
- package/src/lib/scan.js +204 -13
- package/src/lib/signoff.js +147 -0
- package/src/lib/snapshot.js +209 -0
- package/src/lib/spec-ops.js +50 -5
- package/src/lib/templates-model.js +262 -0
- package/src/lib/trace-model.js +95 -0
- package/src/lib/triage-model.js +121 -0
- package/src/lib/usage.js +6 -4
- package/src/lib/validation-model.js +1299 -0
- package/src/lib/version.js +22 -0
- package/src/lib/views.js +410 -0
- package/src/lib/work-model.js +77 -0
- package/templates/AGENTS.md.template +20 -26
- package/templates/README.md +35 -0
- package/templates/change/proposal.md.template +1 -0
- package/templates/hooks/pre-commit.sample +6 -2
- package/templates/playbooks/bootstrap.md.template +43 -0
- package/templates/playbooks/chore.md.template +19 -0
- package/templates/playbooks/work.md.template +76 -0
package/src/commands/metrics.js
CHANGED
|
@@ -1,28 +1,26 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
4
|
import { readdirSync } from "node:fs";
|
|
6
5
|
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
7
6
|
import { today } from "../lib/dates.js";
|
|
8
7
|
import { flagBool, flagString } from "../lib/args.js";
|
|
9
8
|
import { c } from "../lib/colors.js";
|
|
10
9
|
import { EXIT } from "../lib/exit-codes.js";
|
|
11
|
-
import { GIT_STATE, historyState } from "../lib/git.js";
|
|
10
|
+
import { GIT_STATE, historyState, git } from "../lib/git.js";
|
|
12
11
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
13
12
|
import { USAGE_ENV, summarise } from "../lib/usage.js";
|
|
14
13
|
import { OPERATIONS } from "../lib/commands.js";
|
|
14
|
+
import { REEDIT_WINDOW_DAYS, computeSnapshot, parseLog, readSeries, latestSnapshot, trend, round } from "../lib/metrics-model.js";
|
|
15
15
|
|
|
16
16
|
// Local-only adoption metrics derived from git history. No network calls,
|
|
17
17
|
// no telemetry: the numbers stay in the repository, versioned like any
|
|
18
18
|
// other artifact. This is the tooling half of docs/en/validation.md.
|
|
19
19
|
|
|
20
|
-
const REEDIT_WINDOW_DAYS = 21;
|
|
21
|
-
|
|
22
20
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
23
21
|
// adding a command never requires editing the entrypoint — the gap that
|
|
24
22
|
// let six flags ship undeclared and silently swallow a positional (C3).
|
|
25
|
-
export const flags = { boolean: ["json", "save", "commands"], string: ["since"] };
|
|
23
|
+
export const flags = { boolean: ["json", "save", "commands", "trend"], string: ["since"] };
|
|
26
24
|
|
|
27
25
|
export async function run(_positional, flags) {
|
|
28
26
|
const projectRoot = process.cwd();
|
|
@@ -33,6 +31,12 @@ export async function run(_positional, flags) {
|
|
|
33
31
|
// the command surface are actually reached for (M8). Separate report,
|
|
34
32
|
// separate source, so it never dilutes the adoption metrics.
|
|
35
33
|
if (flagBool(flags, "commands", false)) return commandUsage();
|
|
34
|
+
// --trend reads the SERIES of saved snapshots rather than git. The
|
|
35
|
+
// snapshots are a time series versioned in the repository, and for two
|
|
36
|
+
// releases the only thing that read them was a delta against the most
|
|
37
|
+
// recent one (audit finding F15) — a year of measurement answering one
|
|
38
|
+
// question. Reading the series is what makes the saving worth doing.
|
|
39
|
+
if (flagBool(flags, "trend", false)) return printTrend(projectRoot);
|
|
36
40
|
const probe = git(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
|
|
37
41
|
// "No history yet" is a VALID state, not a failure — a brand-new project
|
|
38
42
|
// is the most common state in which someone explores this command, and it
|
|
@@ -59,7 +63,7 @@ export async function run(_positional, flags) {
|
|
|
59
63
|
"log", `--since=${since}`, "--date=unix",
|
|
60
64
|
"--pretty=format:@@%H|%ct|%s", "--name-only",
|
|
61
65
|
]);
|
|
62
|
-
if (log.
|
|
66
|
+
if (log.state !== GIT_STATE.OK) {
|
|
63
67
|
console.error(c.red("error:") + ` git log failed: ${log.stderr.trim()}`);
|
|
64
68
|
return EXIT.ENVIRONMENT;
|
|
65
69
|
}
|
|
@@ -78,7 +82,7 @@ export async function run(_positional, flags) {
|
|
|
78
82
|
if (save) {
|
|
79
83
|
const dir = path.join(projectRoot, ".doctrina", "metrics");
|
|
80
84
|
const file = path.join(dir, `${today()}.json`);
|
|
81
|
-
const previous = latestSnapshot(
|
|
85
|
+
const previous = latestSnapshot(projectRoot, path.basename(file));
|
|
82
86
|
write(file, JSON.stringify(snapshot, null, 2) + "\n", { force: true });
|
|
83
87
|
console.log("");
|
|
84
88
|
console.log(c.green("saved") + ` ${relPath(projectRoot, file)}`);
|
|
@@ -93,49 +97,45 @@ export async function run(_positional, flags) {
|
|
|
93
97
|
return 0;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
// The saved series, oldest first, with the direction each rate moved across
|
|
101
|
+
// the whole span. No verdict attached: a revert rate moves with team size,
|
|
102
|
+
// release cadence and how the window was drawn, and the CLI cannot tell an
|
|
103
|
+
// improvement from a quieter quarter.
|
|
104
|
+
function printTrend(projectRoot) {
|
|
105
|
+
const series = readSeries(projectRoot);
|
|
106
|
+
console.log(c.bold("Doctrina metrics") + c.gray(" — saved snapshots (.doctrina/metrics/)"));
|
|
107
|
+
console.log("");
|
|
108
|
+
if (series.length === 0) {
|
|
109
|
+
console.log(c.gray("no saved snapshots yet — `doctrina metrics --save` writes the first."));
|
|
110
|
+
return 0;
|
|
105
111
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// already touched in the previous REEDIT_WINDOW_DAYS. This is a proxy
|
|
113
|
-
// for rework, not a verdict — iterative work also trips it.
|
|
114
|
-
const windowSec = REEDIT_WINDOW_DAYS * 86400;
|
|
115
|
-
const lastTouch = new Map();
|
|
116
|
-
let reedits = 0;
|
|
117
|
-
for (const cm of [...commits].sort((a, b) => a.ts - b.ts)) {
|
|
118
|
-
const isReedit = cm.files.some((f) => {
|
|
119
|
-
const prev = lastTouch.get(f);
|
|
120
|
-
return prev !== undefined && cm.ts - prev <= windowSec;
|
|
121
|
-
});
|
|
122
|
-
if (isReedit) reedits += 1;
|
|
123
|
-
for (const f of cm.files) lastTouch.set(f, cm.ts);
|
|
112
|
+
console.log(c.gray("date commits reverts fix share re-edit"));
|
|
113
|
+
for (const snap of series) {
|
|
114
|
+
console.log(
|
|
115
|
+
`${String(snap.generated).padEnd(12)} ${String(snap.commits ?? "—").padStart(7)} ` +
|
|
116
|
+
`${pct(snap.revert_rate).padStart(7)} ${pct(snap.fix_share).padStart(9)} ${pct(snap.reedit_rate).padStart(7)}`,
|
|
117
|
+
);
|
|
124
118
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
119
|
+
const t = trend(series);
|
|
120
|
+
if (t.rows.length === 0) {
|
|
121
|
+
console.log("");
|
|
122
|
+
console.log(c.gray(`one snapshot — a trend needs two. Save another with \`doctrina metrics --save\`.`));
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
console.log("");
|
|
126
|
+
console.log(c.bold(`Across ${t.span} snapshots (${t.from} → ${t.to}):`));
|
|
127
|
+
for (const row of t.rows) printDelta(LABELS[row.rate] ?? row.rate, row.first, row.last);
|
|
128
|
+
console.log("");
|
|
129
|
+
console.log(c.gray("A direction, not a verdict: these rates move with team size and cadence."));
|
|
130
|
+
return 0;
|
|
137
131
|
}
|
|
138
132
|
|
|
133
|
+
const LABELS = {
|
|
134
|
+
revert_rate: "revert rate",
|
|
135
|
+
fix_share: "fix share",
|
|
136
|
+
reedit_rate: `${REEDIT_WINDOW_DAYS}-day re-edit rate`,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
139
|
function printReport(s) {
|
|
140
140
|
console.log(`commits ${s.commits}`);
|
|
141
141
|
console.log(`reverts ${s.reverts} (${pct(s.revert_rate)})`);
|
|
@@ -158,50 +158,16 @@ function printDelta(label, before, after) {
|
|
|
158
158
|
console.log(` ${label}: ${pct(before)} → ${pct(after)} (${arrow})`);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
function latestSnapshot(dir, excludeBase) {
|
|
162
|
-
if (!isDir(dir)) return null;
|
|
163
|
-
const files = readdirSync(dir)
|
|
164
|
-
.filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f) && f !== excludeBase)
|
|
165
|
-
.sort();
|
|
166
|
-
if (files.length === 0) return null;
|
|
167
|
-
try {
|
|
168
|
-
return JSON.parse(read(path.join(dir, files[files.length - 1])));
|
|
169
|
-
} catch {
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function parseLog(stdout) {
|
|
175
|
-
const commits = [];
|
|
176
|
-
let current = null;
|
|
177
|
-
for (const line of stdout.split("\n")) {
|
|
178
|
-
if (line.startsWith("@@")) {
|
|
179
|
-
const [hash, ts, ...rest] = line.slice(2).split("|");
|
|
180
|
-
current = { hash, ts: Number(ts), subject: rest.join("|"), files: [] };
|
|
181
|
-
commits.push(current);
|
|
182
|
-
} else if (line.trim() !== "" && current) {
|
|
183
|
-
current.files.push(line.trim());
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
return commits;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function git(cwd, args) {
|
|
190
|
-
return spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function round(x) {
|
|
194
|
-
return Math.round(x * 1000) / 1000;
|
|
195
|
-
}
|
|
196
161
|
|
|
197
162
|
function pct(x) {
|
|
163
|
+
if (typeof x !== "number") return "—";
|
|
198
164
|
return `${(x * 100).toFixed(1)}%`;
|
|
199
165
|
}
|
|
200
166
|
|
|
201
167
|
// Which operations were actually invoked, from the opt-in usage log. The
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
168
|
+
// audit's charge is that some of the surface exists because it was easy to
|
|
169
|
+
// add rather than because anyone reaches for it (how big that surface is,
|
|
170
|
+
// lib/commands.js owns — a count copied into a comment only rots). This is the evidence for that argument — or against it. Zero samples
|
|
205
171
|
// is a CANDIDATE for cutting, never a verdict: a command used once a quarter
|
|
206
172
|
// and a command nobody wants look identical over one week.
|
|
207
173
|
function commandUsage() {
|
|
@@ -256,6 +222,9 @@ Flags:
|
|
|
256
222
|
date ("2026-01-01", "3 months ago").
|
|
257
223
|
--save Write .doctrina/metrics/YYYY-MM-DD.json and print the
|
|
258
224
|
deltas against the most recent prior snapshot.
|
|
225
|
+
--trend Read the saved snapshots instead of git: every snapshot
|
|
226
|
+
in date order, and the direction each rate moved across
|
|
227
|
+
the whole span (first to last, not last to previous).
|
|
259
228
|
|
|
260
229
|
This is the tooling half of the empirical A/B protocol in
|
|
261
230
|
docs/en/validation.md: snapshot before adopting Doctrina, snapshot each
|
package/src/commands/next.js
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { readdirSync } from "node:fs";
|
|
5
|
-
import { exists, isDir
|
|
6
|
-
import * as idx from "../lib/index-json.js";
|
|
7
|
-
import { deriveIndex, indexesMatch, listHeader } from "../lib/scan.js";
|
|
5
|
+
import { exists, isDir } from "../lib/fs-ops.js";
|
|
8
6
|
import { flagBool } from "../lib/args.js";
|
|
9
7
|
import { c } from "../lib/colors.js";
|
|
10
8
|
import { emitJson } from "../lib/json-out.js";
|
|
11
|
-
import {
|
|
9
|
+
import { EXIT } from "../lib/exit-codes.js";
|
|
10
|
+
import { action, computeActions, invocation } from "../lib/actions.js";
|
|
11
|
+
|
|
12
|
+
// `next` answers "what now?". The ANSWER lives in `lib/actions.js` as
|
|
13
|
+
// records; this module renders them and, under `--run`, executes one
|
|
14
|
+
// (change 0032). Before that the answer WAS the sentence, so `--json`
|
|
15
|
+
// returned prose in quotes and an agent had to re-read English to re-issue
|
|
16
|
+
// a command the CLI had already assembled.
|
|
12
17
|
|
|
13
18
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
14
19
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -17,186 +22,127 @@ import { collectRuntimeFindings } from "../lib/runtime.js";
|
|
|
17
22
|
// wrap it in the generic envelope.
|
|
18
23
|
export const jsonNative = true;
|
|
19
24
|
|
|
20
|
-
export const flags = { boolean: ["json"], string: [] };
|
|
25
|
+
export const flags = { boolean: ["json", "run"], string: [] };
|
|
21
26
|
|
|
22
27
|
export async function run(_positional, flags) {
|
|
23
28
|
const projectRoot = process.cwd();
|
|
24
29
|
const json = flagBool(flags, "json", false);
|
|
25
30
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
26
|
-
const first =
|
|
31
|
+
const first = action({
|
|
32
|
+
id: "not-a-project",
|
|
33
|
+
command: "init",
|
|
34
|
+
runnable: false,
|
|
35
|
+
why: "this directory is not a Doctrina project yet",
|
|
36
|
+
});
|
|
27
37
|
if (json) emitJson("next", { actions: [first] });
|
|
28
|
-
else console.log(`1. ${first}`);
|
|
38
|
+
else console.log(`1. ${first.text}`);
|
|
29
39
|
return 0;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
const actions = computeActions(projectRoot);
|
|
33
43
|
|
|
44
|
+
if (flagBool(flags, "run", false)) {
|
|
45
|
+
return runFirst(actions);
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
if (json) {
|
|
35
49
|
emitJson("next", { actions });
|
|
36
50
|
return 0;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
if (actions.length === 0) {
|
|
40
|
-
|
|
54
|
+
// The two doors AGENTS.md tells an agent to use, not the two hand-authoring
|
|
55
|
+
// commands it tells them to avoid (second audit). `intake` first for a
|
|
56
|
+
// project with nothing specced yet, `work` for one that has.
|
|
57
|
+
const specced = isDir(path.join(projectRoot, ".doctrina", "specs"))
|
|
58
|
+
&& readdirSync(path.join(projectRoot, ".doctrina", "specs"), { withFileTypes: true })
|
|
59
|
+
.some((e) => e.isDirectory());
|
|
60
|
+
console.log(c.green("ok") + " every gate is satisfied and no change is open.");
|
|
41
61
|
console.log("");
|
|
42
62
|
console.log("Start something:");
|
|
43
|
-
|
|
44
|
-
|
|
63
|
+
if (!specced) {
|
|
64
|
+
console.log(` doctrina intake --text "<what this project is>" turn intent into specs`);
|
|
65
|
+
}
|
|
66
|
+
console.log(` doctrina work "<prompt>" open the next change`);
|
|
45
67
|
return 0;
|
|
46
68
|
}
|
|
47
69
|
|
|
48
70
|
console.log(c.bold("Next actions") + c.gray(" (in priority order):"));
|
|
49
71
|
console.log("");
|
|
50
|
-
actions.forEach((a, i) => console.log(`${i + 1}. ${a}`));
|
|
72
|
+
actions.forEach((a, i) => console.log(`${i + 1}. ${a.text}`));
|
|
51
73
|
return 0;
|
|
52
74
|
}
|
|
53
75
|
|
|
54
|
-
// The
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
// The command modules `--run` may dispatch to, loaded on demand. Static
|
|
77
|
+
// imports here would make every module that imports `next` (prime, handoff,
|
|
78
|
+
// watch) pay for the whole command tree just to render a list.
|
|
79
|
+
export const RUNNERS = {
|
|
80
|
+
triage: () => import("./triage.js"),
|
|
81
|
+
intake: () => import("./intake.js"),
|
|
82
|
+
analyze: () => import("./analyze.js"),
|
|
83
|
+
"change apply": () => import("./change.js"),
|
|
84
|
+
"change archive": () => import("./change.js"),
|
|
85
|
+
"index rebuild": () => import("./index-rebuild.js"),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Execute the first runnable action, and only that one.
|
|
90
|
+
*
|
|
91
|
+
* ONE action, not the queue: the list is recomputed from the tree after
|
|
92
|
+
* every change to it, so running two in a row would act on a list the
|
|
93
|
+
* second half of which was computed before the first half ran. Stopping
|
|
94
|
+
* also keeps the human's approval point where they put it — `close` is the
|
|
95
|
+
* command that deliberately runs a whole sequence.
|
|
96
|
+
*/
|
|
97
|
+
async function runFirst(actions) {
|
|
98
|
+
const next = actions.find((a) => a.runnable);
|
|
99
|
+
if (!next) {
|
|
100
|
+
const blocked = actions.find((a) => !a.runnable);
|
|
101
|
+
if (!blocked) {
|
|
102
|
+
console.log(c.green("ok") + " no open work to run.");
|
|
103
|
+
return EXIT.OK;
|
|
73
104
|
}
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
|
|
84
|
-
if (status !== "converted") {
|
|
85
|
-
actions.push("doctrina intake — a pending intake awaits conversion into product.md and specs");
|
|
105
|
+
// Everything left needs authorship, not execution. Say what, and say
|
|
106
|
+
// it is not a failure of the command.
|
|
107
|
+
console.log(c.yellow("nothing to run") + " — the next action needs a person, not a command:");
|
|
108
|
+
console.log("");
|
|
109
|
+
console.log(` ${blocked.text}`);
|
|
110
|
+
const inv = invocation(blocked);
|
|
111
|
+
if (inv) {
|
|
112
|
+
console.log("");
|
|
113
|
+
console.log(c.gray("When you have made the call, record it with: ") + c.cyan(inv));
|
|
86
114
|
}
|
|
115
|
+
return EXIT.OK;
|
|
87
116
|
}
|
|
88
117
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (!isFile(proposalPath)) {
|
|
97
|
-
actions.push(`add a proposal.md to .doctrina/changes/${id}/ (open changes need one)`);
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
const status = listHeader(read(proposalPath), "Status") ?? "proposed";
|
|
101
|
-
if (status === "applied") {
|
|
102
|
-
actions.push(`doctrina change archive ${id} — applied but not archived`);
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
const tasksPath = path.join(changesDir, id, "tasks.md");
|
|
106
|
-
const unchecked = isFile(tasksPath)
|
|
107
|
-
? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
|
|
108
|
-
: 0;
|
|
109
|
-
if (unchecked > 0) {
|
|
110
|
-
actions.push(`complete ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md`);
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
const deltas = walk(path.join(changesDir, id, "specs")).filter((p) => p.endsWith("delta.md"));
|
|
114
|
-
if (deltas.length > 0) {
|
|
115
|
-
actions.push(`doctrina analyze ${id}, then doctrina change apply ${id} — tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`);
|
|
116
|
-
} else {
|
|
117
|
-
actions.push(`add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only: doctrina change apply ${id}`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
118
|
+
const loader = RUNNERS[next.command];
|
|
119
|
+
if (!loader) {
|
|
120
|
+
// A runnable action whose command has no runner is a bug in this map,
|
|
121
|
+
// not something to paper over by silently doing nothing.
|
|
122
|
+
console.error(c.red("error:") + ` no runner for "${next.command}"`);
|
|
123
|
+
console.error(c.gray("hint: ") + `run it yourself: ${invocation(next)}`);
|
|
124
|
+
return EXIT.GATE;
|
|
120
125
|
}
|
|
121
126
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
const t = (v ?? "").trim();
|
|
130
|
-
return t === "" || t === "—" || t === "-";
|
|
131
|
-
};
|
|
132
|
-
for (const f of walk(adrDir)) {
|
|
133
|
-
if (!f.endsWith(".md")) continue;
|
|
134
|
-
const m = path.basename(f).match(/^(\d{4})-/);
|
|
135
|
-
if (!m) continue;
|
|
136
|
-
const text = read(f);
|
|
137
|
-
const status = listHeader(text, "Status");
|
|
138
|
-
if (status && status.toLowerCase() === "proposed") {
|
|
139
|
-
actions.push(`review ADR ${m[1]} (${relPath(projectRoot, f)}): doctrina decision accept ${m[1]}, or supersede it`);
|
|
140
|
-
} else if (status && status.toLowerCase() === "accepted") {
|
|
141
|
-
// Bare only when BOTH anchors are empty (Evidence header present-but-bare
|
|
142
|
-
// and no Landed stamp). An ADR that opts out of Evidence entirely
|
|
143
|
-
// (header absent) is not nagged.
|
|
144
|
-
const evidence = listHeader(text, "Evidence");
|
|
145
|
-
const landed = listHeader(text, "Landed");
|
|
146
|
-
if (evidence !== null && isBareValue(evidence) && isBareValue(landed)) {
|
|
147
|
-
landNudges.push(`record what proves ADR ${m[1]}: cite its **Evidence:**, or once it ships, doctrina decision land ${m[1]}`);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
for (const n of landNudges) actions.push(n);
|
|
152
|
-
|
|
153
|
-
// No procedural memory captured yet, but the history shows a fix-shaped
|
|
154
|
-
// change — exactly the lesson a skill exists to keep from being relearned
|
|
155
|
-
// (review §5: the change-0003 "tolerate LLM code fences" case). One gentle
|
|
156
|
-
// nudge, only when skills are empty, so it never nags a project that opted in.
|
|
157
|
-
const skillNudge = suggestSkillCapture(projectRoot);
|
|
158
|
-
if (skillNudge) actions.push(skillNudge);
|
|
159
|
-
|
|
160
|
-
// Index drift is silent rot; surface it last.
|
|
161
|
-
try {
|
|
162
|
-
const current = idx.load(projectRoot);
|
|
163
|
-
if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
|
|
164
|
-
actions.push("doctrina index rebuild — index.json has drifted from the tree");
|
|
165
|
-
}
|
|
166
|
-
} catch {
|
|
167
|
-
actions.push("doctrina index rebuild — index.json is missing or unreadable");
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return actions;
|
|
171
|
-
}
|
|
127
|
+
console.log(c.bold("Running") + ` ${c.cyan(invocation(next))}` + c.gray(` — ${next.why}`));
|
|
128
|
+
console.log("");
|
|
129
|
+
const mod = await loader();
|
|
130
|
+
// A sub-operation ("change archive") is passed as a positional, exactly
|
|
131
|
+
// as the entrypoint would.
|
|
132
|
+
const [, ...sub] = next.command.split(" ");
|
|
133
|
+
const code = await mod.run([...sub, ...next.args], new Map());
|
|
172
134
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
function suggestSkillCapture(projectRoot) {
|
|
180
|
-
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
181
|
-
if (isDir(skillsDir)) {
|
|
182
|
-
const hasSkill = walk(skillsDir).some((f) => f.endsWith(".md"));
|
|
183
|
-
if (hasSkill) return null; // opted in already — never nag
|
|
184
|
-
}
|
|
185
|
-
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
186
|
-
if (!isDir(archiveDir)) return null;
|
|
187
|
-
for (const name of readdirSync(archiveDir).sort()) {
|
|
188
|
-
if (!isDir(path.join(archiveDir, name))) continue;
|
|
189
|
-
const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
|
190
|
-
if (FIX_SHAPED.test(id)) {
|
|
191
|
-
return `capture a skill from past fixes (e.g. "${id}"): doctrina skill new <slug> — ` +
|
|
192
|
-
`on-demand procedural memory so the next agent does not relearn it (none exist yet)`;
|
|
193
|
-
}
|
|
135
|
+
console.log("");
|
|
136
|
+
if (code === EXIT.OK) {
|
|
137
|
+
console.log(c.green("✓ ran ") + c.cyan(invocation(next)) + c.gray(" — `doctrina next` for what follows"));
|
|
138
|
+
} else {
|
|
139
|
+
console.log(c.red("✗ ") + c.cyan(invocation(next)) + c.gray(` exited ${code} — fix it, then rerun`));
|
|
194
140
|
}
|
|
195
|
-
return
|
|
141
|
+
return code;
|
|
196
142
|
}
|
|
197
143
|
|
|
198
144
|
export const help = `
|
|
199
|
-
Usage: doctrina next [--json]
|
|
145
|
+
Usage: doctrina next [--json] [--run]
|
|
200
146
|
|
|
201
147
|
Inspect the .doctrina/ tree and print the recommended next workflow
|
|
202
148
|
actions in priority order: runtime declarations that no longer hold
|
|
@@ -205,8 +151,20 @@ run lied), open changes (missing proposal, unchecked
|
|
|
205
151
|
tasks, deltas ready to apply, applied-but-unarchived), ADRs still in
|
|
206
152
|
proposed status, accepted ADRs with nothing proving them (cite Evidence
|
|
207
153
|
or run "decision land"), a skill-capture nudge when a past fix went
|
|
208
|
-
uncaptured, and index drift last.
|
|
154
|
+
uncaptured, and index drift last.
|
|
209
155
|
|
|
210
156
|
Intended use: agents and humans run it to resume work without
|
|
211
157
|
re-reading the whole tree.
|
|
158
|
+
|
|
159
|
+
Options:
|
|
160
|
+
--json Emit the actions as records — { id, command, args, why, gate,
|
|
161
|
+
severity, runnable, text }. Branch on \`command\`/\`args\`; \`text\`
|
|
162
|
+
is the same line the terminal prints.
|
|
163
|
+
--run Execute the first RUNNABLE action in-process and stop. An action
|
|
164
|
+
that needs a person to decide — accept an ADR, write a proposal,
|
|
165
|
+
complete a task, capture a skill — is never runnable however
|
|
166
|
+
mechanical its edit would be, and \`--run\` names it instead.
|
|
167
|
+
Exits with the executed command's own code.
|
|
168
|
+
|
|
169
|
+
Read-only without --run, and always exits 0 then.
|
|
212
170
|
`;
|