doctrina-cli 0.3.0 → 0.6.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.
@@ -1,9 +1,12 @@
1
1
  import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { readdirSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
4
5
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
- import { flagString } from "../lib/args.js";
6
+ import { flagString, flagBool } from "../lib/args.js";
7
+ import * as idx from "../lib/index-json.js";
6
8
  import { c } from "../lib/colors.js";
9
+ import { assessBrief } from "../lib/clarity.js";
7
10
  import { changeNew } from "./change.js";
8
11
 
9
12
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
@@ -16,15 +19,49 @@ import { changeNew } from "./change.js";
16
19
 
17
20
  export async function run(positional, flags) {
18
21
  const prompt = positional.join(" ").trim();
19
- if (!prompt) {
22
+ const projectRoot = process.cwd();
23
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
24
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
25
+ }
26
+
27
+ // --resume <id>: reprint the playbook for an existing open change rather than
28
+ // open a new one (review G1 — `work "continue"` used to slug the word and
29
+ // fabricate a junk `NNNN-continue` change the operator had to delete).
30
+ if (flags.has("resume")) {
31
+ return resumeChange(projectRoot, flagString(flags, "resume"));
32
+ }
33
+
34
+ // --from-diff backfills from code already written (review F8): the
35
+ // working-tree changes are the input, so no prompt is required.
36
+ const fromDiff = flagBool(flags, "from-diff", false);
37
+ const chore = flagBool(flags, "chore", false) || flagBool(flags, "no-spec", false);
38
+ let files = [];
39
+ if (fromDiff) {
40
+ files = changedFiles(projectRoot);
41
+ if (files.length === 0) {
42
+ console.error(c.red("error:") + " --from-diff found no working-tree changes to backfill from");
43
+ console.error(c.gray("hint: ") + "make (or stage) the code changes first, then run `doctrina work --from-diff`");
44
+ return 1;
45
+ }
46
+ }
47
+
48
+ if (!prompt && !fromDiff) {
20
49
  console.error(c.red("error:") + " work requires a prompt (quote it if it contains spaces)");
21
50
  console.error(c.gray("hint: ") + "example: doctrina work \"add login with email and password\"");
22
51
  return 2;
23
52
  }
24
53
 
25
- const projectRoot = process.cwd();
26
- if (!exists(path.join(projectRoot, ".doctrina"))) {
27
- throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
54
+ // A thin, resume-shaped prompt ("continue", "prossiga", ...) while a change
55
+ // is open is almost certainly "finish what's open", not "start a change named
56
+ // after that word". Suggest resuming instead of creating noise; --force opens
57
+ // a new change anyway. The match is a deterministic stoplist, not language
58
+ // understanding (still ADR 0005).
59
+ if (!fromDiff && !flagBool(flags, "force", false) && isResumeIntent(prompt)) {
60
+ const open = openChanges(projectRoot);
61
+ if (open.length > 0) {
62
+ printResumeSuggestion(open);
63
+ return 0;
64
+ }
28
65
  }
29
66
 
30
67
  const pinned = flagString(flags, "capability");
@@ -33,37 +70,74 @@ export async function run(positional, flags) {
33
70
  return 2;
34
71
  }
35
72
 
36
- const id = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slugify(prompt)}`;
73
+ const effPrompt = prompt || `backfill specs from ${files.length} changed file${files.length === 1 ? "" : "s"}`;
74
+ const slug = prompt ? slugify(prompt) : "backfill";
75
+ const id = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
37
76
  if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
38
77
  console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
39
78
  return 2;
40
79
  }
41
80
 
42
- const code = changeNew([id, prompt], flags);
81
+ const code = changeNew([id, effPrompt], flags);
43
82
  if (code !== 0) return code;
44
83
 
45
84
  // Record the prompt verbatim as the change's Why — the raw intent has
46
- // one home, and it is the proposal, not the playbook output.
85
+ // one home, and it is the proposal, not the playbook output. For --from-diff,
86
+ // also record the changed files so the backfill's input is in the artifact.
47
87
  const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
48
88
  if (isFile(proposalPath)) {
49
89
  const txt = read(proposalPath);
90
+ let why = effPrompt;
91
+ if (fromDiff) {
92
+ const list = files.slice(0, 30).map((f) => `- \`${f}\``).join("\n");
93
+ const more = files.length > 30 ? `\n- … and ${files.length - 30} more` : "";
94
+ why = `${effPrompt}\n\nBackfilled from working-tree changes:\n${list}${more}`;
95
+ }
50
96
  // CRLF-tolerant: templates may be checked out with \r\n on Windows.
