doctrina-cli 0.20.0 → 0.22.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 +7 -7
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +202 -27
- package/src/commands/clarify.js +3 -206
- package/src/commands/close.js +1 -1
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/coverage.js +69 -6
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +83 -5
- package/src/commands/handoff.js +11 -2
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +64 -8
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +16 -1
- package/src/commands/next.js +41 -6
- package/src/commands/onboard.js +113 -0
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +40 -4
- package/src/commands/search.js +2 -2
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/templates.js +2 -1
- package/src/commands/triage.js +16 -1
- package/src/commands/ui.js +512 -0
- package/src/commands/upgrade.js +62 -4
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +35 -7
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +90 -42
- package/src/commands/workspace.js +89 -66
- package/src/index.js +107 -62
- package/src/lib/actions.js +62 -3
- package/src/lib/adapters.js +83 -1
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +34 -2
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +594 -104
- package/src/lib/commands.js +58 -4
- package/src/lib/config.js +27 -8
- package/src/lib/conflict-markers.js +61 -0
- package/src/lib/console-model.js +668 -0
- package/src/lib/console-prefs.js +67 -0
- package/src/lib/console-room.js +201 -0
- package/src/lib/console-state.js +150 -0
- package/src/lib/console-view.js +557 -0
- package/src/lib/context-slice.js +34 -3
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +90 -12
- package/src/lib/docs-impact.js +37 -2
- package/src/lib/fs-ops.js +103 -12
- package/src/lib/gates.js +7 -1
- package/src/lib/git.js +18 -0
- package/src/lib/index-json.js +10 -1
- package/src/lib/index-merge.js +90 -0
- package/src/lib/install.js +80 -0
- package/src/lib/intake-model.js +5 -2
- package/src/lib/json-out.js +9 -1
- package/src/lib/ledger.js +2 -3
- package/src/lib/lock.js +58 -0
- package/src/lib/merge-setup.js +55 -0
- package/src/lib/metrics-model.js +2 -2
- package/src/lib/onboard.js +197 -0
- package/src/lib/preset.js +177 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/properties.js +149 -0
- package/src/lib/runtime.js +19 -7
- package/src/lib/scan.js +13 -8
- package/src/lib/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +101 -8
- package/src/lib/templates-model.js +3 -2
- package/src/lib/triage-model.js +75 -0
- package/src/lib/validation-model.js +23 -5
- package/src/lib/views.js +5 -4
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +67 -20
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +104 -17
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +83 -131
- package/templates/AGENTS.md.template +9 -5
- package/templates/README.md +4 -2
- package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
- 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/skills/doctrina-onboard/SKILL.md.template +18 -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/adapters/kiro/README.md +22 -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/prompt.js
CHANGED
|
@@ -25,7 +25,7 @@ export async function ask(question, { defaultValue = "" } = {}) {
|
|
|
25
25
|
// Ask for consent. Off a terminal there is nobody to ask, so the answer is
|
|
26
26
|
// `whenNonInteractive` — which callers set to false for destructive
|
|
27
27
|
// operations: silence is not consent.
|
|
28
|
-
export async function confirm(question, { defaultYes = false, whenNonInteractive = null } = {}) {
|
|
28
|
+
export async function confirm(question, { defaultYes = false, whenNonInteractive = /** @type {boolean | null} */ (null) } = {}) {
|
|
29
29
|
if (!isInteractive()) {
|
|
30
30
|
return whenNonInteractive === null ? defaultYes : whenNonInteractive;
|
|
31
31
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
5
|
+
import { parseAcceptanceCriteria } from "./criteria.js";
|
|
6
|
+
|
|
7
|
+
// A REQUIREMENT THAT IS AN INVARIANT ASKS FOR A PROPERTY (change 0254).
|
|
8
|
+
//
|
|
9
|
+
// "The system shall never return a code already in use" is a claim about
|
|
10
|
+
// EVERY input, and the evidence a criterion cited for it was an example
|
|
11
|
+
// test: three inputs somebody thought of. Coverage called that proven. A
|
|
12
|
+
// property-based test generates the inputs — hundreds of them, the ones
|
|
13
|
+
// nobody thought of — which is the kind of proof a universal claim wants.
|
|
14
|
+
//
|
|
15
|
+
// Kiro derives such tests from EARS requirements with a model. The CLI has
|
|
16
|
+
// none and does not pretend to: it does the two deterministic halves. It
|
|
17
|
+
// says WHICH requirements read as invariants (`coverage --properties`), and
|
|
18
|
+
// it refuses to count a criterion marked `[property]` as proven unless the
|
|
19
|
+
// test it cites really is a property-based one. Writing the property is the
|
|
20
|
+
// agent's work, as writing the test always was.
|
|
21
|
+
|
|
22
|
+
/** Marks a criterion whose proof must generate its inputs. */
|
|
23
|
+
export const PROPERTY_MARKER = /^property\b/;
|
|
24
|
+
|
|
25
|
+
/** What a property-based test looks like, per library. */
|
|
26
|
+
const LIBRARIES = [
|
|
27
|
+
{ name: "Hypothesis", re: /@given\s*\(|\bfrom\s+hypothesis\b|\bimport\s+hypothesis\b/ },
|
|
28
|
+
{ name: "fast-check", re: /\bfc\.(assert|property|asyncProperty)\s*\(|["']fast-check["']/ },
|
|
29
|
+
{ name: "jqwik", re: /@Property\b|\bnet\.jqwik\b/ },
|
|
30
|
+
{ name: "proptest", re: /\bproptest!\s*\{/ },
|
|
31
|
+
{ name: "quickcheck", re: /\bquickcheck!\s*\{|#\[quickcheck\]|\bquickCheck\b/ },
|
|
32
|
+
{ name: "rapid", re: /\brapid\.Check\s*\(|\bquick\.Check\s*\(/ },
|
|
33
|
+
{ name: "FsCheck", re: /\bProp\.ForAll\b|\[Property\b/ },
|
|
34
|
+
{ name: "ScalaCheck", re: /\bforAll\s*[({]/ },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** The property library a test file uses, or null when it generates nothing. */
|
|
38
|
+
export function propertyLibraryIn(text) {
|
|
39
|
+
return LIBRARIES.find((lib) => lib.re.test(String(text)))?.name ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The shapes a universal claim takes in a requirement, with the property
|
|
43
|
+
// each one suggests. English and Portuguese, as the specs are.
|
|
44
|
+
const SHAPES = [
|
|
45
|
+
{ id: "round-trip",
|
|
46
|
+
re: /\b(round[- ]trip|and back\b|restor\w+ (?:the |its )?(?:original|same|previous)|revers(?:ible|ibly|es)\b|decod\w+|deserializ\w+|ida e volta|restaura\w* (?:o |a )?(?:original|mesm[oa]|anterior))/i,
|
|
47
|
+
property: "for any input x, undoing what was done to x gives x back" },
|
|
48
|
+
{ id: "idempotence",
|
|
49
|
+
re: /\b(idempoten\w+|twice|a second time|more than once|applied again|run again|duas vezes|de novo|mais de uma vez|outra vez)\b/i,
|
|
50
|
+
property: "for any input x, doing it twice leaves what doing it once left" },
|
|
51
|
+
{ id: "bound",
|
|
52
|
+
re: /\b(at most|at least (?!one\b)|no more than|no fewer than|not exceed|never exceed|within (?:\d|(?:\S+ ){0,3}(?:budget|limit|threshold|ratio|bound|quota|window|deadline)\b)|between \d|no máximo|pelo menos (?!um\b|uma\b)|no mínimo|não excede|entre \d)/i,
|
|
53
|
+
property: "for any input, the result stays inside the stated bound" },
|
|
54
|
+
{ id: "uniqueness",
|
|
55
|
+
re: /(?<![\w-])(unique|distinct(?!ive| from)|no two|never the same|(?:not|never) .{0,40}already (?:in use|used|taken|issued|exist\w*|registered)|único|única|distint\w+|nunca .{0,20}repet\w+|já (?:em uso|usad\w+|exist\w+))/i,
|
|
56
|
+
property: "for any sequence of inputs, no two results collide" },
|
|
57
|
+
{ id: "order",
|
|
58
|
+
re: /\b(sorted|ordered|in order(?! to\b)|ascending|descending|monoton\w+|ordenad\w+|em ordem|crescente|decrescente)\b/i,
|
|
59
|
+
property: "for any input, the output is in the stated order" },
|
|
60
|
+
{ id: "invariant",
|
|
61
|
+
re: /\b(always|never|for every|for any|for all|whatever|sempre|nunca|para todo|para toda|para qualquer|qualquer que seja)\b/i,
|
|
62
|
+
property: "for any input, the stated condition holds" },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** Every requirement bullet under `## Requirements`, with its section and ordinal. */
|
|
66
|
+
export function requirementsOf(specText) {
|
|
67
|
+
const out = [];
|
|
68
|
+
let inside = false;
|
|
69
|
+
let section = null;
|
|
70
|
+
let n = 0;
|
|
71
|
+
let cur = null;
|
|
72
|
+
for (const line of String(specText).split(/\r?\n/)) {
|
|
73
|
+
if (/^##\s+/.test(line) && !/^###/.test(line)) {
|
|
74
|
+
inside = /^##\s+Requirements\b/i.test(line);
|
|
75
|
+
section = null;
|
|
76
|
+
cur = null;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!inside) continue;
|
|
80
|
+
const h = line.match(/^###\s+(\S+)/);
|
|
81
|
+
if (h) {
|
|
82
|
+
section = h[1].toLowerCase().replace(/[^a-z-]/g, "").split("-")[0];
|
|
83
|
+
n = 0;
|
|
84
|
+
cur = null;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (section && /^-\s+\S/.test(line)) {
|
|
88
|
+
n += 1;
|
|
89
|
+
cur = { section, n, text: line.replace(/^-\s+/, "").trim() };
|
|
90
|
+
out.push(cur);
|
|
91
|
+
} else if (cur && /^\s+\S/.test(line)) {
|
|
92
|
+
cur.text += " " + line.trim();
|
|
93
|
+
} else if (line.trim() === "") {
|
|
94
|
+
cur = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The first shape a requirement's text takes, or null when it states no universal claim. */
|
|
101
|
+
export function propertyShape(text) {
|
|
102
|
+
const prose = String(text).replace(/`[^`]*`/g, "");
|
|
103
|
+
return SHAPES.find((s) => s.re.test(prose)) ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The property library this project's manifests point at, with how sure that is. */
|
|
107
|
+
export function projectPropertyLibrary(projectRoot) {
|
|
108
|
+
const text = (/** @type {string} */ rel) => (isFile(path.join(projectRoot, rel)) ? read(path.join(projectRoot, rel)) : "");
|
|
109
|
+
const py = text("pyproject.toml") + text("requirements.txt") + text("requirements-dev.txt") + text("setup.cfg") + text("Pipfile");
|
|
110
|
+
const js = text("package.json");
|
|
111
|
+
if (/\bhypothesis\b/i.test(py)) return { name: "Hypothesis", declared: true };
|
|
112
|
+
if (/"fast-check"/.test(js)) return { name: "fast-check", declared: true };
|
|
113
|
+
if (py.trim() !== "") return { name: "Hypothesis", declared: false };
|
|
114
|
+
if (js.trim() !== "") return { name: "fast-check", declared: false };
|
|
115
|
+
if (isFile(path.join(projectRoot, "pom.xml")) || isFile(path.join(projectRoot, "build.gradle")) || isFile(path.join(projectRoot, "build.gradle.kts"))) return { name: "jqwik", declared: false };
|
|
116
|
+
if (isFile(path.join(projectRoot, "Cargo.toml"))) return { name: "proptest", declared: false };
|
|
117
|
+
if (isFile(path.join(projectRoot, "go.mod"))) return { name: "rapid", declared: false };
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Per capability: the requirements that read as invariants, and how many of
|
|
123
|
+
* its criteria are already properties.
|
|
124
|
+
* @param {string} projectRoot
|
|
125
|
+
* @param {{ only?: Set<string> | null }} [opts]
|
|
126
|
+
*/
|
|
127
|
+
export function propertyCandidates(projectRoot, { only = /** @type {Set<string> | null} */ (null) } = {}) {
|
|
128
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
129
|
+
const out = [];
|
|
130
|
+
if (!isDir(specsDir)) return out;
|
|
131
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
132
|
+
if (only && !only.has(cap)) continue;
|
|
133
|
+
const file = path.join(specsDir, cap, "spec.md");
|
|
134
|
+
if (!isFile(file)) continue;
|
|
135
|
+
const text = read(file);
|
|
136
|
+
const candidates = [];
|
|
137
|
+
for (const req of requirementsOf(text)) {
|
|
138
|
+
const shape = propertyShape(req.text);
|
|
139
|
+
if (shape) candidates.push({ ref: `${req.section} ${req.n}`, text: req.text, shape: shape.id, property: shape.property });
|
|
140
|
+
}
|
|
141
|
+
// The specific shapes first: "at most", "twice", "and back" name the
|
|
142
|
+
// property to write; a bare "always" only says that there is one.
|
|
143
|
+
const rank = (/** @type {string} */ id) => SHAPES.findIndex((s) => s.id === id);
|
|
144
|
+
candidates.sort((a, b) => rank(a.shape) - rank(b.shape));
|
|
145
|
+
const properties = parseAcceptanceCriteria(text).filter((row) => row.marker !== null && PROPERTY_MARKER.test(row.marker)).length;
|
|
146
|
+
out.push({ cap, candidates, properties });
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
package/src/lib/runtime.js
CHANGED
|
@@ -203,7 +203,7 @@ export function readWorkflowEnv(text) {
|
|
|
203
203
|
for (let i = 0; i < lines.length; i++) {
|
|
204
204
|
const line = lines[i];
|
|
205
205
|
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
206
|
-
const indent = line.match(/^\s*/)[0].length;
|
|
206
|
+
const indent = (line.match(/^\s*/) ?? [""])[0].length;
|
|
207
207
|
|
|
208
208
|
if (/^jobs:\s*$/.test(line)) {
|
|
209
209
|
sawJobs = true;
|
|
@@ -681,17 +681,29 @@ function compileGlob(glob) {
|
|
|
681
681
|
*/
|
|
682
682
|
export function filesMatching(projectRoot, glob) {
|
|
683
683
|
const re = globToRegExp(glob);
|
|
684
|
+
const text = String(glob);
|
|
685
|
+
const segments = text.split("/");
|
|
684
686
|
// Walk from the deepest literal directory prefix so a narrow glob never
|
|
685
|
-
// costs a full-tree walk.
|
|
686
|
-
|
|
687
|
+
// costs a full-tree walk. The prefix ends at the first segment that is a
|
|
688
|
+
// pattern: taking every literal segment, wherever it stood, let
|
|
689
|
+
// `a/*/b/c.js` start from `a/b` when that directory existed.
|
|
687
690
|
let base = projectRoot;
|
|
688
|
-
|
|
691
|
+
let consumed = 0;
|
|
692
|
+
for (const seg of segments) {
|
|
693
|
+
if (/[*?{]/.test(seg)) break;
|
|
689
694
|
const next = path.join(base, seg);
|
|
690
|
-
if (isDir(next))
|
|
691
|
-
|
|
695
|
+
if (!isDir(next)) break;
|
|
696
|
+
base = next;
|
|
697
|
+
consumed += 1;
|
|
692
698
|
}
|
|
693
699
|
if (!isDir(base)) return [];
|
|
694
|
-
|
|
700
|
+
// ...and no deeper than the pattern can reach (change 0249). A pattern
|
|
701
|
+
// with no `**` names a fixed number of levels; `tsconfig.json` names one.
|
|
702
|
+
// Without the bound, each root-level file a spec declared walked the whole
|
|
703
|
+
// repository — `node_modules` and `.git` included — to find it.
|
|
704
|
+
const spans = text.endsWith("/") || segments.includes("**") || /\{[^}]*\/[^}]*\}/.test(text);
|
|
705
|
+
const depth = spans ? Infinity : Math.max(1, segments.length - consumed);
|
|
706
|
+
return walk(base, { depth }).filter((f) => re.test(relPath(projectRoot, f).replaceAll("\\", "/")));
|
|
695
707
|
}
|
|
696
708
|
|
|
697
709
|
// ---------------------------------------------------------------------------
|
package/src/lib/scan.js
CHANGED
|
@@ -24,6 +24,7 @@ export function listHeader(text, name) {
|
|
|
24
24
|
return getHeader(text, name);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** @returns {string[]} */
|
|
27
28
|
export function parseDependsOn(text) {
|
|
28
29
|
const raw = specHeader(text, "Depends on");
|
|
29
30
|
if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
|
|
@@ -77,6 +78,7 @@ function dirEntries(dir) {
|
|
|
77
78
|
// The capabilities an ADR governs, from its optional "Scope:" header
|
|
78
79
|
// (M4). Returns [] when absent, "n/a", or "—" — an unscoped ADR is global
|
|
79
80
|
// and belongs in every pack, which is what keeps this backward compatible.
|
|
81
|
+
/** @returns {string[]} */
|
|
80
82
|
export function parseAdrScope(text) {
|
|
81
83
|
const raw = listHeader(text, "Scope");
|
|
82
84
|
if (!raw || /^n\/a\b/i.test(raw.trim()) || raw.trim() === "—") return [];
|
|
@@ -175,7 +177,7 @@ export function changeEntry(proposal, id, prev, date) {
|
|
|
175
177
|
// carried the lane: an archived change lost the lane it was born in, so the
|
|
176
178
|
// report's "Lanes" section counted the whole history as unknown (change
|
|
177
179
|
// 0192), which is the question change 0042 recorded the lane to answer.
|
|
178
|
-
export function archivedChangeEntry(proposal, { id, archiveName, applied, specsAffected, prev = null, title = undefined }) {
|
|
180
|
+
export function archivedChangeEntry(proposal, { id, archiveName, applied, specsAffected, prev = /** @type {any} */ (null), title = /** @type {string | null | undefined} */ (undefined) }) {
|
|
179
181
|
return {
|
|
180
182
|
id,
|
|
181
183
|
title: (title === undefined ? parseChangeTitle(proposal) : title) ?? prev?.title ?? id,
|
|
@@ -213,12 +215,12 @@ export function deriveIndex(projectRoot, current) {
|
|
|
213
215
|
version: "0.1.0",
|
|
214
216
|
last_updated: date,
|
|
215
217
|
},
|
|
216
|
-
specs: [],
|
|
217
|
-
decisions: [],
|
|
218
|
-
changes: [],
|
|
219
|
-
changes_archive: [],
|
|
220
|
-
skills: [],
|
|
221
|
-
contracts: [],
|
|
218
|
+
specs: /** @type {any[]} */ ([]),
|
|
219
|
+
decisions: /** @type {any[]} */ ([]),
|
|
220
|
+
changes: /** @type {any[]} */ ([]),
|
|
221
|
+
changes_archive: /** @type {any[]} */ ([]),
|
|
222
|
+
skills: /** @type {any[]} */ ([]),
|
|
223
|
+
contracts: /** @type {any[]} */ ([]),
|
|
222
224
|
},
|
|
223
225
|
};
|
|
224
226
|
|
|
@@ -533,7 +535,10 @@ export function collectStagedIndexDrift(projectRoot) {
|
|
|
533
535
|
const nothing = { ok: true, applicable: false, missing: [], unindexed: [], reason: null };
|
|
534
536
|
if (!isRepo(projectRoot)) return { ...nothing, reason: "not a git repository" };
|
|
535
537
|
|
|
536
|
-
|
|
538
|
+
// `:./` reads the path from the current directory. A bare `:.doctrina/…`
|
|
539
|
+
// is read from the top of the repository, so for a project in a subfolder
|
|
540
|
+
// the check answered "no staged index" for every commit (change 0250).
|
|
541
|
+
const blob = git(projectRoot, ["show", ":./.doctrina/index.json"]);
|
|
537
542
|
if (blob.state !== GIT_STATE.OK || !blob.stdout.trim()) {
|
|
538
543
|
// Not staged at all — this commit does not carry an index, so it cannot
|
|
539
544
|
// carry one that disagrees with itself.
|
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/spec-ops.js
CHANGED
|
@@ -50,7 +50,7 @@ const HEADER_RE = (name) =>
|
|
|
50
50
|
// see ADR 0007"): only the WORD is checked.
|
|
51
51
|
export const STATUS_VALUES = ["draft", "active", "deprecated"];
|
|
52
52
|
export const IMPLEMENTATION_VALUES = ["planned", "partial", "implemented", "verified"];
|
|
53
|
-
export const CRITERION_MARKS = ["verified", "unverified", "orchestration"];
|
|
53
|
+
export const CRITERION_MARKS = ["verified", "unverified", "orchestration", "property"];
|
|
54
54
|
|
|
55
55
|
/** The state word of a header/mark value: the first token, note stripped. */
|
|
56
56
|
export function stateWord(value) {
|
|
@@ -440,7 +440,7 @@ function locateRequirements(text) {
|
|
|
440
440
|
if (h) {
|
|
441
441
|
if (current) current.endIndex = i;
|
|
442
442
|
const token = SECTIONS[h[1].toLowerCase().replace(/[^a-z-]/g, "")] ?? null;
|
|
443
|
-
current = { headingIndex: i, endIndex: lines.length, bullets: [] };
|
|
443
|
+
current = { headingIndex: i, endIndex: lines.length, bullets: /** @type {number[]} */ ([]) };
|
|
444
444
|
if (token) sections[token] = current;
|
|
445
445
|
continue;
|
|
446
446
|
}
|
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
|
|
@@ -55,7 +58,7 @@ function firstParagraph(text, heading) {
|
|
|
55
58
|
* open one. Null `card` with a reason when there is none to give.
|
|
56
59
|
* @returns {{ card: null, reason: string } | { card: object, reason: null }}
|
|
57
60
|
*/
|
|
58
|
-
export function taskCard(projectRoot, changeId, ordinal = null) {
|
|
61
|
+
export function taskCard(projectRoot, changeId, ordinal = /** @type {number | null} */ (null)) {
|
|
59
62
|
const changeDir = path.join(projectRoot, ".doctrina", "changes", changeId);
|
|
60
63
|
const tasksPath = path.join(changeDir, "tasks.md");
|
|
61
64
|
if (!isFile(tasksPath)) return { card: null, reason: "the change has no tasks.md" };
|
|
@@ -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
|
+
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// `commands/templates.js` (audit finding F7). Collecting is not rendering, so
|
|
6
6
|
// the collection lives here and both surfaces read it.
|
|
7
7
|
import path from "node:path";
|
|
8
|
+
import { loadConfig } from "./config.js";
|
|
8
9
|
import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "./fs-ops.js";
|
|
9
10
|
import { adapterFiles, isHubPointer, listAdapterNames } from "./adapters.js";
|
|
10
11
|
import { readTemplate } from "./templates.js";
|
|
@@ -28,7 +29,7 @@ import { agentsMdBudget } from "./validation-model.js";
|
|
|
28
29
|
* @returns {{ used: number, budget: number, slack: number, declared: boolean }}
|
|
29
30
|
*/
|
|
30
31
|
export function surfaceBudget(projectRoot = null) {
|
|
31
|
-
const used = surfaceMarkdown().split("\n").length;
|
|
32
|
+
const used = surfaceMarkdown(projectRoot ? loadConfig(projectRoot).surface : "full").split("\n").length;
|
|
32
33
|
const { value: budget, declared } = projectRoot
|
|
33
34
|
? declaredBudget(projectRoot, "surface-block-lines", SURFACE_LINE_BUDGET)
|
|
34
35
|
: { value: SURFACE_LINE_BUDGET, declared: false };
|
|
@@ -129,7 +130,7 @@ export function collectFindings(projectRoot) {
|
|
|
129
130
|
const block = findSurfaceBlock(text);
|
|
130
131
|
if (!block) {
|
|
131
132
|
findings.push({ message: "AGENTS.md has no doctrina:surface block — agents discover commands through this file", remedy: "doctrina upgrade --write" });
|
|
132
|
-
} else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock())) {
|
|
133
|
+
} else if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(surfaceBlock(loadConfig(projectRoot).surface))) {
|
|
133
134
|
findings.push({ message: "AGENTS.md doctrina:surface block is stale vs the installed CLI", remedy: "doctrina upgrade --write" });
|
|
134
135
|
} else {
|
|
135
136
|
ok.push("AGENTS.md: doctrina:surface block current");
|
package/src/lib/triage-model.js
CHANGED
|
@@ -119,3 +119,78 @@ export function classify(prompt) {
|
|
|
119
119
|
|
|
120
120
|
return { lane, scores: lanes, confident: best > 0 && best - runnerUp >= 2 };
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
// THE TRACKS, BY WHAT THE REQUEST CHANGES (change 0255).
|
|
124
|
+
//
|
|
125
|
+
// The three lanes above say what KIND of request it is. They never said how
|
|
126
|
+
// much ceremony it earns, and the default answer was all of it: "fix the
|
|
127
|
+
// typo in the README" opened a proposal, three tasks and a thirteen-step
|
|
128
|
+
// close. `metrics` already printed "a one-line fix may rightly need none"
|
|
129
|
+
// under its adherence rate — the rule existed, in a footnote.
|
|
130
|
+
//
|
|
131
|
+
// The measure is not the size of the diff. It is whether a sentence in a
|
|
132
|
+
// spec becomes false, or one is missing: a one-line change of a timeout the
|
|
133
|
+
// spec states is a change, and a two-hundred-line reformat is not.
|
|
134
|
+
export const TRACKS = [
|
|
135
|
+
{ id: "direct",
|
|
136
|
+
changes: "nothing a spec says — a typo, a comment, formatting, a fix that makes the code do what the spec already says",
|
|
137
|
+
path: "edit · doctrina verify · commit" },
|
|
138
|
+
{ id: "chore",
|
|
139
|
+
changes: "the build, the tooling or the docs, worth a record — no spec changes",
|
|
140
|
+
path: 'doctrina work --chore "<request>"' },
|
|
141
|
+
{ id: "change",
|
|
142
|
+
changes: "behaviour — a sentence in a spec becomes false, or one is missing",
|
|
143
|
+
path: 'doctrina work "<request>"' },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// What the words alone can settle. Each phrase names an edit that cannot
|
|
147
|
+
// change behaviour; a bare "comment" or "formatting" is also the name of a
|
|
148
|
+
// feature ("comment moderation", "date formatting"), so those need the word
|
|
149
|
+
// that says which one is meant. A fix that restores what the spec already
|
|
150
|
+
// says is direct too, and no word list can know it: that one is the
|
|
151
|
+
// agent's call, by the rule the standing instructions state.
|
|
152
|
+
export const DIRECT_SIGNALS = [
|
|
153
|
+
/\b(typos?|misspell\w*|spelling|grammar|whitespace|indentation|trailing (spaces?|whitespace|commas?|newlines?))\b/,
|
|
154
|
+
/\b(formatting only|reformat\w*|fix(ing)? (the )?formatting|code formatting)\b/,
|
|
155
|
+
/\b((code|stale|outdated|old|wrong|misleading) comments?|comments? only|dead code|unused (imports?|variables?))\b/,
|
|
156
|
+
// pt
|
|
157
|
+
/\b(erros? de digitacao|ortografia|gramatica|espacos? em branco|indentacao)\b/,
|
|
158
|
+
/\b(so (a )?formatacao|reformatar|corrigir (a )?formatacao|formatacao do codigo)\b/,
|
|
159
|
+
/\b(comentarios? (de codigo|antig\w+|obsolet\w+|desatualizad\w+|errad\w+)|so (o |os )?comentarios?|codigo morto|imports? nao usad\w+|variave(l|is) nao usad\w+)\b/,
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The signals that make a prompt a direct edit, or none. A prompt that also
|
|
164
|
+
* carries a product or a runtime signal is not direct: "add" or "should"
|
|
165
|
+
* says behaviour is in play, and the ceremony is the safe side to err on.
|
|
166
|
+
* @param {string} prompt
|
|
167
|
+
* @param {ReturnType<typeof classify>} [verdict]
|
|
168
|
+
* @returns {string[]}
|
|
169
|
+
*/
|
|
170
|
+
export function directSignals(prompt, verdict = classify(prompt)) {
|
|
171
|
+
if (verdict.scores.product.total > 0 || verdict.scores.runtime.total > 0) return [];
|
|
172
|
+
const text = fold(prompt);
|
|
173
|
+
const hits = [];
|
|
174
|
+
for (const re of DIRECT_SIGNALS) {
|
|
175
|
+
const m = text.match(re);
|
|
176
|
+
if (m) hits.push(m[0]);
|
|
177
|
+
}
|
|
178
|
+
return hits;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The track a prompt reads as: direct, chore, change — or diagnosis, for a runtime problem. */
|
|
182
|
+
export function trackOf(prompt, verdict = classify(prompt)) {
|
|
183
|
+
if (directSignals(prompt, verdict).length > 0) return "direct";
|
|
184
|
+
if (verdict.lane === "runtime") return "diagnosis";
|
|
185
|
+
return verdict.lane === "chore" ? "chore" : "change";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The three tracks as plain lines, for the commands that print them. */
|
|
189
|
+
export function trackLines() {
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const t of TRACKS) {
|
|
192
|
+
out.push(` ${t.id.padEnd(7)} ${t.changes}`);
|
|
193
|
+
out.push(` ${t.path}`);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|