great-cto 3.67.1 → 3.68.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.67.1",
4
+ "version": "3.68.0",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -149,6 +149,11 @@
149
149
  "timeout": 5,
150
150
  "command": "_gd=\"$PWD\"; while [ -n \"$_gd\" ] && { [ \"$_gd\" = \"$HOME\" ] || [ ! -f \"$_gd/.great_cto/PROJECT.md\" ]; }; do _gd=\"${_gd%/*}\"; done; [ -n \"$_gd\" ] && cd \"$_gd\"; [ -n \"$GREAT_CTO_AGENT_BUDGET_FILE\" ] || exit 0; node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/agent-execution-budget.mjs\" pre; _budget_exit=$?; if [ $_budget_exit -ne 0 ]; then exit 2; fi",
151
151
  "statusMessage": "Checking shared agent execution budget..."
152
+ },
153
+ {
154
+ "type": "command",
155
+ "command": "_gd=\"$PWD\"; while [ -n \"$_gd\" ] && { [ \"$_gd\" = \"$HOME\" ] || [ ! -f \"$_gd/.great_cto/PROJECT.md\" ]; }; do _gd=\"${_gd%/*}\"; done; [ -n \"$_gd\" ] && cd \"$_gd\"; node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/graph-guard.mjs\" || true",
156
+ "timeout": 10
152
157
  }
153
158
  ]
154
159
  },
@@ -14,7 +14,6 @@
14
14
 
15
15
  import fs from 'node:fs';
16
16
  import { judgeFreshness } from '../../../scripts/lib/freshness.mjs';
17
- import { linkGraph } from '../../../scripts/lib/doc-links.mjs';
18
17
  import path from 'node:path';
19
18
 
20
19
  /**
@@ -130,6 +129,67 @@ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', 'scre
130
129
 
131
130
  /** A cap, so a repository with thousands of markdown files cannot stall the board. */
132
131
  export const MAX_DOCS = 500;
