great-cto 2.85.2 → 2.86.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/board/.claude-plugin/plugin.json +6 -1
- package/board/packages/board/lib/beads.mjs +196 -5
- package/board/packages/board/lib/fleet.mjs +36 -11
- package/board/packages/board/lib/projects.mjs +45 -12
- package/board/packages/board/lib/routes.mjs +73 -24
- package/board/packages/board/lib/util.mjs +48 -1
- package/board/packages/board/public/index.html +87 -6
- package/dist/board-daemon.js +143 -0
- package/dist/main.js +174 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "great_cto",
|
|
3
3
|
"id": "great_cto",
|
|
4
4
|
"description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.86.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -236,6 +236,11 @@
|
|
|
236
236
|
"type": "command",
|
|
237
237
|
"command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); node \"${PLUGIN_DIR}/scripts/hooks/cost-guard.mjs\" 2>&1 1>/dev/null; true",
|
|
238
238
|
"timeout": 3
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"type": "command",
|
|
242
|
+
"command": "[ -n \"$GREAT_CTO_CLASS_TELEMETRY\" ] && { PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); node \"${PLUGIN_DIR}/scripts/hooks/classify-telemetry.mjs\" 2>/dev/null; }; true",
|
|
243
|
+
"timeout": 3
|
|
239
244
|
}
|
|
240
245
|
]
|
|
241
246
|
}
|
|
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { spawnSync } from 'child_process';
|
|
5
|
+
import { readSafe } from './util.mjs';
|
|
5
6
|
import { bdCache } from './state.mjs';
|
|
6
7
|
import { log } from './log.mjs';
|
|
7
8
|
|
|
@@ -119,13 +120,194 @@ function bdList(cwd = process.cwd(), runner = bd) {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
123
|
+
// Map a free-form status word (from either tasks.md dialect) to the UI status
|
|
124
|
+
// the board renders, plus the gate flag. Kept separate so both the checkbox and
|
|
125
|
+
// the table parser classify identically.
|
|
126
|
+
function tasksMdStatus(rawStatus, id, title) {
|
|
127
|
+
const s = String(rawStatus || '').toLowerCase().trim();
|
|
128
|
+
const isGate = /^gate[:\-]/i.test(title || '') || /^gate\b/i.test(id || '')
|
|
129
|
+
|| (title || '').toLowerCase().includes('gate:');
|
|
130
|
+
let status;
|
|
131
|
+
if (s === 'done' || s === 'closed' || s === 'x') status = 'done';
|
|
132
|
+
else if (s === 'in_progress' || s === 'in-progress' || s === 'wip' || s === 'doing') status = 'in_progress';
|
|
133
|
+
else if (s === 'blocked') status = 'blocked';
|
|
134
|
+
else status = isGate ? 'gate' : 'backlog';
|
|
135
|
+
return { status, raw_status: status === 'done' ? 'closed' : 'open', isGate };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Build the full task record both parsers emit — one shape so getTasks can
|
|
139
|
+
// return either verbatim.
|
|
140
|
+
function tasksMdRecord({ id, title, description, status, raw_status, isGate, owner, agent, est }) {
|
|
141
|
+
return {
|
|
142
|
+
id,
|
|
143
|
+
title: (title || '').trim(),
|
|
144
|
+
description: (description || '').trim(),
|
|
145
|
+
notes: '', design: '', acceptance: '',
|
|
146
|
+
status, raw_status,
|
|
147
|
+
priority: 2,
|
|
148
|
+
labels: agent ? [agent] : [],
|
|
149
|
+
owner: owner || agent || '',
|
|
150
|
+
created_at: null, updated_at: null, closed_at: null,
|
|
151
|
+
close_reason: '', comment_count: 0,
|
|
152
|
+
is_gate: isGate,
|
|
153
|
+
agent: agent || '',
|
|
154
|
+
estimated_minutes: est ? (parseInt(est) || null) : null,
|
|
155
|
+
source: 'tasks.md',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The pipeline falls back to a Markdown *table* (`| id | title | status | owner |`)
|
|
160
|
+
// when beads can't open the path (e.g. a space in it). Parse those rows. Requires
|
|
161
|
+
// a header row containing at least `id`, `title`, `status` so unrelated tables in
|
|
162
|
+
// the file (metrics, config) are never misread as tasks.
|
|
163
|
+
// Split one Markdown table row into cells. Markdown escapes a literal pipe
|
|
164
|
+
// inside a cell as `\|` (task notes are full of them: `range=1d\|1w\|1m\|all`,
|
|
165
|
+
// `key\|secret\|token`). Splitting on a bare `|` shredded those rows into extra
|
|
166
|
+
// columns, shoving `open`/`1m\`/`M` into the owner slot → junk filter chips and
|
|
167
|
+
// broken layout. Split only on UNescaped pipes, then unescape each cell.
|
|
168
|
+
function splitTableRow(line) {
|
|
169
|
+
return line
|
|
170
|
+
.replace(/^\s*\|/, '')
|
|
171
|
+
.replace(/\|\s*$/, '')
|
|
172
|
+
.split(/(?<!\\)\|/)
|
|
173
|
+
.map(c => c.replace(/\\\|/g, '|').trim());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// An owner/agent cell is a short handle (senior-dev, CTO, qa-engineer). This
|
|
177
|
+
// tasks.md has 19 tables in 3 schemas (incl. `id|title|size|horizon|status|owner`
|
|
178
|
+
// and `id|severity|finding|status`), plus ragged/duplicated rows, so a mis-aligned
|
|
179
|
+
// row can drop a status word ("done"), a size ("M"), or a horizon code ("H2") into
|
|
180
|
+
// the owner slot. Those became bogus agent/label filter chips. Accept only real
|
|
181
|
+
// handle shapes; reject status/size words and anything with a digit.
|
|
182
|
+
const NON_OWNER = new Set([
|
|
183
|
+
'done', 'closed', 'open', 'in_progress', 'in-progress', 'blocked', 'backlog',
|
|
184
|
+
'todo', 'wip', 'ready', 'gate', 'xs', 's', 'm', 'l', 'xl', // status + t-shirt sizes
|
|
185
|
+
]);
|
|
186
|
+
function cleanOwner(raw) {
|
|
187
|
+
const o = String(raw || '').trim();
|
|
188
|
+
if (!o || o === '—' || o.length > 40 || /\s—\s|[.;]/.test(o)) return '';
|
|
189
|
+
if (NON_OWNER.has(o.toLowerCase())) return '';
|
|
190
|
+
// Real handles are letters + hyphens (senior-dev, product-owner, CTO, pm).
|
|
191
|
+
// Anything with a digit (H2, P1, 1m) is a size/horizon/estimate code, not an owner.
|
|
192
|
+
if (!/^[A-Za-z]+(-[A-Za-z]+)*$/.test(o)) return '';
|
|
193
|
+
return o;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// tasks.md table "titles" can run to hundreds of chars (they carry a full
|
|
197
|
+
// implementation note). bd titles are short, so no view was built to clamp this
|
|
198
|
+
// much text and long rows overflowed the layout. Keep the card title readable and
|
|
199
|
+
// push the overflow into the description (which renders in a scrollable panel).
|
|
200
|
+
const TITLE_MAX = 160;
|
|
201
|
+
function capTitle(title, description) {
|
|
202
|
+
const t = String(title || '').trim();
|
|
203
|
+
if (t.length <= TITLE_MAX) return { title: t, description };
|
|
204
|
+
const cut = t.lastIndexOf(' ', TITLE_MAX);
|
|
205
|
+
const at = cut > TITLE_MAX * 0.6 ? cut : TITLE_MAX;
|
|
206
|
+
const overflow = t.slice(at).trim();
|
|
207
|
+
return {
|
|
208
|
+
title: t.slice(0, at).trim() + '…',
|
|
209
|
+
description: overflow + (description ? ' — ' + description : ''),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseTableTasks(text) {
|
|
214
|
+
const lines = text.split('\n');
|
|
215
|
+
const tasks = [];
|
|
216
|
+
let cols = null; // { id, title, status, owner } → column indices
|
|
217
|
+
const idLike = /^[A-Za-z][\w.]*-[\w.\-]+$/; // e.g. GATE-arch, TASK-12, EPIC-2, AUTH-01
|
|
218
|
+
for (const line of lines) {
|
|
219
|
+
if (!/^\s*\|.*\|\s*$/.test(line)) { cols = null; continue; } // table ended
|
|
220
|
+
const cells = splitTableRow(line);
|
|
221
|
+
if (/^:?-{2,}:?$/.test(cells[0] || '')) continue; // separator row
|
|
222
|
+
if (!cols) {
|
|
223
|
+
const lower = cells.map(c => c.toLowerCase());
|
|
224
|
+
const idx = n => lower.indexOf(n);
|
|
225
|
+
if (idx('id') !== -1 && idx('title') !== -1 && idx('status') !== -1) {
|
|
226
|
+
cols = { id: idx('id'), title: idx('title'), status: idx('status'), owner: idx('owner') };
|
|
227
|
+
}
|
|
228
|
+
continue; // header consumed (or a non-task table's row skipped)
|
|
229
|
+
}
|
|
230
|
+
const id = cells[cols.id] || '';
|
|
231
|
+
if (!idLike.test(id)) continue; // not a task row
|
|
232
|
+
// Split a trailing `[ … ]` completion note off the title into the description.
|
|
233
|
+
let rawTitle = (cells[cols.title] || '').replace(/\*\*/g, '');
|
|
234
|
+
let description = '';
|
|
235
|
+
const noteAt = rawTitle.search(/`?\[/);
|
|
236
|
+
if (noteAt > 0) {
|
|
237
|
+
description = rawTitle.slice(noteAt).replace(/^`|`$/g, '').replace(/^\[|\]$/g, '').trim();
|
|
238
|
+
rawTitle = rawTitle.slice(0, noteAt).trim();
|
|
239
|
+
}
|
|
240
|
+
const owner = cols.owner !== -1 ? cleanOwner(cells[cols.owner]) : '';
|
|
241
|
+
const { status, raw_status, isGate } = tasksMdStatus(cells[cols.status], id, rawTitle);
|
|
242
|
+
const capped = capTitle(rawTitle, description);
|
|
243
|
+
tasks.push(tasksMdRecord({ id, title: capped.title, description: capped.description, status, raw_status, isGate, owner, agent: owner }));
|
|
244
|
+
}
|
|
245
|
+
return tasks;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Best-effort write-back for bd-less projects (e.g. a path with a space, where
|
|
249
|
+
// embedded-dolt can't open its store): flip the `status` cell of a task's row in
|
|
250
|
+
// the tasks.md table so a board gate approve/reject still persists. Returns true
|
|
251
|
+
// if a matching row was updated. Only touches the table dialect (the checkbox
|
|
252
|
+
// dialect has no separate status cell to rewrite in place).
|
|
253
|
+
function setTaskStatusInTasksMd(cwd, id, newStatus) {
|
|
254
|
+
const fp = path.join(cwd, '.great_cto', 'tasks.md');
|
|
255
|
+
if (!fs.existsSync(fp)) return false;
|
|
256
|
+
let text;
|
|
257
|
+
try { text = fs.readFileSync(fp, 'utf8'); } catch { return false; }
|
|
258
|
+
const lines = text.split('\n');
|
|
259
|
+
let cols = null, changed = false;
|
|
260
|
+
for (let i = 0; i < lines.length; i++) {
|
|
261
|
+
if (!/^\s*\|.*\|\s*$/.test(lines[i])) { cols = null; continue; }
|
|
262
|
+
const cells = splitTableRow(lines[i]);
|
|
263
|
+
if (/^:?-{2,}:?$/.test(cells[0] || '')) continue;
|
|
264
|
+
if (!cols) {
|
|
265
|
+
const lower = cells.map(c => c.toLowerCase());
|
|
266
|
+
if (lower.indexOf('id') !== -1 && lower.indexOf('status') !== -1) {
|
|
267
|
+
cols = { id: lower.indexOf('id'), status: lower.indexOf('status') };
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if ((cells[cols.id] || '') === id) {
|
|
272
|
+
cells[cols.status] = newStatus;
|
|
273
|
+
// Re-escape pipes we unescaped on the way in, then rebuild the row.
|
|
274
|
+
lines[i] = '| ' + cells.map(c => c.replace(/\|/g, '\\|')).join(' | ') + ' |';
|
|
275
|
+
changed = true;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (!changed) return false;
|
|
280
|
+
try { fs.writeFileSync(fp, lines.join('\n')); return true; } catch { return false; }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Fallback: parse .great_cto/tasks.md when Beads isn't initialized (or can't
|
|
284
|
+
// open its store). Two dialects, tried in order:
|
|
285
|
+
// 1. checkbox: `- [ ] TASK-001: Title [agent] [~42min]` + indented description
|
|
286
|
+
// 2. table: `| id | title | status | owner |` rows (space-in-path fallback)
|
|
287
|
+
// Why a read failed, per project dir — so the API can report "could not read
|
|
288
|
+
// this" instead of an empty board. `null` means "nothing wrong": either the file
|
|
289
|
+
// is legitimately absent or it parsed fine. Absence is normal; unreadable is not.
|
|
290
|
+
const readDegradation = new Map();
|
|
291
|
+
|
|
292
|
+
/** Degradation reason for a project's task sources, or null when healthy. */
|
|
293
|
+
function getReadDegradation(cwd = process.cwd()) {
|
|
294
|
+
return readDegradation.get(cwd) || null;
|
|
295
|
+
}
|
|
296
|
+
|
|
124
297
|
function parseTasksMd(cwd) {
|
|
125
298
|
const fp = path.join(cwd, '.great_cto', 'tasks.md');
|
|
126
|
-
|
|
299
|
+
const r = readSafe(fp);
|
|
300
|
+
if (!r.ok) {
|
|
301
|
+
// Missing is a normal state (a project may track tasks in beads only).
|
|
302
|
+
// Unreadable is a defect the operator must see rather than read as "no tasks".
|
|
303
|
+
readDegradation.set(cwd, r.reason === 'missing'
|
|
304
|
+
? null
|
|
305
|
+
: `tasks.md could not be read: ${r.error}`);
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
readDegradation.set(cwd, null);
|
|
127
309
|
try {
|
|
128
|
-
const text =
|
|
310
|
+
const text = r.text;
|
|
129
311
|
const tasks = [];
|
|
130
312
|
const lines = text.split('\n');
|
|
131
313
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -162,8 +344,15 @@ function parseTasksMd(cwd) {
|
|
|
162
344
|
source: 'tasks.md',
|
|
163
345
|
});
|
|
164
346
|
}
|
|
347
|
+
// No checkbox tasks → try the table dialect before giving up.
|
|
348
|
+
if (tasks.length === 0) return parseTableTasks(r.text);
|
|
165
349
|
return tasks;
|
|
166
|
-
} catch {
|
|
350
|
+
} catch (e) {
|
|
351
|
+
// The file was readable but we could not make sense of it. Record it: an
|
|
352
|
+
// empty list here is a parser defect, not an empty backlog.
|
|
353
|
+
readDegradation.set(cwd, `tasks.md could not be parsed: ${e?.message || e}`);
|
|
354
|
+
return [];
|
|
355
|
+
}
|
|
167
356
|
}
|
|
168
357
|
|
|
169
358
|
function getTasks(cwd = process.cwd()) {
|
|
@@ -236,6 +425,8 @@ export {
|
|
|
236
425
|
bdWriteSerialised,
|
|
237
426
|
bdList,
|
|
238
427
|
parseTasksMd,
|
|
428
|
+
getReadDegradation,
|
|
429
|
+
setTaskStatusInTasksMd,
|
|
239
430
|
getTasks,
|
|
240
431
|
mapStatus,
|
|
241
432
|
detectAgent,
|
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { GREAT_CTO_DIR } from './config.mjs';
|
|
5
5
|
import { readFileSafe } from './util.mjs';
|
|
6
|
+
import { log } from './log.mjs';
|
|
6
7
|
import { readVerdicts } from './verdicts.mjs';
|
|
7
8
|
|
|
8
9
|
// ── Agent fleet view (DESIGN-agents-fleet-view §3) ─────────────────────────
|
|
@@ -297,23 +298,39 @@ function restoreAgent(slug) {
|
|
|
297
298
|
return { ok: true, slug, restored_at: new Date().toISOString() };
|
|
298
299
|
}
|
|
299
300
|
|
|
300
|
-
// ── decisions.md (
|
|
301
|
+
// ── decisions.md (per-project ADR log) ─────────────────────────────────────
|
|
301
302
|
// Append-only architectural decisions log. Triggered on gate approve/reject.
|
|
302
303
|
// One line per decision; pure markdown so users can `cat` / `grep` / view in
|
|
303
304
|
// their editor without tooling.
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
//
|
|
306
|
+
// SCOPED PER PROJECT (ADR-008). A gate title carries the project's own words —
|
|
307
|
+
// feature names, client names, internal slugs — so writing it to a file under
|
|
308
|
+
// ~/.great_cto made it readable by agents working on *every other* project.
|
|
309
|
+
// That is a real cross-tenant bleed, and it fired: a private client name reached
|
|
310
|
+
// the global log via this exact path. New writes always go project-local; the
|
|
311
|
+
// legacy global file is read-only history and is never appended to again.
|
|
312
|
+
function decisionsLogPath(cwd) {
|
|
313
|
+
return cwd
|
|
314
|
+
? path.join(cwd, '.great_cto', 'decisions.md')
|
|
315
|
+
: path.join(GREAT_CTO_DIR, 'decisions.md');
|
|
306
316
|
}
|
|
307
317
|
|
|
308
|
-
function appendDecisionLog({ ts, project, action, id, title, reason }) {
|
|
309
|
-
|
|
310
|
-
|
|
318
|
+
function appendDecisionLog({ ts, project, action, id, title, reason, cwd }) {
|
|
319
|
+
// No project scope → refuse rather than fall back to the global file. Losing
|
|
320
|
+
// one log line is strictly better than leaking a project's vocabulary into
|
|
321
|
+
// every other project's agent context.
|
|
322
|
+
if (!cwd) {
|
|
323
|
+
log.warn('[decisions] skipped: no project cwd — refusing to write the global log');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const file = decisionsLogPath(cwd);
|
|
327
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch {}
|
|
311
328
|
// Initialize header if file doesn't exist
|
|
312
329
|
if (!fs.existsSync(file)) {
|
|
313
330
|
const header =
|
|
314
331
|
`# great_cto — decisions log
|
|
315
332
|
|
|
316
|
-
Append-only architectural decisions
|
|
333
|
+
Append-only architectural decisions for THIS project. One line per
|
|
317
334
|
gate approve/reject. Agents and humans can grep this for "have we decided
|
|
318
335
|
this before?" lookups.
|
|
319
336
|
|
|
@@ -323,14 +340,22 @@ Format: \`- [TIMESTAMP] [PROJECT] [APPROVED|REJECTED] gate-id — title — reas
|
|
|
323
340
|
fs.writeFileSync(file, header);
|
|
324
341
|
}
|
|
325
342
|
const verdict = action === 'approve' ? 'APPROVED' : 'REJECTED';
|
|
326
|
-
|
|
327
|
-
|
|
343
|
+
// " — " is the field separator, so it must not survive inside a field. Gate
|
|
344
|
+
// titles are literally shaped `gate:plan — decompose X`, which used to make the
|
|
345
|
+
// reader split the title in half and mislabel its tail as the reason. Demote
|
|
346
|
+
// any in-field separator to a plain hyphen and the separator stays unique.
|
|
347
|
+
const clean = (s) => (s || '').replace(/\n/g, ' ').replace(/\s+—\s+/g, ' - ');
|
|
348
|
+
const safeTitle = clean(title).slice(0, 120);
|
|
349
|
+
const safeReason = clean(reason).slice(0, 200);
|
|
328
350
|
const line = `- [${ts}] [${project}] [${verdict}] ${id} — ${safeTitle}${safeReason ? ` — ${safeReason}` : ''}\n`;
|
|
329
351
|
fs.appendFileSync(file, line);
|
|
330
352
|
}
|
|
331
353
|
|
|
332
|
-
|
|
333
|
-
|
|
354
|
+
// Reads are scoped the same way writes are: project X's board shows project X's
|
|
355
|
+
// decisions. The legacy global file is deliberately NOT merged in — surfacing it
|
|
356
|
+
// everywhere is the bleed this change removes.
|
|
357
|
+
function readDecisionsLog(limit = 20, cwd) {
|
|
358
|
+
const file = decisionsLogPath(cwd);
|
|
334
359
|
if (!fs.existsSync(file)) return [];
|
|
335
360
|
try {
|
|
336
361
|
const text = fs.readFileSync(file, 'utf-8');
|
|
@@ -4,7 +4,8 @@ import os from 'os';
|
|
|
4
4
|
import { spawnSync } from 'child_process';
|
|
5
5
|
import { planGates } from '../../../scripts/lib/gate-plan.mjs';
|
|
6
6
|
import { GREAT_CTO_DIR, PROJECTS_FILE } from './config.mjs';
|
|
7
|
-
import { isInsideDir } from './util.mjs';
|
|
7
|
+
import { isInsideDir, readSafe, parseSafe } from './util.mjs';
|
|
8
|
+
import { log } from './log.mjs';
|
|
8
9
|
|
|
9
10
|
// Same HOME-boundary policy /api/projects/register enforces (lib/routes.mjs):
|
|
10
11
|
// a raw absolute/tilde path must resolve inside the operator's home directory,
|
|
@@ -20,10 +21,28 @@ function resolveRawPathWithinHome(slugOrPath) {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
// ── Project registry ───────────────────────────────────────────────────────────
|
|
24
|
+
// Why the registry could not be read, or null when healthy. A corrupt or
|
|
25
|
+
// unreadable projects.json used to yield `{projects: []}` silently, which the UI
|
|
26
|
+
// renders as "you have no projects" — indistinguishable from a fresh install,
|
|
27
|
+
// and a great way to lose a switcher full of work to one bad write.
|
|
28
|
+
let registryDegradation = null;
|
|
29
|
+
function getRegistryDegradation() { return registryDegradation; }
|
|
30
|
+
|
|
23
31
|
function readProjectsRegistry() {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
const r = readSafe(PROJECTS_FILE);
|
|
33
|
+
if (!r.ok) {
|
|
34
|
+
registryDegradation = r.reason === 'missing' ? null : `projects.json could not be read: ${r.error}`;
|
|
35
|
+
if (registryDegradation) log.warn(`[projects] ${registryDegradation}`);
|
|
36
|
+
return { projects: [] };
|
|
37
|
+
}
|
|
38
|
+
const parsed = parseSafe(r.text);
|
|
39
|
+
if (!parsed.ok) {
|
|
40
|
+
registryDegradation = `projects.json is not valid JSON: ${parsed.error}`;
|
|
41
|
+
log.warn(`[projects] ${registryDegradation} — the switcher will look empty until this is fixed`);
|
|
42
|
+
return { projects: [] };
|
|
43
|
+
}
|
|
44
|
+
registryDegradation = null;
|
|
45
|
+
return parsed.value;
|
|
27
46
|
}
|
|
28
47
|
// Pick the best entry among several sharing a slug: prefer one whose path
|
|
29
48
|
// still exists on disk; among several existing (or several missing), prefer
|
|
@@ -180,12 +199,16 @@ async function discoverProjects() {
|
|
|
180
199
|
if (depth < 0 || seen.has(dir)) return;
|
|
181
200
|
seen.add(dir);
|
|
182
201
|
try {
|
|
183
|
-
// Check the dir itself first
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
202
|
+
// Check the dir itself first — but NEVER treat HOME's own .great_cto as a
|
|
203
|
+
// project: ~/.great_cto is the global config dir, not a project. Without
|
|
204
|
+
// this guard, $HOME gets registered as a bogus project (great_cto-…).
|
|
205
|
+
if (dir !== HOME) {
|
|
206
|
+
try {
|
|
207
|
+
await fsAsync.access(path.join(dir, '.great_cto', 'PROJECT.md'));
|
|
208
|
+
found.push(dir);
|
|
209
|
+
return; // don't descend into a registered project
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
189
212
|
if (depth === 0) return;
|
|
190
213
|
// Scan children (skip dotfiles + heavyweight dirs)
|
|
191
214
|
const entries = await fsAsync.readdir(dir, { withFileTypes: true });
|
|
@@ -238,8 +261,17 @@ function listProjects() {
|
|
|
238
261
|
// Auto-register cwd if it has PROJECT.md (cheap)
|
|
239
262
|
autoRegisterProject(process.cwd());
|
|
240
263
|
const reg = readProjectsRegistry();
|
|
241
|
-
|
|
242
|
-
|
|
264
|
+
const HOME = os.homedir();
|
|
265
|
+
// Show only live projects: drop entries whose path is gone, the global
|
|
266
|
+
// ~/.great_cto config dir ($HOME itself), and dead registrations whose
|
|
267
|
+
// .great_cto has no project marker or task source left (no PROJECT.md,
|
|
268
|
+
// no tasks.md, no .beads → nothing to show, just clutters the switcher).
|
|
269
|
+
reg.projects = reg.projects.filter(p =>
|
|
270
|
+
p.path !== HOME &&
|
|
271
|
+
fs.existsSync(p.path) &&
|
|
272
|
+
(fs.existsSync(path.join(p.path, '.great_cto', 'PROJECT.md')) ||
|
|
273
|
+
fs.existsSync(path.join(p.path, '.great_cto', 'tasks.md')) ||
|
|
274
|
+
fs.existsSync(path.join(p.path, '.beads'))));
|
|
243
275
|
// Re-read metadata in case archetype/description changed
|
|
244
276
|
// Enrich with last_activity (mtime of .beads/interactions.jsonl) so the UI
|
|
245
277
|
// can sort projects by recent activity instead of slug-alpha.
|
|
@@ -323,6 +355,7 @@ export {
|
|
|
323
355
|
readProjectMd,
|
|
324
356
|
getChangeTier,
|
|
325
357
|
autoRegisterProject,
|
|
358
|
+
getRegistryDegradation,
|
|
326
359
|
discoverProjects,
|
|
327
360
|
listProjects,
|
|
328
361
|
resolveProjectCwd,
|
|
@@ -14,7 +14,7 @@ import { broadcastTasks } from './sse.mjs';
|
|
|
14
14
|
import { saveNotifHistory } from './notifications.mjs';
|
|
15
15
|
import { getMemory, getPipeline, getCostHistory, getInbox } from './data-readers.mjs';
|
|
16
16
|
import { log } from './log.mjs';
|
|
17
|
-
import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks } from './beads.mjs';
|
|
17
|
+
import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
|
|
18
18
|
import { getMetrics } from './metrics.mjs';
|
|
19
19
|
import { readVerdicts } from './verdicts.mjs';
|
|
20
20
|
import { getAgentsFleet, getAgentProfile, retireAgent, restoreAgent, appendDecisionLog, readDecisionsLog } from './fleet.mjs';
|
|
@@ -102,8 +102,16 @@ async function dispatch(req, res, url, cwd) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
if (pathname === '/api/tasks' && req.method === 'GET') {
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
const tasks = getTasks(cwd);
|
|
106
|
+
// An empty list has two very different meanings: "no tasks" and "we could
|
|
107
|
+
// not read them". Carry the second in a header so the UI can render an
|
|
108
|
+
// error state instead of a clean-looking empty board. A header keeps the
|
|
109
|
+
// array body shape, so existing consumers are unaffected.
|
|
110
|
+
const degraded = getReadDegradation(cwd);
|
|
111
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
112
|
+
if (degraded) headers['X-Board-Degraded'] = encodeURIComponent(degraded);
|
|
113
|
+
res.writeHead(200, headers);
|
|
114
|
+
res.end(JSON.stringify(tasks));
|
|
107
115
|
return true;
|
|
108
116
|
}
|
|
109
117
|
|
|
@@ -332,8 +340,13 @@ async function dispatch(req, res, url, cwd) {
|
|
|
332
340
|
res.end(JSON.stringify({ error: 'invalid action' }));
|
|
333
341
|
return;
|
|
334
342
|
}
|
|
343
|
+
// A project can be tasks.md-backed (no working beads — e.g. its path
|
|
344
|
+
// contains a space, which embedded-dolt can't open). Only 409 when there
|
|
345
|
+
// is neither a beads store NOR a tasks.md to record the decision in.
|
|
335
346
|
const beadsErr = checkBeadsAvailable(gateCwd);
|
|
336
|
-
|
|
347
|
+
const tasksMdPath = path.join(gateCwd, '.great_cto', 'tasks.md');
|
|
348
|
+
const hasTasksMd = fs.existsSync(tasksMdPath);
|
|
349
|
+
if (beadsErr && !hasTasksMd) {
|
|
337
350
|
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
338
351
|
res.end(JSON.stringify(beadsErr));
|
|
339
352
|
return;
|
|
@@ -344,10 +357,20 @@ async function dispatch(req, res, url, cwd) {
|
|
|
344
357
|
// rejected. bdWriteSerialised guarantees one-at-a-time semantics.
|
|
345
358
|
const result = await bdWriteSerialised(() => {
|
|
346
359
|
const status = action === 'approve' ? 'closed' : 'blocked';
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
360
|
+
let via = 'beads';
|
|
361
|
+
// Try beads first (unless there's no store at all); on any bd failure
|
|
362
|
+
// fall back to rewriting the tasks.md status cell so the gate still lands.
|
|
363
|
+
const r = beadsErr
|
|
364
|
+
? { status: 1, error: { code: 'NO_BEADS' } }
|
|
365
|
+
: bd(['update', id, '--status', status, ...(reason ? ['--notes', `[${action}] ${reason}`] : [])],
|
|
366
|
+
{ cwd: gateCwd, timeout: 5000 });
|
|
367
|
+
if (r.status !== 0) {
|
|
368
|
+
if (!setTaskStatusInTasksMd(gateCwd, id, status)) {
|
|
369
|
+
return { error: (beadsErr ? 'beads unavailable' : bdErr(r, 'bd update failed'))
|
|
370
|
+
+ ` — and no matching '${id}' row in tasks.md to update` };
|
|
371
|
+
}
|
|
372
|
+
via = 'tasks.md';
|
|
373
|
+
}
|
|
351
374
|
bdCacheInvalidate(gateCwd);
|
|
352
375
|
// Append to global decisions log — still inside the lock window
|
|
353
376
|
try {
|
|
@@ -362,9 +385,10 @@ async function dispatch(req, res, url, cwd) {
|
|
|
362
385
|
id,
|
|
363
386
|
title,
|
|
364
387
|
reason: reason || '',
|
|
388
|
+
cwd: gateCwd, // project-scoped (ADR-008) — never the global log
|
|
365
389
|
});
|
|
366
390
|
} catch { /* best-effort */ }
|
|
367
|
-
return { ok: true };
|
|
391
|
+
return { ok: true, via };
|
|
368
392
|
});
|
|
369
393
|
if (!result || result.error) {
|
|
370
394
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
@@ -372,8 +396,8 @@ async function dispatch(req, res, url, cwd) {
|
|
|
372
396
|
return;
|
|
373
397
|
}
|
|
374
398
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
375
|
-
res.end(JSON.stringify({ ok: true, id, action }));
|
|
376
|
-
broadcastTasks(
|
|
399
|
+
res.end(JSON.stringify({ ok: true, id, action, via: result.via }));
|
|
400
|
+
broadcastTasks(gateCwd);
|
|
377
401
|
// Auto-republish share report when a gate is approved (fire-and-forget)
|
|
378
402
|
if (action === 'approve') {
|
|
379
403
|
const shareState = getShareState(gateCwd);
|
|
@@ -411,7 +435,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
411
435
|
? Math.min(parsed, 200)
|
|
412
436
|
: 20;
|
|
413
437
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
414
|
-
res.end(JSON.stringify(readDecisionsLog(limit)));
|
|
438
|
+
res.end(JSON.stringify(readDecisionsLog(limit, cwd)));
|
|
415
439
|
return true;
|
|
416
440
|
}
|
|
417
441
|
|
|
@@ -701,6 +725,10 @@ async function dispatch(req, res, url, cwd) {
|
|
|
701
725
|
if (pathname === '/api/logs') {
|
|
702
726
|
const logsDir = path.join(cwd, '.great_cto', 'logs');
|
|
703
727
|
let logs = [];
|
|
728
|
+
// Why the session-log read failed, if it did. "No logs" and "could not read
|
|
729
|
+
// the logs directory" look identical in the UI otherwise — the same collapse
|
|
730
|
+
// that made an unreadable tasks.md look like an empty backlog.
|
|
731
|
+
let logsDegraded = null;
|
|
704
732
|
try {
|
|
705
733
|
const files = fs.readdirSync(logsDir)
|
|
706
734
|
.filter(f => f.startsWith('session-') && f.endsWith('.md'))
|
|
@@ -711,11 +739,18 @@ async function dispatch(req, res, url, cwd) {
|
|
|
711
739
|
const dateM = raw.match(/^date:\s*(.+)$/m);
|
|
712
740
|
const timeM = raw.match(/^time:\s*(.+)$/m);
|
|
713
741
|
const durM = raw.match(/^duration:\s*(.+)$/m);
|
|
714
|
-
const titleM = raw.match(/^#\s+Session:\s*(.+)$/m);
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
742
|
+
const titleM = raw.match(/^#\s+Session:\s*(.+)$/m) || raw.match(/^#\s+(Session[^\n]*)$/m);
|
|
743
|
+
// Headings vary across /save versions and hand-written logs: "## Done",
|
|
744
|
+
// "## Done today", "## Pending", "## Next", "## TODO". Match the keyword
|
|
745
|
+
// and ignore any trailing words on the heading line; bullets may be - or *.
|
|
746
|
+
// Accept "- ", "* ", and "1." / "2)" numbered list items.
|
|
747
|
+
const bulletsFrom = (m) => m
|
|
748
|
+
? m[1].trim().split('\n').filter(l => /^(?:[-*]|\d+[.)])\s+/.test(l)).map(l => l.replace(/^(?:[-*]|\d+[.)])\s+/, ''))
|
|
749
|
+
: [];
|
|
750
|
+
const doneM = raw.match(/##\s+Done[^\n]*\n([\s\S]*?)(?=\n##|$)/i);
|
|
751
|
+
let done = bulletsFrom(doneM);
|
|
752
|
+
const pendM = raw.match(/##\s+(?:Pending|Next(?:\s+steps?)?|To\s?do|Blocked)[^\n]*\n([\s\S]*?)(?=\n##|$)/i);
|
|
753
|
+
let pending = bulletsFrom(pendM);
|
|
719
754
|
|
|
720
755
|
// v2.7.0: SessionEnd hook auto-captures use a different schema
|
|
721
756
|
// (## Git / ## Beads / ## Cost). When no /save format found,
|
|
@@ -749,14 +784,22 @@ async function dispatch(req, res, url, cwd) {
|
|
|
749
784
|
raw,
|
|
750
785
|
};
|
|
751
786
|
});
|
|
752
|
-
} catch {
|
|
787
|
+
} catch (e) {
|
|
788
|
+
// ENOENT is the normal "this project has never run /save" state; anything
|
|
789
|
+
// else means logs exist and we failed to read them, which the user must see.
|
|
790
|
+
if (e && e.code !== 'ENOENT') {
|
|
791
|
+
logsDegraded = `session logs could not be read: ${e.message || e}`;
|
|
792
|
+
log.warn(`[logs] ${logsDegraded}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
753
795
|
|
|
754
796
|
// Fallback: synthesize from verdicts grouped by day
|
|
755
797
|
if (!logs.length) {
|
|
756
798
|
try {
|
|
757
|
-
|
|
758
|
-
//
|
|
759
|
-
//
|
|
799
|
+
// Scope to THIS project: readVerdicts(cwd) returns project-local verdicts
|
|
800
|
+
// plus global lines tagged `project=<slug>`. Without the cwd every project
|
|
801
|
+
// showed the same unfiltered global verdict feed (another project's work).
|
|
802
|
+
const verdicts = readVerdicts(cwd);
|
|
760
803
|
const byDay = new Map();
|
|
761
804
|
for (const v of verdicts) {
|
|
762
805
|
const day = (v.ts || '').slice(0, 10);
|
|
@@ -786,11 +829,17 @@ async function dispatch(req, res, url, cwd) {
|
|
|
786
829
|
pending: b.fail.slice(0, 50),
|
|
787
830
|
raw: '_Auto-generated from ~/.great_cto/verdicts/. Run `/save` to create a curated session log._',
|
|
788
831
|
}));
|
|
789
|
-
} catch {
|
|
832
|
+
} catch (e) {
|
|
833
|
+
logsDegraded = logsDegraded
|
|
834
|
+
|| `verdict fallback could not be built: ${e?.message || e}`;
|
|
835
|
+
log.warn(`[logs] ${logsDegraded}`);
|
|
836
|
+
}
|
|
790
837
|
}
|
|
791
838
|
|
|
792
|
-
|
|
793
|
-
|
|
839
|
+
const logHeaders = { 'Content-Type': 'application/json' };
|
|
840
|
+
if (logsDegraded) logHeaders['X-Board-Degraded'] = encodeURIComponent(logsDegraded);
|
|
841
|
+
res.writeHead(200, logHeaders);
|
|
842
|
+
res.end(JSON.stringify({ logs, degraded: logsDegraded || undefined }));
|
|
794
843
|
return true;
|
|
795
844
|
}
|
|
796
845
|
|
|
@@ -34,6 +34,53 @@ function readFileSafe(p) {
|
|
|
34
34
|
try { return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null; } catch { return null; }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Read a file while keeping the three outcomes a caller must treat differently
|
|
39
|
+
* apart: the file is absent, the file exists but could not be read, or here are
|
|
40
|
+
* its contents.
|
|
41
|
+
*
|
|
42
|
+
* `readFileSafe` collapses the middle case into the first — it returns null for
|
|
43
|
+
* both — so every caller downstream renders "no data". That is how a permission
|
|
44
|
+
* error, a truncated file, or a parse failure became indistinguishable from an
|
|
45
|
+
* empty project, and it is the shared root of five separate board bugs: tasks
|
|
46
|
+
* that silently listed nothing, a metrics panel showing "—" while the API had
|
|
47
|
+
* real counts, and session logs reporting "nothing recorded" over a file full of
|
|
48
|
+
* entries. Emptiness must be a finding, not a fallback.
|
|
49
|
+
*
|
|
50
|
+
* @returns {{ok:true,text:string}
|
|
51
|
+
* |{ok:false,reason:'missing'}
|
|
52
|
+
* |{ok:false,reason:'unreadable',error:string}}
|
|
53
|
+
*/
|
|
54
|
+
function readSafe(p) {
|
|
55
|
+
let exists;
|
|
56
|
+
try {
|
|
57
|
+
exists = fs.existsSync(p);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
// existsSync itself throws on some permission/loop conditions.
|
|
60
|
+
return { ok: false, reason: 'unreadable', error: e?.message || String(e) };
|
|
61
|
+
}
|
|
62
|
+
if (!exists) return { ok: false, reason: 'missing' };
|
|
63
|
+
try {
|
|
64
|
+
return { ok: true, text: fs.readFileSync(p, 'utf8') };
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { ok: false, reason: 'unreadable', error: e?.message || String(e) };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wrap a parse step so a malformed payload is reported rather than swallowed.
|
|
72
|
+
* Callers that used `try { JSON.parse(x) } catch { return [] }` cannot tell a
|
|
73
|
+
* genuinely empty list from a corrupt file; this keeps that distinction.
|
|
74
|
+
* @returns {{ok:true,value:any}|{ok:false,reason:'unparsable',error:string}}
|
|
75
|
+
*/
|
|
76
|
+
function parseSafe(text, parser = JSON.parse) {
|
|
77
|
+
try {
|
|
78
|
+
return { ok: true, value: parser(text) };
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { ok: false, reason: 'unparsable', error: e?.message || String(e) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
37
84
|
// Path-containment check used by any handler that joins user-controlled input
|
|
38
85
|
// onto a base directory (static file serving, doc reads, etc.). Resolves both
|
|
39
86
|
// sides and requires `target` to be exactly `base`, or a real descendant of
|
|
@@ -46,4 +93,4 @@ function isInsideDir(base, target) {
|
|
|
46
93
|
return resolvedTarget === resolvedBase || resolvedTarget.startsWith(resolvedBase + path.sep);
|
|
47
94
|
}
|
|
48
95
|
|
|
49
|
-
export { csvCell, originAllowed, eventSurface, readFileSafe, isInsideDir };
|
|
96
|
+
export { csvCell, originAllowed, eventSurface, readFileSafe, readSafe, parseSafe, isInsideDir };
|
|
@@ -558,6 +558,24 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
558
558
|
text-align: center;
|
|
559
559
|
}
|
|
560
560
|
|
|
561
|
+
/* A read failed. Deliberately louder than .empty — the whole point is that this
|
|
562
|
+
must not be mistaken for "there is nothing here". */
|
|
563
|
+
.degraded-banner {
|
|
564
|
+
display: flex;
|
|
565
|
+
flex-direction: column;
|
|
566
|
+
gap: 2px;
|
|
567
|
+
margin: 0 0 12px;
|
|
568
|
+
padding: 10px 12px;
|
|
569
|
+
border: 1px solid #f0b429;
|
|
570
|
+
border-left-width: 3px;
|
|
571
|
+
border-radius: 6px;
|
|
572
|
+
background: rgba(240, 180, 41, 0.08);
|
|
573
|
+
font-size: 12px;
|
|
574
|
+
line-height: 1.45;
|
|
575
|
+
}
|
|
576
|
+
.degraded-banner strong { color: var(--text1); font-weight: 600; }
|
|
577
|
+
.degraded-banner span { color: var(--text2); font-family: var(--mono, ui-monospace, monospace); word-break: break-word; }
|
|
578
|
+
|
|
561
579
|
/* ── Side panel ───────────────────────────────────────────────────────────── */
|
|
562
580
|
.side {
|
|
563
581
|
position: absolute;
|
|
@@ -2678,7 +2696,12 @@ function renderInbox(d) {
|
|
|
2678
2696
|
inboxData = d || {};
|
|
2679
2697
|
const s = d?.summary || { gates: 0, blocked: 0, p0: 0, stale: 0 };
|
|
2680
2698
|
document.getElementById('nav-inbox-count').textContent = s.gates + s.p0 + s.blocked;
|
|
2681
|
-
|
|
2699
|
+
// Same rule as the all-clear card: "nothing urgent" is a claim about data we
|
|
2700
|
+
// may not have. When a read failed, say that instead of reassuring the user.
|
|
2701
|
+
const greetTail = anyDegraded()
|
|
2702
|
+
? "Some data could not be read — treat the counts below as incomplete."
|
|
2703
|
+
: (s.gates + s.p0 ? "Here's what needs your decision." : 'Nothing urgent — back to deep work.');
|
|
2704
|
+
document.getElementById('inbox-greet').textContent = `${greetByHour()} ${greetTail}`;
|
|
2682
2705
|
document.getElementById('inbox-summary').innerHTML = `
|
|
2683
2706
|
<div class="pill-stat"><span class="dot dot-purple"></span><div><div class="num">${s.gates}</div><div class="lbl">Pending decisions</div></div></div>
|
|
2684
2707
|
<div class="pill-stat"><span class="dot dot-red"></span><div><div class="num">${s.p0}</div><div class="lbl">P0 open</div></div></div>
|
|
@@ -2693,7 +2716,14 @@ function renderInbox(d) {
|
|
|
2693
2716
|
const attention = (d?.pending_gates || []).length + (d?.p0_open || []).length
|
|
2694
2717
|
+ (d?.blocked || []).length + (d?.stale_in_progress || []).length;
|
|
2695
2718
|
const ac = document.getElementById('inbox-allclear');
|
|
2696
|
-
|
|
2719
|
+
// "All clear" is a claim about the data, so it may only be made when the data
|
|
2720
|
+
// was actually read. With an unreadable tasks.md every one of these counts is
|
|
2721
|
+
// zero and the card used to headline "Nothing needs your decision" — maximum
|
|
2722
|
+
// confidence at exactly the moment we knew least. Absence of findings is not
|
|
2723
|
+
// a finding of absence.
|
|
2724
|
+
if (ac) ac.style.display = (attention === 0 && !anyDegraded()) ? '' : 'none';
|
|
2725
|
+
renderDegradedBanner('degraded-read', degradedFor('/api/tasks'),
|
|
2726
|
+
'Some project data could not be read — counts below are incomplete.');
|
|
2697
2727
|
}
|
|
2698
2728
|
|
|
2699
2729
|
function renderInboxList(rootId, items, countId, opts = {}) {
|
|
@@ -3159,7 +3189,7 @@ function renderCost(d) {
|
|
|
3159
3189
|
<div class="cost-cell ${overBudget ? 'warn' : (budget != null ? 'good' : '')}">
|
|
3160
3190
|
<div class="lbl">Projected month</div>
|
|
3161
3191
|
<div class="v">$${projected.toFixed(0)}</div>
|
|
3162
|
-
<div class="sub">${budget
|
|
3192
|
+
<div class="sub">${budget > 0 ? (overBudget ? `over budget by $${(projected - budget).toFixed(0)}` : `${Math.round((projected/budget)*100)}% of $${budget} budget`) : 'set monthly-budget in PROJECT.md'}</div>
|
|
3163
3193
|
</div>
|
|
3164
3194
|
<div class="cost-cell good">
|
|
3165
3195
|
<div class="lbl">vs Human team</div>
|
|
@@ -3484,6 +3514,7 @@ async function switchProject(slug) {
|
|
|
3484
3514
|
loadMemory();
|
|
3485
3515
|
refreshCost();
|
|
3486
3516
|
refreshPipeline();
|
|
3517
|
+
refreshLogs(); // populate the Logs nav badge (was lazy — stuck at 0 until the tab was opened)
|
|
3487
3518
|
refreshAgentsInstalled(); // reload agents with verdicts for the new project
|
|
3488
3519
|
connectSSE();
|
|
3489
3520
|
if (lbl) lbl.textContent = 'live · synced just now';
|
|
@@ -3671,6 +3702,7 @@ async function init() {
|
|
|
3671
3702
|
loadMemory();
|
|
3672
3703
|
refreshCost();
|
|
3673
3704
|
refreshPipeline();
|
|
3705
|
+
refreshLogs(); // populate the Logs nav badge (was lazy — stuck at 0 until the tab was opened)
|
|
3674
3706
|
refreshAgentsInstalled(); // preload fleet so Agents tab is instant
|
|
3675
3707
|
connectSSE();
|
|
3676
3708
|
|
|
@@ -3684,8 +3716,50 @@ async function init() {
|
|
|
3684
3716
|
}
|
|
3685
3717
|
}
|
|
3686
3718
|
|
|
3719
|
+
// Reasons the server could not read something, keyed by API path. An empty list
|
|
3720
|
+
// means either "nothing here" or "we failed to read it", and rendering both as a
|
|
3721
|
+
// tidy empty state is what let a broken tasks.md look like an empty backlog for
|
|
3722
|
+
// a week. The server now says which via X-Board-Degraded; keep it so the view
|
|
3723
|
+
// can show it.
|
|
3724
|
+
const BOARD_DEGRADED = Object.create(null);
|
|
3725
|
+
function degradedFor(path) { return BOARD_DEGRADED[path] || null; }
|
|
3726
|
+
|
|
3727
|
+
/**
|
|
3728
|
+
* Show or clear a read-failure banner, and remove it as soon as the read
|
|
3729
|
+
* recovers — a stale error is its own bug.
|
|
3730
|
+
*
|
|
3731
|
+
* It mounts at the top of `.workspace`, NOT inside the tasks panel. Mounting it
|
|
3732
|
+
* next to the thing that failed hides it on every other tab: with an unreadable
|
|
3733
|
+
* tasks.md the landing view was still headlining "Nothing urgent" and "All
|
|
3734
|
+
* clear" while the banner sat in the DOM, invisible, one tab away. A failure
|
|
3735
|
+
* notice that only appears where you already suspect a problem is decoration.
|
|
3736
|
+
*/
|
|
3737
|
+
function renderDegradedBanner(id, reason, headline) {
|
|
3738
|
+
const existing = document.getElementById(id);
|
|
3739
|
+
if (!reason) { if (existing) existing.remove(); return; }
|
|
3740
|
+
const el = existing || document.createElement('div');
|
|
3741
|
+
el.id = id;
|
|
3742
|
+
el.className = 'degraded-banner';
|
|
3743
|
+
el.setAttribute('role', 'alert');
|
|
3744
|
+
el.innerHTML = `<strong>${esc(headline)}</strong><span>${esc(reason)}</span>`;
|
|
3745
|
+
if (!existing) {
|
|
3746
|
+
const host = document.querySelector('.workspace')
|
|
3747
|
+
|| (document.getElementById('kanban-board') || {}).parentNode;
|
|
3748
|
+
if (host) host.insertBefore(el, host.firstChild);
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
/** True when any project-scoped read failed — used to suppress "all clear" claims. */
|
|
3753
|
+
function anyDegraded() { return Object.keys(BOARD_DEGRADED).length > 0; }
|
|
3754
|
+
|
|
3687
3755
|
function api(url, opts) {
|
|
3688
|
-
|
|
3756
|
+
const key = String(url).split('?')[0];
|
|
3757
|
+
return fetch(url, opts).then(r => {
|
|
3758
|
+
const d = r.headers && r.headers.get('X-Board-Degraded');
|
|
3759
|
+
if (d) { try { BOARD_DEGRADED[key] = decodeURIComponent(d); } catch { BOARD_DEGRADED[key] = d; } }
|
|
3760
|
+
else delete BOARD_DEGRADED[key];
|
|
3761
|
+
return r.json();
|
|
3762
|
+
}).catch(() => null);
|
|
3689
3763
|
}
|
|
3690
3764
|
|
|
3691
3765
|
function connectSSE() {
|
|
@@ -3856,6 +3930,10 @@ function clearFilters() {
|
|
|
3856
3930
|
function renderKanban(tasks) {
|
|
3857
3931
|
renderFilterBar();
|
|
3858
3932
|
const board = document.getElementById('kanban-board');
|
|
3933
|
+
// If the server could not read the task source, say so. An empty board that
|
|
3934
|
+
// looks clean is a lie when the truth is "we failed to read tasks.md".
|
|
3935
|
+
renderDegradedBanner('degraded-read', degradedFor('/api/tasks'),
|
|
3936
|
+
'Tasks could not be read — this board is not empty, it is unreadable.');
|
|
3859
3937
|
const byCol = Object.fromEntries(COLUMNS.map(c => [c.id, []]));
|
|
3860
3938
|
for (const t of tasks) {
|
|
3861
3939
|
if (byCol[t.status]) byCol[t.status].push(t);
|
|
@@ -4034,8 +4112,11 @@ function renderDashboard(m) {
|
|
|
4034
4112
|
const llmUsd = m.cost?.llm_usd ?? 0;
|
|
4035
4113
|
const realLlmUsd = m.cost?.real_llm_usd ?? 0;
|
|
4036
4114
|
const planSavingsX = m.cost?.savings_x;
|
|
4037
|
-
// Prefer window-scoped count
|
|
4038
|
-
|
|
4115
|
+
// Prefer the window-scoped count, but fall back to the all-time total when it
|
|
4116
|
+
// is 0 — otherwise projects with completed-but-untimestamped tasks (e.g. a
|
|
4117
|
+
// tasks.md fallback with no dates) show a meaningless "—" despite real work.
|
|
4118
|
+
// (`??` kept 0 because done_in_window is 0, not null; `||` fixes that.)
|
|
4119
|
+
const done = (m.tasks?.done_in_window || m.tasks?.done) ?? 0;
|
|
4039
4120
|
const avgMin = m.avg_completion_min ?? 0;
|
|
4040
4121
|
const cycleStat = m.cycle_time_stat === 'median_30d' ? 'Median cycle (30d)' : 'Avg cycle time';
|
|
4041
4122
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// OS-supervisor units + the `board ensure` decision — the pure, unit-testable
|
|
2
|
+
// core of ADR-007. No process is spawned and no file is written here; main.ts
|
|
3
|
+
// owns the side effects (spawn / launchctl / systemctl / writing the unit).
|
|
4
|
+
//
|
|
5
|
+
// Kept dependency-free and string-only so the renderers can be asserted directly,
|
|
6
|
+
// the same way board-path.ts isolates resolution from the spawn in main.ts.
|
|
7
|
+
export const DEFAULT_LABEL = "co.greatcto.board";
|
|
8
|
+
function xmlEscape(s) {
|
|
9
|
+
return s
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">");
|
|
13
|
+
}
|
|
14
|
+
/** launchd LaunchAgent plist: RunAtLoad + KeepAlive = "always on" across reboots/crashes. */
|
|
15
|
+
export function renderLaunchdPlist(o) {
|
|
16
|
+
const label = o.label ?? DEFAULT_LABEL;
|
|
17
|
+
const args = [o.nodePath, o.cliPath, "board", "--no-open"];
|
|
18
|
+
const argXml = args.map(a => ` <string>${xmlEscape(a)}</string>`).join("\n");
|
|
19
|
+
const logDir = `${o.home}/.great_cto`;
|
|
20
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
21
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
22
|
+
<plist version="1.0">
|
|
23
|
+
<dict>
|
|
24
|
+
<key>Label</key>
|
|
25
|
+
<string>${xmlEscape(label)}</string>
|
|
26
|
+
<key>ProgramArguments</key>
|
|
27
|
+
<array>
|
|
28
|
+
${argXml}
|
|
29
|
+
</array>
|
|
30
|
+
<key>EnvironmentVariables</key>
|
|
31
|
+
<dict>
|
|
32
|
+
<key>BOARD_PORT</key>
|
|
33
|
+
<string>${o.port}</string>
|
|
34
|
+
</dict>
|
|
35
|
+
<key>RunAtLoad</key>
|
|
36
|
+
<true/>
|
|
37
|
+
<key>KeepAlive</key>
|
|
38
|
+
<true/>
|
|
39
|
+
<key>StandardOutPath</key>
|
|
40
|
+
<string>${xmlEscape(logDir)}/board.log</string>
|
|
41
|
+
<key>StandardErrorPath</key>
|
|
42
|
+
<string>${xmlEscape(logDir)}/board.err</string>
|
|
43
|
+
</dict>
|
|
44
|
+
</plist>
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
/** systemd --user service: Restart=always + WantedBy=default.target = "always on" per login session. */
|
|
48
|
+
export function renderSystemdUnit(o) {
|
|
49
|
+
const execStart = `${o.nodePath} ${o.cliPath} board --no-open`;
|
|
50
|
+
return `[Unit]
|
|
51
|
+
Description=great_cto board (Kanban + CTO Dashboard)
|
|
52
|
+
After=network.target
|
|
53
|
+
|
|
54
|
+
[Service]
|
|
55
|
+
Type=simple
|
|
56
|
+
Environment=BOARD_PORT=${o.port}
|
|
57
|
+
ExecStart=${execStart}
|
|
58
|
+
Restart=always
|
|
59
|
+
RestartSec=3
|
|
60
|
+
|
|
61
|
+
[Install]
|
|
62
|
+
WantedBy=default.target
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
/** Windows Task Scheduler command that (re)creates an at-logon task for the board. */
|
|
66
|
+
export function renderSchtasksCommand(o) {
|
|
67
|
+
const label = o.label ?? DEFAULT_LABEL;
|
|
68
|
+
const tr = `"${o.nodePath}" "${o.cliPath}" board --no-open`;
|
|
69
|
+
return `schtasks /create /tn "${label}" /sc onlogon /rl limited /f /tr "${tr}"`;
|
|
70
|
+
}
|
|
71
|
+
/** Map a platform to its supervisor: where the unit goes and how to (de)activate it. */
|
|
72
|
+
export function daemonSpec(platform, o) {
|
|
73
|
+
const label = o.label ?? DEFAULT_LABEL;
|
|
74
|
+
if (platform === "darwin") {
|
|
75
|
+
const unitPath = `${o.home}/Library/LaunchAgents/${label}.plist`;
|
|
76
|
+
return {
|
|
77
|
+
platform,
|
|
78
|
+
supported: true,
|
|
79
|
+
kind: "launchd",
|
|
80
|
+
label,
|
|
81
|
+
unitPath,
|
|
82
|
+
render: () => renderLaunchdPlist(o),
|
|
83
|
+
// unload first (ignore failure) so re-install is idempotent, then load with -w (persist).
|
|
84
|
+
installCmds: [
|
|
85
|
+
["launchctl", "unload", unitPath],
|
|
86
|
+
["launchctl", "load", "-w", unitPath],
|
|
87
|
+
],
|
|
88
|
+
uninstallCmds: [["launchctl", "unload", "-w", unitPath]],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (platform === "linux") {
|
|
92
|
+
const unitPath = `${o.home}/.config/systemd/user/greatcto-board.service`;
|
|
93
|
+
return {
|
|
94
|
+
platform,
|
|
95
|
+
supported: true,
|
|
96
|
+
kind: "systemd",
|
|
97
|
+
label,
|
|
98
|
+
unitPath,
|
|
99
|
+
render: () => renderSystemdUnit(o),
|
|
100
|
+
installCmds: [
|
|
101
|
+
["systemctl", "--user", "daemon-reload"],
|
|
102
|
+
["systemctl", "--user", "enable", "--now", "greatcto-board.service"],
|
|
103
|
+
],
|
|
104
|
+
uninstallCmds: [["systemctl", "--user", "disable", "--now", "greatcto-board.service"]],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (platform === "win32") {
|
|
108
|
+
return {
|
|
109
|
+
platform,
|
|
110
|
+
supported: true,
|
|
111
|
+
kind: "schtasks",
|
|
112
|
+
label,
|
|
113
|
+
unitPath: "", // no separate file — the task IS the config
|
|
114
|
+
render: () => renderSchtasksCommand(o),
|
|
115
|
+
installCmds: [["cmd", "/c", renderSchtasksCommand(o)]],
|
|
116
|
+
uninstallCmds: [["cmd", "/c", `schtasks /delete /tn "${label}" /f`]],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Unknown platform — degrade safely rather than throw.
|
|
120
|
+
return {
|
|
121
|
+
platform,
|
|
122
|
+
supported: false,
|
|
123
|
+
kind: "none",
|
|
124
|
+
label,
|
|
125
|
+
unitPath: "",
|
|
126
|
+
render: () => "",
|
|
127
|
+
installCmds: [],
|
|
128
|
+
uninstallCmds: [],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Decide what `board ensure` should do:
|
|
133
|
+
* - no live process → start
|
|
134
|
+
* - live process, port hung → restart (the case a liveness supervisor misses)
|
|
135
|
+
* - live process, port answering → noop (never kill a healthy board)
|
|
136
|
+
*/
|
|
137
|
+
export function decideEnsureAction(s) {
|
|
138
|
+
if (s.pid === null || !s.alive)
|
|
139
|
+
return "start";
|
|
140
|
+
if (!s.healthy)
|
|
141
|
+
return "restart";
|
|
142
|
+
return "noop";
|
|
143
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -23,6 +23,7 @@ import { shouldUseLlmFallback, suggestArchetypeFromLlm } from "./llm-fallback.js
|
|
|
23
23
|
import { sendUsagePing, sendInstallPing, telemetrySubcommand, isTelemetryEnabled, computeAnonId } from "./telemetry.js";
|
|
24
24
|
import { checkForUpdate } from "./update-check.js";
|
|
25
25
|
import { findBoardServerPath } from "./board-path.js";
|
|
26
|
+
import { daemonSpec, decideEnsureAction } from "./board-daemon.js";
|
|
26
27
|
import { readFileSync, writeFileSync, copyFileSync, chmodSync, mkdirSync, unlinkSync, existsSync as fsExistsSync } from "node:fs";
|
|
27
28
|
import { dirname, join } from "node:path";
|
|
28
29
|
import { fileURLToPath } from "node:url";
|
|
@@ -345,6 +346,160 @@ async function runBoard(args, surface) {
|
|
|
345
346
|
});
|
|
346
347
|
return 0;
|
|
347
348
|
}
|
|
349
|
+
// ── Always-on board: `ensure` gate + OS-supervisor install (ADR-007) ─────────
|
|
350
|
+
/** Absolute path to the CLI entry (index.mjs), one level up from dist/main.js. */
|
|
351
|
+
function cliEntryPath() {
|
|
352
|
+
const here = dirname(fileURLToPath(import.meta.url)); // …/dist
|
|
353
|
+
return join(here, "..", "index.mjs");
|
|
354
|
+
}
|
|
355
|
+
/** Read the board PID file → number, or null if missing / garbage. */
|
|
356
|
+
function readBoardPid(surface) {
|
|
357
|
+
const pidFile = boardPidFilePath(surface);
|
|
358
|
+
if (!fsExistsSync(pidFile))
|
|
359
|
+
return null;
|
|
360
|
+
const pid = parseInt(readFileSync(pidFile, "utf8").trim(), 10);
|
|
361
|
+
return pid && !isNaN(pid) ? pid : null;
|
|
362
|
+
}
|
|
363
|
+
/** signal-0 existence check. */
|
|
364
|
+
function isPidAlive(pid) {
|
|
365
|
+
try {
|
|
366
|
+
process.kill(pid, 0);
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** Does the board answer HTTP on this port? Any response (even 4xx) = healthy. */
|
|
374
|
+
async function probeBoardPort(port) {
|
|
375
|
+
const http = await import("node:http");
|
|
376
|
+
return new Promise(resolve => {
|
|
377
|
+
const req = http.request({ host: "127.0.0.1", port, path: "/", method: "GET", timeout: 1200 }, res => { res.resume(); resolve(true); });
|
|
378
|
+
req.on("error", () => resolve(false));
|
|
379
|
+
req.on("timeout", () => { req.destroy(); resolve(false); });
|
|
380
|
+
req.end();
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/** Detached relaunch of the board (survives this CLI process). Returns the new pid. */
|
|
384
|
+
async function spawnDetachedBoard(port) {
|
|
385
|
+
const { spawn } = await import("node:child_process");
|
|
386
|
+
const serverPath = findBoardServerPath();
|
|
387
|
+
if (!serverPath) {
|
|
388
|
+
error("Board server not found. Reinstall the CLI (npm i -g great-cto@latest).");
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
const child = spawn(process.execPath, [serverPath, "--no-open"], {
|
|
392
|
+
env: { ...process.env, BOARD_PORT: String(port) },
|
|
393
|
+
stdio: "ignore",
|
|
394
|
+
detached: true,
|
|
395
|
+
});
|
|
396
|
+
child.unref();
|
|
397
|
+
try {
|
|
398
|
+
mkdirSync(join(homedir(), ".great_cto"), { recursive: true });
|
|
399
|
+
if (child.pid)
|
|
400
|
+
writeFileSync(boardPidFilePath(), String(child.pid));
|
|
401
|
+
}
|
|
402
|
+
catch { /* best-effort */ }
|
|
403
|
+
return child.pid;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* `great-cto board ensure` — idempotent health gate. Starts the board only if it
|
|
407
|
+
* isn't already answering; never kills a healthy instance. Safe to call from a
|
|
408
|
+
* supervisor, cron line, or shell hook.
|
|
409
|
+
*/
|
|
410
|
+
async function runBoardEnsure(args) {
|
|
411
|
+
const port = args.boardPort;
|
|
412
|
+
const pid = readBoardPid();
|
|
413
|
+
const alive = pid !== null && isPidAlive(pid);
|
|
414
|
+
const healthy = alive && await probeBoardPort(port);
|
|
415
|
+
const action = decideEnsureAction({ pid, alive, healthy });
|
|
416
|
+
if (action === "noop") {
|
|
417
|
+
log(` ${green("✓")} board already running → http://localhost:${port} (pid ${pid})`);
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
if (action === "restart") {
|
|
421
|
+
log(` ${dim(`board pid ${pid} alive but not answering on ${port} — restarting…`)}`);
|
|
422
|
+
await killExistingBoard();
|
|
423
|
+
}
|
|
424
|
+
const newPid = await spawnDetachedBoard(port);
|
|
425
|
+
if (!newPid)
|
|
426
|
+
return 1;
|
|
427
|
+
log(` ${green("✓")} board ${action === "restart" ? "restarted" : "started"} → http://localhost:${port} (pid ${newPid})`);
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* `great-cto board install-daemon` — write + activate a per-user OS service that
|
|
432
|
+
* keeps the board running across crashes and reboots. `--dry-run` prints only.
|
|
433
|
+
*/
|
|
434
|
+
async function runBoardInstallDaemon(args) {
|
|
435
|
+
const { spawnSync } = await import("node:child_process");
|
|
436
|
+
const spec = daemonSpec(process.platform, {
|
|
437
|
+
nodePath: process.execPath,
|
|
438
|
+
cliPath: cliEntryPath(),
|
|
439
|
+
port: args.boardPort,
|
|
440
|
+
home: homedir(),
|
|
441
|
+
});
|
|
442
|
+
if (!spec.supported) {
|
|
443
|
+
error(`No supervisor integration for platform '${process.platform}'.`);
|
|
444
|
+
log("Run `great-cto board ensure` from your own scheduler instead.");
|
|
445
|
+
return 1;
|
|
446
|
+
}
|
|
447
|
+
const body = spec.render();
|
|
448
|
+
if (args.dryRun) {
|
|
449
|
+
log(bold(`Would install ${spec.kind} unit${spec.unitPath ? ` at ${spec.unitPath}` : ""}:\n`));
|
|
450
|
+
log(body);
|
|
451
|
+
log(dim(`Then run: ${spec.installCmds.map(c => c.join(" ")).join(" && ")}`));
|
|
452
|
+
return 0;
|
|
453
|
+
}
|
|
454
|
+
// Write the unit file (launchd/systemd). win32 has no separate file — the task IS the config.
|
|
455
|
+
if (spec.unitPath) {
|
|
456
|
+
try {
|
|
457
|
+
mkdirSync(dirname(spec.unitPath), { recursive: true });
|
|
458
|
+
writeFileSync(spec.unitPath, body);
|
|
459
|
+
log(` ${green("✓")} wrote ${spec.kind} unit → ${spec.unitPath}`);
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
error(`Could not write unit file: ${e.message}`);
|
|
463
|
+
return 1;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
for (const cmd of spec.installCmds) {
|
|
467
|
+
const r = spawnSync(cmd[0], cmd.slice(1), { stdio: "ignore" });
|
|
468
|
+
// launchctl unload of a not-yet-loaded agent returns non-zero — that's expected on first install.
|
|
469
|
+
if (r.status !== 0 && !(cmd[0] === "launchctl" && cmd.includes("unload"))) {
|
|
470
|
+
warn(`command exited ${r.status}: ${cmd.join(" ")}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
log(` ${green("✓")} board daemon installed — it will start at login and restart on crash.`);
|
|
474
|
+
log(dim(` now: great-cto board ensure (brings it up immediately)`));
|
|
475
|
+
log(dim(` remove: great-cto board uninstall-daemon`));
|
|
476
|
+
return 0;
|
|
477
|
+
}
|
|
478
|
+
/** `great-cto board uninstall-daemon` — deactivate the service and remove its unit file. */
|
|
479
|
+
async function runBoardUninstallDaemon(args) {
|
|
480
|
+
const { spawnSync } = await import("node:child_process");
|
|
481
|
+
const spec = daemonSpec(process.platform, {
|
|
482
|
+
nodePath: process.execPath,
|
|
483
|
+
cliPath: cliEntryPath(),
|
|
484
|
+
port: args.boardPort,
|
|
485
|
+
home: homedir(),
|
|
486
|
+
});
|
|
487
|
+
if (!spec.supported) {
|
|
488
|
+
error(`No supervisor integration for platform '${process.platform}'.`);
|
|
489
|
+
return 1;
|
|
490
|
+
}
|
|
491
|
+
for (const cmd of spec.uninstallCmds) {
|
|
492
|
+
spawnSync(cmd[0], cmd.slice(1), { stdio: "ignore" });
|
|
493
|
+
}
|
|
494
|
+
if (spec.unitPath && fsExistsSync(spec.unitPath)) {
|
|
495
|
+
try {
|
|
496
|
+
unlinkSync(spec.unitPath);
|
|
497
|
+
}
|
|
498
|
+
catch { /* ignore */ }
|
|
499
|
+
}
|
|
500
|
+
log(` ${green("✓")} board daemon removed. (A running board keeps running — stop it with a new \`board\` or reboot.)`);
|
|
501
|
+
return 0;
|
|
502
|
+
}
|
|
348
503
|
function printHelp() {
|
|
349
504
|
log(`${bold("great-cto")} — one-command install for the great_cto Claude Code plugin
|
|
350
505
|
|
|
@@ -367,6 +522,10 @@ ${bold("Board:")}
|
|
|
367
522
|
great-cto board Open Kanban + CTO Dashboard at localhost:3141
|
|
368
523
|
great-cto board --port 4000 Use a different port
|
|
369
524
|
great-cto board --no-open Start server without opening browser
|
|
525
|
+
great-cto board ensure Start only if not already running (idempotent; for cron/hooks)
|
|
526
|
+
great-cto board install-daemon Keep the board always on (launchd/systemd/schtasks)
|
|
527
|
+
great-cto board install-daemon --dry-run Print the service unit without installing
|
|
528
|
+
great-cto board uninstall-daemon Remove the always-on service
|
|
370
529
|
|
|
371
530
|
${bold("Operator console (the second surface — invite-only, hostable):")}
|
|
372
531
|
great-cto console Serve ONLY the operator console (no dev board)
|
|
@@ -1110,7 +1269,21 @@ async function main() {
|
|
|
1110
1269
|
}
|
|
1111
1270
|
if (args.command === "board") {
|
|
1112
1271
|
try {
|
|
1113
|
-
const
|
|
1272
|
+
const verb = args.positional[0];
|
|
1273
|
+
let code;
|
|
1274
|
+
if (verb === "ensure")
|
|
1275
|
+
code = await runBoardEnsure(args);
|
|
1276
|
+
else if (verb === "install-daemon")
|
|
1277
|
+
code = await runBoardInstallDaemon(args);
|
|
1278
|
+
else if (verb === "uninstall-daemon")
|
|
1279
|
+
code = await runBoardUninstallDaemon(args);
|
|
1280
|
+
else if (verb) {
|
|
1281
|
+
error(`great-cto board: unknown subcommand '${verb}'`);
|
|
1282
|
+
log(`Try: ${cyan("board")} · ${cyan("board ensure")} · ${cyan("board install-daemon")} · ${cyan("board uninstall-daemon")}`);
|
|
1283
|
+
code = 2;
|
|
1284
|
+
}
|
|
1285
|
+
else
|
|
1286
|
+
code = await runBoard(args);
|
|
1114
1287
|
await finish(code);
|
|
1115
1288
|
}
|
|
1116
1289
|
catch (e) {
|
package/package.json
CHANGED