doctrina-cli 0.15.0 → 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 -199
- 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 +7 -75
- package/src/commands/validate.js +14 -871
- package/src/commands/verify.js +121 -160
- 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 +109 -6
- 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 +210 -24
- 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 +238 -11
- 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/contract.md.template +7 -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/handoff.js
CHANGED
|
@@ -2,12 +2,10 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists } from "../lib/fs-ops.js";
|
|
5
|
-
import { collectStatus } from "./status.js";
|
|
6
|
-
import { computeActions } from "./next.js";
|
|
7
|
-
import { openChanges } from "./prime.js";
|
|
8
|
-
import { today } from "../lib/dates.js";
|
|
9
5
|
import { c } from "../lib/colors.js";
|
|
10
6
|
import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
7
|
+
import { collectSnapshot } from "../lib/snapshot.js";
|
|
8
|
+
import { renderView } from "../lib/views.js";
|
|
11
9
|
|
|
12
10
|
// Session handoff note: everything the NEXT session (a fresh agent, a
|
|
13
11
|
// teammate, tomorrow's you) needs to resume without re-reading the tree —
|
|
@@ -17,6 +15,10 @@ import { notADoctrinaProject } from "../lib/exit-codes.js";
|
|
|
17
15
|
// change. It is a VIEW derived from the tree at call time, deliberately not
|
|
18
16
|
// a stored file: a saved note goes stale the moment work continues, the
|
|
19
17
|
// tree never does (no new home for facts).
|
|
18
|
+
//
|
|
19
|
+
// Since change 0037 that word "view" is literal: the note is rendered by
|
|
20
|
+
// lib/views.js from the one snapshot lib/snapshot.js collects, and is equally
|
|
21
|
+
// reachable as `doctrina status --view handoff`.
|
|
20
22
|
|
|
21
23
|
// Flags this command accepts. Declared HERE, with the command, so
|
|
22
24
|
// adding a command never requires editing the entrypoint — the gap that
|
|
@@ -28,59 +30,7 @@ export async function run(_positional, _flags) {
|
|
|
28
30
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
29
31
|
throw notADoctrinaProject();
|
|
30
32
|
}
|
|
31
|
-
|
|
32
|
-
const s = collectStatus(projectRoot);
|
|
33
|
-
const open = openChanges(projectRoot);
|
|
34
|
-
const actions = computeActions(projectRoot);
|
|
35
|
-
|
|
36
|
-
console.log(`# Doctrina handoff — ${s.project} (${today()})`);
|
|
37
|
-
console.log("");
|
|
38
|
-
|
|
39
|
-
// Gate digest — the red ones are what the resuming session fixes first.
|
|
40
|
-
console.log("## Where things stand");
|
|
41
|
-
console.log("");
|
|
42
|
-
console.log(`- index: ${s.indexState} · framework stamp: ${s.stamp ?? "—"} (CLI ${s.cli})`);
|
|
43
|
-
console.log(`- coverage: ${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria} criteria` +
|
|
44
|
-
(s.coverage.totalDangling ? `, ${s.coverage.totalDangling} dangling` : "") +
|
|
45
|
-
(s.coverage.totalConditional ? `, ${s.coverage.totalConditional} conditional` : "") + ")");
|
|
46
|
-
const tr = s.trace.anchors === 0 ? "no anchors declared" : `${s.trace.realized}/${s.trace.anchors} anchors realized`;
|
|
47
|
-
console.log(`- trace: ${tr}` + (s.trace.untraceable ? ` (${s.trace.untraceable} untraceable)` : ""));
|
|
48
|
-
console.log(`- verify: ${s.verify.configured ? `${s.verify.checks} checks declared — run \`doctrina verify\`` : "not configured"}`);
|
|
49
|
-
|
|
50
|
-
// The in-flight work, task by task.
|
|
51
|
-
console.log("");
|
|
52
|
-
if (open.length === 0) {
|
|
53
|
-
console.log("## Open work");
|
|
54
|
-
console.log("");
|
|
55
|
-
console.log("- none — the tree is at rest; start with `doctrina work \"<prompt>\"`");
|
|
56
|
-
} else {
|
|
57
|
-
for (const ch of open) {
|
|
58
|
-
console.log(`## Open change \`${ch.id}\`${ch.title ? ` — ${ch.title}` : ""}`);
|
|
59
|
-
console.log("");
|
|
60
|
-
console.log(`- proposal status: ${ch.status}`);
|
|
61
|
-
if (ch.tasksTotal > 0) {
|
|
62
|
-
console.log(`- tasks: ${ch.tasksDone}/${ch.tasksTotal} checked`);
|
|
63
|
-
for (const t of ch.unchecked.slice(0, 8)) console.log(` - [ ] ${t}`);
|
|
64
|
-
if (ch.unchecked.length > 8) console.log(` - … ${ch.unchecked.length - 8} more in tasks.md`);
|
|
65
|
-
} else {
|
|
66
|
-
console.log("- tasks: no tasks.md checklist found");
|
|
67
|
-
}
|
|
68
|
-
console.log(`- resume with: \`doctrina work --resume ${ch.id}\` · close with: \`doctrina close ${ch.id}\``);
|
|
69
|
-
console.log("");
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// What to do, in priority order.
|
|
74
|
-
console.log("## Next actions");
|
|
75
|
-
console.log("");
|
|
76
|
-
if (actions.length === 0) {
|
|
77
|
-
console.log("1. nothing pending — `doctrina next` will confirm; pick up new work");
|
|
78
|
-
} else {
|
|
79
|
-
actions.forEach((a, i) => console.log(`${i + 1}. ${a}`));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
console.log("");
|
|
83
|
-
console.log("*Generated read-only from the tree — regenerate anytime with `doctrina handoff`.*");
|
|
33
|
+
for (const line of renderView("handoff", collectSnapshot(projectRoot))) console.log(line);
|
|
84
34
|
if (process.stdout.isTTY) {
|
|
85
35
|
console.log("");
|
|
86
36
|
console.log(c.gray("tip: pipe it — `doctrina handoff > note.md` — or paste it as the first message of the next session"));
|
|
@@ -96,6 +46,9 @@ with its task-by-task progress and the exact resume command, and the
|
|
|
96
46
|
prioritised next actions. Made to be pasted as the first message of the
|
|
97
47
|
next agent session (or piped to a file).
|
|
98
48
|
|
|
49
|
+
The same view as \`doctrina status --view handoff\`; both render one
|
|
50
|
+
collection of the tree.
|
|
51
|
+
|
|
99
52
|
Deliberately a derived view, not a stored file — the tree is the truth
|
|
100
53
|
and never goes stale; regenerate on demand. Read-only; always exits 0.
|
|
101
54
|
`;
|
package/src/commands/hooks.js
CHANGED
|
@@ -3,7 +3,10 @@ import path from "node:path";
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { chmodSync } from "node:fs";
|
|
5
5
|
import { exists, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
|
|
6
|
-
import { locateTemplatesDir } from "../lib/templates.js";
|
|
6
|
+
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
7
10
|
import { flagBool } from "../lib/args.js";
|
|
8
11
|
import { c } from "../lib/colors.js";
|
|
9
12
|
import { suggest } from "../lib/suggest.js";
|
|
@@ -45,7 +48,13 @@ export async function run(positional, flags) {
|
|
|
45
48
|
|
|
46
49
|
const templatesDir = locateTemplatesDir();
|
|
47
50
|
const tplPath = path.join(templatesDir, "hooks", "pre-commit.sample");
|
|
48
|
-
|
|
51
|
+
// The hook pins the CLI that installed it (change 0116). `.git/hooks/` is
|
|
52
|
+
// local to the clone, so an absolute path is the right thing to write;
|
|
53
|
+
// a bare `doctrina` ran whichever release was on the PATH — a global
|
|
54
|
+
// 0.15.0 under a 0.15.1 tree rewrote the stamp backwards on every commit.
|
|
55
|
+
// `DOCTRINA=` in the environment overrides it.
|
|
56
|
+
const entry = path.resolve(here, "..", "index.js").replaceAll("\\", "/");
|
|
57
|
+
const body = substitute(read(tplPath), { DOCTRINA_BIN: `node \"${entry}\"` });
|
|
49
58
|
write(target, body, { force: true });
|
|
50
59
|
chmodSync(target, 0o755);
|
|
51
60
|
console.log(c.green("installed") + ` ${relPath(projectRoot, target)}`);
|
|
@@ -3,8 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists } from "../lib/fs-ops.js";
|
|
5
5
|
import * as idx from "../lib/index-json.js";
|
|
6
|
-
import {
|
|
7
|
-
import { cliVersion } from "../lib/version.js";
|
|
6
|
+
import { collectIndexDrift } from "../lib/scan.js";
|
|
8
7
|
import { today } from "../lib/dates.js";
|
|
9
8
|
import { flagBool } from "../lib/args.js";
|
|
10
9
|
import { c } from "../lib/colors.js";
|
|
@@ -38,39 +37,30 @@ function rebuild(flags) {
|
|
|
38
37
|
throw notADoctrinaProject();
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
current = idx.load(projectRoot);
|
|
44
|
-
} catch (err) {
|
|
40
|
+
const state = collectIndexDrift(projectRoot);
|
|
41
|
+
if (state.unreadable) {
|
|
45
42
|
if (check) {
|
|
46
|
-
console.error(c.red("error:") + ` ${
|
|
43
|
+
console.error(c.red("error:") + ` ${state.unreadable}`);
|
|
47
44
|
return 1;
|
|
48
45
|
}
|
|
49
|
-
console.log(c.yellow("warn:") + ` ${
|
|
46
|
+
console.log(c.yellow("warn:") + ` ${state.unreadable} — rebuilding from scratch`);
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
// Migrate the framework stamp to the running CLI (3.6). deriveIndex carries
|
|
54
|
-
// the old value over (so `next` does not nag on a version-only difference);
|
|
55
|
-
// overriding it here lets a stale stamp count as drift, so `index rebuild`
|
|
56
|
-
// both reports and fixes it instead of short-circuiting on "nothing to do".
|
|
57
|
-
derived.framework_version = cliVersion();
|
|
58
|
-
|
|
59
|
-
if (indexesMatch(derived, current)) {
|
|
49
|
+
if (state.ok) {
|
|
60
50
|
console.log(c.green("ok") + " index.json matches the tree (nothing to do)");
|
|
61
51
|
return 0;
|
|
62
52
|
}
|
|
63
53
|
|
|
64
|
-
const drift
|
|
65
|
-
for (const line of drift) console.log((check ? c.yellow("drift: ") : c.gray("sync: ")) + line);
|
|
54
|
+
for (const line of state.drift) console.log((check ? c.yellow("drift: ") : c.gray("sync: ")) + line);
|
|
66
55
|
|
|
67
56
|
if (check) {
|
|
68
57
|
console.log("");
|
|
69
|
-
console.log(c.red("fail") + ` index.json has drifted from the tree (${drift.length} difference${drift.length === 1 ? "" : "s"})`);
|
|
58
|
+
console.log(c.red("fail") + ` index.json has drifted from the tree (${state.drift.length} difference${state.drift.length === 1 ? "" : "s"})`);
|
|
70
59
|
console.log(c.gray("hint: ") + "run `doctrina index rebuild` to regenerate it");
|
|
71
60
|
return 1;
|
|
72
61
|
}
|
|
73
62
|
|
|
63
|
+
const derived = state.derived;
|
|
74
64
|
derived.last_updated = today();
|
|
75
65
|
idx.save(projectRoot, derived);
|
|
76
66
|
const a = derived.artifacts;
|
|
@@ -80,35 +70,6 @@ function rebuild(flags) {
|
|
|
80
70
|
return 0;
|
|
81
71
|
}
|
|
82
72
|
|
|
83
|
-
// Human-readable category-level drift between the on-disk index and the
|
|
84
|
-
// derived one: added / removed / changed entry ids.
|
|
85
|
-
function describeDrift(current, derived) {
|
|
86
|
-
const lines = [];
|
|
87
|
-
if (!current) return ["index.json missing or unreadable"];
|
|
88
|
-
if ((current.framework_version ?? null) !== (derived.framework_version ?? null)) {
|
|
89
|
-
lines.push(`framework_version: ${current.framework_version ?? "unset"} -> ${derived.framework_version}`);
|
|
90
|
-
}
|
|
91
|
-
const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
|
|
92
|
-
for (const cat of categories) {
|
|
93
|
-
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
94
|
-
const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
95
|
-
for (const id of der.keys()) {
|
|
96
|
-
if (!cur.has(id)) lines.push(`${cat}: "${id}" on disk but not in index`);
|
|
97
|
-
else if (stableStringify(cur.get(id)) !== stableStringify(der.get(id))) {
|
|
98
|
-
lines.push(`${cat}: "${id}" metadata differs from the files`);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
for (const id of cur.keys()) {
|
|
102
|
-
if (!der.has(id)) lines.push(`${cat}: "${id}" in index but not on disk`);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
if (stableStringify(current.artifacts?.product ?? null) !== stableStringify(derived.artifacts.product)) {
|
|
106
|
-
lines.push("product: metadata differs");
|
|
107
|
-
}
|
|
108
|
-
if (lines.length === 0) lines.push("structural difference (key order or missing category)");
|
|
109
|
-
return lines;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
73
|
export const help = `
|
|
113
74
|
Usage: doctrina index <subcommand>
|
|
114
75
|
|
package/src/commands/init.js
CHANGED
|
@@ -7,13 +7,14 @@ import {
|
|
|
7
7
|
surfaceBlock, findSurfaceBlock, agentChangelogBlock, findAgentChangelogBlock,
|
|
8
8
|
} from "../lib/commands.js";
|
|
9
9
|
import * as idx from "../lib/index-json.js";
|
|
10
|
+
import { scaffoldConfig } from "../lib/config.js";
|
|
10
11
|
import { today } from "../lib/dates.js";
|
|
11
12
|
import { cliVersion } from "../lib/version.js";
|
|
12
|
-
import { flagBool, flagString } from "../lib/args.js";
|
|
13
|
+
import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
|
|
13
14
|
import { c } from "../lib/colors.js";
|
|
14
15
|
import { EXIT } from "../lib/exit-codes.js";
|
|
15
16
|
import { ask, isInteractive } from "../lib/prompt.js";
|
|
16
|
-
import { writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake } from "
|
|
17
|
+
import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake } from "../lib/intake-model.js";
|
|
17
18
|
|
|
18
19
|
const SUPPORTED_AGENTS = [
|
|
19
20
|
"claude",
|
|
@@ -32,7 +33,7 @@ const SUPPORTED_AGENTS = [
|
|
|
32
33
|
|
|
33
34
|
export const flags = {
|
|
34
35
|
boolean: ["json", "force", "non-interactive", "overwrite-content"],
|
|
35
|
-
string: ["project-name", "project-description", "agent", "intake", "from", "date"],
|
|
36
|
+
string: ["project-name", "project-description", "agent", "intake", "intake-text", "from", "date"],
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
export async function run(_positional, flags) {
|
|
@@ -45,17 +46,56 @@ export async function run(_positional, flags) {
|
|
|
45
46
|
// --intake hands over the FULL description up front (ADR 0005); the
|
|
46
47
|
// one-line summary is then derived instead of prompted for.
|
|
47
48
|
const intakeFile = flagString(flags, "intake");
|
|
49
|
+
const intakeText = flagString(flags, "intake-text");
|
|
48
50
|
let intakeBody = null;
|
|
51
|
+
let intakeSource = null;
|
|
52
|
+
// Onboarding is ONE moment, so it gets one command (change 0051). The
|
|
53
|
+
// separation between `init` and `intake` is an architectural fact — `init`
|
|
54
|
+
// refuses to read language, ADR 0005 — and it was being charged to the
|
|
55
|
+
// user as two steps of setup. Both commands stay: `intake` is still the
|
|
56
|
+
// door for converting a description in a project that already exists.
|
|
57
|
+
if (intakeFile && intakeText !== undefined) {
|
|
58
|
+
console.error(c.red("error:") + " pass --intake <file> or --intake-text \"<text>\", not both");
|
|
59
|
+
return EXIT.USAGE;
|
|
60
|
+
}
|
|
61
|
+
// A flag written without a value is a usage error, never a silent fallback
|
|
62
|
+
// to "no intake": the operator asked for one and would get a project
|
|
63
|
+
// without it, and nothing would say so.
|
|
64
|
+
for (const name of ["intake", "intake-text"]) {
|
|
65
|
+
if (flagGivenWithoutValue(flags, name)) {
|
|
66
|
+
console.error(c.red("error:") + ` --${name} needs a value`);
|
|
67
|
+
console.error(c.gray("hint: ") + (name === "intake"
|
|
68
|
+
? "doctrina init --intake ./description.md"
|
|
69
|
+
: "doctrina init --intake-text \"<the whole project description>\""));
|
|
70
|
+
return EXIT.USAGE;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (typeof intakeText === "string" && intakeText.trim() !== "") {
|
|
74
|
+
intakeBody = intakeText;
|
|
75
|
+
intakeSource = "inline (--intake-text)";
|
|
76
|
+
}
|
|
49
77
|
if (intakeFile) {
|
|
50
78
|
const intakeAbs = path.resolve(projectRoot, intakeFile);
|
|
51
|
-
if (
|
|
79
|
+
if (isFile(intakeAbs)) {
|
|
80
|
+
intakeBody = read(intakeAbs);
|
|
81
|
+
intakeSource = relPath(projectRoot, intakeAbs);
|
|
82
|
+
if (intakeBody.trim().length === 0) {
|
|
83
|
+
console.error(c.red("error:") + ` --intake file is empty: ${intakeFile}`);
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
} else if (looksLikePath(intakeFile)) {
|
|
52
87
|
console.error(c.red("error:") + ` --intake file not found: ${intakeFile}`);
|
|
88
|
+
console.error(c.gray("hint: ") +
|
|
89
|
+
'to pass the description itself, use `--intake-text "<description>"`');
|
|
53
90
|
return 1;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
91
|
+
} else {
|
|
92
|
+
// The same rule `intake` applies to its positional (change 0071): the
|
|
93
|
+
// two commands answer the same input shape the same way, so an agent
|
|
94
|
+
// that learned one is not surprised by the other.
|
|
95
|
+
intakeBody = intakeFile;
|
|
96
|
+
intakeSource = "inline";
|
|
97
|
+
console.error(c.gray("note: ") +
|
|
98
|
+
'read as the description itself, not as a file — `--intake-text "<description>"` says so explicitly');
|
|
59
99
|
}
|
|
60
100
|
}
|
|
61
101
|
|
|
@@ -214,6 +254,20 @@ export async function run(_positional, flags) {
|
|
|
214
254
|
console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
|
|
215
255
|
}
|
|
216
256
|
|
|
257
|
+
// config.json, written from the module that READS it (lib/config.js) for
|
|
258
|
+
// the same reason index.json is written from its schema: a template copy
|
|
259
|
+
// is how a project is born already needing a scaffold update. Before this
|
|
260
|
+
// the file was created by nothing, named in no surface block and reported
|
|
261
|
+
// by no command — it existed only for someone who had read the source, and
|
|
262
|
+
// a pt-BR project sat permanently red under `clarify` with no clue why.
|
|
263
|
+
{
|
|
264
|
+
const dest = path.join(doctrinaDir, "config.json");
|
|
265
|
+
if (!isFile(dest) || force) {
|
|
266
|
+
write(dest, scaffoldConfig(), { force });
|
|
267
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, dest)}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
217
271
|
// Adapters
|
|
218
272
|
if (agentSelector) {
|
|
219
273
|
const agents = agentSelector === "all" ? SUPPORTED_AGENTS : [agentSelector];
|
|
@@ -229,7 +283,7 @@ export async function run(_positional, flags) {
|
|
|
229
283
|
if (intakeBody) {
|
|
230
284
|
const intakePath = writeIntakeFile(projectRoot, {
|
|
231
285
|
body: intakeBody,
|
|
232
|
-
source:
|
|
286
|
+
source: intakeSource,
|
|
233
287
|
projectName,
|
|
234
288
|
date,
|
|
235
289
|
force,
|
|
@@ -361,9 +415,12 @@ Options:
|
|
|
361
415
|
--project-description <text> One-sentence description
|
|
362
416
|
--agent <name> Install adapter for one agent
|
|
363
417
|
(claude|codex|cursor|copilot|gemini|aider|windsurf|continue|amp|devin|factory|jules|all)
|
|
364
|
-
--intake <file> Full project description; stored verbatim at .doctrina/intake.md
|
|
365
|
-
|
|
366
|
-
|
|
418
|
+
--intake <file> Full project description; stored verbatim at .doctrina/intake.md,
|
|
419
|
+
used to derive the one-line description when absent, and the
|
|
420
|
+
bootstrap playbook is printed inline — no second command
|
|
421
|
+
--intake-text "<text>" The same, inline, with no file to write first. Mutually
|
|
422
|
+
exclusive with --intake; either one written without a value
|
|
423
|
+
is a usage error, not a project scaffolded without an intake
|
|
367
424
|
--from <path> Local conventions directory; if it contains AGENTS.md and/or
|
|
368
425
|
.doctrina/product.md, the content is folded into the new project
|
|
369
426
|
--date <YYYY-MM-DD> Override the system date
|
package/src/commands/intake.js
CHANGED
|
@@ -8,6 +8,11 @@ import * as idx from "../lib/index-json.js";
|
|
|
8
8
|
import { today } from "../lib/dates.js";
|
|
9
9
|
import { flagBool, flagString } from "../lib/args.js";
|
|
10
10
|
import { c } from "../lib/colors.js";
|
|
11
|
+
import { looksLikePath, warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
|
|
12
|
+
|
|
13
|
+
// `init --intake` performs the same operations; they live in
|
|
14
|
+
// lib/intake-model.js so neither command imports out of the other (F7).
|
|
15
|
+
export { warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
|
|
11
16
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
12
17
|
|
|
13
18
|
// `intake` is the first half of the no-ceremony path (ADR 0005): store the
|
|
@@ -58,12 +63,24 @@ export async function run(positional, flags) {
|
|
|
58
63
|
source = "inline (--text)";
|
|
59
64
|
} else {
|
|
60
65
|
const abs = path.resolve(projectRoot, sourceFile);
|
|
61
|
-
if (
|
|
66
|
+
if (isFile(abs)) {
|
|
67
|
+
body = read(abs);
|
|
68
|
+
source = relPath(projectRoot, abs);
|
|
69
|
+
} else if (looksLikePath(sourceFile)) {
|
|
62
70
|
console.error(c.red("error:") + ` description file not found: ${sourceFile}`);
|
|
71
|
+
console.error(c.gray("hint: ") +
|
|
72
|
+
'to pass the description itself, use `doctrina intake --text "<description>"`');
|
|
63
73
|
return 1;
|
|
74
|
+
} else {
|
|
75
|
+
// Prose, not a path (change 0071). The surface block says "store the
|
|
76
|
+
// intent", so passing the intent is what its wording invites; answering
|
|
77
|
+
// "description file not found:" and echoing the author's whole sentence
|
|
78
|
+
// back as a filename was the CLI blaming the reader for reading it.
|
|
79
|
+
body = sourceFile;
|
|
80
|
+
source = "inline";
|
|
81
|
+
console.error(c.gray("note: ") +
|
|
82
|
+
'read as the description itself, not as a file — `--text "<description>"` says so explicitly');
|
|
64
83
|
}
|
|
65
|
-
body = read(abs);
|
|
66
|
-
source = relPath(projectRoot, abs);
|
|
67
84
|
}
|
|
68
85
|
if (body.trim().length === 0) {
|
|
69
86
|
console.error(c.red("error:") + " the description is empty");
|
|
@@ -74,6 +91,16 @@ export async function run(positional, flags) {
|
|
|
74
91
|
console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} already exists (pass --force to overwrite)`);
|
|
75
92
|
return 1;
|
|
76
93
|
}
|
|
94
|
+
// After conversion the specs are the only source of truth, and the intake
|
|
95
|
+
// is never edited to change requirements (change 0112). `--force` was the
|
|
96
|
+
// CLI-sanctioned way to do exactly that: it rewrote the file to `pending`
|
|
97
|
+
// and printed the bootstrap playbook as if the tree were empty. The door
|
|
98
|
+
// for new intent is `intent add`; for a change of behaviour, `work`.
|
|
99
|
+
if (exists(intakePath) && (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase() === "converted") {
|
|
100
|
+
console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} is already converted — the specs are the source of truth now, and --force does not reopen it`);
|
|
101
|
+
console.error(c.gray("hint: ") + "new product intent: `doctrina intent add \"<text>\"` · a change of behaviour: `doctrina work \"<prompt>\"`");
|
|
102
|
+
return EXIT.PRECONDITION;
|
|
103
|
+
}
|
|
77
104
|
|
|
78
105
|
const projectName = idx.load(projectRoot).project ?? path.basename(projectRoot);
|
|
79
106
|
writeIntakeFile(projectRoot, { body, source, projectName, date: today(), force });
|
|
@@ -84,95 +111,6 @@ export async function run(positional, flags) {
|
|
|
84
111
|
return 0;
|
|
85
112
|
}
|
|
86
113
|
|
|
87
|
-
// Clarification gate (review Topic A). A description too thin to spec from is
|
|
88
|
-
// the moment to ask the user, not to let the agent invent requirements. Prints
|
|
89
|
-
// the specific gaps before the bootstrap playbook; advisory, never blocking —
|
|
90
|
-
// the intake is still captured verbatim (the playbook step 6 resolves it).
|
|
91
|
-
export function warnIfThinIntake(body) {
|
|
92
|
-
const assessment = assessBrief(body, { kind: "intake" });
|
|
93
|
-
if (!assessment.thin) return;
|
|
94
|
-
console.log(c.yellow("⚠ thin intake — clarify with the user before converting to specs:"));
|
|
95
|
-
for (const reason of assessment.reasons) console.log(` - ${reason}`);
|
|
96
|
-
console.log("");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Shared with `init --intake`. Writes the intake verbatim under a small
|
|
100
|
-
// status header; the agent flips Status to converted at the end of the
|
|
101
|
-
// playbook, which is how `next` knows the bootstrap is done.
|
|
102
|
-
export function writeIntakeFile(projectRoot, { body, source, projectName, date, force = false }) {
|
|
103
|
-
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
104
|
-
const content = [
|
|
105
|
-
`# Intake — ${projectName}`,
|
|
106
|
-
"",
|
|
107
|
-
"- **Status:** pending",
|
|
108
|
-
`- **Date:** ${date}`,
|
|
109
|
-
`- **Source:** ${source}`,
|
|
110
|
-
"",
|
|
111
|
-
"<!-- Raw project intent, stored verbatim. The bootstrap playbook",
|
|
112
|
-
" (doctrina intake) converts it into product.md and capability",
|
|
113
|
-
" specs, then flips Status to converted. After conversion the",
|
|
114
|
-
" specs are the only source of truth; never edit this file to",
|
|
115
|
-
" change requirements. -->",
|
|
116
|
-
"",
|
|
117
|
-
"---",
|
|
118
|
-
"",
|
|
119
|
-
body.replace(/\r\n/g, "\n").replace(/\n*$/, "\n"),
|
|
120
|
-
].join("\n");
|
|
121
|
-
write(intakePath, content, { force });
|
|
122
|
-
return intakePath;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Exported so `init --intake` can print the same playbook inline instead
|
|
126
|
-
// of bouncing the user to a second command.
|
|
127
|
-
export function printBootstrapPlaybook(projectRoot) {
|
|
128
|
-
const hasSpecs = exists(path.join(projectRoot, ".doctrina", "specs"));
|
|
129
|
-
console.log(c.bold("Bootstrap playbook") + c.gray(" — agent-executed (ADR 0005); the CLI does no interpretation."));
|
|
130
|
-
console.log("");
|
|
131
|
-
console.log("Execute in order, in a single linear pass:");
|
|
132
|
-
console.log("");
|
|
133
|
-
console.log(`1. Read ${c.cyan(".doctrina/intake.md")} (raw intent) and ${c.cyan(".doctrina/product.md")}.`);
|
|
134
|
-
console.log("");
|
|
135
|
-
console.log("2. Fill every product.md section from the intake: Vision, Problem,");
|
|
136
|
-
console.log(" Target users, Scope (in/out), Non-goals, Success criteria, and the");
|
|
137
|
-
console.log(" Delivery order — name the ONE end-to-end walking skeleton to build");
|
|
138
|
-
console.log(" and verify before fanning out (depth before breadth).");
|
|
139
|
-
console.log(" One fact, one home — product.md holds vision, never requirements.");
|
|
140
|
-
console.log("");
|
|
141
|
-
console.log("3. Derive the capability list: kebab-case, one per area of behaviour");
|
|
142
|
-
console.log(" (e.g. auth, billing, reports). For each capability:");
|
|
143
|
-
console.log(` ${c.cyan("doctrina spec new <capability>")}`);
|
|
144
|
-
console.log(" then replace the template placeholders in");
|
|
145
|
-
console.log(" .doctrina/specs/<capability>/spec.md with EARS requirements derived");
|
|
146
|
-
console.log(" from the intake (Ubiquitous / Event-driven / State-driven /");
|
|
147
|
-
console.log(" Unwanted-behavior / Optional). Keep the two axes honest — leave");
|
|
148
|
-
console.log(" Implementation: planned for what is not built yet; keep aspiration");
|
|
149
|
-
console.log(" under ## Maturity → Future, not in EARS; write concrete Acceptance");
|
|
150
|
-
console.log(" criteria, each [unverified] until a cited test proves it.");
|
|
151
|
-
console.log("");
|
|
152
|
-
console.log("4. If the project spans services/front-ends, own the seams between");
|
|
153
|
-
console.log(" them (port map, env, API/WS/event shapes) as a contract:");
|
|
154
|
-
console.log(` ${c.cyan("doctrina contract new <name>")} → fill it → ${c.cyan("doctrina contract check")}`);
|
|
155
|
-
console.log("");
|
|
156
|
-
console.log("5. Record any architectural decision the intake forces (cite Evidence):");
|
|
157
|
-
console.log(` ${c.cyan("doctrina decision new \"<title>\"")} → edit → ${c.cyan("doctrina decision accept <num>")}`);
|
|
158
|
-
console.log("");
|
|
159
|
-
console.log("6. Quality gates — fix everything they report before moving on:");
|
|
160
|
-
console.log(` ${c.cyan("doctrina clarify --all")}`);
|
|
161
|
-
console.log(` ${c.cyan("doctrina validate")}`);
|
|
162
|
-
console.log(` ${c.cyan("doctrina coverage")} (and ${c.cyan("doctrina verify --init")} to declare the build gate)`);
|
|
163
|
-
console.log(" If something in the intake is genuinely ambiguous, ask the user");
|
|
164
|
-
console.log(" before assuming.");
|
|
165
|
-
console.log("");
|
|
166
|
-
console.log("7. Mark the intake consumed: in .doctrina/intake.md flip");
|
|
167
|
-
console.log(" \"- **Status:** pending\" to \"- **Status:** converted\".");
|
|
168
|
-
console.log("");
|
|
169
|
-
console.log(`8. ${c.cyan("doctrina next")} — follow the recommendation. Implementation then`);
|
|
170
|
-
console.log(` flows through ${c.cyan("doctrina work \"<brief prompt>\"")}, one change at a time.`);
|
|
171
|
-
if (!hasSpecs) {
|
|
172
|
-
console.log("");
|
|
173
|
-
console.log(c.yellow("warn:") + " .doctrina/specs/ is missing — re-run `doctrina init` first.");
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
114
|
|
|
177
115
|
export const help = `
|
|
178
116
|
Usage: doctrina intake [<file>] [--text "<description>"] [--force]
|