132
+ export const MAX_DOC_BYTES = 1024 * 1024;
133
+
134
+ /** Root aliases are allowed; links inside a project are not read capabilities. */
135
+ function safePath(root, rel) {
136
+ if (!rel || path.isAbsolute(rel) || rel.includes('\0') || rel.split(/[\\/]/).includes('..')) return null;
137
+ try {
138
+ const canonical = fs.realpathSync(root);
139
+ const abs = path.resolve(canonical, rel);
140
+ if (!abs.startsWith(canonical + path.sep)) return null;
141
+ let current = canonical;
142
+ for (const part of path.relative(canonical, abs).split(path.sep)) {
143
+ current = path.join(current, part);
144
+ if (fs.lstatSync(current).isSymbolicLink()) return null;
145
+ }
146
+ return abs;
147
+ } catch { return null; }
148
+ }
149
+
150
+ /** Bounded descriptor reads also refuse a path replaced between lookup and open. */
151
+ export function readProjectFile(root, rel) {
152
+ let fd;
153
+ try {
154
+ const abs = safePath(root, rel);
155
+ if (!abs) return { state: 'unreadable', content: null };
156
+ const expected = fs.lstatSync(abs);
157
+ if (!expected.isFile()) return { state: 'unreadable', content: null };
158
+ if (expected.size > MAX_DOC_BYTES) return { state: 'too-large', content: null };
159
+ fd = fs.openSync(abs, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
160
+ const before = fs.fstatSync(fd);
161
+ if (!before.isFile() || before.dev !== expected.dev || before.ino !== expected.ino
162
+ || safePath(root, rel) !== abs) return { state: 'unreadable', content: null };
163
+ if (before.size > MAX_DOC_BYTES) return { state: 'too-large', content: null };
164
+ const bytes = Buffer.alloc(Math.min(before.size + 1, MAX_DOC_BYTES + 1));
165
+ let count = 0, n;
166
+ while (count < bytes.length && (n = fs.readSync(fd, bytes, count, bytes.length - count, count))) count += n;
167
+ const after = fs.fstatSync(fd);
168
+ if (count > MAX_DOC_BYTES) return { state: 'too-large', content: null };
169
+ if (count !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs
170
+ || after.ctimeMs !== before.ctimeMs) return { state: 'unreadable', content: null };
171
+ return { state: 'readable', content: bytes.subarray(0, count).toString('utf8') };
172
+ } catch { return { state: 'unreadable', content: null }; }
173
+ finally { if (fd !== undefined) fs.closeSync(fd); }
174
+ }
175
+
176
+ export function declaredStatus(text) {
177
+ let fence = null;
178
+ const head = String(text || '').slice(0, 8192).replace(/^\uFEFF/, '').split('\n').filter(line => {
179
+ const marker = line.match(/^ {0,3}(`{3,}|~{3,})/);
180
+ if (marker) {
181
+ if (!fence) fence = marker[1];
182
+ else if (marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = null;
183
+ return false;
184
+ }
185
+ return !fence;
186
+ }).join('\n');
187
+ const fm = head.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
188
+ const value = (fm ? fm[1].match(/^status:\s*(.*?)\s*$/im)?.[1] : null)
189
+ ?? head.match(/^(?:\*\*)?Status(?:\*\*)?:\s*(.*?)\s*$/im)?.[1];
190
+ const clean = value?.replace(/^["']|["']$/g, '').trim().toLowerCase();
191
+ return ['draft', 'accepted', 'superseded'].includes(clean) ? clean : null;
192
+ }
133
193
 
134
194
  /**
135
195
  * The title an author gave a document.
@@ -138,8 +198,8 @@ export const MAX_DOCS = 500;
138
198
  * is a filename and not what anyone called it. Read from a bounded head: a title
139
199
  * is in the first few lines or it is not a title.
140
200
  */
141
- export function titleOf(absPath, { read = fs.readFileSync } = {}) {
142
- try { return titleFromText(String(read(absPath, 'utf8'))); }
201
+ export function titleOf(absPath, { read } = {}) {
202
+ try { return titleFromText(read ? String(read(absPath, 'utf8')) : readProjectFile(path.dirname(absPath), path.basename(absPath)).content); }
143
203
  catch { return null; }
144
204
  }
145
205
 
@@ -155,11 +215,13 @@ const IS_TRANSLATED = /^docs[/\\][a-z]{2}(-[A-Z]{2})?[/\\]/;
155
215
 
156
216
  function walk(dir, root, out, depth = 0) {
157
217
  if (depth > 3 || out.length >= MAX_DOCS) return;
218
+ const safe = safePath(root, path.relative(root, dir));
219
+ if (!safe) return;
158
220
  let entries;
159
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
221
+ try { entries = fs.readdirSync(safe, { withFileTypes: true }); } catch { return; }
160
222
  for (const e of entries) {
161
223
  if (out.length >= MAX_DOCS) return;
162
- if (SKIP.has(e.name) || e.name.startsWith('.') && e.name !== '.great_cto') continue;
224
+ if (e.isSymbolicLink() || SKIP.has(e.name) || e.name.startsWith('.') && e.name !== '.great_cto') continue;
163
225
  const abs = path.join(dir, e.name);
164
226
  if (e.isDirectory()) { walk(abs, root, out, depth + 1); continue; }
165
227
  if (!e.name.toLowerCase().endsWith('.md')) continue;
@@ -168,7 +230,7 @@ function walk(dir, root, out, depth = 0) {
168
230
  // and stood a machine-written summary in the index beside its own source.
169
231
  if (IS_COPY.test(e.name) || IS_TRANSLATED.test(path.relative(root, abs))) continue;
170
232
  let st;
171
- try { st = fs.statSync(abs); } catch { continue; }
233
+ try { if (!safePath(root, path.relative(root, abs))) continue; st = fs.lstatSync(abs); if (!st.isFile()) continue; } catch { continue; }
172
234
  out.push({ abs, rel: path.relative(root, abs), size: st.size, modified: st.mtime.toISOString() });
173
235
  }
174
236
  }
@@ -333,13 +395,15 @@ function freshnessOf(text, nowMs = Date.now(), staleDays = 180) {
333
395
  }
334
396
 
335
397
  export function listDocs(root, { max = MAX_DOCS } = {}) {
398
+ try { root = fs.realpathSync(root); } catch { return { total: 0, truncated: false, groups: [], state: 'unreadable' }; }
336
399
  const found = [];
337
400
  for (const g of DOC_GROUPS) {
338
401
  for (const d of g.dirs || []) walk(path.join(root, d), root, found);
339
402
  for (const f of g.files || []) {
340
403
  const abs = path.join(root, f);
341
404
  try {
342
- const st = fs.statSync(abs);
405
+ if (!safePath(root, f)) continue;
406
+ const st = fs.lstatSync(abs);
343
407
  if (st.isFile()) found.push({ abs, rel: f, size: st.size, modified: st.mtime.toISOString() });
344
408
  } catch { /* absent */ }
345
409
  }
@@ -349,13 +413,6 @@ export function listDocs(root, { max = MAX_DOCS } = {}) {
349
413
  // the link graph is defined; anything outside it gets `null` — "not measured"
350
414
  // and "measured, and the answer is none" are different facts, and rendering
351
415
  // the first as the second is the substitution this whole board refuses.
352
- let inboundBy = null;
353
- try {
354
- const g = linkGraph(path.join(root, 'docs'));
355
- inboundBy = new Map();
356
- for (const [k, v] of g.inbound) inboundBy.set(path.relative(root, k), v.length);
357
- } catch { inboundBy = null; }
358
-
359
416
  const seen = new Set();
360
417
  const docs = [];
361
418
  for (const d of found) {
@@ -365,15 +422,17 @@ export function listDocs(root, { max = MAX_DOCS } = {}) {
365
422
  // questions about the same bytes, and this used to open every file twice.
366
423
  // `null` distinguishes "could not be read" from "read and said nothing" —
367
424
  // the distinction the freshness badge now rests on.
368
- let text = null;
369
- try { text = fs.readFileSync(d.abs, 'utf8'); } catch { /* unreadable */ }
425
+ const read = readProjectFile(root, d.rel), text = read.content;
370
426
  docs.push({
371
427
  path: d.rel,
372
428
  name: path.basename(d.rel),
373
429
  title: (text !== null && titleFromText(text)) || path.basename(d.rel, '.md'),
374
430
  group: groupFor(d.rel, { text: text ?? '' }),
375
431
  size: d.size,
376
- inbound: inboundBy ? (inboundBy.has(d.rel) ? inboundBy.get(d.rel) : null) : null,
432
+ inbound: null,
433
+ readState: read.state,
434
+ status: declaredStatus(text),
435
+ sourceRevision: null,
377
436
  modified: d.modified,
378
437
  // A modification time answers "when was this file last touched", which is
379
438
  // a different question from "is this still true". A typo fix rejuvenates a
@@ -382,6 +441,27 @@ export function listDocs(root, { max = MAX_DOCS } = {}) {
382
441
  // rule produced each — see scripts/lib/freshness.mjs.
383
442
  ...freshnessOf(text),
384
443
  });
444
+ // Kept only for this bounded index pass, never serialized into the listing.
445
+ docs.at(-1)._text = text;
446
+ }
447
+
448
+ const cited = new Map(docs.filter(d => d.path.startsWith('docs/')).map(d => [d.path, new Set()]));
449
+ const complete = docs.length < max && docs.every(d => d.readState === 'readable');
450
+ for (const d of docs) {
451
+ if (!cited.has(d.path) || d._text === null) continue;
452
+ const add = target => { if (target !== d.path) cited.get(target)?.add(d.path); };
453
+ for (const m of d._text.matchAll(/\]\(([^)#\s]+\.md)/g)) {
454
+ add(path.normalize(path.join(path.dirname(d.path), m[1])));
455
+ add(path.normalize(m[1].replace(/^\.\.\//, '')));
456
+ add(path.normalize(path.join('docs', m[1])));
457
+ }
458
+ for (const m of d._text.matchAll(/\bADR-(\d{3})\b/g)) {
459
+ for (const target of cited.keys()) if (path.basename(target).startsWith(`ADR-${m[1]}`)) add(target);
460
+ }
461
+ }
462
+ for (const d of docs) {
463
+ d.inbound = complete && cited.has(d.path) ? cited.get(d.path).size : null;
464
+ delete d._text;
385
465
  }
386
466
 
387
467
  const groups = DOC_GROUPS.map((g) => ({
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { GROUPS, groupOf } from '../../../scripts/lib/system-map.mjs';
4
+ import { readProjectFile, MAX_DOCS } from './docs.mjs';
5
+
6
+ /** Legacy source-map projection, using the same bounded reads as Knowledge. */
7
+ export function knowledgeMap(root) {
8
+ const nodes = [], files = [], edges = new Map();
9
+ let visited = 0, partial = false;
10
+ const walk = (dir, ext, out, depth = 0) => {
11
+ if (depth > 3 || visited >= MAX_DOCS) { partial = true; return; }
12
+ let current = root;
13
+ try {
14
+ for (const part of dir.split(path.sep)) {
15
+ current = path.join(current, part);
16
+ if (fs.lstatSync(current).isSymbolicLink()) { partial = true; return; }
17
+ }
18
+ for (const e of fs.readdirSync(current, { withFileTypes: true })) {
19
+ if (visited >= MAX_DOCS) { partial = true; break; }
20
+ if (e.name.startsWith('.') || ['node_modules', 'dist', 'build', 'coverage', 'vendor', '_shared'].includes(e.name)) continue;
21
+ if (e.isSymbolicLink()) { partial = true; continue; }
22
+ const rel = path.join(dir, e.name);
23
+ if (e.isDirectory()) walk(rel, ext, out, depth + 1);
24
+ else if (e.isFile() && ext.some(x => e.name.endsWith(x))) {
25
+ visited++;
26
+ const read = readProjectFile(root, rel);
27
+ if (read.state === 'readable') out.push({ rel, text: read.content });
28
+ else partial = true;
29
+ }
30
+ }
31
+ } catch (e) { if (e.code !== 'ENOENT') partial = true; }
32
+ };
33
+ for (const g of GROUPS) {
34
+ const own = [];
35
+ for (const dir of g.dirs) walk(dir, g.ext, own);
36
+ files.push(...own);
37
+ if (own.length) nodes.push({ key: g.key, label: g.label, what: g.what, count: own.length });
38
+ }
39
+ for (const f of files.filter(f => /\.(mjs|ts)$/.test(f.rel))) {
40
+ const from = groupOf(f.rel);
41
+ for (const m of f.text.matchAll(/(?:^|\n)\s*import\s[^'"]*from\s+['"](\.[^'"]+)['"]/g)) {
42
+ const to = groupOf(path.join(path.dirname(f.rel), m[1]));
43
+ if (!to || from === to) continue;
44
+ const key = `${from}:${to}`;
45
+ edges.set(key, { from, to, count: (edges.get(key)?.count || 0) + 1 });
46
+ }
47
+ }
48
+ return { nodes, edges: [...edges.values()].sort((a, b) => b.count - a.count),
49
+ generatedAt: new Date().toISOString(), state: partial ? 'partial' : 'observed' };
50
+ }
@@ -123,9 +123,16 @@ async function dispatch(req, res, url, cwd) {
123
123
 
124
124
  // These read models accept registered projects or the server's own root,
125
125
  // never an arbitrary HOME directory supplied as a query parameter.
126
- const scopedProject = ['/api/skills', '/api/usage', '/api/outcomes'].includes(pathname)
126
+ const knowledgeRead = ['/api/docs', '/api/doc', '/api/docs/search'].includes(pathname);
127
+ const scopedProject = (knowledgeRead || ['/api/skills', '/api/usage', '/api/outcomes'].includes(pathname))
127
128
  ? scopedReadProject(cwd, serverCwd, requestedProject) : null;
128
129
 
130
+ if (knowledgeRead && req.method === 'GET' && !scopedProject) {
131
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
132
+ res.end(JSON.stringify({ error: 'Unknown project; documentation was not read', state: 'unavailable' }));
133
+ return true;
134
+ }
135
+
129
136
  // Existing synchronous projections may consume a completed snapshot, but
130
137
  // HTTP cold reads must await async I/O instead of freezing unrelated clients.
131
138
  const taskRead = ['/api/work', '/api/sse', '/api/tasks', '/api/metrics',
@@ -646,44 +653,48 @@ async function dispatch(req, res, url, cwd) {
646
653
  }
647
654
 
648
655
  if (pathname === '/api/docs' && req.method === 'GET') {
649
- const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
656
+ const c = scopedProject.path;
650
657
  try {
651
- const [{ listDocs }, { systemMap, toMermaid, pipelineMermaid }] = await Promise.all([
652
- import('./docs.mjs'), import('../../../scripts/lib/system-map.mjs'),
658
+ const [{ listDocs, readProjectFile }, { toMermaid, pipelineMermaid }, { knowledgeMap }] = await Promise.all([
659
+ import('./docs.mjs'), import('../../../scripts/lib/system-map.mjs'), import('./knowledge.mjs'),
653
660
  ]);
654
- const map = systemMap(c);
661
+ const map = knowledgeMap(c);
655
662
  let pipeline = null;
656
- try { pipeline = pipelineMermaid(fs.readFileSync(path.join(c, 'shared', 'pipeline.toml'), 'utf8')); } catch { /* not a great_cto project */ }
663
+ const pipelineSource = readProjectFile(c, 'shared/pipeline.toml');
664
+ if (pipelineSource.state === 'readable') pipeline = pipelineMermaid(pipelineSource.content);
657
665
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
658
666
  res.end(JSON.stringify({
659
667
  ...listDocs(c),
660
- map: { nodes: map.nodes, edges: map.edges, generatedAt: map.generatedAt, mermaid: toMermaid(map) },
668
+ map: { ...map, mermaid: toMermaid(map) },
661
669
  pipeline,
662
670
  }));
663
- } catch (e) {
671
+ } catch {
664
672
  res.writeHead(500, { 'Content-Type': 'application/json' });
665
- res.end(JSON.stringify({ error: String(e.message || e) }));
673
+ res.end(JSON.stringify({ error: 'Documentation index is unavailable' }));
666
674
  }
667
675
  return true;
668
676
  }
669
677
 
670
678
  if (pathname === '/api/doc' && req.method === 'GET') {
671
- const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
679
+ const c = scopedProject.path;
672
680
  const rel = String(url.searchParams.get('path') || '');
673
681
  const abs = path.resolve(c, rel);
674
- if (!rel || !abs.startsWith(path.resolve(c) + path.sep) || !abs.toLowerCase().endsWith('.md')) {
682
+ if (!rel || path.isAbsolute(rel) || rel.includes('\0') || rel.split(/[\\/]/).includes('..')
683
+ || !abs.startsWith(c + path.sep) || !abs.toLowerCase().endsWith('.md')) {
675
684
  res.writeHead(400, { 'Content-Type': 'application/json' });
676
685
  res.end(JSON.stringify({ error: 'path must be a .md file inside the project' }));
677
686
  return true;
678
687
  }
679
688
  try {
680
- const st = fs.statSync(abs);
681
- if (!st.isFile() || st.size > 1024 * 1024) throw new Error('not a readable doc');
689
+ const { readProjectFile, declaredStatus } = await import('./docs.mjs');
690
+ const read = readProjectFile(c, rel);
691
+ if (read.state !== 'readable') throw new Error('not a readable doc');
682
692
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
683
- res.end(JSON.stringify({ name: path.basename(abs), path: rel, content: fs.readFileSync(abs, 'utf8') }));
693
+ res.end(JSON.stringify({ name: path.basename(abs), path: rel, content: read.content,
694
+ status: declaredStatus(read.content), sourceRevision: null }));
684
695
  } catch {
685
696
  res.writeHead(404, { 'Content-Type': 'application/json' });
686
- res.end(JSON.stringify({ error: 'doc not found: ' + rel }));
697
+ res.end(JSON.stringify({ error: 'Document is missing, unreadable or exceeds the size limit' }));
687
698
  }
688
699
  return true;
689
700
  }
@@ -2332,7 +2343,7 @@ async function dispatch(req, res, url, cwd) {
2332
2343
  //
2333
2344
  // GET /api/docs/search?q=... -> { q, matched, scanned, results[] }
2334
2345
  if (pathname === '/api/docs/search' && req.method === 'GET') {
2335
- const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
2346
+ const c = scopedProject.path;
2336
2347
  const q = (url.searchParams.get('q') || '').trim();
2337
2348
  if (q.length < 2) {
2338
2349
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -2340,7 +2351,7 @@ async function dispatch(req, res, url, cwd) {
2340
2351
  return true;
2341
2352
  }
2342
2353
  try {
2343
- const { listDocs } = await import('./docs.mjs');
2354
+ const { listDocs, readProjectFile } = await import('./docs.mjs');
2344
2355
  const all = listDocs(c);
2345
2356
  const needle = q.toLowerCase();
2346
2357
  const results = [];
@@ -2348,9 +2359,9 @@ async function dispatch(req, res, url, cwd) {
2348
2359
  for (const g of (all.groups || [])) {
2349
2360
  for (const d of (g.docs || [])) {
2350
2361
  if (results.length >= 60) break;
2351
- let text;
2352
- try { text = fs.readFileSync(path.join(c, d.path), 'utf8'); scanned++; }
2353
- catch { unreadable++; continue; } // counted, never silently skipped
2362
+ const read = readProjectFile(c, d.path);
2363
+ if (read.state !== 'readable') { unreadable++; continue; }
2364
+ const text = read.content; scanned++;
2354
2365
  const lines = text.split('\n');
2355
2366
  const hits = [];
2356
2367
  for (let i = 0; i < lines.length && hits.length < 3; i++) {
@@ -2366,9 +2377,9 @@ async function dispatch(req, res, url, cwd) {
2366
2377
  // 187 files and "no results" over 3 that could be opened are different
2367
2378
  // facts, and only one of them means the phrase is not there.
2368
2379
  res.end(JSON.stringify({ q, matched: results.length, scanned, unreadable, results }));
2369
- } catch (e) {
2380
+ } catch {
2370
2381
  res.writeHead(500, { 'Content-Type': 'application/json' });
2371
- res.end(JSON.stringify({ error: String(e.message || e) }));
2382
+ res.end(JSON.stringify({ error: 'Documentation search is unavailable' }));
2372
2383
  }
2373
2384
  return true;
2374
2385
  }
@@ -16,17 +16,38 @@ const date = v => typeof v === 'string' && Number.isFinite(Date.parse(v)) ? v :
16
16
  const RUN_PHASE = { ready: 'accepted', 'awaiting-gate': 'needs_decision',
17
17
  'awaiting-release': 'needs_decision', blocked: 'blocked', 'manual-action': 'blocked' };
18
18
 
19
+ /** Historical attempt observations only; no context, results or causal edges cross this boundary. */
20
+ function executionAttempts(run) {
21
+ const short = v => text(v)?.slice(0, 128) || null;
22
+ const identity = v => text(v) && v.length <= 128 ? v : null;
23
+ const attempts = Array.isArray(run?.attempts) ? run.attempts : [];
24
+ const window = attempts.slice(-200);
25
+ const nodes = window.filter(a => a && identity(a.id) && short(a.role)).map(a => ({
26
+ runId: identity(run.id), attemptId: identity(a.id), role: short(a.role),
27
+ host: ['codex', 'claude-code'].includes(a.host) ? a.host : null,
28
+ number: Number.isInteger(a.number) && a.number > 0 ? a.number : null,
29
+ phase: short(a.phase), status: short(a.status), startedAt: date(a.startedAt), finishedAt: date(a.finishedAt),
30
+ }));
31
+ const truncated = attempts.length > 200;
32
+ return { state: nodes.length ? 'recorded' : 'unknown', nodes, topology: 'unknown', truncated,
33
+ partial: !run || truncated || nodes.length !== window.length,
34
+ reason: !run ? 'Stable attempt observations are unavailable without a single explicitly linked controlled run.'
35
+ : !nodes.length ? 'No usable attempt observations are recorded.' : null };
36
+ }
37
+
19
38
  /** Recorded observations are not current-tree verification or gate authority. */
20
39
  function evidenceSections(entry) {
21
40
  const events = entry.inspector?.state === 'recorded' ? entry.inspector.events.length : 0;
41
+ const attempts = entry.execution?.nodes.length || 0;
22
42
  const verdicts = entry.evidence.filter(e => e.kind === 'verdict');
23
43
  const reported = verdicts.filter(e => e.verifierReported === true).length;
24
44
  const publication = entry.publication;
25
45
  const published = ['prepared', 'pushed', 'pr-linked'].includes(publication?.state);
26
46
  return [
27
- { id: 'execution', label: 'Execution', state: events ? 'recorded' : 'unknown',
47
+ { id: 'execution', label: 'Execution', state: events || attempts ? 'recorded' : 'unknown',
28
48
  detail: events ? `${events} linked activity events in the bounded window. Liveness and task acceptance are not established.${entry.inspector.partial ? ' The activity window is incomplete.' : ''}`
29
- : entry.inspector?.reason || 'No explicitly linked execution events are available.' },
49
+ : attempts ? `${attempts} historical attempt observations. Current verification, dependencies and task acceptance are not established.${entry.execution.partial ? ' Attempt observations are incomplete.' : ''}`
50
+ : entry.inspector?.reason || 'No explicitly linked execution events are available.' },
30
51
  { id: 'checks', label: 'Checks', state: verdicts.length ? 'recorded' : 'unknown',
31
52
  detail: verdicts.length ? `In this bounded view: ${verdicts.length} role verdict records; ${reported} verifier-success reports. Test counts and current input bindings are not available in this projection.`
32
53
  : 'No task-linked check records are available. Completed roles do not establish verification.' },
@@ -50,7 +71,7 @@ export function projectWork({ projectId, issues = [], codex = { state: 'absent',
50
71
  const phase = active ? 'working' : RUN_PHASE[r.status] || null;
51
72
  const pending = !!r.pending || ['awaiting-gate', 'awaiting-release'].includes(r.status);
52
73
  const canCopyResume = !terminal && !pending && !active && r.status === 'ready';
53
- const updatedAt = (r.attempts || []).flatMap(a => [date(a.startedAt), date(a.finishedAt)])
74
+ const updatedAt = (Array.isArray(r.attempts) ? r.attempts : []).flatMap(a => [date(a?.startedAt), date(a?.finishedAt)])
54
75
  .filter(Boolean).sort((a, b) => Date.parse(b) - Date.parse(a))[0] || date(r.pending?.createdAt);
55
76
  entries.push({ key: `run:${r.id}`, kind: 'run', taskId: null, runId: r.id, issueIds: [],
56
77
  title: `Controlled run ${r.id}`, goal: null, acceptance: [], host: 'codex',
@@ -66,6 +87,7 @@ export function projectWork({ projectId, issues = [], codex = { state: 'absent',
66
87
  evidence: (r.rolesCompleted || []).map(role => ({ kind: 'recorded_role', label: role })),
67
88
  stage: text(r.active) || text(r.pending?.role) || (r.wave?.status === 'running' && Array.isArray(r.wave.roles) ? r.wave.roles.filter(role => typeof role === 'string').join(', ') : null),
68
89
  inspector: taskInspector([r.id], activity),
90
+ execution: executionAttempts(r),
69
91
  release: r.release ? { status: text(r.release.status), url: text(r.release.url), verifiedAt: date(r.release.verifiedAt) } : null,
70
92
  });
71
93
  }
@@ -106,6 +128,7 @@ export function projectWork({ projectId, issues = [], codex = { state: 'absent',
106
128
  label: `${e.role.slice(0, 80)}: ${text(e.verdict)?.slice(0, 80) || 'not recorded'}`, verifierReported: typeof e.verified === 'boolean' ? e.verified : null })),
107
129
  release: run?.release || null, revision: t.revision, metrics: t.metrics,
108
130
  inspector: taskInspector([...t.links.runs, ...t.links.sessions], activity),
131
+ execution: run?.execution || executionAttempts(null),
109
132
  publication: t.publication || null,
110
133
  publicationAllow: Array.isArray(t.authority?.writeScope) ? t.authority.writeScope.join(',') : '',
111
134
  publicationPreview: t.managed !== false && t.intent !== 'research' && t.phase === 'verified' && !owner && !t.decisions?.length,
@@ -0,0 +1,38 @@
1
+ /* Approved task-first shell, using existing light/dark semantic tokens. */
2
+ #nav-tablist .nav-item{width:100%;background:transparent;border:0;border-radius:5px;font-family:var(--sans,Geist,sans-serif);font-size:14px;text-align:left;min-height:44px;color:var(--text2);cursor:pointer}
3
+ #nav-tablist .nav-item.active{background:var(--accent-soft);color:var(--text);font-weight:600}
4
+ #nav-tablist .nav-item:hover{background:var(--bg-muted)}
5
+ .nav-view-item{min-height:44px;font-size:14px}
6
+ .work-task-row small{font-size:12px}
7
+ .admin-page,.work-page{width:100%}.work-task-row>strong,.work-task-row>span{display:block}
8
+ .admin-page-heading>.gate-btn{flex:0 0 auto;width:auto;padding:8px 14px;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-card);color:var(--text);cursor:pointer}
9
+ #tools-nav[hidden],[hidden]{display:none!important}
10
+ #admin-subnav{padding:12px 24px;border-bottom:1px solid var(--border);display:flex;gap:9px;flex-wrap:wrap;background:var(--bg-card)}
11
+ .admin-subtab{border:1px solid var(--border-strong);color:var(--text2);background:transparent;padding:8px 13px;font:inherit;border-radius:5px;cursor:pointer}
12
+ .admin-subtab[aria-pressed=true]{color:var(--text);background:var(--accent-soft);border-color:var(--accent)}
13
+ .admin-page{padding:26px;max-width:1700px;margin:0 auto;font-size:14px}
14
+ .admin-page-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:24px}
15
+ .admin-page-heading h1{font-size:28px;margin:0 0 8px;letter-spacing:-.7px}
16
+ .admin-page-heading p{color:var(--text2);margin:0;line-height:1.6}
17
+ .admin-page h2{font-size:18px;margin:0 0 12px;line-height:1.5}
18
+ .admin-notice{padding:14px 16px;margin:16px 0;line-height:1.7;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-muted);color:var(--text2);font-size:12px}
19
+ .knowledge-layout{display:grid;grid-template-columns:280px minmax(0,1fr);border:1px solid var(--border-strong);border-radius:7px;background:var(--bg-card);min-height:600px;overflow:hidden}
20
+ #knowledge-index{padding:18px 12px;border-right:1px solid var(--border);max-height:calc(100vh - 200px);overflow:auto}
21
+ .admin-doc-search{font-size:12px;color:var(--text2);display:block;padding:0 6px}.admin-doc-search input{width:100%;box-sizing:border-box;margin-top:9px;padding:10px;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-page);font:inherit;color:var(--text)}
22
+ #knowledge-index h2{font-size:12px;margin:23px 8px 9px;color:var(--text2)}
23
+ .knowledge-file{display:block;text-align:left;width:100%;border:0;border-left:2px solid transparent;background:transparent;color:var(--text);font:inherit;font-size:13px;padding:10px;line-height:1.6;cursor:pointer;overflow-wrap:anywhere}
24
+ .knowledge-file small{display:block;font-size:10px;color:var(--text2)}
25
+ .knowledge-file:hover,.knowledge-file[aria-current=true]{background:var(--accent-soft);border-left-color:var(--accent)}
26
+ #knowledge-document{min-width:0;padding:27px;line-height:1.7;overflow-wrap:anywhere}
27
+ .admin-doc-heading{display:flex;justify-content:space-between;gap:15px;align-items:center}.admin-doc-heading h2{font-size:25px;margin:0}
28
+ .admin-source{font-family:var(--mono,monospace);font-size:12px;color:var(--text2)}
29
+ .admin-document{max-width:1000px}.admin-document p,.admin-document li{max-width:85ch}.admin-document pre{max-width:100%;overflow:auto}.admin-document table{display:block;max-width:100%;overflow:auto}
30
+ .admin-diagram{padding:15px;border:1px solid var(--border-strong);border-radius:6px;background:var(--bg-muted);overflow:auto;margin:15px 0}.admin-diagram summary{cursor:pointer;font-size:12px}.knowledge-diagram{display:block;min-width:500px;width:100%;max-height:500px}.knowledge-diagram rect{fill:var(--bg-card);stroke:var(--border-strong)}.knowledge-diagram text{fill:var(--text);font:14px var(--sans,sans-serif)}.diagram-edges{stroke:var(--text2);stroke-width:1.5}
31
+ .admin-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-block:1px solid var(--border-strong);margin:22px 0}.admin-metrics section{padding:22px 17px;border-right:1px solid var(--border)}.admin-metrics section:first-child{padding-left:0}.admin-metrics section:last-child{border:0}.admin-metrics h2{font-size:28px;overflow-wrap:anywhere;margin:0 0 8px}.admin-metrics p{font-size:13px;margin:0 0 8px}.admin-metrics small{color:var(--text2);font-size:11px;line-height:1.7;display:block}
32
+ .admin-section-title{margin-top:30px!important}.admin-table-scroll{overflow:auto}.admin-table{border-collapse:collapse;width:100%;font-size:13px}.admin-table th,.admin-table td{padding:14px 10px;border-bottom:1px solid var(--border);text-align:left;overflow-wrap:anywhere}.admin-table th{font-size:12px;color:var(--text2);font-weight:500}
33
+ .admin-compose{margin:16px 0 22px;padding:14px 16px;border:1px solid var(--border-strong);border-radius:6px;background:var(--bg-card)}.admin-compose summary{cursor:pointer;font-size:14px}.admin-compose form{margin-top:18px}
34
+ .work-page{max-width:1700px!important;padding:26px!important;margin:0 auto;font-size:14px}.work-page>h1{font-size:28px;margin:0 0 10px}.work-meta{color:var(--text2);font-size:12px;line-height:1.7}.work-warning{border-left:3px solid var(--status-progress);padding:10px 14px;background:var(--bg-muted)}
35
+ .work-cockpit{display:grid;grid-template-columns:280px minmax(0,1fr);background:var(--bg-card);border:1px solid var(--border-strong);border-radius:7px;overflow:hidden;min-height:450px}.work-task-list{border-right:1px solid var(--border);padding:0;background:var(--bg-muted)}.work-task-row{display:block;width:100%;text-align:left;border:0;border-bottom:1px solid var(--border);border-left:3px solid transparent;padding:18px 15px;background:transparent;color:var(--text);font:inherit;line-height:1.6;cursor:pointer;overflow-wrap:anywhere}.work-task-row[aria-current=true],.work-task-row[aria-current=page]{border-left-color:var(--accent);background:var(--accent-soft)}.work-task-row small{display:block;color:var(--text2)}.work-detail{min-width:0;padding:24px;overflow-wrap:anywhere}.work-card{border:0!important;margin:0!important;background:transparent!important;box-shadow:none!important}.work-card h2{font-size:23px;line-height:1.5;margin:0 0 15px}.work-card p{line-height:1.7}.work-card details{margin:15px 0;padding:12px;border:1px solid var(--border);border-radius:5px}.work-card pre{overflow:auto;max-width:100%}.work-filters{display:flex;gap:8px;flex-wrap:wrap;margin:15px 0 20px}.work-filter,.work-tab{padding:9px 13px;min-height:38px;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-card);color:var(--text2);font:inherit;cursor:pointer}.work-filter[aria-pressed=true]{color:var(--text);background:var(--accent-soft);border-color:var(--accent)}.work-tabs{display:flex;gap:10px;border-bottom:1px solid var(--border);margin-bottom:20px;flex-wrap:wrap}.work-tab{border:0;border-radius:0}.work-tab[aria-selected=true]{color:var(--text);border-bottom:2px solid var(--accent)}.work-execution{padding:15px;border:1px solid var(--border);border-radius:5px;background:var(--bg-muted)}.work-observation{margin:12px 0;padding:12px;border:1px solid var(--border);background:var(--bg-card);border-radius:5px;line-height:1.7}
36
+ #panel-inbox .inbox-page{max-width:1500px}.nav-item:focus-visible,.admin-subtab:focus-visible,.knowledge-file:focus-visible,.work-filter:focus-visible,.work-tab:focus-visible,.work-task-row:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px}
37
+ @media(max-width:1100px){.knowledge-layout,.work-cockpit{grid-template-columns:230px minmax(0,1fr)}.admin-metrics{grid-template-columns:1fr 1fr}.admin-metrics section{border-bottom:1px solid var(--border)}}
38
+ @media(max-width:768px){.admin-page,.work-page{padding:16px!important}.knowledge-layout,.work-cockpit{grid-template-columns:minmax(0,1fr)}#knowledge-index{border-right:0;border-bottom:1px solid var(--border);max-height:270px}.work-task-list{border-right:0;border-bottom:1px solid var(--border);max-height:240px;overflow:auto}#knowledge-document,.work-detail{padding:18px}.admin-page-heading{align-items:flex-start}.admin-metrics section{padding:15px 10px}.admin-metrics h2{font-size:23px}.admin-doc-heading{align-items:flex-start;flex-direction:column}#admin-subnav{padding:10px 16px}.admin-table{min-width:530px}.work-tabs{gap:4px}}
@@ -0,0 +1,122 @@
1
+ /* Task-first projection. Existing controller commands and authorization remain authoritative. */
2
+ (() => {
3
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
4
+ const aliases = { docs:'knowledge', usage:'insights/consumption', ledger:'insights/ledger', budgets:'insights/ledger', dashboard:'insights/ledger', fleet:'settings/executors', agents:'settings/executors', harness:'settings/runtime', skills:'settings/capabilities', history:'work/completed', inbox:'decisions', notifications:'settings/general', share:'settings/general' };
5
+ function route(input) {
6
+ if (/^(?:agents|fleet)\/[a-z0-9-]+$/i.test(input)) return {canonical:input,panel:'agents',primary:'settings'};
7
+ const canonical = aliases[input] || input;
8
+ const panels = { work:'work','work/completed':'history',decisions:'inbox',knowledge:'knowledge',insights:'admin-outcomes','insights/outcomes':'admin-outcomes','insights/consumption':'usage','insights/ledger':'budgets',settings:'agents','settings/executors':'agents','settings/runtime':'harness','settings/capabilities':'skills','settings/general':'settings' };
9
+ if (!panels[canonical]) return null;
10
+ return { canonical, panel:panels[canonical], primary:canonical.split('/')[0] };
11
+ }
12
+ function outcomes(snapshot) {
13
+ const tasks = (snapshot.entries || []).filter(e => e.kind === 'task');
14
+ const completed = tasks.filter(e => e.taskOutcome?.state === 'completed').length;
15
+ return {tasks:tasks.length,completed,unknown:tasks.length-completed,acceptanceRate:null,costPerAcceptedTask:null};
16
+ }
17
+ let generation=0, selectedDoc=null, selectedRoute='work', request=0, indexRequest=0;
18
+ const scope = () => new URLSearchParams(location.search).get('project') || '';
19
+ const scoped = path => path+(path.includes('?')?'&':'?')+'project='+encodeURIComponent(scope());
20
+ const panel = id => document.getElementById(id);
21
+ function current(seq, project) { return seq===generation && project===scope(); }
22
+ function reset() {
23
+ generation++; request++; selectedDoc=null;
24
+ for (const id of ['knowledge-index','knowledge-document','admin-outcomes-body']) if(panel(id)) panel(id).textContent='Loading selected project…';
25
+ if (selectedRoute==='knowledge') loadKnowledge();
26
+ if (selectedRoute==='insights'||selectedRoute==='insights/outcomes') loadTaskOutcomes();
27
+ }
28
+ async function loadTaskOutcomes() {
29
+ const seq=generation, project=scope(), ticket=++request, el=panel('admin-outcomes-body');
30
+ el.textContent='Reading task-linked outcomes…';
31
+ try {
32
+ const data=await api(scoped('/api/work'));
33
+ if (!current(seq,project)||ticket!==request) return;
34
+ if (!data || data.error || data.schemaVersion!==1) throw Error(data?.error || 'Task outcomes are unavailable.');
35
+ const d=outcomes(data);
36
+ el.innerHTML=`<p class="work-meta">Observed ${esc(data.observedAt)} · ${data.health==='current'?'Sources read':'Sources incomplete'} · Recorded task metadata, not a benchmark</p>
37
+ <div class="admin-metrics"><section><h2>${d.tasks}</h2><p>Tracked tasks</p><small>Legacy issues and standalone runs excluded</small></section><section><h2>${d.completed}</h2><p>Explicitly completed outcomes</p><small>Recorded completion, not an acceptance-rate estimate</small></section><section><h2>${d.unknown}</h2><p>No completed outcome recorded</p><small>Includes in-progress tasks; not a failure count</small></section><section><h2>Unavailable</h2><p>Cost per accepted task</p><small>Complete task-bound actual spend is not available</small></section></div>
38
+ <div class="admin-notice">Acceptance rate, time to acceptance and severe regressions are not measured by this projection. Role verdicts, closed issues and successful processes cannot supply those numbers.</div>
39
+ <h2 class="admin-section-title">Recorded work</h2><div class="admin-table-scroll"><table class="admin-table"><thead><tr><th>Task</th><th>Host</th><th>State</th><th>Outcome</th></tr></thead><tbody>${(data.entries||[]).filter(e=>e.kind==='task').slice(0,100).map(e=>`<tr><td>${esc(e.title)}</td><td>${esc(e.host||'Not linked')}</td><td>${esc(e.phase||'Unknown')}</td><td>${esc(e.taskOutcome?.state||'Not recorded')}</td></tr>`).join('')||'<tr><td colspan="4">No tracked task outcomes in the readable records.</td></tr>'}</tbody></table></div><p class="work-meta">All available tracked tasks, not a time-window cohort. Host quality comparisons require matched tasks, budgets and acceptance criteria.</p>`;
40
+ } catch(error) { if(current(seq,project)&&ticket===request) el.textContent='Outcomes could not be read: '+error.message; }
41
+ }
42
+ async function loadKnowledge() {
43
+ const seq=generation, project=scope(), ticket=++indexRequest;
44
+ request++; selectedDoc=null;
45
+ panel('knowledge-index').textContent='Reading project documents…';
46
+ try {
47
+ const data=await api(scoped('/api/docs'));
48
+ if (!current(seq,project)||ticket!==indexRequest) return;
49
+ if (!data || data.error || data.state==='unreadable' || !Array.isArray(data.groups)) throw Error(data?.error || 'Document index is unavailable.');
50
+ const unreadable=data.groups.flatMap(g=>g.docs).filter(d=>d.readState&&d.readState!=='readable').length;
51
+ panel('knowledge-index').innerHTML=`<label class="admin-doc-search">Find a document<input type="search" id="knowledge-search" placeholder="Filter document titles and paths"></label><p class="work-meta">${esc(data.total)} indexed${data.truncated?' · Index truncated':''}${unreadable?' · '+unreadable+' unreadable or oversized':''}</p>`+data.groups.map(g=>`<section><h2>${esc(g.label)}</h2>${g.docs.map(d=>`<button type="button" class="knowledge-file" data-doc-path="${esc(d.path)}" data-doc-search="${esc(d.title+' '+d.path)}">${esc(d.title)}<small>${esc(d.path)}${d.readState&&d.readState!=='readable'?' · '+esc(d.readState):''}</small></button>`).join('')}</section>`).join('');
52
+ panel('knowledge-search').addEventListener('input',e=>{const q=e.target.value.toLowerCase();panel('knowledge-index').querySelectorAll('[data-doc-search]').forEach(b=>b.hidden=!b.dataset.docSearch.toLowerCase().includes(q));});
53
+ panel('knowledge-index').querySelectorAll('[data-doc-path]').forEach(b=>b.addEventListener('click',()=>openDocument(b.dataset.docPath)));
54
+ panel('knowledge-document').innerHTML='<h2>Select a document</h2><p>Read existing project files with their source path. Document text is not an instruction to the board.</p><p class="work-meta">Architecture diagrams describe declared design, not running task state.</p>';
55
+ } catch(error) { if(current(seq,project)&&ticket===indexRequest) { panel('knowledge-index').textContent='Documents could not be read: '+error.message; panel('knowledge-document').textContent='No document from another project will be substituted.'; } }
56
+ }
57
+ async function openDocument(path) {
58
+ const seq=generation, project=scope(), docTicket=++request;
59
+ selectedDoc=path; panel('knowledge-document').textContent='Reading document…';
60
+ panel('knowledge-index').querySelectorAll('[data-doc-path]').forEach(b=>b.setAttribute('aria-current',String(b.dataset.docPath===path)));
61
+ try {
62
+ const data=await api(scoped('/api/doc?path='+encodeURIComponent(path)));
63
+ if (!current(seq,project)||docTicket!==request||selectedDoc!==path) return;
64
+ if (!data || data.error || typeof data.content!=='string') throw Error(data?.error || 'Document is unavailable.');
65
+ panel('knowledge-document').innerHTML=`<div class="admin-doc-heading"><h2>${esc(data.name)}</h2><span class="work-meta">Read-only</span></div><p class="admin-source">${esc(data.path)}</p><div class="admin-notice">Source revision: ${esc(data.sourceRevision||'Not recorded')} · Document status: ${esc(data.status||'Not declared')}<br>Declared status is not a verification verdict. Original document language is preserved.</div><div class="md admin-document">${mdToHtml(data.content)}</div>`;
66
+ panel('knowledge-document').querySelectorAll('pre code.language-mermaid').forEach(code=>{
67
+ const source=code.textContent, result=window.GctoDiagram.render(source), section=document.createElement('section');section.className='admin-diagram';
68
+ section.innerHTML=`<p class="work-meta">Architecture / declared diagram, not live execution</p>${result.state==='rendered'?result.html:`<p>${esc(result.reason)}</p>`}<details><summary>Mermaid source</summary><pre></pre></details>`;
69
+ section.querySelector('pre').textContent=source;code.parentElement.replaceWith(section);
70
+ });
71
+ // Links remain project documents or external navigation, never commands.
72
+ panel('knowledge-document').querySelectorAll('a[href]').forEach(a=>{
73
+ const href=a.getAttribute('href');
74
+ if (/^https?:\/\//i.test(href)) {a.target='_blank';a.rel='noopener noreferrer';}
75
+ else if(!href.startsWith('#')) {a.removeAttribute('href');a.title='Open this relative document from the project index.';}
76
+ });
77
+ } catch(error) {if(current(seq,project)&&docTicket===request) panel('knowledge-document').textContent='Document could not be read: '+error.message;}
78
+ }
79
+ function setup() {
80
+ const nav=panel('nav-tablist'), existing={};
81
+ document.querySelectorAll('.nav-item[data-tab]').forEach(n=>existing[n.dataset.tab]=n.querySelector('svg')?.outerHTML||'');
82
+ nav.innerHTML=['work','decisions','knowledge','insights','settings'].map(key=>`<button type="button" class="nav-item${key==='work'?' active':''}" role="tab" aria-selected="${key==='work'}" data-tab="${key}" tabindex="0" onkeydown="navTabKeydown(event,this)">${existing[key]||existing[key==='knowledge'?'skills':key==='insights'?'usage':'harness']||''}<span class="nav-label">${key[0].toUpperCase()+key.slice(1)}</span>${key==='decisions'?'<span class="count" id="nav-inbox-count">0</span>':''}</button>`).join('');
83
+ nav.querySelectorAll('[data-tab]').forEach(n=>n.addEventListener('click',()=>window.switchTab(n.dataset.tab)));
84
+ const tools=panel('tools-nav');tools.hidden=true;
85
+ const workspace=document.querySelector('.workspace');
86
+ const bar=document.createElement('div');bar.id='admin-subnav';bar.hidden=true;workspace.querySelector('.topbar').after(bar);
87
+ const knowledge=document.createElement('div');knowledge.className='panel';knowledge.id='panel-knowledge';knowledge.setAttribute('role','tabpanel');knowledge.innerHTML='<div class="admin-page"><header class="admin-page-heading"><div><h1>Knowledge</h1><p>Project documents and architecture. Git files remain the source.</p></div><button type="button" class="gate-btn" id="knowledge-reload">Reload</button></header><div class="knowledge-layout"><aside id="knowledge-index" aria-label="Project documents"></aside><article id="knowledge-document" aria-live="polite"></article></div></div>';workspace.append(knowledge);
88
+ panel('knowledge-reload').addEventListener('click',()=>{generation++;loadKnowledge();});
89
+ const insights=document.createElement('div');insights.id='panel-admin-outcomes';insights.className='panel';insights.setAttribute('role','tabpanel');insights.innerHTML='<div class="admin-page"><header class="admin-page-heading"><div><h1>Insights</h1><p>Recorded outcomes are separate from token consumption.</p></div><button type="button" class="gate-btn" id="outcomes-reload">Reload</button></header><div id="admin-outcomes-body" aria-live="polite"></div></div>';workspace.append(insights);
90
+ panel('outcomes-reload').addEventListener('click',loadTaskOutcomes);
91
+ const original=window.switchTab;
92
+ window.switchTab=function(id,el){
93
+ const agentLink=String(id).match(/^(?:agents|fleet)\/([a-z0-9-]+)$/i);
94
+ if(agentLink) {
95
+ window.switchTab('settings/executors');history.replaceState(null,'','#agents/'+agentLink[1]);
96
+ const expected=location.hash, project=scope();
97
+ const open=(n)=>{if(location.hash!==expected||scope()!==project)return;if(typeof currentProject==='string'&&currentProject){openAgentDrawer(agentLink[1],{fromHash:true});}else if(n<50)setTimeout(()=>open(n+1),100);};
98
+ open(0);return;
99
+ }
100
+ const r=route(id);
101
+ if(!r) {bar.hidden=true;return original(id,el);}
102
+ selectedRoute=r.canonical;
103
+ const old={inbox:'decisions',budgets:'ledger',agents:'fleet','admin-outcomes':'admin-outcomes',knowledge:'knowledge'}[r.panel]||r.panel;
104
+ original(old); // Keep established loaders and mutation guards.
105
+ document.querySelectorAll('.nav-item[data-tab]').forEach(n=>{const active=n.closest('#nav-tablist')&&n.dataset.tab===r.primary;n.classList.toggle('active',!!active);n.setAttribute('aria-selected',String(!!active));});
106
+ panel('crumb-here').textContent=r.primary[0].toUpperCase()+r.primary.slice(1);
107
+ const items=r.primary==='settings'?[['settings/executors','Executors'],['settings/runtime','Runtime'],['settings/capabilities','Capabilities'],['settings/general','General']]:r.primary==='insights'?[['insights/outcomes','Outcomes'],['insights/consumption','Consumption'],['insights/ledger','Ledger & budgets']]:[];
108
+ bar.hidden=!items.length;bar.innerHTML=items.map(([key,label])=>`<button type="button" class="admin-subtab" data-route="${key}" aria-pressed="${r.canonical===key||(r.canonical===r.primary&&key===items[0][0])}">${label}</button>`).join('');
109
+ bar.querySelectorAll('[data-route]').forEach(b=>b.onclick=()=>window.switchTab(b.dataset.route));
110
+ history.replaceState(null,'','#/'+r.canonical);
111
+ if(r.primary==='knowledge')loadKnowledge();
112
+ if(r.panel==='admin-outcomes')loadTaskOutcomes();
113
+ // Restore a working route after projection errors; an error is not an empty list.
114
+ };
115
+ const composer=panel('work-compose');
116
+ const disclosure=document.createElement('details');disclosure.className='admin-compose';const summary=document.createElement('summary');summary.textContent='New task · prepare launch command';disclosure.append(summary);composer.before(disclosure);disclosure.append(composer);
117
+ composer.querySelector('button[type="submit"]').textContent='Copy launch command';
118
+ window.switchTab((location.hash||'#/work').replace(/^#\/?/,''));
119
+ }
120
+ window.GctoAdmin={route,outcomes,reset,openDocument};
121
+ if(typeof document!=='undefined') setup();
122
+ })();
@@ -0,0 +1,42 @@
1
+ /* Bounded, non-executable Mermaid flowchart subset. Other syntax stays source. */
2
+ (() => {
3
+ const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
4
+ function render(source) {
5
+ const unsupported = { state: 'unsupported', html: '', reason: 'This diagram syntax is not supported by the bounded viewer. Read the Mermaid source below.' };
6
+ if (typeof source !== 'string' || source.length > 20000) return unsupported;
7
+ const lines = source.split(/\n|;/).map(s => s.trim()).filter(s => s && !s.startsWith('%%'));
8
+ if (!/^(?:flowchart|graph)\s+LR$/.test(lines.shift() || '')) return unsupported;
9
+ const nodes = new Map(), edges = [];
10
+ const node = part => {
11
+ const m = part.trim().match(/^([A-Za-z_][\w-]*)(?:\[([^\]\n]{1,120})\])?$/);
12
+ if (!m) throw Error('unsupported node');
13
+ if (m[2] !== undefined || !nodes.has(m[1])) nodes.set(m[1], (m[2] || m[1]).replace(/^"(.*)"$/, '$1'));
14
+ if (nodes.size > 40) throw Error('node bound'); return m[1];
15
+ };
16
+ try {
17
+ for (const line of lines) {
18
+ const parts = line.split(/\s*-->\s*/);
19
+ if (parts.length > 8) return unsupported;
20
+ const ids = parts.map(node);
21
+ for (let i = 1; i < ids.length; i++) edges.push([ids[i-1], ids[i]]);
22
+ if (edges.length > 80) return unsupported;
23
+ }
24
+ if (!nodes.size) return unsupported;
25
+ const level = new Map([...nodes.keys()].map(id => [id, 0]));
26
+ let changed = false;
27
+ for (let i = 0; i < nodes.size; i++) {
28
+ changed = false;
29
+ for (const [a,b] of edges) if (level.get(b) <= level.get(a)) { level.set(b, level.get(a)+1); changed = true; }
30
+ if (!changed) break;
31
+ }
32
+ if (changed) return unsupported; // Cyclic diagrams need a full layout engine.
33
+ const lanes = new Map(), positions = new Map();
34
+ for (const id of nodes.keys()) { const l = level.get(id), row = lanes.get(l) || 0; lanes.set(l,row+1); positions.set(id,{x:25+l*220,y:35+row*100}); }
35
+ const width = 50+(Math.max(...level.values())+1)*220, height = 55+Math.max(...lanes.values())*100;
36
+ const paths = edges.map(([a,b]) => { const p=positions.get(a),q=positions.get(b);return `<path d="M${p.x+180} ${p.y+28} C${p.x+205} ${p.y+28},${q.x-25} ${q.y+28},${q.x} ${q.y+28} M${q.x-6} ${q.y+23} L${q.x} ${q.y+28} L${q.x-6} ${q.y+33}"/>`; }).join('');
37
+ const boxes = [...nodes].map(([id,label]) => {const p=positions.get(id);return `<g><rect x="${p.x}" y="${p.y}" width="180" height="56" rx="5"/><text x="${p.x+90}" y="${p.y+33}" text-anchor="middle">${esc(label.length > 24 ? label.slice(0,23)+'…' : label)}</text><title>${esc(label)}</title></g>`;}).join('');
38
+ return {state:'rendered',html:`<svg class="knowledge-diagram" viewBox="0 0 ${width} ${height}" role="img" aria-label="Declared diagram, not live execution"><g class="diagram-edges" fill="none">${paths}</g>${boxes}</svg>`};
39
+ } catch { return unsupported; }
40
+ }
41
+ window.GctoDiagram = { render };
42
+ })();
@@ -57,6 +57,53 @@
57
57
  + (e.durationMs !== null ? ` · ${esc(e.durationMs)} ms` : '') + '</li>').join('') + '</ol>' : '')
58
58
  + '</details>';
59
59
  }
60
+ const categories = [['active', 'All active'], ['attention', 'Needs attention'], ['running', 'Running'], ['queued', 'Queued'], ['completed', 'Completed']];
61
+ function inCategory(entry, category) {
62
+ if (category === 'completed') return entry.terminal;
63
+ if (entry.terminal) return false;
64
+ if (category === 'attention') return (entry.decisions || []).length > 0 || entry.nativeState === 'blocked' || ['needs_decision', 'blocked', 'unknown'].includes(entry.phase);
65
+ if (category === 'running') return ['working', 'publishing'].includes(entry.phase);
66
+ if (category === 'queued') return entry.phase === 'accepted';
67
+ return true;
68
+ }
69
+ function cockpitEntries(data, category, search) {
70
+ return data.entries.filter(e => inCategory(e, category) && (!search || `${e.title} ${e.key}`.toLowerCase().includes(search.toLowerCase())));
71
+ }
72
+ function renderExecution(entry) {
73
+ const roles = Array.isArray(entry.evidence) ? entry.evidence : [];
74
+ const execution = entry.execution, nodes = Array.isArray(execution?.nodes) ? execution.nodes.slice(0, 200) : [];
75
+ return '<section class="work-execution"><h3>Recorded observations</h3>'
76
+ + '<p>Dependencies not recorded. This is an observation list, not an inferred execution graph. Event order does not establish dependencies.</p>'
77
+ + `<p>Recorded stage: ${esc(entry.stage || 'not recorded')}</p>`
78
+ + (execution?.reason ? `<p>${esc(execution.reason)}</p>` : '')
79
+ + (execution?.truncated ? '<p class="work-warning">Showing the latest 200 recorded attempts; earlier observations are truncated.</p>' : execution?.partial ? '<p class="work-warning">Attempt observations are incomplete.</p>' : '')
80
+ + (nodes.length ? '<ul class="work-observations" aria-label="Recorded execution attempts">' + nodes.map(n => `<li class="work-observation" data-attempt-id="${esc(n.attemptId)}"><strong>${esc(n.role)} · attempt ${esc(n.number || 'unknown')}</strong>`
81
+ + `<p>${esc(n.host || 'Host not recorded')} · phase: ${esc(n.phase || 'not recorded')}</p><p>Controller recorded status: ${esc(n.status || 'unknown')}; not current verification or acceptance.</p>`
82
+ + `<p>Run: ${esc(n.runId)} · Attempt: ${esc(n.attemptId)}</p><p>Started: ${esc(n.startedAt || 'not recorded')} · Finished: ${esc(n.finishedAt || 'not recorded')}</p></li>`).join('') + '</ul>' : '')
83
+ + (roles.length ? '<ul class="work-observations">' + roles.map(role => `<li class="work-observation">${esc(role.label)} · recorded role observation; acceptance is not inferred</li>`).join('') + '</ul>' : '<p>No role observations recorded.</p>')
84
+ + (entry.inspector ? renderInspector(entry.inspector).replace('<details ', '<details open ') : '<p>Linked activity unavailable. Absence of events is not proof of inactivity.</p>')
85
+ + '</section>';
86
+ }
87
+ function renderCockpit(data, options = {}) {
88
+ const category = categories.some(([key]) => key === options.category) ? options.category : 'active';
89
+ const tab = ['overview', 'execution', 'evidence'].includes(options.tab) ? options.tab : 'overview';
90
+ const rows = cockpitEntries(data, category, options.search || '');
91
+ const selected = rows.find(e => e.key === options.selectedKey) || rows[0];
92
+ const warnings = data.sources.filter(s => ['degraded', 'unavailable'].includes(s.health));
93
+ const feedback = warnings.map(s => `<p class="work-warning">${esc(s.id)}: ${esc(s.reason || s.health)}</p>`).join('');
94
+ const observed = `<p class="work-meta">Observed ${esc(data.observedAt)} · ${data.health === 'current' ? 'Sources read' : 'Sources incomplete'}</p>`;
95
+ const toolbar = '<div class="work-filters" role="group" aria-label="Task status filters">' + categories.map(([key, label]) => `<button type="button" class="work-filter" data-work-filter="${key}" aria-pressed="${category === key}">${label} <span>${cockpitEntries(data, key, options.search || '').length}</span></button>`).join('') + '</div>';
96
+ if (!selected) return toolbar + observed + feedback + `<p>${warnings.length || data.health !== 'current' ? 'No readable entries match this view; sources are incomplete.' : options.search ? 'No entries match the search.' : 'No work records match this filter.'}</p>`;
97
+ const list = '<div class="work-task-list" role="group" aria-label="Project tasks">' + rows.map(e => `<button type="button" class="work-task-row" data-work-select="${esc(e.key)}" aria-current="${e.key === selected.key}"><strong>${esc(e.title)}</strong><span>${esc(phaseLabel(e))}</span><small>${esc(e.host || 'Host not linked')} · ${esc(e.stage || 'Stage not recorded')}</small></button>`).join('') + '</div>';
98
+ const tabs = '<div class="work-tabs" role="tablist" aria-label="Selected task">' + ['overview', 'execution', 'evidence'].map(name => `<button type="button" class="work-tab" id="work-tab-${name}" data-work-tab="${name}" role="tab" aria-selected="${tab === name}" aria-controls="work-detail-panel" tabindex="${tab === name ? 0 : -1}">${name[0].toUpperCase() + name.slice(1)}</button>`).join('') + '</div>';
99
+ // Only the selected Overview owns mutation controls; observation tabs cannot duplicate them.
100
+ const detail = tab === 'execution' ? renderExecution(selected) : tab === 'evidence'
101
+ ? (Array.isArray(selected.evidenceSections) && selected.evidenceSections.length ? renderEvidence(selected.evidenceSections).replace('<details ', '<details open ') : '<p>Evidence unavailable. No verification or acceptance is inferred.</p>')
102
+ : render({ ...data, sources: [], sessions: [], entries: [{ ...selected, inspector: null, evidenceSections: null }] }, selected.terminal);
103
+ return toolbar + (tab === 'overview' ? '' : observed) + feedback + '<p class="work-meta">Running means host activity was recorded; current liveness is not measured. Closed records do not by themselves prove acceptance.</p>'
104
+ + `<div class="work-cockpit">${list}<section class="work-detail" aria-label="Selected task details">${tabs}<div id="work-detail-panel" role="tabpanel" aria-labelledby="work-tab-${tab}" tabindex="0">${detail}</div></section></div>`
105
+ + (data.sessions.length ? `<details><summary>Claude sessions (${data.sessions.length}) · task linkage not recorded</summary>${data.sessions.map(s => `<p>${esc(s.session)} · ${esc(s.state)} · ${esc(s.reason || '')}</p>`).join('')}</details>` : '');
106
+ }
60
107
  function renderEvidence(sections) {
61
108
  if (!Array.isArray(sections)) return '';
62
109
  return '<details data-work-detail="evidence"><summary>Task evidence · read-only</summary>'
@@ -105,7 +152,7 @@
105
152
  status('Draft PR publication recorded. Merge and release remain separate decisions.'); await refresh();
106
153
  } catch (error) { if (project === config.project()) { status(error.message); await refresh(); } }
107
154
  }
108
- let config, snapshot, generation = 0, filter = '';
155
+ let config, snapshot, generation = 0, filter = '', selectedKey = null, category = 'active', tab = 'overview';
109
156
  const status = message => { document.getElementById('work-feedback').textContent = message; };
110
157
  async function refresh() {
111
158
  const seq = ++generation, project = config.project();
@@ -127,14 +174,18 @@
127
174
  const expanded = [...document.querySelectorAll('.work-card details[open]')].map(d => `${d.closest('[data-entry-key]').dataset.entryKey}:${d.dataset.workDetail}`);
128
175
  const focused = document.activeElement;
129
176
  const focusKey = focused?.dataset?.workKey, focusAction = focused?.dataset?.workAction;
130
- document.getElementById('work-list').innerHTML = render(snapshot, false, filter);
177
+ const focusSelect = focused?.dataset?.workSelect, focusFilter = focused?.dataset?.workFilter, focusTab = focused?.dataset?.workTab;
178
+ document.getElementById('work-list').innerHTML = renderCockpit(snapshot, { search: filter, selectedKey, category, tab });
131
179
  document.getElementById('history-list').innerHTML = render(snapshot, true, filter);
132
180
  for (const card of document.querySelectorAll('[data-entry-key]')) for (const detail of card.querySelectorAll('details'))
133
181
  if (expanded.includes(`${card.dataset.entryKey}:${detail.dataset.workDetail}`)) detail.open = true;
134
182
  if (focusKey) [...document.querySelectorAll('[data-work-key]')].find(b => b.dataset.workKey === focusKey && b.dataset.workAction === focusAction)?.focus();
183
+ else if (focusSelect) [...document.querySelectorAll('[data-work-select]')].find(b => b.dataset.workSelect === focusSelect)?.focus();
184
+ else if (focusFilter) [...document.querySelectorAll('[data-work-filter]')].find(b => b.dataset.workFilter === focusFilter)?.focus();
185
+ else if (focusTab) [...document.querySelectorAll('[data-work-tab]')].find(b => b.dataset.workTab === focusTab)?.focus();
135
186
  }
136
187
  function reset() {
137
- generation++; snapshot = null; previews.clear(); status('');
188
+ generation++; snapshot = null; previews.clear(); selectedKey = null; category = 'active'; tab = 'overview'; filter = ''; status('');
138
189
  for (const id of ['work-list', 'history-list']) document.getElementById(id).textContent = 'Loading project work…';
139
190
  refresh();
140
191
  }
@@ -156,7 +207,17 @@
156
207
  document.getElementById('work-command').textContent = ''; status('');
157
208
  });
158
209
  for (const id of ['work-list', 'history-list']) document.getElementById(id).addEventListener('click', event => {
159
- const button = event.target.closest('[data-work-action]'); if (!button || !snapshot) return;
210
+ if (!snapshot) return;
211
+ const button = event.target.closest('[data-work-action]');
212
+ if (!button?.dataset.workAction) {
213
+ const selection = event.target.closest('[data-work-select]');
214
+ const filterButton = event.target.closest('[data-work-filter]');
215
+ const tabButton = event.target.closest('[data-work-tab]');
216
+ if (selection?.dataset.workSelect && snapshot.entries.some(e => e.key === selection.dataset.workSelect)) { selectedKey = selection.dataset.workSelect; tab = 'overview'; paint(); }
217
+ else if (categories.some(([key]) => key === filterButton?.dataset.workFilter)) { category = filterButton.dataset.workFilter; selectedKey = null; tab = 'overview'; paint(); }
218
+ else if (['overview', 'execution', 'evidence'].includes(tabButton?.dataset.workTab)) { tab = tabButton.dataset.workTab; paint(); }
219
+ return;
220
+ }
160
221
  const entry = snapshot.entries.find(e => e.key === button.dataset.workKey);
161
222
  if (button.dataset.workAction === 'preview_publication' && entry?.publicationPreview) { previewPublication(entry); return; }
162
223
  if (button.dataset.workAction === 'publish_publication' && entry?.publicationPreview) { publish(entry, button); return; }
@@ -165,7 +226,17 @@
165
226
  if (action.action === 'copy_resume' || action.action === 'copy_approve') copy(entry.command);
166
227
  else options.openIssue(entry.issueIds[0], action.action === 'review_decision');
167
228
  });
229
+ document.getElementById('work-list').addEventListener('keydown', event => {
230
+ const button = event.target.closest('[data-work-tab]');
231
+ if (!button || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
232
+ const tabs = ['overview', 'execution', 'evidence'], current = tabs.indexOf(button.dataset.workTab);
233
+ if (current < 0) return;
234
+ event.preventDefault();
235
+ tab = event.key === 'Home' ? tabs[0] : event.key === 'End' ? tabs[2] : tabs[(current + (event.key === 'ArrowRight' ? 1 : 2)) % 3];
236
+ paint();
237
+ [...document.querySelectorAll('[data-work-tab]')].find(b => b.dataset.workTab === tab)?.focus();
238
+ });
168
239
  setInterval(() => { if (!document.hidden && options.visible()) refresh(); }, 10000);
169
240
  }
170
- window.GctoWork = { bind, refresh, reset, render, commandFor, publicationCommand, search(value) { filter = value; paint(); } };
241
+ window.GctoWork = { bind, refresh, reset, render, renderCockpit, commandFor, publicationCommand, getSnapshot() { return snapshot; }, showCompleted() { category = 'completed'; selectedKey = null; tab = 'overview'; paint(); }, search(value) { filter = value; paint(); } };
171
242
  })();
@@ -24,6 +24,7 @@
24
24
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16.png" />
25
25
  <link rel="shortcut icon" href="/assets/favicon.ico" />
26
26
  <link href="/assets/fonts/fonts.css" rel="stylesheet" />
27
+ <link href="/assets/admin-shell.css" rel="stylesheet" />
27
28
  <script src="/assets/vendor/marked.min.js"></script>
28
29
  <script src="/assets/vendor/purify.min.js"></script>
29
30
  <style>
@@ -6176,6 +6177,7 @@ let usageData = null;
6176
6177
  let _usageTimer = null;
6177
6178
  let _usageRequest = 0;
6178
6179
  function resetProjectUsage() {
6180
+ if (typeof window !== 'undefined') window.GctoAdmin?.reset();
6179
6181
  clearTimeout(_usageTimer); clearTimeout(_outcomesTimer);
6180
6182
  ++_usageRequest; ++_outcomesRequest;
6181
6183
  usageData = null;
@@ -9407,6 +9409,7 @@ function navTabKeydown(event, el) {
9407
9409
  const LEGACY_ROUTES = { inbox: 'decisions', budgets: 'ledger', dashboard: 'ledger', agents: 'fleet', notifications: 'settings', share: 'settings' };
9408
9410
  function routeFromHash() {
9409
9411
  const h = (location.hash || '').replace(/^#\/?/, '');
9412
+ if (window.GctoAdmin?.route(h)) return h;
9410
9413
  if (!h || h.startsWith('agents/')) return null;
9411
9414
  // #/fleet/<slug>: open the agent in place (DESIGN §2 deep link).
9412
9415
  const m = h.match(/^fleet\/([a-z0-9-]+)$/i);
@@ -10544,5 +10547,7 @@ const _costLbl = document.getElementById('mp-cost-label');
10544
10547
  if (_costLbl) _costLbl.textContent = `Cost · last ${periodLabel(currentPeriod)}`;
10545
10548
  </script>
10546
10549
 
10550
+ <script src="/assets/diagram-view.js"></script>
10551
+ <script src="/assets/admin-shell.js"></script>
10547
10552
  </body>
10548
10553
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.67.1",
3
+ "version": "3.68.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",
@@ -1,146 +0,0 @@
1
- /**
2
- * doc-links — how much of the documentation is connected to the rest of it.
3
- *
4
- * The operator's words: "документация — по-прежнему не связана в единое целое."
5
- * First measured over `docs/`, excluding machine summaries and translations:
6
- *
7
- * 155 documents
8
- * 89 orphans — link to nothing, and nothing links to them
9
- * 18 ADRs, every one of which links to another ADR
10
- *
11
- * As of 2026-09-01: 160 documents, 58 orphans. The eighteen orphaned plans were
12
- * closed by `docs/plans/README.md` — written by hand, one line per plan taken
13
- * from the plan's own heading. The ratchet in tests/lib/doc-links.test.mjs
14
- * carries the current floor.
15
- *
16
- * So the corpus is one connected island and a field of loose leaves. `ADR-009` has
17
- * ten inbound links; eighteen plans in `docs/plans` have none in either direction.
18
- *
19
- * WHY THIS DOES NOT TRY TO FIX IT
20
- * ------------------------------
21
- * The obvious idea is to derive the links: a PLAN about X should point at the ARCH
22
- * about X and the QA report about X. Measured before attempting it — across 155
23
- * documents there are THREE shared slugs, covering six files, and front-matter
24
- * exists on eight. There is no naming convention to derive from. Two real pairs
25
- * (`judge-provenance`, `stale-after`) are the exception that shows the rule.
26
- *
27
- * Connecting the other 89 means reading and understanding 89 documents. A script
28
- * that guessed would produce links nobody meant, which is exactly the confident
29
- * fabrication this project spends its checks removing.
30
- *
31
- * So this measures, and it ratchets. The number may not grow. Closing it is
32
- * authoring work, done deliberately, a few documents at a time.
33
- */
34
-
35
- import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
36
- import path from 'node:path';
37
-
38
- /** Generated summaries and translations are copies, not documents. */
39
- const IS_SUMMARY = /\.summary\.md$/;
40
-
41
- /**
42
- * A two-letter directory is not evidence of a language.
43
- *
44
- * This used to be `/^docs\/[a-z]{2}(-[A-Z]{2})?\//`, which reads `docs/qa/`,
45
- * `docs/ai/`, `docs/ci/`, `docs/ux/` and `docs/db/` as language codes and drops
46
- * every document under them — silently, from the board's docs tab and from the
47
- * link graph both, because a dropped document declares nothing that could go
48
- * missing. `docs/qa/` was already being eaten; the rest were waiting.
49
- *
50
- * A whitelist of language codes would rot the first time someone adds one, so
51
- * the test is STRUCTURAL instead: a translation MIRRORS a document at the docs
52
- * root. `docs/ru/index.md` is a translation because `docs/index.md` exists;
53
- * `docs/qa/QA-judge-provenance.md` is not, because `docs/QA-judge-provenance.md`
54
- * does not. Measured across all ten two-letter directories here — de, es, fr,
55
- * ja, ko, pt-BR, ru, zh-CN, zh-TW each mirror 1 of 1; qa mirrors 0 of 2 — and it
56
- * self-corrects when a language is added or removed.
57
- *
58
- * Consequence worth stating, because it is a visible surface change hiding
59
- * inside a classifier fix: `docs/qa/` drafts now APPEAR on the board's docs tab.
60
- * That is the intended direction — the board is local and should show drafts —
61
- * but it is not what a reader expects from a regex change.
62
- */
63
- const LANG_DIR = /^docs\/[a-z]{2}(?:-[A-Z]{2})?\//;
64
-
65
- /** The basename a translation would be a translation OF. */
66
- const baseName = (rel) => rel.slice(rel.lastIndexOf('/') + 1);
67
- export function listDocs(root = 'docs') {
68
- // The translation rule is written against a path that starts at the docs
69
- // directory. `root` may be relative ('docs') or absolute (the board serves
70
- // other projects by absolute path), so every candidate is re-expressed
71
- // relative to root's parent before the rule is applied — otherwise the rule
72
- // matches nothing on an absolute walk and translations count as documents,
73
- // which is a wrong number that looks like a right one.
74
- const base = path.dirname(root);
75
- const found = [];
76
- const walk = (dir) => {
77
- let entries;
78
- try { entries = readdirSync(dir); } catch { return; }
79
- for (const e of entries) {
80
- const full = path.join(dir, e);
81
- let st;
82
- try { st = statSync(full); } catch { continue; }
83
- if (st.isDirectory()) walk(full);
84
- else if (e.endsWith('.md') && !IS_SUMMARY.test(e)) found.push({ full, rel: path.relative(base, full) });
85
- }
86
- };
87
- walk(root);
88
-
89
- // Two passes, because whether a file under a two-letter directory is a
90
- // TRANSLATION depends on the rest of the tree: it is one when a document of
91
- // the same name lives outside every language directory. One pass cannot know
92
- // that, and the single-pass version of this rule only looked at the docs
93
- // root — which called `docs/ru/ADR-001-a.md` a document because its source
94
- // sits in `docs/adr/`.
95
- const sources = new Set(
96
- found.filter((f) => !LANG_DIR.test(f.rel)).map((f) => baseName(f.rel)),
97
- );
98
- return found
99
- .filter((f) => !(LANG_DIR.test(f.rel) && sources.has(baseName(f.rel))))
100
- .map((f) => f.full)
101
- .sort();
102
- }
103
-
104
- /**
105
- * @returns {{docs:string[], orphans:string[], inbound:Map<string,string[]>}}
106
- * An orphan links to no document in this set AND is linked to by none. Both
107
- * directions matter: a document nobody references is unreachable, and one that
108
- * references nothing is unplaced.
109
- */
110
- export function linkGraph(root = 'docs', read = readFileSync) {
111
- const docs = listDocs(root);
112
- const known = new Set(docs);
113
- const out = new Map(docs.map((d) => [d, new Set()]));
114
- const inbound = new Map(docs.map((d) => [d, new Set()]));
115
-
116
- for (const f of docs) {
117
- let text = '';
118
- try { text = String(read(f, 'utf8')); } catch { continue; }
119
- const add = (target) => {
120
- if (!known.has(target) || target === f) return;
121
- out.get(f).add(target);
122
- inbound.get(target).add(f);
123
- };
124
- // Markdown links, resolved relative to the file and to the repo root.
125
- for (const m of text.matchAll(/\]\(([^)#\s]+\.md)/g)) {
126
- const rel = m[1];
127
- add(path.normalize(path.join(path.dirname(f), rel)));
128
- add(path.normalize(rel.replace(/^\.\.\//, '')));
129
- add(path.normalize(path.join(root, rel)));
130
- }
131
- // `ADR-009` in prose is a reference even without a link — the convention this
132
- // repository actually uses, and the reason the ADRs are its one connected set.
133
- for (const m of text.matchAll(/\bADR-(\d{3})\b/g)) {
134
- for (const c of docs) {
135
- if (path.basename(c).startsWith(`ADR-${m[1]}`)) add(c);
136
- }
137
- }
138
- }
139
-
140
- const orphans = docs.filter((d) => out.get(d).size === 0 && inbound.get(d).size === 0);
141
- return {
142
- docs,
143
- orphans,
144
- inbound: new Map([...inbound].map(([k, v]) => [k, [...v]])),
145
- };
146
- }