doctrina-cli 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +7 -7
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +202 -27
  5. package/src/commands/clarify.js +3 -206
  6. package/src/commands/close.js +1 -1
  7. package/src/commands/context.js +33 -2
  8. package/src/commands/contract.js +1 -0
  9. package/src/commands/coverage.js +69 -6
  10. package/src/commands/delivery.js +135 -17
  11. package/src/commands/doctor.js +83 -5
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/hooks.js +56 -11
  14. package/src/commands/index-rebuild.js +47 -1
  15. package/src/commands/init.js +64 -8
  16. package/src/commands/intake.js +16 -5
  17. package/src/commands/metrics.js +16 -1
  18. package/src/commands/next.js +41 -6
  19. package/src/commands/onboard.js +113 -0
  20. package/src/commands/prime.js +53 -4
  21. package/src/commands/review.js +40 -4
  22. package/src/commands/search.js +2 -2
  23. package/src/commands/skill.js +2 -1
  24. package/src/commands/status.js +28 -0
  25. package/src/commands/templates.js +2 -1
  26. package/src/commands/triage.js +16 -1
  27. package/src/commands/ui.js +512 -0
  28. package/src/commands/upgrade.js +62 -4
  29. package/src/commands/validate.js +18 -0
  30. package/src/commands/verify.js +35 -7
  31. package/src/commands/why.js +1 -1
  32. package/src/commands/work.js +90 -42
  33. package/src/commands/workspace.js +89 -66
  34. package/src/index.js +107 -62
  35. package/src/lib/actions.js +62 -3
  36. package/src/lib/adapters.js +83 -1
  37. package/src/lib/agent-bridge.js +400 -0
  38. package/src/lib/applied-deltas.js +82 -0
  39. package/src/lib/args.js +34 -2
  40. package/src/lib/change-numbers.js +115 -0
  41. package/src/lib/change-ops.js +2 -2
  42. package/src/lib/clarify-scan.js +221 -0
  43. package/src/lib/code-inventory.js +594 -104
  44. package/src/lib/commands.js +58 -4
  45. package/src/lib/config.js +27 -8
  46. package/src/lib/conflict-markers.js +61 -0
  47. package/src/lib/console-model.js +668 -0
  48. package/src/lib/console-prefs.js +67 -0
  49. package/src/lib/console-room.js +201 -0
  50. package/src/lib/console-state.js +150 -0
  51. package/src/lib/console-view.js +557 -0
  52. package/src/lib/context-slice.js +34 -3
  53. package/src/lib/coverage-model.js +29 -6
  54. package/src/lib/delivery.js +90 -12
  55. package/src/lib/docs-impact.js +37 -2
  56. package/src/lib/fs-ops.js +103 -12
  57. package/src/lib/gates.js +7 -1
  58. package/src/lib/git.js +18 -0
  59. package/src/lib/index-json.js +10 -1
  60. package/src/lib/index-merge.js +90 -0
  61. package/src/lib/install.js +80 -0
  62. package/src/lib/intake-model.js +5 -2
  63. package/src/lib/json-out.js +9 -1
  64. package/src/lib/ledger.js +2 -3
  65. package/src/lib/lock.js +58 -0
  66. package/src/lib/merge-setup.js +55 -0
  67. package/src/lib/metrics-model.js +2 -2
  68. package/src/lib/onboard.js +197 -0
  69. package/src/lib/preset.js +177 -0
  70. package/src/lib/prompt.js +1 -1
  71. package/src/lib/properties.js +149 -0
  72. package/src/lib/runtime.js +19 -7
  73. package/src/lib/scan.js +13 -8
  74. package/src/lib/shell.js +45 -0
  75. package/src/lib/snapshot.js +5 -1
  76. package/src/lib/spec-ops.js +2 -2
  77. package/src/lib/task-card.js +101 -8
  78. package/src/lib/templates-model.js +3 -2
  79. package/src/lib/triage-model.js +75 -0
  80. package/src/lib/validation-model.js +23 -5
  81. package/src/lib/views.js +5 -4
  82. package/src/lib/work-model.js +8 -2
  83. package/src/lib/workspace-agents.js +67 -20
  84. package/src/lib/workspace-guard.js +65 -0
  85. package/src/lib/workspace-locate.js +141 -0
  86. package/src/lib/workspace-tools.js +104 -17
  87. package/src/lib/workspace-view.js +391 -0
  88. package/src/lib/workspace.js +83 -131
  89. package/templates/AGENTS.md.template +9 -5
  90. package/templates/README.md +4 -2
  91. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  92. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  93. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  94. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  95. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  96. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  97. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  98. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  99. package/templates/adapters/kiro/README.md +22 -0
  100. package/templates/playbooks/absorb.md.template +39 -16
  101. package/templates/playbooks/bootstrap.md.template +18 -6
  102. package/templates/playbooks/delivery.md.template +12 -6
