great-cto 3.67.1 → 3.68.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/.claude-plugin/plugin.json +6 -1
- package/board/packages/board/lib/docs.mjs +97 -17
- package/board/packages/board/lib/knowledge.mjs +50 -0
- package/board/packages/board/lib/routes.mjs +33 -22
- package/board/packages/board/lib/work.mjs +26 -3
- package/board/packages/board/public/assets/admin-shell.css +38 -0
- package/board/packages/board/public/assets/admin-shell.js +122 -0
- package/board/packages/board/public/assets/diagram-view.js +42 -0
- package/board/packages/board/public/assets/work-view.js +76 -5
- package/board/packages/board/public/index.html +5 -0
- package/board/scripts/lib/receipt.mjs +43 -8
- package/package.json +1 -1
- package/board/scripts/lib/doc-links.mjs +0 -146
|
@@ -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.
|
|
4
|
+
"version": "3.68.1",
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
656
|
+
const c = scopedProject.path;
|
|
650
657
|
try {
|
|
651
|
-
const [{ listDocs }, {
|
|
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 =
|
|
661
|
+
const map = knowledgeMap(c);
|
|
655
662
|
let pipeline = null;
|
|
656
|
-
|
|
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: {
|
|
668
|
+
map: { ...map, mermaid: toMermaid(map) },
|
|
661
669
|
pipeline,
|
|
662
670
|
}));
|
|
663
|
-
} catch
|
|
671
|
+
} catch {
|
|
664
672
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
665
|
-
res.end(JSON.stringify({ error:
|
|
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 =
|
|
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 ||
|
|
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
|
|
681
|
-
|
|
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:
|
|
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: '
|
|
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 =
|
|
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
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
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
|
|
2380
|
+
} catch {
|
|
2370
2381
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2371
|
-
res.end(JSON.stringify({ error:
|
|
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
|
-
:
|
|
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
|
|
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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'&¤tProject){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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
-
|
|
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
|
-
|
|
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>
|
|
@@ -29,7 +29,8 @@ import { execFileSync } from 'node:child_process';
|
|
|
29
29
|
import { createHash } from 'node:crypto';
|
|
30
30
|
import { readFileSync } from 'node:fs';
|
|
31
31
|
import * as fsModule from 'node:fs';
|
|
32
|
-
import { join } from 'node:path';
|
|
32
|
+
import { dirname, join, resolve } from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
33
34
|
|
|
34
35
|
const sha = (s) => createHash('sha256').update(String(s)).digest('hex');
|
|
35
36
|
|
|
@@ -351,12 +352,23 @@ export function describeDrift(cmp, { max = 10 } = {}) {
|
|
|
351
352
|
export const APPROVING_AGENTS = Object.freeze(['code-reviewer', 'security-officer', 'qa-engineer']);
|
|
352
353
|
const APPROVING_VERDICTS = new Set(['APPROVED', 'PASS', 'PASSED']);
|
|
353
354
|
|
|
354
|
-
|
|
355
|
+
/** Same state location as log-verdict.sh; the candidate bytes stay in cwd. */
|
|
356
|
+
export function verdictStateDir(cwd = process.cwd(), env = process.env) {
|
|
357
|
+
if (env.GREAT_CTO_DIR) return resolve(cwd, env.GREAT_CTO_DIR);
|
|
358
|
+
const common = git(['rev-parse', '--git-common-dir'], cwd)?.trim();
|
|
359
|
+
const gitdir = git(['rev-parse', '--git-dir'], cwd)?.trim();
|
|
360
|
+
if (common && gitdir && resolve(cwd, common) !== resolve(cwd, gitdir)) {
|
|
361
|
+
return join(dirname(resolve(cwd, common)), '.great_cto');
|
|
362
|
+
}
|
|
363
|
+
return join(cwd, '.great_cto');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function latestApproval(cwd = process.cwd(), { agents = APPROVING_AGENTS, read = readFileSync, stateDir = verdictStateDir(cwd) } = {}) {
|
|
355
367
|
let best = null;
|
|
356
368
|
for (const agent of agents) {
|
|
357
369
|
let text;
|
|
358
370
|
try {
|
|
359
|
-
text = read(join(
|
|
371
|
+
text = read(join(stateDir, 'verdicts', `${agent}.log`), 'utf8');
|
|
360
372
|
} catch { continue; }
|
|
361
373
|
for (const line of String(text).split('\n')) {
|
|
362
374
|
if (!line.trim()) continue;
|
|
@@ -454,8 +466,26 @@ function requireFs() {
|
|
|
454
466
|
// `--check <file>` compares a recorded receipt held in a file.
|
|
455
467
|
// `--verify` compares the newest approving verdict's receipt against now.
|
|
456
468
|
|
|
457
|
-
|
|
469
|
+
function currentForReceipt(cwd, receipt) {
|
|
470
|
+
// Persisted evidence is data, not Git options or a new revision expression.
|
|
471
|
+
// Missing bases retain legacy behavior; invalid declared bases stay unreadable.
|
|
472
|
+
let base = receipt?.base;
|
|
473
|
+
// The emitter uses HEAD when there is no conventional default branch. Bind
|
|
474
|
+
// that legacy symbolic base to the recorded commit, not today's moving HEAD.
|
|
475
|
+
if (base === 'HEAD') {
|
|
476
|
+
if (!receipt?.head) return null;
|
|
477
|
+
base = receipt.head;
|
|
478
|
+
}
|
|
479
|
+
if (base != null && (typeof base !== 'string' || !/^[0-9a-f]{7,64}$/i.test(base))) return null;
|
|
480
|
+
return treeReceipt(cwd, { base: base || null });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
458
484
|
const argv = process.argv.slice(2);
|
|
485
|
+
if (argv.includes('--state-dir')) {
|
|
486
|
+
console.log(verdictStateDir());
|
|
487
|
+
process.exit(0);
|
|
488
|
+
}
|
|
459
489
|
if (argv.includes('--emit')) {
|
|
460
490
|
// `--verdicts <dir>`: measure the change from where the previous stage stood.
|
|
461
491
|
const vi = argv.indexOf('--verdicts');
|
|
@@ -467,7 +497,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
467
497
|
if (argv.includes('--verify')) {
|
|
468
498
|
const cwd = process.cwd();
|
|
469
499
|
const approval = latestApproval(cwd);
|
|
470
|
-
const current =
|
|
500
|
+
const current = currentForReceipt(cwd, approval?.receipt);
|
|
471
501
|
const cmp = compareReceipts(approval?.receipt ?? null, current);
|
|
472
502
|
if (cmp.state === 'no-receipt') {
|
|
473
503
|
// Not silence. A push with no approval to compare against and a push whose
|
|
@@ -479,6 +509,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
479
509
|
console.log(`receipt: against ${who}`);
|
|
480
510
|
console.log(describeDrift(cmp).split('\n').map((l) => ` ${l}`).join('\n'));
|
|
481
511
|
|
|
512
|
+
if (cmp.state === 'unreadable') process.exit(2);
|
|
482
513
|
if (cmp.state !== 'differs') process.exit(0);
|
|
483
514
|
|
|
484
515
|
// Drift, but a human may already have looked at exactly this state.
|
|
@@ -507,8 +538,12 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
507
538
|
if (argv.includes('--accept')) {
|
|
508
539
|
const cwd = process.cwd();
|
|
509
540
|
const approval = latestApproval(cwd);
|
|
510
|
-
const current =
|
|
541
|
+
const current = currentForReceipt(cwd, approval?.receipt);
|
|
511
542
|
const cmp = compareReceipts(approval?.receipt ?? null, current);
|
|
543
|
+
if (cmp.state === 'unreadable') {
|
|
544
|
+
console.error(`receipt: cannot accept — ${cmp.why}`);
|
|
545
|
+
process.exit(2);
|
|
546
|
+
}
|
|
512
547
|
if (cmp.state !== 'differs') {
|
|
513
548
|
console.log(`receipt: nothing to accept — ${cmp.why}`);
|
|
514
549
|
process.exit(0);
|
|
@@ -537,9 +572,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
537
572
|
const { readFileSync } = await import('node:fs');
|
|
538
573
|
let recorded = null;
|
|
539
574
|
try { recorded = JSON.parse(readFileSync(argv[i + 1], 'utf8')); } catch { /* absent */ }
|
|
540
|
-
const cmp = compareReceipts(recorded,
|
|
575
|
+
const cmp = compareReceipts(recorded, currentForReceipt(process.cwd(), recorded));
|
|
541
576
|
console.log(describeDrift(cmp));
|
|
542
|
-
process.exit(cmp.state === 'differs' ? 1 : 0);
|
|
577
|
+
process.exit(cmp.state === 'unreadable' ? 2 : cmp.state === 'differs' ? 1 : 0);
|
|
543
578
|
}
|
|
544
579
|
console.log(JSON.stringify(treeReceipt(process.cwd()), null, 2));
|
|
545
580
|
}
|
package/package.json
CHANGED
|
@@ -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
|
-
}
|