doctrina-cli 0.18.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 +3 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +84 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +164 -17
- package/src/commands/clarify.js +1 -204
- package/src/commands/close.js +18 -1
- package/src/commands/context.js +81 -9
- package/src/commands/contract.js +1 -0
- package/src/commands/delivery.js +356 -0
- package/src/commands/doctor.js +21 -1
- package/src/commands/handoff.js +11 -2
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +28 -1
- package/src/commands/intake.js +60 -3
- package/src/commands/metrics.js +43 -3
- package/src/commands/next.js +41 -6
- package/src/commands/prime.js +125 -7
- 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 +33 -0
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +32 -4
- package/src/commands/work.js +126 -7
- package/src/commands/workspace.js +419 -0
- package/src/index.js +100 -66
- package/src/lib/actions.js +44 -4
- package/src/lib/adapters.js +143 -1
- package/src/lib/args.js +33 -1
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +770 -0
- package/src/lib/commands.js +52 -5
- package/src/lib/context-slice.js +149 -0
- package/src/lib/delivery.js +286 -0
- 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/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/shell.js +45 -0
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +43 -2
- package/src/lib/task-card.js +195 -0
- package/src/lib/views.js +52 -3
- package/src/lib/work-model.js +66 -2
- package/src/lib/workspace-agents.js +306 -0
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +160 -0
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +323 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +11 -1
- package/templates/adapters/claude/.claude/settings.json.template +18 -0
- package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
- 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/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +63 -0
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +44 -0
- package/templates/playbooks/work.md.template +1 -1
package/src/lib/commands.js
CHANGED
|
@@ -19,6 +19,8 @@ export const COMMAND_NAMES = [
|
|
|
19
19
|
"prime", "context", "show", "search", "status", "next", "why", "handoff",
|
|
20
20
|
// gates
|
|
21
21
|
"analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor",
|
|
22
|
+
// orchestrate
|
|
23
|
+
"workspace", "delivery",
|
|
22
24
|
// maintenance
|
|
23
25
|
"templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade", "ci",
|
|
24
26
|
];
|
|
@@ -48,6 +50,7 @@ export const OPERATIONS = [
|
|
|
48
50
|
["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
|
|
49
51
|
["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
|
|
50
52
|
["change delta", "Add a requirement/criterion/bump to a change's delta (ops written for you)"],
|
|
53
|
+
["change card", "One task as an executor receives it: the task, its why, the spec cut to it"],
|
|
51
54
|
["contract new", "Own the integration surface (ports, env, interfaces)"],
|
|
52
55
|
["contract list", "List contracts with status and last-updated date"],
|
|
53
56
|
["contract check", "Verify port collisions, env drift, referenced specs"],
|
|
@@ -82,6 +85,20 @@ export const OPERATIONS = [
|
|
|
82
85
|
["status", "One-glance project health dashboard"],
|
|
83
86
|
["why", "Explain provenance: a capability's chain, or an anchor's (SC1)"],
|
|
84
87
|
["handoff", "Markdown handoff note: open work, task state, resume command"],
|
|
88
|
+
["workspace init", "Create a multi-repository workspace (a private git repo of context)"],
|
|
89
|
+
["workspace add", "Register a repository: in-repo, or shadowed when it cannot carry .doctrina/"],
|
|
90
|
+
["workspace attach", "Re-create a shadow project's links in its repository"],
|
|
91
|
+
["workspace detach", "Remove a shadow project's links; the shadow stays"],
|
|
92
|
+
["workspace list", "Projects with mode, state and open changes"],
|
|
93
|
+
["workspace tool", "Declare a tool (MCP server) the work uses: role, read or write"],
|
|
94
|
+
["workspace guard", "Pre-call hook: allow, deny or ask an MCP call from the declared access"],
|
|
95
|
+
["delivery new", "Open a delivery across repositories; print the orchestration playbook"],
|
|
96
|
+
["delivery route", "Choose the projects a delivery touches, dependencies first"],
|
|
97
|
+
["delivery list", "Deliveries with status, ref and closed/total changes"],
|
|
98
|
+
["delivery status", "Per project: the changes carrying a delivery and their tasks"],
|
|
99
|
+
["delivery tick", "List or tick a delivery's Release claims"],
|
|
100
|
+
["delivery check", "What still keeps a delivery from closing (exit 1 while anything does)"],
|
|
101
|
+
["delivery close", "Close a delivery once every project's change is closed"],
|
|
85
102
|
["templates list", "List the templates shipped by the installed CLI"],
|
|
86
103
|
["templates check", "Compare the project against the recommended template shape"],
|
|
87
104
|
["templates update", "Additive fixer for check findings (preview; --write applies)"],
|
|
@@ -127,8 +144,8 @@ export const COMMAND_META = {
|
|
|
127
144
|
why: { moment: "Orient", when: "you need to justify or trace a capability's existence", purpose: "provenance: intent, proof, ADRs, and history" },
|
|
128
145
|
handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
|
|
129
146
|
|
|
130
|
-
triage: { moment: "Change", when: "
|
|
131
|
-
work: { moment: "Change", when: "a request
|
|
147
|
+
triage: { moment: "Change", when: "diagnosing an incident or a runtime symptom — `work` classifies every other request itself", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
|
|
148
|
+
work: { moment: "Change", when: "a request changes behaviour — BEFORE editing code; it classifies the lane itself", purpose: "scaffold a change and print the playbook to execute" },
|
|
132
149
|
spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
|
|
133
150
|
change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "drive a change by hand — check --verbose previews every delta" },
|
|
134
151
|
decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
|
|
@@ -146,6 +163,9 @@ export const COMMAND_META = {
|
|
|
146
163
|
close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
|
|
147
164
|
doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
|
|
148
165
|
|
|
166
|
+
delivery: { moment: "Orchestrate", when: "a story or screen must land in more than one repository", purpose: "plan, route, track and close one item of work across repositories" },
|
|
167
|
+
workspace: { moment: "Orchestrate", when: "the work spans several repositories, or a repository cannot carry .doctrina/", purpose: "register repositories, in-repo or shadowed, in one versioned workspace" },
|
|
168
|
+
|
|
149
169
|
templates: { moment: "Maintain", when: "customising a scaffold, to see which template file wins", purpose: "where each shipped template resolves from" },
|
|
150
170
|
upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
|
|
151
171
|
index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
|
|
@@ -158,7 +178,7 @@ export const COMMAND_META = {
|
|
|
158
178
|
};
|
|
159
179
|
|
|
160
180
|
// The order moments appear in the generated block.
|
|
161
|
-
export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
|
|
181
|
+
export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Orchestrate", "Maintain"];
|
|
162
182
|
|
|
163
183
|
// The Commands block of `doctrina --help`, generated from OPERATIONS so the
|
|
164
184
|
// printed surface can never omit an operation the catalog knows about.
|
|
@@ -192,7 +212,10 @@ export function surfaceHelp() {
|
|
|
192
212
|
const ops = live.filter(([op]) => op.split(" ")[0] === cmd);
|
|
193
213
|
const subs = ops.map(([op]) => op.split(" ")[1]).filter(Boolean);
|
|
194
214
|
const text = ops.length === 1 ? ops[0][1] : capitalise(COMMAND_META[cmd].purpose);
|
|
195
|
-
|
|
215
|
+
const op = subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd;
|
|
216
|
+
// A compact moment's long invocation stands alone rather than wrapping
|
|
217
|
+
// onto a second line (change 0220): `doctrina <cmd> --help` has the rest.
|
|
218
|
+
out.push(COMPACT_MOMENTS.has(moment) && op.length >= 20 ? ` ${op}` : line(op, text));
|
|
196
219
|
}
|
|
197
220
|
}
|
|
198
221
|
const old = OPERATIONS.filter(([op]) => DEPRECATED[op]);
|
|
@@ -317,7 +340,10 @@ export const SURFACE_LINE_BUDGET = 40;
|
|
|
317
340
|
// each its own line pushed the block past its budget. The budget is the
|
|
318
341
|
// forcing function — a surface that cannot describe itself in 40 lines is
|
|
319
342
|
// too large, and the answer is to cut commands (M8), not to raise it.
|
|
320
|
-
|
|
343
|
+
// "Orchestrate" is compact too (change 0220): an orchestrating session reads
|
|
344
|
+
// the workspace's own AGENTS.md, which carries the delivery playbook in full;
|
|
345
|
+
// inside one repository the commands only need to be discoverable.
|
|
346
|
+
const COMPACT_MOMENTS = new Set(["Orchestrate", "Maintain"]);
|
|
321
347
|
|
|
322
348
|
// The full managed section, heading included, between the markers.
|
|
323
349
|
//
|
|
@@ -517,6 +543,27 @@ export const AGENT_CHANGELOG = {
|
|
|
517
543
|
// Each entry lists only ITS OWN delta. The series carries the rest
|
|
518
544
|
// forward (see agentChangelogEntries), so a patch never has to restate
|
|
519
545
|
// the minor that introduced the commands, and never silently erases it.
|
|
546
|
+
"0.21.0": [
|
|
547
|
+
"At a workspace root, `next`, `handoff`, `status`, `validate`, `context`, `doctor` and `metrics` answer for the workspace; never `doctrina init` there or in a shadowed repository's clone — the commands name `doctrina workspace attach` instead.",
|
|
548
|
+
"A task card carries the delta, the delivery's story and every binding decision, global ones included: as an executor, stop and report when the story or the task contradicts one; a card on placeholder tasks means the planner writes them first (`doctrina work --resume <id>`).",
|
|
549
|
+
"Run the command lines the CLI prints as printed: paths and titles are quoted for the shell, and tracker text stays data.",
|
|
550
|
+
"Cursor: the core loop is skills in `.cursor/skills/doctrina-*/`; run `doctrina upgrade --write` to move an older install. With a tool declared the guard fails closed — a refused call means fix the install or ask the human.",
|
|
551
|
+
"Absorb: after writing specs by hand run `doctrina validate --fix`; a stored code inventory resumes with a bare `doctrina intake`, never `--from-code` again.",
|
|
552
|
+
],
|
|
553
|
+
"0.20.0": [
|
|
554
|
+
"Work across repositories is a delivery: from the workspace, `doctrina delivery new \"<title>\" --ref <id>`, then follow its playbook — route, one change per repository with `doctrina work \"<title>\" --delivery <id>`, close each, then `doctrina delivery close <id>`.",
|
|
555
|
+
"Write tasks as self-contained cards (indented `Files:`, `Requirement:`, `Verify:` lines) and hand an executor `doctrina change card <id>`; one writer per repository, and only the planner closes (ADR 0030).",
|
|
556
|
+
"An existing codebase with no specs: `doctrina intake --from-code`, then the absorb playbook — describe what the code does today and send what it cannot tell to the human as open questions.",
|
|
557
|
+
"A repository that may not carry `.doctrina/` is a workspace shadow: its `.doctrina/` is a link, hidden from its git — never commit it there; commit the workspace.",
|
|
558
|
+
"MCP tools declared in the workspace are read-only unless declared `write`: a refused or held call means ask the human, never route around it.",
|
|
559
|
+
],
|
|
560
|
+
"0.19.0": [
|
|
561
|
+
"A request that changes behaviour is a change opened BEFORE code, whether or not it names Doctrina; say its id in your first sentence to the human, and repeat the `Tell the human:` line `work` and `close` print.",
|
|
562
|
+
"`doctrina context <cap> --for \"<task>\" --concat` is now lean (no AGENTS.md, ADR digests, the spec sliced to the task) — read it before editing; `prime` is state, not the spec. `--full` gives the whole text.",
|
|
563
|
+
"Project skills are exported to `.agents/skills/` and `.claude/skills/`, where your agent lists skills — use them; edit `.doctrina/skills/`, never the exports.",
|
|
564
|
+
"`work` holds (exit 3) a request that repeats an open change or one archived in the last 14 days: resume it, or tell the human it is done and where; `--force` only for genuinely new work.",
|
|
565
|
+
"Hooks: `doctrina adapter add claude|cursor` merges session/prompt hooks (`prime --hook`, `prime --pulse`) into the agent's settings.",
|
|
566
|
+
],
|
|
520
567
|
"0.18.0": [
|
|
521
568
|
"`doctrina change delta <id> <capability> --requirement \"<section>: <text>\" --criterion \"<text>\" --bump minor` writes the ops lines for you and dry-runs them against the spec — stop hand-writing ops blocks.",
|
|
522
569
|
"`close` runs `verify --reuse`: running `doctrina verify` yourself right before `doctrina close` is no longer paid twice, and a batch close runs the suite once. Use `verify --affected` while working when checks declare `\"paths\"`.",
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { fold } from "./lexicon.js";
|
|
3
|
+
import { commentRanges, getSection } from "./doc-model.js";
|
|
4
|
+
|
|
5
|
+
// The lean read pack (change 0216).
|
|
6
|
+
//
|
|
7
|
+
// A field review: the agent ran `prime` and `work` and skipped `context`,
|
|
8
|
+
// although AGENTS.md calls it the read for ANY task. Measured on this
|
|
9
|
+
// repository, the reason is the price: `context gates --concat` was ~40k
|
|
10
|
+
// tokens — AGENTS.md again (the agent already carries it), the whole spec,
|
|
11
|
+
// and every accepted ADR in full — and `--for "<task>"` cut nothing, because
|
|
12
|
+
// retrieval only degraded the pack once the 50k budget overflowed. These
|
|
13
|
+
// cuts are the default now; `--full` restores the whole text.
|
|
14
|
+
|
|
15
|
+
// How much of an ADR's Decision a digest keeps.
|
|
16
|
+
const ADR_DIGEST_CHARS = 700;
|
|
17
|
+
// How many requirements and criteria a sliced spec keeps at most, each.
|
|
18
|
+
const SLICE_MAX_ITEMS = 25;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A change file as a reader needs it: without its HTML comments (change
|
|
22
|
+
* 0228). The proposal, tasks and delta templates carry their authoring
|
|
23
|
+
* instructions in comments — forty lines of "for ADDED… for MODIFIED…" in
|
|
24
|
+
* every delta — and the change in focus enters the pack whole, so a change
|
|
25
|
+
* with eight deltas spent ~2.6k tokens repeating the template to an agent
|
|
26
|
+
* that was reading, not writing. Null when the text has no comment.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} text
|
|
29
|
+
* @returns {string | null}
|
|
30
|
+
*/
|
|
31
|
+
export function withoutComments(text) {
|
|
32
|
+
const lf = String(text).replace(/\r\n/g, "\n");
|
|
33
|
+
const ranges = commentRanges(lf);
|
|
34
|
+
if (ranges.length === 0) return null;
|
|
35
|
+
let out = "";
|
|
36
|
+
let at = 0;
|
|
37
|
+
for (const [a, b] of ranges) {
|
|
38
|
+
out += lf.slice(at, a);
|
|
39
|
+
at = b;
|
|
40
|
+
}
|
|
41
|
+
out += lf.slice(at);
|
|
42
|
+
return out.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* An ADR as a reader deciding whether it binds the task needs it: the
|
|
47
|
+
* title and the opening of its Decision section. `doctrina show NNNN`
|
|
48
|
+
* reads the rest.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} text
|
|
51
|
+
* @param {string} title
|
|
52
|
+
* @param {string} id four-digit ADR number
|
|
53
|
+
*/
|
|
54
|
+
export function adrDigest(text, title, id) {
|
|
55
|
+
const decision = (getSection(text.replace(/\r\n/g, "\n"), "Decision") ?? "")
|
|
56
|
+
.split("\n").filter((l) => !l.trim().startsWith("<!--")).join("\n").trim();
|
|
57
|
+
const clipped = decision.length > ADR_DIGEST_CHARS
|
|
58
|
+
? `${decision.slice(0, ADR_DIGEST_CHARS).replace(/\s+\S*$/, "")} …`
|
|
59
|
+
: decision;
|
|
60
|
+
return [`# ${title}`, "", clipped || "(no Decision section)", "",
|
|
61
|
+
`(digest — full text: \`doctrina show ${id}\`, or \`context --full\`)`].join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A spec cut to what the task is about: its headers, its Purpose, and the
|
|
66
|
+
* requirements and criteria that match the query best — the rest named by
|
|
67
|
+
* count, never silently dropped. Deterministic: an item's score is how many
|
|
68
|
+
* distinct query terms it contains; the items scoring at least half the
|
|
69
|
+
* best score are kept, best first in document order, up to a cap per list.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} text
|
|
72
|
+
* @param {string[]} queryTerms already folded, stop words removed
|
|
73
|
+
* @param {string} rel the spec's path, for the footer
|
|
74
|
+
* @returns {{ text: string, kept: number, total: number } | null} null when nothing matched
|
|
75
|
+
*/
|
|
76
|
+
export function sliceSpec(text, queryTerms, rel) {
|
|
77
|
+
if (queryTerms.length === 0) return null;
|
|
78
|
+
// Read without the template's comments (second audit, change 0233). The
|
|
79
|
+
// EARS comment lists the five forms as bullets — "- Ubiquitous: The system
|
|
80
|
+
// shall <action>." — and they were counted as requirements: every pack of
|
|
81
|
+
// a scaffolded spec reported "… 5 not matching the task" for bullets no
|
|
82
|
+
// one wrote, and carried the forty-line header comment besides.
|
|
83
|
+
const lines = (withoutComments(text) ?? text.replace(/\r\n/g, "\n")).split("\n");
|
|
84
|
+
// Items: a bullet or numbered entry with its continuation lines.
|
|
85
|
+
/** @type {Array<{ start: number, end: number, kind: "req"|"crit", score: number }>} */
|
|
86
|
+
const items = [];
|
|
87
|
+
let section = "";
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const l = lines[i];
|
|
90
|
+
if (/^##\s/.test(l)) { section = l; continue; }
|
|
91
|
+
const req = /^##\s+Requirements/i.test(section) && /^\s*-\s+\S/.test(l);
|
|
92
|
+
const crit = /^##\s+Acceptance criteria/i.test(section) && /^\s*\d+\.\s+\S/.test(l);
|
|
93
|
+
if (!req && !crit) continue;
|
|
94
|
+
let end = i;
|
|
95
|
+
while (end + 1 < lines.length && /^\s+\S/.test(lines[end + 1]) && !/^\s*(-|\d+\.)\s/.test(lines[end + 1])) end += 1;
|
|
96
|
+
const body = fold(lines.slice(i, end + 1).join(" "));
|
|
97
|
+
const score = queryTerms.filter((t) => body.includes(t)).length;
|
|
98
|
+
items.push({ start: i, end, kind: req ? "req" : "crit", score });
|
|
99
|
+
i = end;
|
|
100
|
+
}
|
|
101
|
+
const best = Math.max(0, ...items.map((it) => it.score));
|
|
102
|
+
if (best === 0) return null;
|
|
103
|
+
const floor = Math.max(1, Math.ceil(best / 2));
|
|
104
|
+
const keep = new Set();
|
|
105
|
+
for (const kind of ["req", "crit"]) {
|
|
106
|
+
items.filter((it) => it.kind === kind && it.score >= floor)
|
|
107
|
+
.sort((a, b) => b.score - a.score || a.start - b.start)
|
|
108
|
+
.slice(0, SLICE_MAX_ITEMS)
|
|
109
|
+
.forEach((it) => keep.add(it.start));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const out = [];
|
|
113
|
+
section = "";
|
|
114
|
+
let omitted = 0;
|
|
115
|
+
const flushOmitted = () => {
|
|
116
|
+
if (omitted > 0) out.push(` … ${omitted} not matching the task`);
|
|
117
|
+
omitted = 0;
|
|
118
|
+
};
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
const l = lines[i];
|
|
121
|
+
if (/^##\s/.test(l)) {
|
|
122
|
+
flushOmitted();
|
|
123
|
+
section = l;
|
|
124
|
+
out.push(l);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const item = items.find((it) => it.start === i);
|
|
128
|
+
if (item) {
|
|
129
|
+
if (keep.has(i)) { flushOmitted(); out.push(...lines.slice(i, item.end + 1)); } else omitted += 1;
|
|
130
|
+
i = item.end;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// Headers, Purpose and subsection headings are kept; prose in the other
|
|
134
|
+
// sections (Out of scope, Maturity, …) is the first thing a task can do
|
|
135
|
+
// without.
|
|
136
|
+
const inLists = /^##\s+(Requirements|Acceptance criteria)/i.test(section);
|
|
137
|
+
const prose = section !== "" && !/^##\s+Purpose/i.test(section) && !inLists;
|
|
138
|
+
if (/^###\s/.test(l)) { flushOmitted(); out.push(l); continue; }
|
|
139
|
+
if (section === "" || /^##\s+Purpose/i.test(section)) { out.push(l); continue; }
|
|
140
|
+
if (inLists && l.trim() !== "") continue;
|
|
141
|
+
if (!prose) out.push(l);
|
|
142
|
+
}
|
|
143
|
+
flushOmitted();
|
|
144
|
+
const kept = keep.size;
|
|
145
|
+
const shownTerms = queryTerms.length > 12 ? `${queryTerms.slice(0, 12).join(" ")} …` : queryTerms.join(" ");
|
|
146
|
+
out.push("", `(sliced for "${shownTerms}" — ${kept} of ${items.length} requirements and criteria kept; ` +
|
|
147
|
+
`full text: ${rel}, or \`context --full\`)`);
|
|
148
|
+
return { text: out.join("\n").replace(/\n{3,}/g, "\n\n"), kept, total: items.length };
|
|
149
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { eolOf, isDir, isFile, read, withEol, write } from "./fs-ops.js";
|
|
5
|
+
import { listHeader } from "./scan.js";
|
|
6
|
+
import { checklistProgress, maskComments, parseChecklist, setHeader } from "./doc-model.js";
|
|
7
|
+
import { parseLedger } from "./ledger.js";
|
|
8
|
+
import { terms } from "./lexicon.js";
|
|
9
|
+
import { slugify, padNumber } from "./dates.js";
|
|
10
|
+
import { artifactRootOf, projectState } from "./workspace.js";
|
|
11
|
+
|
|
12
|
+
// A DELIVERY is one item of work that crosses repositories (ADR 0030): a
|
|
13
|
+
// story, a screen, a fix. It lives in the workspace's `deliveries/` and
|
|
14
|
+
// holds what no single repository owns — the story, the sources, the plan
|
|
15
|
+
// across projects, the release. Each project it touches gets ONE change,
|
|
16
|
+
// opened in that repository and carrying `- **Delivery:** <id>`, so the
|
|
17
|
+
// delivery's state is READ from the projects, never copied into it.
|
|
18
|
+
|
|
19
|
+
export const DELIVERY_FILE = "delivery.md";
|
|
20
|
+
export const DELIVERY_ID = /^\d{4}-[a-z0-9][a-z0-9-]*$/;
|
|
21
|
+
|
|
22
|
+
export function deliveriesDir(wsDir) {
|
|
23
|
+
return path.join(wsDir, "deliveries");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Every delivery on disk, oldest first. */
|
|
27
|
+
export function listDeliveries(wsDir) {
|
|
28
|
+
const dir = deliveriesDir(wsDir);
|
|
29
|
+
if (!isDir(dir)) return [];
|
|
30
|
+
return readdirSync(dir).filter((d) => DELIVERY_ID.test(d) && isFile(path.join(dir, d, DELIVERY_FILE))).sort()
|
|
31
|
+
.map((id) => readDelivery(wsDir, id));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function deliveryPath(wsDir, id) {
|
|
35
|
+
return path.join(deliveriesDir(wsDir), id, DELIVERY_FILE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A delivery by id — or by its number alone (`1`, `0001`). Null when absent. */
|
|
39
|
+
export function resolveDeliveryId(wsDir, ref) {
|
|
40
|
+
if (!ref) return null;
|
|
41
|
+
if (DELIVERY_ID.test(ref) && isFile(deliveryPath(wsDir, ref))) return ref;
|
|
42
|
+
if (/^\d+$/.test(ref)) {
|
|
43
|
+
const prefix = padNumber(Number(ref)) + "-";
|
|
44
|
+
return listDeliveries(wsDir).find((d) => d.id.startsWith(prefix))?.id ?? null;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readDelivery(wsDir, id) {
|
|
50
|
+
// CRLF on a Windows checkout: a `$` would leave the \r in the title.
|
|
51
|
+
const text = read(deliveryPath(wsDir, id)).replace(/\r\n/g, "\n");
|
|
52
|
+
const title = text.match(/^#\s+Delivery\s+\S+\s+[—-]\s+(.+)$/m)?.[1]?.trim() ?? id;
|
|
53
|
+
const projects = (listHeader(text, "Projects") ?? "").split(",").map((s) => s.trim()).filter((s) => s && s !== "—");
|
|
54
|
+
return {
|
|
55
|
+
id, title, text,
|
|
56
|
+
status: (listHeader(text, "Status") ?? "open").toLowerCase(),
|
|
57
|
+
date: listHeader(text, "Date") ?? null,
|
|
58
|
+
closed: listHeader(text, "Closed") ?? null,
|
|
59
|
+
ref: listHeader(text, "Ref") ?? "—",
|
|
60
|
+
projects,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function nextDeliveryId(wsDir, title) {
|
|
65
|
+
const numbers = listDeliveries(wsDir).map((d) => Number(d.id.slice(0, 4)));
|
|
66
|
+
const next = (numbers.length ? Math.max(...numbers) : 0) + 1;
|
|
67
|
+
return `${padNumber(next)}-${slugify(title).slice(0, 40).replace(/-+$/, "") || "delivery"}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Rewrite one list header of a delivery (adding it when absent). */
|
|
71
|
+
export function setDeliveryHeader(wsDir, id, name, value) {
|
|
72
|
+
const p = deliveryPath(wsDir, id);
|
|
73
|
+
let text = read(p);
|
|
74
|
+
const next = setHeader(text, name, value);
|
|
75
|
+
if (next !== null) text = next;
|
|
76
|
+
else text = text.replace(/^(- \*\*Projects:\*\*[^\r\n]*)/m, `$1\n- **${name}:** ${value}`);
|
|
77
|
+
write(p, withEol(text, eolOf(read(p))), { force: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Projects ranked against a delivery's text — how many of its terms each
|
|
82
|
+
* project's name, role, description and capability names share. A hint for
|
|
83
|
+
* the orchestrator, never a decision (ADR 0005): `delivery route` is where
|
|
84
|
+
* the projects are chosen.
|
|
85
|
+
*/
|
|
86
|
+
export function rankProjects(wsDir, ws, text) {
|
|
87
|
+
const want = new Set(terms(text));
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const p of ws.projects) {
|
|
90
|
+
const caps = capabilityNames(wsDir, p);
|
|
91
|
+
const words = new Set(terms([p.name, p.role, p.about, ...caps].join(" ")));
|
|
92
|
+
const shared = [...want].filter((t) => words.has(t));
|
|
93
|
+
out.push({ project: p, score: shared.length, shared, capabilities: caps });
|
|
94
|
+
}
|
|
95
|
+
return out.sort((a, b) => b.score - a.score || a.project.name.localeCompare(b.project.name));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function capabilityNames(wsDir, project) {
|
|
99
|
+
const specs = path.join(artifactRootOf(wsDir, project), ".doctrina", "specs");
|
|
100
|
+
if (!isDir(specs)) return [];
|
|
101
|
+
return readdirSync(specs).filter((d) => isFile(path.join(specs, d, "spec.md")));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The projects in dependency order: a project after every project it depends on. */
|
|
105
|
+
export function dependencyOrder(ws, names) {
|
|
106
|
+
const byName = new Map(ws.projects.map((p) => [p.name, p]));
|
|
107
|
+
const out = [];
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
const visit = (name, trail) => {
|
|
110
|
+
if (seen.has(name) || trail.has(name)) return;
|
|
111
|
+
trail.add(name);
|
|
112
|
+
for (const dep of byName.get(name)?.dependsOn ?? []) if (names.includes(dep)) visit(dep, trail);
|
|
113
|
+
seen.add(name);
|
|
114
|
+
out.push(name);
|
|
115
|
+
};
|
|
116
|
+
for (const n of names) visit(n, new Set());
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The changes each routed project carries for this delivery — open ones in
|
|
122
|
+
* `changes/`, closed ones in `changes/archive/` — read from the projects.
|
|
123
|
+
*/
|
|
124
|
+
export function deliveryChanges(wsDir, ws, delivery) {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const name of delivery.projects) {
|
|
127
|
+
const project = ws.projects.find((p) => p.name === name);
|
|
128
|
+
if (!project) {
|
|
129
|
+
out.push({ project: name, missing: "not registered in the workspace", changes: [] });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const changesDir = path.join(artifactRootOf(wsDir, project), ".doctrina", "changes");
|
|
133
|
+
const changes = [];
|
|
134
|
+
for (const { dir, archived } of [{ dir: changesDir, archived: false }, { dir: path.join(changesDir, "archive"), archived: true }]) {
|
|
135
|
+
if (!isDir(dir)) continue;
|
|
136
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
137
|
+
const proposal = path.join(dir, entry, "proposal.md");
|
|
138
|
+
if (entry === "archive" || !isFile(proposal)) continue;
|
|
139
|
+
const text = read(proposal);
|
|
140
|
+
if ((listHeader(text, "Delivery") ?? "").trim() !== delivery.id) continue;
|
|
141
|
+
const tasks = path.join(dir, entry, "tasks.md");
|
|
142
|
+
const progress = isFile(tasks) ? checklistProgress(read(tasks)) : { total: 0, done: 0, placeholders: 0 };
|
|
143
|
+
changes.push({
|
|
144
|
+
id: archived ? entry.replace(/^\d{4}-\d{2}-\d{2}-/, "") : entry,
|
|
145
|
+
archived,
|
|
146
|
+
status: archived ? "archived" : (listHeader(text, "Status") ?? "proposed").toLowerCase(),
|
|
147
|
+
tasksDone: progress.done, tasksTotal: progress.total,
|
|
148
|
+
// Scaffold boxes no one wrote (third audit, change 0234): counted
|
|
149
|
+
// as tasks, they read "3 tasks left — hand the next card" for a
|
|
150
|
+
// change whose card command answers that none is written.
|
|
151
|
+
placeholders: progress.placeholders ?? 0,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Abandoned for this delivery: the ledger keeps the delivery's id (change 0234).
|
|
156
|
+
const ledger = path.join(changesDir, "archive", "LEDGER.md");
|
|
157
|
+
const abandoned = isFile(ledger)
|
|
158
|
+
? parseLedger(read(ledger)).entries.filter((e) => e.kind === "abandoned" && new RegExp(`\\(delivery ${delivery.id}\\)\\s*$`).test(e.title))
|
|
159
|
+
.map((e) => ({ id: e.id, reason: e.title.replace(/^abandoned\s*(—\s*)?/i, "").replace(/\s*\(delivery [^)]*\)\s*$/, "") }))
|
|
160
|
+
: [];
|
|
161
|
+
out.push({ project: name, path: project.path, absent: !isDir(project.path), changes, abandoned });
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A section's own text: comments and blank lines removed. Empty string when absent. */
|
|
167
|
+
export function sectionText(text, heading) {
|
|
168
|
+
const lines = String(text).replace(/\r\n/g, "\n").replace(/<!--[\s\S]*?-->/g, "").split("\n");
|
|
169
|
+
const at = lines.findIndex((l) => l.trim() === `## ${heading}`);
|
|
170
|
+
if (at < 0) return "";
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const l of lines.slice(at + 1)) {
|
|
173
|
+
if (/^## /.test(l)) break;
|
|
174
|
+
if (l.trim()) out.push(l.trim());
|
|
175
|
+
}
|
|
176
|
+
return out.join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Days between two YYYY-MM-DD dates, or null. */
|
|
180
|
+
export function daysBetween(from, to) {
|
|
181
|
+
const a = Date.parse(String(from ?? "") + "T00:00:00Z");
|
|
182
|
+
const b = Date.parse(String(to ?? "") + "T00:00:00Z");
|
|
183
|
+
return Number.isNaN(a) || Number.isNaN(b) ? null : Math.round((b - a) / 86400000);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The record a delivery keeps of its own work (change 0224): the story it
|
|
188
|
+
* came from, every Release claim ticked, and at least one line of what was
|
|
189
|
+
* watched after the release. The changes prove the code; these prove the
|
|
190
|
+
* delivery reached its users and was looked at once it had.
|
|
191
|
+
*/
|
|
192
|
+
export function recordBlockers(delivery) {
|
|
193
|
+
const out = [];
|
|
194
|
+
if (sectionText(delivery.text, "Story") === "") out.push("## Story is empty — copy the tracker item's story and acceptance criteria, verbatim");
|
|
195
|
+
// The same boxes `delivery tick` lists and ticks — one reader, so a box
|
|
196
|
+
// the check holds you to is always one the CLI can tick.
|
|
197
|
+
for (const box of releaseBoxes(delivery.text).filter((b) => !b.checked)) out.push(`## Release: unticked — ${box.text}`);
|
|
198
|
+
if (sectionText(delivery.text, "Observability") === "") out.push("## Observability is empty — record what was watched after the release (monitor, dashboard or query, the window, what it showed)");
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Everything that keeps a delivery from closing. Each routed project must
|
|
204
|
+
* carry at least one change for it, and every such change must be archived;
|
|
205
|
+
* the delivery's own record must be written (recordBlockers).
|
|
206
|
+
*/
|
|
207
|
+
export function deliveryBlockers(wsDir, ws, delivery) {
|
|
208
|
+
const blockers = [];
|
|
209
|
+
if (delivery.projects.length === 0) blockers.push("no project routed — `doctrina delivery route <id> <a,b>`");
|
|
210
|
+
for (const row of deliveryChanges(wsDir, ws, delivery)) {
|
|
211
|
+
if (row.missing) blockers.push(`${row.project}: ${row.missing}`);
|
|
212
|
+
else if (row.absent) blockers.push(`${row.project}: the repository is not at ${row.path} — \`doctrina workspace attach ${row.project} --path <dir>\``);
|
|
213
|
+
else if (row.changes.length === 0 && row.abandoned?.length) blockers.push(`${row.project}: its change ${row.abandoned.map((a) => a.id).join(", ")} was abandoned — re-route the delivery without ${row.project}, or open a new change there with \`doctrina work "<title>" --delivery ${delivery.id}\``);
|
|
214
|
+
else if (row.changes.length === 0) blockers.push(`${row.project}: no change carries this delivery — open one there with \`doctrina work "<title>" --delivery ${delivery.id}\``);
|
|
215
|
+
else for (const ch of row.changes.filter((x) => !x.archived)) {
|
|
216
|
+
blockers.push(`${row.project}: change ${ch.id} is ${ch.status} (${ch.tasksDone}/${ch.tasksTotal} tasks) — close it there with \`doctrina close ${ch.id}\``);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
blockers.push(...recordBlockers(delivery));
|
|
220
|
+
return blockers;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The `## Release` boxes of a delivery, in order, with their line index in
|
|
225
|
+
* the LF-normalised text (change 0226). The other sections are prose the
|
|
226
|
+
* planner writes; these are claims, and a claim is ticked through the CLI.
|
|
227
|
+
*/
|
|
228
|
+
export function releaseBoxes(text) {
|
|
229
|
+
// One owner for the box grammar (ADR 0021): the document model reads the
|
|
230
|
+
// boxes; a box inside a comment is the template's example, not a claim.
|
|
231
|
+
const lf = String(text).replace(/\r\n/g, "\n");
|
|
232
|
+
return parseChecklist(maskComments(lf), { section: "Release" })
|
|
233
|
+
.filter((b) => b.section === "Release")
|
|
234
|
+
.map((b, i) => ({ ordinal: i + 1, line: b.line, checked: b.checked, text: b.text }));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The text with the given Release boxes ticked (all when `ordinals` is "all"). */
|
|
238
|
+
export function tickRelease(text, ordinals) {
|
|
239
|
+
const lines = String(text).replace(/\r\n/g, "\n").split("\n");
|
|
240
|
+
for (const box of releaseBoxes(text)) {
|
|
241
|
+
if (ordinals === "all" || ordinals.includes(box.ordinal)) {
|
|
242
|
+
lines[box.line] = lines[box.line].replace(/\[\s\]/, "[x]");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return lines.join("\n");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Projects a delivery cannot be routed to yet (change 0226): one whose
|
|
250
|
+
* repository is missing, or one still `pending` — no spec to write a delta
|
|
251
|
+
* against, so the change opened there would have nothing to change.
|
|
252
|
+
* @returns {{ name: string, state: string, reason: string, path: string }[]}
|
|
253
|
+
*/
|
|
254
|
+
export function unroutable(wsDir, ws, names) {
|
|
255
|
+
const out = [];
|
|
256
|
+
for (const name of names) {
|
|
257
|
+
const project = ws.projects.find((p) => p.name === name);
|
|
258
|
+
if (!project) continue;
|
|
259
|
+
const st = projectState(wsDir, project);
|
|
260
|
+
if (st.state !== "ready") out.push({ name, state: st.state, reason: st.reason, path: project.path });
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Open changes in a routed repository that do NOT belong to this delivery
|
|
267
|
+
* (change 0226). Each is a second writer the planner must sequence: one
|
|
268
|
+
* writer per repository at a time holds across deliveries too.
|
|
269
|
+
* @returns {{ project: string, id: string, delivery: string|null }[]}
|
|
270
|
+
*/
|
|
271
|
+
export function otherWriters(wsDir, ws, delivery) {
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const name of delivery.projects) {
|
|
274
|
+
const project = ws.projects.find((p) => p.name === name);
|
|
275
|
+
if (!project) continue;
|
|
276
|
+
const changesDir = path.join(artifactRootOf(wsDir, project), ".doctrina", "changes");
|
|
277
|
+
if (!isDir(changesDir)) continue;
|
|
278
|
+
for (const entry of readdirSync(changesDir).sort()) {
|
|
279
|
+
const proposal = path.join(changesDir, entry, "proposal.md");
|
|
280
|
+
if (entry === "archive" || !isFile(proposal)) continue;
|
|
281
|
+
const of = (listHeader(read(proposal), "Delivery") ?? "").trim() || null;
|
|
282
|
+
if (of !== delivery.id) out.push({ project: name, id: entry, delivery: of });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
package/src/lib/docs-impact.js
CHANGED
|
@@ -257,10 +257,16 @@ export function documentedSurfaceSignals(changeDir, projectRoot = null) {
|
|
|
257
257
|
signals.push(`endpoints: ${[...routes].sort().slice(0, 6).join(", ")}`);
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
// An UPPER_SNAKE name is an environment variable when something says so
|
|
261
|
+
// (change 0239): the shape alone also matches an error code
|
|
262
|
+
// (`CONTA_BLOQUEADA`) or a constant (`MAX_FALHAS`), and every change that
|
|
263
|
+
// merely cited one was asked to document an "environment" it never touched.
|
|
264
|
+
const exampleNames = projectRoot === null ? new Set() : envExampleNames(projectRoot);
|
|
260
265
|
const envVars = new Set();
|
|
261
266
|
for (const m of code.matchAll(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g)) {
|
|
262
267
|
// Not twice: a declared variable is already reported by name above.
|
|
263
|
-
if (
|
|
268
|
+
if (touchedDeclared.includes(m[1])) continue;
|
|
269
|
+
if (looksLikeEnv(m[1], text) || exampleNames.has(m[1])) envVars.add(m[1]);
|
|
264
270
|
}
|
|
265
271
|
if (envVars.size > 0) {
|
|
266
272
|
signals.push(`environment: ${[...envVars].sort().slice(0, 8).join(", ")}`);
|
|
@@ -408,3 +414,32 @@ export function checkDocsImpact(projectRoot, changeDir) {
|
|
|
408
414
|
reason: "alters a documented surface but no docs/ or README change accompanies it",
|
|
409
415
|
};
|
|
410
416
|
}
|
|
417
|
+
|
|
418
|
+
// The suffixes configuration read from the environment carries.
|
|
419
|
+
const ENV_SUFFIX = /_(URL|URI|DSN|HOST|PORT|ENDPOINT|KEY|TOKEN|SECRET|PASSWORD|PASS|USER|USERNAME|PATH|DIR|FILE|HOME|ENV|MODE|LEVEL|TIMEOUT|REGION|BUCKET|ENABLED|DISABLED|DEBUG|FLAG|PROFILE)$/;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Whether the change text marks a name as an environment variable: read the
|
|
423
|
+
* way code reads one (`process.env.X`, `$X`, `${X}`, `getenv("X")`,
|
|
424
|
+
* `os.environ["X"]`, `X=`), named on a line that speaks of the environment,
|
|
425
|
+
* or carrying a configuration suffix (`_URL`, `_TOKEN`, `_PORT`…).
|
|
426
|
+
* @param {string} name
|
|
427
|
+
* @param {string} text the change's authored text
|
|
428
|
+
*/
|
|
429
|
+
export function looksLikeEnv(name, text) {
|
|
430
|
+
if (ENV_SUFFIX.test(name)) return true;
|
|
431
|
+
const n = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
432
|
+
if (new RegExp(`(process\\.env\\.|process\\.env\\[["'\`]|\\benv\\.|\\$\\{?|getenv\\(["']|environ(?:\\.get\\(|\\[)["']|ENV\\[["'])${n}\\b|\\b${n}=`).test(text)) return true;
|
|
433
|
+
return text.split("\n").some((line) => line.includes(name) && /\b(env|environment|environment variable|variável de ambiente|ambiente|secret|segredo|\.env)\b/i.test(line));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The variable names a project's env example files declare. */
|
|
437
|
+
function envExampleNames(projectRoot) {
|
|
438
|
+
const out = new Set();
|
|
439
|
+
for (const f of [".env.example", ".env.sample", ".env.template", ".env.dist", "example.env"]) {
|
|
440
|
+
const file = path.join(projectRoot, f);
|
|
441
|
+
if (!isFile(file)) continue;
|
|
442
|
+
for (const m of read(file).matchAll(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/gm)) out.add(m[1]);
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
package/src/lib/fs-ops.js
CHANGED
|
@@ -19,6 +19,29 @@ import path from "node:path";
|
|
|
19
19
|
// A file that had one loses it when a command rewrites it. That is a
|
|
20
20
|
// normalisation, and the right one — nothing in this tree is served by
|
|
21
21
|
// keeping it.
|
|
22
|
+
/**
|
|
23
|
+
* The line ending a file already uses: CRLF when it carries one, LF otherwise.
|
|
24
|
+
* A command that rewrites a file a person owns keeps the file's ending — a
|
|
25
|
+
* Windows checkout with `core.autocrlf` stores CRLF, and turning it into LF
|
|
26
|
+
* on a tick makes every line of the file show as changed in the diff.
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @returns {"\r\n"|"\n"}
|
|
29
|
+
*/
|
|
30
|
+
export function eolOf(text) {
|
|
31
|
+
return /\r\n/.test(String(text)) ? "\r\n" : "\n";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `text` with every line ending made `eol` — what a rewrite hands to `write`
|
|
36
|
+
* after editing in LF, so an inserted line never mixes endings.
|
|
37
|
+
* @param {string} text
|
|
38
|
+
* @param {"\r\n"|"\n"} eol
|
|
39
|
+
*/
|
|
40
|
+
export function withEol(text, eol) {
|
|
41
|
+
const lf = String(text).replace(/\r\n/g, "\n");
|
|
42
|
+
return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
export function read(p) {
|
|
23
46
|
const text = readFileSync(p, "utf8");
|
|
24
47
|
return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
|
package/src/lib/intake-model.js
CHANGED
|
@@ -15,7 +15,7 @@ import { stateWord } from "./spec-ops.js";
|
|
|
15
15
|
// Clarification gate (review Topic A). A description too thin to spec from is
|
|
16
16
|
// the moment to ask the user, not to let the agent invent requirements. Prints
|
|
17
17
|
// the specific gaps before the bootstrap playbook; advisory, never blocking —
|
|
18
|
-
// the intake is still captured verbatim (the playbook step
|
|
18
|
+
// the intake is still captured verbatim (the playbook step 8 resolves it).
|
|
19
19
|
/**
|
|
20
20
|
* Does this argument read as a FILE PATH, or as the description itself?
|
|
21
21
|
*
|