doctrina-cli 0.18.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +84 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +164 -17
  6. package/src/commands/clarify.js +1 -204
  7. package/src/commands/close.js +18 -1
  8. package/src/commands/context.js +81 -9
  9. package/src/commands/contract.js +1 -0
  10. package/src/commands/delivery.js +356 -0
  11. package/src/commands/doctor.js +21 -1
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/index-rebuild.js +23 -0
  14. package/src/commands/init.js +28 -1
  15. package/src/commands/intake.js +60 -3
  16. package/src/commands/metrics.js +43 -3
  17. package/src/commands/next.js +41 -6
  18. package/src/commands/prime.js +125 -7
  19. package/src/commands/review.js +39 -3
  20. package/src/commands/skill.js +2 -1
  21. package/src/commands/status.js +28 -0
  22. package/src/commands/upgrade.js +33 -0
  23. package/src/commands/validate.js +18 -0
  24. package/src/commands/verify.js +32 -4
  25. package/src/commands/work.js +126 -7
  26. package/src/commands/workspace.js +419 -0
  27. package/src/index.js +100 -66
  28. package/src/lib/actions.js +44 -4
  29. package/src/lib/adapters.js +143 -1
  30. package/src/lib/args.js +33 -1
  31. package/src/lib/clarify-scan.js +221 -0
  32. package/src/lib/code-inventory.js +770 -0
  33. package/src/lib/commands.js +52 -5
  34. package/src/lib/context-slice.js +149 -0
  35. package/src/lib/delivery.js +286 -0
  36. package/src/lib/docs-impact.js +36 -1
  37. package/src/lib/fs-ops.js +23 -0
  38. package/src/lib/intake-model.js +1 -1
  39. package/src/lib/json-out.js +9 -1
  40. package/src/lib/metrics-model.js +23 -1
  41. package/src/lib/playbook.js +1 -1
  42. package/src/lib/shell.js +45 -0
  43. package/src/lib/skill-export.js +95 -0
  44. package/src/lib/snapshot.js +43 -2
  45. package/src/lib/task-card.js +195 -0
  46. package/src/lib/views.js +52 -3
  47. package/src/lib/work-model.js +66 -2
  48. package/src/lib/workspace-agents.js +306 -0
  49. package/src/lib/workspace-guard.js +65 -0
  50. package/src/lib/workspace-locate.js +141 -0
  51. package/src/lib/workspace-tools.js +160 -0
  52. package/src/lib/workspace-view.js +391 -0
  53. package/src/lib/workspace.js +323 -0
  54. package/templates/AGENTS.md.template +17 -14
  55. package/templates/README.md +11 -1
  56. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  57. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  58. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  59. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  60. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  61. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  62. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  63. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  64. package/templates/delivery.md.template +39 -0
  65. package/templates/playbooks/absorb.md.template +63 -0
  66. package/templates/playbooks/bootstrap.md.template +18 -6
  67. package/templates/playbooks/delivery.md.template +44 -0
  68. package/templates/playbooks/work.md.template +1 -1
