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/src/lib/commands.js
CHANGED
|
@@ -96,6 +96,7 @@ export const OPERATIONS = [
|
|
|
96
96
|
["delivery route", "Choose the projects a delivery touches, dependencies first"],
|
|
97
97
|
["delivery list", "Deliveries with status, ref and closed/total changes"],
|
|
98
98
|
["delivery status", "Per project: the changes carrying a delivery and their tasks"],
|
|
99
|
+
["delivery tick", "List or tick a delivery's Release claims"],
|
|
99
100
|
["delivery check", "What still keeps a delivery from closing (exit 1 while anything does)"],
|
|
100
101
|
["delivery close", "Close a delivery once every project's change is closed"],
|
|
101
102
|
["templates list", "List the templates shipped by the installed CLI"],
|
|
@@ -542,6 +543,13 @@ export const AGENT_CHANGELOG = {
|
|
|
542
543
|
// Each entry lists only ITS OWN delta. The series carries the rest
|
|
543
544
|
// forward (see agentChangelogEntries), so a patch never has to restate
|
|
544
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
|
+
],
|
|
545
553
|
"0.20.0": [
|
|
546
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>`.",
|
|
547
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).",
|
package/src/lib/context-slice.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { fold } from "./lexicon.js";
|
|
3
|
-
import { getSection } from "./doc-model.js";
|
|
3
|
+
import { commentRanges, getSection } from "./doc-model.js";
|
|
4
4
|
|
|
5
5
|
// The lean read pack (change 0216).
|
|
6
6
|
//
|
|
@@ -17,6 +17,31 @@ const ADR_DIGEST_CHARS = 700;
|
|
|
17
17
|
// How many requirements and criteria a sliced spec keeps at most, each.
|
|
18
18
|
const SLICE_MAX_ITEMS = 25;
|
|
19
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
|
+
|
|
20
45
|
/**
|
|
21
46
|
* An ADR as a reader deciding whether it binds the task needs it: the
|
|
22
47
|
* title and the opening of its Decision section. `doctrina show NNNN`
|
|
@@ -50,7 +75,12 @@ export function adrDigest(text, title, id) {
|
|
|
50
75
|
*/
|
|
51
76
|
export function sliceSpec(text, queryTerms, rel) {
|
|
52
77
|
if (queryTerms.length === 0) return null;
|
|
53
|
-
|
|
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");
|
|
54
84
|
// Items: a bullet or numbered entry with its continuation lines.
|
|
55
85
|
/** @type {Array<{ start: number, end: number, kind: "req"|"crit", score: number }>} */
|
|
56
86
|
const items = [];
|
|
@@ -112,7 +142,8 @@ export function sliceSpec(text, queryTerms, rel) {
|
|
|
112
142
|
}
|
|
113
143
|
flushOmitted();
|
|
114
144
|
const kept = keep.size;
|
|
115
|
-
|
|
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; ` +
|
|
116
147
|
`full text: ${rel}, or \`context --full\`)`);
|
|
117
148
|
return { text: out.join("\n").replace(/\n{3,}/g, "\n\n"), kept, total: items.length };
|
|
118
149
|
}
|
package/src/lib/delivery.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { readdirSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { isDir, isFile, read, write } from "./fs-ops.js";
|
|
4
|
+
import { eolOf, isDir, isFile, read, withEol, write } from "./fs-ops.js";
|
|
5
5
|
import { listHeader } from "./scan.js";
|
|
6
|
-
import { checklistProgress, setHeader } from "./doc-model.js";
|
|
6
|
+
import { checklistProgress, maskComments, parseChecklist, setHeader } from "./doc-model.js";
|
|
7
|
+
import { parseLedger } from "./ledger.js";
|
|
7
8
|
import { terms } from "./lexicon.js";
|
|
8
9
|
import { slugify, padNumber } from "./dates.js";
|
|
9
|
-
import { artifactRootOf } from "./workspace.js";
|
|
10
|
+
import { artifactRootOf, projectState } from "./workspace.js";
|
|
10
11
|
|
|
11
12
|
// A DELIVERY is one item of work that crosses repositories (ADR 0030): a
|
|
12
13
|
// story, a screen, a fix. It lives in the workspace's `deliveries/` and
|
|
@@ -72,8 +73,8 @@ export function setDeliveryHeader(wsDir, id, name, value) {
|
|
|
72
73
|
let text = read(p);
|
|
73
74
|
const next = setHeader(text, name, value);
|
|
74
75
|
if (next !== null) text = next;
|
|
75
|
-
else text = text.replace(/^(- \*\*Projects
|
|
76
|
-
write(p, text, { force: true });
|
|
76
|
+
else text = text.replace(/^(- \*\*Projects:\*\*[^\r\n]*)/m, `$1\n- **${name}:** ${value}`);
|
|
77
|
+
write(p, withEol(text, eolOf(read(p))), { force: true });
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/**
|
|
@@ -138,16 +139,26 @@ export function deliveryChanges(wsDir, ws, delivery) {
|
|
|
138
139
|
const text = read(proposal);
|
|
139
140
|
if ((listHeader(text, "Delivery") ?? "").trim() !== delivery.id) continue;
|
|
140
141
|
const tasks = path.join(dir, entry, "tasks.md");
|
|
141
|
-
const progress = isFile(tasks) ? checklistProgress(read(tasks)) : { total: 0, done: 0 };
|
|
142
|
+
const progress = isFile(tasks) ? checklistProgress(read(tasks)) : { total: 0, done: 0, placeholders: 0 };
|
|
142
143
|
changes.push({
|
|
143
144
|
id: archived ? entry.replace(/^\d{4}-\d{2}-\d{2}-/, "") : entry,
|
|
144
145
|
archived,
|
|
145
146
|
status: archived ? "archived" : (listHeader(text, "Status") ?? "proposed").toLowerCase(),
|
|
146
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,
|
|
147
152
|
});
|
|
148
153
|
}
|
|
149
154
|
}
|
|
150
|
-
|
|
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 });
|
|
151
162
|
}
|
|
152
163
|
return out;
|
|
153
164
|
}
|
|
@@ -181,9 +192,9 @@ export function daysBetween(from, to) {
|
|
|
181
192
|
export function recordBlockers(delivery) {
|
|
182
193
|
const out = [];
|
|
183
194
|
if (sectionText(delivery.text, "Story") === "") out.push("## Story is empty — copy the tracker item's story and acceptance criteria, verbatim");
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
for (const box of
|
|
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}`);
|
|
187
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)");
|
|
188
199
|
return out;
|
|
189
200
|
}
|
|
@@ -198,6 +209,8 @@ export function deliveryBlockers(wsDir, ws, delivery) {
|
|
|
198
209
|
if (delivery.projects.length === 0) blockers.push("no project routed — `doctrina delivery route <id> <a,b>`");
|
|
199
210
|
for (const row of deliveryChanges(wsDir, ws, delivery)) {
|
|
200
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}\``);
|
|
201
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}\``);
|
|
202
215
|
else for (const ch of row.changes.filter((x) => !x.archived)) {
|
|
203
216
|
blockers.push(`${row.project}: change ${ch.id} is ${ch.status} (${ch.tasksDone}/${ch.tasksTotal} tasks) — close it there with \`doctrina close ${ch.id}\``);
|
|
@@ -206,3 +219,68 @@ export function deliveryBlockers(wsDir, ws, delivery) {
|
|
|
206
219
|
blockers.push(...recordBlockers(delivery));
|
|
207
220
|
return blockers;
|
|
208
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
|
*
|
package/src/lib/json-out.js
CHANGED
|
@@ -71,12 +71,14 @@ export function deferJson() {
|
|
|
71
71
|
* Write the held payload with the exit code the command actually returned,
|
|
72
72
|
* and leave deferred mode. A no-op when the command emitted nothing.
|
|
73
73
|
*/
|
|
74
|
+
/** Write the held payload; false when the command held none. */
|
|
74
75
|
export function flushJson(code) {
|
|
75
76
|
deferring = false;
|
|
76
|
-
if (!held) return;
|
|
77
|
+
if (!held) return false;
|
|
77
78
|
const { command, data } = held;
|
|
78
79
|
held = null;
|
|
79
80
|
writeEnvelope(command, data, { ok: code === EXIT.OK, exitCode: code });
|
|
81
|
+
return true;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
// Print a structured payload. `command` is the invocation, `data` whatever
|
|
@@ -165,6 +167,12 @@ export async function captureOutput(fn) {
|
|
|
165
167
|
try {
|
|
166
168
|
const code = await fn();
|
|
167
169
|
return { code: code ?? EXIT.OK, stdout, stderr };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
// A thrown precondition still answers inside the envelope (change 0234):
|
|
172
|
+
// it escaped as English on stderr, with no JSON on stdout at all.
|
|
173
|
+
stderr.push(`error: ${err?.message ?? err}`);
|
|
174
|
+
if (err?.remedy) stderr.push(`hint: run ${err.remedy} first, then retry`);
|
|
175
|
+
return { code: err?.exitCode ?? EXIT.GATE, stdout, stderr, error: err };
|
|
168
176
|
} finally {
|
|
169
177
|
console.log = origLog;
|
|
170
178
|
console.error = origError;
|
package/src/lib/shell.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
// THE COMMANDS THE CLI PRINTS ARE RUN AS PRINTED (fourth audit, change 0235).
|
|
5
|
+
//
|
|
6
|
+
// `next`, `delivery status`, the playbooks and the hub hand an agent a line
|
|
7
|
+
// to paste into its shell: `cd <repo> && doctrina work "<title>" --delivery
|
|
8
|
+
// <id>`. The title comes from the tracker — text the delivery playbook calls
|
|
9
|
+
// data, never an instruction — and it was interpolated between double quotes
|
|
10
|
+
// as it came: a story titled `Fix $(curl … | sh)` became a command the agent
|
|
11
|
+
// ran, and a repository under `~/my api/` became a `cd` into `~/my`.
|
|
12
|
+
|
|
13
|
+
// Inside double quotes a POSIX shell still expands these.
|
|
14
|
+
const DOUBLE_QUOTE_UNSAFE = /["`$\\!]/;
|
|
15
|
+
const BARE_PATH = /^[A-Za-z0-9_./:\\~+@%=,-]+$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One shell argument, quoted so the shell reads it back verbatim. Plain text
|
|
19
|
+
* keeps the double quotes every surface has always printed; text a double
|
|
20
|
+
* quote would still expand is single-quoted — POSIX `'\''` for an embedded
|
|
21
|
+
* quote, PowerShell's `''` on Windows.
|
|
22
|
+
* @param {string} value
|
|
23
|
+
* @param {NodeJS.Platform} [platform]
|
|
24
|
+
*/
|
|
25
|
+
export function qarg(value, platform = process.platform) {
|
|
26
|
+
const s = String(value ?? "");
|
|
27
|
+
if (!DOUBLE_QUOTE_UNSAFE.test(s)) return `"${s}"`;
|
|
28
|
+
const inner = platform === "win32" ? s.replace(/'/g, "''") : s.replace(/'/g, "'\\''");
|
|
29
|
+
return `'${inner}'`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A path as a shell argument: bare when nothing in it needs quoting.
|
|
34
|
+
* @param {string} dir
|
|
35
|
+
* @param {NodeJS.Platform} [platform]
|
|
36
|
+
*/
|
|
37
|
+
export function qpath(dir, platform = process.platform) {
|
|
38
|
+
const s = String(dir ?? "");
|
|
39
|
+
return BARE_PATH.test(s) ? s : qarg(s, platform);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `cd <dir> && <command>`, with the directory quoted when it must be. */
|
|
43
|
+
export function cdThen(dir, command) {
|
|
44
|
+
return `cd ${qpath(dir)} && ${command}`;
|
|
45
|
+
}
|
package/src/lib/snapshot.js
CHANGED
|
@@ -155,7 +155,11 @@ export function openChanges(projectRoot) {
|
|
|
155
155
|
// The capabilities its deltas target — what `prime` turns into the
|
|
156
156
|
// exact read command for the change (change 0217).
|
|
157
157
|
const capabilities = deltaCapabilities(path.join(changesDir, id));
|
|
158
|
-
|
|
158
|
+
// The delivery a change belongs to (change 0226): a session opened in the
|
|
159
|
+
// repository — an executor's, or a human's — sees it is one part of a
|
|
160
|
+
// larger item of work, not a change on its own.
|
|
161
|
+
const delivery = proposal ? (listHeader(proposal, "Delivery") ?? null) : null;
|
|
162
|
+
out.push({ id, title, status, tasksDone, tasksTotal, unchecked, date, ageDays: ageInDays(date), capabilities, delivery });
|
|
159
163
|
}
|
|
160
164
|
return out;
|
|
161
165
|
}
|
package/src/lib/task-card.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { isFile, read } from "./fs-ops.js";
|
|
4
|
-
import { parseChangeTitle, parseChecklist } from "./doc-model.js";
|
|
5
|
-
import { listHeader, deltaCapabilities } from "./scan.js";
|
|
6
|
-
import {
|
|
3
|
+
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
4
|
+
import { getSection, parseChangeTitle, parseChecklist } from "./doc-model.js";
|
|
5
|
+
import { listHeader, deltaCapabilities, parseAdrScope } from "./scan.js";
|
|
6
|
+
import { extractOps } from "./spec-ops.js";
|
|
7
|
+
import { findWorkspace } from "./workspace.js";
|
|
8
|
+
import { deliveryPath, resolveDeliveryId, sectionText } from "./delivery.js";
|
|
9
|
+
import { sliceSpec, withoutComments } from "./context-slice.js";
|
|
7
10
|
import { terms } from "./lexicon.js";
|
|
8
11
|
|
|
9
12
|
// A TASK CARD (change 0222, ADR 0030): one task of a change, carrying what
|
|
@@ -64,6 +67,10 @@ export function taskCard(projectRoot, changeId, ordinal = null) {
|
|
|
64
67
|
let box;
|
|
65
68
|
if (ordinal === null) {
|
|
66
69
|
box = boxes.find((b) => !b.checked && !b.placeholder);
|
|
70
|
+
// Only scaffold boxes left is not "every task is ticked" (third audit,
|
|
71
|
+
// change 0234): the planner was told to review and close a change
|
|
72
|
+
// whose tasks no one had written.
|
|
73
|
+
if (!box && boxes.some((b) => !b.checked && b.placeholder)) return { card: null, reason: "tasks.md holds only the scaffold's placeholder boxes — write the tasks as cards first" };
|
|
67
74
|
if (!box) return { card: null, reason: "every task is ticked — the change is ready for review and close" };
|
|
68
75
|
} else {
|
|
69
76
|
box = boxes.find((b) => b.ordinal === ordinal);
|
|
@@ -73,14 +80,40 @@ export function taskCard(projectRoot, changeId, ordinal = null) {
|
|
|
73
80
|
// CRLF on a Windows checkout: the Why is read paragraph by paragraph.
|
|
74
81
|
const proposal = isFile(path.join(changeDir, "proposal.md")) ? read(path.join(changeDir, "proposal.md")).replace(/\r\n/g, "\n") : "";
|
|
75
82
|
const fields = cardFields(box.details);
|
|
76
|
-
|
|
83
|
+
// What this change DOES to each spec (change 0231). The slice below is
|
|
84
|
+
// the spec as it stands — the current truth, before apply — so the
|
|
85
|
+
// requirement the executor is there to implement lived only in the delta,
|
|
86
|
+
// and reached the card only if the planner copied it into a line by hand.
|
|
87
|
+
const changes = [];
|
|
88
|
+
for (const cap of deltaCapabilities(changeDir)) {
|
|
89
|
+
const delta = path.join(changeDir, "specs", cap, "delta.md");
|
|
90
|
+
if (!isFile(delta)) continue;
|
|
91
|
+
for (const op of extractOps(read(delta))) {
|
|
92
|
+
if (op.verb === "append-requirement") changes.push({ capability: cap, line: `adds ${/^[aeiou]/.test(op.section) ? "an" : "a"} ${op.section} requirement: ${op.value}` });
|
|
93
|
+
else if (op.verb === "replace-requirement") changes.push({ capability: cap, line: `replaces ${op.section} requirement ${op.n} with: ${op.value}` });
|
|
94
|
+
else if (op.verb === "remove-requirement") changes.push({ capability: cap, line: `removes ${op.section} requirement ${op.n}` });
|
|
95
|
+
else if (op.verb === "append-criterion") changes.push({ capability: cap, line: `adds the criterion: ${op.value}` });
|
|
96
|
+
else if (op.verb === "replace-criterion") changes.push({ capability: cap, line: `replaces criterion ${op.n} with: ${op.value}` });
|
|
97
|
+
else if (op.verb === "remove-criterion") changes.push({ capability: cap, line: `removes criterion ${op.n}` });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// The task's words AND what the change writes into the spec (second
|
|
101
|
+
// audit, change 0233): a card for "lock the account on the third wrong
|
|
102
|
+
// password" cut the spec's only /login requirement, because the task never
|
|
103
|
+
// said "login" — the delta it implements did. Requirements only: a
|
|
104
|
+
// criterion's "[unverified] … — verified by `…`" matched any spec's
|
|
105
|
+
// placeholder on the boilerplate alone.
|
|
106
|
+
const query = terms([box.text, ...box.details, parseChangeTitle(proposal) ?? "", ...changes.filter((ch) => / requirement[^:]*: /.test(ch.line)).map((ch) => ch.line.slice(ch.line.indexOf(": ") + 2))].join(" "));
|
|
77
107
|
const slices = [];
|
|
78
108
|
for (const cap of deltaCapabilities(changeDir)) {
|
|
79
109
|
const rel = `.doctrina/specs/${cap}/spec.md`;
|
|
80
110
|
const specPath = path.join(projectRoot, rel);
|
|
81
111
|
if (!isFile(specPath)) continue;
|
|
82
|
-
|
|
83
|
-
|
|
112
|
+
// Nothing in the spec matched the task: the whole spec, as `context`
|
|
113
|
+
// gives it (change 0231). Dropping it left a card with no spec at all.
|
|
114
|
+
const text = read(specPath);
|
|
115
|
+
const slice = sliceSpec(text, query, rel);
|
|
116
|
+
slices.push(slice ? { capability: cap, ...slice } : { capability: cap, text: withoutComments(text) ?? text, kept: null, total: null, whole: true });
|
|
84
117
|
}
|
|
85
118
|
return {
|
|
86
119
|
card: {
|
|
@@ -96,7 +129,67 @@ export function taskCard(projectRoot, changeId, ordinal = null) {
|
|
|
96
129
|
fields,
|
|
97
130
|
why: firstParagraph(proposal, "Why"),
|
|
98
131
|
slices,
|
|
132
|
+
changes,
|
|
133
|
+
story: deliveryStory(projectRoot, listHeader(proposal, "Delivery")),
|
|
134
|
+
decisions: bindingDecisions(projectRoot, deltaCapabilities(changeDir)),
|
|
99
135
|
},
|
|
100
136
|
reason: null,
|
|
101
137
|
};
|
|
102
138
|
}
|
|
139
|
+
|
|
140
|
+
// The story the change serves, when it belongs to a delivery (change 0231):
|
|
141
|
+
// its acceptance criteria are what the work is checked against, and they
|
|
142
|
+
// live in the workspace, where an executor opened in the repository never
|
|
143
|
+
// looks.
|
|
144
|
+
const STORY_MAX_CHARS = 1500;
|
|
145
|
+
function deliveryStory(projectRoot, deliveryId) {
|
|
146
|
+
if (!deliveryId) return null;
|
|
147
|
+
const found = findWorkspace(projectRoot);
|
|
148
|
+
if (!found) return null;
|
|
149
|
+
const id = resolveDeliveryId(found.dir, deliveryId.trim());
|
|
150
|
+
if (!id || !isFile(deliveryPath(found.dir, id))) return null;
|
|
151
|
+
const story = sectionText(read(deliveryPath(found.dir, id)), "Story");
|
|
152
|
+
if (!story) return null;
|
|
153
|
+
return story.length > STORY_MAX_CHARS ? `${story.slice(0, STORY_MAX_CHARS).replace(/\s+\S*$/, "")} …` : story;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The accepted decisions that bind the capabilities the change touches:
|
|
157
|
+
// those whose Scope names one, and the unscoped ones that cite one. A card
|
|
158
|
+
// the executor follows without them is how an implementation re-decides
|
|
159
|
+
// what an ADR settled.
|
|
160
|
+
function bindingDecisions(projectRoot, capabilities) {
|
|
161
|
+
const dir = path.join(projectRoot, ".doctrina", "decisions");
|
|
162
|
+
if (!isDir(dir) || capabilities.length === 0) return { list: [], more: 0 };
|
|
163
|
+
/** @type {Array<{ id: string, title: string, decision: string, rank: number }>} */
|
|
164
|
+
const found = [];
|
|
165
|
+
for (const f of walk(dir).sort()) {
|
|
166
|
+
const m = path.basename(f).match(/^(\d{4})-.*\.md$/);
|
|
167
|
+
if (!m) continue;
|
|
168
|
+
const text = read(f).replace(/\r\n/g, "\n");
|
|
169
|
+
if ((listHeader(text, "Status") ?? "").trim().toLowerCase() !== "accepted") continue;
|
|
170
|
+
const scope = parseAdrScope(text);
|
|
171
|
+
const cites = (/** @type {string} */ cap) => new RegExp(`(^|[^a-z0-9-])${cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9-]|$)`, "im").test(text);
|
|
172
|
+
// An unscoped ADR is GLOBAL — it binds every capability, as `decision
|
|
173
|
+
// scope` documents and the context pack loads it (sixth audit, change
|
|
174
|
+
// 0237). The card bound one only when its text named the capability, so
|
|
175
|
+
// a repository-wide rule ("error codes are MAIUSCULAS_SNAKE") never
|
|
176
|
+
// reached the executor, who reads nothing else. Scoped first, then the
|
|
177
|
+
// global ones that name the capability, then the rest.
|
|
178
|
+
const rank = scope.length > 0 ? (capabilities.some((cap) => scope.includes(cap)) ? 0 : -1) : (capabilities.some(cites) ? 1 : 2);
|
|
179
|
+
if (rank < 0) continue;
|
|
180
|
+
const decision = (getSection(text, "Decision") ?? "").replace(/<!--[\s\S]*?-->/g, "").replace(/\s+/g, " ").trim();
|
|
181
|
+
found.push({
|
|
182
|
+
id: m[1],
|
|
183
|
+
title: text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m)?.[1]?.trim() ?? path.basename(f),
|
|
184
|
+
decision: decision.length > DECISION_EXCERPT ? `${decision.slice(0, DECISION_EXCERPT).replace(/\s+\S*$/, "")} …` : decision,
|
|
185
|
+
rank,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const sorted = found.sort((a, b) => a.rank - b.rank || a.id.localeCompare(b.id));
|
|
189
|
+
return { list: sorted.slice(0, DECISIONS_ON_CARD), more: Math.max(0, sorted.length - DECISIONS_ON_CARD) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// How many binding decisions a card names, and how much of each it quotes.
|
|
193
|
+
const DECISIONS_ON_CARD = 8;
|
|
194
|
+
const DECISION_EXCERPT = 240;
|
|
195
|
+
|
package/src/lib/views.js
CHANGED
|
@@ -187,7 +187,7 @@ export function prime(s) {
|
|
|
187
187
|
// one forgotten for three weeks used to read identically.
|
|
188
188
|
const age = ch.ageDays !== null && ch.ageDays !== undefined && ch.ageDays >= STALE_CHANGE_DAYS
|
|
189
189
|
? ` · open ${ch.ageDays}d` : "";
|
|
190
|
-
out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks}${age})`)}`);
|
|
190
|
+
out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks}${age}${ch.delivery ? ` · delivery ${ch.delivery}` : ""})`)}`);
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
@@ -236,7 +236,7 @@ export function prime(s) {
|
|
|
236
236
|
* Worded as facts, not orders: an agent's prompt-injection defences treat
|
|
237
237
|
* imperatives from a hook with suspicion, and they should.
|
|
238
238
|
*
|
|
239
|
-
* @param {Array<{id: string, title: string|null, status: string}>} open
|
|
239
|
+
* @param {Array<{id: string, title: string|null, status: string, delivery?: string|null}>} open
|
|
240
240
|
* @param {Array<{id: string, date: string, path: string}>} [recent]
|
|
241
241
|
*/
|
|
242
242
|
export function pulse(open, recent = []) {
|
|
@@ -247,7 +247,7 @@ export function pulse(open, recent = []) {
|
|
|
247
247
|
return ["Doctrina: no change is open in this project. A request that changes behaviour is opened as a change " +
|
|
248
248
|
"before code is edited — `doctrina work \"<request>\"` (it classifies the lane itself); a question or a read needs none." + last];
|
|
249
249
|
}
|
|
250
|
-
const named = open.slice(0, 3).map((ch) => `${ch.id}${ch.title ? ` — "${ch.title}"` : ""} (${ch.status})`).join("; ");
|
|
250
|
+
const named = open.slice(0, 3).map((ch) => `${ch.id}${ch.title ? ` — "${ch.title}"` : ""} (${ch.status}${ch.delivery ? `, part of delivery ${ch.delivery}` : ""})`).join("; ");
|
|
251
251
|
const more = open.length > 3 ? ` and ${open.length - 3} more` : "";
|
|
252
252
|
return [`Doctrina: open change${open.length === 1 ? "" : "s"}: ${named}${more}. Work that belongs to ${open.length === 1 ? "it" : "one of them"} ` +
|
|
253
253
|
"is named by its id in the first sentence to the human and finishes with `doctrina close <id>`; " +
|
package/src/lib/work-model.js
CHANGED
|
@@ -16,7 +16,13 @@ import { globToRegExp } from "./runtime.js";
|
|
|
16
16
|
// file scores its capability when the file sits under a path segment named for
|
|
17
17
|
// it, or when the spec cites the file as evidence. A deterministic overlap
|
|
18
18
|
// hint for the agent — never a decision (ADR 0005).
|
|
19
|
-
|
|
19
|
+
//
|
|
20
|
+
// `pending` (change 0228) is, per capability, the spec as the change under
|
|
21
|
+
// review will leave it: the criterion a change adds cites its new test, a
|
|
22
|
+
// `set-header Source:` claims new paths, and `review` runs before `apply`
|
|
23
|
+
// merges either — so without it the files the change itself claims were
|
|
24
|
+
// reported as belonging to no capability.
|
|
25
|
+
export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3, pending = new Map() } = {}) {
|
|
20
26
|
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
21
27
|
if (!files || files.length === 0 || !isDir(specsDir)) return [];
|
|
22
28
|
const norm = files.map((f) => f.replace(/\\/g, "/"));
|
|
@@ -25,7 +31,7 @@ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
|
|
|
25
31
|
for (const cap of readdirSync(specsDir).sort()) {
|
|
26
32
|
const specPath = path.join(specsDir, cap, "spec.md");
|
|
27
33
|
if (!isFile(specPath)) continue;
|
|
28
|
-
const text = read(specPath);
|
|
34
|
+
const text = pending.get(cap) ?? read(specPath);
|
|
29
35
|
specs.push({ cap, text, declared: parseSourceGlobs(text).map((g) => globToRegExp(g)) });
|
|
30
36
|
}
|
|
31
37
|
|