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.
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/commands/adapter.js +10 -0
- package/src/commands/change.js +202 -27
- package/src/commands/clarify.js +3 -206
- package/src/commands/close.js +1 -1
- package/src/commands/context.js +33 -2
- package/src/commands/contract.js +1 -0
- package/src/commands/coverage.js +69 -6
- package/src/commands/delivery.js +135 -17
- package/src/commands/doctor.js +83 -5
- package/src/commands/handoff.js +11 -2
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +64 -8
- package/src/commands/intake.js +16 -5
- package/src/commands/metrics.js +16 -1
- package/src/commands/next.js +41 -6
- package/src/commands/onboard.js +113 -0
- package/src/commands/prime.js +53 -4
- package/src/commands/review.js +40 -4
- package/src/commands/search.js +2 -2
- package/src/commands/skill.js +2 -1
- package/src/commands/status.js +28 -0
- package/src/commands/templates.js +2 -1
- package/src/commands/triage.js +16 -1
- package/src/commands/ui.js +512 -0
- package/src/commands/upgrade.js +62 -4
- package/src/commands/validate.js +18 -0
- package/src/commands/verify.js +35 -7
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +90 -42
- package/src/commands/workspace.js +89 -66
- package/src/index.js +107 -62
- package/src/lib/actions.js +62 -3
- package/src/lib/adapters.js +83 -1
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +34 -2
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +221 -0
- package/src/lib/code-inventory.js +594 -104
- package/src/lib/commands.js +58 -4
- package/src/lib/config.js +27 -8
- package/src/lib/conflict-markers.js +61 -0
- package/src/lib/console-model.js +668 -0
- package/src/lib/console-prefs.js +67 -0
- package/src/lib/console-room.js +201 -0
- package/src/lib/console-state.js +150 -0
- package/src/lib/console-view.js +557 -0
- package/src/lib/context-slice.js +34 -3
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +90 -12
- package/src/lib/docs-impact.js +37 -2
- package/src/lib/fs-ops.js +103 -12
- package/src/lib/gates.js +7 -1
- package/src/lib/git.js +18 -0
- package/src/lib/index-json.js +10 -1
- package/src/lib/index-merge.js +90 -0
- package/src/lib/install.js +80 -0
- package/src/lib/intake-model.js +5 -2
- package/src/lib/json-out.js +9 -1
- package/src/lib/ledger.js +2 -3
- package/src/lib/lock.js +58 -0
- package/src/lib/merge-setup.js +55 -0
- package/src/lib/metrics-model.js +2 -2
- package/src/lib/onboard.js +197 -0
- package/src/lib/preset.js +177 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/properties.js +149 -0
- package/src/lib/runtime.js +19 -7
- package/src/lib/scan.js +13 -8
- package/src/lib/shell.js +45 -0
- package/src/lib/snapshot.js +5 -1
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +101 -8
- package/src/lib/templates-model.js +3 -2
- package/src/lib/triage-model.js +75 -0
- package/src/lib/validation-model.js +23 -5
- package/src/lib/views.js +5 -4
- package/src/lib/work-model.js +8 -2
- package/src/lib/workspace-agents.js +67 -20
- package/src/lib/workspace-guard.js +65 -0
- package/src/lib/workspace-locate.js +141 -0
- package/src/lib/workspace-tools.js +104 -17
- package/src/lib/workspace-view.js +391 -0
- package/src/lib/workspace.js +83 -131
- package/templates/AGENTS.md.template +9 -5
- package/templates/README.md +4 -2
- package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
- package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
- package/templates/adapters/kiro/README.md +22 -0
- package/templates/playbooks/absorb.md.template +39 -16
- package/templates/playbooks/bootstrap.md.template +18 -6
- package/templates/playbooks/delivery.md.template +12 -6
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isFile, mkdirp, read, write } from "./fs-ops.js";
|
|
4
|
+
import { doctrinaHome } from "./workspace-locate.js";
|
|
5
|
+
|
|
6
|
+
// WHAT A PERSON CHOSE IN THE CONSOLE (change 0258).
|
|
7
|
+
//
|
|
8
|
+
// Which agent answers and which model it runs are the person's choices, not
|
|
9
|
+
// the project's: two people on one repository use different agents. So they
|
|
10
|
+
// are kept beside the person's other Doctrina state, in
|
|
11
|
+
// `$DOCTRINA_HOME/console.json`, per directory the console was opened in —
|
|
12
|
+
// never in the repository, and never read by any command but the console.
|
|
13
|
+
|
|
14
|
+
export const PREFS_FILE = "console.json";
|
|
15
|
+
|
|
16
|
+
/** @typedef {{ agent: string | null, models: Record<string, string> }} ConsolePrefs */
|
|
17
|
+
|
|
18
|
+
const prefsPath = (/** @type {NodeJS.ProcessEnv} */ env) => path.join(doctrinaHome(env), PREFS_FILE);
|
|
19
|
+
|
|
20
|
+
function readAll(env) {
|
|
21
|
+
const file = prefsPath(env);
|
|
22
|
+
if (!isFile(file)) return {};
|
|
23
|
+
try {
|
|
24
|
+
const data = JSON.parse(read(file));
|
|
25
|
+
return data && typeof data.projects === "object" && data.projects !== null ? data.projects : {};
|
|
26
|
+
} catch {
|
|
27
|
+
// A file a person broke by hand is their choices lost, not a console that will not open.
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The choices kept for a directory; the defaults when there are none.
|
|
34
|
+
* @param {string} cwd
|
|
35
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
36
|
+
* @returns {ConsolePrefs}
|
|
37
|
+
*/
|
|
38
|
+
export function consolePrefs(cwd, env = process.env) {
|
|
39
|
+
const entry = readAll(env)[path.resolve(cwd)];
|
|
40
|
+
/** @type {Record<string, string>} */
|
|
41
|
+
const models = {};
|
|
42
|
+
if (entry && typeof entry.models === "object" && entry.models !== null) {
|
|
43
|
+
for (const [id, name] of Object.entries(entry.models)) {
|
|
44
|
+
if (typeof name === "string" && name.trim() !== "") models[id] = name;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { agent: typeof entry?.agent === "string" ? entry.agent : null, models };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Keep a directory's choices. Returns false when the file cannot be written:
|
|
52
|
+
* the choice then lasts for the session, which is what it did before.
|
|
53
|
+
* @param {string} cwd
|
|
54
|
+
* @param {ConsolePrefs} prefs
|
|
55
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
56
|
+
*/
|
|
57
|
+
export function saveConsolePrefs(cwd, prefs, env = process.env) {
|
|
58
|
+
try {
|
|
59
|
+
const projects = readAll(env);
|
|
60
|
+
projects[path.resolve(cwd)] = { agent: prefs.agent, models: prefs.models };
|
|
61
|
+
mkdirp(doctrinaHome(env));
|
|
62
|
+
write(prefsPath(env), JSON.stringify({ version: 1, projects }, null, 2) + "\n", { force: true, atomic: true });
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { isDir, isFile, read } from "./fs-ops.js";
|
|
6
|
+
import { doctrinaHome, findWorkspace, projectAt, readWorkspace } from "./workspace-locate.js";
|
|
7
|
+
|
|
8
|
+
// CONSOLES THAT TALK TO EACH OTHER (change 0259, ADR 0033).
|
|
9
|
+
//
|
|
10
|
+
// A person with three repositories has three terminals. A console in each
|
|
11
|
+
// is three conversations that cannot hear one another — and the planner at
|
|
12
|
+
// the workspace root (ADR 0030) has nobody to hand a card to.
|
|
13
|
+
//
|
|
14
|
+
// A ROOM is what consoles opened for the same work share: the workspace
|
|
15
|
+
// when there is one, the directory otherwise. It is a folder under the
|
|
16
|
+
// person's `$DOCTRINA_HOME`, readable and writable by them alone. Each
|
|
17
|
+
// console keeps one small file there saying it is alive, and has an inbox;
|
|
18
|
+
// a message is a file moved into an inbox. No socket, no port, no daemon:
|
|
19
|
+
// the CLI still makes no network call, and a console that dies leaves a
|
|
20
|
+
// file that stops being fresh.
|
|
21
|
+
//
|
|
22
|
+
// Nothing here reads a terminal or starts a process.
|
|
23
|
+
|
|
24
|
+
/** @typedef {{ id: string, name: string, role: "planner" | "project" | "sender", cwd: string, pid: number, agent: string | null, model: string | null, busy: boolean, listening: boolean, seen: number }} Peer */
|
|
25
|
+
/** @typedef {{ dir: string, root: string, id: string, name: string, role: Peer["role"] }} Room */
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {{ type: "request", id: string, from: { id: string, name: string }, text: string, at: number }
|
|
28
|
+
* | { type: "result", re: string, from: { id: string, name: string }, ok: boolean, summary: string, detail: string, at: number }
|
|
29
|
+
* | { type: "refused", re: string, from: { id: string, name: string }, reason: string, at: number }} Message
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** A peer that has not said it is alive for this long is gone. */
|
|
33
|
+
export const PEER_STALE_MS = 10_000;
|
|
34
|
+
|
|
35
|
+
const key = (/** @type {string} */ root) => createHash("sha256").update(root).digest("hex").slice(0, 12);
|
|
36
|
+
const token = () => randomBytes(3).toString("hex");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Where the consoles of this directory meet, and what this one is called there.
|
|
40
|
+
* @param {string} cwd
|
|
41
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
42
|
+
*/
|
|
43
|
+
export function roomFor(cwd, env = process.env) {
|
|
44
|
+
const here = path.resolve(cwd);
|
|
45
|
+
const found = findWorkspace(here, env);
|
|
46
|
+
let root = here;
|
|
47
|
+
let name = path.basename(here) || "console";
|
|
48
|
+
/** @type {Peer["role"]} */
|
|
49
|
+
let role = "project";
|
|
50
|
+
if (found) {
|
|
51
|
+
try {
|
|
52
|
+
const ws = readWorkspace(found.dir);
|
|
53
|
+
const member = projectAt(ws, here);
|
|
54
|
+
if (path.resolve(found.dir) === here) {
|
|
55
|
+
root = here;
|
|
56
|
+
name = ws.name || name;
|
|
57
|
+
role = "planner";
|
|
58
|
+
} else if (member) {
|
|
59
|
+
root = path.resolve(found.dir);
|
|
60
|
+
name = member.name;
|
|
61
|
+
}
|
|
62
|
+
} catch { /* an unreadable workspace is no room: the directory is */ }
|
|
63
|
+
}
|
|
64
|
+
return { dir: path.join(doctrinaHome(env), "rooms", key(root)), root, name, role };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeJson(file, data) {
|
|
68
|
+
const tmp = `${file}.${token()}.tmp`;
|
|
69
|
+
writeFileSync(tmp, JSON.stringify(data));
|
|
70
|
+
renameSync(tmp, file);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readJson(file) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(read(file));
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every peer file in the room that is still fresh. */
|
|
82
|
+
function alive(dir, now) {
|
|
83
|
+
const peers = path.join(dir, "peers");
|
|
84
|
+
/** @type {Peer[]} */
|
|
85
|
+
const out = [];
|
|
86
|
+
if (!isDir(peers)) return out;
|
|
87
|
+
for (const f of readdirSync(peers).filter((n) => n.endsWith(".json")).sort()) {
|
|
88
|
+
const p = readJson(path.join(peers, f));
|
|
89
|
+
if (!p || typeof p.id !== "string" || typeof p.seen !== "number") continue;
|
|
90
|
+
if (now - p.seen > PEER_STALE_MS) {
|
|
91
|
+
// What a console that died left behind: its card and its unread mail.
|
|
92
|
+
rmSync(path.join(peers, f), { force: true });
|
|
93
|
+
rmSync(path.join(dir, "inbox", p.id), { recursive: true, force: true });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
out.push(p);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Enter the room: a card of this console's own, and an inbox.
|
|
103
|
+
* @param {string} cwd
|
|
104
|
+
* @param {{ env?: NodeJS.ProcessEnv, role?: Peer["role"], name?: string, now?: number }} [opts]
|
|
105
|
+
* @returns {Room}
|
|
106
|
+
*/
|
|
107
|
+
export function joinRoom(cwd, { env = process.env, role, name, now = Date.now() } = {}) {
|
|
108
|
+
const at = roomFor(cwd, env);
|
|
109
|
+
// The person's alone: a request found here was put here by them.
|
|
110
|
+
mkdirSync(path.join(at.dir, "peers"), { recursive: true, mode: 0o700 });
|
|
111
|
+
mkdirSync(path.join(at.dir, "inbox"), { recursive: true, mode: 0o700 });
|
|
112
|
+
if (!isFile(path.join(at.dir, "room.json"))) writeJson(path.join(at.dir, "room.json"), { root: at.root });
|
|
113
|
+
const id = token();
|
|
114
|
+
// Two consoles in one directory are told apart by a number.
|
|
115
|
+
const taken = new Set(alive(at.dir, now).filter((p) => p.role !== "sender").map((p) => p.name));
|
|
116
|
+
let unique = name ?? at.name;
|
|
117
|
+
// A `--send` speaks as the console whose agent ran it: it keeps that name.
|
|
118
|
+
for (let n = 2; role !== "sender" && taken.has(unique); n++) unique = `${name ?? at.name}-${n}`;
|
|
119
|
+
mkdirSync(path.join(at.dir, "inbox", id), { recursive: true, mode: 0o700 });
|
|
120
|
+
return { dir: at.dir, root: at.root, id, name: unique, role: role ?? at.role };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Say this console is alive, and what it is doing.
|
|
125
|
+
* @param {Room} room
|
|
126
|
+
* @param {{ cwd: string, agent: string | null, model: string | null, busy: boolean, listening: boolean }} info
|
|
127
|
+
* @param {number} [now]
|
|
128
|
+
*/
|
|
129
|
+
export function beat(room, info, now = Date.now()) {
|
|
130
|
+
/** @type {Peer} */
|
|
131
|
+
const card = { id: room.id, name: room.name, role: room.role, pid: process.pid, seen: now, ...info };
|
|
132
|
+
try {
|
|
133
|
+
writeJson(path.join(room.dir, "peers", `${room.id}.json`), card);
|
|
134
|
+
} catch { /* a home that cannot be written is a console alone, not a console that stops */ }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Leave: the card and the inbox go. */
|
|
138
|
+
export function leaveRoom(room) {
|
|
139
|
+
rmSync(path.join(room.dir, "peers", `${room.id}.json`), { force: true });
|
|
140
|
+
rmSync(path.join(room.dir, "inbox", room.id), { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The other consoles in the room, by name. A `--send` in flight is not one.
|
|
145
|
+
* @param {Room} room
|
|
146
|
+
* @param {number} [now]
|
|
147
|
+
* @returns {Peer[]}
|
|
148
|
+
*/
|
|
149
|
+
export function listPeers(room, now = Date.now()) {
|
|
150
|
+
return alive(room.dir, now).filter((p) => p.id !== room.id && p.role !== "sender").sort((a, b) => a.name.localeCompare(b.name));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The consoles open for a directory, without entering the room.
|
|
155
|
+
* @param {string} cwd
|
|
156
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
157
|
+
* @param {number} [now]
|
|
158
|
+
* @returns {Peer[]}
|
|
159
|
+
*/
|
|
160
|
+
export function roomPeers(cwd, env = process.env, now = Date.now()) {
|
|
161
|
+
return alive(roomFor(cwd, env).dir, now).filter((p) => p.role !== "sender").sort((a, b) => a.name.localeCompare(b.name));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Put a message in a console's inbox. False when that console is gone.
|
|
166
|
+
* @param {Room} room
|
|
167
|
+
* @param {string} toId
|
|
168
|
+
* @param {Message} message
|
|
169
|
+
*/
|
|
170
|
+
export function sendMessage(room, toId, message) {
|
|
171
|
+
const inbox = path.join(room.dir, "inbox", toId);
|
|
172
|
+
if (!/^[0-9a-f]+$/.test(toId) || !isDir(inbox)) return false;
|
|
173
|
+
try {
|
|
174
|
+
writeJson(path.join(inbox, `${String(message.at).padStart(15, "0")}-${token()}.json`), message);
|
|
175
|
+
return true;
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Take what arrived, oldest first. Each message is read once.
|
|
183
|
+
* @param {Room} room
|
|
184
|
+
* @returns {Message[]}
|
|
185
|
+
*/
|
|
186
|
+
export function takeInbox(room) {
|
|
187
|
+
const inbox = path.join(room.dir, "inbox", room.id);
|
|
188
|
+
/** @type {Message[]} */
|
|
189
|
+
const out = [];
|
|
190
|
+
if (!isDir(inbox)) return out;
|
|
191
|
+
for (const f of readdirSync(inbox).filter((n) => n.endsWith(".json")).sort()) {
|
|
192
|
+
const file = path.join(inbox, f);
|
|
193
|
+
const m = readJson(file);
|
|
194
|
+
rmSync(file, { force: true });
|
|
195
|
+
if (m && typeof m.type === "string" && m.from && typeof m.from.id === "string" && typeof m.from.name === "string") out.push(m);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A new message id. */
|
|
201
|
+
export const messageId = () => `${Date.now().toString(36)}${token()}`;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isDir } from "./fs-ops.js";
|
|
4
|
+
import * as idx from "./index-json.js";
|
|
5
|
+
import { collectSnapshot } from "./snapshot.js";
|
|
6
|
+
import { invocation } from "./actions.js";
|
|
7
|
+
import { cliVersion } from "./version.js";
|
|
8
|
+
import { workspaceActions, workspaceRootAt, workspaceSnapshot } from "./workspace-view.js";
|
|
9
|
+
|
|
10
|
+
// WHAT THE CONSOLE'S PANEL SHOWS (change 0257).
|
|
11
|
+
//
|
|
12
|
+
// The console is a face for a human; the panel beside the conversation is
|
|
13
|
+
// the project's state — the gates, the open work, the next step — and it is
|
|
14
|
+
// read from the tree after every command, never kept by the console. It is
|
|
15
|
+
// the snapshot `prime`, `status` and `next` already render, reduced to what
|
|
16
|
+
// fits a side panel, so the panel cannot disagree with them.
|
|
17
|
+
//
|
|
18
|
+
// Nothing here prints and nothing here writes. `doctrina ui --state` emits
|
|
19
|
+
// it as JSON from a process of its own: the console is long-lived and the
|
|
20
|
+
// CLI's collectors were written for one run.
|
|
21
|
+
|
|
22
|
+
/** @typedef {"ok" | "warn" | "fail" | "off"} Light */
|
|
23
|
+
/** @typedef {{ id: string, state: Light, text: string }} Gate */
|
|
24
|
+
/** @typedef {{ id: string, title: string, status: string, done: number, total: number, stage: "plan" | "build" | "close", delivery: string | null }} OpenChange */
|
|
25
|
+
/** @typedef {{ kind: string, id: string, label: string, path: string }} Mention */
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {{
|
|
28
|
+
* kind: "project" | "workspace" | "none",
|
|
29
|
+
* project: string | null,
|
|
30
|
+
* cli: string,
|
|
31
|
+
* gates: Gate[],
|
|
32
|
+
* counts: { specs: number, decisions: number, skills: number, closed: number },
|
|
33
|
+
* open: OpenChange[],
|
|
34
|
+
* next: { text: string, command: string | null }[],
|
|
35
|
+
* recent: { id: string, date: string }[],
|
|
36
|
+
* projects: { name: string, mode: string, state: string, reason: string }[],
|
|
37
|
+
* mentions: Mention[],
|
|
38
|
+
* }} ConsoleState
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const NEXT_SHOWN = 5;
|
|
42
|
+
|
|
43
|
+
/** A change's stage, from its tasks: written yet, ticked yet. */
|
|
44
|
+
function stageOf(ch) {
|
|
45
|
+
const placeholders = ch.unchecked.filter((/** @type {string} */ t) => t.startsWith("(unwritten task")).length;
|
|
46
|
+
if (ch.tasksTotal === 0 || placeholders > 0) return /** @type {const} */ ("plan");
|
|
47
|
+
return ch.tasksDone >= ch.tasksTotal ? /** @type {const} */ ("close") : /** @type {const} */ ("build");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @returns {ConsoleState} */
|
|
51
|
+
function blank(kind) {
|
|
52
|
+
return {
|
|
53
|
+
kind, project: null, cli: cliVersion(), gates: [],
|
|
54
|
+
counts: { specs: 0, decisions: 0, skills: 0, closed: 0 },
|
|
55
|
+
open: [], next: [], recent: [], projects: [], mentions: [],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The panel's state for the directory the console runs in.
|
|
61
|
+
* @param {string} cwd
|
|
62
|
+
* @returns {ConsoleState}
|
|
63
|
+
*/
|
|
64
|
+
export function consoleState(cwd) {
|
|
65
|
+
const wsRoot = workspaceRootAt(cwd);
|
|
66
|
+
if (wsRoot) return workspaceState(wsRoot);
|
|
67
|
+
if (!isDir(path.join(cwd, ".doctrina"))) {
|
|
68
|
+
const out = blank("none");
|
|
69
|
+
out.next = [
|
|
70
|
+
{ text: "this directory is not a Doctrina project yet", command: "doctrina init" },
|
|
71
|
+
{ text: "the whole cycle once, guided", command: "doctrina onboard" },
|
|
72
|
+
];
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
const s = collectSnapshot(cwd);
|
|
76
|
+
const out = blank("project");
|
|
77
|
+
out.project = s.project;
|
|
78
|
+
const cov = s.coverage;
|
|
79
|
+
out.gates = [
|
|
80
|
+
{ id: "index", state: s.indexState === "in-sync" ? "ok" : "fail", text: s.indexState },
|
|
81
|
+
cov.pct === null || cov.pct === undefined
|
|
82
|
+
? { id: "coverage", state: "off", text: "no criteria" }
|
|
83
|
+
: { id: "coverage", state: cov.pct === 100 ? "ok" : "warn", text: `${cov.pct}% (${cov.totalCovered}/${cov.totalCriteria})` },
|
|
84
|
+
s.trace.anchors === 0
|
|
85
|
+
? { id: "trace", state: "off", text: "no anchors" }
|
|
86
|
+
: { id: "trace", state: s.trace.realized === s.trace.anchors ? "ok" : "warn", text: `${s.trace.realized}/${s.trace.anchors}` },
|
|
87
|
+
!s.verify.configured
|
|
88
|
+
? { id: "verify", state: "off", text: "not set" }
|
|
89
|
+
: (s.verify.invalid
|
|
90
|
+
? { id: "verify", state: "fail", text: "invalid" }
|
|
91
|
+
: { id: "verify", state: "ok", text: `${s.verify.checks} check${s.verify.checks === 1 ? "" : "s"}` }),
|
|
92
|
+
];
|
|
93
|
+
out.counts = { specs: s.specs.total, decisions: s.decisions.total, skills: s.skills, closed: s.archive.length };
|
|
94
|
+
out.open = s.openChanges.map((ch) => ({
|
|
95
|
+
id: ch.id, title: ch.title ?? "", status: ch.status, done: ch.tasksDone, total: ch.tasksTotal,
|
|
96
|
+
stage: stageOf(ch), delivery: ch.delivery,
|
|
97
|
+
}));
|
|
98
|
+
out.next = s.actions.slice(0, NEXT_SHOWN).map((a) => ({ text: a.why, command: invocation(a) }));
|
|
99
|
+
out.recent = s.recentlyClosed.map((r) => ({ id: r.id, date: r.date }));
|
|
100
|
+
out.mentions = mentionsOf(cwd);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What `@` completes to: the artifacts the index names. */
|
|
105
|
+
function mentionsOf(projectRoot) {
|
|
106
|
+
/** @type {Mention[]} */
|
|
107
|
+
const out = [];
|
|
108
|
+
let index;
|
|
109
|
+
try {
|
|
110
|
+
index = idx.load(projectRoot);
|
|
111
|
+
} catch {
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
const a = index?.artifacts ?? {};
|
|
115
|
+
for (const ch of a.changes ?? []) out.push({ kind: "change", id: ch.id, label: ch.title ?? "", path: `${ch.path}/proposal.md` });
|
|
116
|
+
for (const s of a.specs ?? []) out.push({ kind: "spec", id: s.id, label: `v${s.version} · ${s.implementation ?? s.status ?? ""}`, path: s.path });
|
|
117
|
+
for (const s of a.skills ?? []) out.push({ kind: "skill", id: s.id, label: s.description ?? "", path: s.path });
|
|
118
|
+
for (const d of a.decisions ?? []) out.push({ kind: "adr", id: d.id, label: d.title ?? "", path: d.path });
|
|
119
|
+
for (const ct of a.contracts ?? []) out.push({ kind: "contract", id: ct.id, label: "", path: ct.path });
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @returns {ConsoleState} */
|
|
124
|
+
function workspaceState(wsRoot) {
|
|
125
|
+
const snap = workspaceSnapshot(wsRoot);
|
|
126
|
+
const out = blank("workspace");
|
|
127
|
+
out.project = snap.ws.name ?? path.basename(wsRoot);
|
|
128
|
+
out.projects = snap.projects.map((p) => ({
|
|
129
|
+
name: p.project.name, mode: p.project.mode ?? "", state: p.state, reason: p.reason ?? "",
|
|
130
|
+
}));
|
|
131
|
+
const ready = out.projects.filter((p) => p.state === "ready").length;
|
|
132
|
+
out.gates = [
|
|
133
|
+
{ id: "projects", state: ready === out.projects.length ? "ok" : "warn", text: `${ready}/${out.projects.length} ready` },
|
|
134
|
+
{ id: "deliveries", state: snap.open.length === 0 ? "off" : "ok", text: `${snap.open.length} open` },
|
|
135
|
+
];
|
|
136
|
+
out.counts.closed = snap.deliveries.length - snap.open.length;
|
|
137
|
+
out.open = snap.open.map(({ delivery: d, rows }) => {
|
|
138
|
+
const changes = rows.flatMap((r) => r.changes ?? []);
|
|
139
|
+
const closed = changes.filter((/** @type {any} */ ch) => ch.archived || ch.status === "archived").length;
|
|
140
|
+
return {
|
|
141
|
+
id: d.id, title: d.title, status: d.status, done: closed, total: Math.max(d.projects.length, changes.length),
|
|
142
|
+
stage: d.projects.length === 0 ? /** @type {const} */ ("plan") : /** @type {const} */ ("build"),
|
|
143
|
+
delivery: null,
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
out.next = workspaceActions(snap).slice(0, NEXT_SHOWN).map((a) => ({ text: a.text, command: a.command ?? null }));
|
|
147
|
+
for (const p of out.projects) out.mentions.push({ kind: "project", id: p.name, label: `${p.mode} · ${p.state}`, path: p.name });
|
|
148
|
+
for (const { delivery: d } of snap.open) out.mentions.push({ kind: "delivery", id: d.id, label: d.title, path: `deliveries/${d.id}/delivery.md` });
|
|
149
|
+
return out;
|
|
150
|
+
}
|