doctrina-cli 0.18.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 +3 -2
- package/package.json +1 -1
- package/src/commands/adapter.js +74 -0
- package/src/commands/analyze.js +14 -3
- package/src/commands/change.js +73 -4
- package/src/commands/close.js +18 -1
- 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/prime.js +76 -7
- package/src/commands/upgrade.js +15 -0
- package/src/commands/work.js +86 -2
- package/src/commands/workspace.js +409 -0
- package/src/index.js +5 -7
- package/src/lib/actions.js +1 -1
- package/src/lib/adapters.js +80 -0
- package/src/lib/code-inventory.js +297 -0
- package/src/lib/commands.js +44 -5
- package/src/lib/context-slice.js +118 -0
- package/src/lib/delivery.js +208 -0
- package/src/lib/metrics-model.js +23 -1
- package/src/lib/playbook.js +1 -1
- package/src/lib/skill-export.js +95 -0
- package/src/lib/snapshot.js +39 -2
- package/src/lib/task-card.js +102 -0
- package/src/lib/views.js +51 -2
- package/src/lib/work-model.js +58 -0
- 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 +9 -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/delivery.md.template +39 -0
- package/templates/playbooks/absorb.md.template +40 -0
- package/templates/playbooks/delivery.md.template +38 -0
- package/templates/playbooks/work.md.template +1 -1
|
@@ -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/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", "consolidate"];
|
|
97
|
+
export const PLAYBOOKS = ["work", "chore", "bootstrap", "consolidate", "absorb"];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { isDir, isFile, pruneEmptyDirs, read, remove, write, walk } from "./fs-ops.js";
|
|
5
|
+
import { parseFrontmatter } from "./doc-model.js";
|
|
6
|
+
import { describeAdapter, ADAPTER_STATES } from "./adapters.js";
|
|
7
|
+
|
|
8
|
+
// The project's skills, where the agent looks for skills (change 0212).
|
|
9
|
+
//
|
|
10
|
+
// A field review of an agent in Cursor: the editor's skill catalogue lists
|
|
11
|
+
// `~/.cursor/skills` and plugins, never `.doctrina/skills/`, so a skill that
|
|
12
|
+
// matched the task fired only when the request named its file. AGENTS.md
|
|
13
|
+
// said "skills are on demand"; nothing said the editor would never offer
|
|
14
|
+
// them. The agents now share a format — a folder per skill holding a
|
|
15
|
+
// SKILL.md with `name` and `description` — and discover it on their own:
|
|
16
|
+
// `.agents/skills/` (Cursor, Codex) and `.claude/skills/` (Claude Code;
|
|
17
|
+
// Cursor reads it too). `.doctrina/skills/` stays the one source; the
|
|
18
|
+
// exports are generated from it, marked as generated, and pruned with it.
|
|
19
|
+
|
|
20
|
+
export const EXPORT_MARK = "<!-- doctrina:skill-export";
|
|
21
|
+
const DESCRIPTION_MAX = 1024;
|
|
22
|
+
|
|
23
|
+
/** Where this project's skills are exported, as project-relative roots. */
|
|
24
|
+
export function exportRoots(projectRoot) {
|
|
25
|
+
const roots = [".agents/skills"];
|
|
26
|
+
if (describeAdapter(projectRoot, "claude").state === ADAPTER_STATES.INSTALLED) roots.push(".claude/skills");
|
|
27
|
+
return roots;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The SKILL.md a Doctrina skill becomes. */
|
|
31
|
+
export function renderSkillExport(name, text) {
|
|
32
|
+
const body = text.replace(/\r\n/g, "\n");
|
|
33
|
+
const description = parseFrontmatter(body, "description") ?? name;
|
|
34
|
+
const when = parseFrontmatter(body, "when");
|
|
35
|
+
// The agent decides from the description alone, so the trigger goes in it.
|
|
36
|
+
const full = (when ? `${description} Use when: ${when}` : description).replace(/\s+/g, " ").trim();
|
|
37
|
+
const clipped = full.length > DESCRIPTION_MAX ? `${full.slice(0, DESCRIPTION_MAX - 1)}…` : full;
|
|
38
|
+
const rest = body.replace(/^---\n[\s\S]*?\n---\n/, "").replace(/^\n+/, "");
|
|
39
|
+
return [
|
|
40
|
+
"---",
|
|
41
|
+
`name: ${name}`,
|
|
42
|
+
`description: ${JSON.stringify(clipped)}`,
|
|
43
|
+
"---",
|
|
44
|
+
`${EXPORT_MARK} — generated from .doctrina/skills/${name}.md by \`doctrina index rebuild\`; edit that file, not this one. -->`,
|
|
45
|
+
"",
|
|
46
|
+
rest,
|
|
47
|
+
].join("\n").replace(/\n*$/, "\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What the export would change: files to write (missing or different) and
|
|
52
|
+
* generated skill folders to remove (their source is gone). Folders not
|
|
53
|
+
* carrying the generated mark are someone else's and never touched.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} projectRoot
|
|
56
|
+
* @returns {{ writes: Array<{ rel: string, body: string }>, removes: string[] }}
|
|
57
|
+
*/
|
|
58
|
+
export function planSkillExport(projectRoot) {
|
|
59
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
60
|
+
const skills = isDir(skillsDir)
|
|
61
|
+
? walk(skillsDir).filter((f) => f.endsWith(".md") && path.dirname(f) === skillsDir)
|
|
62
|
+
: [];
|
|
63
|
+
const wanted = new Map(skills.map((f) => [path.basename(f, ".md"), read(f)]));
|
|
64
|
+
const writes = [];
|
|
65
|
+
const removes = [];
|
|
66
|
+
if (wanted.size === 0 && !exportRoots(projectRoot).some((r) => isDir(path.join(projectRoot, r)))) {
|
|
67
|
+
return { writes, removes };
|
|
68
|
+
}
|
|
69
|
+
for (const root of exportRoots(projectRoot)) {
|
|
70
|
+
for (const [name, text] of wanted) {
|
|
71
|
+
const rel = `${root}/${name}/SKILL.md`;
|
|
72
|
+
const body = renderSkillExport(name, text);
|
|
73
|
+
const abs = path.join(projectRoot, rel);
|
|
74
|
+
if (!isFile(abs) || read(abs).replace(/\r\n/g, "\n") !== body) writes.push({ rel, body });
|
|
75
|
+
}
|
|
76
|
+
const rootAbs = path.join(projectRoot, root);
|
|
77
|
+
if (!isDir(rootAbs)) continue;
|
|
78
|
+
for (const entry of readdirSync(rootAbs)) {
|
|
79
|
+
const skillFile = path.join(rootAbs, entry, "SKILL.md");
|
|
80
|
+
if (wanted.has(entry) || !isFile(skillFile)) continue;
|
|
81
|
+
if (read(skillFile).includes(EXPORT_MARK)) removes.push(`${root}/${entry}/SKILL.md`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { writes, removes };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Apply a plan; returns it. */
|
|
88
|
+
export function applySkillExport(projectRoot, plan = planSkillExport(projectRoot)) {
|
|
89
|
+
for (const w of plan.writes) write(path.join(projectRoot, w.rel), w.body, { force: true });
|
|
90
|
+
for (const r of plan.removes) {
|
|
91
|
+
remove(path.join(projectRoot, r)); // the generated file only
|
|
92
|
+
pruneEmptyDirs(path.dirname(path.join(projectRoot, r)), projectRoot);
|
|
93
|
+
}
|
|
94
|
+
return plan;
|
|
95
|
+
}
|
package/src/lib/snapshot.js
CHANGED
|
@@ -4,7 +4,7 @@ import { readdirSync } from "node:fs";
|
|
|
4
4
|
import { isDir, isFile, read, walk } from "./fs-ops.js";
|
|
5
5
|
import { projectName } from "./project.js";
|
|
6
6
|
import * as idx from "./index-json.js";
|
|
7
|
-
import { deriveIndex, indexesMatch, specHeader, listHeader } from "./scan.js";
|
|
7
|
+
import { deriveIndex, indexesMatch, specHeader, listHeader, deltaCapabilities } from "./scan.js";
|
|
8
8
|
import { cliVersion } from "./version.js";
|
|
9
9
|
import { summarize as coverageSummary } from "./coverage-model.js";
|
|
10
10
|
import { summarize as traceSummary } from "./trace-model.js";
|
|
@@ -72,6 +72,7 @@ export function collectSnapshot(projectRoot, { actions = true } = {}) {
|
|
|
72
72
|
decisions: countDecisions(projectRoot),
|
|
73
73
|
skills: countSkills(projectRoot),
|
|
74
74
|
openChanges: openChanges(projectRoot),
|
|
75
|
+
recentlyClosed: recentlyClosed(projectRoot),
|
|
75
76
|
adrs: acceptedDecisions(projectRoot),
|
|
76
77
|
nonGoals: productSection(projectRoot, "Non-goals"),
|
|
77
78
|
hasNonGoalsSection: hasProductSection(projectRoot, "Non-goals"),
|
|
@@ -151,7 +152,43 @@ export function openChanges(projectRoot) {
|
|
|
151
152
|
const tasksDone = progress.done;
|
|
152
153
|
const unchecked = progress.open.map((t) => t || "(unwritten task — the scaffold's placeholder)");
|
|
153
154
|
const date = proposal ? (listHeader(proposal, "Date") ?? null) : null;
|
|
154
|
-
|
|
155
|
+
// The capabilities its deltas target — what `prime` turns into the
|
|
156
|
+
// exact read command for the change (change 0217).
|
|
157
|
+
const capabilities = deltaCapabilities(path.join(changesDir, id));
|
|
158
|
+
out.push({ id, title, status, tasksDone, tasksTotal, unchecked, date, ageDays: ageInDays(date), capabilities });
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// How far back `prime` and the pulse look for closed changes (change 0214).
|
|
164
|
+
export const RECENT_CLOSE_DAYS = 7;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Changes archived in the last RECENT_CLOSE_DAYS, newest first: what the
|
|
168
|
+
* human may ask for again without knowing it is done. A field review: the
|
|
169
|
+
* close moved the work to `changes/archive/`, out of every default read, and
|
|
170
|
+
* the next request was to "create the doctrina work" for it.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} projectRoot
|
|
173
|
+
* @param {{ limit?: number }} [opts]
|
|
174
|
+
* @returns {Array<{ id: string, title: string|null, date: string, path: string, ageDays: number|null }>}
|
|
175
|
+
*/
|
|
176
|
+
export function recentlyClosed(projectRoot, { limit = 3 } = {}) {
|
|
177
|
+
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
178
|
+
if (!isDir(archiveDir)) return [];
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const name of readdirSync(archiveDir).sort().reverse()) {
|
|
181
|
+
const m = name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
|
|
182
|
+
if (!m || !isDir(path.join(archiveDir, name))) continue;
|
|
183
|
+
const ageDays = ageInDays(m[1]);
|
|
184
|
+
if (ageDays === null || ageDays > RECENT_CLOSE_DAYS) break;
|
|
185
|
+
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
186
|
+
out.push({
|
|
187
|
+
id: m[2], date: m[1], ageDays,
|
|
188
|
+
title: isFile(proposal) ? parseChangeTitle(read(proposal)) : null,
|
|
189
|
+
path: `.doctrina/changes/archive/${name}/`,
|
|
190
|
+
});
|
|
191
|
+
if (out.length >= limit) break;
|
|
155
192
|
}
|
|
156
193
|
return out;
|
|
157
194
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @ts-check
|
|
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 { sliceSpec } from "./context-slice.js";
|
|
7
|
+
import { terms } from "./lexicon.js";
|
|
8
|
+
|
|
9
|
+
// A TASK CARD (change 0222, ADR 0030): one task of a change, carrying what
|
|
10
|
+
// an executor needs to do it ALONE — the task, the lines indented under it
|
|
11
|
+
// (files, requirement, verify command), why the change exists, and the spec
|
|
12
|
+
// cut to the task. A subagent does not see the planner's conversation, so
|
|
13
|
+
// the card is the whole of its context; a card that needs the conversation
|
|
14
|
+
// is a card the planner has not finished writing.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The tasks.md boxes, each with the indented lines under it.
|
|
18
|
+
* Ordinals are the same ones `change tick` uses (tasks.md comes first).
|
|
19
|
+
*/
|
|
20
|
+
export function taskBoxes(tasksText) {
|
|
21
|
+
const lines = String(tasksText).replace(/\r\n/g, "\n").split("\n");
|
|
22
|
+
return parseChecklist(tasksText).map((box, i) => {
|
|
23
|
+
const indent = (lines[box.line].match(/^\s*/)?.[0] ?? "").length;
|
|
24
|
+
const details = [];
|
|
25
|
+
for (let j = box.line + 1; j < lines.length; j++) {
|
|
26
|
+
const l = lines[j];
|
|
27
|
+
if (l.trim() === "") {
|
|
28
|
+
if (details.length) break;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if ((l.match(/^\s*/)?.[0] ?? "").length <= indent) break;
|
|
32
|
+
details.push(l.trim().replace(/^[-*]\s+/, ""));
|
|
33
|
+
}
|
|
34
|
+
return { ordinal: i + 1, text: box.text, checked: box.checked, placeholder: box.placeholder, details };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The `Field: value` lines of a card's details, keyed by lowercased field. */
|
|
39
|
+
export function cardFields(details) {
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const d of details) {
|
|
42
|
+
const m = d.match(/^([A-Za-z][A-Za-z ]{0,20}):\s*(.+)$/);
|
|
43
|
+
if (m) out[m[1].trim().toLowerCase()] = m[2].trim();
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function firstParagraph(text, heading) {
|
|
49
|
+
const m = String(text).match(new RegExp(`^## ${heading}\\s*\\n+([\\s\\S]*?)(?:\\n\\s*\\n|\\n## |$)`, "m"));
|
|
50
|
+
return m ? m[1].replace(/<!--[\s\S]*?-->/g, "").trim() : "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The card for task `ordinal` of a change — or, with no ordinal, the first
|
|
55
|
+
* open one. Null `card` with a reason when there is none to give.
|
|
56
|
+
* @returns {{ card: null, reason: string } | { card: object, reason: null }}
|
|
57
|
+
*/
|
|
58
|
+
export function taskCard(projectRoot, changeId, ordinal = null) {
|
|
59
|
+
const changeDir = path.join(projectRoot, ".doctrina", "changes", changeId);
|
|
60
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
61
|
+
if (!isFile(tasksPath)) return { card: null, reason: "the change has no tasks.md" };
|
|
62
|
+
const boxes = taskBoxes(read(tasksPath));
|
|
63
|
+
if (boxes.length === 0) return { card: null, reason: "tasks.md has no task box" };
|
|
64
|
+
let box;
|
|
65
|
+
if (ordinal === null) {
|
|
66
|
+
box = boxes.find((b) => !b.checked && !b.placeholder);
|
|
67
|
+
if (!box) return { card: null, reason: "every task is ticked — the change is ready for review and close" };
|
|
68
|
+
} else {
|
|
69
|
+
box = boxes.find((b) => b.ordinal === ordinal);
|
|
70
|
+
if (!box) return { card: null, reason: `no task ${ordinal} (tasks.md has ${boxes.length})` };
|
|
71
|
+
}
|
|
72
|
+
if (box.placeholder) return { card: null, reason: `task ${box.ordinal} is a scaffold placeholder — write the task first` };
|
|
73
|
+
// CRLF on a Windows checkout: the Why is read paragraph by paragraph.
|
|
74
|
+
const proposal = isFile(path.join(changeDir, "proposal.md")) ? read(path.join(changeDir, "proposal.md")).replace(/\r\n/g, "\n") : "";
|
|
75
|
+
const fields = cardFields(box.details);
|
|
76
|
+
const query = terms([box.text, ...box.details].join(" "));
|
|
77
|
+
const slices = [];
|
|
78
|
+
for (const cap of deltaCapabilities(changeDir)) {
|
|
79
|
+
const rel = `.doctrina/specs/${cap}/spec.md`;
|
|
80
|
+
const specPath = path.join(projectRoot, rel);
|
|
81
|
+
if (!isFile(specPath)) continue;
|
|
82
|
+
const slice = sliceSpec(read(specPath), query, rel);
|
|
83
|
+
if (slice) slices.push({ capability: cap, ...slice });
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
card: {
|
|
87
|
+
change: changeId,
|
|
88
|
+
title: parseChangeTitle(proposal) ?? changeId,
|
|
89
|
+
delivery: listHeader(proposal, "Delivery") ?? null,
|
|
90
|
+
ordinal: box.ordinal,
|
|
91
|
+
total: boxes.length,
|
|
92
|
+
done: boxes.filter((b) => b.checked).length,
|
|
93
|
+
checked: box.checked,
|
|
94
|
+
text: box.text,
|
|
95
|
+
details: box.details,
|
|
96
|
+
fields,
|
|
97
|
+
why: firstParagraph(proposal, "Why"),
|
|
98
|
+
slices,
|
|
99
|
+
},
|
|
100
|
+
reason: null,
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/lib/views.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { c } from "./colors.js";
|
|
3
3
|
import { today } from "./dates.js";
|
|
4
4
|
import { churnByCapability } from "./ledger.js";
|
|
5
|
-
import { STALE_CHANGE_DAYS } from "./snapshot.js";
|
|
5
|
+
import { STALE_CHANGE_DAYS, RECENT_CLOSE_DAYS } from "./snapshot.js";
|
|
6
6
|
|
|
7
7
|
// Executed proof and signed proof are different claims (change 0039). One
|
|
8
8
|
// phrasing, used by every view, so none of them can quietly add the two
|
|
@@ -191,6 +191,16 @@ export function prime(s) {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// What was just finished, where it went (change 0214): archived work is out
|
|
195
|
+
// of every default read, so without this line it looks undone.
|
|
196
|
+
if ((s.recentlyClosed ?? []).length > 0) {
|
|
197
|
+
out.push("");
|
|
198
|
+
out.push(c.bold("Recently closed") + c.gray(` (last ${RECENT_CLOSE_DAYS} days)`));
|
|
199
|
+
for (const ch of s.recentlyClosed) {
|
|
200
|
+
out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.date} → ${ch.path})`)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
194
204
|
out.push("");
|
|
195
205
|
out.push(c.bold("Next"));
|
|
196
206
|
if (s.actions.length === 0) {
|
|
@@ -201,10 +211,49 @@ export function prime(s) {
|
|
|
201
211
|
}
|
|
202
212
|
|
|
203
213
|
out.push("");
|
|
204
|
-
|
|
214
|
+
// The read for the work in hand, as a command to copy (change 0217): a
|
|
215
|
+
// field review found `prime` taken as enough and `context` skipped, and a
|
|
216
|
+
// generic "read deeper" is the line a reader skips first.
|
|
217
|
+
const focus = s.openChanges.find((ch) => (ch.capabilities ?? []).length > 0);
|
|
218
|
+
if (focus) {
|
|
219
|
+
const query = String(focus.title ?? focus.id).replace(/["`]/g, "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
220
|
+
out.push(c.bold("Read for ") + c.cyan(focus.id) + ": " +
|
|
221
|
+
c.cyan(`doctrina context ${focus.capabilities[0]} --for "${query}" --concat`) +
|
|
222
|
+
(focus.capabilities.length > 1 ? c.gray(` (also: ${focus.capabilities.slice(1).join(", ")})`) : ""));
|
|
223
|
+
}
|
|
224
|
+
out.push(c.gray("Read deeper: `doctrina context <cap> --for \"<task>\" --concat` · `doctrina why <cap>` · `doctrina show <ref>`"));
|
|
205
225
|
return out;
|
|
206
226
|
}
|
|
207
227
|
|
|
228
|
+
// ------------------------------------------------------------------- pulse
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The one fact an agent needs with EVERY prompt (change 0211): whether a
|
|
232
|
+
* change is open, which, and that a request changing behaviour opens one.
|
|
233
|
+
* A field review: "without the framework's name in the request, the request
|
|
234
|
+
* for code wins" — the cycle sat in AGENTS.md, read once, against the
|
|
235
|
+
* request read now. A UserPromptSubmit hook prints this beside each prompt.
|
|
236
|
+
* Worded as facts, not orders: an agent's prompt-injection defences treat
|
|
237
|
+
* imperatives from a hook with suspicion, and they should.
|
|
238
|
+
*
|
|
239
|
+
* @param {Array<{id: string, title: string|null, status: string}>} open
|
|
240
|
+
* @param {Array<{id: string, date: string, path: string}>} [recent]
|
|
241
|
+
*/
|
|
242
|
+
export function pulse(open, recent = []) {
|
|
243
|
+
if (open.length === 0) {
|
|
244
|
+
// The last closed change, so "do the work for X" is answered "already
|
|
245
|
+
// done, archived at …" instead of done twice (change 0214).
|
|
246
|
+
const last = recent[0] ? ` Last closed: ${recent[0].id} (${recent[0].date}, ${recent[0].path}).` : "";
|
|
247
|
+
return ["Doctrina: no change is open in this project. A request that changes behaviour is opened as a change " +
|
|
248
|
+
"before code is edited — `doctrina work \"<request>\"` (it classifies the lane itself); a question or a read needs none." + last];
|
|
249
|
+
}
|
|
250
|
+
const named = open.slice(0, 3).map((ch) => `${ch.id}${ch.title ? ` — "${ch.title}"` : ""} (${ch.status})`).join("; ");
|
|
251
|
+
const more = open.length > 3 ? ` and ${open.length - 3} more` : "";
|
|
252
|
+
return [`Doctrina: open change${open.length === 1 ? "" : "s"}: ${named}${more}. Work that belongs to ${open.length === 1 ? "it" : "one of them"} ` +
|
|
253
|
+
"is named by its id in the first sentence to the human and finishes with `doctrina close <id>`; " +
|
|
254
|
+
"a different request is its own change (`doctrina work \"<request>\"`)."];
|
|
255
|
+
}
|
|
256
|
+
|
|
208
257
|
// ----------------------------------------------------------------- handoff
|
|
209
258
|
|
|
210
259
|
export function handoff(s) {
|
package/src/lib/work-model.js
CHANGED
|
@@ -120,3 +120,61 @@ export function rankCapabilities(projectRoot, prompt) {
|
|
|
120
120
|
// 0, the same convention `triage` uses for a lane nothing scored against.
|
|
121
121
|
return sorted.slice(0, 3).map((e, i) => ({ ...e, margin: e.score - (sorted[i + 1]?.score ?? 0) }));
|
|
122
122
|
}
|
|
123
|
+
|
|
124
|
+
// How far back `work` looks for an archived change the prompt may repeat,
|
|
125
|
+
// and how much of one side the other must cover to count (change 0215).
|
|
126
|
+
export const SIMILAR_CHANGE_DAYS = 14;
|
|
127
|
+
// Three shared terms: two matched "mudar uma coisa" with "mudar outra coisa".
|
|
128
|
+
const SIMILAR_MIN_SHARED = 3;
|
|
129
|
+
const SIMILAR_MIN_COVER = 0.6;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Open changes, and changes archived in the last SIMILAR_CHANGE_DAYS, whose
|
|
133
|
+
* title and Why the prompt mostly repeats. A field review: two changes were
|
|
134
|
+
* done and archived, and the next request — "create the doctrina work for
|
|
135
|
+
* payment and admin" — would have opened the same work twice. Deterministic
|
|
136
|
+
* term overlap (ADR 0005), measured from both sides: a short prompt that
|
|
137
|
+
* names most of a change, or a change named mostly by a long prompt.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} projectRoot
|
|
140
|
+
* @param {string} prompt
|
|
141
|
+
* @returns {Array<{ id: string, title: string, where: "open"|"archived", path: string, shared: string[] }>}
|
|
142
|
+
*/
|
|
143
|
+
export function similarChanges(projectRoot, prompt) {
|
|
144
|
+
const asked = terms(prompt);
|
|
145
|
+
if (asked.length === 0) return [];
|
|
146
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
147
|
+
if (!isDir(changesDir)) return [];
|
|
148
|
+
const candidates = [];
|
|
149
|
+
for (const name of readdirSync(changesDir).sort()) {
|
|
150
|
+
if (name === "archive" || name.startsWith(".")) continue;
|
|
151
|
+
const proposal = path.join(changesDir, name, "proposal.md");
|
|
152
|
+
if (isFile(proposal)) candidates.push({ id: name, where: /** @type {"open"|"archived"} */ ("open"), rel: `.doctrina/changes/${name}/`, text: read(proposal) });
|
|
153
|
+
}
|
|
154
|
+
const archiveDir = path.join(changesDir, "archive");
|
|
155
|
+
if (isDir(archiveDir)) {
|
|
156
|
+
const cutoff = Date.now() - SIMILAR_CHANGE_DAYS * 86400000;
|
|
157
|
+
for (const name of readdirSync(archiveDir)) {
|
|
158
|
+
const m = name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
|
|
159
|
+
if (!m || Date.parse(`${m[1]}T00:00:00Z`) < cutoff) continue;
|
|
160
|
+
const proposal = path.join(archiveDir, name, "proposal.md");
|
|
161
|
+
if (isFile(proposal)) candidates.push({ id: m[2], where: /** @type {"open"|"archived"} */ ("archived"), rel: `.doctrina/changes/archive/${name}/`, text: read(proposal) });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const cand of candidates) {
|
|
166
|
+
const title = (cand.text.match(/^#\s+Change\s+\S+\s+—\s+(.+)$/m)?.[1] ?? cand.id).trim();
|
|
167
|
+
const why = cand.text.match(/##\s*Why\s*\r?\n+([^\r\n]+)/)?.[1] ?? "";
|
|
168
|
+
const theirs = terms(`${cand.id.replace(/^\d+-/, "").replace(/-/g, " ")} ${title} ${why}`);
|
|
169
|
+
const shared = asked.filter((t) => theirs.includes(t));
|
|
170
|
+
if (shared.length < SIMILAR_MIN_SHARED) continue;
|
|
171
|
+
// Each side's cover counts only the terms on that side: a word the Why
|
|
172
|
+
// happens to share must not stand in for the title (a prompt about "the
|
|
173
|
+
// work" matched "trabalho aparece para o humano" through its Why).
|
|
174
|
+
const titleTerms = terms(title);
|
|
175
|
+
const titleShared = asked.filter((t) => titleTerms.includes(t));
|
|
176
|
+
const cover = Math.max(shared.length / asked.length, titleTerms.length > 0 ? titleShared.length / titleTerms.length : 0);
|
|
177
|
+
if (cover >= SIMILAR_MIN_COVER) out.push({ id: cand.id, title, where: cand.where, path: cand.rel, shared, cover });
|
|
178
|
+
}
|
|
179
|
+
return out.sort((a, b) => b.cover - a.cover).map(({ cover: _c, ...rest }) => rest);
|
|
180
|
+
}
|