doctrina-cli 0.20.0 → 0.21.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 +2 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +92 -14
- package/src/commands/clarify.js +1 -204
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +21 -1
- package/src/commands/handoff.js +11 -2
- package/src/commands/init.js +13 -1
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +15 -0
- package/src/commands/next.js +41 -6
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +39 -3
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/upgrade.js +18 -0
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +32 -4
- package/src/commands/work.js +43 -8
- package/src/commands/workspace.js +73 -63
- package/src/index.js +98 -62
- package/src/lib/actions.js +43 -3
- package/src/lib/adapters.js +63 -1
- package/src/lib/args.js +33 -1
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +576 -103
- package/src/lib/commands.js +8 -0
- package/src/lib/context-slice.js +34 -3
- package/src/lib/delivery.js +88 -10
- package/src/lib/docs-impact.js +36 -1
- package/src/lib/fs-ops.js +23 -0
- package/src/lib/intake-model.js +1 -1
- package/src/lib/json-out.js +9 -1
- package/src/lib/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/task-card.js +100 -7
- package/src/lib/views.js +3 -3
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +65 -18
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +63 -16
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +82 -130
- package/templates/AGENTS.md.template +1 -1
- package/templates/README.md +2 -1
- package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/playbooks/absorb.md.template +39 -16
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +12 -6
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> CLI for the Doctrina framework. The package is published as
|
|
4
4
|
> `doctrina-cli`; the executable it installs is `doctrina`.
|
|
5
5
|
|
|
6
|
-
**Status:** v0.
|
|
6
|
+
**Status:** v0.21.0 — current release. Zero runtime dependencies
|
|
7
7
|
(Node.js standard library only).
|
|
8
8
|
|
|
9
9
|
## Install
|
|
@@ -29,7 +29,7 @@ Change triage · work · spec new|list|set · change new|apply|archive|chec
|
|
|
29
29
|
contract new|list|check · decision new|accept|land|supersede|list|scope
|
|
30
30
|
skill new|list|suggest · intent add|list
|
|
31
31
|
Gate clarify · validate · coverage · trace · review · verify · close · doctor
|
|
32
|
-
Orchestrate workspace init|add|attach|detach|list|tool|guard · delivery new|route|list|status|check|close
|
|
32
|
+
Orchestrate workspace init|add|attach|detach|list|tool|guard · delivery new|route|list|status|tick|check|close
|
|
33
33
|
Maintain upgrade · index rebuild · hooks install · watch · metrics · completion · ci · templates list
|
|
34
34
|
```
|
|
35
35
|
|
package/package.json
CHANGED
package/src/commands/adapter.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { exists, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { applyAdapterMigration, planAdapterMigration } from "../lib/adapters.js";
|
|
6
|
+
import { relinkShadow, shadowRootOf } from "../lib/workspace.js";
|
|
5
7
|
import {
|
|
6
8
|
ADAPTER_STATES, adapterFiles, describeAdapter, listAdapterNames, renderAdapterFile,
|
|
7
9
|
isMergedConfig, mergeJson, unmergeJson, containsJson, holdsAnyOf,
|
|
@@ -137,6 +139,14 @@ function adapterAdd(projectRoot, name, cmdFlags) {
|
|
|
137
139
|
written += 1;
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
// An adapter installed before its files moved (change 0238): the copies
|
|
143
|
+
// the CLI wrote under the old paths go, an edited one stays and is named.
|
|
144
|
+
const filesRoot = shadowRootOf(projectRoot) ?? projectRoot;
|
|
145
|
+
const moves = planAdapterMigration(projectRoot, tokens, filesRoot);
|
|
146
|
+
const gone = applyAdapterMigration(filesRoot, moves);
|
|
147
|
+
relinkShadow(projectRoot, gone);
|
|
148
|
+
for (const g of gone) console.log(c.green("removed") + ` ${g.rel} (replaced by its skill)`);
|
|
149
|
+
for (const rel of moves.kept) console.log(c.yellow("kept ") + ` ${rel} (edited since install — Cursor now reads the skill; remove it once your edits are moved)`);
|
|
140
150
|
console.log("");
|
|
141
151
|
console.log(`${c.bold("Adapter " + name)} — ${written} file${written === 1 ? "" : "s"} written` +
|
|
142
152
|
(skipped > 0 ? `, ${skipped} left alone` : "") +
|
package/src/commands/change.js
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
import { parseChecklist, getSection, getHeader, parseChangeTitle } from "../lib/doc-model.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
|
-
import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { eolOf, exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, withEol, write } from "../lib/fs-ops.js";
|
|
6
6
|
import { diffLines, formatUnified } from "../lib/diff.js";
|
|
7
7
|
import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
|
|
8
8
|
import * as idx from "../lib/index-json.js";
|
|
9
|
-
import { archivedChangeEntry, deriveIndex } from "../lib/scan.js";
|
|
9
|
+
import { archivedChangeEntry, deriveIndex, listHeader } from "../lib/scan.js";
|
|
10
10
|
import { extractOps, applyOps } from "../lib/spec-ops.js";
|
|
11
11
|
import { printAdrCheckpoint } from "../lib/adr-guard.js";
|
|
12
12
|
import { GATES, TRANSITIONS, checkTransition, recordForcedGap } from "../lib/gates.js";
|
|
13
13
|
import { today } from "../lib/dates.js";
|
|
14
|
-
import { flagBool, flagString } from "../lib/args.js";
|
|
14
|
+
import { flagAll, flagBool, flagString } from "../lib/args.js";
|
|
15
15
|
import { c } from "../lib/colors.js";
|
|
16
16
|
import { appendLedgerLine, archivedLine, abandonedLine } from "../lib/ledger.js";
|
|
17
17
|
import { suggest } from "../lib/suggest.js";
|
|
@@ -21,6 +21,7 @@ import { EXIT } from "../lib/exit-codes.js";
|
|
|
21
21
|
import { ensureDoctrinaProject, refuseChangeRef } from "../lib/project.js";
|
|
22
22
|
import { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
|
|
23
23
|
import { changeNew, addDeltaOps } from "../lib/change-ops.js";
|
|
24
|
+
import { scanText } from "../lib/clarify-scan.js";
|
|
24
25
|
|
|
25
26
|
const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta", "card"];
|
|
26
27
|
|
|
@@ -103,8 +104,26 @@ function changeCard(args) {
|
|
|
103
104
|
if (card.why) {
|
|
104
105
|
L.push("", "## Why the change exists", "", card.why);
|
|
105
106
|
}
|
|
107
|
+
if (card.story) {
|
|
108
|
+
L.push("", `## The story (delivery ${card.delivery})`, "", card.story);
|
|
109
|
+
}
|
|
110
|
+
// What the change does to the spec — the requirement the card implements
|
|
111
|
+
// is here, not in the spec as it stands (change 0231).
|
|
112
|
+
if (card.changes.length > 0) {
|
|
113
|
+
L.push("", "## What this change does to the spec", "");
|
|
114
|
+
for (const ch of card.changes) L.push(`- ${ch.capability} ${ch.line}`);
|
|
115
|
+
}
|
|
106
116
|
for (const s of card.slices) {
|
|
107
|
-
L.push("", `## Spec — ${s.capability} (cut to this task)`, "", s.text.trim());
|
|
117
|
+
L.push("", `## Spec — ${s.capability} as it stands (${s.whole ? "whole: nothing in it matched the task" : "cut to this task"})`, "", s.text.trim());
|
|
118
|
+
}
|
|
119
|
+
if (card.decisions.list.length > 0) {
|
|
120
|
+
L.push("", "## Decisions that bind it", "");
|
|
121
|
+
for (const d of card.decisions.list) {
|
|
122
|
+
L.push(`- ADR ${d.id} — ${d.title} (\`doctrina show ${d.id}\`)`);
|
|
123
|
+
if (d.decision) L.push(` ${d.decision}`);
|
|
124
|
+
}
|
|
125
|
+
if (card.decisions.more > 0) L.push(`- … and ${card.decisions.more} more accepted global decisions — \`doctrina prime --rules\``);
|
|
126
|
+
L.push("", "Where the story or the task contradicts one of these, stop and report it: the decision stands until the planner supersedes it.");
|
|
108
127
|
}
|
|
109
128
|
L.push("", "## When done", "");
|
|
110
129
|
L.push(card.fields.verify
|
|
@@ -417,6 +436,13 @@ async function changeCheck(id, { verbose = false } = {}) {
|
|
|
417
436
|
opsFindings += 1;
|
|
418
437
|
} else {
|
|
419
438
|
console.log(c.green("✓ ") + `${rel}: ${result.applied.length} op${result.applied.length === 1 ? "" : "s"} would apply cleanly`);
|
|
439
|
+
// What clarify will say about the spec once this lands (change 0232):
|
|
440
|
+
// the smells the merged spec has and the current one does not.
|
|
441
|
+
// Advisory — clarify is not a close gate — but a project that runs
|
|
442
|
+
// `clarify --all` in its build learned about the word mid-close.
|
|
443
|
+
for (const smell of newSmells(read(targetPath), result.text, projectRoot)) {
|
|
444
|
+
console.log(c.yellow("! ") + `${rel}: writes ${smell.rule} "${smell.token}" into ${capability}'s spec — ${smell.hint} (\`clarify\` flags it after apply)`);
|
|
445
|
+
}
|
|
420
446
|
}
|
|
421
447
|
}
|
|
422
448
|
if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
|
|
@@ -484,14 +510,17 @@ function changeDelta(args, flags) {
|
|
|
484
510
|
}
|
|
485
511
|
// In the order a reader expects them: requirement, criterion, any raw op,
|
|
486
512
|
// and the bump last.
|
|
513
|
+
// `--requirement`, `--criterion` and `--op` repeat (change 0228): each
|
|
514
|
+
// occurrence is one op, in the order given. The parser keeps the last
|
|
515
|
+
// value of a repeated flag, so reading them with flagString wrote one op
|
|
516
|
+
// and reported success for a command line that asked for three.
|
|
487
517
|
const lines = [];
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
const raw = flagString(flags, "op") ?? (rest.length > 0 ? rest.join(" ") : undefined);
|
|
518
|
+
const raw = flagAll(flags, "op");
|
|
519
|
+
if (raw.length === 0 && rest.length > 0) raw.push(rest.join(" "));
|
|
491
520
|
const bump = flagString(flags, "bump");
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
521
|
+
for (const r of flagAll(flags, "requirement")) lines.push(`append-requirement ${r}`);
|
|
522
|
+
for (const r of flagAll(flags, "criterion")) lines.push(`append-criterion ${r}`);
|
|
523
|
+
lines.push(...raw);
|
|
495
524
|
if (bump) lines.push(`bump-version ${bump}`);
|
|
496
525
|
|
|
497
526
|
const r = addDeltaOps(projectRoot, id, capability, lines);
|
|
@@ -614,11 +643,12 @@ function changeTick(args, flags) {
|
|
|
614
643
|
byPath.get(box.file.path).push(box);
|
|
615
644
|
}
|
|
616
645
|
for (const [filePath, hits] of byPath) {
|
|
617
|
-
const
|
|
646
|
+
const before = read(filePath);
|
|
647
|
+
const lines = before.split(/\r?\n/);
|
|
618
648
|
for (const box of hits) {
|
|
619
649
|
lines[box.lineIndex] = lines[box.lineIndex].replace(/\[ \]/, "[x]");
|
|
620
650
|
}
|
|
621
|
-
write(filePath, lines.join("\n"), { force: true });
|
|
651
|
+
write(filePath, withEol(lines.join("\n"), eolOf(before)), { force: true });
|
|
622
652
|
}
|
|
623
653
|
console.log(c.green("ticked ") + `${picked.length} box${picked.length === 1 ? "" : "es"} in ${byPath.size} file${byPath.size === 1 ? "" : "s"}`);
|
|
624
654
|
return 0;
|
|
@@ -771,13 +801,22 @@ async function changeAbandon(args, flags) {
|
|
|
771
801
|
|
|
772
802
|
const reason = flagString(flags, "reason") ?? "";
|
|
773
803
|
const date = today();
|
|
804
|
+
// The delivery it belonged to stays on record (third audit, change 0234):
|
|
805
|
+
// the proposal that carried it is deleted, and the delivery's status went
|
|
806
|
+
// back to "no change yet", as if the planner had never decided anything.
|
|
807
|
+
const proposalFile = path.join(changeDir, "proposal.md");
|
|
808
|
+
const delivery = isFile(proposalFile) ? (listHeader(read(proposalFile), "Delivery") ?? "").trim() : "";
|
|
809
|
+
const recorded = delivery ? `${reason ? `${reason} ` : ""}(delivery ${delivery})` : reason;
|
|
774
810
|
|
|
775
811
|
remove(changeDir);
|
|
776
812
|
console.log(c.green("removed") + ` ${relPath(projectRoot, changeDir)}`);
|
|
777
813
|
|
|
778
814
|
// Ledger line so the abandonment is part of the visible history.
|
|
779
|
-
const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id,
|
|
815
|
+
const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id, recorded, date));
|
|
780
816
|
console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
|
|
817
|
+
if (delivery) {
|
|
818
|
+
console.log(c.gray("hint: ") + `it was this repository's part of delivery ${delivery} — from the workspace, re-route the delivery without this repository (\`doctrina delivery route ${delivery} <the others>\`) or open a new change here with \`doctrina work "<title>" --delivery ${delivery}\``);
|
|
819
|
+
}
|
|
781
820
|
|
|
782
821
|
// Drop the change entry; rebuild from the tree so nothing drifts.
|
|
783
822
|
const current = idx.load(projectRoot);
|
|
@@ -830,6 +869,26 @@ export function printDeltaPreview(projectRoot, changeDir, deltaFiles) {
|
|
|
830
869
|
continue;
|
|
831
870
|
}
|
|
832
871
|
console.log(c.yellow("MODIFIED") + ` ${targetRel}`);
|
|
872
|
+
// A delta with an ops block is previewed as the spec it produces (change
|
|
873
|
+
// 0228): the ops run in memory, as apply runs them, and the diff is the
|
|
874
|
+
// spec before against the spec after. Diffing the spec against the
|
|
875
|
+
// delta FILE printed every line of the spec as a '-' — three hundred
|
|
876
|
+
// lines of noise around the four that change.
|
|
877
|
+
const ops = extractOps(text);
|
|
878
|
+
if (ops.length > 0) {
|
|
879
|
+
const current = read(targetPath);
|
|
880
|
+
const after = applyOps(current, ops);
|
|
881
|
+
if (after.errors.length > 0) {
|
|
882
|
+
for (const e of after.errors) console.error(c.red("error:") + ` ${rel}: ${e}`);
|
|
883
|
+
errors += 1;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
console.log(formatUnified(diffLines(current, after.text), {
|
|
887
|
+
aLabel: targetRel,
|
|
888
|
+
bLabel: `${targetRel} (after apply)`,
|
|
889
|
+
}));
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
833
892
|
console.log(c.gray("note: the delta body is a fragment to merge — '-' lines are current"));
|
|
834
893
|
console.log(c.gray(" spec content absent from the delta, not necessarily removals."));
|
|
835
894
|
const out = formatUnified(diffLines(read(targetPath), body), {
|
|
@@ -850,6 +909,22 @@ function extractDeltaBody(text) {
|
|
|
850
909
|
}
|
|
851
910
|
|
|
852
911
|
|
|
912
|
+
// The smells `after` has that `before` does not, matched by rule, token and
|
|
913
|
+
// the line's own text — line numbers shift when a delta inserts a line.
|
|
914
|
+
function newSmells(before, after, projectRoot) {
|
|
915
|
+
const key = (/** @type {string} */ text, /** @type {{ line: number, rule: string, token: string }} */ sm) =>
|
|
916
|
+
`${sm.rule}|${sm.token.toLowerCase()}|${text.split("\n")[sm.line - 1] ?? ""}`;
|
|
917
|
+
const seen = new Map();
|
|
918
|
+
for (const sm of scanText(before, projectRoot)) seen.set(key(before, sm), (seen.get(key(before, sm)) ?? 0) + 1);
|
|
919
|
+
const out = [];
|
|
920
|
+
for (const sm of scanText(after, projectRoot)) {
|
|
921
|
+
const k = key(after, sm);
|
|
922
|
+
if ((seen.get(k) ?? 0) > 0) seen.set(k, seen.get(k) - 1);
|
|
923
|
+
else out.push(sm);
|
|
924
|
+
}
|
|
925
|
+
return out;
|
|
926
|
+
}
|
|
927
|
+
|
|
853
928
|
export const help = `
|
|
854
929
|
Usage: doctrina change <subcommand> [args]
|
|
855
930
|
|
|
@@ -872,6 +947,7 @@ Subcommands:
|
|
|
872
947
|
its target + the archive gate preview + an advisory
|
|
873
948
|
list of accepted ADRs citing the touched capabilities.
|
|
874
949
|
Everything close would refuse, listed BEFORE it runs.
|
|
950
|
+
--verbose also prints each delta as it would land.
|
|
875
951
|
tick <id> [n... |--all] List the unchecked boxes (tasks.md + proposal
|
|
876
952
|
## Verification) with ordinals; tick the given ones,
|
|
877
953
|
or every one with --all. No args = list only.
|
|
@@ -883,7 +959,9 @@ Subcommands:
|
|
|
883
959
|
capability — created from the template when absent —
|
|
884
960
|
parsed by the grammar apply reads and dry-run against
|
|
885
961
|
the spec first: nothing is written unless the whole
|
|
886
|
-
block applies.
|
|
962
|
+
block applies. --requirement, --criterion and --op
|
|
963
|
+
repeat, one op each, in order; a second --bump
|
|
964
|
+
replaces the first.
|
|
887
965
|
card <id> [<n>] Print task <n> (default: the next open one) as a
|
|
888
966
|
self-contained card for an executor: the task and the
|
|
889
967
|
lines indented under it (Files:, Requirement:, Verify:),
|
package/src/commands/clarify.js
CHANGED
|
@@ -5,112 +5,9 @@ import { readdirSync } from "node:fs";
|
|
|
5
5
|
import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
|
|
6
6
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
8
|
-
import {
|
|
9
|
-
import { loadConfig } from "../lib/config.js";
|
|
8
|
+
import { scanFile } from "../lib/clarify-scan.js";
|
|
10
9
|
import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
|
|
11
10
|
|
|
12
|
-
// Two lexicons, one linter (0.11.0 field review item 3: an English-only
|
|
13
|
-
// clarify is permanently red on a PT-BR project — "some" is the verb *sumir*,
|
|
14
|
-
// "TODO o pensamento" is the pronoun *todo* — which teaches people to ignore
|
|
15
|
-
// the gate). The language comes from `.doctrina/config.json` ("language":
|
|
16
|
-
// "pt-BR" | "en"), else a per-file stopword heuristic. Portuguese mode swaps
|
|
17
|
-
// the ambiguous EN tokens for a PT lexicon; TODO only counts with a colon
|
|
18
|
-
// ("TODO:") since bare TODO is the pronoun.
|
|
19
|
-
const RULES_EN = [
|
|
20
|
-
{
|
|
21
|
-
// "may" is intentionally NOT in this list: EARS Optional grammar
|
|
22
|
-
// uses "the system may ..." legitimately, and a clarify pass that
|
|
23
|
-
// flags every EARS Optional is unusable on Doctrina-style specs.
|
|
24
|
-
// The remaining weasel terms are unambiguous.
|
|
25
|
-
name: "weasel",
|
|
26
|
-
re: /\b(might|could|should\s+probably|perhaps|maybe|approximately|roughly)\b/gi,
|
|
27
|
-
hint: "state explicitly with a definite verb",
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
name: "vague",
|
|
31
|
-
// Two exclusions, both structural rather than a vocabulary loosening.
|
|
32
|
-
// The lookahead was already here: `many 5` quantifies. The lookbehind is
|
|
33
|
-
// change 0079: `how many` is INTERROGATIVE, and a requirement that says
|
|
34
|
-
// "shall report how many contracts declared no rows" names exactly the
|
|
35
|
-
// number the command must print — the opposite of vague. Five of this
|
|
36
|
-
// repository's seventeen smells were that phrase, and the noise is what
|
|
37
|
-
// hid the one real finding among them ("name some of them", which said
|
|
38
|
-
// nothing about how many). A gate that cries wolf teaches people to
|
|
39
|
-
// ignore it.
|
|
40
|
-
//
|
|
41
|
-
// The quality adjectives sit here for the same reason the quantifiers do:
|
|
42
|
-
// "robust" names no threshold, so it cannot be met or missed. They were
|
|
43
|
-
// in the Portuguese lexicon and nowhere in this one, so the SAME sentence
|
|
44
|
-
// translated came back with three smells in one language and none in the
|
|
45
|
-
// other — and this project keeps its own pages in both. A smell test that
|
|
46
|
-
// answers differently per language is not testing the writing.
|
|
47
|
-
re: /(?<!\bhow\s+)\b(many|few|some|several|adequate|robust|scalable)\b(?!\s+\d)/gi,
|
|
48
|
-
hint: "quantify (use a number or a precise scope)",
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
name: "placeholder",
|
|
52
|
-
re: /\b(TBD|TODO|FIXME|XXX|\?\?\?)\b/g,
|
|
53
|
-
hint: "resolve before applying",
|
|
54
|
-
},
|
|
55
|
-
];
|
|
56
|
-
|
|
57
|
-
// How much of the previous line a rule may look back into. Long enough for
|
|
58
|
-
// a wrapped two-word phrase, short enough that the probe stays cheap.
|
|
59
|
-
const LOOKBEHIND_TAIL = 40;
|
|
60
|
-
|
|
61
|
-
const RULES_PT = [
|
|
62
|
-
{
|
|
63
|
-
name: "weasel",
|
|
64
|
-
re: /\b(talvez|possivelmente|provavelmente|aproximadamente|quem sabe|eventualmente)\b/gi,
|
|
65
|
-
hint: "afirme explicitamente, com um verbo definido",
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
// ASCII-folded variants included so "varios"/"vários" both match.
|
|
69
|
-
name: "vague",
|
|
70
|
-
re: /\b(muitos|muitas|poucos|poucas|v[aá]rios|v[aá]rias|alguns|algumas|adequado|adequada|robusto|robusta|escal[aá]vel)\b(?!\s+\d)/gi,
|
|
71
|
-
hint: "quantifique (um número ou um escopo preciso)",
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
// Bare TODO is the Portuguese pronoun; only TODO: is a marker here.
|
|
75
|
-
name: "placeholder",
|
|
76
|
-
re: /\b(TBD|FIXME|XXX|\?\?\?)\b|\bTODO:/g,
|
|
77
|
-
hint: "resolva antes de aplicar",
|
|
78
|
-
},
|
|
79
|
-
];
|
|
80
|
-
|
|
81
|
-
// Language for a file: the project-declared `.doctrina/config.json`
|
|
82
|
-
// ("language": "pt-BR" / "pt" / "en") wins; otherwise a deterministic
|
|
83
|
-
// stopword count over the file text decides. Never semantic (ADR 0005).
|
|
84
|
-
|
|
85
|
-
function projectLanguage(projectRoot) {
|
|
86
|
-
// One configuration reader (lib/config.js, change 0047): the key still
|
|
87
|
-
// lives in .doctrina/config.json, but `doctor` can now show what it
|
|
88
|
-
// resolved to, which is what a pt-BR project sitting red needed.
|
|
89
|
-
return loadConfig(projectRoot).language;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Which lexicon a document is written in — the shared detector (change
|
|
93
|
-
// 0040), so `clarify` and any future consumer count the same grammar words.
|
|
94
|
-
function detectLang(text) {
|
|
95
|
-
return detectLanguage(text);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function rulesFor(projectRoot, text, forcedLang = null) {
|
|
99
|
-
const lang = forcedLang ?? projectLanguage(projectRoot);
|
|
100
|
-
if (lang) return lang === "pt" ? RULES_PT : RULES_EN;
|
|
101
|
-
// Detected per file, the document is scanned with BOTH lexicons (change
|
|
102
|
-
// 0107). A bilingual spec — Portuguese purpose, English EARS, or the
|
|
103
|
-
// reverse — detected as one language hid every smell written in the
|
|
104
|
-
// other: `clarify --all` found nothing where `--lang pt` found two. The
|
|
105
|
-
// two vocabularies do not overlap, so a smell in either is a smell; the
|
|
106
|
-
// detected language only decides which hint text the reader gets first.
|
|
107
|
-
const detected = detectLang(text);
|
|
108
|
-
const [first, second] = detected === "pt" ? [RULES_PT, RULES_EN] : [RULES_EN, RULES_PT];
|
|
109
|
-
// The `placeholder` rule is language-neutral and lives in both lists:
|
|
110
|
-
// one copy, or TBD is reported twice.
|
|
111
|
-
return [...first, ...second.filter((r) => r.name !== "placeholder")];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
11
|
// --lang pt|en wins over the project config and the per-file heuristic
|
|
115
12
|
// (operator review 2026-07-19 §4.8: on a PT-BR project with no config.json,
|
|
116
13
|
// a file whose stopword count tips English gets the EN lexicon and "some" —
|
|
@@ -221,106 +118,6 @@ function clarifyAll(projectRoot, lang = null) {
|
|
|
221
118
|
return 1;
|
|
222
119
|
}
|
|
223
120
|
|
|
224
|
-
function scanFile(fullPath, projectRoot = process.cwd(), lang = null) {
|
|
225
|
-
const text = read(fullPath);
|
|
226
|
-
const skippedRanges = collectSkippedRanges(text);
|
|
227
|
-
const rules = rulesFor(projectRoot, text, lang);
|
|
228
|
-
const smells = [];
|
|
229
|
-
|
|
230
|
-
// Per-line rules: regex pass with skip-range filter. A line carrying the
|
|
231
|
-
// inline suppression marker `<!-- clarify:ok -->` is deliberately accepted
|
|
232
|
-
// by the author — skip it entirely (the escape hatch for a false positive
|
|
233
|
-
// the lexicon cannot know about; "block, never imprison").
|
|
234
|
-
const lines = text.split("\n");
|
|
235
|
-
let cursor = 0;
|
|
236
|
-
for (let i = 0; i < lines.length; i++) {
|
|
237
|
-
const line = lines[i];
|
|
238
|
-
const lineStart = cursor;
|
|
239
|
-
const lineEnd = cursor + line.length;
|
|
240
|
-
if (line.includes("clarify:ok")) {
|
|
241
|
-
cursor = lineEnd + 1;
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
// A rule reads the previous line as CONTEXT, never as content: a match
|
|
245
|
-
// is only reported when it starts inside this line. Prose wraps, and
|
|
246
|
-
// "report per spec how / many criteria..." split the interrogative
|
|
247
|
-
// across a line break — which a line-at-a-time scanner cannot see, so
|
|
248
|
-
// the phrase came back as a smell on one line of the same document and
|
|
249
|
-
// not on the next (change 0079). The prefix is trimmed to the tail of
|
|
250
|
-
// the previous line: enough for a lookbehind, cheap on every line.
|
|
251
|
-
const prev = i > 0 ? lines[i - 1].slice(-LOOKBEHIND_TAIL) : "";
|
|
252
|
-
const probe = prev === "" ? line : `${prev}\n${line}`;
|
|
253
|
-
const offset = probe.length - line.length;
|
|
254
|
-
for (const rule of rules) {
|
|
255
|
-
rule.re.lastIndex = 0;
|
|
256
|
-
let m;
|
|
257
|
-
while ((m = rule.re.exec(probe))) {
|
|
258
|
-
if (m.index < offset) continue; // the match belongs to the previous line
|
|
259
|
-
const absoluteStart = lineStart + m.index - offset;
|
|
260
|
-
if (isInSkippedRange(absoluteStart, skippedRanges)) continue;
|
|
261
|
-
smells.push({
|
|
262
|
-
line: i + 1,
|
|
263
|
-
rule: rule.name,
|
|
264
|
-
token: m[0],
|
|
265
|
-
hint: rule.hint,
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
cursor = lineEnd + 1; // +1 for the newline
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Section rule: empty Acceptance criteria.
|
|
273
|
-
if (hasEmptyAcceptanceCriteria(text)) {
|
|
274
|
-
smells.push({
|
|
275
|
-
line: findAcceptanceLine(text),
|
|
276
|
-
rule: "empty-section",
|
|
277
|
-
token: "Acceptance criteria",
|
|
278
|
-
hint: "fill in observable, verifiable signals",
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
smells.sort((a, b) => a.line - b.line);
|
|
283
|
-
return smells;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Build ranges to skip: fenced code blocks, HTML comments, inline backticks.
|
|
287
|
-
function collectSkippedRanges(text) {
|
|
288
|
-
const ranges = [];
|
|
289
|
-
// Fenced code blocks (``` ... ```)
|
|
290
|
-
for (const m of text.matchAll(/```[\s\S]*?```/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
291
|
-
// HTML comments (<!-- ... -->)
|
|
292
|
-
for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
293
|
-
// Inline backtick spans (`...`)
|
|
294
|
-
for (const m of text.matchAll(/`[^`\n]+`/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
295
|
-
return ranges;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function isInSkippedRange(offset, ranges) {
|
|
299
|
-
for (const [a, b] of ranges) if (offset >= a && offset < b) return true;
|
|
300
|
-
return false;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function hasEmptyAcceptanceCriteria(text) {
|
|
304
|
-
const headingRe = /^##\s+Acceptance criteria\s*$/m;
|
|
305
|
-
const m = headingRe.exec(text);
|
|
306
|
-
if (!m) return false;
|
|
307
|
-
const after = text.slice(m.index + m[0].length);
|
|
308
|
-
// Find the body between this heading and the next heading of equal level.
|
|
309
|
-
const nextHeadingMatch = after.match(/^##\s+/m);
|
|
310
|
-
const body = nextHeadingMatch ? after.slice(0, nextHeadingMatch.index) : after;
|
|
311
|
-
// Strip HTML comments before checking emptiness.
|
|
312
|
-
const stripped = body.replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
313
|
-
return stripped.length === 0;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function findAcceptanceLine(text) {
|
|
317
|
-
const lines = text.split("\n");
|
|
318
|
-
for (let i = 0; i < lines.length; i++) {
|
|
319
|
-
if (/^##\s+Acceptance criteria\s*$/.test(lines[i])) return i + 1;
|
|
320
|
-
}
|
|
321
|
-
return 1;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
121
|
export const help = `
|
|
325
122
|
Usage: doctrina clarify <path> [--lang pt|en]
|
|
326
123
|
doctrina clarify --all [--lang pt|en]
|
package/src/commands/context.js
CHANGED
|
@@ -14,7 +14,9 @@ import { GIT_STATE, historyState, changedFiles } from "../lib/git.js";
|
|
|
14
14
|
import { terms as queryTerms, relevance } from "../lib/lexicon.js";
|
|
15
15
|
import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
|
|
16
16
|
import * as idx from "../lib/index-json.js";
|
|
17
|
-
import { adrDigest, sliceSpec } from "../lib/context-slice.js";
|
|
17
|
+
import { adrDigest, sliceSpec, withoutComments } from "../lib/context-slice.js";
|
|
18
|
+
import { contextRouting, workspaceActions, workspaceFindings, workspaceHandoff, workspaceRootAt, workspaceSnapshot, workspaceStatus } from "../lib/workspace-view.js";
|
|
19
|
+
import { cdThen, qarg } from "../lib/shell.js";
|
|
18
20
|
|
|
19
21
|
// Materialise the AGENTS.md read order as a command: print the exact
|
|
20
22
|
// context pack for a task, in the order an agent should read it. This is
|
|
@@ -85,6 +87,21 @@ export async function run(positional, cmdFlags) {
|
|
|
85
87
|
const diffRef = flagString(cmdFlags, "diff");
|
|
86
88
|
const query = flagString(cmdFlags, "for");
|
|
87
89
|
const projectRoot = process.cwd();
|
|
90
|
+
// At a workspace root there is no one spec tree to read: the pack is per
|
|
91
|
+
// repository. Name which repositories the task is about, and the command
|
|
92
|
+
// that reads each one's pack (change 0230).
|
|
93
|
+
const wsRoot = workspaceRootAt(projectRoot);
|
|
94
|
+
if (wsRoot) {
|
|
95
|
+
const ranked = contextRouting(workspaceSnapshot(wsRoot), query ?? capability ?? "");
|
|
96
|
+
console.log(c.bold("The context pack is read inside a repository.") + c.gray(query ? ` Projects for "${query}":` : " Projects:"));
|
|
97
|
+
for (const r of ranked) {
|
|
98
|
+
const mark = r.state === "ready" ? "" : c.yellow(` (${r.state})`);
|
|
99
|
+
console.log(` ${r.name.padEnd(16)} ${query ? c.gray(`${r.score} shared term${r.score === 1 ? "" : "s"}${r.shared.length ? ` (${r.shared.slice(0, 5).join(", ")})` : ""}`) : ""}${mark}`);
|
|
100
|
+
console.log(` ${c.cyan(cdThen(r.path, `doctrina context ${capability ?? "<capability>"}${query ? ` --for ${qarg(query)}` : ""} --concat`))}`);
|
|
101
|
+
}
|
|
102
|
+
if (ranked.length === 0) console.log(c.gray(" none registered — `doctrina workspace add <path>`"));
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
88
105
|
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
89
106
|
throw notADoctrinaProject();
|
|
90
107
|
}
|
|
@@ -319,7 +336,17 @@ export async function run(positional, cmdFlags) {
|
|
|
319
336
|
for (const ch of open) {
|
|
320
337
|
if (ch.id === focusId) {
|
|
321
338
|
// The change being worked on: every file, whole, in CORE.
|
|
322
|
-
for (const f of ch.files)
|
|
339
|
+
for (const f of ch.files) {
|
|
340
|
+
const item = pushFile(relPath(projectRoot, f), `open change: ${ch.id}`);
|
|
341
|
+
// Read, not written: the templates' authoring comments stay out
|
|
342
|
+
// unless --full (change 0228).
|
|
343
|
+
const bare = item && !full ? withoutComments(read(f)) : null;
|
|
344
|
+
if (item && bare !== null) {
|
|
345
|
+
item.slice = bare;
|
|
346
|
+
item.tokens = estimateTokens(bare);
|
|
347
|
+
item.note = `${item.note} — comments left out`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
323
350
|
continue;
|
|
324
351
|
}
|
|
325
352
|
// A PARKED change is ONE entry in a queue, not three documents. It
|
|
@@ -749,6 +776,10 @@ active spec), open changes, accepted ADRs. Skills are
|
|
|
749
776
|
listed name + description + when-trigger only — they are on-demand by
|
|
750
777
|
design. The change archive is excluded.
|
|
751
778
|
|
|
779
|
+
At a workspace root, where no one spec tree exists, it ranks the
|
|
780
|
+
registered projects against --for and prints the command that reads
|
|
781
|
+
each one's pack inside its repository.
|
|
782
|
+
|
|
752
783
|
The pack is LEAN by default: AGENTS.md is not repeated (your agent
|
|
753
784
|
loads it every turn), an accepted ADR is a digest — its title and the
|
|
754
785
|
opening of its Decision; \`doctrina show NNNN\` reads the rest — and
|
package/src/commands/contract.js
CHANGED
|
@@ -278,6 +278,7 @@ the port map, the environment contract, and API/WS/event interfaces.
|
|
|
278
278
|
|
|
279
279
|
Subcommands:
|
|
280
280
|
new <name> Scaffold .doctrina/contracts/<name>.md and index it
|
|
281
|
+
(--force overwrites an existing contract)
|
|
281
282
|
list One line per contract: id, status, last updated
|
|
282
283
|
check [<name>] Verify the mechanically checkable parts.
|
|
283
284
|
|