doctrina-cli 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/change.js +111 -14
  4. package/src/commands/clarify.js +2 -2
  5. package/src/commands/close.js +1 -1
  6. package/src/commands/coverage.js +69 -6
  7. package/src/commands/doctor.js +62 -4
  8. package/src/commands/hooks.js +56 -11
  9. package/src/commands/index-rebuild.js +47 -1
  10. package/src/commands/init.js +51 -7
  11. package/src/commands/metrics.js +1 -1
  12. package/src/commands/onboard.js +113 -0
  13. package/src/commands/review.js +1 -1
  14. package/src/commands/search.js +2 -2
  15. package/src/commands/templates.js +2 -1
  16. package/src/commands/triage.js +16 -1
  17. package/src/commands/ui.js +512 -0
  18. package/src/commands/upgrade.js +46 -6
  19. package/src/commands/verify.js +3 -3
  20. package/src/commands/why.js +1 -1
  21. package/src/commands/work.js +53 -40
  22. package/src/commands/workspace.js +16 -3
  23. package/src/index.js +9 -0
  24. package/src/lib/actions.js +19 -0
  25. package/src/lib/adapters.js +23 -3
  26. package/src/lib/agent-bridge.js +400 -0
  27. package/src/lib/applied-deltas.js +82 -0
  28. package/src/lib/args.js +1 -1
  29. package/src/lib/change-numbers.js +115 -0
  30. package/src/lib/change-ops.js +2 -2
  31. package/src/lib/clarify-scan.js +3 -3
  32. package/src/lib/code-inventory.js +22 -5
  33. package/src/lib/commands.js +50 -4
  34. package/src/lib/config.js +27 -8
  35. package/src/lib/conflict-markers.js +61 -0
  36. package/src/lib/console-model.js +668 -0
  37. package/src/lib/console-prefs.js +67 -0
  38. package/src/lib/console-room.js +201 -0
  39. package/src/lib/console-state.js +150 -0
  40. package/src/lib/console-view.js +557 -0
  41. package/src/lib/coverage-model.js +29 -6
  42. package/src/lib/delivery.js +2 -2
  43. package/src/lib/docs-impact.js +1 -1
  44. package/src/lib/fs-ops.js +80 -12
  45. package/src/lib/gates.js +7 -1
  46. package/src/lib/git.js +18 -0
  47. package/src/lib/index-json.js +10 -1
  48. package/src/lib/index-merge.js +90 -0
  49. package/src/lib/install.js +80 -0
  50. package/src/lib/intake-model.js +4 -1
  51. package/src/lib/ledger.js +2 -3
  52. package/src/lib/lock.js +58 -0
  53. package/src/lib/merge-setup.js +55 -0
  54. package/src/lib/metrics-model.js +2 -2
  55. package/src/lib/onboard.js +197 -0
  56. package/src/lib/preset.js +177 -0
  57. package/src/lib/prompt.js +1 -1
  58. package/src/lib/properties.js +149 -0
  59. package/src/lib/runtime.js +19 -7
  60. package/src/lib/scan.js +13 -8
  61. package/src/lib/spec-ops.js +2 -2
  62. package/src/lib/task-card.js +1 -1
  63. package/src/lib/templates-model.js +3 -2
  64. package/src/lib/triage-model.js +75 -0
  65. package/src/lib/validation-model.js +23 -5
  66. package/src/lib/views.js +2 -1
  67. package/src/lib/workspace-agents.js +2 -2
  68. package/src/lib/workspace-tools.js +41 -1
  69. package/src/lib/workspace.js +1 -1
  70. package/templates/AGENTS.md.template +8 -4
  71. package/templates/README.md +2 -1
  72. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  73. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  74. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  75. package/templates/adapters/kiro/README.md +22 -0