@@ -71,12 +71,14 @@ export function deferJson() {
71
71
  * Write the held payload with the exit code the command actually returned,
72
72
  * and leave deferred mode. A no-op when the command emitted nothing.
73
73
  */
74
+ /** Write the held payload; false when the command held none. */
74
75
  export function flushJson(code) {
75
76
  deferring = false;
76
- if (!held) return;
77
+ if (!held) return false;
77
78
  const { command, data } = held;
78
79
  held = null;
79
80
  writeEnvelope(command, data, { ok: code === EXIT.OK, exitCode: code });
81
+ return true;
80
82
  }
81
83
 
82
84
  // Print a structured payload. `command` is the invocation, `data` whatever
@@ -165,6 +167,12 @@ export async function captureOutput(fn) {
165
167
  try {
166
168
  const code = await fn();
167
169
  return { code: code ?? EXIT.OK, stdout, stderr };
170
+ } catch (err) {
171
+ // A thrown precondition still answers inside the envelope (change 0234):
172
+ // it escaped as English on stderr, with no JSON on stdout at all.
173
+ stderr.push(`error: ${err?.message ?? err}`);
174
+ if (err?.remedy) stderr.push(`hint: run ${err.remedy} first, then retry`);
175
+ return { code: err?.exitCode ?? EXIT.GATE, stdout, stderr, error: err };
168
176
  } finally {
169
177
  console.log = origLog;
170
178
  console.error = origError;
@@ -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];
@@ -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,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
+ }
@@ -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
+ }
@@ -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,47 @@ 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
- out.push({ id, title, status, tasksDone, tasksTotal, unchecked, date, ageDays: ageInDays(date) });
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
+ // 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 });
163
+ }
164
+ return out;
165
+ }
166
+
167
+ // How far back `prime` and the pulse look for closed changes (change 0214).
168
+ export const RECENT_CLOSE_DAYS = 7;
169
+
170
+ /**
171
+ * Changes archived in the last RECENT_CLOSE_DAYS, newest first: what the
172
+ * human may ask for again without knowing it is done. A field review: the
173
+ * close moved the work to `changes/archive/`, out of every default read, and
174
+ * the next request was to "create the doctrina work" for it.
175
+ *
176
+ * @param {string} projectRoot
177
+ * @param {{ limit?: number }} [opts]
178
+ * @returns {Array<{ id: string, title: string|null, date: string, path: string, ageDays: number|null }>}
179
+ */
180
+ export function recentlyClosed(projectRoot, { limit = 3 } = {}) {
181
+ const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
182
+ if (!isDir(archiveDir)) return [];
183
+ const out = [];
184
+ for (const name of readdirSync(archiveDir).sort().reverse()) {
185
+ const m = name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
186
+ if (!m || !isDir(path.join(archiveDir, name))) continue;
187
+ const ageDays = ageInDays(m[1]);
188
+ if (ageDays === null || ageDays > RECENT_CLOSE_DAYS) break;
189
+ const proposal = path.join(archiveDir, name, "proposal.md");
190
+ out.push({
191
+ id: m[2], date: m[1], ageDays,
192
+ title: isFile(proposal) ? parseChangeTitle(read(proposal)) : null,
193
+ path: `.doctrina/changes/archive/${name}/`,
194
+ });
195
+ if (out.length >= limit) break;
155
196
  }
156
197
  return out;
157
198
  }
