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.
@@ -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.85.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
- // Fallback: parse .great_cto/tasks.md when Beads isn't initialized.
123
- // Format: `- [ ] TASK-001: Title [agent] [~42min]\n Description: ...\n Depends: ...`
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
- if (!fs.existsSync(fp)) return [];
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 = fs.readFileSync(fp, 'utf8');
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 { return []; }
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 (global ADR log) ──────────────────────────────────────────
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
- function decisionsLogPath() {
305
- return path.join(GREAT_CTO_DIR, 'decisions.md');
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
- const file = decisionsLogPath();
310
- try { fs.mkdirSync(GREAT_CTO_DIR, { recursive: true }); } catch {}
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 across all projects. One line per
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
- const safeTitle = (title || '').replace(/\n/g, ' ').slice(0, 120);
327
- const safeReason = (reason || '').replace(/\n/g, ' ').slice(0, 200);
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
- function readDecisionsLog(limit = 20) {
333
- const file = decisionsLogPath();
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
- try { if (fs.existsSync(PROJECTS_FILE)) return JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf8')); }
25
- catch {}
26
- return { projects: [] };
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
- try {
185
- await fsAsync.access(path.join(dir, '.great_cto', 'PROJECT.md'));
186
- found.push(dir);
187
- return; // don't descend into a registered project
188
- } catch {}
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
- // Filter out projects whose paths no longer exist
242
- reg.projects = reg.projects.filter(p => fs.existsSync(p.path));
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
- res.writeHead(200, { 'Content-Type': 'application/json' });
106
- res.end(JSON.stringify(getTasks(cwd)));
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
- if (beadsErr) {
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
- const args = ['update', id, '--status', status];
348
- if (reason) args.push('--notes', `[${action}] ${reason}`);
349
- const r = bd(args, { cwd: gateCwd, timeout: 5000 });
350
- if (r.status !== 0) return { error: bdErr(r, 'bd update failed') };
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(cwd);
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
- const doneM = raw.match(/## Done\n([\s\S]*?)(?=\n##|$)/);
716
- let done = doneM ? doneM[1].trim().split('\n').filter(l => l.startsWith('- ')).map(l => l.slice(2)) : [];
717
- const pendM = raw.match(/## Pending\n([\s\S]*?)(?=\n##|$)/);
718
- let pending = pendM ? pendM[1].trim().split('\n').filter(l => l.startsWith('- ')).map(l => l.slice(2)) : [];
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
- const verdicts = readVerdicts();
758
- // Filter to verdicts referencing this project (best-effort: include all
759
- // when project-tagging not available)
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
- res.writeHead(200, { 'Content-Type': 'application/json' });
793
- res.end(JSON.stringify({ logs }));
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
- document.getElementById('inbox-greet').textContent = `${greetByHour()} ${s.gates + s.p0 ? "Here's what needs your decision." : 'Nothing urgent back to deep work.'}`;
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
- if (ac) ac.style.display = attention === 0 ? '' : 'none';
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 != null ? (overBudget ? `over budget by $${(projected - budget).toFixed(0)}` : `${Math.round((projected/budget)*100)}% of $${budget} budget`) : 'set monthly-budget in PROJECT.md'}</div>
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
- return fetch(url, opts).then(r => r.json()).catch(() => null);
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 when API provides it (matches selected period)
4038
- const done = m.tasks?.done_in_window ?? m.tasks?.done ?? 0;
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, "&amp;")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;");
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 code = await runBoard(args);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.85.2",
3
+ "version": "2.86.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",