@@ -0,0 +1,557 @@
1
+ // @ts-check
2
+ import { paletteOf } from "./console-model.js";
3
+
4
+ // THE CONSOLE, AS A SCREEN (change 0257).
5
+ //
6
+ // One function turns the state into the lines of a frame and the place the
7
+ // cursor sits. It writes nothing: the command module paints what this
8
+ // returns, and the tests read it.
9
+ //
10
+ // The layout is the project's own shape. The gates run across the top,
11
+ // because they are what every command ends on; the conversation is the
12
+ // body; the panel beside it is the tree — what is open, at which stage,
13
+ // and the next step — and the line a human types on is at the bottom, where
14
+ // a terminal has always had it. Narrower than 84 columns the panel folds
15
+ // into one strip under the gates, so a split pane or a phone still works.
16
+
17
+ /** @typedef {import("./console-model.js").State} State */
18
+ /** @typedef {import("./console-model.js").Entry} Entry */
19
+ /** @typedef {{ t: string, s?: string }} Seg text and a style name */
20
+
21
+ const STYLES = /** @type {Record<string, string>} */ ({
22
+ brand: "38;5;179", brandBold: "1;38;5;179", bold: "1", dim: "2", gray: "90",
23
+ cyan: "36", green: "32", yellow: "33", red: "31", inverse: "7", selected: "1;38;5;179",
24
+ });
25
+ // Every glyph the console draws is one a plain monospace font carries: box
26
+ // drawing, ◆ ● ○ ■ □ ►. The spinner is braille where the terminal's font
27
+ // has it, and four ASCII strokes in the legacy Windows console.
28
+ const SPINNER = process.platform === "win32" && !process.env.WT_SESSION
29
+ ? ["-", "\\", "|", "/"]
30
+ : ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
31
+ const PALETTE_ROWS = 8;
32
+ const INPUT_ROWS = 4;
33
+
34
+ /** Columns a character takes: 0 for a combining mark, 2 for wide East Asian and emoji. */
35
+ function charWidth(ch) {
36
+ const cp = ch.codePointAt(0) ?? 0;
37
+ if (cp === 0x200d || (cp >= 0x300 && cp <= 0x36f) || (cp >= 0xfe00 && cp <= 0xfe0f)) return 0;
38
+ if ((cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
39
+ (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe6f) || (cp >= 0xff00 && cp <= 0xff60) ||
40
+ (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd)) return 2;
41
+ return 1;
42
+ }
43
+
44
+ /** The columns a plain string takes. */
45
+ export function textWidth(s) {
46
+ let w = 0;
47
+ for (const ch of String(s)) w += charWidth(ch);
48
+ return w;
49
+ }
50
+
51
+ const segsWidth = (/** @type {Seg[]} */ segs) => segs.reduce((n, g) => n + textWidth(g.t), 0);
52
+
53
+ /** Cut segments to `width` columns (an ellipsis marks the cut) and pad to it. */
54
+ function fit(segs, width, color) {
55
+ let left = width;
56
+ let out = "";
57
+ const total = segsWidth(segs);
58
+ for (const g of segs) {
59
+ if (left <= 0) break;
60
+ let t = "";
61
+ let w = 0;
62
+ for (const ch of g.t) {
63
+ const cw = charWidth(ch);
64
+ if (w + cw > left) break;
65
+ t += ch;
66
+ w += cw;
67
+ }
68
+ if (total > width && w === left && w > 0) {
69
+ // The last column of a cut line is the ellipsis.
70
+ const chars = Array.from(t);
71
+ let drop = 0;
72
+ while (chars.length > 0 && drop < 1) drop += charWidth(chars.pop() ?? "");
73
+ t = `${chars.join("")}…${" ".repeat(Math.max(0, drop - 1))}`;
74
+ }
75
+ left -= w;
76
+ out += color && g.s && STYLES[g.s] ? `\x1b[${STYLES[g.s]}m${t}\x1b[0m` : t;
77
+ }
78
+ return out + " ".repeat(Math.max(0, left));
79
+ }
80
+
81
+ /**
82
+ * Wrap segments at word boundaries. A word longer than the line is split.
83
+ * @param {Seg[]} segs
84
+ * @param {number} width
85
+ * @param {{ first?: Seg[], rest?: Seg[] }} [lead] what each line starts with
86
+ * @returns {Seg[][]}
87
+ */
88
+ function wrap(segs, width, { first = [], rest = [] } = {}) {
89
+ /** @type {Seg[][]} */
90
+ const lines = [];
91
+ let line = [...first];
92
+ let used = segsWidth(first);
93
+ const lead = segsWidth(rest);
94
+ const room = Math.max(4, width - lead);
95
+ const newline = () => {
96
+ lines.push(line);
97
+ line = [...rest];
98
+ used = lead;
99
+ };
100
+ for (const g of segs) {
101
+ for (const token of g.t.split(/(\s+)/)) {
102
+ if (token === "") continue;
103
+ const w = textWidth(token);
104
+ if (/^\s+$/.test(token)) {
105
+ // Kept even when it lands on the last column: the word after it then
106
+ // starts the next line instead of joining this one.
107
+ if (used > (lines.length === 0 ? segsWidth(first) : lead) && used + w <= width) {
108
+ line.push({ t: token, s: g.s });
109
+ used += w;
110
+ }
111
+ continue;
112
+ }
113
+ if (used + w > width && used > (lines.length === 0 ? segsWidth(first) : lead)) newline();
114
+ if (w <= room) {
115
+ line.push({ t: token, s: g.s });
116
+ used += w;
117
+ continue;
118
+ }
119
+ // Longer than a line: cut it, after a hyphen or a slash where one is near.
120
+ let piece = "";
121
+ let pw = 0;
122
+ for (const ch of token) {
123
+ const cw = charWidth(ch);
124
+ if (used + pw + cw > width) {
125
+ const soft = Math.max(piece.lastIndexOf("-"), piece.lastIndexOf("/"), piece.lastIndexOf("_"));
126
+ const keep = soft >= piece.length - 14 && soft > 0 ? piece.slice(0, soft + 1) : piece;
127
+ const carry = piece.slice(keep.length);
128
+ line.push({ t: keep, s: g.s });
129
+ newline();
130
+ piece = carry;
131
+ pw = textWidth(carry);
132
+ }
133
+ piece += ch;
134
+ pw += cw;
135
+ }
136
+ line.push({ t: piece, s: g.s });
137
+ used += pw;
138
+ }
139
+ }
140
+ lines.push(line);
141
+ return lines;
142
+ }
143
+
144
+ /** `**bold**` and `` `code` `` in what an agent wrote. */
145
+ function inline(text, base) {
146
+ /** @type {Seg[]} */
147
+ const out = [];
148
+ let rest = String(text);
149
+ const re = /\*\*([^*]+)\*\*|`([^`]+)`/;
150
+ while (rest !== "") {
151
+ const m = re.exec(rest);
152
+ if (!m) {
153
+ out.push({ t: rest, s: base });
154
+ break;
155
+ }
156
+ if (m.index > 0) out.push({ t: rest.slice(0, m.index), s: base });
157
+ out.push(m[1] !== undefined ? { t: m[1].replace(/`/g, ""), s: "bold" } : { t: m[2], s: "cyan" });
158
+ rest = rest.slice(m.index + m[0].length);
159
+ }
160
+ return out;
161
+ }
162
+
163
+ const LEAD = /^(\s*)(ok\b|✓|✗|error:|fail\b|warn:?|hold:|note:|direct:|created\b|updated\b|opened\b|added\b|converted\b|routed\b|ticked\b|rebuilt\b)/;
164
+ const LEAD_STYLE = /** @type {Record<string, string>} */ ({ ok: "green", "✓": "green", "✗": "red", "error:": "red", fail: "red", "warn:": "yellow", warn: "yellow", "hold:": "yellow", "note:": "yellow", "direct:": "green" });
165
+
166
+ /** A line a command printed, with the word it leads with in its colour. */
167
+ function outputSegs(text) {
168
+ if (/^\s*[─═-]{4,}/.test(text) || /^\s*<!--/.test(text)) return [{ t: text, s: "gray" }];
169
+ if (/^#+\s/.test(text)) return [{ t: text.replace(/^#+\s/, ""), s: "bold" }];
170
+ const m = LEAD.exec(text);
171
+ if (!m) return inline(text, undefined);
172
+ return [{ t: m[1] }, { t: m[2], s: LEAD_STYLE[m[2]] ?? "green" }, ...inline(text.slice(m[0].length), undefined)];
173
+ }
174
+
175
+ /** What the console opens on. */
176
+ function banner(width) {
177
+ const lead = { first: [{ t: " " }], rest: [{ t: " " }] };
178
+ return [
179
+ [{ t: " ◆ ", s: "brandBold" }, { t: "Doctrina", s: "bold" }],
180
+ ...wrap([{ t: "specs are the truth · changes go through the gates", s: "gray" }], width, lead),
181
+ [],
182
+ ...wrap([{ t: "Say what you want changed", s: "bold" }, { t: " and the agent opens the change, writes the delta, implements it and closes it. Every step lands in this window." }], width, lead),
183
+ [],
184
+ ...(width >= 62
185
+ ? [
186
+ [{ t: " /", s: "brandBold" }, { t: " a command " }, { t: "/next /status /work \"…\" /close <id>", s: "gray" }],
187
+ [{ t: " @", s: "brandBold" }, { t: " an artifact " }, { t: "@spec:… @change:… @skill:… @adr:…", s: "gray" }],
188
+ ]
189
+ : [
190
+ [{ t: " /", s: "brandBold" }, { t: " a command " }, { t: "/next /work \"…\"", s: "gray" }],
191
+ [{ t: " @", s: "brandBold" }, { t: " an artifact " }, { t: "@spec:… @change:…", s: "gray" }],
192
+ ]),
193
+ [{ t: " ?", s: "brandBold" }, { t: " help" }],
194
+ ];
195
+ }
196
+
197
+ /**
198
+ * The conversation as wrapped lines.
199
+ * @param {Entry[]} transcript
200
+ * @param {number} width
201
+ * @returns {Seg[][]}
202
+ */
203
+ function transcriptLines(transcript, width) {
204
+ /** @type {Seg[][]} */
205
+ const out = [];
206
+ let fenced = false;
207
+ for (const e of transcript) {
208
+ switch (e.kind) {
209
+ case "banner":
210
+ out.push(...banner(width));
211
+ break;
212
+ case "blank":
213
+ if (out.length > 0 && out[out.length - 1].length > 0) out.push([]);
214
+ break;
215
+ case "user":
216
+ out.push(...wrap([{ t: e.text, s: "bold" }], width, { first: [{ t: " › ", s: "brandBold" }], rest: [{ t: " " }] }));
217
+ break;
218
+ case "agent":
219
+ for (const [i, para] of e.text.replace(/\s+$/, "").split("\n").entries()) {
220
+ if (/^\s*```/.test(para)) {
221
+ fenced = !fenced;
222
+ continue;
223
+ }
224
+ const lead = { first: [i === 0 ? { t: " ◆ ", s: "brand" } : { t: " " }], rest: [{ t: " " }] };
225
+ if (fenced) out.push(...wrap([{ t: para, s: "cyan" }], width, lead));
226
+ else if (/^#+\s/.test(para)) out.push(...wrap([{ t: para.replace(/^#+\s/, ""), s: "bold" }], width, lead));
227
+ else out.push(...wrap(inline(para, undefined), width, lead));
228
+ }
229
+ fenced = false;
230
+ break;
231
+ case "tool": {
232
+ const [name, ...detail] = e.text.split(" ");
233
+ out.push(...wrap([{ t: name, s: "cyan" }, { t: detail.length ? ` ${detail.join(" ")}` : "", s: "gray" }], width, { first: [{ t: " └ ", s: "gray" }], rest: [{ t: " " }] }));
234
+ break;
235
+ }
236
+ case "note":
237
+ out.push(...wrap([{ t: e.text, s: "gray" }], width, { first: [{ t: " " }], rest: [{ t: " " }] }));
238
+ break;
239
+ case "peer": {
240
+ // What crossed between consoles: who, then what.
241
+ const [head, ...body] = e.text.split(" ");
242
+ out.push(...wrap([{ t: head, s: "brandBold" }, { t: body.length ? ` ${body.join(" ")}` : "", s: "bold" }], width, { first: [{ t: " ↔ ", s: "brandBold" }], rest: [{ t: " " }] }));
243
+ break;
244
+ }
245
+ case "quote":
246
+ for (const para of e.text.replace(/\s+$/, "").split("\n")) {
247
+ if (/^\s*```/.test(para)) continue;
248
+ out.push(...wrap(inline(para.replace(/^#+\s/, ""), undefined), width, { first: [{ t: " │ ", s: "gray" }], rest: [{ t: " │ ", s: "gray" }] }));
249
+ }
250
+ break;
251
+ case "ok":
252
+ out.push([{ t: " ✓ ", s: "green" }, { t: e.text, s: "gray" }]);
253
+ break;
254
+ case "fail":
255
+ out.push(...wrap([{ t: e.text, s: "red" }], width, { first: [{ t: " ✗ ", s: "red" }], rest: [{ t: " " }] }));
256
+ break;
257
+ default:
258
+ out.push(...wrap(outputSegs(e.text), width, { first: [{ t: " " }], rest: [{ t: " " }] }));
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+
264
+ const LIGHT = /** @type {Record<string, [string, string]>} */ ({ ok: ["✓", "green"], warn: ["!", "yellow"], fail: ["✗", "red"], off: ["·", "gray"] });
265
+
266
+ /**
267
+ * `doctrina next` as it is typed here: `/next`. A command that belongs to
268
+ * another repository is shown as it is run there, with the repository named.
269
+ */
270
+ function asTyped(command) {
271
+ const text = command ?? "";
272
+ const elsewhere = /^cd\s+(\S+)\s+&&\s+(.+)$/.exec(text);
273
+ if (elsewhere) return `in ${elsewhere[1].replace(/[\\/]+$/, "").split(/[\\/]/).pop()}: ${elsewhere[2]}`;
274
+ return text.replace(/^doctrina\s+/, "/");
275
+ }
276
+
277
+ const STAGES = ["plan", "build", "close"];
278
+
279
+ /** The stage track of one open change: ● plan ─ ○ build ─ ○ close. */
280
+ function stageTrack(stage) {
281
+ const at = STAGES.indexOf(stage);
282
+ /** @type {Seg[]} */
283
+ const out = [];
284
+ STAGES.forEach((name, i) => {
285
+ if (i > 0) out.push({ t: " ─ ", s: "gray" });
286
+ out.push(i < at ? { t: `✓ ${name}`, s: "green" } : (i === at ? { t: `● ${name}`, s: "brandBold" } : { t: `○ ${name}`, s: "gray" }));
287
+ });
288
+ return out;
289
+ }
290
+
291
+ function taskBar(done, total, cells = 10) {
292
+ const filled = total > 0 ? Math.round((done / total) * cells) : 0;
293
+ return [{ t: "■".repeat(filled), s: "brand" }, { t: "□".repeat(cells - filled), s: "gray" }, { t: ` ${done}/${total}`, s: "gray" }];
294
+ }
295
+
296
+ /** The first `n` lines of a wrapped block; an ellipsis says there was more. */
297
+ function cut(lines, n) {
298
+ if (lines.length <= n) return lines;
299
+ const kept = lines.slice(0, n);
300
+ kept[n - 1] = [...kept[n - 1], { t: "…", s: "gray" }];
301
+ return kept;
302
+ }
303
+
304
+ /**
305
+ * The panel: what is open, at which stage, and what comes next.
306
+ * @param {State} s
307
+ * @param {number} width
308
+ * @returns {Seg[][]}
309
+ */
310
+ function panelLines(s, width) {
311
+ const p = s.panel;
312
+ /** @type {Seg[][]} */
313
+ const out = [];
314
+ const head = (/** @type {string} */ t) => out.push([{ t: ` ${t}`, s: "gray" }]);
315
+ if (!p) return [[{ t: " reading the project…", s: "gray" }]];
316
+ const inner = width - 2;
317
+ if (p.kind === "none") {
318
+ head("NO PROJECT HERE");
319
+ out.push(...wrap([{ t: "This directory has no .doctrina/ yet." }], inner, { first: [{ t: " " }], rest: [{ t: " " }] }));
320
+ }
321
+ if (p.kind === "workspace") {
322
+ head("PROJECTS");
323
+ for (const pr of p.projects) {
324
+ const [glyph, style] = pr.state === "ready" ? LIGHT.ok : (pr.state === "missing" ? LIGHT.fail : LIGHT.warn);
325
+ out.push([{ t: ` ${glyph} `, s: style }, { t: pr.name, s: "bold" }, { t: ` ${pr.state === "ready" ? pr.reason : pr.state}`, s: "gray" }]);
326
+ }
327
+ out.push([]);
328
+ }
329
+ // The consoles open in other terminals, when there are any.
330
+ if (s.peers.length > 0) {
331
+ head("CONSOLES");
332
+ for (const peer of s.peers.slice(0, 5)) {
333
+ out.push([{ t: " ↔ ", s: "brand" }, { t: peer.name, s: "bold" }, { t: ` ${peer.busy ? "busy" : "idle"}${peer.agent ? ` · ${peer.agent}` : " · no agent"}${peer.listening ? "" : " · deaf"}`, s: "gray" }]);
334
+ }
335
+ if (s.peers.length > 5) out.push([{ t: ` … and ${s.peers.length - 5} more — /peers`, s: "gray" }]);
336
+ out.push([]);
337
+ }
338
+ if (p.kind !== "none") {
339
+ head(p.kind === "workspace" ? "OPEN DELIVERIES" : "OPEN WORK");
340
+ if (p.open.length === 0) out.push([{ t: " nothing open", s: "gray" }]);
341
+ for (const ch of p.open.slice(0, 3)) {
342
+ out.push([{ t: ` ${ch.id}`, s: "bold" }]);
343
+ if (p.kind === "project") {
344
+ out.push([{ t: " " }, ...stageTrack(ch.stage)]);
345
+ out.push([{ t: " " }, ...taskBar(ch.done, ch.total), { t: " tasks", s: "gray" }]);
346
+ } else {
347
+ out.push([{ t: " " }, ...taskBar(ch.done, ch.total), { t: " changes closed", s: "gray" }]);
348
+ }
349
+ }
350
+ if (p.open.length > 3) out.push([{ t: ` … and ${p.open.length - 3} more`, s: "gray" }]);
351
+ out.push([]);
352
+ }
353
+ head("NEXT");
354
+ if (p.next.length === 0) out.push([{ t: " nothing pending", s: "gray" }]);
355
+ p.next.slice(0, 3).forEach((a, i) => {
356
+ out.push(...cut(wrap([{ t: a.text }], inner, { first: [{ t: ` ${i + 1} `, s: "brand" }], rest: [{ t: " " }] }), 3));
357
+ if (a.command) out.push(...cut(wrap([{ t: asTyped(a.command), s: "cyan" }], inner, { first: [{ t: " " }], rest: [{ t: " " }] }), 2));
358
+ });
359
+ if (p.recent.length > 0) {
360
+ out.push([]);
361
+ head("RECENTLY CLOSED");
362
+ for (const r of p.recent.slice(0, 3)) out.push([{ t: " ✓ ", s: "green" }, { t: r.id, s: "gray" }]);
363
+ }
364
+ return out;
365
+ }
366
+
367
+ /** The gates, as lights: index ✓ coverage ✓ 100% … */
368
+ function gateSegs(s, roomy) {
369
+ const p = s.panel;
370
+ /** @type {Seg[]} */
371
+ const out = [{ t: " " }];
372
+ if (!p) return [{ t: " reading the project…", s: "gray" }];
373
+ if (p.kind === "none") return [{ t: " not a Doctrina project — ", s: "gray" }, { t: "/init", s: "cyan" }, { t: " sets it up, ", s: "gray" }, { t: "/onboard", s: "cyan" }, { t: " walks you through", s: "gray" }];
374
+ p.gates.forEach((g, i) => {
375
+ const [glyph, style] = LIGHT[g.state];
376
+ if (i > 0) out.push({ t: " " });
377
+ out.push({ t: `${g.id} `, s: g.state === "off" ? "gray" : undefined }, { t: glyph, s: style });
378
+ if (roomy && g.id !== "index") out.push({ t: ` ${g.text}`, s: "gray" });
379
+ });
380
+ return out;
381
+ }
382
+
383
+ function countsText(p) {
384
+ if (!p || p.kind !== "project") return "";
385
+ return `${p.counts.specs} spec${p.counts.specs === 1 ? "" : "s"} · ${p.counts.decisions} ADR${p.counts.decisions === 1 ? "" : "s"} · ${p.counts.closed} closed`;
386
+ }
387
+
388
+ /** Two groups on one line: the left one, and the right one flush right. */
389
+ function spread(left, right, width, color) {
390
+ const rw = segsWidth(right);
391
+ if (rw === 0 || rw + 2 > width) return fit(left, width, color);
392
+ return fit(left, width - rw, color) + fit(right, rw, color);
393
+ }
394
+
395
+ /**
396
+ * One frame.
397
+ * @param {State} s
398
+ * @param {{ cols: number, rows: number, color?: boolean, now?: number }} size
399
+ * @returns {{ lines: string[], cursor: { row: number, col: number } }}
400
+ */
401
+ export function renderFrame(s, { cols, rows, color = true, now = Date.now() }) {
402
+ if (cols < 30 || rows < 10) {
403
+ const lines = Array.from({ length: rows }, (_, i) => (i === 0 ? fit([{ t: "Doctrina — the window is too small" }], cols, color) : " ".repeat(cols)));
404
+ return { lines, cursor: { row: 0, col: 0 } };
405
+ }
406
+ const p = s.panel;
407
+ const agent = s.agents.find((a) => a.id === s.agent) ?? null;
408
+ const panelW = s.showPanel ? (cols >= 104 ? 38 : (cols >= 84 ? 32 : 0)) : 0;
409
+
410
+ // Header: the mark, the project, and who answers.
411
+ /** @type {string[]} */
412
+ const top = [];
413
+ const name = p?.project ? [{ t: " │ ", s: "gray" }, { t: p.project, s: "bold" }, ...(p.kind === "workspace" ? [{ t: " workspace", s: "gray" }] : [])] : [];
414
+ // The model a person chose, or the one the agent said it is running.
415
+ const model = agent ? (s.models[agent.id] ?? s.reported[agent.id] ?? "") : "";
416
+ // How many other consoles are in the room, when any is.
417
+ const room = s.peers.length > 0 ? [{ t: `↔ ${s.peers.length} `, s: "brand" }] : [];
418
+ const who = agent
419
+ ? [...room, { t: "● ", s: "green" }, { t: agent.id }, ...(model && cols >= 72 ? [{ t: ` · ${model}`, s: "gray" }] : []), { t: ` v${p?.cli ?? ""} `, s: "gray" }]
420
+ : [...room, { t: "○ no agent", s: "gray" }, { t: ` v${p?.cli ?? ""} `, s: "gray" }];
421
+ top.push(spread([{ t: " ◆ ", s: "brandBold" }, { t: "Doctrina", s: "bold" }, ...name], who, cols, color));
422
+ const counts = countsText(p);
423
+ top.push(spread(gateSegs(s, cols >= 72), cols >= 96 && counts ? [{ t: `${counts} `, s: "gray" }] : [], cols, color));
424
+ // Without room for the panel, its first facts ride one strip.
425
+ if (panelW === 0 && s.showPanel && p && p.kind !== "none") {
426
+ const first = p.open[0];
427
+ const step = p.next[0];
428
+ /** @type {Seg[]} */
429
+ const strip = [{ t: " " }];
430
+ if (first) strip.push({ t: "► ", s: "brand" }, { t: first.id, s: "bold" }, { t: ` ${first.stage} ${first.done}/${first.total}`, s: "gray" });
431
+ else strip.push({ t: "nothing open", s: "gray" });
432
+ if (step) strip.push({ t: " next ", s: "gray" }, step.command ? { t: asTyped(step.command), s: "cyan" } : { t: step.text });
433
+ top.push(fit(strip, cols, color));
434
+ }
435
+ top.push(fit([{ t: "─".repeat(cols), s: "gray" }], cols, color));
436
+
437
+ // The line being typed, wrapped.
438
+ const typed = s.input.join("");
439
+ const boxInner = cols - 4;
440
+ const inputSegs = typed === ""
441
+ ? [{ t: agent ? `Ask ${agent.label} for a change, or type / for commands` : "Type / for commands — no agent is connected", s: "gray" }]
442
+ : [{ t: typed }];
443
+ const allInput = wrapInput(s.input, s.cursor, boxInner - 2);
444
+ // The box grows with the line, but never takes the conversation's last row.
445
+ const inputRows = Math.max(1, Math.min(INPUT_ROWS, rows - top.length - 4));
446
+ const firstShown = Math.max(0, Math.min(allInput.cursorLine - inputRows + 1, allInput.lines.length - inputRows));
447
+ const shown = typed === "" ? [""] : allInput.lines.slice(firstShown, firstShown + inputRows);
448
+
449
+ // The box's top edge says what is running.
450
+ const spin = SPINNER[Math.floor(now / 90) % SPINNER.length];
451
+ const label = s.busy
452
+ ? [{ t: ` ${spin} `, s: "brand" }, { t: `${s.busy.label} `, s: "bold" },
453
+ { t: `${Math.max(0, Math.round((now - s.busy.since) / 1000))}s${s.serving ? ` · for ${s.serving.from.name}` : ""} · esc interrupts `, s: "gray" }]
454
+ : (agent
455
+ ? [{ t: ` to ${agent.label}${model ? ` · ${model}` : ""}${s.session ? " · same conversation" : ""} `, s: "gray" }]
456
+ : [{ t: " no agent — /agent chooses one ", s: "gray" }]);
457
+ const edge = (/** @type {string} */ l, /** @type {string} */ r, /** @type {Seg[]} */ mid) => {
458
+ const w = segsWidth(mid);
459
+ const fill = Math.max(0, cols - 4 - w);
460
+ return fit([{ t: `${l}─`, s: "gray" }, ...mid, { t: `${"─".repeat(fill)}─${r}`, s: "gray" }], cols, color);
461
+ };
462
+ /** @type {string[]} */
463
+ const bottom = [edge("╭", "╮", label)];
464
+ shown.forEach((line, i) => {
465
+ const lead = i === 0 && firstShown === 0 ? [{ t: "› ", s: "brandBold" }] : [{ t: " " }];
466
+ const body = typed === "" ? inputSegs : [{ t: line }];
467
+ bottom.push(fit([{ t: "│ ", s: "gray" }, ...lead, ...body], cols - 2, color) + fit([{ t: " │", s: "gray" }], 2, color));
468
+ });
469
+ bottom.push(edge("╰", "╯", []));
470
+ const palette = paletteOf(s);
471
+ const hints = s.busy ? "esc interrupt · keep typing your next request"
472
+ : (palette ? "↑↓ choose · tab complete · enter run · esc close"
473
+ : (cols >= 80 ? "enter send · / commands · @ mention · ↑↓ history · pgup/pgdn scroll · ? help" : "enter send · / commands · @ mention · ? help"));
474
+ // One column short: a character in the last cell of the last row scrolls some terminals.
475
+ bottom.push(spread([{ t: ` ${hints}`, s: "gray" }], s.notice ? [{ t: `${s.notice} `, s: "yellow" }] : [], cols - 1, color));
476
+
477
+ // The body: the conversation, and the panel beside it.
478
+ const bodyH = Math.max(1, rows - top.length - bottom.length);
479
+ const leftW = panelW > 0 ? cols - panelW - 1 : cols;
480
+ const all = transcriptLines(s.transcript, leftW - 1);
481
+ const maxScroll = Math.max(0, all.length - bodyH);
482
+ const scroll = Math.min(s.scroll, maxScroll);
483
+ // The opening screen is read from its first line; a conversation from its last.
484
+ const start = s.said ? Math.max(0, all.length - bodyH - scroll) : 0;
485
+ const visible = all.slice(start, start + bodyH);
486
+ /** @type {Seg[][]} */
487
+ const left = [...visible];
488
+ while (left.length < bodyH) left.push([]);
489
+ if (scroll > 0) left[bodyH - 1] = [{ t: ` ↓ ${scroll} more line${scroll === 1 ? "" : "s"} below — pgdn`, s: "yellow" }];
490
+
491
+ // The palette covers the bottom of the conversation.
492
+ if (palette) {
493
+ const n = palette.items.length;
494
+ const at = Math.min(s.paletteAt, n - 1);
495
+ const rowsShown = Math.min(PALETTE_ROWS, n, bodyH - 1);
496
+ const from = Math.max(0, Math.min(at - Math.floor(rowsShown / 2), n - rowsShown));
497
+ // The names share one column, as wide as the longest shown — up to half the line.
498
+ const nameW = Math.min(Math.floor(leftW / 2), Math.max(...palette.items.slice(from, from + rowsShown).map((it) => textWidth(it.value))) + 2);
499
+ const base = bodyH - rowsShown - 1;
500
+ left[base] = [{ t: ` ${palette.kind === "slash" ? "commands" : "artifacts"} `, s: "gray" }, { t: "─".repeat(Math.max(0, leftW - 14)), s: "gray" },
501
+ { t: n > rowsShown ? ` ${at + 1}/${n}` : "", s: "gray" }];
502
+ for (let i = 0; i < rowsShown; i++) {
503
+ const it = palette.items[from + i];
504
+ const sel = from + i === at;
505
+ const value = textWidth(it.value) > nameW - 2 ? `${Array.from(it.value).slice(0, nameW - 3).join("")}… ` : it.value.padEnd(nameW);
506
+ left[base + 1 + i] = [{ t: sel ? " ► " : " ", s: "brandBold" }, { t: value, s: sel ? "selected" : "cyan" }, { t: it.label, s: sel ? undefined : "gray" }];
507
+ }
508
+ }
509
+
510
+ const right = panelW > 0 ? panelLines(s, panelW) : [];
511
+ const body = left.map((segs, i) => (panelW > 0
512
+ ? fit(segs, leftW, color) + fit([{ t: "│", s: "gray" }], 1, color) + fit(right[i] ?? [], panelW, color)
513
+ : fit(segs, cols, color)));
514
+
515
+ const lines = [...top, ...body, ...bottom];
516
+ const cursorRow = top.length + bodyH + 1 + (typed === "" ? 0 : allInput.cursorLine - firstShown);
517
+ const cursorCol = 4 + (typed === "" ? 0 : allInput.cursorCol);
518
+ return { lines: lines.slice(0, rows), cursor: { row: Math.min(cursorRow, rows - 1), col: Math.min(cursorCol, cols - 1) } };
519
+ }
520
+
521
+ /**
522
+ * The typed line cut into rows of `width` columns, and where the cursor falls.
523
+ * @param {string[]} chars
524
+ * @param {number} cursor
525
+ * @param {number} width
526
+ */
527
+ function wrapInput(chars, cursor, width) {
528
+ const w = Math.max(4, width);
529
+ /** @type {string[]} */
530
+ const lines = [""];
531
+ let used = 0;
532
+ let cursorLine = 0;
533
+ let cursorCol = 0;
534
+ chars.forEach((ch, i) => {
535
+ const cw = charWidth(ch);
536
+ if (used + cw > w) {
537
+ lines.push("");
538
+ used = 0;
539
+ }
540
+ if (i === cursor) {
541
+ cursorLine = lines.length - 1;
542
+ cursorCol = used;
543
+ }
544
+ lines[lines.length - 1] += ch;
545
+ used += cw;
546
+ });
547
+ if (cursor >= chars.length) {
548
+ // The cursor sits after the last character, on a new row when that one is full.
549
+ if (used >= w) {
550
+ lines.push("");
551
+ used = 0;
552
+ }
553
+ cursorLine = lines.length - 1;
554
+ cursorCol = used;
555
+ }
556
+ return { lines, cursorLine, cursorCol };
557
+ }
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
+ import { PROPERTY_MARKER, propertyLibraryIn } from "./properties.js";
3
4
  import { readdirSync } from "node:fs";
4
5
  import { exists, isDir, isFile, read } from "./fs-ops.js";
5
6
  import { specHeader } from "./scan.js";
@@ -27,9 +28,11 @@ import { parseAcceptanceCriteria } from "./criteria.js";
27
28
  // never a --strict failure. Declared debt and hidden debt stop being punished
28
29
  // identically (0.11.0 field review item 4: one deferred capability poisoned
29
30
  // the close of every unrelated change).
30
- export function collect(projectRoot, { only = null } = {}) {
31
+ export function collect(projectRoot, { only = /** @type {Set<string> | null} */ (null) } = {}) {
31
32
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
32
33
  const reports = [];
34
+ /** @type {Map<string, boolean>} evidence file → is its suite skipped */
35
+ const skippedOf = new Map();
33
36
  if (isDir(specsDir)) {
34
37
  for (const cap of readdirSync(specsDir).sort()) {
35
38
  if (only && !only.has(cap)) continue;
@@ -40,7 +43,7 @@ export function collect(projectRoot, { only = null } = {}) {
40
43
  if (criteria.length === 0) continue;
41
44
  const deferred = isDeclaredDeferral(text);
42
45
  const rows = criteria.map((crit, i) => {
43
- const row = classify(crit, i + 1, projectRoot, path.dirname(specPath));
46
+ const row = classify(crit, i + 1, projectRoot, path.dirname(specPath), skippedOf);
44
47
  if (deferred && row.kind !== "covered") {
45
48
  return { ...row, kind: "deferred", was: row.kind };
46
49
  }
@@ -145,7 +148,7 @@ export function deriveImplementation(row) {
145
148
  *
146
149
  * @returns {Map<string, {derived: string, covered: number, total: number, deferred: boolean, unverified: number}>}
147
150
  */
148
- export function derivedImplementations(projectRoot, { only = null } = {}) {
151
+ export function derivedImplementations(projectRoot, { only = /** @type {Set<string> | null} */ (null) } = {}) {
149
152
  const out = new Map();
150
153
  for (const rep of collect(projectRoot, { only })) {
151
154
  const row = {
@@ -301,7 +304,7 @@ function evidenceClause(criterion) {
301
304
  return m ? criterion.slice(m.index) : null;
302
305
  }
303
306
 
304
- function classify(criterion, n, projectRoot, specDir) {
307
+ function classify(criterion, n, projectRoot, specDir, skippedOf = /** @type {Map<string, boolean>} */ (new Map())) {
305
308
  // An orchestration criterion is judged on its GUARD, not on whether a
306
309
  // cited file exists — the whole point is that existence proves nothing here.
307
310
  const marker = criterion.match(/^\[([^\]]+)\]/)?.[1]?.toLowerCase() ?? "";
@@ -349,10 +352,30 @@ function classify(criterion, n, projectRoot, specDir) {
349
352
  continue;
350
353
  }
351
354
  const isTest = looksLikeTestFile(token);
352
- const skipped = isTest && isFile(hit) ? testSuiteIsSkipped(read(hit)) : false;
353
- resolved.push({ token, isTest, skipped });
355
+ // One read per evidence file, not one per criterion that cites it
356
+ // (change 0249): a suite that proves forty criteria was read and
357
+ // scanned forty times, on every `prime`, `status` and `validate`.
358
+ let skipped = false;
359
+ if (isTest && isFile(hit)) {
360
+ if (!skippedOf.has(hit)) skippedOf.set(hit, testSuiteIsSkipped(read(hit)));
361
+ skipped = skippedOf.get(hit) === true;
362
+ }
363
+ resolved.push({ token, isTest, skipped, hit });
354
364
  }
355
365
  if (resolved.length === 0) return { kind: "dangling", n, missing };
366
+ // A `[property]` criterion claims something of EVERY input (change 0254):
367
+ // the test it cites must be one that generates its inputs. An example
368
+ // test proves three cases; calling that proof of a universal claim is the
369
+ // false confidence this gate exists against.
370
+ if (PROPERTY_MARKER.test(marker)) {
371
+ const generating = resolved.find((r) => r.isTest && !r.skipped && propertyLibraryIn(read(r.hit)) !== null);
372
+ if (!generating) {
373
+ return {
374
+ kind: "unguarded", n, label: "property claim is proven by no property",
375
+ reason: `${resolved.map((r) => `\`${r.token}\``).join(", ")} generates no input — a [property] criterion is proven by a property-based test (Hypothesis \`@given\`, fast-check \`fc.property\`, jqwik \`@Property\`, proptest), or it is an example and its mark is [verified]`,
376
+ };
377
+ }
378
+ }
356
379
  // Runnable evidence: the resolving test-shaped citations, recorded so
357
380
  // `coverage --run` can execute them (existence → passing proof).
358
381
  const evidence = resolved.filter((r) => r.isTest && !r.skipped).map((r) => r.token);
@@ -126,11 +126,11 @@ export function deliveryChanges(wsDir, ws, delivery) {
126
126
  for (const name of delivery.projects) {
127
127
  const project = ws.projects.find((p) => p.name === name);
128
128
  if (!project) {
129
- out.push({ project: name, missing: "not registered in the workspace", changes: [] });
129
+ out.push({ project: name, missing: "not registered in the workspace", changes: /** @type {any[]} */ ([]) });
130
130
  continue;
131
131
  }
132
132
  const changesDir = path.join(artifactRootOf(wsDir, project), ".doctrina", "changes");
133
- const changes = [];
133
+ const changes = /** @type {any[]} */ ([]);
134
134
  for (const { dir, archived } of [{ dir: changesDir, archived: false }, { dir: path.join(changesDir, "archive"), archived: true }]) {
135
135
  if (!isDir(dir)) continue;
136
136
  for (const entry of readdirSync(dir).sort()) {
@@ -123,7 +123,7 @@ function withoutVerification(text) {
123
123
  return out.join("\n");
124
124
  }
125
125
 
126
- export function documentedSurfaceSignals(changeDir, projectRoot = null) {
126
+ export function documentedSurfaceSignals(changeDir, projectRoot = /** @type {string | null} */ (null)) {
127
127
  const signals = [];
128
128
  const known = new Set(COMMAND_NAMES);
129
129