@@ -0,0 +1,195 @@
1
+ // @ts-check
2
+ import path from "node:path";
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";
10
+ import { terms } from "./lexicon.js";
11
+
12
+ // A TASK CARD (change 0222, ADR 0030): one task of a change, carrying what
13
+ // an executor needs to do it ALONE — the task, the lines indented under it
14
+ // (files, requirement, verify command), why the change exists, and the spec
15
+ // cut to the task. A subagent does not see the planner's conversation, so
16
+ // the card is the whole of its context; a card that needs the conversation
17
+ // is a card the planner has not finished writing.
18
+
19
+ /**
20
+ * The tasks.md boxes, each with the indented lines under it.
21
+ * Ordinals are the same ones `change tick` uses (tasks.md comes first).
22
+ */
23
+ export function taskBoxes(tasksText) {
24
+ const lines = String(tasksText).replace(/\r\n/g, "\n").split("\n");
25
+ return parseChecklist(tasksText).map((box, i) => {
26
+ const indent = (lines[box.line].match(/^\s*/)?.[0] ?? "").length;
27
+ const details = [];
28
+ for (let j = box.line + 1; j < lines.length; j++) {
29
+ const l = lines[j];
30
+ if (l.trim() === "") {
31
+ if (details.length) break;
32
+ continue;
33
+ }
34
+ if ((l.match(/^\s*/)?.[0] ?? "").length <= indent) break;
35
+ details.push(l.trim().replace(/^[-*]\s+/, ""));
36
+ }
37
+ return { ordinal: i + 1, text: box.text, checked: box.checked, placeholder: box.placeholder, details };
38
+ });
39
+ }
40
+
41
+ /** The `Field: value` lines of a card's details, keyed by lowercased field. */
42
+ export function cardFields(details) {
43
+ const out = {};
44
+ for (const d of details) {
45
+ const m = d.match(/^([A-Za-z][A-Za-z ]{0,20}):\s*(.+)$/);
46
+ if (m) out[m[1].trim().toLowerCase()] = m[2].trim();
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function firstParagraph(text, heading) {
52
+ const m = String(text).match(new RegExp(`^## ${heading}\\s*\\n+([\\s\\S]*?)(?:\\n\\s*\\n|\\n## |$)`, "m"));
53
+ return m ? m[1].replace(/<!--[\s\S]*?-->/g, "").trim() : "";
54
+ }
55
+
56
+ /**
57
+ * The card for task `ordinal` of a change — or, with no ordinal, the first
58
+ * open one. Null `card` with a reason when there is none to give.
59
+ * @returns {{ card: null, reason: string } | { card: object, reason: null }}
60
+ */
61
+ export function taskCard(projectRoot, changeId, ordinal = null) {
62
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", changeId);
63
+ const tasksPath = path.join(changeDir, "tasks.md");
64
+ if (!isFile(tasksPath)) return { card: null, reason: "the change has no tasks.md" };
65
+ const boxes = taskBoxes(read(tasksPath));
66
+ if (boxes.length === 0) return { card: null, reason: "tasks.md has no task box" };
67
+ let box;
68
+ if (ordinal === null) {
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" };
74
+ if (!box) return { card: null, reason: "every task is ticked — the change is ready for review and close" };
75
+ } else {
76
+ box = boxes.find((b) => b.ordinal === ordinal);
77
+ if (!box) return { card: null, reason: `no task ${ordinal} (tasks.md has ${boxes.length})` };
78
+ }
79
+ if (box.placeholder) return { card: null, reason: `task ${box.ordinal} is a scaffold placeholder — write the task first` };
80
+ // CRLF on a Windows checkout: the Why is read paragraph by paragraph.
81
+ const proposal = isFile(path.join(changeDir, "proposal.md")) ? read(path.join(changeDir, "proposal.md")).replace(/\r\n/g, "\n") : "";
82
+ const fields = cardFields(box.details);
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(" "));
107
+ const slices = [];
108
+ for (const cap of deltaCapabilities(changeDir)) {
109
+ const rel = `.doctrina/specs/${cap}/spec.md`;
110
+ const specPath = path.join(projectRoot, rel);
111
+ if (!isFile(specPath)) continue;
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 });
117
+ }
118
+ return {
119
+ card: {
120
+ change: changeId,
121
+ title: parseChangeTitle(proposal) ?? changeId,
122
+ delivery: listHeader(proposal, "Delivery") ?? null,
123
+ ordinal: box.ordinal,
124
+ total: boxes.length,
125
+ done: boxes.filter((b) => b.checked).length,
126
+ checked: box.checked,
127
+ text: box.text,
128
+ details: box.details,
129
+ fields,
130
+ why: firstParagraph(proposal, "Why"),
131
+ slices,
132
+ changes,
133
+ story: deliveryStory(projectRoot, listHeader(proposal, "Delivery")),
134
+ decisions: bindingDecisions(projectRoot, deltaCapabilities(changeDir)),
135
+ },
136
+ reason: null,
137
+ };
138
+ }
139
+
140
+ // The story the change serves, when it belongs to a delivery (change 0231):
141
+ // its acceptance criteria are what the work is checked against, and they
142
+ // live in the workspace, where an executor opened in the repository never
143
+ // looks.
144
+ const STORY_MAX_CHARS = 1500;
145
+ function deliveryStory(projectRoot, deliveryId) {
146
+ if (!deliveryId) return null;
147
+ const found = findWorkspace(projectRoot);
148
+ if (!found) return null;
149
+ const id = resolveDeliveryId(found.dir, deliveryId.trim());
150
+ if (!id || !isFile(deliveryPath(found.dir, id))) return null;
151
+ const story = sectionText(read(deliveryPath(found.dir, id)), "Story");
152
+ if (!story) return null;
153
+ return story.length > STORY_MAX_CHARS ? `${story.slice(0, STORY_MAX_CHARS).replace(/\s+\S*$/, "")} …` : story;
154
+ }
155
+
156
+ // The accepted decisions that bind the capabilities the change touches:
157
+ // those whose Scope names one, and the unscoped ones that cite one. A card
158
+ // the executor follows without them is how an implementation re-decides
159
+ // what an ADR settled.
160
+ function bindingDecisions(projectRoot, capabilities) {
161
+ const dir = path.join(projectRoot, ".doctrina", "decisions");
162
+ if (!isDir(dir) || capabilities.length === 0) return { list: [], more: 0 };
163
+ /** @type {Array<{ id: string, title: string, decision: string, rank: number }>} */
164
+ const found = [];
165
+ for (const f of walk(dir).sort()) {
166
+ const m = path.basename(f).match(/^(\d{4})-.*\.md$/);
167
+ if (!m) continue;
168
+ const text = read(f).replace(/\r\n/g, "\n");
169
+ if ((listHeader(text, "Status") ?? "").trim().toLowerCase() !== "accepted") continue;
170
+ const scope = parseAdrScope(text);
171
+ const cites = (/** @type {string} */ cap) => new RegExp(`(^|[^a-z0-9-])${cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9-]|$)`, "im").test(text);
172
+ // An unscoped ADR is GLOBAL — it binds every capability, as `decision
173
+ // scope` documents and the context pack loads it (sixth audit, change
174
+ // 0237). The card bound one only when its text named the capability, so
175
+ // a repository-wide rule ("error codes are MAIUSCULAS_SNAKE") never
176
+ // reached the executor, who reads nothing else. Scoped first, then the
177
+ // global ones that name the capability, then the rest.
178
+ const rank = scope.length > 0 ? (capabilities.some((cap) => scope.includes(cap)) ? 0 : -1) : (capabilities.some(cites) ? 1 : 2);
179
+ if (rank < 0) continue;
180
+ const decision = (getSection(text, "Decision") ?? "").replace(/<!--[\s\S]*?-->/g, "").replace(/\s+/g, " ").trim();
181
+ found.push({
182
+ id: m[1],
183
+ title: text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m)?.[1]?.trim() ?? path.basename(f),
184
+ decision: decision.length > DECISION_EXCERPT ? `${decision.slice(0, DECISION_EXCERPT).replace(/\s+\S*$/, "")} …` : decision,
185
+ rank,
186
+ });
187
+ }
188
+ const sorted = found.sort((a, b) => a.rank - b.rank || a.id.localeCompare(b.id));
189
+ return { list: sorted.slice(0, DECISIONS_ON_CARD), more: Math.max(0, sorted.length - DECISIONS_ON_CARD) };
190
+ }
191
+
192
+ // How many binding decisions a card names, and how much of each it quotes.
193
+ const DECISIONS_ON_CARD = 8;
194
+ const DECISION_EXCERPT = 240;
195
+
package/src/lib/views.js CHANGED
@@ -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
@@ -187,7 +187,17 @@ export function prime(s) {
187
187
  // one forgotten for three weeks used to read identically.
188
188
  const age = ch.ageDays !== null && ch.ageDays !== undefined && ch.ageDays >= STALE_CHANGE_DAYS
189
189
  ? ` · open ${ch.ageDays}d` : "";
190
- out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks}${age})`)}`);
190
+ out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks}${age}${ch.delivery ? ` · delivery ${ch.delivery}` : ""})`)}`);
191
+ }
192
+ }
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})`)}`);
191
201
  }
