doctrina-cli 0.17.0 → 0.20.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 +8 -4
- package/package.json +1 -1
- package/src/commands/adapter.js +74 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +131 -7
- package/src/commands/close.js +92 -28
- package/src/commands/context.js +49 -8
- package/src/commands/delivery.js +238 -0
- package/src/commands/index-rebuild.js +23 -0
- package/src/commands/init.js +15 -0
- package/src/commands/intake.js +47 -1
- package/src/commands/metrics.js +28 -3
- package/src/commands/next.js +1 -0
- package/src/commands/prime.js +76 -7
- package/src/commands/review.js +47 -8
- package/src/commands/skill.js +3 -127
- package/src/commands/upgrade.js +15 -0
- package/src/commands/verify.js +59 -6
- package/src/commands/work.js +139 -5
- package/src/commands/workspace.js +409 -0
- package/src/index.js +5 -7
- package/src/lib/actions.js +9 -5
- package/src/lib/adapters.js +80 -0
- package/src/lib/change-ops.js +92 -0
- package/src/lib/code-inventory.js +297 -0
- package/src/lib/commands.js +52 -5
- package/src/lib/context-slice.js +118 -0
- package/src/lib/delivery.js +208 -0
- package/src/lib/git.js +59 -2
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/runtime.js +6 -0
- package/src/lib/scan.js +13 -0
- package/src/lib/signoff.js +24 -0
- package/src/lib/skill-candidates.js +162 -0
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +76 -2
- package/src/lib/spec-ops.js +117 -1
- package/src/lib/task-card.js +102 -0
- package/src/lib/validation-model.js +1 -1
- package/src/lib/verify-cache.js +76 -0
- package/src/lib/views.js +56 -2
- package/src/lib/work-model.js +90 -12
- package/src/lib/workspace-agents.js +259 -0
- package/src/lib/workspace-tools.js +113 -0
- package/src/lib/workspace.js +371 -0
- package/templates/AGENTS.md.template +17 -14
- package/templates/README.md +11 -0
- 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/change/spec-delta.md.template +2 -0
- package/templates/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +40 -0
- package/templates/playbooks/consolidate.md.template +28 -0
- package/templates/playbooks/delivery.md.template +38 -0
- package/templates/playbooks/work.md.template +4 -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
|
];
|
|
@@ -47,6 +49,8 @@ export const OPERATIONS = [
|
|
|
47
49
|
["change check", "Pre-close dry-run: everything close would refuse, listed first"],
|
|
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)"],
|
|
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"],
|
|
50
54
|
["contract new", "Own the integration surface (ports, env, interfaces)"],
|
|
51
55
|
["contract list", "List contracts with status and last-updated date"],
|
|
52
56
|
["contract check", "Verify port collisions, env drift, referenced specs"],
|
|
@@ -81,6 +85,19 @@ export const OPERATIONS = [
|
|
|
81
85
|
["status", "One-glance project health dashboard"],
|
|
82
86
|
["why", "Explain provenance: a capability's chain, or an anchor's (SC1)"],
|
|
83
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 check", "What still keeps a delivery from closing (exit 1 while anything does)"],
|
|
100
|
+
["delivery close", "Close a delivery once every project's change is closed"],
|
|
84
101
|
["templates list", "List the templates shipped by the installed CLI"],
|
|
85
102
|
["templates check", "Compare the project against the recommended template shape"],
|
|
86
103
|
["templates update", "Additive fixer for check findings (preview; --write applies)"],
|
|
@@ -126,8 +143,8 @@ export const COMMAND_META = {
|
|
|
126
143
|
why: { moment: "Orient", when: "you need to justify or trace a capability's existence", purpose: "provenance: intent, proof, ADRs, and history" },
|
|
127
144
|
handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
|
|
128
145
|
|
|
129
|
-
triage: { moment: "Change", when: "
|
|
130
|
-
work: { moment: "Change", when: "a request
|
|
146
|
+
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" },
|
|
147
|
+
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" },
|
|
131
148
|
spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
|
|
132
149
|
change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "drive a change by hand — check --verbose previews every delta" },
|
|
133
150
|
decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
|
|
@@ -145,6 +162,9 @@ export const COMMAND_META = {
|
|
|
145
162
|
close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
|
|
146
163
|
doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
|
|
147
164
|
|
|
165
|
+
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" },
|
|
166
|
+
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" },
|
|
167
|
+
|
|
148
168
|
templates: { moment: "Maintain", when: "customising a scaffold, to see which template file wins", purpose: "where each shipped template resolves from" },
|
|
149
169
|
upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
|
|
150
170
|
index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
|
|
@@ -157,7 +177,7 @@ export const COMMAND_META = {
|
|
|
157
177
|
};
|
|
158
178
|
|
|
159
179
|
// The order moments appear in the generated block.
|
|
160
|
-
export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
|
|
180
|
+
export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Orchestrate", "Maintain"];
|
|
161
181
|
|
|
162
182
|
// The Commands block of `doctrina --help`, generated from OPERATIONS so the
|
|
163
183
|
// printed surface can never omit an operation the catalog knows about.
|
|
@@ -191,7 +211,10 @@ export function surfaceHelp() {
|
|
|
191
211
|
const ops = live.filter(([op]) => op.split(" ")[0] === cmd);
|
|
192
212
|
const subs = ops.map(([op]) => op.split(" ")[1]).filter(Boolean);
|
|
193
213
|
const text = ops.length === 1 ? ops[0][1] : capitalise(COMMAND_META[cmd].purpose);
|
|
194
|
-
|
|
214
|
+
const op = subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd;
|
|
215
|
+
// A compact moment's long invocation stands alone rather than wrapping
|
|
216
|
+
// onto a second line (change 0220): `doctrina <cmd> --help` has the rest.
|
|
217
|
+
out.push(COMPACT_MOMENTS.has(moment) && op.length >= 20 ? ` ${op}` : line(op, text));
|
|
195
218
|
}
|
|
196
219
|
}
|
|
197
220
|
const old = OPERATIONS.filter(([op]) => DEPRECATED[op]);
|
|
@@ -316,7 +339,10 @@ export const SURFACE_LINE_BUDGET = 40;
|
|
|
316
339
|
// each its own line pushed the block past its budget. The budget is the
|
|
317
340
|
// forcing function — a surface that cannot describe itself in 40 lines is
|
|
318
341
|
// too large, and the answer is to cut commands (M8), not to raise it.
|
|
319
|
-
|
|
342
|
+
// "Orchestrate" is compact too (change 0220): an orchestrating session reads
|
|
343
|
+
// the workspace's own AGENTS.md, which carries the delivery playbook in full;
|
|
344
|
+
// inside one repository the commands only need to be discoverable.
|
|
345
|
+
const COMPACT_MOMENTS = new Set(["Orchestrate", "Maintain"]);
|
|
320
346
|
|
|
321
347
|
// The full managed section, heading included, between the markers.
|
|
322
348
|
//
|
|
@@ -516,6 +542,27 @@ export const AGENT_CHANGELOG = {
|
|
|
516
542
|
// Each entry lists only ITS OWN delta. The series carries the rest
|
|
517
543
|
// forward (see agentChangelogEntries), so a patch never has to restate
|
|
518
544
|
// the minor that introduced the commands, and never silently erases it.
|
|
545
|
+
"0.20.0": [
|
|
546
|
+
"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
|
+
"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).",
|
|
548
|
+
"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.",
|
|
549
|
+
"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.",
|
|
550
|
+
"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.",
|
|
551
|
+
],
|
|
552
|
+
"0.19.0": [
|
|
553
|
+
"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.",
|
|
554
|
+
"`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.",
|
|
555
|
+
"Project skills are exported to `.agents/skills/` and `.claude/skills/`, where your agent lists skills — use them; edit `.doctrina/skills/`, never the exports.",
|
|
556
|
+
"`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.",
|
|
557
|
+
"Hooks: `doctrina adapter add claude|cursor` merges session/prompt hooks (`prime --hook`, `prime --pulse`) into the agent's settings.",
|
|
558
|
+
],
|
|
559
|
+
"0.18.0": [
|
|
560
|
+
"`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.",
|
|
561
|
+
"`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\"`.",
|
|
562
|
+
"The review inside `close` counts the change's own deltas as spec updates and reports code under other capabilities in ONE line — read it: either this change forgot a delta or other work is lying in the tree.",
|
|
563
|
+
"`close` ends by listing other open changes that look finished or are a week old; close them with the `doctrina close <id>` it prints. `next` no longer suggests `change archive`.",
|
|
564
|
+
"A spec can shrink: `remove-requirement <section> <n>` / `remove-criterion <n>`, and `doctrina work --consolidate <cap>` when a spec has grown past what it needs to say.",
|
|
565
|
+
],
|
|
519
566
|
"0.17.0": [
|
|
520
567
|
"Preview a close with `doctrina change check <id>` (it exits 1 while a Verification box is open), then run `doctrina close <id>` — do not run `analyze`, `change apply` or `change archive` by hand; a chore closes the same way.",
|
|
521
568
|
"Deprecated names still run with a notice — use the replacement: `analyze` → `change check`, `report` → `status --view report`, `skill sync` → `index rebuild`, `templates check|update` → `upgrade [--write]`. `constitution` and `change diff` are removed (exit 2).",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { fold } from "./lexicon.js";
|
|
3
|
+
import { 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
|
+
* An ADR as a reader deciding whether it binds the task needs it: the
|
|
22
|
+
* title and the opening of its Decision section. `doctrina show NNNN`
|
|
23
|
+
* reads the rest.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} text
|
|
26
|
+
* @param {string} title
|
|
27
|
+
* @param {string} id four-digit ADR number
|
|
28
|
+
*/
|
|
29
|
+
export function adrDigest(text, title, id) {
|
|
30
|
+
const decision = (getSection(text.replace(/\r\n/g, "\n"), "Decision") ?? "")
|
|
31
|
+
.split("\n").filter((l) => !l.trim().startsWith("<!--")).join("\n").trim();
|
|
32
|
+
const clipped = decision.length > ADR_DIGEST_CHARS
|
|
33
|
+
? `${decision.slice(0, ADR_DIGEST_CHARS).replace(/\s+\S*$/, "")} …`
|
|
34
|
+
: decision;
|
|
35
|
+
return [`# ${title}`, "", clipped || "(no Decision section)", "",
|
|
36
|
+
`(digest — full text: \`doctrina show ${id}\`, or \`context --full\`)`].join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A spec cut to what the task is about: its headers, its Purpose, and the
|
|
41
|
+
* requirements and criteria that match the query best — the rest named by
|
|
42
|
+
* count, never silently dropped. Deterministic: an item's score is how many
|
|
43
|
+
* distinct query terms it contains; the items scoring at least half the
|
|
44
|
+
* best score are kept, best first in document order, up to a cap per list.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} text
|
|
47
|
+
* @param {string[]} queryTerms already folded, stop words removed
|
|
48
|
+
* @param {string} rel the spec's path, for the footer
|
|
49
|
+
* @returns {{ text: string, kept: number, total: number } | null} null when nothing matched
|
|
50
|
+
*/
|
|
51
|
+
export function sliceSpec(text, queryTerms, rel) {
|
|
52
|
+
if (queryTerms.length === 0) return null;
|
|
53
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
54
|
+
// Items: a bullet or numbered entry with its continuation lines.
|
|
55
|
+
/** @type {Array<{ start: number, end: number, kind: "req"|"crit", score: number }>} */
|
|
56
|
+
const items = [];
|
|
57
|
+
let section = "";
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
const l = lines[i];
|
|
60
|
+
if (/^##\s/.test(l)) { section = l; continue; }
|
|
61
|
+
const req = /^##\s+Requirements/i.test(section) && /^\s*-\s+\S/.test(l);
|
|
62
|
+
const crit = /^##\s+Acceptance criteria/i.test(section) && /^\s*\d+\.\s+\S/.test(l);
|
|
63
|
+
if (!req && !crit) continue;
|
|
64
|
+
let end = i;
|
|
65
|
+
while (end + 1 < lines.length && /^\s+\S/.test(lines[end + 1]) && !/^\s*(-|\d+\.)\s/.test(lines[end + 1])) end += 1;
|
|
66
|
+
const body = fold(lines.slice(i, end + 1).join(" "));
|
|
67
|
+
const score = queryTerms.filter((t) => body.includes(t)).length;
|
|
68
|
+
items.push({ start: i, end, kind: req ? "req" : "crit", score });
|
|
69
|
+
i = end;
|
|
70
|
+
}
|
|
71
|
+
const best = Math.max(0, ...items.map((it) => it.score));
|
|
72
|
+
if (best === 0) return null;
|
|
73
|
+
const floor = Math.max(1, Math.ceil(best / 2));
|
|
74
|
+
const keep = new Set();
|
|
75
|
+
for (const kind of ["req", "crit"]) {
|
|
76
|
+
items.filter((it) => it.kind === kind && it.score >= floor)
|
|
77
|
+
.sort((a, b) => b.score - a.score || a.start - b.start)
|
|
78
|
+
.slice(0, SLICE_MAX_ITEMS)
|
|
79
|
+
.forEach((it) => keep.add(it.start));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const out = [];
|
|
83
|
+
section = "";
|
|
84
|
+
let omitted = 0;
|
|
85
|
+
const flushOmitted = () => {
|
|
86
|
+
if (omitted > 0) out.push(` … ${omitted} not matching the task`);
|
|
87
|
+
omitted = 0;
|
|
88
|
+
};
|
|
89
|
+
for (let i = 0; i < lines.length; i++) {
|
|
90
|
+
const l = lines[i];
|
|
91
|
+
if (/^##\s/.test(l)) {
|
|
92
|
+
flushOmitted();
|
|
93
|
+
section = l;
|
|
94
|
+
out.push(l);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const item = items.find((it) => it.start === i);
|
|
98
|
+
if (item) {
|
|
99
|
+
if (keep.has(i)) { flushOmitted(); out.push(...lines.slice(i, item.end + 1)); } else omitted += 1;
|
|
100
|
+
i = item.end;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Headers, Purpose and subsection headings are kept; prose in the other
|
|
104
|
+
// sections (Out of scope, Maturity, …) is the first thing a task can do
|
|
105
|
+
// without.
|
|
106
|
+
const inLists = /^##\s+(Requirements|Acceptance criteria)/i.test(section);
|
|
107
|
+
const prose = section !== "" && !/^##\s+Purpose/i.test(section) && !inLists;
|
|
108
|
+
if (/^###\s/.test(l)) { flushOmitted(); out.push(l); continue; }
|
|
109
|
+
if (section === "" || /^##\s+Purpose/i.test(section)) { out.push(l); continue; }
|
|
110
|
+
if (inLists && l.trim() !== "") continue;
|
|
111
|
+
if (!prose) out.push(l);
|
|
112
|
+
}
|
|
113
|
+
flushOmitted();
|
|
114
|
+
const kept = keep.size;
|
|
115
|
+
out.push("", `(sliced for "${queryTerms.join(" ")}" — ${kept} of ${items.length} requirements and criteria kept; ` +
|
|
116
|
+
`full text: ${rel}, or \`context --full\`)`);
|
|
117
|
+
return { text: out.join("\n").replace(/\n{3,}/g, "\n\n"), kept, total: items.length };
|
|
118
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isDir, isFile, read, write } from "./fs-ops.js";
|
|
5
|
+
import { listHeader } from "./scan.js";
|
|
6
|
+
import { checklistProgress, setHeader } from "./doc-model.js";
|
|
7
|
+
import { terms } from "./lexicon.js";
|
|
8
|
+
import { slugify, padNumber } from "./dates.js";
|
|
9
|
+
import { artifactRootOf } from "./workspace.js";
|
|
10
|
+
|
|
11
|
+
// A DELIVERY is one item of work that crosses repositories (ADR 0030): a
|
|
12
|
+
// story, a screen, a fix. It lives in the workspace's `deliveries/` and
|
|
13
|
+
// holds what no single repository owns — the story, the sources, the plan
|
|
14
|
+
// across projects, the release. Each project it touches gets ONE change,
|
|
15
|
+
// opened in that repository and carrying `- **Delivery:** <id>`, so the
|
|
16
|
+
// delivery's state is READ from the projects, never copied into it.
|
|
17
|
+
|
|
18
|
+
export const DELIVERY_FILE = "delivery.md";
|
|
19
|
+
export const DELIVERY_ID = /^\d{4}-[a-z0-9][a-z0-9-]*$/;
|
|
20
|
+
|
|
21
|
+
export function deliveriesDir(wsDir) {
|
|
22
|
+
return path.join(wsDir, "deliveries");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Every delivery on disk, oldest first. */
|
|
26
|
+
export function listDeliveries(wsDir) {
|
|
27
|
+
const dir = deliveriesDir(wsDir);
|
|
28
|
+
if (!isDir(dir)) return [];
|
|
29
|
+
return readdirSync(dir).filter((d) => DELIVERY_ID.test(d) && isFile(path.join(dir, d, DELIVERY_FILE))).sort()
|
|
30
|
+
.map((id) => readDelivery(wsDir, id));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function deliveryPath(wsDir, id) {
|
|
34
|
+
return path.join(deliveriesDir(wsDir), id, DELIVERY_FILE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A delivery by id — or by its number alone (`1`, `0001`). Null when absent. */
|
|
38
|
+
export function resolveDeliveryId(wsDir, ref) {
|
|
39
|
+
if (!ref) return null;
|
|
40
|
+
if (DELIVERY_ID.test(ref) && isFile(deliveryPath(wsDir, ref))) return ref;
|
|
41
|
+
if (/^\d+$/.test(ref)) {
|
|
42
|
+
const prefix = padNumber(Number(ref)) + "-";
|
|
43
|
+
return listDeliveries(wsDir).find((d) => d.id.startsWith(prefix))?.id ?? null;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readDelivery(wsDir, id) {
|
|
49
|
+
// CRLF on a Windows checkout: a `$` would leave the \r in the title.
|
|
50
|
+
const text = read(deliveryPath(wsDir, id)).replace(/\r\n/g, "\n");
|
|
51
|
+
const title = text.match(/^#\s+Delivery\s+\S+\s+[—-]\s+(.+)$/m)?.[1]?.trim() ?? id;
|
|
52
|
+
const projects = (listHeader(text, "Projects") ?? "").split(",").map((s) => s.trim()).filter((s) => s && s !== "—");
|
|
53
|
+
return {
|
|
54
|
+
id, title, text,
|
|
55
|
+
status: (listHeader(text, "Status") ?? "open").toLowerCase(),
|
|
56
|
+
date: listHeader(text, "Date") ?? null,
|
|
57
|
+
closed: listHeader(text, "Closed") ?? null,
|
|
58
|
+
ref: listHeader(text, "Ref") ?? "—",
|
|
59
|
+
projects,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function nextDeliveryId(wsDir, title) {
|
|
64
|
+
const numbers = listDeliveries(wsDir).map((d) => Number(d.id.slice(0, 4)));
|
|
65
|
+
const next = (numbers.length ? Math.max(...numbers) : 0) + 1;
|
|
66
|
+
return `${padNumber(next)}-${slugify(title).slice(0, 40).replace(/-+$/, "") || "delivery"}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Rewrite one list header of a delivery (adding it when absent). */
|
|
70
|
+
export function setDeliveryHeader(wsDir, id, name, value) {
|
|
71
|
+
const p = deliveryPath(wsDir, id);
|
|
72
|
+
let text = read(p);
|
|
73
|
+
const next = setHeader(text, name, value);
|
|
74
|
+
if (next !== null) text = next;
|
|
75
|
+
else text = text.replace(/^(- \*\*Projects:\*\*.*)$/m, `$1\n- **${name}:** ${value}`);
|
|
76
|
+
write(p, text, { force: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Projects ranked against a delivery's text — how many of its terms each
|
|
81
|
+
* project's name, role, description and capability names share. A hint for
|
|
82
|
+
* the orchestrator, never a decision (ADR 0005): `delivery route` is where
|
|
83
|
+
* the projects are chosen.
|
|
84
|
+
*/
|
|
85
|
+
export function rankProjects(wsDir, ws, text) {
|
|
86
|
+
const want = new Set(terms(text));
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const p of ws.projects) {
|
|
89
|
+
const caps = capabilityNames(wsDir, p);
|
|
90
|
+
const words = new Set(terms([p.name, p.role, p.about, ...caps].join(" ")));
|
|
91
|
+
const shared = [...want].filter((t) => words.has(t));
|
|
92
|
+
out.push({ project: p, score: shared.length, shared, capabilities: caps });
|
|
93
|
+
}
|
|
94
|
+
return out.sort((a, b) => b.score - a.score || a.project.name.localeCompare(b.project.name));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function capabilityNames(wsDir, project) {
|
|
98
|
+
const specs = path.join(artifactRootOf(wsDir, project), ".doctrina", "specs");
|
|
99
|
+
if (!isDir(specs)) return [];
|
|
100
|
+
return readdirSync(specs).filter((d) => isFile(path.join(specs, d, "spec.md")));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The projects in dependency order: a project after every project it depends on. */
|
|
104
|
+
export function dependencyOrder(ws, names) {
|
|
105
|
+
const byName = new Map(ws.projects.map((p) => [p.name, p]));
|
|
106
|
+
const out = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
const visit = (name, trail) => {
|
|
109
|
+
if (seen.has(name) || trail.has(name)) return;
|
|
110
|
+
trail.add(name);
|
|
111
|
+
for (const dep of byName.get(name)?.dependsOn ?? []) if (names.includes(dep)) visit(dep, trail);
|
|
112
|
+
seen.add(name);
|
|
113
|
+
out.push(name);
|
|
114
|
+
};
|
|
115
|
+
for (const n of names) visit(n, new Set());
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The changes each routed project carries for this delivery — open ones in
|
|
121
|
+
* `changes/`, closed ones in `changes/archive/` — read from the projects.
|
|
122
|
+
*/
|
|
123
|
+
export function deliveryChanges(wsDir, ws, delivery) {
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const name of delivery.projects) {
|
|
126
|
+
const project = ws.projects.find((p) => p.name === name);
|
|
127
|
+
if (!project) {
|
|
128
|
+
out.push({ project: name, missing: "not registered in the workspace", changes: [] });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const changesDir = path.join(artifactRootOf(wsDir, project), ".doctrina", "changes");
|
|
132
|
+
const changes = [];
|
|
133
|
+
for (const { dir, archived } of [{ dir: changesDir, archived: false }, { dir: path.join(changesDir, "archive"), archived: true }]) {
|
|
134
|
+
if (!isDir(dir)) continue;
|
|
135
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
136
|
+
const proposal = path.join(dir, entry, "proposal.md");
|
|
137
|
+
if (entry === "archive" || !isFile(proposal)) continue;
|
|
138
|
+
const text = read(proposal);
|
|
139
|
+
if ((listHeader(text, "Delivery") ?? "").trim() !== delivery.id) continue;
|
|
140
|
+
const tasks = path.join(dir, entry, "tasks.md");
|
|
141
|
+
const progress = isFile(tasks) ? checklistProgress(read(tasks)) : { total: 0, done: 0 };
|
|
142
|
+
changes.push({
|
|
143
|
+
id: archived ? entry.replace(/^\d{4}-\d{2}-\d{2}-/, "") : entry,
|
|
144
|
+
archived,
|
|
145
|
+
status: archived ? "archived" : (listHeader(text, "Status") ?? "proposed").toLowerCase(),
|
|
146
|
+
tasksDone: progress.done, tasksTotal: progress.total,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
out.push({ project: name, path: project.path, changes });
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A section's own text: comments and blank lines removed. Empty string when absent. */
|
|
156
|
+
export function sectionText(text, heading) {
|
|
157
|
+
const lines = String(text).replace(/\r\n/g, "\n").replace(/<!--[\s\S]*?-->/g, "").split("\n");
|
|
158
|
+
const at = lines.findIndex((l) => l.trim() === `## ${heading}`);
|
|
159
|
+
if (at < 0) return "";
|
|
160
|
+
const out = [];
|
|
161
|
+
for (const l of lines.slice(at + 1)) {
|
|
162
|
+
if (/^## /.test(l)) break;
|
|
163
|
+
if (l.trim()) out.push(l.trim());
|
|
164
|
+
}
|
|
165
|
+
return out.join("\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Days between two YYYY-MM-DD dates, or null. */
|
|
169
|
+
export function daysBetween(from, to) {
|
|
170
|
+
const a = Date.parse(String(from ?? "") + "T00:00:00Z");
|
|
171
|
+
const b = Date.parse(String(to ?? "") + "T00:00:00Z");
|
|
172
|
+
return Number.isNaN(a) || Number.isNaN(b) ? null : Math.round((b - a) / 86400000);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The record a delivery keeps of its own work (change 0224): the story it
|
|
177
|
+
* came from, every Release claim ticked, and at least one line of what was
|
|
178
|
+
* watched after the release. The changes prove the code; these prove the
|
|
179
|
+
* delivery reached its users and was looked at once it had.
|
|
180
|
+
*/
|
|
181
|
+
export function recordBlockers(delivery) {
|
|
182
|
+
const out = [];
|
|
183
|
+
if (sectionText(delivery.text, "Story") === "") out.push("## Story is empty — copy the tracker item's story and acceptance criteria, verbatim");
|
|
184
|
+
const release = sectionText(delivery.text, "Release");
|
|
185
|
+
const open = release.split("\n").filter((l) => /^[-*]\s*\[\s\]/.test(l));
|
|
186
|
+
for (const box of open) out.push(`## Release: unticked — ${box.replace(/^[-*]\s*\[\s\]\s*/, "")}`);
|
|
187
|
+
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
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Everything that keeps a delivery from closing. Each routed project must
|
|
193
|
+
* carry at least one change for it, and every such change must be archived;
|
|
194
|
+
* the delivery's own record must be written (recordBlockers).
|
|
195
|
+
*/
|
|
196
|
+
export function deliveryBlockers(wsDir, ws, delivery) {
|
|
197
|
+
const blockers = [];
|
|
198
|
+
if (delivery.projects.length === 0) blockers.push("no project routed — `doctrina delivery route <id> <a,b>`");
|
|
199
|
+
for (const row of deliveryChanges(wsDir, ws, delivery)) {
|
|
200
|
+
if (row.missing) blockers.push(`${row.project}: ${row.missing}`);
|
|
201
|
+
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
|
+
else for (const ch of row.changes.filter((x) => !x.archived)) {
|
|
203
|
+
blockers.push(`${row.project}: change ${ch.id} is ${ch.status} (${ch.tasksDone}/${ch.tasksTotal} tasks) — close it there with \`doctrina close ${ch.id}\``);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
blockers.push(...recordBlockers(delivery));
|
|
207
|
+
return blockers;
|
|
208
|
+
}
|
package/src/lib/git.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { copyFileSync, existsSync, rmSync } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import process from "node:process";
|
|
3
8
|
|
|
4
9
|
// One place that knows how to ask git a question and how to interpret not
|
|
5
10
|
// getting an answer (audit item C8).
|
|
@@ -32,8 +37,13 @@ export const GIT_STATE = Object.freeze({
|
|
|
32
37
|
|
|
33
38
|
// Run a git command. Never throws. Returns
|
|
34
39
|
// { state, stdout, lines, stderr, code }.
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} cwd
|
|
42
|
+
* @param {string[]} args
|
|
43
|
+
* @param {{ env?: Record<string, string> }} [opts]
|
|
44
|
+
*/
|
|
45
|
+
export function git(cwd, args, { env } = {}) {
|
|
46
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
37
47
|
if (r.error) {
|
|
38
48
|
// ENOENT here means the binary is missing, not that the command failed.
|
|
39
49
|
return { state: GIT_STATE.ABSENT, stdout: "", lines: [], stderr: String(r.error.message ?? ""), code: null };
|
|
@@ -227,3 +237,50 @@ export function lastTagDate(cwd) {
|
|
|
227
237
|
const date = tags[0] ?? "";
|
|
228
238
|
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
|
|
229
239
|
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A content fingerprint of the working tree under `cwd` — tracked AND
|
|
243
|
+
* untracked files, .gitignore respected, uncommitted edits included — with
|
|
244
|
+
* the top-level entries named in `exclude` left out (change 0203). Null when
|
|
245
|
+
* git cannot answer.
|
|
246
|
+
*
|
|
247
|
+
* Built from git's own object model rather than by reading files: the real
|
|
248
|
+
* index is copied to a scratch one, `git add -A` stages the whole tree into
|
|
249
|
+
* the copy (reusing the stat cache, so only changed files are hashed), and
|
|
250
|
+
* `git write-tree` names the result. The real index, HEAD and the working
|
|
251
|
+
* tree are never touched; the only side effect is loose objects for changed
|
|
252
|
+
* files, which `git gc` reclaims.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} cwd
|
|
255
|
+
* @param {{ exclude?: string[] }} [opts]
|
|
256
|
+
* @returns {string | null}
|
|
257
|
+
*/
|
|
258
|
+
export function worktreeFingerprint(cwd, { exclude = [] } = {}) {
|
|
259
|
+
const top = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
260
|
+
const dir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
|
|
261
|
+
if (top.state !== GIT_STATE.OK || dir.state !== GIT_STATE.OK) return null;
|
|
262
|
+
const root = top.stdout.trim();
|
|
263
|
+
const scratch = path.join(os.tmpdir(), `doctrina-fingerprint-${process.pid}-${Date.now()}`);
|
|
264
|
+
try {
|
|
265
|
+
const real = path.join(dir.stdout.trim(), "index");
|
|
266
|
+
if (existsSync(real)) copyFileSync(real, scratch);
|
|
267
|
+
const env = { GIT_INDEX_FILE: scratch };
|
|
268
|
+
if (git(root, ["add", "-A", "--", "."], { env }).state !== GIT_STATE.OK) return null;
|
|
269
|
+
const tree = git(root, ["write-tree"], { env });
|
|
270
|
+
if (tree.state !== GIT_STATE.OK) return null;
|
|
271
|
+
// Asked of git, never computed with path.relative: on macOS the tmpdir
|
|
272
|
+
// is `/var/…`, a symlink to `/private/var/…`, and git answers with the
|
|
273
|
+
// resolved toplevel — the relative path between the two spellings is
|
|
274
|
+
// `../..`, which names nothing in the tree (the 0.18 macOS CI run).
|
|
275
|
+
const prefixed = git(cwd, ["rev-parse", "--show-prefix"]);
|
|
276
|
+
if (prefixed.state !== GIT_STATE.OK) return null;
|
|
277
|
+
const prefix = prefixed.stdout.trim().replace(/\/$/, "");
|
|
278
|
+
const listing = git(root, ["ls-tree", prefix ? `${tree.stdout.trim()}:${prefix}` : tree.stdout.trim()]);
|
|
279
|
+
if (listing.state !== GIT_STATE.OK) return null;
|
|
280
|
+
const skip = new Set(exclude);
|
|
281
|
+
const kept = listing.lines.filter((l) => !skip.has(l.split("\t")[1]));
|
|
282
|
+
return createHash("sha256").update(kept.join("\n")).digest("hex");
|
|
283
|
+
} finally {
|
|
284
|
+
rmSync(scratch, { force: true });
|
|
285
|
+
}
|
|
286
|
+
}
|
package/src/lib/metrics-model.js
CHANGED
|
@@ -48,6 +48,16 @@ export function computeSnapshot(commits, since) {
|
|
|
48
48
|
for (const f of cm.files) lastTouch.set(f, cm.ts);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Workflow adherence (change 0218): of the commits that changed code, how
|
|
52
|
+
// many carried a change — touched `.doctrina/changes/`, or named one in the
|
|
53
|
+
// subject. A field review found an agent that "knows the framework
|
|
54
|
+
// exists" and still went straight to code when the request did not name
|
|
55
|
+
// it; this is the number that says whether the hooks and the contract
|
|
56
|
+
// moved that. A proxy, like re-edit: a typo fix needs no change.
|
|
57
|
+
const code = commits.filter((cm) => cm.files.some(isCodePath));
|
|
58
|
+
const withChange = code.filter((cm) =>
|
|
59
|
+
cm.files.some((f) => f.startsWith(".doctrina/changes/")) || CHANGE_REF.test(cm.subject));
|
|
60
|
+
|
|
51
61
|
return {
|
|
52
62
|
generated: today(),
|
|
53
63
|
window: since,
|
|
@@ -57,10 +67,22 @@ export function computeSnapshot(commits, since) {
|
|
|
57
67
|
fix_share: round(fixes / total),
|
|
58
68
|
reedit_window_days: REEDIT_WINDOW_DAYS,
|
|
59
69
|
reedit_rate: round(reedits / total),
|
|
70
|
+
code_commits: code.length,
|
|
71
|
+
change_adherence: code.length > 0 ? round(withChange.length / code.length) : null,
|
|
60
72
|
top_churn: topChurn,
|
|
61
73
|
};
|
|
62
74
|
}
|
|
63
75
|
|
|
76
|
+
// A subject that names a change: "Change 0212", or an id like "0212-x".
|
|
77
|
+
const CHANGE_REF = /\bchange\s+\d{4}\b|\b\d{4}-[a-z0-9][a-z0-9-]*\b/i;
|
|
78
|
+
|
|
79
|
+
// Code, for the adherence rate: not the Doctrina tree, not an agent's own
|
|
80
|
+
// config or generated skills, not prose.
|
|
81
|
+
function isCodePath(f) {
|
|
82
|
+
if (/^\.(doctrina|agents|claude|cursor)\//.test(f)) return false;
|
|
83
|
+
return !/\.(md|mdx|txt)$/i.test(f);
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
|
|
65
87
|
export function parseLog(stdout) {
|
|
66
88
|
const commits = [];
|
|
@@ -174,7 +196,7 @@ export function latestSnapshot(projectRoot, excludeBase = null) {
|
|
|
174
196
|
* @param {ReturnType<typeof readSeries>} series
|
|
175
197
|
*/
|
|
176
198
|
export function trend(series) {
|
|
177
|
-
const rates = ["revert_rate", "fix_share", "reedit_rate"];
|
|
199
|
+
const rates = ["revert_rate", "fix_share", "reedit_rate", "change_adherence"];
|
|
178
200
|
if (series.length < 2) return { span: series.length, rows: [] };
|
|
179
201
|
const first = series[0];
|
|
180
202
|
const last = series[series.length - 1];
|
package/src/lib/playbook.js
CHANGED
|
@@ -94,4 +94,4 @@ export function printPlaybookTemplate(projectRoot, name, tokens = {}) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/** The playbooks the CLI ships and `templates check` expects to resolve. */
|
|
97
|
-
export const PLAYBOOKS = ["work", "chore", "bootstrap"];
|
|
97
|
+
export const PLAYBOOKS = ["work", "chore", "bootstrap", "consolidate", "absorb"];
|
package/src/lib/runtime.js
CHANGED
|
@@ -631,6 +631,12 @@ export function expandBraces(glob) {
|
|
|
631
631
|
// `{a,b}` alternates. Enough for the "where do my selectors live" and "which
|
|
632
632
|
// code is mine" declarations, with no dependency and no surprises.
|
|
633
633
|
export function globToRegExp(glob) {
|
|
634
|
+
// A pattern ending in `/` names a DIRECTORY, and a directory claims what
|
|
635
|
+
// is under it (change 0199). Compiled as written it matched only the
|
|
636
|
+
// literal path `src/health/` — which no file has — so the most natural way
|
|
637
|
+
// to declare a module's ownership claimed nothing, and `review` reported
|
|
638
|
+
// every file in it as ownerless.
|
|
639
|
+
if (/\/$/.test(String(glob))) glob = `${glob}**`;
|
|
634
640
|
// Always compile the EXPANSION, never the original. Using it only when it
|
|
635
641
|
// produced more than one alternative left `src/{a}.js` compiling with its
|
|
636
642
|
// braces as literal characters, so a one-element group matched nothing
|
package/src/lib/scan.js
CHANGED
|
@@ -599,3 +599,16 @@ function artifactPathOf(file) {
|
|
|
599
599
|
if (m) return m[1];
|
|
600
600
|
return null;
|
|
601
601
|
}
|
|
602
|
+
|
|
603
|
+
// The capabilities a change's deltas target, sorted — the change's own scope.
|
|
604
|
+
// `close` gates coverage on it and `review --change` reads it as "these specs
|
|
605
|
+
// are being updated by this change", because the delta merges at apply.
|
|
606
|
+
export function deltaCapabilities(changeDir) {
|
|
607
|
+
const caps = new Set();
|
|
608
|
+
for (const deltaPath of walk(path.join(changeDir, "specs"))) {
|
|
609
|
+
if (!deltaPath.endsWith("delta.md")) continue;
|
|
610
|
+
const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
|
|
611
|
+
if (cap) caps.add(cap);
|
|
612
|
+
}
|
|
613
|
+
return [...caps].sort();
|
|
614
|
+
}
|
package/src/lib/signoff.js
CHANGED
|
@@ -96,6 +96,30 @@ export function changedSince(projectRoot, sha, paths) {
|
|
|
96
96
|
return [...out].sort();
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Which of `paths` the uncommitted work touches — edits against HEAD plus
|
|
101
|
+
* untracked files, `.gitignore` respected (change 0204). The same pathspec
|
|
102
|
+
* reading a sign-off's "paths" gets, so "what this check covers" means one
|
|
103
|
+
* thing in verify.json. Null when git cannot answer: a caller then runs the
|
|
104
|
+
* check rather than reading "nothing changed" out of a question it never
|
|
105
|
+
* got to ask.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} projectRoot
|
|
108
|
+
* @param {string[]} paths
|
|
109
|
+
* @returns {string[] | null}
|
|
110
|
+
*/
|
|
111
|
+
export function workingChanges(projectRoot, paths) {
|
|
112
|
+
const tracked = git(projectRoot, ["diff", "--name-only", "HEAD", "--", ...paths]);
|
|
113
|
+
// No commit yet: every file is new, so what is staged counts as changed.
|
|
114
|
+
const staged = tracked.state === GIT_STATE.EMPTY
|
|
115
|
+
? git(projectRoot, ["ls-files", "--cached", "--", ...paths])
|
|
116
|
+
: tracked;
|
|
117
|
+
if (staged.state !== GIT_STATE.OK) return null;
|
|
118
|
+
const untracked = git(projectRoot, ["ls-files", "--others", "--exclude-standard", "--", ...paths]);
|
|
119
|
+
if (untracked.state !== GIT_STATE.OK) return null;
|
|
120
|
+
return [...new Set([...staged.lines, ...untracked.lines].filter(Boolean))].sort();
|
|
121
|
+
}
|
|
122
|
+
|
|
99
123
|
/** The commit a signature is anchored to, or null outside a repository. */
|
|
100
124
|
export function currentSha(projectRoot) {
|
|
101
125
|
const r = git(projectRoot, ["rev-parse", "HEAD"]);
|