51
- let updated = txt.replace(/(## Why\r?\n\r?\n)<!--[\s\S]*?-->[ \t]*\r?\n?/, `$1${prompt}\n`);
52
- if (updated === txt) updated = txt.replace(/(## Why\r?\n)/, `$1\n${prompt}\n`);
97
+ let updated = txt.replace(/(## Why\r?\n\r?\n)<!--[\s\S]*?-->[ \t]*\r?\n?/, `$1${why}\n`);
98
+ if (updated === txt) updated = txt.replace(/(## Why\r?\n)/, `$1\n${why}\n`);
53
99
  if (pinned) {
54
100
  updated = updated.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, `$1 ${pinned}`);
55
101
  }
56
102
  if (updated !== txt) write(proposalPath, updated, { force: true });
57
103
  }
58
104
 
59
- const matches = pinned ? [] : rankCapabilities(projectRoot, prompt);
105
+ // Capability hint: term overlap for a prompt, changed-file overlap for a diff
106
+ // (review F10 — rank by what the working tree touched, not just prompt words).
107
+ const allChanged = fromDiff ? files : changedFiles(projectRoot);
108
+ const diffMatches = pinned ? [] : rankCapabilitiesByDiff(projectRoot, allChanged);
109
+ const matches = pinned ? [] : (fromDiff ? diffMatches : rankCapabilities(projectRoot, effPrompt));
60
110
  const capability = pinned ?? matches[0]?.id ?? null;
111
+ const clarity = assessBrief(effPrompt, { kind: "prompt" });
61
112
 
62
113
  console.log("");
63
- printPlaybook(projectRoot, { id, prompt, pinned, matches, capability });
114
+ if (chore) {
115
+ printChorePlaybook(projectRoot, { id, prompt: effPrompt });
116
+ } else {
117
+ printPlaybook(projectRoot, {
118
+ id, prompt: effPrompt, pinned, matches, capability, clarity,
119
+ fromDiff, diffMatches: fromDiff ? [] : diffMatches,
120
+ });
121
+ }
64
122
  return 0;
65
123
  }
66
124
 
125
+ // Changed files in the working tree (tracked + untracked), for the diff-based
126
+ // capability hint and `--from-diff` backfill. Empty outside a git repo or with
127
+ // no changes. Read-only: never invokes a mutating git command.
128
+ function changedFiles(projectRoot) {
129
+ const run = (args) => {
130
+ const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
131
+ if (r.error || r.status !== 0) return [];
132
+ return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
133
+ };
134
+ const set = new Set([
135
+ ...run(["diff", "--name-only", "HEAD"]), // tracked, staged + unstaged
136
+ ...run(["ls-files", "--others", "--exclude-standard"]), // untracked
137
+ ]);
138
+ return [...set];
139
+ }
140
+
67
141
  // Next sequential NNNN across open changes and the archive, so work-driven
68
142
  // ids stay ordered no matter how earlier changes were named.
69
143
  function nextChangeNumber(projectRoot) {
@@ -89,6 +163,70 @@ function nextChangeNumber(projectRoot) {
89
163
  return String(max + 1).padStart(4, "0");
90
164
  }
91
165
 
166
+ // Open (non-archived) changes from the index — the set `work --resume` and
167
+ // the thin-prompt guard offer to continue.
168
+ function openChanges(projectRoot) {
169
+ try {
170
+ return idx.load(projectRoot).artifacts?.changes ?? [];
171
+ } catch {
172
+ return [];
173
+ }
174
+ }
175
+
176
+ // A deterministic stoplist of "carry on" prompts (en + pt). Folded and
177
+ // stripped to letters so "go on" / "keep going" / "continuação" all match.
178
+ // Not language understanding — just the words that mean "resume", which is
179
+ // exactly when slugging the prompt produces a junk change (G1).
180
+ const RESUME_WORDS = new Set([
181
+ "continue", "continua", "continuar", "continuacao", "continuemos", "continuando",
182
+ "prossiga", "prosseguir", "prossegue", "proceed", "resume", "resumir",
183
+ "retomar", "retoma", "segue", "seguir", "go", "goon", "next", "keepgoing",
184
+ "vamos", "more", "mais",
185
+ ]);
186
+
187
+ function isResumeIntent(prompt) {
188
+ return RESUME_WORDS.has(fold(prompt).replace(/[^a-z0-9]+/g, ""));
189
+ }
190
+
191
+ function printResumeSuggestion(open) {
192
+ console.log(c.yellow(`open change${open.length === 1 ? "" : "s"} detected`) +
193
+ " — did you mean to continue one of these?");
194
+ console.log("");
195
+ for (const ch of open) {
196
+ console.log(` ${c.cyan(ch.id)} ${c.gray((ch.title ?? "").slice(0, 60))} ${c.gray(`[${ch.status ?? "open"}]`)}`);
197
+ }
198
+ console.log("");
199
+ console.log("Resume the existing change (reprints its playbook):");
200
+ console.log(` ${c.cyan(`doctrina work --resume ${open[0].id}`)}`);
201
+ console.log("Or open a new change anyway:");
202
+ console.log(` ${c.cyan(`doctrina work "<prompt>" --force`)}`);
203
+ }
204
+
205
+ // Reprint the work playbook for an already-open change, ranking capabilities
206
+ // from its recorded title (the original prompt). Creates nothing.
207
+ function resumeChange(projectRoot, resumeId) {
208
+ const open = openChanges(projectRoot);
209
+ if (!resumeId || resumeId === true) {
210
+ console.error(c.red("error:") + " --resume needs a change id");
211
+ if (open.length) console.error(c.gray("open: ") + open.map((ch) => ch.id).join(", "));
212
+ return 2;
213
+ }
214
+ const entry = open.find((ch) => ch.id === resumeId);
215
+ if (!entry && !isDir(path.join(projectRoot, ".doctrina", "changes", resumeId))) {
216
+ console.error(c.red("error:") + ` no open change "${resumeId}"`);
217
+ if (open.length) console.error(c.gray("open: ") + open.map((ch) => ch.id).join(", "));
218
+ return 1;
219
+ }
220
+ const prompt = entry?.title ?? resumeId;
221
+ const matches = rankCapabilities(projectRoot, prompt);
222
+ const capability = matches[0]?.id ?? null;
223
+ const clarity = assessBrief(prompt, { kind: "prompt" });
224
+ console.log(c.bold(`Resuming change ${resumeId}`) + c.gray(" — agent-executed (ADR 0005)."));
225
+ console.log("");
226
+ printPlaybook(projectRoot, { id: resumeId, prompt, pinned: null, matches, capability, clarity });
227
+ return 0;
228
+ }
229
+
92
230
  // ASCII-folded kebab-case of the prompt ("Faça o login" -> "faca-o-login").
93
231
  export function slugify(text) {
94
232
  let slug = text
@@ -150,23 +288,93 @@ export function rankCapabilities(projectRoot, prompt) {
150
288
  return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
151
289
  }
152
290
 
153
- function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability }) {
291
+ // Rank capabilities by the working tree, not the prompt (review F10): a changed
292
+ // file scores its capability when the file sits under a path segment named for
293
+ // it, or when the spec cites the file as evidence. A deterministic overlap
294
+ // hint for the agent — never a decision (ADR 0005).
295
+ export function rankCapabilitiesByDiff(projectRoot, files) {
296
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
297
+ if (!files || files.length === 0 || !isDir(specsDir)) return [];
298
+ const norm = files.map((f) => f.replace(/\\/g, "/"));
299
+
300
+ const ranked = [];
301
+ for (const cap of readdirSync(specsDir).sort()) {
302
+ const specPath = path.join(specsDir, cap, "spec.md");
303
+ if (!isFile(specPath)) continue;
304
+ const specText = read(specPath);
305
+ let score = 0;
306
+ for (const f of norm) {
307
+ if (f.split("/").includes(cap)) score += 3; // src/<cap>/... etc.
308
+ if (specText.includes(f)) score += 5; // file cited in the spec
309
+ else if (specText.includes(path.basename(f))) score += 2; // filename cited
310
+ }
311
+ if (score > 0) ranked.push({ id: cap, score, path: `.doctrina/specs/${cap}/spec.md` });
312
+ }
313
+ return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
314
+ }
315
+
316
+ // A chore is a spec-less change (review F9): the playbook drops the spec-delta
317
+ // steps and goes straight to implement → verify → archive → validate, so the
318
+ // agent is not prompted to invent a delta for infra/docs/build work.
319
+ function printChorePlaybook(projectRoot, { id, prompt }) {
320
+ console.log(c.bold(`Chore playbook — change ${id}`) + c.gray(" — agent-executed (ADR 0005); no spec deltas."));
321
+ console.log("");
322
+ console.log(`Prompt: "${prompt}"`);
323
+ console.log("");
324
+ console.log("Execute in order, in a single linear pass:");
325
+ console.log("");
326
+ console.log(`1. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
327
+ console.log(" with small, checkable steps; implement them, checking each box.");
328
+ console.log("2. Prove it (archive refuses unchecked boxes):");
329
+ console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
330
+ console.log(` ${c.cyan("doctrina verify --clean")} — clean-checkout reproducibility lint`);
331
+ console.log(" Then check the proposal's ## Verification boxes and every task.");
332
+ console.log("3. This is a chore: no spec changes. If you find yourself needing a");
333
+ console.log(` spec delta, it is not a chore — reopen with ${c.cyan("doctrina work \"<prompt>\"")}.`);
334
+ console.log(`4. ${c.cyan(`doctrina change apply ${id}`)} (zero deltas → flips to applied).`);
335
+ console.log(`5. ${c.cyan(`doctrina change archive ${id}`)}, then ${c.cyan("doctrina validate")}.`);
336
+ }
337
+
338
+ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity, fromDiff = false, diffMatches = [] }) {
154
339
  const capToken = capability ?? "<capability>";
155
- console.log(c.bold(`Work playbook — change ${id}`) + c.gray(" — agent-executed (ADR 0005)."));
340
+ const title = fromDiff ? "Backfill playbook" : "Work playbook";
341
+ console.log(c.bold(`${title} — change ${id}`) + c.gray(" — agent-executed (ADR 0005)."));
156
342
  console.log("");
157
343
  console.log(`Prompt: "${prompt}"`);
344
+ if (fromDiff) {
345
+ console.log(c.gray("Code-first: write the spec that describes what the working tree already does."));
346
+ }
347
+
348
+ // Clarification gate (review Topic A): a thin prompt is the moment to ask
349
+ // the user, not to invent a spec. Advisory — the change is still scaffolded
350
+ // (it is a draft), but the agent is told to resolve the gaps first.
351
+ if (clarity?.thin && !fromDiff) {
352
+ console.log("");
353
+ console.log(c.yellow("⚠ thin prompt — clarify with the user before writing spec deltas:"));
354
+ for (const r of clarity.reasons) console.log(` - ${r}`);
355
+ }
158
356
 
159
357
  if (pinned) {
160
358
  const hasSpec = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md"));
161
359
  console.log(`Capability (pinned): ${c.cyan(pinned)}` +
162
360
  (hasSpec ? "" : c.yellow(" — no spec yet; create it in step 2")));
163
361
  } else if (matches.length > 0) {
164
- console.log("Likely capabilities (deterministic term match — a hint, not a decision):");
362
+ const how = fromDiff ? "touched by your working tree" : "deterministic term match";
363
+ console.log(`Likely capabilities (${how} — a hint, not a decision):`);
165
364
  for (const m of matches) {
166
365
  console.log(` ${c.cyan(m.id.padEnd(20))} score ${String(m.score).padStart(3)} ${c.gray(m.path)}`);
167
366
  }
168
367
  } else {
169
- console.log(c.gray("No existing spec matches the prompt — likely a new capability."));
368
+ console.log(c.gray(fromDiff
369
+ ? "No existing spec matches the changed files — likely a new capability."
370
+ : "No existing spec matches the prompt — likely a new capability."));
371
+ }
372
+
373
+ // Extra signal (review F10): even for a prompt-driven change, show which
374
+ // capabilities the working tree touched — often the truer hint.
375
+ if (!fromDiff && diffMatches.length > 0) {
376
+ console.log(c.gray("Also touched by your working tree: ") +
377
+ diffMatches.map((m) => c.cyan(m.id)).join(", "));
170
378
  }
171
379
 
172
380
  console.log("");
@@ -187,6 +395,11 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability })
187
395
  console.log(c.gray(" <EARS body. Keep the two axes honest (Status vs Implementation),"));
188
396
  console.log(c.gray(" keep aspiration under ## Maturity → Future, and cite the proof per"));
189
397
  console.log(c.gray(" criterion: \"1. [unverified] <signal> — verified by `path/to/test`\">"));
398
+ if (fromDiff) {
399
+ console.log(c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"));
400
+ console.log(c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"));
401
+ console.log(c.gray(" diff is tested). The changed files are listed in the proposal's ## Why."));
402
+ }
190
403
  console.log("");
191
404
  console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
192
405
  console.log(" with small, checkable implementation tasks (a few hours each, max).");
@@ -196,7 +409,7 @@ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability })
196
409
  console.log(" If the prompt is genuinely ambiguous, ask the user before assuming.");
197
410
  console.log("");
198
411
  console.log(`6. ${c.cyan(`doctrina analyze ${id}`)} — fix every ✗ before applying.`);
199
- console.log(`7. ${c.cyan(`doctrina change apply ${id}`)} (MODIFIED deltas are merged by hand).`);
412
+ console.log(`7. ${c.cyan(`doctrina change apply ${id}`)} (MODIFIED with an ops block applies mechanically; prose merges by hand).`);
200
413
  console.log("8. Prove it before declaring done (archive refuses unchecked boxes):");
201
414
  console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
202
415
  console.log(` ${c.cyan("doctrina coverage")} — each acceptance criterion cites a real test`);
@@ -214,13 +427,24 @@ Turn a brief prompt into a scaffolded change plus the work playbook the
214
427
  host AI agent executes (see ADR 0005). The CLI derives a sequential
215
428
  change id (NNNN-<slug>) from the prompt, opens the change folder,
216
429
  records the prompt under the proposal's "## Why", ranks existing specs
217
- by term overlap as a capability hint, and prints the ordered steps:
430
+ by term overlap as a capability hint, flags a thin/under-specified prompt
431
+ so you clarify before writing deltas (advisory), and prints the steps:
218
432
  context → spec delta → tasks → implement → analyze → apply → verify
219
433
  (verify + coverage) → archive → validate. No natural-language
220
434
  interpretation happens in the CLI.
221
435
 
436
+ When the prompt is a bare "continue"/"prossiga"/"next" and a change is
437
+ already open, work suggests resuming it instead of opening a junk change
438
+ named after that word (review G1). Use --resume to do so directly.
439
+
222
440
  Options:
223
441
  --capability <cap> Pin the capability instead of ranking matches
224
442
  --id <id> Override the derived change id
225
- --force Overwrite an existing change folder
443
+ --resume <id> Reprint the playbook for an open change; create nothing
444
+ --from-diff Backfill: scaffold from the working-tree changes (no
445
+ prompt needed) and print a code-first playbook (F8)
446
+ --chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
447
+ playbook that skips the spec-delta steps (F9)
448
+ --force Open a new change even when one is open, and overwrite
449
+ an existing change folder
226
450
  `;
package/src/index.js CHANGED
@@ -1,11 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { readFileSync } from "node:fs";
6
3
  import { parseArgs } from "./lib/args.js";
7
4
  import { c } from "./lib/colors.js";
8
5
  import { suggest } from "./lib/suggest.js";
6
+ import { cliVersion } from "./lib/version.js";
9
7
 
10
8
  import * as init from "./commands/init.js";
11
9
  import * as spec from "./commands/spec.js";
@@ -27,11 +25,12 @@ import * as work from "./commands/work.js";
27
25
  import * as coverage from "./commands/coverage.js";
28
26
  import * as verify from "./commands/verify.js";
29
27
  import * as contract from "./commands/contract.js";
28
+ import * as trace from "./commands/trace.js";
30
29
 
31
30
  const COMMANDS = {
32
31
  init, spec, change, decision, validate, hooks, analyze, clarify,
33
32
  templates, skill, index: indexCmd, next, metrics, context, search,
34
- intake, work, coverage, verify, contract,
33
+ intake, work, coverage, verify, contract, trace,
35
34
  };
36
35
 
37
36
  const TOP_HELP = `
@@ -51,6 +50,7 @@ Commands:
51
50
  contract check Verify port collisions, env drift, referenced specs
52
51
  decision new Create the next sequentially numbered ADR
53
52
  decision accept Flip a proposed ADR to accepted
53
+ decision land Record that an accepted ADR is now implemented (non-mutating)
54
54
  decision supersede Create a new ADR that supersedes an existing one
55
55
  decision list List ADRs with status, date, and title
56
56
  skill new Scaffold an on-demand procedural memory skill
@@ -62,6 +62,7 @@ Commands:
62
62
  search Search the artifact tree, grouped by category
63
63
  validate Run schema and structural checks
64
64
  coverage Report acceptance criteria with linked evidence (--strict gates)
65
+ trace Report intent provenance: product intent → specs (--strict gates)
65
66
  verify Run project-declared typecheck/test/build checks (the real gate)
66
67
  templates list List the templates shipped by the installed CLI
67
68
  templates check Compare the project against the recommended template shape
@@ -81,7 +82,7 @@ async function main(argv) {
81
82
  });
82
83
 
83
84
  if (flags.get("version") || flags.get("v")) {
84
- console.log(readVersion());
85
+ console.log(cliVersion());
85
86
  return 0;
86
87
  }
87
88
 
@@ -119,14 +120,4 @@ async function main(argv) {
119
120
  }
120
121
  }
121
122
 
122
- function readVersion() {
123
- const here = path.dirname(fileURLToPath(import.meta.url));
124
- const pkgPath = path.resolve(here, "..", "package.json");
125
- try {
126
- return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
127
- } catch {
128
- return "0.0.0";
129
- }
130
- }
131
-
132
123
  main(process.argv.slice(2)).then((code) => process.exit(code ?? 0));
@@ -0,0 +1,49 @@
1
+ // Deterministic under-specification detector for free-text briefs and
2
+ // intakes — the clarification gate (review Topic A). It does NOT judge
3
+ // meaning (that stays the agent's job, ADR 0005); it flags input that is
4
+ // structurally too thin to spec from, so `intake` and `work` prompt the
5
+ // agent to clarify with the user before speculating a spec or change out of
6
+ // too little. Advisory only: it surfaces the gap, it does not block.
7
+
8
+ const WEASEL = /\b(might|could|perhaps|maybe|probably|somehow|approximately|roughly|etc)\b/gi;
9
+ const VAGUE = /\b(some|several|various|appropriate|robust|scalable|nice|good|better|fast|simple|stuff|things?)\b/gi;
10
+
11
+ // Generic verbs/nouns that carry no capability information on their own. A
12
+ // brief made only of these ("add the feature", "do the thing") names nothing
13
+ // concrete to build.
14
+ const FILLER = new Set([
15
+ "the", "and", "for", "with", "this", "that", "something", "anything",
16
+ "make", "add", "create", "build", "implement", "feature", "thing",
17
+ "things", "stuff", "task", "work", "please", "just", "some", "any",
18
+ ]);
19
+
20
+ // Assess a brief/intake. `kind` tunes the thresholds: a one-line work prompt
21
+ // is held to a lower bar than a full project intake.
22
+ export function assessBrief(text, { kind = "prompt" } = {}) {
23
+ const clean = String(text ?? "").replace(/\s+/g, " ").trim();
24
+ const tokens = clean ? clean.split(" ") : [];
25
+ const words = tokens.length;
26
+ const vague = (clean.match(VAGUE) ?? []).length + (clean.match(WEASEL) ?? []).length;
27
+ const meaningful = tokens
28
+ .map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, ""))
29
+ .filter((t) => t.length >= 3 && !FILLER.has(t));
30
+
31
+ const minWords = kind === "intake" ? 20 : 3;
32
+ const maxVague = kind === "intake" ? 4 : 2;
33
+
34
+ const reasons = [];
35
+ if (words < minWords) {
36
+ reasons.push(
37
+ `only ${words} word${words === 1 ? "" : "s"} — say what the behaviour is, ` +
38
+ `what the inputs are, and how you'll know it's done`,
39
+ );
40
+ }
41
+ if (words > 0 && meaningful.length === 0) {
42
+ reasons.push("no concrete terms once filler words are removed — name the actual capability or behaviour");
43
+ }
44
+ if (vague >= maxVague) {
45
+ reasons.push(`${vague} vague/weasel term${vague === 1 ? "" : "s"} — replace with definite, checkable wording`);
46
+ }
47
+
48
+ return { thin: reasons.length > 0, words, reasons };
49
+ }
@@ -1,7 +1,8 @@
1
1
  import path from "node:path";
2
2
  import { read, write, exists } from "./fs-ops.js";
3
+ import { cliVersion } from "./version.js";
3
4
 
4
- const SCHEMA_VERSION = "0.1.0";
5
+ export const SCHEMA_VERSION = "0.1.0";
5
6
 
6
7
  export function indexPath(projectRoot) {
7
8
  return path.join(projectRoot, ".doctrina", "index.json");
@@ -21,6 +22,11 @@ export function load(projectRoot) {
21
22
  }
22
23
 
23
24
  export function save(projectRoot, index) {
25
+ // Stamp the managing framework version on every write (3.6): the index
26
+ // records which CLI last wrote it, so a stale stamp (an index written by an
27
+ // older CLI) is detectable by `doctrina validate` and migrated by
28
+ // `doctrina index rebuild`.
29
+ index.framework_version = cliVersion();
24
30
  const p = indexPath(projectRoot);
25
31
  const text = JSON.stringify(index, null, 2) + "\n";
26
32
  write(p, text, { force: true });
@@ -30,7 +36,7 @@ export function blank(projectName, date) {
30
36
  return {
31
37
  $schema_version: SCHEMA_VERSION,
32
38
  project: projectName,
33
- framework_version: "0.0.0",
39
+ framework_version: cliVersion(),
34
40
  last_updated: date,
35
41
  artifacts: {
36
42
  product: {
package/src/lib/scan.js CHANGED
@@ -81,6 +81,12 @@ export function deriveIndex(projectRoot, current) {
81
81
  const implRaw = specHeader(text, "Implementation");
82
82
  const impl = implRaw ? implRaw.trim() : prev?.implementation;
83
83
  if (impl) entry.implementation = impl;
84
+ // Realizes: the product intent anchors this capability delivers (ADR
85
+ // 0006). Stored as an id list so the provenance link is queryable, not
86
+ // just prose. Header wins over the previous index, like Implementation.
87
+ const realizesRaw = specHeader(text, "Realizes");
88
+ const realizes = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? []) : prev?.realizes;
89
+ if (realizes && realizes.length) entry.realizes = realizes;
84
90
  out.artifacts.specs.push(entry);
85
91
  }
86
92
 
@@ -104,6 +110,8 @@ export function deriveIndex(projectRoot, current) {
104
110
  if (supersedes && supersedes !== "—") entry.supersedes = supersedes;
105
111
  const supersededBy = listHeader(text, "Superseded by");
106
112
  if (supersededBy && supersededBy !== "—") entry.superseded_by = supersededBy;
113
+ const landed = listHeader(text, "Landed");
114
+ if (landed && landed !== "—") entry.landed = landed;
107
115
  out.artifacts.decisions.push(entry);
108
116
  }
109
117