192
202
  }
193
203
 
@@ -201,10 +211,49 @@ export function prime(s) {
201
211
  }
202
212
 
203
213
  out.push("");
204
- out.push(c.gray("Read deeper: `doctrina context [<cap>] --concat` · `doctrina why <cap>` · `doctrina show <ref>`"));
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, delivery?: string|null}>} 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}${ch.delivery ? `, part of delivery ${ch.delivery}` : ""})`).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) {
@@ -16,7 +16,13 @@ import { globToRegExp } from "./runtime.js";
16
16
  // file scores its capability when the file sits under a path segment named for
17
17
  // it, or when the spec cites the file as evidence. A deterministic overlap
18
18
  // hint for the agent — never a decision (ADR 0005).
19
- export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
19
+ //
20
+ // `pending` (change 0228) is, per capability, the spec as the change under
21
+ // review will leave it: the criterion a change adds cites its new test, a
22
+ // `set-header Source:` claims new paths, and `review` runs before `apply`
23
+ // merges either — so without it the files the change itself claims were
24
+ // reported as belonging to no capability.
25
+ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3, pending = new Map() } = {}) {
20
26
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
21
27
  if (!files || files.length === 0 || !isDir(specsDir)) return [];
22
28
  const norm = files.map((f) => f.replace(/\\/g, "/"));
@@ -25,7 +31,7 @@ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
25
31
  for (const cap of readdirSync(specsDir).sort()) {
26
32
  const specPath = path.join(specsDir, cap, "spec.md");
27
33
  if (!isFile(specPath)) continue;
28
- const text = read(specPath);
34
+ const text = pending.get(cap) ?? read(specPath);
29
35
  specs.push({ cap, text, declared: parseSourceGlobs(text).map((g) => globToRegExp(g)) });
30
36
  }
31
37
 
@@ -120,3 +126,61 @@ export function rankCapabilities(projectRoot, prompt) {
120
126
  // 0, the same convention `triage` uses for a lane nothing scored against.
121
127
  return sorted.slice(0, 3).map((e, i) => ({ ...e, margin: e.score - (sorted[i + 1]?.score ?? 0) }));
122
128
  }
129
+
130
+ // How far back `work` looks for an archived change the prompt may repeat,
131
+ // and how much of one side the other must cover to count (change 0215).
132
+ export const SIMILAR_CHANGE_DAYS = 14;
133
+ // Three shared terms: two matched "mudar uma coisa" with "mudar outra coisa".
134
+ const SIMILAR_MIN_SHARED = 3;
135
+ const SIMILAR_MIN_COVER = 0.6;
136
+
137
+ /**
138
+ * Open changes, and changes archived in the last SIMILAR_CHANGE_DAYS, whose
139
+ * title and Why the prompt mostly repeats. A field review: two changes were
140
+ * done and archived, and the next request — "create the doctrina work for
141
+ * payment and admin" — would have opened the same work twice. Deterministic
142
+ * term overlap (ADR 0005), measured from both sides: a short prompt that
143
+ * names most of a change, or a change named mostly by a long prompt.
144
+ *
145
+ * @param {string} projectRoot
146
+ * @param {string} prompt
147
+ * @returns {Array<{ id: string, title: string, where: "open"|"archived", path: string, shared: string[] }>}
148
+ */
149
+ export function similarChanges(projectRoot, prompt) {
150
+ const asked = terms(prompt);
151
+ if (asked.length === 0) return [];
152
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
153
+ if (!isDir(changesDir)) return [];
154
+ const candidates = [];
155
+ for (const name of readdirSync(changesDir).sort()) {
156
+ if (name === "archive" || name.startsWith(".")) continue;
157
+ const proposal = path.join(changesDir, name, "proposal.md");
158
+ if (isFile(proposal)) candidates.push({ id: name, where: /** @type {"open"|"archived"} */ ("open"), rel: `.doctrina/changes/${name}/`, text: read(proposal) });
159
+ }
160
+ const archiveDir = path.join(changesDir, "archive");
161
+ if (isDir(archiveDir)) {
162
+ const cutoff = Date.now() - SIMILAR_CHANGE_DAYS * 86400000;
163
+ for (const name of readdirSync(archiveDir)) {
164
+ const m = name.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
165
+ if (!m || Date.parse(`${m[1]}T00:00:00Z`) < cutoff) continue;
166
+ const proposal = path.join(archiveDir, name, "proposal.md");
167
+ if (isFile(proposal)) candidates.push({ id: m[2], where: /** @type {"open"|"archived"} */ ("archived"), rel: `.doctrina/changes/archive/${name}/`, text: read(proposal) });
168
+ }
169
+ }
170
+ const out = [];
171
+ for (const cand of candidates) {
172
+ const title = (cand.text.match(/^#\s+Change\s+\S+\s+—\s+(.+)$/m)?.[1] ?? cand.id).trim();
173
+ const why = cand.text.match(/##\s*Why\s*\r?\n+([^\r\n]+)/)?.[1] ?? "";
174
+ const theirs = terms(`${cand.id.replace(/^\d+-/, "").replace(/-/g, " ")} ${title} ${why}`);
175
+ const shared = asked.filter((t) => theirs.includes(t));
176
+ if (shared.length < SIMILAR_MIN_SHARED) continue;
177
+ // Each side's cover counts only the terms on that side: a word the Why
178
+ // happens to share must not stand in for the title (a prompt about "the
179
+ // work" matched "trabalho aparece para o humano" through its Why).
180
+ const titleTerms = terms(title);
181
+ const titleShared = asked.filter((t) => titleTerms.includes(t));
182
+ const cover = Math.max(shared.length / asked.length, titleTerms.length > 0 ? titleShared.length / titleTerms.length : 0);
183
+ if (cover >= SIMILAR_MIN_COVER) out.push({ id: cand.id, title, where: cand.where, path: cand.rel, shared, cover });
184
+ }
185
+ return out.sort((a, b) => b.cover - a.cover).map(({ cover: _c, ...rest }) => rest);
186
+ }