@@ -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,6 +1,6 @@
1
1
  // @ts-check
2
2
  import { fold } from "./lexicon.js";
3
- import { getSection } from "./doc-model.js";
3
+ import { commentRanges, getSection } from "./doc-model.js";
4
4
 
5
5
  // The lean read pack (change 0216).
6
6
  //
@@ -17,6 +17,31 @@ const ADR_DIGEST_CHARS = 700;
17
17
  // How many requirements and criteria a sliced spec keeps at most, each.
18
18
  const SLICE_MAX_ITEMS = 25;
19
19
 
20
+ /**
21
+ * A change file as a reader needs it: without its HTML comments (change
22
+ * 0228). The proposal, tasks and delta templates carry their authoring
23
+ * instructions in comments — forty lines of "for ADDED… for MODIFIED…" in
24
+ * every delta — and the change in focus enters the pack whole, so a change
25
+ * with eight deltas spent ~2.6k tokens repeating the template to an agent
26
+ * that was reading, not writing. Null when the text has no comment.
27
+ *
28
+ * @param {string} text
29
+ * @returns {string | null}
30
+ */
31
+ export function withoutComments(text) {
32
+ const lf = String(text).replace(/\r\n/g, "\n");
33
+ const ranges = commentRanges(lf);
34
+ if (ranges.length === 0) return null;
35
+ let out = "";
36
+ let at = 0;
37
+ for (const [a, b] of ranges) {
38
+ out += lf.slice(at, a);
39
+ at = b;
40
+ }
41
+ out += lf.slice(at);
42
+ return out.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
43
+ }
44
+
20
45
  /**
21
46
  * An ADR as a reader deciding whether it binds the task needs it: the
22
47
  * title and the opening of its Decision section. `doctrina show NNNN`
@@ -50,7 +75,12 @@ export function adrDigest(text, title, id) {
50
75
  */
51
76
  export function sliceSpec(text, queryTerms, rel) {
52
77
  if (queryTerms.length === 0) return null;
53
- const lines = text.replace(/\r\n/g, "\n").split("\n");
78
+ // Read without the template's comments (second audit, change 0233). The
79
+ // EARS comment lists the five forms as bullets — "- Ubiquitous: The system
80
+ // shall <action>." — and they were counted as requirements: every pack of
81
+ // a scaffolded spec reported "… 5 not matching the task" for bullets no
82
+ // one wrote, and carried the forty-line header comment besides.
83
+ const lines = (withoutComments(text) ?? text.replace(/\r\n/g, "\n")).split("\n");
54
84
  // Items: a bullet or numbered entry with its continuation lines.
55
85
  /** @type {Array<{ start: number, end: number, kind: "req"|"crit", score: number }>} */
56
86
  const items = [];
@@ -112,7 +142,8 @@ export function sliceSpec(text, queryTerms, rel) {
112
142
  }
113
143
  flushOmitted();
114
144
  const kept = keep.size;
115
- out.push("", `(sliced for "${queryTerms.join(" ")}" — ${kept} of ${items.length} requirements and criteria kept; ` +
145
+ const shownTerms = queryTerms.length > 12 ? `${queryTerms.slice(0, 12).join(" ")} …` : queryTerms.join(" ");
146
+ out.push("", `(sliced for "${shownTerms}" — ${kept} of ${items.length} requirements and criteria kept; ` +
116
147
  `full text: ${rel}, or \`context --full\`)`);
117
148
  return { text: out.join("\n").replace(/\n{3,}/g, "\n\n"), kept, total: items.length };
118
149
  }